TestTogglingKeepFinishedAgentsPersists "reloaded" by calling the setter
again, which sets the value on its returned config, so it passed even if
nothing reached disk; it now decodes the file after each toggle. The two
completion-model tests called setModel directly, so deleting the
completion handler's model update or its non-empty guard left them green;
they now send specialistCompleteMsg through Update.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The stall watchdog was armed when the plan runner began, before the
executor resolved the manifest, built the arguments and prompt file and
recorded start accounting, so the parent's own preparation was charged as
the child's silence and a slow launch could be killed as "stalled" before
the child existed. That is how TestAChattyChildOutlivesItsStallTimeout
failed on a loaded Windows runner with the child never called. The
watchdog now holds until the executor's new TaskRunOptions.OnLaunch hook
fires immediately before launch; a child that launches and never speaks
is still caught.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
shellStartsUnderEngine discarded the CommandPlan it built, so the shared
lease that planning takes on the workspace's sandbox runtime directory
was never released: each probe left an open, locked file for the rest of
the process, keeping the runtime root "in use" to other Zero processes'
cleanup and holding a handle under the cache directory on Windows.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Without a standalone helper, the sandbox launches os.Executable() with a
hidden __windows-command-runner / __windows-sandbox-setup /
__sandbox-helper subcommand. Tests that run the cli test binary as the
zero CLI (TestLocalAdoptionSavesAndReloadsEligibleModel) therefore made
the test binary the helper, and it ran the whole package suite instead.
With #829's sandboxed shell probe at prompt build, every `zero exec` in
that child hit it: two 15s probe timeouts, then the 30s test kill on
Windows. TestMain now routes the hidden subcommands exactly as
cmd/zero/main.go and runWithDeps do.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The card re-decoded the raw orchestrate arguments with its own decoder,
which accepted only JSON numbers, while admission also accepts numeric
strings: a plan asking for "500000" tokens was approved as "no token
limit". It also looked for background inside the budget object, where no
call puts it, so a background plan (inline or saved) was never flagged.
The card now uses specialist.DecodePlanBudget and PlanRunsInBackground,
the same decoders the tool runs, and shows a budget admission will refuse
as invalid with its reason.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
TaskOutput published len(summary.Tools) as the child's tool count, but
Tools holds distinct names while the AGENTS panel counts every call live,
so a background agent that ran read_file forty times showed "1 tool". The
stream summary now counts calls (StreamResult.ToolCalls), TaskOutput
publishes that as Meta["tool_calls"], and the TUI reads that key. The
meta test now drives TaskOutput itself instead of only the summary.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
SessionBudget.admit incremented before judging, so each refused spawn
was counted as a start: Started() over-reported and every later refusal
quoted a larger "has started" figure. Admission is now one
compare-and-swap that counts only a start it admits, so the limit also
holds exactly under concurrent spawns.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
taskIdentity sorted depends_on as if it were a set, but the briefing
walks it in declared order and spends one shared budget, so reordering
decides which dependency outputs a task receives. A reordered task kept
its old fingerprint and resume replayed a result built from a different
briefing. The identity now hashes depends_on in declared order (tools
stay sorted: the grant is a set). A plan recorded before this change
re-runs its multi-dependency tasks once on resume, which is the safe side.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The isolator prepared a fixed plan-<name> worktree under a PID-less lease
and its Release did nothing, so the lock outlived the run and every rerun
of the plan was refused as "locked by another active run". Each execution
now gets its own tree (timestamp + random suffix), the lease names this
process like `zero exec --worktree`, and Release drops the lease while
keeping the tree: Clean never removes an unlocked tree with uncommitted
work, so the unreviewed output is retained without a lock nobody clears.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The live read-root supplier was wired into the orchestrate tool only: a fresh
ordinary Task got no read-only roots, and BuildResumeArgs emitted only write
roots, so a child could read a granted external path inside a plan but not when
launched normally or resumed. Executor now has an ExtraReadRoots supplier like
ExtraWriteRoots, wired from the CLI for both TUI and headless runs, and both
argument builders emit it only as --add-read-dir (skipping roots already passed
as write roots), under the same within-turn lifetime rule as write grants.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Scope.ExtraRoots returned AddTemporaryWrite roots like permanent ones, so a
turn-scoped request_permissions grant left in a child's argv as --add-dir and
stayed writable for the child's whole life after the parent released it.
Semantics now: a child inherits a turn-scoped grant only when it is certain to
exit inside the turn, i.e. a foreground Task or resume that the tool call waits
on (TaskRunOptions.WithinTurn). Background Tasks, plan tasks (a plan may run in
the background) and swarm members get only grants that outlive the turn, via
the new Scope.SessionExtraRoots. The zero value is the conservative one.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
appendExtraWriteRootArgs skipped a root only when it was byte-equal to the
child's --cwd, so a parent write root that is an ancestor of the parent
workspace (--add-dir ~/dev, config additionalWriteRoots) or lies inside it
followed a write-capable plan task into its isolated worktree and let it write
into the parent checkout. The executor now knows the parent workspace, and a
child whose --cwd lies outside it gets no write root that contains or is
contained in that workspace (canonical paths, separator-bounded, case-folded on
Windows and macOS); unrelated roots are still forwarded.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Restart, bare resume and named resume staged generated plans under fixed
names in the user's plan storage. Exclusive publication stopped them
replacing a user's plan, but refused instead, and Zero's own staged file
then blocked every later restart or resume in the project. Staging now
publishes under the preferred name or the first free variant, tracks
the bytes it wrote, and removes that file once its plan is admitted
unless someone edited it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
removeCompletedLater waited CompletedRetention and then called Remove,
which only drops the live process entry; the immutable record in the
completed store survived until 64 newer completions evicted it. remember
now schedules each record's own expiry when it publishes it, stamped with
a generation so a timer only removes the publication it belongs to, and
expiry also frees the record's slot in the FIFO order.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
budgetLeft was decremented on every harvest and never read after its initial
"bounded" check; dispatch is decided by the per-pool harvested spend and the
report by TokensUsed. Three comments still called it the authoritative number
that decides dispatch. The variable is removed and the comments now name what
actually enforces the budget. No behaviour change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
terminalStatus returned PlanCancelled whenever nothing succeeded and anything
was cancelled, so a real task failure followed by a user stop surfaced as a
deliberate stop in Meta["plan_status"] and the durable plan event. Cancelled
now also requires that no task failed; such a plan reports PlanFailed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
runTaskWithRetries bounded stall retries with the attempt number, so the
one-shot decline, provider and model-fallback retries each consumed a stall
retry: a decline followed by a stall got zero of the documented one. Stall
retries are now counted on their own, and the exhaustion message reports the
number of stalled attempts rather than every attempt.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
runTaskWithRetries passed the full cap on every attempt and NewPlanRunner
started each attempt's meter at zero, so a task that stalled after spending
most of its cap could spend (max_retries+1) times it. Each attempt now carries
TaskTokensSpent, the runner seeds its meter with it, and a retry whose task has
already spent the whole cap is not launched.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
req.Spend.add ran as a side effect inside the second switch case's condition,
and Go stops at the first true case, so the event that tripped
max_tokens_per_task and every event after it never reached the plan meter.
Downstream dispatch, gated on limit - upstream, then ran on an undercount. The
add is hoisted above the switch so both limits judge a meter that saw it all.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
refuseUnreachablePerTaskCap multiplied taskCount by max_tokens_per_task with
no overflow check; past math.MaxInt the product wrapped negative, every
max_tokens compared >= it, and the unreachable cap was admitted. A product
that cannot be represented now refuses up front, matching the rule
refuseUnrepresentableSeconds already applies to timeouts.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
planIntSet truncated JSON floats with an unchecked int() and returned
"absent" for any other type, so max_wall_seconds 0.9 disabled the wall bound,
max_workers 1.9 ran one worker, and max_tokens "500000" silently erased the
spending limit (and dropped it from a saved plan's Args()). planIntField now
decodes every integer budget field: whole finite numbers and numeric strings
(the intArg convention) are accepted; fractions, NaN/Inf, out-of-range values,
null and other types are refused; absence still means absence.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
planStringsStrict returned (nil, nil) whenever args[key] was not a []any, so
a present string, object, number or null depends_on/tools was admitted exactly
like an omitted key: the task lost its declared dependency, or a malformed
tools narrowing became the default inherited grant. The parser now separates a
missing key from a present wrong-typed one and refuses the latter; omission and
an explicit empty array keep their meaning. Covered inline and via a saved plan.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
harvest recorded every completion to the pad before its outcome was
decided, so a failed task's diagnostic output was listable by unrelated
tasks while the dependency briefing deliberately refuses to pass it on.
Recording now happens per branch once the outcome is final, only for
succeeded and cancelled tasks, matching the briefing policy.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
ParsePlan's uniqueness check was exact-string, so "Alpha" and "alpha" were
admitted as distinct tasks while sharing one scratchpad file on
case-insensitive filesystems, serving one task's answer as the other's.
Task ids are user-visible identifiers (event log, plan panel, depends_on),
so admission now refuses case-variant ids with an error naming both.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Record used os.WriteFile, so a symlink or hard link planted at a
not-yet-recorded task's path redirected the unsandboxed executor's write.
It now creates each file with O_EXCL and refuses anything already at the
destination. The package comment claimed no task could write to the pad;
write-capable tasks can, so it now states what is and is not guaranteed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The watcher armed a timer for the pre-pause remainder and the pause credit
was applied only after WaitWhilePaused returned, so a pause longer than the
remaining budget fired the timer mid-pause and cancelled the plan. The clock
now owns hold/release and in-flight state under one lock, the executor holds
it before blocking, and the watcher decides expiry under that lock. Only time
with dispatch held AND no task in flight is credited, so a task running
through a pause (max_workers > 1) still spends the budget.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
#909 now admits only the runner's package-level result as timing
evidence, because test2json cannot prove a per-test event came from the
runner. The agent and plan measurement tests claimed a per-test figure,
which is now (correctly) silent, so they claim the package figure instead.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The copies vendored from #897 (3eae423c) and #909 (8fd02228) had fallen
behind both heads. Reviewers accepted carrying them only while they stayed
byte-identical to their owning PRs, so bring them back in line. The only
difference left is the Gitlawb -> Twigpine module path (plus the gofmt
realignment it causes).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Brings gnanam1990's 195-commit zeromaxing/orchestrate branch (head
6916f853) onto main e034a938, with every original commit unchanged.
All conflict resolution lives in this merge commit.
Resolutions that combine both sides:
- module path: Gitlawb/zero imports rewritten to Twigpine/zero
- config: project maxTurns stays tighten-only and records maxTurnsSet
only when it applies; the partial (no-provider) config keeps Profiles
- config writer: SetKeepFinishedAgents takes main's config file lock
- exec: execTurnBudget applies before the zeromaxing refusal/delta notice
- exec_command: tty-gated interactive guard (main) plus the
sandbox-selected shell preflight (PR); the Windows description
guidance is the <environment> pointer, kept paired with
hostExecCommandShellGuidance for the token ratchet
- specialist: LifecycleHooks (main) alongside the PR's executor fields;
main and the PR both fixed resume --model, so it is emitted once
- cli: registerSpecialistTools keeps the PR's signature and wires main's
lifecycle hooks bridge into the executor
- tui: main's file-view refresh, run-details clicks, notify fields and
locked model selection, combined with the PR's plan messages, chip,
background-plan /new guard and spinner-tick ordering
- resume model tests: both add/add files kept (resume_parent_model_test.go)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* perf(tui): bounded file read, LRU rendering cache, and size caps for full-file view
- Bound full-file view reads (4000 lines, 1 MiB total, 4 KiB per line) with async loading and an LRU render cache capped at 64 entries and 8 MiB.
- Refresh both BTW surfaces when a tool result (such as write_stdin) carries both exact changed paths and an unknown-scope or aggregated directory summary outside Git.
- Gate Run details FILES mouse hit-testing on Run details being the visible foreground overlay so higher-priority modals (speech API-key prompt, leader help) block clicks behind them.
(cherry picked from commit 100d847aaf)
* fix(tui): gate full-view read errors on snapshot validity
renderFileViewFull returned renderedContent whenever hasError was set,
before the snapshot-validity check that governs every other completed
snapshot. A /btw fork of a full view that had settled on a read error
invalidates the inherited snapshot and clears renderedContent, but the
error latch survived, so the side pane rendered an empty body instead of
the loading placeholder until its own load landed. Reloads of an errored
view (resize, refresh, theme) likewise kept showing the stale error.
Serve the error snapshot through the same validity predicate as content
snapshots, so a pending or invalidated load falls through to "Loading…".
Error displays still do not count as cache hits.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit a1887f4b03)
* fix(tui): keep file-view revisions monotonic and revalidate on git sweeps
Shared path revisions reset to zero on every cache purge, while each surface
kept its own required revision. Three sites could also raise that revision
past the shared one by incrementing it locally. After a purge, an exact-path
edit made on the other BTW surface could produce a shared revision no higher
than this surface's own. recoverInvalidatedFileView then saw nothing new, and
the view stayed stale. Revisions now come from one cache-wide clock that
survives purges. A purge raises the epoch floor to that clock instead of
resetting it, and each surface adopts the revision the cache returns.
Every git sweep invalidated each swept path and forced a full refresh. The
swept list is cumulative since the baseline, and each live sweep is launched
beside the tool-result, turn-end or BTW-return handling that already reloads
or rechecks the open view. The sweep that followed therefore reread and
re-highlighted an unchanged file and flashed "Loading…". A sweep now
revalidates the open view instead. It waits for an in-flight load to land,
then rereads the bounded source in the background and compares it with the
displayed snapshot's identity (hash and truncation flags). The view reloads
only if the bytes changed. A mutation the swept list omits is still caught.
Regression tests (all fail on 311290c0 and pass here):
- TestFileViewCrossSurfaceExactEditAfterPurgeReloadsOtherSurface
- TestFileViewGitSweepRevalidatesWithoutDuplicateReload
- TestFileViewGitSweepDuringLoadDefersToThatLoad
- TestFileViewStaleRevalidationDoesNotReloadNewerSnapshot
- TestFileViewCachePurgeResetsPathRevisions (now asserts monotonicity)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* refactor(tui): return source identity from loadAndRender directly
1ce4f96e added loadAndRenderSource and left loadAndRender as a two-value
wrapper. After that change only tests called the wrapper, so make deadcode
newly reported it. Fold the wrapper away: loadAndRender now returns the
rendered text, the source identity, and the error, and its callers take the
value they need. Behavior is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(tui): close three file-view staleness gaps from review
- fileViewSourceHash joined lines with separators, so an empty file (no
lines) and a file holding one newline (one empty line) hashed alike.
Sweep revalidation compares identities without the file size, so adding
or removing that blank line went undetected. Every line is now
terminated, which keeps the encoding injective.
- A load cancelled inside the bounded read returned errFileViewSuperseded
with no lines. loadAndRender then treated it as a read error and evicted
the path, discarding an entry a newer load had already committed. The
sentinel now returns before the eviction branch.
- A cancelled run keeps going until it finishes, and agentRowMsg ignores its
late tool results. Its completion only flushed session events, so a write
that finished during cancellation left an open full-file view stale. The
flush completion now runs the unknown-scope refresh.
Regression tests (each fails on ca1cdaf3 and passes here):
- TestFileViewGitSweepDetectsBlankLineAddedToEmptyFile
- TestFileViewSupersededReadKeepsNewerCacheEntry
- TestFileViewCancelledRunCompletionRefreshesOpenView
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(tui): gate Run details Enter on the visible overlay
The Run details Enter drill-in keyed off the raw runDetailsOpen flag,
while mouse FILES picks use runDetailsVisible(). The flag outlives the
overlay: clicking a specialist card beside Run details enters the
subchat, which hides the overlay but leaves runDetailsOpen set, and
Enter then opened the parent's file view from inside the subchat.
Use runDetailsVisible() and the shared runDetailsInnerWidth() geometry
on the keyboard path, matching the mouse path. The branch's existing
Esc/Ctrl+B and key-swallow behavior is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: hazyhaar <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(hooks): dispatch specialistStart/Stop and allow matchers
These events were documented and accepted by hooks add, but never fired.
Wire advisory dispatch from specialist accounting and allow specialist
name matchers so EXTENDING.md matches runtime behavior.
* fix(hooks): unlock before specialistStop hooks and fire once
Release accountingMu before running lifecycle hooks, and dispatch
specialistStop only when the stop event is newly recorded so concurrent
finishers cannot re-run arbitrary hook commands.
* fix(hooks): fire specialistStop without relying on session append
Decouple stop-hook dispatch from parent-session event append so swarm
members (empty parent) and TaskOutput still get exactly one hook via a
shared LifecycleHooks claim, and wire that bridge into TaskOutput.
* fix(hooks): claim specialistStop durably and gate hooks on real launches
Address the review findings on specialist lifecycle dispatch:
- specialistStop is now claimed in the parent session's event log via a
record-once specialist_stop_hook event, sharing the accounting run-ID
equivalence (empty runId is a catch-all). A later reaper in another
process (TaskOutput poll, recovered TaskStop, restart) no longer re-runs
the hook. The in-process claim remains only as fallback for swarm
members with no parent session and for session-store errors.
- Recovered TaskStop records the terminal transition after Kill, so an
explicitly stopped recovered task emits specialistStop exactly once.
- specialistStart fires only after the child process actually started
(onStarted after Start()); a launch that fails before exec records
attempt accounting but no public start/stop hooks. Reapers derive the
same eligibility from the durable stop event's childStarted flag.
Adds a CLI startup wire-up test asserting both real entry points
(interactive TUI, headless exec) attach the lifecycle bridge to the same
LifecycleHooks the TaskOutput tool holds, fixes the stale import path on
the rebased branch, and documents the once-per-run semantics.
* fix(hooks): gate background exit accounting behind the start decision
A background child that exits immediately can run its launcher exit
callback before Executor.LaunchBackground returns, so the terminal
accounting could claim and dispatch specialistStop before runBackground
dispatched specialistStart, and the recorded stop missed the pid that
SetPID had not stored yet.
The exit callback now waits on a settled channel closed when runBackground
returns (after the start hook and SetPID), from its own goroutine so a
synchronous injected launcher cannot deadlock waiting for a signal only
the launch return can close.
Regression test: TestRunBackgroundDefersExitUntilStartDecisionCompletes
drives a synchronous fast-exit launcher and asserts start-before-stop hook
order, the recorded stop pid, and no deadlock. It fails on the previous
code with hooks "specialistStop,specialistStart".
* fix(hooks): record specialistStop only after the orphan kill succeeds
When SetPID fails after a background child has started, runBackground kills
the unrecorded child and records the stop. If TerminateProcess fails, the
child may still be running, but the branch still marked the task errored,
recorded the stop, took the durable specialist_stop_hook claim and fired
specialistStop. The hook could then run while the sub-agent was alive, and
the one-shot claim suppressed the hook on the child's real exit. Marking the
task non-running also let a TaskOutput or TaskStop reap claim the stop
before the launcher's Wait observed the exit.
On a failed kill, surface the joined error and return without the status
update or the stop. The task stays running, so reapers remain no-ops, and the
launcher's exit callback marks it exited and records the single stop when the
child ends. A successful kill keeps the immediate stop as before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(hooks): keep the prompt on a failed orphan kill and share one stop claim
When SetPID fails and terminating the unrecorded child also fails, the child
may still be running and the task stays running. That branch still deleted
the --file prompt, so a child that had not yet read it failed at startup.
The launcher's exit callback already removes the prompt once the child
really exits, and Runtime.Close removes tracked prompts at shutdown, so the
failed-kill branch no longer removes it.
dispatchSpecialistStopHook made two independent dispatch decisions: the
durable specialist_stop_hook claim, and the in-process claim used when the
session store errors. A won durable claim never recorded the in-process
claim, so on one bridge a transient store error before or after a durable
claim dispatched the same stop twice, breaking the documented at-most-once
per process. A won durable claim now also takes the in-process claim
(including for an ineligible stop), and the hook runs only if both are free.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Ayush7614 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(plugins): add CLI enable/disable for plugin.json
Add zero plugins enable|disable so installed plugins can be toggled
without removing them, matching hooks/MCP parity and EXTENDING docs.
* fix(plugins): serialize toggles with installs and preserve manifest bytes
Address review findings on the enable/disable CLI:
- Take the same cross-process installtxn lock plugins add/remove take on
the plugin's install root before the final lookup and read, and publish
plugin.json with installtxn.WriteFileAtomically while holding it.
- Re-resolve the plugin under the lock so a concurrent install can never
be overwritten with manifest bytes read before it committed, retrying
when precedence moves to the other root; a plugin removed under the
lock reports ErrNotInstalled instead of being recreated.
- Edit the enabled value in place (a targeted token walk) so field
order, formatting, and numeric literals survive; the old
decode/re-encode dropped precision (9007199254740993 became
9007199254740992) and sorted the keys.
- Fail closed on manifest stat errors, refresh a clean plugins.lock hash
after a toggle, and leave pre-existing drift in place instead of
absorbing it into the lockfile.
- Redact the text-mode output, reject an empty plugin id as a usage
error, and document project-over-user precedence in the help text.
* fix(plugins): recover interrupted install transactions before toggling
SetEnabledByID takes the plugin root's installtxn lock but went straight
to reading and rewriting the tree. installtxn.Recover requires every
holder of that lock to resolve interrupted transactions first and to
abort on its error, as Install and Remove already do.
Without it, a toggle after an install killed between its tree swap and
its lockfile publish edited the unpublished tree, and the next install
or removal restored the retained backup over it, silently undoing the
enable/disable. A transaction recovery could not resolve also failed to
stop the toggle.
Call installtxn.Recover with the plugins reconciler right after taking
the lock, and add regression tests for every interrupted state that
leaves the plugin discoverable, the unresolved-transaction abort, and a
plugin removed while the toggle waits on the lock.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(plugins): say when a plugin toggle takes effect
Zero reads plugin.json "enabled" only when it activates plugins at
startup (runExec and the interactive TUI bootstrap), and nothing reloads
them afterwards. A running interactive session therefore keeps the
tools, hooks, and skills of a plugin disabled from another terminal.
State that in the plugins docs and in the enable/disable help so the
documented activation rule matches what the command does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(plugins): pin toggled manifests, redact toggle errors, honor last enabled key
Address the review findings on the plugin enable/disable toggle.
Pin the plugin directory before editing its manifest. The install lock
only serializes cooperating installers, so swapping the plugin directory
for a link after discovery redirected the read and the atomic publish
through it, overwriting another manifest. The toggle now opens the
directory under its install root with pathjail, refuses a link or
reparse point, checks that the handle is the directory entry itself,
and reads, stats, creates the temporary file, and renames through that
handle.
Redact every stderr message the toggle writes. Parser and not-installed
usage errors echoed the raw argument, so a mistyped credential reached
stderr unredacted while the toggle's other output was redacted.
Validate only the last "enabled" value, as the loader does. The loader
keeps the last duplicate key, but the toggle rejected an earlier,
overwritten non-boolean value, so a plugin discovered as enabled could
not be toggled.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(plugins): keep the toggle on the pinned plugin directory throughout
The previous fix pinned the manifest read and publish to a handle on the
plugin directory but still hashed the tree for plugins.lock by path, and
reported success without checking that the path still named the pinned
directory. If the directory was replaced after it was opened, the edit
landed in the moved-away original while the lockfile check and refresh
read the replacement, and the command reported a state the installed
plugin did not have.
Hash the tree before and after the edit through the pinned handle, and
confirm that the plugin's directory entry is still the pinned directory
before refreshing the lockfile or reporting any result, including a
no-op. hashTree now delegates to the same rooted walker, so path and
handle callers hash identical bytes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(plugins): hash plugin trees through native os.Root calls
20b484f moved hashTree onto an os.Root handle but walked it through
handle.FS(). That adapter accepts only fs.ValidPath names, which must be
UTF-8, so a plugin holding a Linux-valid name such as byte 0xff failed
with "lstat ...: invalid argument". copyTree and the previous walker
accept those names, so Install, the toggle's lock refresh, and Info's
drift check all regressed for such plugins.
Walk the handle with its own Open/Lstat methods instead. The walk stays
pinned to the directory, and the hash bytes match the merge-base
implementation (checked on a fixture with nested and non-UTF-8 names,
an exec bit, a symlink, .git and a FIFO).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Ayush7614 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(aimlapi): shared service key and attribution for aimlapi.com services
Adds the pieces the Toolbox and MCP integrations share:
- internal/aimlapi: X-AIMLAPI-Source (agent/zero), the fixed Toolbox and
MCP URLs, and an exact-hostname check (https, default port, no
userinfo) for api/tools/mcp.aimlapi.com before the key or attribution
headers are attached. The partner id still comes from ResolvePartnerID.
- internal/config: AIMLAPIServiceKey picks the key from the aimlapi
profile (or AIMLAPI_API_KEY without one), reading the credential
store, and withholds it when any aimlapi-keyed profile points at a
proxy base URL or AIMLAPI_INFERENCE_URL is overridden. Keys that are
not a single token are refused.
* feat(mcp): AI/ML API MCP server as an opt-in built-in default
Seeds `aimlapi` (https://mcp.aimlapi.com/mcp, streamable HTTP) next to
Exa as a built-in MCP server that ships disabled: its tool runs and
generations are billed to the user's AI/ML API balance. The disable is
marked as explicitly set, so a project config cannot lift it; the user
turns it on with `zero mcp enable aimlapi`, which now writes
`"disabled": false` for a default that ships off (and `zero mcp add
aimlapi` does the same). `zero doctor` reports the untouched default as
a pass rather than a warning.
The MCP client attaches the AI/ML API key and the X-AIMLAPI-Source /
X-AIMLAPI-Partner-ID headers in one place (internal/mcp/aimlapi.go),
called from the three header-application points. Both apply only to a
request whose own URL is exactly https://mcp.aimlapi.com on the default
port, whatever the server is named. The key is resolved for every
request through config.LoadAIMLAPIServiceKey, so switching the aimlapi
provider to a proxy, or a key that is not a single token, means no
Authorization header on the next request. It is never put in config,
the server identity used for OAuth token binding, list output or errors.
A user Authorization header, `"auth": "oauth"`, or a project-touched
server goes without it. A 401 from that host explains how to add a key
or sign in with `zero mcp oauth login`.
* fix(aimlapi): validate whitespace and preserve store errors
* fix(mcp): tailor unauthorized hints to credential policy
* docs(mcp): clarify automatic credential eligibility
* test(mcp): cover edited disabled built-in defaults
* fix(aimlapi): report store reads after proxy eligibility checks
* docs(aimlapi): document MCP key precedence and clarify 401 hints
Operator copy now matches normalizeProvider resolution (inline key,
apiKeyEnv, then credential store) and warns that AIMLAPI_API_KEY can
bill MCP before a stored profile key. SECURITY and service-key godoc
stay aligned. 401 hints no longer imply a proxy-only failure mode.
Adds a regression test that resolved profile keys win over the store.
Co-authored-by: Cursor <[email protected]>
* fix(mcp): keep the aimlapi key off redirects and error text, persist enable
Redirect hops: net/http copies the first request's headers onto every
redirect hop without calling the header hooks, so the automatic AI/ML API
key and attribution followed a same-origin hop that fails the exact-URL
rule (userinfo) and an eligible hop reused the first hop's key. The
redirect guard now restores the server's own values for those headers and
applies the per-request rule again.
Error text: httpStatusError copied up to 1 KiB of the response body into
the error, so a body echoing the injected key (or its prefix, when cut by
the read limit) reached CLI output. When the request carried the automatic
key, the body is now omitted instead of matched.
Enable persistence: MCPServerConfig had no serializer and Disabled is
omitempty, so any read-modify-write of config.json (SetTheme,
SetActiveProvider, ...) dropped `"disabled": false` and the shipped
disable turned aimlapi off again. MarshalJSON now writes the flag whenever
it was explicitly set; `zero mcp add` keeps writing only a disable.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Stan <[email protected]>
Co-authored-by: Hugo <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(web-fetch): AI/ML API Toolbox as an opt-in web_fetch backend
With ZERO_WEBFETCH_PROVIDER=aimlapi, web_fetch fetches the page as Markdown
through the Toolbox tool search1api/crawl instead of a direct GET. A key
alone never selects it; unset or unknown values keep the direct fetch.
- The URL checks (pre-permission and resolved host) run before the URL is
sent, so loopback, private and metadata URLs never reach the crawler.
- The key is resolved on every call through LoadAIMLAPIServiceKey; a
missing, proxied or invalid key is an error with no request and no
fallback. A failed run (is_error, failed status, no content) is also an
error.
- Requests go only to aimlapi.ToolboxBaseURL, checked with
IsAttributedURL before the key and attribution headers are attached;
redirects are refused. The client uses the default transport, so the
proxy environment applies and the target-site SSRF pin does not.
- Every run carries max_cost_usd 0.005, wait_ms 30000 and a fresh
idempotency key. 202 runs are polled (hint clamped to 250 ms..5 s,
120 s overall). 429/502/503/504 are retried with the identical body,
honouring Retry-After (capped at 10 s); max_cost_exceeded is not.
- Output keeps the result shape with a Backend line, respects max_bytes,
refuses format raw, and scrubs the key and redacts all Toolbox text.
* fix(web-fetch): honor poll retries and reject raw before permission
* docs(web-fetch): explain the bounded run response
* test(web-fetch): rename the Toolbox fake that collides with web_search tests
#1189 added a fakeToolbox type with a recorded method to the tools test
package. The web_fetch Toolbox tests declared their own fakeToolbox with a
different shape, so the package's tests no longer compiled once both were on
main. Rename the web_fetch one to webFetchFakeToolbox, matching the file's
webFetch prefix. Test-only; no behavior change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(web-fetch): describe the aimlapi backend in the tool schema
With ZERO_WEBFETCH_PROVIDER=aimlapi the max_bytes and format descriptions
still promised direct-fetch behavior: raw body bytes before conversion and
HTML conversion with a raw option. The Toolbox backend returns crawled
Markdown, applies max_bytes to that Markdown and refuses format "raw", as
the README says. Say so in both descriptions so the model sees the same
contract.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(web-fetch): keep the AI/ML API key out of Toolbox errors and output
The Toolbox backend scrubbed the key with a literal string replace. A key
echoed back split by an invisible character (NUL, ZWSP) passed it on every
path the model sees: error bodies, run errors, page content and the run_id
meta field. scrubWebFetchKey now uses scrubAIMLAPIResult, the same
split-aware redaction the web_search backend uses.
A failed Toolbox request also returned the *url.Error from client.Do. That
error quotes the request URL, and a polling URL carries the run id
percent-escaped, a form no key scrubbing can match. send now reports the
cause without the URL, as web_search does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(web-fetch): state only established billing for Toolbox runs
The README said every fetch is a billed Toolbox run, while the next
sentence says a run over max_cost_usd is refused and not charged, and a
code comment said a 404 comes back as an unbilled is_error run. The
AI/ML API Toolbox documentation says you pay for runs but not whether a
failed run is billed. Both now say only that Toolbox runs are billed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Stan <[email protected]>
Co-authored-by: Hugo <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(aimlapi): shared service key and attribution for aimlapi.com services
Adds the pieces the Toolbox and MCP integrations share:
- internal/aimlapi: X-AIMLAPI-Source (agent/zero), the fixed Toolbox and
MCP URLs, and an exact-hostname check (https, default port, no
userinfo) for api/tools/mcp.aimlapi.com before the key or attribution
headers are attached. The partner id still comes from ResolvePartnerID.
- internal/config: AIMLAPIServiceKey picks the key from the aimlapi
profile (or AIMLAPI_API_KEY without one), reading the credential
store, and withholds it when any aimlapi-keyed profile points at a
proxy base URL or AIMLAPI_INFERENCE_URL is overridden. Keys that are
not a single token are refused.
* feat(web-search): AI/ML API Toolbox as an opt-in web_search backend
ZERO_WEBSEARCH_PROVIDER=aimlapi runs the Toolbox tool web/search at the
fixed https://tools.aimlapi.com/v1/tools URL. It is selected only by that
setting; an AI/ML API key alone never enables it or registers web_search.
- The key comes from config.LoadAIMLAPIServiceKey on every search; a
missing, proxy-profile or invalid key fails before any request.
ZERO_WEBSEARCH_BASE_URL and ZERO_WEBSEARCH_API_KEY are ignored.
- The key and attribution headers are attached only after
aimlapi.IsAttributedURL accepts the URL, and redirects are refused.
- Every run carries max_cost_usd 0.025 and a fresh idempotency key;
limit maps to max_results and domains to include_domains, with the
local domain filter kept.
- A queued run (202) is polled until it ends, honouring
next_poll_hint_ms clamped to 250 ms..5 s. This backend gets a 30 s
budget with wait_ms 20 s; the generic backends keep 10 s.
- Non-2xx responses, is_error runs, failed/stopped/timed_out runs and
malformed bodies are errors carrying the error code and request id,
with the key scrubbed.
* fix(aimlapi): preserve store errors and isolate Windows config tests
* fix(web-search): redact results and preserve domain errors
* docs(web-search): clarify minimum search charge
* fix(aimlapi): reject Unicode whitespace in service keys
* fix(aimlapi): report store reads after proxy eligibility checks
* fix(web-search): keep plain no-results for unscoped backends
Only a backend that applied the domain allowlist itself (the AI/ML API
Toolbox) should turn an empty result into the "no results matched
domains" error. A generic backend never saw the allowlist, so an empty
result from it is an ordinary "No results for query", as before this
branch.
Also clear ZERO_WEBSEARCH_PROVIDER in the unconfigured registry test:
ZERO_WEBSEARCH_PROVIDER=aimlapi alone now registers web_search, so an
exported value flipped the test's oracle.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(aimlapi): resolve the Toolbox key without an active provider
A user config that lists several providers but marks none active (or
names a removed one) makes Resolve return ErrNoActiveProvider together
with the normalized profiles. The app keeps running on that partial
config, but LoadAIMLAPIServiceKey stopped on the error, so web_search
failed even with an aimlapi profile or AIMLAPI_API_KEY set. Look the
key up in the partial config instead; the proxy check still scans every
resolved profile, and all other resolve errors are still returned.
Also state the key rules in the README as implemented: user config
only (project .zero providers are not used), which aimlapi profile is
used, and that any aimlapi profile or any profile reading
AIMLAPI_API_KEY on a non-canonical base URL withholds the key.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(aimlapi): withhold the Toolbox key for dropped proxy profiles
Resolve drops a non-active profile it cannot normalize (an unknown or
unsupported catalog entry, a missing base URL, ...), so the proxy check
never saw it. A dropped profile that reads AIMLAPI_API_KEY from a proxy
still shows the key belongs to that proxy, yet the key was sent to
tools.aimlapi.com. LoadAIMLAPIServiceKey now also checks the configured
user profiles, merged the same way Resolve merges them, and withholds
the key when a dropped profile carries it and is not explicitly on the
canonical base URL.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* test(web-search): pin the generic backend route in BASE_URL registration tests
defaultSearchBackend now checks ZERO_WEBSEARCH_PROVIDER=aimlapi before
ZERO_WEBSEARCH_BASE_URL. TestWebSearchRegisteredInCoreNetworkTools and
TestCoreNetworkToolsExposeSafetyMetadata set only the base URL and inherit
the provider from the process environment, so with an exported
ZERO_WEBSEARCH_PROVIDER=aimlapi they took the Toolbox route and still passed
even if generic base-URL registration broke. Clear the provider so they
exercise the generic route they describe, matching
TestCoreNetworkToolsOmitWebSearchWhenUnconfigured.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(redaction): redact caller-supplied secrets split by invisible characters
RedactString replaced Options.ExtraSecretValues only as contiguous text,
and the gap-tolerant pass from #1067 covers only the built-in credential
shapes. An opaque key such as the AI/ML API service key, echoed by a
service with a NUL or zero-width space inside it, matched neither pass.
Both fragments then reached Toolbox web_search errors (before clipping)
and successful results sent to the model and transcript.
Match each caller-supplied secret with runs of the same invisible
separators allowed between its characters, right after the contiguous
replacement. Separators inside the match are covered; those beside it
stay. Text without a separator takes the old path unchanged.
Regressions: split explicit values in the redaction package, and split
keys in the Toolbox HTTP error, run error and successful-result paths.
Raised by Copilot on #1189.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(redaction): claim each caller secret whole or split before shorter ones
RedactString replaced every ExtraSecretValues entry contiguously, longest
first, and only then ran the split match. A shorter secret inside a
longer one that was split by an invisible character was replaced first,
so "abc\x00def" with secrets abcdef and abc became "[REDACTED]\x00def"
and the rest of the longer secret stayed visible.
Run the contiguous and split match together per value, longest first,
so a longer value is claimed in either form before any shorter one.
Regressions cover a shorter secret at the start, end and middle of a
split longer one, and alongside it. Raised by CodeRabbit on #1189.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(redaction): merge overlapping caller-secret occurrences before replacing
ExtraSecretValues were replaced one occurrence at a time, so each
replacement could consume bytes another occurrence needed. With secret
abcabc, "abc\x00abcabc" became "abc\x00[REDACTED]". A key whose first and
last characters match lost all but one character the same way
("xY7q[REDACTED]"), and two partly overlapping values left the
rest of one visible. Reordering the passes cannot fix this: the
contiguous and regexp replacements are both non-overlapping.
Find every occurrence of every value, whole or split by invisible
separators, in one separator-free copy of the text (linear KMP, all
overlapping starts), map the spans back, merge overlapping ones, and
replace each merged span once. Occurrences that only touch stay
separate replacements; separators beside a match stay; tab, newline
and carriage return are still not gaps. This honours the existing
TestRedactString_OverlappingExtraSecrets contract for both forms.
Raised by CodeRabbit on #1189.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(web-search): keep the request URL out of Toolbox transport errors
When http.Client.Do failed, the backend scrubbed err.Error() from the
*url.Error, which quotes the request URL. A polling URL carries the run
id the Toolbox returned, percent-escaped, so a key echoed in that id
appeared as "Lp9%00Xr4" or "Lp9%E2%80%8BXr4". The key scrubber matches
the key whole or split by raw invisible separators, not percent-escapes,
so the key stayed recoverable in the web_search error.
Report only the cause inside the *url.Error. The message prefix, the
redirect refusal text and key scrubbing are unchanged. The regression
fails a polling GET after a queued run whose id carries a NUL- or
ZWSP-split key and checks the output has neither the polling URL nor
the key. Raised by CodeRabbit on #1189.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(redaction): always redact a caller secret as written
Once the text held any invisible separator, redactExtraSecretValues
matched each caller secret only with separators removed from both. That
replaced the contiguous match instead of adding to it: a secret made
only of separators compacted to nothing and was skipped, and separators
at either end of a secret fell outside its match. RedactString("a\x00b")
with secret "\x00" returned the text unchanged, where main replaced it.
In the separator path, also record every occurrence of the secret as
written, then merge spans as before. Text without a separator takes the
unchanged path. Raised by CodeRabbit on #1189.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Stan <[email protected]>
Co-authored-by: Hugo <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Sandboxed tool runs in the agent tests keep runtime state and lease files
under os.UserCacheDir, so go test ./internal/agent/ left
zero/runtime/v1/*.lease files in the developer's real cache. TestMain
now points every per-user root at its temp dir via
testutil.IsolateUserDirs, as internal/tui does, and a guard test pins it.
Refs #1103
Signed-off-by: ShryukGrandhi <[email protected]>
* fix(agent): mark headless guardrail stops incomplete
The empty-turn and repeated-tool-failure guards returned the run as a
successful final answer even with RequireCompletionSignal enabled, so
zero exec reported exit 0 and a success run_end for work it had just
abandoned.
Set Result.Incomplete and a reason at both stops when the headless gate
is on. Thresholds, stop answers, retry behavior, interactive runs, and
--no-completion-gate are unchanged.
Fixes#1154
* fix(agent): count consecutive empty turns in stop reason
The headless no-output stop reason used result.Turns, which counts every
turn in the run, so a run with an earlier tool-call turn claimed "stopped
after 4 turns with no output" for a 3-turn empty streak. Use the guard's
emptyTurns counter and say "consecutive".
Pinned by a case with a successful tool turn before the empty streak.
* fix(chatgpt): refresh subscription defaults and compatibility version
Replace retired preset suggestions with vendor-documented Sol, Terra, Astra, and Luna IDs. Update the Codex discovery compatibility floor for Terra and verify that saved model selections are retained.
Refs Twigpine/zero#1177 (defaults portion)
* test(chatgpt): assert discovered model in compatibility probe
Verify the live response contains exactly gpt-future-coder in addition to checking the Codex compatibility query value.
Refs Twigpine/zero#1177
* docs(chatgpt): align Terra description with vendor guidance
TestScanSSEDataWithContextContentResetsOnData wrote a data line every 30ms against a 100ms idle timeout, so a ~70ms scheduling stall on a shared runner tripped the idle watchdog. Use a 500ms idle timeout (600ms content window) and 25 writes (~750ms), which keeps every gap far under the idle timeout while the run still outlasts the content window.
Signed-off-by: ShryukGrandhi <[email protected]>
* fix(sessions): capture freeform apply_patch checkpoints
Freeform apply_patch arguments are raw patch text, but both checkpoint
paths passed them to json.Unmarshal and dropped the error, so they found
no mutation targets and recorded no checkpoint. The tool still executed,
leaving rewind unable to restore the edit.
Resolve targets through a shared agent.CheckpointTargets helper that
prepares freeform input on the registered built-in apply_patch, exactly
as executeToolCall does. This keeps absolute patch headers
workspace-relative (rewriting them and setting cwd) instead of handing
absolute paths to the checkpoint layer. Non-freeform calls now use the
same tolerant decoder as execution, so the two cannot disagree.
Covers the CLI, spec-draft, and TUI capture paths.
Fixes#1162
* test(sessions): canonicalize freeform absolute-header fixtures
t.TempDir returns the symlinked /var spelling on macOS and short names on
Windows, while the apply_patch tool resolves the physical root. The
absolute-header cases must build the header from the resolved root, as the
repo's cross-platform test guidance requires.
A finishing member's task reads Done before its slot is freed: the
supervisor completes the task, removes the member and only then frees
the slot in afterExit. The test waited for Done == 5 and checked the
running count straight after, so a busy runner could see one slot still
held. It now waits for the drain itself.
TestExecCommandTTYSkipsInteractiveBlock runs ssh to a bogus host under
tty with a 250ms yield. When ssh is still resolving the host after the
yield, the session keeps running in the test's directory and nothing
stops it, so on Windows t.TempDir's cleanup fails with a sharing
violation. It failed Smoke (windows-latest) on a README-only PR.
Stop the session in a cleanup, and build the tool on execTestRoot,
which retries the removal while Windows still holds the directory right
after the stop, as the other exec tests that leave a session do.
Adds Vercel beside GMI Cloud in the Partners table of both READMEs,
with Vercel's official logotype in light and dark variants, taken
unchanged from its brand page apart from a trailing newline.
* docs: add Vercel OSS Program badge
* docs: add the Vercel OSS Program badge to README_ZH too
The two READMEs carry the same badge row, so the Chinese one gets the
badge as well. Both now list alt before src, like the other badges in
the row.
* feat(skills): install a skill's bundled files and point the skill tool at them
A skill was only its SKILL.md: install copied that one file, and the skill
tool returned only the markdown body, so a skill whose instructions run its
own scripts was broken by installing it, and a hand-placed skill directory
gave the model no way to find the files beside it.
Install now copies the whole skill directory, leaving out hidden entries
(.git, .env) and symlinks, which it neither follows nor copies but reports.
A script stays executable; everything else is installed 0644. A tree over
1,000 files or 32 MiB is refused. skills.lock records a treeHash beside the
SKILL.md hash; skill info reports drift in bundled files, and an entry from
before this is still checked on SKILL.md alone. A reinstall that changed
only a bundled file is reported as an update.
Both skill tools end their output with the skill's directory and a list of
up to 50 bundled files when there are any; a SKILL.md-only skill is
returned unchanged.
Closes#584
* fix(skills): compare a reinstall over an old lock entry with SKILL.md alone
A lock entry written before tree hashes has no treeHash, so the reinstall
compared the new tree hash with an empty string and always counted as an
update. Re-adding an unchanged skill that is only a SKILL.md printed
"Updated" and "the files bundled with it changed" when there were none.
That entry comes from an install that copied SKILL.md alone, so compare
with that: it is an update when SKILL.md changed or the reinstall now
brings bundled files. InstallResult gains previousTreeHash, and the CLI
uses it to say the bundled files are now installed rather than changed
on the first reinstall over such an entry.
* fix(skills): match the tree hash when recovering an interrupted install
lockReconciler matched both trees of an interrupted workspace on the
SKILL.md hash alone. A reinstall that changes only a bundled file keeps
SKILL.md, so a kill between the tree swap and the lockfile publish left
two trees carrying the recorded hash, and recovery kept the new one and
retired the backup the lockfile still described.
A tree now matches an entry with a tree hash only when both hashes
match. An entry from before tree hashes is still matched on SKILL.md
alone. A tree hash that cannot be computed is returned as an error, the
way an unreadable SKILL.md already is, so recovery decides nothing about
a tree it could not read.
Also bring the package comment up to date: install copies the skill
directory, not SKILL.md alone.
* test(tui): pin the cwd in tests that assert text is absent from the view
Nine tests built their model with an empty Options.Cwd, so newModel took
the package directory and the title bar showed the developer's real
branch and checkout path. Each then asserted that some text was absent
from the whole view, and failed wherever that branch or path happened to
contain it: TestSuggestionOverlayRenders on a branch with /model in its
name, TestSuggestionOverlayCapsRowsWithoutMoreText on a branch or path
containing "more" or "compact", the provider wizard tests on "offline",
"chatgpt" or "gpt-4.1", and so on.
They now pin Options.Cwd to isolatedCwd, a path that does not exist, so
no branch is read and the path is the same on every machine. A guard
test checks that isolatedCwd stays outside any git checkout.
* test(tui): make isolatedCwd fully qualified and absent by construction
The fixed /workspace/zero path could exist on some machine, a dev
container for one, and newModel would then read whatever is there. On
Windows it was also only drive-relative: \workspace\zero is not a fully
qualified path.
isolatedCwd is now a child of the home TestMain isolates that nothing
creates, so it is fully qualified, absent and outside any checkout on
every platform, and the title bar shows ~/not-a-checkout everywhere. A
t.TempDir path would have put the user name and the test name into the
view. The guard test checks all three properties.
* test(tui): clear the branch in view-test models instead of trusting the cwd
isolatedCwd is a child of the isolated home, which lives under the
temporary directory. With TMPDIR inside a checkout, gitBranch walks up
from that absent path to the checkout and puts its branch in the title
bar, so the guard failed on an ordinary branch and a colliding branch
broke the nine view tests again.
newViewTestModel now builds those models: it sets the isolated cwd and
clears the branch after construction, since no path can promise there
is no checkout above it. The guard keeps only what a path can promise
(fully qualified and absent), and a new test puts the home inside a
checkout on feat/model-picker and checks that neither that branch nor
the real checkout path reaches the view.
* test(tui): assert no checkout path reaches the view in the enclosing-checkout test
The test checked the branch strings and that the isolated cwd shows,
but a view could show both the isolated cwd and a real checkout path
without failing it. It now also asserts that neither the enclosing
checkout nor the directory the tests run from appears in the view.
The variable turns off both the models.dev fetch and the cached overlay,
and four tests in modelsdev_test.go inherited it from the shell. With it
set, TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering and
TestDefaultModelEntriesAppliesFreshCache fail, and
TestCachedModelsDevProvidersIgnoresStaleCache and
TestCachedModelsDevProvidersRequiresOptIn pass without reaching the
check they are named for: the nil they expect comes from the variable.
Clear it in each of them, as the two sibling refresh tests already do.
GHSA-5r4p-p66f-jhc7 (moderate): hono's serveStatic decodes the request
path a second time, so middleware on static paths can be bypassed.
Versions 4.12.4 to 4.13.10 are affected and main pins 4.13.9 through
tuistory, which release packaging installs as a local-control helper.
npm update takes hono to 4.13.13, inside every range that asks for it.
package.json is unchanged and nothing else in the lock moves.
* fix(oauth): use the crash-safe secret store and sync token publications
The encrypted-file OAuth backend carried its own AES-GCM secret implementation that had drifted from internal/securefile: a crash during first login left <secret>.lock behind and every later Save timed out instead of reclaiming it, and the secret and token blobs were published without fsync.\n\nUse securefile.Crypter with an opt-in publication directory so the secret still publishes through the sandbox-denied .secret.publish directory, add file and directory barriers to the encrypted token blob, and extend securefile with the publication directory plus destination-directory sync.\n\nMove the concurrent-creation and wrong-sized-secret coverage to securefile, add a stale-lock regression through the OAuth store, and test publication-dir use, sync ordering, sync-failure preservation, and retry after a failed secret sync. The stale-lock test fails on the previous code.
* test(oauth): cover post-rename directory-sync failures
The existing failure tests inject a file sync error before the rename. Add coverage for a destination-directory barrier failure after the token or secret has been atomically replaced: the write must report the error and the newly published bytes must remain readable. Also cover the corresponding securefile secret path, including its expected size.
* fix(securefile): never clobber a newer lock when restoring a reclaimed one
reclaimStaleLock could rename a lock that had just been replaced, then restore the quarantined copy with os.Rename, replacing a third holder's new lock. Both holders could then publish different secrets for the same file. Restore with O_CREATE|O_EXCL and discard the quarantined copy when the destination is already held.
Extend the directory-sync failure test to prove a later caller actually loads the published secret, not just its size. Add restore tests for the free and already-held cases.
govulncheck now reports GO-2026-6809 (crypto/x509 distrusts compromised
intermediate certificates) against Go 1.26.9, reachable through
daemon.WriteFrame, so Security & Code Health fails on main and on every
open PR. It is fixed in 1.26.10.
CI and release builds read the toolchain from go.mod. The stated
source-build minimum in README.md, README_ZH.md and docs/INSTALL.md
moves with it.
runExec starts a background models.dev download unless
ZERO_DISABLE_MODELS_FETCH is set. TestRunExecImageAttachmentHistory moved
the user directories into a TempDir by hand but left the fetch on, so a
5.7 MB download landed in the TempDir while its cleanup ran, and the
macOS runner failed with "unlinkat ... directory not empty". The two
older image tests had no isolation and fetched into the real user cache.
Use isolateCLIUserState in all three, which also points the cache path
into the fixture and turns the fetch off.
* fix(tools): grep stops where a file's line breaks stop
scanGrepFile treated EOF after a trailing line break - and an empty file -
as the START of another line, and handed the matcher an empty line one
number past the real content. Every pattern that matches an empty string
then reported a match on a line no reader sees:
a.txt holds "x\n"; grep pattern "^$"
-> a.txt:2: (grep -c '' GNU-style reports no such line)
same file, pattern ".*", count mode
-> "2 matches found" (one real line, one phantom)
an empty file, pattern "^$"
-> a.txt:1: (the file has no lines at all)
read_file and the file tracker's countLines already state the rule the
scan violated: "the trailing newline does not open a line", and an empty
file has no lines. The scan now stops there too, so anchored and
empty-matching patterns can only match the lines the content carries.
Real matches, numbering, count-per-hit accounting for non-empty patterns,
file mode, glob and symlink handling are unchanged.
* test(tools): make the grep line-number parity test catch the phantom
The parity test matched only "needle", a pattern that cannot match the
empty line the old EOF branch invented, so its absence-of-line-4
assertion passed on the code being fixed. The pattern now also matches an
empty line, and the expected output is asserted exactly: on the pre-fix
scanner the same run reported
grep = "a.txt:2: needle two\na.txt:4: ",
want exactly the line read_file numbers 2
so the phantom is visible right beside the real line, and the test is
about the numbering claim it states. Also gofmt-aligns the case table
that made the CI formatting check fail.
Two credentials glued end to end share one recovering property: the second
one's trigger prefix is where redaction can re-anchor. For fixed-length
bodies that anchor is exact (AKIA{16} stops at the next key), which is the
case the anchored end-chaining always covered. For unbounded bodies it
isn't: github's [A-Za-z0-9]{36,} body eats the literal "ghp" of the token
glued behind it, so the first match ends PAST the second trigger, the scan
that re-anchors at the span end finds nothing there, and the second
credential leaves the output as +_bbbb... / .eyJzdWIi... plaintext:
RedactString("ghp_aaa..36a..ghp_bbb..36b..")
-> "[REDACTED]_bbbb......" (second token's body printed in full)
RedactString(jwtA+jwtB)
-> "[REDACTED].eyJzdWIiOiI5ODc2...SA" (second token's payload+sig)
The same holds across shapes (a github key directly feeding "sk-ant-..."
through its body) and for three tokens in one run.
redactAdjacent now follows the run a match starts: at each boundary it
re-binds the match at the EARLIEST trigger occurrence inside the span that
stills validates as its shape (the head before the trigger must match the
anchored pattern exactly, and the run chained from the trigger must reach
past the span, so a mid-body lookalike and a mid-span cut that recovers
nothing leave the span alone), and continues under whichever shape matched
the swallowed trigger. Candidate examination is capped at
maxTriggerOccurrences per span so body spam of trigger lookalikes costs
what the span costs, and the first validating cut wins.
End-chaining for fixed-length bodies, single-credential shapes, custom
replacement templates, and the split path's announced no-op-for-contiguous
text guarantee are all unchanged; the regression tests pin the glued pairs
that previously leaked plus the AWS chaining and lone-token cases.
#1149 was opened before #1105 moved the module to github.com/Twigpine/zero
and merged right after it, so its two imports of internal/gitsafe still
used the old path. Nothing provides github.com/Gitlawb/zero any more, so
main stopped building at 2ffe1831.
* fix(sessions): preserve image attachment summaries on resume
Persist TUI-compatible attachment counts for ACP and exec user events.
Restore readable markers in live history, session replay and resumed
prompts without storing or resending historical image data.
Cover image-only and mixed prompts, restart recovery, malformed metadata
and the aborted-turn boundary with regression tests.
Fixes#1135
* fix(workspaceindex): follow the current module path
Use github.com/Twigpine/zero/internal/gitsafe in the ignore scanner and its
tests. The upstream module rename preceded the scanner's gitsafe change,
leaving these two imports on the old path and preventing the rebased tree
from compiling.
* fix(sessions): count only images accepted by the model
Take ACP and exec attachment counts after the vision capability gate,
matching the TUI's persisted attachment semantics. Update the ignored-image
regressions to require zero saved images and no historical image marker.
Refs #1135
* fix(workspaceindex): run Scan's git calls through gitsafe
Scan asks git for ignored paths on every turn with its own gitCommand, which
turned off core.fsmonitor and dropped the repository-location variables but
still let a clean filter or diff driver from the repository's own config run
during status. It now goes through gitsafe, like the TUI and the scratch-file
warning: hooks are off as well, and a repository whose config defines a filter
or diff command takes the non-git path. The status call also passes
--ignore-submodules=dirty.
Scan makes two git calls, so gitsafe gains Check, which reads the repository
config once and builds several commands from it. Command is now Check plus one
command, and each command gets its own copy of the environment.
* test(workspaceindex): pin Scan's fallback when gitsafe refuses the repository
A repository whose own config defines a filter or diff command is refused by
gitsafe, and Scan then takes the non-git path. Scan reaches gitsafe.Check
through a package variable now, so a test can stand in for such a repository
without configuring one that names a command, and
TestScanFallsBackWhenGitsafeRefusesTheRepository checks both sides: with git
consulted, .gitignore decides and the fixed list does not apply; refused, the
fixed list applies and .gitignore is not read.
Update the Go module path, imports, install scripts, docs, workflows,
packaging and action defaults from Gitlawb/zero to Twigpine/zero. The npm
scope (already @twigpine/zero on main, with its @gitlawb/zero migration
notes), LICENSE, action author, the OpenGateway provider and historical
CHANGELOG links are unchanged.
The X-AIMLAPI-Integration-Repo header keeps Gitlawb/zero: it is what
AIMLAPI sees for attribution, so changing it needs a maintainer's yes.
docs/INSTALL.md keeps `go install github.com/Gitlawb/zero/...`, because
every tagged release so far declares that module path. The Homebrew
formula comment names the Twigpine/homebrew-tap tap.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(sandbox): support self-exec pattern to eliminate separate helper binary
Linux sandboxing previously required distributing a separate 'zero-linux-sandbox'
binary and looking it up on PATH.
This wires an internal '__sandbox-helper' entrypoint directly in cmd/zero/main.go
and configures findLinuxSandboxHelperCommand to fallback to os.Executable(),
enabling single-binary zero distributions with fully functional sandboxing.
* fix(sandbox): prefer PATH helper before self-exec fallback
Colocated zero-linux-sandbox stays first. A standalone helper on PATH
is used next. Self-exec of the main zero binary is only the last
fallback, so an installed helper is not ignored.
* feat(sandbox): clean formatting in main.go and add self-exec unit tests
* feat(sandbox): insert __sandbox-helper verb in bwrap plan for self-exec binary
* fix(sandbox): drive Linux selection from the self-exec helper resolver
Availability and command-plan construction now share the same helper
resolution and keep the __sandbox-helper prefix on the backend, so a
main-only install with bwrap reaches native sandboxing.
* fix(sandbox): self-exec only the zero main binary, not test binaries
* fix(sandbox): carry standalone vs self-exec identity into the inner bwrap plan
A versioned physical helper (zero-linux-sandbox-v1) must not receive
__sandbox-helper. The inner plan uses an explicit SelfExec flag, threaded
from argv, instead of guessing from filepath.Base.
* docs(install): describe self-exec as the last helper fallback
Self-execution runs only when neither a colocated nor a PATH
zero-linux-sandbox is available.
* docs(install): state colocated, PATH, then self-exec helper order
---------
Co-authored-by: cl-ment <[email protected]>
* perf(redaction): add fast-path byte checks to eliminate regex allocations on clean strings
Evaluating cascading regular expressions across every log line, tool output,
and session event incurred ~23 µs and 51 heap allocations even on strings with
zero secrets.
This introduces fast-path substring checks before invoking expensive regex
substitutions, yielding a 70x speedup and 0 B/op heap allocation on clean
strings.
* test(redaction): lengthen ghp_ fixtures and cover conditional gates
GitHub classic tokens need 36 characters after ghp_. Benchmarks and
shape tests now use a matching fixture. A table-driven test checks each
fast-path gate, including near-misses and case-insensitive Authorization.
* perf(redaction): structure triggered patterns and avoid allocation in header case checks
* test(redaction): cover secret-header and URL-password gates
Keep the redaction marker literal after url.URL rewrites userinfo, so
callers still see [REDACTED] instead of a percent-encoded form.
* fix(redaction): unescape only the generated URL password
A path or query that already contains the encoded redaction marker
must stay encoded. Only the userinfo password is rewritten in the
clear [REDACTED] form.
* fix(redaction): re-escape URL username after parse
Username() is decoded. Re-encoding with url.User keeps control bytes
out of the redacted URL so a %0AAuthorization userinfo cannot inject
a header after the header pass.
* fix(redaction): fail closed on password URLs without a host
* test(redaction): isolate query-gate coverage from assign and text secrets
* fix(redaction): fold Unicode U+017F and U+212A in containsCaseInsensitive
Go (?i) regular expressions apply Unicode simple case folding, where U+017F (ſ) folds to 's' and U+212A (K) folds to 'k'. Teach containsCaseInsensitive to fold these runes to ASCII lowercase with zero allocations so case-insensitive header gates cannot be bypassed by Unicode-folded equivalents.
---------
Co-authored-by: cl-ment <[email protected]>
* feat(providers): add Concentrate provider preset
Register Concentrate (https://api.concentrate.ai/v1) as an
OpenAI-compatible catalog provider. Concentrate is already listed in the
README Partners section; this adds the provider itself.
- models.dev has no Concentrate provider, so the id `concentrate` and the
key name CONCENTRATE_API_KEY match OpenClaude's Concentrate
integration. `Concentrate AI`, `concentrate.ai` and `concentrate-ai`
resolve to it.
- Default model deepseek-v4-flash-0731: the dated id behind the
deepseek-v4-flash alias that OpenClaude uses. Concentrate's /v1/models
keeps the rolling name only in `aliases`, and onboarding keeps the
default selected only when it matches a live id, so an alias default
made setup land on the first model in the list (claude-fable-5).
- Curated models: deepseek-v4-flash-0731 (default), deepseek-v4-pro,
kimi-k3 and glm-5.3. Context windows (1,048,576 each), tool calling and
thinking support come from Concentrate's public /v1/models.
- Regression test: a Concentrate-shaped live list must contain the
preset's default model.
- README and README_ZH: key line and setup command.
* test(providers): prove the Concentrate default check runs on the live list
The regression test passed whenever the default model was in the result,
and the curated fallback also contains it. Assert that the mock served
/v1/models and that the result includes claude-fable-5, a model only the
live response lists, so a silent fall back to the curated catalog fails.
* fix(cli): let providers subcommands run with no active provider
`zero providers list`, `use`, `remove` and `rename` exited 3 with
`no active provider configured: active provider "" not found` as soon as the
environment supplied more than one provider and config.json had no active
provider: normalizeProvidersWithOptions only elects an active provider by
itself when there is exactly one, and resolveCommandCenterConfig treated the
resulting error as fatal, so the command stopped before doing anything. A
base URL without a key counts as a second provider too (applyProviderEnv
builds a profile from a base URL alone).
The resolver already returns the successfully normalized list alongside
ErrNoActiveProvider, and the interactive TUI already falls back to a usable
entry from it in runInteractiveTUIWithSetup. Do the same here instead of
failing, so these subcommands can answer for providers that only come from
the environment.
Also pin the four env-derived provider tests to a fixed environment and a
temporary user config. They set OPENAI_API_KEY but resolved through the real
resolver against the developer's own config.json and ambient provider
variables, so they failed on any machine that also exported another provider
variable and whose user config had no active provider.
Adds regression coverage for the two-env-provider case: `providers list`
must report every detected provider and `use`/`remove`/`rename` must give
their usual env-derived answer. Verified against the fix (pass) and with the
fix reverted (exit 3, `active provider "" not found`).
Fixes#1130
* refactor(cli): report the configured active provider, not an elected one
Provider-management subcommands answer for the SAVED configuration, while
the TUI's recovery path answers "what can I run with". Adopting
firstUsableProvider in resolveCommandCenterConfig conflated the two: with
two env-derived providers it made `providers current`/`list` present a
provider that `zero exec` would still refuse to run with, and a
base-URL-only profile failed `providers list` because the election demands
a credential. Return the resolver's partial config untouched instead, so
nothing is reported as active when nothing is selected.
Also removes the OAuth store read this path picked up with firstUsableProvider.
Adds assertions that `current` shows nothing selected, `list` marks nothing
active, `use --json` leaves activeProvider empty, plus a base-URL-only case.
* fix(cli): scope the no-active-provider exception to provider management
Returning the resolver's partial config made `zero config` exit 0 with a
partial summary, contradicting the resolver's documented headless contract:
`zero config` / `zero exec` still fail with the actionable message and only
the interactive TUI recovers from a missing active provider. Split the
helper so only issue #1130's commands — `providers list`/`current` and the
unpersisted `use`/`remove`/`rename` reporters — tolerate the state; `zero
config`, `detect`, `models` and `check` keep failing as before.
Also pin the OAuth store in providerEnvSetupDeps: summarizeConfig reads
OAuth login state through the default store, so a developer's stored login
could flip these assertions. Point ZERO_OAUTH_TOKENS_PATH at a temp file
and force ZERO_OAUTH_STORAGE=file so an ambient keyring setting cannot pull
the OS keychain in either.
* fix(workspaceindex): skip git-ignored and build-cache dirs in Scan only
Scan has a fixed file budget, and build output (Cargo target/, Python
virtualenvs, caches) could exhaust it before real source was reached:
repo-map reported truncation and ranked fingerprint files above code.
Inside a git work tree, Scan now asks git for the ignored set
(`git status --porcelain=v1 -z --ignored=matching .`), so the repo's own
.gitignore, nested ignore files, info/exclude and core.excludesFile all
apply, including names no fixed list anticipates (.locust_env/). Outside
a work tree, or when git is missing, older than 2.16, fails, or exceeds
5s, a fixed list of common build/cache directory names stands in.
ShouldSkipDir is unchanged: glob, grep, list_directory and path
autocomplete share it and must keep seeing target/, .venv/ and friends.
The lookup runs with GIT_OPTIONAL_LOCKS=0 so a per-turn scan never takes
.git/index.lock next to a concurrent git command.
Fixes#1108
* fix(workspaceindex): skip a rename's original-path field when parsing status
In `git status --porcelain=v1 -z`, a rename or copy record is followed by one
more NUL field holding the original path, with no XY prefix. The parser
inspected every field, so an original name beginning with "!! " read as an
ignored entry: after `git mv "!! a" b`, a tracked file "a" disappeared from
Scan.
Move the parsing into parseIgnoredStatus, which consumes that field for R or C
in either status column, and test it directly and end to end.
* fix(workspaceindex): disable repo fsmonitor and force untracked-files=normal for status
Scan's git status lookup must not run a repository's core.fsmonitor hook or fail
when status.showUntrackedFiles=no would reject --ignored=matching.
* fix(workspaceindex): ignore inherited git repository location
Git exports GIT_DIR and GIT_WORK_TREE to hooks. With those pointing at
another repository, Scan kept .locust_env/lib/x.py, which root's own
.gitignore excludes.
* fix(workspaceindex): quote the fsmonitor hook path in the regression
On Windows, sh treats an unquoted backslash path as escapes, so the hook
ran and the marker the test stats was never created. Require a status
without the override to create that marker first.
* fix(workspaceindex): pass the fsmonitor hook path to git with forward slashes
Smoke (windows-latest) failed TestGitIgnoredPathsDisablesRepoFsmonitor
because core.fsmonitor was set to a backslash script path. Git for Windows
hands that value to sh, which consumes the separators, so the hook never
runs and the marker is never created. The test now stores a quoted forward-slash
path. The product code is unchanged.
* fix(tui,cli): stop a repository's git config from running programs in Zero's own git calls
Zero runs git status and git diff on its own: the TUI's files sweep at
startup and after each command, its pull request diffstat, and the
scratch-file warning after a run. A repository's own config can name
programs those commands run (core.fsmonitor on every status, hooks when
the index is rewritten, clean filters and diff drivers assigned through
.gitattributes), so a workspace unpacked from an archive ran its own code
on the first turn, although Zero refuses the same workspace's project
hooks and plugins until the user trusts it.
internal/gitsafe builds those commands with core.fsmonitor and
core.hooksPath turned off and GIT_OPTIONAL_LOCKS=0, and drops the
repository-location variables git exports to hooks, using the same
environment for its config check and for the command. A repository whose
own config, or per-worktree config when extensions.worktreeConfig is on,
defines a filter or diff driver command is refused, and the callers
already treat that like a directory that is not a repository. The status
and diff calls also pass --ignore-submodules=dirty, because a submodule's
own config is not checked.
* test(gitsafe): keep the real-git setup off an inherited GIT_DIR
The setup helper in TestCommandAgainstRealGit ran git with the inherited
environment, so from a hook, or a shell that exports GIT_DIR or
GIT_WORK_TREE, git init and git config would act on the repository those
name instead of the temporary one. It now uses the same environment as
Command, and the test sets both variables to a decoy repository and checks
after each setup step that the decoy was not touched.
* fix(npm): wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps
npm applies a publish asynchronously: `npm publish` returns once the upload
is accepted, and the version appears later. In the 0.10.0 release the
@twigpine/zero wrapper appeared 9 seconds after publish-packages.sh stopped
checking for it, and the last platform payload about ten minutes after its
upload. Two things went wrong:
- The wrapper became visible before four of its five platform payloads, so
installs in that window resolved aliases that 404 and fell back to the
GitHub-release download. Ordering the uploads does not order what npm
shows.
- The final `latest` check gave up after 25 seconds and failed the job even
though every publish landed, which also skipped the @gitlawb/zero steps
until a re-run.
publish-packages.sh now waits until npm shows every platform version before
it publishes the wrapper, then waits until `latest` is the wrapper version.
One deadline bounds the waits (ZERO_NPM_PUBLISH_WAIT_TIMEOUT, default 30
minutes). On timeout it fails before the wrapper goes out and says what npm
never showed. These lookups treat an error as "not yet", unlike the skip
decision, which still stops on anything but E404.
The test fake can now apply a publish only after a number of later lookups,
and records which aliased versions were missing when the wrapper was
published. Against the script as released in v0.10.0, the two new tests fail
the way the release did: the job exits 1 although everything landed, and the
wrapper goes out while its platform payload is not visible.
The two release steps that published 0.10.0 once more as @gitlawb/zero have
done their job and are removed; docs/NPM_PACKAGING.md and the comments that
pointed at them now describe what happened. The Go and wrapper handling of
the old name stays, since only a build installed under it can trigger it.
* fix(npm): validate the publish wait settings, and correct the legacy publishing claim
ZERO_NPM_PUBLISH_WAIT_TIMEOUT went into Bash arithmetic as typed. A leading
zero reads as octal (0600 waited 384 seconds), and 08 is an arithmetic error
that, in a script, does not stop it: Bash prints the error, leaves the
deadline unset and carries on, so the run only died at its first wait, after
the platform payloads were published. A negative timeout ended the wait
before it started, and a bad ZERO_NPM_PUBLISH_RETRY_DELAY only failed at the
first sleep, also after publishing began.
Both settings are now checked before anything is published: whole seconds
only, converted with 10# so a leading zero is decimal, bounded before the
conversion, and capped (timeout 86400, delay 600). A bad value fails with
the variable's name.
TestPublishPackagesReadsTheWaitTimeoutAsDecimal makes the script wait, which
is where the old code broke: against the reviewed script it publishes the
platform payload and then exits 1 on "08: value too great for base".
TestPublishPackagesRejectsAnInvalidWaitSetting covers -5, abc, 1e3, an
over-range and an overflowing timeout, and a non-numeric delay; the old
script published with five of them and crashed on the sixth with
"abc: unbound variable".
docs/NPM_PACKAGING.md said nothing could publish @gitlawb/zero again.
Removing the trusted publisher stops this workflow; a maintainer could
still publish by hand, and the doc now says that.
A session's lease file lived in the Store that took it, so once that Store
was unreachable the garbage collector closed the file, and closing it drops
the lock. That made TestForkAndChildHoldTheParentTheyAreCreatedFrom fail on
main whenever a collection ran before its dry-run prune. It can also happen
in an exec run: PrepareExec takes the calling or forked session's lease on a
Store the run never touches again, so the parent's lease can disappear part
way through and a prune in another terminal could remove it.
Leases now belong to the process, as Hold documents. Every Store holding a
session shares one lease file in a process-wide table, and the file is
unlocked and closed only when the last Store holding it releases it.
* feat(npm): publish as @twigpine/zero
The npm package moves from @gitlawb/zero to @twigpine/zero, and everything
that finds Zero's GitHub repository by name now names Twigpine/zero.
Package name
- package.json, the lockfile and release-please use @twigpine/zero.
`repository` names Twigpine/zero: npm checks provenance against it, so a
publish from this repo with the old URL fails with E422. The homepage is
zero.twigpine.com.
- The name lives in package.json only. build-platform-packages.mjs names the
wrapper, platform versions, alias names and alias targets from it, and
bin/zero.js reads its own package.json for the alias it resolves and for
every reinstall hint. The Go constant npmPackageName is held equal to
package.json by a test.
Repository defaults
- The update check (DefaultRepository), install.sh, install.ps1, the npm
wrapper's fallback downloader, and the GitHub Action's zero-repo default
all used Gitlawb/zero. That name now belongs to an unrelated GitHub org, and
these paths download and run what they find there, so they point at
Twigpine/zero. Docs, README and issue-template links follow. The Go module
path (and so `go install github.com/Gitlawb/zero/...`) is unchanged here.
Publishing
- The two inline publish steps become scripts/npm/publish-packages.sh, which
reads the name from the assembled payloads, refuses to start when the
wrapper's aliases do not match the platform payloads, skips versions
already on the registry (so re-running a half-finished job is safe), and
publishes with --ignore-scripts. The dist-tag rules are unchanged.
internal/npmwrapper/publish_test.go runs it against a fake registry.
Docs explain the switch for existing installs: uninstall @gitlawb/zero first,
because both packages own the `zero` bin and npm refuses the second (EEXIST).
* feat(npm): publish 0.10.0 once more as @gitlawb/zero to point users at the new name
Deprecating @gitlawb/zero alone would strand every existing npm install.
0.9.0's `zero upgrade` runs `npm install -g @gitlawb/zero@latest`; with
nothing newer under that name npm installs nothing, prints no deprecation
warning (it only warns when it installs something), and Zero then reports
"updated via npm to 0.10.0" while staying on 0.9.0.
So this release goes out once more under the old name, as a bridge:
- The publish job assembles the same verified artifacts a second time with
the new `build-platform-packages.mjs --package-name @gitlawb/zero` and
publishes them with publish-packages.sh, after the @twigpine/zero publish.
A gate runs it only while @gitlawb/zero's latest is still 0.9.0 (or is
this version, on a re-run), so it happens once.
- The build knows its name from the package.json beside it. The Go side
reports InstallMethodNpmLegacy: `zero upgrade` refuses with the switch
steps before any release check (so it works offline too), and
`zero update --check` adds them whether or not an update is out.
- bin/zero.js prints the same steps after zero exits (the TUI would hide a
notice printed first), only when stderr is a terminal, and not for
`update`/`upgrade`, which print it themselves.
- Updating from 0.9.0 now installs a real version, so npm also shows the
deprecation message.
The steps say to uninstall @gitlawb/zero before installing @twigpine/zero:
both own the `zero` bin, npm refuses the second install with EEXIST, and a
forced install loses the bin when the old package is later removed (checked
against npm 11.16).
docs/NPM_PACKAGING.md has the maintainer runbook: bootstrap and trust the new
package, retarget the old one's trusted publisher for the bridge, then
deprecate @gitlawb/zero, revoke its publisher, and delete the bridge steps.
* fix(npm): only skip a publish when npm says the version is not there
publish-packages.sh treated any failed `npm view <name>@<version>` as "not
published". A transient network, auth or registry error on the re-run that the
skip exists for therefore published a version that was already out, and npm
rejected it with EPUBLISHCONFLICT; with the registry unreachable it published
blind. npm 11 reports every kind of missing (no such package, no such version,
including platform versions) as E404, so only E404 now means "not published".
Any other failure is retried three times and then stops the job before it
publishes anything, with npm's own error shown.
The test fake now answers a missing version with E404 the way npm does (it
returned an empty success before), and can fail lookups with a network error.
The two new tests fail on the old lookup: one with EPUBLISHCONFLICT after
transient failures, the other by publishing while the registry is unreachable.
Also renames the unused retry variable in the legacy-name gate step to `_`
(shellcheck SC2034), matching the script.
shell-quote 1.9.0 is inside the range GHSA-pqg4-j6r4-53mv covers
(>= 1.8.4, < 1.11.0). It reaches the lock through tuistory,
@opentui/react and react-devtools-core, which asks for ^1.6.1, so
refreshing the lock moves it to 1.12.0 without touching package.json.
Release packaging installs the local-control helpers from this lock,
so the old version shipped in the helper tree.
* fix(acp): persist token usage events for ACP sessions
ACP turns never set agent.Options.OnUsage, so provider token counts were
dropped on the floor. zero exec and the TUI persist the same counts as
session usage events, and `zero usage report` totals those events, which
left every editor-driven ACP session at zero requests and zero tokens.
runTurn now queues each report through usage.EventUsagePayload, the same
writer exec uses for a non-escalating run, and commits it in the turn's
existing buffered batch under the session id. A hard run failure still
returns before persistTurn, so that turn's usage is dropped with its
history. StopCancelled stays a successful outcome and keeps the usage
already reported. session/load and session/resume still skip event types
other than messages, compaction, and tools, so the restored prompt and
the wire replay do not gain a usage channel. ACP has no model escalation,
so the payload has no "model" key and no prompt, tool argument, or other
raw input.
This sits beside the permission-mode field persisted on the session
(#1134). It does not change the ACP wire protocol, exec, the TUI, or the
report reader.
Refs #1118
* test(acp): pin cancel, resume, and payload shape for usage events
The persistence test showed that a successful turn records token counts,
and the hard-failure test showed that an aborted turn records nothing.
Neither one locked the two edges that are easy to get backwards.
A client cancel is a StopCancelled outcome, not a hard failure, so the
tokens the provider already reported stay in the session log. session/load
and session/resume read that same log; the replay and the next prompt
must still be the conversation, with no usage payload on the wire and no
token counts folded into the resumed prompt. The persisted JSON is also
checked to be token-count keys only, so a prompt or tool argument cannot
ride along in the usage record.
Refs #1118
* test(cli): pin sandbox policy golden temp roots
TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields compared the
policy snapshot to a fixed golden after stripping only the temp roots
NewScope keeps. NewScope drops a candidate that sits inside an earlier one,
while the JSON (built with a nil scope) still lists it, so a TMPDIR inside
/tmp — or TMP inside TEMP on Windows — survived normalization and failed
the golden. Pin the variables that path reads (TMPDIR on Unix, TEMP and TMP
on Windows) and cover a nested temp dir that is not inside the earlier
candidate, so the normalizer removes it instead of leaving it in the JSON.
Refs #1121
* test(cli): run the golden against an ambient nested temp dir
The nested golden created its temp dir in the package working directory and
failed when that path sat inside /tmp, so a checkout under /tmp could not
run it and the test wrote into the source tree. Stage the issue's shape
first — TMPDIR inside /tmp, or TMP inside TEMP — then let the pin override
those variables. Directories come from t.TempDir. If the nested shape cannot
be built, skip.
Refs #1121
* test(cli): give the local adoption probe a test-sized timeout
TestLocalAdoptionSavesAndReloadsEligibleModel relied on the 400ms
defaultLocalProbeTimeout, which treats a slow probe as "runtime not running"
and silently drops the candidate; under -race on a busy runner one slow
loopback round trip can exceed it and empty the detection result. Pass a
5-second test deadline instead. The production default is unchanged and stays
covered by TestDetectLocalRuntimesAppliesDefaultTimeout.
Refs #1132
* test(cli): correct the timeout-coverage note on the adoption probe
TestDetectLocalRuntimesAppliesDefaultTimeout pins the probe timeout with an
explicit 20ms deadline, so it covers timeout enforcement rather than the
400ms production default. Reword the comment accordingly; no assertion or
production behavior changes.
Addresses the CodeRabbit finding on #1136.
ACP kept the permission mode in memory only: both mode doors called setMode,
registerSession always started at PermissionModeAuto, and nothing wrote the
mode to the session store, so session/load and session/resume returned a
plan/ask session as auto after a restart.
Save the mode next to the model on both doors, store first so a failed write
fails the request instead of leaving disk behind memory, and restore it through
registerSession. A session with no saved mode (written before this change)
keeps auto; a saved value outside auto/ask/plan never comes back weaker than
ask, and unsafe stays unreachable over ACP.
Refs #1125
* test(sessions): run the session lock test on Windows and add a cross-process one
TestSessionFileLockSerializesAcrossStores was skipped on Windows with
"flock semantics differ on windows", so the LockFileEx path in
filelock_windows.go was never exercised by a test on the platform that
uses it. LockFileEx locks are per handle, which is what the test relies
on, so the skip was unnecessary: remove it.
Also add a helper-process test (same pattern as lockutil's) proving the
session lock blocks a different process and is released by the OS when
the holder exits without unlocking.
Both fail on Windows when the lock is replaced by a no-op.
Fixes#1104
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
* test(sessions): keep the session lock alive in the helper process
The helper discarded the unlock closure returned by lockSession, which is the only reference to the open lock file/handle. GC finalization could close it while the helper waits on stdin, releasing the OS lock early and making the cross-process test pass for the wrong reason. Retain the closure and runtime.KeepAlive it after io.Copy, as the lockutil helper does.
---------
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
* test(tui): isolate user home, config and cache dirs for the TUI tests
newModel reads <user config>/zero/commands/*.md, and the package has 500+
newModel calls with no isolation, so a developer's own slash commands and
state leaked into the TUI tests (and tests could write to real user
directories), against the AGENTS.md "Hermetic tests" rule.
Add testutil.IsolateUserDirs, which points HOME, USERPROFILE, APPDATA,
LOCALAPPDATA and the XDG_* roots at a temp dir (covering Linux, macOS and
Windows), and call it from a TestMain in internal/tui. Per-test t.Setenv
overrides still win.
Refs #1103
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
* test(testutil): fail closed when user dir isolation cannot be applied
IsolateUserDirs now returns an error when any Setenv fails, rolling back
the variables it had already redirected, and its restore func reports
restoration failures. The TUI TestMain exits non-zero when isolation,
restoration or removal of the temp root fails, and the helper test
restores the inherited XDG_DATA_HOME via t.Setenv before clearing it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* test(testutil): cover partial-redirect rollback and compare physical paths
Make Setenv injectable so a test can let HOME be redirected and fail the
next variable, proving IsolateUserDirs rolls back a variable it really
changed (the NUL-root case fails on the first call and cannot).
Add testutil.PathWithin, which resolves the existing part of each path
with EvalSymlinks before the containment check, and use it in both the
helper test and the TUI hermetic test so symlinked temp dirs and Windows
short names cannot cause false results.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
The peer approval prompt's decide closure called runtimeMessageSink from
inside Update. The sink ends in program.Send, which blocks on bubbletea's
unbuffered message channel; the only reader is the event loop that is
running Update, so answering a held cross-session message hung the TUI.
Give pendingPermissionPrompt a decideCmd that returns a tea.Cmd yielding
the peerDecisionMsg, and have resolvePermissionWithReason return it
instead of calling the sink. Tests now drive the decision through the
returned command and a blocking sink, which hangs on the old code.
Fixes#1099
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
TestExecCommandForegroundServerReturnsSessionAndServesHTTP parsed the
helper's listening address from the first exec_command result only, with
a 500 ms yield. On a loaded Windows runner the helper had not printed its
address yet, so the test failed even though the session was running.
Poll the session with write_stdin until the address appears (20 s
deadline), and register the cleanup before the address check so the
server is stopped on early failure too.
Fixes#1097
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
SetNotify was kept in #1001 as a thin replace-form wrapper over
UpdateNotify for existing tests, but every product surface (the CLI
`zero config notify` and the TUI /notify picker) calls UpdateNotify's
merge form directly, so the advisory deadcode job reports it as
unreachable. Move the replace helper into writer_test.go as the
unexported setNotify, keep every test it served unchanged in behaviour,
and point the two CLI doc comments at UpdateNotify.
`make deadcode` no longer lists internal/config; the other advisory
findings in that job are pre-existing and untouched.
* fix(config): sync atomic config replacements
Persist temporary config contents before rename, then sync the parent directory on platforms that support it. Return persistence failures and cover ordering, cleanup, and pre/post-replacement failure states.
FixesGitlawb/zero#1087
Amp-Thread-ID: https://ampcode.com/threads/T-01a0dcd7-47fc-76eb-82d3-3f6c25384912
Co-authored-by: Pierre Bruno <[email protected]>
* fix(config): sync new config directories and relax unopenable dir sync
Sync each directory entry created by MkdirAll into its parent so a crash
cannot lose the config directory after a successful first write.
Treat a config directory that cannot be opened as best-effort durable,
matching the sessions store's syncDir; only a failed Sync or Close is
reported, since the rename has already replaced the file.
Check the temp file is closed with Seek instead of Stat: on Windows,
File.Stat on a closed file returns "The handle is invalid" rather than
os.ErrClosed.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(config): report directory-open failures except for write-only directories
syncConfigDir treated every os.Open failure as success, so a caller could
acknowledge a save whose directory barrier was never reached (Copilot
review, PR #1093). Keep the maintainer-requested best effort for a
directory the user can write but not read, as in the sessions store, and
return every other open failure.
TestSyncConfigDir now expects os.ErrNotExist for a missing directory on
non-Windows hosts; it fails on the previous code with
"missing directory: error = <nil>, want file does not exist".
* fix(config): create the config directory durably on the lock path and roll back failed barriers
Every exported mutator acquires the config lock before it writes, and the
lock file is a sibling of the config, so lockConfigFile's MkdirAll was what
created the config directory on a first write. writeConfigData then found
nothing missing and never synced the new entries into their parents, so a
locking mutator could report success without the directory creation being
durable (CodeRabbit review, PR #1093).
A failed parent sync also left the newly created directories in place: a
retry found nothing missing, skipped the barrier, and reported success.
Route both creation sites through one helper, ensureConfigDir. It creates
each missing component with os.Mkdir so it knows exactly what it created,
syncs each new entry into its parent deepest first, and on any barrier
failure removes only the directories this call created, deepest first,
with os.Remove so a directory another process has populated survives. A
failed rollback is joined into the error, never swallowed. A component
that appeared between the lookup and the Mkdir is not rolled back but its
parent is still synced.
Regression coverage:
- TestLockingMutatorSyncsCreatedDirectories drives UpdateNotify on a
missing nested directory, asserts the parent syncs happen before the
lock file exists, that a failed barrier rolls the directories back, and
that the retry re-runs the barrier. On the previous code it fails with
"error = <nil>, want injected sync failure" because the lock path
bypassed the barrier entirely.
- TestWriteConfigDataRollsBackOnlyCreatedDirectories asserts a failed
barrier removes the created directories but keeps a pre-existing
directory and its file, and that a directory populated by another
process is kept and the failed rollback reported. On the previous code
it fails with "must be removed after a failed parent sync: <nil>".
---------
Co-authored-by: Amp <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Kevin Codex <[email protected]>
Register GMI Cloud (https://api.gmi-serving.com/v1) as an
OpenAI-compatible catalog provider and add it to the README Partners
section.
- The catalog id `gmicloud` matches the models.dev provider id, so the
remote model catalog resolves without a mapping. `gmi-cloud`,
`GMI Cloud` and `gmi` resolve to it.
- Reads GMI_API_KEY (the name in GMI's docs) and GMICLOUD_API_KEY (the
name models.dev uses).
- Defaults to Qwen/Qwen3.8-Max. The curated list is Qwen 3.8 Max and
Flash (GMI's partner models), then DeepSeek V4 Pro and Flash. All four
are served by GMI's /v1/models.
- README and README_ZH: key line, setup command, and the partner logo in
light and dark variants under docs/assets/.
Verified live with a GMI key: `zero providers check gmicloud
--connectivity` passes, and `zero exec` completes a read-then-write task
on both Qwen 3.8 models.
* feat(providers): opt-in ZERO_RESPONSE_HEADER_TIMEOUT override for slow first-byte deployments
The shared HTTP transport waits at most 120s for a response header. A local
model server that has to load a large model can take longer than that to
produce the first byte (measured 1-5 minutes on a throttled local Ollama), so
an otherwise-alive request is aborted.
Add ZERO_RESPONSE_HEADER_TIMEOUT, mirroring ZERO_STREAM_IDLE_TIMEOUT: a Go
duration or a bare number of seconds; "0", "off", "none" or "disabled" remove
the limit; an unparseable or non-positive value falls back to the default
instead of removing the limit. The default stays 120s, so nothing changes for
anyone who does not set the variable. The constant and the resolver sit next to
the idle-timeout equivalents in providerio.
Checked against a throttled local Ollama: ZERO_RESPONSE_HEADER_TIMEOUT=5s
fails at ~6.4s, =300s succeeds at ~223s (a request that would have hit the
120s ceiling).
Refs #1038
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0142QEjA7eEFcdXTpcTmcAZk
* fix(providers): keep the 120s header timeout when bare seconds overflow
A value such as 36028797018963968 passes strconv.Atoi and wraps to zero
when multiplied by time.Second, which would disable the default. Values
that do not fit in time.Duration stay on the default.
* fix(providers): keep timeout defaults when bare seconds overflow
ResolveStreamIdleTimeout multiplied bare seconds by time.Second the same way the header timeout did, so a large count wrapped to zero and disabled the watchdog. Both resolvers now share a bounds-checked conversion.
* fix(providers): apply the header timeout when the shared client is taken
The stall-hardened transport baked ResponseHeaderTimeout in at package init, so a later ZERO_RESPONSE_HEADER_TIMEOUT never reached the real client and the 120s check depended on the env at init. Each resolved value now keeps its own transport, and a slow header proves that transport enforces it.
---------
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Add three Trendshift badges under the tagline (#1 Go repository of the
day, #3 Go repository of the week, #3 on GitHub Trending) and a Partners
section listing Twigpine, Xiaomi MiMo, Novita AI, Concentrate, and Exa,
following the layout used in the OpenClaude README. README_ZH.md gets
the same badge row and a matching section.
The Novita, Concentrate, and Exa logos are vendored under docs/assets
with light and dark variants. The Twigpine and Xiaomi MiMo logos are
loaded from their own sites, as OpenClaude does; Twigpine switches to
its dark variant under a dark color scheme.
Reject starts when all slots are live or reserved, prune only completed history, and release reservations on launch failure. Keep live processes tracked even after failed termination. Add concurrent admission and real-process regressions for #1085.
Amp-Thread-ID: https://ampcode.com/threads/T-01a0dcd5-eb93-74c9-a1ac-5b140edaf973
Co-authored-by: Amp <[email protected]>
* fix(security): harden shell flag parsing, write roots, and formatters
- sandbox: recognize POSIX short-option clusters in shellCommandFlag (ZERO-ESC-02)
Extract payload commands behind combined short flags (-ec, -lc, -xc) and long
--command in dashCPayload and shellDashCPayload, preventing destructive
subcommands from bypassing recursive AST inspection and auto-allowing execution.
- tools: anchor write_file and edit_file to *os.Root descriptor (ZERO-TRAV-01)
Use openScopedWriteRoot and commitRootedFileContents to traverse parents relative
to the root descriptor handle, eliminating residual TOCTOU directory swaps.
- tools: harden format_on_write against untrusted execution and secret leakage
Pass --no-config to prettier to disable dynamic JS config execution, reject
formatter binaries resolved inside the workspace (formatterBinaryInWriteRoots),
and scrub credential-bearing environment variables before running formatters.
* fix(security): stop shell flag parsing at dash-dash and verify root descriptor identity
Stop recursing into shell `-c` payloads for tokens that follow the `--`
separator, which marks positional operands rather than options, in both
shellDashCPayload (text detector) and dashCPayload (AST analyzer). This
prevents `bash -- -ec '...'` from being classified on a command the shell
never runs as a command string.
Verify the identity of the *os.Root descriptor returned by openScopedWriteRoot
against a pre-open os.Stat of the resolved root, closing the check-to-use
window opened by os.OpenRoot re-resolving the path. A root substituted for a
symlink or a different directory is refused and the descriptor closed.
Regression tests fail on the unfixed code:
- TestShellDashCPayloadStopsAtDashDash, TestDetectInteractiveCommandAllowsNonInteractive
- TestAnalyzeCommand (`bash -- -ec ...` must stay non-interactive/non-destructive)
- TestOpenScopedWriteRootRejectsSubstitutedRoot
Bureau: desk-pr-1066
* fix(tools): relax symlink resolution error handling on host formatters and clean up test directory
* fix(security): anchor write preimage on root descriptor and report skipped formatters
---------
Co-authored-by: hazyhaar <[email protected]>
* fix(tools): hint when Windows resolves POSIX paths under the workspace root
On Windows a leading-slash POSIX path is not absolute, so read tools joined
it under the workspace root and surfaced a raw GetFileAttributesEx error.
Append a Windows-only hint naming the host OS and the workspace root when such
a path is missing, so the model switches to a workspace-relative path instead
of retrying the same shape.
Guidance only: which path is opened, confinement errors, and write-path
resolution are unchanged. All read tools share the annotated resolver.
Refs #972
* test(tools): keep the Windows path hint wiring test hermetic
The cross-platform wiring test used a fixed "/home/..." request, but
resolveScopedReadPathForGOOS injects only the annotation GOOS: on Unix the
request is host-absolute, so root was ignored and the test read state it did
not own. Build the request from t.TempDir() instead - joined under root on
Windows, anchored in root on Unix - and resolve an existing file under that
tree to prove the resolution stayed in test-owned storage. The native Windows
end-to-end test is unchanged.
Addresses the P2 finding on #1063.
* fix(tools): reject mixed-separator UNC paths in the Windows hint
Windows treats both separators as valid, so a second leading separator in
either position (/\server\share, /\?\C:...) is the same UNC or device-style
prefix as //. looksLikePosixAbsolutePath excluded only the literal "//", so a
missing mixed-separator target was misreported as a POSIX path resolved under
the workspace root. Exclude either separator byte and cover both mixed forms
in the shape and annotation tables.
Addresses the P3 finding on #1063.
* feat(sessions): prune old sessions, never one another process has open
Store.Prune removes sessions last updated before a cutoff of at least a
day, and reports what it removed, what it kept and why, and what failed.
A dry run makes the same decisions and removes nothing.
A session holds no lock between writes, so nothing told a prune in one
terminal that another terminal still had the session open. Each session
now has a lease file: a process that writes a session, or loads it
through the rehydrated reads the TUI, exec --resume and ACP use to
continue one, holds a shared lock on it for its lifetime, and prune
takes it exclusively and leaves alone any session it cannot get. On
Windows the lease file is opened with FILE_SHARE_DELETE so a held lease
never blocks removing the directory.
Prune also keeps every ancestor of a session that stays, since Lineage
and Tree fail on a missing ancestor, removes descendants before their
ancestors, re-checks each session under its lease and write lock, and
removes the metadata first so a removal that fails part way leaves
nothing that looks like a session.
Part of #971.
* feat(cli): add zero sessions prune, with a retention setting read from user config only
`zero sessions prune --older-than 30d` removes sessions last updated
before the cutoff, `--dry-run` lists what it would remove and why it
keeps the rest, and `--json` prints the report. The cutoff is days or a
Go duration and never under a day. Without --older-than, prune uses
sessions.retentionDays; with neither it refuses and says how to set one,
so nothing is removed unless asked for.
sessions.retentionDays is read straight from the user config file.
Sessions belong to the user rather than to a repository, so a project's
.zero/config.json cannot choose what prune deletes, and no resolver
merge copies the setting from anywhere.
Closes#971.
* test(sessions): a dry run reports a session another process has open as kept
* test(sessions): pin the dry run for undated sessions and the lease a new session takes
* test(sessions): report every failure when a session is written while pruning
* docs(readme): list sessions prune in the Chinese README as well
* fix(sessions): a fork or child holds its parent, and is refused while prune holds it
A lease is taken best effort, so a fork whose parent prune held at that
moment went ahead: the rehydrated read in exec --fork could not hold the
parent, Fork read it anyway, and the fork was created under a parent
prune was removing. Fork and CreateChild now take the parent's lease
themselves before reading it, and refuse when prune holds it, so the
parent of a session being created is never removed under it.
* docs(sessions): say that nothing calls Release yet, and what it is for
* fix(sessions): let go of the lease before removing a pruned session's directory
lease.lock is deleted with the rest of the session while prune still
holds it. Where a delete only takes effect once the last handle closes
(Windows without POSIX delete semantics: older builds, or FAT and exFAT
volumes), prune's own handle kept the directory from being empty, so
the removal failed and left an empty directory behind. Prune now closes
the lease after session.lock is gone and before the directory goes.
* test(cli): an empty --older-than is a usage error, not the retention setting
* fix(sessions): refuse to continue a session prune holds, rather than read it without the lease
A resume that started while prune held the session could not take the
lease, read the session anyway, and went on to use one prune was free to
remove: a read changes nothing prune checks again. The rehydrated read
behind the TUI resume, exec --resume and --fork, and ACP session/load
and session/resume now fails with ErrPruning at that moment, and none of
the three falls back to a raw read on it. ACP load stays best effort
about a history it cannot read, but not about this one. Writes are
unchanged: a write lands under session.lock, where prune checks the
session again.
Prune takes the lease through the new Store.HoldExclusive, which the
TUI and ACP tests use to hold a session the way prune does.
* test(sessions): a lease that cannot be taken keeps the session, and does not stop a resume
Prune fails closed when it cannot take a session's lease at all, so a
process whose own hold fails the same way can carry on: prune cannot
remove the session either. Refusing there instead would stop resume
and fork wherever locking does not work, for people who never prune.
* fix(sessions): keep a parent forked after the plan, and refuse sessions removed under a caller
Three gaps jatmn found in the prune lease rules.
A fork made after prune's plan, by a process that has since exited,
was not in the plan, so its old parent was removed and the fork was
left under a missing ancestor. Once prune holds a parent exclusively it
now looks for sessions the plan did not see that name it as parent,
and keeps it if there is one.
Store.Create with a ParentSessionID, which exec --calling-session-id
and spec implementations use, did not hold the parent at all. It now
holds it the way Fork and CreateChild do, and all three refuse a
parent whose directory is still there without its metadata: prune
removes the metadata first and unlinks lease.lock after it, so a lease
taken at that moment lands on a fresh lease file and proves nothing.
A resume, exec --resume or --fork, or ACP load that picked a session a
moment before prune removed it could take a fresh lease and read the
session as empty. HoldToContinue now holds the picked session and
requires its metadata, and those callers go through it. The
rehydrated read on its own still reads a missing session as empty.
* fix(sessions): make a dry run decide at removal time like a real prune
A dry run appended every planned session to Removed without calling
pruneSession, so none of the checks made once a session is held for removal
ran: the second lease check, the re-read of the last update, and the scan for
a child created after the plan. On the same disk, a dry run could list a
session that a real run keeps.
pruneSession now takes a dry-run flag, makes every one of those checks under
the same locks, and stops before removing anything. The prune command reports
a session a dry run could not check as such, not as one it could not remove.
* test(cron): pass the history limit in the claim tests
#1094 added a limit argument to cron.Store.Runs. #1090 merged shortly
after from a branch that predated it, so its two new tests in
cron_claim_test.go still called Runs with only the job id and the
internal/cli test package stopped compiling on main:
internal/cli/cron_claim_test.go:111:38: not enough arguments in call to stores[0].Runs
have (string)
want (string, int)
That failed vet and every job that runs go test. Pass 0, which selects
the default MaxRunHistory cap, as the other callers in this package do.
* fix: bump Go to 1.26.9 for stdlib vulnerability fixes
govulncheck reports ten reachable standard-library vulnerabilities on
Go 1.26.6, all fixed in 1.26.9: GO-2026-6603, 6605, 6610, 6611, 6612,
6613 and 6617 in net/http, GO-2026-6608 in net/textproto, GO-2026-6607
in crypto/tls, and GO-2026-6604 in os (Windows only).
CI and release workflows read the toolchain from go.mod, so the go
directive is the only version to change. Update the stated source-build
minimum in the READMEs and install guide to match.
Login discovers the token endpoint (RFC 9728 / 8414) and may obtain a
client_id/client_secret through dynamic registration, but persisted none
of it. Refresh read only the config-file OAuthConfig, which is empty for
the standard `auth: oauth` setup, so every expired access token failed
with "no token endpoint configured for refresh" and required a new
`zero mcp oauth login`.
Store the discovered token endpoint (and whether it was server-advertised)
and the dynamically registered client on the token, and fall back to them
in Refresh. Explicit config still wins, and values already in config are
not copied into the store. A stored endpoint is re-validated before use:
the shared https/loopback rule always, and for a server-advertised
endpoint the same public-network policy and pinned client Login used. The
refreshed token keeps the stored fields so later refreshes keep working.
Fixes#1101
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
* fix(cron): preserve newer claims when overlapping runs complete
Let claimFire own schedule state and increment the current fire count under the store lock. Abort completion writes on read errors rather than restoring a stale snapshot.
Cover adjacent slots in both completion orders using independent stores, including failed executions, duplicate claim rejection, and unreadable completion state.
FixesGitlawb/zero#962
Amp-Thread-ID: https://ampcode.com/threads/T-01a0dc9c-8bc2-72cc-ad0b-6c64e3578df8
Co-authored-by: Pierre Bruno <[email protected]>
* fix(cron): avoid stale next-run summary on persist failure
Report the next run as unknown when completion state cannot be persisted, as requested in review. Extend the existing corrupt-state regression to verify the summary.
Amp-Thread-ID: https://ampcode.com/threads/T-01a0e862-0c9c-753c-9afc-45f9c1082c17
Co-authored-by: Pierre Bruno <[email protected]>
---------
Co-authored-by: Amp <[email protected]>
The pool stopped retrying only on exit 0 or ExitPermanent (76), so the
other codes `zero exec` uses to say a run is over (2 usage, 3 provider,
4 incomplete, 130 interrupted) were treated as crashes and retried up to
MaxAttempts with backoff. Exit 4 means the agent already ran, so each
retry repeated its side effects, re-billed the provider and appended a
second run to the same session stream.
Treat those four codes as final: return the worker's exit code with
ErrPermanent. Crash-type exits (1, signals) and launch/read errors still
retry.
Fixes#1100
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
* fix(redaction): redact adjacent AWS keys glued end to end
Two AWS keys with no separator between them (AKIA...AKIA...) only had the
first redacted: the leading \b in the secret patterns sees a word character
on the left of the second key and misses it.
Apply each textSecretPattern via a helper that also retries at every redacted
span end with a \b-stripped, start-anchored variant of the pattern, so a
credential starting exactly where the previous one ended is still caught.
Chained runs (three or more glued credentials) are followed to the end. A
mid-word occurrence that does not abut a redacted span still does not match.
Extracted from #1075 per Vasanthdev2004's suggestion on #969; #1067 remains
the fix for the RedactString leak.
* Address CodeRabbit critical: bound redactAdjacent span-chaining loop
The outer loop re-read len(spans) while the inner loop appended chained
spans to the same slice, so appended spans were re-chained and the span
count grew exponentially on long glued-key runs (RedactString handles
untrusted content). Bound the loop to the original match count and add a
regression test with 64 glued AWS keys asserting 64 markers promptly.
* fix(cron): bound run history and read recent records from the tail
Retain the newest 1,000 outcomes under the job lock and publish complete history snapshots. Add bounded reverse reads and regression coverage for legacy logs, failures, and concurrent stores.
FixesGitlawb/zero#1086
Amp-Thread-ID: https://ampcode.com/threads/T-01a0dcd6-ad63-74d8-8396-45573c57dcec
Co-authored-by: Pierre Bruno <[email protected]>
* fix(cron): skip oversized legacy history records
Discard entire oversized lines during bounded tail reads so compaction can record new outcomes without weakening atomic publication. Cover exact size boundaries and oversized first, middle, and unterminated final records.
Amp-Thread-ID: https://ampcode.com/threads/T-01a0e861-ebee-7764-be1d-e67517cd914c
Co-authored-by: Pierre Bruno <[email protected]>
* fix(cron): append the outcome when the history log cannot be replaced
On Windows the atomic replace of runs.jsonl fails while another process
holds the log open, which dropped every outcome for as long as it stayed
open. Keep the replace, and only when it fails append the record on a
fresh line. The leading newline terminates any unterminated tail; the
blank line it may leave is already skipped by readRuns. Zero's own
readers take the job lock, so they never observe a partial append.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(cron): preserve the history DACL and append only on sharing violations
Compacting runs.jsonl renamed a temporary file over it, which on Windows
replaced a DACL applied to the log with the job directory's inherited
one. Publish through ReplaceWithRetry so the log keeps its descriptor.
Fall back to appending only when the replace fails with a Windows
sharing or lock violation, the error a reader holding the log open
produces. Any other failure, such as a persistent access denial, is
returned so a log that can never be replaced cannot grow past the
retention limit. fsutil.IsSharingOrLockViolation exposes the existing
classifier, gated to Windows because errno 32 and 33 mean EPIPE and EDOM
elsewhere.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Amp <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* test(daemon): stop what a worker left running after it was reaped
A daemon worker is launched into its own process group, but Kill and the
Cancel hook pass its PID to background.TerminateProcess, which looks the
group up again with Getpgid when it signals. Once the worker has exited
and been waited, that lookup fails, only the dead PID is signalled, and a
child the worker left running survives. This pins the case with a shell
worker that backgrounds a sleep and exits: after the wait, Kill and the
Cancel hook must still stop the sleep.
Committed ahead of the fix so CI shows it failing on the current
behaviour on Linux and macOS.
* fix(daemon): stop worker process groups by their launch-time identity
Kill and the Cancel hook now go through background.TerminateCommandGroup,
which signals the group the worker was launched into (ConfigureChild-
ProcessGroup's Setpgid) instead of rediscovering it from the PID, so a
worker that has exited, or on Darwin one that is still a zombie, no longer
leaves its children running. It is TerminateCommand without the reap:
the pool waits on its workers itself and exec.Cmd calls Cancel from
inside Wait, so reaping there would be a second Wait.
On Windows it is the same tree kill, with the process handle pinned while
it runs. A command that has already been waited cannot be pinned; that
now reports os.ErrProcessDone, which exec.Cmd's Cancel contract treats
as nothing to cancel, instead of the unexported "already released" error.
Fixes#861
* fix(tui): show provider ownership in recent and favorite models
Display saved provider names in mixed-provider picker groups and include them in row sizing. Preserve provider-grouped rows and selection values.
FixesGitlawb/zero#840
Amp-Thread-ID: https://ampcode.com/threads/T-01a0d8e9-d346-71f9-bdac-4ac762d810a5
Co-authored-by: Pierre Bruno <[email protected]>
* fix(tui): label mixed-group rows with the provider Enter uses
Recent and Favorites rows labeled their owner from recent history, but
selection falls back to the active provider when that owner was renamed
or removed, so the label could name a provider that would not be used.
Derive the label from the same decision selection makes
(modelPickerSwitchOwner) and store it on the row as OwnerLabel.
Cap the owner prefix at 20 cells so an unbounded profile name cannot
consume the model name once the overlay reaches its 76-column maximum.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(tui): distinguish abbreviated model picker owners
Budget owner cues against the rendered row width, preserve model names, and distinguish abbreviated saved profiles with numbered suffix cues. Show the highlighted full owner as a wrapped key before selection. Cover shared-prefix and shared-suffix owners, narrow terminals, Favorites, and Enter routing.
Amp-Thread-ID: https://ampcode.com/threads/T-01a0e862-13e1-756e-9608-58d5fed96f05
Co-authored-by: Pierre Bruno <[email protected]>
* fix(tui): preserve model labels when owner cues cannot fit
Split owner cues onto a second line when the model needs the row width. Check the actually rendered row before suppressing the full owner key, so owner text hidden inside a clipped model label cannot hide that key.
Amp-Thread-ID: https://ampcode.com/threads/T-01a0e862-13e1-756e-9608-58d5fed96f05
Co-authored-by: Pierre Bruno <[email protected]>
* fix(tui): fit multiline model rows and mouse targets
Fit the model picker item window to its rendered viewport height and share that window with mouse hit-testing. Account for group headers and two-line rows so clicking either line selects the correct model and the keyboard-selected item remains visible.
Amp-Thread-ID: https://ampcode.com/threads/T-01a0e862-13e1-756e-9608-58d5fed96f05
Co-authored-by: Pierre Bruno <[email protected]>
---------
Co-authored-by: Amp <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* fix(sandbox): say when a session's sandbox enforcement is degraded
Only `zero doctor` and `zero sandbox policy` reported a degraded sandbox, so
a TUI session or a `zero exec` run went ahead with the native sandbox
effectively off and nobody saw it. Both now say so once, before the first
prompt or tool call, naming the downgrade reason: exec on stderr next to
the grant migration notice, the TUI as a system notice when it opens.
The line comes from the session's own engine through Backend.BuildPlan,
the plan `zero sandbox policy` prints, so the notice and the command that
explains it cannot describe two different sandboxes. Only the degraded
level speaks: a disabled sandbox is the user's choice, and the unelevated
Windows tier still enforces the write jail.
Exec tests that expect a quiet stderr now compare against the notice this
host prints under the default policy, since they run on the host's own
backend and a Linux runner without the helper is degraded while macOS and
Windows are not.
Fixes#1041
* fix(sandbox): say so when the nesting markers skip wrapping
With ZERO_SANDBOXED and ZERO_SANDBOX_BACKEND set, BuildCommandPlan passes
every command through unwrapped on the word of an outer sandbox nothing
verifies (#727). The notice came from Backend.BuildPlan, which does not
model that path, so a native backend reported full enforcement and a
session started with the markers ran every command unwrapped in silence.
The engine's notice now mirrors the runner and names the markers and the
unverified outer sandbox. A disabled sandbox still says nothing.
hostSandboxNotice builds its expectation through the engine too, and the
tests that pin the notice clear the markers so a suite run inside a Zero
sandbox does not change their answer.
* fix(tui): say the startup notices again when a session is resumed
/resume rebuilds the transcript from an empty one, so the startup notices
went with the old transcript and the session it switched to ran under a
degraded sandbox without a word. The model keeps the notices and puts
them back first, before the resume summary. Rewind and compaction stay in
the same session and keep the notice in the scrollback above their
divider.
Also pins why the notice can be decided once, at startup: grants made
during a session only open the network or add allow and deny paths,
never the mode, and every mode but disabled already needs the platform
sandbox, so no grant reaches a degraded command plan the startup notice
missed, and a deny path the backend cannot enforce is refused instead.
* fix(tui): say the startup notices again when /new opens a session
/new cleared the transcript and added only its own note, so the new
session started under a degraded sandbox without a word, the same gap
/resume had. It now puts the notices back first, the way /resume does.
/clear stays in the same session and, like rewind and compaction, keeps
the notice in the scrollback above its divider.
* fix(modelregistry): start no background refresh when the fetch is turned off
The TUI and exec each started a goroutine that called
RefreshModelsDevCache, which reads ZERO_DISABLE_MODELS_FETCH and the
cache path when it runs. A test that turned the fetch off through
t.Setenv could finish and have the setting restored before that
goroutine ran, and the refresh would then fetch into the developer's
real cache. StartModelsDevRefresh reads the setting before starting
anything, so with the fetch off no refresh exists to outlive the test.
* test(cli): keep the degraded-notice tests out of the developer's user state
runExecWithSandbox and the TUI launch test left the sandbox grant store
and the models.dev cache at their defaults, so they read the
developer's sandbox-grants.json and could migrate it, and could refresh
the real models cache. Both now run under isolateCLIUserState, and both
join TestCLILaunchHelpersLeaveUserStateAlone so the isolation is pinned
for each of them.
* fix(cli): stop exec when the degraded-sandbox notice cannot be written
The notice goes out before any tool runs, and before MCP servers and
plugins start through the same sandbox, but a failed write to stderr
was ignored, so the run went ahead with reduced isolation and no
warning delivered. It now stops with exitCrash, the way the image and
reasoning-effort notices in the same function already do.
The lock pinned hono 4.12.27 and @hono/node-server 1.19.14 through
tuistory 0.10.0, and npm audit reports advisories against both. tuistory
already declares ^4.11.7 and ^1.19.9, so the patched releases fit the
existing ranges: hono 4.13.9 and @hono/node-server 1.19.17. package.json
is unchanged.
npm also re-syncs the lock's root entry with package.json, which main had
already changed without regenerating the lock: license MIT, android in
os, and no install script.
* fix(oauth): give every loopback callback server the bounded shutdown
The loopback OAuth callback server was written out three times: the shared
oauth.LoopbackListener, MCP's Login and OpenRouter's login. Each ended with
Shutdown under a one-second deadline, and Shutdown only closes idle
connections. A client that has sent part of a request header is not idle, so it
survived the deadline, and its goroutine and the Serve goroutine stayed alive
after the flow returned (#1028).
#1051 fixed that for LoopbackListener (a header read bound, force-close when
Shutdown runs out of time, and a join on Serve) and left the other two exactly
as they were, because each was its own copy.
The lifecycle now lives once, in oauth.CallbackServer, with #1051's behavior
unchanged: ReadHeaderTimeout of 5s, Shutdown within 1s, Close() on a missed
deadline, and Close returning only after Serve has. LoopbackListener uses it
through an unexported constructor that keeps its ConnState test hook, and MCP's
Login and OpenRouterLogin use StartCallbackServer, which both packages could
already import.
Each call site gets a regression test that stalls a raw client mid-header ahead
of the real callback and then requires the stalled connection to be closed once
the login returns. The ordering does the synchronising: the stalled client is
dialled before the real callback, Serve accepts in order, and the login cannot
finish until the real callback has been served, so no sleep is involved.
Fixes#1028
* test(oauth): pin the callback server's header bound and its join on Serve
Two of the three things CallbackServer does had no test that noticed their
removal, the same as when #1051 introduced them on LoopbackListener: the
five-second header read bound, and Close waiting for the Serve goroutine.
The header test stalls a client mid-header and never calls Close, so it can
only pass on ReadHeaderTimeout; the bound is a package var so the test can
shorten it. It synchronises on StateNew. Go reports StateActive only after a
request read returns, and a stalled header read returns only when the header
timeout fires, so a helper waiting for StateActive depended on the bound under
test and turned its removal into a SETUP INVALID instead of a failure.
The join test uses the case where the join matters: with no connections open,
Shutdown closes the listener and returns at once, so without the join Close can
return before Serve does. Without it the test failed 10 runs out of 10.
* fix(acp): publish sub-run sessions loaded over ACP as view-only
session/load registered a fully promptable session for any session kind,
including the child, side and spec sub-runs that every other resume path
refuses through sessions.IsResumableKind: the TUI, exec sessions, the exec CLI,
and ACP's own session/resume. So a sub-run could be opened and continued as a
standalone conversation through ACP and nowhere else, with whatever its parent
run relied on about that sub-run's lifecycle no longer true.
Load has a real render-only use, a client rebuilding a transcript it can scroll,
so it still loads those sessions and still replays their history. What changes
is that the session it publishes for a non-resumable kind is marked read-only,
and session/prompt refuses it with an invalid-params error that says why.
runTurn is reached only from session/prompt, so that one gate covers every way a
turn can run; set_mode and the config options only change settings.
The flag is decided at first registration and never changes. Resume refuses a
non-resumable kind before it registers anything, and session/new always creates
a standalone session, so the only path that can publish one of these ids is
load, which now publishes it read-only.
TestACPResumeAppliesStandaloneSessionKindPolicy claimed "session/load should
retain render-only access", but its prompt check ran before the load, so it only
proved the refused resume never registered the session. It now prompts after the
load as well. TestACPLoadPublishesSubRunsReadOnly checks every kind in both
directions, so a gate that refused everything would fail on the resumable kinds,
with a setup guard that the table agrees with IsResumableKind.
Fixes#1045
* test(acp): require resumable sessions to be promptable after load, not merely unrefused
TestACPLoadPublishesSubRunsReadOnly decided the outcome from the error text:
"refused as view-only" was false for any error other than the view-only one, so
a regular or fork session whose prompt failed for some other reason still
passed. With every writable prompt made to fail with an internal error, the old
test stayed green. Raised by CodeRabbit on the PR.
Resumable sessions now have to prompt successfully. For the sub-run kinds the
decision is the RPC code, and the message is checked only to tell the view-only
refusal apart from other invalid-params errors such as an unknown session id.
* fix(mcp): forward image tool results on the existing image channel
Recreated from Gitlawb/zero#1012 (approved but unmerged).
Original: euxaristia/zero:feat/823-forward-mcp-images
* test(mcp): pin the pre-decode encoded cap and MIME sniffing guards
Adds the two reviewer-requested tests for the untrusted-input boundary:
- TestOverCapEncodedPayloadIsRejectedBeforeDecoding: a payload one byte
over EncodedLen(MaxImageBytes) is rejected and the decoder is never
called, so a hostile server cannot force arbitrary decode work.
- TestDeclaredMimeTypeDisagreeingWithSniffedBytesIsDropped: an HTML
payload declared as image/png is sniffed from its bytes and dropped
rather than forwarded to the provider as an image.
Both mutations (removing the cap, trusting the declared mimeType) were
verified to turn their test red locally.
* fix(agent): retry mid-stream transport aborts before tool dispatch
Recreated from Gitlawb/zero#1014 (approved but unmerged).
Original: euxaristia/zero:fix/973-mid-stream-transport-retry
* Address CodeRabbit nit: add Unix ECONNABORTED needle to shouldReconnect
midStreamAbortNeedles claims to be a subset of shouldReconnect but listed
'software caused connection abort' (the Unix/macOS ECONNABORTED text),
which shouldReconnect did not match. Add the needle alongside the other
abort phrases, and cover it in TestShouldReconnectClassification.
* fix(search): redact every text field of session metadata, not ten of thirty
`zero search --json` prints Hit.Session straight to stdout and redactMetadata
covered ten of its string fields. Filling every string-kinded field of
sessions.Metadata with a credential and walking the result names twenty that
came back carrying it: SessionKind, Tag, RootSessionID, AgentName, TaskID,
SpawnedFromEventID, SpecID, SpecFilePath, SpecStatus, SpecDraftModelID,
SpecDraftReasoning, SpecUserComment, SpecRejectReason, SpecSourceSessionID,
SpecImplSessionID, and the five on the Goal (Objective, Status, StatusReason,
CreatedAt, UpdatedAt).
The ones carrying the most free text are the ones a hand-kept list is least
likely to reach: SpecUserComment and SpecRejectReason are review text the user
typed, SpecDraftReasoning is the model's own prose, and Goal.Objective and
Goal.StatusReason are however the goal was phrased.
Goal is redacted through a copy. Metadata is passed by value but Goal is a
pointer inside it, so redacting in place would edit the session the caller still
holds, and a search would quietly rewrite the live record it was only asked to
report on.
TestRedactMetadataCoversEveryTextField walks the struct rather than repeating
the list, so a string field added to Metadata or Goal later fails on the day it
lands. It carries a SETUP INVALID guard that the chosen secret is a shape
RedactString actually recognizes, since otherwise every leg would pass
vacuously, and a second guard that the walk visited any fields at all.
* test(search): make the metadata coverage guard recurse, so nesting cannot slip past it
The first version of the guard walked only the direct string fields of
sessions.Metadata and hand-allocated the Goal beside it. That left it with the
blind spot it exists to close: a nested struct or pointer field added later
would arrive empty, the walk would find nothing in it, and the test would pass
while the new field went out unredacted. Raised by CodeRabbit on the PR.
Filling is now recursive through structs, pointers and slices, allocating what
it finds nil, and the walk is its mirror. The test compares the two counts, so a
shape one traversal understands and the other does not fails as SETUP INVALID
rather than passing quietly.
Both helpers fail rather than skip on a kind they do not understand. A field the
guard cannot populate is a field it is not guarding, and saying so at the point
it is added is the whole value of having it.
`go test ./...` is red on a developer machine and green in CI, for two tests
that take an input from the machine rather than from the test.
TestResolveReportsExplicitMaxTurns left ResolveOptions.Env nil on four of its
five cases, and a nil Env means "read the real process environment" (envValue).
Any one of the ten variables the resolver reads is enough to break it. With
ANTHROPIC_BASE_URL exported, applyProviderEnv synthesizes a second, keyless
"anthropic" profile from a base URL on its own, the single-provider shortcut in
normalizeProviders no longer applies because there are now two, and Resolve
returns `no active provider configured: active provider "" not found` before the
test reaches its assertion. Measured one variable at a time against a clean
environment, all ten fail the same test: ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY,
ANTHROPIC_MODEL, OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, GEMINI_API_KEY,
GOOGLE_API_KEY, ZERO_PROVIDER and ZERO_MAX_TURNS.
The cases now state an empty environment instead of no environment, which is
what they meant, and the test exports three hostile values itself so it proves
that rather than relying on the machine it runs on being clean. Reverting either
changed case fails the test on a clean environment too, so this cannot regress
silently on CI.
TestAltScreenTranscriptScrollKeepsFooterFixed asserted the branch survives in
the title bar at width 90 while leaving Options.Cwd empty, so newModel took the
real working directory. The outcome is not a monotonic function of that path's
length, which is why this took a measurement rather than a guess. The title bar
picks the widest candidate that fits: workspace carries branch and path, cwdOnly
carries the path alone, compactLeft carries the branch alone. At width 90 with
this branch, a cwd of up to 52 bytes keeps both, 53 to 74 falls to path-only and
drops the branch, and 75 or more falls again to branch-only and restores it. The
test failed from a checkout whose package path is 74 bytes and passed from one
at 80; CI sits at 40. Cwd is now pinned, which also stops newModel reading the
real repository for a branch the test overwrites anyway.
No production code changes. The resolver's nil-Env contract and the header's
candidate ladder are both deliberate; only the tests were leaning on them.
* fix(redaction): redact credentials split by an invisible character
Every shape matcher in RedactString describes a contiguous run of body
characters, and none of their classes admits a control or format character.
One NUL or ESC dropped into the body of a key therefore ended the match and
both fragments were emitted untouched. Whoever read that output next saw the
original key back, because nothing about those characters was ever displayed:
a terminal eats the escape, a log viewer or JSON consumer strips the control
byte, and a copy-paste drops both.
The matchers now also run over a copy with those characters removed, and the
spans they find are mapped back so the replacement covers the original bytes,
separators included. Emitting the compacted copy would hand the caller a
different string than the one they passed in, and leaving a separator behind
would read as two secrets where there was one.
Written against the character classes rather than the two bytes in the report,
because every other Cc and Cf character behaves the same way: BEL, backspace,
DEL, the C1 controls, zero-width space and joiner, word joiner, BOM and soft
hyphen all split a key on main. Tab, newline and carriage return are
deliberately excluded and a test says so: they are real text structure, a
credential broken across a line is visible to whoever reads it rather than
hidden from them, and stripping them would let one match span a line break and
replace unrelated lines.
Text with no such character takes exactly the path it took before, decided by
a byte scan that allocates nothing, so the common case is unchanged at 51
allocations. Text that carries control characters and no credential pays for
one compacted copy and a second pattern pass.
Fixes#969
* test(redaction): assert the exact output around a malformed byte
The malformed-UTF8 legs only asked whether the key was gone, so a decoder that measured an invalid byte as the three bytes of U+FFFD still passed: the replacement covered the secret and moved its edges onto the bytes beside it. They now assert the whole string. One leg also records that an invalid byte is not one of the invisible separators, on the same ground as tab and newline: it decodes to U+FFFD and is drawn.
* fix(redaction): keep credential boundaries while matching across invisible characters
The split-credential pass compacted every Cc and Cf character out of the whole
string before matching, which removed delimiters as well as filler. Two things
followed. A whole credential written after a NUL lost the word boundary the
shape matchers open with, so "prefix<NUL>AKIA...<ESC>..." stopped redacting even
though the unsplit "prefix<NUL>AKIA..." has always redacted. And with the
delimiter between two credentials gone, the unbounded JWT shapes ran out of the
first key and through the header of the next, replacing both with one marker and
exposing the second token's payload and signature. The same reach consumed
ordinary prose written after a key.
Three changes, one rule: a separator is filler inside a credential and a
delimiter outside one, and the difference comes from the source rather than from
the compacted copy.
The pass now runs after the contiguous matchers instead of before them, over
what they left, and never across a replacement. A whole credential is claimed by
its own strict match, so neighbours stay separate and the delimiter between them
survives. The shape matchers used here drop their leading word boundary, and the
byte before the match's original start is what has to be a non-word byte, so a
key after a NUL redacts and a key glued to the end of a word still does not.
The offset table is gone with them. It carried a start and an end for every
source byte, which is sixteen bytes per byte of input on a 64-bit build, and
RedactString is handed whole command output and notification bodies with no
bound in front of it. Only the endpoints of actual matches are resolved now, in
one walk. On a 4 MiB input with a split key, the split path allocated 223.6 MB
before this change and 154.9 MB after, against 146.3 MB for the same text with
no separator in it.
Tests cover a delimiter in front of a split key against its unsplit verdict at
every kind of leading byte, each supported shape placed in front of a JWT with
each class of separator, text immediately behind a key, a caller-supplied
replacement made of word characters, and the allocation ceiling.
* fix(redaction): match split credentials with gap-tolerant shapes on the original text
CodeRabbit found a bypass in the compaction approach:
"xAKIA<NUL>AKIAIOSF<SOH>ODNN7EXAMPLE" leaked while its unsplit counterpart
redacts. With the separators removed first, the leftmost AWS match began at the
decoy AKIA, was then rejected for the "x" in front of it, and had already
consumed the start of the real key, so the real key was never considered. The
ordinary-prose version is any word ending in "sk" followed by a hyphen, "ask-"
or "task-", ahead of a split OpenAI key.
Resuming the scan one byte after each rejection would close that but is
quadratic for the greedy shapes on text like "ask-ask-ask-..." with one NUL in
it. So the boundary goes back inside the pattern instead. Each shape is rewritten
through regexp/syntax so a run of separators may sit between any two characters
it consumes, but never before the first, and matched against the ORIGINAL text
with its leading \b intact. RE2 then judges the word boundary against the real
neighbour in one linear pass, and there is no compaction and no offset mapping.
The separator class is generated from the same Unicode tables as
splitSecretSeparator.
Two further cases came out of rerunning the seeded differential against main:
- The shapes are matched independently and the union of their spans replaced.
Applied one after another, a split key's unbounded body ran on across a
separator into a following JWT, stopped at its first dot, and left the JWT
shape nothing to match, exposing its payload and signature.
- A second pass, ungated, catches a credential glued onto a split one, which
gains its word boundary only once the first is replaced. It is often whole
itself, so its region can hold no separator by then.
On 6,000 control-free inputs the output is byte-for-byte main's. On 6,000 with
invisible characters, 147 outputs still hold a known credential once those are
removed, against 2112 on main; 140 of the 147 leak on main too when the input is
unsplit, and the remaining 7 are the one limit now written into the header
comment: a split key whose first fragment is a complete credential on its own,
with another credential glued onto its tail.
* fix(redaction): keep two split credentials of one shape apart
Matches of one pattern never overlap, so when a split credential's body ran
on through the separator after it, a following credential of the same shape
could not start a match of its own. A JWT body stops at the next token's
first dot and a classic GitHub body at its underscore, so two split JWTs
side by side left the second one's payload and signature in the clear.
A match the OpenAI filter rejects spans what it ran into as well, so a
split key after kebab-case prose was rejected with the prose and printed.
When a match runs through a separator run into a position where the same
shape matches again and reaches at least as far, that credential is taken
on its own, and the first is cut at the separators when it is a complete
match by itself. Each piece is filtered on its own. Candidates are tried
latest first and only where the shape's literal prefix follows the
separators, at most eight per match, so the pass stays linear.
The header comment now says plainly that the rest of a key whose front
part is complete alone stays visible, and drops a stale sentence from
before the shapes were matched independently.
* fix(redaction): recognise the next credential when its prefix is split too
The run-on search looked for a shape's literal prefix contiguously after
the separators, so a token split inside its first few characters was never
seen as a new start and its payload leaked again. The prefix is now matched
with gaps allowed between its characters, the way the shapes are. The new
test splits the second token's header at every character, which also
pins that the bounded search is spent only where a credential can begin.
* fix(redaction): take one run-on step per match, not the whole chain
Following a run-on from the second credential into a third, and on to the
end of the chain, did that walk again from every match inside the chain,
which is quadratic: a megabyte of split JWTs back to back allocated about
seventeen gigabytes and ran for ten minutes. Each match now separates only
the credential it ran into. The scan's own next match starts inside that
credential and takes the following step, so a chain is still covered, and
in chains of three or more the markers after the first pair can join.
A test bounds the allocation on a chain of split JWTs; the linear pass uses
about forty times the input and the walk used far more.
* feat(notify): make permission-prompt alerts on by default and discoverable
The notify system existed but was silent unless the user hand-edited
config.json, with no UI surface to discover or change it.
- resolver: fall back to mode=both, focusMode=unfocused when the notify
block is missing or empty (Fixes#579)
- tui: add /notify slash command with popup picker, mirroring /theme;
explicit choices persist via config.SetNotify
- cli: add `zero config notify` to read/update/reset the preference
(--mode, --focus, --reset, --json)
- config: add SetNotify writer using the existing atomic-write helper,
validating against the same vocab the resolver accepts
The TUI effectiveTUINotifyMode default (empty -> both) now matches the
resolver. exec_test.go seeds notify.mode=off where a test asserted
silent stderr, which the old empty-default implicitly provided.
* fix(notify): apply review feedback from CodeRabbit
- cli: omitted --mode/--focus flags now preserve the current resolved
value instead of wiping it (--reset remains the only clearing path);
aligns the CLI with the TUI's mode-only preservation behavior
- tui: reject /notify inputs with more than two tokens instead of
silently accepting them
- tui: apply /notify choices to the live notifier via the new
notify.Notifier.Configure, so the change takes effect on the next
permission prompt in the same session (the previous message claimed
this but only the persisted value was updated)
- notify: add Notifier.Configure (mutex-guarded policy swap that
preserves sinks, focus state, and the writer)
- tests: mode-only/focus-only CLI preservation, live-notifier apply,
trailing-argument rejection, Configure immediate-effect + sink
retention
* fix(notify): read cfg under the lock in Notify
Configure (334c688) made cfg mutable at runtime, but Notify still read
n.cfg.Mode before acquiring n.mu — a data race with a concurrent
Configure. Move the mode check inside the critical section and copy cfg
to a local for all reads.
Regression test TestConfigureConcurrentWithNotify runs Configure
concurrently with Notify; verified it reports DATA RACE on the unfixed
code and passes after the fix (go test -race -count=5).
* fix(notify): keep defaults out of the resolver; preserve the user's own values
Addresses the maintainer review (Vasanthdev2004) on PR #1001. All three
findings share one root cause: the resolved config was treated as if it
were the user's choice.
- resolver: no longer defaults notify.mode/focusMode. The TUI's
effectiveTUINotifyMode already maps empty -> both on its own, so the
permission-prompt alert still works out of the box, while headless
`zero exec` stays byte-identical to base (no BEL/OSC-9 on stderr under
-o json), the exec empty-stderr fixture is restored, and the
ZERO_NOTIFY_WEBHOOK_URL sink is not armed by an implicit default.
- cli: `zero config notify` seeds omitted fields from the user's own
file (new config.UserNotify), never from the resolved view — a
project config's mode:off can no longer be copied into the user's
global config, and blank stays blank instead of pinning today's
default as an explicit choice. --reset remains the only clearing path.
- tui: /notify mode-only changes preserve the focus stored in the
user's own file (blank stays blank); the /notify picker enumerates the
full 4x3 mode x focus space so every valid pair is a row, the current
pair is always preselected, and Enter can never commit a setting the
user did not choose. State view reads the stored pair.
- tests: the three regressions from the review — exec writes nothing to
stderr on a clean run (fixture restored + resolver empty-default
test), a ProjectConfigPath test proving project notify cannot leak
into the user file, and Enter on an open picker from a pair outside
the old curated list (off, always) keeps the setting unchanged.
* fix(cli): zero config notify no longer requires a configured provider
The command resolved the full config (providers included) before
touching notification settings, so a fresh user with no provider hit
ErrNoActiveProvider and could not read, set, or reset their notify
preference — the exact first-run user this feature targets (CodeRabbit
review, PR #1001).
The command manages a user preference, so it now talks only to the
user's own config file (config.UserNotify / config.SetNotify) and never
runs config resolution. Display reports the user's stored values — a
project config that overrides notify for one repo is not shown here,
matching the write path's source-of-truth from the maintainer review.
* fix(notify): separate live session policy from persisted user preference
Addresses the review from jatmn (PR #1001). The three notification
states — stored user file, resolved startup pair, live session — are
distinct contracts and must not be conflated:
- handleNotifyCommand now keeps two focus values per update: the LIVE
focus (in-session, initialized from the resolved pair, so a project's
focus rule keeps applying) feeds the notifier's Configure and the
status line; the PERSISTED focus seeds from the user's own file
(config.UserNotify), so a mode-only /notify writes the mode while the
user's focus value — blank included — is preserved and project values
never leak into the global file.
- /notify list reports the live pair (model fields), not the stored
file, which can legitimately disagree under a project override.
- the /notify picker preselects the live pair; committing a row is an
explicit choice, so Enter on the preselected row keeps the setting.
- /notify list requires exactly one token, matching /theme, /effort,
and /style (rejects "/notify list junk").
- shell completions: config gained a notify child with its flags
(--mode/--focus/--reset/--json), plus a completion-tree assertion for
both contexts so new CLI surfaces cannot go stale.
- help/copy pass: the CLI help now describes the stored global
notification preference controlling BOTH the completion and
needs-input alerts, --reset as returning the TUI to its effective
default (headless unconfigured stays silent), lists all three focus
values in /notify usage, and drops the stale "resolver default"
wording from effectiveTUINotifyMode and the command doc.
- tests: contract-matrix coverage with the three states deliberately
disagreeing (blank user file + project-resolved off/focused) asserting
list/picker/preselection/Enter, mode-only and explicit-pair updates on
both live and persisted sides, and restart precedence; the SA4006
lint hits are resolved by asserting the returned live model.
lint-static: 0 issues. Affected suites green under -race; the only
remaining local failures are the known environment flakes
(TestRunAuthOpenRouterSavesMintedKey on macOS keychain under a
sanitized env, and TestLoadProviderCommand* 5s subprocess timeouts
under full-suite load — both pass in isolation and fail identically on
clean origin/main here).
* fix(notify): close the remaining review findings (isolation, races, fidelity)
Addresses the third review round (PR #1001) as one boundary pass:
- BTW isolation: /notify mutations and the picker are now behind the
existing btwCommandUnavailable guard (like /theme), because the side
conversation shares the parent's notifier pointer — an unblocked
/notify off inside BTW reconfigured the parent and wrote the global
file while only the side's display changed. /notify list stays
available. Regression: side-conversation attempt then return, checking
the parent's actual notification output and the stored preference.
- Serialization: new UpdateNotify runs read-merge-write as ONE
transaction under a cross-process advisory lock (flock/LockFileEx on a
.lock file beside the config, credstore's pattern), so two concurrent
partial updates (--mode off vs --focus always) cannot interleave and
silently undo each other's explicit change. The CLI and the TUI's
mode-only persistence both go through it; the TUI's stale pre-read of
the stored focus is gone (the merge reads under the lock). Verified
0/30 lost updates driving the real binary with two concurrent
processes per round; a goroutine-level regression test asserts both
explicit fields survive.
- Fidelity: notification writes now edit ONLY the notify member's bytes
(setNotifyJSONObject, on the existing byte-preserving JSON editor),
so unrelated values survive with their explicit presence intact —
including tools.deferThreshold: 0 and MCP disabled: false, which the
typed serializer's omitempty cannot round-trip, and keys FileConfig
does not model at all. A reset removes the notify member instead of
leaving a {} husk.
- Startup recovery: Resolve's ErrNoActiveProvider partial result now
carries the parsed non-provider fields (notify, sandbox, tools, ...),
and the setup-recovery branch that clears the resolved config keeps
the notify block — a stored explicit mode:off no longer resurrects
alerts through the provider-recovery path. Regression: stale
activeProvider + stored off/always through recovery asserts the
launched TUI receives the opt-out, not the both/unfocused default.
- docs nits: zero config --help now describes both alert classes; the
two remaining "resolver default" wordings updated.
lint-static: 0 issues. Affected packages green under -race (the only
local failure is the known TestRunAuthOpenRouterSavesMintedKey macOS
keychain flake, which fails identically on clean origin/main here).
* fix(config): notify reset removes every duplicate notify member
A hand-edited config may contain duplicate notify members (JSON decoders
tolerate them; the last occurrence wins). The reset path removed only
the final member, so an earlier block survived and became the effective
preference on the next decode — `zero config notify --reset` reported
success while the old value still applied (CodeRabbit review, PR #1001).
Loop the removal until no notify member remains, mirroring
setPetPreferenceJSON's duplicate handling. The replace path still edits
the LAST member, which is the one a decoder treats as effective, so an
earlier duplicate stays inert there.
Regression TestSetNotifyResetRemovesEveryDuplicateNotifyMember verifies
the reset-orphaned failure output on the pre-fix code and passes after;
it also pins the no-op reset and the last-member-replace behavior.
* fix(config): join UpdateNotify release error; restore main's test surface
CodeRabbit pass on the rebased head, three findings:
- UpdateNotify discarded the advisory-lock release error while every
other writer joins it into the result (errors.Join). A failed unlock
would leave later config updates blocked while the caller is told
success. Now named-result + deferred Join, matching SetPet et al.
- The rebase had resolved model_test.go by taking the pre-rebase file,
which silently dropped every test upstream added or changed since the
fork point (composer blink/cursor suite, beginRun run-details,
scrim-preserves-semantic-colors, session payload, Termux burst, ...).
Rebuilt the file from origin/main plus only this branch's own additions
(TestEffectiveTUINotifyMode), so main's newer assertions are back:
the stale scrim assertion now expects semantic ANSI preservation, and
the removed TestBeginRunResetsSidebarHidden stays removed exactly as
upstream had already replaced it with TestBeginRunKeepsRunDetailsClosed.
* fix(tui): restore pickerTheme's transcript append; pin the picker failure path
Rebase regression (Vasanthdev2004, PR #1001): resolving the choosePicker
conflict attached the shared transcript-append line to the new
pickerNotify case, so the pickerTheme case lost it — a theme choice
whose preference could not be saved fell out with the session theme
changed and nothing reported, while the /theme text path kept its line,
so no test went red. Restore main's line in the pickerTheme case.
New regression TestThemePickerAppendNoteOnFailedSave drives the exact
reported shape: UserConfigPath under a regular file (the write must
fail), a pickerTheme item, choosePicker, and the could-not-save note
asserted in the transcript. Verified it fails on the pre-fix code with
the reviewer's own repro output and passes after.
Also restores the TestEffectiveTUINotifyMode comment to the post-review
wording (the default lives in the TUI, not config.Resolve, so headless
runs stay silent); the model_test.go rebuild had pulled the older
pre-notify phrasing back in.
* test(tui): restore the default palette after the picker regression test
Vasanthdev2004 follow-up (PR #1001): handleThemeCommand applies its
theme to the package-level zeroTheme, so TestThemePickerAppendNoteOnFailedSave
left dracula installed and the next palette-sensitive test
(TestFocusedPermissionSelectedRowUsesSelectionTintNotBrandChip) read
dracula's selection tint instead of the default. One line, the package's
existing convention: defer applyTheme(themeDark, true), as every other
theme-committing test already does. Verified the tint test fails in
sequence before the defer and the full internal/tui suite passes under
-race after.
* fix(tui): label the effective-default notify mode as "(default)"
jatmn round (PR #1001): the TUI printed the effective default mode as a
bare "both" while the new CLI labeled the same configuration "mode:
(default)" — the two first-party surfaces contradicted each other, and a
user could not tell whether "both" was stored or effective. Focus
already labeled its built-in default ("unfocused (default)"); mode now
mirrors it.
The model keeps the RAW configured mode (notifyConfiguredMode) alongside
the effective one, so state output can distinguish "both because the user
chose both" from "both because nothing is configured" — clearing to the
raw value when an explicit choice is made. effectiveModeLabel renders the
(default) tag on the /notify list card and the /notify change line, the
same wording the CLI read path uses. No defaults are persisted; resolver
and headless behavior untouched.
Regression TestNotifyStateLabelsEffectiveDefaultMode pins the contract:
unconfigured shows "both (default)", explicit stored "both" renders bare,
an explicit /notify bell choice renders bare in both the change line and
later state. Verified it fails on the pre-fix code and passes after.
* fix(config): one lock authority; full-replace across duplicate notify keys
jatmn round (PR #1001), two findings plus one documented precedence:
- UpdateNotify took a SECOND lock primitive over the same config.json.lock.
On Windows its LockFileEx range did not overlap lockutil's, so notify
writes could race provider/theme/credential/MCP writes and silently
discard them; on every platform it also bypassed the established
no-follow path checks and the bounded wait. Delete filelock_unix.go /
filelock_windows.go entirely and use the existing lockConfigFileFn
authority every other mutator already shares. New regression
TestUpdateNotifyRacesExistingConfigMutatorWithoutLoss holds notify vs
SetTheme and requires both acknowledged edits to survive; the real
binary also races 30/30 clean cross-process on the shared lock.
- setNotifyJSONObject replaced only the last duplicate notify member, but
Go's decoder MERGES the fields of duplicate members into one struct, so
a replacement omitting a field inherited it from an earlier duplicate
({"notify":{"mode":"off"},"notify":{"focusMode":"focused"}} +
SetNotify({FocusMode:"always"}) left mode "off" effective). The
nonempty path now removes every duplicate and inserts the replacement
fresh — full replace semantics, nothing inherited. UpdateNotify's
partial merge is unaffected (it seeds omitted fields before the edit).
Regression TestSetNotifyReplacementInheritsNoFieldFromEarlierDuplicate,
verified failing on the pre-fix editor and passing after.
- zero config notify --reset combined with --mode/--focus now documents
its precedence: --reset wins, explicit values are ignored (the help
previously did not define the combination).
* fix(config): replace single notify member in place; reserve wipe for duplicates
Vasanthdev2004 review note (PR #1001, non-blocking): the duplicate-key fix
ran remove-and-reinsert for EVERY notify write, including the ordinary
single-member file — insertJSONMember appends after the last member, so
every /notify toggle moved the notify key to the end of the config object
and collapsed its formatting while everything around kept theirs.
Nothing was lost (member order carries no meaning), but a byte-preserving
write should not reformat what it need not touch.
setNotifyJSONObject now counts notify members first: exactly one is a
positional value replace (key stays where the user put it, surrounding
bytes untouched); two or more keeps the remove-every-duplicate-and-insert
path, where Go's field-merging decode would otherwise let an earlier
block leak fields into the replacement. Regression
TestSetNotifySingleMemberReplacedInPlace asserts exact bytes through an
in-place replace, verified failing on the over-eager version and passing
after; the duplicate regression and the whole config suite stay green.
* fix(providerio): close idle connections periodically to prevent HTTP/2 PROTOCOL_ERROR
OpenRouter and other HTTP/2 backends can silently reset or drop connections
that sit idle in Go's client connection pool across user turns. When a
subsequent turn reuses the stale connection, it fails on stream connect with
PROTOCOL_ERROR.
Launch a background idle connection closer on non-darwin platforms to evict
idle pooled connections on a 30-second interval, forcing fresh handshakes
after idle pauses while preserving connection reuse across fast agent turns.
Refs #1057
* test(providerio): poll for idle connection eviction with bounded deadline
Replace the fixed 60ms sleep in TestStartIdleConnCloserClosesIdleConnections
with a 2-second bounded polling loop to avoid test flakes under heavy CI
load or under the race detector.
Refs #1057
---------
Co-authored-by: euxaristia <[email protected]>
* feat(execution): add a stdin-only write for interactive processes
* feat(tui): attach overlay for interactive terminal sessions
* feat(tui): add /attach for interactive terminal sessions
* feat(tools): point the model at tty sessions for interactive prompts
* feat(tui): open the terminal automatically for tty exec sessions
* feat(tools): tell the model to keep polling while the user types
* feat(tui): give attached terminal sessions the full viewport
* feat(tools): route sudo through an escalated tty session
* fix(tui): route the auto-attach tick to its run
* fix(tui): defer auto-attach while another view owns the screen
* fix(tui): keep bracketed-paste framing for attached sessions
* fix(tools): let tty sessions run interactive commands
* fix(tui): capture the auto-attach baseline before the exec starts
* fix(mcp): reject a server-initiated request as a streamable-HTTP response
networkClient.request matched the decoded POST body to the in-flight call on
id alone, so a server that answered tools/call with a method-bearing frame
produced an empty, error-free result. Apply the same isRequestOrNotification
guard the SSE paths already use and return a protocol error naming the method.
Fixes#1050
Signed-off-by: Vladimir Babin <[email protected]>
* fix(mcp): describe the unexpected frame by method, not as a request
A method-bearing frame without an id is a notification, so the error
should not call it a request.
Signed-off-by: Vladimir Babin <[email protected]>
---------
Signed-off-by: Vladimir Babin <[email protected]>
Motivation: scripts/action-summary.mjs is invoked by the composite
action in action.yml on every action run, and ships with a 12-test
suite in scripts/action-summary.test.mjs. Neither the action-smoke
workflow nor the main Go-focused ci.yml executed that suite, and the
action-smoke workflow's paths filters did not even include those two
script files, so a change to the helper or its tests triggered no CI
job at all.
Approach: add scripts/action-summary.mjs and
scripts/action-summary.test.mjs to both the pull_request and push
paths filters in .github/workflows/zero-action-smoke.yml, and add a
Setup Node (actions/setup-node, node-version 24, matching the pin
already used in release-artifacts.yml) step followed by a
`node --test scripts/action-summary.test.mjs` step in the validate
job, ahead of the existing action.yml validation steps.
Validation:
- `node --test scripts/action-summary.test.mjs` passes locally: 12/12
tests pass (Node v22.23.1; repo engines field requires >=18, CI pins
24).
- Broke an assertion in a scratch copy of the test file outside the
repo and reran `node --test`: 1 test failed and the process exited
non-zero, confirming the new CI step actually gates on failures as
the issue requires.
- `python3 -c "import yaml; yaml.safe_load(...)"` confirms the edited
workflow YAML still parses.
- Confirmed .github/workflows/ci.yml has no Node step (Go-only
pipeline), so zero-action-smoke.yml is the correct integration
point.
- Base branch CI (upstream/main) is currently green.
Report: https://github.com/Gitlawb/zero/issues/1030
Assisted-by: claude-sonnet-5 (via Claude Code)
Signed-off-by: Pujitha Paladugu <[email protected]>
Co-authored-by: Pujitha Paladugu <[email protected]>
Motivation:
release-artifacts.yml's three actions/checkout steps (ordinary checkout,
workflow-dispatch requested-ref checkout, and the npm-publish job
checkout) and pr-auto-review.yml's single checkout step all leave
persist-credentials at its default, so the workflow token stays in the
job's local git config for every later step, including build/test/package
steps that never need it. ci.yml and zero-action-smoke.yml already
disable this. Neither modified file runs git push/commit/tag: the GitHub
Release publish step supplies its own explicit GH_TOKEN, and npm
publishing uses OIDC trusted publishing, so removing the persisted
credential changes no behavior.
Approach:
Add `persist-credentials: false` to each of the four checkout steps,
matching the exact convention already used in ci.yml/zero-action-smoke.yml.
Validation:
- go build ./... and go vet ./... pass.
- gofmt -l . reports nothing.
- go test ./internal/installtest/... passes.
- actionlint on both changed files reports only two pre-existing shellcheck
warnings in release-artifacts.yml (SC2016, SC2034) that are unrelated to
this diff and were confirmed present before the change as well (verified
by stashing and re-running actionlint against the original files).
- A live GitHub Actions run of the release/publish workflows was not
reproducible locally; this change only disables a default the workflows
never rely on, so no functional change is expected.
Report: https://github.com/Gitlawb/zero/issues/1029
Assisted-by: claude-sonnet-5 (via Claude Code)
Signed-off-by: Pujitha Paladugu <[email protected]>
Co-authored-by: Pujitha Paladugu <[email protected]>
* fix(oauth): force-close loopback callback connections past the shutdown deadline
Motivation:
LoopbackListener.Close shuts the callback server down with a 1-second
context and discards the result. http.Server.Shutdown only closes idle
connections, so a client that sends a partial request header and never
finishes it counts as active; Shutdown blocks out the full deadline,
times out, and Close returns while that connection and its Serve
goroutine remain open indefinitely. This is a loopback-only (127.0.0.1),
short-lived, single-use server, so the impact is a resource leak past
the end of the flow, not a remote vulnerability or crash.
Approach:
- add a ReadHeaderTimeout to the server so a stalled request header is
bounded on its own;
- follow a timed-out or otherwise failed Shutdown with server.Close()
to forcibly tear down any connection still open past the deadline;
- join the Serve goroutine (via a done channel) before Close returns,
so its completion is observable instead of left dangling.
internal/mcp/oauth.go and internal/provideroauth/openrouter.go build
their own loopback servers with the same pattern; they are left
untouched here to keep this change scoped to the file the issue's
reproduction demonstrates.
Validation:
- go build ./internal/oauth/... and go vet ./internal/oauth/...: clean.
- gofmt -l internal/oauth/: clean (make fmt-check clean repo-wide).
- go test ./internal/oauth/... -race -v -count=1: all pass, including
three new tests (TestLoopbackCloseForceClosesSlowHeaderConnection,
TestLoopbackCloseIsIdempotent,
TestLoopbackCloseReturnsPromptlyAfterSuccessfulCallback).
- Confirmed TestLoopbackCloseForceClosesSlowHeaderConnection fails
against the pre-fix loopback.go (with the new test kept) and passes
with the fix applied.
- Full repo go build ./..., go vet ./..., and go test ./... pass.
- Could not run make lint-static (golangci-lint) locally: it needs a
very large dependency download and the local environment ran out of
disk space fetching it. That check is continue-on-error (advisory)
in .github/workflows/ci.yml, not a required gate.
Report: https://github.com/Gitlawb/zero/issues/1028
Signed-off-by: Pujitha Paladugu <[email protected]>
Assisted-by: claude-sonnet-5 (via Claude Code)
* test(oauth): synchronize loopback regression tests instead of sleeping
TestLoopbackCloseForceClosesSlowHeaderConnection relied on a fixed
100ms sleep to let Serve accept a connection before sending a stalled
header; under load this could fail or silently skip the force-close
path it's meant to exercise. Use an http.Server.ConnState hook (via a
new unexported newLoopbackListener, set before Serve starts to avoid
a race) to wait for the real StateNew signal instead.
TestLoopbackCloseReturnsPromptlyAfterSuccessfulCallback measured
Close's elapsed time without joining its http.Get goroutine or
draining/closing the response body; join it first so the timing
starts from a connection that's actually back to idle.
Also register t.Cleanup(l.Close) in both tests so a Fatalf between
construction and the existing explicit Close doesn't leak the
listener's Serve goroutine.
Signed-off-by: Pujitha Paladugu <[email protected]>
---------
Signed-off-by: Pujitha Paladugu <[email protected]>
Co-authored-by: Pujitha Paladugu <[email protected]>
* feat(config): report whether the tool-turn budget was explicitly set
ResolvedConfig.MaxTurns is ambiguous today: a configured 80 and the
built-in default 80 are indistinguishable, so a caller that wants a
different default cannot tell "user chose this" from "nobody set it".
FileConfig now records at each merge gate (config files, provider
command, ZERO_MAX_TURNS, CLI overrides) when a source supplies a
positive maxTurns, and Resolve surfaces it as MaxTurnsSet.
* feat(exec): default headless runs to the turn-budget ceiling
The shared 80-turn default is sized for the TUI, where the user can
say "continue" and the run resumes. Headless exec has nobody to do
that, so benchmark evals truncated at 80 turns mid-implementation.
An unset budget now resolves to MaxTurnsCeiling for exec only; any
explicit source (flag, mode preset, ZERO_MAX_TURNS, config file) and
exec-profile budgets keep their precedence. The TUI default is
unchanged.
* test(cli,config): cover the headless turn-budget default
* refactor(cli): extract the headless turn-budget decision
* feat(tui): support an optional detail line on picker rows
Add a faint second line under picker items so richer surfaces can show
secondary metadata (project, model, size) without crowding the label.
The line renders on the selection band, joins the search haystack, and
only appears at medium width and above; detail rows halve the visible
window so the overlay keeps its height, and mouse hit-testing maps a
click on either line to the same item.
* feat(tui): show project, model, and size under /resume rows
Each session row now carries a detail line with the project directory
(~-contracted), the model that ran it, and its event count, plus a
status chip for forks and tagged sessions. The line also joins the
filter haystack, so searching by project path or model name works.
Narrow terminals keep the existing single-line list.
* feat(tui): show relative last-activity age in /resume rows
Replace the absolute timestamp column with relative age ("2h ago",
"12d ago") so recency reads at a glance; distant history still shows
the calendar date. Also adds coverage for the detail line: rendering
at wide vs narrow tiers, status chips, and filtering by model.
* refactor(tui): drop the event count from /resume detail rows
Event count is an internal metric — the detail line now carries just
project, model, and the status chip, which are the fields that
actually discriminate sessions.
* fix(tui): sanitize session metadata before it reaches picker rows
Persisted titles (--session-title, /rename) and other metadata can
legally contain newlines; a stray line would break the picker's fixed
row geometry and misalign mouse hit-testing. Reuse sanitizeCardField
for the label and detail line, and cover the detail-line click path.
* fix(tui): strip carriage returns from rendered session metadata
A \r in a persisted title or model id rewinds the cursor to column 0
and overwrites the row. Fold it into sanitizeCardField alongside the
existing newline handling, and cover it in the picker test.
* fix(tui): pad picker detail lines on the selection band
The detail line only painted its text, so the selection highlight
stopped at the end of the text instead of spanning the row like the
label line above it. Pad the remainder with the row surface.
* test(tui): cover carriage returns in sanitizeCardField
* fix(tui): drop all control runes from rendered session metadata
The picker detail line newly renders cwd, model id, and tag, and any
of them can legally carry ESC/CSI/OSC/BEL/BS/VT/NEL bytes — a project
directory name containing \x1b[2J would repaint the terminal when the
picker opens. sanitizeCardField now maps line separators and tabs to
spaces and drops every remaining control rune, matching the package's
other sanitizers; this also closes the same hole for titles, which
predates the detail line.
* fix(tui): include exactly-30-day sessions in the relative-age branch
* fix(sandbox): keep a derived ACL target inside the write root it came from
FILE_FLAG_OPEN_REPARSE_POINT refuses a reparse point at the final path
component and resolves every component above it, which is right for a path
the operator named and not enough for one this package derived. The
write-root carveouts are derived: <root>/.git/hooks and <root>/.git/config
are constructed from a root, and <root>/.git is a name an unprivileged
workspace writer can create before setup runs. mklink /J needs no
privilege, so a junction there had the apply open <junction-target>/hooks,
an ordinary directory with nothing wrong about its final component, and
zero sandbox setup wrote a deny ACE on it as Administrator, outside the
workspace.
A derived entry now carries the root it came from, and the apply requires
the object it finally holds to still live under it. The check is on the
handle rather than the name: GetFinalPathNameByHandle answers where the
open object actually is, and both sides go through it so the spelling
normalizes the same way. Materialization is checked before it creates,
since os.MkdirAll follows the same reparse points.
Deliberately not a refusal of every reparse point on the path. Above the
write root the path is the operator's, who may keep a workspace under a
junction or a mapped directory; only the tail the sandbox appended is held
strict.
Closes#1024
* fix(sandbox): reject the reparse ancestor itself before materializing
The pre-create check asked where the deepest existing ancestor lives, and
a junction answers with its own path: the open does not follow a
final-component reparse point, so <root>/.git came back as <root>/.git and
matched. os.MkdirAll does follow it. What disqualifies that ancestor is
that it IS a reparse point, not where it reports living.
Found by reverting the check and watching the test still pass, which said
the guard was doing nothing rather than that the test was weak. Pinned
against the function now, because through the whole apply the create is
made and then removed on the failure path, so the filesystem afterwards
looks identical either way. The plan wiring gets its own test for the same
reason: every apply-level case here hands the group an anchor directly, so
none of them would notice the builder never setting one.
* fix(sandbox): anchor only the carveouts that are under the root, and put the DACL back
Two from review, both right.
ReadOnlySubpaths is a profile field an operator can set to any path, and one
placed outside the write root is a configuration that works today; anchoring
it unconditionally turned that into a containment refusal. Only paths
lexically under the root are anchored now, which is where the derived
carveouts are anyway, and anything else keeps the final-component guard it
always had.
The successful-apply tests left their deny ACE in place, so t.TempDir could
fail to remove the tree. Restoring the snapshot exposed the sharper half:
the ACE denied the group the test runs as, which revoked its own WRITE_DAC
and left the rollback unable to reopen the target. The tests deny a group
this process is not a member of instead, which is what a capability SID is
in production, and roll back afterwards.
* test(sandbox): check the plan anchoring on every platform, out-of-root case included
The plan builder is cross-platform and its anchor tests were in a Windows-only
file, so nothing checked the wiring on Linux or macOS. Moved beside the other
BuildWindowsACLPlan tests, with the case review raised: an out-of-root
ReadOnlySubpath stays unanchored, and an in-root one is still held to its
write root.
* test(sandbox): give the cross-platform anchor tests native paths
Moving these beside the other BuildWindowsACLPlan tests made them run on
Linux and macOS, where their Windows path literals stop meaning what they
say: pathWithinRoot is filepath.Rel underneath, a backslash is an ordinary
character off Windows, and C:\workspace\.git\hooks is then one component
that is not under C:\workspace, so every anchor came back empty. The
neighbouring tests get away with such literals because they only compare
strings they built the same way and never ask whether one contains another.
Paths are built with filepath.Join from temp roots now, so the containment
question is asked in the separator the running platform actually uses.
* feat(acp): add standard session list and resume
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): preserve notification wire order
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): bind sessions to persisted workspaces
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): compare workspaces by filesystem identity, not by spelling
@Vasanthdev2004's three findings, all reproduced before changing anything.
TWO SPELLINGS OF ONE DIRECTORY WERE TWO WORKSPACES. Both comparisons were string
equality on ResolveWorkspaceRoot output, and that resolver is abs plus
filepath.Clean plus a stat — it does not fold case and does not resolve
junctions. A session persisted from the TUI was unresumable from an editor
holding a different spelling of the same project folder, and session/list
filtered by the other spelling returned nothing, which makes it an invisible
failure rather than a reported one. It lands on exactly the case this feature
exists for, and on the two processes most likely to disagree about spelling.
os.SameFile asks the filesystem which directories these are, which is the
question. filepath.EvalSymlinks is NOT the fix on Windows — it normalises a
drive letter and returns a junction path unchanged, so the alias survives it,
and junctions need no privilege. String equality stays as the fast path, and a
stat failure falls back to it rather than widening the match: this gate refuses
access to another workspace's files and configuration, so an unanswerable
comparison denies.
THE TEST COULD NOT SEE ANY OF IT. testDeps resolves with the identity function,
so the guard degenerated to "are these two strings different" fed two unrelated
temp directories — it could only ever answer yes. The rejection direction was
pinned and the acceptance direction was asserted nowhere. The new test uses a
resolver reproducing the production normalisation and drives the ACCEPTANCE
direction through an alias, skipping if the filesystem folds the alias away so
it never passes vacuously. Reverting to string equality fails it three ways:
load, resume, and a list that returns zero.
THE WIRE KEYS ARE AN EXTERNAL CONTRACT. Nothing pinned sessionId, cwd, title,
updatedAt, _meta, modelId, createdAt, sessions, nextCursor, cursor, loadSession,
promptCapabilities, sessionCapabilities, list or resume, so renaming a Go field
would break every client and leave the suite green. Renaming modelId to model_id
now fails.
Origin-Session: local-79d7a0 | Claude Code | 5 prompts
Origin-Snapshot: c175cabb9d50
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): test the alias guard where the bug lives, and stop listing unusable sessions
THE TEST COULD NOT RUN ON THE PLATFORM THE BUG IS FROM. @Vasanthdev2004's point,
and he is right that it is not a nit. The alias test built its second name with
os.Symlink, which needs a privilege an ordinary Windows session does not hold, so
it SKIPPED there — and Windows is where junctions exist and where this defect came
from. The identity guard was verified by CI on the two platforms that never had
the problem. It now builds the alias with mklink /J on Windows, which needs no
privilege and is how he found the defect in the first place, and keeps the symlink
arm elsewhere.
This is the second time in this series a correct fix shipped with a test that
could not exercise it: the same helper shape was added to internal/memory for the
same reason a day earlier.
A SESSION WITH NO PERSISTED WORKSPACE IS NOT RESUMABLE, SO IT IS NOT LISTED.
CodeRabbit's finding. activatePersistedSession refuses an empty Cwd, but the
listing advertised it anyway — a menu entry that only fails when taken. The test
asserts both halves, because the listing is only correct relative to what resume
will accept: the omitted session is checked to really fail on resume, and a usable
session is checked to survive the filter.
Mutation-checked: removing the filter advertises the unusable session again.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): resolve every listed workspace, not only when a filter was supplied
@anandh8x's P1, both halves reproduced before changing anything.
The loop resolved item.Cwd only when a cwd filter was present, and skipped only a
blank one. Two shapes stayed on the menu that session/resume then refuses:
- a session whose persisted workspace has since been deleted, advertised as
resumable
- a legacy entry holding a relative path, reported as cwd "." although ACP
requires SessionInfo.cwd to be absolute
Every entry is now resolved unconditionally, anything that cannot resolve is
omitted, and the RESOLVED root is what SessionInfo carries — absolute as the
contract requires, and the same value the client hands back on resume. The
optional identity filter then applies to the resolved roots, which is also where
it belonged.
Listing is a menu: activatePersistedSession resolves and refuses what it cannot
reach, so anything this loop cannot resolve is something a client would be
offered and then denied.
Mutation-checked: restoring the resolve-only-when-filtered shape re-advertises
the deleted workspace and re-emits the relative cwd.
Origin-Session: local-76c8d7 | Claude Code | 6 prompts
Origin-Snapshot: 259b715cf0fd
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* test(acp): assert the relative entry is retained, not merely absolute
CodeRabbit's catch, and the test was genuinely weaker than it looked. It checked
that the deleted workspace was gone, the live one kept, and every listed cwd
absolute — all of which a "fix" that simply DISCARDED any non-absolute entry
would satisfy, while losing a resumable session.
Presence is now asserted separately from spelling: the relative entry must still
be listed, and listed with an absolute path.
Mutation-checked: skipping non-absolute entries instead of resolving them now
fails with "a session with a resolvable relative workspace was dropped rather
than normalised". The first attempt at that mutation did not compile, so it
proved nothing until it was rewritten — worth saying, because a mutation that
fails to build looks exactly like a test that passes.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): a relative persisted workspace is omitted, not rebased
Reported by @anandh8x. Resolving a stored relative cwd does not recover the
session's workspace, it invents one: ResolveWorkspaceRoot joins it against
whatever directory the ACP server happens to be running in, and that invented
absolute path was then advertised as the session's workspace and accepted as
its home on resume.
Reproduced on f2c6fc9a, a session persisted with cwd ".":
LISTED legacy-rel as cwd="/Users/kratos/dev/f914/internal/acp"
resume with an UNRELATED workspace -> err=... cwd does not match its persisted workspace
resume with NO cwd (falls back to ".") -> err=<nil>
The mismatch check does its job when the client names a workspace, so the only
opening was the fallback path, where the rebased value was compared against
itself and always agreed. A conversation created for one project could be
resumed against another project's files, configuration and tools.
Both doors now take the same guard: handleSessionList omits an entry whose
persisted cwd is not absolute, and activatePersistedSession refuses one rather
than resolving it. The original base is not knowable from the metadata, so
guessing at it is not an option a fix can take.
This reverses an earlier assertion in TestSessionListResolvesEveryWorkspace,
which expected the relative entry to be normalised and retained. That was
requested in review on the grounds that dropping it loses a resumable session.
It does, but the entry was never resumable into its own workspace, only into
this process's. The test now asserts it is dropped, and a new
TestResumeRefusesARelativePersistedWorkspace covers the fallback path that the
listing filter alone leaves open.
Both guards mutation-checked: removing either one fails its test.
Pre-existing on this branch and on its merge-base, unrelated to this change:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider both exit 3 in this environment.
Origin-Session: local-8cd239 | Claude Code | 11 prompts
Origin-Snapshot: 365efe3045f2
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* test(acp): the resume guard is asserted through both activating methods
Raised by CodeRabbit: the test is named for resume and called session/load.
session/load and session/resume are separate entry points that today share
activatePersistedSession, so an assertion through either one passes while the
guard holds — but the name promised a surface it was not touching. Both are now
named explicitly, which keeps that true: if resume is ever given its own path,
this fails rather than quietly covering half of what it claims to.
With the guard removed, both methods accept a relative persisted workspace on
the no-cwd fallback. The named-workspace case was already refused by the
existing mismatch check; the fallback was the only door open, and it is open on
both.
Not taken in this PR, from the same review: threading a rooted directory handle
through ResolveWorkspaceRoot and workspace construction so a root rename or link
swap cannot redirect later file operations. That is a real question and a
pre-existing one — this change adds a refusal and no path handling — but it is a
capability refactor across workspace and tool access with its own race test, not
something to fold into a session-list fix. Worth its own issue.
Origin-Session: local-8cd239 | Claude Code | 11 prompts
Origin-Snapshot: 365efe3045f2
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): activation requires an absolute cwd in the REQUEST, not just the record
Reported by @jatmn.
ResumeSessionParams is a type alias for LoadSessionParams, so JSON decoding turns
an OMITTED resume cwd into an empty string. That blank reached the shared
activation path, whose blank-cwd fallback substitutes meta.Cwd — so
{"sessionId":"known"} activated a persisted session, even though ACP v1 requires
session/resume to carry an absolute working directory.
This is a different hole from the persisted-cwd one fixed earlier on this branch.
That guard asks whether the STORED workspace is identifiable; this asks whether
the CALLER named one at all. The earlier fix does not cover it, because a stored
absolute cwd passes that check and the blank request then silently inherits it.
requestedWorkspace validates the request's own cwd before anything reaches the
fallback: absent, empty and whitespace-only are all invalid params, and so is a
relative path. Applied to both activating methods rather than to resume alone —
load's omitted-cwd fallback was inheriting the same way, and leaving one door
open is how this class survived the last fix.
Mutation-checked at the wire, which is where the defect lives: making the blank
case return no error compiles and fails the regression on four separate requests
— session/load and session/resume, each with cwd omitted and with cwd blank. A
Go-level test would not have caught it, since the defect is in decoding an absent
field.
Two notes from a verification pass, neither a defect:
Error precedence changed: a blank cwd with an UNKNOWN session id now reports the
cwd problem instead of "session not found". Kept deliberately and pinned — it
stops the server confirming whether a session exists to a request that named no
workspace.
AdditionalDirectories is declared on two params structs and consumed nowhere in
the repo. Not a hole today, but it is the same shape — client-supplied paths with
no absoluteness rule — so the field now carries a note saying it must go through
requestedWorkspace when wired up.
Rebased onto ad34dc8d. go test -race ./internal/acp/ -count=5: clean.
Pre-existing here and on main: TestRunDoctorFormatsRedactedProviderDiagnostics
and TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(acp): resume the conversation a session became, and fail closed when it cannot
Three defects in ACP session restoration, all reported by @jatmn.
loadHistory read the raw event log and kept only EventMessage. A compacted
session stores its original prefix alongside an EventCompaction naming the
events it replaced and carrying their summary, so restoring from the raw log
replayed superseded turns AND dropped the summary that replaced them. It now
reads the same rehydrated view the TUI and exec paths use, and explicitly
projects the compaction summary -- switching readers alone would still drop it,
because rehydration substitutes the compaction event in place of what it
replaced.
historyErr only suppressed replay and raised a warning: the session was
registered and reported ready regardless, so an unreadable events file left the
caller holding a live, promptable session ID whose next prompt ran as a fresh
conversation under the old identity. Resume now fails. Load keeps the
best-effort policy deliberately rather than by inheriting the shared helper.
Tool calls and their results were dropped from session/load, so a restored
transcript showed prose asserting edits with no record that any tool ran. They
now replay through the same toolCallStart/toolCallResult mapping a live turn
uses, keyed on the stored toolCallId so results pair with their calls. They do
not enter turnRecord, so load and resume still consume the same effective
history. Resume stays replay-free.
Also asserts that an unset sessionCapabilities is omitted rather than
serialized as null, raised by CodeRabbit.
* fix(acp): persist tool activity for session replay
* test(acp): bound replay notification waits
* fix(acp): preserve session restore invariants
* fix(acp): restore session lifecycle boundaries
* fix(acp): preserve replay and restore identity
* fix(acp): commit a completed turn as one store batch
A completed turn was buffered until its outcome was known and then persisted
one event per call. Each call took and released the store's session lock, so a
second ACP instance holding the same session -- its turnMu is its own, only the
store is shared -- could append its whole turn between two of them: user A,
user B, answer B, answer A. Every write was individually locked and the log
was valid, but a fresh load pairs prose by order and reconstructed three wrong
turns from two right ones. Before this branch the user and assistant events
went to AppendEvents together; buffering until the outcome is known was the
right change and splitting the commit into singleton writes was not.
The whole buffer now goes through one Store.AppendEvents call, which writes the
batch under one lock and one file write. That is the contiguity being restored:
not a disk transaction, and not a promise about a crash mid-fsync. The outcome
gate still runs first; a hard failure still commits nothing; cancellation still
commits through the same boundary. A persistence failure is now the failure of
the whole batch, so there is no dependent suffix to protect and the rewritten
regression asserts that nothing of the turn is durable, the warning is raised,
and a fresh load replays none of it.
The two-instance regression holds both writers at the commit boundary until
each has arrived and then delegates to the real batch API under the real lock;
it observes what production hands it rather than batching anything itself, so
reverting to singleton writes fails it before any interleaving has to be
provoked. The restored prompt keeps both prose pairs and no historical tool
output.
Also normalizes the expected workspace in the listing test through
filepath.Clean, matching the resolver's output contract, so a TMPDIR spelled
with a ".." component no longer fails a correct result.
Rebased onto main to pick up the ACP browser metadata, specialist model
restoration and worktree pointer protection changes. Both reported by @jatmn.
* fix(tools): say when format-on-write ran out of time
The 10s bound exists so a wedged formatter cannot hang a tool call, which
is right, and on expiry the unformatted write stood with nothing said. Every
other fallback here is a standing fact about the environment (toggle off, no
formatter for the extension, binary not installed) and is silent because
nothing is wrong. A deadline firing is not that: formatting was configured,
available and expected, and the file was written unformatted because the
machine was slow. Left silent, the caller believes it wrote canonical style
and finds out from a CI format check it cannot see, which is the failure
this feature exists to prevent.
The timeout is now reported on the write and edit summaries, naming the file
and the formatter. The caller cancelling and the formatter exiting non-zero
are deliberately not reported: the first is already surfaced as a cancelled
run, and the second usually means content the formatter could not parse,
which a write does not promise to fix.
The bound stays 10s rather than being raised. Raising it makes a wedged
formatter block longer, which is the thing the bound is for; what was
missing was knowing when it fired.
Closes#1022
* fix(tools): put the written bytes back when the formatter fails
These formatters edit in place, so one killed by the deadline, killed by
the caller, or exiting partway through its own rewrite can leave the target
truncated: neither the input nor the output. Returning the written bytes on
top of that left the tracker baseline and the diff preview describing a
file that is not on disk, so the next edit would compare against content the
file does not have. That is a worse failure than the missing formatting the
previous commit set out to disclose.
The failure paths write the bytes back, and when even that fails the user
hears about it, since it is their file. Written back unconditionally on that
path rather than only when the bytes differ: comparing first means reading
the file to find out, and a read that fails leaves the same ambiguity this
exists to remove.
The fixture is proven to damage the file on a throwaway copy, because a
successful restore erases the evidence from the real one. My first attempt
asserted that on the target itself, where it could only pass vacuously or
report the opposite of what it saw.
* fix(sessions): render resume context fields in key order
extractText walked a map[string]any directly and Go randomizes map
iteration, so the same session put the same fields in a different order in
every process: one run rendered a tool call as c1 read_file {"path":...},
the next as {"path":...} c1 read_file. That block is part of the user
prompt, so the prompt itself changed run to run, which leaves a provider
nothing stable to prefix-cache for it and makes two resumes of one session
impossible to diff while debugging.
Only the ordering is decided here. The summary short-circuit is untouched,
a list keeps the order it was written in, and which fields survive at all
stays the caller's projection.
Pinned as exact rendered strings rather than as two renders agreeing: a
same-process repeat passes whenever the random order happens to repeat,
which for a small map is often. The raw-JSON case proved it while being
written, matching on the first five attempts before diverging on the sixth.
Closes#1020
* test(sessions): name the expected context block instead of the first render
Comparing later renders against the first only asks whether the renderer is
stable, and a renderer that is stably wrong passes it: any fixed field order
satisfies it, including the unsorted one this change replaces.
It also hid something. The events were tool calls, which
promptContextEvents filters out of the resume context on this branch, so
the block being compared was a single message line and nothing said so. The
test now names the block it expects, on events that survive the filter, and
both failures are visible.
Creating a symlink on Windows needs SeCreateSymbolicLinkPrivilege, which an
ordinary developer account does not hold, so this reported a failure with
nothing wrong in the code: "A required privilege is not held by the client".
CI runners are elevated, so it only ever appeared locally, where it cost a
round of triage on #1003 before being ruled environmental and would do the
same to any contributor on a stock Windows machine.
The two other symlink tests in this package, and most of the rest of the
repo, already answer this way. Swept the remaining os.Symlink-then-Fatal
sites rather than assuming: internal/release passes, the one in
internal/agent is never reached, and the unix-only files cannot hit it. This
was the outlier.
Closes#1021
* feat(tui): offer mid-run model escalation behind --allow-escalation
Mid-run escalation existed but only exec offered it. The TUI already handled
every consequence of a switch, re-deriving the compaction threshold "after a
mid-run escalate_model switch" and resolving the summarizer against the active
profile, while nothing on that surface could cause one: escalate_model was
registered by exec alone. This closes that, opt-in, the same conservative way
exec answers the question.
Opt-in rather than default because escalation moves a run onto a different model,
which changes what it costs and which provider sees the conversation. That is the
operator's call, and the exec flag already answers it.
The switchers are built per turn from m.providerProfile rather than once at
launch. A TUI session can change models with /model, so a closure capturing the
startup profile would escalate from whatever the session began with instead of
what is in force now, and would carry that stale profile's base URL and
credential with it. exec has no such problem because its profile cannot change
mid-run, which is why the wiring lives in different places on the two surfaces.
The nil-contract logic is now shared rather than copied. providers.
EscalationSwitchers is the single implementation both surfaces call: the loop
swaps only on a non-nil provider, so a (nil, nil) return has to leave everything
untouched including the caller's usage attribution, and the session switcher is
installed only when the run STARTED optimized so escalation cannot change the
transport underneath a session. A second copy of that would only ever have been
exercised on one surface. exec's behaviour is unchanged: its six existing
escalation tests pass against the shared builder.
The tool and the switchers ride on one flag and a test asserts they cannot be
separated. Registering escalate_model without wiring a switcher ships a tool the
loop silently ignores, so the model reports an escalation that never happened;
that is the specific failure worth a guard rather than a comment.
Note this does not answer all of #554. Assigning models per phase, plan with one
and execute with another, is still not built. This makes the mechanism reachable
from the interactive surface, which is the part that was one flag away.
Refs #554
* fix(tui): attribute usage after an escalation to the model in force
exec reassigns currentModel when the escalation switcher fires; the TUI
captured usageModelID once per run and handed the switcher no callback,
so every usage event after a mid-run escalation was billed to the model
the run started on. The switcher now updates usageModelID, and the final
response carries the model per usage event so the batch fallback does
not swing the other way and bill the events before the switch to the
escalated model. Covered by a run through a scripted escalation that
checks both the live and the batch attribution.
* fix(cli): accept the root flags in any order
Each leading-flag splitter stops at the first token it does not own, so
running them once in a fixed sequence made the order load-bearing:
`zero --allow-escalation --theme auto` stranded the theme as an unknown
command and exited with an argument error instead of launching, and the
same happened after --skip-permissions-unsafe. The three splitters now
run until they make no progress, at the root and after the unsafe flag,
so every ordering reaches the TUI with every flag applied. A --theme
written before --skip-permissions-unsafe was dropped on that path; it is
kept now, with a later one winning as the last occurrence does.
* fix(cli): forward a root --allow-escalation into exec, reject it elsewhere
The root flag was stripped before dispatch, so `zero --allow-escalation
exec ...` and `zero --allow-escalation -p ...` ran without the opt-in the
help text promises, and `zero --allow-escalation version` accepted a flag
it could only discard. It now follows --add-dir: re-synthesised into the
exec argument list for the exec and -p shapes, so exec parses it as its
own flag, and rejected loudly for every other command. The ordering test
now generates every permutation of the three root flags, with
--skip-permissions-unsafe absent or at any position.
* fix(tui): record the model on an escalated run persisted usage events
The live usage record carried the model in force; the persisted session
payload did not. The usage report rebuilds cost from that payload and
falls back to the session-wide model when an event does not name one, so
an escalated interactive run was priced end to end at the model it started
on. Written only under escalation, matching what exec records under the
same flag, so an ordinary run persists the same compact payload as before.
* test(cli): isolate the new launch helpers from real user state
A temporary working directory isolates project files and nothing else.
runWithDeps fills the dependencies a test leaves out with production ones,
and the interactive launch path reads user config, opens stores, refreshes
the models.dev cache and migrates any inline plaintext API key into the
credential store before it reaches an injected runTUI callback. Running
these tests on a developer machine therefore rewrote that developer's
config.json and wrote a credential file beside it, and the results depended
on whatever providers, MCP servers and plugins the machine had.
Both new helpers now point every per-user base directory at a throwaway
root, pin the models.dev cache path, disable its background fetch, and
force the file credential backend so nothing reaches the host keyring. A
new test seeds a config with an inline key outside those fixture roots and
fails if either helper rewrites it or leaves a file beside it; without the
isolation it reports the rewritten config and the credentials.enc files.
* test(cli): check each launch helper isolation on its own
t.Setenv restores at the end of the test, not when the helper returns, so
one test exercising both helpers let the first isolation cover for the
second. Each helper now seeds its own emulated user config in a subtest,
and removing the isolation from either one fails that subtest.
* test(cli): pin the isolation itself, not only what this platform writes
The exec helper writes its models.dev cache from a background goroutine,
so a footprint check could not prove its isolation deterministically. Each
subtest now also asserts the helper moved the per-user paths off the
seeded root and disabled the fetch and the keyring backend, which fails
for either helper when the isolation call is removed.
* fix(sessions): carry an interrupted turn's work into the resume prompt
A turn that read six files and then died on a provider error left this behind
for the next turn:
- #1 message: add retries to the http client
- #6 error: provider error: upstream timeout
Nothing named the files, so the next turn re-read them from scratch (#913).
The work was never lost from the session: tool calls and results are recorded as
they happen, and the error path carries them out with everything else.
promptContextEvents then dropped both types on the way into the prompt, so the
record existed and nothing read it.
Filtering them was deliberate (#460) and is right for work an answer already
describes: forty read_file results add length and no information next to the
assistant message explaining them. It is wrong only for work with no answer
after it, and that is exactly what an interruption leaves. A turn that ends
normally was survivable for that reason, which is why this went unnoticed: the
answer was doing the remembering.
So the events after the last assistant message come along and the rest stays
filtered. The tail gets its own allowance rather than sharing the conversation
budget, so a tool-heavy interrupted turn still cannot push an earlier message
out of the context. That is the property #460 added the filter for and it is
asserted directly.
This changes a tested contract. TestFormatExecPromptKeepsConversationMessagesWhenNoisyEventsFollow
asserted that tool results are omitted, and its fixture is an interrupted turn:
43 results after an assistant answer with nothing speaking for them. That is the
#913 shape, so no rule satisfies both and the contract had to move. The test
keeps the guarantee it was written for, that conversation survives a noisy turn,
and a counterpart now pins the other half: work an answer already covers is
still filtered.
Not fixed here, and worth saying plainly: this carries what was DONE, not what
was READ. Zero has no conversation-history parameter at all, agent.Run seeds
from the system prompt and the user prompt every time, so file contents cannot
survive a turn boundary today. The next turn learns it already read a path and
can decide, rather than starting blind.
* fix(sessions): carry the tool outcome, not the tool output
The tail was carrying whole tool-result events, so up to 500 bytes of raw tool
output rode into a later turn prompt. Nothing redacts on the way in, and the
prompt renderer truncates rather than filters, so the only thing standing between
a file read and a later prompt was a length limit.
It bought almost nothing. The tail exists so a resumed turn knows what the
interrupted one did, and the CALL already says that: the tool name and its
arguments, which is the path for a read. The result body is the one part of the
pair that can carry file contents.
The status is kept rather than dropping the result entirely. A bare call reads as
work that succeeded, so a failed read would come back as a file the next turn
believes it already has.
Raised by CodeRabbit on the PR, and right.
* test(sessions): scope the outcome assertion to the tool_result lines
The status check searched the whole prompt for "error", which the provider error
message in the same fixture also contains. Blanking the status out left the test
passing, so it was asserting the presence of an unrelated line.
Scoped to the tool_result lines, it fails on that mutation naming the outcome it
lost.
* test(sessions): pin the whole of every tool line, not a search for one string
Two assertions here were weaker than what they claimed.
Searching the prompt for the fixture's whole secret only rejects that exact
string, so a change that carried a truncated or partly redacted prefix of the
output into the prompt would have passed. And asking whether "ok" and "error"
each appear somewhere among the result lines passes just as well with the two
statuses swapped, or both hung off the wrong call.
Both tool calls and both results are now pinned outright, which says what the
trim actually promises: nothing but the tool name and the outcome survives a
result, and the line order pairs each outcome with its own call. Compared as a
set of fields per line rather than as a string, because the renderer walks the
payload map and Go randomizes that order, so the same events come out in a
different field order on every run.
Reported by CodeRabbit on this PR.
* fix(sessions): keep a tool call's identity and drop its payload in resume context
toolResultOutcome strips a result down to name and status, and calls were left
verbatim, so an interrupted write_file put its content into the next turn's
prompt while the matching result body did not. Same secret, one door left open.
Admitting calls is the #913 fix and has to stay: a resumed turn knowing which
file was read is the whole point. What had to change is the symmetry.
toolCallIdentity is the call-side half of toolResultOutcome. It decodes the
arguments, keeps the fields that say what a call was about, and drops the ones
that carry what it was about to write, run or search for. Paths, directories,
urls, names, patterns and a read window survive; write_file's content, edit_file's
old and new strings, apply_patch's hunks and a shell command line do not.
An allow-list rather than a deny-list, so an argument this file has never seen is
dropped rather than replayed. A new tool with a new body field then shows up as a
missing path in a resume prompt, which is visible, instead of a new leak, which
is not. Keyed by argument name rather than tool name, so an MCP tool whose
argument is a url keeps it without being enumerated. Arguments that do not decode
as an object are removed outright: text that could not be read is text that
cannot be checked.
The tail budget gets a comment rather than a change. Tail slots exist only when
the conversation uses fewer than the 80-event cap, so a session already at the
cap carries no interrupted tool work, which is what every session did before
tool events were admitted and what the #460 cap is there to hold. Reserving a
minimum tail by trimming conversation further is a product decision, and it is
not this one.
Reported by jatmn.
* fix(sessions): scrub credentials out of retained tool-call values
An allow-listed key is not a safe value. web_fetch accepts a credential in
the query string and redacts the URL it reports back, so an interrupted
fetch had its token dropped from the result and kept verbatim in the call,
and this projection then carried it into the next resume or fork prompt.
Retained string values now go through the same redaction web_fetch uses on
its own returned URL: host, path and ordinary query survive, the token does
not, and non-string values are untouched.
* fix(sessions): project a tool call's top-level fields instead of patching one
toolResultOutcome builds its output from the fields it keeps, so a field it
has never heard of cannot reach a prompt through it. The call side edited
arguments in place and returned every sibling key, and returned the payload
untouched when there were no arguments at all, so a producer recording one
more top-level field would put it into the next turn. Both halves now name
what survives and drop the rest.
* fix(sessions): name the value shapes a projected argument may keep, and scope the ambiguous aliases
Two halves of the same gap: the projection decided which KEYS survive and
said nothing about the values under them or about which tool gave them
meaning.
Values. Strings were scrubbed and everything else passed through, which
reads as "a number is nothing to scrub" and is true of a number and not of
an object. MCP schemas allow object and array properties, so
{"query":{"api_key":"...","content":"private body"}} arrived under a
permitted key and carried a whole payload into the next prompt. Redacting a
serialized object would not have helped, since ordinary private text has no
credential shape to match. The retained shapes are named now: a scrubbed
string, and the scalars a read window is made of. A container is dropped and
the call keeps its name and id, so a resumed turn still knows which tool ran.
Meaning. grep accepts "search" for its pattern and edit_file accepts the
same word for the text being replaced, so admitting it globally would have
replayed file contents. It is scoped to grep, and the rule is kept as a
test: an argument name any mutating tool accepts for body content belongs in
the per-tool table or nowhere. The unambiguous supported forms are added to
the shared table: glob's match, and read_file's start_line, end_line,
max_lines, byte_offset and byte_limit.
Driven through the store on both resume and fork, including a call the tool
would have rejected, since the agent records OnToolCall before execution. The
persisted events are asserted unchanged: this is a view for the prompt, not
an edit to the record.
* test(sessions): assert the window key with its value, not the number alone
strings.Contains(out, "40") finds it inside "4096", so the start_line
assertion passed whether or not start_line survived the projection, which
is the opposite of what it claimed. The rendered arguments carry the pairs,
so the assertions name them.
* fix(installtxn): put back an install a killed commit left behind
CommitDir publishes by two renames: the live target moves into a workspace
backup, then the staged copy is renamed into place. Neither is journaled, so a
process killed between them left the target absent with the install's only copy
retained in a workspace nothing ever read. plugins.Load and skills.Load
enumerate directories, so the extension simply disappeared, while the lockfile
went on listing it. Recovering needed one fact the transaction never wrote
down: which install a backup belonged to. CommitDir now records that before it
moves anything, and Recover puts the backup back when the target is absent.
Recovery is a second data-loss surface, so it is bounded on every side. It
refuses a name that is not a single element inside the install root, never
replaces a live target, identifies a workspace by the name StageDir gives one
rather than by contents alone, and leaves intact anything it cannot attribute.
Every path that takes the install lock recovers, not just the two that install.
Recovering on the install path alone is worse than not recovering: a removal
takes the not-present branch, drops the lockfile entry and reports success
while the backup it never looked at stays on disk, and the next install
republishes it, reinstating an extension the user deleted. Removal and the
terminalpet install hold the same lock and now do the same thing first.
Recovery stays an explicit call rather than a side effect of Lock, matching how
the other staged-swap transactions here invoke their repair pass and keeping a
filesystem mutation visible at the sites that cause it.
* fix(installtxn): retire a backup the publish rename already superseded
Recover skipped any workspace whose target was occupied, which left the backup
of a commit that was killed after its publish rename but before its cleanup.
Removing that install then deleted the live target and its lockfile entry but
not the skipped backup, and the next recovery read the absent target as an
interrupted swap and published the stale tree again. plugins.Load and
skills.Load enumerate directories, so the removed extension was loadable again
with nothing in the lockfile naming it.
The target already records how far the commit got, so recovery reads it as the
phase rather than carrying a phase file. Absent means the swap never finished
and the backup is put back as before. Present means the publish rename
committed, so the backup beside it is superseded and its workspace is retired.
The live install is never replaced or removed either way, and the guards that
skip a workspace with no backup, an unreadable or missing marker, or a recorded
name that is not a single element inside the install root are unchanged. The
retire path removes the workspace directly rather than through
cleanupWorkspace, which refuses one holding a previous precisely because it
cannot tell a superseded backup from one still owed a restore.
Reading the target that way is only safe once nothing can leave a partial tree
there. rollback deleted the failed install in place before restoring, so a
process killed partway through that delete left a husk at the target while the
backup was still the only complete copy, and recovery would have taken the husk
for a committed publish and deleted the last good tree. The renames are now
ordered so the target is never partial: the failed install moves aside into the
workspace, the backup moves back to the target, and only then is the set aside
tree removed. A first install has no backup to protect and recorded no target,
so it still deletes in place.
The move aside can fail too, and then the failed install stays live at the
target with the backup still the only copy of what it replaced. Rollback drops
the workspace marker on that path, which leaves a workspace nothing can
attribute, and recovery already leaves those alone.
* fix(installtxn): reconcile both halves of an interrupted commit
Recovery inferred three facts from incidental filesystem shape: target
presence stood in for the transaction phase, a public dot prefix plus two
ordinary filenames stood in for ownership, and a void best effort call stood
in for both "nothing to recover" and "recovery failed".
CommitDir swaps the trees and only then publishes the lockfile, so a kill
between the two leaves exactly what a kill after both leaves. A phase bit
written after the publish only inverts which side of the write the ambiguity
falls on, so Recover now asks the caller which of the two trees its published
metadata records. plugins and skills answer from the recorded hash; the
terminalpet install publishes no metadata and passes nil, where a live target
is the committed one. A backup the metadata still records goes back over the
target through the same move aside ordering rollback uses.
Recover returns an error, and every caller that takes the install lock aborts
on it before reading the lockfile, inspecting the target, installing over it,
or reporting a successful removal. Malformed, legacy, and unattributable
workspaces are still skipped without error; a workspace we own and could not
resolve is reported, and every workspace is processed before returning.
Ownership is now proven rather than inferred. The workspace marker carries a
magic and version first line, so a user authored skill directory that happens
to be named with the workspace prefix and to hold a previous directory is left
alone. A recorded name carrying the workspace prefix is refused too, since it
names another transaction rather than an install. Retirement clears the backup
before the workspace around it: os.RemoveAll unlinks the marker first, so a
failure inside the backup used to cost the workspace the attribution that gets
it reported at all.
* fix(installtxn): do not read a target we could not probe as absent
recoverWorkspace read any os.Lstat failure on the target as nothing being
there, which enters the restore branch: the one branch that moves a tree,
taken on the strength of a question that never got an answer. Only a
not-exist error means the swap did not finish; every other probe failure
leaves the phase unknown, so it is reported and the workspace is left as it
was.
The same distinction the reconcilers already draw between a missing file and
a file that could not be read, applied to the probe that decides which branch
recovery takes at all.
* fix(installtxn): ask the reconciler about the name that resolved the path
recoverableTarget trims the recorded name before resolving the path, but the
raw line went to the reconciler, so the two halves of one decision ran on two
different values. A marker recording "demo " resolves dir/demo and then asks
the lockfile about "demo ". The lookup misses, a miss proves the publish never
ran, and the committed target is replaced with the tree it superseded. Found
by CodeRabbit on the round-2 push; the test observes that replacement before
the fix.
Also fixes the two CI failures on the new tests, neither of which was a
product defect:
macOS and Windows: the skills recovery matrix compared the lock source against
the path the test handed the installer, while Install records
canonicalSource(source). t.TempDir returns /var on macOS where the recorded
value is /private/var, and an 8.3 short name on Windows where it is the long
one. The expectation now comes from the same resolver the installer uses.
Windows: the two plugins failure-injection tests make a directory unwritable to
force a rename or removal to fail. Windows does not block either that way and
root ignores the bits, so the injected failure never fired and the tests
asserted against a success they never meant to produce. They now skip there, as
the installtxn and skills equivalents already did.
* fix(installtxn): stop recovery acting on trees the lockfile never described
An adversarial pass over the recovery protocol, driven end to end through the
built binary rather than package tests, found that the protocol still acted on
evidence it did not have.
The one that loses data: a missing lock entry was read as proof the publish
never ran, so recovery replaced whatever was at the target with the retained
backup. A missing entry proves no such thing. The entry can be lost after a
publish that did run, since a truncated or deleted lockfile reads as an empty
one, and a directory with no entry is a state these packages already support
(Load returns it, Remove handles it). Worse, the tree at the target may have
nothing to do with the transaction: `zero tools make foo` scaffolds into the
plugins root, and a hand-written skill directory is an ordinary thing to find
in the skills root. Both were destroyed and replaced by a stale backup, and the
install reported success. Reproduced through the CLI both ways before the fix.
A missing entry is now PhaseUnknown, which stops every caller and touches
nothing.
The rest are the same class as the target probe fixed in 005fff72, a probe
whose failure was read as a fact:
- A marker that could not be read was skipped as somebody else's content, so
the only copy of an install stayed stranded while the caller was told there
was nothing to do. Present but unreadable is now reported.
- The retained backup was probed with Stat, which follows symlinks and never
checked the type, so a symlink there published content from outside the
install root and a regular file published a file where a directory belongs.
Only a directory is a tree this package set aside.
- An entry at the target was read as an install at the target. A dangling
symlink satisfies Lstat, and treating it as live retired the only real copy.
- Two workspaces claiming one install were acted on in turn, which published
one over the other and deleted the rest. Nothing ranks two claims, so both
are reported.
- A stale `failed` tree from an earlier rollback made the restore rename fail
for good, and since every caller aborts on a recovery error that wedged every
install and removal in the root. The workspace is proven ours, so the stale
tree is cleared first.
- A recorded name carrying a NUL or longer than a path element can be reached
Lstat and came back with an error that is not not-exist, reported on every
pass forever with no way for a caller to clear it. Such a name is not an
install to recover and is skipped.
Unresolved-transaction errors now name the workspace directory, because the
only remedy is to delete it by hand and nothing in the CLI can reach it.
Left alone deliberately: an interrupted RemoveDir still writes no marker, which
the PR body already discloses as a pre-existing residual.
* fix(redaction): split camelCase keys in normalizeKey
A lower-to-upper boundary now inserts an underscore, so accessToken
and refreshToken match the registry instead of leaking into logs.
Token-count fields such as promptTokens stay unredacted.
Fixes#922
* fix(config): replace deprecated reflect.Ptr with reflect.Pointer
* fix(doctor): report auth presence under a non-sensitive key
camelCase normalization treats credentialConfigured as a credential
segment, so check() redacted the set/not-set indicator. authConfigured
does not match the registry.
* docs: name the five supported targets and drop Plan 9 as a review gate
Plan 9 has never compiled and is not a release or CI target. Reviews still
asked for plan9 shims. Document the five release platforms as the supported
set so unsupported GOOS is not a review criterion.
FixesGitlawb/zero#817
* docs: exclude unsupported GOOS/GOARCH from review criteria
CONTRIBUTING.md names five exact release combinations. Align AGENTS.md and
.coderabbit.yaml so other GOOS/GOARCH pairs are not a review criterion.
Unsupported targets such as plan9 remain allowed as fixtures.
* docs: retain Android support in target policy
---------
Co-authored-by: cairn-intern <[email protected]>
* fix(config): lock config read-modify-write across processes
Every config mutator loads the whole document, edits independent fields, and
publishes a complete replacement by rename. The rename is atomic, so a reader
never sees partial JSON — but two processes that loaded the same revision each
write a full document, and the second rename silently discards the first one's
acknowledged update. The result is valid JSON with one update missing and no
error anywhere.
lockConfigFile takes a cross-process advisory lock through lockutil, using the
retry-with-deadline idiom cron, hooks and oauth already share (10s timeout, 20ms
retry). Callers acquire BEFORE their first read, so the lock spans load,
mutation, validation and publication and the read inside it is authoritative —
holding it only around the write would still let both processes start from the
same stale revision.
The lock file is a sibling (config.json.lock), never the config itself: an
advisory lock is held against an inode, and publishing by rename installs a new
one, so locking the config directly would leave each process holding a
different inode.
Covered: all 14 mutators in writer.go, plus ClearProviderKeyStored and
MigratePlaintextProviderKeys in credentials.go. The migration matters most — it
rewrites the config on every startup, so it is the likeliest writer to collide
with an interactive mutation in another Zero. The SetProviderDescription test
seam locks too, so it cannot stand in as the one unsynchronized writer.
Two shapes needed care:
- The lock is not reentrant. EnsureCatalogProvider scans for an existing
profile and then upserts, so UpsertProvider is split into a locking wrapper
and upsertProviderLocked; one lock now spans the scan and the upsert, which
also closes the window where two processes could both create the same
catalog profile.
- SetPet edits raw bytes to preserve unknown members and formatting rather than
round-tripping the struct. It takes the same lock, so it neither clobbers nor
is clobbered by the struct writers.
Regression tests, each verified to FAIL with the lock disabled:
- TestConcurrentMutationsDoNotLoseUpdates — theme, pet, recaps, favorites and a
provider mutated at once; all five are independent fields, so a lost update
shows up as a zero value in exactly one of them.
- TestConcurrentProviderUpsertsAllSurvive — 16 distinct providers added
concurrently, all must be present.
- TestConcurrentSameFieldMutationsSerialize — 24 writers contending on one
field; every call succeeds and the document stays readable.
- TestCrossProcessMutationExcludesAndPreserves — the coordinated two-process
case. Goroutines share this process's descriptors, so only a second OS
process shows the lock is kernel-held. The child announces itself, then the
parent asserts its write cannot land while the lock is held elsewhere, does
its own mutation, releases, and requires both updates to survive.
Fixes#832
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U389qmQUhoZB3YtXkFmiTq
* fix(cli): take the config lock for MCP config edits
The previous commit locked internal/config's mutators, but that is not every
writer of the user config document. internal/cli's MCP editor reads the SAME
file, edits it, and republishes it with the identical temp-file+rename shape,
at three sites (add/update, remove, disable/enable). Locking only one package
left `zero mcp add` free to clobber a concurrent provider or preference write —
and to be clobbered by one — with the file still valid JSON afterwards, which
is the same silent lost update issue #832 reports.
config.LockFile exports the existing helper so the lock is one authority across
packages rather than a private detail of internal/config. A second, adjacent
implementation would drift from the first exactly the way this writer already
drifted from the mutators.
TestRunMCPAddParticipatesInConfigLock is the regression. Racing the two writers
and waiting to observe a lost update is NOT reliable — the losing interleaving
is narrow, and a straightforward concurrent version of this test passed five
consecutive runs against the unlocked code, so it would have shipped as
reassurance that proved nothing. It instead asserts the deterministic property:
while the config lock is held elsewhere, the MCP writer's update cannot land,
and it completes once the lock is released. That fails immediately against the
unlocked code and passes with it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U389qmQUhoZB3YtXkFmiTq
* fix(config): report config unlock failures instead of discarding them
AGENTS.md requires that a mutator never report success when unlock failed, and
the release error was being dropped on the floor. A failed Release can leave the
lock held for the rest of the process, so returning (cfg, nil) after one claims
a state the next mutation cannot reproduce — it will block for the full lock
timeout and then fail.
lockConfigFile now returns lock.Release directly, and every caller joins it into
its own result with the idiom credstore already uses:
defer func() { err = errors.Join(err, unlock()) }()
Joined, not chosen between: a release failure annotates the result rather than
masking the mutation error that explains what actually went wrong. That covers
all 16 mutators in writer.go, both in credentials.go, and the test seam. The
three MCP sites return an exit code rather than an error, so they convert a
release failure into a crash exit and a stderr message, without overwriting a
failure the command had already reported.
A release failure cannot be provoked through the public API — lockutil.Release
is idempotent and reports nil once released — so the mutators call through a
lockConfigFileFn seam that a test can substitute. My first attempt at this test
could only ever t.Skip, which asserts nothing; the seam is what turns it into a
real assertion.
- TestMutationReportsUnlockFailure: SetTheme surfaces the release error AND the
mutation is still published, since a release failure annotates the result
rather than undoing the write.
- TestMutationErrorSurvivesUnlockFailure: an unknown-provider error and the
release failure are both present in the joined error.
Both verified to fail when the defer discards the release error.
Note for review: cron, hooks, oauth and swarm all still discard their
lockutil.Release error, so this makes internal/config stricter than its
siblings. Worth deciding whether the guideline should be applied to them too —
out of scope here.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U389qmQUhoZB3YtXkFmiTq
* fix(cli): complete MCP config lock handling
Amp-Thread-ID: https://ampcode.com/threads/T-01a043da-1703-70c5-9d8d-904cd8fd964b
Co-authored-by: Pierre Bruno <[email protected]>
* test(cli): synchronize MCP config lock assertion
Amp-Thread-ID: https://ampcode.com/threads/T-01a0448f-5860-721c-8a47-5119fc57f685
Co-authored-by: Amp <[email protected]>
* fix(tui): persist provider model selection atomically
Amp-Thread-ID: https://ampcode.com/threads/T-01a04c92-2d1d-7508-91bc-416341b7e8b0
Co-authored-by: Amp <[email protected]>
* fix(tui): surface unsaved model selections on every switch path
choosePicker replaces handleModelCommand's status text with its own
applied-notice, so the "· not saved (…)" qualifier that text carried was
dropped and the selection reported an unqualified success. The user only
found out at the next launch, when the old persisted model came back.
dispatchCommand drops the same text on a committed switch, so typed
/model had the identical hole — the cross-provider path already returned
the outcome as a value precisely because display copy is not a contract.
handleModelCommand now returns that outcome too, and one
modelAppliedNoticeFor decides how a committed switch is announced for
both entry points, so neither can regain the gap independently.
Recent-model history is written under the same lock, so a selection that
already lost the lock would pay a SECOND full timeout for one keystroke
and add a redundant save error. It now falls back to the in-session
updateRecentModels for exactly that case, keyed on a new
config.ErrLockUnavailable rather than on any persistence failure: a
missing config file also fails the lookup, and that write still has its
own reason to be attempted.
Regressions cover both entry points. The picker test holds the config
lock — the case the review asked for — and bounds elapsed time, which is
what proves one keystroke costs one wait; it fails at 20s against the
previous code. The typed test uses an unwritable config path so it
proves the same plumbing without a second ten-second timeout.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01REzorhNj3F1DGPXyn5Uq7j
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: Amp <[email protected]>
* fix(providers): register Atomic Chat as local OpenAI-compatible runtime
Point atomic-chat at the default local API (http://127.0.0.1:1337/v1),
mark it Local with no API key, and auto-detect it alongside Ollama and
LM Studio. Keep opencode-go-anthropic-compatible in the provider catalog
and curated model list after rebasing onto latest main.
Co-authored-by: Cursor <[email protected]>
* docs(zh): sync Atomic Chat local runtime into README_ZH
The English README lists Atomic Chat in the provider list and tells
local-model users to run its desktop app on http://127.0.0.1:1337/v1,
but the Chinese README still mentioned only Ollama and LM Studio.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(providers): give the local Atomic Chat runtime its own catalog ID
Repurposing the existing atomic-chat ID from the hosted, key-gated preset
to a keyless local one silently downgraded credential checks for profiles
already saved against the remote endpoint: MissingCredentialEnv resolves
RequiresAuth from the catalog descriptor, so those profiles were reported
as ready and only failed later with an unauthenticated call to
api.atomic.chat.
Keep the hosted atomic-chat descriptor as it was and register the local
runtime under a separate atomic-chat-local ID, mirroring the existing
ollama-cloud/ollama split. Stored profiles keep their identity and no
migration is needed.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* test(onboarding): assert the full Atomic Chat local endpoint and default model
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(models): drop the placeholder curated list for atomic-chat-local
Atomic Chat's model catalog is a Hugging Face scrape refreshed every 12h
plus arbitrary owner/repo lookups, so the served model ID is whichever
model the user loaded and no hardcoded list stays accurate.
The single local-model entry short-circuited Models() without adding
anything: the descriptor is openai-compatible, so modelMatchesProvider
never matches a registry entry and the fallback already yields the
descriptor's DefaultModel. Output is unchanged; the difference is that
local-model is no longer presented as a curated contract.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(onboarding): pin the probed model in the local-runtime adopt command
probeLocalRuntime already reads the runtime's /v1/models, but SetupAction
dropped the result and emitted 'zero providers add <id> --set-active'
with no --model. The add path then persisted the catalog DefaultModel,
which for a local runtime is the placeholder 'local-model', and the first
completion failed with an unknown-model response.
SetupAction now pins the probed id via SetupCommandWithModel. The catalog
default wins when the server actually serves it, so an existing Ollama
setup does not switch models; otherwise the first advertised id is used.
An empty probe result leaves the command on the catalog default rather
than inventing an id.
Covers a server whose returned id is not 'local-model'.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(onboarding): shell-safe adopt command + reject atomic-chat-local placeholder model
commandArg now POSIX single-quotes rendered args instead of strconv.Quote, so a model id from an untrusted /v1/models response cannot expand when the adopt command is pasted into a shell.
providerProfileForAdd now errors when atomic-chat-local is added without --model instead of persisting the local-model placeholder that fails the first completion.
Adds regression tests for command substitution, backticks, and the require-model guard.
* fix(onboarding): finish Atomic Chat setup across supported shells
Rebase on current main and keep the hosted Atomic Chat preset intact.
Only emit setup arguments that are portable across POSIX, cmd.exe and
PowerShell; show interactive guidance for other model IDs. Reject the
Atomic Chat placeholder before both CLI and TUI persistence, and stop
advertising adoption when discovery finds no usable model.
Validation: full Go test suite, focused race suite, vet, release build,
smoke, formatting, advisory lint and vulncheck pass with isolated config
and inherited Anthropic overrides removed. Onboarding, CLI and TUI test
packages cross-compile for Windows and Linux. Regression tests fail on
the rebased pre-fix code and pass with these changes.
Refs Gitlawb/zero#550 and Gitlawb/zero#592.
* fix(onboarding): preserve option-looking model IDs in adopt commands
Use the existing --model=value grammar when a discovered model begins
with a hyphen. Validate the model separately from the fixed separator so
shell safety remains unchanged and '=' is not added to the allowlist.
Exercise generated detection actions through shell tokenization, the actual
add parser, and profile construction for Atomic Chat, LM Studio and Ollama.
Cover leading hyphens, flag-looking IDs, ordinary IDs, spaces, and unsafe
inline values. Before the fix, the new regression failed with:
generated adoption command rejected by add parser: --model requires a value
Validation: full Go suite, focused race tests, formatting, vet, release
build, smoke, lint-static and vulncheck passed. Affected test packages
cross-compiled for Windows and Linux.
Refs Gitlawb/zero#550.
* fix(onboarding): select eligible chat models and preserve Ollama defaults
P2: apply the existing known-noncoding exclusion before default preference
or first-model fallback. When a nonempty list has no eligible model, offer
commandless guidance instead of persisting the rejected/default model.
P3: recognize an Ollama untagged default's :latest spelling, preserving the
advertised ID. Prefer exact matches and leave other tags/providers alone.
Add selector tests plus a mock-server integration test through discovery,
the generated action, the real add parser, profile persistence, a fresh
process, and the chat completion request. Before this fix, that test failed:
fresh process sent model "text-embedding-local", want eligible model "-loaded chat model"
The Ollama alias case also failed before the fix:
AdoptModel() = "qwen3:8b", want "llama3.1:latest"
Explicit model choices, shell checks, hosted/local provider identities and
Atomic placeholder guards retain their existing behavior.
Validation: full Go suite, focused race tests, formatting, vet, release
build, smoke, lint-static and vulncheck passed. Affected test packages
cross-compiled for Windows and Linux. Integration evidence uses a mock
server; native Windows shell and actual runtime behavior are not claimed.
Refs Gitlawb/zero#550 and Gitlawb/zero#592.
---------
Co-authored-by: Cursor <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: alex <[email protected]>
* fix(background): treat POSIX zombie leader as already-exited
terminateOwnedProcess always returned leaderAlreadyExited=false, so
TerminateCommand surfaced "did not exit after SIGKILL" when ps was
unavailable and the leader was already a waitable zombie. Probe the
leader the way Windows uses GetExitCodeProcess, then still signal the
group so live descendants are not skipped.
* fix(background): preserve live-group termination failures
---------
Co-authored-by: cairn-intern <[email protected]>
* ci: harden workflow permissions, add timeouts, concurrency and coverage report
* ci: reuse make fmt-check and refine coverage table formatting
* ci: scan Windows build graph via vulncheck-windows and collapse coverage summary to single-pass awk
* ci: invoke the pinned Windows govulncheck via a local GOBIN
go install writes the binary to GOBIN, which is not necessarily on PATH.
Install into .cache/gobin and run that executable. GOOS=windows applies
only to the scan so the scanner stays a host binary.
* ci: declare contents:read on the action smoke workflow
Pull-request workflows inherit the default GITHUB_TOKEN unless they
set an explicit permission block. The smoke workflow is now covered
by the same least-privilege policy as ci.yml.
* ci: parse workflow permissions structurally and quote the Windows scanner path
The pull-request token check now ignores commented lookalikes and accepts
an inline on: [pull_request] trigger. vulncheck-windows keeps the cache
path as one shell word.
* ci: drop -race from this PR so issue 939 stays with PR 940
Coverage profile remains. The dedicated race-detector job is owned by
the open race PR.
* ci: parse workflow permissions as YAML and scope GOWORK=off
* ci: label the non-race smoke test explicitly
---------
Co-authored-by: cl-ment <[email protected]>
* perf(providers): parallelize isolated provider unit tests and shrink retry backoffs
Enable t.Parallel() on independent provider test cases and shrink default
retry backoffs from seconds to 1ms during test runs, reducing provider test
suite duration from ~300s to ~2.4s without flakiness.
* test(providerio): add unit coverage for ShrinkBackoffForTest and restoration
* test(openai): keep Codex idle-timeout acceptance test serial
StreamIdleTimeout arms a wall-clock watchdog. Parallel scheduling can
lose the 50ms race against response.completed even though the test
only checks that the option is accepted.
* fix(config): replace deprecated reflect.Ptr with reflect.Pointer
---------
Co-authored-by: cl-ment <[email protected]>
* security(mcp): prevent server requests from matching client pending responses
* fix(mcp): send courtesy -32601 reply asynchronously off read loop
* fix(mcp): restore jsonRPCIDEchoable and validate request IDs before responding
* fix(mcp): queue and drop courtesy replies without blocking client callers
* fix(mcp): drop canceled queued requests
* fix(mcp): preserve valid numeric request IDs
* fix(mcp): treat JSON method member presence as the request discriminator
An explicit empty method string is a request, not a response.
Courtesy -32601 is still dropped when the write queue is full: failAll
on that path stopped the reader and blocked inbound pipe writes
(TestStdioClientUndrainedServerDoesNotBlockCallerWithDeadline).
* fix(mcp): count dropped courtesy replies when the write queue is full
The inbound reader must not stop. An empty select default is now an
atomic counter rather than a silent discard.
* fix(mcp): retain method presence, writer lifecycle, and original JSON-RPC IDs
* fix(mcp): bound courtesy overflow, pin writer queue, match exponent IDs
* fix(mcp): skip SSE frames that carry a method member
Treat method presence the same on stdio and SSE so empty or null method
never completes a pending client request.
---------
Co-authored-by: cl-ment <[email protected]>
Co-authored-by: KRATOS <[email protected]>
* fix(oauth): split an oversized keyring token blob across entries
Store every provider and MCP token in one keyring entry and the store stops
working once the logins outgrow it. On macOS the secret rides inside a
`security -i` command line capped at 4095 bytes, which leaves 3027 bytes of
JSON for all logins combined, so a second OIDC login fails to save and every
write after it fails too.
Split a blob that does not fit across numbered entries and put a manifest in
the anchor account. Chunks live in two alternating generations: a write fills
the one that is not live, then replaces the manifest, so that single write is
the commit point and a crash partway through still reads the previous
generation. `zc1:` cannot prefix base64, so an entry written by an existing
build is still recognised and read without a migration step.
Reserve the range a write will occupy before occupying it. Without that, a
write interrupted while filling a longer generation leaves chunks above the
count the manifest records, and no later cleanup knows to delete them: a
fragment of a token blob would stay in the keychain for good.
Expose the per-entry budget from internal/keyring rather than hardcoding the
macOS figure in the oauth store, sharing one line builder with Set so the
budget and the boundary it describes cannot drift. Backends with no limit
report so and keep the single-entry layout, so Linux is untouched.
Refs #937
* fix(oauth): lock keyring reads, validate manifest digest, and harden chunk tests
* test(oauth): assert Status blocks during keyring lock regression test
* fix(oauth): sweep both chunk generations when a store regrows
A shrink writes the blob back under the anchor, which replaces the manifest
and takes the per-generation chunk counts with it. From then on nothing can
name the chunks a failed cleanup left behind, so the growth branch's sweep of
the target generation is the only one that will ever reach them — and it only
ever targets family A. A keychain that refused one delete during the shrink
therefore kept a superseded generation of access, ID and refresh tokens
indefinitely, with no way for the user to know.
Sweep the other generation alongside the target, and document that the
reclaim waits for the next growth: a store that shrinks once and never grows
again keeps the residue, which sweeping on every whole write would close at a
cost of 128 `security` invocations per save on macOS.
Also record why Load and Status take the cross-process lock, and give the
missing-chunk error the same "log in again" advice the digest failure carries.
Refs #937
* fix(oauth): serialize keyring operations under user-level lock
Derive the keyring backend lock file path from the user's home directory rather than file store configuration, ensuring processes with distinct store paths share the same lock domain for the OS keychain.
Refs #938
* Address review feedback on chunked keyring token store and recovery
Refs #938
* Refine auth reset message and chunk format notation
Refs #938
* Harden OAuth store reset lifecycle, keyring migration recovery, and completions.
Refs #938
* Validate keyring cleanup markers, propagate lock resolution errors, and test reader concurrency.
Refs #938
* Preserve live OAuth credentials when stale cleanup markers survive migration.
Refs #937
* Verify JSON output from OAuth reset to cover its recovery response.
Refs #937
* Verify that JSON OAuth reset actually removes stored logins.
Refs #937
* Require confirmation before resetting shared OAuth credentials.
Refs #937
---------
Co-authored-by: euxaristia <[email protected]>
* fix(update): keep Windows failed-restore state in trusted recovery records
A failed restore currently records the last verified binary only as a
.deletable .keep marker in the install directory. Write an unresolved
identity-bound record to the existing per-user update-recovery store on
that failure path too, and consult it during preflight so deleting the
marker cannot silence the tamper refusal.
Fixes#868
* fix(update): keep a fail-closed signal if unresolved recovery records cannot be written
Ignoring a failed appendUnresolvedRecoveryRecord left only the sibling
.keep marker. An install-directory writer can delete that marker, after
which preflight finds no unresolved state and a retry can overwrite the
last verified recovery copy. Relocate that copy to a .recovery name when
the per-user record cannot be persisted so promotion still refuses.
Fixes#868
* fix(update): do not relocate the live binary when failed-restore compensation cannot record
persistFailedRestoreSignal relocated through the handle when the per-user
unresolved record could not be written, without checking that the object
was still the aside copy. A restore that had already put the verified
bytes back at targetPath then had that binary moved out of the executable
path. Verify identity with verifyPromotedTarget before relocating.
Unreadable recovery-state JSON now fails closed instead of looking like
an empty queue. The per-user vs per-machine split is documented: a second
account still relies on the install-directory .keep marker.
Fixes#868
* fix(update): durably commit recovery state on Windows
---------
Co-authored-by: cairn-intern <[email protected]>
* security(update): prevent tar extraction from traversing escaping symlinks (fixes#920)
When extracting tar archives, lexical path cleaning alone does not prevent
subsequent file entries from being written through a previously extracted
directory symlink that resolves outside destDir (Zip/Tar Slip).
This introduces verifyNoSymlinkEscape in safeExtractPath, which recursively
inspects existing path components between destDir and target using os.Lstat
and filepath.EvalSymlinks to reject entries attempting to write through escaping
symlinks.
* test(update): exercise chained symlink escape during file extract
The previous case was a single escaping symlink already rejected by the
existing Linkname check. Seed a mid -> outside chain under destDir and
extract a regular file through it so verifyNoSymlinkEscape is what fails,
with no write outside destDir.
* fix(update): resolve destDir symlinks and permit valid dangling symlinks in archive
* docs(update): document POSIX-only scope for verifyNoSymlinkEscape
* internal/update: test extracting files through directory symlinks
* fix(update): reject dangling symlinks instead of lexical fallback
EvalSymlinks failure used filepath.Clean on the link text, which does
not preserve already-traversed links. A dangling member is now an
error.
* fix(update): resolve dangling symlink chains from the physical parent
* fix(update): authorize tar paths through OpenRoot and reject outbound symlink entries
* fix(update): resolve intermediate symlink parents and cap Root link depth
Walk each path component from the physical parent, reject a ninth link,
and look up release binaries through OpenRoot as regular files only.
---------
Co-authored-by: cl-ment <[email protected]>
* ci: run the race detector on pull requests
The Makefile declares `go test ./... -race -count=1` as the project's test
command, but no workflow invokes that target. ci.yml and pr-auto-review.yml both
run plain `go test ./...`, and `-race` appears nowhere under .github/workflows,
so the detector has never run on a pull request. The concurrent paths where a
race is easiest to introduce and hardest to see in review, the turn loop,
streaming provider I/O, the session store, cron, swarm mailboxes, and the
cross-process lock paths in the credential and OAuth stores, are all unchecked.
Add a dedicated job rather than putting -race on the existing matrix step. A
race run is typically several times a plain one, and the matrix Test step is
followed by build and smoke, so folding it in would serialize behind those and
make that job the new long pole. On its own it runs alongside the Windows job,
which is the current long pole at 9m29s, and a failure reads as a race rather
than as a smoke failure.
Run `make test` instead of repeating its flags, so the project's declared test
command and what CI executes cannot drift apart again.
Refs #939
* ci: add permissions to race detector job
---------
Co-authored-by: euxaristia <[email protected]>
* fix(providerhealth,tools): let the configured forward proxy through the loopback guard
With HTTPS_PROXY pointing at a local forward proxy, the transport dials the
proxy first and tunnels the request through it. The SSRF-guarded dialer in
providerhealth refused that dial as a loopback address, so
`zero doctor --connectivity` failed with "proxyconnect tcp: provider
connectivity URL is unsafe: loopback hosts are blocked" for a public
provider that had already been validated, and web_fetch had no proxy
support at all (#569).
Each dialer is now handed its transport's own Proxy function and lets a dial
through unpinned only when the address is exactly the proxy endpoint that
function names for the request scheme: same host, same port, with a missing
proxy port taken as the scheme default. A sibling loopback port is still
refused. The shared check lives in internal/proxydial since providerhealth
and tools otherwise share nothing.
web_fetch honours http.ProxyFromEnvironment again. The guarded fetch had set
Proxy to nil as part of its SSRF hardening; the target is still validated
before the request, and what a forward proxy removes is only the dial-time
rebind pin, which is inherent to using any forward proxy. The pre-permission
URL validation stays in place.
Started from #594 by baoyu0. This version keeps the pre-permission check,
matches the proxy port exactly instead of any port when the proxy URL has
none, and injects the transport's Proxy function instead of reading the
environment a second time.
Closes#569
Co-authored-by: baoyu0 <[email protected]>
* fix(proxydial): have the dialer ask its transport for the proxy at dial time
Copying transport.Proxy into the dialer at construction left the wiring
between the two unpinned: a dialer handed nil would pass every unit test
while a proxied request still died as loopback. The dialer now consults
the transport it is installed on, and each package drives a real request
through a loopback listener acting as the proxy and checks that the
CONNECT arrives. The proxy is substituted on the transport rather than
through HTTPS_PROXY, since http.ProxyFromEnvironment reads the
environment once per process and an env-based test would depend on
test order.
* fix(proxydial): bind the proxy exemption to the route the transport chose
Asking the Proxy function a question of our own invention answered for a
request the transport never made. With NO_PROXY excluding a host, the real
request to that host goes direct while a synthetic probe still comes back
proxied, so a direct dial whose address happened to equal the configured
proxy endpoint skipped the guard and reached whatever the name resolved to
at connect time. A probe on the previous head dials rebind.example:80 with
zero safety lookups; the guard now runs and refuses its private answer.
Host-dependent proxy callbacks make the mismatch general, and no spelling
of the probe host closes it, because the two calls are about different
requests.
The decision is now recorded where it is made. proxydial.Transport wraps
the transport, asks its Proxy function about the actual request, and puts
the answer in that request's context; the transport derives the dial
context from the request context, so the dialer reads back the route this
very connection is being opened for. Per-request, so concurrent requests
cannot overwrite each other, and the context key is unexported so nothing
outside the package can plant one.
Address matching is gone with it. A transport opens one connection dial,
and when a proxy is selected that dial goes to the proxy, so the recorded
route already identifies the dial. Comparing the address on top could only
disagree with the transport, which is what a Unicode proxy hostname did:
Go dials xn--bro-hoa.example while the URL still spells it as configured,
and the proxy was refused as an ordinary private address. Both spellings
are now one endpoint with no comparison to get wrong.
Documents where destination enforcement ends: with a proxy configured, the
target hostname is sent to the proxy and the proxy resolves it, so the
local pre-request checks no longer bind the address that is reached. A
forward proxy already sees and can rewrite every request through it, so
that trust comes with configuring one; the alternative is refusing to use
proxies at all, which is the outage in #569.
---------
Co-authored-by: baoyu0 <[email protected]>
* fix(agent): stop the tool-schema ratchet meaning a different thing per host
TestEagerToolSchemaTokenBudget counted exec_command shell guidance along with
the schemas, and that guidance is host-specific. The same tree measured 3498 on
Linux, about 3600 on a Windows host with pwsh 7, and 3653 on Windows PowerShell
5.1, because shellGuidanceForRuntime appends a longer block for 5.1 (it has to
explain that && and || do not work there).
5.1 is stock Windows, and that case has been over the 3650 ceiling since
view_image landed. So `go test ./...` failed for anyone on a stock Windows box
while CI stayed green: the runners have pwsh 7 and take the cheaper branch. The
workflow does run the tests on windows-latest, so this was never a coverage gap,
just a ceiling sized against whichever shell the runner happened to have.
The two halves are now charged separately. The schema ceiling measures the
schemas alone and reads 3498 on Linux and on Windows PowerShell 5.1, so it
ratchets the same thing everywhere. The host guidance gets its own ceiling at
200, above the worst case rather than above this machine: 5.1 is 155 tokens,
pwsh 7 is 110, cmd.exe is 87, POSIX is 0.
No guidance text was trimmed. It is what stops the model writing bash on Windows,
and the 5.1 note is the reason a chained command does not silently do half its
work. Paying 155 tokens for it is the right trade; measuring it as if it were a
schema was not.
HostExecCommandShellGuidance is exported for the budget, which is production
surface added for a test. It is one accessor over existing logic, and the
alternative is a ratchet that cannot tell the two costs apart. A guard in
internal/tools pins that the guidance really is the tail of the description, so
the subtraction cannot silently start measuring the wrong thing.
* test(tools,agent): make the guidance contract checkable off Windows
The ratchet separates the fixed tool schemas from this host's exec_command
shell guidance, and both halves of that separation were only being checked on
Windows.
The description test returned early off Windows once the accessor reported no
guidance, so it never looked at the description. Dropping the GOOS condition in
the constructor would have a POSIX host append its own guidance (about 150
characters on darwin) with the accessor still reporting none and the ratchet
charging every byte of it to the schemas, and nothing would fail. Both the
description and the accessor now take goos, so the contract is checked for all
three platforms from any one of them.
The budget also went from subtracting a separately rounded guidance estimate to
removing the guidance from the description before estimating. ApproxTextTokens
is an integer division, so floor((schema+guidance)/4) - floor(guidance/4) is one
token high whenever the two remainders add past four, and which side of that a
host lands on depends on the length of its guidance. That is the same
host-dependence the ratchet was being fixed for, one token smaller.
No behaviour change: exec_command's description is byte for byte what it was on
every platform.
* fix(sandbox): resolve git metadata carveouts for worktrees
.git is a file (gitdir: ...) in worktrees/submodules, not a dir. The
hooks/config write-deny carveout assumed a directory and pointed at
<root>/.git/hooks, which has no mkdir-able parent under the .git file.
bwrap then failed 'Can't mkdir parents ... Not a directory', blocking
every sandboxed tool in worktree checkouts.
Resolve the real (common) git dir via the gitdir pointer + commondir so
worktrees deny the shared hooks/config like a plain clone. Plain
checkouts (.git dir) and .git-absent roots keep the literal paths.
* fix(sandbox): bound worktree git metadata carveouts to the write root
Refuse gitdir/commondir pointers that escape the workspace instead of
carving outside confinement. Invalid, stale, or non-directory pointers
return no carveouts so bwrap does not mkdir under a .git file.
* fix(sandbox): evaluate gitdir/commondir through symlinks
Containment must use the real path so an in-root symlink cannot punch
hooks/config carveouts outside the write root.
* fix(sandbox): fail closed on gitdir eval and protect the pointer file
Refuse carveouts when EvalSymlinks cannot produce a real in-root path,
compare evaluated roots and targets, and write-deny the .git pointer so
it cannot redirect hooks/config between commands.
* fix(sandbox): deny worktree-local gitdir metadata and parse first gitdir line
Keep hooks/config write-denied on both the common dir and the in-root
worktree gitdir, emit those paths under the lexical write root, and
accept a gitdir: pointer that has trailing comment lines.
* fix(sandbox): skip special git metadata files and re-check carveouts at apply
Read .git and commondir only when they are regular files so a FIFO cannot
block profile construction. At sandbox apply, drop ReadOnlySubpaths that
no longer sit inside the write root after a symlink swap.
* fix(sandbox): follow directory .git symlinks and unix-tag FIFO tests
A .git symlink to a directory is a normal checkout, so keep lexical
hooks/config carveouts. FIFO coverage stays on unix so Windows still
compiles.
* fix(sandbox): protect worktree git pointer directly
* test(sandbox): skip unavailable symlink fixture
* fix(sandbox): preserve git symlink semantics
* fix(sandbox): limit git pointer carveout to regular files
---------
Co-authored-by: jatmn <[email protected]>
Co-authored-by: jatmn <[email protected]>
* fix(specialist): keep the pinned model when a specialist is resumed
Metadata.Model exists so a bounded, delegated task can run on a cheaper model
than its parent, and BuildArgs appends it to the child argv through
appendModelArgs. BuildResumeArgs never did.
So a specialist pinned to a cheap model ran on that model exactly once. The
moment the orchestrator resumed it, the child fell back to whatever the
parent's configured model resolved to. Nothing surfaced it: the resumed child
starts normally and does the work, so the only symptom is the bill.
Cost-motivated delegation quietly stopped saving anything.
Resuming does not restore the recorded model on its own. sessions.PrepareExec
records the model a run used but never feeds it back into provider
construction, so the flag has to be passed again rather than relied upon.
BuildResumeArgsInput now carries ParentModel and ParentReasoningEffort, the
same fallbacks the fresh path takes, and runResume passes what TaskRunOptions
already held. The reasoning-effort rule travels with the model unchanged: the
parent's effort is inherited only when the manifest pins no model of its own,
because a manifest that chose a different model has not agreed to the parent's
effort for it.
Regressions drive both builders and compare them, so the two paths cannot
drift again: a pinned model survives resume, an unpinned one still inherits the
parent's, both halves of the effort rule hold, and the flag keeps its position
relative to --auto in both.
Refs #554
* test(specialist): cover the resume call site, not just its builder
The builder tests all call BuildResumeArgs directly, so dropping the
ParentModel field from the runResume call site still compiled and still passed
every one of them. The defect this fixes lived at the call site, so it needs a
test that goes through Run.
Driven through the real dispatch with the RunChild seam capturing argv.
* test(specialist): guard the fresh call site as well
runFresh and runResume each construct their builder input by hand and carry a
byte-identical ParentModel line. Deleting either compiles and, until now,
deleting the fresh one was silent.
The resume half is what this branch repairs. This covers the other half so the
pair cannot drift again in the direction nobody was looking.
@jatmn's six findings, taken at the two root causes he named rather than as six
phrase-specific patches. Three are closed at the root; two are not, and this
message says which and why rather than implying six.
## Closed: a duration is read whole or refused (F1)
Three unanchored regexes each hunted for their own suffix with no shared left
boundary, so a failed outer match restarted inside the same token. Measured
before:
.86s -> 86s 1,200ms -> 0.2s
.5m -> 300s 1m10ms -> 0.01s
1h1m500ms -> 0.5s
Every one turns an honest claim into a fabricated correction, which is the single
failure this package exists to prevent.
One scanner now recognises a token whole or not at all, with explicit left and
right boundaries, and BOTH callers use it — parseClaimedDuration and the clause
scan. They were separate heuristics, so a token the parser refused could still
bound a clause; the two disagreeing about what a duration is was its own defect
class. Ambiguity is still silence rather than a second-best reading.
## Closed: every timed mention is checked (F4)
claimedSecondsFor returned at its first successful occurrence, so an agreeing
mention shielded every later one: "TestFoo took 1.00s; TestFoo later took 9.00s"
reported nothing against a recorded 1s. Extraction now returns every value and
the caller compares, which is why "later" needs no special case. Per-value
dedupe applies within a call as well as across calls, so repeated equivalent
spellings are one finding and two distinct wrong values are two.
## Closed: a package is a measurement subject (F5)
The unrecorded-neighbour guard knew test-shaped names but recognised packages
only when that exact package had been recorded, so a truthful
"github.com/x/first passed github.com/x/unrecorded took 4.20s" charged the
neighbour's figure backwards. Both classes now live in the same subject layer.
## NOT closed: threshold ownership (F3)
"TestQuick stayed under the 10s timeout and completed in 0.86s" still reports
10s. A clause carrying two durations is now ambiguous, which fixes the wordings
where both figures share a clause — "well under the 10s budget" and "against a 5s
baseline" are silent now. It does not fix this one, because " and " is already a
clause separator, so the two figures are in DIFFERENT clauses and the first
clause owns the threshold before any ambiguity rule sees it.
Fixing it properly means the clause boundary and the ownership model have to be
decided together, which is exactly the single model jatmn asked for and is more
than this change carries. Reported rather than patched.
## NOT closed: postfix qualifiers (F6)
"TestFoo passed, 9.90s elapsed" still reports nothing where the same sentence
without "elapsed" is caught.
I implemented the suggested fix — recognise a subject rather than any letter,
using the same measurement-name layer — and it reopened the case that check
exists for. All six following-subject tests failed: "TestFoo passed; 4.20s was
the whole suite." went back to charging the suite's figure to the test. That is a
FALSE ACCUSATION where the current behaviour is only a miss, so it was reverted.
"the whole suite" and "elapsed" are both ordinary words. Separating them by
vocabulary is the qualifier allowlist jatmn explicitly ruled out and would reopen
at the next synonym. Closing this needs an ownership model reading structure
rather than words; the code now says so where the check lives.
## Housekeeping
Six symbols died with the three regexes — claimedDuration, claimedMinuteDuration,
claimedHourDuration, bareUnitIsAmbiguous, startsFirst, compoundPart — plus the
scalar claimedSecondsFor. All removed, and make lint-static run BEFORE pushing
this time: 0 issues. That obsolete-helper lint failure is what broke Windows CI
on #911.
Three mutations, each caught by its own test: dropping the left boundary accuses
2 honest claims, returning at the first mention breaks 4 mention cases, and
demoting package paths mis-charges the neighbour's figure.
Rebased onto ad34dc8d, 0 behind. go test -race ./internal/measurements/ -count=3:
clean. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
Origin-Session: local-c962d7 | Claude Code | 17 prompts
Origin-Snapshot: a599377c09e0
Reported by @jatmn.
keepLocalScopePrivate treated a local/.gitignore containing "*" as proof that
later writes could not enter git. That holds for an UNTRACKED path and for
nothing else: git applies no ignore rule to a path already in the index. A clone,
an earlier `git add -f`, or a hand-authored repository restores both the ignore
AND local/<note>.md as tracked, and the atomic rename then replaced a tracked
file with a body the user was told stays on this machine — sitting in git status,
one `git commit -a` from being shared.
refuseTrackedLocalStore asks git what its index actually holds under the store,
and gates BOTH branches. Refusing only where an existing ignore is accepted would
leave the same leak reachable by deleting the ignore from the worktree: O_EXCL
would create a fresh one, report a clean first write, and overwrite the tracked
note anyway.
The regression jatmn asked for is there: a repository with a committed ignore and
a committed local note refuses the write and leaves nothing index-visible.
## A linked worktree carries .git as a FILE
Found by a verification pass, and it was the real gap. gitDirName's comment says
a file counts as much as a directory — that is what a linked worktree and a
submodule carry — and nothing tested it. Changing the check to
`err == nil && info.IsDir()` compiled and all nine tests still passed, so the
whole linked-worktree and submodule class was resting on a comment.
TestALocalWriteIsRefusedInsideALinkedWorktree builds the real article with
`git worktree add` rather than a stand-in, so it exercises the same code a user's
linked worktree does. That mutation now fails it.
## Two branches that are deliberately unreachable
The same pass reported the EvalSymlinks-failure branch and enclosingGitRepo's
unreadable-ancestor branch as untested fail-open points. Both mutate cleanly and
break nothing, and I could not build a fixture that reaches either — because
neither is reachable through Write. refuseReparseChain has already refused every
link in the chain, and opening the rooted handle has already failed on an
unreadable ancestor: a self-referential store link is refused as a link, and a
000 ancestor fails at mkdir. Measured both.
They stay, and the code now says why: reachability is a property of the CALLERS,
not of this function, whose contract is that an unanswerable privacy question is
a refusal. A future caller that skips the earlier guards would otherwise inherit
a silent fail-open. Writing a mock seam purely to reach dead code would have
tested the mock.
Rebased onto ad34dc8d. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Both reported by @Vasanthdev2004.
## memory_forget destroyed what read and write refuse to touch
Same hand-authored findings.MD, project scope, three calls at 6df0c097:
1. Read -> a differently spelled note already occupies this name
2. Write -> a differently spelled note already occupies this name;
rename or remove it first
3. Forget -> ok
4. on disk: nothing left
Read and Write both consult storedEntryName and refuse when the stored spelling
differs, because on a case-insensitive filesystem findings.md and findings.MD
are one file. Forget did not, so the one operation with nothing left to inspect
afterwards was the one that ignored the guard. The tool layer gates only on
ErrNotFound; a clash returns ErrNameClash, falls through, and handle.Remove
takes the occupant with it.
This is not a regression: forget behaved this way before the change too. It
matters more now because the change establishes a contract that forget
contradicts, and because the refusal the other two doors return said "rename or
remove it first" while memory_forget is the obvious way to remove. The message
routed the reader from a refusal to the destructive door.
The guard now lives in memory.Forget rather than in the tool, so every caller
gets it and not just this one. Absence is still not an error: a name nobody has
stored has no occupant to clash with, and the documented idempotence is
unchanged. All three doors share one wording, which names the occupant and asks
for a rename in the store directory — the only step that actually resolves the
clash, and not a suggestion to delete the file being described.
## The named listing order applied to nothing anyone could see
ResolveScopes("") returns [local project], so memory.List's len(scopes) == 0
branch was unreachable from the product and listingScopeOrder never ran. The
variable, its comment, and TestListingShowsBothScopesWithoutShadowing all
described a project-first listing that no user was getting.
Wired up rather than deleted, because the distinction it draws is real:
resolution order decides which scope WINS a name and must be local-first;
listing order decides only what a reader sees first and shadows nothing. The
listing tool now hands List no scopes when the caller named none, which is what
asks List to choose. A caller who names a scope still gets exactly that one, and
the unknown-scope refusal is unaffected.
Three mutations, each caught by the test written for it: dropping the Forget
guard deletes the note; forwarding the resolved order lists local first;
restoring the "remove it first" wording fails the assertion that a refusal must
not route the reader to the sharpest door.
Pre-existing on this branch and on its merge-base, in this environment only:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider both exit 3.
Origin-Session: local-8cd239 | Claude Code | 11 prompts
Origin-Snapshot: 365efe3045f2
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
CodeRabbit's review of the current head. Three real, one declined with reasoning.
THE SCOPE FIX WAS INCOMPLETE. List kept its own project-first literal, so making
ResolveScopes authoritative fixed the read round trip and left the listing
walking the other way — two copies of one decision, which is exactly how they
drift. Correctly flagged.
BUT THEY ARE NOT ONE DECISION, and following the suggestion literally would have
changed behaviour a test asserts on purpose:
resolution order decides which note WINS when both scopes hold the name, and
must be local-first or an unscoped write cannot be read back
listing order decides only what a reader sees FIRST — both scopes are shown
and neither shadows the other, so nothing is being resolved
Project first in a listing puts the checked-in, team-facing notes at the top of a
long one. So the listing order is now NAMED rather than left as a literal, with
the difference from the resolution order written down — the duplication CodeRabbit
saw was real, and the fix is to state the distinction rather than erase it.
DefaultScopeOrder now returns a copy through an accessor, with the backing slice
private: handing out the slice let any caller reorder the resolution every other
caller depends on.
EACH GODOC BACK ON ITS OWN ERROR. Inserting two sentinels above the block left
every comment attached to the wrong error — ErrNotPrivate's text sat above
ErrNameClash, and so on down. Cosmetic, and the kind of cosmetic that misleads
whoever reads the package docs next.
Also removed a duplicate uncreated-store test I added without noticing the
existing one, and added CRLF cases to the ignore-gate table: a .gitignore written
on Windows carries them, git does not care, and if the gate ever stops stripping
them every local write on a Windows checkout fails for a file doing its job.
Not done: reusing List's ReadDir entries to skip storedEntryName per note. It is
a real saving, but it adds a second read path next to the one that just fixed a
correctness bug, and the listing is bounded by the store size.
Origin-Session: local-8cd239 | Claude Code | 1 prompt
Origin-Snapshot: 627f6f8664c6
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Two findings from @Vasanthdev2004, both reproduced before changing anything, plus
the corrections he asked for on the previous commit.
SAVE A NOTE, READ IT STRAIGHT BACK, GET SOMEONE ELSE'S. memory_write and
memory_forget default to local while an unscoped read resolved project FIRST, and
scope is optional in every schema:
memory_write{name, content} -> Saved "findings" (local).
memory_read{name} -> memory "findings" (project) ... not what was written
memory_forget{name} -> Forgot "findings" (local).
memory_read{name} -> memory "findings" (project) ... still there
Every message is accurate about the scope it acted on and the round trip is still
broken. A checked-in project note is the ordinary way it happens, since it
arrives with a clone and the model has no reason to expect it. The tool's own
approval text is "Saves a note that future sessions will read and believe", which
is exactly what fails. There is now ONE order, local first, matching where an
unscoped write lands; a project note is still found when nothing local shadows
it, so the ordering decides which wins rather than what exists.
A NOTE THE LISTING DENIES EXISTS WAS DESTROYED BY THE NEXT WRITE. List matches
the extension exactly; Read reopened name+".md", which on a case-insensitive
filesystem is the same file as a hand-authored "findings.MD". So the listing
reported an empty store, writing was the reasonable next move, and a checked-in
file was silently overwritten — verified: the note came back holding
"OVERWRITTEN". Read now agrees with List, and the write refuses with ErrNameClash
naming the occupant rather than taking its place.
AND THE CORRECTION HE ASKED FOR. My previous commit was written as a junction
fix. It is not one: he re-measured and every junction placement is already
refused a layer earlier, because os.Root.Lstat reports a junction as
ModeIrregular and RefuseReparse rejects it on the first component. The comment
now says what the branch actually covers — a store present but unopenable for an
ordinary reason, which used to surface as "no such memory" — and records the race
he spotted, where a store being created concurrently reads as refused rather than
absent.
The chmod arm cannot build its refusal on Windows, since os.Chmod there only
toggles the read-only attribute — the opposite of what my comment claimed. A
junction companion covers that platform, so between the two arms the branch is
exercised everywhere and neither passes vacuously.
Mutation-checked: project-first breaks the round trip, and removing the clash
guard destroys the hand-authored note again.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
My own defect, and it broke the run on the platform the test exists to stand in
for. The skip-check opened the blocked directory to find out whether this
environment enforces directory permissions, and discarded the handle without
closing it. Windows will not remove a directory while a handle is open, so
t.TempDir's cleanup failed and the smoke job went red — after the test had
correctly decided to skip.
escape_test.go:404: this environment ignores directory permissions...
testing.go:1464: TempDir RemoveAll cleanup: unlinkat ...\.zero\memory:
The process cannot access the file because it is being used by another process.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
@Vasanthdev2004 measured why my last attempt did not take, and he is exactly
right. The branch was keyed on the component being ABSENT to the handle, and a
junction is not absent to it:
presentOnDisk(root, ".zero\\memory\\project") = true
componentExists(handle, ".zero\\memory\\project") = true <- Lstat succeeds
Lstat does not traverse a reparse point, so it reports the junction as being
right there. !exists was false, presentOnDisk was never consulted, and the walk
continued past the very component it was meant to refuse — the later open failed
as ErrNotFound and List dropped it, exactly as before the "fix". The reasoning in
the comment was right and the condition guarding it was wrong.
OPENING IS WHAT TRAVERSES, so opening is the question. A path the confined handle
will not open while it is plainly present on disk has been REFUSED; a path absent
from both is simply not there yet, which is what a first write is for.
A Windows junction is not a symlink and does not answer like one — os.ModeSymlink
misses it, EvalSymlinks returns it unchanged, Lstat says it is there. Only the
open disagrees. That is the third time this shape has bitten this branch.
ErrUnreadable is its own sentinel rather than ErrIsSymlink, because this path
cannot identify what it refused: a junction and a directory the process may not
enter are indistinguishable here, and claiming "is a link" for the second would
be a guess stated as fact.
THE TEST RUNS ON THIS PLATFORM NOW. A directory that is present but cannot be
entered produces the same disagreement as the junction — Lstat sees it, Open
refuses it — and unlike the junction it is constructible on darwin and Linux. It
skips where directory permissions are not enforced rather than passing vacuously.
Reverting to the Lstat condition fails it. Absence is asserted separately: a
clean workspace must still read ErrNotFound, list empty without error, and take
its first write.
Still unverified by me: the junction itself, which remains @Vasanthdev2004's
measurement on Windows rather than mine.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
@Vasanthdev2004 and @anandh8x both, and @Vasanthdev2004 named the real problem
with my last attempt: I answered it by loosening the test rather than changing
what the caller is told, and the commit title said so. He is right. This changes
the behaviour.
@anandh8x supplied the detail that made it tractable — the junction's target is
OUTSIDE the workspace. So the confined handle reports the component absent while
it is plainly present on disk, the chain read it and everything under it as
missing, the later open became ErrNotFound, and List drops ErrNotFound. A
tampered store presented as an empty one, which is exactly what memory.go's own
contract forbids:
A refused link, an oversized file or a permission error is a problem the
caller needs told about rather than a note that silently vanishes.
The harm is specific: the model concludes its notes are gone and writes over
whatever is really there.
Absence and refusal are now different answers. A component the handle will not
open, while it is present on disk by ordinary pathname, has been refused and
returns the reparse sentinel — which Read propagates and List reports, because
neither suppresses anything but ErrNotFound. presentOnDisk stats and never opens,
so nothing is traversed on the strength of it; it decides only which error the
caller is given.
An uncreated store is still absence, and that is asserted: a clean workspace must
read ErrNotFound, list empty with no error, and accept its first write.
NOT VERIFIED, and this is the limitation to weigh. I am on darwin, where a
symlink is caught by the reparse check one step earlier, so the branch this adds
is unreachable locally — the junction path is your measurement, not mine. What IS
verified here is presentOnDisk itself: it reports a directory, a nested
directory, a reparse point and a dangling link as present, a missing path and a
blank root as absent. If either of you can re-run the junction case on Windows I
would rather have that confirmation than my inference.
Origin-Session: local-ae96ee | Claude Code | 4 prompts
Origin-Snapshot: 701aaeb8081a
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
My test, not the production code — it turned Windows red while the behaviour was
correct. linkDir builds a JUNCTION on Windows, which is a directory, so the ignore
path became a directory and the create failed with "is a directory" rather than
reaching the reparse check that returns ErrNotPrivate. The write still failed
closed; the assertion was simply about the wrong thing.
Split in two, so each platform exercises the case it can actually build:
file symlink what a .gitignore would really be; Windows needs a
privilege for these, so it skips there
directory reparse point junction on Windows, symlink elsewhere; the refusal
arrives differently by platform, so what is asserted is
the property that holds on both — THE WRITE DOES NOT
SUCCEED
Still mutation-checked: removing the reparse refusal makes the write succeed and
both subtests fail.
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Three findings, all reproduced before changing anything, and the first two are
the same mistake: certifying a privacy rule that git does not actually apply.
GIT DOES NOT READ A SYMLINKED .gitignore. @anandh8x's P1. keepLocalScopePrivate
saw O_EXCL return EEXIST and then read through the link, accepting the target's
"*" as proof of a rule git warns about and ignores. An in-workspace RELATIVE link
is followed by os.Root; an ABSOLUTE one is refused, which is exactly why my own
review of this looked clean and was not — I attacked it with an absolute target
and reported the refusal as the general answer. The ignore path now goes through
the same no-reparse rule as a note before its contents are read.
THE GATE DISAGREED WITH GIT IN BOTH DIRECTIONS. @Vasanthdev2004's finding,
re-verified here against git 2.55.0 rather than taken from the spec:
" *" gate said covered; git does NOT ignore, because LEADING whitespace
is part of the pattern — so a note the user was told stays on this
machine was picked up by a routine `git add -A`, which is precisely
what this function exists to prevent, and it failed silently
"*" gate said not covered; git strips a UTF-8 BOM and honours the rule
— so an ignore written by an ordinary editor failed every local write
Both came from validating a pattern language by trimming and comparing strings.
Leading whitespace is significant and is no longer stripped; trailing whitespace
is not and still is; a BOM is stripped once at the start of the file, as git does.
REFUSED IS NOT ABSENT. Also @Vasanthdev2004's: a junction at an ancestor made the
store read as empty, so the caller was never told, and for this feature that means
the model concludes its notes are gone and writes over what is there. A component
cannot exist inside one that does not, so where absence does not hold all the way
down the handle is refusing to traverse rather than reporting an empty path, and
that now surfaces as a refusal.
NOT VERIFIED, and this is the limitation to weigh: the junction behaviour itself
is Windows-only and I am on darwin. The anomaly the check keys on is
platform-independent, but that it is the shape a junction produces is his
measurement, not mine.
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
CodeRabbit's catch. The ancestor-link regression called os.Symlink directly, so
on Windows it skipped — and a junction is precisely the case that guard is built
for: a reparse point that is NOT a symlink, which is why RefuseReparse tests
ModeIrregular alongside ModeSymlink. The Windows behaviour went unasserted while
the run reported green.
linkDir already existed in this file for exactly this reason and creates a
junction there. Using it is the whole change.
Same shape as the sandbox helper that reached for /Users/Shared and skipped off
macOS: a skip is not a pass, and a guard whose platform-specific case is never
exercised is a guard nobody has tested.
Origin-Session: local-abff1c | Claude Code | 5 prompts
Origin-Snapshot: dddd3415c4e0
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Both P1s from @anandh8x, both reproduced exactly as he described before any
change was made.
A LINK ABOVE THE STORE REDIRECTED EVERYTHING BELOW IT. The comment on openScope
claimed this class was closed by routing every operation through os.Root, and it
is only half closed: os.Root refuses to traverse OUT of the workspace but
follows a link that resolves back INSIDE it, and the store path is checked in.
A repository shipping ".zero -> redirected" served notes from a directory nobody
asked for — Read returned the planted note — while every individual check
passed, because the only component ever inspected was the note file at the end.
Every component of the store path is now checked, outermost first, composed from
pathjail.RefuseReparse so the Windows-junction and trailing-separator handling
stays in one place rather than being restated here.
THE PRIVACY PROMISE FAILED OPEN. keepLocalScopePrivate read every failure of its
O_EXCL create as "a previous run wrote the ignore", and O_EXCL cannot tell that
from "something else got there first": precreating an empty .gitignore made every
later local write succeed with the store fully tracked. The old comment argued
best-effort was right because failing a write would trade privacy for an outage —
that reasoning does not hold for this function, because the promise IS the
feature. A note the user was told stays on this machine, sitting in git status,
is worse than a refused write: the refusal is visible and the leak is not. The
ignore is now installed or verified, and a store that cannot be made private
returns ErrNotPrivate naming the file.
The effectiveness check recognises a bare "*" — what this store writes — and
nothing narrower, because guessing at the effect of an arbitrary pattern set is
how such a check ends up agreeing with a file that protects nothing. A single
re-inclusion line fails the file wherever it sits.
Both mutation-checked: removing the ancestor chain lets the redirected read
through again, and ignoring the verify result lets an empty ignore pass. The
ancestor test derives its components from Paths rather than naming them, so it
keeps covering every ancestor if the layout moves — and writing them by hand is
how the first version of it tested a path that was not a store ancestor at all.
Origin-Session: local-abff1c | Claude Code | 3 prompts
Origin-Snapshot: 07900397c62e
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Four gaps CodeRabbit found in the tests, each covering behaviour that only a
comment held in place:
- a truncated description now has to END WITH the ellipsis, not merely fit the
cap: a silent cut reads as the author's own words stopping there
- ResolveScopes is asserted on the VALUE it resolves to, not just the count —
" local " returning one scope proves nothing if that scope is the project store
- the exact ".md" match, which decides that List and Read agree about what a
note is
- List returning readable notes BESIDE the error, which is the contract the
memory tool depends on to avoid rendering one bad file as an empty memory
Both of the last two were mutation-checked, and the extension test only half
holds: loosening the extension check alone does NOT fail it, because the
case-sensitive TrimSuffix then leaves "shouty.MD" whole and the name pattern
refuses it, so nothing reaches the listing to catch. It fails on the refactor
that does reach it — extension check and suffix strip made case-insensitive
together. That limit is written into the test rather than left for the next
reader to discover.
Origin-Session: local-abff1c | Claude Code | 1 prompt
Origin-Snapshot: 0e7ed28981cb
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Both raised by @Vasanthdev2004 against the cap added in the previous commit, and
they compound: the read path is where an unbounded description shows up, and the
read path was not where the bound ran.
THE CAP WAS QUADRATIC. It dropped one rune per iteration and re-encoded the whole
slice each time to measure it:
1 KiB -> 1.6ms 16 KiB -> 381ms 64 KiB -> 13.3s
maxNoteBytes is 64 KiB, so a 64 KiB description is a note the write path accepts
— the cap was at its slowest on exactly the input it exists to handle, and
multi-byte runes were worse per byte. Ranging over the string yields rune
boundaries with their byte offsets, so the cut point is now found in one pass
with no intermediate string.
THE CAP ONLY RAN ON WRITE. A project-scope note arrives with a clone and never
passes through this process's write path, so a checked-in note with an enormous
single-line description was unbounded in the listing. The bound now runs where
the description is PARSED, which covers both paths.
Both regressions fail against the old behaviour: the quadratic loop takes ~5s on
64 KiB, and bounding only on write lets a 23,999-byte checked-in description
reach the listing.
Origin-Session: local-abff1c | Claude Code | 1 prompt
Origin-Snapshot: 0e7ed28981cb
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Split 2/3 of #829, stacked on the pathjail primitive from #891 (1/3).
- internal/memory: a durable note store confined to a pathjail handle, so every
read, write, rename and delete stays on the confined handle rather than being
re-resolved by pathname. Writes publish through an O_EXCL temporary file.
- internal/tools: memory (read/list), memory_write (save), memory_forget
(delete).
Reads are confined by the same rule as writes, the size ceiling holds on the way
out as well as in, scope is resolved in one place, and deletion is its own tool
so its approval can disclose it — memory_write's says only that it saves, and an
omitted or blank content used to fall through to Forget under that sentence.
PARTIAL SUCCESS IS PRESERVED END TO END. The earlier fix for "errors reported as
absence" overshot in two places, and both are corrected here:
- The listing returned an error INSTEAD of the notes whenever any note failed,
so the library's careful partial success became total failure one unreadable
directory entry away from an empty memory. The failure is now appended to the
rendered notes rather than substituted for them.
- The named read returned on the first non-ErrNotFound failure, and project is
searched before local. So one unreadable project note called "findings" made
the user's own local "findings" unreachable — the shared, externally-supplied
scope masking the private one. Failures are carried and reported only when
nothing readable turns up.
A description is bounded separately from the note. The listing prints every
description, so the field is shared screen space: the total-size check alone let
one note carry a 60 KiB single-line description and consume the listing everyone
else has to fit in.
Origin-Session: local-de382f | Claude Code | 9 prompts
Origin-Snapshot: 92ae33c95cd1
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
determinism assertion
@Vasanthdev2004's three remaining points. His blocking finding — a zero-value
Ledger panicking on l.raised — was already closed at 0c1b1bc7 and is confirmed
still closed: a declared Ledger now survives a CONTRADICTORY claim through both
Conflicts and ConflictsAcrossRuns, which is the path an agreeing claim never
reaches.
## The determinism assertion was vacuous by construction
He was right that it looked armed and was not. The merge walked its runs in Go
map order, so "identical between identical passes" could only be asserted about
something that had no stable order to compare. sort.Strings on the merge keys
gives it one, and TestTheRunOrderTheMergeWalksIsStable now fails without it —
the key comes back as a real run key instead of the expected first one.
## A neighbouring number charged to this test
"The subject rule charges a neighbouring number to the current test whenever the
subject follows its number rather than preceding it." Reproduced: six ordinary
report shapes, all mis-charged —
TestFoo passed; 4.20s was the whole suite.
TestFoo passed - 4.20s was the package total
TestFoo passed | 4.20s for the whole package
TestFoo ok: 4.20s across every package
TestFoo passed (4.20s for the suite)
TestFoo was fine, 4.20s covered every package
The clause scan looked for a word BEFORE the figure and never after it, so a
figure whose subject trails it read as belonging to the test named earlier. It
now scans the figure's own segment on both sides. Removing the trailing half
mis-charges all six.
The presentation forms still read, which is what stopping at the end of the
figure's own segment buys: "| TestFoo | 9.90s | passes |" and
"TestFoo passed, 9.90s. The suite took 34.249s." both still catch a fabricated
9.90s. Cutting at the first word instead would have silenced them.
## An "m" that is not minutes
"TestParseCorpus handled 5m rows in 0.86s" read the count of rows as five
minutes and accused a truthful report of claiming 300s — the one failure this
package must never produce. A compound form ("1m10s") cannot be a count, and a
bare figure with nothing after it ("took 2m", "(9h)") has no noun to count, so
the bare-figure-plus-word shape is the whole ambiguity.
An ambiguous token now yields nothing rather than a second-choice reading:
reaching past it to a later figure would answer the same question by guessing.
The cost is stated plainly in the code — "TestSlow took 2m to finish" is now
unreadable, which is the safe direction for this package.
The clause scan refuses exactly what the parser refuses, or the two disagree
about where a clause ends. TestTheClauseScanRefusesWhatTheParserRefuses pins
that agreement and catches its own mutation on four inputs.
Four mutations, each caught by the test written for it: widening the minute
pattern past its word boundary, dropping the merge sort, removing the trailing
subject scan (6 cases), and disabling the bare-unit ambiguity guard (4 cases).
Rebased onto ad34dc8d. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Reported by @jatmn, and it is a hole in my own previous fix rather than a new
defect.
That fix made a declared-but-not-constructed Ledger survive Record by
initialising the two maps Record touches. It left `raised` nil. Only Conflicts
and ConflictsAcrossRuns write that map, and they write it exclusively on the
contradiction path — the dedupe that stops the same wrong number being reported
twice — so nothing in the Record path could reach it.
The test I wrote could not catch this. It asked an AGREEING claim:
ledger.Conflicts(Run{}, "TestSomething took 1.25s") // against a recorded 1.25s
which returns early with no conflict and never reaches the write. A contradicting
claim panics:
var ledger Ledger
ledger.Record(Run{}, "--- PASS: TestSomething (1.25s)\n")
ledger.Conflicts(Run{}, "TestSomething took 99s")
-> panic: assignment to entry in nil map
Centralised into ensureMaps rather than adding a third `if` beside the other two.
The field list now sits beside NewLedger's, so a fourth map added later is a
visible omission in one place instead of a panic in whichever entry point forgot
it — this is the second time these lists have drifted apart.
TestAZeroValueLedgerSurvivesAContradiction covers both conflict entry points and
also asserts the dedupe actually works, since that is what `raised` is for.
Honest note on the mutation checks: removing `raised` from ensureMaps panics the
new test, so the real defect is covered. Removing the ensureMaps call from
ConflictsAcrossRuns does NOT fail anything — neither conflict path can write
`raised` without data having been recorded first, and Record initialises. Those
two calls are defence-in-depth for a future entry point, not independently
reachable today.
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Both raised by CodeRabbit on the full review.
## "1.5m" read as five minutes
The minute and hour components accepted integers only, so neither pattern could
match at the digit the number starts on. The leftmost match began after the
decimal point instead:
"took 1.5m" -> 300s (want 90)
"took 0.5m" -> 300s (want 30) half a minute read as five
"took 1.5h" -> 18000s (want 5400)
"took 10.25h" -> 90000s (want 36900) ten and a quarter hours read as
twenty-five
This is the failure this package exists to prevent, occurring inside its own
parser. A model that truthfully restated a recorded 90s as "1.5m" was reported
as contradicting the transcript, and the nudge quoted 300s back at it — a number
nothing in the run ever produced. The file already carries two comments about
exactly this shape of mistake, one for minutes and one for hours.
compoundPart parses with ParseFloat, so the fraction only had to be allowed into
the capture for the match to start where the number does. The word boundary that
keeps "1.5ms" out of the minute pattern is unchanged, and the positional rule
that makes "took 0.86s (package total 1m20s)" read 0.86 is unaffected.
## A zero-value Ledger panicked on its first Record
The nil receiver is handled; a Ledger that was declared rather than constructed
got past that guard and panicked with "assignment to entry in nil map". Both
maps are now allocated lazily, which costs nothing on the NewLedger path where
they are already non-nil.
Both mutation-checked. Restoring the integer-only minute pattern reports the
honest 1.5m and 0.5m claims as conflicts at Claimed:300, and reads a fabricated
4.5m as 300 rather than 270 — the regression asserts the reported value, not
merely that something was reported, because a whole read and a lucky one are
otherwise indistinguishable. Removing the lazy allocation panics.
Unrelated to this change, in this environment:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 here and on the merge-base.
TestResolveSandboxEnabledIgnoredFromProviderCommand failed once under full-suite
load and passes 3/3 in isolation; internal/config has no dependency path to
internal/measurements, so this change cannot reach it.
Origin-Session: local-8cd239 | Claude Code | 12 prompts
Origin-Snapshot: dd397730a138
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
CodeRabbit's finding on the current head, verified before changing anything.
separatorBreaksClause locates the next duration to decide whether a separator
introduces a new subject, and it knew the seconds and minute patterns but not the
hour one added a commit earlier. So it read the "h" of "9h" as the first letter
of a new subject, turned the punctuation into a clause boundary, and cut the
test's own number away from its name:
"TestVerySlow - 9h" missed
"TestVerySlow passed - 9h" missed
"TestVerySlow (9h)" missed
"TestVerySlow: 9h" missed
"TestVerySlow took 9h" caught, because no separator was involved
A duration this package can PARSE has to be one this scan can SEE, or the two
disagree about where a clause ends — and the disagreement is silent, because the
answer it produces is the same shape as an honest bound.
Both bounds still hold: an hour figure belonging to another subject
("…passed - the whole suite took 9h") stays that subject's, and a truthful
1h10m0s restatement of a recorded 4200s is not a conflict.
NOT DONE, with the reason. The review also asked for a real ParseGoTest call site
from internal/agent or internal/specialist to clear an unreachable-function
finding. ParseGoTest is called by Record at measurements.go:213, and the
integration that calls Record lives in #829 — this split branch deliberately has
no caller, which is the same for Run.key and Run.Label. Adding one here to quiet
a reachability scan would put the wiring in the wrong PR.
Mutation-checked: dropping the hour pattern from the scan misses all four
separator spellings again.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
CodeRabbit's findings on the current head, all verified before changing anything.
HOURS COUNT, for the reason minutes did one round earlier. Without an hour form
"1h10m0s" matched only its minute remainder and read as 600s, so a truthful
restatement of a recorded 4200s was reported as a fabrication — the accusation
this package exists to avoid, one unit further up.
The hour form is its OWN pattern rather than an optional prefix on the minute
one. Making every part optional lets the expression match the EMPTY string, which
regexp then finds at offset 0 ahead of any real duration: that version read
"1h10m0s" as 0s, which is worse than the bug it was fixing. I shipped it that way
for one iteration and the round-trip table caught it.
A TEST THIS PR HAD DISARMED. The stability assertion recorded the SAME name under
both runs, which makes the report a merged one — and the merged-attribution fix
earlier in this same PR drops the label for merged reports. So it watched an
always-empty string and could not have failed. A production change silently
disabled a test guarding a different property, which is the fourth time in this
series that a green test was asserting nothing. It now uses one name per run, so
a single run stands behind the conflict and a label exists to be stable, and it
fails outright if the label is ever empty again.
Also from the review: ConflictsAcrossRuns is asserted nil-safe alongside the
other two entry points, the dedupe doc now says name AND VALUE rather than name
alone, and the unused outer ledger is gone from the honest-reporting table.
The plain-seconds fall-through now checks for nil rather than relying on the
branch conditions above it to guarantee non-nil. That was provably safe and
provable-by-argument is what this file has already paid for once.
Mutation-checked: disabling the hour branch reports 1h10m0s as 600s and 1h2m3s as
123s, and recording one name under both runs fails the stability test outright.
Origin-Session: local-79d7a0 | Claude Code | 5 prompts
Origin-Snapshot: c175cabb9d50
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
@Vasanthdev2004 found the ASCII hyphen still leaking. Walking the same nine
shapes here found FIVE that leak, not one — the finding is a class, not an
instance:
ACCUSED "TestChattyChild passed - the suite took 34.249s."
ACCUSED "TestChattyChild passed — the suite took 34.249s."
ACCUSED "TestChattyChild passed – the suite took 34.249s."
ACCUSED "TestChattyChild passed (the suite took 34.249s)"
ACCUSED "TestChattyChild passed | suite 34.249s"
The review reported the em dash, en dash, parenthetical and pipe as bounding
correctly. They do not at 36c79cdf; each is quoted above from a run against that
head. This does not change his recommendation, only its size: clause punctuation
is still a closed set, and it is now enumerated.
PUNCTUATION ALONE IS NOT THE BOUNDARY. Adding the missing separators outright
cost real detections, because the same marks are how a test's OWN number gets
written:
"TestChattyChild (9.99s)" "TestChattyChild passed - 9.99s"
What makes a mark a break is a SUBJECT named after it, so the test is whether any
word appears between the separator and the next duration. That rule also recovers
two detections the pre-existing comma and colon separators were already costing
silently — "TestChattyChild passed, 9.99s" and "…passed: 9.99s" were both MISSED
at 36c79cdf and are caught now.
Measured on the widened set: 11 bleed shapes bound, 10 own-number shapes read,
none lost. Mutation-checked both halves — removing the new separators leaks 7
shapes, and removing the subject rule loses 8 detections.
Reviewed adversarially before committing: hyphenated test names and import paths
still parse, the minute and millisecond forms are read after a separator and
bounded before one, and a truncated "(" or trailing "-" is inert.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Both reproduced before changing anything.
A FULL STOP ENDS A CLAUSE. @Vasanthdev2004's blocker. clauseSeparators had no
sentence terminator, so when the next sentence's subject was an ordinary noun
phrase — not a recorded name, not name-shaped — nothing bounded the clause and
its number was charged to the previous sentence's test:
"TestNested is green. The full run took 34.249s."
-> [{Name:TestNested Claimed:34.249 Recorded:[0.03]}]
Both sentences are true, both numbers were really measured, and the writer
attached each to the right subject. Accusing a correct report is the failure this
package's own doc says gets the tripwire switched off.
A decimal point is not a terminator and neither is the dot in an import path: a
terminator is followed by whitespace or the end of the line and never sits
between two digits. A colon is now a separator too.
One correction to the review, since it changes the scope rather than the fix: he
reported "TestChattyChild ok. Package total 34.249s." as NOT leaking, and it does
leak at this head — I measured it. The bound covers it either way, but the shape
was not as narrow as it looked.
A MERGED RESULT NAMES NO SINGLE COMMAND. @anandh8x's P1. ConflictsAcrossRuns
merges every run's values for a name, and labelling that union with one run said
that command reported a number it never printed:
`go test ./a` in this session reported 0.1s, 0.2s
where 0.2s came only from ./b. Choosing the run deterministically fixed the
reshuffling and left the attribution just as untrue. With more than one run behind
the values the label is dropped and the nudge names the session; with one run it
is kept, which is the useful case because the model is told exactly what to re-run.
Both mutation-checked: removing the terminator bound brings back three bleeds, and
removing the attribution guard brings back two false attributions.
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
CodeRabbit's catch, and it is a real one. ConflictsAcrossRuns merged each name's
values by ranging over the per-run map, and Go randomises that order — so the run
it picked, and therefore the dedupe key built from it, varied between identical
calls. A name recorded under two commands was re-reported on a later pass 50
times in 200. The agent loop feeds this back to the model as a correction, and
the dedupe exists precisely so that cannot loop.
The same randomness reached the message: the nudge named whichever command the
map happened to yield first, so identical passes produced different text — the
problem the sorted output in this function already exists to avoid.
THE SORT IS THE FIX. Selecting the lowest run key makes both the quoted run and
any key derived from it stable, and reverting it alone fails the new test at the
first attempt. Repeated 200 times in the test, because a defect that depends on
map order passes a single run and would be called fixed.
The cross-run dedupe now also has its own key namespace rather than borrowing the
per-run one. That is a SEMANTIC choice, not part of the nondeterminism fix, and
it has a visible consequence worth stating: asking both questions on one ledger
reports the same number twice, once per question. They are different questions
and neither should silence the other. No caller asks both — the agent loop and
the specialist each use the cross-run form — and the behaviour is pinned by a
test so the split is deliberate rather than discovered later.
Found by running the review protocol over my own pushed head, after CI had passed
it. Mutation-checked: reverting the sort makes the quoted run vary between
passes, and reverting both makes the same conflict raise twice.
Origin-Session: local-abff1c | Claude Code | 6 prompts
Origin-Snapshot: b59446f78949
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Found by self-review of the previous commit, not by CI. That commit claimed
clauseEnd gained three bounds. It gained two.
nextNameShaped compared against LOWERCASE prefixes, and nothing in this package
lowercases the claim — unlike the guardrails, it compares against recorded names
and must preserve their case. So "test" never matched "TestUnrecorded" and the
function returned -1 for every realistic input. Lowercasing the input would not
have saved it either: the rule that the next character must not be lowercase then
rejects "testunrecorded". It could not fire on anything.
THE TESTS CERTIFIED THE WRONG MECHANISM. Every case added for the name-shape
bound contained a comma, a semicolon or an " and ", so the clause-SEPARATOR bound
caught them all and the dead branch looked alive. A claim with no separator at
all went straight through:
recorded: TestFoo 0.10s
claim: "TestFoo passed TestUnrecorded took 4.20s"
-> [{Name:TestFoo Claimed:4.2 Recorded:[0.1]}]
which is the exact fabricated conflict the commit said it had closed.
The prefixes are capitalised, and the character after one must be uppercase, a
digit or an underscore — how `go test` spells these names, and what separates
TestFoo from the ordinary words "test", "testing" and "tested". The new cases
carry no separator, so only this bound can satisfy them, and two more assert that
an ordinary word beginning with a prefix does not cut the clause short.
Mutation-checked: restoring the lowercase prefixes brings back all four bleeds.
Origin-Session: local-abff1c | Claude Code | 5 prompts
Origin-Snapshot: dddd3415c4e0
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Both from CodeRabbit, both verified against the current head first.
A NEIGHBOUR BOUNDS THE CLAUSE WHETHER OR NOT IT WAS MEASURED. Cutting only at
names the ledger knows left the unrecorded neighbour holding the number and the
name before it taking the blame:
recorded: TestFoo 0.10s
claim: "TestFoo passed; TestUnrecorded took 4.20s"
-> [{Name:TestFoo Claimed:4.2 Recorded:[0.1]}]
That is the same fabricated conflict @anandh8x reported, reached through a name
the session never ran — and whether a number belongs to a name cannot depend on
whether some OTHER name happened to be measured. The clause now also ends at a
name-SHAPED token (Test/Benchmark/Fuzz/Example, or an import path) and at a
clause separator, which catches the neighbours that are not name-shaped at all
("TestFoo passed, the suite took 4.20s"). All three bounds only ever shorten the
search, so each can cost a detection and none can invent one — the right
direction for a check whose worst failure is accusing a correct number.
THE PREFIX FIXTURE COULD NOT FAIL. TestAPrefixNameDoesNotAccuseAnHonestClaim
recorded the parent at 0.03s and the subtest at 0.01s, which sit INSIDE
tolerance of each other — so a subtest claim matching the parent's entry read as
agreement, and the test passed whether or not the prefix boundary worked. It
certified nothing. At 5.00s against 0.01s the two cannot be confused, and
breaking the boundary now fails it: the mutation reports
`{Name:TestNested Claimed:0.01 Recorded:[5]}`.
NOT DONE HERE, and it is real: CI runs `go test ./...` while the Makefile's test
target runs `-race -count=1`, so the concurrency test in this package has never
been exercised under the race detector in CI. That is .github/workflows/ci.yml —
repo-wide, outside this PR's files, and affecting every PR rather than this one.
It wants its own issue. This package is race-clean when run that way locally.
Origin-Session: local-abff1c | Claude Code | 5 prompts
Origin-Snapshot: dddd3415c4e0
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Follow-up to the provenance change, and the thing that makes it usable. The
agent loop checks a FINAL ANSWER, which may summarise several commands, so it
has no single run to hold the claim to — and requiring one would have forced it
to pick arbitrarily.
ConflictsAcrossRuns asks the question that caller can actually answer: does this
number match nothing this session printed, anywhere. A value one of the commands
really did print is not invention, and accusing the model of fabricating it would
be the false accusation this package exists to avoid.
Conflicts keeps the strict, per-run meaning for callers that DO know the command,
where a claim about an ordinary run is not answered by a value only
`go test -race` printed. Two functions rather than one with a flag, because the
difference is not a preference — it is how much the caller knows, and a flag
would let a caller that knows the run quietly ask the weaker question.
A conflict raised across runs is deduped against the same (run, name, value) key
as the strict path, so the two cannot report the same thing twice.
Origin-Session: local-abff1c | Claude Code | 3 prompts
Origin-Snapshot: 07900397c62e
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
All three from @anandh8x, all reproduced before changing anything. The package
has no callers yet, so the data model could be fixed rather than worked around.
A DURATION BELONGS TO THE NAME BESIDE IT. claimedSecondsFor searched the whole
remainder of the line, so one name took another's number:
recorded: TestFoo 0.10s, TestBar 4.20s
claim: "TestFoo passed; TestBar took 4.20s"
-> [{Name:TestFoo Claimed:4.2 Recorded:[0.1]}]
Every word of that claim is true. This is the same failure as reading a package
total as a test's own timing, reached through the name binding instead of the
pattern order — and it is the one this package must never produce, because a
missed conflict is silence while this is a confident wrong accusation. The clause
now ends where the next recorded name begins; the ledger knows those names, so
they are passed in rather than guessed at from punctuation.
TIMINGS FROM DIFFERENT COMMANDS ARE DIFFERENT MEASUREMENTS. Everything pooled
into map[name][]seconds, losing which command produced what, so a claim about an
ordinary run was satisfied by a value only `go test -race` ever printed — and
-race is routinely several times slower, which is the size of discrepancy this
exists to catch. Record and Conflicts now take the Run, and the ledger is keyed
by run FIRST so a future caller cannot reintroduce the pooling by forgetting to
pass it. A zero Run is still a legitimate "this caller does not distinguish
runs", but the call site now says so out loud instead of it being the only thing
the type could express. The nudge names the command, so the model is told which
run to repeat.
A SECOND WRONG NUMBER IS A SECOND THING TO SAY. Suppression keyed on the name
alone switched the check off for that test permanently: after one bad 4.20s, a
later and differently bad 9.90s was silent. It keys on the claimed value too, so
re-reading the SAME answer still says nothing — which is all the dedupe was for,
and what keeps a correction fed back to the model from looping.
Each mutation-checked: unbinding the clause, pooling the runs, and suppressing by
name alone each fail the test that covers them.
Origin-Session: local-abff1c | Claude Code | 3 prompts
Origin-Snapshot: 07900397c62e
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
CodeRabbit's catch: the assertion table carried TestNested/subcase but not
TestNested, leaving the parent side of the prefix-trimming unpinned. A change
that stopped parsing parent lines, or folded the parent's time into the child,
passed every assertion in this test.
Mutation-checked: requiring indentation on the case-line pattern makes
TestNested read 0 instead of 0.03 and this test fails.
Origin-Session: local-abff1c | Claude Code | 1 prompt
Origin-Snapshot: 0e7ed28981cb
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Raised by @Vasanthdev2004 against the minute support added in the previous
commit. Trying the minute pattern over the whole tail before the seconds pattern
let it reach past a nearer figure to claim a later one:
"TestChattyChild took 0.86s (package total 1m20s)"
-> [{Name:TestChattyChild Claimed:80 Recorded:[0.86]}]
The claim is the test's own 0.86s; the 1m20s is the package total that `go test`
prints after it. Reading the far number as the claim invents a conflict against a
number the model got RIGHT, then quotes it back as a correction — the one failure
this package exists to avoid, and worse than the miss it was fixing, because a
missed conflict is silence while this is a confident wrong accusation.
Both patterns are now located with FindStringSubmatchIndex and position decides:
the minute form wins only when it starts no later than the seconds form. Group 2
is optional, so a bare "1m" reports index -1 rather than an empty span, which is
why the check is `>= 0` and not a string test.
Every case in TestAMinuteDurationIsReadWhole put the minute figure first, so it
passed against this. The new test fails without the fix on both a trailing
package total and a trailing budget ("450ms, well under the 2m budget" -> 120),
and still catches a fabricated 5m00s when a seconds figure sits nearby.
Origin-Session: local-abff1c | Claude Code | 1 prompt
Origin-Snapshot: 0e7ed28981cb
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
Split out of #829 as an independent package, per @Vasanthdev2004's review asking
for the small self-contained pieces to arrive separately.
A measured run finished a benchmark and reported a table of test timings that no
command in the session had produced: the same test read 0.86s in one paste and
4.20s in the next, a -race overhead moved from +3.7% to +133% between two
tellings of the same result, and the column summed to an exact total no real
transcript lands on.
A prompt rule — "re-run every command before you paste it" — is the obvious
answer and the weak one, because a model willing to write numbers it did not
measure is equally willing to say it re-ran them. The harness is not: every
command's output passed through this process and was written to the session log,
so this package reads the run's real numbers back and compares them against what
the answer claims.
Deliberately loose: a 50% band, so ordinary variation passes and 0.86s reported
as 4.20s does not. A tripwire that cries wolf gets turned off and then catches
nothing.
Two ways it cried wolf, both found in review and both fixed here:
The name match is now bounded on both sides. A raw substring search called an
HONEST report a fabrication whenever one recorded name is a prefix of another,
which go test -v guarantees — it prints the parent above every subtest and the
ledger records both, so a truthful "TestNested/subcase took 0.01s" matched the
entry for TestNested. Package names collide with no subtests at all:
internal/agent is a prefix of internal/agentinit.
Durations with a minute component are read whole. The pattern was ms-or-s only,
so "1m10s" failed on "1m" and "10s" won: a truthful restatement of a recorded 70
seconds became a conflict, and the nudge quoted 10s back at the model — a number
its answer never contained.
The fixture now carries the parent line that go test -v really prints. Omitting
it is what hid the whole class: with only the subtest present no ledger name was
ever a prefix of another, so the substring match looked correct.
No importers yet by design — internal/agent and internal/specialist adopt it with
the orchestration work.
Origin-Session: local-de382f | Claude Code | 9 prompts
Origin-Snapshot: 92ae33c95cd1
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
internal/memory a refused ancestor junction reported as an empty store rather
than a refusal, which is the outcome the package's own
contract forbids
internal/agent a failure state inside a NEGATED proposition was read as the
reported outcome, so a successful negative finding was marked
incomplete
Origin-Session: local-ae96ee | Claude Code | 4 prompts
Origin-Snapshot: 701aaeb8081a
Five punctuation shapes still let a following clause's number be charged to the
name in front of it — the plain hyphen, both typographic dashes, a parenthetical
and a pipe. A separator now ends a clause only when a new subject follows it,
which also recovers two detections the comma and colon were already costing.
This branch is where the package is called from, so the nudge those bounds
protect is the one the agent loop and the specialist feed back to the model.
The linked-ignore regression asserted ErrNotPrivate, but linkDir builds a
junction on Windows — a directory — so the create failed with "is a directory"
before reaching the reparse check. The write failed closed either way; the test
now asserts that property rather than which error carried it.
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Eighth sync. The three split branches moved on this round's review:
internal/memory the ignore gate disagreed with git in both directions,
and a symlinked .gitignore was accepted as proof of a
rule git does not apply
internal/measurements a full stop did not end a clause, so the next
sentence's number was charged to the previous
sentence's test; and a merged cross-run result named
one command as having printed all of it
internal/agent a recognised absence object outranked an explicit
statement that the work was unverified
All 17 files the five split branches touch are byte-identical to their split
heads, verified per file. Full suite, fmt-check, vet, release build and smoke
pass.
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
ConflictsAcrossRuns picked the run it quoted — and the key it deduped on — by
map iteration order, which Go randomises, so the same conflict was re-reported
one pass in four and the nudge named a different command between identical runs.
This branch is where that function is called, from both the agent loop and the
specialist, so the loop-safety it provides is relied on here.
Origin-Session: local-abff1c | Claude Code | 6 prompts
Origin-Snapshot: b59446f78949
Self-review of the previous sync found that clauseEnd's name-shape bound was dead
code — lowercase prefixes compared against a claim this package deliberately does
not lowercase — and that every test for it contained a clause separator, so the
separator bound caught them and the dead branch looked alive.
Origin-Session: local-abff1c | Claude Code | 5 prompts
Origin-Snapshot: dddd3415c4e0
Fifth sync today. The three split branches moved again on review feedback:
internal/memory the ancestor regression skipped on Windows, so the
junction case the guard exists for went unasserted
internal/measurements an UNRECORDED neighbour still let one name take
another's number; the prefix fixture's two durations sat
inside tolerance of each other and could not fail
internal/agent an admission escaped by putting its consequence in a
second sentence, and "the work is blocked" matched no
marker at all
All 17 files the five split branches touch are byte-identical to their split
heads, verified per file. Full suite, fmt-check, vet, release build and smoke
pass.
Origin-Session: local-abff1c | Claude Code | 5 prompts
Origin-Snapshot: dddd3415c4e0
Sync of all five split packages after @anandh8x's P1s were fixed on them, plus
the integration work his measurements finding forced here — this branch is where
that package is actually wired in, and the split branch that owns it has no
caller at all.
Adopted from the split heads:
internal/memory a link at any store ANCESTOR redirected reads and
writes; the local-scope privacy promise failed open
internal/measurements a name borrowed the next name's duration; timings from
different commands were pooled; one bad number silenced
every later one for that test
internal/sandbox a permanent grant was revoked by a temporary holder's
release; one holder's undo could consume another's
reference
internal/agent "the objective is met" read as failure; a tool caveat
excused blocked work; "find any solution" passed as a
successful absence finding
WIRED HERE: Record now carries the call that produced the output. The agent loop
has the ToolCall in hand at exactly the point it reads the output, and the
specialist has the tool, its arguments and the cwd on the stream event — so a
timing is recorded against the command that printed it instead of being pooled
with every other command's. That pooling is what let a claim about an ordinary
run be answered by a number only a `-race` run produced.
Both call sites check ACROSS runs rather than against one: a final answer
summarises several commands, and holding each number to a single run would
accuse the model of inventing a figure another of its own commands really
printed. The per-run check exists for callers that know the command; neither of
these two does, and saying so at the call site is the point.
Full suite, fmt-check, vet, release build and smoke pass.
Origin-Session: local-abff1c | Claude Code | 3 prompts
Origin-Snapshot: 07900397c62e
#911 and #912 both moved when CodeRabbit's findings were fixed, so this branch
was behind again in two more packages:
internal/sandbox the concurrency test was not concurrent — instrumented over
200 runs, 194 peaked at ONE simultaneous holder — and its
helper skipped outright on Windows
internal/agent "the objective" and "the assignment" were bare nouns, so a
finished answer reporting success was read as admitting
failure; and a tool caveat excused blocked work
Same check as before: all 17 files the five split branches touch are
byte-identical to their split heads. Full suite, fmt-check, vet, release build
and smoke pass.
Origin-Session: local-abff1c | Claude Code | 2 prompts
Origin-Snapshot: d2f269b81f33
Follow-up to the sync commit: #897 and #909 each gained tests after it, so this
branch was behind again by four assertions — the ellipsis on a truncated
description, the scope ResolveScopes actually resolves to, the exact ".md"
match, List returning readable notes beside its error, and a parent test's own
duration.
Re-verified the same way: all 17 files the five split branches touch are
byte-identical to their split heads. Suite, fmt-check, vet, release build and
smoke pass.
Origin-Session: local-abff1c | Claude Code | 1 prompt
Origin-Snapshot: 0e7ed28981cb
@Vasanthdev2004 found this branch still carrying its own older copies of
internal/measurements and internal/memory. Checking the rest of the split for the
same drift found four more packages behind, so the reviewer fixes lived only on
the split branches and merging this would have reintroduced every one of them:
internal/measurements the minute pattern reached past a nearer seconds figure
("took 0.86s (package total 1m20s)" -> 80), inventing a
conflict against a number the model had right
internal/memory the description cap was quadratic (64 KiB -> 13.3s) and
ran only on the write path, so a cloned note was unbounded
internal/tools/memory a list error discarded the notes that WERE readable, and
an unreadable project note masked the user's own local one
internal/tools RecordEdit branched on the raw whole flag while SeenWhole
derived it, so a file read whole in two chunks lost that
status after one edit and write_file refused the overwrite
internal/sandbox AddTemporaryRead took no reference when a TEMPORARY write
root covered the path, so the writer's cleanup revoked a
live reader
internal/agent the incompletion allowance fired on blocked work
Two sweeps were wrong before this one, and both failed in the direction that
reports trouble as fine:
- The first reported all three unflagged splits in sync. zsh does not word-split
an unquoted parameter, so the loop had compared one bogus path and found no
diff. Rerun per file, every one of them was behind.
- The second synced only the files I guessed at, which missed that each split
keeps its regression in a SEPARATE file — scope_temp_refcount_test.go,
edit_preserves_reads_test.go, memory_test.go. The fixes would have arrived
with nothing here to hold them in place.
So the check is now every file each split branch touches, enumerated from the
branches themselves rather than named by hand: all 17 are byte-identical to their
split head. Full suite, fmt-check, vet, release build and smoke pass.
Origin-Session: local-abff1c | Claude Code | 1 prompt
Origin-Snapshot: 0e7ed28981cb
Vasanth asked for this to be measured rather than argued, so it was.
The successNegationTails list lets an absence-establishing sentence through, so
a finder reporting "I could not find where X is set" is not marked incomplete
for doing its job. But the stems it added — "find the", "reproduce ",
"confirm any", "observe any" — also head the most ordinary way of admitting
defeat, and the allowance fired on the tail prefix alone regardless of how the
sentence ended.
Measured against eleven genuine admissions, TEN passed the detector undetected:
"I could not reproduce the crash, so the fix is unverified."
"I could not find the root cause; someone else will need to pick this up."
"I could not locate the source of the regression and have run out of ideas."
That is the guard's whole purpose defeated — it is the last thing between a
stalled run and a report that reads like success.
The allowance now yields when the sentence ALSO says the work is blocked
(blockedWorkMarkers). Re-measured: 3 of 11 still pass, all single-clause
sentences carrying no blocked-work signal at all ("I failed to reproduce it
locally."), and false positives on legitimate findings stayed at zero — the
motivating audit case, "I could NOT find where AllowManifestToolAutoApproval is
set to true in production code", still passes as a finding.
Deliberately not tuned further: the remaining three are genuinely ambiguous
without more context, and fitting the list to them would be over-fitting to the
eleven examples rather than measuring.
Origin-Session: local-de382f | Claude Code | 5 prompts
Origin-Snapshot: 8d8cced3f645
Vasanth's review found this branch carrying OLDER copies of code under active
review elsewhere, so merging it would silently revert those reviews:
internal/pathjail/pathjail.go 135 lines here, 177 on #891's head
internal/memory/memory.go 333 lines here, 539 on #897's head
The memory copy was not merely older. Its name allow-list was
^[a-zA-Z0-9_-]+$, which lets "Findings" and "findings" collide into one file on
Windows and default APFS — the case-collision bug #897 fixed, where a write
silently replaced the other's body and a delete removed a note the caller never
named. The reserved-device-name refusal was absent too, so a note called "con"
or "nul" would make `git add -A` stage nothing and leave the repo
un-checkoutable on Windows.
Those three packages are now taken verbatim from #897's reviewed head (which
contains #891's) rather than kept as a fork. Two consequences had to be handled:
- internal/tools/memory_tool_test.go asserted the OLD destructive behaviour
(TestOmittingContentForgetsTheNote) — a test encoding the very defect #897
fixed, which is what would make a careless resolve look correct. Replaced with
one asserting deletion goes through memory_forget and that a save-shaped call
with no content is refused.
- internal/cli/app.go registered the read and write tools but not the new
memory_forget, which would have left notes undeletable. Registered.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
livePlanProvider fell back to the captured profile on two conditions, a resolve
error and an empty provider name, and neither covers the case that actually
happens on a machine with no config file.
DefaultResolveOptions leaves both config paths empty when the files are absent.
config.Resolve then SUCCEEDS rather than failing, falling back to a built-in
default provider: anthropic, claude-sonnet-4.5. That name is not empty and there
is no error, so both guards pass and the default is treated as a deliberate
switch. The captured profile is discarded.
That profile is the one carrying --provider, --base-url and an inline --api-key,
applied by the caller on top of Resolve. Losing it points discovery at the
default endpoint with none of the caller's credentials, which is this function's
own failure mode arriving from the other direction: it exists because discovery
and child execution disagreed about the provider, and here it manufactures that
disagreement itself. A headless run in a fresh container hits it every time.
So the third condition is the one that was missing: when neither config path nor
a provider command gives resolution anything to read, there is nothing to
re-resolve from and the captured profile stands. Read off the options rather
than by stat-ing again, since DefaultResolveOptions already computed it.
ZERO_PROVIDER is deliberately not part of that test even though it looks like it
belongs. With no config file there are no profiles to select from, so a
ZERO_PROVIDER naming one resolves to ErrNoActiveProvider and the existing error
branch already returns the captured profile. Including it would read as a
meaningful case while changing no outcome, so the comment says why it is absent.
The failing test was already in the tree, written against the intended behaviour;
this makes the code match it. Verified by mutation: disabling the new guard
reproduces the original failure exactly, and the two tests guarding the opposite
directions, following an in-session switch and keeping flag overrides when the
provider did not change, both still pass.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Reported by anandh8x. Both stores checked their own directory and file with
Lstat and then handed those same strings to MkdirAll, CreateTemp, Rename and
Remove. Every one of those re-resolves the ANCESTORS, so a link anywhere above
the store redirected the operation while the checks passed, because the
components they looked at genuinely were not links.
Reproduced before fixing, with .zero as a junction to an outside directory:
SavePlan and memory.Write both landed their bytes outside the workspace, and
memory.Forget deleted a file outside it. The delete is the sharpest of the
three, since a write leaves a file somewhere and a delete removes somebody
else's.
There is a second, independent failure on Windows that the report did not
reach, and it changes what the fix has to be. A junction is a reparse point but
NOT a symlink, so os.Lstat returns ModeIrregular with ModeSymlink clear and the
guard answered "not a link" when pointed straight at one. Applying the same
predicate to every ancestor, which is the obvious repair, would therefore have
passed all of them. Junctions also need no privilege to create, unlike Windows
symlinks, so this was reachable by exactly the unprivileged workspace owner the
guard exists to contain.
So the fix stops naming things by path. New internal/pathjail opens a handle on
a declared root once and addresses everything below it relative to that handle,
which is what os.Root gives us: each component is resolved against an object
that cannot be swapped underneath, and a reparse point is refused rather than
followed. It is the same answer internal/sandbox already uses for ACL
materialization, reused rather than reinvented.
Both stores now carry that root. memory.Paths gains Root and specialist
PlanPaths gains ProjectRoot and UserRoot, all set by their DefaultPaths
constructors from the workspace, and SavePlan takes the root it must stay
inside. The root itself is resolved normally, because it is the boundary the
caller chose and may legitimately sit under a link; only what is below it is
confined.
Reads are confined too, not just writes. A note read through a link is an
exfiltration primitive in a tool the model can call by name, which is the same
hole with the arrow reversed.
Tests: pathjail covers the ancestor link, the junction the old predicate missed,
an absent name still being creatable, targets outside the root, and O_EXCL temp
names. Each store has an end-to-end test that the escape is refused and an
ordinary workspace still round-trips. The link helpers use a junction on Windows
on purpose, since a test written with os.Symlink skips on an ordinary account
and proves nothing there. Verified by mutation: restoring the ModeSymlink-only
predicate fails the junction test, and removing the containment fails the
ancestor tests in all three packages.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The AGENTS panel toggle wrote its preference to disk and then lost it on every
launch. mergeConfig copies FavoriteModels, RecentModels, Recaps and Theme field
by field and never touched keepFinishedAgents, so Resolve always answered false:
app.go passed KeepFinishedAgents:false into tui.Options, the panel seeded
showDoneAgents:false, and finished sub-agents were dropped after the linger
every session. The key sat in the config file doing nothing.
Copied as a pointer like Recaps, so an explicit false stays distinguishable from
an absent key.
The reason this survived is worth recording, because the test that should have
caught it is green and stays green. TestTogglingKeepFinishedAgentsPersists
round-trips through SetKeepFinishedAgents and reloads the file, which proves the
WRITE. The broken half was the read on the next launch, and the only thing on
that path is Resolve, which that test never calls. Its comment claimed the
preference survives a restart, which was not a property anything checked.
So the new coverage sits in internal/config, next to the merge it is about, and
goes through Resolve: an explicit keep, an explicit drop (the case the pointer
exists for), an absent key staying absent so an unconditional assign cannot pass,
and the neighbouring theme and recaps values, which are what localise a future
break to this merge rather than to the unmarshal or the writer. Verified failing
with the fix removed, where the older test still passes.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
detectShellRuntime probes PowerShell by starting it in this process, which is
unrestricted, and the command then runs inside the sandbox, which is not. On
Windows the sandbox wraps commands in a WRITE_RESTRICTED token, and PowerShell
is a .NET program whose crypto initialization fails there:
System.DllNotFoundException: Unable to load DLL 'BCrypt.dll'
or one of its dependencies (0x8007045A)
So PowerShell answers "usable" when asked in the parent, is selected, and is
then unable to run anything at all: exec_command and bash could not execute so
much as `echo` under the sandbox. The probe was asking a different environment
than the one that had to answer.
buildBashCommand is the single execution site for both tools, so it now resolves
the shell through the engine that will wrap the command. When PowerShell cannot
start there, detection falls through to cmd.exe, which does survive the token
and was already the documented fallback -- no new fallback path, just an honest
probe reaching the existing one. The sandboxed answer is cached separately from
the unsandboxed one because the two environments genuinely disagree; sharing one
cache would let whichever question was asked first answer both.
This is the same restricted-token incompatibility class already recorded for
Schannel TLS and MSYS2/Cygwin in internal/sandbox/windows_command_runner_windows.go.
Verified by running the feature on all three platforms against the real binary,
same commit, A/B: Windows 6/7 -> 7/7, macOS 7/7 and Linux 7/7 unchanged. The
added test pins the contract the fix relies on -- that a failed probe reaches
the cmd.exe fallback -- but not the wiring itself, which would need lookPath
injected into buildBashCommand; the cross-platform run is what covers that.
Known gap, not addressed here: the bash/exec_command tool descriptions still
advertise PowerShell syntax, because they are built in NewScopedBashTool, which
receives no engine. Sandboxed Windows now runs cmd.exe while the model is told
otherwise. shellGuidanceForRuntime already contains the correct cmd.exe wording;
reaching it means threading the engine through tool construction.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
#867 landed on main while this branch was being rebased, reshaping
read_file's schema to the canonical path/offset/limit contract and
rewording its description. The golden hashes the tool DEFINITION bytes,
so a description change on main moves it — the same way #838's rewording
did before.
The new value is what the fixture's tools actually hash to on this base,
and it is the value CI computed independently: the macOS and Ubuntu smoke
jobs both reported got ea7b4e64… against the old constant, which is the
figure recorded here now.
This is main's schema change, not a posture leak from this branch. The
byte-identical guarantee is unaffected: what the guard protects is that a
posture-off run's definitions match a binary built from the same main,
and both sides moved together.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Rebasing onto main flattened the two merge commits this branch carried,
and in doing so replayed an older value of postureOffDefinitionsFingerprint
over the current one — reverting it to the pre-#838 hash and dropping the
comment recording why it had moved.
The constant is a frozen golden over the tool DEFINITION bytes, and main's
#838 reworded four tool descriptions (glob, grep, list_directory,
read_file), so the correct value on this base is the one the branch
already had. TestPostureOffToolDefinitionsMatchGolden proves it: with the
reverted constant it fails with got 51ffc34f… want 3693b93a…, and the
'got' is what the fixture's tools actually hash to now.
Restored with its rationale intact, since that comment is the thing that
tells the next reader the move was main's schema change rather than a
posture leak from this branch.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The fix for "cancel must not stop a background plan" over-corrected into
its own opposite. The guard wrapped BOTH cancelRunning calls in
BackgroundPlanLive, and the specialist tracker holds foreground and
background children TOGETHER — so one live background plan left every
FOREGROUND sub-agent spinning forever: still specialistRunning, still
showing its current tool, still counted live in MODELS, over a process
that died with the run context. Nothing else settles it, because a late
completion is dropped by the stale-run guard once cancelRun has zeroed
activeRunID. That is verbatim what TestCancelSettlesEveryRunningAgentAndTask
exists to prevent, and the background test missed it because it had no
foreground child.
Children now carry the distinction themselves. specialistInfo gains a
background marker, set from the two places that can know: a background
plan's task-start message carries the flag, and a specialistRebindMsg is
only ever emitted for a background Task spawn. cancelRunning skips marked
rows and settles the rest. The orchestrate panel keeps its whole-panel
guard, because one plan runs at a time and there is no mixed case there.
The new test is the mixed state neither existing test covered: a
foreground child and a background child under a live background plan,
asserting the first settles and the second does not. The background test
now marks its row the way production does, rather than passing because
nothing settled at all.
specialistInfo's new field is added to specialistCacheFingerprint, as the
render-cache guard test requires — without it a card that changed through
the field would keep serving its previous render.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two regressions the review found in the previous two commits, plus the
hole that made one of them reachable.
THE CAP WENT INERT ON ANY PRICED CATALOGUE. Exempting priced catalogues
from the cut fixed the cost inversion by removing the bound entirely, and
catalogueIsPriced is an ANY test — so one priced model among three
hundred disabled the cut for all three hundred, planModels.topModels
contradicted its own documentation, and the router prompt lost its only
bound (measured 2,194 -> 22,985 characters on a 300-model catalogue, paid
once per plan on the strongest model).
The cut now keeps BOTH ENDS of the ranking instead of exempting anything:
a third from the least-capable end, the rest from the most-capable. That
satisfies every reader of the list at once — the cheap tier reads
eligible[0], the strong tier reads the last, and the router gets a
bounded shortlist that still spans the real range. It also preserves the
unsized models without a special case, since an id whose size cannot be
read sorts at the cheap end.
VERSION TEXT WAS READ AS A PARAMETER COUNT. The suffix boundary was
[^a-z], which a DIGIT satisfies, so "deepseek-r1t2-chimera" matched
"1t" followed by "2" and reported a trillion-parameter model — on a
free provider that would have made it the strong tier every judgement
task was routed to. The b/m suffixes always had the same hole
("r1b2-x" read as 1B); adding t is what made it reachable on a real id,
so the boundary excludes digits for all three rather than for the one
that was reported.
Both mutation-checked, and the boundary now has a test naming the exact
reported id — reverting it alone previously failed nothing.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The ranking sorts an unparseable id as size 0 — the least-capable end —
so taking a plain tail deleted exactly the models whose names do not
state a parameter count. On a real ollama-cloud account that removed
kimi-k2.6, glm-5.2 and deepseek-v4-flash from routing while gpt-oss:20b
survived, and the operator's own router guidance calls the first two the
strongest reasoners on that machine.
Dropping a model because its name is uninformative is a parsing
accident, not a capability judgement — and applyMinSizeFloor in this same
file already follows exactly that fail-open rule. The cut now applies to
SIZED models only: every unknown-size model is kept, and the top N of the
ones that can actually be compared join them, with order preserved so
cheap/balanced/strong still read off the ends.
On the fourteen-model catalogue this was measured against, the shortlist
now drops only phi4:14b — small AND sized — and the cheap tier becomes
deepseek-v4-flash rather than gpt-oss:20b.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
CANCEL CLOBBERED A LIVE BACKGROUND PLAN. cancelRun settled the trackers
unconditionally, with none of the BackgroundPlanLive guard beginRun makes
a few lines above — so stopping a foreground turn marked a background
plan's tasks and sub-agents cancelled while they kept spending tokens and
writing files. The mirror image of the defect the background-status tests
exist to prevent. Foreground children still die with the run context and
are still settled.
A RESUME BLIND-PINNED A STALE SESSION MODEL. runResume applied
session.ModelID with no served-check, so a sub-agent recorded on one
provider and resumed after a switch was spawned with a model that
provider has never heard of and died at spawn — the failure autoTaskModel
already guards against. The recorded model is now REJECTED ON EVIDENCE
rather than requiring proof, the opposite direction to autoTaskModel and
deliberately so: this restores a model the session demonstrably ran on,
so no discoverer, a failed listing or an empty one all leave the recovery
in place.
THE PROVIDER RETRY FIRED ON PERMANENT FAILURES. Exit code 3 means "the
provider failed", but internal/cli returns it for every agent-run error
— an expired key, an unknown model, a quota wall — so an expired key cost
a ten-task plan twenty spawns to produce the same ten auth errors. The
budget is now PLAN-WIDE (two, mutex-guarded because tasks dispatch
concurrently) instead of per-task: a genuine blip is still retried, a
systemic failure costs two extra children for the whole plan.
THE SESSION BUDGET CHARGED FOR RESUMES AND REFUSED CALLS. admit() ran
ahead of prompt validation and ahead of the resume branch, so iterating
on one sub-agent's answer thirty times consumed thirty of the session's
slots while spawning nothing. It now runs on the fresh path only, after
the checks that can refuse the call outright and still before every side
effect.
NON-LATIN LABELS WERE REJECTED AS NON-WORDS. hasNameLetter tested ASCII
ranges, so every CJK, Cyrillic, Greek and Arabic token was dropped and
the sidebar row lost its label — a silent degradation for anyone not
writing in English. unicode.IsLetter/IsDigit is the same question asked
correctly.
DRACULA'S SUCCESS COLOUR COLLIDED WITH ITS BLUE. The recolour set green
to #8be9fd, byte-identical to that palette's blue, collapsing two of the
four series colours modelMixPalette cycles through so two models in the
MODELS bar could render indistinguishably. Success moves to a teal in the
same cool family; the registry-wide contrast tests still pass.
Also drops TestZeromaxingSkinPreview, a visual preview with no assertion
that could not fail.
Each fix is mutation-checked: gutting any one fails its own test.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two more defects in the auto-appended verification task, both of the same
shape: a task the author never wrote changing the outcome of the plan
they did write.
A THIRD BUDGET CEILING. The headroom check covered
refuseImplausibleBudget (max_tokens vs 50,000 per task) but not
refuseUnreachablePerTaskCap (max_tokens vs max_tokens_per_task x
taskCount), so the extra task raised that bar by one whole per-task cap.
A 4-task plan with max_tokens 400,000 and max_tokens_per_task 100,000
parsed fine and was then refused outright — naming a task count and a
budget its author never chose. Any plan whose per-task cap exceeds the
50,000 floor falls in that window, so the floor check alone never
covered it.
THE VERIFIER'S FAILURE FAILED THE WHOLE PLAN. It runs last, on the
strongest tier, with the largest dependency briefing — the task most
likely to stall or exhaust its provider retry — and terminalStatus
counted it like any other, so three succeeded author tasks plus one
stalled verifier returned StatusError and the orchestrating model re-ran
a plan whose work was already done. When the appended task is the ONLY
thing that did not succeed, the call now stays OK and says plainly that
the claims were not independently verified and that re-running would
repeat completed work. The report stays honest either way: Failed still
counts it and the summary still names it; only the verdict on the
author's plan is left to the author's tasks. appendVerifyStage returns
the id actually used, so a suffixed id cannot match the wrong task.
Both mutation-checked.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two defects a high-effort review found in the top-ranked shortlist.
THE SHORTLIST INVERTED COST ON PRICED PROVIDERS. The ranked list is
sorted least-to-most capable, but "capable" is measured by SIZE on a
free provider and by COST on a priced one — so taking the tail meant
"keep the biggest" in the first case and "keep the DEAREST" in the
second. Measured on a 12-model priced fixture: the cheap tier moved from
cost 1 to cost 3, and on a real 30-model account every scan-role task in
every auto-assigned plan silently moved an order of magnitude up in
price, reported only as "scan -> <model>".
A priced catalogue is now never narrowed. The asymmetry is the point: on
a free provider the small models cost nothing and buy nothing, so
dropping them is pure gain; on a priced one the cheap end is exactly what
the cheap tier exists to spend. catalogueIsPriced is extracted as one
authority so the ranking and the shortlist cannot disagree about what a
free provider is.
A TRILLION-PARAMETER MODEL PARSED AS SIZE ZERO. modelSizeToken accepted
only the b/m suffixes, so "kimi-k2:1t" read as unknown and sorted BELOW
gpt-oss:20b on a free provider — the largest model on the account became
the cheap tier every scan task was routed to, and the first thing the
shortlist discarded. The suffix class now covers t.
Both mutation-checked: re-narrowing a priced catalogue, and dropping the
t suffix, each fail their own test.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A measured ten-task run lost one task to a bare "Internal Server Error"
after 6 tool calls and 42,524 tokens, and everything depending on it went
with it. Nothing about the task produced that.
Two layers each declined to retry, and only one of them was right to.
The transport does not replay a 500/502/504 on purpose: unlike 429/503/
529 they do not guarantee the request had no effect, so replaying the
same POST could pay for the same completion twice
(providerio.ShouldRetryStatus). That is correct for a replay. The plan's
own loop then returned on "anything other than a stall is an ANSWER,
including a failure" — true of a task that read the code and concluded
wrongly, which will conclude the same thing, and not true of an HTTP 500,
which is a coin flip. A fresh child is not that replay; it is a new
request the provider is very likely to answer.
So the loop grows a third carve-out beside the two that already exist for
"not the task's fault" (ModelRejected, Declined): one more attempt when
the child died on the provider, recognised from its exit code the way
Declined is — a flag, never a message match.
READ-ONLY TASKS ONLY, and that bound is the whole safety argument. A
write task may have applied part of its change before the provider died
and no exit code says how far it got, so re-running it could apply that
change twice. A read-only task re-reads, which costs tokens and nothing
else — the same asymmetry the no-tool-call check rests on.
Bounded at exactly one by its own flag (a provider failing twice is
having an outage), gated by the same cancellation and wall-clock stops as
every other retry here, and NOT gated by max_retries, which bounds stall
thrashing rather than this. Both attempts' spend is reported.
Five mutations, five tests: removing the branch, dropping the write-task
gate, dropping the one-retry bound, never setting the flag from the exit
code, and drifting the duplicated constant each fail. The flag test is
separate on purpose — gutting the classification left the four executor
tests green, which is the layer-join defect this package keeps
relearning, so one test drives the real runner. The constant is now
pinned on BOTH sides; pinning only cli's left specialist's free to move
silently.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Ranking spanned everything the provider serves, so buildModelTiers read
the cheap tier off the SMALLEST model on the account and the router was
handed every candidate to choose between. On a provider listing twenty
models that is a toy running a scan while nineteen better ones sit
unused — the reason 20b-class models kept turning up on tasks that had
a 120b available.
rankedEligibleModels now keeps the most capable N (default 10) after
exclusions and the size floor, so the tiers span the good models rather
than the full catalogue and the router chooses from a shortlist worth
choosing from. Configurable as planModels.topModels for anyone whose
provider warrants a wider or narrower field.
THE TAIL, NOT THE HEAD: the list is sorted least-to-most capable, so the
shortlist is its end — taking the front would keep exactly the toys this
drops. Fail-open throughout: a provider offering fewer than the cap keeps
all of them, and a zero or negative cap means the default rather than
none, because a zero value must never leave a plan with nothing to
assign.
A PIN IS UNAFFECTED. Pins are validated against what the provider serves,
never against this list, so naming a model outside the top N still routes
to it — an explicit instruction outranks a heuristic. Tested, along with
the fail-open cases; gutting the shortlist, taking the head instead of
the tail, or reading a zero cap as none each fails its own test.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan fans out, every task reports, and the report carries what they
said — under zeromaxing that is five or more sub-agents' claims, each
written by a model that cannot see the others' work and will never be
asked about it again. The bundled research plan ends in a refute step
for exactly this reason and planTaskSystemPrompt's evidence rules exist
to make one possible, but both are habits: a plan whose author forgot a
verifier finished green with nothing checked, and a wrong "looks fine"
is indistinguishable from a right one once it reaches the report.
Under the posture, a multi-task plan that names no verification now gets
one appended — a task depending on every other, told to REFUTE and to
default to refuted when uncertain, because a verifier that sets out to
agree always does. It classifies as verify, so the user's verify pin
routes it to their strongest model, and it works on the ARGS before
ParsePlan, so it is validated by the same constructor as a hand-written
task, round-trips into a saved plan, and re-admits on resume.
THE APPEND MUST NEVER BE THE REASON A PLAN IS REFUSED, and two ceilings
can do that. The size tier caps task COUNT. The budget floor is the one
the existing suite caught: refuseImplausibleBudget requires max_tokens
to fund 50,000 per task, so one more task raises the bar by 50,000 and a
plan whose budget exactly funded its own tasks was rejected outright —
for a task its author never wrote. Both have a guard and a test.
It also names NO tools: planToolGrant then resolves the read-only
intersection of the parent's grant, so it can neither modify anything
nor flip the plan into worktree isolation (isolation is plan-level, the
measured failure plan_workspace_reach.go exists to catch). It defers
entirely to a plan that already verifies, moves its id aside on a
collision, leaves a malformed task list to ParsePlan, and reports itself
in the run's output so a task nobody wrote never appears unexplained.
Each guard is mutation-checked: gutting the posture gate, either ceiling,
the already-verifies check or the id probe fails its own test.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
NTFS ignores trailing dots, so Lstat("…/streamer.go.") succeeds for the
file named "…/streamer.go" — the exact-hit branch returned the
sentence's full stop as part of the path, and the reach check compared
the wrong spelling. When the trimmed spelling names the SAME file, the
canonical name wins; a POSIX file genuinely named with a trailing dot
keeps its exact spelling, which the new cases pin (dotted-only, distinct
dotted sibling, and the plain trim), with the unconditional-rewrite
mutation killed by them.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
make deadcode (-test=false) flags production functions whose only
callers are tests: ExecutePlan, withDependencyBriefing and
autoAssignModels in specialist, markDone and joinColumns in tui. Each
was a deliberate convenience seam with documented rationale; they keep
that rationale and move to export_test.go, the repo's home for test-only
helpers, so the production binary carries none of them. SortedPlanSizeNames
had no callers anywhere and is deleted.
Also clears the three staticcheck findings the advisory lint reported in
PR-owned code: a tagged switch in plan_model_assign, time.Since in a
test, and a self-comparison rewritten as two named renders.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
absolutePathPattern only matched POSIX-style paths, so on Windows the
reach check silently never fired: prompts naming C:\ paths contributed
no candidates, and four tests showed it. The pattern now takes both
separators and an optional drive letter, plus '~' for 8.3 short names
(RUNNER~1), which real CI paths contain. pathInsideRoot keyed on the
single platform separator, answering false for every /-separated pair on
Windows; both separators are boundaries on every platform now.
Also makes the completed-session retention test cross-platform (cmd /c
on Windows) instead of assuming /bin/sh — the behaviour under test is
OS-agnostic, so it runs everywhere rather than skipping.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A saved plan's prompts are model-authored text read back from disk — the
same trust level as a live task's — and planTaskSummaryLine only cut at
the first newline, so control characters (ESC included, the byte that
starts an ANSI sequence) reached the display. It now routes through
sanitizeCardText, the chokepoint the live path already uses.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Entering the posture through /effort zeromaxing is guarded by the
idle-session rule; leaving it through /effort auto reached
revertExecProfile — moving the turn budget, the self-correct setting and
the shared orchestrate gate — with no guard at all, leaving one turn
running under two different budgets. The same rule now guards both
doors. Plain /effort auto outside the posture keeps working mid-run: it
only clears the effort selection, which mutates no shared budget.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The exec wiring handed children execScope.Roots() — the parent workspace
root included — as extra write roots. For a worktree-isolated plan that
defeats the isolation outright: the worktree exists so a write-capable
plan cannot touch the parent tree, and this line was handing the parent
tree back as a writable --add-dir. The TUI has always passed only
scope.ExtraRoots; the two surfaces now agree, through a named helper the
regression test drives with a real sandbox scope.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The status card stacked three renderers — a labelled list, the resolved
posture line, and the delta sentence — so the profile name appeared
twice and the effort and turn budget three times each. It is now a
single sentence of key-value clauses: each fact once, the transitions
as clauses on the row they qualify ("was 80", "raised by the posture",
"NOT raised — high is unsupported on this model"), no section header
and no hint advertising the command the user just typed.
Every honesty guarantee the old card's tests enforced survives on the
one line: the refused effort level is named, the self-correct claim
tracks live state ("your /selfcorrect off overrides the posture"),
exactly one effort-transition claim exists, and the escalation row keeps
the headless-only asymmetry. /effort still carries the shared delta
sentence verbatim, so the switch notice and that surface cannot
disagree. Also learned and recorded: compactCommandOutputText collapses
every whitespace run, so padded column alignment can never survive this
renderer.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The stock #50fa7b green and #ff5555 red are the highest-chroma signals
in the whole registry, and in a zeromaxing session — success ticks and
error rows arriving constantly — they read as alarms, not status. The
success and failure roles now wear Dracula's signature cyan and pink,
which sit naturally beside the purple accent and stay clear of amber's
permission meaning. The palette token names stay green/red (every
consumer binds to them as success/failure); diff colours keep their
conventional hues — a diff is a diff on every theme. The registry-wide
contrast floors still hold.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
While the posture is on, the workspace wears it; off, every surface is
byte-identical to before (each gate mutation-checked).
MODELS panel: the sidebar grows a section showing the live mix of models
the fleet runs on — a proportional mix bar plus a row per model with
live counts, colours stable per model name. Absent until an agent runs
on a known model, so a plain session's layout is untouched.
The skin: an electric gradient (theme blue blended into the accent —
deliberately two-tone) paints the always-on surfaces: the composer box
on all four edges, the chat|sidebar divider as a full-height rail, the
sidebar section headers, and the Working ripple. The gradient sweep is
mirrored, so no seam ever reads as "half painted". No new timers:
every walk reads the spinner clock that already runs during a turn, and
reduced motion pins all of it.
State-aware animation: thinking drifts, writing races (4x), and
orchestrating gets a scanner — a bright band sweeping the composer bar
while plan tasks are actually running. The Working ripple's wavelength
follows the same states.
Plan bars join the skin: done work fills with the gradient, the running
head burns accent, failure stays findable in calm dimmed red, and the
update_plan checklist gets a progress bar of its own (the orchestrate
bar's renderer, so the two cannot drift). Click offsets for plan steps
account for the inserted bar row, test-pinned against the rendered
sidebar.
Settled cancels: cancelling the run kills the children but the trackers
were never told — agents kept their spinner, ticking clock and "live"
mark in MODELS beside "Run cancelled." in the transcript. cancelRun now
marks running specialists and orchestrate tasks cancelled (not failed —
stopping a run is not a defect), freezing their clocks and dropping the
live counts.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A bare Task delegation inherited the parent's model unconditionally, so
under zeromaxing every sub-agent ran on the session model no matter what
it was for. A Task that names no model is now routed to the configured
role pin — the plan path's own classifier and pins, the grant outranking
the prose — gated on the posture AND the autoAssign config, so a
posture-off spawn stays byte-identical.
Four defects found and closed while verifying the seam end to end:
- A resumed task drifted back to the parent's model. runResume applied
neither the call's model nor the assigned one; it now recovers the
model the session actually ran on from the session store, with an
explicit model on the resume call winning exactly as on a fresh
launch.
- A plan task could be second-guessed at dispatch. Plan tasks flow
through the same executor, and a model the plan tool deliberately left
empty (a reported decision, or auto_assign:false) would have been
re-assigned blind — including to a pin the plan level had passed over
as not served. autoTaskModel now refuses plan-authored manifests,
keyed on the provenance the manifest already carries.
- Naming an uncurated model killed the spawn. applyTaskModel forwarded
the posture's raised reasoning effort unconditionally; the child only
clamps efforts for models the registry knows, so providers that reject
the parameter refused the whole request — a real orchestrator lost
three children to it and retreated to inherit-everything. The effort
now forwards only where the registry can vouch, the same gate the plan
path already applies.
- A stale pin after a provider switch killed the spawn. Pins named
Ollama models while the session ran on xai, and three children died
with not-found. A pin now fires only when the provider's own listing
carries both the pin and the session's model (the plan path's
provider-mismatch guard); anything uncertain inherits. One listing is
cached for two minutes, so a five-agent fan-out costs one probe.
Every load-bearing line is mutation-checked: the pin lookup, the
manifest-model guard, the posture gate, the session-model recovery, the
explicit-resume-model apply, the plan-provenance skip, the effort vouch
gate and the served-set guards each have a test that goes red when the
line is gutted.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Review feedback: Done() and Remaining() were only ever called by their own
tests — the resume path narrows through RemainingPlan and never touched either,
and the comments falsely claimed orchestrate_saved consulted Done() first.
They are also unsafe to wire in as suggested: Done() is id-based, so with the
identity-aware resume a plan whose ids all succeeded but whose task was edited
would be reported "done" and its re-run silently skipped. Removed both; the one
test that asserted "a failed task is remaining" now asserts it through the real
gate (RemainingPlan) instead of the dropped helper.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Independent improvements to the plan/orchestrate subsystem, built and verified
together on this branch. All are posture-gated: the posture-off cold-start
request stays byte-identical.
- Identity-aware resume. A plan resumes from where it stopped; a completed task's
bounded output survives into the tasks that depended on it; and an edited task
(plus everything downstream) re-runs instead of replaying a stale result.
Bare "/plans resume" continues the last cancelled run.
- Read-grant propagation to sub-agents. A plan's tasks inherit the parent's
request_permissions READ grants through a new --add-read-dir flag applied with
scope.AddRead, so a read-only audit of a granted external path no longer fails
"outside the workspace". A read grant is never emitted on the write channel, so
it can never become writable in the child.
- Size-aware per-task model routing. Free providers (no prices) tier by the model
size parsed from the id instead of falling through to alphabetical; the router
is shown explicit size labels; a configurable plan_models.min_size floor drops
models known to be too small; and `providers models` shows the classification.
- Plan-run observability. Per-worker token and model display, a worker view
folded from the accounting events, background-agent completion/status, and
durable per-task numbers that survive resume.
Every production change is covered by a regression test; mutation-checked.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
BuildFinalResult prefixes "session_id: <id>" so a Task caller can continue the
child it started. A plan task has no such caller: the plan owns its children's
lifetimes, and TaskResult.SessionID already carries the id structurally.
Its output, though, is quoted into the report under "result:", pasted into the
dependency briefing every downstream task reads, and rendered in the plan
panel — so the line surfaced a raw child session id in the middle of a
user-facing answer three times over, and told every dependent task to treat it
as a finding.
Removed at the plan boundary, keyed on the id we were handed rather than on
the pattern: the first line goes only when it is exactly the prefix followed
by that id, so a child whose own prose opens the same way comes through whole.
The Task path keeps the line, which is what it exists for.
Asserted at the caller as well as the helper — a real task through the real
executor seam — because a helper that works while nothing calls it is the
defect this repo keeps finding.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A completed two-task plan drew
AGENTS 2 done
no agents spawned
sidebarSpecialists drops a finished agent once it is past its linger, so the
section had no lines to render; doneAgentCount counts exactly those agents, so
the header reported two. The header and the body were describing the same run
and disagreeing about it.
The count is the true one — two agents ran. The placeholder was reporting the
emptiness of a filtered list as the emptiness of the session, so it now says
what is actually there and names the toggle that brings them back. "no agents
spawned" survives for the case where it is true.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The hit test scanned every row footerView renders and took the first one
containing the label. But footerView is not only chips: it renders the plan
panel, the idle hints, a queued-message preview and the composer.
So typing "zeromaxing" into the composer put a matching row above the chip's,
and the hover target became the composer — a click meant for the chip landed
on text the user was still writing, and the chip itself stopped responding. A
plan task prompt carrying the word did the same through the panel.
The chip renders in statusLine and nowhere else, so the search is narrowed to
the rows the status line occupies: rendered separately and taken off the end
of the footer, which is where statusLine writes on every branch.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A manual run emitted budget.max_tokens_per_task: 5000. Admission refused it
for sitting below minimumPlausibleTaskTokens, correctly — and the model spent
a turn and a call discovering a rule that was written down only in prose, in a
description it had already read.
Every bound here was already enforced; none was declared. A schema minimum is
checked by the provider before the request is made, so the invalid call is
never emitted rather than being caught and retried.
Each bound mirrors its enforcement constant rather than repeating the literal,
because two spellings of one limit drift. A test pairs them and admits the
declared floor while refusing one below it: a declared bound that does not
match enforcement is worse than none, since it teaches the model a rule the
code does not hold.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
research.json read "Find where THE SUBJECT is DEFINED" — prose describing a
hole rather than a parameter declaring one, so nothing refused it and nothing
filled it. Run from the documented path it dispatched five child agents to
search the repository for the literal words "THE SUBJECT".
A stand-in that is not a ${parameter} is invisible to expandPlanParams, which
is the one thing in this package that would otherwise have caught it. So the
plan now declares ${subject}, and a test asserts no builtin plan carries a
prose stand-in — the next bundled plan gets the guarantee without anyone
remembering to ask for it.
That alone would have made the plan unrunnable from the path that names it:
/plans run took a name and nothing else. It now takes the words after the
name, and fails closed in both directions — a plan declaring a parameter will
not run without one, and words handed to a plan declaring none are refused
rather than dropped. A single parameter is resolved by the command, since
everything after the name is the value; several are named to the model so it
fills those and invents none.
Refused BEFORE the turn is dispatched. expandPlanParams refuses at admission
too, but only after a model call has composed the plan and the run has begun.
For a five-task plan the difference is five sub-agent runs.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Separated from the code it wires because every capability in the previous commit
is inert without this, and because app.go touches all four - so folding it in
would have made three of the four commits unbuildable on their own. Bisecting a
branch whose middle does not compile is worse than one commit that does two
things.
ExtraRoots replaces Roots for a child's write roots, so an isolated plan task
stops being handed the parent's tree back. One SessionBudget for the whole
session, shared by every run and plan in it - a per-run counter would reset on
each message and bound nothing a conversation does over time. The keyword gate
and the memory tools are read from resolved config, so both stay off unless
asked for.
The memory tools are registered HERE rather than in the core tool set on
purpose: adding them there changes the advertised tool set for every run, which
is what this branch guarantees the posture does not do when it is off. Measured
by watching the posture-off request body diverge, and reverted.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Each is inert until the wiring commit that follows, so this one adds no
behaviour on its own.
SCOPE.EXTRAROOTS. A write-capable plan runs in a worktree so it has "somewhere
to write that is not the user's tree" - one branch, one diff, a discard path.
Scope.Roots returns the workspace root AND anything granted beyond it, which is
right for "everything this run may write" and wrong for a field documented as
"the directories THIS RUN may reach outside its workspace". Wiring the first
where the second was meant handed an isolated child the parent's tree straight
back. ExtraRoots answers the second question.
NOT the fix I first proposed: workspaceWriteAutoAllowed looked like the culprit,
but an outside-workspace path is blocked earlier and that branch was never
reached - the path was not outside. The sandbox was doing its job with the roots
it was given.
SESSIONBUDGET. Depth caps nesting, maxPlanWorkers caps concurrency, a session
shows one plan at a time, and a run is bounded by spend. All bound ONE run or
ONE plan; a conversation that keeps starting more work was bounded by nothing.
200, because across 75 recorded sessions the most any one started was 22. A
COUNT, NOT A COST - the failure that shaped the token budget was one run
spending 35.8M tokens, which a count would not have seen. Counted on admission
and never decremented: the question is how much work this conversation set
going, not how much runs now.
REQUIRE-PLAN-KEYWORD. Once the posture is on, orchestrate exists for the rest of
the session, and everything the model reads shares context with the user's
instructions. An imperative sentence in a file, a PR comment or MCP output reads
like an instruction to the tool that spends the most. So a session may require
that a plan was asked for in the user's OWN words, checked at admission rather
than requested in the prompt - a prompt rule is advice to the same model the
injected text is talking to. That needed the turn's raw user text to reach a
tool, which nothing carried; RunOptions.UserMessage does now. An empty message
is NOT consent, or the gate would be off in exactly the headless and scheduled
paths most exposed to untrusted payloads.
MEMORY. A session ends and what it worked out goes with it. internal/memory
reuses plan_store's answers verbatim - an allow-list name that cannot spell a
traversal component, symlink refusal on directory and file, an O_EXCL temp file
renamed into place - because a note store is a write primitive pointed at a path
the model chooses, the same shape as "save my plan". Reading is free; writing
prompts, because a note is read back and believed in every future session. Local
scope by default. Plan tasks reach NEITHER tool: a stale note could steer a
whole fan-out, and twenty tasks racing to describe one finding is the update_plan
problem again.
Both new config flags default off, which is what keeps a build carrying this
byte-identical to one without it. A project may ENABLE the keyword gate - asking
to be handled carefully is not a privilege escalation - but never switch it off.
A project may not enable memory at all: memory_write writes into the workspace
and project config is not trust-gated.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two constants contradicted each other:
maxEmptyPollYield = 5 * time.Minute // a poll may wait this long
defaultCompletedRetention = 30 * time.Second // ...but we forget after this
Zero prints "Use write_stdin with session_id N ... to poll", accepts a
five-minute poll, and forgot the answer after thirty seconds. In a measured run
a 60-second test was started, polled with yield_time_ms 40000, and the id was
already gone - so the SAME 60-second test ran again to recover a result the
first had produced. The reply told the caller "do not guess or probe session
ids" about an id this manager had issued and instructed it to poll.
Retention is now DERIVED from the poll bound rather than chosen beside it, and a
test pins the relationship rather than the number, so moving the poll bound
cannot silently reopen the gap.
Past that window a finished session is still remembered - id, command, exit code
and the output TAIL, which is where a result, a failure and an exit summary
live. A late poll gets that instead of an error. An id this manager never issued
still returns ErrProcessNotFound, so a real probe is refused and
UnknownExecSessionError stays id-invariant for the repeated-failure guard that
keys on it.
Recorded when the process exits, not only on the polling paths. The common case
is that NOBODY polled while it ran - the reason to background a command is to go
and do something else - and that is the case that was measured. It is also the
case my first six tests did not cover: deleting that capture left them all
passing, which is why a real-process test for it exists now.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two things a plan run lost, both found in one measured plan.
A TASK'S OWN COMMANDS ARE THE CHECK ON ITS OWN NUMBERS. The parent's
measurement tripwire compares the parent's answer against the parent's tool
output; a plan task's commands run in the CHILD's session, so a figure a task
invents was caught by neither. The child's tool results do stream to the parent,
which is what makes the check possible at all: each task now keeps a ledger of
what its own commands printed, and its answer is checked against it. A conflict
is REPORTED, not retried - the parent's version sends an answer back for another
turn, and doing that here would spend a whole task to re-ask a question the
report can simply carry the answer to. It lands above the fold because it is the
one finding a reader cannot check for themselves: the command ran inside a
child's session and its output is not in the report.
A CUT-SHORT TASK MUST HAND SOMETHING ON. withDependencyBriefing already carries
a cancelled task's output to its dependents labelled INCOMPLETE, so the
machinery for a follow-up to pick the work up existed - and was fed nothing. A
task killed mid-edit-loop has written no prose, so Output was empty: a measured
run lost 858,231 tokens of real work with no record of what it had touched.
The handoff is built from the child's own ChangedFiles tool results, not from a
model call. By the time the meter trips the child's process is already gone, so
a summary turn would mean resuming a task that just overspent - paying more for
the report than the report is worth. The file list is free, exact, and cannot be
invented: a file is on it because a tool reported changing it. Prose the task
did write is kept and the list appended, never the reverse.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The posture's evidence contract rides the turn loop's reminders, and a plan
task runs as a child process that BuildArgs never hands --exec-profile - so a
task starts with the posture OFF and the contract never reaches it. The tasks
that fan work out are exactly the ones whose claims land in the plan's report
unchecked.
Most of the substance was already in this prompt: claims backed by what was
read, quoted file:line, an honest "not found" preferred to a plausible guess.
Two rules were not, and both are ones a task can violate straight into the
report a reader will trust:
A passing test is not proof that a property holds. A measured submission
scored a property top marks in five places on the strength of "the test
passes" - while the test consumed the lazy iterator it was meant to prove
lazy, discarded the value whose absence was the bug, and reported avg x 2.5
as a p99.
A number must come from a command actually run. The same submission reported
a table of timings no command in the session produced, with the same test
reading 0.86s in one paste and 4.20s in the next.
Stated in this file rather than shared with agent.ZeromaxingEvidenceNotice
because this package CANNOT import agent - agent's own test binary imports
specialist, so the reference would compile and then break `go test
./internal/agent` with a cycle, the same trap PostureReasoningEffort exists to
avoid. The tests pin the RULES rather than the wording, so the text stays free
to change and a deletion still fails.
NOT FIXED HERE, and worth naming: the measurement tripwire still has a blind
spot for plan work. It runs in the parent's loop against the parent's own tool
output, and a plan task's commands run in the child's session - so a number a
task invents is caught by neither. Closing that means either a ledger inside
each child or feeding the child's streamed tool results into the parent's, and
both cross the agent/specialist boundary this comment is about.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
320 turns was cutting real work short. A measured run spent every one of them
on legitimate sequential edit-verify work and was forced into a summary before
it could write up what it had found - and the check that this was not simply
wasted turns is in the data: of 49 turns that looked batchable, 43 were repeat
reads of the SAME file, where the next call's arguments depend on the previous
result. The realistic saving from perfect batching was 4 turns out of 321.
So the ceiling moves to 480, which is triple thorough's rather than double.
The multiple stays exact so the relationship remains something a reader can
check, and the doc comment moves with it.
NOT HIGHER, and the reason is the token curve rather than caution. In that run
the prompt grew monotonically from 35k to 186k tokens per turn and had not
flattened, so turns near the end cost five times what early ones did. 640 would
land near 360k per turn and push the run into compaction - which is where
"paste the actual terminal output" quietly becomes a summary of the output,
the exact failure the evidence contract exists to prevent.
AND THE TWO MOVE TOGETHER. Raising the turn ceiling alone would double the
worst case while still ending runs at an arbitrary place: that run spent
35,781,390 tokens reaching 320, and 480 on the same growing prompt reaches well
over 100M. Profile.MaxTokens bounds spend, and only the posture sets it -
every other profile stays unbounded, so nothing outside it gains a ceiling
nobody chose.
50M is anchored on measurement, not preference: the largest observed legitimate
run spent 35.8M and needed roughly 45M to finish, so this lets that work
complete while stopping a run that has stopped making progress. TWO RUNS IS A
THIN BASIS - the mechanism is what matters, and the number is the part most
likely to be wrong.
Deliberately NOT exported to the environment the way the turn budget is: a
child inheriting the parent's ceiling would be handed the whole budget each, so
a ten-task plan could spend ten times it. The parent's bound is the parent's.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
MaxTurns is a proxy for cost that does not track cost. A measured heavy run
reached its 320-turn limit having spent 35,781,390 tokens; a cheap run reaches
the same limit at roughly a tenth of that. The turn count bounds round trips.
Nothing bounded spend.
Options.MaxTokens does. Zero is unbounded, which is every caller that does not
set it, so nothing existing moves. Spend accumulates from what the provider
actually reported rather than from an estimate, at the same point the compaction
estimator is calibrated - past error recovery and past any reactive compaction
that re-sent the request, so it counts the exchange that happened rather than
one that was abandoned.
Checked at the TOP of a turn, for the same reason the turn count is: the
previous turn's tool calls have run and their results are in hand. Stopping
mid-turn would discard work the run has already paid for, which is pinned by a
test.
Crossing it takes the same ending MaxTurns takes - one final call asking for a
summary - so a budget expiry produces a written report of what was done instead
of a run that simply stops. Reusing that path rather than inventing a second
ending also means there is one place where a run can end early.
WHICH BOUND FIRED NOW REACHES THE READER. A turn-count stop still says
"max-turns limit"; a spend stop says "token budget", in IncompleteReason and in
the prompt the model is given. Reporting the first for the second is how someone
comes to raise a turn limit that was never what ran out - which is exactly what
happened when the 320-turn stop was read as a turn problem.
This is the mechanism only. Nothing sets MaxTokens yet, so the posture-off
request body stays byte-identical; wiring the profile's budget and choosing its
value is a separate change against a more delicate path.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A saved plan is stored args re-admitted through ParsePlan, which makes it
exactly repeatable and completely fixed. "Audit internal/tui" cannot be run
against internal/cli without opening the file and editing the prompt of every
task, so in practice a plan gets copied per target and the copies drift from
the one that was reviewed.
Task prompts may now contain ${name}, and `params` fills them in.
WHAT MAY BE SUBSTITUTED IS DELIBERATELY NARROW: prompts and the plan
description. Not `tools`, which is the authority grant; not `id` or
`depends_on`, which are the graph; not `model` or the budget. A parameter that
could reach the grant would turn "run the sweep plan with scope=x" into a way
to widen what the plan may do, and one that could reach an id would let a
single argument silently detach a dependency edge. Those are fixed when the
plan is saved and reviewed, and they stay fixed.
Expansion happens BEFORE ParsePlan, so the plan that runs is the plan that was
validated - same tool-grant narrowing, depth cap and budget checks - rather
than a template of it.
It fails closed in both directions. A placeholder with no value would leave a
literal "${scope}" in a prompt, which reads to the model as a directory that
does not exist. A value matching no placeholder is almost always a typo, and
ignoring it runs the plan against the wrong target while reporting success.
Both are refused, naming the offender and what the plan actually takes.
The stored plan is never mutated: LoadPlans caches by path, so rewriting it
would leave the next run carrying the previous run's arguments. A plan with no
placeholders is untouched, so every plan saved before this is inert to it.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Family-specific prompt guidance was selected by matching the model id against
a list of prefixes - gpt, o1, o3, o4, gemini, claude - and nothing else. Of
the providers in the catalog, only a handful ship a default model that
matches any of them. The rest, including the recommended default and every
gateway a user is most likely to reach for, matched nothing and silently
received no guidance at all. Nobody decided that; the switch simply fell
through.
OpenAI's own OAuth entry was correct for one reason: "gpt-5.5" happens to
begin with "gpt". OpenAI has already shipped o1, o3 and o4, each of which
needed a new arm added here to keep a first-party provider working, and the
next id is under no obligation to help. The provider knows the answer without
being asked twice.
So the catalog carries the fact. Descriptor.ModelFamily is set ONLY where a
provider is first-party and single-family - openai, chatgpt, chatgpt-proxy,
anthropic, google - and left empty everywhere else, which is the honest
answer for a gateway: Ollama Cloud speaks openai-compatible and serves Qwen,
and reading the wire format as a family would hand GPT-shaped guidance to a
model that never asked for it. Empty means "ask the model id", and the id is
genuinely the only thing that separates openai/gpt-4.1 from z-ai/glm-4.6 on
one endpoint.
Options.ModelFamily carries it to prompt assembly, which prefers it over the
guess. In the TUI it is read from the LIVE profile inside the per-run
assembly rather than resolved once at startup - /model can switch provider
mid-session, and a family fixed at launch would keep describing the provider
the session began on, which is the staleness that had plan discovery
assigning from the wrong provider's model list.
What this does NOT do: the providers that declare nothing still receive no
addendum. Whether GPT-shaped rules help Qwen or GLM is untested, and
inventing guidance on a guess is the failure this replaces rather than a fix
for it. The difference is that the silence is now a declared position, and a
family can be added in one line when there is evidence for it.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
TestConcurrentSavesOfOnePlanDoNotCorruptIt required all eight concurrent
saves to succeed. SavePlan publishes with os.Rename, which on Windows is
MoveFileEx with REPLACE_EXISTING, and concurrent replaces of one destination
can come back as a sharing violation there. That is a platform property no
other OS can verify, so requiring it was asserting something unchecked - and
it would have surfaced as an intermittent failure on the one CI job that
cannot be reproduced locally, in the same change that exists to fix that job.
The property the test is for is unchanged and still enforced everywhere: no
corruption, no temp file left behind, and the saved plan still parses. What
is relaxed is only the claim that nobody loses the race, and only on Windows,
where a lost rename is logged rather than failed.
Deliberately not adding a retry to SavePlan: writing Windows-specific
recovery that cannot be exercised from here is worse than not writing it.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A run halted after six consecutive edit_file refusals and lost its work. The
cause was its own successful edit: RecordHash drops every recorded read range
when the content hash moves, and edit_file called it on the way out. That is
right for a change we did not make - we cannot say which lines still hold
what was read. After our own edit we can say exactly.
A 371-line file had been read in three pieces (40-45, 85-260, 260-371). One
two-line edit at line 92 erased the credit for all three, so the next six
edits - into regions that HAD been read, that the edit never touched, in a
file whose line count had not changed - were each refused as content "not
read in this session", and the repeated-failure guard stopped the run. The
error told the model to re-read, which its next successful edit would have
undone again.
RecordEdit computes the changed span from a common prefix and suffix of the
before and after content, then keeps what the edit provably did not disturb:
ranges before the first changed line at the same numbers, ranges after the
last changed line shifted by the delta, and ranges SPANNING the edit split
around it. That last case is the one that matters and the one I got wrong
first - dropping on overlap left the defect exactly in place, because a read
almost always contains the line it is about to edit, containing it being why
it was read. Byte intervals get the same treatment.
The guard is unchanged in what it guards: content never read is still
refused, and a file changed outside Zero still invalidates everything, both
pinned by their own tests. An external edit through sed remains a legitimate
refusal - that half of the original cascade was correct.
Verified against the observed run: seven consecutive edits across a
partially-read file now succeed where the second used to fail, and both wrong
implementations fail the tests.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Three failure classes from a measured benchmark run, each answered where the
harness can hold the line rather than where a prompt asks nicely.
A property was scored top marks in five places on the strength of "the test
passes" - while the test consumed the lazy iterator it was meant to prove
lazy, discarded the value whose absence was the bug, and reported avg x 2.5
as a p99. Told its code had a defect, the run agreed; the claim was wrong,
and it revised a correct answer down with a proof contradicting its own
preceding step. So the posture now states what counts as evidence, on the
first turn of every run: name what a passing test would still pass with,
report only numbers this session produced, and refute or confirm a defect
claim from the code before agreeing - agreeing without a line-level trace
being wrong even when the claim turns out to be right.
Once per run and not once per posture change, because turn 1 is where the
user's message sits, which is where a claim arrives and where a report gets
written. Announcing it only on the flip would leave every later run of a long
session without it, and the long sessions are the ones that produce reports.
The third class was worse than an oversight: a table of benchmark timings no
command in the session had produced, the same test reading 0.86s in one paste
and 4.20s in the next with nothing said, summing to a total no real
transcript lands on. A prompt rule cannot catch that - a model willing to
write a number it did not measure is equally willing to say it re-ran it.
The transcript can. internal/measurements reads every tool result for go test
durations and checks the final answer against them; a number that contradicts
what ran sends the answer back once, naming both values. Deliberately loose -
a 50% band, same-line pairing only, benchmarks not parsed - because a
tripwire that cries wolf gets ignored, and then it catches nothing.
Posture-gated and nil-safe throughout: a posture-off run takes exactly the
path it took before, pinned by its own test.
Last, the orchestrate tool teaches conflicts and relaxations apart. A run
built to a specification, listed its requirement conflicts cleanly, and filed
not one relaxation - having lowered a million-record bound to ten thousand,
cut a sixty-second soak to five, and excluded a latency class from its own
numbers. All three reached the reader as cells in a results table. A conflict
is a question the spec failed to answer; a relaxation is an answer the work
failed to reach, and merging them is how the second disappears.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The Windows job failed on one test, not on the deadcode noise filling its
log: plan_live_provider_test redirected config with XDG_CONFIG_HOME, which
os.UserConfigDir ignores on Windows in favour of %AppData%. The file was
never found, resolution fell back to the captured profile, and the switch
could not be followed. Its two siblings passed there by accident, both
expecting that same fallback. The helper now sets the variable the platform
actually reads and FAILS if the redirect lands outside the temp root, so a
wrong variable can no longer read and write the developer's real config
while the tests pass for the wrong reason.
The rest are the open review findings, each verified against the code first
and each pinned by a test that fails when the fix is reverted:
- a wall budget that a paused plan no longer burns. It was a fixed
context.WithTimeout, so pausing for twenty minutes took twenty minutes off
a thirty-minute budget and pausing past it meant the plan was already dead
on resume, stopped by time in which nothing ran.
- a task that failed on its own merits stays failed. The harvest relabelled
anything reaching it after a stop as cancelled, so a real compile error
became "the run was stopped" and the one result worth reading was
overwritten. Structural signals decide it now, never the message.
- max_wall_seconds past ~292 years wrapped int64 to a NEGATIVE duration,
which reads as no bound at all. Refused instead: it failed OPEN.
- the model probe fan-out is bounded in width, not only in time. Measured a
200-wide burst on the session's own key, where the provider answers 429,
which classifies as Unknown - paying for every request and learning
nothing.
- plan discovery filters through the same allow-list providers.New enforces,
so a scoped provider stops offering models that die at client creation.
`providers models` already filtered; the plan path was the odd one out.
- project config sets no plan models at all. Exclusion was allowed on the
premise that removing a candidate can only lower spend; it does the
opposite just as easily, since the selector promotes the next model up.
- SavePlan writes through an O_EXCL temp file with an unpredictable name,
closing both the check-then-write window and two concurrent saves sharing
one <name>.json.tmp.
- PlanProgress.Done derives from Remaining instead of counting alongside it,
so the two cannot disagree in the direction that silently drops work.
- the specialist card's cache key covers tokenCount, model and result. All
three are written AFTER the first render, so the key never moved and a
finished task kept showing none of them.
- a return no longer mutates the model it returns in the same statement. Go
does not specify the order, and all five call sites relied on it.
- panel width and the posture chip's hit-test measure COLUMNS, not runes or
bytes. A styled glyph is 22 runes of one column, and the chip's span sat
two columns off whatever multi-byte text preceded it.
Five tests that could not fail are repaired rather than deleted: an
unwired runner, an unseeded saved plan, a watchdog that never ticked inside
its child's life, a skip hiding a modal guard, and a budget case asserting
only that parsing returned no error.
Verified: 83 packages pass, vet and gofmt clean, -race clean, cross-compiles
for windows and linux, and the posture-off request body stays byte-identical
to origin/main across all five modes.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Six runs of one seven-task audit, none of which produced a report. Each failure
had a different immediate cause and the same shape: the plan spent its budget and
returned nothing.
RESERVE, IN THREE LAYERS, because fixing one exposed the next.
Feeders consuming the whole budget left nothing to dispatch verify, sweep or
synthesis with, so work that others wait on now stops early. A ceiling alone did
not hold: four tasks in flight each overshoot by about one usage event, and with
max_workers 4 that overshoot was the size of the reserve itself, drawn from the
same account it was capping. Two pools fixed that — upstream cannot touch
downstream's share however far it overruns. Then a sticky budgetExhausted flag
undid it anyway: the moment ANY task was skipped for budget, every task after it
was too, including work drawing from a pool nobody had touched. Per pool now.
SIZED BY THE PLAN, NOT THE BUDGET. A flat quarter gave three downstream tasks
125,000 between them; verify and sweep used 149,769 and synthesis was skipped.
Three of seven tasks downstream reserves three sevenths: a plan that is mostly
synthesis keeps most of its budget for synthesis, one that is mostly finding
keeps little, and a plan with no dependencies keeps all of it.
THE SCHEMA DECLARES ITS SHAPE. tasks was a bare array with a paragraph of
English, so a model composing a plan had to invent the field names — and read
"an optional read-only tool subset", which names no tool, as tools:
["read-only"]. Twice. The item schema now types every field and the tools enum
comes from PlanGrantableToolNames, which admission already validates against and
which was exported for exactly this ("ONE list ... so the grant and the validator
cannot come to disagree"); the schema was the one caller not using it.
AND THE NUMBERS ARE EXPLAINED WHERE THEY ARE CHOSEN. max_tokens bounds the whole
plan and max_tokens_per_task bounds one task; setting both so the cap can never
be reached is now refused with the arithmetic rather than silently resolved in
favour of the smaller. An undersized budget is warned about BEFORE the first task
rather than in the output afterwards — the estimate was computed correctly on
five consecutive runs and read by nobody, because it rode a result that arrives
once the run is already over.
Estimates are weighted by position: a task that reads a repository has measured
510k-1,017k, one that reads its dependencies' findings 62k-87k. Pricing both
alike over-estimated a correct plan by 60%, and a warning that cries wolf is
worse than none.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two runs of the same seven-task audit, 3.6 million tokens, and no report at all.
Four finder tasks were stopped at their token budget — three of them holding
substantial partial findings — and the verify, sweep and synthesis tasks that
depended on them were every one skipped. The evidence to write the report was
sitting in the results map the whole time.
TWO SEPARATE LOSSES, and both had to go.
WHAT THE CHILD WROTE WAS DISCARDED AT THE BOUNDARY. The kill path returned an
empty Result, so a task stopped mid-investigation handed on nothing however much
it had already found. summary.Text held it; nothing carried it. Status stays
StatusError — the task did not succeed and must not read as a finished answer —
but the text now travels with it.
AND A DEPENDENT WAS SKIPPED IF ANY DEPENDENCY FELL SHORT. Now only if EVERY one
did. A task with some evidence can do useful work and name what it lacked; a task
with none would produce a confident answer drawn from nothing, which is worse
than the gap. That is the line, and it is where the old rule sat one step too far
to the left.
LABELLED, NOT LAUNDERED. Partial input arrives under an INCOMPLETE heading, the
briefing opens and closes by saying which inputs were cut short, and the reader
is told plainly that an unfinished input's silence is not evidence of anything —
nothing was said about X must not become X is fine.
A FAILED dependency is still not partial evidence. It ran and did not deliver;
its output is a harness diagnostic or an answer already judged wrong, and passing
that on as a finding would launder a failure into a source. Only a CANCELLED
task — one interrupted while working — carries anything forward.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A cloned repo could raise the per-run tool-turn budget, increasing cost for
whoever opened it. Project config now mirrors the rule PlanSize and
DisableZeromaxing already follow three fields below; user config stays free to
raise, being the user's own file and their own money.
COMPARED AGAINST THE EFFECTIVE VALUE, NOT THE FIELD, and that is the whole of
it. Zero does not mean "no limit" here, it means "fall back to defaultMaxTurns"
— and that fallback runs AFTER this merge. The obvious guard,
if src.MaxTurns > 0 && (dst.MaxTurns == 0 || src.MaxTurns < dst.MaxTurns)
reads as tighten-only and is not: with no user config dst.MaxTurns is 0, the
first clause holds, and the repo's 500 is taken. That is the common case — every
user who never wrote a config — and the one the rule exists for. Both the
original defect and that near-miss are pinned by tests.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan that names no max_tokens has nothing to meter against — and on a provider
that reports no usage, max_tokens would not have bounded it either: the meter
sums the children's own usage events, and a provider emitting none leaves it at
zero forever while the work runs. Nothing then stops the plan but the work
finishing.
SO A PLAN THAT ASKED FOR NOTHING GETS A WALL, and only that plan. An explicit
wall wins, and a named token budget is left alone entirely — defaulting a wall
over it would put a new ceiling on a plan deliberately running long inside a
bound its author chose. An hour is far beyond the 3-5 minutes measured plans on
this repo take; it exists to catch a runaway, not to discipline a slow plan.
ONE SOURCE FOR THE WALL. Two sites read it — the context timeout that bounds
children in flight, and the deadline that stops dispatching new tasks — and only
one was reading the new value, so a backstop would have killed running work while
still dispatching more. They now read the same function.
AND THE REPORT SAYS WHEN SPEND COULD NOT BE MEASURED, because zero tokens after
two successful tasks is not a cheap plan, it is an unmeasured one — and a
max_tokens set against that number bounds nothing while reading like a guarantee.
Verified while doing this and NOT built: update_plan already cannot reach a plan
task. Named explicitly it is refused at admission with the tools it may use
instead; inherited from the parent's grant it is filtered out. The reasoning is
recorded at planReadOnlyTools and holds.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
TWO RUNS DECIDED THE FIRST ONE. The same audit of this repo, once ending in a
verify task and once not: the verified run dropped five overclaims the other
passed through to its report, including an inference the unverified run stated as
fact. Fan-out multiplies whatever a finder produces, mistakes included, and
nothing in this tool attacks a claim once it has been made.
So the shape is TAUGHT, not built. No verdict is parsed and no claim is
filtered — a plan author adopts find/verify/synthesize with the tasks they
already write, or does not, and the only place that can live is the description
they read. The prompt rules that make a verifier worth its tokens are named
there too: refute first and confirm only when the code forces it, quote what the
code says rather than guessing a refutation, judge each claim independently of
the finder's confidence, and re-read the cited locations because a briefing
carries a claim and not a trace.
AND THE SILENCE BEFORE A PLAN. Auto-assignment runs ahead of admission: a
/models call, a probe of every candidate, and when routing is on a full child run
on the strongest model. Tens of seconds with no plan, so no panel and no rows —
a foreground run looked frozen at exactly the moment it was doing the most.
Reported as a STATUS, not a plan row, through an optional recorder interface
alongside the others: the plan does not exist yet and admission may still refuse
it, so putting a task on screen would show work that never runs. Empty clears it,
and the stale-run guard applies as it does to every other plan message.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Each lands with the test that fails without it.
A MALFORMED DEPENDENCY WAS DROPPED, NOT REFUSED. planStrings skips an entry it
cannot decode — right for a display label, wrong for an edge: "depends_on": [42]
decoded to no dependency at all, so the task was admitted as dependency-free and
ran BEFORE the precondition it declared. Silently; nothing failed and nothing
warned. That defeats this file's own rule that an unknown edge is rejected and
never skipped. depends_on and tools now go through a strict variant that names
the offending index, and the lenient one stays for the fields where dropping is
harmless.
Tasks() SHARED ITS SLICES. copy() duplicates the structs and shares their
backing arrays, so plan.Tasks()[0].Tools[0] = "bash" rewrote the validated grant
in place, from outside, after every check had passed — the widening this file
exists to prevent, reached through the accessor rather than around it.
SavePlan GUARDED THE DOOR AND LEFT THE WALL OPEN. Symlinks were refused on the
directory and on <name>.json, and the bytes go to <name>.json.tmp, which nothing
checked. A repo shipping that name as a symlink turned "save my plan" into the
file-overwrite primitive the other two checks exist to prevent.
A SAVED PLAN LOST THE CALLER'S EXECUTION FLAGS. resolveSavedPlan replaces the
caller's arguments with the stored plan's, which is right for plan content and
wrong for the two flags that say HOW to run rather than what:
{"saved":"sweep","background":true} passed the refusal list and then ran in the
foreground, because background was read from the map that had replaced it.
Plan content is still refused.
SHUTDOWN RAN BACKWARDS. Defers are LIFO, so registering planLaunch.Close()
beside the launcher it belongs to — which reads naturally — meant it ran AFTER
closeSpecialistRuntime. Close cancels AND WAITS, so a background plan was being
waited on with the specialist runtime it needs already torn down. The ordering is
invisible at the point where the next defer would be added, and a comment did not
prevent it happening once, so a source-order guard asserts it.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The surface a person reads was the last to learn what the plan already knew.
THE MODEL THAT RAN, NOT THE ONE THAT WAS CHOSEN. A card's model is set once at
dispatch from the assigned model, and a task whose model the provider refuses is
re-run on the session's — so the row went on naming a model that never executed
it. The plan report the MODEL reads was corrected first; this is the half a
person looks at. A refused model is still named, because it stays in the
provider's list and the next plan will pick it again.
WHY WORK REMAINS, on resume. A plan that ran to the end with failures and one
stopped partway both leave tasks behind, and they call for different decisions:
re-running things that already failed, or finishing work that never started.
PlanProgress.Complete distinguished them and was recorded, never read.
WHAT A BUDGET CUT SHORT, in the headline rather than in per-row text. A measured
run came back missing a third of its questions and nothing above the fold said
which — an incomplete answer that does not announce itself gets used as a
complete one.
Also removes a workers count that rode planAdmittedMsg for planRunningCardKey,
which was deleted when per-task progress began carrying its own task id. The
field outlived its only consumer and its comment still sent readers to a
function that no longer exists.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The hooks specialist declares, supplied from the two surfaces that own a
provider and a sandbox.
DISCOVERY FOLLOWS THE LIVE PROVIDER. It was bound once at startup, so a session
that switched provider with /model kept probing the one it began on: a run on
xai/grok-4.5 had its plan assigned from a nineteen-model Ollama list and the
provider refused every task. It now re-resolves through the same path a freshly
spawned child takes, config plus ZERO_PROVIDER, so discovery and child execution
read one source of truth rather than two kept in sync. The captured profile
still wins when the provider is unchanged, because --provider, --base-url and an
inline --api-key are applied on top of Resolve and re-resolving would discard
them.
A CHILD STANDS ON THE SAME GROUND AS ITS PARENT. A run granted a directory
outside its workspace could create it, populate it, and then watch every plan
task be refused at that same directory — a child rebuilds its sandbox from --cwd
alone and the grant lived only in the parent's engine. The run's roots now reach
the child as --add-dir, read at launch so a mid-session approval lands, and on
the resume path too: this file has already seen the resume path forget what the
fresh path carried.
PROVING, wired to the provider stack and exposed as `providers models --verify`
— which model this provider will actually run, as opposed to which it lists.
That is the difference the exclude list was being maintained by hand to express.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan ran every task on the session's model and metered a budget it could not
enforce. This makes both real.
CHOOSING. A task may name a model; auto_assign picks one per task from what the
provider serves, ranked by price, with an optional routing call that reads the
task text rather than its verbs. Resolution goes through ResolveWithFallback,
not ResolveID, because the latter misses regex aliases and skips the deprecation
redirect — the plan would display one model while the child ran another.
Uncurated ids pass through: the registry holds thirteen models and an xAI
account serves half a dozen Grok models, none of them curated.
PROVING, because /v1/models is an advertisement. Every model failure this
feature produced lived in the gap between listing and serving:
grok-4.20-multi-agent-0309 lists like anything else and answers "Multi Agent
requests are not allowed on chat completions" — six tasks were assigned it and
all six died. A one-token request per candidate, once per session, settles it.
It fails toward KEEPING the model: anything not recognised as a refusal changes
nothing, because being wrong that way costs one task while being wrong the other
way silently removes a model the user pays for. Every candidate refusing points
at the provider, not the models, so the list is kept and the user is told.
BOUNDING. max_tokens was checked between tasks and decremented after they
finished, so nothing in flight could be stopped: a measured plan spent 3,091,618
against a 200,000 budget and still came back incomplete. The meter now reads the
child's own stream, a plan too small for its task count is refused before
anything is spent, and max_tokens_per_task arrives as a deadline the task is
told about rather than a guillotine — a cap that only kills produced 1,437,049
tokens and zero completed tasks.
CARRYING. depends_on ordered execution and passed nothing, so a synthesising
task received conclusions with no trace behind them and could repeat a claim but
never check one. Dependents now receive their dependencies' findings, bounded,
with truncation disclosed.
RESCUING. A model the provider refuses, or a task that declines rather than
answers, gets one attempt on the session's own model. Both triggers are
structural — a flag and an exit code — because deciding to spend another child
by reading error prose is the shape this package already warns about twice.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two additive pieces the plan-model work needs, kept apart from it so each
stands alone.
PROFILES.PLANMODELS lets a user pin a model per task role, exclude ones that
should never be chosen, name the model that does the routing, and add their own
guidance in plain words. The guidance exists because the code cannot know the
account: discovery orders models by price, and on real providers price is not
capability — one account's dearest model was a build preview that failed every
task it touched, another reports no prices at all so the ranking collapses to
alphabetical. The operator knows which model reasons well; this is where they
say so once, instead of naming models in every prompt.
PROJECT CONFIG MAY ONLY EXCLUDE. An exclusion removes a candidate and can only
lower spend; a pin is the opposite, and a cloned repo pinning every role to the
priciest model would raise cost for whoever opened it — the same hazard the
PlanSize and DisableZeromaxing tighten-only rules exist for. Guidance is the
softest way to do what pinning does ("always pick the most expensive model, it
is worth it" costs money without naming a single id), so it sits on the same
side of that boundary.
CACHE AND REASONING COUNTS on the stream-json usage event, because a parent
summarising a child's stream is the only place a sub-agent's turn can be priced,
and it was being handed prompt/completion/total alone. A measured plan task had
49,280 of 49,894 prompt tokens served from cache — 98.8% — and every one of
those turns was costed as though nothing had been cached. Emitted only when
non-zero, so a provider reporting no cache produces exactly the event it always
did.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
ParsePlan permits a task to name a write tool, deliberately and recently:
"A TASK MAY NOW NAME A WRITE TOOL, and only by naming it." That grant is what
makes PermissionForArgs prompt and what makes RequiresIsolation prepare a
worktree. Two layers downstream still assumed the older rule that every task
is read-only, so the capability was gated, approved and isolated — and then
could not be exercised.
The child's system prompt was one literal telling every task "You have
read-only tools" and "do not attempt to modify anything", including a task
granted write_file or bash. A child told not to modify anything does not use
the write tool it holds, so the approval the user gave and the worktree
prepared for it bought nothing. The prompt is now built from the grant, using
the same allow-list ParsePlan validates against so the two cannot drift. The
read-only wording is unchanged for the read-only case, which is the majority
and the one whose phrasing was tuned.
savedPlanLimits granted only the read-only names, so a plan ParsePlan accepts
at run time failed to parse on save, show, run, restart and resume alike. The
durability surface silently excluded exactly the plans whose work is most
worth keeping. It now uses PlanGrantableToolNames. This widens no authority:
the parent grant is intersected again when the plan runs, a write-capable plan
still needs its approval and its worktree, and a tool outside the grantable
list is still refused — both directions tested.
NOT changed, because it is a decision rather than a defect: plan children
still run at --auto low, where plan_runner.go says "Explicitly NOT
MemberAutonomy: Phase 2 tasks are read-only, and granting it here would be the
authority widening that was ruled out as needing its own decision." That
comment and ParsePlan's contradict each other, and only one of them can stand.
Widening child autonomy is not a call to make inside a review fix.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The wall deadline was consulted only before dispatching a task, and the walk
only reaches that check when it needs a free worker slot. A plan whose ready
set fits the worker pool dispatches in one wave and is never checked again,
so it ran for as long as its slowest child took and reported "completed"
with nothing skipped.
Measured before the fix, four 2s tasks under a 1s wall:
max_workers=4 -> 2.0s elapsed, status=completed, 4 succeeded, 0 skipped
max_workers=1 -> status=partial, 1 succeeded, 3 skipped
So the bound existed only on the sequential path, and asking for parallelism
silently deleted it. Nothing cancelled an in-flight child either — the plan
context was WithCancel, and the only deadline reads were the two dispatch
gates.
The plan context now carries the wall as a real deadline, which reaches the
children through the same machinery a user stop already proves works. The
walk keeps its skip-the-rest behaviour for tasks not yet dispatched.
This is the defect max_tokens' cap was removed for, in the commit that said a
number which neither bounds spend nor lets a plan finish "is worse than no
number, because it reads like a guarantee". max_wall_seconds was the worse
case: the token check at least fires between tasks, while the wall deadline
fired zero times in a fan-out — and the approval card shows "300s wall" while
never showing max_workers, the parameter that disabled it.
Both cancellation paths now say WHICH reason stopped a task. A wall expiry is
the plan spending what it was allowed; a cancel is a person deciding. Both
read "the run was stopped", which sent the reader looking for who stopped it.
The tests bound their own waits rather than blocking on ctx.Done(). An
unbounded wait there wedged a full run for ten minutes when the fix was
mutated out — a test that hangs on a regression is worse than one that fails.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
planInt returns 0 for both an absent key and a present-but-negative one, so
`seconds > 0` read a model-supplied max_wall_seconds of -60 as "unset" and
ran the plan unbounded with no error. Every other numeric in the budget
refuses a negative — max_retries and max_tokens explicitly, and
max_stall_seconds even refuses positive values under its floor — so this was
the one invalid input silently accepted, and the failure mode is the worst
kind: a bound the caller asked for that is not applied. Absent and explicit
zero keep their meanings, which is tested, so the stricter parse cannot turn
"unset" into an error.
The orchestrate approval card said "read-only specialist sub-agents".
PermissionForArgs prompts ONLY when argsCanWrite is true, so every card a
user actually sees is asking about a plan that CAN write — the description
was exactly inverted on the one screen where it matters. Description() said
the same thing to the model. Both now say tasks inherit the parent's grant
and may hold write tools granted by name.
The posture-off identity test claimed Zero is byte-identical to a build
without the feature when the posture is off. It is not: system_prompt.go
drops the ~5 KB confirmation policy for any run that cannot mutate, and
runCanMutate is evaluated regardless of posture, so an all-read-only run
gets a smaller prompt either way. The drop is deliberate and fails closed —
the claim was just wider than the proof, and the comparison could not catch
it since both registries take the same branch. The comment now states the
property actually proved: registering the tool changes nothing while the
posture is off.
Left alone deliberately, as design calls rather than defects: PlanProgress
Done/Remaining have only test callers, and research.json ships with an
unsubstituted "THE SUBJECT" placeholder plus dependents that assume upstream
output they never receive. Both need a decision (wire in vs drop; add
argument substitution vs unship the plan) that belongs to the author.
Co-Authored-By: Claude Opus 5 <[email protected]>
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Three guards that were missing beside siblings that have them.
/effort zeromaxing delegated to handleProfileCommand with no idle check.
That is the same mutation /profile refuses mid-run — turn budget,
self-correct, and the shared orchestrate gate — and /profile refuses it
because the budget propagates to sub-agents spawned later in the same run,
so changing it mid-turn leaves one run straddling two budgets. Reaching the
mutation through the effort namespace skipped the guard the profile
namespace applies. Idle behaviour is unchanged and tested, so the guard
cannot quietly disable the feature.
orchestrateHeaderAtMouse had no modal guard while orchestrateTaskAtMouse
directly above it does, and sidebar.go states the rule: sidebarAvailable
deliberately does not suppress the sidebar for the `/` palette, because a
palette must not reflow the layout, so each hit-tester carries its own
suggestionsActive() guard. Without it, clicking the PLAN header while the
palette is open toggled the section behind the overlay.
The posture chip in mouse.go is checked BEFORE the surface switch, so the
modal guards in those cases do not cover it. With the /model picker or a
provider wizard open, clicking the chip swapped in a fresh effort picker and
discarded whatever the open one had loaded or the user had typed.
The header test first used a picker and passed against the unguarded code:
sidebarAvailable already suppresses the sidebar for pickers, so it proved
nothing. It uses the `/` palette now, which is the case the guard exists
for, and that mutation fails it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
releaseTemporaryRead deleted the refcount, dropped the lock, then stripped
the root in a second acquisition. Between those two holds the root was still
in readRoots but no longer in tempReads, and AddTemporaryRead reads exactly
that pair to decide what a caller is holding: it finds the root covered,
finds no refcount, concludes the cover is PERMANENT, and returns a no-op
undo. The release then strips the root and that caller has silently lost
access it believes it holds.
Reproduced by driving the two mutations apart and stepping a second caller
through the gap:
after add: readRoots=[.../shared] tempReads=map[.../shared:1]
in window: second caller tracked=false (a no-op undo)
after release: validateRead -> "requires access outside the workspace"
Both mutations now happen under one hold, and releaseTemporaryWrite gets the
same treatment. removeReadRoot/removeWriteRoot existed only to re-acquire the
lock and are gone.
ON THE TESTS, because they are weaker than they look: neither reproduces the
window on demand. The concurrent one cannot hit a gap that narrow reliably —
it survived the unfixed code even with an injected scheduling point — and the
sequential one holds two references, so the refcount never reaches zero and
the delete never runs. They assert the invariant the bug broke (a live grant
stays readable, the root retires only with its last holder) and are worth
having, but the evidence for the bug is the reproduction above, not them.
Two earlier versions of these tests were worse than weak: they used
t.TempDir() with NewScope, which adds the system temp directory as a write
root, so AddTemporaryRead returned early and never touched the refcount at
all. The scope is built directly here for that reason.
Co-Authored-By: Claude Opus 5 <[email protected]>
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Three from the review on #829.
RESUME NARROWED BY THE WRONG PLAN'S PROGRESS. ReducePlanEvents keeps only
the LAST admitted plan's state and records whose it is in PlanProgress.Name,
but resumeSavedPlan narrowed whichever plan the user named by that progress
without checking the two match. Run plan A, run plan B, then /plans resume A:
every A task whose id sits in B's succeeded set is dropped from the remainder
and never runs. Ids like "tests" or "lint" collide across plans constantly, so
this is the ordinary case rather than a contrived one, and it fails silently —
the remainder validates and runs, just without the work.
The comparison is against plan.Name(), not stored.Name: plan_admitted records
the plan's own name, which is independent of the name it was saved under. A
test asserting the saved name would have passed while breaking every
legitimate resume, so both directions are pinned.
TASK SUMMARIES CARRIED CONTROL BYTES. planTaskSummary cut at the first
newline and left every other control byte intact, and these strings are
model-authored — the realistic path is indirect prompt injection, poisoned
file or web content echoed into orchestrate args and painted into the inline
panel, the sidebar rows and the detail pane. It now goes through
sanitizeCardText, which permission_detail.go already applies to exactly this
class of data.
BACKGROUND PLANS LOST THEIR PANEL. beginRun cleared the orchestrate panel
unconditionally, but background plans are built to outlive the run that
launched them and every message they post carries the background flag
precisely to pass the stale-run guard. After the clear those messages passed
the guard and no-oped against an empty byID, so the PLAN surface vanished for
the rest of the plan's life while it kept running. BackgroundPlanLive gates
the clear, consulting background alongside cancelPlan for the same reason
RunningPlanName does — the launcher sets one synchronously and the goroutine
sets the other later, so reading either alone leaves a window.
Co-Authored-By: Claude Opus 5 <[email protected]>
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The test already redirects HOME, USERPROFILE and GOOGLE_APPLICATION_CREDENTIALS
so host credential stores cannot leak into the golden. It missed the variable
that decides where the config directory actually is:
credentialDenyReadPathsForEnvironment reads XDG_CONFIG_HOME FIRST and only falls
back to $HOME/.config.
Every ubuntu GitHub runner sets it. So redirecting HOME left configHome pointing
at the real /home/runner/.config, and the golden gained
denyRead: ["/home/runner/.config/zero"]
the moment that directory existed. Other packages in the same `go test ./...`
create it — zero/config.json from internal/config, zero/trust.json from
internal/workspacetrust, zero/specialists from this package's own specialist
test — and `go test` runs packages in parallel, so whether this golden saw it
was a race nobody was arbitrating.
That is why it only ever went red on ubuntu: macOS and Windows runners leave
XDG_CONFIG_HOME unset, so those hosts fell back to the redirected HOME and the
path could not exist.
Verified against the exact CI condition — XDG_CONFIG_HOME set AND $XDG/zero
already present — passing with the fix and failing without it.
The wider issue is untouched and belongs to its own change: tests write into the
user's real config directory, which is what made this reachable at all.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
TestIndependentTasksRunConcurrently asserted that four tasks reach a peak
concurrency of four. effectivePlanWorkers deliberately caps a plan at
runtime.NumCPU()-2, so on a four-core runner a plan asking for four workers
correctly gets two — and the assertion could never hold.
Not flaky. Deterministic on any host with fewer than six cores, which is every
CI runner: green on a ten-core laptop, red on macos, ubuntu and windows alike.
The test was measuring the machine and calling it the scheduler.
It now waits for effectivePlanWorkers(requested) — what this host will actually
run. The minPlanWorkers floor of 2 is what keeps it honest: want is never below
two, so it still fails against a scheduler that runs tasks one at a time, which
is the only thing it exists to catch. The failure message now reports the
request, the effective count and the host's capacity, so the next person reads
the cause instead of rediscovering it.
Verified by forcing machinePlanWorkers() to 2 to simulate the runner and
checking both directions: the old assertion fails with CI's exact message, the
new one passes alongside the five sibling concurrency tests.
This was the single cause of all four failing checks — the three Smoke jobs run
go test ./... directly, and Zero Review's blocker gate fires on its test step
(diffcheck, build and smoke were already green there).
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two findings from the review pass, and one root cause: the surface that shows a
plan can hold exactly one, and only the path a USER drives enforced that.
/plans restart already refused to start under a running plan, with a comment
saying precisely why — "the panel is keyed by task id, the second would
overwrite the first's rows". The orchestrate TOOL had no such gate, and the tool
is the reachable path: a background plan returns immediately by design, so the
model's very next call lands while it is still running.
What that cost, both confirmed against the code:
A foreground plan finishing cleared the shared background flag and cancel. The
foreground plan was announced to the model as "the background plan finished" —
for a plan that had already returned its summary inline — /plans stop then
reported no plan running while the real one kept spending, and when the
background plan genuinely finished its own completion saw the flag already
false and told nobody. Spend nobody sees and no result, which is the failure
mode that code's own comment names.
And cardByTask is keyed by task id, unique WITHIN a plan and not between two.
Two plans sharing an id as ordinary as "tests" would have one plan's dispatch
overwrite the other's entry; the first completion closes the wrong row, and the
card left behind can be closed by nothing at all — the forever-spinning agent
from this morning's screenshot, back by another door.
PlanSurfaceBusy is the optional CONCURRENCY half of a recorder, type-asserted
like PlanController beside it, so a recorder that cannot answer — the headless
path, which runs one plan per process and has no surface to contend for — is
treated as free rather than blocked.
The gate is checked AFTER parsing, so an invalid plan is still called invalid
rather than blamed on the plan already running, and BEFORE admission, so a
refused plan leaves no record of having started.
The bridge reports busy on `cancelPlan != nil || background`, not on the cancel
alone. The launcher sets background synchronously before starting the goroutine;
the cancel arrives only when the executor reaches its first task. A gate reading
the cancel alone would wave the next plan through the gap between them — which
is exactly where the model's next call arrives.
Both preconditions are now written where the code rests on them, so relaxing the
gate cannot silently reintroduce either. Five compile-time assertions added for
the optional interfaces: each is reached by a type assertion, so a signature
that drifts does not fail the build — it stops being found, and the behaviour
disappears with no error anywhere. This branch has shipped that defect seven
times.
Four mutations, all caught.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The column clips to height-1 and pins the token readout at that last row, but
the selectable tables are built from the full section heights. Only
fileRowAtMouse checked for this, inline; the plan, orchestrate and agent tables
did not — so a click on the token readout opened whichever row had been pushed
underneath it, and hovering it highlighted the readout as though it were one.
Expanding an agent is what makes it reachable in a single click. On an 11-row
terminal with a four-step plan:
before after one click on the agent row
6 |PLAN 2/4| 6 |PLAN 2/4|
7 | ✓ alpha | 7 | ✓ alpha |
8 | ✓ bravo | 8 | ✓ bravo |
9 | • charlie | 9 | • charlie |
10 | ○ delta | 10 | 0 tokens | <- clicking here
opened "delta"
The rule now lives in one predicate and is applied to the TABLES rather than to
each hit-test, so hover and every future consumer inherit it instead of each
having to remember. fileRowAtMouse's inline copy is gone with it: two statements
of one rule is how they drift apart.
It fails OPEN on an unmeasured terminal. Filtering the whole table before the
first WindowSizeMsg changes nothing in production — every consumer already
requires a live sidebar — but it made the offset tables untestable in isolation,
which is how the existing TestSidebarPlanSelectablesOffsets caught me.
Also repairs a gap the shared PLAN section opened: sidebarOrchestrateSelectables
returned nil whenever update_plan had steps, so the running plan's rows rendered
but could not be clicked, and its base ignored the checklist, the naming line and
the bar above them. The prefix is now MEASURED off the renderers rather than
re-derived from the conditions that produce them — the bar already had a
hand-written correction term here, and a second one for the naming line would
have been a second chance to disagree with what was drawn.
Found by an adversarial review pass, with a reproduction. Four mutations.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan's finished tasks ARE its result, and they vanished a second and a half
after each one landed. A nine-task run that succeeded showed an empty AGENTS
section, with no way left to ask what any of them did.
The header carries the control: "AGENTS 1 · ▸2 done". Clicking it keeps the
finished agents for the session; clicking a finished agent opens it.
AGENTS 1 · ▸2 done AGENTS 3 · ▾2 done
⠋ d-report ✓ a-reltime
You are auditing package
10s · 22.8K tok
pkg/reltime: 3 findings.
reltime.go:41 — Parse ign…
…
✓ a-fsutil
⠋ d-report
WHAT IT PRODUCED is new data, not a new rendering. TaskResult.Output has always
held the child's full answer verbatim and the TUI never saw it: planTaskDoneMsg
carried the outcome, the spend and the error, so a finished row could report
everything except the thing the user ran the agent for. It is bounded to 2000
bytes at the bridge — the report task in a nine-task plan returned a hundred
thousand tokens of output, and carrying that through the event loop to show four
lines would be paying megabytes for text nothing reads. The whole answer is
still in the child's session, which the row's drill-in opens.
The toggle is registered independently of the agent rows. Hung off them it would
have been unclickable in exactly the state it exists for: when every agent has
finished, there are no rows.
Six mutations, all caught. The output is tested bridge → message → Update rather
than by calling setResult, because testing the setter would pass with the bridge
sending nothing at all.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Four of nine tasks dispatched, none finished, and the bar was 44% filled with
the same solid block a completed task gets — sitting directly beside its own
"0/9". The bar contradicted its own number, and the number was the one telling
the truth.
done, failed, skipped and cancelled are TERMINAL: the task is not coming back
and the bar can spend a full block on it. Running is not a fraction of done.
It now draws as a half-shade, which ranks correctly between the settled blocks
and the pending track and cannot be misread as either.
nothing started ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0/9
4 running, 0 done ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░ 0/9
3 done, 2 running █████████▓▓▓▓▓▓░░░░░░░░░░░░░ 3/9
all 9 done ████████████████████████████ 9/9
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The screenshot showed the footer line still there, on a session that had been
running for five minutes. The gate I added stands the inline panel down only
when the sidebar's PLAN section is showing the orchestrate plan — and it never
was, so the gate never fired and nothing had changed.
update_plan and orchestrate are not alternatives. A zeromaxing turn routinely
runs an update_plan checklist whose middle step IS "run this orchestrate plan",
so both are live for the whole run. Every piece of orchestrate rendering in the
sidebar — the task list AND the progress bar — was gated on update_plan being
empty, so the running plan had no sidebar surface in the one configuration it
actually appears in, and got pushed back into the footer panel it was supposed
to have replaced.
The section now draws update_plan's checklist and, beneath it, the running
plan's own tally, bar and tasks. The outer plan is above because it is the outer
plan. The running plan names itself, because the section header is already spent
on update_plan's count and two stacked tallies otherwise read as one list.
The bar moves inside sidebarPlanLines with everything else. It was drawn beside
it and carried a correction term in sidebarFileSelectables to compensate — a
standing invitation to the exact offset drift that function's comment warns
about. One function now owns every line the section draws.
sidebarOwnsOrchestrate keys on the section being COLLAPSED rather than on
update_plan's presence: a section collapsed by a click shows a count and a hint,
which is not a surface, so the plan goes back to the panel until it is reopened.
Four mutations. M18 and M19 missed first: the file-offset loop iterated over a
model with no touched files, and nothing asserted the bar at all. Both were
passing for want of a fixture rather than for correctness.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
"" is the zero value on BOTH sides of the expansion test: expandedAgent when
nothing is open, and childSessionID for a specialist keyed by a tool-call id the
provider left blank. Compared bare, such a row is permanently expanded — nobody
opened it and nobody can close it, because the hit that would toggle it is
already guarded on a non-empty key and never records one.
The consequence is not local. The sidebar renders AGENTS first and clips the
whole column to its height, so four uninvited lines at the top push PLAN, FILES
and ACTIVITY down and off the bottom. The mutation makes it plain: PLAN moves
from line 2 to line 6.
The guard now matches the hit's. A row that cannot be clicked open must not be
able to open itself.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The collapsed row says which agent and which tool it is in. The three things it
cannot say are what the agent was actually asked to do, what it has cost, and —
when it did not simply finish — what happened to it. A click now opens those
under the row, and closes them again.
In place, not a drill-in. A specialist row is keyed by its CARD, and a plan
task's card key is not a session id until the task finishes and reconciles one,
so there was nothing to drill into during the exact stretch where the detail is
most wanted. Swarm rows keep their drill-in; only specialist rows toggle. The
expansion follows the card→session rename at completion, or a row the user had
open would collapse at the moment it gained a result to show.
Four lines is the cap. This section shares its column with PLAN, FILES and
ACTIVITY, and an expansion that pushes those off has traded three sections for
one.
The spend line drops segments that do not fit rather than truncating the joined
result. These are figures: prose ending in an ellipsis still reads as prose, but
"3,4…" is three thousand or three million with equal authority. Elapsed uses the
footer's own 1m10s and tokens the column floor's own 3.4K, so a 30-cell sidebar
shows "1m10s · 3.4K tok" whole instead of all three cut off.
Six mutations, all caught — including the offset hazard sidebarPlanLines warns
about, since PLAN and FILES derive their click targets from the agent section's
rendered height and every added line moves them.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The right column already carries the plan: progress bar, task list, live detail,
and an agent row per task. The inline panel above the composer predates all of
that, and keeping both meant the same plan announced itself three times on one
screen while taking footer height from the conversation to do it.
It now draws only when the sidebar is not the plan's surface. That is a real
set of states, not an empty one: the sidebar needs a wide enough terminal, is
suppressed under every full-screen overlay, and hands its PLAN section to
update_plan whenever that has steps. In all of them the panel is still the only
place a running plan appears, so it still draws — a fallback, not dead code.
Two supporting changes:
An admitted plan now counts as sidebar content on its own. It did not, so the
column waited for the first task to spawn an agent row, and the inline panel
flashed on screen for exactly that gap before standing down. visible(), not
isEmpty(): a finished plan's tasks are kept for the record, and holding the
column open for those would mean the sidebar never auto-hides again once a
session has run a plan.
Ctrl+G moves off sidebarActive() onto the same predicate, so it acts on
whichever surface is on screen. The two disagree precisely when the sidebar is
up but update_plan owns its PLAN section — where the old key cycled a selection
in a list that was not visible while the panel the user was looking at ignored
it. Mutation M7 missed until that case was in the test.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The screenshot: five agents in the sidebar, every one of them spinning, on a
plan whose tasks had long since finished. The footer said 6 running and it
stayed 6 running.
finish() resolved the card with planTaskKey(bridge.dispatched) — the LAST
dispatched task's key. With one worker that was indistinguishable from a
lookup: a task could only finish after it was dispatched and before the next
one was, so the last card was always the finishing task's card. Fan-out opened
six at once, completions started arriving in whatever order the tasks finished,
and the counter then marked a task that was still running as done while the one
that actually finished spun for the rest of the session.
cardByTask has existed since fan-out landed and was written on every dispatch.
Nothing read it here. Seventh instance this branch of a field populated at one
site and consumed at none.
The map is also the authority on whether a task was DISPATCHED, which the
outcome cannot answer. TaskCancelled is emitted both for a task stopped
mid-flight (it has a card) and for one cancelled before it ever ran (it does
not); reading the first as undispatched opened a second card and closed that,
leaving the real one running forever. That is the "even after cancelling it
keeps spinning" half of the same report.
Read-and-delete, so an entry exists exactly while its task is in flight and a
later plan reusing a task id cannot resolve against a dead card.
Three tests, three mutations, all caught. The existing tests only ever completed
tasks in dispatch order, which is precisely why this survived.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
LOWERCASE, like every label beside it — "ask", "high", the model name. Shouting
was the badge's job and the badge is gone; the word earns attention by moving,
so caps were doing the same work twice and losing to the row around them.
THE HOVER FLOWS. A three-letter band travels left to right through the word and
wraps, so hovering reads as motion rather than as a second static state — which
is what an underline across the whole label had been. Three letters is the
width that reads as a band: one blinks, and all ten is a rule.
IT NEEDED A TICK, and the bound is the whole justification. The resting word
walks its colours on the spinner already running during a turn and holds still
otherwise, but a band under a resting cursor has no frames to move on. So the
hover starts the existing tick loop and stops it the moment the cursor leaves —
ensureSpinnerTick's rule is "an idle plain session schedules no timer", and a
cursor held on a control is a direct interaction rather than an idle session.
Reduced motion pins it, like every other animation here.
Wall clock rather than a frame counter, so the band crosses at the same speed
whatever cadence the ticks arrive at.
Seven mutations. F7 initially MISSED and it is the sixth instance today of one
shape: the test asserted the PREDICATE (zeromaxingChipAnimating) and not that
the scheduler consults it, so removing the hover from ensureSpinnerTick's gate
passed — the predicate would have stayed right while the band never moved. The
test now drives ensureSpinnerTick itself, including that it declines to schedule
a second timer over a live one.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Corrects the last change rather than extending it. I read "no yellow, colourful
hover" as "same badge, different fill, spectrum on hover" — the ask was for the
WORD to animate, and for the box to go entirely.
NO BACKGROUND FILL AT ALL. The badge signalled "on" by being a solid slab; the
word signals it by MOVING, which the eye catches at least as well and which
costs the footer none of its calm. A footer is a row of quiet labels and a lit
block in it is a shout; letters that walk their colours are noticed without
being loud.
The ramp rotates on the pulse the chip already rides, so it costs NO TIMER. It
walks while a turn is in flight and holds a static rainbow when idle —
deliberately, and not a compromise: an idle session schedules no timer, which is
the rule this pulse was built around, and a footer animating forever would be
CPU nobody asked for. The word is alive exactly while the posture is doing
something, which is also what it means.
HOVER IS AN UNDERLINE. The chip opens /effort so it must say it is pressable,
and a background fill is the one thing this deliberately does not have — so it
cannot be the hover signal either.
Colours come from the palette, never hex, so the word stays coherent on dracula
and on light themes. postureBadge is DELETED rather than left unused: a style
nothing renders is a knob someone later trusts.
One real consequence, checked before touching anything: per-letter colouring
means the label is no longer a contiguous run in the styled output. Both
production hit-testers already strip ANSI first, so clicking is unaffected — but
a footer test was asserting Contains on the RAW string, which was asserting an
accident of how the chip happened to be styled. It strips now, as it should
always have.
Five mutations, all caught, including the box returning and the animation
running on an idle session.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two changes, and the first is about meaning rather than taste.
IT IS NOT AMBER ANY MORE. Amber fills the PERMISSION badge, so in this UI it is
the colour of "something needs your attention". The posture is a standing MODE,
not a caution, and sharing the fill made it read as the wrong sentence — a
warning that never resolves. It sits on the brand accent now, still filled, so
"is it on?" is still answerable from across the room.
THE HOVER IS A SPECTRUM, not another flat fill. A flat hover on an
already-filled chip is two solid blocks differing by a shade, which is close to
invisible; letters that each take their own hue cannot be mistaken for the
resting state, and they say "this does something" louder than any single colour
can. The chip opens /effort, and a clickable thing that looks like a label never
teaches anyone it can be pressed.
EVERY COLOUR COMES FROM THE PALETTE, never hex. The ramp is built in buildTheme
from accent/green/blue/amber/red/gitAdd, so the effect stays coherent on
dracula, on a light theme, and on whatever is added next — the same reason the
permission card stopped tinting its body with an amber wash.
The shimmer rotates on the pulse the chip already rides, so it costs NO TIMER:
an idle session still schedules none, which is the rule the pulse was built
around, and reduced motion pins it still like every other animation here.
Width is unchanged, which is what keeps the chip clickable — colour adds ANSI
bytes and no cells, and the span finder strips ANSI before locating the label.
Asserted directly: the hit region must be identical resting and hovered.
Six mutations, all caught, including the two that would look fine in a
screenshot and be wrong: the spectrum collapsing to one hue, and the shimmer
running on an idle session.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A defect I introduced two commits ago and did not notice until a stale build
plan made me look at the permission layer again.
permissionSupportsPersistentDecision refuses "always allow" for bash,
exec_command, write_stdin and apply_patch — deliberately, because a remembered
approval for those is too broad a standing grant. orchestrate fell through to
the default and was offered it.
THE COMPOSITION IS THE PROBLEM. A read-only plan does not prompt at all, so
every prompt orchestrate produces is for a plan that CAN WRITE — and a
write-capable plan may name bash, which is in planWriteTools. So "always allow
orchestrate" is a strictly broader standing grant than the one the name list
exists to refuse, reached by a different route. One keystroke would have
permanently disabled the write-plan approval gate that the whole three-step arc
was built to justify.
DECLARED BY THE TOOL, NOT LISTED BY NAME. A name list cannot describe a tool
whose reach depends on its arguments, and it cannot cover a tool that RUNS the
ones already refused — the next such tool would be silently persistable too.
tools.PersistentPermissionRefuser lets a tool state its own reach, which is the
same reason ChildProgressStreamer exists and the same mistake `call.Name ==
"Task"` was.
The name list is unchanged and still decides for tools that declare nothing, so
this adds a rule rather than replacing one. A nil registry falls back to it
rather than refusing everything.
The plan stays approvable for THIS call and for the session. Only the permanent
form is withheld — exactly how bash behaves.
Four mutations, all caught, including the two that would be silent: orchestrate
declaring false, and the agent ignoring the declaration. The specialist-side
test also asserts bash is still in PlanWriteToolNames, because the argument
above stops holding if it ever leaves — and then this rule needs rethinking
rather than quietly persisting.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The second of §5.1's three prerequisites — and it turns out NOT to be a
prerequisite for plan fan-out at all. It is a live defect on the parallel
tool-call path, which is a different thing, and worth fixing on its own merits.
A CORRECTION TO WHAT I SAID EARLIER. I claimed this blocked combining
max_workers > 1 with write-capable tasks. It does not: plan tasks are separate
PROCESSES (osexec.CommandContext), each with its own sandbox Engine and Scope,
so concurrent plan tasks never share this state. What does share it is
internal/agent/parallel_tools.go, which runs up to eight read-only tools
concurrently in ONE process — and a read-only tool blocked outside the
workspace prompts and takes a temporary read grant.
THE DEFECT. AddTemporaryRead appended the root and returned an undo that
removed it, with no notion of who else needed it. The second caller to ask for a
root already present got a NO-OP undo, so when the first caller finished its
cleanup removed the root out from under the second — which was still running.
Two read-only tools in one parallel batch, both blocked on the same directory,
is exactly that shape.
Reproduced before fixing: two holders, then A's cleanup, and the root goes
present -> absent while B still holds a grant.
Temporary roots are now reference counted and removed only when the last holder
releases. A caller whose request is COVERED by an existing temporary root takes
a reference on the covering root rather than a no-op undo, so the broader-root
case — the same defect one level up — is closed too. A permanent root is never
refcounted and never removed by a temporary holder, which is asserted.
Write roots get the identical rule, or a write grant would keep the bug reads no
longer have.
THE TEST SETUP IS THE INTERESTING PART. A first version used t.TempDir() and
passed against completely unfixed code: /tmp and $TMPDIR are default write
roots, so every grant for a path under one is a no-op and every assertion held
vacuously. That is RULES §2.3 in its exact documented form, met head-on. The
helper now builds its directories outside any default root and skips when it
cannot, rather than quietly proving nothing.
Five mutations, all caught. Race detector clean at -count=20 with eight
concurrent holders of one root.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The last item from the fan-out plan, and the one I had not closed: does the
resume reducer still narrow correctly when events come from tasks running at
once? It does, and the reason is a property worth pinning rather than assuming.
THE EVENT LOG STAYS SINGLE-WRITER. Tasks run on their own goroutines; the five
lifecycle events do not. Dispatch is recorded by the walk before the goroutine
starts, and every terminal event by the harvest the same walk performs. So the
log is written by one goroutine in a defined order however many tasks overlap —
which is why ReducePlanEvents can rely on Sequence and why none of that
bookkeeping needs a lock. Asserted by recording which goroutine each recorder
call arrives on.
RESUME AFTER A FOUR-WIDE CRASH. Several tasks can be in flight when a process
dies, so the reducer must report EVERY unfinished one rather than the last, the
remainder must re-run all of them, a joining task must keep the edges to the
predecessors that did not finish and lose the ones that did, and the remainder
must still be concurrent — narrowing a fan-out into a chain would be a silent
downgrade.
Four mutations. R4 — dropping the lock around the bridge's card map — initially
MISSED under -race, and the test was at fault: each task's goroutine made five
quick calls and finished before the next dispatch, so the reader and the writer
never actually met. Made to spin until signalled, the race is found immediately.
A concurrency test whose threads do not overlap is not a concurrency test, and
"passes under -race" meant nothing here until the overlap was forced.
Also adds the combination nothing else exercised: a real bridge recording a
concurrent plan into a real session store while per-task progress arrives from
every task's goroutine. Race-clean at -count=10.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A child's stream events carry no task identity. The agent loop's progress
callback holds the parent's TOOL-CALL id, which is identical for every task in a
plan, so a display could only guess which task an event belonged to — and the
guess it made was "whichever task was dispatched last". Sound while exactly one
task ran at a time; a lie the moment two could.
THE IDENTITY NOW TRAVELS WITH THE EVENT, from the only place that has it. The
recorder opened the cards, so the recorder knows which card belongs to which
task; the runner tags each event with its task id on the way out and the
recorder resolves it. Nothing at the display end reconstructs anything.
SO THE GUESS IS DELETED, not conditioned. planRunningCardKey and the
planConcurrent flag that briefly protected it are both gone. The previous commit
turned attribution OFF for concurrent plans because a card that says nothing is
honest while one that says something false is not; this makes that compromise
unnecessary, and leaving the field behind would have left a second, wrong way to
answer the same question.
BOTH CALLBACKS FIRE, and they are not alternatives. My first version routed only
to the recorder, and a test caught it: a plan task's child must stream under the
same contract a Task sub-agent's child does, and restoring that was a fix in its
own right (finding: plan children streamed to nobody). Sending only to the
recorder would have quietly undone it for every caller without one — the
headless case. The recorder gets the event WITH the task id; the caller's own
callback still fires as it always did.
Only tool calls reach a card. A message per streamed token would put the event
loop under a load for a display that shows neither the tokens nor the count of
them.
Seven mutations, all caught, including the two that would look right on screen
and be wrong: all tasks sharing one card, and progress routed to whichever card
was newest.
Race detector clean at -count=5 across both packages. Additivity re-proven, five
modes byte-identical.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Admission, scheduler and worker cap in ONE commit, because splitting them the
way the plan suggested would have shipped a regression: relaxing max_workers
while the executor stayed sequential means a plan asking for 8 silently runs
with 1, which is exactly what the old rejection existed to prevent.
ONE SCHEDULER, NOT TWO. With a single worker it walks plan.Order() one task at
a time, applying the same checks in the same order and producing the same
report — so the entire existing suite, written against the sequential executor,
IS the equivalence oracle. It passed unchanged except for the one test that
encoded "max_workers must be 1". A second executor would have been easier to
write and impossible to keep in step; a duplicated rule drifts, and a duplicated
executor drifts faster.
THE WALK IS WHAT PRESERVES ORDER. Rather than keeping a ready SET and picking
from it — which would let task 7 start before task 3 because its dependencies
happened to resolve first — the scheduler walks the validated topological order
and, for each task in turn, waits until that task's dependencies have resolved
AND a worker is free. With one worker the wait is "until the previous task
finished", which is today exactly.
TWO NUMBERS, BOTH REPORTED. max_workers is what the plan asked for; the machine
may carry less. A plan that asked for sixteen and ran six has not been given
sixteen, so the report carries Workers and WorkersRequested rather than letting
the request stand as if honoured. Out of range is REJECTED, not trimmed, for the
same reason the old rule rejected: a trimmed number is one nobody can reason
about afterwards.
THE TUI STOPS ATTRIBUTING RATHER THAN ATTRIBUTING WRONGLY. Child progress events
carry no task identity — the loop's callback holds only the parent's tool-call
id — so with two tasks in flight there is nothing to route them by. Pointing
every child's output at whichever task started last would show one task's tool
calls under another's name. A card that says nothing is honest; a card that says
something false is not. Per-task progress needs the child's identity threaded
through the callback, and that is its own change.
Eight mutations, all caught, including the two that would be invisible in
production: dependencies ignored, and one worker overlapping anyway.
Race detector clean at -count=10.
MEASURED IN THE REAL BINARY, same plan, same build — a five-task diamond:
max_workers=1 WALL 40.0s
max_workers=4 WALL 27.0s
Note what this spends: MaxSpeedup was defined as the theoretical ceiling
measured FROM a sequential run, and it was the evidence the decision to build
this rested on. Once plans run parallel that instrument no longer measures a
ceiling. The 1.67x it reports for this diamond still matches the observed
40→27; on plans that are mostly chain it will not.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Found by the user running a fifteen-task plan: 260 seconds on the first task
with ZERO tool calls. The task was "find every definition and call site and
quote the file:line of each" — and the child answered it by thinking.
The prompt said "You have read-only tools" and stopped there. That states a fact
and asks for nothing, so a model is free to reason from its own weights about
what the code probably says. It now says USE THEM: search before answering,
start with a tool call rather than prose, back every claim with something read
in THIS run and quoted with its file:line, and say plainly when something cannot
be found.
The honest-failure clause is the half that matters most. A guess and a finding
are indistinguishable once they reach the plan's report, so a task that cannot
find something has to be able to say so without that reading as failure.
THE STALL WATCHDOG CANNOT CATCH THIS, and that is worth recording rather than
fixing blind. It keys on SILENCE — deliberately, because a task reading forty
files for four minutes is working — and a model writing prose is not silent. A
task that monologues indefinitely without calling a tool trips nothing. The
bound that would have applied is max_wall_seconds, which the model omitted.
Verified at the wire, and my first probe was WRONG in a way worth noting: it
searched only system-role messages, found nothing, and I briefly concluded the
manifest prompt never reached the child at all. It does — WrapSystemPrompt puts
it in the user message under "## Specialist Instructions". Checking every role
showed all four new phrases arriving. A probe that looks in one place and
reports absence is how a non-defect becomes a rewrite.
Whether this changes glm-5.2's behaviour on a real plan is NOT proven here; the
stub cannot answer it. It needs a run.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The last step, and the only one that changes what a plan may DO. It is safe to
make because the previous two exist: an approval that can show the plan
(b52ed77) and a worktree of the plan's own (b2673f8). Making it first would have
been asking a user to approve something the screen could not describe, into a
tree they had not agreed to have changed.
WRITING IS NAMED, NEVER INHERITED. A task that names no tools still gets the
read-only grant and nothing else; a task that wants to change something says
which tool. Otherwise every unqualified task in every plan becomes write-capable
the day the parent grant widens — and the grant DID widen here, which is exactly
when that would have happened.
AN ALLOW-LIST, not "anything not read-only": write_file, edit_file, apply_patch,
bash, exec_command. The negative form would hand a plan task every future tool
the moment it is registered, including ones nobody considered when this was
written. Every deny-list in this repository has leaked.
BOTH GRANT SITES MOVED TOGETHER. validateTaskTools permits a named write tool
and planToolGrant delivers one; relaxing admission alone would have produced a
task that validated and then ran with less than it asked for, silently. That
pair is the sibling rule this feature exists to respect.
PROMPTING IS ARGUMENT-AWARE because it has to be: Safety() sees no arguments, so
it can only describe the tool, and "this plan writes" is a property of the PLAN.
A read-only plan keeps PermissionAllow — friction with nothing to show for it is
the click-through problem. An unreadable plan, or a `saved` one whose tasks are
in a file the check has not opened, errs toward ASKING: a wrong guess that way
costs one prompt, the other way runs write tasks unasked.
THE ADDITIVITY GUARD THIS NEEDED. Implementing ArgsPermissioner makes
permanentlyDenied return false — deliberately, since only a tool with no
per-argument override can be ruled out from its static permission. But
orchestrate is gated on the POSTURE, which is a session state and not an
argument, so it would have counted as held in every run merely for implementing
the interface, and the confirmation policy would have appeared in read-only runs
that never turned the feature on. tools.PermanentDenier lets a tool say what
ArgsPermissioner cannot, and it is consulted first. Five modes still
byte-identical.
Seven mutations, all caught. Two existing tests encoded the OLD rule and were
rewritten to the new one rather than around it — and TestBothGrantEnforcement
PointsAgree now CALLS validateTaskTools instead of restating it, so the next
time the rule moves the test moves with it rather than failing against correct
code.
Verified in the real binary: a write-capable plan outside a git repository is
refused with the reason, and inside one it gets a worktree named for the plan.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
STEP 2 OF THREE. Step 1 gave the approval prompt something to show; this gives a
write-capable plan somewhere to write that is not the user's tree; step 3 allows
write tools with both as preconditions.
THE REQUIREMENT IS DERIVED FROM THE PLAN, not declared by a caller, and that is
what makes step 3 safe by construction. RequiresIsolation reads the plan's own
tools: a task naming anything outside the read-only set can change something,
which is exactly the condition isolation exists for. Today validateTaskTools
rejects such a task so it is always false; the moment step 3 stops rejecting
them it becomes true, with no second place needing to be updated. Three defects
in this feature have been a rule that lived in one place and a condition that
lived in another.
PER PLAN, NOT PER TASK. Per-task isolation is more isolation and less use: this
phase is sequential and a later task routinely needs an earlier one's output, so
per-task trees would either hide that or need a merge step nobody wrote. A plan
is the unit a user reviews, so a plan is the unit that gets a tree — one branch,
one diff.
FAIL CLOSED, four ways, because a precondition that degrades is not one. No
isolator, a prepare that errors, an isolator reporting success with no path, and
one reporting a path it did not isolate are all REFUSED — never run in the
parent tree with a warning. The third and fourth are what a buggy isolator
produces, and honouring either would put write tasks exactly where this exists
to keep them out of.
BOTH DISPATCH PATHS. Foreground and background resolve the same workspace by the
same rule, and the background path resolves it BEFORE launching so a plan that
cannot be isolated is refused now, with a reason the model reads, rather than
failing invisibly on a later turn.
It reuses internal/worktrees — the package `zero exec --worktree` already uses —
rather than shelling out to git again. Release does NOT delete the tree: a plan
that wrote produced work nobody has reviewed, and removing it would remove the
only copy.
Eleven mutations. I7 initially MISSED and it is the same shape as the Stalled
flag: every test drove ExecutePlanIn with a FAKE runner that reads req.Cwd, so
all of them passed against a real runner ignoring it. Fixed with a test that
drives NewPlanRunner and asserts on the `--cwd` argument the child actually
receives — both directions, since a runner that used only the override would put
read-only children nowhere.
A read-only plan still runs in the parent tree, verified with the real binary:
no worktree is created for one.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
PermissionRequest has carried an Args map since it was written and NOTHING has
ever read it — verified across permission_prompt.go, transcript.go and
rendering.go. So the card names a tool and states a static reason. For a
one-file write that is survivable, because the path already surfaces as the
grant scope. For a plan it is not: a twenty-task plan and a one-task plan
produce the identical card, which is precisely the argument plan_tool.go makes
for NOT prompting on orchestrate today — an approval that cannot show what it
approves trains click-through, and that is worse than no dialog because it also
erodes the prompts that do carry information.
STEP 1 OF THREE, and it is a step rather than a product. Step 2 is worktree
isolation; step 3 flips plan tasks to allow write tools with both as
preconditions. The order is forced: the gate cannot be justified until the
renderer exists, so this lands first and is wired by step 3.
A REGISTRY KEYED BY TOOL NAME, and a tool with no entry renders exactly what it
rendered before. That is what keeps every existing permission prompt
byte-identical, and it is asserted with Args deliberately PRESENT so the test
proves the renderer lookup is the gate rather than the absence of arguments.
THE ARGUMENTS ARE UNTRUSTED. Every value came from the model, so text is
collapsed to one printable line: newlines dropped (or the model writes its own
rows), control characters and ESC dropped (or it repaints the screen around the
card), truncated on RUNE boundaries. The row count is bounded and says what it
hid — a card a plan can make taller than the terminal is a card whose options an
attacker can push off screen, leaving only the key still visible.
A malformed task is SHOWN as malformed rather than skipped: a task the card
silently drops is a task the user approved without seeing. An unbounded budget
is NAMED, because "no token limit" is the thing worth reading on an approval and
omitting it reads as though a limit exists. A `saved` reference says which plan
and where to read it rather than inventing a summary of tasks the card never saw.
Nine mutations, all caught. The one that mattered most was P2 — pointing every
tool at the plan renderer — because the whole no-regression claim rests on that
lookup being the gate.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The last open decision, settled by measuring instead of reasoning — and the
premise I had been carrying was wrong.
I had recorded this as "update_plan blocks the prompt saving; drop it or add a
session-only capability". Driving a real plan and dumping the CHILD request
showed the saving was not reaching plan tasks at all, and the reason was neither
option: update_plan declares readOnlySafety("Updates in-memory planning state
only") and sets NO capabilities, so CapabilitiesOf reports EffectUnknown — the
fail-closed default — while its safety string says read-only. runCanMutate reads
the CAPABILITY. One unclassified tool in an otherwise read-only grant made the
whole child look mutating.
Measured at the wire, same plan, before and after:
child system prompt 18,118 -> 11,639 chars (-36%)
confirmation policy present -> absent
That is per TASK, multiplied by every task of every plan.
DROPPED FROM THE GRANT rather than reclassified, and the distinction matters:
the same capability drives concurrency eligibility, so promoting update_plan to
EffectReadOnly would make it eligible for parallel execution it is not safe for
— it REPLACES the whole plan list on every call, so concurrent callers race and
the last one wins. The safety/capability mismatch is a pre-existing main defect
and is filed as one, not fixed here.
It also has no business in the grant on its own merits. A plan task is an
investigator that reports to the parent; the parent owns the plan list.
WHY NO TEST CAUGHT THIS. internal/agent's readOnlyRegistry enumerated its own
copy of the plan grant, and the copy differed from the authoritative list by
exactly one entry — update_plan, the entry that broke it. So
TestAReadOnlyRunDropsTheConfirmationPolicy asserted the policy was dropped for a
grant that was not the grant, and passed green for the whole period real plan
children carried it. Invariant 5, in a test rather than in production. The
helper now reads specialist.PlanReadOnlyToolNames.
Three mutations. U1 — putting update_plan back — is MISSED by the agent tests
and the fixture now says why in place: a grant name outside the scoped core set
is invisible to it. That half proves "these read-only tools drop the policy";
the other half, that every name in the grant is read-only BY CAPABILITY, is
TestEveryPlanGrantToolIsReadOnlyByCapability against the real registry, where
the same mutation is CAUGHT. Neither alone is the guarantee.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Opt-in via `background: true`, default off, so nothing changes unless asked.
THE CONTRACT IS INJECTION, of the three the report offered. The agent loop
already drains post-edit diagnostics into the conversation tail as a user-role
message — background work reporting into a later turn has a channel here, and a
second one would be a second ordering and a second thing to budget. The
completion goes down the same path, at the same point in the turn.
THE SEAM IS A LAUNCHER, NOT A CONTEXT. A background plan must outlive its tool
call and must not outlive its session, and only the surface running the session
knows where that ends. So the surface supplies a launcher, keeps the context and
drains on exit; the tool asks to be run and is told yes or no. Headless exec's
refusal then falls out of the wiring rather than being special-cased: that
process exits when the turn ends, so it supplies no launcher, and the refusal
says exactly that.
THREE DEFECTS FIXED THAT THE FEATURE WOULD HAVE LANDED ON, all found by sizing
it before writing it:
1. Card keys collided. `dispatched` reset on every Attach, safe only while
every plan lived inside one run; a background plan still dispatching when
the next run attached would restart the counter and hand the new run's
tasks keys the old plan was still using. Now monotonic for the bridge's
life.
2. Progress was silently dropped. The stale-run guard discards anything whose
runID is not the active one — right for a finished run's leftovers, wrong
for a plan still working. The panel would have frozen with no error, no
card, nothing. The guard is now background-aware in all four handlers, and
a test asserts a stale FOREGROUND message is still dropped.
3. The goroutine would have outlived the session. The TUI root was
context.Background(), never cancelled. Now a cancellable session root, and
Close cancels AND WAITS — returning early would let the process exit while
a child was still being torn down, which is how orphans happen.
One at a time, by rule. Two background plans report into one panel keyed by task
id and the second would overwrite the first's rows; refusing the second is
stated, letting the UI silently pick one is a defect.
A background plan returns "NOT finished", never a summary. Returning one would
be reporting work that has not happened, which is this repo's oldest defect
class at the point where it would be least visible.
Eighteen mutations. G7 was a genuine miss — the terminal message's background
mark, which has to be captured BEFORE the flag is cleared and carried down.
Without it a background plan's panel freezes one row from the end: every task
done, the plan never closing. It was caught earlier only because the compiler
complained an unused variable, which is luckier than it deserved to be, and now
has a test. G12 read as a miss and was an inert mutation; re-run as "never
marked at all", CAUGHT.
Verified in the real binary: headless exec refuses it with the reason.
Additivity re-proven, five modes byte-identical. The TUI path needs a user run.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The last of §5.5. It was left out of the control commit deliberately, because
restarting needs the plan's ARGUMENTS and nothing stored them; named plans
(b2f22d2) made the bridge keep them, so this is now a thin layer rather than a
reconstruction.
It stages the plan the BRIDGE holds, not the panel's rendering of it. The panel
has ids, statuses and depths — restarting from that would run something that
merely resembles what ran. Staged as a saved plan rather than replayed as a tool
call, so `/plans show last_run` reads exactly what is about to happen and it
travels the one execution path every other plan travels.
RESTART AND RESUME ARE OPPOSITES and the test asserts them together, because the
whole risk is that one silently becomes the other: restart ignores the session's
recorded progress entirely, resume honours it.
Refused while a plan is running. Two plans reporting into one panel keyed by
task id means the second overwrites the first's rows, so the rule is explicit
rather than letting the UI silently pick one.
Four mutations. Two initially read as MISSED and were bad mutations, not missing
tests — one was inert, and the other hit the FIRST `m.planProgress.LastPlan()`
in the file, which belongs to savePlanText. That is the same wrong-occurrence
trap this project hit once before; re-targeted at the last occurrence, both
CAUGHT.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Found sweeping for siblings of the OrchestrateAvailable defect, and it is the
same family: `OrchestrateTool.Limits *Limits` was read by limits() and set by
NOTHING — not a production call site, not a test — so `if tool.Limits != nil`
could not be true.
It is worse than merely dead. It sat directly beside Size, which is the knob
that actually works, so a reader looking for how a plan's caps are configured
would find the one that does nothing first.
Deleted rather than wired, because nothing needs it: every value it could have
overridden is already derived from something real — the tier for the task
ceiling, the run's grant for the tools, the run's depth for the headroom check.
Wiring it would have been building a second way to set caps that no caller
asked for.
That is three instances of this family in this feature now: ParentTools
(declared, forwarded, consumed, populated nowhere), OrchestrateAvailable (same),
and this. The first two were found by running the binary; this one only by
looking for them on purpose.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Found by the user running the binary: the posture turned on and nothing
orchestrated.
AgentOptions.OrchestrateAvailable was declared, documented, and consumed by the
reminder selector — and set by NEITHER production call site. So the enter notice
that names the tool could not fire, in either surface, ever. The tool was
advertised in the tool list and the conversation never mentioned it, and a model
handed an unfamiliar tool it was never told about does not reach for it.
This is invariant 1 for the SECOND time in this feature — the same shape as
ParentTools, which was likewise declared, forwarded, consumed by two guards, and
populated at neither site. Both were found the same way, and it was not by a
test: a unit test on zeromaxingReminders passes the flag in itself, so it stayed
green for the whole period the field was set nowhere.
Derived from the registry rather than passed as a flag, and set ONCE in the TUI
rather than beside each of the three places it assigns Zeromaxing. A bool a
caller has to remember is how this happened; the registry is the thing that
actually knows.
ALSO FIXED, a regression from b2f22d2: adding the `saved` argument dropped both
tasks and budget from Required, which emits no "required" key at all — so the
model was handed a tool whose every argument looked optional. tools.Schema has
no oneOf, so the constraint that is actually true, "tasks and budget, or saved,
never neither", cannot be spelled there. It is spelled in Description, which is
what a model reads for intent, along with when the tool is worth using at all.
Four mutations, all caught, including the two that ARE the reported bug — exec
not setting it, and the TUI not setting it. They are caught by driving Run() to
the wire and by capturing the tui.Options the TUI is handed, because those are
the only levels at which this was ever visible.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan format nobody has seen an example of is a format nobody writes. `/plans
show research` reads the shape working, `/plans run research` runs it, and
neither needs a file on disk.
IT ENCODES THE HOUSE STYLE ON PURPOSE, because a starter plan that merely
demonstrated the JSON would teach nothing worth teaching. The three searches are
INDEPENDENT — by definition, by call site, by test — because one search angle
finds one kind of thing, and a synthesis over three blind searches is the
argument for fan-out at all. The last task is told to REFUTE and to default to
refuted when uncertain, because a verifier that sets out to agree always does.
That is the verified-vs-refuted distinction the report asked for.
It fits the SMALL tier, asserted by test: a shipped example that a user with the
tightest ceiling cannot run is an example for everyone except the people most
likely to want one.
SHADOWED, NEVER AUTHORITATIVE. Builtin loses to user, user loses to project, so
shipping an example can never override something someone wrote — and the
listing does not count bundled plans as the user's own, or a fresh install would
read as though they already had plans and drop the line telling them how to save
one.
SavedPlan.Project became SavedPlan.Scope. A bool could say "project or not", and
there are three origins now: "not project" would have meant both "the user's"
and "shipped with the binary", which is the one distinction a listing exists to
draw.
Four mutations, all caught, including the ordering one that matters most — a
bundled plan placed after the disk scan would silently replace a user's plan of
the same name on the next release.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The write half put the five lifecycle events in the session log from both
surfaces. This reduces them back into plan state, which is the shape
ARCHITECTURE.md asked for: a deterministic reduction over the five events, with
no second store to disagree with.
A DISPATCH WITH NO TERMINAL EVENT IS UNFINISHED, NEVER DONE. Dispatch is written
before the child runs precisely so a task in flight when the process died is
distinguishable from one that never started, and it is the task most likely to
have been interrupted mid-work. Treating it as complete would silently drop
exactly that one. A failed task is remaining too: unlike a success it produced
nothing to keep.
WHY RESUME NEEDS A SAVED PLAN, and it is a consequence of a decision made two
commits ago rather than an oversight. The event payloads are deliberately small
— ids, counts, durations, terminal status, not prompts — so the log knows which
tasks finished and cannot know what they were asked to do. Rather than growing
the log into a second copy of the plan (a fifty-task plan's prompts, written
again, per run), the plan supplies the work and the log supplies the progress.
Neither has to become the other.
NARROWING REMOVES A COMPLETED TASK AND EVERY REFERENCE TO IT. Leaving the edge
would produce a plan whose dependency names nothing, which ParsePlan refuses —
correctly — so a resume would fail on its own output. Rewriting the edge to the
next task along would invent an ordering nobody declared. A dependency on work
already done is simply satisfied. The remainder goes back through ParsePlan, so
it is re-validated against the CURRENT run: it cannot keep a tool grant this run
does not hold, or a task count the tier no longer allows.
The remainder is STAGED AS A SAVED PLAN rather than taught to the tool as a
"skip these ids" argument. It is a plan — it validates, it runs, /plans show
reads it before anyone spends a token — and it travels the one path every other
plan travels. A skip list would have been a parallel notion of what a plan is,
resolved somewhere other than ParsePlan. It overwrites a fixed name, because a
directory of sweep-resume-1, -2, -3 is a directory nobody can tell apart.
`/plans resume` is overloaded BY ARITY, and the two meanings are the same
intention at two scales: bare, it un-pauses the plan running now; with a name,
it picks up a saved plan where its last run stopped. Bare resume keeps exactly
the meaning it had, which is asserted rather than assumed.
Eleven mutations, all caught, including the two that would have been silent in
production: an unfinished dispatch counted as done, and a second plan in one
session inheriting the first plan's completions.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan that took a real sweep to get right could only ever run once. Saved plans
live at .zero/plans/<name>.json (project, checked in and shared) or the user
config equivalent, with project shadowing user — the same precedence
usercommands and the specialist loader already use.
A PLAN IS STORED AS ARGUMENTS AND RE-ADMITTED THROUGH ParsePlan. Never as a
serialised Plan: the fields are unexported and ParsePlan is the only constructor
precisely so nothing can enter execution having skipped validation, and a stored
object that deserialises straight into an executable would be that hole with a
filesystem in front of it. So a saved plan is re-validated against the CURRENT
run — a plan saved when the tier was large is refused when it is small, and one
naming a tool this run does not hold is refused rather than granted. Proved with
the real binary, not only in tests.
RUNNING ONE IS NOT A NEW EXECUTION PATH, which is the deliberate divergence from
the report. The report wants saved plans to become /name slash commands; they do
not. A plan is not a prompt, and a command that reached into tool execution
would need its own copy of the posture gate, the depth check, the grant
intersection and the recorder wiring — this feature's whole defect history is
second call paths carrying less than the first. Instead orchestrate takes a
`saved` argument, /plans run dispatches an ordinary prompt, and by the time
ParsePlan sees it a stored plan and a model-supplied one are indistinguishable.
A saved plan runs AS IT WAS SAVED: `saved` alongside tasks/budget/name/
description is refused rather than merged, because a half-overridden plan is not
the plan whose name is still in the transcript.
The name is the PATH GUARD as well as the name guard, and it is an allow-list:
no separator, dot or traversal component can be spelled with those characters,
so "../../etc/passwd" is refused by the same rule that refuses a space. Symlinks
are refused on the file AND the directory, on write and on read — without it
"save my plan" is a file-overwrite primitive. A malformed plan file is reported
by name rather than skipped, and a lookup past one says the file was unreadable
instead of "you have no plan by that name".
Save-from-run reads the bridge, not the panel. The panel holds a RENDERING of a
plan — ids, statuses, depths — and saving that would write something that merely
resembles what ran.
Thirteen mutations. N10 could not be killed and the code now says why rather
than implying otherwise: the re-admission on the SAVE side is unreachable,
because those args came from a Plan that was already admitted. It is handled
because ignoring a returned error is how a reachable version goes wrong later,
not because it guards anything today. The re-admission that DOES fire is on the
way back in, and a hand-edited file that is valid JSON and an impossible plan is
now refused there by name.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The large plan-size tier allows fifty tasks. Three surfaces truncated a long
plan and all three took the FIRST N rows, so task 40 would be running while the
user watched tasks 1 to 12 with "+38 more" underneath. A progress display that
cannot show the thing in progress is not one, and the previous ceiling of twenty
is the only reason this was survivable before.
The window FOLLOWS rather than scrolling. Keeping the anchor — the running task,
or the one the user selected — in view means the list moves itself through the
surfaces that already exist, clicking and ctrl+g, rather than needing a scroll
offset and another chord in a keymap where seventeen ctrl letters are taken.
A running task OUTRANKS the selection. Someone who clicked a task to read its
detail is still watching the plan move, and pinning the list to their last click
would hide the work; the detail pane shows the selection either way.
ONE window function, three callers. The footer panel, the sidebar list and the
sidebar's HIT TABLE each derived their own slice, and they had already drifted —
two took the head, one took the tail once everything faded. The renderer and the
hit table are two doors onto one state, so that drift is a click landing on a
different task from the one drawn under the cursor. The test asserts EQUALITY
between them, which is the relationship two doors have.
Both directions are named. "+38 more" under a list whose first row is task 30
reads as though the plan has 68 tasks.
Seven mutations, all caught. The bounds are exercised directly across every
count/limit/anchor shape rather than only through a rendered plan, because that
is the part that panics when it is wrong — and it found the degenerate case
where a zero-height window reported no hidden tasks at all instead of all of
them.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Abandoning a twenty-task plan meant Ctrl-C, which cancels the whole run and
takes the conversation with it. "Stop this plan" and "stop this turn" are
different intentions and now have different acts.
THE CANCEL IS DERIVED IN ExecutePlan, not in the orchestrate tool. The first
version put it in the tool, which would have given per-plan cancellation to
exactly one call path — the recurring defect of this codebase, written on
purpose. ExecutePlan is the function that owns a plan's lifetime, so the seam
belongs where the lifetime is, and every caller gets it.
Control arrives through the SAME seam the display does: PlanController is
type-asserted off the recorder exactly like PlanLifecycleRecorder, so nothing
that only records is affected and no existing signature changes. The surface
that shows a plan is the one a user asks to stop it.
PAUSE HOLDS AT A TASK BOUNDARY, and the message says so. A child already talking
to a provider cannot be suspended; a pause claiming to be immediate would be
claiming tokens had stopped being spent when they had not. The gate is consulted
BEFORE the cancellation check so stopping a paused plan is not a wait for a
resume that will never come, and resume is a CLOSED channel rather than a signal
so a waiter arriving after the resume never blocks on a send nobody makes.
The handle is dropped when the plan ends. A stale cancel would let a later stop
cancel a context that has since been reused — the PostureGate lifetime mistake
in another costume — and a new plan never inherits the last one's pause.
A SLASH COMMAND, not the reference's bare p/x/r. Bare letters go to the
composer, seventeen ctrl chords are already bound, and — the actual reason —
only commandPrompt is queued while a run is pending, so a slash command is
dispatched immediately. That is the entire requirement for "stop the plan that
is running right now". Bare /plans is unchanged.
NOT BUILT, both from this gap item, both with reasons. RESTART needs the plan's
arguments and nothing stores them; the panel holds a rendering of a plan, not a
plan, and re-running from that copy would produce something that merely
resembles what ran. That is what named plans (§5.7) is for. FILTER solves the
second problem first: a 50-task plan needs SCROLLING, which is the row cap.
Eleven mutations, all caught after one repair. The miss was the same shape as
the last commit's: the comment on StopPlan claimed clearing the pause is what
releases the parked executor. It is not — WaitWhilePaused selects on ctx, so the
cancel does that on its own. What the clear actually fixes is the reported
STATE, which otherwise goes on offering "/plans resume" for a plan being
abandoned. The test now asserts both, because they are not one thing.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The watchdog file said retrying needed its own argument before it was built.
Here it is.
ONLY A STALL IS RETRIED, and that asymmetry is the entire justification. A task
whose child ran and reported an error produced an ANSWER; running it again
spends another child to receive the same one. A stall is the opposite — it is
the ABSENCE of an answer, so there is no result to disbelieve and nothing
partial to lose. Every other failure behaves exactly as it did before.
That is why the retry keys on TaskResult.Stalled, set by the runner from the
watchdog's own verdict, rather than on "the task failed" or on a phrase in the
error text. Deciding to spend another child by matching a message is the same
class as comparing errors with == instead of errors.Is, which is what silently
disabled every stall retry in the prototype.
THE DEFAULT IS ONE EXTRA ATTEMPT, and that is a judgment, not a measurement. It
costs a genuinely wedged task twice the stall timeout before the plan moves on —
six minutes at the default — in exchange for surviving a transient provider
condition without losing the task's dependents with it. Under a posture whose
premise is spending more for a better answer, six minutes of patience is the
cheaper mistake. A caller who disagrees sets max_retries to 0, which is why an
explicit 0 has to be distinguishable from an absent key: planInt returns 0 for
both, so the budget parses PRESENCE. An unset value reading as an explicit one is
the invariant that made an empty tool grant expand to the full read-only
category.
THE RETRY LIVES IN THE EXECUTOR, not the runner. The executor owns the budget,
the wall deadline, cancellation and the record; a retry hidden inside the runner
would spend a second child's tokens with none of them counting. Duration and
tokens come back as the TOTALS across attempts, so a plan's reported spend is
its real spend. Every reason to stop is checked before launching another child —
a cancelled run, an expired wall deadline, an exhausted attempt budget. The
prototype's equivalent loop retried a cancelled task, which turned Ctrl-C into
another spawn.
One dispatch per task, so the panel still shows one card and the transcript
keeps one result per tool call. The count reaches the summary, the session
events and the task detail, because a retried task's totals otherwise read as one
extraordinarily expensive attempt.
Seventeen mutations. The one that MISSED is the reason this note exists:
TaskResult.Stalled — the flag the whole policy turns on — was produced by the
real runner and asserted by nothing, because every retry test supplies it from a
fake runner. Same shape as the budget meter fed by a counter NewPlanRunner never
populated. A fake that fabricates its own inputs cannot test the producer.
Driven through the real binary, not only the tests: a wedged child with
max_stall_seconds 30 produced two child requests and "stuck [failed] 1m10s (2
attempts)", the independent task still ran, the dependent was skipped, exit 4.
With max_retries 0, exactly one child request and 35s.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan's task count was bounded by ONE hard-coded number with no way to move
it. Twenty was too many for someone on a metered provider and too few for a real
sweep, and the only way to discover it was to read plan_tool.go.
profiles.planSize now names the ceiling: small 5, medium 20, large 50,
unrestricted. NAMED rather than numeric so the config file says what it means
and so "no ceiling at all" is expressible without a sentinel integer.
TWO DELIBERATE DIVERGENCES FROM THE REPORT, both worth stating.
It stays a HARD CAP, not the advisory warning the reference implementation has.
Under this posture every plan task is a child inheriting a 320-turn ceiling, so
fifty tasks authorise 16,000 child turns from a single tool call. A warning the
model emits to itself is not a bound on that. What the report actually wanted —
that the number be discoverable and movable — is delivered by the rejection
naming the tier and the setting instead of printing a bare integer.
And medium is 20, not the reference's 15, because 20 is the number
defaultPlanMaxTasks already held. Making a bound configurable and moving it in
the same change hides a behaviour change inside a mechanism change; the tier
mechanism is the deliverable and the number stays where it was.
PROJECT CONFIG MAY ONLY TIGHTEN IT. A cloned repo can ask for a smaller ceiling
and can never raise one, because raising it raises a spend ceiling for whoever
opens the repo. Same privilege boundary as Sandbox.Network's allow/deny rule and
DisableZeromaxing's disable-only rule, and a looser tier is dropped silently the
way an ignored network "allow" is: the project scope simply does not hold that
privilege, which is not a malformed config.
Fail closed throughout. An unrecognised tier resolves to the default and never
to "no ceiling" — a typo must not be the thing that removes a bound — and the
same is true when the early config resolve in exec fails.
The mutation sweep found that rank() returned -1 for an unknown tier with a
comment claiming that made it safe. It does the opposite: the tighten-only
comparison adopts the SMALLER rank, so -1 would have beaten every real tier.
Nothing was reachable through it, because ParsePlanSize rejects the name first —
but a defence-in-depth value that only holds because of the guard in front of it
is not defence in depth.
Verified against the real binary, not just the tests: the same six-task plan is
admitted with no setting and refused at 5 with planSize small, and a project
config asking for unrestricted against a user config of small stays refused.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The report asked for an append-only journal beside the session store. It is not
needed, and ARCHITECTURE.md says why: "Plan state is a deterministic reduction
over the five events. No new store."
The five events already reached the session log under `zero exec`. The TUI wrote
NONE of them — PlanProgressBridge drove the panel and persisted nothing — so a
plan was durable or not depending on which surface you ran it from, and only one
of the two could ever be resumed. That was the actual gap; a journal would have
been a second store papering over it.
Events are appended AS THEY HAPPEN, not batched to run end. Batching would lose
exactly the crash the durability is for. Written from the TOOL's goroutine,
never the Bubble Tea loop — appending is file I/O and the loop must not block on
it. Dispatch is written BEFORE the child runs, so a task that was in flight when
the process died is distinguishable on resume from one that never started; that
ordering IS the guarantee.
THE PAYLOADS NOW HAVE ONE AUTHOR. They were built in internal/cli, and a TUI
copy would have drifted — with the drift only surfacing when someone resumed a
plan recorded by the other surface. specialist.PlanAdmittedEvent and its four
siblings are now the single source both recorders append, and a test compares
the two by round-tripping through JSON, the form they are actually stored in.
Best effort at every level, mirroring execSessionRecorder.append: no store, no
session, or a latched earlier failure and it does nothing. The first failure is
latched and readable, so a surface can say once that the plan was not fully
persisted rather than leaving a user to believe it was.
Five mutations, all caught. The fifth needed a test that drives beginRun rather
than the bridge: a recorder that records perfectly and is never bound records
nothing, which is the shape this feature keeps producing.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
runResume resolved the manifest by session.AgentName and ignored
params.Manifest entirely, while the fresh path honoured it. That is not merely
"inline-manifest children are unresumable", which is how it was filed — it is a
WIDENING.
A plan task launched under a parent holding only grep carries the inline grant
[grep]. Resuming it reloaded the registered explorer manifest and handed the
child back [glob grep list_directory read_file read_minified_file]. The
parent-grant narrowing that Workstream A made real was undone by resuming.
Measured, not inferred: the registered explorer resolves five read-only tools;
a plan grant under --enabled-tools grep is one.
Both paths now share ONE resolver — an inline manifest wins, validated exactly
as before, and only a caller that supplied none falls back to the registry. The
session's identity still governs: a mismatched specialist name is refused, and
the registry fallback still looks up the specialist the session belongs to.
Not currently reachable from a plan (NewPlanRunner sets no Resume), so this is a
latent widening rather than a live one — and it is the defect that stood in
front of Stage 2c, since plan tasks are exactly the inline-manifest children
that could not resume.
Four mutations, all caught. One test needed fixing first and the failure was
instructive: the fixture set ResolvedTools but not Metadata.Tools, and Validate
re-resolves from Metadata.Tools — so the fixture was re-expanded to the default
read-only category and the test "failed" against correct code. It now uses
planTaskManifest itself, which is what a plan task actually carries.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Removing the token cap left max_wall_seconds — optional and model-supplied — as
the only bound on a plan, and NOTHING bounding a single task. A child that
wedges against an unresponsive provider hung until the user noticed.
KEYED ON SILENCE, NOT DURATION, and that distinction is the whole design. A task
reading forty files for four minutes is working; one that has emitted nothing
for three is not. Killing the first would be worse than having no watchdog,
because a timeout that fires on healthy work teaches users to raise it until it
never fires. The clock resets on every stream event the child emits.
Default 180s, settable per plan via budget.max_stall_seconds, floored at 30s —
below that it fires on ordinary think-time and becomes a random task-killer. The
floor is enforced on MODEL input in planBudget, not at construction, so internal
callers with an already-validated value are not second-guessed.
It cancels the TASK's own context, derived from the caller's, so a wedged task
never takes the plan with it; the dependents skip as they would for any failure.
A stall reports as a stall, not as a cancellation: both surface as a context
error, and a user who stopped a plan and a plan that hung are looking at very
different problems.
A BOUND, NOT A RETRY POLICY. A stall is usually a provider or network condition
that a second identical attempt hits again, and spending another task's budget
to find that out needs its own argument.
The runner now always hands the executor a progress callback, because the
watchdog needs the liveness signal whether or not a UI wants the events.
TestPlanRunnerWithoutProgressPassesNil asserted the opposite and was right until
this existed — with no feed, a child talking happily would be judged silent and
killed. The executor is unaffected: `progress != nil` gates only the call, and
the event is parsed and stored regardless.
Ten mutations, all caught — but four of them only after fixing the tests:
Two were indistinguishable. The wedged-child test's child emits nothing, so
dropping the watchdog's feed and using the parent context both still produced
a stall. A chatty-child test (speaks every 10ms, 60ms timeout, must survive)
and a parent-context-liveness test tell them apart.
One test could HANG rather than fail: with the watchdog disabled, the wedged
child blocked forever and took the whole mutation sweep with it. A test for a
hang must not be able to hang; it carries its own deadline now.
And the sweep itself needed per-run timeouts, for the same reason.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The chip is now clickable — pressing it opens /effort, where the posture can be
turned off or changed — so it highlights under the cursor. A clickable chip that
looks identical to a label never teaches anyone it can be pressed.
Not while a turn is in flight: /effort refuses mid-run, and opening a dialog
that cannot be acted on is worse than opening none.
Its hit region is measured from the RENDERED footer rather than assumed. The
chips before it — permission mode, effort — vary in width with the session, so
a fixed offset would drift the moment either changed.
The sidebar's plan rows highlight too, joining the agent, plan-step and file
rows that already did. The hover is held by TASK ID, not row index, for the
reason the others hold identities: the rendered row set changes as tasks finish
and fade, with no mouse motion in between to re-resolve it, and a cached offset
would light up whatever slid into the slot.
The /effort picker already highlighted its rows on hover — selectGenericPicker-
AtMouse live-previews on motion — so the zeromaxing row needed nothing.
Six mutations, five caught. The sixth is belt-and-braces rather than a gap and
the comment now says so: the posture-off guard in the chip's hit test is a fast
path, not the enforcement — with the posture off the footer carries no chip, so
the span lookup fails anyway.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The posture raises a cost multiplier — 320 turns per run, inherited by every
sub-agent — and it was a word in amber, in the same weight as everything beside
it. "Is it on?" should be answerable from across the room.
The footer chip is now a FILLED badge: the label sits on the amber rather than
in it, which is what reads as lit rather than as another label in the row. Off
still means ABSENT, not dim — the footer is byte-identical without the posture.
It breathes while a turn is in flight, ◦ • ● ◉ ● •, on a 1.4s cycle. Slow on
purpose: this marks a standing state, not activity, and a fast blink beside a
working spinner reads as a second thing happening.
THE PULSE IS FREE, and that constraint shaped it. It advances on the spinner
tick that is ALREADY running during a turn and holds a steady full glyph
otherwise. ensureSpinnerTick deliberately schedules no timer on an idle session
— "an idle plain session schedules no timer" — and a chip is not a reason to
break that. Steady rather than frozen mid-cycle, because a half-lit chip on an
idle session would read as a rendering fault. Reduced motion pins it, like
every other animation here.
The /effort picker marks the posture row too, so what you are about to turn on
looks like what you will see once it is on. Marked with a glyph rather than
merely coloured: the selected row already owns its background, so colour alone
would be invisible exactly when you are looking at it. The marker is display
only — the VALUE handed to the command stays the bare name, or selecting it
would be refused as an unknown effort.
Seven mutations, all caught. Two needed the tests fixed first: the chip test
called the helper rather than the footer, so reverting the footer to plain amber
text passed it; and the value test located the row BY value, so the moment the
marker leaked into the value the row stopped matching and the test passed by
finding nothing.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The overlay was wrong and the screenshot showed why: composited into the
transcript column, a twelve-task plan drew its phase list straight through the
user's own prompt. It is gone.
The right column already owns "what is happening" — agents, files, activity —
and had half a screen padded with blank lines down to the token floor. The plan
lives there now:
PLAN 1/8
█████████░░░░░░░░░░░░░░░░░ 1/8
✓ t-sandbox x
✗ t-agent y
• t-tools Read the Go package at…
○ t-redaction
+2 more
TASK t-tools
running
52 tool calls · 21,400 tok
blocks pair-a
↳ read_file internal/tools/registry.go
Read the Go package at internal/tools
A progress bar that keeps done, failed, skipped and running in their own
colours inside one span, sized to the column rather than to a fixed width, and
never rounding a segment away — one failure in thirty still gets a cell,
because a failure the bar hides is a failure it does not report.
TASK fills what was dead space, and yields first when the column is short: the
list above it is what the section is for. It shows the REVERSE dependency edge
too — what a task blocks — because a failure matters in proportion to what
waits on it and the forward edge alone does not say.
Click a task to select it, click PLAN to collapse the section, ctrl+g to cycle
the selection from the keyboard. The sidebar is not focusable, so a mouse-only
affordance would not be one.
THE OFFSET ARITHMETIC IS THE RISK HERE, not the drawing. The progress bar sits
between the header and the first task, so every clickable row below it moves
down one — in the plan's own hit table AND in the FILES section beneath. Both
are adjusted, and both are asserted against where the row actually RENDERS
rather than against a re-derivation of the sum: the first version of the FILES
test recomputed the arithmetic and would have passed with the bar unaccounted
for on both sides.
Nine mutations, all caught. Two needed the tests rewritten first — the FILES
one asserted nothing, and the bar one used numbers that never round to zero.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The right-hand column showed one grey line — "trace-chain 3/6 · helpers" — for
a plan the panel below it was rendering in full. It now carries the plan
properly: a progress count in the header and one coloured line per task, in the
same shape the update_plan steps use so the section reads consistently whichever
plan is in it.
green ✓ done · red ✗ failed · accent • in flight · faint ○ pending
faint ⊘ skipped or cancelled — deliberately NOT red, since neither is a defect
The header count turns red on a failure and green when everything is done, so
the one thing worth noticing carries from across the screen.
THE LINES LIVE INSIDE sidebarPlanLines, and that is not a detail.
sidebarFileSelectables computes the FILES section's click offsets from
len(sidebarPlanLines); the previous one-liner sat beside the placeholder and was
safe only because it was exactly one line. Rendering a whole section there would
have misdirected every file click by the number of lines added, silently. Inside
the function the arithmetic stays correct by construction.
Live tasks first, so a long plan shows what is happening rather than what
already happened, bounded at six lines with the remainder stated — the column is
26 to 40 cells wide and shares its height with AGENTS, FILES and ACTIVITY. The
panel and the detail view are where a whole plan is read.
update_plan's own steps still win the section when it has any: the two are
different things and the TODO list is the one the model is actively editing.
The plan's NAME is not in the sidebar. At this width it would cost a task row,
and it is on the panel and in the detail view.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Pressing the PLAN header opens a two-pane drill-in: every phase down the left,
the selected phase's live agent state on the right — status, elapsed,
dependencies, tool count, spend, its prompt, what it is doing right now, and
where it ended up. Up/down moves, enter drills into the child session, esc
closes.
An OVERLAY, in the same slot the help and picker overlays use, so it composites
over the chat rather than replacing it. Nothing about the transcript, footer or
composer changes, and it renders nothing when closed or when no plan has been
admitted — which is what keeps a posture-off session unchanged. No separate
posture gate: a plan only exists under the posture, so without one there is no
PLAN line to press.
The panel's own expand/collapse stays on ctrl+g, for a glance without leaving
the conversation.
Opening lands on the task worth reading: the one running, else the first that
failed, else the first task. The selection clamps rather than wraps.
Enter is offered ONLY when the selected task has a child session to open. A
running task is still keyed by a temporary id, and a hint for a key that does
nothing trains the user to ignore hints.
The phase list marks the task in flight. Running and pending both fell into the
default glyph, so the list could not show what was actually happening.
Prompts are SUMMARISED and say when they were cut; the full text stays in the
tool output. A task that never started shows what it is waiting on rather than
a row of zeroes.
Eight mutations, all caught. Two needed the tests fixed first: the running-task
assertion compared whole rows, which differ by the selection marker anyway, so
removing the in-flight glyph passed; and nothing drove the click path at all.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The footer read "budget 0/200000 tokens" for an entire run while the cards
directly above it showed 84,953 spent. tokensUsed was assigned in exactly one
place -- from plan_completed, which arrives when the whole plan ends -- so the
one number a user watches that line for was zero for as long as it mattered.
Spend now accumulates as each task finishes. The executor's own total still
wins at the end, since it counts every task including any whose message the
panel dropped as stale, but a plan that reports no total no longer erases what
was counted.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
All five from one screenshot of a six-task chain, all in what the run REPORTED
rather than in what it did.
"error (exit code 0)" on a failed task, directly above a body reading
"Subagent failed (exit 4)" -- the card contradicting its own detail. A plan
task's failure carries no exit code, and rendering the zero value claimed one
it did not have. Only shown when non-zero now.
Dependency-skipped tasks rendered "error". specialistCancelled landed in
specialistStatusString's default arm, so a plan with ONE real failure showed
three errors: the failure plus every task skipped behind it. The default stays
"error" so an unmapped status still fails closed.
"0 tokens" in the specialists rollup while the plan reported 130,135. Nothing
has ever populated specialistInfo.tokenCount -- not for plan tasks, not for
Task sub-agents -- so the total was always zero. The segment is now omitted at
zero, matching the per-card rule (M18): a number that looks measured and is
not is worse than no number.
A plan task's spend now reaches its card, so the rollup adds up to what the
plan reports. Task sub-agents still bridge no usage; that gap is unchanged and
now shows as an absent segment rather than a false zero.
The sidebar said "no active plan" while the panel two lines below showed one
mid-flight. Its PLAN section reads the update_plan TODO list and knew nothing
about orchestrate plans. It now shows a one-line summary -- name, done/total,
failures, the task in flight -- when an orchestrate plan is running. EXACTLY
one line, because the FILES section below computes its click offsets from the
lines above it.
The sidebar test drives renderContextSidebar rather than the line helper: the
first version called the helper directly, and the mutation that removes the
section from the sidebar entirely still passed.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The popup that /effort opens showed a single "auto" row on glm-5.2. Reported
from use, with a screenshot; I had just told the user no such picker existed,
which was wrong -- newEffortPicker has been there all along.
It read availableReasoningEfforts(), the CATALOG's answer. A model with no
catalog entry has an empty ring, so the picker offered nothing settable -- on
the model this user runs. And it never offered the zeromaxing posture on ANY
model, even though picking one routes straight into handleEffortCommand, which
accepts it.
The picker now shares settableEfforts() with the /effort card, so the three
surfaces that answer "which efforts can I set" -- card, picker, command --
cannot disagree. Catalog authority is preserved: gpt-4o, which the catalog
vouches has no reasoning controls, still offers only auto and the posture.
It also preselects the active posture. Under zeromaxing m.reasoningEffort holds
"high" -- the level the posture FILLED -- so preselecting from that alone would
highlight the wrong row.
The parity test now feeds every value from BOTH surfaces back through the real
command, and asserts the two offer the same set. The picker is the surface that
actually failed in use while the card's own test passed, which is the argument
for testing them against each other rather than each against its own
expectation.
Two existing tests encoded the old behaviour. One asserted "[auto] as the only
effort option on an unsupported model" -- the reported bug, written down as an
expectation; an uncatalogued model is not an unsupported one, and its levels are
forwarded. The other pressed enter expecting to land on auto, which only held
while auto was the sole row; it now selects auto deliberately and asserts the
preselection instead.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
TWO SEPARATE THINGS FROM DRIVING A REAL PLAN.
Finished tasks now drop out of the panel after a 5s linger, so it tracks live
work instead of accumulating a transcript of it. Long enough to see a task land,
short enough that a six-task chain does not fill the footer with history.
Matches how the AGENTS sidebar already retires finished agents.
A faded task is hidden, not forgotten: the header still counts it, and /plans
still lists the whole plan. That matters, because the dependency shape fades
with the tasks -- a diamond stops looking like a diamond once its first task
goes. /plans is where the shape stays readable for the whole run, and it names
each task's dependencies explicitly rather than relying on indentation.
/effort NOW SAYS WHAT IT ACCEPTS.
On glm-5.2 the card reported "available: not listed - model is not in Zero's
catalog" and stopped. Nothing typeable, on the model this user actually runs.
And on a catalogued model it listed low/medium/high while omitting zeromaxing --
which is selected through this very command, and which the actions line already
offered.
"available" (what the CATALOG vouches for) and "you can set" (what the command
accepts) are different questions and were conflated into one line. They are now
two fields.
The distinction is enforced, not decorative:
- catalogued with a ring -> exactly that ring
- catalogued with NO ring -> nothing but the posture. gpt-4o genuinely has no
reasoning controls and the command refuses every
level there, so offering them would advertise
what the command rejects
- not catalogued -> low/medium/high, because Zero cannot vouch
either way and the headless path forwards them
- posture disabled -> the posture is dropped from BOTH the list and
the actions line, since suggesting a command the
run will refuse is worse than saying nothing
The card and the command are two doors onto "which efforts can I set", so the
parity test feeds every advertised value back through the real command rather
than comparing two lists -- a list comparison would pass while the command
rejected all of them.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two changes from driving a real six-task chain.
THE TOKEN BUDGET IS NOW OPTIONAL, AND OMITTED MEANS UNBOUNDED.
max_tokens was required and capped at 200_000, and a chain asking for exactly
that spent 469,555 -- 2.3x over -- and still lost its last task. The check runs
only BETWEEN tasks: a task is dispatched whenever any budget remains and then
spends whatever it spends, so the cap bounded nothing while stopping real work
from finishing. A number that neither bounds spend nor lets a heavy plan
complete is worse than no number, because it reads like a guarantee.
Spend is still METERED and reported everywhere it was -- PlanReport.TokensUsed,
the plan_completed event, the panel footer -- so an unbounded plan is not an
unmeasured one. A caller that wants a bound still sets max_tokens and gets the
same dispatch-time behaviour as before, asserted by test in both directions.
This removes the only ceiling on what one orchestrate call can spend. Under the
zeromaxing posture a twenty-task plan authorises 6,400 child turns. That is the
user's decision, taken after seeing the cap fail to do the job it existed for.
THE PANEL COLLAPSES.
A six-task chain took seven footer lines for the whole run, pushing the
conversation up for detail that is one keypress away. The header alone is the
state; the tasks are detail. Click the header line to open it -- the interaction
that was actually asked for -- or Ctrl+G.
NOT Ctrl+O, which already toggles the detailed transcript view: the first
attempt bound it there and the existing handler silently won. Not Ctrl+P
either, which belongs to the update_plan panel.
The dependency indent is capped at five levels. A twenty-task chain adds a rung
per link, which would indent forty columns and run off a narrow terminal; the
depths still reflect the real graph, only the drawing stops. Past the cap every
task sits at one depth, which is honest for a chain.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A read-only run carried ~5 KB of confirmation policy governing actions it could
not take. Measured on a real plan-task child: 8,365 bytes of the 17,903-byte
system prompt -- 47% of it -- were policy for capabilities the child provably
lacked (no write, no edit, no shell, no ask_user), and the system prompt is 71%
of that request. Multiplied by every task in a plan and every specialist
sub-agent.
Keyed on the RESOLVED TOOL SET, never a name or a manifest flag, and fail closed
at every step: a nil registry, a run holding nothing, and any tool whose effect
is not positively read-only all keep the policy. EffectUnknown -- MCP tools,
plugin tools, the specialist tools -- keeps it, which is why an ordinary session
is untouched.
Two things this got wrong first, both caught rather than reasoned away:
It used ToolVisible, which applies the permission mode's ADVERTISING rules on
top of the operator filters. A write tool auto mode does not advertise is
still held and still callable once approved, so a run launched with
--enabled-tools read_file,grep,glob,write_file counted as read-only and lost
its policy -- a fail-OPEN exactly inverse to the purpose. Found by driving the
binary; every unit test passed at the time. It now asks ToolAllowedByFilters,
which answers "does this run hold the tool".
Reading the registered tool set made registering a posture-gated tool change
the prompt with the posture OFF, breaking the additive-only guarantee.
TestPostureOffPrefixUnchangedByRegisteringTheTool caught it, which is what
that test exists for. A tool the run can never call now says nothing about
what the run can do -- and a tool that varies its permission by argument is
never ruled out from its static safety, however that reads.
Verified on the wire: --enabled-tools read_file,grep,glob drops it (12,310-byte
prompt); adding write_file or bash keeps it (18,789). Additivity re-proven
byte-identical across all five permission modes.
Only the confirmation policy is gated. The remaining ~3.3 KB (Editing
discipline, Testing gate, Permission and safety) lives inside system_prompt.md
and needs that file split, which touches the prompt every run shares. Separate
change.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
planReadOnlyTools bounds what a plan task may hold; readOnlySpecialistTools
decides whether a specialist counts as read-only for the Task tool's permission
gate. Different questions, same answer -- and they disagreed: lsp_navigate was
in the first and not the second, so a manifest holding exactly the plan grant
failed the read-only check.
The omission was chronology rather than a decision. readOnlySpecialistTools was
written in #243 (2026-06-18); lsp_navigate arrived in #276 two days later and
nobody went back. The tool declares EffectReadOnly with the safety reason "reads
files, modifies nothing" and resolves its path through the same scoped
confinement its read-only siblings use, so it belongs in the set.
Pinned as a SUBSET, not equality, and the direction is the point: anything a
plan task may hold must be something the wider gate also calls read-only. The
reverse is not required -- update_plan is read-only for a specialist and
deliberately not a plan tool.
NOT derived from the tools package's declared capabilities, which would be the
real class fix: update_plan is EffectInteractive, so deriving the set would
silently drop it and change the Task tool's permission gate. That is its own
change with its own reasoning, not a drive-by.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Launching a specialist and resuming one are two doors onto the same question --
which model does this specialist run on -- and they answered it differently.
BuildResumeArgsInput carried no model fields at all and BuildResumeArgs never
called appendModelArgs, so a resumed child was launched with no --model and fell
back to whatever its own config resolved. The same specialist could change model
between its first turn and its second.
Verified by building both argument sets side by side rather than by reading:
FRESH args: exec --init-session-id … --model parent-chose-this …
RESUME args: exec --resume … (no --model)
The fix calls the SHARED helper rather than repeating its rules, so the
manifest-pins-its-own-model precedence is applied once and cannot drift between
the two paths.
The relationship the regression test asserts is EQUALITY (RULES.md §3): fresh
and resumed launches of the same specialist must produce the same model and the
same reasoning effort. A second test drives Executor.Run with Resume set, since
the builder having the fields proves nothing if the call site never fills them
-- which is exactly how the two paths diverged.
PRE-EXISTING main defect, not ZeroMaxing: it affects every resumed specialist.
Separate commit so it can carry its own issue.
Note for Stage 2c: runResume also resolves the manifest by session.AgentName and
ignores params.Manifest, which is why inline-manifest children are unresumable.
Same function, still open, deliberately not fixed here.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The struct comment always claimed a plan task "inherits exactly the parent's
policy". It did not: both production call sites left PlanTaskContext.ParentModel
empty, so appendModelArgs received no parent model and passed no --model, and
the child fell back to whatever its own config resolved. After a /model switch
or a --model flag, plan tasks ran on a different model than the run that
launched them.
Decided: the documentation states the safer behaviour, so the code moves to
match the doc. Explicitly NOT a router decision -- choosing a cheaper model per
task is a real future feature and would arrive as an explicit plan field on top
of correct inheritance. Silently failing to propagate is not that feature.
The fix is not "populate the field at registration", which would have been
wrong for the TUI: its registry is built once per session while /model changes
the model between runs, so a captured value is stale by design. That is why the
fields were empty rather than merely unset -- they were in the wrong home.
ParentSessionID/ParentModel/ParentReasoningEffort now travel per call on
PlanTaskRequest, read from the same tools.RunOptions fields the Task tool reads,
and are gone from PlanTaskContext so there is no second source to drift from.
Before: parent ran --model parent-chose-this, child reported stub-model.
After: child reports parent-chose-this. Asserted per call, so a second
orchestrate call with a different model launches on that model.
The parent session id travels with it, which is what links a plan task back to
the run that spawned it rather than leaving it an orphan.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Gate 1a made a plan's tasks visible as cards. Cards cannot show the one thing
that makes a plan a plan: its shape. This adds the panel.
The shape is carried on planAdmittedMsg, not assembled as tasks finish.
ParsePlan already proved the graph acyclic and computed the topological order,
so the panel is complete before the first task starts rather than growing a
node at a time. Each task is ranked by dependency depth -- one more than its
deepest dependency -- and indented by it, so a diamond reads as one node, two
beside each other, one node. A chain steps in once per link; independent tasks
share a rung. All three are asserted.
Mounted above the pinned update_plan panel, since it describes work running
right now. It renders NOTHING when no plan has been admitted -- not an empty
box, not a blank line -- which is what keeps a posture-off session unchanged:
the panel never runs, rather than running and producing nothing.
Named orchestrate throughout. /plan (update_plan's TODO list) and /plans (this)
are genuinely different things with near-identical names, so every type, field
and function here says orchestrate and only the user-facing command says plans.
The command lists every task including any the pinned panel had to drop, and
names each task's dependencies explicitly, so the shape survives copied text and
narrow terminals where indentation does not.
Bounded at 12 rows and it states what it hid: a silently truncated list reads as
a complete one. Summaries cut on rune boundaries. The full result stays in the
tool output -- the panel summarises, and a display formatter on a data path is
how a 583-rune work product became 200 mangled runes.
The header clock stops when the plan ends, and when the run ends without a
terminal plan event. It previously kept counting while the turn that produced
the plan carried on, so a 20-second plan read as minutes.
TestSuggestionOverlayCapsRowsWithoutMoreText pinned the first and last entries
of the command palette's first window; inserting /plans pushed /ps out of it.
The window's start and size are unchanged, so the tail anchor moves to
/permissions and the comment says why it is incidental.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
A plan ran silently: sequential, synchronous, no output until it finished. Four
things stood between the existing live-progress machinery and the plan tool,
all of them wiring rather than missing capability.
loop.go gated the progress callback on `call.Name == "Task"`, so the second
sub-agent-spawning tool got nil. Keyed now on the TOOL'S OWN DECLARATION
(tools.ChildProgressStreamer) rather than its name: `|| call.Name ==
"orchestrate"` would have been the same defect one name later, which is this
codebase's most repeated shape. Every tool that does not declare keeps the
nil callback it has today -- swarm tools included, deliberately.
NewPlanRunner built TaskRunOptions without Progress and RunWithOptions never
read options.Progress. Either half alone leaves a plan's children streaming to
nobody, so they are ONE change with one end-to-end test. This is finding 7's
class again: a second construction path that does not carry what the first one
did. PlanRunner now takes a PlanTaskRequest struct rather than a widening
parameter list, because each new positional argument is another chance for
that omission.
app.go never set Recorder, so the TUI recorded NONE of the five plan
lifecycle events (finding 9). The recorder is now a required POSITIONAL
parameter of registerSpecialistTools, like the operator filters: forgetting it
is a compile error rather than a silent nil.
TaskCancelled is a distinct outcome. Cancellation marked every remaining task
failed with "context canceled", so a deliberate Ctrl-C on a twenty-task plan
read as nineteen defects. PlanCancelled joins it as a terminal status, and
terminalStatus counts it -- without that a plan with two successes and two
cancellations reported "completed", which is RC-F exactly.
Also fixes a live render-cache collision this change would have made certain.
A rowSpecialist row carries no id and no text; every distinguishing field lives
behind row.specialistInfo, and none of it was in the cache key. Two specialist
cards in one run hashed identically, so a failed sub-agent rendered as a
successful one. Rare with Task (one child per run is typical), guaranteed with a
plan. Proven by test before fixing, then asserted with four cards including a
failure and a cancellation.
Additivity re-proven: posture off, all five permission modes byte-identical
against an origin/main binary with DeferThreshold unset, hashes unchanged.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
RULES.md §3: for every rule you touch, find its sibling and prove by test that
both agree -- one test comparing the paths on the same input, not two tests
each asserting their own expectation. The authority rule has two enforcement
points and Workstream A changed both; two sites fixed together is exactly the
shape that drifts apart later.
The relationship asserted is SUBSET, not equality, and the direction is the
point. Admission is stricter: it rejects a task naming anything outside the
grant. Dispatch narrows: it drops such a tool and grants the rest. Requiring
them to refuse identically would assert a symmetry the design does not want --
dispatch only runs on an already-admitted task, so when it disagrees its job is
to hand over less, never more.
Mutation-checked: dropping the parent check from the dispatch intersection is
caught with "the second layer widened authority", which is the defect the
"BELT AND BRACES" comment promises cannot happen.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
PlanContext.Depth was unset at both call sites, so Limits.CurrentDepth and the
executor's own nesting check both measured a constant zero. The admission
headroom check could never fire. It was only harmless because plan tasks run at
--auto low, where specialist tools do not register, so nothing could re-enter
-- an inert guard that reads like a live one, which is the shape someone later
trusts.
Wired rather than deleted: the value exists (--depth), it costs two lines, and
the check it feeds is the one thing standing between a nested run and
maxSpecialistDepth if a future phase ever lets a plan task spawn.
The TUI passes an explicit 0 with a comment: it is always a root session, so
zero is the measured value there, not an unset one.
Proven at the process boundary rather than by a unit test, since the defect was
the call site and not the helper: before, `exec --depth 7 --exec-profile
zeromaxing` ran a four-task plan; after, it refuses with "a plan's tasks would
run at depth 8, which reaches the maximum nesting depth of 8", while --depth 6
still runs.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
task_completed carried session_id and tokens; task_failed carried neither. So
the one task a user needs to open was the one task the event log could not
point them at -- and the per-task token records did not add up to the plan's
own total.
Executor.Run already goes out of its way to preserve the child session id on a
post-start failure, with a comment saying it exists so a failed child stays
drillable. The recorder discarded it one layer up.
A dependency or budget skip records an empty session id rather than omitting
the field: that task never started a child, and present-and-empty says so,
where an absent field would look like the same drop being fixed here.
The regression test compares the two events against EACH OTHER. Both already
had tests and both passed; nothing compared them, which is how the asymmetry
survived.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
reconcileProfileAfterModelSwitch called reasoningEffortAllowed directly while
the fill site called profileEffortApplies, added in 009f12b precisely to stop
treating an uncatalogued model's empty ring as a refusal. The two disagreed on
exactly that case, which is both of this user's configured models (glm-5.2 and
grok-4.5 are catalogued=false with an empty ring): selecting the posture on one
of them filled high, and switching to one with the posture already active
dropped the effort to auto and reported it as a level the model would not take.
e782470 was meant to fix this class at the cause across all consumers. It
updated one door and not the other -- the seventeenth instance of the pattern,
introduced by the fix for the sixteenth.
The rule is now ONE function, profileEffortAppliesOn, that both doors call, and
the ring's authority is passed through rather than inferred from its emptiness.
reconcileProfileAfterModelSwitch takes ringKnown, which its caller already had
and did not forward.
The comment at command_center.go described the old conservatism as deliberate
("only ever applies where support is known"). That is now the bug's
description, so it is replaced rather than left for the next reader to restore.
TestProfileEffortDoorsAgree asserts the doors against EACH OTHER across
catalogued / catalogued-with-no-ring / uncatalogued / no-model. Both doors had
tests before and both passed; nothing compared them, which is why the
disagreement shipped.
Three existing tests used kimi-k2.7-code:cloud as their "unsupported"
destination -- an UNCATALOGUED model, so they were asserting the defect. They
now use gpt-4o, which the catalog vouches has no reasoning controls, so the
genuine drop case stays covered.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
The rule "a plan task may narrow the parent's grant, never widen it" was
declared, documented and validated against -- and both production call sites
left ParentTools nil, so neither the validation check nor the dispatch-time
intersection ever ran. A run restricted to grep handed a plan task read_file.
The empty case was worse. planToolGrant returned an empty slice when a task
declared no tools and the parent grant was empty; resolvedToolAllowlist read an
empty tool list as UNSPECIFIED and expanded it to the default read-only
category. The narrower the parent, the wider the child -- on the default path,
where a task declares no tools at all.
Three changes:
- The grant is now DERIVED inside registerSpecialistTools from specialist's
own exported read-only list, narrowed by the registry and the run's
operator filters. The filters are explicit parameters rather than wiring
fields, so a call site cannot omit them without failing to compile.
- Both intersections are unconditional. The "skip the check when the parent
list is empty" escape hatch is what made the rule inert; an unsupplied
grant is now a wiring bug that fails closed.
- Manifest gains ToolsResolved, so a resolved tool list can say
"deliberately nothing". A bare []string cannot: absence and emptiness are
both len()==0, and omitempty erases an empty slice entirely, so a nil check
would not survive a round trip. Same shape as audit finding M24. The flag's
meaningful value is true, so omitempty only drops the meaningless false.
planToolGrant now refuses an empty result rather than returning one, and
ExecutePlan records that as a task failure with a reason without dispatching
it, so an ungrantable plan reports partial/failed and exits 4.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Closes the two gaps left by the registration step.
TUI GATE. handleProfileCommand and revertExecProfile write the shared
PostureGate on every transition, threaded through tui.Options in the same shape
as ZeromaxingDisabled. What is proved without a TTY:
- the gate is written on posture ON and OFF through the REAL handlers, for
BOTH entry points (/effort and /profile) — not through a helper, which is
what missed the wiring four times in this feature
- a REFUSED selection (disabled workspace) leaves the gate off
- a nil gate is safe, so a caller that never wires one simply has no tool
- THE cloneToolRegistry HAZARD, proved rather than assumed: a tool reached
from a CLONE taken BEFORE a flip still observes the flip. That is the exact
ordering a captured copy or a closure over this value-typed model would get
wrong, and it is why the gate is a pointer
- concurrent Set/read, for -race: the TUI writes from its update loop while a
run's tool dispatch reads from the agent goroutine
FAILING-TASK VERIFICATION. The stub gained a prompt sentinel that makes one
specific child fail with a real provider-stream error, so a dependency failure
runs end to end rather than being asserted only at the executor. A 4-task
diamond where b fails now reports, verbatim:
Plan partial: 2 succeeded, 1 failed, 1 skipped max_speedup: 1.49x
- a [succeeded] full result verbatim
- b [failed] provider error: sentinel failure
- c [succeeded] independent sibling unaffected
- d [dependency_failed] skipped: dependency "b" did not succeed
Run incomplete (not reported as success) EXIT=4
Every level of the RC-F seam is now covered by something that can fail:
executor status, tool status, recorder, and the process exit code.
THE BUDGET DEFECT GETS ITS OWN GUARD. TestRealRunnerFeedsTheBudgetMeter drives
NewPlanRunner over an Executor whose child reports usage, and asserts a
max_tokens=1 plan launches exactly one child. A fake runner that fabricates its
own token counts cannot catch a producer that never populates them — which is
precisely how the defect survived every executor test. A second test pins that
the runner honours the per-call context rather than a captured one.
registerSpecialistTools regains its original name and takes the wiring
argument directly; the transitional wrapper is gone now that both call sites
pass wiring, so no dead indirection ships.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Wires Phase 2 into the real binary and maps a plan that did not fully complete
onto the existing incomplete exit path.
POSTURE CALLBACK — a POINTER, not a closure. The TUI model is a VALUE type
(every handler takes and returns `m model`), so a func() bool closing over it
would capture a copy frozen at registration and report the posture as it was
when the session started, forever. Re-registration fails for the reason
decision 2 rejected it earlier and worse: the TUI clones the registry per run
and the clone copies tool POINTERS, so a replacement registered into the session
registry would never reach a run already holding a clone. specialist.PostureGate
is one shared atomic flag the tool holds for the process's life; every clone
sees the same one and a posture flip needs no re-registration.
PLAN RUNNER — captures only run-INVARIANT state (executor, workspace, parent
identity and policy) and NO context. The ctx it uses is the one ExecutePlan
hands it per task, so a cancelled run cancels the task in flight; it also checks
ctx.Err() before launching, so a cancelled plan does not spend another task's
budget. Capturing a context at construction is precisely how the prototype's
background goroutine kept running after cancellation. The runner is synchronous
and holds no goroutine, so it cannot outlive its plan.
PLAN RECORDER — preserves execSessionRecorder's best-effort contract exactly.
Every bridge method returns nothing, so a recording failure has no path back
into ExecutePlan and cannot abort a plan mid-flight; nil is a no-op at every
level. The bridge is built before the session exists and its inner recorder
attached afterwards, so events before that point are simply not recorded rather
than fatal.
PARTIAL -> EXIT 4, folded into the EXISTING result.Incomplete path rather than a
second exit route, so a partial plan and a stalled loop report the same way. It
never overrides an incompleteness the loop already found.
A DEFECT FOUND BY DRIVING THE BINARY, not by any test: the budget was enforced
at dispatch against a counter that never moved. NewPlanRunner never populated
TaskResult.Tokens, so a plan with max_tokens:1 ran all three tasks. The unit
test passed because its fake runner fabricated its own token counts — the same
"test the helper, miss the wiring" pattern that produced the flag-parser hole,
the self-correct capture point, and the identity test's wrong gate.
ExecResult gains an additive TotalTokens, populated from the stream summary the
accounting path already computes.
Verified at the real surface: orchestrate absent with the posture off and
present as [shell/allow] with it on in the same auto-mode session; a 4-task
diamond running in topological order with results verbatim and max_speedup
1.33x (hand-checked: four equal tasks over three hops = 4/3); cyclic, write-tool,
max_workers and missing-budget plans each rejected with the offending ids or
values named; and budget exhaustion reporting partial with `echo $?` showing 4.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
ZeroMaxing Phase 2: an `orchestrate` tool that accepts a plan as typed tool
arguments, validates it, records it as session events, and runs it SEQUENTIALLY
through the same specialist path a Task call uses. The deliverable is DATA — is
fan-out worth building at all — not concurrency.
ADDITIVE. With the posture off, the advertised tool set, the tool-definition
bytes and the assembled prompt are byte-identical to a build without this
feature, proved by TestPostureOffPrefixUnchangedByRegisteringTheTool: two
registries differing only in whether the tool is registered, asserted equal
under BOTH auto and unsafe. The enforcing mechanism is Safety() returning
PermissionDeny, not Deferred() — deferral only hides anything when it is ACTIVE,
and an unsafe session with deferral inactive would otherwise have advertised the
tool. Deferred() stays as a second layer and is now asserted rather than assumed.
VALIDATION CANNOT BE SKIPPED. Plan's fields are unexported and ParsePlan is the
only constructor, so there is no path from tool arguments to an executable plan
that bypasses it — the prototype's validator was reachable, correct, and never
called. Rejected by default: ids by ALLOW-LIST charset, unique; every depends_on
resolves (unknown edges rejected, never skipped); the graph is acyclic by Kahn's
with the involved ids NAMED (audit U24 — nothing in this tree checked, and a
cyclic graph hangs forever); task count capped; tools read-only and within the
parent's grant; a budget with max_tokens required and max_workers exactly 1,
REJECTED rather than coerced so the field stays meaningful for Phase 3; and
depth checked at admission with the remaining headroom named, rather than an
opaque failure mid-plan.
ONE counting function — a length, not a text scan. The prototype counted source
text, so `agent ("x")` with one space counted as zero and executed anyway.
BUDGET ENFORCED AT DISPATCH, not merely validated. Under this posture every
child inherits a 320-turn ceiling, so a twenty-task plan authorises 6,400 child
turns from one tool call. The grant is intersected again at dispatch too, so a
validator bug cannot widen a task's authority.
FAILURE IS NOT SUCCESS. A failed task skips its transitive dependents, each
RECORDED with the dependency that blocked it; independent siblings still run;
the plan runs to exhaustion. Partial is its own terminal status and the tool
returns StatusError for it, so nineteen of twenty failing can never surface as a
clean result (audit RC-F).
THE METRIC is value, not safety: max_speedup = sequential_total / critical_path,
computed from recorded per-task durations over the declared DAG and surfaced in
the summary and in Meta. Kill criterion: median >= 2.0x across >= 20 real plans,
or Phase 3 is not built. Independence-violation would answer safety, but
read-only tasks are always safe to parallelise, so it would read 0 and decide
nothing.
PermissionAllow when the posture is on, deliberately, with the reasoning in the
code so Phase 3 does not inherit it blind: the approval surface renders only the
tool name and a static sentence — PermissionRequest carries Args but no renderer
reads it — so a prompt could not show the plan it was gating and would train
click-through. What bounds it instead is enforced, not advisory: read-only at
validation and dispatch, a required budget enforced at dispatch, and the posture
itself as explicit consent. When tasks can WRITE, an approval gate becomes
necessary and needs a real renderer first.
Plan state is recorded as five ordinary session events beside the specialist
ones; no new store. Recording is best-effort and never fails the run.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
PRE-EXISTING defect, surfaced while investigating the zeromaxing posture and
verified on origin/main. It is not something the posture introduced.
availableReasoningEfforts() returns an EMPTY ring for two very different
situations: a catalog model that genuinely has no reasoning controls (gpt-4.1),
and a model with no catalog entry at all (the reporter's glm-5.2 on
ollama-cloud, any custom openai-compatible endpoint). Every TUI consumer treated
the second as if it were the first.
That single fact produced two visible failures at once:
- /effort listed no levels and reported "no reasoning controls on this model"
- /effort high was REFUSED outright
- and it is why a profile's effort fill was declined with "the model does not
support that level"
Meanwhile the headless path has always disagreed. On origin/main, for the same
glm-5.2: forwardedReasoningEffort returns "high" and emits no notice, because an
unknown model "forwards the requested value as-is, since no support claim can be
made for it". So the CLI forwarded the level while the TUI refused to let the
user set it — one rule, two answers, predating this feature.
There are THREE consumers of "does this model take this level?": a manual
/effort, a profile's fill, and the headless forwarding decision. They now share
one rule — a catalog entry is authoritative and refuses; no entry means Zero
cannot vouch either way and does not block. TestEffortSettabilityAgreesAcross-
AllThreeConsumers pins that they agree, per model, including the reporter's.
/effort also distinguishes the two states in its output, because "this model has
no reasoning controls" and "Zero has no entry for this model" are different
facts and rendered identically before. Setting a level on an unlisted model now
says plainly that support is unconfirmed and an unsupported value is ignored by
the provider.
The regression tests drive the real /effort path rather than the helper. A green
helper test alongside an empty user surface is how this feature has now produced
four defects: the flag-parser hole, the self-correct capture point, the identity
test passing through the wrong gate, and this.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Three defects found by running the real binary, all in what the posture reports
or applies.
1. THE FILL SILENTLY DID NOT HAPPEN on a custom endpoint. /effort zeromaxing
left the effort at "auto" while --exec-profile zeromaxing on the SAME model
sent reasoning_effort:"high" on the wire. Same posture, same model, two
answers — the fifteenth instance of one rule applied to one of two call
paths.
The fill site used reasoningEffortAllowed, which returns false both for a
model KNOWN to lack the level and for a model the catalog has never heard of.
The headless path already distinguishes them (an unknown model forwards the
requested effort "since no support claim can be made for it"), and so does
the TUI's own model-SWITCH site, which takes a ringKnown flag for exactly
this reason. Only the profile-fill site conflated them.
profileEffortApplies now fills when the model lists the level OR when no
catalog entry exists, and declines only when an authoritative empty ring says
the model has no reasoning controls. A model name that is empty is a third
case and still declines — there is nothing to make a support claim about.
That last distinction was caught by the pre-existing fast-posture test, which
the first version of this fix broke.
THE HOLE THAT HID IT: every test asserted a surface against its OWN
expectation — the TUI tests against the TUI's rule, the CLI tests against the
CLI's. Both suites were green while the two paths disagreed, because nothing
compared them against each other for the same model. A unit test on either
helper could not have caught it.
TestProfileEffortFillAgreesWithTheHeadlessPath is that missing comparison,
run across a catalog reasoning model, an inferred one, a catalog model with
an authoritative empty ring, and an unknown endpoint.
2. THE OUTPUT CONTRADICTED ITSELF: "reasoning effort: unchanged — already at
the highest level" and "NOT raised to high: the active model does not support
that level", in the same card. Both cannot be true. The fixed clause lived in
Delta while the refusal lived in a separate line, so they were only ever
consistent by coincidence.
Delta now renders every clause from one DeltaState, with the effort clause a
single switch over EffortTransition. A contradiction is unrepresentable
rather than merely unlikely, and the separate line is gone.
3. THE BUDGET CLAUSE compared against thorough — "(thorough uses 160)" — which
is information about two profiles, not about the user. It now states the
caller's own transition, "turn budget: 80 → 320", with distinct renderings
for an already-at-320 session and an unknown current budget.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Two corrections to what zeromaxing tells the user, both cases of documentation
describing behaviour that is not happening.
1. --spec-reasoning-effort zeromaxing stays rejected — it is a run posture and
has no meaning for a spec draft — but the generic "Expected low, medium, or
high" read like a bug to anyone who had just learned the name works on
--reasoning-effort. It now says why it does not apply and names both flags
that do what the user was reaching for. A genuinely unknown value keeps the
plain message: the explanation is specific to the posture name, not a new
blanket wording.
2. Delta's self-correct clause described the change from THOROUGH, not from the
caller. Telling a user sitting on the LSP-only default that self-correction
is "already armed" while silently moving them to the full project test plan
is exactly the failure this posture's honesty rules exist to prevent. Delta
is now a function of the caller's actual state and renders one of three
transitions, in /selfcorrect's own vocabulary:
self-correct: lsp -> tests (the common case)
self-correct: unchanged (tests) (genuinely unchanged)
self-correct: lsp (your /selfcorrect choice ...) (user override wins)
The third exists because the TUI lets a user turn it back off after selecting
the posture; reporting a raise there would be a third way to describe
behaviour that is not happening. The budget and effort clauses stay fixed —
effort genuinely is caller-independent, since "high" is the ceiling every
provider accepts.
The headless path captures self-correct BEFORE applyExecProfile, which arms it
as a side effect; reading it afterwards would make every run report "unchanged".
The TUI reads its LIVE state rather than selection-time state, so a later
/selfcorrect off stops the status claiming a raise the session no longer has.
A unit test on the transition helper cannot catch the capture being read at the
wrong moment, because it never runs the code that captures — so the ordering is
pinned by a test that drives the real exec path and reads stderr. That gap was
found by mutation, not by review.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
/effort zeromaxing is the primary way in; /profile zeromaxing is its alias. It
is handled beside "auto" with an EARLY RETURN, before the ReasoningEffort
conversion and before ValidReasoningEffort — it is a posture name, not a
provider level, and must never become one.
It DELEGATES to handleProfileCommand rather than re-applying the knobs, so
"both entry points resolve identically" is true by construction instead of by
two implementations a test hopes agree. The CLI mirrors it:
normalizeZeromaxingEffort folds --reasoning-effort zeromaxing into the
equivalent --exec-profile selection before mode/profile expansion, leaving the
documented precedence ordering untouched. A conflicting --exec-profile is a
usage error rather than a silent winner.
THE LOAD-BEARING GUARD: forwardedReasoningEffort refuses to forward the posture
name for any model, including catalog-unknown ones where unrecognized values
otherwise pass straight through. Normalization already prevents it reaching
there; the guard is what makes a regression upstream fail loudly instead of
sending a provider a parameter value no provider defines.
"max" stays RESERVED and unchanged through all of this: the flag parser still
rejects it, the normalizer passes it through untouched, and /effort max still
reports it unsupported exactly as before.
Driving the real binary is what found the one real gap here: the flag parser
rejected "zeromaxing" outright, so the entire --reasoning-effort entry point was
dead at the user surface while every unit test passed, because they called the
normalizer directly. TestReasoningEffortFlagAcceptsZeromaxingButNotMax is that
regression, and it fails in both directions.
Degrade honestly — this closes a real asymmetry. exec already told the user via
reasoningEffortNotice when a model could not take the requested effort; the TUI
skipped the fill in SILENCE. Same rule, one of two call paths. Both status
surfaces now render ONE shared resolved-state line
("effort: high · profile: zeromaxing · turns: 320") plus the real delta, so a
user sees what actually reached the provider rather than just a posture name.
reconcileProfileAfterModelSwitch is the sibling re-derive site and records the
same reason, so a switch that drops the effort cannot leave the status implying
a raise the run is not making.
revertExecProfile gains a FOURTH knob. It is knob-by-knob, which is exactly
where a new one gets forgotten, so the posture's restore is asserted alongside
budget, effort and self-correct — and gated on zeromaxing specifically, so
/effort auto under fast/thorough keeps its existing meaning of "clear the
effort" instead of dropping the whole profile.
The footer carries a ZEROMAXING chip beside the effort chip while the posture is
on, and drops it while Exiting — by then the posture is already off and only its
announcement is pending.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
Four model-facing notices tell the model when its own posture flips: enter
(once, on the first turn after activation), a budget guideline beside it,
still-on (every continuing turn), and exit (once, after deactivation).
WHERE THEY GO IS THE WHOLE DESIGN. Every notice is appended to the CONVERSATION
tail as a user-role message, on the same channel as the existing diagnostics
nudge and the failure/plan hints. None of it reaches buildSystemPromptParts.
The system prompt and tool definitions are the provider's cached prefix — the
anthropic mapper puts its cache_control breakpoint on the last system block and
the last tool — and #760 made that prefix build once per run precisely so it
stays byte-identical across turns. Per-turn text above that breakpoint
invalidates the cache every turn, roughly doubling input cost, and nothing in
the system reports it.
So ZeromaxingStillOnNotice is a fixed literal: no turn counter, no remaining
budget, no timestamp. A test asserts it carries no digits at all, because the
hazard is not today's placement but a future edit that adds a varying part and
then moves the text into prompt assembly.
The reminders also promise NO orchestration — no workers, no fan-out, no
workflow tool. Phase 1 ships none of that, and a prompt advertising capabilities
the run does not have is a prompt-level lie the model will try to act on. A test
enforces the vocabulary.
The state machine is one pure function of (posture, turn) with no memory, so
"enter exactly once" and "still-on never on the first turn" are assertions about
a table rather than about observed side effects.
Proof, both shapes of the hazard, each caught by a DIFFERENT assertion:
- per-turn text written into messages[0] -> the prefix-extension assertion
- static text baked into the system prompt -> the explicit leak assertion
TestRunPreservesRequestPrefixAcrossTurnsUnderZeromaxing is the zeromaxing
variant of the existing prefix test; the openai one is its wire-level sibling,
proving the mapper serializes an append-only conversation into an append-only
request body. A third openai test pins that the posture name can never appear in
a serialized request at all.
Options.Zeromaxing is separate from Options.Profile because the posture arms no
escalation triggers, so Profile.Policy() returns nil for it and could not carry
this. Its zero value is Off, leaving every existing caller byte-identical.
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
zeromaxing is thorough with double the turn budget again: 320 tool-turns, effort
high, self-correction armed. It fills only what the caller left unset, exactly
as the other profiles do, so an explicit flag and a --mode preset both still win
— precedence stays enforced by ordering, not by a new check.
ONE NAME. /effort zeromaxing, /profile zeromaxing, --exec-profile zeromaxing and
--reasoning-effort zeromaxing all name the same thing, and nothing else does.
No aliases: "max", "deep" and "deepmode" resolve to nothing, asserted by test,
because a second spelling is a second thing to keep in step.
"max" is RESERVED and untouched. ValidReasoningEffort still accepts
ReasoningEffortMax, so /effort max and --reasoning-effort max behave exactly as
they did — parsed as a raw provider level, rejected by the flag parser, and
unsupported by every current model. That spelling stays free for a real provider
rung; claiming it for a Zero posture would burn it.
HONEST DELTA, stated to the user rather than left in a commit message: the turn
budget is the ONLY mechanical change over thorough. Effort is already at the
ceiling and self-correction is already on. execprofile.Delta carries that
sentence to /effort, /profile and the exec selection notice.
ReasoningEffort is "high", pinned by test rather than by comment.
TestZeromaxingReasoningEffortStaysHigh explains why: every level above "high"
falls through the providers' effort mappers into their default arm — anthropic
and gemini both compute a thinking budget of 0 (extended thinking DISABLED) and
the openai mapper drops the field entirely. Raising it would silently turn
reasoning off while the UI claimed a higher posture. Raising the real ceiling
means teaching the providers those tiers first.
The raised budget propagates to spawned sub-agents. Deliberate — this is a
maximal posture — so it is asserted by test and stated in Delta rather than left
to be discovered.
SelectionRefusal is the single authority both selection paths consult. The
headless exec path and the TUI paths already apply a profile's knobs through
different code with different state; letting each decide SELECTION independently
is how a rule gets applied to one call path and omitted from its sibling. Config
gating follows mergeProjectConfig's tighten-only template: a project
.zero/config.json may set profiles.disableZeromaxing (DISABLE) but can never
clear it, so a cloned repo cannot switch a cost multiplier on for whoever opens
it — an attempted enable is dropped silently, exactly like an ignored network
"allow".
Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
2026-08-16 09:10:38 +05:30
1167 changed files with 132212 additions and 5049 deletions
Review tests for Bun test idioms, deterministic behavior, and meaningful coverage of safety decisions, provider/runtime flows, session events, and TUI command behavior.
- path:"**/*.go"
instructions:|
Do not request Plan 9 or other unsupported GOOS/GOARCH shims. The five native prebuilt release targets are linux-x64, linux-arm64, macos-arm64, macos-x64, and windows-x64. Android/Termux is also a supported runtime through the documented android-arm64 source build and Linux artifact fallback. Other GOOS/GOARCH combinations are not a review criterion.
echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2
exit 1
;;
esac
echo "dist-tag latest is ${latest} (unchanged by platform publishes)."
* **providers:** add MiniMax-M3.1-Flash to catalog ([#1180](https://github.com/Twigpine/zero/issues/1180)) ([d99f92b](https://github.com/Twigpine/zero/commit/d99f92b81a671f186cede81614c89b8e8a7f9451))
* **sandbox:** support self-exec pattern to eliminate separate helper binary ([#946](https://github.com/Twigpine/zero/issues/946)) ([8c4e759](https://github.com/Twigpine/zero/commit/8c4e7597989ef9b3ca67b7703dfd15d92372aed2))
* **skills:** install a skill's bundled files and point the skill tool at them ([#1167](https://github.com/Twigpine/zero/issues/1167)) ([1641506](https://github.com/Twigpine/zero/commit/164150633a8b2e6ae864ae02fd7105b201eeb68e))
### Bug Fixes
* **agent:** mark headless guardrail stops incomplete ([#1182](https://github.com/Twigpine/zero/issues/1182)) ([5aa54fe](https://github.com/Twigpine/zero/commit/5aa54fe1d2b419c9bbef73605a77919c9007cf7b))
* bump Go to 1.26.10 for GO-2026-6809 ([#1168](https://github.com/Twigpine/zero/issues/1168)) ([6393a90](https://github.com/Twigpine/zero/commit/6393a90ed618a3536e09c02852f4ec829b97545b))
* **chatgpt:** refresh subscription defaults and compatibility version ([#1179](https://github.com/Twigpine/zero/issues/1179)) ([f912f80](https://github.com/Twigpine/zero/commit/f912f80d002602e11ba0ef8548ecee58442874aa))
* **deps:** refresh the lockfile to hono 4.13.13 ([#1171](https://github.com/Twigpine/zero/issues/1171)) ([de1443b](https://github.com/Twigpine/zero/commit/de1443b2edb44fd3747cc77200d54dc8cffcd0a7))
* **oauth:** reuse the crash-safe secret store and sync encrypted publications ([#1165](https://github.com/Twigpine/zero/issues/1165)) ([84c1b15](https://github.com/Twigpine/zero/commit/84c1b1554076679a342e9cac61f8f8cdbaf3a429))
* **tools:** grep stops where a file's line breaks stop ([#1160](https://github.com/Twigpine/zero/issues/1160)) ([aa9ba84](https://github.com/Twigpine/zero/commit/aa9ba8412eb8917daa2ca5cb3fdbd0a39276fa88))
* **workspaceindex:** import gitsafe under the Twigpine module path ([#1153](https://github.com/Twigpine/zero/issues/1153)) ([ef4da13](https://github.com/Twigpine/zero/commit/ef4da1323f1097863ae278224b5f304c2fff909a))
* **workspaceindex:** run Scan's git calls through gitsafe ([#1149](https://github.com/Twigpine/zero/issues/1149)) ([2ffe183](https://github.com/Twigpine/zero/commit/2ffe1831b0950b1463ec46d487d03a5fcb679cc6))
* **cli:** let providers subcommands run with no active provider ([#1143](https://github.com/Twigpine/zero/issues/1143)) ([d1a0d04](https://github.com/Twigpine/zero/commit/d1a0d048ee2c1e7cbfb75160ec4c9f099f3c4472))
* **npm:** wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps ([#1145](https://github.com/Twigpine/zero/issues/1145)) ([8e363fc](https://github.com/Twigpine/zero/commit/8e363fcaa5d51bf5b5b244692dc61bf8d4ae8799))
* **tui,cli:** stop a repository's git config from running programs in Zero's own git calls ([#1133](https://github.com/Twigpine/zero/issues/1133)) ([640d936](https://github.com/Twigpine/zero/commit/640d936bfbe00c4cd3017ca35fb7b5c15d91a1c5))
* **workspaceindex:** skip git-ignored and build-cache dirs in Scan only ([#1117](https://github.com/Twigpine/zero/issues/1117)) ([c88c77b](https://github.com/Twigpine/zero/commit/c88c77bf701039dca01d9786bf5648d779c949b3))
### Performance Improvements
* **redaction:** add fast-path byte checks to eliminate regex allocations on clean strings ([#945](https://github.com/Twigpine/zero/issues/945)) ([ce2fcff](https://github.com/Twigpine/zero/commit/ce2fcffaf10d8e8c8c5a13e06fd7809f09fc9867))
* **notify:** make permission-prompt alerts on by default and discoverable ([#1001](https://github.com/Twigpine/zero/issues/1001)) ([7f8e5f0](https://github.com/Twigpine/zero/commit/7f8e5f08bb83371ce16cc54c97ab032a010026cf))
* **npm:** publish as @twigpine/zero ([#1142](https://github.com/Twigpine/zero/issues/1142)) ([530563a](https://github.com/Twigpine/zero/commit/530563a0bb58d3cbadceb97b3ee234ef763124df))
* **sessions:** add zero sessions prune, which never removes a session another process has open ([#1089](https://github.com/Twigpine/zero/issues/1089)) ([23c27e0](https://github.com/Twigpine/zero/commit/23c27e0778e3af54c04b8749d1eda846d3253f4e))
### Bug Fixes
* **acp:** persist the session permission mode across restarts ([#1134](https://github.com/Twigpine/zero/issues/1134)) ([bb96206](https://github.com/Twigpine/zero/commit/bb96206c2781e846fd13437113eec5790afe9c81)), closes [#1125](https://github.com/Twigpine/zero/issues/1125)
* **cron:** bound run history and read recent records from the tail ([#1094](https://github.com/Twigpine/zero/issues/1094)) ([42eb6ab](https://github.com/Twigpine/zero/commit/42eb6ab5fbe4ae9037f1f4cd42a00243c456a22e))
* **daemon:** bound pending sessions and prune on completion ([#1091](https://github.com/Twigpine/zero/issues/1091)) ([8a68660](https://github.com/Twigpine/zero/commit/8a68660161b04ce9668363faa14ff72c9fbad223))
* **daemon:** do not retry workers that finished with a final exit code ([#1112](https://github.com/Twigpine/zero/issues/1112)) ([eefa978](https://github.com/Twigpine/zero/commit/eefa978f6183332b7416c70c25242bcdfba1a75c)), closes [#1100](https://github.com/Twigpine/zero/issues/1100)
* **daemon:** stop worker process groups by their launch-time identity ([#1088](https://github.com/Twigpine/zero/issues/1088)) ([ab018b3](https://github.com/Twigpine/zero/commit/ab018b3098b5cb103cf8f90b8698d32a6687bdf7)), closes [#861](https://github.com/Twigpine/zero/issues/861)
* **deps:** refresh the lockfile to patched hono and @hono/node-server ([#1080](https://github.com/Twigpine/zero/issues/1080)) ([aa52040](https://github.com/Twigpine/zero/commit/aa52040498e012c41d5e8f6027a91c18eff861d4))
* **deps:** refresh the lockfile to patched shell-quote ([#1131](https://github.com/Twigpine/zero/issues/1131)) ([c56a4c4](https://github.com/Twigpine/zero/commit/c56a4c4e6e598504ab417e8cc4f5ab8d69f1b42f))
* **execution:** reserve process capacity before launching ([#1092](https://github.com/Twigpine/zero/issues/1092)) ([bb3346a](https://github.com/Twigpine/zero/commit/bb3346a82883c19ed8815fe1e874f54950711d69))
* **mcp:** forward image tool results on the existing image channel ([#1077](https://github.com/Twigpine/zero/issues/1077)) ([0324c1d](https://github.com/Twigpine/zero/commit/0324c1d4103328eb90cb24a0b582dba335255b5c))
* **mcp:** refresh OAuth tokens for discovered endpoints and registered clients ([#1113](https://github.com/Twigpine/zero/issues/1113)) ([571af70](https://github.com/Twigpine/zero/commit/571af701bd3d8dfaf40f87b5b112b25e7def5b29)), closes [#1101](https://github.com/Twigpine/zero/issues/1101)
* **oauth:** give every loopback callback server the bounded shutdown ([#1079](https://github.com/Twigpine/zero/issues/1079)) ([5473301](https://github.com/Twigpine/zero/commit/547330197b20ece591e077e876a4b8ffd015cb6e))
* **providers:** send stable session headers to OpenCode ([#1060](https://github.com/Twigpine/zero/issues/1060)) ([99721c7](https://github.com/Twigpine/zero/commit/99721c762f37cd43ac511007a5f51d1846df959e))
* **redaction:** redact adjacent AWS keys glued end to end ([#1095](https://github.com/Twigpine/zero/issues/1095)) ([df67ede](https://github.com/Twigpine/zero/commit/df67edec13345b73a85327adc003cacc7bdd8009))
* **redaction:** redact credentials split by an invisible character ([#1067](https://github.com/Twigpine/zero/issues/1067)) ([e20b762](https://github.com/Twigpine/zero/commit/e20b7624261cef8336a8eb0fbdd44d536ad77fc2))
* **sandbox:** say when a session's sandbox enforcement is degraded ([#1082](https://github.com/Twigpine/zero/issues/1082)) ([6b5852d](https://github.com/Twigpine/zero/commit/6b5852dc18bb256af1f25745697c2f2e3ea6f112))
* **search:** redact every text field of session metadata, not ten of thirty ([#1073](https://github.com/Twigpine/zero/issues/1073)) ([9520f2b](https://github.com/Twigpine/zero/commit/9520f2bad7e09ce4a8d0d42be7ce77d2ae928ddd))
* **security:** harden shell flag parsing, write roots, and formatters ([#1066](https://github.com/Twigpine/zero/issues/1066)) ([ac5fe0d](https://github.com/Twigpine/zero/commit/ac5fe0d21849b0f74af428dd3ca12e3b80a8b7aa))
* **sessions:** keep a session lease for the life of the process, not of the Store ([#1144](https://github.com/Twigpine/zero/issues/1144)) ([883e5be](https://github.com/Twigpine/zero/commit/883e5bec24dac791d1d60618c7f4927f4659b687))
* **tools:** hint when Windows resolves POSIX paths under root ([#1063](https://github.com/Twigpine/zero/issues/1063)) ([0c6f179](https://github.com/Twigpine/zero/commit/0c6f179a8443d4dfdfbbefe5cc6caf11c2a525f0))
* **tui:** bound live dictation regions ([#985](https://github.com/Twigpine/zero/issues/985)) ([feb9927](https://github.com/Twigpine/zero/commit/feb992757c582c68f0397e070e206d4bf07071e1))
* **tui:** show provider ownership in Recent and Favorites ([#1083](https://github.com/Twigpine/zero/issues/1083)) ([1697b7a](https://github.com/Twigpine/zero/commit/1697b7a5ce948e781877cdbe9223adac62c3fde6))
* **tui:** stop held peer message Allow/Deny from deadlocking Update ([#1111](https://github.com/Twigpine/zero/issues/1111)) ([a195e88](https://github.com/Twigpine/zero/commit/a195e8848cec1a27b4fe1789ecc572b943c32186)), closes [#1099](https://github.com/Twigpine/zero/issues/1099)
* unbreak main CI (cron claim tests, Go 1.26.9) ([#1124](https://github.com/Twigpine/zero/issues/1124)) ([2c418fc](https://github.com/Twigpine/zero/commit/2c418fc8ff6763de16703128f372fe526fddd331))
* **acp:** add standard session list and resume ([#914](https://github.com/Gitlawb/zero/issues/914)) ([c1937df](https://github.com/Gitlawb/zero/commit/c1937dfac72e6ad0e5ade6e48e2d9c17d9c3e5d6))
* **acp:** make configured MCP tools callable ([#1003](https://github.com/Gitlawb/zero/issues/1003)) ([7f5e5af](https://github.com/Gitlawb/zero/commit/7f5e5af7bbdbe5bb4d91706146072d461c0234a4))
* **agent:** stop the tool-schema ratchet meaning a different thing per host ([#1017](https://github.com/Gitlawb/zero/issues/1017)) ([f909266](https://github.com/Gitlawb/zero/commit/f9092668a832ea73a69551f0ea2df4f6eaf356df))
* **background:** treat POSIX zombie leader as already-exited ([#981](https://github.com/Gitlawb/zero/issues/981)) ([1aacf38](https://github.com/Gitlawb/zero/commit/1aacf38b727d31b5ca09949316d6afa8292aec46))
* **config:** lock config read-modify-write across processes ([#960](https://github.com/Gitlawb/zero/issues/960)) ([b04fe96](https://github.com/Gitlawb/zero/commit/b04fe96e9ef3f2deda4fb2a972270300629079d2))
* **daemon:** publish status files atomically ([#949](https://github.com/Gitlawb/zero/issues/949)) ([9001421](https://github.com/Gitlawb/zero/commit/90014210d2ada2e3d61f4eca5087d3f35a3c4b8b))
* **installtxn:** put back an install a killed commit left behind ([#997](https://github.com/Gitlawb/zero/issues/997)) ([2837f5e](https://github.com/Gitlawb/zero/commit/2837f5efb38dcd3c69771b0ba31565f98a206120))
* let the configured forward proxy through the loopback guard ([#1025](https://github.com/Gitlawb/zero/issues/1025)) ([1e4db7c](https://github.com/Gitlawb/zero/commit/1e4db7c6643ca7e2c3d8e4b8b30371cfbe43561e))
* **lockutil:** use kernel-held advisory locks ([#950](https://github.com/Gitlawb/zero/issues/950)) ([6fe0d1e](https://github.com/Gitlawb/zero/commit/6fe0d1edcdbadea6d566800fbcca739c1cd54ea1))
* **mcp:** discover OAuth through protected resource metadata ([#991](https://github.com/Gitlawb/zero/issues/991)) ([1b5db17](https://github.com/Gitlawb/zero/commit/1b5db1765672820caac1684b168c9898b5ba3593))
* **mcp:** reject a server-initiated request as a streamable-HTTP response ([#1054](https://github.com/Gitlawb/zero/issues/1054)) ([2479d94](https://github.com/Gitlawb/zero/commit/2479d946c15dbf74ee3c04a637e97181d1786f08))
* **oauth:** force-close loopback callback connections past the shutdown deadline ([#1051](https://github.com/Gitlawb/zero/issues/1051)) ([176f5a2](https://github.com/Gitlawb/zero/commit/176f5a242b754a86ea4c7121a8dcc1c0d72cf2e4))
* **oauth:** Split an oversized keyring token blob across entries ([#1007](https://github.com/Gitlawb/zero/issues/1007)) ([275e248](https://github.com/Gitlawb/zero/commit/275e2486695338ce38193a6e3319f8b6a9a5c33c))
* **providerio:** close idle connections periodically to prevent HTTP/2 PROTOCOL_ERROR ([#1058](https://github.com/Gitlawb/zero/issues/1058)) ([b76c764](https://github.com/Gitlawb/zero/commit/b76c764b77926891aa67163e6605a2d8540aacaa))
* **providers:** register Atomic Chat as local OpenAI-compatible runtime ([#550](https://github.com/Gitlawb/zero/issues/550)) ([79fa142](https://github.com/Gitlawb/zero/commit/79fa1422ef1a3691b93342788453ebc284d57f9c))
* **redaction:** split camelCase keys in normalizeKey ([#994](https://github.com/Gitlawb/zero/issues/994)) ([08fbea7](https://github.com/Gitlawb/zero/commit/08fbea7a3221d2061d88038924f90a4cba9af1ac))
* **sandbox:** keep a derived ACL target inside the write root it came from ([#1040](https://github.com/Gitlawb/zero/issues/1040)) ([3b43c43](https://github.com/Gitlawb/zero/commit/3b43c43eca968c8619adadc81fcc1a17e220216a))
* **sandbox:** keep Windows restricted-token SIDs narrow and fail closed on DenyRead ([#1006](https://github.com/Gitlawb/zero/issues/1006)) ([0207f2b](https://github.com/Gitlawb/zero/commit/0207f2b2697149a73ecc89a5ab5db0bd665f1911))
* **sandbox:** Make WSL backend tests deterministic. ([#948](https://github.com/Gitlawb/zero/issues/948)) ([e59bf78](https://github.com/Gitlawb/zero/commit/e59bf78cabc2703678778b904c27c8bc8ef82bc8))
* **sandbox:** refcount temporary roots so one holder's cleanup cannot revoke another's ([#911](https://github.com/Gitlawb/zero/issues/911)) ([7acac7a](https://github.com/Gitlawb/zero/commit/7acac7acee65565e25089f2a7b5813c086ce65eb))
* **sessions:** carry an interrupted turn's work into the resume prompt ([#1016](https://github.com/Gitlawb/zero/issues/1016)) ([6ae8396](https://github.com/Gitlawb/zero/commit/6ae8396f0282a5f18a431f0c0ef1a272ecc2ca2b))
* **sessions:** render resume context fields in key order ([#1043](https://github.com/Gitlawb/zero/issues/1043)) ([da244bc](https://github.com/Gitlawb/zero/commit/da244bcbfcfa05e414567ac0acc3cf8ba745ad7d))
* **specialist:** keep the pinned model when a specialist is resumed ([#1009](https://github.com/Gitlawb/zero/issues/1009)) ([aadb4a2](https://github.com/Gitlawb/zero/commit/aadb4a27e9cdb41e774e8e1a8f23cf9c2fb50db2))
* **swarm:** stop original member before handoff ([#957](https://github.com/Gitlawb/zero/issues/957)) ([0021475](https://github.com/Gitlawb/zero/commit/0021475d4c58c91911098f414795512cf60a0b56))
* **tools:** an edit keeps the reads it did not disturb ([#908](https://github.com/Gitlawb/zero/issues/908)) ([1649008](https://github.com/Gitlawb/zero/commit/1649008506da0e675431dc056251a01891b15cc8))
* **tools:** make apply_patch tolerant and re-expose edit_file ([#956](https://github.com/Gitlawb/zero/issues/956)) ([2749447](https://github.com/Gitlawb/zero/commit/2749447cdce4ae5dd333538b36fa919b80ecc66d))
* **tools:** reject fuzzy edits with drifted content ([#983](https://github.com/Gitlawb/zero/issues/983)) ([8b3d1b1](https://github.com/Gitlawb/zero/commit/8b3d1b1597b04bfa6d4bc7f912abc13ca80a6298))
* **tools:** say when format-on-write ran out of time ([#1044](https://github.com/Gitlawb/zero/issues/1044)) ([6937a30](https://github.com/Gitlawb/zero/commit/6937a309cf00825572210a7610a1f3ea8b74c2f9))
* **tui:** move voice capture to Ctrl+Space ([#968](https://github.com/Gitlawb/zero/issues/968)) ([eeea330](https://github.com/Gitlawb/zero/commit/eeea3308fed38b37b203522624babaf2f30df91f))
* **update:** keep Windows failed-restore state in trusted recovery records ([#979](https://github.com/Gitlawb/zero/issues/979)) ([a3852ba](https://github.com/Gitlawb/zero/commit/a3852bac595e06c5d3ca9da22212b281b8796802))
@@ -110,6 +110,8 @@ Discovery roots (earlier wins on name collisions):
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
For a skill installed since Zero started installing bundled files, `skill info` also reports drift in any file installed with it.
@@ -140,6 +142,15 @@ Only `name` and `description` are recognized in the frontmatter today. The `name
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).
A skill can ship files beside its `SKILL.md`, in the [agentskills.io](https://agentskills.io/specification) layout: `scripts/`, `references/`, `assets/` or anything else the instructions refer to. `zero skill add` installs the whole skill directory, leaving out hidden entries such as `.git` and not following or copying symlinks (it lists any it skipped). A skill larger than 1,000 files or 32 MiB is refused. When the `skill` tool loads a skill that has such files, its output ends with the skill's directory and a list of the files, so instructions like "run `scripts/fill.py`" can be followed. Running a bundled script goes through the normal tool permissions; installing or loading a skill never runs anything.
```text
pdf/
SKILL.md
scripts/fill.py
references/form-fields.md
```
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
@@ -192,7 +203,11 @@ Events the agent emits (in dispatch order):
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
For swarm members the matcher subject is the **agent type** (`subagent`, `teammate`, …), not a registered specialist profile name — swarm members run from an inline manifest keyed by agent type.
`specialistStart` fires once the child process has actually started; a launch that fails before exec (missing binary, spawn error) records no public lifecycle hooks. `specialistStop` fires once per terminal stop: completion, error, explicit stop of a recovered task, and a background task reaped by a later `TaskOutput` all share one durable claim, so a hook that notifies or cleans up does not run twice for the same sub-agent run.
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). `sessionStart` / `sessionEnd` / `specialistStart` / `specialistStop` hooks are advisory (failures are audited but do not interrupt the run). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
@@ -246,6 +261,52 @@ or use a new server name. OAuth tokens are bound to the resolved server identity
so changing a server target can require `zero mcp oauth login <server>` again.
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
#### Built-in servers
Zero seeds two MCP servers before your config is merged. Both show up in
`zero mcp list`, and any field can be overridden in your user config.
-`exa` (`https://mcp.exa.ai/mcp`) is on by default for keyless web search.
`zero mcp disable exa` turns it off.
-`aimlapi` (`https://mcp.aimlapi.com/mcp`, the AI/ML API Toolbox, model
discovery, image/video/audio generation, chat, embeddings and balance tools)
is **off by default**, because its tool runs and generations are billed to
your AI/ML API balance. Turn it on with `zero mcp enable aimlapi`, which
writes only `"disabled": false` to your user config, and off again with
`zero mcp disable aimlapi`. A project config cannot turn it on.
When `aimlapi` connects, Zero sends your AI/ML API key as
`Authorization: Bearer <key>`. The key is resolved again on every request and
is never written to `config.json`. With an aimlapi.com provider profile
(`zero providers setup aimlapi`), the key is chosen in this order:
1. An inline key on the profile (if set in config).
2. Otherwise the environment variable named by the profile's `apiKeyEnv`
(the catalog default is `AIMLAPI_API_KEY`).
3. Otherwise the key held in the credential store when the profile uses
`apiKeyStored`.
When there is no aimlapi profile, Zero uses `AIMLAPI_API_KEY` alone. If both
`AIMLAPI_API_KEY` and a stored profile key exist, step 2 wins over the store
whenever the inline key is empty — MCP requests may bill the environment key's
account, not the stored key.
No automatic key is sent when an aimlapi profile, or any profile reading
`AIMLAPI_API_KEY`, uses a base URL other than `https://api.aimlapi.com/v1`, or
when `AIMLAPI_INFERENCE_URL` is overridden, because that credential may belong
to a proxy. No key is sent when the resolved value is not a single token (for
example a comma- or newline-separated pool), when you set your own
non-empty `Authorization` header, when the server uses `"auth": "oauth"`, or
when project config touched the server. Without a key the server answers 401.
To sign in with your aimlapi.com account instead, set `"auth": "oauth"` on the
server and run `zero mcp oauth login aimlapi`.
Every request to exactly `https://mcp.aimlapi.com` (https, default port,
whatever the server is named, including one you add by hand) also carries the
attribution headers `X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`,
and gets the key under the rules above. Other MCP servers never receive these
headers or the key.
### As a server — expose Zero's tools to another agent
```bash
@@ -290,10 +351,12 @@ Install and manage:
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
zero plugins disable github-pr-review # sets "enabled": false in plugin.json
zero plugins enable github-pr-review # sets "enabled": true in plugin.json
zero plugins remove github-pr-review # alias: rm
```
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting`"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
A plugin is active when it is present under a plugins directory and its `plugin.json` has `"enabled": true` (the default when the field is omitted). Use `zero plugins disable <id>` / `zero plugins enable <id>` to toggle that field without removing the plugin. Pass `--user` to target only user plugins and ignore `./.zero/plugins`; without it, a project plugin with the same id takes precedence. The toggle edits the`enabled` value in place — the rest of the manifest keeps its formatting, field order, and numeric literals — and serializes with `plugins add` / `plugins remove` on the same plugins root. Zero reads plugins when it starts, so the change applies to the next `zero exec` run or interactive session; an interactive session that is already running keeps the plugin tools, hooks, and skills it started with until you restart it.
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
Installer scripts download the matching release asset for the local platform and
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
@@ -93,14 +97,24 @@ binary:
| State on disk | Meaning |
|---|---|
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. |
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker, or a per-user update-recovery record naming that copy | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. The `.keep` marker is in the installation directory and can be deleted; the identity-bound record is not, so deleting the marker alone does not end the refusal. |
| `<binary>.…old.<suffix>.recovery` | A previous update could not even write the marker, so it moved the last verified binary to that name. |
| The binary is missing and one or more `*.old` files exist | The previous attempt was interrupted between moves. |
The refusal names the paths involved and the two moves that end the state:
either move the recovery binary back over the executable path, or — if the
installed binary is the one you want — delete the `.keep` marker (or the
`.recovery` copy, once you have verified the installed binary) and update again.
installed binary is the one you want — delete the recovery copy itself (not
only the `.keep` marker) after verifying the installed binary, then update
again. Deleting the marker alone is not enough: a failed restore also records
that copy in per-user state outside the installation directory, and the next
update consults that record even if the marker is gone.
That trusted record lives under `%AppData%\zero\update-recovery` (per-user).
The recovery copy and its `.keep` marker sit in the installation directory
(per-machine). A second Windows account on the same machine does not see the
first account's unresolved record; the sibling marker is the machine-visible
signal for every account. There is no separate machine-wide store: writing one
would need a shared writable location and ACL rules the updater does not own.
This is deliberately fail-closed and differs from older releases, which deleted
`<binary>.old` and proceeded. The trade-off is that anyone who can write in the
ifgot:=browserToolTitle("open",`{"URL":"https://different.example.test","url":"https://actual.example.test/path"}`);got!="browser open https://actual.example.test"{
t.Fatalf("exact URL key title = %q",got)
}
ifgot:=browserToolTitle("action",`{"command":"not an action"}`);got!="browser action"{
Loaded 100 of 1167 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.