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.
* test(execution): fail loudly when ps cannot answer instead of skipping
Every test in process_unix_test.go proves something about zombie detection, and
every one of those proofs is read out of ps. When ps could not answer, the
tests skipped — which is indistinguishable from success in CI output — and one
assertion passed vacuously for the same reason. A regression in ps parsing or
availability would have shown up as green.
requireProcessState probes BOTH ps pipelines against this test process, which
is by definition alive and not a zombie, so a working ps must report a live
state for it. When it cannot:
- Under CI (or ZERO_REQUIRE_PS=1) this is a hard failure. ps is present on every
image used today, so its absence means the pipeline regressed and that has to
surface immediately.
- Otherwise it stays a skip, so a contributor on an unsupported box is not
blocked.
Both pipelines are probed because they are deliberately separate: the tests
assert what the production process-table parser concludes, so this file keeps
its own per-PID ps oracle rather than borrowing that parser to establish its own
preconditions. One regression must not be able to silence both sides at once.
Once the probe passes, failing to observe an EXPECTED state is a defect rather
than an environment problem, so the remaining escapes become failures:
- The zombie waits (awaitZombie) fail instead of skipping. The caller never
waits on the child, so its zombie row persists until reaped — reaching the
deadline means the child never exited or ps stopped answering, and the two
are reported distinctly.
- A zombie group that is not signalable fails instead of skipping: a zombie
still occupies the process table, so if kill(-pid, 0) fails the case under
test is not present and the assertions after it would prove nothing.
- processSignalTarget returning anything but the individual PID fails instead
of skipping: the child inherited this process's group, so its PGID is this
process's PID and can never equal its own PID.
The vacuous assertion in TestTerminateProcessTreeStopsRunningGroup now requires
ok. That is sound rather than merely stricter: the test never waits on the
child, so the group's zombie row must still be present after termination, and
an unknown state means ps stopped answering rather than that the group is
clean.
Verified on Linux (WSL, go1.26.6) rather than by cross-compilation alone:
- all three tests pass unmodified
- with a stub ps that exits 1: CI unset skips with the reason named; CI=true
fails, naming which pipeline could not answer
- the same broken-ps run against the ORIGINAL file produced two silent skips
plus one unrelated failure after 12s, versus an immediate, explicit failure now
Windows is unaffected (the file is !windows); go vet passes for linux, darwin
and windows.
Fixes#863
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U389qmQUhoZB3YtXkFmiTq
* test(execution): treat an unsignalable zombie group as a kernel difference
The macOS Smoke job failed:
the zombie group 5931 is not signalable (operation not permitted)
That was my error. I converted this skip along with the ps-availability ones,
but it never had anything to do with ps: whether kill(2) may signal a group
whose only member is a zombie is a KERNEL difference. Linux permits it, so
signalTargetRunning goes on to consult ps — the branch this test was written
for. Darwin returns EPERM, so signalTargetRunning short-circuits on the kill and
never reaches ps.
Rather than restore the blanket skip, the test now records which branch answers:
- Signal permitted: assert processGroupHasRunningMember directly, so a ps-side
regression cannot hide behind the kill result. This is new coverage; the old
test only checked signalTargetRunning, which conflates the two paths.
- Signal refused: log it and continue.
The outcome assertion — a zombie-only group must not count as running — now runs
on EVERY platform. Previously Darwin skipped before reaching it, so this is
strictly more coverage there, not less.
Any errno other than EPERM still fails: it means the group is not in the state
the test needs, and nothing after it would be meaningful.
TerminateProcessTree is asserted only where the signal is permitted, because
terminateTarget's own kill(2) would hit the same EPERM and return it as a
cleanup error. Worth a maintainer's eye: that may be a real gap in
TerminateProcessTree on such a platform — a zombie-only tree is already exited
and arguably should terminate cleanly rather than report failure, which is the
same spurious-cleanup-failure class #774 set out to remove. Confirming that
needs a Darwin host and is a production change, so it is flagged rather than
guessed at here.
Verified on Linux (WSL, go1.26.6): all three tests pass, including the new
direct ps assertion. The Darwin branch was exercised by forcing the EPERM path
on Linux — it logs both explanations and passes with the outcome assertion
still enforced. go vet passes for linux, darwin and windows.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U389qmQUhoZB3YtXkFmiTq
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
* fix(sandbox): a temporary root is refcounted, so one holder's cleanup cannot revoke another's
Split out of #829 as an independent fix, per @Vasanthdev2004's review asking for
the self-contained pieces to arrive separately.
A temporary grant was add-then-remove with no notion of who still needed the
root. The second caller to ask for a root already present got a NO-OP undo, and
the first caller's cleanup then removed the root out from under it — so a live
grant lost its access while its holder still believed it had it.
Two read-only tools in the same parallel batch, both blocked on the same
directory, is exactly that shape, and read-only tools are precisely the ones the
batch runs concurrently.
tempReads/tempWrites now count live holders, and the root is only stripped when
the last one releases.
The release also performs both mutations under ONE hold of the lock. Dropping it
between them left a window where the root was still in readRoots but no longer in
tempReads: a concurrent AddTemporaryRead landing there reads it as a PERMANENT
root, hands its caller a no-op undo, and this call then strips the root — so that
caller believes it holds access it has already silently lost.
Independent of the remaining #829 work; builds and tests against current main on
its own.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(sandbox): a read covered by a TEMPORARY write root takes a reference too
Found by a self-review of this branch: the refcount closed the defect within
reads and within writes, but not across them.
AddTemporaryRead returned a no-op undo whenever writeRootCoversLocked said a
write root already covered the path, and the comment there claimed that cover was
permanent. It is not: writeRootCoversLocked scans extraRoots, and
AddTemporaryWrite appends TEMPORARY write roots into that same slice. So a read
covered by another holder's temporary write took no reference, and that holder's
cleanup silently revoked the reader's access — the very defect the refcount
exists to close, reached across the read/write boundary instead of within one
side of it.
Demonstrated before fixing, and the regression fails without it:
ReadRoots before release = [ws, …/outer]
ReadRoots after release = [ws]
the write holder's cleanup revoked a live read grant on …/outer/inner
The read now takes a reference on the covering temporary write root, and the
genuinely permanent case — the workspace root, or a session-scoped grant — still
returns a no-op undo.
The regression also asserts the other direction: once the reader releases too,
the root is gone, so the reference does not leak the grant past its last holder.
Origin-Session: local-0484ba | Claude Code | 6 prompts
Origin-Snapshot: e6d90ff3ce46
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* test(sandbox): make the concurrency test concurrent, and portable off macOS
Both raised by CodeRabbit. The third finding on this PR — a read covered by a
TEMPORARY write root taking no reference — was already fixed in 87a722cb with the
regression it asked for; this covers the two that were still live, and both are
cases of a test reporting more than it checked.
THE CONCURRENCY TEST WAS NOT CONCURRENT. The gate spun until hasReadRoot saw
somebody holding the root, which is satisfied by the FIRST holder, then opened
the release. The spin wins that race against goroutines still being scheduled:
instrumented over 200 runs, 194 peaked at a single simultaneous holder and not
one ever reached eight. A test named for concurrent holders was exercising one
holder at a time. Against a deliberately broken refcount, where the first release
strips the root, it passed 33 times in 40 — it was a flaky test that mostly
agreed with broken code. Counting all eight acquisitions makes the overlap real:
200 of 200 runs now peak at eight, and the same broken refcount is caught 40
times in 40. Holders signal the barrier on every path including failure, so a
holder that returns early fails the test rather than hanging it.
THE HELPER SKIPPED ON WINDOWS. scopeOutsideRoots reached for /Users/Shared and
fell back to /var/empty, neither of which exists there, so every test built on it
skipped and reported green having asserted nothing. It now uses the home
directory, which no platform lists as a default write root, matching what
scope_extra_read_test.go already does — and then PROVES the choice against
defaultTempWriteRoots rather than assuming it, so a future default root turns
these tests into an honest skip instead of a silent no-op.
Origin-Session: local-abff1c | Claude Code | 2 prompts
Origin-Snapshot: d2f269b81f33
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(sandbox): a permanent grant outlives the temporary one, and an undo is per holder
Both P1s from @anandh8x, both reproduced before changing anything, and both are
the same mistake in different places: treating "something covers this path" as
though it answered "and will it still be there afterwards".
A PERMANENT GRANT WAS REVOKED BY A TEMPORARY HOLDER. extraRoots holds temporary
write roots alongside permanent ones, so Add asked only whether the path was
already covered. A session-scoped grant made while a temporary holder happened to
cover it recorded nothing at all, and disappeared the moment that holder
released. Outliving the request that prompted it is the entire difference between
Add and AddTemporaryWrite, and it was not happening. AddRead had it too,
including across the boundary: a permanent read covered only by a temporary WRITE
root died with that root.
Permanent coverage is now looked for first and across every root, so a temporary
cover cannot decide the answer when a permanent one is also present. The same
root held temporarily is promoted in place — its holders' releases become no-ops,
which is what permanence means here. A BROADER temporary root over a narrower
permanent grant cannot be fixed by promotion, so the narrower root is recorded in
its own right; that is the case he specifically called out, and it is the one a
promotion-only fix would have missed.
ONE HOLDER'S UNDO IS ONE HOLDER'S REFERENCE. The comment on releaseTemporaryRead
claimed each undo is called exactly once and that flooring the count at zero
covered the rest. Flooring stops the count going negative; it does nothing about
a holder's second call consuming a DIFFERENT holder's reference. With two
readers, calling the first's undo twice took the count 2 -> 1 -> 0 and removed a
root the second was still using — the exact revocation this refcount exists to
prevent, reached through a duplicate call rather than a sibling's cleanup, and a
deferred cleanup in a retry path is how a caller does that by accident. Every
undo handed out is now wrapped so it drops its own reference at most once, and
the comment says what is actually true.
Both mutation-checked: unwrapping the undo, and ignoring permanence in Add, each
fail the test that covers them. Race detector clean.
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 | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* chore(sandbox): re-request an automated review of this head
No code change. CodeRabbit's verdict on this PR is pinned to ff85dbcc, a commit
that no longer exists on the branch, and a stale changes-requested keeps the PR
at mergeStateStatus=BLOCKED exactly as a human one would. It re-reviews on push
rather than on request, and repeated @coderabbitai triggers over five days
produced nothing, so this is the only lever available without maintainer rights.
The cost is that this dismisses @Vasanthdev2004's and @anandh8x's approvals of
4a7063aa. The tree they approved is byte-identical to this one.
Origin-Session: local-8cd239 | Claude Code | 6 prompts
Origin-Snapshot: 149ab98453ad
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* test(sandbox): a fixture failure fails, a report cannot deadlock, and AddRead's
own branches are covered
All raised by CodeRabbit on the full review. Every one verified before changing
anything.
## The concurrent test could hang instead of failing
failures was buffered at one slot per holder, but each goroutine can report at
BOTH hasReadRoot checks — its own grant going missing while held, and again
after the release gate. Nothing drains the channel until after wg.Wait(), so the
ninth report blocks forever, wg.Done() is never reached and the wait hangs. The
test would deadlock in exactly the case it exists to catch: a refcount bug
severe enough to fail more than eight of eight holders. Buffered at 2*holders.
## A broken fixture reported itself as a pass
TestANarrowerRequestHoldsTheCoveringGrant called t.Skip when os.MkdirAll failed.
The directory is the fixture, not a precondition the environment may reasonably
withhold, and the sibling test above it already uses t.Fatal for the same call.
## Two tests assumed something they never checked
Both tests in scope_extra_read_test.go place their grant under $HOME precisely
because NewScope seeds /tmp and $TMPDIR as write roots — a grant that collapsed
into one of those would be writable for a reason unrelated to the property under
test. $HOME is normally clear of them, but a sandboxed or CI environment that
points HOME at a temp directory turns both tests into something that proves
nothing while still reporting PASS.
homeGrantOutsideTempRoots now builds the fixture for both and skips with the
reason when the assumption does not hold. Verified by running with HOME set to a
temp directory: both tests SKIP naming the covering root, where before they
passed. The path is resolved before comparing, because the temp roots are
resolved and /var is a symlink to /private/var on macOS.
## AddRead's branches were only covered from the write side
Two subtests added: a permanent read under a temporary WRITE root, and a narrow
permanent read under a BROAD temporary read root. Both mutation-checked by
removing the corresponding temporary-coverage guard in AddRead — each fails.
The first mutation I ran did not fail its test, and the finding looked wrong: the
pattern being edited occurs in Add, AddRead and AddTemporaryWrite alike, and a
first-match edit had changed Add. Re-run against the line inside AddRead, it
fails as claimed. Recorded because a mutation that misses its target is
indistinguishable from a test that holds.
## Also asserted: the refcount table is empty, not merely invisible
hasReadRoot answers what a caller can see. An entry left at zero in tempReads is
not visible that way and would still leak one map entry per acquire/release
cycle, so the contention test now checks the terminal state directly.
go test -race ./internal/sandbox/ -count=5: clean.
Unrelated and pre-existing here: TestRunDoctorFormatsRedactedProviderDiagnostics
and TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
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 | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(sandbox): a reader outliving its writer keeps read authority, not write
Reported by @jatmn, and it is a privilege escalation rather than a lifetime bug.
AddTemporaryRead, given a path already covered by a temporary WRITE root, took a
reference on that write root and returned an undo that released it. The lifetime
was then correct — the reader no longer lost its access when the writer cleaned
up — but the capability was wrong. extraRoots feeds validate(), which authorises
WRITES, so once the write holder released first the path stayed in extraRoots for
as long as the read holder lived. A caller that asked only to READ could write
anywhere below the former write root after the write grant had ended.
His diagnosis names the root cause exactly: one write-capability reference was
doing two jobs, tracking a lifetime and standing in for a read-only dependent.
The lifetime is now kept as what the caller actually asked for — a temporary READ
root on its own path. AddTemporaryRead consults permanentWriteRootCoversLocked
rather than writeRootCoversLocked, so a merely temporary write cover no longer
short-circuits it into borrowing write authority.
Mutation-checked: restoring writeRootCoversLocked in that branch compiles and
fails the regression on three separate assertions — the reader keeps no read root
of its own, ReadRoots() loses the path, and the writer's cleanup revokes a live
read grant.
Two things a verification pass turned up that are worth recording:
The comment justifying this design said readRoots "feeds ReadRoots() and nothing
else". That was false — it also feeds ExtraReadRoots(). The security argument
survives, because neither confers write authority and validate() reads
workspaceRoot plus extraRoots, which this path never enters. But that sentence IS
the written justification for the whole choice, so it now lists both and says why
neither grants write.
The "the reader releases first" subtest passes on the unfixed tree: the
escalation needs the WRITER to go first. It is a real no-regression control for
the opposite ordering, not evidence the finding is closed, and it is renamed to
say so rather than padding the count.
Rebased onto ad34dc8d as requested. go test -race ./internal/sandbox/ -count=5:
clean. Pre-existing here and on main, unrelated:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
* fix(sandbox): a nested write holder keeps only the subtree it asked for
Reported by CodeRabbit, and it is the write-side twin of the read-side
escalation @jatmn found — I had disclosed it on this PR as still open, so this
closes it rather than deferring it again.
AddTemporaryWrite, given a path already covered by a broader TEMPORARY write
root, took a reference on that covering root. The lifetime was right and the
capability was wrong, exactly as on the read side: extraRoots is what validate()
authorises writes from, so keeping the covering root alive handed the nested
holder everything its neighbour had asked for.
Reproduced:
both held tempWrites={outer:2} extraRoots=[... outer]
after outer release tempWrites={outer:1} extraRoots=[... outer]
-> the inner-only holder can WRITE outer/sibling/not-mine.txt
A narrower request now records its own root. Each holder gets exactly the
authority it asked for and a lifetime of its own:
both held tempWrites={outer:1, inner:1} extraRoots=[... outer inner]
after outer release tempWrites={inner:1} extraRoots=[... inner]
-> sibling denied, inner retained
Only an EXACT match shares a refcount entry; a narrower one never does, which is
the distinction the escalation turned on. The nesting costs a redundant
extraRoots entry while both holders are live, which is not wrong — the broader
root already permits everything the narrower one does.
Three regressions: the escalation itself (broad holder releases first), the
opposite order as a control, and two holders of the same root sharing one entry.
Mutation: restoring the covering-root borrow compiles and fails the escalation
test on both the sibling and the covering root itself.
Honest note on a second mutation. Removing the permanent-cover early return
compiles and fails NOTHING. That is correct rather than a coverage gap: a
permanently covered request would then record its own temporary root, which is
redundant but harmless, since the permanent root outlives it and already permits
the writes. The early return is a cleanliness guard, not a correctness one, and
I would rather say so than write a test that pretends otherwise.
go test -race ./internal/sandbox/ -count=5: clean. 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
* test(sandbox): the outside-roots fixture fails loudly instead of skipping
Raised by CodeRabbit, and the helper had already written the argument against
itself: its own comment says "A skip is not a pass" while three of its four exits
were skips.
Every caller of scopeOutsideRoots asserts an AUTHORIZATION boundary — who may
write where, and whose release revokes it. A skipped fixture means that assertion
never ran and the build reported green anyway, which is precisely the failure the
helper was written to close. It closed it for one case (a home directory that
resolves nowhere on Windows) and left the others open.
Three exits become fatal:
os.UserHomeDir failing — resolves on every platform Zero supports
os.MkdirTemp(home) failing — a home that will not take a temp directory is a
broken machine, not a limited one
os.MkdirAll under base — base was just created successfully, so a
subdirectory failing under it is the same
If any fires, the right outcome is a red build somebody fixes, not silent
non-coverage of the sandbox's write rules.
ONE SKIP STAYS, and it is a different kind. When the grant path is already inside
a default write root, the fixture built correctly and the machine is fine — the
grant simply cannot demonstrate anything, because that path is writable before
any temporary grant exists. Running on would assert a permission that was never
in question. The comment now says which kind it is, so the distinction survives
the next reader.
Verified the fatal path fires: forcing the MkdirTemp error compiles and fails
TestANestedWriteHolderKeepsOnlyItsOwnSubtree at the fixture with the reason,
where it previously reported a pass.
go test -race ./internal/sandbox/ -count=3: clean. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
Origin-Session: local-c962d7 | Claude Code | 13 prompts
Origin-Snapshot: a599377c09e0
* fix(sandbox): least authority for nested readers, and a hermetic grant fixture
@jatmn's three findings, taken as one pass over the grant construct rather than
as three local patches — his point that the same invariant kept not being
reapplied to the symmetric branch was fair, and this is the symmetric branch.
## The nested reader kept its neighbour's tree
AddTemporaryRead had exactly the shape AddTemporaryWrite was fixed for one
commit earlier: reader B asking for outer/inner took a reference on reader A's
covering root. Lifetime correct, capability wrong. readRoots feeds validateRead
and the native sandbox profile, so once A released, B could read outer/sibling.
Reproduced: tempReads={outer:2}; after A releases, the inner-only reader reads a
sibling it never asked for.
The rule is now the same on both sides: permanent coverage decides first, an
EXACT temporary root shares one refcount, and a narrower request records its own
root. Mutation: restoring the borrow fires both escalation assertions.
## Slice order no longer decides permanent coverage
permanentReadRootCoversLocked asks the whole question over both lists before any
insertion order can answer it, and AddRead now uses the same predicate instead of
its own scan, so the two cannot drift.
Honest note: with the borrow gone, this mutation is no longer catchable. Making
the scan return at the first covering entry compiles and fails nothing, because
AddTemporaryRead no longer borrows anything — it either no-ops or records its own
root, and neither escalates. The guard now prevents a redundant refcount entry
rather than an escalation. Verified by measurement, not assumed.
## A test that asserted the defect
TestANarrowerRequestHoldsTheCoveringGrant proved a lifetime property by
asserting the BROAD root was still present after the broad holder released —
which is the escalation written down as a requirement. It is replaced by tests
that assert lifetime and authority separately: the requested path stays readable,
the sibling and the covering root do not, plus the reverse release order,
exact-root sharing, and a permanent narrow root not borrowing a broad temporary
one.
## The obsolete predicate that broke CI
writeRootCoversLocked lost its last caller when the three coverage decisions
moved to permanentWriteRootCoversLocked and exact-root logic. make lint-static
reproduced the Windows `unused` failure locally. Removed rather than suppressed.
This is on me: I did not run lint-static before the previous push, which is a
required step, and CI caught what I should have.
## Fixtures out of the real home
scopeOutsideRoots and homeGrantOutsideTempRoots both built fixtures under
os.UserHomeDir(). Fixed at the cause jatmn identified rather than by relaxing the
setup checks: NewScope seeds the system temp directory as a permanent write root,
which is what made a t.TempDir() grant vacuous and sent the fixture to HOME in the
first place. Both helpers now build the Scope DIRECTLY — the pattern
scopeOutsideDefaults and temporaryWriteFixture already used — so no defaults are
seeded, t.TempDir() is outside by construction, and nothing touches HOME.
Each helper now asserts the NEGATIVE against its own scope before returning: the
target must be unwritable, and unreadable where relevant, before any grant
exists. That is stronger than the old check against defaultTempWriteRoots(),
which asked about a scope these tests no longer build.
Verified with HOME readable but not writable (caches redirected): the sandbox
suite passes. The one remaining failure there is
TestSeatbeltEnforcesExtraWriteRoots in seatbelt_integration_darwin_test.go, which
is NOT in this PR's diff — pre-existing, same shape, and worth its own issue
rather than riding along here.
gofmt, go vet, lint-static (0 issues), go test -race ./internal/sandbox/ -count=5:
all clean. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
Origin-Session: local-c962d7 | Claude Code | 14 prompts
Origin-Snapshot: a599377c09e0
* refactor(sandbox): drop the child-grant accessors this PR does not consume
Reported by @jatmn.
ExtraRoots and ExtraReadRoots have no production caller anywhere in the tree —
only their own definitions and this PR's tests. They arrived with the initial
split because the extraction was made at the file level: the temporary-root
implementation came across while the CLI and runner wiring that consumes them
stayed behind in #829's child read-grant propagation area. That leaves unrelated
production surface in a branch scoped to refcounting.
Both accessors are removed, along with the getter-only propagation assertion that
existed to exercise them. The refcount regressions that used ExtraRoots only to
inspect beyond-workspace write state now use a test-only helper over Roots(),
because that inspection is a test concern rather than an API this branch needs.
Nothing about the refcount, permanence, least-authority, atomic-release or
hermetic-fixture work changes, and no child propagation is wired here — that
would widen the scope rather than fix the extraction boundary.
One comment needed correcting rather than deleting. The justification for keeping
a reader's lifetime as a READ root named the lists readRoots feeds, and that
sentence has now been wrong in both directions: an earlier draft said "ReadRoots
and nothing else" while ExtraReadRoots also read it, and the accessor it was
corrected to mention is the one being removed here. It now describes what ships
and says why the list has to stay exhaustive as accessors change.
Rebased onto 6fe0d1ed, 0 behind. lint-static: 0 issues.
go test -race ./internal/sandbox/ -count=3: clean. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
* fix(tools): an edit keeps the reads it did not disturb
Split out of #829 as an independent fix, per @Vasanthdev2004's review asking for
the small self-contained pieces to arrive separately.
A successful edit re-baselined the file through RecordHash, which drops EVERY
recorded read range. A file read in three pieces therefore lost all three to a
single two-line edit, and the next six edits into regions that had already been
read were refused as unseen — the model had to re-read what it had just read in
order to keep working.
RecordEdit replaces that on the edit path. Because the edit is ours, the changed
span is known exactly, so ranges the edit did not touch are carried across,
ranges after it are shifted by the line delta, and a range the edit spans is
split around it.
An EXTERNAL change still drops everything, deliberately: once a formatter has
rewritten the file we no longer know which line holds what was read, and the
conservative drop is the only honest answer there.
"Seen whole" is now decided in ONE place. SeenWhole derives the answer from the
ranges because the raw flag is only ever set by a single covering read, but
RecordEdit branched on the flag — so a file read whole in two chunks reported
seen-whole true, then false after one edit, putting back the write_file refusal
the derived answer exists to prevent.
countLines now reports the count a READER would give. strings.Split leaves an
empty final element for content ending in a newline, so it counted 4 lines for a
3-line file; since SeenWhole asks whether the ranges cover 1..total, that
inflated total made full coverage unreachable for a file just read in full.
Nearly every text file ends in a newline, so this was the common case.
Origin-Session: local-de382f | Claude Code | 9 prompts
Origin-Snapshot: 92ae33c95cd1
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: ae653221fa15
* chore(tools): re-request an automated review of this head
No code change. CodeRabbit's verdict on this PR is pinned to 6f0cd6c4, a commit
that was force-pushed away five days ago, and a stale changes-requested keeps the
PR at mergeStateStatus=BLOCKED exactly as a human one would. It re-reviews on
push rather than on request, and repeated @coderabbitai triggers over five days
produced nothing, so this is the only lever available without maintainer rights.
The cost is that this dismisses @Vasanthdev2004's and @anandh8x's approvals of
0b6fcf54, which is an unhappy trade for an empty commit — the tree they approved
is byte-identical to this one.
Origin-Session: local-8cd239 | Claude Code | 6 prompts
Origin-Snapshot: 149ab98453ad
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: ae653221fa15
* perf(tools): derive the changed-line span from bytes, not from per-line slices
Reported by @jatmn.
changedLineSpan split both file versions into []string, so it allocated one
string header per line for EACH version — work that scales with the size of the
file rather than the size of the edit. countLines then split a third time just to
take a length. All of it ran before RecordEdit had checked whether there was a
tracked observation to update at all.
He measured 32,036,640 bytes for two 2 MB versions of a million short lines, and
extrapolated roughly 1.6 GB of headers for a 100 MB file before counting the
content, the updated bytes and the hashes already live beside them.
The memory was not the sharp end. edit_file writes the updated bytes BEFORE
calling RecordEdit, so an out-of-memory kill lands after the user's file has
changed and before the tracker baseline catches up: the file and the record of it
disagree, and nothing says so. His instruction was to fix the allocation source
rather than add a size guard, and that a guard — if used at all — would have to
reject before os.WriteFile rather than during post-write re-baselining. Taken as
written: there is no new limit, and the same edits are accepted as before.
Two identical byte prefixes share every line that ends inside them, so counting
newlines in the common prefix counts the unchanged leading lines directly. Same
for the suffix, with one rule that the line-array version got for free: the line
a shared tail STARTS in is only shared when the tail begins on a line boundary in
both versions, or its opening bytes differ and it is a changed line that merely
ends the same way.
Measured on his own scenario, two 2 MB versions:
changedLineSpan 32,036,640 bytes -> 0 allocations
countLines per-line slice -> 0 allocations
## The equivalence is tested, not asserted
A rewrite of a diff primitive is only safe if it is behaviour-preserving, so the
replaced implementation is kept in the test file as an oracle and both are asked
the same questions. That caught 206 mismatches in my first attempt at the byte
scan — empty versions, and trailing lines that are only partially shared. Neither
appears in the hand-written cases, and I would have shipped it.
Two regressions: the oracle comparison, and an absolute allocation ceiling rather
than a ratio, so a future rewrite that reintroduces per-line slices fails rather
than merely getting slower. Mutation: restoring strings.Split in either function
allocates 3.2 MB and 3.6 MB against a 4 KB bound.
splitLinesForTracking lost its last caller and is removed — the same obsolete-
helper lint failure that broke Windows CI on #911, caught here by running
make lint-static before pushing rather than after. 0 issues.
Rebased onto ad34dc8d, 0 behind. go test -race ./internal/tools/: 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
* perf(agent): cheap compaction — dedicated summarizer model, input caps, free reactive prune, window re-derive
Compaction summarization was the single most expensive recurring event
in a long run: the full elided middle — verbatim tool bodies included —
went to the session's main model at full price (a ~100k-token call near
a 200k window, recursively multiplied on overflow).
Four changes, each independently safe:
- Dedicated summarizer model. Compaction summaries route to a cheap
model resolved as ZERO_COMPACTION_MODEL > preferences.compactionModel
> a curated default on official endpoints (Haiku 4.5 / Gemini
Flash-Lite / gpt-4.1-mini; custom-endpoint catalogs are unknowable so
they get none). Built lazily on the first paid compaction; ANY
failure permanently falls back to the main provider for the run, so a
misconfigured summarizer can never break compaction. "main" at any
level forces the old behavior.
- Summarizer input caps. renderTranscript clamps tool-result bodies to
2,000 chars and tool-call arguments to 500 (head+tail, rune-safe) —
the summary needs what was done and decided, not raw payloads. Cuts
summarizer input 5-10x on tool-heavy sessions.
- Free reactive prune. recover() now runs the same prune-first stage as
the proactive path: a context-limit error is first answered by
pruning stale tool bodies at zero cost, without consuming the
one-shot reactive budget; the paid summarizer only runs when a later
error finds nothing left to prune.
- Window re-derivation on model switch. Options.ContextWindowFor
(wired to the model registry in exec and the TUI) lets the loop
re-derive the compaction threshold after escalate_model, closing the
documented KNOWN LIMITATION where a switch to a smaller-window model
could overflow and a larger one over-compacted.
## Rebase notes (2026-08-25, onto 2749447c)
Rebased across 214 commits. main now summarizes through the context planner
(#879) and switches models via turn sessions, so the agent side was reworked
rather than merged textually:
- Summarizer selection lives on compactionState and is resolved lazily per
main model through summarizeWithPlanner (planner + trace preserved); a
failing dedicated summarizer is marked broken and the same slice is
retried once on the main provider.
- Options.Summarizer now receives the main model in force:
`func(ctx, mainModelID string) (Provider, error)`, and may return
(nil, nil) when no dedicated model applies. After a successful
escalate_model switch the loop calls compactor.switchModel, which
re-derives the window and re-resolves the summarizer — a run that started
on the cheap model and escalated regains cheap summaries (review P2).
- recover()'s free prune stage runs before the one-shot reactive guard
(pruning is free and cannot loop) and mirrors maybeCompact's two prune
passes; the free retry is taken only when the pruned history fits the
window the provider enforces (with a 5% estimate margin), otherwise the
same call falls through to the paid summarizer. Gating on the 70%
proactive threshold would have sent histories in the 70-95% band to the
summarizer unnecessarily (review P1 and the follow-up question).
- ContextWindowFor is wired through the turn-session switch path; the
planner's window is updated alongside the compactor's.
Tests: the PR's suite is ported to the new seams, plus insufficient-prune
fall-through, free prune after the budget is spent, window-vs-threshold
gating, and summarizer re-resolution across a model switch.
* fix(agent): branch on a dedicated-summarizer flag instead of comparing providers
Review follow-ups for #532:
- summarizeProvider now reports whether the dedicated summarizer was
selected and summarizeClosure branches on that flag; the previous
`chosen == provider` compared Provider interface values, which panics
when the dynamic type is not comparable. Regression test uses a provider
with a slice field for both the summarizer and the main provider.
- CompactionModelID's curated-default test covers the official OpenAI
profile alongside Anthropic and Google.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* fix(tools): make apply_patch tolerant and re-expose edit_file
Head-to-head benchmarking against Grok Build on grok-4.6 (3 tasks × 3 runs)
showed Zero solving every task but spending 2.7× the tokens and 2.3× the
time on the multi-file feature task. Every one of the 20 apply_patch calls
in those runs failed, after which the model degraded to whole-file
write_file rewrites and read→write verification loops.
Root causes and fixes:
- The sandbox and the tool both rejected an absolute path that lies inside
the workspace ("must stay inside the workspace"). Models routinely echo
the absolute path read_file showed them. Absolute paths now flow through
the normal workspace-scope validation (inside → allowed, outside → still
denied); unified-diff headers are rewritten to a/ b/ relative form before
git apply, and platform symlink prefixes (/var → /private/var) are
normalised the same way the other file tools do.
- The structured parser demanded a byte-exact "*** Begin Patch" /
"*** End Patch"; grok-4.6 writes "*** Begin Patch ***". Decorated marker
spellings are accepted, and a unified range after "@@" ("-12,4 +12,6",
optionally with a heading) no longer becomes a literal context search.
- Parse errors now carry a one-line format reminder so a failed call is
recoverable in one retry instead of a format-guessing loop.
- edit_file (exact search/replace with uniqueness guard, CRLF and fuzzy
fallbacks) is advertised to the model again alongside apply_patch, and
the apply_patch description shows the structured format.
- Writers (edit_file, write_file, apply_patch) recorded a file's line total
with lineCount, which is one higher than read_file's count for a file
ending in a newline. RecordSeenRange resets every observation when the
total changes, so a partial read_file after a successful edit silently
discarded whole-file knowledge and the next edit was refused with
"content that has not been read exactly in this session". All writers
now record the same total read_file reports (trackedLineTotal).
- read_file's padded " N | " line prefix was ~19% of every read (about
1.5k tokens per 800-line file) and was re-sent on every later call. It is
now the compact "N→" prefix models already know; nothing parsed the old
form.
- The editing-discipline prompt steers: edit_file for a targeted change,
apply_patch for multi-hunk/multi-file changes, write_file only for new
files or full rewrites; a successful edit result is confirmation, so the
file is not re-read to verify it.
Tests: regression tests for decorated markers, writer/reader line-total
agreement (edit → partial read → edit far away still succeeds), range hunk headers,
absolute in-workspace paths (structured + unified), outside-workspace
rejection, header-line-only relativisation, and tool visibility; the
prompt and tool-budget tests updated to the new contract.
Benchmark (grok-4.6, 3 tasks x 3 runs, harness in demo/bench-zero-vs-grok):
Zero 0.8.0 617 s / 1,527k tokens -> with this change 346 s / 493k tokens;
Grok Build 407 s / 713k. Every run of every configuration solved its task.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(tools): keep CRLF line endings when relativizing patch headers
relativizeUnifiedPatchPaths split on a normalized "\n" and rejoined with
"\n", so any CRLF patch that needed a header rewrite lost its line endings
everywhere; git apply could then fail to match a CRLF file. Split on "\n"
only and carry each line's trailing "\r" through, including on rewritten
headers.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(sandbox): share the structured-patch classifier with apply_patch
Review follow-ups for #956:
- The tool accepted decorated structured markers ("***Begin Patch") that
the sandbox's applyPatchPaths did not recognise, so for that spelling the
boundary scanned the patch as a unified diff, extracted no targets and
validated nothing. The marker classifier now lives in the sandbox package
(StructuredPatchMarker / IsStructuredPatch) and the tool delegates to it,
so both sides accept exactly the same set; tests cover inside, traversal
and outside targets for the no-space spelling at the boundary.
- Document the containment model at the apply site: structured patches are
applied through an opened os.Root (descriptor-relative, no-follow);
unified diffs keep the pre-existing git-apply pathname flow, which the
absolute-path rewrite does not widen (they become the same root-relative
form relative paths always used).
- OpenAI addendum: the one-file-per-call rule names read_file, edit_file and
write_file explicitly and allows a single multi-file apply_patch.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* test(sandbox): compare slash-normalised patch paths so the check passes on Windows
structuredPatchHeaderPaths normalises separators to "/", so the absolute
outside-workspace path in the boundary test must be compared in its slash
form; on Windows the backslash original never matched and the Smoke job
failed.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* test(sandbox): validate the parsed patch path at the scope boundary
For each accepted marker spelling, pass the exact path applyPatchRequestPaths
produced to scope.validate: an outside path must be denied and an inside
path accepted, so a separator-handling regression between the parser and the
scope cannot slip past the test.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(tools): apply unified diffs in-process through the workspace root
Review follow-up for #956. Unified diffs were validated by pathname and then
handed to git apply, which reopened the targets by name — a check-to-use
window a symlink swap could exploit — and the absolute-path rewrite added in
this PR normalised `diff --git` headers with strings.Fields, which splits a
git C-quoted path containing whitespace.
Both go away by not shelling out at all: parseUnifiedPatch translates a
unified diff into the same operations a structured patch produces, and both
formats are applied by applyPatchOperations through os.OpenRoot — every
stat, read, create and write is descriptor-relative and refuses to follow a
link out of the root. git is no longer required by apply_patch.
Unified-diff support: modifications, creations (--- /dev/null), deletions
(+++ /dev/null), multiple hunks per file, git "rename from/to" (a move,
hunks optional) and "copy from/to" (a new copy operation whose destination
inherits only the tracker credit the source had), "\ No newline at end of
file" on either side, a/ b/ prefixes, C-quoted paths, CRLF patches, and
git's diff/index/mode lines. Hunks are placed at their "@@ -a,b" range when
the expected lines match there and located by context otherwise (so a
stale range still applies and duplicate blocks are disambiguated by the
range). Binary patches are rejected.
Removed: the git apply flow, relativizeUnifiedPatchPaths,
recheckPatchWriteTargets, missingPatchTargets, completeCreatedPatchTargets
and recordCreatedPatchTargets. validatePatchPaths/changedFilesFromPatch stay
for mutation-target listing.
Tests: the existing unified-diff suite now runs through the in-process
applier unchanged; new coverage for quoted whitespace paths (inside applies,
outside is refused untouched), a workspace path swapped for a symlink that
escapes the root (both formats refused, outside file unmodified), create +
delete + no-newline markers, range hint vs context fallback, rename with a
hunk, and binary/empty rejection.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(tools): harden unified-diff parsing and cover the copy operation
Review follow-ups for #956 (in-process unified applier):
- A hunk that ends before its declared line counts is reported as
malformed instead of silently absorbing the next file's ---/+++ header
pair (which then failed later against the wrong file). Both shapes are
caught: leftover counts at a header or at end of input, and a --- line
directly followed by +++ inside a hunk. The element after a patch's final
newline is no longer counted as an empty context line, so a truncated
hunk cannot look complete.
- "\ No newline at end of file" may only follow a file's final hunk; a
marker before another hunk is rejected rather than leaking into that
hunk's result.
- Zero-context insertions (git diff -U0) are documented as
position-trusting by design, matching git apply without fuzz; hunks with
context are verified at the range before it is used.
Tests: copy from/to (source kept, destination created with the hunk
applied and tracked with the source's whole-file state; existing
destination refused untouched; copy onto itself refused), over-declared and
truncated hunk counts, and the no-newline marker before vs after the final
hunk.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(tools): require an @@ line before treating ---/+++ inside a hunk as a file boundary
A removed "-- x" line directly followed by an added "++ y" line is valid
hunk content that also matches the ---/+++ header shape; the boundary check
now additionally requires the following line to be a hunk header, and a
regression test keeps such a pair intact. End-of-input strictness for a hunk
cut short is documented as deliberate: applying a truncated hunk would make
a partial change (removals without their replacements).
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* refactor(tools): share one line-count implementation between reader and writer
sourceLineCount and trackedLineTotal computed the same count; keeping two
copies invites exactly the reader/writer drift this PR fixed. The reader now
delegates to trackedLineTotal, which carries the tracker contract.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(tools): verify a unified deletion's expected content before removing the file
A unified diff's "+++ /dev/null" hunks state the content being removed, but
the in-process applier dropped them and deleted unconditionally, so a stale
deletion removed a file whose current content the patch never described.
Unified deletions now keep their hunks, require at least one, and run them
through the same verifier as updates: the hunks must match the current file
and leave nothing behind, otherwise the deletion is refused and the file is
untouched. A structured "*** Delete File" keeps its unconditional meaning.
Hardening from the same review: every non-create change re-reads its source
through the opened root immediately before commit and refuses to proceed if
the bytes differ from what was planned.
Tests: matching deletion, stale deletion (file byte-for-byte unchanged),
partial deletion, header-only deletion, multi-hunk deletion with one stale
hunk (nothing removed) and all-good hunks, structured delete unchanged, and
a source changed between planning and commit for update and delete.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* test(tools): a move with a missing source is refused before publishing
The pre-commit recheck now refuses a move whose source vanished after
planning instead of publishing the destination and then failing; update the
test to the safer contract (error names the recheck, nothing is created).
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(tools): byte-exact unified deletions and git's header-only empty-file forms
Review follow-ups for #956:
- A unified deletion is verified byte for byte: the removed lines (with the
removed side's "\ No newline at end of file" honoured) must equal the
current file exactly, with only the file's own line-ending style
normalised. None of the whitespace tolerance used to locate update hunks
applies, and a whitespace-only remainder no longer passes; a deletion is
not recoverable, so it fails closed.
- git describes an empty file's deletion or creation with header lines only
("diff --git", "deleted file mode"/"new file mode", "index") and no
hunks. Both forms are recognised: the deletion is applied only when the
current file is empty, the creation writes an empty file, and they compose
with hunk-carrying files in the same patch.
- The pre-commit recheck test also covers the copy operation and asserts no
destination is created on refusal.
Tests: whitespace/indentation/blank-line/space-only/missing-newline
mismatches all refused with the file unchanged, matching no-newline marker
and CRLF file accepted, header-only deletion of a non-empty file refused,
multi-file patch with header-only delete + create + a normal hunk.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* test(tools): remove the move source after planning via a deterministic hook
The refused-move test now creates source.txt, plans the move, and removes
the file through a test-only pre-commit hook (structuredPatchBeforeCommit)
so it exercises the post-planning disappearance path rather than a source
that was never there. Assertions unchanged: refused before publishing, no
destination created.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* test(tui): read_file fixtures use the compact N→ prefix
Three TUI fixtures still built fake read_file output in the legacy
" N | text" shape; they pass either way but pinned an outdated contract.
They now mirror what appendReadFileRange emits, and the shape comment and
the expanded-card assertion are updated.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(tools): name the committed files when a patch is partially applied
Review follow-up for #956. When a later change in a multi-file patch fails
(for example the pre-commit recheck refuses a file that changed after
planning), the error now lists exactly which files were already committed
and states that the rest are unchanged, instead of a generic "partially
applied" warning. The model can then re-read only what actually changed.
Test: a three-file structured patch whose second file is altered after
planning fails with "already committed: first.txt", first.txt holds the
patched content, and the other two files are untouched.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
---------
Co-authored-by: Claude Fable 5 <[email protected]>
@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
* fix(sandbox): retire recorded approvals through a policy bump
Ahead of tightening which command prefixes may be approved, make the
upgrade path safe. A grant already on disk that the narrower rules
reject fails validation on read, and that path rejects the entire grants
file rather than the one entry. Every caller treats a read error as "no
grant" (engine.go LookupCommandPrefix, ApprovedCommandPrefixes, and the
Lookup in Evaluate), so one stale prefix would silently disable the
user's persisted deny grants too, with the error text never reaching
them.
Bump the approval policy version instead and let the store's existing
migration handle it: it backs the file up, keeps deny grants,
invalidates prior approvals, and reports the counts once through the
startup notice that both frontends already print. That is what the
mechanism was built for.
The v1-migration test pinned the policy version as a literal, so it now
derives the expected value from the constant and will not need editing
on the next bump.
* fix(sandbox): normalize launcher names before the command-prefix denylist
unsafeCommandPrefixLauncher matched the program name as written, so
python3.11, python2.7, python.exe, node.exe and git.exe all validated as
ordinary commands and could be persisted as command-prefix grants.
Lookup is a string-prefix match, so a single approval then auto-allowed
every later invocation: granting [python3.11 -c] covers
`python3.11 -c "import os; os.system(...)"` with no further prompt.
internal/agent's commandName already normalizes case and Windows
executable extensions for the allow side of the same decision; the deny
side did not, and the two disagreeing is the bug. Reduce the program to
the launcher it actually runs and match on that: executable extension,
Windows' trailing dots and spaces (python. starts python.exe), CPython
ABI flags, then a trailing version, testing the digit-stripped form as
well so python3.11 and python3 both arrive as python. A name is only
ever narrowed toward an existing entry, so the failure direction is an
extra permission prompt, never a silent grant. Digits without a
separator stay part of the name, which keeps base64, 7z and sha256sum
grantable.
Two Windows spellings cannot be resolved by name at all, so the shape is
refused alongside a path: 8.3 short names truncate the stem, which makes
POWERS~1.EXE unrecognizable, and python.exe::$DATA reaches the same
executable through its default stream. Normalizing the first token
before the banned-suggestion comparison keeps git.exe at parity with
git.
The list also gains launchers of the same class it already covers and
that normalization cannot reach: cmd, wsl, the remaining POSIX shells,
busybox, uv and uvx. Entries normalization now covers (/bin/bash,
/bin/zsh, powershell.exe, python3, pypy3) are gone; the first two were
already unreachable behind the path check above them.
Covers the persisted store, the session-scoped grants the agent takes
during a turn, the legacy-schema migration that prunes such a grant
per entry, and the Windows spellings hermetically.
* fix(sandbox): treat free-threaded CPython as a launcher too
The ABI flags came from the pre-3.13 set, so "python3.13t" and
"python3.13t.exe" normalized to themselves and stayed grantable as
command prefixes while the GIL build "python3.13" was refused. A
free-threaded interpreter runs arbitrary code just the same.
Add "t" to the flags trimmed under the existing digit guard, which is
what keeps ordinary names intact: "zstd" and "cat" have no digit beneath
the trimmed letters, so they keep their own names and stay grantable.
Reported by CodeRabbit on #934.
* fix(sandbox): refuse the remaining spellings of listed launchers
Same defect as the free-threaded build, found by sweeping the families
already on the list for names it cannot reach:
- nodejs is node on Debian and Ubuntu, and was grantable.
- python3-dbg, python3.11-dbg and python3.13t-dbg are interpreters that
run arbitrary code; so are bash-static, pwsh-preview and
node-nightly. A closed set of build-channel suffixes now normalizes
them. The set stays closed deliberately: a general "-word" strip would
also swallow node-gyp, python3-config and ruby-lsp, which are ordinary
tools a user should still be able to approve, and those are pinned as
must-stay-grantable.
- pnpm, yarn and bunx run package scripts exactly as npm and npx do, and
npm and npx are already refused.
- sudoedit escalates like sudo.
The launcher-wrapper family (winpty, chroot, unshare, nsenter, parallel,
script, unbuffer, taskset, flock) has the same inconsistency against the
env/nohup/timeout/setsid entries already on the list, and is left for a
separate decision rather than widened here.
* test(sandbox): cover the -debug and -beta launcher suffixes
Four of the six build-channel suffixes had regression cases; -debug and
-beta did not, so removing either from the set left every test green.
Both now fail RED when their entry is dropped.
Reported by CodeRabbit on #934.
deepseek-v4-flash / deepseek-v4-pro expose thinking-mode effort control
through the OpenAI-compatible reasoning_effort parameter (low/high/max;
medium/xhigh map onto high). reasoningEffortsForModelName did not recognize
the deepseek family, so /effort only offered 'auto' for these models.
Advertise {low, high, max} so the /effort picker and Ctrl+T ring work, and
add fallback coverage for deepseek-v4-flash/pro/reasoner/chat.
* feat(update): recognise Homebrew installs and add mise, go install docs
* fix(update): match the keg shape, correct the docs, cover Apply and cross-target
* test(update): build the release fixture from the running platform
* fix(update): require a Homebrew receipt before refusing self-update
Review found the keg check was shape-only, and it was right: a directory
tree a user built themselves can have exactly a keg's shape. Classifying
one as Homebrew refuses a `zero upgrade` that would have worked
perfectly, on a machine Homebrew has never touched.
Two changes, cheap one first. The shape rule now requires the full
<prefix>/Cellar/<formula>/<version>/bin/<binary> depth rather than one
segment fewer, which already excludes the exact path the review raised.
Then, only for a path that still looks like a keg, one Stat for
INSTALL_RECEIPT.json — the file Homebrew writes into every keg and
nothing else does.
The receipt is what the old comment argued against, on the grounds that
it would put filesystem I/O on a path that runs at every version check.
That is no longer true of it: the Stat happens only after the shape test
has already matched, so an ordinary install still reaches no filesystem
here at all.
A directory named INSTALL_RECEIPT.json is not a receipt, which a bare
Stat would have accepted silently.
NOT taken: the same review asked for the Homebrew branch to be moved
ahead of the cross-target one in upgradeGuidance. That ordering is
deliberate and already pinned by
TestUpgradeGuidanceKeepsCrossTargetAnswerForHomebrew, whose comment
predicted exactly this reading — "it reads like an ordering bug until you
see which question is being answered". Homebrew is a property of the
binary on THIS machine; when the check was asked about a different
target the answer belongs to that other machine, and `brew upgrade zero`
would change this one instead. The reasoning is now recorded at the
branch as well as in the test, so the next reader finds it in both
places.
The install docs claimed detection was the Cellar path alone, which
undersold it, and stated the self-update rule absolutely in one paragraph
before excepting mise in the next. Both say the same thing now.
* test(update): give the Homebrew fixtures a receipt, and stop gating the receipt rule on the host
The previous commit made a receipt the thing that distinguishes a keg from any
directory sitting under a path containing "Cellar". Two fixtures still built a
keg without one, so detection correctly answered standalone and both failed:
TestDetectInstallMethodRecognisesAHomebrewKeg keg path: got "standalone", want "homebrew"
TestApplyRefusesToUpdateAHomebrewKeg refusal does not name the command that works:
download release archive: dial tcp: lookup example.test
The second is worth reading twice: with detection returning standalone, Apply
stopped refusing and went to the network, so the failure surfaced as a DNS error
rather than as a missing refusal. Both fixtures write a receipt now.
The reason this reached CI at all is the second half. Every test of the receipt
rule passed runtime.GOOS to isHomebrewPath, which already takes the target as a
parameter, so all of them skipped on Windows and the rule could only ever break
somewhere else. They pass an explicit target now and run everywhere. The two
that exercise DetectInstallMethod stay host-gated, because those are about the
wired entry point rather than the rule, but the rule underneath them is no
longer invisible here.
Verified the receipt path arithmetic on both non-Windows targets rather than
trusting the host-gated tests: isHomebrewPath is false before the receipt is
written and true after, with the receipt at the keg directory hasHomebrewReceipt
actually reads.
* feat: use Exa as default search provider
Amp-Thread-ID: https://ampcode.com/threads/T-01a01522-a5f1-7139-8a55-5e417cba2751
Co-authored-by: Amp <[email protected]>
* fix(config): carry a legacy default's disable across the Exa migration
A user who ran `zero mcp disable firecrawl` chose not to open that outbound
connection. Renaming the built-in default to exa re-opened it under the new
name on upgrade, and because the replacement looked like an untouched default
the startup warning stayed quiet too.
Carry an explicit user-level disable of a retired default onto its successor,
but only when the user never declared the successor themselves — an explicit
exa entry wins either way. The carried-over disable is recorded as a user-level
decision, so the lower-trust project layer cannot lift it while
`zero mcp enable exa` still can.
Regression coverage: the legacy-disabled upgrade path, the explicit-exa
override, a configured-but-enabled legacy entry, the CLI re-enable, the
project-layer re-enable attempt, and the resulting startup behavior
(NormalizeConfig must not hand back an exa server to connect to).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(config): keep a retired default's transport for partial user entries
A user who added an API key to the firecrawl default wrote only the header —
the seeded default supplied the http type and URL. Once the default stops being
seeded that entry resolves with no type, url, or command, and NormalizeConfig
rejects it, taking down startup for every other server too rather than just
that one.
Record what Zero used to ship for a retired default and re-seed it under the
user's own fields. Only for an entry that named no transport of its own: an
entry with a self-hosted url or a stdio command is a complete definition the
user owns, and grafting the retired http default onto a command produces a
server that is rejected outright.
Regression coverage at both ResolveMCP and NormalizeConfig for the header-only
entry and its failure path, the self-hosted stdio entry that must be left
alone, and a disabled retired entry keeping a usable transport so a later
re-enable resolves.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Amp <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
* Fix LM studio (local) model discovery.
* fix(openai): handle typed-nil properties in normalizeToolParameters
The `properties == nil` interface comparison misses typed-nil
`map[string]any` values, which JSON-serialize as `null` instead of
`{}`. Strict OpenAI-compatible servers (LM Studio) reject the request.
Type-assert first, then compare the underlying map, so a typed-nil
properties field is rewritten to an empty object. Adds a regression
test that exercises the typed-nil path.
* test(openai): cover non-map properties in normalizeToolParameters
CodeRabbit follow-up on the typed-nil fix: lock in the behavior
when `properties` is a non-map value (e.g., empty slice) so the
type-assert branch normalizes it to an empty object on the wire.
* fix(acp): four defects a desktop ACP client hits
Found by driving `zero acp` from ZeroApp and comparing both sides of the
wire. None of them fail loudly — they produce a blank transcript, a
silent denial, a spurious crash, or two buttons nobody can tell apart.
session/load restored the conversation into the agent's own memory and
sent nothing. The turns were read from the store and used to build every
later prompt, but not one notification went out, so a client that resumed
opened an empty transcript against a model that remembered everything.
Ask a follow-up and ZERO answers correctly, about a question no longer on
screen. It now replays the loaded turns as user/agent message chunks
before returning, so a client rendering on the reply already has them.
Whole messages rather than per-token deltas: this is a transcript, not a
stream.
A permission option we offered could not be accepted. The fallback list
for a request with no AvailableDecisions lived inside
buildPermissionOptions, while requestPermission validated the client's
answer against the raw field — so whenever ZERO did not enumerate, the
client was sent Allow and Reject and its reply was checked against an
empty slice. Every button it could possibly show failed closed to deny
with "permission option was not offered". The user clicked Allow and ZERO
recorded a denial, and nothing surfaced it, because a denial is a
legitimate answer. One resolver now feeds both, so they agree by
construction. The narrowing this validation exists for is unchanged: an
unoffered broader grant is still refused.
Cancelling a permission prompt came back as an internal error. Only
context.Canceled was recognised, so errPermissionApprovalCanceled fell
through to -32603 carrying the sentinel text, and clients render that as
a failed turn — declining a tool looked like ZERO falling over. For
apply_patch, dismissing the dialog is the only refusal a client is
offered. It maps to StopCancelled now. The sentinel is exported so the
surfaces can recognise it; a genuine failure is still an error.
request_permissions labelled two different decisions "Allow". The label
is the only thing an ACP client shows, and plain allow and strict-review
allow are offered together, so the panel had two identical buttons where
one silently enabled strict auto-review of what was granted. The
distinction cannot live in the option id, which is opaque to the client,
so it is in the name.
Eleven tests, each confirmed to fail without its fix.
* refactor(acp): drop the session/load replay, superseded by #914
gnanam's #914 lands the same fix and does more with it: the replayed
messages carry stable ids derived from the store's event ids, session/load
replays while session/resume deliberately does not, and both are
capability-gated. Keeping a second, weaker replay here would have meant a
conflict in handleSessionLoad and translate.go for no gain.
translate.go is back to its state on main. What remains are the three
defects #914 does not touch: the permission option that could not be
accepted, the cancel that arrived as an internal error, and the two
options labelled the same.
* fix(acp): validate a permission reply against the options that were sent
CodeRabbit caught a hole on the same seam this PR already fixes, in the
other direction.
buildPermissionOptions drops PermissionDecisionCancel, because ACP
expresses cancellation through the outcome rather than an option. But
ZERO enumerates cancel in AvailableDecisions for shell commands and
apply_patch — the two commonest prompts — so validating against the
decisions accepted {"outcome":"selected","optionId":"cancel"}: an
identifier no client was ever shown, aborting the whole turn.
Rather than reject cancel by name, decisionFromOutcome now takes the
[]PermissionOption that were actually sent, and requestPermission builds
that list once and uses it for both halves. "What we offered" and "what we
accept" become the same slice instead of two things kept in step, so
anything optionKindFor declines to render is excluded structurally — the
next such action is covered the day it is added.
The regression test is written over every option-less decision rather
than over cancel specifically, for the same reason. Cancelling through
the outcome still works, which is asserted separately: closing the
selected-id route must not close the real one.
Also adds the cancellation coverage CodeRabbit asked for on the two
sandbox-retry paths. Both wrap ErrPermissionApprovalCanceled and each is
reached by a different denial, and the ACP mapping in this PR depends on
them producing something recognisable. They also assert the escalated
retry does not run: a cancelled prompt that still ran it would run the
command the user had just declined.
* docs(agents): Record four recurring review blockers
These classes come up across unrelated pull requests, are not caught by CI,
and nothing in AGENTS.md currently prevents them, so each is rediscovered by a
reviewer at the cost of a round trip.
Host tool version floors is the clearest case. Zero shells out to git and the
project declares no minimum version anywhere, so adding a modern flag raises
that floor silently and an older host fails with a usage error instead of a
clear message. Prove-the-test-fails addresses a recurring shape where the
problem is not a missing test but one that passes either way, because an
earlier guard rejects the input before the behavior under test runs. Hermetic
tests covers isolation helpers applied to some tests but not others reaching
the same storage. Platform error codes extends the existing platform-truth
rule, which covers path canonicalization but not errno and NTSTATUS values.
Advisory lint findings are deliberately not included: deadcode and lint-static
both run with continue-on-error in ci.yml, and section 3 already requires
fixing findings the change introduces.
Refs #898
* docs(agents): Tighten version-floor, isolation, and errno guidance
Three corrections from review, two of them to claims that were too strong.
Git version floors are per subcommand, not per flag. --end-of-options reached
the shared parser in 2.24 but git rev-parse only accepted it from 2.30, so
recording the flag's general debut would still let a caller ship something
that fails on 2.24 through 2.29.
The hermetic-tests bullet named config, cache, and state but gave a procedure
covering only config. It now covers each root, including %LocalAppData% for
the cache root, which os.UserCacheDir resolves without consulting XDG.
The errno bullet asserted ENOTDIR on Linux and Darwin. Only Linux was actually
measured; Darwin was assumed, in a bullet whose whole point is not to assume.
It now says the call may report either code and both should be accepted, which
is what the guidance was trying to teach.
Refs #898
* docs(agents): Clarify macOS and Windows roots for hermetic test isolation.
os.UserConfigDir and os.UserCacheDir derive from HOME on macOS and AppData/LocalAppData on Windows rather than reading XDG variables.
Refs #899
---------
Co-authored-by: euxaristia <[email protected]>
* Checkpoint UI polish
* Fix structured apply patch handling
* Restore tool previews when resuming sessions
* Improve theme-aware code rendering
* Replace persistent sidebar with run details
* Remove inert transcript interactions
* Cover resumed edit previews
* Smooth active run status feedback
* Preserve semantic colors behind overlays
* Normalize ChatGPT model aliases
* Render staged image attachments in terminal
* Polish transcript and tool feedback
* Resolve rebased diff renderer conflict
* Address UI polish review findings
* Fix smoke test regressions
* Harden patch and transcript edge cases
* Bind structured patches to workspace root
* Use unique structured patch temp files
* Retry structured patch temp allocation
* Reuse rooted patch temp allocator
* Fix Windows CI process checks
* Fix structured patch safety boundaries
Preserve read-before-write credit only when the model already knew the full source, reject ambiguous hunk matches, and retain CRLF line endings. Add regression coverage for unsafe overwrites, positive tracking cases, duplicate matches, and Windows-style files.
* Resolve plan test seam after rebase
Drop the obsolete sticky-plan height helper while retaining the PR status test seams brought forward by the UI changes.
* Address final review correctness gaps
Bound narrow waiting labels, reject malformed plan records, preserve missing final newlines, reject out-of-order EOF hunks, clear stale tracking after failed rollback, and keep deferred tool loading aligned with model visibility.
* Harden patch transactions and review edges
Publish new patch targets without overwriting raced destinations, journal only completed mutations, and create replacement temps beside their targets. Reject pre-plan diagnostics, cover tool-search transcript suppression, and leave sufficient cleanup time in the Windows process fixture.
* Bind patch rollback to committed versions
Capture file identity and content before publication, quarantine rollback targets atomically, and refuse destructive undo when a path was replaced or modified in place. Add deterministic coverage for publication and rollback races.
* Align structured patch failure semantics
Use prevalidated sequential application with explicit partial-success errors instead of unsafe multi-file rollback. Retain atomic per-file publication, no-overwrite creates, unsupported-hardlink fallback, and conservative tracker invalidation.
* Preserve patch context bytes
Track source offsets for structured-patch context lines and reuse the exact matched source text during replacement. This keeps tolerant matching from changing indentation or mixed line endings outside the requested edit.
* chore: remove dead wrapper functions and unused test seams
These five functions are unreachable from any cmd/* main even when tests are included (deadcode -test ./...), so they are genuinely dead on every platform:
- TraceEventKeys: one-line alias of ParseTraceEventKeys, no callers
- ValidateToolCapabilities: one-line alias of ValidateCapabilities, no callers
- GetLocalDiffStats and WatchPRState: test seams in internal/tui/export_test.go that no test calls
- ScanSSEData: non-context SSE scanner in internal/providers/providerio, fully superseded by ScanSSEDataWithContext
The five remaining -test findings are //go:build !linux stubs (landlock/seccomp/linux_helper) that are live on Linux, so they are left in place.
Co-Authored-By: cairn-code <[email protected]>
* Bump Go to 1.26.6 so govulncheck and Windows smoke pass.
The 1.26.5 standard library still reports GO-2026-6218 and the Punycode
label check that fail required CI; 1.26.6 matches main and clears them.
---------
Co-authored-by: euxaristia <[email protected]>
Co-authored-by: cairn-code <[email protected]>
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
* feat(zerogit): auto-create a conventional branch before push/pr on default branch
zero changes push/pr refused to push straight to the default branch but
never offered an alternative, unlike other agent tooling that names and
checks out a feature branch automatically. Add CreateBranch, IsDefaultBranch,
CurrentGitUser, and branch-name slugify/build helpers to zerogit, and wire
push/pr to auto-create a "<user>/<slug>" branch (slug from an LLM when a
provider is configured, otherwise a deterministic fallback from the diff)
whenever the current branch is the default one and --yes/--dry-run weren't
passed.
* fix(zerogit): normalize LLM branch slugs, bound remote lookup, handle existing branches
Address CodeRabbit's review on the auto-branch-naming PR:
- generateAutoBranchSlug now takes the first non-empty, quote-trimmed
line of the model's response before slugifying, instead of folding
any preamble or wrapping quotes into the branch name.
- isDefaultBranch's ls-remote lookup is now bounded by a 5s timeout so
a slow or unreachable remote can't stall push/pr; falls back to the
local main/master heuristic as before.
- CreateBranch checks whether the target branch already exists locally
and checks it out instead of failing checkout -b on the collision.
Also add regression coverage: CurrentGitUser's OS-username fallback
tier, CreateBranch's existing-branch and generic checkout-failure
paths, a CLI-level test for changes pr (previously untested) mirroring
the existing changes push coverage, and a messy-LLM-response case for
the slug normalization fix.
* fix(zerogit,cli): make auto-branching collision-safe, remote-aware, fail-closed, and opt-in for LLM naming
- CreateBranch never checks out an existing branch on a name collision: it
picks a unique suffixed name off the current HEAD (name-2 .. name-9) and
fails visibly when the namespace is exhausted, so a stale branch with
unrelated history can no longer be published while the new commit stays
behind on the default branch.
- ensureFeatureBranch resolves the remote once (explicit --remote, then the
original branch's configured upstream, then origin) via IsDefaultBranch,
which now reports the resolved remote, and push/pr thread that remote into
Push, so a freshly created branch without tracking configuration no longer
falls back to origin in fork setups.
- The default-branch check fails closed: main and master count as default
with no network, an unreachable remote falls back to the local
refs/remotes/<remote>/HEAD record, and when neither answers the push is
refused with guidance instead of silently dropping the confirmation guard
for repositories whose default is trunk or develop.
- LLM branch naming is now opt-in via --auto on push/pr, matching commit
--auto: a configured provider alone no longer causes the change diff to be
uploaded during ordinary git-only pushes.
- After the ordinary commit-then-push sequence the tree is clean, so the
fallback name comes from the HEAD commit subject instead of the constant
user/changes.
* style(zerogit): align fake-runner result comments to gofmt output
* test(zerogit): cover Push's fail-closed path when the default branch is unknown
* fix(zerogit): keep a stale cached remote HEAD from clearing the push guard
When the live ls-remote default-branch lookup fails, isDefaultBranch fell
back to the local refs/remotes/<remote>/HEAD cache and returned
branch == cachedName. That cache is only a hint: if the server renamed its
default (main -> trunk) the record still names main, so checking whether
pushing trunk is safe returned false and the newly protected branch was
pushed without --yes.
Trust the cache only to block a push (a positive match proves the branch is
the recorded default); on a mismatch fall through to the existing
fail-closed unknown-default error instead of treating the branch as
unprotected.
* fix(cli): honor --diff-bytes when inspecting the diff for auto-branch naming
changes push/pr accept and document --diff-bytes, but ensureFeatureBranch
inspected the working tree with only Cwd set. With --auto the resulting
unbounded diff was embedded in the branch-name request to the provider, so a
user who capped the diff to limit how much proprietary source is uploaded
for naming still sent the complete diff.
Thread options.maxDiffBytes through ensureFeatureBranch into InspectOptions,
matching how the commit path already bounds the diff.
* fix(cli): refuse an auto-branch push when HEAD has nothing to publish
The auto-branch path decided to branch solely from working-tree status and
never checked that HEAD was ahead of the default branch. On a clean,
up-to-date default branch it would create and push user/<subject> at the
exact default tip; with only uncommitted edits it named a branch from those
edits but pushed the unchanged HEAD, leaving the edits local, and changes pr
then abandoned the empty branch before the host rejected the comparison.
Add zerogit.CommitsAhead and consult it before branching: when HEAD is
provably not ahead of <remote>/<default>, report "no changes to publish"
instead of creating the branch. An indeterminate count (for example a
remote-tracking ref that was never fetched) is treated as "cannot tell" so a
legitimate first push is not blocked.
* fix(cli): recover the branch slug from preamble or fenced LLM replies
The slug helper returned the first non-empty line verbatim, so a reply like
"Here is a suggested branch name:\nadd-login-page" produced
user/here-is-a-suggested-branch-name, and a reply wrapped in a code fence
slugified the ``` line to nothing and silently fell back to the local name.
Drop Markdown code-fence lines and prefer a line that already reads as a
kebab-case slug, falling back to the first quoted/plain line only when no
slug-shaped line exists. Cover preamble and fenced responses with tests.
* fix(zerogit): harden auto-branch remote handling
Three review findings:
- isDefaultBranch passed the remote to ls-remote before the HEAD
positional with no --, so a remote value shaped like an option
(--upload-pack=/bin/echo) was parsed as one. Terminate options with --
and cover a dash-prefixed remote.
- An unborn remote (freshly created, zero refs) answered ls-remote with
empty output, fell through to the fail-closed unknown-default error,
and made the very first feature-branch push a --yes dead end that
git remote set-head --auto cannot repair. ls-remote succeeding with no
refs now counts as proof there is no protected default; main/master
stay guarded by the name heuristic and every failure path stays
fail-closed.
- CreateBranch probed only local refs/heads for collisions, so a branch
existing only on the target remote (an old merged-PR branch) was
silently fast-forwarded by the later push -u. The target remote's
heads are now probed once (bounded, with --) and count as taken;
an unreachable remote fails visibly before anything is created.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(zerogit): close branch-naming and push safety gaps from review
Require a confirmed unborn HEAD, not just an empty ls-remote --symref,
before granting the first-push exception, since a dangling remote
HEAD looked the same. Resolve branch naming and ahead-count checks
against the remote branch instead of a working-tree snapshot, which
could describe uncommitted edits a commit-only push would never
publish. Guard concurrent branch creation with a force-with-lease
push. Scope the ls-remote timeout to the default-branch preflight
only, so it no longer caps Push's own unrelated check.
* fix(cli): require a clean tree before auto-branch push/pr
Refuse ensureFeatureBranch when the working tree is dirty or the
ahead count against the remote default cannot be determined, so a
default-branch push cannot leave uncommitted edits behind or publish
an empty comparison under a guessed name.
* fix(zerogit): drop fixed five-second remote lookup timeout
IsDefaultBranch and CreateBranch applied a hard 5s bound on ls-remote,
so ordinary feature-branch pushes and the collision probe failed on slow
but reachable remotes (SSH/VPN) before Push could run. Honor the
caller's context instead; callers that need a bound pass a deadline.
* fix(cli): bypass the ahead-count and diff-base checks on a confirmed-unborn remote
jatmn flagged that the confirmed-unborn-remote exception this PR added to
IsDefaultBranch never actually helps the main/master case it was written
for: when the local branch is named main, isDefaultBranch returns true from
the name heuristic before it ever consults the remote, so
ensureFeatureBranch still unconditionally calls commitsAhead against
<remote>/<branch>. On a genuinely empty remote that ref cannot exist, so
`zero changes push`/`pr` from a local default branch against a brand-new
empty remote still dead-ended with "cannot determine whether HEAD is
ahead of origin/main" - the exact bug this PR set out to fix.
Tracing the flow further, the same problem also hit the diff-base Inspect
call right after: it uses the same nonexistent <remote>/<branch> ref, so
bypassing only commitsAhead would have left the dead end one line later.
ensureFeatureBranch now probes the new zerogit.IsUnbornRemote (ls-remote
--heads on the remote) whenever commitsAhead fails, and only bypasses both
checks when that probe positively confirms the remote has no refs at all.
An unreachable remote, or one that answers with refs, still fails closed
exactly as before - the fail-closed "unknown default branch" behavior is
unchanged and covered by TestEnsureFeatureBranchFailsWhenAheadCountUnknown
and the new TestEnsureFeatureBranchFailsWhenUnbornCheckErrors.
TestEnsureFeatureBranchCreatesBranchOnConfirmedUnbornRemote is the
regression test: confirmed failing (with the exact dead-end error) against
the pre-fix logic, passing after.
* fix(zerogit): resolve main/master against the live remote default first
isDefaultBranch short-circuited on the literal branch names main/master
before ever consulting the remote. A repository whose remote default is
genuinely something else (e.g. trunk) could still have a local main branch
tracking that remote, and the shortcut wrongly treated it as protected
instead of trusting the live symref result. The conventional-name check
now only applies as a last-resort fallback, after both the live ls-remote
symref lookup and the local remotes/<remote>/HEAD cache fail to answer.
* fix(cli): preserve push lease on retry and check the live default ref
Two related gaps in ensureFeatureBranch's auto-branch preflight:
- When an auto-branch push loses a force-with-lease race against a
concurrent creator, the generated branch is left checked out locally
with no successful push behind it. A retry took the "already off the
default branch" early return and dropped RequireNewRemoteBranch,
letting the next push silently fast-forward whatever the concurrent
creator published. The lease now stays required unless the branch
already has a configured upstream (proof a push already landed).
- The ahead-of-default check compared HEAD against the local
remote-tracking ref, which is only refreshed at clone or the last
fetch. A stale ref can under-report how far ahead HEAD is, or say
zero when the remote has actually moved past it, so ensureFeatureBranch
now refreshes that tracking ref before trusting it, and fails closed
if the refresh itself cannot be confirmed.
Adds regression coverage for both: a retry after a lease collision keeps
requiring the lease (and an already-published branch does not), and a
stale tracking ref is refreshed before the ahead check runs (with a
fail-closed case when that refresh cannot be confirmed).
* fix(cli): track generated branches, reject PR on unborn remote, classify main on unborn
* fix(zerogit,cli): preserve explicit nonexistence lease on force, propagate marker write errors, check unborn remote before branch switch
* fix(zerogit): preserve non-existence lease on target remote and roll back failed branch marking
Compare target remote against configured upstream remote to retain non-existence lease on target, roll back created branch if marking fails, and allow --yes to bypass PR default-branch preflight.
Refs #671
* fix(cli): address review findings for auto feature branch creation
* fix(cli): refine LLM preamble candidate slug extraction and enforce unborn-remote guard
* fix(cli): restore default branch after auto-branch and resolve --yes upstream
Do not leave auto-branch commits on the protected local default branch,
and resolve the real upstream for the unborn-remote guard under --yes
instead of always probing origin.
* test(cli): assert auto-branch passes capped diff into the provider
Thread --auto and a mock provider through DiffBytes coverage so the
cap is checked on the real LLM request path, not only Inspect.
* fix(cli): block auto-branch on unborn remotes and fix lease/restore
Refuse auto-creating a feature branch when the target remote has no refs yet.
Publishing only user/slug leaves remote HEAD dangling, so the next changes pr
fails closed and a later main push has no PR diff. Require push --yes first.
Keep the nonexistence lease unless branch@{upstream} is exactly the target
remote/generated-branch ref from a successful push -u. Inherited origin/main
from branch.autoSetupMerge=inherit must not drop the lease.
Restore the original default branch against its own upstream tip, not the
push destination, so --remote upstream does not rewrite a fork main that
tracks origin/main.
* fix(cli): recover push -u upstream and filter LLM slug preambles
Push -u can publish a remote branch then fail to write local upstream
config. Treat that as incomplete, recover with set-upstream-to, and probe
the remote before reasserting the nonexistence lease on retry. Also skip
one-word LLM acknowledgements before accepting a slug-shaped line.
Refs #671
* fix(cli): decide the branch-collision lease with one live remote check
Replace the local-state inference chain (upstream config, a
zeroAutoBranch marker, per-remote provenance tracking) that kept
producing new collision-safety edge cases across many review rounds
with a single live remoteHasBranch check against the destination
remote: missing there gets the nonexistence lease, already there gets
a plain push, and git's own fast-forward check is the safety net if
that existing branch isn't actually ours.
Removes MarkGeneratedBranch/IsGeneratedBranch, now dead, along with
the tests that pinned the specific local-state heuristics rather than
the collision-safety property itself.
* fix(cli,zerogit): address CodeRabbit findings on auto-branch naming
Recover trailing-punctuation LLM slugs, notice deterministic fallback,
report dual restore/rollback failures, and skip Push upstream repair on
dry-run. Align comments with fail-closed and DryRun behavior.
Refs #671
* fix(tools): set explicit stdin reader and use resolved binary for format-on-write
Use the binary path resolved by exec.LookPath and set formatter.Stdin
to an empty reader so format-on-write does not hang on inherited stdin
handles on Windows.
Refs #671
* fix(cli,zerogit,tools): address CodeRabbit review findings on PR 671
Remove redundant command validation, eliminate duplicate remote lookup in PR
workflow, harden ref options and branch switching, isolate test Git
environment, and add formatter lookup test.
Refs #671
* fix(cli,zerogit): address new CodeRabbit review findings on PR 671
Harden CreateBranch name validation, remove unreachable workflow error check,
complete doc comment, isolate test Git environment, and clean up unused
appDeps field.
Refs #671
* fix(cli,zerogit,tools): address third round of CodeRabbit review findings on PR 671
Use featureBranchOptions struct for ensureFeatureBranch, bound preflight
contexts, add upstream repair mismatch subtest, and centralize test Git
config isolation.
Refs #671
* fix(cli,zerogit): finish ensureFeatureBranch doc contract and close review gaps
The ensureFeatureBranch doc comment ended mid-sentence and sat on the featureBranchOptions type, so go doc attached function prose to the struct. Move it above the function and state which deps are required non-nil (inspectChanges, commitsAhead, headCommitSubject, currentGitUser, createBranch) versus optional and nil-guarded (refreshTrackingRef, isUnbornRemote, branchUpstreamRef, resetBranchRef, deleteBranch). Also extract the branch slug filename with path.Base because git paths are always slash-separated, gate two tests on the git version their fixtures need (autoSetupMerge=inherit requires 2.35, init --bare -b requires 2.28), and cover the literal "user" CurrentGitUser fallback via a test seam.
Refs #671
Co-Authored-By: cairn-code <[email protected]>
* Protect auto-branch publication and default ref restoration
Co-Authored-By: cairn-code <[email protected]>
* fix(cli): wire expected-old restore through the dependency seam
The P1 fix added expectedOld to resetBranchRef but left the appDeps field and the workflows.go import behind, so the CLI package did not compile. Update the field type to the 5-argument signature and restore the filepath import used by worktree release resolution.
Co-Authored-By: cairn-code <[email protected]>
* fix(cli,zerogit): wire CAS tip and close review test gaps
Addresses the CodeRabbit round on 93e3448e: fillAppDeps now defaults currentBranchTip so the compare-and-swap old value actually reaches ResetBranchRef in production; TestEnsureFeatureBranchRestoresDefaultBranchAfterCreate asserts the CAS value; the real-git auto-branch test asserts local main is restored to its upstream tip; docs explain the deliberate context.Background push, CurrentBranchTip semantics, the RemoteHasBranch tip-equality ownership check, and the Git 2.24 requirement for CommitsAhead; CreateBranch gains name-validation subtests.
Co-Authored-By: cairn-code <[email protected]>
* fix(zerogit): make RemoteHasBranch report existence only, not tip equality
RemoteHasBranch's tip-equality check made ensureFeatureBranch reassert the
nonexistence lease (--force-with-lease=<branch>:) on every push after the
first: a second local commit puts HEAD ahead of the just-published remote
tip, so the check reported false for a branch that plainly exists, and Git
rejected the follow-up push with a stale-info error instead of a normal
fast-forward.
The concurrent-creator race the tip check was meant to guard was already
handled one layer up: ensureFeatureBranch drops to a plain push when the
branch exists and relies on Git's own fast-forward check as the safety net
(see TestRunChangesPushDropsLeaseWhenBranchExistsOnRemote), so a race
survivor with unrelated history is rejected there regardless of tip
equality here.
Add TestRemoteHasBranchReportsTrueForDivergedTip, which fails under the old
tip-equality check and passes now.
Refs #671
* fix(cli,zerogit): preserve the generated branch on a compare-and-swap conflict
After auto-branching, ensureFeatureBranch restores the default branch to its
own upstream tip using the compare-and-swap form of update-ref. When that
refused because the default branch had moved concurrently, the failure was
indistinguishable from an ordinary update-ref write error, so the rollback
path ran and deleted the freshly created feature branch. That branch
exclusively owns the user's commits at that point, so a lost race destroyed
work in order to "recover" from it.
Add ErrCompareAndSwapConflict, returned by ResetBranchRef only when an
expected-old value was supplied (the sole way git refuses is a tip mismatch).
ensureFeatureBranch now recognizes it and returns without rolling back,
reporting that the branch was preserved so the user can reconcile the default
branch by hand.
Extend TestResetBranchRefRefusesConcurrentDefaultAdvance, which already drove
a real concurrent advance, to require the sentinel. Add
TestEnsureFeatureBranchPreservesBranchOnCompareAndSwapConflict, which fails
with "must not delete the generated branch" when the new branch is removed.
Refs #671
* Address review feedback for auto-branch workflow.
Refresh the exact configured upstream ref before branching and restoring, guard direct URL and path remote destinations from tracking-ref assertion, and remove undeclared --end-of-options from CommitsAhead.
Refs #671
* fix(zerogit,cli): resolve reviewer feedback on branch restoration and remote handling
Update CommitsAhead and ResetBranchRef to safely terminate option parsing with --, inspect all linked worktrees before ref updates, resolve advertised branches on direct path and URL remotes, and query git configuration directly for tracking remote and merge branch without splitting on slashes.
Refs #671
* fix(zerogit): invoke rev-list without positional delimiter to treat revs correctly
Remove premature option terminator from rev-list command invocation while validating base ref against option-like prefixes.
Refs #671
* fix(cli,zerogit): resolve restore tip commit and enforce push remote unborn policy
Resolve restore tip commits via ResolveRemoteBranchTip for named and direct URL/path upstreams to eliminate bare branch fallback, enforce unborn destination policy on push remotes before branch creation, and extract resolveBranchPushPlan to isolate decision logic from repository mutation.
Refs #671
* fix(cli,zerogit): address review feedback on destination remote and upstream matching
Forward resolved pushRemote to branchPushPlan, update HasUpstream to support multi-segment remote upstream references, clarify IsNamedRemote comment, and extend CLI argument parsing tests.
Refs #671
* docs(cli): complete ensureFeatureBranch documentation and wire branchUpstreamRemoteAndMerge
Refs #671
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: euxaristia <[email protected]>
Co-authored-by: cairn-code <[email protected]>
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
Three separate changes hit this in a fortnight, each written by someone
being careful about exactly the thing that got them.
A transform that removes bytes without leaving a gap is also a
reassembler. Redaction that matches by shape, run before a sanitizer that
strips control bytes, lets a credential split by a NUL or an ESC pass the
patterns as two fragments and be rejoined on the way out: the MCP failure
reason in #835, and the imported-transcript chokepoint in #878. The path
form of the same mistake is comparing where a handle landed against a
value produced by the same resolver the kernel just used, so a redirect
agrees with itself: the ACL guard in #808, where junctions were caught
only by an accident of Go's mode bits and directory symlinks were not
caught at all.
The unsplit value passing is what makes it survive review, so the note
says the test needs a split case.
* fix(dictation): redact API keys from streaming transcriber errors
Streaming dictation (Deepgram, OpenAI Realtime) injected API keys into
websocket URLs/headers and echoed unredacted transport errors and
server-side error payloads. Wrap those error endpoints with
providerio.Redact and switch the connect-error format specifier from %w
to %s so the raw error cannot leak via errors.Unwrap().
Adds regression tests asserting the API key is not present in the
returned stream error for both transcribers.
Co-authored-by: euxaristia <[email protected]>
* fixup(dictation): address review: redact session/write errors, move redaction tests
- Redact API key in OpenAI Realtime session configuration error.
- Return redacted OpenAI Realtime write error from writeErrCh instead of discarding.
- Move TestOpenAIRealtimeStreamTranscribeErrorRedaction and
TestDeepgramStreamTranscribeErrorRedaction next to their source files.
Refs #710
* fixup(dictation): sync Deepgram redaction test on CloseStream before close
Address CodeRabbit nitpick: the server now drains the client's audio frames and
waits for CloseStream before rejecting the connection with an API-key-bearing
policy-violation close reason. This ensures StreamTranscribe observes the close
reason (and redacts it) instead of failing earlier on a write error.
Refs #710
* fix(dictation): preserve context.Canceled through streaming error redaction
Formatting the read error with %s dropped the unwrap chain, so an Esc
abort of a Deepgram or OpenAI Realtime dictation no longer matched
errors.Is(msg.err, context.Canceled) in the TUI and produced a spurious
error toast on top of the cancellation notice. Return the cancellation
sentinel itself before redacting — it carries no key — and keep the
redacted flat string for genuine failures. Adds regression tests that
cancel a live stream and assert the sentinel survives.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(dictation): close remaining cancellation and redaction gaps
Key cancellation detection on ctx.Err() instead of
errors.Is(err, context.Canceled), which was racing against
writer-goroutine transport errors that could overwrite the error
value right before the check. Applied consistently across both
transcriber implementations. Fix goroutine leaks and a possible hang
in the cancellation regression tests.
* test(dictation): cover dial and startup cancel identity
Add regression tests for Esc-abort during WebSocket dial and right after
accept (session update / first write) so those redaction sites keep
returning context.Canceled for the TUI errors.Is check.
* fix(dictation): prefer cancel over a racing OpenAI realtime error
Esc can cancel after conn.Read already has an error frame. Check ctx.Err()
after partial callbacks and on the realtimeError path so the stream still
returns context.Canceled instead of a redacted failure notice.
Refs Gitlawb/zero#710
* fix(dictation): prevent auto-submit on cancelled realtime race
handleDictationTranscribed suppressed context.Canceled but then fell
through to the msg.submit branch. A delta -> Esc -> already-buffered
realtime error race returns a nonempty transcript alongside
context.Canceled, so with stt.autoSubmit on, Esc could submit the
composer's restored pre-existing text instead of doing nothing.
Treat a canceled result as terminal before the auto-submit path is
reached, and add a TUI-level test covering the race.
* test(dictation): exercise writeErrCh cancellation branch in realtime transcriber
* fix(dictation): add custom key header redaction and single-pass redaction tests
* fix(dictation): track dictation sessionID to drop stale completions after Esc and synchronize write cancellation test
* fix(dictation): initialize non-zero session ID and filter stale session 0 messages
Ensure the first dictation session in a process gets stale-message protection by initializing sessionID to 1 in newDictationController and removing msg.sessionID != 0 exemptions in handleDictationTranscribed and handleDictationPartial. Clean up unreachable redaction guards in providerio.
Refs #710
* fix(dictation): redact API keys from streaming transcriber errors
Streaming dictation (Deepgram, OpenAI Realtime) injected API keys into
websocket URLs/headers and echoed unredacted transport errors and
server-side error payloads. Wrap those error endpoints with
providerio.Redact and switch the connect-error format specifier from %w
to %s so the raw error cannot leak via errors.Unwrap().
Adds regression tests asserting the API key is not present in the
returned stream error for both transcribers.
Co-authored-by: euxaristia <[email protected]>
* fixup(dictation): address review: redact session/write errors, move redaction tests
- Redact API key in OpenAI Realtime session configuration error.
- Return redacted OpenAI Realtime write error from writeErrCh instead of discarding.
- Move TestOpenAIRealtimeStreamTranscribeErrorRedaction and
TestDeepgramStreamTranscribeErrorRedaction next to their source files.
Refs #710
* fixup(dictation): sync Deepgram redaction test on CloseStream before close
Address CodeRabbit nitpick: the server now drains the client's audio frames and
waits for CloseStream before rejecting the connection with an API-key-bearing
policy-violation close reason. This ensures StreamTranscribe observes the close
reason (and redacts it) instead of failing earlier on a write error.
Refs #710
* fix(dictation): preserve context.Canceled through streaming error redaction
Formatting the read error with %s dropped the unwrap chain, so an Esc
abort of a Deepgram or OpenAI Realtime dictation no longer matched
errors.Is(msg.err, context.Canceled) in the TUI and produced a spurious
error toast on top of the cancellation notice. Return the cancellation
sentinel itself before redacting — it carries no key — and keep the
redacted flat string for genuine failures. Adds regression tests that
cancel a live stream and assert the sentinel survives.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(dictation): close remaining cancellation and redaction gaps
Key cancellation detection on ctx.Err() instead of
errors.Is(err, context.Canceled), which was racing against
writer-goroutine transport errors that could overwrite the error
value right before the check. Applied consistently across both
transcriber implementations. Fix goroutine leaks and a possible hang
in the cancellation regression tests.
* test(dictation): cover dial and startup cancel identity
Add regression tests for Esc-abort during WebSocket dial and right after
accept (session update / first write) so those redaction sites keep
returning context.Canceled for the TUI errors.Is check.
* fix(dictation): prefer cancel over a racing OpenAI realtime error
Esc can cancel after conn.Read already has an error frame. Check ctx.Err()
after partial callbacks and on the realtimeError path so the stream still
returns context.Canceled instead of a redacted failure notice.
Refs Gitlawb/zero#710
* fix(dictation): prevent auto-submit on cancelled realtime race
handleDictationTranscribed suppressed context.Canceled but then fell
through to the msg.submit branch. A delta -> Esc -> already-buffered
realtime error race returns a nonempty transcript alongside
context.Canceled, so with stt.autoSubmit on, Esc could submit the
composer's restored pre-existing text instead of doing nothing.
Treat a canceled result as terminal before the auto-submit path is
reached, and add a TUI-level test covering the race.
* test(dictation): exercise writeErrCh cancellation branch in realtime transcriber
* fix(dictation): add custom key header redaction and single-pass redaction tests
* fix(dictation): track dictation sessionID to drop stale completions after Esc and synchronize write cancellation test
* fix(dictation): initialize non-zero session ID and filter stale session 0 messages
Ensure the first dictation session in a process gets stale-message protection by initializing sessionID to 1 in newDictationController and removing msg.sessionID != 0 exemptions in handleDictationTranscribed and handleDictationPartial. Clean up unreachable redaction guards in providerio.
Refs #710
* fix(dictation): drop providerio widen; pin session and redaction tests
Revert the Token/X-Api-Key providerio.Redact expansion (belongs in its own PR with coverage). Make Deepgram single-pass, cancel-race, and live-session partial tests pin the branches they name, and restore the ST1005 nolint on the user-facing Deepgram error string.
* fix(dictation): ignore stale batch startup by session ID
Gate dictationStartedMsg on session ID so a late Start result after cancel cannot reset a newer recording. Rename and comment cleanups from review; race suite recorded on the PR body.
* fix(dictation): exit writers on cancel and pin dial/write redaction paths
Select on ctx.Done while waiting for audio chunks so cancelled streaming
sessions do not leave writer goroutines blocked on an open idle channel.
Add non-cancellation redaction tests for Deepgram dial failure and OpenAI
Realtime dial, session-configuration, and async writer failures.
Refs #852
* fix(dictation): close the realtime socket on writer failure so a silent server cannot strand the read loop
CodeRabbit review 4885680006 flagged that the OpenAI Realtime writer goroutine pushed write errors to writeErrCh and returned without closing the connection, so conn.Read stays parked forever when the peer goes silent after session setup and the error is never drained. Close the socket on both the append and commit write failure paths (CloseNow, since the close handshake itself would block on a silent peer), and add a silent-server regression test.
Refs #852
Co-Authored-By: cairn-code <[email protected]>
* test(dictation): cancel the silent-server writer failure test during teardown
CodeRabbit review 4909044011 identified that the silent-server regression test used context.Background(), allowing its goroutine to remain blocked in conn.Read after a timeout. Give the test a cancellable context and defer cancellation so teardown always releases the blocked read.
Refs #852
Co-Authored-By: cairn-code <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: euxaristia <[email protected]>
Co-authored-by: cairn-code <[email protected]>
* fix(daemon): clean up child after startup timeout
* fix(daemon): terminate startup process tree
* test(background): accept reaped-later zombie children
* fix(background): retain configured process group target
* fix(background): address termination review feedback
* fix(daemon): use launch-time group identity, fix zombie/reap classification
Addresses jatmn's three #774 review findings:
- TerminateCommand rediscovered the process group via
execution.TerminateProcessTree's Getpgid(pid) lookup. On Darwin,
that lookup can return ESRCH once an unreaped group leader has
already exited, even though live descendants remain in the group
it configured at launch — silently falling back to signalling only
the (already dead) leader PID and leaving descendants running (the
required macOS smoke job's failure). Add
execution.TerminateProcessGroup, which signals the negative PID
directly using the launch-time Setpgid invariant instead of
rediscovering it, and route TerminateCommand through it.
- signalTargetRunning's zombie check matched the individual-PID
fallback target (a process that is not its own group leader)
against the process table by PGID. That target's actual PGID
differs from its own PID by definition, so no row ever matched,
unknown resulted every time, and a zombie individual-PID target
was always conservatively reported as still running. Branch on the
sign of the target: group members by PGID, individual PIDs by PID.
- TerminateCommand returned the termination error even when the
subsequent reap succeeded — a successful cmd.Wait is authoritative
proof the leader is gone and collected, regardless of how the kill
attempt itself went (e.g. taskkill /T racing an already-exited PID
on Windows). Classify a successful reap as success.
All three regression tests are verified to fail against the pre-fix
code (confirmed by temporarily reverting each fix and rerunning).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(daemon): harden command termination inputs
Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-abb8-73de-9489-51e9e6514c6b
Co-authored-by: Pierre Bruno <[email protected]>
* fix(background): preserve process tree failures
Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8
Co-authored-by: Pierre Bruno <[email protected]>
* docs(daemon): clarify boundary-probe and Setsid-guard comments per PR #774 review
Co-authored-by: Cursor <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
Co-authored-by: Cursor <[email protected]>
Split 1/3 of #829, carved out as the independently-reversible hardening layer
with no dependency on the orchestration work that stacks on top of it.
- internal/pathjail: os.Root handle-relative path confinement — ancestor
traversal is confined, the final component is checked for reparse points
(including a name written with a trailing separator), unprivileged Windows
junctions are covered, temp-name fragments are rejected before they can
escape the directory, whitespace-bearing paths are confined as spelled, and
temp files stay unpredictable and exclusive.
- internal/credstore: file locking around the credential read-modify-write —
exclusive for writers, shared for readers, so a reader cannot race a publish.
- internal/worktrees: git invocation hardening.
pathjail has no importers in this PR by design; internal/memory and
internal/specialist adopt it in the stacked PRs (2/3 and 3/3).
Origin-Session: local-de382f | Claude Code | 4 prompts
Origin-Snapshot: 52db9b74dad5
Co-authored-by: Claude Opus 4.8 <[email protected]>
* security(update): stage binary replacement at an unpredictable, exclusive path
Fixes#742. installBinary staged downloaded release bytes at the fixed
<target>.new path and opened it with O_CREATE|O_WRONLY|O_TRUNC. In an
installation directory writable by a lower-privileged process, that
process could pre-create <target>.new as a hard link or reparse point
to another file the (possibly elevated) updater can write; the
truncating open then overwrote that unintended file with verified Zero
executable bytes.
stagingFilePath now generates a cryptographically random suffix so the
path can't be targeted in advance, and platform-specific
createStagingFile opens it exclusively without following a pre-existing
link: O_CREATE|O_EXCL on POSIX (which POSIX guarantees fails on a
pre-existing path, symlink or not, without resolving it), and
CreateFile with CREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINT plus a
post-open GetFileInformationByHandle check on Windows (not a reparse
point, not a directory, single hard link).
replace_windows.go's rename-swap sequence is unchanged: the dangerous
operation was always the truncating open of the staged path, not the
.old rename/removal that follows.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* security(update): bind the binary swap to the staged object, not its pathname
Addresses the two open findings on #751.
[P1] The staged file is no longer promoted by pathname. Randomizing the name
and creating it exclusively stops PRE-creation, but under #742's model — a
lower-privileged principal that can write in the installation directory — the
name can be observed after the fact and the entry replaced between the write and
the swap, so the updater would install the substituted file. installBinary now
keeps the creating handle open and promotes through it:
- Windows renames through the handle itself
(SetFileInformationByHandle/FileRenameInfo, hence the added DELETE access), so
there is no second pathname lookup to win. replaceBinary is gone; promote owns
the aside-rename of the running binary and its restore-on-failure retry.
- POSIX has no rename-by-descriptor, so staging moves into a private directory
created next to the target by os.MkdirTemp (mode 0700, random name, created
exclusively). An attacker who can write in the installation directory cannot
create, replace, or list entries inside it. promote additionally sets the
executable bit through the handle (os.Chmod would re-resolve the path) and
fails closed if the entry ever stops naming the object it wrote.
[P2] Staging cleanup now covers every failure path: the removal defer is
registered before the copy, and stageBinary discards the object if the copy
fails, so a mid-write error (ENOSPC, unreadable source) no longer leaks a
release-sized file that the next attempt will not reuse. CleanupStaleBinary also
sweeps leftovers from a hard crash — staging files on Windows, staging
directories on POSIX, where it used to be a no-op — skipping anything younger
than an hour so a concurrent update is never disturbed.
Tests: substitution-race regression per platform (Windows asserts the verified
bytes are installed despite a replaced staging entry; POSIX asserts the private
0700 directory and that promote refuses a substituted entry), success controls,
cleanup-on-failure, and the crash-leftover sweep. The forced-staging-failure test
for helper refresh now uses a stageBinary seam, because the staging location can
no longer be occupied from outside — which is the point of the fix.
* security(update): close remaining updater staging races
Address jatmn's review findings on PR #751:
- Windows promote() now verifies targetPath is actually reachable after a
reported-successful handle rename before trusting it. Some Windows versions
have been observed accepting SetFileInformationByHandle against a handle
whose directory entry was substituted out from under it without the object
actually moving, which let promote report success while targetPath was
left missing entirely. renameFileByHandle is now a package var so this is
covered by a deterministic regression test rather than relying on
reproducing the exact trigger condition.
- When a promotion failure's restore-to-.old also fails (a writable-parent
attacker occupying targetPath with a lock MOVEFILE_REPLACE_EXISTING can't
get past), that combination is now wrapped in ErrTargetPossiblyTampered
instead of reading like an ordinary failed update, with a best-effort
MOVEFILE_DELAY_UNTIL_REBOOT fallback so an admin-context updater can still
recover the original at next boot.
- POSIX promote() now binds its final rename to a directory descriptor
opened when the staging directory is created (unix.Renameat), not a
pathname. The 0700 staging directory protects its contents from a
writable-parent principal, but not its own directory entry — that
principal could rename the staging directory aside and recreate a
look-alike with an attacker file at the same basename in the gap between
the identity check and the rename, which a plain os.Rename would silently
follow.
- Stale-cleanup on both platforms now matches the exact generated artifact
shape (POSIX: prefix + os.MkdirTemp's all-digit suffix; Windows: prefix +
32 lowercase hex chars + ".new") instead of a loose prefix/suffix, so a
user's own similarly-named file or directory is never swept up. Both also
re-check identity immediately before deleting to shrink the window a
writable-parent principal could swap the checked path in.
- copyFrom now copies in chunks and refreshes the POSIX staging directory's
mtime between them, since writing into an already-created file never
touches the parent directory's own mtime — a large or slow copy could
previously look abandoned to a concurrent update's cleanup sweep while
still in progress.
Verified on real Windows and Linux (via WSL), plus cross-compiled build/vet
checks for darwin/linux-arm64.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(update): address staging review findings
* fix(update): verify promoted object identity safely
Bind POSIX staging creation and both rename endpoints to directory descriptors, clean only identity-matched empty directories, and verify Windows promotion by file identity rather than path spelling.
Preserve unverifiable crash leftovers and add adversarial cleanup, hard-link, and reparse-ancestor coverage.
Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-27da-72ec-8e6d-5d43f127b6a1
Co-authored-by: Pierre Bruno <[email protected]>
* fix(update): preserve promotion recovery signals
Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8
Co-authored-by: Pierre Bruno <[email protected]>
* fix(update): keep the recovery copy a failed restore promised
Two error-path follow-ups from the #742 staging review.
When a promotion fails and restoring the original also fails, the error tells
the operator their original is preserved at <binary>.old — and then the next
Apply deleted it, because a present target read as proof the copy was stale.
What occupies the target in exactly that case is the bytes the updater could
not verify, so cleanup was erasing the one it could. A failed restore now marks
the copy, cleanup honors the mark, and a successful promotion clears it.
The POSIX staging flow could also leave an empty .zero-stage-* directory behind
when the stat immediately after mkdirat failed. Nothing knows that name yet, so
it would have stayed for good; it is now removed best-effort with rmdir
semantics, which cannot touch anything but the empty directory just created.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(update): stop the recovery copy from being destroyed by the next attempt
The marker added in d9523ba kept cleanup from deleting <target>.old, but four
paths still undermined the promise it was meant to make.
Promotion destroyed the copy it was preserving. Skipping the pre-rename cleanup
was not enough: os.Rename uses MOVEFILE_REPLACE_EXISTING on Windows, so renaming
the running binary aside overwrote the last verified copy with the unverified
bytes the earlier failure left at the target — and a promotion that then failed
could only move those unverified bytes back. Promotion now refuses while the
tamper state is unresolved. Only the operator can say whether the file at the
target is theirs, so the error names both moves that end it: restore the copy,
or delete the marker to accept what is installed.
The marker itself was a predictable link-following truncate write. Its path is
fixed, so a writer in the install directory could pre-create it as a hard link
or reparse point and have the elevated updater write through it. It is now
created with the same CREATE_NEW + FILE_FLAG_OPEN_REPARSE_POINT + fresh-regular-
file check as a staging file, and never truncates; an existing name is treated
as already-marked rather than an object to open.
A marker that could not be written left the promise unqualified while the next
run's cleanup deleted exactly the file it named. That failure now rides in the
error, telling the operator to copy it somewhere safe now.
Helper refreshes downgraded tampering to a warning. An ordinary helper failure
still warns — a stale helper is better than a failed update — but a helper whose
path may hold unverified content fails the apply, because helpers are resolved
from the install directory and executed by the sandbox runner, and reporting
Applied: true would hand the operator a success while a sibling executable is
suspect. ErrTargetPossiblyTampered moved to apply.go so callers on every
platform can test for it without build tags.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(update): keep the recovery copy when its marker cannot be established
Surfacing a failed marker write told the operator to hurry; it did not stop the
next run from deleting the file they were being told to save. When no marker can
be established, the copy is now moved to an unpredictable sibling name that
routine cleanup never touches — CleanupStaleBinary only ever removes the exact
"<target>.old" — and the error names where it went. Telling the operator to act
now remains the fallback for when even that move fails.
The marker check is conservative in the same direction: only a definite
"not there" allows the copy to be deleted. An Lstat that fails for any other
reason leaves the question open, and deleting is irreversible while keeping
costs one stale file.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(update): preserve Windows recovery state across retries
Amp-Thread-ID: https://ampcode.com/threads/T-019faf74-99d4-75cf-ac7a-661c308240ef
Co-authored-by: Amp <[email protected]>
* fix(update): watch every recovery path and never orphan a partial marker
A failed second-or-later update marks the randomized aside it used, but
promotion only consulted the canonical <target>.old marker, so a retry
proceeded over an unverified target while the verified copy sat marked on a
path the refusal logic did not watch. Promotion now enumerates every recovery
candidate — canonical and randomized asides — and refuses while any of them
is marked, naming each recovery path and its marker.
The missing-target refusal had the same single-path blind spot: with a stale
canonical .old from an earlier successful update plus the aside from an
interrupted attempt, it named only the canonical path, which can be the wrong
binary. It now names the single actual candidate or refuses the ambiguous
layout outright until the operator resolves it.
A failed marker write could also leave a partial .keep behind while the
recovery copy was relocated elsewhere; later refusals then named a location
that no longer held the verified bytes. markOldBinaryPreserved now removes
the entry it created before reporting a write/close failure, and
conservatively reports success when that removal (or the state check) fails
so oldPath stays authoritative. keepUnmarkedRecoveryCopy is handle-bound end
to end: it pins the recovery copy with a no-delete-sharing, no-reparse open,
verifies it is a single-link regular file, renames through the handle with
ReplaceIfExists false, and confirms identity at the destination, failing
closed with ErrTargetPossiblyTampered on any race.
Finally, the error for an unestablishable marker still promised that a later
update would otherwise remove the copy, but CleanupStaleBinary is now a
no-op; the operator is told that a manual copy is required.
* fix(update): tolerate the fail-closed outcome of a fully-unlinked substitution
TestPromoteInstallsTheStagedObjectNotTheStagedPath deletes the staging
file's only directory entry and recreates it as an "attacker" file, then
required promote to succeed with the verified bytes installed. CI's
windows-latest runner permits that delete (this workstation's exclusive
share mode blocks it, so the path went unexercised here); on that runner,
renaming the now fully-unlinked staging handle back into existence is not
something every Windows build honors, so verifyPromotedTarget's post-
rename identity check finds nothing at targetPath, promote fails, and
restoreOriginalBinary moves the pre-update binary back — a safe, fail-
closed outcome, not a security regression.
The test now accepts either outcome after a real substitution: promote
succeeding with the verified bytes, or promote failing as long as the
attacker's substituted bytes were never installed. Any promote failure
that occurs without substitution having actually happened still fails
the test outright.
* fix(update): surface unverified recovery path, match .old case-insensitively
keepUnmarkedRecoveryCopy discarded the destination path when the rename
to it succeeded but the post-rename identity verification failed,
leaving restoreOriginalBinary's error telling the operator to save
oldPath after it had already been vacated. Return the attempted kept
path alongside the verification error so the caller can still name it.
existingRecoveryPaths compared "<target>.old" filenames case-sensitively,
but NTFS is case-insensitive/case-preserving, so a recovery or marker
file spelled e.g. "zero.exe.OLD" could be silently skipped by every
caller's fail-closed check. Fold both sides before matching.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(update): bound Windows recovery cleanup
Amp-Thread-ID: https://ampcode.com/threads/T-019fbdec-f8dc-71f9-abdd-ea044a902b9a
Co-authored-by: Pierre Bruno <[email protected]>
* fix(update): secure Windows recovery cleanup
Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-a912-7462-8938-1b4a8d95f1f3
Co-authored-by: Pierre Bruno <[email protected]>
* fix(update): terminate Windows rename paths
Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-a912-7462-8938-1b4a8d95f1f3
Co-authored-by: Pierre Bruno <[email protected]>
* test(update): allow namespaced recovery copies
Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-a912-7462-8938-1b4a8d95f1f3
Co-authored-by: Pierre Bruno <[email protected]>
* fix(update): bind Windows cleanup to handles, drop dead cleanup API
Failure-path staging cleanup on Windows re-resolved the staging pathname
after the exclusive handle was closed, so a principal who can write in the
installation directory could substitute that entry in the gap and have the
updater delete a file of their choosing. Removal is now requested through the
handle (FileDispositionInfo) before it is released, and nothing is removed by
name afterwards; the same change covers the staging-file verification failure
path and the partial recovery marker.
CleanupStaleBinary has had no production caller since bounded, identity-bound
cleanup moved into promote, so the exported no-op and the tests that could only
pass vacuously against it are removed. The POSIX link-regression tests now
drive createStagingFileAt, the primitive createStagedBinary actually uses,
instead of a path-taking helper kept alive only for them.
Also documents the fail-closed Windows recovery states in docs/UPDATE.md,
including the refusal a planted .old/.keep pair can cause.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(update): bind Windows recovery to handles and bound its state
Addresses the 2026-08-02 review round on #751.
Keep the recovery restore bound to the object promote moved aside instead
of its pathname: promote now opens the target with delete access and no
delete sharing before renaming it aside, and restoreOriginalBinary renames
that same handle back (retrying through the handle, never re-resolving the
source name), so a directory writer cannot swap the aside entry during the
promotion gap and have the substitute installed.
Run the recovery-state checks as a standalone-install precondition, under
the same lock promotion uses, so "already up to date" can no longer report
success while a .keep/.recovery state is unresolved. The in-promotion check
stays to close the race.
Make the cleanup record a queue: entries are only retired once their object
is actually gone, so a copy held open by a scanner is retried by a later
update rather than leaking, while records for paths that vanished or that
this updater could never have created are retired instead of accumulating.
Also from review of the above:
- delete the pathname restore chain that lost its production callers
(restoreOriginalBinary's pathname form, openIdentityFile,
recordRecoveryCleanup) and point their tests at the live handle path
- name the relocation path in the error when the post-move verification
fails, since oldPath is already vacated by then
- scope the recovery docs to Windows and describe POSIX replace semantics
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
* Add terminal companions
* Polish interactive terminal companions
* Fix terminal companion edge cases
* Strengthen terminal companion regression tests
* Preserve config layout when selecting pets
* Handle sparse pet preference layouts
* fix(terminalpet): reset SGR before erasing a sixel companion
On Windows Terminal the companion draws a filled black rectangle over the
interface, and dragging it mangles text under and around it. It looks like a
platform quirk. It is an untested code path that only Windows reaches.
Sixel is the only protocol here that erases by PAINTING CHARACTERS. Kitty hands
the terminal an image id and the placement goes away. A sixel is already in the
cell grid, so clearRenderedImage writes literal spaces over the cells it
occupied. Those spaces are ordinary characters and carry whatever SGR the TUI
last set, and nothing normalised it, so every erase stamped a filled block in
the ambient background. A drag erases and repaints on every pixel, so the block
was continuously refreshed over the UI.
It stayed invisible everywhere else because ghostty, kitty, WezTerm and iTerm
all resolve to the Kitty protocol and delete by id, never reaching this code.
Windows Terminal is the only mainstream terminal that lands on the sixel branch,
which is why testing on Linux and macOS could not have caught it.
The erase now resets SGR before painting, and uses DECSC/DECRC rather than
CSI s/u because only DECSC saves the attributes this clobbers. Restoring them
matters: the erase is emitted mid-frame, between styled writes the TUI has
already begun.
Measured before and after rather than reasoned about. Before:
\x1b[s\x1b[3;41H \x1b[4;41H \x1b[5;41H \x1b[u
no reset anywhere. After, the reset leads and DECRC closes.
Tests assert the bytes, since bytes are what the terminal sees: SGR is reset
BEFORE the first row is painted rather than merely present somewhere, the erase
still covers every claimed cell so the fix cannot be satisfied by erasing
nothing, and the Kitty path still deletes by id rather than acquiring the
character-painting erase. Verified by mutation: restoring the old sequence fails
the first of those with the exact reported symptom.
Deliberately not fixed here, since both are design calls rather than defects:
the erase rectangle is computed from the app's own cell maths rather than the
terminal's real footprint, and the sixel path has no in-place move, so every
drag pixel is a full erase and repaint where Kitty gets a cheap reposition.
* fix(tui): ask sixel terminals for their cell size and erase what was painted
The companion blanks a row and a column of live interface on Windows Terminal.
The cause is one predicate.
petPixelProtocolSupported gates two unrelated questions that only looked like
one: whether pixel-precise dragging is available, and whether to ask the
terminal for its cell size with CSI 16 t. It answers true for Kitty only, so on
a sixel terminal the request was never sent, the reply never arrived, and
petCellPixelWidth/Height stayed zero for the whole session.
Everything downstream then took its fallback. petImageHeightPixels returned its
blind preferredHeight of 75 pixels instead of the cell-aware branch below it,
and the erase in clearRenderedImage used petImageColumns and petImageRows. Those
constants describe the RESERVED area, not what was painted. Windows Terminal
reports a 20x10 cell, so a 75-pixel sprite covers 4 rows and about 8 columns
while the erase blanked 5 by 9, taking a row and a column of interface with it
on every move. A drag repaints per pixel, so it did that continuously.
Windows Terminal answers CSI 16 t, confirmed by hand: it replies
ESC [6;20;10t. This was never a question of terminal support. Nothing asked.
So the request now goes out for every image protocol, via a predicate separate
from the dragging one, and the sixel footprint is computed from the rendered
pixel height and the reported cell size rather than from the constants. It is
still clamped to the reserved area, because the layout keeps that many cells
clear and the drag clamps to it, so a larger computed footprint would mean
erasing into live interface.
Deliberately NOT widening petPixelProtocolSupported itself: that would enable
pixel-precise dragging on sixel as a side effect, which is a separate feature
decision rather than part of this defect.
Kitty is untouched and a test pins that. There Columns and Rows are the
placement REQUEST handed to the terminal, which owns the region and scales into
it, so recomputing them would resize the image rather than fix an erase.
Tests use the real 20x10 measurement: the sixel footprint is 4 rows not 5, Kitty
keeps its requested placement, the fallback without metrics stays the reserved
area rather than collapsing to zero and erasing nothing, and every image
protocol asks for the measurement.
* Keep Sixel companions in the reserved dock
---------
Co-authored-by: Vasanthdev2004 <[email protected]>
* fix(mcp): name the non-text blocks a tool result drops
TextContent keeps only blocks whose type is "text", so a server that
returns an image, audio, or an embedded resource produced "(empty MCP
tool result)". A screenshot or browser server would complete a call
successfully, hand back a perfectly good image, and the model would be
told nothing came back. It then usually retries, which spends another
call to reach the same empty answer, and the user never learns an image
existed at all.
Carrying the payload is the larger half of #823 and is not this change.
Dropping silently is the part that causes the retry loop, and it can be
fixed on its own.
Decode mimeType on Content, and add DroppedContentSummary, which
describes what TextContent discarded ("1 image/png block", "2 resource
blocks, 1 audio/wav block"), grouping by mime type and ordering by first
appearance so the same result always reads the same way. Servers that
omit mimeType fall back to the block type rather than printing an empty
label.
registryTool.Run appends that as a note, and only when something was
actually dropped, so a text-only result is byte-for-byte unchanged. The
note also says retrying will return the same thing, since telling the
model what happened without telling it not to retry only fixes half the
loop.
Refs #823.
* fix(mcp): do not promise a retry returns the same content
The dropped-block note said "Retrying will return the same thing".
registryTool.Run makes a fresh remote call on every retry, so the server
may well answer differently; that is a promise this code is in no
position to make.
What cannot change is that Zero still has nowhere to put a non-text
block, so the note now says retrying cannot RECOVER the payload. Same
guidance, a claim that is actually true.
The test pins both directions: the new wording must be present, and the
absolute claim must not come back.
Raised by CodeRabbit on #874.
* feat(providers): fetch live model lists for OpenRouter and OpenGateway
Prefer each aggregator public /v1/models catalog over the dead OpenGateway
models.json path and models.dev-only OpenRouter mapping so the picker shows
current routes with context windows and free labels.
Refs #859
* fix(providers): parse OpenRouter string pricing and simplify sort assertions
Parse OpenRouter prompt and completion string pricing fields into InputCost and OutputCost, and directly assert alphabetical model sort order in discovery tests.
Refs #859
* fix(providers): scale live pricing and restore OpenRouter fallback
Convert OpenRouter/OpenGateway per-token pricing to per-million for Model costs, and fall back to models.dev when the live OpenRouter fetch fails so the picker still degrades.
* fix(providers): fall back on OpenRouter parse errors; cover live-only merge
Route malformed OpenRouter HTTP 200 bodies through the models.dev fallback,
and exercise catalog vs live payload split so live-only models stay merged.
* fix(providers): address remaining CodeRabbit findings on live catalogs
Reject non-finite OpenRouter pricing, keep capability-marked live-only
models, and harden related regression tests.
---------
Co-authored-by: euxaristia <[email protected]>
main is red. TestEagerToolSchemaTokenBudget fails at 3578 tokens against
a 3550 ceiling, and every open PR inherits it.
Bisected: edf660ab and 4ca4fa75 pass, cd0eb194 (#843, which added
view_image to the core set) fails at 3588. #867 later trimmed it to 3578.
#843's own CI was green, because the ceiling is only crossed once the
tool set is combined, so no per-PR check could have caught it.
Raised rather than paid down. The overrun is 28 tokens, and view_image
costs 82, the second cheapest tool in the set against exec_command's 909
and request_permissions' 397. It is simply the one that crossed the line.
The alternative was trimming a description to reclaim 28 tokens,
degrading a tool's usability to satisfy a line drawn against a 2026-07
measurement that predates two tools. The ratchet worked exactly as
intended: it caught the creep and forced a decision instead of a drift.
Also corrects the failure message. It advised "defer a tool", but this
test pins DeferThreshold at 0 so deferral is inactive and marking a tool
deferred leaves it exposed here, changing nothing. Deferring is still
worth doing for real sessions; it just cannot move this number. The
levers that can are a smaller schema, one fewer core tool, or a
deliberate raise, and the message now says so.
Approving a command prefix does not only stop the prompt from reappearing.
shellExecutionArgsForApproval rewrites the call to require_escalated, which
resolves to a nil sandbox engine, so the command runs unsandboxed on the host.
The engine's escalation gate is then satisfied by the PermissionGranted flag
from the approval the user just gave for the sandboxed form.
The escalation itself is deliberate and guarded: proposedCommandPrefix refuses
to offer a prefix when any other segment of the command is not known-safe,
precisely because approval covers the whole command. The defect is that the
prompt never said so. The user chose "allow command prefix for session" and got
"and also run it outside the sandbox".
So this discloses rather than removes. PermissionRequest gains
PrefixApprovalEscalates, computed by shellPrefixApprovalEscalates from the same
guards the rewrite consults, and the two prefix options append "(runs outside
the sandbox)" when it is set. Both the TUI and the ACP path carry it, since an
editor user decides on the same terms as a terminal user.
The main test asserts the disclosure and the rewrite agree on every input
rather than checking each against a fixed expectation, so the two cannot drift
apart later. Verified by mutation: reverting the helper to always-false fails
it, as does making the label unconditional.
* fix(secrets,worktrees): fix secret redaction leakage and prune stale worktrees
1. Prevent trailing redaction leaks in github_token, aws_access_key_id, and google_api_key by adding trailing word boundaries and allowing variable lengths. Refine the openai_key pattern to cleanly distinguish legacy keys and modern prefixed keys (sk-proj-, sk-svcacct-) from ordinary kebab-case phrases.
2. Implement auto-pruning of zero-owned git worktrees older than 24 hours at the start of worktrees.Prepare to prevent indefinite disk space leaks.
* fix(secrets,worktrees): close tail-leak edge case and worktree data-loss risk
Drop the trailing \b anchor on the four secret patterns whose body
class allows "-" (slack_token, google_api_key, the modern openai_key
branch, jwt). \b requires a word/non-word transition, so a secret
ending in "-" right before a delimiter has none, and the engine
backtracked the greedy quantifier to drop that last character instead
of failing the match, leaking it. The body character class already
provides the real stopping boundary, so the anchor was unnecessary.
Fix two issues in worktree Clean flagged in review:
- Staleness was decided by the worktree directory's own mtime, which
only changes when an entry is added/removed/renamed directly inside
it, not when a long-running task edits existing files deeper in the
tree. Clean now walks the tree and treats any recently modified entry
as live, and also skips any worktree a caller has explicitly locked
via git worktree lock.
- baseDir ownership used a raw strings.HasPrefix, so a sibling like
"<baseDir>-other" would false-match. Replaced with a filepath.Rel
path-boundary check.
* fix(worktrees): check exit codes on removal, fail closed on inspection errors
defaultRunGit deliberately returns a nil error alongside a nonzero
CommandResult.ExitCode for a failed git invocation, so the worktree
remove call must check ExitCode itself instead of trusting a nil error
to mean success. Route it through gitOutput, which already does that.
worktreeIsStale treated an inspection failure (an unreadable file, a
WalkDir error) the same as "keep walking," which can let an
incompletely-inspected worktree be judged stale. Any inspection error
now makes it ineligible for removal instead.
* fix(secrets,worktrees): catch appended-suffix keys and scope pruning to owned worktrees
The scanner's trailing \b anchors made a credential vanish entirely when
followed by a word character outside its body class (an appended suffix
like AKIA...EXTRA, or ghp_..._suffix): the fixed or unbounded-greedy
quantifier had no valid word boundary to land on and the whole match
failed, so the real secret reached the redaction output unredacted.
Dropping the trailing anchors lets the body class itself stop the match,
so the credential prefix still gets redacted even when noise follows it.
Also recognize sk-admin- alongside sk-proj-/sk-svcacct- so OpenAI admin
keys aren't skipped by the narrowed modern-key branch.
Clean pruned any worktree under the caller-supplied BaseDir, but Prepare
only ever creates worktrees under a per-repository
zero-worktree-<repoKey> subtree of it. Scope pruning to that subtree so a
worktree a user manages by hand elsewhere under a shared BaseDir is never
force-removed. Also refuse to force-remove a worktree whose mtime looks
stale but that still has uncommitted or untracked changes: a task can
hold live work while waiting on a model, network, or user for longer
than the staleness window without writing to the tree again.
* fix(worktrees): make nested-activity test exercise the deep walk it claims to
activePath/internal was created by the same MkdirAll as the nested pkg
dir but never backdated, so it kept a fresh mtime and worktreeIsStale's
walk reported "not stale" as soon as it hit that directory, before ever
reaching the freshly-written file two levels deeper. The test passed
without actually exercising recursion past the first directory.
* fix(worktrees): lock zero-created worktrees and treat ignored files as dirty
Prepare never called git worktree lock, so the entry.locked skip in Clean
only ever protected worktrees a human locked by hand, never zero's own; a
worktree that finished committing and sat idle (e.g. waiting on a slow
model or network retry) for more than 24h looked clean-and-stale and got
force-removed by the mtime+dirty heuristic alone. Lock every worktree
Prepare creates so it gets the same protection.
worktreeIsDirty also used git status --porcelain with no --ignored, so a
worktree holding only .gitignore-matched task data (credentials, generated
drafts, artifacts) reported as clean and got force-removed with --force,
silently discarding it. Add --ignored so those files count as dirty too.
* fix(worktrees): release Zero's Prepare lock so Clean can reclaim finished worktrees
Prepare locks every worktree it creates so Clean's mtime+dirty staleness
heuristic never force-removes one Zero is still using, but nothing ever
unlocked it, making the automatic disk-space cleanup permanently inert.
Add Release (git worktree unlock) and wire it in two ways: zero exec
--worktree defers a release once its own run finishes, since that flow's
use of the worktree is bound to its own process. zero worktrees prepare
hands the path to a longer-lived external caller with no defined
end-of-life, so a new zero worktrees release <path> subcommand lets that
caller release it explicitly when done.
* fix(worktrees): normalize release path, aggregate Clean errors, unlock deleted worktrees
Address CodeRabbit's review on the lock-release fix:
- zero worktrees release now resolves its path argument to absolute
before calling Release, since git worktree unlock matches against
the path git recorded at creation, not whatever directory the
caller happens to be running from.
- Clean now aggregates removal failures with errors.Join instead of
overwriting lastErr, so multiple stale worktrees failing removal in
the same pass are all reported, not just the last one.
- Release falls back to options.Cwd as the git working directory when
the worktree path itself no longer exists (e.g. a caller deleted a
locked worktree by hand instead of releasing it first), so the
orphaned lock can still be cleared.
Added regression coverage for all three.
* style(worktrees): align aggregation test comments to gofmt output
* fix(worktrees,cli): restore reuse lease and scope unlock to owned locks
- Prepare re-locks a reused worktree so Clean's staleness heuristic cannot
force-remove it while the new caller is still using it; a lock already
held by a live external caller is kept in place and reported through the
new Result.LockAcquired field.
- exec --worktree only releases the lock its own Prepare call acquired and
surfaces a failed release on stderr with the affected path instead of
discarding the error.
- worktrees release wires the resolved workspace root into Options.Cwd so
the deleted-path recovery works outside the worktree directory.
- The relative-path release test derives its expected value via
filepath.Abs, matching the resolution the CLI uses, so macOS /var vs
/private/var spellings no longer break it.
* fix(worktrees,cli): reject in-use leases, validate before cleanup, add release -C
- Prepare rejects a worktree whose lock another run still holds, on both
the reuse and the create-race paths, instead of handing a second live
caller an unprotected shared checkout whose sole Git lock the first
caller's exit would release.
- The automatic stale-worktree pruning runs only after the request itself
validates, so a rejected command (an invalid --name) has no destructive
cleanup side effect; covered end to end with real git in both directions.
- worktrees release accepts -C/--cwd naming the source repository, which
the deleted-path recovery needs when launched outside the repo (the
deleted worktree path is a one-way hash with no way back to its source).
* test(worktrees): canonicalize test roots to physical spelling
git records worktree paths in physical form, so the CI runners' symlinked
(/var -> /private/var) and 8.3-short (RUNNER~1) temp spellings made Clean's
containment check skip the test's stale entry and the pruning assertion
fail on macOS and Windows.
* fix(worktrees): preserve orphaned commits, verify release ownership, canonicalize base dir
Four fixes from the latest review round:
- Clean now creates a durable ref (refs/zero/orphaned-worktree/<sha>)
for a detached worktree's HEAD before force-removing it, when that
commit isn't already reachable from any other ref. Prepare always
creates worktrees with `worktree add --detach`, so a commit made
there had no ref pointing at it once the worktree was deleted,
making it immediately eligible for git gc despite never having been
merged/pushed elsewhere.
- Clean resolves its configured base directory through EvalSymlinks
before comparing it against git's reported worktree paths: `git
worktree list --porcelain` reports each worktree's PHYSICAL location
(resolving symlink components), so a symlinked --worktree-dir made
every worktree created under it permanently unprunable.
- Release now verifies path has a zero-worktree-<repoKey> ancestor
directory component before running `git worktree unlock`, so it
can't be used to clear the lock on a worktree a user or another tool
manages by hand. The check doesn't need to know which --dir a given
Prepare call used (nothing records that against a specific worktree,
and the CLI never threads BaseDir through to Release) — the repoKey
component is Prepare's actual ownership signature regardless of
which directory it was created under.
- Route the release command's printed path and exec's release-failure
diagnostic through the existing CLI redaction helper, and split the
worktrees help text into prepare-specific and release-specific flag
sections (release only ever supported -C/--cwd, not the --name/--dir/
--json the shared block advertised).
All four have regression tests confirmed to fail without their fix
(two using real git worktrees, not just fakeRunner sequences). Build,
vet, and gofmt clean on linux/windows/darwin.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(worktrees): recoverable PID leases and reclaimable released worktrees
Two review findings on the cleanup lifecycle:
- A lock left by an abnormal exit (SIGKILL, crash, power loss) was
skipped by Clean forever, recreating the permanent disk leak this PR
set out to fix. exec --worktree now records its PID in the lease
reason; Clean expires a lease whose recorded owner is provably dead,
unlocking only after the staleness, dirty, and HEAD-preservation
guards all pass. Human locks and PID-less leases (external
`worktrees prepare` owners) remain permanent until explicit release,
and any ambiguity in the liveness probe counts as alive.
- An explicitly released worktree holding only gitignored residue
(node_modules, build output) was skipped at every age. Release is the
owner's completion signal, so unlocked entries now block removal only
on tracked/untracked changes; expired crashed leases keep the
conservative --ignored probe since they never signaled completion.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(worktrees): address review feedback on lease detection and release safety
Split dead-lease PID checking into posix/windows implementations so
Windows can reliably tell a dead process from a live one. Fix a path
canonicalization mismatch in two release tests. Derive release
ownership from git worktree list instead of the git-dir parent, which
was wrong for repos with a separate git-dir. Refuse to clear a lock
that was not taken by Zero in the first place.
* fix(worktrees): canonicalize paths for clean/release ownership checks
Compare Clean containment and Release ownership against physical path
spellings so macOS /var vs /private/var and symlink TMPDIR layouts match
git worktree list. Require a registered porcelain entry and a Zero lease
reason before unlock; treat an already-unlocked Zero worktree as a no-op.
* fix(worktrees,secrets): address jatmn review findings on #632
- Prepare and Release now agree on repoKey regardless of which worktree
(main or linked) Prepare runs from, by keying off git worktree list's
first entry (always the main worktree) instead of --show-toplevel.
A worktree prepared from a linked checkout previously failed its own
ownership check on release and its lease could never be cleared.
- osProcessAlive on Windows no longer treats every OpenProcess failure
as "process is dead": only ERROR_ACCESS_DENIED (a live process this
caller lacks rights to query) is now distinguished from a genuinely
missing PID, so Clean can no longer force-remove an active worktree
whose owning process it simply couldn't query.
- The openai_key redaction pattern now recognizes sk-or-v1- (OpenRouter)
alongside the existing sk-proj-/sk-svcacct-/sk-admin- prefixes, so
hyphenated OpenAI-compatible provider keys are redacted again without
reopening the sk-<kebab-case-phrase> false-positive this pattern was
narrowed to avoid.
- Release/exec --worktree error text is redacted before reaching
stderr, matching the already-redacted success path; ownership errors
interpolate the caller-supplied path, which could carry a key-shaped
segment.
- canonicalizePath resolves symlinks through the nearest existing
ancestor when the target itself no longer exists, so the documented
`release -C` recovery path works again for a worktree deleted by hand
under a symlinked --worktree-dir.
- Prepare rolls back the worktree `git worktree add` just created if the
subsequent lock call fails for a reason other than a concurrent
racer, instead of leaking an unleased checkout until Clean's 24h
staleness window reclaims it.
Not addressed here: the P2 finding that worktree ownership is provable
only by a directory-name convention plus a lock-reason prefix, both of
which a user can reproduce by hand. A durable per-worktree ownership
marker would close that gap, but internal/worktrees has been on main
since #70, so a marker requirement could reject worktrees an
already-installed zero created before this change existed. Needs a
decision on migration before implementing.
* fix(worktrees): prove Prepare ownership with a git-admin marker
Address review findings that path convention plus lease-reason prefix are
forgeable by hand. Prepare now writes a zero-owner marker into the worktree
admin dir; Release and Clean require it before force-touching a path. Clean
also keys its owned subtree off the main worktree root so linked-checkout
calls still prune the same bucket Prepare uses. Tests plant the marker and
list the main worktree first so fixtures match production.
* fix(cli): complete worktrees release in shell completions
* test(worktrees,cli): add coverage for linked-worktree Clean, forged lease rejection, and completions
- TestCleanFromLinkedWorktreePrunesStaleWorktree: pins Clean deriving its
owned-subtree key from the main worktree root (not the invoking linked
checkout's --show-toplevel) so Prepare/Clean run from a linked worktree
actually reclaim the worktrees Prepare created there.
- TestReleaseRejectsForgedZeroLeaseWithoutOwnershipMarker: pins the
ownership-marker requirement against the exact forgery jatmn described - a
worktree under the predictable zero-worktree-<repoKey> path, manually
locked with a reason that merely starts with the zero lease prefix.
- Fix TestPrepareCreatesDetachedGitWorktree: its fake git runner ran out of
canned results at Prepare's post-lock ownership-marker write, so
writeOwnershipMarker resolved gitDir to "" and os.WriteFile wrote
"zero-owner" as a relative path into the test process's real working
directory instead of failing loudly. Give it autoAbsoluteGitDir like the
other Prepare-exercising tests use and assert on the marker-write call.
- completions_test.go: assert `worktrees`/`worktree` completions include
`release` alongside `prepare`.
* fix(redaction,worktrees): sort extra secret values by length descending and canonicalize worktree paths
* fix(secrets,worktrees): restore Anthropic key redaction and handle legacy worktree cleanup
* fix(secrets): add bash output redaction regression test for Anthropic API keys
* fix(worktrees): touch worktree mtime on reuse to prevent stale pruning
When Prepare reuses an existing worktree, it now touches the directory
to refresh its mtime. This prevents Clean from force-removing a
long-running but idle worktree (e.g. waiting on a model) that has no
recent file changes but is still actively in use.
Addresses the data-loss risk flagged in the PR review where mtime-only
staleness + --force removal could discard live worktrees.
Co-authored-by: cairn-code
Co-Authored-By: cairn-code <[email protected]>
* fix(worktrees): properly handle os.Chtimes error on reused worktree path
Co-Authored-By: cairn-code <[email protected]>
* fix(worktrees,secrets): unlock using porcelain entry path, set RepoRoot to primaryRoot, and add anthropic_key pattern
Unlock using matched porcelain entry path, set Result.RepoRoot to primaryRoot in Prepare, add dedicated anthropic_key secret pattern, and canonicalize fake-runner test paths.
Refs #632
* fix(tools): correct redaction placeholder assertion in Anthropic key test
TestFormatBashOutputRedactsAnthropicKey checked for the openai_key
placeholder instead of anthropic_key, a copy-paste leftover. The
redaction itself was already correct; only the assertion was wrong.
* test(worktrees): physicalize test paths for macOS tempdir symlink resolution
* fix(secrets,worktrees): address CodeRabbit review findings
Align RedactString token boundaries with secrets.Scan, strengthen the
Anthropic bash redaction assertion, fix Clean fixtures so they reach
the guards under test, and fail closed on ambiguous processAlive probes.
* fix(secrets,worktrees): restore broad key redaction and legacy Clean safety
Address human review on #855: keep sk- bodies with a digit filter instead
of enumerated vendor prefixes, add a looser JWT form, restore the
sk-test fixture, probe legacy ownership before dirty, treat non-INVALID
Windows OpenProcess errors as alive, redact Abs/cwd release errors, and
reclaim dead-owner leases on Prepare reuse.
* fix(secrets,redaction): always redact known OpenAI key prefixes
Alphabet-only sk-proj-/sk-svcacct-/sk-admin- tokens are still credentials;
keep the digit filter only for unknown sk- vendor forms so kebab phrases
like sk-learn-… stay un-redacted.
* Preserve digit-free legacy keys during redaction.
Refs #855
* fix(secrets,worktrees): address CodeRabbit findings on PR #855
Redact full compact JWE tokens, pin git locale for lock parsing, write the
ownership marker atomically, surface missing-dir unlock failures, and
fail closed when HEAD probes are indeterminate. Align Clean fixtures and
digit-free known-prefix redaction tests with the shipped behavior.
Refs #855
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: cairn-code <[email protected]>
Co-authored-by: euxaristia <[email protected]>
* feat(agent): add PermissionModePlan for interactive read-only planning
* fix(agent): drop redundant modeName branch, add plan mode regression tests
string(permissionMode) already yields "plan" / "spec-draft", so the
if/else recomputing modeName in the denial message was dead branching
on the same values. Also add plan-mode coverage mirroring three of the
four existing spec-draft regression tests: advertised tool set, and
denied write_file/bash calls. The fourth (submit-and-stop review
control) has no plan-mode analog, since plan mode has no submit tool.
* fix(agent): deny request_permissions in plan/spec-draft even when the registry omits it
request_permissions is dispatched by name in executeToolCall before the
registry-based ToolAdvertised gate runs, so that gate only helps when
the tool happens to be present in the caller's registry. A plan- or
spec-draft-mode registry that simply omits the tool (rather than
registering it as denied) let the call fall through to a real
turn/session-scoped permission grant, defeating the read-only
boundary. Deny it unconditionally at the top of
executeRequestPermissions for both read-only modes instead.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(agent): suppress executable hooks while plan/spec-draft mode is active
Plan mode promises a read-only turn, but sessionStart/sessionEnd fire on
every run and beforeTool/afterTool fire around allowed read calls, and
all four execute configured host commands outside the advertised-tool
and sandbox gates — so a project hook could mutate the workspace or
spawn a process from a session that advertises it cannot. Gate all four
dispatch points on the run's permission mode, with a regression test
asserting no hook command launches during a plan-mode run.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(agent): close plan-mode tool advertisement bypass
toolAdvertisedInPlan whitelisted ask_user and update_plan by name
alone, so a caller could register a mutating tool under either name
and have it advertised and executed in plan mode. Validate every tool
against its Safety() instead. Also exclude lsp_navigate, which is
marked SideEffectRead but lazily spawns a real language-server
process, contradicting plan mode's read-only guarantee. Add
regression tests for both.
* fix(agent): keep trust-gated hooks in spec-draft mode
Plan mode still suppresses executable hooks so a read-only planning turn
cannot spawn host processes via session or tool hooks. Spec-draft keeps
the existing trust model: project hooks fire when the workspace (or its
worktree trust root) is trusted. Unconditionally suppressing hooks in
spec-draft broke TestExecSpecWorktreeInheritsTrustEndToEnd for trusted
worktrees under --use-spec --worktree.
* fix(agent): cover plan-mode spoof and lsp_navigate denials
Expand the name-only spoof regression to both update_plan and ask_user,
and add an execution-path denial for lsp_navigate so plan mode cannot
spawn language servers even when a call still arrives.
* fix(agent): filter tool_search deferred candidates by plan/spec-draft visibility
tool_search resolved and ranked deferred tools by EnabledTools/DisabledTools
only, never by the run's permission-mode visibility. tool_search itself is
already denied at dispatch in plan/spec-draft (its Safety carries no side
effect, so it fails the same SideEffect==Read advertisement gate direct calls
use), so this was not reachable through the normal Run() path today. But it
is a real landmine: if that outer gate is ever loosened independently (e.g.
tool_search's no-side-effect Safety is judged advertisable), the loader had
no gate of its own and would hand a deferred write/mutator tool's name,
description, and full schema straight to a plan/spec-draft model.
Mirror agent.ToolAdvertised's plan/spec-draft branches inside the tools
package (toolAdvertisedForPermissionMode, next to the existing
toolAllowedByFilters mirror that avoids the same import cycle) and apply it
alongside the operator filters in visibleDeferredTools and
visibleEagerToolNames.
Added unit tests in tool_search_test.go for both modes, and an end-to-end
agent test that force-calls tool_search in plan mode and asserts no schema
leaks. Verified by reverting tool_search.go and confirming the new tests fail
(one shows load_tools resolving to the mutator's name); restored and
confirmed they pass. Also confirmed via a temporary probe that if plan mode's
outer advertisement gate is loosened, this filter is what actually stops the
leak.
* fix(agent): require Safety for spec-draft ask_user/submit_spec
Do not advertise or load re-registered control tools by name alone in
spec-draft mode. ask_user must be SideEffectRead+Allow and submit_spec
must be SideEffectWrite+Allow, matching the real tools. Apply the same
filter in tool_search and add spoof regression tests.
Refs Gitlawb/zero#642
* fix(agent): wire plan mode into TUI, CLI, and ACP entry points
Address the P1 finding that PermissionModePlan was documented but never
selected by /plan, zero exec, or ACP mode selectors. /plan on|off now
toggles the session permission mode (restoring the prior mode on off),
zero exec --plan selects plan for a run, and ACP advertises plan as a
client-selectable mode. Integration coverage for each entry path.
* fix(cli): reject --plan combined with --worktree
Worktree preparation runs in runExec before the plan permission mode is
assigned, so `zero exec --plan --worktree` could still trigger workspace
mutation ahead of the read-only gate. Reject the combination during
option validation, alongside the existing --use-spec/--skip-permissions-
unsafe conflict checks, so no worktree prep can occur.
Addresses a coderabbitai finding on PR #642.
* test(tools): assert spoofed tool schema doesn't leak via tool_search
The spoofed-control-tool regression only asserted on the description
string; Parameters() exposed no distinctive schema marker, so a
regression that leaked the schema without the description would still
have passed. Add a spoofed_secret property to the test tool's schema
and assert it's absent from result.Output alongside the description.
Addresses a coderabbitai finding on PR #642.
* fix(tui): gate local mutating commands behind plan mode
/plan on only flips the agent permission mode, which gates agent tool
calls. Local TUI commands that run entirely inside the TUI process
bypass that gate: /rewind restores workspace files from a checkpoint,
/export writes a transcript to disk, and /sandbox-setup spawns a
native host process. Add a shared plan-mode guard at the start of
dispatchCommand (mirroring the existing BTW-unavailable guard) that
rejects these three commands while permissionMode is
agent.PermissionModePlan, with regression coverage proving each is
blocked with no mutation/process spawn in plan mode and unaffected
outside it.
Addresses a coderabbitai finding on PR #642.
* fix(agent,specialist): layer plan mode system prompt and enforce read-only subagent mode
* Fix jatmn review findings for PR 642
* fix(agent,cli,tui): resolve CodeRabbit review comments on active turn model field and --plan permission mode conflict
* fix(agent): propagate permission mode to exec options and serialize ACP mode changes
Parse permissionMode in resolveExecPermissionMode, acquire turnMu.Lock in ACP handleSetMode to serialize mode changes with active turns, and block MCP subcommands in TUI plan mode.
Refs #642
* fix(agent,tui): update tests off removed tools.CoreTools/NewWriteFileTool/NewLSPNavigateTool wrappers
Those were thin unscoped wrappers around the Scoped variants, deleted
upstream in #706 since nothing else called them directly. Only these
tests still did; switch to the Scoped calls main's own tests already
use.
* fix(agent): fail-closed beforeTool vetoes and permission-mode plan guards
Keep beforeTool deny gates active under plan mode so hooksSuppressed no longer
fails open, and stop propagating --permission-mode for auto/ask/member children
so swarm members keep write tools. Apply --plan combination rejects to
--permission-mode plan as well.
Refs #853
* fix(agent): address CodeRabbit findings for plan-mode advertisement and entry paths
Unify plan/spec-draft tool advertisement in tools.ToolAdvertisedForPermissionMode
(with PermissionDeny short-circuit), cover ACP config and --permission-mode plan
list-tools paths, and allow bare /mcp while blocking mutating MCP subcommands.
Refs #853
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: euxaristia <[email protected]>
* fix(tui): fall back to CellMotion where AllMotion is unreliable
AllMotion (1003) buys exactly one thing over CellMotion (1002): hover
highlighting. Wheel, click and drag all arrive under either. But a
terminal that does not implement 1003 does not degrade to 1002, it sends
nothing at all, and because the TUI holds the alternate screen the
terminal's own scrollback and wheel are gone too. The user is left
unable to scroll by any means, and a long answer then reads as a hang
rather than as a missing highlight. That is how #870 arrived: a
completed turn with "59 more" lines below the fold, reported as a
freeze.
The existing code already conceded 1003 is fragile by carving out PRoot.
The legacy Windows console is the same case and was not covered, so a
bare zero.exe outside Windows Terminal asked for a mode its terminal
does not answer.
Move the decision into mouseModeFor, which trusts terminals that
identify themselves (WT_SESSION for Windows Terminal, TERM_PROGRAM for
hosts like VS Code and mintty) and assumes an unidentified Windows
terminal is the legacy console. Guessing wrong that way costs a hover
highlight; guessing wrong the other way costs every mouse event.
ZERO_MOUSE_MODE=all|cell|off overrides it, so a user on a terminal we
guessed wrong about can fix it without waiting for a release. An
unrecognised value is ignored rather than treated as "off", so a typo
cannot silently kill the mouse.
Refs #870.
* test(tui): pin the inherited-environment limit of the mouse-mode guess
WT_SESSION and TERM_PROGRAM are inherited, so they answer for whichever
process set them rather than for the console host attached now. A shell
started from Windows Terminal or Git Bash that later runs zero.exe
against the legacy console still carries them, and mouseModeFor then
asks for AllMotion on a host that may drop every mouse event: the #870
failure class through a narrower door.
Not fixed, and deliberately so. Telling an inherited variable from a live
one needs the console host rather than the environment, and erring the
other way costs every Windows Terminal user their hover highlighting for
a case that takes an unusual launch path to reach.
So the limit is written down in the code and pinned by a test that also
asserts ZERO_MOUSE_MODE=cell still overrides it, since that override is
the only recourse an affected user has. If real host detection lands
later the test fails by design, and its message says to delete it.
Raised by @jatmn on #872.
* fix(tui): fill alt-screen frame with theme panel background
Blank and cleared cells fell back to the terminal's own default
background instead of the theme's panel color, flashing white on
light themes during resize or when ClearScreen erases the frame.
Wrap the rendered content in zeroTheme.panel sized to the full frame
so every cell is explicitly painted.
* fix(tui): apply theme panel content to tea.View after render
tea.NewView stores the original string; reassigning the local content
variable left the view unchanged. Call SetContent after the panel
render so the alt-screen frame uses the themed output.
* fix: prevent leaking sensitive info in wrapped error responses
Modified `fmt.Errorf` calls in `internal/cli/trust.go` to use `%s` instead of `%w` when wrapping errors that are subsequently redacted via `redaction.ErrorMessage()`. This ensures the underlying error is flattened to a plain string, preventing sensitive information from being exposed later via `errors.Unwrap()`.
Co-authored-by: euxaristia <[email protected]>
* chore(cli): drop agent sentinel from trust error-format tidy
Remove .jules/sentinel.md (local agent artifact, not in main). Keep the
trust.go %s flatten as a readability tidy only; redaction.ErrorMessage
already stringifies inline, so this is not a security fix.
* style(cli): note intentional %s flatten on trust resolve errors
Keep a short comment at both sites so the %s form is not "fixed" back to %w; ErrorMessage stringifies inline.
---------
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
* fix(lsp): dispatch notifications off read loop
* fix(lsp): keep notification reads non-blocking
* fix(lsp): make the notification queue lossless
Addresses the P1 on #759.
The previous commit traded the read-loop deadlock for silent protocol-event
loss: at 64 queued items enqueueNotification discarded the OLDEST message. A
textDocument/publishDiagnostics is the server's only report for its URI, so a
dropped one makes session.waitForDiagnostics time out and Manager.Check return
no diagnostics even though the server published them — and the overflow test
codified that loss as intended behavior.
The queue is now unbounded: the read loop still never blocks (so a handler may
make a re-entrant Client.Call), and nothing is discarded. Both alternatives are
worse — blocking on a full buffer is the original deadlock, and dropping loses
state the protocol never repeats. Growth is bounded in practice by what the
server emits while a handler runs; the backing array is released as soon as the
queue drains, so a burst does not retain capacity afterwards.
Tests: TestClientNotificationQueueIsLossless replaces the drop-oldest test, and
the end-to-end burst test now also asserts that all 512 notifications sent while
a handler was blocked in a re-entrant call arrive, in order. Both were checked
against the old drop-oldest policy and fail there (64 of 512 delivered, FIFO
broken), so they have teeth.
* fix(lsp): stop retaining notifications after Close
Addresses the shutdown defect CodeRabbit found in the lossless queue.
Client.Close signals the worker loop to exit, but readLoop keeps consuming
frames until the transport ends — and Server.Shutdown closes the client BEFORE
closing stdin, so a language server emitting notifications while it handles
shutdown/exit fed an unbounded queue that nothing would ever drain.
failPending's closeOnce now marks the queue closed and releases whatever is in
it, and enqueueNotification drops instead of appending once that flag is set.
Both run under notifyMu, so an enqueue racing with shutdown either sees the flag
and drops its item or appends just before it and has the item cleared. Queued
work is dropped rather than drained: the worker is gone by definition of close,
and callers that need diagnostics collect them before shutting the client down.
TestClientDropsNotificationsAfterClose keeps the transport writable after Close,
as Server.Shutdown does, and requires 64 subsequent notifications to neither
reach the handler nor accumulate. Verified against the unguarded enqueue, where
it reports 64 retained notifications.
* fix(lsp): baseline diagnostics against receipt, bound the notification backlog
Addresses jatmn's two #759 review findings:
- Manager.Check captured its diagnostics baseline from publishCount,
which only advances once session.handleNotification actually runs
for a URI. Dispatch happens off a queue now, so a publish for a
version Check is about to supersede could already be sitting
received-but-undispatched at baseline-capture time, then run
afterward and look newer than the baseline purely because handling
lagged receipt. Since many servers omit the version field, the
existing staleness check in handleNotification (positive-version
only) couldn't catch it either. Stamp every notification with a
receipt sequence at enqueue time (Client.NotificationSeq), carry it
through the queue to the handler, and baseline against receipt
instead of against how many publishes have been processed.
- The lossless notification queue had no failure policy: a language
server sustaining a higher notification rate than the single
handler can drain (no stuck handler required) would retain every
full json.RawMessage payload without bound. Add notifyQueueLimit;
exceeding it fails and closes the client observably, letting the
manager's existing dead-session eviction take over on next use,
instead of growing the queue until the heap gives out.
Both regression tests are verified to fail against the pre-fix code:
the receipt-vs-baseline race was reproduced directly against the
pre-fix API (confirmed real, not hypothetical) before porting the
test to the fixed API, and the overload test was confirmed to fail
with the limit check removed.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(lsp): bound notification backlog bytes
Track retained notification payload bytes in addition to queue length and fail the client before the backlog exceeds 16 MiB. Wake diagnostic waiters when that failure closes the client, preserving already-received fresh diagnostics during debounce. Add race-enabled regression coverage for byte overload cleanup and client-failure wakeup.
Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-b80c-717b-b041-5d5135a61d96
Co-authored-by: Pierre Bruno <[email protected]>
* fix(lsp): preserve diagnostics across client closure
When a fresh publish and client closure wake a diagnostic waiter together, reconcile against the recorded receipt sequence instead of allowing select order to discard the publish.
Add a regression that repeatedly makes both wakeups ready together and verifies diagnostics handled before closure are preserved.
Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-5dd3-7118-a7a6-fba8f9d5af29
Co-authored-by: Pierre Bruno <[email protected]>
* fix(lsp): drain accepted notifications on close
Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8
Co-authored-by: Pierre Bruno <[email protected]>
* fix(lsp): pin the receipt boundary at read time and finish the drain
Stamping a notification's receipt seq at enqueue left a window async dispatch
had opened: readMessage consumes the frame, then json.Unmarshal and the enqueue
run later on the same goroutine while another can capture a publishBaseline. A
publishDiagnostics for superseded text could land above a baseline taken after
it was already off the wire and — most servers omit the version field the
staleness check needs — reach the caller as the answer for the new text. The
read loop now stamps every frame the moment it leaves the wire and hands that
seq to the enqueue, so decoding cost sits outside the boundary. ReceiptSeq
replaces NotificationSeq: the clock counts frames, and gaps in it are fine.
Notification drain handling is now uniform. Every exit from waitForDiagnostics
catches the worker up on notifications it already accepted before reading what
was recorded, instead of only the seq<=baseline closure arm doing so. That
covers cancellation racing an accepted-but-queued publish (which used to make
Check return (nil, nil) for text the server had answered), and the debounce
closure arm returning while a newer publish was still queued (which handed the
caller the older one). After closure the drain is bounded — admission is shut
and a handler's re-entrant Call fails immediately — so it is no longer cut short
by ctx; on a live client the catch-up is bounded by a short grace instead.
Also cap Content-Length before allocating the body, so the notification byte
budget is authoritative rather than measured after a huge frame is materialized.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(lsp): follow publishes accepted while catch-up is waiting
Catch-up snapshotted the accepted sequence once, but the reader keeps running
while it waits. The worker finishing the item that was last at the start
satisfied the check, so catch-up returned with a newer publish for the same URI
still queued — and the caller read diagnosticsFor and got the older one. That is
the failure the closed-client drain exists to prevent, reproduced on the live
path by the async dispatch this PR introduces.
The target is now re-read every pass. This cannot spin forever: the live-client
grace already caps the wait no matter how fast the server publishes, and a
closed client still drains unconditionally because its backlog cannot grow.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* test(lsp): wait for follow-up notification enqueue
Amp-Thread-ID: https://ampcode.com/threads/T-019faf74-99d4-75cf-ac7a-661c308240ef
Co-authored-by: Amp <[email protected]>
* test(lsp): pin receipt stamping to read time, not enqueue
Regression test for the gap flagged in review pullrequestreview-4834747507
on #759: TestPublishBaselineRejectsFrameReadBeforeBaseline calls
stampReceipt directly and never drives readLoop, so it doesn't pin where
the stamp is taken. A response frame (read but never enqueued as a
notification) distinguishes the two call sites directly.
Co-authored-by: Cursor <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
Co-authored-by: Cursor <[email protected]>
* fix(specialist): make overwrites atomic
* fix(specialist): harden atomic replacement
* fix(specialist): replace through a Windows-atomic, DACL-preserving primitive
Addresses both P1 findings on #757.
os.Rename — which fsutil.RenameWithRetry wraps — is documented non-atomic
outside Unix, and the retry loop only handles sharing violations. Worse, the
replacement is a freshly created temporary file, so renaming it over the
destination also publishes the directory's inherited DACL in place of the
destination's own: overwriting a specialist that had been explicitly restricted
inside a group-readable directory would expose its system prompt to that group.
temp.Chmod(0o600) cannot prevent that on Windows, where Go maps only the
owner-write bit.
New fsutil.ReplaceWithRetry publishes through a platform primitive:
- Windows uses ReplaceFileW with REPLACEFILE_WRITE_THROUGH, one operation that
carries the replaced file's security descriptor, attributes, and streams over
to the replacement. REPLACEFILE_IGNORE_ACL_ERRORS is deliberately not passed —
silently losing the descriptor is the failure being fixed — so a merge failure
surfaces and leaves the destination untouched. A missing destination falls back
to a rename (nothing to replace or preserve), as do volumes that cannot perform
a replace at all (FAT/exFAT, some network redirectors), which carry no ACL
either. The transient-lock retry is unchanged.
- Unix keeps rename(2), which is already atomic within one filesystem.
Tests: Windows coverage asserts the destination's creation time survives (a
rename would not, proving the call goes through ReplaceFileW) and that a
protected owner-only DACL survives both at the fsutil layer and through the real
`Create --force` path; a plain rename was verified to replace that DACL with the
directory's inherited ACEs, so the assertion has teeth. POSIX covers both
destination shapes.
* fix(fsutil): stop ignoring ACL merge errors, recover partial ReplaceFileW
Addresses jatmn's two #757 review findings:
- The call passed REPLACEFILE_IGNORE_MERGE_ERRORS (0x2) while the
comment claimed ACL failures were fail-closed. Microsoft documents
0x2 and REPLACEFILE_IGNORE_ACL_ERRORS (0x4) identically: with
either set, a call lacking WRITE_DAC "succeeds but the ACLs are not
preserved". Passing 0x2 therefore let a --force overwrite silently
publish the temp file's inherited directory DACL over a restricted
specialist and expose its system prompt. Pass no flags at all.
- With no backup file, ERROR_UNABLE_TO_MOVE_REPLACEMENT (1176) and
ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 (1177) leave the destination
deleted (1176) or renamed to an unreachable name (1177) while the
replacement survives only under its temporary name. Returning those
unrepaired let the caller's deferred temp-file cleanup remove the
sole surviving copy, so one failed 'specialist create --force'
destroyed both the old manifest and the new one. recoverPartialReplace
now moves the replacement into place and still reports the error,
since the destination's descriptor died with the destination.
Also drops the doc claim that REPLACEFILE_WRITE_THROUGH (0x1) flushes
before returning: Microsoft documents that value as "not supported".
The two fsutil regression tests are verified to fail against the
pre-fix code. The specialist-side test guards the caller half of the
recovery contract and does not fail pre-fix; its comment says so.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(fsutil): preserve Windows replacement backups
Use a managed ReplaceFileW backup so partial failures can restore the original file and DACL. Fail closed when atomic replacement is unsupported, retry rollback and backup cleanup, and retain a known backup if rollback cannot complete.\n\nCover unsupported volumes, 1176/1177 semantics, cleanup, rollback retries, and symlink rejection.
Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-be8e-75eb-b274-b7e22af373a0
Co-authored-by: Pierre Bruno <[email protected]>
* fix(specialist): report committed cleanup warnings
Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8
Co-authored-by: Pierre Bruno <[email protected]>
* docs(specialist): describe what a failed overwrite actually leaves behind
The Windows partial-replace recovery errors told an operator the replacement
"remains at" the caller's temporary path, but the only caller removes that file
in a deferred cleanup before the error ever surfaces — it named a file that was
already gone. Recovery from a failed overwrite is always about the original, so
the errors now describe only what the replace itself left on disk: the state of
the destination and of the managed backup. The replacement path is no longer a
parameter, since nothing reports it.
The one terminal state that needs a human — 1177 whose rollback exhausted its
retries — now says so outright. The original then exists only under an opaque
.zero-replace-*.backup name, and the loader reads *.md, so the specialist stays
invisible until somebody moves it back. The error names both paths and the
action; docs/SPECIALISTS.md walks through it; and loadDirectory records why the
non-.md siblings an interrupted overwrite can leave are skipped rather than
guessed at.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(specialist): serialize managed loads during replacement
Amp-Thread-ID: https://ampcode.com/threads/T-019faf74-99d4-75cf-ac7a-661c308240ef
Co-authored-by: Amp <[email protected]>
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: Amp <[email protected]>
Addresses a Gosec G204 (Command Injection) warning where provider command strings were passed directly into `sh -c` and `cmd /C`. Since these commands originate from the user's trusted local config and explicitly require shell features (like pipes and variable expansions) to function, removing the shell invocation introduces a breaking change. This adds a documented `#nosec` annotation to suppress the false positive instead of degrading functionality.
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
* fix(sandbox): deny reads of zero credential stores
* fix(sandbox): address PR review comments
- Deny reads of mcp-oauth-tokens.json.secret in the default Zero
config candidates: file-backed oauth stores keep their encryption
secret in a sibling .secret file, so the default MCP token store
needs the same protection as the override paths (CodeRabbit).
- Clarify that explicit credential-file overrides are still filtered
by on-disk existence like every other candidate (Copilot).
- Align the zeroUserConfigDir doc comment with config.UserConfigDir:
macOS honors XDG_CONFIG_HOME before falling back to ~/.config
(Copilot).
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(sandbox): deny the whole Zero config dir, not itemized filenames
Address code review on PR #681:
- Deny credentialDenyReadPaths' Zero-config candidate as the containing
directory instead of an itemized filename list. The token/credential/
config stores each publish through a randomly-named .tmp-<pid>-<nanos>
sibling before their atomic rename, and the legacy MCP token store
leaves a mcp-oauth-tokens.json.migrated backup after importing it —
none of those names were covered by the old itemized list, so a
sandboxed command could read them under the read-all posture.
- Stop dropping default candidates that don't exist yet at profile-build
time. A store created later in a long-lived sandboxed session (e.g. a
concurrent ) previously got no deny rule at all; every
backend already treats a deny rule over a not-yet-existing path as a
harmless no-op that still takes effect once the path appears.
- Resolve ZERO_OAUTH_TOKENS_PATH / ZERO_MCP_OAUTH_TOKENS_PATH overrides
the same way the token stores resolve them (relative-to-cwd, no ~
expansion) instead of through normalizeProfilePath, which tilde-expands
and so could derive a deny path different from where the store actually
writes.
Adds regression coverage for the directory-wide deny (including the
migrated backup and synthetic temp-file siblings), for building the
profile before the store directory exists, and for the tilde-override
resolution mismatch.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(sandbox): address macOS deny-path test and config parity
Recompute the expected Zero config deny path after MkdirAll and resolve
the temp base with EvalSymlinks so macOS /var -> /private/var does not
flake TestPermissionProfileDeniesZeroCredentialFiles. Add a parity test
that sandbox.zeroUserConfigDir stays aligned with config.UserConfigDir.
Co-authored-by: PierrunoYT <[email protected]>
* fix(sandbox): harden credential deny paths
* test(cli): normalize credential deny baseline
* fix(sandbox): close credential path gaps
* test(sandbox): normalize command credential path
* fix(sandbox): handle absent credential paths safely
* fix(sandbox): protect MCP OAuth siblings
* fix(sandbox): protect OAuth lock siblings
* docs(sandbox): clarify Linux future-path limits
* test(sandbox): probe credential store reads in the Linux smoke test
The smoke test wired HOME/XDG_CONFIG_HOME at credential directories but never
attempted a read through the sandbox, so it could not catch a real-bwrap
regression for the paths this change protects. Write a secret into ~/.aws and
$XDG_CONFIG_HOME/zero and add both to the deny-read table.
* fix(sandbox): keep the credential deny usable, current, and unguessable
Four problems with the credential deny baseline, all in the same mechanism.
The whole-directory deny over $XDG_CONFIG_HOME/zero also hid the supported
user plugin, specialist, and command roots, whose commands and scripts are
executed through this sandbox, so an installed user plugin could no longer
start. Narrowing the deny to itemized files was not an option: the stores
publish through temporary names and a concurrent login can add a store that
did not exist when the profile was built, and only a directory rule covers
both. The directory deny therefore stays, and the non-secret subtrees are
carved back out through a new DenyReadCarveouts field: seatbelt re-allows
them after the deny (SBPL is last-match-wins) plus ancestor metadata so path
resolution can reach them, and bubblewrap masks the directory with --perms
111 instead of 000 -- unlistable but traversable -- and re-binds the carveout
read-only before the tmpfs is remounted read-only.
A relative ZERO_OAUTH_TOKENS_PATH, ZERO_MCP_OAUTH_TOKENS_PATH, or
XDG_CONFIG_HOME was resolved against the command directory only, while
oauth.ResolveStorePath and mcp.ResolveTokenStorePath call filepath.Abs, i.e.
resolve against the Zero process working directory. A command run from a
subdirectory therefore had the wrong path denied and the real store stayed
readable under the read-all posture. Relative candidates now resolve against
both directories, since the process that resolves the value is not
necessarily the one that writes the store. The regression test runs the
command from a different directory than the process, which the previous
tests did not.
bubblewrap cannot mount over a path that is absent when the namespace is
assembled, so a store created mid-session stayed readable through the live
read-only host-root bind for the rest of a long-lived sandbox session. The
directories Zero owns -- its config directory and the per-store publication
directories -- are now reported as EnsureDenyReadDirs and created (0700)
before the namespace is assembled, so their mask always exists. Third-party
stores such as ~/.aws are still skipped when absent: Zero must not create
them, and that limitation stays documented on the field.
The fixed <store>.tmp publication sibling made the plaintext token, and via
the same path its encryption key, observable to a same-user sandboxed process
with write access to an override parent: it could wait for the known name and
rename the opened inode into an allowed path. Both stores now publish through
a per-store <store>.publish directory with a randomly-named file inside. The
directory name is derived from the store path, so the profile can deny it up
front, while the random name inside is what cannot be waited for or renamed
away. A test keeps the duplicated suffix in step with oauth.PublicationDirSuffix.
Also adds the seatbelt render coverage that was missing: dropping
DenyReadIfExists from denyReadRules made every credential store readable
again on macOS without failing a single test.
* fix(sandbox): close credential deny review gaps
Preserve process credential roots alongside command overrides while limiting host precreation and read carveouts to trusted process-derived paths.
Canonicalize future credential roots through existing path aliases, reject symlink carveouts, and collapse automatic masks already covered by user or automatic directory denies. Normalize the policy golden and add regression coverage across profile, Seatbelt, and bubblewrap rendering.
Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0
Co-authored-by: Pierre Bruno <[email protected]>
* fix(sandbox): normalize credential carveout paths
Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0
Co-authored-by: Pierre Bruno <[email protected]>
* fix(sandbox): canonicalize backend credential masks
Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0
Co-authored-by: Pierre Bruno <[email protected]>
* test(sandbox): normalize backend mask expectations
Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0
Co-authored-by: Pierre Bruno <[email protected]>
* fix(sandbox): fail closed for replaceable token stores
Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8
Co-authored-by: Pierre Bruno <[email protected]>
* fix(sandbox): allow keyring token path overrides
Amp-Thread-ID: https://ampcode.com/threads/T-019fbdfa-8469-731e-87c0-b26b7641cf59
Co-authored-by: Pierre Bruno <[email protected]>
* fix(sandbox): harden command credential roots
Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-c723-74d9-a323-026040b94436
Co-authored-by: Pierre Bruno <[email protected]>
* fix(sandbox): close credential deny review gaps
Reapply credential denies after Seatbelt extension carveouts and model the legacy MCP token path as a migration input rather than an atomic writer.
Retain Bubblewrap fail-closed markers for OAuth outputs exposed through readable carveouts, and add regressions for both platform policies.
Amp-Thread-ID: https://ampcode.com/threads/T-019fbf55-f6b0-71fc-b384-652ba32b9645
Co-authored-by: Pierre Bruno <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: PierrunoYT <[email protected]>
Co-authored-by: Amp <[email protected]>
* docs(readme): document /new, zero upgrade, and add Discord link
Add the /new slash command and zero upgrade command to the README's
command references, expand docs/UPDATE.md to cover the --apply install
flow, and link the project Discord server.
* docs(update): escape pipe in --endpoint table cell
CodeRabbit flagged the unescaped | in the --endpoint placeholder
breaking the Markdown table layout.
* docs(readme-zh): bring /new and zero upgrade in line with README.md
README_ZH.md had picked up the Discord link but was still missing the
/new slash command and zero upgrade entries added to README.md,
letting the translation drift out of sync.
* cli: add upgrade to root help output
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
* docs: make required update flag explicit in common commands
zero update errors without --check or --apply, so the bare form in
the common-commands examples was misleading. Addresses the Copilot
finding from the PR #489 review.
* docs: use a permanent (non-expiring) Discord invite in the READMEs
* docs(cli): match upgrade help summary to download/verify/install wording
Copilot flagged the root zero --help summary for upgrade as less
specific than the download/verify/install phrasing already used in
README.md and docs/UPDATE.md for the same command.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* docs: use discord.gg short link for the community invite
discord.com/invite/<code> and discord.gg/<code> resolve to the same
invite; discord.gg is the canonical short form to publish.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(update,readme-zh): keep npm --json stdout clean, add ZH Discussions route
`zero upgrade --json` (and `zero update --apply --json`) on an npm-managed
install wired npm's own stdout directly to the process stdout, so its
progress output landed ahead of the JSON result and made it unparseable.
Route npm's stdout into a buffer when JSON output is requested, only
surfacing it in the error message if the install fails.
README_ZH.md's Community section only pointed to Discord, unlike the
English README, which reserves Discord for real-time chat and routes
Q&A/ideas/sharing to GitHub Discussions per SUPPORT.md/CONTRIBUTING.md
policy. Bring the Chinese section in line with the same split.
Addresses the outstanding findings from jatmn's review on PR #489.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* docs(update): qualify npm-managed JSON output
Document the npm subprocess output caveat instead of promising a single parseable JSON document for apply operations. Drop the JSON-specific updater plumbing and tests so the PR remains focused on documentation and help discovery.
Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-8bcd-71bc-9bde-9b4c28b78b38
Co-authored-by: Pierre Bruno <[email protected]>
* docs(update): align upgrade guidance
Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8
Co-authored-by: Pierre Bruno <[email protected]>
* fix(update): keep the upgrade recommendation about this machine
`zero update --check --target windows-x64` answers a question about a different
machine, but the new call to action told the user to run `zero upgrade`, which
only ever installs onto this one — replacing the target-specific download
guidance the flag used to print. The recommendation is now conditional: a
current-platform check keeps it, and a cross-target check gets the manual
instruction plus a note naming the machine `zero upgrade` would have touched
instead.
Also align two flag contracts with what the code does: `zero update --help`
described --repo/--endpoint as check-only although apply honors them, and
UPDATE.md did not mention that both are ignored on an npm-managed apply, which
takes its release from the npm registry rather than GitHub.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(update): do not send a custom-source check to the default source
`zero upgrade` is a fresh invocation: it does not inherit the --repo/--endpoint
of the check that suggested it, so recommending it bare after a custom-source
check told the user to install from a repository they never asked about. The
check now records the flag it was given and the guidance repeats it, naming
where a bare upgrade would install from instead.
ZERO_UPDATE_RELEASE_URL is deliberately not recorded: every Check reads it from
the environment, including the one inside Apply, so a bare upgrade already
follows it and telling the user to repeat it would be wrong.
Two smaller edges from the same review: a cross-target check on a host with no
published release target no longer ends by describing what `zero upgrade` does
there, since it does not work there at all; and the flag table and help text now
carry the npm caveat the prose bullet already had, so docs and help agree.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(update): keep source guidance accurate
Amp-Thread-ID: https://ampcode.com/threads/T-019fafa2-7d9d-75bc-8801-6d0efc8db1df
Co-authored-by: Pierre Bruno <[email protected]>
---------
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
* Add cross-session messaging
* Harden cross-session messaging
Authenticate sender metadata against live registry records, bound peer lifecycle state, preserve records across transient discovery failures, and make receipt delivery direct and bounded.
Also serialize TUI peer approvals, harden platform runtime paths and transport deadlines, widen session references, and add regression coverage for identity spoofing, queue limits, path boundaries, and tool failures.
* Support canonical macOS socket paths
Resolve Unix transport directories to their physical path before applying strict no-symlink traversal, allowing the standard /var to /private/var macOS alias without weakening runtime-directory validation.
* Fix macOS socket path regression test
Create the canonicalization fixture beneath a guaranteed short Unix path so it exercises the primary endpoint on macOS, and report temporary-directory cleanup failures.
* Harden peer messaging lifecycle
* Fix Windows peer directory traversal
* Handle Windows token-owned peer directories
* Accept equivalent Windows ACL masks
* Build Windows peer ACL explicitly
* Pin Windows ACL trustee SIDs
* Validate Windows peer ACL semantics
* Propagate Windows peer handle cleanup errors
* Use null handle for Windows close failure test
* Test held-message eviction callback
* docs(agents): codify recurring review blockers in AGENTS.md
Capture the high-frequency classes that drive multi-round maintainer reviews
(stale bases, path canonicalization, secret handling, partial setup, missing
regression tests, scope honesty) so coding agents fix them before first review.
Refs #856
* docs(agents): tighten security and atomic-write review guidance
Require open-time containment binding instead of pre-open EvalSymlinks, and
require temp-file then atomic replace rather than exclusive create alone.
Refs #856
* docs(agents): require traversal-wide containment for no-follow APIs
Final-component-only O_NOFOLLOW is insufficient on Linux and reparse
points need platform protections. Align the Security edges guidance
with the full-path containment requirement from review.
syscall.StringToUTF16 PANICS rather than returning an error when the
string contains a NUL — documented standard-library behaviour — and
github.com/atotto/clipboard's Windows path calls it directly. So one NUL
anywhere in a copied transcript selection killed the whole program:
"zero: tui error: program was killed: program experienced a panic",
reported from a real Windows machine.
The NUL reaches the clipboard because ansi.Strip removes escape
SEQUENCES and leaves C0 bytes untouched, and the transcript carries them
from two independent directions: tool output (a binary read, git's -z
NUL-separated listings — which this repo itself parses in
files_git_sweep.go) and Zero's own card protocol, whose row prefixes are
literally "\x00command-card\x00" and "\x00plan-card\x00".
Sanitised at the WRITE rather than at each source, because the sources
are unbounded: any tool result a user can select from is one, and a fix
per source is a fix the next tool re-breaks. The same pass protects the
OSC52 fallback, where a stray control byte terminates the escape
sequence early and silently corrupts the copy instead of crashing —
which is likely why this went unnoticed off Windows, where the identical
NUL produces a bad copy rather than a dead process.
\n and \t survive: a multi-line selection is the normal case and both
are legal clipboard content.
Fixes#875
* feat(tools): give tool results an image channel and add view_image
Closes#824.
tools.Result carried only text, so no tool could hand the model a picture.
The sharpest edge was our own capture tooling: browser_screenshot,
desktop_screenshot and browser_pdf wrote a PNG, set Meta["artifact_path"]
and returned "Artifact captured: <path>". Nothing read that path
afterwards, so the model asked for a screenshot, was told one existed, and
could not look at it.
Zero was never image-blind on the way in — zeroruntime.ImageBlock,
Message.Images and internal/imageinput are mature. What was missing is the
model-initiated leg.
Result.Images is delivered as a following USER message, not on the tool
result itself. Every provider drops images on a tool-role message:
Anthropic maps a tool result to a tool_result block whose content is a
string, Gemini to a functionResponse, and OpenAI guards its image
content-parts to the user role with an explicit comment saying so. Attaching
them to the tool result would have plumbed the whole path and delivered
nothing. A separate message also keeps one tool result per tool call, which
the providers validate.
Blocks with no data, or a media type outside the provider allow-list, are
dropped rather than sent — a rejected image would fail the whole turn, and
the tool's text still stands on its own. The message names the tool, since
the images arrive detached from their result and nothing else says which
call produced them when several ran in one turn.
view_image resolves through resolveScopedReadPath before imageinput.LoadFile.
That scoping is load-bearing: LoadFile joins a relative path to the
workspace root and otherwise takes the path as given, so without it the tool
would read anywhere on disk. It is registered with the other scoped readers
and declared read-only and thread-safe, matching read_file.
The capture tools now attach what they wrote. A PDF or an unreadable file
yields no image and the text stands alone, so a capture is never failed over
its attachment.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(agent): keep tool results contiguous when a tool returns an image
The image messages are user-role, and the first version appended each one
immediately after its own tool result. With two tool calls in a turn that
produces tool_result, user, tool_result — and a user message between two
tool results breaks strict provider replay.
Anthropic makes it concrete. appendUserBlocks coalesces consecutive user
content into one block list, and tool_result blocks must come first in that
message, so the interleaved shape maps to:
[tool_result, text, image, tool_result]
which the API rejects. loop.go already documents this exact rule three lines
below the change, for the self-correction feedback it defers for the same
reason.
Images are now collected during the tool loop and appended once every
tool_result is recorded — including the aborted placeholders on the abort,
stop-reason and repeated-failure paths, so an early exit cannot strand them
mid-sequence either.
The original test used a single tool call, which cannot exhibit the bug. The
new one runs two calls where the first returns an image and asserts the image
message does not fall between the first and last tool result.
Also from review: view_image joins the "execute" tool category, which grants
read_file but not view_image, so a specialist declared with tools: [execute]
could not look at a screenshot it had just captured. Covered by a test that
holds for every category granting read_file rather than naming one. And the
core read-only set is now asserted by tool name, not only by count — a count
still passes when one reader is swapped for another.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(agent): pin the tool-result/image ordering contract
The assertions only tracked the first and last tool-result index, so four
distinct broken orderings passed: a message wedged between the two tool results,
a duplicated image flush, a duplicated tool result, and both results answering
the same tool_call_id. Mutation-testing each of those against the real loop
confirmed the old assertions were green on all four.
Now it collects every tool-result index and id and requires exactly two, in
c1-then-c2 order, at adjacent indexes, with the image following the last one and
delivered exactly once. Adjacency is the assertion that carries the contract:
Anthropic needs every tool_result block ahead of any other block in the user
message they coalesce into, so anything between the two results is the 400 this
change exists to avoid.
The image is deliberately NOT pinned to exactly last+1. appendUserBlocks merges
consecutive user-role content, so [tool_result, tool_result, text, image] maps to
tool_result blocks first and replays fine; pinning would fail that valid shape
while killing nothing the adjacency and count assertions do not already catch.
messageShape reports the actual role ordering on failure, and the loop uses two
ifs rather than a switch so an image left on a tool-role message is reported as
the misplacement it is instead of "the image never reached the model".
---------
Co-authored-by: Claude Opus 5 <[email protected]>
* fix(agent): end the additional_permissions retry loop
Asking the agent to run an ordinary read-only command on Windows could leave it
oscillating between two rejections instead of executing:
additional_permissions requires sandbox_permissions set to "with_additional_permissions"
additional_permissions must include at least one of network or file_system, ...
Both come from inlineAdditionalPermissionsProfile, and each names only the fix
that ADDS something. The first pushes the model toward setting the flag; the
second pushes it back toward requesting a permission the command does not need.
Neither ever says to omit the field, which is the correct action for a read-only
command, so the two messages form a two-state oscillator. Every rejection now
carries that remedy.
The schema half is why the object is attached at all. Neither field description
said to omit them for ordinary commands, so a model that legitimately used
with_additional_permissions once in a session had nothing telling it to stop
attaching a permission object to the next command. Both descriptions now lead
with "omit", and they are shared constants rather than duplicated verbatim in
bash.go and exec_command.go, where they were free to drift.
Rejection is still rejection. Nothing that was refused is now accepted on the
strength of an empty or mismatched payload, and sandbox_permissions is never
upgraded on the model's behalf; only the guidance changed.
The one behavioural change is that an explicit additional_permissions: null is
now treated as the omitted field, since a null grants nothing and carries no
flag. That flips three shapes from reject to execute: null with use_default,
with require_escalated, and with no mode at all. The escalated one still routes
through the normal escalation prompt and is not a new capability — the identical
request without the null key already behaved that way. A FLAGGED null still
errors, so with_additional_permissions plus null is not silently downgraded.
Fixes#845. Fixes#847.
* fix(tools): condition the omit guidance on needed access, not read-only
The description said to omit sandbox_permissions "for ordinary commands,
including every read-only one". That overstates it: curl and gh are read-only
and still need sandboxed network, so a model following it literally would omit
the override on a command that genuinely needs one and hit the opposite failure.
It now says to omit unless the command needs access the sandbox does not already
give it. That is the claim that is actually true, and it is one a model can act
on without first deciding what counts as read-only.
The schema test asserted the wrong half too — a bare substring check for
"read-only" pinned the overstatement rather than the contract. It now pins the
conditional, with the reasoning recorded so the weaker assertion does not come
back.
Reported by CodeRabbit on #864.
* fix(sandbox): stop the Windows write jail honouring Everyone-granted paths
The restricted token put the World SID (S-1-1-0, Everyone) in its
restricted-SID list unconditionally, from the original sandbox baseline
and with no comment saying why.
A WRITE_RESTRICTED token runs two checks for a write and needs both: the
normal one against its enabled SIDs, and a second against the restricted
list. The write jail rests entirely on that second check only succeeding
where Zero has explicitly ACL'd a capability SID. Everyone is a SID every
principal carries, so on any path whose DACL grants Everyone write, the
restricted half passed for free and confinement fell back to the ordinary
user's own permissions -- the exact boundary this token exists to be
stricter than.
Reachable with no privilege, no symlink and no race: an Everyone-writable
directory is enough, and share roots opened with Everyone:F and loose
third-party installer ACLs supply them. Ruled out as the common case:
C:\Users\Public\Documents grants BATCH, not Everyone.
The runner already stated the rule this broke -- it declines to add the
user SID, Administrators or SYSTEM because "each has write access nearly
everywhere" -- while listing Everyone among the restricted SIDs.
Removed only from the WRITE_RESTRICTED token, because without that flag it
is load-bearing rather than gratuitous. The restricted-SID check then
applies to reads too, and default Windows DACLs grant BUILTIN\Users rather
than anything in this list, so a token with no Everyone cannot open cmd.exe
and dies at launch with STATUS_ACCESS_DENIED (0xC0000022). Dropping it
there was tried first and takes TestWindowsUnelevatedRealSandboxSmoke with
it. That path is taken only when a profile configures DenyRead, which is
already the posture trading capability for read-deny enforcement (#612), so
the bypass survives there deliberately and narrowly; closing it needs a
read-side grant that is not a universal group.
Verified against real tokens rather than reasoned about. The new smoke test
drives the real runner and asserts three outcomes that only agree when the
jail is intact: a granted root is writable, an ordinary outside path is
denied, and an Everyone-writable outside path is denied too. Before the
change its third case wrote the file with exit 0; the first two were
already correct, which is what made the gap invisible. Restoring the SID
unconditionally turns it red again.
* test(sandbox): make a denied write distinguishable from a runner failure
The denial assertions expected exit 1, which the runner also returns for
its own errors -- a failed marker validation, a bad argument, a token it
could not build. Written that way the test passes both when the sandbox
denies the write and when nothing ever ran, and the second case proves
nothing while being indistinguishable from success.
That is a poor property for any test and a bad one for this test, whose
entire job is to notice a confinement regression. The leading
"granted root is writable" assertion covered part of it, but said nothing
about whether the two later commands launched.
The denial commands now report a distinctive exit code the runner cannot
produce for itself, so the code proves cmd.exe actually ran and its
redirect was refused.
Re-checked in both directions rather than assumed: the test passes against
the fix, and restoring the World SID unconditionally fails it with
"exit code = 0, want 77" -- still catching the bug it was written for.
Raised by CodeRabbit on #865.
* fix(cli): report MCP servers that failed to start (#822)
`zero mcp check` inspected only the error returned by registration and then
printed "is reachable". RegisterTools is best-effort by design, so a server
that fails to connect is recorded on the runtime and registration returns
nil. That is right for startup, where one bad server must not stop Zero
launching, and wrong here: this is the command a user runs when a server is
misbehaving, and it told them the broken server was fine.
The failure data already existed as SkippedServer{Name, Err}; nothing
consumed it in this path. The check now fails with the recorded error, in
both text and JSON output.
Covered by a test that spawns a genuinely missing command rather than
stubbing the runtime, because the defect was precisely a mismatch between
what registration returns and what it records. Reverting the fix makes it
print "MCP server broken is reachable. 0 tool(s) available."
* test(cli): cover the unreachable --json contract
The text path was covered and the JSON path was not, though the change
adds its own return with a different payload: status "unreachable" plus a
redacted error, rather than status "ok" with a tool count. A caller
parsing that shape was relying on something nothing pinned.
Reverting the fix now fails both cases. The JSON one is the more useful
of the two, because the wrong output is still valid JSON and would decode
cleanly into a caller expecting success.
* Improve harness context efficiency
* Address harness efficiency review findings
* Make recaps idle and cancellable
Delay recap generation until the session has been inactive, orient it around the active goal and next task, cancel stale work on activity, and render the result ephemerally above the composer.
Also strengthen the formatter safety regression to exercise formatter-modified edit output.
* Track every replace-all edit span
Translate each replacement into its post-edit line range, including cumulative shifts from earlier replacements, so follow-up edits remain authorized for every model-supplied change.
* Make review regressions cross-platform
Verify replace-all tracking through the canonical edit tool path instead of a platform-sensitive temp path, and cover cancellation when recaps are disabled during generation.
* fix(tools): derive SeenWhole from the recorded ranges
SeenWhole was a flag set only by a SINGLE read covering the file, while
SeenRange judged coverage from the accumulated ranges. The two then
disagreed about identical reads: after reading lines 1-50 and 51-100 of a
100-line file, SeenRange(1,100) was true and SeenWhole was false.
That matters because write_file gates overwrites on SeenWhole. A file past
the read byte budget cannot be read whole in one call — the read is
truncated and RecordSeenRange is correctly skipped, since the model did not
see those lines — so no sequence of reads could ever set the flag, and the
overwrite was refused permanently with:
the intended change depends on content that has not been read exactly
in this session. Read the affected lines with read_file, then retry.
Advice that could not change the answer. edit_file was unaffected because it
gates on SeenRange, so the escape was to edit rather than rewrite, but a
model following the error text would loop.
SeenWhole is now computed from the ranges against the file's recorded line
count, so scoped reads that together cover a file make it writable and the
error text becomes actionable. The observation carries that line count, and
a read reporting a different one clears the accumulated ranges: they
describe a shape the file no longer has.
Coverage is judged by merging sorted ranges rather than walking every line
against every range, which the previous SeenRange did — same answer, and it
no longer scans line-by-line over a large file.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(tools): key the large-file test on the resolved path
read_file resolves the workspace root through EvalSymlinks before recording
anything, so the tracker is keyed on the resolved path. The test asserted
against the raw t.TempDir() path, which is the same string on Windows and a
different one on macOS, where the temp dir sits under /var — a symlink to
/private/var. The SeenWhole assertions would have looked up a key that never
existed: passing here, failing on macOS CI.
Also switches the line generator to fmt.Fprintf (staticcheck QF1012).
Both from CodeRabbit on #842.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(tools): actually switch the line generator to fmt.Fprintf
The previous commit's message claimed this was done. It was not: the edit
silently did not apply and I reported it fixed without re-reading the file,
so builder.WriteString(fmt.Sprintf(...)) survived at the head — caught by
@anandh8x, not by me.
Verified this time by running the linter rather than by reading the diff:
golangci-lint --enable-only unused,ineffassign,staticcheck ./internal/tools/
reports 0 issues.
* fix: keep exact-read workflows recoverable
* test: cover permission normalization wiring
* fix: commit file observations after output budgeting
* fix: defer file observations across retries
* test: normalize patch line endings
---------
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
* fix: use OAuth credentials in provider health checks
* fix(providerhealth): verify OAuth login for all probes
* fix(providerhealth): retain API key redaction
* fix(providerhealth): redact bare OAuth tokens
* test(cli): isolate OAuth doctor config
* test(cli): isolate doctor config path
* fix(acp): interrupt idle reads on cancellation
* fix(acp): make idle-read interruption per-Read, not per-Serve-call
Addresses the P2 on #782.
The previous design raced a single, whole-connection Once (endRead) between the
read loop's own "I have a terminal result" claim and a watcher goroutine's
"ctx is done, treat this as interrupted" claim. This has a real, deterministic
failure mode that has nothing to do with goroutine-scheduling luck: bufio.Reader
can obtain data AND a terminal error from ONE underlying Read call (io.Reader
explicitly permits returning (n>0, err) together). If a line delimiter is found
within that data, ReadBytes returns the line with a nil error for that call, but
bufio caches the error internally and returns it — without ever touching the
underlying reader again — on the very next ReadBytes call. If cancellation
happens in the gap between that first, successful read and the next one (e.g.
while a synchronously-dispatched handler is still writing its response), the
watcher's ctx.Done() case fires and claims the Once BEFORE the read loop ever
gets back to a ReadBytes call — so when the cached error then surfaces (with
zero contact with the underlying reader, let alone anything closing it), it is
misattributed as a cancellation-driven interruption and silently swallowed as a
clean shutdown, hiding a genuine broken transport.
The fix moves interruption-detection down to the Read call itself instead of
bracketing the whole Serve loop. interruptibleReader wraps the connection's
reader: each Read runs the underlying call on its own goroutine and races it
against ctx.Done(). If the underlying call finishes first, its result is
returned untouched — cancellation a moment later has no bearing on a call that
already had its answer. If ctx.Done() fires first, the reader is closed (when
closable) to nudge a genuinely blocked read, a generation counter is bumped, and
the call still waits for and returns the underlying call's own result rather
than a synthesized one.
Serve then brackets each ReadBytes call (which may invoke the wrapped Read zero
or more times — zero when bufio answers from its own buffer, including a
previously-cached error) with a snapshot of that counter before and after. A
read whose bracket shows no change was never raced against cancellation — either
answered from the buffer or genuinely completed before ctx.Done() could matter —
so its outcome is reported truthfully regardless of the ctx state by the time it
surfaces. Only a bracket that changed proves this specific call actually took
the cancellation branch.
Verified the fix has teeth: reverted to the original implementation and
confirmed the new regression test (which forces the exact bufio-caching
sequence deterministically, with a panic guard proving the underlying reader is
never called a second time) fails 100% of the time against it, then restored
the fix and confirmed it passes.
Also applied the two open review threads on the existing tests: readStarted and
writeStarted are now buffered channels with non-blocking sends rather than
close(), so a future change causing an extra read or write can't panic on a
double-close.
Validated with go build/vet/test on Windows, plus go vet/build and
`go test -race ./internal/acp/... -count=50` (and count=100 focused on the
cancellation/error tests) under WSL/Linux — this session's Windows host has no
C toolchain for -race directly.
* fix(acp): preserve decided read results and require explicit reader ownership
Addresses jatmn's two #782 review findings:
- interruptibleReader.Read's select between resultCh and ctx.Done()
chooses pseudo-randomly when both are ready, so a real terminal read
error decided just before cancellation could be misattributed to
shutdown and silently swallowed. Re-check resultCh non-blockingly
before claiming a call for cancellation.
- NewConn inferred reader ownership from an io.Closer assertion and
closed any reader that happened to implement it, including one a
caller merely borrowed. Split into NewConn (never closes r) and
NewOwnedConn (explicit ownership, closes r on cancellation), and
switch the sole production call site (process stdin) to the latter.
Also applies CodeRabbit's atomic.Int64 and leading-ctx-parameter nits.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(acp): synchronize read completion with cancellation
Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-9e4e-7506-85ab-e151888bfd71
Co-authored-by: Pierre Bruno <[email protected]>
* fix(acp): preserve read completion across cancellation
Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-483a-7119-92aa-4953a72f2a76
Co-authored-by: Pierre Bruno <[email protected]>
* test(acp): bound buffered error synchronization
Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-483a-7119-92aa-4953a72f2a76
Co-authored-by: Pierre Bruno <[email protected]>
* fix(acp): report shutdown transport errors
Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8
Co-authored-by: Pierre Bruno <[email protected]>
* test(acp): inject cancellation context
Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8
Co-authored-by: Pierre Bruno <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
* fix(swarm): close lifecycle admission on shutdown
* fix(swarm): admit lifecycle work without holding the lock across Launch
Addresses the P1 on #776 plus the two documentation P3s.
[P1] beginLifecycleAdmission returned with lifecycleMu.RLock() held for the
caller's entire duration — including the call into the injected
MemberLauncher.Launch. Close needs the write lock before it can call s.cancel(),
so a launcher that blocks in Launch waiting on its context (the ordinary shape of
a launcher that waits for a slot, a daemon connection, or a sandbox handshake)
deadlocked every Close caller: the launch held the read lock while Close waited
for the write lock to reach the very cancellation the launch needed to unblock.
beginLifecycleAdmission is now a counted ticket: it holds lifecycleMu only across
the closed check and a lifecycleWork.Add(1), then returns a release func
(lifecycleWork.Done) the caller defers. Close flips closed and releases the lock
BEFORE calling cancel, then waits out lifecycleWork (admitted-but-unfinished
work) followed by watchers (goroutines admitted work may still be starting) — so
it remains a true completion barrier without holding any lock across external
launcher code. Every call site (Spawn, Handoff, AdoptOrphans, the retry path in
watch, and afterExit) moved to the ticket pattern.
[P3] beginLifecycleAdmission's contract and the Scheduler/Close handoff are now
both documented at the point that matters: the func-returning signature makes
the release contract explicit in the type itself, and Scheduler's comment spells
out why the read/write lock ordering guarantees exactly one of Scheduler or
Close closes whatever scheduler ends up installed.
Tests: TestCloseCancelsLauncherBlockedInLaunch reproduces the exact deadlock
(spawn parked in Launch, Close in a second goroutine, both must complete once
cancellation reaches the launcher) — verified it hangs under the old held-lock
pattern by temporarily restoring it. TestCloseWaitsForAdmittedSpawn is the
converse: Close must not return while an admitted spawn is still running.
Validated with `go test -race ./internal/swarm/... -count=1` under WSL/Linux
(this session's Windows host has no C toolchain for -race directly), plus
`go vet ./...` under WSL and go build/vet/test on Windows.
* fix(swarm): fail late queue admissions and dequeues after shutdown
Addresses jatmn's two #776 review findings:
- Close swept (clearQueue) every team's queue before waiting for
admitted-but-in-flight lifecycle operations to finish. A Spawn,
Handoff, or AdoptOrphans call that got its ticket right before
closed flipped could still append to a team's queue after the
sweep ran, stranding that spec forever (never launched, since
shutdown began; never failed, since the sweep won't run again).
Reorder Close to wait for lifecycleWork before sweeping queues, so
every queue append from already-admitted work is visible to the
sweep.
- afterExitAdmitted dequeued a queued spec (t.onExit) and launched it
unconditionally once its own ticket was granted, even though Close
can flip closed in the gap between that dequeue (which makes the
spec invisible to the clearQueue sweep) and the launch. Re-check
closed after dequeuing so a spec pulled off the queue after
shutdown began fails with ErrSwarmClosed instead of starting a new
worker.
Both regression tests are verified to fail against the pre-fix code
(confirmed by temporarily reverting each fix and rerunning).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(swarm): snapshot teams after lifecycle work stops
Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-a4d5-7078-a80b-802bab23607e
Co-authored-by: Pierre Bruno <[email protected]>
* fix(swarm): reject late admitted launches during shutdown
Re-check the closed state at the final launcher boundary so lifecycle work admitted before Close cannot start or retry a member after shutdown has already won the race.
Add deterministic late-dispatch and scheduler-handoff coverage, stress concurrent Spawn and Close under the race detector, and keep the late-team snapshot regression isolated from launcher behavior.
Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-4f7f-766e-a312-a7db232b9987
Co-authored-by: Pierre Bruno <[email protected]>
* fix(swarm): reject post-close launch commits
Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8
Co-authored-by: Pierre Bruno <[email protected]>
* fix(swarm): gate retry relaunch on the post-close commit
The post-Launch shutdown gate added for spawns and queue drains was missing
from the bounded-retry path, which called the launcher and swapped in the new
handle unguarded. Since FuncLauncher returns a live handle immediately without
consulting its context, a Close landing between the pre-check and Launch's
return left the watcher supervising a member relaunched after shutdown began —
and recording that member's outcome on the task.
Extract the commit gate as commitLaunch and route the relaunch through it via
relaunchAdmitted, so an initial launch and a retry pass the same three gates:
admission, the pre-Launch re-check, and the post-Launch commit. When shutdown
wins, the handle is reaped and the watcher records the terminal failure instead
of adopting it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
* fix(providers): retry provably pre-send transport failures (#447)
A flaky link that failed the streaming completion POST at connect time
stopped the turn and forced a manual continue, because the shared retry
layer never retries transport errors: a completion POST is
non-idempotent, so a replay could duplicate billable work.
That rationale does not apply to a PROVABLY pre-send failure, where no
request bytes ever left this host: a replay cannot duplicate anything,
exactly like a 429/503 where the server told us it did not accept the
request. SendWithRetry now retries, with the existing backoff, only that
narrow class: DNS resolution failure, a refused or unreachable dial, and
a TLS handshake timeout. Every ambiguous or post-send failure stays
unretried and is checked first so a mixed message can never be misread:
connection reset, broken pipe, EOF, a bare i/o timeout, context
deadline. All three providers route their completion POST through this
layer, so one change covers them uniformly.
This deliberately narrows the issue's ask (which listed connection
reset) to the provably-safe subset, and keeps the reset-stays-unretried
test green. Tests cover the retry-on-pre-send, no-retry-on-ambiguous,
and predicate-classification paths.
NOTE FOR REVIEW: this refines a deliberately conservative, documented
no-retry stance. Opening it for the owner's sign-off on the stance, not
as a settled call.
* fix(providers): classify pre-send dials by errno so Windows retries too
CodeRabbit flagged that the pre-send predicate matched only POSIX dial
wording, so a refused or unreachable dial on Windows (whose text differs
entirely: 'No connection could be made because the target machine
actively refused it') would not be retried.
Match the syscall errno via errors.Is instead: Go maps ECONNREFUSED /
ENETUNREACH / EHOSTUNREACH / ECONNRESET to the host's real values,
including the WSA codes on Windows, so classification is identical on
every platform. Errno matching is also authoritative where the string
markers were only heuristic, closing the theoretical substring gap. The
string markers stay as a fallback for errors already flattened to text.
ECONNRESET is added to the post-send exclusions by identity so a Windows
reset is excluded too.
Tests build the production error shape (net.OpError -> os.SyscallError ->
errno) so the Windows path is exercised portably on every platform, plus
an end-to-end SendWithRetry case for an errno-wrapped refused dial. Cross
-compiled and vetted for linux and darwin.
* fix(providers): scope pre-send retry to the dial phase and fail fast on permanent errors
Addresses gnanam's two [Major] review findings on this PR.
[Major] Pre-send errno classification was not scoped to the dial, so a post-send
read/write carrying ECONNREFUSED/ENETUNREACH/EHOSTUNREACH (a route dropping
mid-generation) was misclassified "provably pre-send" and could replay a
non-idempotent completion POST up to 6 times. errors.Is matches an errno
anywhere in the chain regardless of which operation raised it, so
isPreSendTransportError now requires an Op=="dial" *net.OpError before admitting
those errnos or their POSIX wording. That makes "no request bytes were sent"
structural: a post-send failure can no longer replay a POST. The dial errno
match still covers Windows (Go maps the WSA* codes), so the platform coverage
this PR added is preserved.
[Major] Permanent failures hung the agent ~60s on the 429-tuned schedule.
NXDOMAIN (net.DNSError with IsNotFound) is now treated as permanent and not
retried, and the pre-send path gets its own short bound and sub-second schedule
(preSendMaxAttempts=3, 500ms/1s) instead of reusing the 2/4/8/16/30s rate-limit
backoff, so a mistyped host or a dead local daemon fails in about 1.5s.
[Minor] Fixed the stale SendWithRetry doc that still claimed transport errors are
never replayed.
Tests: post-send read/write errnos and NXDOMAIN now assert exactly 1 call (no
replay); dial errnos plus the string fallback assert the pre-send cap; added the
pre-send backoff schedule. Race detector and all provider packages green.
* fix(providers): match Windows WSA dial errnos so refused dials retry on Windows too
CodeRabbit caught a real Windows bug, which I verified with a real dial on
Windows: errors.Is(err, syscall.ECONNREFUSED) returns FALSE for a refused dial
there, because a real Windows dial carries the raw WSAECONNREFUSED (10061) while
Go's syscall.ECONNREFUSED is a distinct value the net package never produces. The
POSIX string fallback ("connection refused") does not match the Windows wording
("No connection could be made because the target machine actively refused it")
either. So the pre-send dial classification silently never fired on Windows, the
exact platform this PR set out to fix.
Move the dial errnos into a platform-specific list (dialPreSendErrnos): POSIX
everywhere, plus the WSA* codes on Windows. The existing wrapDialErrno fixtures
wrap the Go syscall constants, which is why they passed on both platforms and
missed this. Adds TestIsPreSendTransportErrorRealRefusedDial, which does a REAL
refused dial and asserts pre-send classification, so the platform's true error
shape is exercised on every OS.
* fix(providers): exclude Plan 9 from the POSIX dial-errno build tag
* fix(providers): keep the pre-send errno classification off Plan 9
retry.go referenced syscall.ECONNRESET and dialPreSendErrnos directly, but the
errno definitions live in build-tagged files that exclude Plan 9 (which has no
BSD-style errno constants), so the package stopped compiling for GOOS=plan9 on
this branch even though it builds on the merge base.
Move the two syscall touch-points behind per-platform helpers: retry.go now
calls isConnResetErrno and ranges dialPreSendErrnos, both provided by
retry_dialerrno.go, retry_dialerrno_windows.go, and a new
retry_dialerrno_plan9.go. The Plan 9 variant supplies an empty errno list and a
false isConnResetErrno, so retry.go falls back to its existing string-marker
heuristics there. Behavior on linux/darwin/windows is unchanged (isConnResetErrno
matches the same syscall.ECONNRESET as before). retry_test.go is tagged !plan9
because it constructs real syscall.Errno dial shapes.
Verified: GOOS=plan9 build and vet now pass; windows and linux build, vet, gofmt,
and the full providerio test suite stay green.
* fix(providers): stop pre-send retry from replaying a redirected POST
Addresses jatmn's review on #750.
[P1] client.Do could follow a 307/308 after already transmitting the original
POST, and a dial failure to the redirect target then satisfied
isPreSendTransportError and replayed the request, re-billing a completion the
first host already received. SendWithRetry now disables redirect following
(CheckRedirect returns http.ErrUseLastResponse on a cloned client), so any
dial/DNS/TLS error from Do can only come from the single initial request and
the pre-send classification is sound. A 3xx surfaces as the response for the
caller's existing non-2xx handling. Regression test asserts the POST is sent
once and the 307 is not followed.
[P2] The pre-send retry used the loop-wide attempt counter shared with 429/503
retries, so a couple of status retries exhausted the pre-send budget and the
first dial/DNS/TLS blip after them got no retry. Track pre-send failures and
their backoff ordinal in a separate counter. Regression test covers a
status-then-transport-failure sequence.
[P3] Correct the Plan 9 and wrapDialErrno comments: the Plan 9 string-marker
fallback keeps the pre-send inclusions and the separate reset exclusion, and
the errno fixtures do not reproduce a real Windows dial (that carries distinct
WSA errnos), which the real-dial test covers.
* fix(providers): decouple the status retry budget from pre-send retries
Addresses jatmn's follow-up on #750.
The previous change gave pre-send failures their own counter, but every retry
still advanced the loop-wide attempt counter that the 429/503 branch used. So
the coupling survived in the opposite direction: two safely replayed refused
dials shortened the later status retry window and started its backoff at a
later rung.
Give the status path its own statusAttempts counter and backoff ordinal. With
both budgets tracked separately the shared loop counter has no remaining
reader, so the loop no longer carries one. Behaviour is unchanged when only one
kind of failure occurs: the first retryable status still waits on rung 1 and
still gets maxAttempts requests.
Adds TestSendWithRetryStatusBudgetSurvivesPreSendRetries, the inverse of the
existing mixed-sequence test: two refused dials followed by rate limiting must
still leave the status path its full window.
* fix(providers): keep following redirects, but never replay a redirected POST
Addresses jatmn's follow-up on #750.
Blocking redirects outright was too blunt. SendWithRetry is the shared
completion path, so replacing every caller's CheckRedirect with
ErrUseLastResponse turned a legitimate 307/308 from a redirecting
OpenAI-compatible gateway into a terminal non-2xx that providers report as an
upstream error, and it discarded an injected client's redirect policy.
Observe the redirect instead of preventing it: a per-attempt flag is set from
CheckRedirect, which then delegates to the caller's policy when one is set and
otherwise mirrors net/http's default ten-hop cap. The pre-send retry is gated on
that flag, so an attempt that entered a redirect is never replayed while
redirecting endpoints keep working. The safety property is unchanged, since a
dial failure on a redirect hop follows a POST the origin already received.
Adds three regressions: a redirect is followed through to the final response, a
dial failure after a redirect is not replayed, and a caller-supplied
CheckRedirect still runs and still decides.
* test(providerio): pin the redirect asymmetry for retryable statuses
A reviewer read the redirect guard as incomplete, because it stops a redirected
POST being replayed after a transport error but not after a 429/503/529. The
asymmetry is intentional, and nothing recorded that, so the next reader would
reasonably reach the same conclusion.
The two cases differ in what is known about the request. A transport failure
after a redirect leaves it unknown whether the redirect target received and
began processing the POST, so replaying could duplicate billable work. A
retryable status means every hop answered: the 307/308 is the origin declining
and delegating, and the 429 is the target declining as well, so nothing accepted
the request and a replay duplicates nothing.
The exposure is also the same as a retryable status with no redirect involved,
which this package has always retried, so guarding here would not close a gap.
It would make a rate-limited completion behind a redirecting gateway fail
outright rather than back off, which is the regression class already raised
against disabling redirects wholesale.
Adds the case as a test asserting the intended behaviour, plus a comment at the
branch explaining why it is not guarded.
* fix(providers): retry connect timeouts and proxy CONNECT failures
Three classification gaps against #447, all raised in review.
A connect-phase timeout was never retried. It is provably pre-send for the same
structural reason a refused dial is, since the connection was never established
and no request bytes left this host, but it arrives carrying "i/o timeout" and
the blanket wording exclusion rejected it before the connect gate could admit
it. Intermittent connect timeouts are exactly the transient failure the issue
is about, so the most common case was the one not covered. It is now admitted
ahead of the wording exclusions, gated on the connect phase so a post-send read
timeout, which is also Timeout(), still cannot replay a POST.
A failed CONNECT through an HTTP proxy was never retried either. net tags it Op
"proxyconnect" rather than "dial", so it missed the gate entirely. Same
guarantee applies: the tunnel to the model host was never built. The gate now
accepts both, which matters most in the environment where transient connect
failures are most common.
ETIMEDOUT and WSAETIMEDOUT join the errno lists so a connect timeout still
classifies if it reaches us with the errno but without the timeout flag.
The pre-send budget also now resets after a hop that reaches the server. It is
meant to bound consecutive connect failures, not the lifetime of the call, and
without the reset an early run of blips permanently spent it, so a later blip
surfaced as a transport error while the status budget still had room. That is
the coupling the two counters were split apart to remove, in the one ordering
the earlier tests missed. Total work stays bounded, since every reset costs a
status attempt and those are capped.
Also corrects the SendWithAuthRetry comment, which still claimed the inner path
retried only 429/503/529.
Tests cover dial and proxyconnect timeouts as retryable and read/write timeouts
as not, a refused proxy CONNECT, and the interleaved pre-send/status/pre-send
sequence. Each fix fails its test when reverted.
providerCommandTimeout bounded only the wait, so the call could take arbitrarily
longer than the 5s its error text promised. Two phases sat outside it.
Starting the process ran before the clock began. On Windows that is
CREATE_SUSPENDED, job-object creation and assignment, and a system-wide thread
snapshot, none of it fast on a cold or contended machine. The deadline is now
armed first and the start races it, so process creation is inside the budget.
Draining after termination was unbounded too: the timer path called Terminate
and then received on the wait with no limit. Terminate normally makes that
immediate, which is exactly why it survived unnoticed, but a tree that resists
termination held the call open long past the deadline that had just expired.
That receive is now bounded, and a call that gives up leaves cmd.Wait running,
so the output buffers are synchronised: the I/O pump keeps writing into buffers
the caller already holds, which a plain bytes.Buffer cannot survive.
The timeout error also carried nothing actionable. It was a bare string with the
underlying error and the command's stderr both dropped, so a user whose provider
command was slow got no clue which phase ran out or what the command had said.
It now names the phase, wraps the cause, and includes the redacted output the
failure path already had.
Both bounds are asserted on extracted helpers rather than end to end, because
end to end they are untestable: a terminated command stops writing and its wait
returns at once, so an unbounded drain and an unsynchronised buffer both pass a
whole-call test. The first version of these tests did exactly that and proved
nothing. Removing either bound now fails, as does arming the deadline after the
start, which reports the wrong phase.
* feat(providers): add Fireworks AI provider preset
Add an OpenAI-compatible Fireworks catalog entry with curated coding
models, models.dev alias mapping, onboarding tests, and README setup.
* fix(provider): use correct Fireworks DeepSeek V3 model ID
Update default and catalog model IDs to deepseek-v3-0324 per Fireworks API.
scrubSensitiveEnv removed ZERO_DAEMON_REMOTE_TOKEN but not its file
counterpart. TokenFromEnv accepts either form, so a sandboxed command
still inherited the path to the bridge token, and the default posture is
read-all, so it could then read the token itself.
Scrubbing the pointer is the whole fix rather than half of one: the path
comes from this variable alone and TokenFromEnv has no default location
to fall back on, so a command that never sees the variable has nothing to
guess at. This mirrors GOOGLE_APPLICATION_CREDENTIALS, which is already
scrubbed for the same reason.
Extends the existing scrub table, which compares the surviving
environment exactly, so the case fails if the entry is dropped. Verified:
without the fix the assertion reports ZERO_DAEMON_REMOTE_TOKEN_FILE
surviving into the scrubbed environment.
* fix(sandbox): deny reads of git's credential stores (#815)
git's credential-store backend keeps host passwords and personal access tokens
in cleartext, at ~/.git-credentials or ~/.config/git/credentials depending on
whether the user is on the XDG layout. Neither was on the deny list, so any
command the agent runs could read them.
Scoped to those two files deliberately. #815 also covers SSH private keys and
the GPG keyring, and those are a harder trade: denying ~/.ssh stops a sandboxed
git push over SSH from working, and excluding that directory is not even
sufficient, since an IdentityFile directive can point key material anywhere.
These two cost nothing by comparison, because git reads them to authenticate
rather than to know who the user is, so a sandboxed git keeps working and
simply cannot act as the user.
The XDG one denies the file rather than the directory around it, which is the
opposite of the choice made for ~/.config/zero and is deliberate: ~/.config/git
also holds the global git config, which userGitConfigReadPaths grants on
purpose so a sandboxed git can read user.name and aliases instead of failing
outright. Denying the directory would take that away.
Tests assert both stores are denied, that the git config beside the XDG one
stays readable, and that an explicit read grant still re-includes them like
every other entry. Dropping either entry fails the first; widening the XDG
entry to the directory fails the config assertion.
* test(sandbox): assert the credential deny exists before the grant lifts it
The allow-read case only checked that the credential store was absent from
the deny list once an explicit grant covered it. A deny rule that was never
added satisfies that just as well, so the test passed with the production
change reverted and proved nothing about the interaction it was named for.
Check both runs: denied with no grant, absent with one. The first leg is
what establishes there is something for the grant to override.
* test(tools): stop internal/tools failing on stock Windows
This package fails on every run on a Windows box without PowerShell 7, which
is what a stock install is: PowerShell 7 is a separate download. CI never sees
any of it, because the runners have 7. Three distinct causes, all surfaced by
the move to PowerShell in #804.
The stop-intent test waited a fixed second for a session to die and asserted on
whatever was true by then. Under cmd.exe that was enough; under PowerShell the
tree is slower to stand up and tear down, and the FIRST stop in a fresh process
pays the interpreter's cold start, so it routinely returned with termination
under way but unfinished. The assertion read that as a failed stop. It now
re-sends the stop until the session reports it has exited. Repeating is safe,
since Terminate is a no-op against an already-dead tree, and it keeps the
interrupt flag that a poll with empty chars would drop.
Separately, and pre-existing, every test that starts a session hit a teardown
race: a terminated process does not release its handles the instant the session
reports exited, and Windows refuses to remove a directory with an open handle
where POSIX does not. t.TempDir removes once and fails the test on the sharing
violation, so ordinary teardown timing was reported as a broken test. Those
roots now clean up with a bounded retry, and give up quietly rather than
failing: the process is already terminated, so the worst case is a stale
directory under the OS temp root.
Last, the MSYS guard test asserted a windows_shell_syntax block using a command
containing `&&`. On PowerShell 5.1 the windows_powershell_version preflight
added by #804 fires first and shadows it. The `&&` was incidental to what that
case is about, so it is gone. This one is worth noting as a shape rather than a
typo: a new preflight silently changed which block an older assertion got, and
only on the shell CI does not run.
Verified by running the package repeatedly rather than once: previously failing
on every run, now passing 3 of 3, and the stop-intent test passing 8 of 8 alone
and 6 of 6 under CPU contention. Disabling the terminate path still fails it,
so the assertion continues to bind.
* test(tools): assert the stop intent killed the session
The retry loop added to survive a slow PowerShell teardown also made the
test unfalsifiable. The helper sleeps 5s, well inside the 30s deadline, so
a terminate that did nothing at all would let the sleep end on its own and
every existing assertion would still hold: the session reports exited, it
reports an exit code, and the interrupted flag echoes the request rather
than the outcome. Neutering KillProcessTree confirmed it, the test passed.
Assert instead that the helper's completion line never appears, which
distinguishes a killed session from a finished one without depending on
how long the loop took. Output is accumulated across every poll rather
than read off the last result, because each continuation returns only what
the session produced since the previous collection.
* fix(tui): paste clipboard images with Ctrl+V
Ctrl+V did nothing when the clipboard held a screenshot, while right-click
paste attached it. The image plumbing was already there: routePaste probes the
OS clipboard for an image whenever a paste arrives with empty text.
The gap is that a clipboard holding only an image produces no bracketed paste
at all, since the terminal has no text to send, so routePaste never runs and its
probe never fires. Right-click reached the probe only because that path always
delivers a clipboardReadMsg, empty or not. Bubble's own Ctrl+V binding stays
disabled, so nothing else was listening for the key.
Handle Ctrl+V by issuing the image probe directly. It reads image content only,
never text, so bracketed paste remains the single text path and a Ctrl+V with
text on the clipboard is unaffected. The probe yields no message when there is
no image, keeping it a silent no-op, and it is gated on noBlockingModal so a
permission prompt or picker still swallows the key.
readClipboardImage becomes a var so the tests stub it instead of depending on
the machine's real clipboard.
Fixes#534
* fix(tui): also probe the clipboard on Cmd+V, and test the real key route
Two things raised in review, both fair.
The handler matched keyCtrl only, which is ModCtrl. macOS reports Command as
ModSuper, as the rest of the TUI already models it, so Cmd+V never reached the
image probe even though the change claimed to cover it. It now matches both, via
a keySuper helper alongside the existing modifier helpers.
Worth being precise about the limit: this only helps where the terminal delivers
the key to the application. A terminal that handles Cmd+V itself pastes the
clipboard TEXT and sends no key event at all, so an image-only clipboard still
produces nothing for this to react to. That is a separate problem from the one
being fixed here.
The tests also did not exercise the route they were named for. One asserted that
Update returned some non-nil command and then ran a freshly built probe; the
other never called Update at all. Both now stub the reader BEFORE Update and run
the command Update actually returned.
That matters more than it sounds. Making the branch return a different non-nil
command leaves the old assertion passing and fails the new one, which is exactly
the hole: the old test proved a command was issued, not that it was the image
probe. Removing the route entirely, or reverting to ModCtrl only, also fails now.
* fix(imageinput): report an unreadable clipboard image instead of silence
On macOS the clipboard reader shells to pngpaste or a PyObjC one-liner, and
neither ships with the OS: pngpaste is Homebrew-only and Apple dropped the
bundled PyObjC. A stock Mac therefore fails every time, and the failure was
returned as (nil, nil), which the caller reads as "no image on the clipboard"
and treats as a silent no-op. Pressing Ctrl+V produced nothing at all: no
image, no error, no hint that anything was missing.
The darwin path already knows the difference. It probes with `clipboard info`
first and only reaches the extraction step once the clipboard is confirmed to
hold an image, so a failure there is not absence, it is "there is an image and
nothing here can read it". That now returns ErrClipboardImageUnreadable, which
the existing error path turns into a transcript notice naming what to install.
The defect predates this branch, but this branch is what makes the path
reachable from Ctrl+V, so shipping without it would close#534 with a feature
that is inert on one of the three platforms. Raised by gnanam, who measured it
on a real Mac rather than inferring it.
Deliberately narrow. The Linux arm cannot tell "no tool" from "no image"
without probing separately, and Windows uses PowerShell, which is always
present, so neither is touched.
The test drives the whole path: stubbed reader returns the error, Ctrl+V
issues the probe, and the assertion is that the failure reaches the transcript
as an error row rather than stopping earlier. Swallowing the error again makes
it fail.
* test(imageinput): cover the darwin unreadable-clipboard decision itself
The test added with the fix asserted around the change rather than on it. It
substituted ReadClipboardImage with a stub returning the error, which proves
the TUI surfaces an error handed to it, but never reaches
readClipboardImageDarwin, where the decision lives. Reverting both production
return sites to nil,nil left every package green, which is the definition of a
test that is not testing the change. Caught by gnanam.
readClipboardImageDarwin shells out twice and had no seam, so the two steps are
now package-level vars, the same shape as the readClipboardImage var this
change already relied on. That makes the case that matters reachable: the info
probe succeeds and reports an image, and the extractor then fails. It cannot be
arranged from outside the function, because from there both steps are one
opaque call.
Five cases, and the negative ones carry as much weight as the positive. An
image with no working extractor, and an extractor that exits 0 with no bytes,
must both be actionable. A clipboard offering no image, and a failing info
probe, must both stay silent, because turning those into a notice would make
every stray Ctrl+V nag the user. The extractor must not run at all in either
silent case.
Repeating gnanam's experiment now fails: reverting both return sites to nil,nil
fails the two actionable cases and leaves the silent ones passing, which is
exactly the discrimination the fix is supposed to add.
* fix(sandbox): preserve user config and retry denied writes
Keep user identity and configuration paths discoverable while retaining managed cache and temporary storage. Classify native sandbox failures as typed recoverable denials so approved commands can retry outside the sandbox.
Add regression coverage for global Git ignores, typed denial handling, and permission retry behavior.
* fix(sandbox): tighten denial retry safeguards
* test(sandbox): assert denial contract
* fix(sandbox): trust typed denials and restore Cargo state
Stop promoting command output keywords into recoverable unrestricted denials, so only structured adapter facts can enter the approved unsandboxed retry path.
Restore the managed CARGO_HOME directory and environment override so Cargo remains writable while the caller's real home stays read-only.
* fix(sandbox): mask preserved credential configs
Preserving caller configuration made credential-bearing tool files discoverable under Linux's read-all sandbox profile. Add default and environment-overridden npm, GitHub CLI, netrc, Docker, Kubernetes, and Zero config paths to the credential deny set, with regression coverage.
Clarify the typed-denial retry and platform read-model comments without changing the approval behavior.
* fix(sandbox): protect custom gcloud config
CLOUDSDK_CONFIG can relocate gcloud credentials outside the XDG-derived default. Include the configured directory in the credential deny profile and cover the override with a regression test.
* fix(sandbox): deny zero credential directory
* test(sandbox): keep zero credential directory deny path expectations
* fix(windows): prefer PowerShell for command execution
Detect PowerShell 7 or Windows PowerShell before falling back to cmd.exe, and share the selected host shell across agent tools and the interactive shell escape.
Configure UTF-8 output, preserve restricted-sandbox MSYS safeguards, and add Windows coverage for pipelines, workspace writes, and interactive stdin sessions.
* test(windows): align shell coverage with PowerShell
Make Windows command fixtures and guidance assertions follow the selected host shell, preserve cmd fallback coverage, and use a genuine MSYS executable when PowerShell aliases are available.
Remove the unsupported non-TTY PowerShell stdin assertion; Windows interactive stdin requires a separate PTY transport.
* fix(sandbox): classify Windows executable suffixes
Normalize .exe, .cmd, .bat, and .com command names before safety and network lookup so PowerShell invocations such as curl.cmd receive the same network permission handling as curl.
Add analyzer and engine-level regressions for the Windows PowerShell command shape observed in CI.
* fix(sandbox): normalize absolute Windows command paths
Extract drive-letter and UNC basenames before removing shell escape characters so fully qualified Windows executables continue matching canonical safety and network classifications.
Cover both drive and UNC curl paths with analyzer regressions.
* fix(sandbox): normalize relative Windows paths
Recognize drive-relative, explicit relative, and suffixed backslash executable paths before generic shell escape removal so Windows commands continue to match canonical sandbox risk entries.
* fix(windows): harden PowerShell execution
* fix(sandbox): classify PowerShell removal aliases
* test(cli): order the prewarm probe against the turn
TestRunExecOptimizedSessionUnderGate intermittently sees zero prewarm HEAD
probes on the Windows runner. It polled for up to 5s after the run returned,
reasoning that the deadline exceeded the 3s prewarm timeout so a slow probe
could not flake it. That reasoning does not hold. The probe is issued on the
run's context and runExec cancels that context as it returns, because the stop
function signalContext hands back is a CancelFunc. Once the run is over the
probe is cancelled rather than slow, so polling cannot observe one that has not
already landed, at any deadline.
Against a mock server the turn finishes in microseconds, which leaves the probe
racing teardown. The turn now blocks until the probe arrives, giving it a
window while the run is still alive. The wait is bounded, so a probe that never
fires still fails the assertion with a useful message.
This widens the window rather than closing it: prewarmTimeout is armed inside
the probe goroutine, so holding the turn cannot extend it. The comment says so,
and names prewarmTimeout as the thing to look at if this ever recurs.
* test(cli): fail when the prewarm hold gives up
The bounded wait let the turn proceed after its guard expired, so a probe that
arrived late still left the count at 1 and the test passed. That proves the
probe was eventually received, not that it preceded the turn, which is the only
thing this test claims. Raised by CodeRabbit.
The expiry is now recorded and checked before the count, since the count cannot
tell the two apart.
Verified by suppressing the probe, which makes the guard expire: the assertion
fires with its own message rather than the count assertion, and the run takes
the full guard duration.
* fix(sandbox): add platform-specific write lock around grant state file (#752)
Add grants_lock_unix.go and grants_lock_windows.go with a lockStateFile
method that serializes access to the persisted grant state across
processes. Wire the lock into Grant, GrantCommandPrefix, Revoke,
RevokePath, and Clear.
Also add .gitattributes to force LF line endings for Go files so
gofmt checks pass on Windows.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(sandbox): do not lock the grants file when there is no notice to consume
Addresses jatmn's #755 finding.
ConsumeMigrationNotice took the interprocess lock unconditionally, and
lockStateFile MkdirAlls the grants directory and creates
<grants>.lockfile. Every startup and every 'zero exec' calls this, so it
turned reading the grant state into requiring a WRITABLE grants
directory: a user with no grants file whose ZERO_SANDBOX_GRANTS_PATH (or
its parent config directory) sits on a read-only mount failed outright
with 'failed to migrate sandbox grants', where the pre-lock code read an
empty state and wrote nothing.
Peek at the state without the lock first and return early when no
migration write is required. Only when one is — a schema/policy
migration, or clearing NoticePending — take the lock and re-read under
it, since the unlocked peek is a snapshot another process may have
already consumed or migrated, and is meaningless by contract when
needsMigration is set.
Both regression tests are verified to fail against the pre-fix code.
The read-only-directory test skips on Windows (mode bits do not deny
writes) and as root (permissions are bypassed), so a portable companion
asserts the same requirement by requiring that nothing is created at
all.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
* fix pinned Go quality toolchain
* fix(make): use a POSIX GOTOOLCHAIN prefix for pinned tool targets
The Windows_NT branch used cmd.exe syntax (set "..." &&), but GNU Make
runs recipes through sh even on Windows (MSYS/Git Bash), where that
form fails and breaks make lint-static/deadcode/vulncheck. A plain
GOTOOLCHAIN=... prefix works everywhere, so the OS check is gone.
Also switch GO_VERSION/GO_TOOLCHAIN to recursive expansion so
'go list -m' only runs when a tool target actually needs it, instead
of at parse time for every target. Addresses PR review from CodeRabbit
and Copilot.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(build): make pinned-tool targets shell-neutral, fix README drift
Address code review on PR #689:
- lint-static/deadcode/vulncheck previously set GOTOOLCHAIN via a
POSIX `VAR=value cmd` recipe prefix. GNU Make on Windows runs
recipes through whatever SHELL is configured, and under a native
cmd.exe setup that syntax is invalid — cmd.exe treats the whole
"GOTOOLCHAIN=go1.26.5" token as the program to run and fails before
go ever executes (reproduced locally: cmd.exe errors with
"'MYVAR' is not recognized..." for the equivalent pattern). Switched
to a target-specific `export`, which sets the variable in the child
process environment via Make itself, before is invoked —
works under sh, cmd.exe, and PowerShell alike (verified against all
three targets with SHELL := cmd.exe).
- README.md's Code Quality and Security Checks section still told
contributors to run the raw `go run ...@version` commands this PR
identifies as selecting the wrong (older) toolchain and failing to
load this Go 1.26.5 module — only CI and AGENTS.md had been updated
to the new make targets. Points steps 3-4 at `make lint-static` /
`make vulncheck` instead, matching AGENTS.md.
Verified end-to-end: `make vulncheck`, `make deadcode`, and `make
lint-static` all run successfully and reach their tools (deadcode's
advisory report and lint-static's 35 pre-existing findings match the
PR's documented baseline).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(build): support stock macOS make
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Both background-child tests fail intermittently on the Windows runner, and
have since #690. #800 and #802 each relaxed an assertion, which is why neither
held: the deadline itself was the problem.
The tests drove LoadProviderCommand, so the fixture's own startup was billed to
the production 5s budget. On a loaded runner the outer timer won the select in
runProviderCommand, and both symptoms follow from that. The nonzero-exit test
saw a timeout instead of the exit status it asserts, because the timer
preempted the shell's exit. The other test lost its PID file, because the
timer's Terminate killed the background child before it could record a PID,
putting the file permanently out of reach however long the assertion polled.
Neither test is a claim about the 5s budget. They assert which error each
cmd.Wait path yields and that the leftover descendant is killed, so they now
call runProviderCommand directly with a budget of their own and the production
deadline stops competing with process startup. Making the child outlive that
budget lets both drop their elapsed-versus-lifetime bounds as well, so no
wall-clock assertion is left in either test.
That is only worth doing if the two timeout paths can be told apart, and they
could not: the WaitDelay path and the outer-timer path both returned the same
bare sentinel, so asserting on it would have been satisfied by the very timeout
the tests exist to rule out. The WaitDelay path now wraps rather than replaces.
Callers asking errors.Is for the sentinel are unaffected, so no user-visible
message changes.
The Windows fixture also waited for its ready marker by launching a second
PowerShell. That cold start is pure overhead added after the PID is already
written, so the wait now runs in cmd itself and is bounded.
Verified by looping both tests under CPU contention: 7 failures in 16
iterations before, none in 32 after, with per-iteration time going from a
spread of 2.3s to 66s down to a steady 2.1s. Removing the wrap makes the
WaitDelay assertion fail, so it is load-bearing rather than decorative.
Mirror plugin info: compare on-disk SKILL.md to the skills.lock hash so
edited installs are visible. Also drop the nonexistent skills lock
command from EXTENDING.md.
New turns inherited lastStreamActivity from the prior turn, so a fresh request could immediately look stalled. Initialize stream activity from the same timestamp as turnStartedAt and cover the stale-clock case with a deterministic regression test.
Cold PowerShell startup under concurrent Windows CI can consume most of the provider timeout, so an unsynchronized PID file may never be written and a fixed four-second bound can fail despite correct process-tree cleanup. Treat an absent PID as a helper that never reached startup, retain strict liveness checks for synchronized children, and bound completion by the child's natural lifetime instead.
* chore: remove code unreachable on every supported platform
The Security and code health job's advisory deadcode step reports 152
unreachable functions on main. Running the same check under GOOS=linux,
windows, and darwin shows 13 of those are false positives whose callers
live in windows build-tagged files, so only functions unreachable on all
three platforms are touched.
Functions with no references anywhere are deleted, including the
internal/reasoning package, which nothing imports. Functions that only
tests reference move into per-package export_test.go files so tests keep
their seams while the production binary sheds the code. Test call sites
of deleted convenience wrappers are ported to the live APIs (scoped tool
constructors, tools.DeferredSource, FinishReason checks).
Kept despite the report: cron.Store.Runs (only reader of runs.jsonl,
which production writes), and agenteval.TraceEventKeys and
MissingTraceEvents (used across packages by the trace contract test).
* docs(config): correct ValidateFile's errors.As claim
Message is a flattened string (.Error() already called on the parse
error), not a wrapped error, so callers can't recover the concrete
json error type via errors.As. Fix the comment to match.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* chore(trace,skills): drop obsolete Sink adapters and fix stale doc references
The dead-code pass removed the Emit methods but left Sink, NDJSONSink, and
TextSink exported with a contract nothing implements; remove the types with
their contract. Also remove the stray blank line at EOF of trace.go flagged
by gofmt, and update skills package docs and HOW_ZERO_WORKS.md to stop
directing callers to DiscoveryRoots/Duplicates, which are now test seams.
Co-Authored-By: Claude Fable 5 <[email protected]>
* chore: clean up remaining review nits on dead code removal
Replace a pointless fmt.Errorf(...%w...).Error() with fmt.Sprintf
using %v, since the wrapped error was discarded immediately anyway.
Remove a comment block that was duplicated verbatim.
* docs(skills): drop remaining references to test-only List/Get
Load and MergedSkills comments still named List/Get as production
entry points after they moved to export_test.go. Point callers at
Load and ListFromRoots instead.
* chore: remove now-unreferenced export_test.go seams
execSessionManager.len() and likelySandboxDenied/sandboxDenialKind had
no remaining callers after the sandbox unify-command-execution rebase;
the production code they wrapped for tests no longer exists.
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
* fix(config): kill provider-command process tree via job object on Windows
taskkill /T walks the process tree by parent PID in user space and can
miss descendants, leaving the sleeping child holding the inherited
stdout/stderr pipes so cmd.Wait() blocked until natural completion
(~10s instead of the 5s timeout).
Assign the started command to a job object with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE and terminate the job on timeout so
the kernel kills the whole tree atomically; taskkill remains as a
fallback. Also set cmd.WaitDelay so Wait can never hang on pipes held
by an escaped orphan, and add a regression assertion that the sleeping
child is actually gone after the timeout returns.
Fixes#683
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(config): harden provider-command tree termination
Address code review on PR #690: terminate the process tree when
cmd.Wait() returns exec.ErrWaitDelay (a background descendant kept
inherited pipes open, previously left running); start the Windows
process suspended and assign it to the job object before resuming its
main thread, closing the window where a fast child could escape the
job; drop JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so a successful command's
detached helpers survive Close() instead of being reaped; escape
single quotes in generated PID-file paths; and correct a misnamed
Windows exit-code constant in a comment.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(config): avoid gofmt smart-quote rewrite in doc comment
gofmt's doc-comment formatter rewrites a trailing '' into a curly
closing quote, which CI's gofmt -l check flags as unformatted. Reword
to avoid the pattern instead of embedding a raw quote pair.
* fix(config): terminate leaked descendants on nonzero exit, avoid PID reuse
Terminate the process tree on any error returned from cmd.Wait, not only
exec.ErrWaitDelay. Go only returns ErrWaitDelay when the shell itself
exited successfully; a nonzero exit (e.g. sleep 600 & exit 7) yields the
bare *ExitError instead, so a detached background child that held the
inherited pipes open was never terminated on that path.
Also stop falling through to the taskkill/PID fallback once a job handle
successfully contained the tree: by the time Terminate runs on the
ErrWaitDelay path, cmd.Wait has already reaped the root process, so its
PID may have been reused by an unrelated process, and taskkill /T /PID
against a reused PID would force-kill the wrong tree.
Addresses PR #690 review feedback.
* fix(config): surface suspended-thread resume failures instead of hanging
resumeMainThread now reports whether it actually resumed at least one
thread. If Toolhelp/OpenThread/ResumeThread all fail, the launched
process would otherwise sit suspended forever, never exit on its own,
and get misreported as a plain 5s provider-command timeout instead of
the real resume failure. attachCommandProcess now terminates the
process immediately in that case.
Also documents the known, non-regressive limitation that the taskkill
fallback (used only when job creation/assignment itself fails) can
still miss a descendant that has reparented away from the tree it
walks by PID.
Addresses remaining PR #690 review feedback; PR description corrected
separately to drop the outdated JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE claim.
* fix(config): guard taskkill with retained process identity
* fix(config): retain POSIX process group identity
* test(tui): match provider wizard shortcut
* test(tui): pin credential-step env for deterministic footer assertion
The credential step's footer only shows the /→ shortcut once a key
is present, whether typed or inherited from the provider's
AuthEnvVars. The previous commit changed the assertion to
"Enter/→ continue" without accounting for that, so the test only
passed by coincidence when ANTHROPIC_API_KEY happened to be set in the
environment — failing deterministically on a clean CI runner (as
smoke reported on all three OSes). Pin the env var empty and assert
the no-key footer text so the test is correct regardless of ambient
environment.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(config): preserve provider process attributes
* test(config): keep SysProcAttr coverage portable
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* fix(tui): disable bubbletea hard scroll optimization unconditionally
Vendors a patched charm.land/bubbletea/v2 (via a local go.mod replace)
with hard scroll optimization (using terminal scroll-region sequences
to shift existing lines instead of repainting) disabled unconditionally,
instead of only on GOOS=="windows".
That GOOS check only catches a Windows terminal driving the program
directly. It misses a Linux process running inside a multipass VM
whose output is ultimately displayed by Windows Terminal over a
remote shell, which corrupts rendering badly (garbled/overlapping
text, worse on resize) the same way the check exists to prevent.
There's no reliable way to detect that remote hop from inside the VM,
so scroll optimization is now off in all cases.
* fix(tui): force redraw on streamed newlines to clear stale caret
The streaming caret (appendStreamingCursor) is appended to whichever
visual line is currently last. Over a remote terminal chain
(multipass VM output displayed by Windows Terminal), the renderer's
incremental cell diff sometimes fails to clear the caret's old cell
when a newline moves it to a new line, leaving ghost carets behind.
Fire tea.ClearScreen when a streamed delta contains a newline: that's
exactly the moment the caret moves and the risk exists, so it forces
a full redraw right then instead of leaving it to the incremental
diff, without doing so on every animation tick.
* chore(tui): trim upstream repo metadata from vendored bubbletea fork
The vendored bubbletea-v2-patched module is ~99% byte-identical to
upstream charm.land/bubbletea/v2 v2.0.8; only cursed_renderer.go
differs (one line disabling hard scroll optimization). The .github
workflows, README.md, UPGRADE_GUIDE_V2.md, Taskfile.yaml and CI config
files are upstream repo metadata that serve no purpose inside this
repo, so remove them to slim the PR. The Go source and testdata golden
files are kept because Go's replace directive requires the full module
to be present and buildable.
Refs #709
* fix(tui): address review round on the rendering workaround and vendored fork
Fork correctness fixes (all flagged by review):
- key.go: KeyMediaRecord repeated the previous const expression and
decoded record presses as previous-track; map it to uv.KeyMediaRecord.
- mouse.go: MouseButton11 likewise collapsed onto MouseButton10.
- termios_unix.go: BSDLY/BS0 are output-delay bits; read them from Oflag
instead of Lflag so useBackspace is set correctly.
- cursed_renderer.go: count wrapped rows with (lineWidth-1)/w so an
exact terminal-width multiple no longer inserts a blank line; compare
colors by normalized RGBA via a nil-safe helper instead of interface
equality (which panics on non-comparable implementations); and make
clearScreen invalidate lastView so the queued erase flushes with the
next frame instead of deferring until an unrelated view change — the
reordering that could leave the ghost caret in place.
- exec_test.go: implement reset() on the spy renderer and inject it
before Run; assert exec preserves renderer state (releaseTerminal
with reset=false).
- logging_test.go: restore the default logger's output/prefix/flags and
fail fast on setup errors.
- tea_test.go: tie the Quit-pumping goroutine to a channel closed when
Run returns instead of leaking it until process exit.
TUI scoping (review P2): the forced redraw on streamed newlines is now
rate-limited to ~10/s so heavy streaming output no longer turns every
newline into a full-screen repaint, and ZERO_NO_STREAM_CLEAR=1 opts a
correctly-rendering terminal back into the fast incremental path.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(tui): guarantee deferred stream-clear redraw and other review fixes
A throttled stream-clear could drop the redraw entirely when a
newline arrived in the last delta of a turn, leaving a ghost caret on
screen. Add a one-shot deferred flush timer plus a flush on stream
end. Fix a stale comment and a test that leaked global logger state
in the vendored bubbletea fork.
* fix(tui): use m.now for stream-clear throttle and harden tests
Drive stream-clear rate limiting through the mockable clock and a
shared streamClearThrottle constant so both the agentTextMsg and
flush paths stay in sync. Add an explicit two-newline-within-window
regression and detect ClearScreen by function pointer so tests do not
evaluate Tick side effects.
* fix(tui): relocate bubbletea fork out of third_party
Move the maintained bubbletea/v2 hard-scroll patch from
third_party/bubbletea-v2-patched to patches/bubbletea-v2 and update the
root go.mod replace. third_party/ is immutable vendored content per
AGENTS.md; a maintained fork belongs outside that tree.
* fix(ci,docs): run vendored bubbletea fork tests and document unsupported go install path
patches/bubbletea-v2 is its own Go module so `go test ./...` from the repo
root never touched its 7 test files. Add a CI step (and a make test-patches
target for local use) that runs the fork's own suite across the smoke
matrix.
The root go.mod's local `replace charm.land/bubbletea/v2 =>
./patches/bubbletea-v2` also breaks `go install
github.com/Gitlawb/zero/cmd/zero@<version>`, since a versioned install can't
resolve a replace pointing at a filesystem path. Document that path as
unsupported in README.md and docs/INSTALL.md and point at git clone + go
build instead, per jatmn's review findings on PR #709.
* ci: run vendored bubbletea fork tests with -race
CodeRabbit review on this PR. The fork exists specifically to patch a
concurrency bug (charmbracelet/bubbletea#1690), so its regression test
needs the race detector to actually catch a reintroduced race instead
of just passing on a benign-looking run.
* fix(tui): drop vendored bubbletea fork; keep stream-clear workaround
Maintainers asked not to ship an in-tree bubbletea replace: it breaks
versioned go install and is a large maintenance surface for a narrow
multipass/Windows Terminal scroll bug. Remove patches/bubbletea-v2 and
the replace directive; keep the model.go full-redraw-on-streamed-newline
workaround (ZERO_NO_STREAM_CLEAR to opt out). Restore normal install docs.
Refs Gitlawb/zero#709
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* fix(cron): reserve job IDs atomically
* fix(cron): hold the per-ID lock from reservation through persistence
Address code review on PR #686: reserveID made the timestamp directory
visible via os.Mkdir before metadata.json existed, but Remove (which
takes the per-ID lock before deleting) never coordinated with that
window. A concurrent `zero cron rm <id>` could delete the bare
reservation and report success right after Add reserved it; writeJob's
MkdirAll would then silently resurrect the directory and persist the
job anyway. A second Add could also re-reserve the same id while the
first writer was still running, putting two writers on the same
metadata path.
reserveID now acquires the same per-ID lock (used by Get/Update/
Mutate/Remove/AppendRun) for each candidate id before attempting
os.Mkdir, and returns it still held; Add releases it only after
writeReservedJob commits (or tears down) the reservation. A concurrent
Remove for that id now blocks until the reservation is resolved one
way or the other, and a concurrent Add attempting the same candidate
id serializes through the lock rather than only racing the Mkdir.
Adds two regression tests: one proving Remove waits for and removes
the committed job rather than the bare reservation, and one proving a
second reserveID call for the same candidate id blocks on the shared
lock and moves on to the next candidate.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
* feat(plugins): add zero plugins info command
Add plugins info to inspect manifest state, extension counts, and
lockfile source/hash with optional hash drift detection.
* fix(plugins): derive lock dir from plugin install path for info
Remove LockDir from InfoOptions and read the lockfile from
filepath.Dir(plugin.PluginDir) so lock metadata matches the resolved
plugin location. Consolidate duplicate lock hash output lines.
* Add shell completion generation
Add zero completions for bash, zsh, fish, PowerShell, and Elvish using one shared command tree that covers aliases, nested commands, root flags, and common exec flags.
Validate missing and unsupported shell arguments as usage errors and document safe installation examples in command help.
Refs #499
* Complete shell target suggestions
Expose every supported shell as a completions subcommand candidate and document Elvish activation.
Refs #499
* Fix Elvish completion syntax
Close generated Elvish conditional branches, use value equality, and validate generated script structure plus native Bash and Zsh syntax.
Refs #499
* feat(sandbox): unify command execution and enforcement
* test(sandbox): make platform contracts portable
* fix(sandbox): close execution lifecycle races
Bound retained interactive output, preserve interruption state, and start workspace observation before process launch.
Protect active runtime roots with cross-platform leases, capture transient protected-path creation events, and restore process attributes after PTY fallback.
* fix(sandbox): restore strict macOS isolation
Remove Seatbelt host-local network allowances after native validation showed localhost filters can reach host interfaces.
Track process reap completion so retained sessions cannot signal a stale numeric PID.
Closes#721.
`zero providers use <name>` wrote activeProvider to config.json and reported
"Active provider set to <name>" even when ZERO_PROVIDER was set, which applyEnv
makes win over config.json unconditionally. So the switch was reported as a
success while `providers current` still showed the env-pinned provider, a silent
no-op.
Detect the override and say so: the text path prints a note to stderr naming the
env var and the provider that stays active, and the --json path adds
effectiveProvider / overriddenByEnv fields so a machine consumer is not misled
either. The config write still lands unchanged; only the reporting is honest.
The env read is injected via a new appDeps.getenv (os.Getenv in production, left
nil by fillAppDeps like exportActiveProvider) so tests stay hermetic against an
ambient ZERO_PROVIDER. The related env-derived-profile "not found" case the issue
also mentions is already handled by the unpersisted-provider path (#707) and #716.
* fix(lsp): make the real-gopls check opt-in so a broken gopls can't fail the suite
Closes#684.
TestManagerCheckRealGopls skipped only when gopls was absent from PATH, so an
installed-but-unhealthy gopls (e.g. an unusable persistent-index cache that makes
the server exit during initialization with EOF) failed `go test ./...` for every
developer who merely had gopls on PATH.
Gate it behind ZERO_GOPLS_INTEGRATION, matching the repo's other real-server
opt-ins (internal/dictation smoke tests). The default hermetic suite keeps its
coverage on the fake server and never touches the developer's global gopls; the
real check still runs on demand with ZERO_GOPLS_INTEGRATION=1.
* test(lsp): require ZERO_GOPLS_INTEGRATION=1 exactly, so =0 keeps the check off
Closes#728.
The Windows ACL setup resolved each target's pathname independently across
os.Stat, GetNamedSecurityInfo, and SetNamedSecurityInfo (and again on rollback),
so during elevated setup a lower-privileged local user could swap a target for a
symlink/junction between operations and redirect the ACL change onto a system
object the process never validated. That is a TOCTOU across a privilege boundary.
Open each target once with a no-follow handle (FILE_FLAG_OPEN_REPARSE_POINT plus
FILE_FLAG_BACKUP_SEMANTICS, READ_CONTROL|WRITE_DAC) and drive both the read
(GetSecurityInfo) and the write (SetSecurityInfo) through that one handle, so they
hit the same kernel object. isDir is read from the same handle rather than a
separate Stat. A target that resolves to a reparse point is refused outright,
since a setup target that is a symlink/junction during elevated setup is the
swap-attack signature. Rollback re-opens no-follow rather than restoring by
pathname; the residual window is small because the target is ACL-restricted by
then, and one caller discards the rollback closure so handles are not retained for
the whole sandbox lifetime.
Tests (windows-tagged, no elevation needed): the handle-based apply plus rollback
round-trip on a user-owned temp dir, the materialize-and-remove path, reparse
rejection via a junction (needs no admin, so it runs in CI), and the not-exist
mapping. The full privilege-boundary race can't be reproduced in CI, so the guard
is verified structurally (one handle for read and write) and by junction rejection.
Documents expectations for keeping contribution flow sustainable during the
stabilization phase: one issue per bug (no bundled multi-finding audits), keep
only a few items open at a time, and hold AI-assisted contributions to the same
per-item verification bar. Complements the existing issue-approved gate, which
already covers PR-to-issue linkage and scope; the gap was submission volume.
* feat(tui): let the permission prompt take free-text feedback inline
"No, and tell Zero what to do differently" now opens an inline input on the
permission card instead of silently cancelling: type an instruction, Enter sends
it, Esc returns to the option list. The text is delivered as a Deny decision whose
Reason is the typed instruction, so the agent surfaces it via deniedPermissionResult
as the tool result and the model adjusts in the SAME turn — where the old cancel
returned an error and aborted the run. Empty input + Enter falls back to a plain
cancel (the prior meaning).
No agent-side change: PermissionDecision.Reason already carries free text to the
model. The UI mirrors the ask_user "type your own answer" pattern — the feedback
field reuses the shared composer input and the card renders it in the transcript
body.
Also: a focused permission prompt now suppresses the composer (and idle
hints/plan panel), keeping only the status line, exactly like the ask_user modal.
Its keys are inert anyway, so a "describe a task for zero…" box below the card was
misleading — and in feedback mode the shared input would otherwise echo in two
places.
Tests cover open / submit-as-Deny-with-text / empty-cancels / Esc-returns, the
composer suppression in both modes, and that the feedback text renders exactly
once.
* fix(tui): preserve the composer draft across permission feedback (#780 review)
anand + CodeRabbit (Major): entering feedback mode cleared the shared composer
input via SetValue(""), and neither submit nor cancel restored it — so a user
with a half-typed or queued next-turn message who chose "tell Zero what to do
differently" lost that draft.
Snapshot the composer value into pendingPermissionPrompt.savedDraft when feedback
mode opens, and restore it on BOTH exits (submit and Esc-cancel). Only the
feedback text is consumed, delivered via the decision Reason. Adds regression
tests for both paths; mutation-verified against the SetValue("") regression.
* fix(tui): guard permission-feedback backspace and clicks (#780 review)
Vasanthdev2004 review, all on the permission path:
1. Real bug: Backspace on an empty feedback field dropped a staged attachment.
The keyBackspace case runs before the typing branch and, on an empty composer
(feedback mode clears it), fell to removeLastAttachment — silently dropping a
staged image/doc that savedDraft does not restore. Route Backspace to the
shared input while typing so it is a plain edit of the feedback text.
2. Defence-in-depth: the mouse click resolver in handleTranscriptSelectionMouse
resolved a clicked permission option (Allow included) with no typing check. It
is safe today only because renderFocusedPermissionPrompt returns nil clickable
offsets in feedback mode — a single early-return in a function other PRs also
edit. Add an explicit !typing guard on the click path so the safety is stated,
not emergent.
3. Test hardening (also raised by CodeRabbit): assert len(got) == 1 before
indexing got[0] in the draft-preservation test, so a regression fails cleanly
instead of panicking.
Tests: TestPermissionFeedbackBackspaceKeepsStagedAttachment (mutation-verified
against the missing guard) and TestPermissionFeedbackRendersNoClickableOptionsWhileTyping.
---------
Co-authored-by: KRATOS <[email protected]>
* fix(tools): read_file recovers a backwards line range instead of erroring
renderReadFileRange hard-failed when end_line < start_line, costing the caller a
whole retry for an arithmetic slip. It was also inconsistent: start_line past EOF
returns a friendly note and end_line past EOF is auto-clamped to the last line —
only a backwards range hard-errored.
Recover it the same way: clamp end_line UP to start_line (read just that line) and
surface the adjustment in the output so it is never silent. Valid ranges
(end_line >= start_line) are byte-for-byte unchanged.
The change is in the argument-validation phase, before any file I/O, so there is
no runtime cost; if anything it saves the agent a wasted retry turn.
Tests: TestReadFileToolRecoversBackwardsRange (recovery + note + reads only
start_line, mutation-verified against the old hard error) and
TestReadFileToolValidRangeUnchangedByRecovery (valid ranges carry no note and read
the identical window).
* test(tools): assert full valid-range output in read_file recovery test (#779 review)
anand + CodeRabbit: TestReadFileToolValidRangeUnchangedByRecovery claimed
byte-for-byte preservation but only checked substrings, so an altered
header/separator or a stray out-of-range line would still pass. Compare the
whole result.Output against the exact expected rendering.
---------
Co-authored-by: KRATOS <[email protected]>
The focused permission prompt was the only prompt card in the TUI that used
brand/warning colours for its interior, and both read badly on cool themes such
as dracula:
1. The selected option ("Yes, proceed") was filled with zeroTheme.badge — the
brand chip meant for short labels (" 0 ", " ASK ", " SPEC REVIEW "), a
full-brightness accent fill. Every other selectable list in the TUI (model
picker, provider list, / palette) highlights its selected row with onSel/selBg
instead. The permission card was the outlier, painting a saturated accent slab
across the row and skipping the card tint every other line composed onto.
2. The card BODY was tinted with permBg, an amber-family wash intended to signal
a warning surface. It works on the warm default palette but renders as a
brown-yellow slab over cool themes, and it made the permission card the only
prompt whose body carries a fill — ask_user, spec review and plan all use a
transparent body (lipgloss.NewStyle()).
Fix both to match the rest of the TUI: the selected row uses onSel(ink) like
every other picker, and the body drops its permBg fill for a transparent one.
Warning identity is unchanged — the amber PERMISSION badge and the amber-mixed
border still mark it as a permission gate; only the clashing interior fills go.
TestFocusedPermissionSelectedRowUsesSelectionTintNotBrandChip asserts against
palette entries (not hardcoded hex, so it survives a theme retune): the selected
row carries selBg and not the brand accent, the body carries no permBg wash, and
the PERMISSION badge keeps its amber fill so the card still reads as a warning.
Co-authored-by: KRATOS <[email protected]>
* feat(sandbox): disable the sandbox via config (#687)
Add a sandbox.enabled field (`"sandbox": {"enabled": false}`). ModeDisabled
already short-circuits the engine but had no config surface.
- SandboxConfig.Enabled *bool (pointer distinguishes an explicit false from an
omitted key).
- mergeConfig honors it (global config / CLI); applyConfiguredSandboxPolicy maps
enabled:false to ModeDisabled.
- mergeProjectConfig intentionally does NOT merge it: a cloned repo must not be
able to disable the sandbox that constrains it — same posture as
AdditionalWriteRoots and the Network tighten-only rule.
* fix(config): honor sandbox.enabled from the CLI override path (#687 review)
applyOverrides copied BlockUnixSockets/MonitorDenials but not the new Enabled
tri-state pointer, so a CLI/programmatic Overrides{Sandbox:{Enabled:&false}} was
dropped — contradicting the 'global config / CLI can disable' contract. Preserve
the pointer in applyOverrides (runs after all config sources, so an explicit
override wins) and add a resolver regression test: an explicit false override
disables, and an explicit true override wins over a lower-precedence config false.
* fix(config): keep sandbox.enabled out of the provider-command path (#687 review)
mergeConfig now copies Sandbox.Enabled, and it is shared by two callers: the
global-config merge (trusted) and the provider-command merge (not). A provider
command is an arbitrary executable named in config, and LoadProviderCommand
parses its stdout into a full FileConfig -- so one returning a valid provider
plus {"sandbox":{"enabled":false}} could disable the sandbox meant to constrain
it, despite the setting being documented as global-config/CLI only. Confirmed
reproducible in both directions before the fix.
Clear Sandbox.Enabled from the provider-command config before merging, mirroring
the carve-out mergeProjectConfig already applies for the same reason. Done at the
call site rather than inside mergeConfig because that helper must keep honouring
the setting for the trusted global-config merge.
Adds TestResolveSandboxEnabledIgnoredFromProviderCommand covering both
directions (cannot disable, cannot enable) and asserting the provider itself is
still applied, so the guard cannot be satisfied by dropping the command wholesale.
---------
Co-authored-by: KRATOS <[email protected]>
* fix(sandbox): AST second opinion for interactive-command bypasses (#473)
The interactive-command guard split shell commands with a hand-written parser
(splitShellSegments), which mis-handles unusual quoting, command substitution,
subshells, and newline separators — an interactive program hidden by one slips
through and the agent hangs until the per-command timeout.
Add astCommandFields(): mvdan.cc/sh/v3/syntax (already used by analyzer.go) to
re-extract the real simple commands, then apply the SAME per-program checks the
regex path uses (interactivePrograms + hasNonInteractiveFlag). This only ADDS
detections and classifies every program exactly as before — ssh with a trailing
command, python -c, etc. stay allowed — while catching the splitter's bypasses.
Unparseable input (Windows cmd.exe, obfuscation) yields no commands and falls
through to the regex path; the guard never hard-blocks on a parse error.
* fix(sandbox): AST pass runs the full pipeline + guards dynamic programs (#473 review)
Address review on #745:
- The AST second opinion now applies the complete detection pipeline via a new
inspectCommandFields helper (multi-word interactive segments, sh -c payload
recursion, then the per-program lookup), so parser-hidden bypasses like
{ git rebase -i HEAD~1; } and { sh -c 'vim file.txt'; } are caught, not just
bare interactive programs.
- astCommandFields now skips a CallExpr whose program word is not a static
literal (isLiteralWord), so a substitution concatenated with a literal
($(printf foo)vim -> foovim) can't fabricate an interactive program name.
Regression tests added for both boundaries. (The un-braced $(printf foo)vim
false positive is a separate, pre-existing hand-parser behavior — present on
main before this PR — and is out of scope here.)
* fix(sandbox): require literal words for the whole AST call (#473 review)
astCommandFields guarded only the program word, then ran every argument
through wordText, which keeps literal/quoted parts and silently drops
CmdSubst/ParamExp. A dynamic ARGUMENT was therefore reconstructed lossily:
`git $(printf foo)rebase -i HEAD~1` runs as `git foorebase -i` (a
non-existent, non-interactive subcommand) but collapsed to `git rebase -i`
and was blocked -- a false positive on a command the user never wrote, and
new on this branch (the base correctly reports not-interactive).
Extend the literalness guard to every word in the call: if any word carries
an expansion, skip that AST command rather than classify a reconstruction
whose runtime value is unknowable. Skipping is the safe direction -- the
hand-written passes still run first, and a miss falls through to the normal
permission prompt instead of hard-blocking.
Genuine detection is unchanged: `git rebase -i HEAD~1` and the #473 bypass
cases still classify exactly as before.
---------
Co-authored-by: KRATOS <[email protected]>
* fix(tui): label model rows by id when the description is prose
The model picker (provider wizard AND onboarding, which share displayLabel)
labelled each row with the provider's Description, falling back to the ID only
when the description was "generic". That is right when Description holds a short
display name -- "Grok 4.3" for x-ai/grok-4.3, which is what the field is for and
why the row prefers it over an ugly raw ID.
Providers do not all honour that. Aggregators commonly return a prose blurb
instead ("Fast DeepSeek model for efficient chat, coding help, and agent
loops"), which fails as a label twice: it overflows the row, and because such
blurbs are written per model FAMILY rather than per model, several DIFFERENT
models collapse to byte-identical rows. Observed in a live picker with two
identical "Open-weight GPT model for self-hosted reasoning..." rows and two
identical "Kimi multimodal agent model..." rows -- the distinguishing ID was
visible only in the detail line of whichever row happened to be highlighted, so
the list could not be used to pick between them.
providerWizardGenericModelDescription cannot catch these: it rejects vague
descriptions ("live model", "catalog default"), whereas these are specific, just
shared and sentence-shaped. So gate on shape instead -- a display name is short
and few-worded; anything longer is prose and the ID (unique by construction)
makes the better label. Friendly names are still preferred where providers
supply them, so the existing behaviour is unchanged for well-behaved catalogs.
* fix(tui): keep the sidebar open under the / command palette
sidebarAvailable grouped suggestionsActive() with the full-screen overlays
(setup, wizards, pickers), whose stated rationale is that they "take over the
chat column and render at full width". That is not true of the `/` palette:
suggestionOverlay draws it through centerRenderedBlock at
minInt(width, suggestionPaletteMaxWidth), a centred box capped at 76 cells
floating over the chat column.
Treating it as full-screen collapsed the whole layout on every `/`: the sidebar
disappeared, the plan panel dropped out of it and re-rendered inline above the
composer, and the transcript re-wrapped to full width -- a jarring reflow for a
popup, and worst mid-run with a live plan on screen.
It cannot overlap the sidebar either: twoColumnTranscriptView renders the
overlay with width = chatColumnWidth, so the palette is capped at
min(chatColumn, 76) and centred inside the chat column. Mouse hit-testing is
unaffected because sidebarLineAtMouse carries its own suggestionsActive() guard,
so clicks still reach the palette rather than the sidebar rows beneath it.
The genuinely full-width overlays keep suppressing the sidebar, pinned by the
new test alongside the palette case.
---------
Co-authored-by: KRATOS <[email protected]>
* fix(tools): give write_stdin's invalid-session errors the same recovery guidance
Follow-up to #749 (write_stdin session-id probing) / #702.
write_stdin had three "no valid live session" error paths with three different
messages and signatures: a missing session_id returned "session_id is required",
a zero/negative/non-integer one returned "session_id must be at least 1", and
only a valid-but-unknown id got the rich UnknownExecSessionError recovery guidance
("do not guess or probe; start a session with exec_command, or use edit_file/
write_file"). So a model that called write_stdin with session_id 0 (the common
"I have no id" placeholder) got a terse error with no way forward, and naming the
minimum nudged it toward trying 1, 2, 3... the exact id-probing #749 suppresses.
The three signatures also meant a model moving 0 to 1 to 2 reset the
repeated-failure streak on each class of mistake.
Route the missing / non-integer / <1 cases through the same UnknownExecSessionError
message. Now every "no live session" mistake gives the same recovery guidance and,
because that message is id-invariant, shares one repeated-failure signature, so any
mix of missing/zero/probing accumulates toward the halt.
Updates TestWriteStdinRequiresPositiveSessionID to cover missing/zero/negative/
non-integer and assert the recovery message. Guardrail signature/halt tests
unchanged.
* test(tools): cover the explicit session_id: nil case for write_stdin
The ambient ZERO_SANDBOXED=1 + ZERO_SANDBOX_BACKEND markers are user-controlled
at an unsandboxed process boundary. Policy could auto-allow an ordinary shell
command (backend reports a native wrapping sandbox) while the runner, seeing the
same markers, returned an unwrapped pass-through plan — the command then ran
directly on the host with no isolation and no prompt.
shellSandboxActive now mirrors the runner: when IsAlreadySandboxed() (the markers
are present, so wrapping is skipped) the sandbox is not the boundary for this
command, so it reports inactive and the shell command takes the normal approval
path instead of auto-allowing. Immediate mitigation per the issue; authenticated
launch provenance is the longer-term fix.
Co-authored-by: KRATOS <[email protected]>
* fix(perfbench): grant write tools so mutating tasks measure real edits
The turn benchmark invoked `zero exec` in its default read-only posture,
which exposes no write or shell tools (no edit_file/apply_patch/
write_file/exec_command/bash). So the mutating classes (edit/fix/
refactor) could never apply a change: a run either ground to the turn
ceiling hunting for an edit tool or reported a no-tool blocker, and the
only tasks that 'passed' were ones whose oracle grep matched the stamped
.zero-answer.txt narration rather than a real edit.
This invalidated every mutating-class number in both baselines. A live
trace of fix-07 (a one-line admin->user prefix change) showed the model
calling only read_file/grep/lsp_navigate/update_plan and then stating it
had no file-writing tool. Adding --skip-permissions-unsafe exposes the
full tool set; each task already runs in an isolated throwaway fixture
copy, so there is nothing to protect. With the flag, edit-01/fix-01/
fix-07/refactor-01 go from failing (or false-passing) to 4/4 real
passes, and tool_execution rises from ~0.4% to ~29% of wall as the write
tools are actually exercised.
Adds TestBuildTurnExecArgsGrantsWriteTools pinning the flag as a
correctness contract.
* fix(perfbench): use --auto member and let the oracle judge incomplete exits
Reviewer feedback on the write-tools grant: --skip-permissions-unsafe is
broader than the benchmark needs. It also drops the workspace, network,
and destructive-command guards and auto-retries sandbox-blocked commands
unsandboxed. Switch to --auto member, which grants exactly the write +
sandboxed-shell tools the mutating classes need while keeping those
safeguards.
member-auto's shell is sandboxed, though, so on a host without sandbox
setup the agent's own self-verification (go test/build) can't run and the
turn exits INCOMPLETE even after a correct edit. The runner used to treat
any nonzero exit as failure before consulting the oracle, which would
false-fail those correct edits. Make the oracle authoritative for
oracle-bearing tasks: the stamped test/grep against the actual fixture
files is ground truth, so a correct edit passes regardless of the exit
code. Latency-only tasks have no oracle, so a nonzero exit still fails
them.
Verified with a 4-task glm-5.2 smoke (edit-01/fix-01/fix-07/refactor-01):
4/4 real passes under --auto member. Updates the exec-args test to assert
--auto member is present and --skip-permissions-unsafe is absent, and adds
TestOracleAuthoritativeOnIncompleteExit and
TestNonzeroExitStillFailsLatencyOnly.
* fix(perfbench): reject fixtureless tasks so the write-tools grant can't escape isolation
CodeRabbit and gnanam both flagged the same structural gap: buildTurnExecArgs
grants the write + sandboxed-shell tools to every invocation, but the fixture
isolation was conditional (a task with no workspaceFixture ran in the caller's
cwd). So the grant and the guard could drift apart, and a fixtureless task would
run an agent with write/shell tools in the caller's real repo dir.
Make the isolation a checked precondition: NewTurnExecRunner now rejects a task
with no workspaceFixture before launching zero exec, failing loudly rather than
silently running in cwd. Every shipped task already declares a fixture, so this
only fires on a malformed or newly added one.
Adds TestTurnRunnerRejectsFixturelessTask (rejection is pre-launch: it returns
the fixture error even with a nonexistent binary).
* fix(perfbench): defer to the oracle only for INCOMPLETE (exit 4), not every nonzero exit
anand's follow-up: the previous commit cleared VerifyErr for any nonzero
run_end whenever a VerificationCommand was present, which would also convert
a crash (1), usage error (2), provider failure (3), or interruption (130)
into a passing result whenever a partial edit happened to satisfy the oracle.
Gate the oracle override on exitCode == exitIncomplete (4): the completion
gate's "work maybe done but not signaled" case, the only one where a correct
edit can legitimately exit nonzero (its sandboxed self-verify couldn't run
under --auto member). Every other nonzero exit stays authoritative, so a
partial edit can't launder a crashed or interrupted run into a pass.
The 4-task glm-5.2 smoke stays 4/4 (those tasks exit INCOMPLETE, not some
other code). Adds TestNonIncompleteExitStaysAuthoritative (a correct edit that
reports exit 1 must still fail) and mirrors internal/cli.exitIncomplete as a
local execExitIncomplete constant.
* test(perfbench): pin the exit-4 oracle deferral from both sides; drop dead fixture branch
Follow-ups from a self-review of the exit-4 gating:
- Add TestIncompleteExitStillFailsWhenOracleFails: an INCOMPLETE (exit 4) run
that did NO work must still FAIL, because deferring to the oracle still means
the oracle is consulted. The sibling TestOracleAuthoritativeOnIncompleteExit
applies a correct edit and would pass either way, so without this a regression
that turned exit 4 into a blanket pass would go unnoticed.
- Broaden TestNonIncompleteExitStaysAuthoritative to a table over exit 1/3/130
(crash/provider/interrupt), not just 1, so every named launder-risk code is
covered.
- Tighten the NewTurnExecRunner doc: the verdict gate is the VerificationCommand
(which runs the stamped OracleTest via go test), not OracleTest on its own.
- Drop the now-dead "if WorkspaceFixture != empty" branch: the fixtureless guard
added earlier already guarantees it is set.
No behavior change; the 4-task glm-5.2 smoke stays 4/4.
* feat(tui): add /undo as an alias for /rewind (#698)
/undo was unregistered and fell through to commandUnknown. Add it to the
/rewind command's aliases (the same mechanism /quit, /find, /mcp-status use), so
it dispatches to the existing rewind handler with args intact.
* test(tui): cover the /undo numeric-sequence form (#698 review)
Add a parseCommand("/undo 123") assertion so both the latest and numeric
<sequence> forms of the /rewind contract are exercised through the alias.
---------
Co-authored-by: KRATOS <[email protected]>
Token exchange/refresh and the device authorization/poll POSTs validated only
the initial endpoint, then let the http.Client follow a 307/308 that replays the
form body (code, PKCE verifier, refresh_token, client_secret) to an unvalidated
origin.
Add withoutRedirects(): a shallow client copy whose CheckRedirect refuses every
redirect (ErrUnsafeRedirect). Applied at PostToken (covers ExchangeCode, Refresh,
and MCP which delegate to it), RequestDeviceCode, and pollDeviceOnce. The caller's
client is never mutated; legitimate token endpoints that return 200 are unaffected.
Co-authored-by: KRATOS <[email protected]>
* docs(readme): note govulncheck/golangci-lint install to GOPATH/bin
The installed binaries land in $GOPATH/bin (default ~/go/bin), which must
be on PATH to run govulncheck directly. Document the export snippet so the
lint/security tooling from the contributor setup actually resolves.
* docs(readme): say "them" instead of singling out govulncheck
golangci-lint installs to the same $GOPATH/bin as govulncheck, so the
PATH note applies to both, not just the one named.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* docs(readme): honor GOBIN and make the PATH snippet idempotent
go install writes to GOBIN when set, with GOPATH/bin as the fallback, and
rerunning the old snippet appended the same directory repeatedly.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
* fix(tui): cache settled alt-screen transcript
* fix(tui): invalidate settled alt-screen cache on in-place status row updates
setDoctorStatusRow, setSandboxSetupStatusRow, and setCompactStatusRow all
mutate an already-flushed transcript row in place (m.transcript[i] = row)
without invalidating the alt-screen settled cache. Since none of frontier,
width, or row count change on such an update, settleTranscript's rebuild
guard never fires and the View fast path serves the stale cached snapshot -
e.g. the doctor connectivity spinner freezes on its first frame.
Zero altScreenSettledWidth on the in-place path, mirroring the existing
pattern in toggleTranscriptRow, so the next settle rebuilds the cache.
Addresses PR #647 review feedback from Vasanthdev2004.
* fix(tui): invalidate settled transcript cache
* fix(tui): invalidate settled file selection cards
A model that calls write_stdin with no live session probed sequential
session_ids (1, 2, 3, …), each failing, and the run ground on for a long
time before halting. The repeated-failure guard keys on a normalized,
80-char-truncated signature of the tool error, and the old message
"Unknown exec session_id %d" put the varying id inside that prefix, so
every probe produced a fresh signature and the 6x-same-error halt never
fired.
- Rewrite the unknown-session error so stable recovery guidance leads and
the id trails past the signature window: probing now collapses to one
signature and trips the halt, and the model is told how to recover
(start an exec_command, or use the file-edit tools).
- Pin session_id's schema Minimum to 1 so the advertised schema matches
the runtime floor (intArg min 1).
- system_prompt: state that session_id is only ever an id returned by a
still-running exec_command and must never be guessed or probed.
Tests pin the id-invariance of the signature and that distinct-id probing
now halts at the guard's stop threshold, plus the schema floor and the
recovery text. The TUI "Sent input" label on a failed write_stdin
(issue criterion 4) is a separate cosmetic concern in a shared hot path
and is left as a follow-up.
* Add transcript-grounded structured task state
Project existing plan, tool-result, changed-file, verification, and completion events into a deterministic per-run snapshot.
Emit content-free aggregate snapshots to traces. Carry bounded objective context through compaction and ground completion checks only when the projection matches the transcript; mismatches retain transcript-derived behavior and drop stale carried state.
Tested: make fmt-check
Tested: go vet ./...
Tested: go test ./...
Tested: go test -race ./internal/agent ./internal/trace
Tested: go run ./cmd/zero-release build
Tested: go run ./cmd/zero-release smoke
Tested: govulncheck ./... (no vulnerabilities found)
Advisory: pinned golangci-lint reports only unrelated pre-existing findings
Tested: git diff HEAD --check
* Preserve task objective across repeated compaction
Keep the immutable run objective when plan calls have been elided, while admitting mutable task fields only when plan parity still matches the transcript.
Make plan parsing match update_plan rejection semantics, coalesce and cap task-state trace events, rename parity to its plan-only scope, use explicit nilable dependencies, and remove confirmable objective fingerprints from traces.
Tested: make fmt-check
Tested: go vet ./...
Tested: go test ./...
Tested: go test -race ./internal/agent ./internal/trace
Tested: go run ./cmd/zero-release build
Tested: go run ./cmd/zero-release smoke
Tested: govulncheck ./... (no vulnerabilities found)
Advisory: pinned golangci-lint reports only unrelated pre-existing findings
Tested: git diff HEAD --check
* feat(tui): add isolated btw conversations
Add /btw with inline-question support, isolated non-resumable session forks, background main-run routing, and safe return behavior.
Refs #637
* test(tui): cover btw return controls
Preserve drafts when Ctrl+C is pressed during an active BTW run and cover returning to the parent through the documented /btw toggle.
* Address BTW isolation review feedback
* Reject explicit resumes of side sessions
* Harden BTW isolation lifecycle
* perf(agent): preserve prompt cache prefixes
Build the system prompt once per run, fingerprint the exact emitted prompt and ordered tool definitions, and expose the full system-prompt hash in traces.
Add normalized and serialized two-turn regressions covering append-only messages, stable tools, and a stable session cache key.
* fix(tools): stabilize registry iteration order
Return registered tools in name order so provider-visible tool lists and prompt-prefix fingerprints cannot drift with Go map iteration. Add a regression covering the registry ordering contract.
Discovery metadata was merged without the https/loopback endpoint rule that
configured endpoints already pass, letting a malicious issuer downgrade the
authorization/token/device/registration endpoint after config validation.
- Add exported ValidateEndpointURL as the single endpoint-safety rule;
validateTokenEndpoint now delegates to it.
- Backstop in BuildAuthorizationURL (the choke point both the provider and MCP
flows build their browser URL through).
- Validate each discovered endpoint before merge in the provider resolveEndpoints
and validate resolved MCP metadata in both branches; fail closed on insecure.
Co-authored-by: KRATOS <[email protected]>
* perf(agent): execution profiles, --exec-profile, bench passthrough, TUI /profile (PR10b+10c)
* fix(execprofile): harden profiles against review findings
- fast escalates on critical-risk mutations, not high: the sandbox marks
every shell command high before command analysis, so a high trigger
spent the one-shot on the first go test of any task
- escalation can now restore the provider-default effort a profile
displaced (PostureEscalation.RestoreDefaultEffort; a plain empty
target means leave untouched)
- TUI /turns while a profile is active pins the budget: disarms the
escalation turn target (mirroring exec's explicit --max-turns rule)
and profile reverts use touched bits instead of value equality, so
explicit choices survive even when they coincide with profile values
- spec-draft runs get the same escalation policy as the main run
instead of the displaced budget with no safety net
- bench validates and canonicalizes --exec-profile before spawning:
an unknown name previously produced an exit-0 report full of
millisecond usage-error walls
- turn summary prints exec-profile alongside mode; zero exec rejects
an inline-empty --exec-profile=; balanced trace label documented and
pinned as deliberate attribution
* fix(execprofile): explicit effort choices disarm the escalation effort restore
An explicit effort set AFTER profile selection could be cleared by a
mid-run escalation (the effort analog of the /turns pin, introduced by
RestoreDefaultEffort): TUI /effort and Ctrl+T now disarm the restore on
a policy clone, and the spec-draft path only arms it when no explicit
--spec-reasoning-effort governs the draft.
* fix(execprofile): address review findings on the profile surfaces
- --use-spec projects away a profile's self-correct knob instead of
silently arming an option the draft runner ignores (the explicit
--self-correct --use-spec combination is already a parse-time error)
- RunTurnBench canonicalizes and validates ExecProfile at the library
boundary, so a direct caller's casing/whitespace cannot fork report
labels and an unknown name fails before anything runs
- reverting a profile whose displaced budget was zero clears
ZERO_MAX_TURNS (SetMaxTurnsEnv ignores zero, so sub-agents would have
kept the removed profile's budget)
- the TUI effort-disarm test fails instead of skipping when the catalog
stops filling low effort
* fix(tui): reconcile an active profile's effort fill on model switch
The fill is per-model, not a session preference: fast selected on a
model without low fills nothing, and switching to a model that supports
it should behave like selecting the profile there; the reverse switch
must not keep sending a level the destination does not support. Both
switch paths (the /model command and the cross-provider picker) now
re-derive the fill, leaving explicitly touched efforts alone and
keeping the escalation's effort restore in step. Also: the revert-env
test asserts key removal via LookupEnv, and applyProfileTurnBudget's
pinned-budget parameter is named and documented as covering mode
presets, not just the flag.
* fix(tui): void profile effort bookkeeping when the model-switch drop erases it
The /model path's pre-existing unsupported-effort drop ran before the
profile reconciliation and left contradictory state behind: a dropped
touched override kept blocking the profile from governing, and a
dropped profile fill left a stale applied value with the effort restore
still armed. The drop itself is load-bearing (the TUI run path forwards
the effort to the provider unfiltered), so instead of preserving a
known-unsupported level, the voided choice now resets the profile's
effort bookkeeping and the reconcile re-derives from scratch. The whole
sequence lives in one ring-parameterized helper driven by the switch
target's authoritative ring, unit-tested against synthetic rings for
both the void-then-refill arc and the supported-override survival.
* fix(tui): only drop an explicit effort when the target ring is authoritative
A nil ring meant both a catalog model with no effort controls and a
live-discovered custom model the catalog cannot vouch for. The drop now
requires an authoritative ring (a resolved catalog entry, whose ring may
legitimately be empty); an unknown ring preserves an explicit preference,
matching the cross-provider picker's behavior, while the profile's own
fill still applies only where support is known.
* fix(perfbench): absolutize the bench binary and make errored tasks first-class
* fix(perfbench): state that errored tasks count as pass-rate failures
* fix(perfbench): align errored-task wording with the actual tier accounting
* chore(perfbench): never commit generated baseline reports
* fix(doctor): report missing npm native binary in wrapper
Co-authored-by: Cursor <[email protected]>
* fix(doctor): preserve JSON output for missing native binary
* fix(doctor): preserve doctor CLI behavior in wrapper fallback
Co-authored-by: Cursor <[email protected]>
* style: gofmt npmwrapper doctor fallback tests
Co-authored-by: Cursor <[email protected]>
* fix(doctor): reuse shared Bun recovery helpers in wrapper fallback
Extract bunRecoveryParagraph and related helpers so the doctor missing-binary text path and the generic wrapper bail share the same copy. Add regression tests with ZERO_WRAPPER_SIMULATE_BUN to guard against future drift.
Co-authored-by: Cursor <[email protected]>
---------
Co-authored-by: Cursor <[email protected]>
Extract completion decisions from the agent loop into a feature-gated typed policy with complete, incomplete, and uncertain outcomes. Preserve bounded plan-stall nudges and allow at most one semantic acceptance check for self-correcting runs.
Tested: make build
Tested: make test
Tested: go fmt ./...
Tested: go vet ./...
Tested: make lint
Tested: govulncheck ./...
Note: repository-wide pinned golangci-lint reports 36 pre-existing unrelated findings; internal/agent/... reports 0 issues.
Upgrade the parallel tool planner from SideEffectRead probes to the PR5
CapabilitiesOf contract: EffectReadOnly + ThreadSafe + auto-allowed, with
resource-key conflict boundaries so same-path reads stay sequential.
Mark audited pure reads ThreadSafe (read_file, read_minified_file,
list_directory, glob, grep, skill, web_fetch). Glob/grep use scopedScan
directory keys (nil for workspace-wide "." so scans do not false-conflict).
Tests cover the capability gate, key conflicts, extendParallelRun windows,
and catalog ThreadSafe audit for concurrent-safe reads.
Co-authored-by: KRATOS <[email protected]>
* perf(turn-bench): Phase 0 — strengthen oracles so pass rate can't be misread as correctness
The baseline manifest's pass/fail contract had three holes that let a weak
or no-op agent read as a pass:
- edit grep oracles were substring checks: a reworded line (e.g.
fmt.Println("debug: starting") -> fmt.Println("starting up")) passed
`! grep 'debug: starting'`.
- refactor lived in the build tier with a non-positive `go build ./...`,
so a no-op refactor passed.
- the 24 read-only tasks (nav/longproc/longctx/parallel) had no oracle;
a zero-exit only proved the turn ran.
This makes the tiers honest:
- edit/refactor now carry a stamped oracle_test.go compiled by
`go test ./...` — the Go compiler is the structural verifier, so a
no-op refactor, a missing field, or a reworded-but-not-removed line
fails to compile/run. The test is stamped into the fixture copy AFTER
the agent run, so it can't interfere with the agent's own build or be
pre-seen.
- nav gains an answer-oracle: the harness captures the agent's final
answer (.zero-answer.txt) and greps it for determinable facts. Count
tasks (nav-04/05/08) use a structured `count: N` token so the oracle is
tight rather than a loose prose-contains on "0".
- refactor graduates from build to correctness (its oracle is now
positive); nav graduates from latency-only to correctness. The build
tier is empty (buildOnlyClasses: []).
- longproc/longctx/parallel stay permanently latency-only — there is no
deterministic oracle for "report whether the build succeeds" or
"summarize a large file"; documented in MANIFEST.md.
Tier counts: correctness 34 (edit 10 + fix 8 + nav 10 + refactor 6),
build 0, latency 14. Total 48 unchanged.
TurnSchemaVersion 2 -> 3: the result shape is unchanged (only class-list
membership shifts) but correctnessPassRate now means something strictly
stronger, so the bump prevents a v2->v2 cross-version comparison from
misreading the jump as a model improvement.
Prerequisite fix: the mutating fixtures had no go.mod, so `go test ./...`
in the temp copy failed with "cannot find main module". Added go.mod per
fixture (edit/fix/refactor/nav) and moved copyFixture's temp dir one level
below the system temp root — Go ignores go.mod in direct children of the
temp dir (a hijack guard), which would have broken every compiler-backed
oracle. This also fixes the pre-existing fix/refactor oracles, which were
silently broken because the test suite used fake runners that never ran
go test.
Tests: stub-binary gating tests prove each oracle fails the wrong thing
(no-op refactor, missing field, wrong nav answer, reworded debug print) and
passes the right thing (real refactor, added field, correct answer); an
edit-01 test proves the scoped negative grep doesn't false-fail a correct
rename that leaves the doc comment. Aggregation, schema, and unit tests
cover the v3 tier model and the answer-capture helper.
* Address CodeRabbit review on PR #712: tighten oracles + isolation
- nav count oracles (nav-01/04/05/08): anchored line-exact
`^count: N$` with the fixture's exact expected count, so a substring
like "the count: 0" or a wrong count like "count: 3" fails instead
of rubber-stamping. nav-01 added to the structured-count contract.
- stampOracleAndAnswer now errors when a task has an oracle or
verification but no workspace fixture, so verification can't run in
the caller's cwd with the oracle never compiled (false-pass closed).
- copyFixture uses a unique 0700 os.MkdirTemp parent under the temp
root and nests the copy beneath it (grandchild of temp root), so the
Go go.mod hijack guard is satisfied and cleanup removes the whole
parent. Avoids a predictable shared world-readable dir.
- Rejection tests require a non-empty VerifyErr via a shared
assertVerifyFailed helper, proving the failure came from
verification rather than a harness error or a silent non-pass.
- Unify streamJSONExitCode + streamJSONFinalText behind one
parseStreamJSON parser (behavior-preserving; scanner limits and
malformed-event handling now live in one place).
* Address gnanam1990 review on PR #712: close count=0 gameability + stronger oracles
- nav count tasks (nav-04/05/08) had a count=0 ground truth, so an agent
that never opened the workspace and always emitted "count: 0" passed
three correctness tasks -- the exact pass-rate-misread-as-correctness
failure this PR closes. nav-04 now has a real TestGreet (count 1) and
nav-05 a real TODO (count 1); nav-01's file count moves to 5. nav-08
stays at 0 (a real third-party import would need a network fetch and
break the offline suite) but its oracle now requires the answer to name
both stdlib imports (fmt from main.go, testing from main_test.go), so a
blind count:0 or a main.go-only read fails. nav-08's prompt now asks the
agent to enumerate and classify every import, so naming the stdlib
imports is part of a correct answer, not a false-fail.
- refactor-05: stamp a behavioral oracle (TestGreetWrapped) so deleting
both Wrapper and greetWrapped, or stubbing the caller to return "",
fails to compile/run. The negative grep alone passed that wrong edit.
- refactor-04: replace the brittle exact-text shell grep with a stamped
file-reading oracle using a spacing-tolerant regex, so a bare
map[string]int decl under any spacing/:= variant can't dodge it, and
the Stats type must be referenced in main.go.
- edit-05: also assert the "debug: starting" message text is absent, so
a respell onto fmt.Printf / backticks / log.Println / println that
still prints the message fails (the fmt.Println(" check missed those).
- v3 doc comments on ClassSummary / TurnBenchResult /
FormatTurnBenchSummary now describe the v3 tiers (correctness =
edit/fix/nav/refactor, build empty, latency = longproc/longctx/
parallel) instead of the stale v2 model.
- stampOracleAndAnswer failure now mutates the existing outcome instead
of returning a fresh one, so the already-parsed trace and trace issue
survive a stamp error (attribution is preserved, pass/fail unchanged).
- Schema comment provenance: "PR #701" -> neutral "Phase 0 oracle
hardening" (this PR is #712).
- New gating + satisfiable stub tests for refactor-04/05 and edit-05, and
count-oracle zero-guess / inspection-proof tests for nav-04/05/08.
* Fix macOS CI: copyFixture isolation test was slash-sensitive
TestCopyFixtureIsolatesSourceFromMutation compared filepath.Dir(parent)
against os.TempDir() verbatim. On macOS $TMPDIR ends in a trailing slash
("/var/folders/.../T/") while filepath.Dir strips it, so the equality
false-failed even though the parent is genuinely a direct child of the
temp root. Linux (/tmp, no slash) and Windows (no slash in this Go
build) matched by accident.
The production copyFixture nesting is correct on all platforms (the
grandchild copy sits one level below the temp root, satisfying Go's
go.mod hijack guard); only the assertion was slash-sensitive. Normalize
both sides with filepath.Clean.
* Close nav-04/05 blind-count-one gameability (anandh8x review)
nav-04 and nav-05 had a count-only oracle (anchored '^count:1$'). Once the
ground truth became 1, an agent that never inspects the fixture and always
emits 'count: 1' passed both — the blind-count-one surface the prior
non-zero-ground-truth fix opened. Pair the count with a workspace-derived
named fact that proves inspection, mirroring nav-08's fmt+testing:
- nav-04 now also requires 'TestGreet' (the one test function in
main_test.go) — a specific, non-generic name a blind agent can't produce
without reading main.go to learn a greet function exists. Fully closes
the blind-count-one surface.
- nav-05 now also requires 'main.go' (the file holding the one TODO).
main.go is the conventional entrypoint and the most guessable filename, so
this reduces rather than fully closes the surface — a blind 'count: 1, in
main.go' can still pass. Accepted for Phase 0: the blind floor drops below
100% (a bare 'count: 1' fails), so a model still moves the pass rate by
inspecting. MANIFEST.md documents this residual honestly.
Two gating tests per task: RejectsBlindCountOne (bare 'count: 1' now fails)
and, for nav-05, RejectsSubstring (main.go present but unanchored count
still fails — parity with nav-04). Existing accept tests still pass (their
answers already name TestGreet / main.go).
Doc nits also fixed: MANIFEST.md #701 -> #712; refactor-05 removed from the
'no oracleTest' inventory (it now has TestGreetWrapped); the nav-01 named-
fact prose corrected to the three files the oracle actually anchors on;
test comment #701 -> #712.
* fix(sandbox): scrub dynamic credential env vars
* fix(sandbox): tolerate env assignments in configured sensitive keys
A config value mistakenly given as a full assignment (e.g.
"COMPANY_LLM_SECRET=...") would never match the real env key during
scrubbing, silently re-exposing the credential to sandboxed commands.
normalizeSensitiveEnvKeys now keeps only the name part before '=' and
re-trims it, per PR review.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(sandbox): scrub credentials on direct/degraded command plans
directCommandPlan (used when the native sandbox backend is unavailable,
disabled, or not required, including the EnforcementDegraded fallback)
left Env nil whenever spec.Env was nil, so exec.Cmd inherited the full
caller environment unscrubbed. Configured apiKeyEnv credentials and
dynamically named ZERO_OAUTH_<NAME>_CLIENT_SECRET variables leaked
through that path even though the wrapped sandbox plans already
scrubbed them.
Route direct plans through scrubSensitiveEnv the same way wrapped
plans do, snapshotting os.Environ() when spec.Env is nil so scrubbing
still applies. Adds a regression test covering the degraded-fallback
path with both a configured and a dynamically named secret.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
A user-level MCP server disable is now sticky. The project config
(a cloned repo's ./.zero/config.json) cannot re-enable a server the
user disabled, and cannot disable a server the user explicitly enabled.
The previous merge just assigned base.Disabled = next.Disabled, so a repo
that set disabled:true could silently defeat a user's explicit mcp enable
<server>. The guard is now symmetric via a mergeMCPServer canReenable
flag: once a higher-trust scope has explicitly set the disabled flag, a
lower-trust layer may not override that decision in either direction. Only
the CLI override scope (canReenable=true) may lift a sticky user decision.
The prior disabledSet-before-check ordering bug is also fixed: the
higher-trust scope's prior disabledSet/Disabled is captured before folding
in next's flag, so a project layer can still disable a default-enabled
server (e.g. firecrawl) rather than being silently ignored.
Ports the fix onto main's refactored MCP merge (internal/config/mcp_merge.go
and mergeProjectMCPConfig), which splits project-scope merging from the
general mergeMCPConfig path.
Fixes#512
* fix(hooks): run sessionEnd hooks after Esc/Ctrl+C interrupts
On interrupt, Run returns with ctx already canceled. The deferred
dispatchSessionEnd call passed that same ctx into Hooks.Dispatch, whose
context.WithTimeout derives an already-canceled child context, so the
hook process never actually launches (recorded as timed-out/error in
the audit log). Since most sessions end via interrupt, sessionEnd
hooks were effectively unreliable.
Detach the sessionEnd dispatch from the run context's cancellation
with context.WithoutCancel so the dispatcher's own timeout still
applies without being short-circuited by the run's cancellation.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* test(agent): fall back to GOROOT for hook regression
* test(agent): justify GOROOT fallback
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
* feat(aimlapi): AI/ML API guided onboarding (top-up + key issuance)
Add the AI/ML API provider integration to the zero CLI: a guided TUI
onboarding sub-flow that takes an existing key or an email top-up and
writes the issued key into the provider profile, attributed to the
Gitlawb rebate partner.
- internal/aimlapi: OpenAI-compatible client, partner-checkout top-up,
key validation, streaming, and endpoint config (Paths A/B/C).
- providercatalog / providermodelcatalog: register the aimlapi provider
with default headers and models.
- tui: aimlapi onboarding screen wired into the provider wizard and
onboarding flow.
Default endpoints point at production (auth/app/api/pay/aimlapi.com);
each is overridable via AIMLAPI_*_URL env vars.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(aimlapi): address review findings in onboarding flow
- topup: use errors.As for the 5xx retry check so a wrapped APIError
still retries instead of aborting a paid checkout.
- onboarding: a Path-A top-up on an email with no account now lands on a
single coherent "key saved, balance not topped up" screen instead of a
green "ready" plus a red error.
- onboarding: an empty amount field is rejected instead of silently
falling through to the $25 default.
- collapse the two identical top-up prompt constants into MsgTopUpPrompt
and drop the dead amountPrompt() branch.
- reuse stdlib maps.Clone instead of a third bespoke header-copy helper.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(aimlapi): retry transient poll errors and sharpen amount validation
* fix(aimlapi): retry transient poll errors and surface real amount errors
* docs(aimlapi): document exported client API; drop out-of-scope gitignore entry
Addresses the two CodeRabbit PR checks on #655:
- Add doc comments to the exported aimlapi client API (Client, APIError,
the session/checkout/onboarding types and their methods) and
ParseAmountUSD, to satisfy the docstring-coverage check.
- Remove the unrelated /zero.exe~ .gitignore entry that an earlier
review-fix commit added; it is out of scope for the provider work, so
the PR no longer touches .gitignore.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(aimlapi): render aimlapi row like other recommended providers
* fix(aimlapi): resolve onboarding review findings (Path A funding, base URL, request cancellation)
* fix(aimlapi): validate pasted API keys
* test(aimlapi): clarify pasted key validation
* test(aimlapi): cover key-balance low-balance transition
* fix(aimlapi): reject non-finite top-up amounts
* fix(cli): scope and canonicalize provider headers
* fix(tui): distinguish invalid verification codes
* feat(aimlapi): resume account-bound top-ups safely
* feat(tui): offer top-up for existing AIMLAPI keys
* feat(tui): reuse AIMLAPI env key on first run
* feat(tui): reuse configured AIMLAPI profiles
* test(aimlapi): trim redundant flow coverage
* fix(tui): preserve existing AIMLAPI profile metadata
* fix(tui): clone AIMLAPI headers in env fallback
* fix(aimlapi): address remaining review findings
* fix(aimlapi): harden onboarding lifecycle
* fix(aimlapi): preserve validated checkout context
* fix(aimlapi): harden checkout retry state
* fix(tui): clarify AIMLAPI configured choices
* fix(aimlapi): make checkout retries idempotent
* fix(aimlapi): validate checkout responses
* fix(providers): reconcile aimlapi catalog after rebase
---------
Co-authored-by: Lookoff123 <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
* perf(tools): add explicit tool effect metadata
Introduce fail-closed EffectClass and ToolCapabilities on every built-in
tool so a later concurrent read-only batch PR can decide safety from
explicit metadata rather than tool names. Plugin and MCP tools stay
EffectUnknown. No concurrent execution is enabled.
* fix(tools): address review feedback on effect metadata
Harden capability normalization and resource keys before PR6 consumes them:
- Force ThreadSafe=false for every non-ReadOnly effect at read time
- Reject duplicate builtin names in ValidateBuiltinCatalog
- Coerce numeric session ids and accept "session" for process keys
- Strip path/query before userinfo in endpoint host parsing
- Wire apply_patch keys from unified-diff paths + cwd
- Reclassify bash as WorkspaceWrite (one-shot shell mutator)
- Expand regression tests for keys, normalization, and gates
* fix(tools): keep multiFileResourceKeys used by apply_patch keys
Satisfies unused lint after switching apply_patch to applyPatchResourceKeys.
* fix(tools): resolve CodeRabbit residual capability/key issues
- Join apply_patch diff paths under cwd for conflict keys
- Validate constructor-declared capabilities before normalization
- Unify session/process ID key extraction helper
---------
Co-authored-by: KRATOS <[email protected]>
Compute a seven-field SHA-256 fingerprint of the cacheable prompt
prefix (base instructions, confirmation policy, project context, skills,
tools, tool schemas, complete) and emit one prefix_hash event per turn
through the trace recorder. The complete hash is the SHA-256 of the
canonical join of the other six, so any sub-component drift is
observable both individually and in aggregate.
The decomposition exists so a downstream observer can detect which
sub-component of the prefix broke cache stability across turns: a
stable complete_prefix hash means the cacheable prefix is byte-
identical, and the sub-hash that changed names the culprit.
No behavior change for untraced runs. For OpenAI, the existing
prompt_cache_key (session ID) is unchanged; the fingerprint is purely
additive observability. The Anthropic cache_control breakpoint work
already in main is not touched.
Co-authored-by: anandh8x <[email protected]>
* feat(tui): Ctrl+X leader chords and emacs menu navigation
Add Ctrl+X leader shortcuts for common slash commands (with Ctrl+X ?
chord map), and Ctrl+P/N previous/next selection in modals and pickers.
* fix(tui): drop Ctrl+X e /edit leader chord
/edit replaces the composer and would discard an in-progress draft; leave
it unbound so leader shortcuts stay draft-preserving.
* fix(tui): consume idle Ctrl+N as a no-op
Reserve Ctrl+N for emacs menu navigation so it never falls through to
remapped global bindings when no selection surface is open.
* docs: drop manual CHANGELOG entries for TUI shortcuts
* fix(tui): stop the composer cursor blinking while typing or unfocused
The composer's cursor blinked on a fixed timer unconditionally, including
mid-keystroke, which made typing feel janky since the cursor's job is to mark
the current position. Gate the existing blink tick on two states instead:
force solid while a keystroke landed within the last 500ms (reusing the
existing lastCharTime tracker), and hide it entirely while the terminal
window is unfocused (new terminalFocused field, driven by tea.FocusMsg/
BlurMsg). Idle and focused keeps the prior blink behavior unchanged.
Also decouples view.ReportFocus from the notifier (it was previously gated on
m.notifier != nil, which is effectively always true in production but left
cursor-focus behavior fragile if that ever changes) so focus reporting is
requested unconditionally.
* fix(tui): apply caret visibility synchronously and in the argument-hint line
- A keypress, focus, or blur now updates composerCursorVisible in its own
message handler instead of waiting for the next 530ms composerBlinkMsg
tick, which could render a typed character caret-less, leave a visible
caret in an unfocused terminal, or keep it hidden after a refocus.
- commandArgumentHintComposerLine takes the caret visibility state instead
of painting its cursor cell unconditionally, so the required-argument
hint path follows the same focus and blink contract as the main
composer renderer.
- New tests assert the state immediately after each message, with no blink
tick in between, and cover the argument-hint render in both states.
* fix(tui): apply the caret state on composer paste
A paste is the same immediate-input transition as a keypress: routePaste now
refreshes lastCharTime and sets the caret visible for both the bracketed
tea.PasteMsg and right-click clipboardReadMsg paths, so a paste right after
the blink phase hid the caret renders solid immediately instead of waiting
for a tick that would toggle off a stale idle state.
* draft(tui): add theme interface and Claude/Codex presets proposal
Introduces the Theme interface alongside the initial specifications and styling models for the Claude (card-based, warm) and Codex (high-density, split-screen cyberpunk) layout presets. Detailed design plans are documented in docs/THEMES.md.
* fix(tui): implement Claude/Codex themes on the existing palette system
theme.go had replaced the tuiTheme/buildTheme/palette architecture every
renderer depends on with a standalone Theme interface using the wrong
lipgloss import, breaking the build and orphaning the existing theme
registry, /theme command, and picker.
Revert theme.go to the working implementation and add claude and codex
as real palette entries in theme_palettes.go, following the file's own
extension pattern. Both pass the WCAG AA contrast and gray-ramp
invariants asserted across the whole registry, and are picked up
automatically by --theme, ZERO_THEME, /theme, and the picker.
Rewrite docs/THEMES.md to describe the actual palette-registry
architecture instead of the removed interface.
* fix(tui): rename Claude/Codex to neutral Dune/Neon, ensure WCAG AA contrast compliance, and add tests
* style(tui): gofmt theme_select_test.go (drop trailing whitespace)
The ubuntu smoke run fails on gofmt -l; this clears the only unformatted
file in the draft so CI goes green.
* test(tui): cover the CLI/env resolution path for the dune and neon presets
TestNewThemePresetsWired only checked lookupTheme and the IsDark
grouping. Add coverage for the actual --theme/ZERO_THEME precedence
path (resolveThemeMode) and confirm applyTheme swaps zeroTheme to each
preset's own palette rather than a built-in fallback.
* fix(tui): raise Dune/Neon palette contrast and fix theme docs
Darken dune's accent, blue, and red tokens so the selected-row caret,
local-model dot, and diff deletion text clear WCAG AA against selBg and
delBg, including after ANSI-256 downsampling. Also correct THEMES.md's
description of bare /theme (it opens a live-preview picker, not a
list), and add the dune/neon names to the ZERO_THEME/--theme tables in
README.md and README_ZH.md.
* fix(tui): keep Dune and Neon readable after ANSI-256 downsampling
- Dune's accent becomes #724028: still AA on selBg and panel in truecolor
(5.46:1 and 7.03:1), and its xterm-256 quantization (#444444) now clears
AA on quantized selBg at 6.47:1 where the previous #7c4712 quantized to
#875f00 at 3.81:1. Blue already survived quantization at 4.67:1.
- Neon's diff bands move to hexes that quantize to distinct green and red
xterm entries (addBg #083c10 -> #005f00, delBg #3c0810 -> #5f0000,
addBgWord #147828 -> #008700, delBgWord #74202e -> #870000) instead of
collapsing to the same grays, while keeping every truecolor invariant.
- New TestExtendedThemeANSI256Contrast quantizes the palette tokens the way
a 256-color terminal does and asserts the Dune selected-row pairs stay AA
and the Neon bands keep their green/red identity and mutual separation.
- docs/THEMES.md is now linked from the Documentation sections of both
READMEs, and the Unreleased changelog inventory says twelve themes and
names dune and neon.
* fix(tui): keep Neon diff content AA after quantization, fix error border
- faintest becomes #74c468: line numbers now quantize to #87d75f, which
clears AA on the xterm-green addBg (#005f00) where the old #58af69
quantized to 2.95:1, while staying dimmer than faint so the gray ramp
stays monotonic.
- addInk becomes #ecffdc, quantizing to #ffffd7 for 4.60:1 on the word
band's #008700 (was 4.29:1). Both rendered diff-content pairs are now
asserted in the ANSI-256 test instead of documented as omissions.
- cardErr becomes #9a4042: 3.01:1 against the panel (2.43:1 before) and
3.65:1 quantized, meeting the 3:1 non-text component threshold, with the
border surface asserted in both renderings.
- docs/THEMES.md no longer claims the extended invariants run against the
whole registry: it now says new palettes must be added to the
per-palette contrast tests or given equivalent coverage.
* feat(cli): show ZERO wordmark on --version
The TUI's empty-state ZERO ascii art gets exposed via a new tui.Wordmark
helper and printed above the version line for `zero -v`/`--version`.
Coloring only applies when stdout is a real TTY and NO_COLOR isn't set,
so redirected or piped output (scripts parsing the version) stays plain.
* fix(release): match --version output by suffix, not exact string
The release smoke check compared zero --version output to an exact
"zero <version>" string, which broke once --version started printing
the wordmark above the version line. Check for a "version: <version>"
suffix instead so the smoke check doesn't couple to the wordmark art.
* fix(cli,tui): keep --version machine-readable off-TTY, drop wordmark color
- When stdout is not a terminal, --version prints exactly "zero <version>"
again: pipes, redirects, command substitutions, and the release smoke
contract all parse that single record, so the banner is a TTY-only
affordance now.
- The TTY path keeps the banner but restores "zero <version>" as the
version record instead of "version: <version>".
- Wordmark renders uncolored: only newModel resolves --theme/ZERO_THEME,
so painting the global dark palette here was unreadable on light
terminals. The terminal's default foreground works on any background.
- smokeVersion goes back to requiring the exact "zero <version>" output
instead of the suffix match that papered over the banner.
* feat(providers): add AI/ML API preset
* fix(providers): relocate aimlapi preset and drop referral header
Addresses the two review asks on #402:
- Move the AI/ML API descriptor out of catalog slot #2 (it sat above the
first-party OpenAI/Anthropic/Google entries) down next to openrouter in
the aggregator cluster. Re-sync expectedCatalogIDs and the
TransportOpenAICompat order-list to match.
- Drop the always-on X-AIMLAPI-Partner-ID / Integration-Repo /
Integration-Version attribution headers. There's no partner arrangement
behind the preset, so a non-overridable referral tag on every user's
request isn't something to ship on a community preset. aimlapi now rides
through plain CopyHeaders like every other provider. Removes
HeadersForCatalog (it existed only for this special-case), reverts the
factory and discovery call sites to CopyHeaders, and drops the header
test. The factory/discovery tests now assert the endpoint, auth, and the
user's own custom headers survive while no attribution header is sent.
* fix(providers): address CodeRabbit review on #621
- Document AIMLAPIID for docstring coverage.
- Apply providerio.CopyHeaders for Anthropic and Google provider paths,
matching the OpenAI path introduced for aimlapi header isolation.
Co-authored-by: Cursor <[email protected]>
* fix(providers): gofmt factory.go to unblock CI smoke
Remove mixed tab/space indentation in the openai.New options block so
ubuntu-latest fmt-check passes.
---------
Co-authored-by: Dmitry Tumanov <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: KRATOS <[email protected]>
* feat(skills): discover shared ~/.agents/skills with multi-root skill loading
Add ~/.agents/skills as a read-only global discovery root after the
primary
Zero skills dir and before plugin roots. Unify runtime and CLI discovery
on
one multi-root path (DiscoveryRoots / LoadFromRoots / GlobalRoots) and
always
register the multi-root skill tool so bare runs and plugin runs share
the
same load surface.
Install/remove/lock still target only the primary Zero skills directory.
Fixes#610
* docs(skills): note lock stays on the primary write root
Document that install/remove/lock target only the primary Zero skills
directory, matching multi-root discovery write-root isolation.
* fix(skills): bubble primary root load errors from LoadFromRoots
Return non-missing failures from the first non-empty skills root so a
broken primary dir is not reported as an empty skill list. Keep fail-open
behavior for optional roots (~/.agents/skills, plugins).
* fix(skills): treat non-directory skills root as error on Windows
Windows maps ENOTDIR to ErrNotExist, so ReadDir on a regular file looked
like a missing skills dir. Reclassify existing non-directories and assert
the portable load behavior in tests.
* Add per-turn tracing and a turn-benchmark harness (Phase 0 baseline)
Introduce an opt-in `internal/trace` package that attributes a run's wall
time to named spans — prompt build, provider connect/queue, generation,
tool queue/execution, permission wait, verification, compaction,
persistence — plus counters for model requests, tool calls, retries,
reconnects, compactions, completion nudges, acceptance checks, model
switches, and input/cached/output tokens. The recorder is nil-safe (a nil
*Recorder is a no-op), so every stamp site can call it unguarded; an
untraced run is byte-identical to today.
The recorder threads into agent.Run via a new `Options.Trace` field and a
single `trace.WithContext` assignment, so the shared providerio seam
(connect around client.Do, queue around OAuth resolve, first token at the
first non-keepalive SSE payload) reaches it through `trace.FromContext`
without any provider-interface change. All instrumentation is opt-in: when
`Options.Trace` is nil the hot path is untouched.
Headless `zero exec` gains `--trace <path>` (and `ZERO_TRACE=<path>`),
writing an agenteval-contract-compatible NDJSON trace (`-` for stderr).
The trace is pure observation; enabling it does not change behavior.
A new per-turn benchmark harness extends `internal/perfbench` (reusing the
existing TaskSet/NumericStats/subprocess runner shapes): `RunTurnBench`
aggregates per-span median/P95 across iterations, ranks the top three
controllable latency sources by share of attributed time, rolls up per
task class, and totals tokens/counts. A checked-in 42-task manifest across
seven classes (nav, edit, fix, refactor, longproc, longctx, parallel) and
local offline fixtures back it, and `make baseline` runs it against the
built `zero`, writing `internal/perfbench/reports/baseline.json`. The
report is machine-specific and regenerated, not hand-edited.
The manifest is JSON rather than YAML to avoid adding a dependency;
per-task verification targets the specific test (`go test -run <name>`)
so fix tasks are independent. Each task is a fresh `zero exec` process, so
iterations are cold-start samples; a warm in-process path is left for
follow-up.
Verified: `go build ./...`, `go vet ./...`, `gofmt -l` clean on touched
files; `go test -race` green for internal/trace, internal/agent,
internal/perfbench, and cmd/zero-perf-bench.
* test(trace): make TestAttributionRatio clock-independent
The test recorded two synthetic 10ms spans with no real delay and asserted
the attribution ratio stayed <= 1.0. On a precise clock (Linux/macOS CI) the
wall is microseconds while attributed is 20ms, so attributed/wall blows past
1.0 and the test failed — even though the ratio legitimately exceeds 1.0
when spans overlap (parallel tool execution). On Windows the coarse timer
masked it, so the failure only surfaced in CI.
Assert the contract instead: attributed is the deterministic sum of span
durations, and the ratio equals attributed/wall (0 when wall is zero). Add a
zero-wall case for the divide-by-zero guard.
* Address review feedback on the per-turn tracer + benchmark harness
Benchmark runner (internal/perfbench):
- A nonzero agent exit code no longer counts as Passed. The runner set
VerifyErr but then fell through to outcome.Passed = true, so a crashed
run could be recorded as a pass. Return before the verification block
when VerifyErr is already set.
- A task now passes only when every iteration passes. The previous
"any iteration passes" rule let a flaky fail hide behind one good run.
- Each invocation copies its workspace fixture to a fresh temp dir and
runs the agent + verifier there, so mutating tasks (edit/fix/refactor)
can't dirty the checked-in fixtures or bleed into the next iteration.
- Trace load failures (missing/malformed trace file) are surfaced as
result warnings via a new TraceIssue field instead of being swallowed.
Tracer (internal/trace):
- Finish freezes the recorder: Counter, addSpan, and the first-event
stamps are dropped after Finish returns, so a snapshot captures exactly
one turn and late stamps can't mutate it.
- WriteText propagates the first write error instead of silently
swallowing sink failures.
Provider seam (internal/providers/providerio):
- FirstTokenAt is stamped only after a payload is accepted as real model
output, not before handle() decides to drop it as an error payload.
Agent loop (internal/agent):
- The compaction counter increments only when the history actually
shrinks, in both the proactive and reactive paths — paid no-ops no
longer inflate the compaction total.
- Document on Options.Trace that a fresh Recorder is required per Run.
CLI (internal/cli/exec):
- Finish the trace at the agent.Run boundary and defer only the
serialization, so the snapshot covers exactly the turn.
- Reject --trace / ZERO_TRACE for spec-draft runs with a clear error
instead of silently accepting and writing nothing.
Manifest + fixtures:
- Strengthen the edit verifiers: rename and value-bump tasks now assert
the old symbol is gone, and the getter task greps the method signature.
- Add the missing testdata/edit fixture (10 edit tasks referenced a
directory that was never committed, so the whole edit class errored
out at run time).
- The manifest test now stats each fixture so a missing dir fails at
load time instead of at every run.
- longproc benchmark keeps Process live across the loop; README code
fence gets a language tag and documents the per-invocation fixture copy.
- Add tests for the tracing wrapper (nil vs wired recorder, OnUsage
forwarding, token aggregation) and the frozen-recorder contract.
* Address review feedback: exclusive-time attribution + wired spans + fix-suite compile
Rewrite the trace attribution model so concurrent/nested spans no longer
double-count. Spans record wall intervals; the recorder derives a parent for
each by interval containment and computes exclusive time (duration minus the
union of its children). AttributedDuration sums only top-level spans; Coverage
is the union of intervals over wall, capped at 1, the honest ">=95%" metric.
Top-latency ranking uses exclusive time so shares sum to ~1 instead of being
inflated by overlapping provider_connect/generation and nested
permission_wait/tool_execution.
Wire the spans that were named but never stamped: verification around the
self-correct verify pass, and StampFirstVisibleEvent on the first forwarded
text event. Drop the tool_queue/process_wait/persistence constants that had no
stamps, and rename the misnamed near-noise prompt_build span to tool_partition.
Trim RequiredEventKeys to events a healthy short run actually emits; move
conditional phases (tools, permission, compaction, verification) to
OptionalEventKeys so a short trace does not false-fail.
Make ReadNDJSON fail loudly on a corrupt file: require a type:trace header,
error on span/counter lines that appear before it, and error when a header
yields no spans or counters.
Fix the blocking fix-suite compile error: Label's %d format verb on a string
tripped go test's vet before any fix task could verify. Make it a runtime
wrong-prefix bug ("admin-" vs "user-") that still type-checks, so each fix
task is independent again.
Manifest: correct refactor-04's stats.go to main.go, reword fix-07 to the
wrong-prefix bug, add a latency-only description (read-only tasks carry no
verification oracle by design), and frame the report README on exclusive time
and coverage.
Tests updated for the occurrence/exclusive/coverage model; trace, perfbench,
and agent green under -race.
* Fix exclusive-time round-trip, identical-interval nesting, and empty-trace parse
Three correctness bugs in the attribution model found by an adversarial review
of the prior commit, all undermining the no-double-count guarantee:
1. exclusive_ms:0 was overwritten with the inclusive Duration on re-parse. A
parent whose children tile its interval legitimately has exclusive time 0;
WriteNDJSON emits exclusive_ms:0, but ReadNDJSON's "<= 0 means missing"
fallback turned that back into the full duration, so every re-parsed trace
re-introduced the double-counting the model exists to prevent (the benchmark
harness, which ranks re-parsed traces, inherits it). Gate the fallback on
key ABSENCE, so a written 0 is preserved.
2. deriveNesting formed a 2-cycle for two spans with identical [start, end]
intervals: the symmetric containment check made each parent the other,
dropping both from top-level and zeroing AttributedDuration. Break the tie
deterministically — strict containment is an unambiguous parent; for
identical intervals, only the lower-indexed span may parent the other, so
one stays top-level.
3. ReadNDJSON returned a valid empty TurnTrace for empty/blank-only input, so a
run that crashed before emitting a trace (or where --trace was not honored)
masqueraded as a clean zero-attribution sample. Empty input now errors, so
the harness records a TraceIssue.
Also: decode counters via json.Number so int64 values above 2^53 round-trip
exactly instead of losing precision through float64; and drop the harness's
own "exclusive <= 0 -> Duration" ranking fallback, which was the same
double-count on the re-parsed path. Added regression tests covering the
zero-exclusive round-trip, the identical-interval nesting, empty-input
rejection, and counter precision.
* perf(turn-bench): split pass/fail into oracle tiers so the pass rate can't be misread as correctness
The turn benchmark counted any exit-0 task as passed, including the 24
read-only tasks (nav/longproc/longctx/parallel) that carry no verification
oracle — so the JSON tasksPassed / "N/M passed" headline could read as a
correctness verdict it never was. Split pass/fail into three oracle tiers:
- correctness (edit grep + fix go test): the only pass rate that can move
with model quality (tasksVerified / tasksPassed / correctnessPassRate)
- build-only (refactor go build): a non-positive oracle, reported as
buildPassRate and excluded from correctnessPassRate; the manifest declares
buildOnlyClasses: ["refactor"]
- latency-only (no oracle): counted in latencyOnlyTasks, never in any pass
rate
NewTurnExecRunner no longer sets Passed=true for tasks with no
verificationCommand, and the summary headline names each tier explicitly so
an exit-0 read-only run cannot inflate a correctness number. Bumps
TurnSchemaVersion to 2. Class breakdown and tier contract documented in
internal/perfbench/MANIFEST.md and reports/README.md.
Also install the trace first-token/visible-event forwarding handlers in
agent.Run whenever options.Trace is set, not only when a UI OnText /
OnReasoning callback is set, so a headless traced run (zero exec --trace)
still captures FirstTokenAt. forwardedVisibleText stays tied to the user
callback so stall-retry semantics are unchanged. Adds a regression
assertion to the existing traced-run test.
* Address CodeRabbit review on 2bfb3406: omitempty + crash visibility
Three findings from CodeRabbit's review of the 3-tier pass-rate fix:
- BuildPassRate dropped its omitempty so an explicit 0.0 (all refactor
tasks fail to compile) is no longer indistinguishable from 'no build
tasks ran' in the JSON. Matches CorrectnessPassRate's always-serialize
behavior, which is the whole point of the tier split: the report
cannot be misread.
- A crashed sample (outcome.Err) now emits a Warning mirroring the
TraceIssue path, so a run that died every iteration can't look like a
normal benchmark with fewer measurements instead of a failure.
- cmd.Run()'s returned error is preserved on the no-run_end path: when
stderr is empty and Run failed (binary missing, exec permission
denied), the actual OS error is reported instead of collapsing to the
generic 'missing terminal run_end event'.
go build/vet/gofmt clean; go test -race green for perfbench/agent/trace.
* Address CodeRabbit re-review: anti-averaging README + fixture-isolation test
Re-review at d0615249 asked for the pass-rate contract to be loud to
downstream consumers and for the fixture-isolation property to be asserted,
not just read from code.
- reports/README.md gains a prominent 'do not average the tier pass rates /
do not report a single pass rate' warning. The schema offers no headline
pass rate on purpose; a consumer must name the tier it is reporting. This
is the durability fix for the misread the tier split exists to prevent.
- turn_bench_test.go gains TestCopyFixtureIsolatesSourceFromMutation: copies
a fixture, mutates the copy, and asserts the source is byte-identical
(and that a file added to the copy never appears in the source). Proves the
'fixtures stay clean across runs' property without needing the binary.
Items the re-review also raised that were already satisfied: the
oracle-presence-over-declared-list precedence is documented on the
BuildOnlyClasses field (taskbench.go), and the PR body already says
'48-task manifest'. The Share->exclusiveShare rename is tracked in #701.
go build/vet/gofmt clean; go test -race green for perfbench.
* feat(cli): wire MCP serve WorkspaceRoot and --add-dir scope
Pass the resolved workspace into mcp.ServeOptions and honor --add-dir so
resources/list and scoped tools can expose extra roots beyond cwd.
* fix(cli): keep lexical serve --add-dir roots for path matching
Use symlink-resolved paths only for deduplication so Scope roots stay
aligned with the un-evaluated WorkspaceRoot MCP serve already passes.
The launcher recording a spawn and the job's Runs counter incrementing
are sequential but distinct steps in the scheduler goroutine
(fireIfIdle's Spawn call, then run's job.incRuns()). The test polled
only the launcher's recorded count, then asserted Runs immediately
with no wait, so a scheduler goroutine preempted between those two
steps could be observed with a stale Runs value under CI load. Wait
for Runs itself instead of assuming the launcher signal implies it.
* fix(windows): retry atomic file renames on Access is denied / Sharing violation
* test(swarm): add unit tests for rename retry and non-retryable errors
* test(swarm): skip TestMailboxRenameRetry on non-Windows platforms
* fix(sandbox): scrub sensitive provider credentials from sandbox env
* refactor(sandbox): unify environment creation and reduce lines of code
* fix(sandbox): forward workspaceRoot through sandboxEnvironment, gate Windows env on backend
* style(sandbox,swarm): remove stray blank lines flagged by gofmt
* refactor(fsutil): extract shared rename retry helper, derive scrub list from provider catalog
Address CodeRabbit review feedback:
- Add internal/fsutil.RenameWithRetry with an injectable rename override and
move the four duplicated renameWithRetry/isWindowsSharingViolation copies
(cron, sessions, sandbox, swarm) onto it. Mailbox keeps its rename test
seam by passing its field through. Add fsutil tests for the Windows retry
and non-retryable paths.
- Remove the orphaned metadata.json.tmp in cron writeJob when the rename
permanently fails, matching the sibling writers.
- Derive scrubSensitiveEnv keys from providercatalog AuthEnvVars plus a
static list of non-catalog secrets, so new providers are scrubbed
automatically. AWS_PROFILE and GOOGLE_APPLICATION_CREDENTIALS stay
because they are pointers, not secrets.
* fix(sandbox): deny sandboxed reads of cloud credential stores by default
Address CodeRabbit finding that scrubbed env pointers still left the
underlying credential files readable under the read-all workspace posture:
- Add credentialDenyReadPaths: default profile-level deny-read entries for
~/.aws, ~/.config/gcloud, ~/.azure, and the GOOGLE_APPLICATION_CREDENTIALS
target file, wired into both permission profile builders so the seatbelt
and Linux helper backends enforce them. Entries are stat-filtered and a
store nested under a user AllowRead entry is dropped, keeping an explicit
opt-out.
- Keep the defaults out of Policy.DenyRead, whose emptiness gates escalated
execution, and skip Windows so the profile deny list does not flip the
runner off the WRITE_RESTRICTED token path.
- Scrub GOOGLE_APPLICATION_CREDENTIALS from the sandbox env; keep AWS_PROFILE
since the deny-read rule on ~/.aws is the real protection and the SDKs
fall back to the default profile regardless.
- Add credential deny path tests, extend the scrub test, relax the profile
test's exact deny count to containment.
* test(cli): pin HOME in sandbox policy golden test
The default credential-store deny-read entries depend on what exists in the
real home directory (the macOS CI image ships ~/.aws), which leaked host
paths into the policy JSON golden comparison. Point HOME at an empty temp
directory so the golden stays hermetic.
* fix(sandbox): protect GOOGLE_APPLICATION_CREDENTIALS without a home, retry lock violations
Address CodeRabbit review feedback:
- credentialDenyReadPaths no longer returns early when the home lookup
fails; home-based candidates become conditional and the
GOOGLE_APPLICATION_CREDENTIALS target is always considered.
- fsutil.RenameWithRetry also retries ERROR_LOCK_VIOLATION (errno 33),
matching the mcp lock handling; helper renamed to
isWindowsSharingOrLockViolation.
- Extend tests: homeless GOOGLE_APPLICATION_CREDENTIALS case and a lock
violation step in the retry sequence.
* fix(sandbox): tighten env-scrub test assertion and add missing secret keys
TestScrubSensitiveEnv only checked an allowlist plus a length count, so a
duplicate allowed key silently replacing a required one would still pass.
Compare the exact expected key/value map instead.
Also add ZERO_WEBSEARCH_API_KEY and ZERO_DAEMON_REMOTE_TOKEN to
scrubSensitiveEnv's sensitiveKeys list: both are real bearer secrets read
from the environment but were missing from the scrub list, leaving them
readable by sandboxed commands when set in the parent shell.
* fix(tools): classify silent wrapped Windows command failures as sandbox denials
A Windows restricted-token sandbox failure is often completely silent:
when the token cannot open the target executable or its DLLs (or an
MSYS runtime dies during init), the command exits nonzero having
written zero bytes to stdout and stderr. The existing detection is
keyword-based, so these failures were never marked as sandbox denials,
the agent loop never offered the unsandboxed-retry prompt, and the
model was left staring at 'Command completed with no output' until it
gave up.
Treat a wrapped command on the Windows restricted-token or elevated
backend that exits nonzero with completely empty output as a likely
sandbox denial. A command that legitimately fails silently (findstr
with no match, for example) now costs one extra approval prompt; the
inverse misclassification silently strands the session, so the trade
goes to prompting.
* test(tools): cover the elevated Windows backend in the silent-denial test
The heuristic treats windows-elevated identically to
windows-restricted-token, but only the latter was exercised.
Parameterize the silent-failure test over both backends.
* fix(keyring): pass generic password via stdin on macOS
Pass the generic password secret to security add-generic-password via stdin instead of passing it as a command-line argument. This prevents the secret from leaking to the local process list (visible via ps) and aligns the macOS keyring implementation with the Linux secret-tool implementation.
* fix(keyring): pipe secret twice for macOS security password+retype prompt
The macOS security add-generic-password command prompts for both a password and a retype confirmation when -w has no trailing value. The previous stdin approach only piped the secret once, causing a passwords don't match failure. Write the secret twice separated by newlines to satisfy both prompts.
* fix(keyring): reject multiline secrets on macOS instead of corrupting them
Addresses jatmn's review:
- security's -w password+retype prompt reads stdin line by line, so a
secret containing its own newline can't be distinguished from the
password/retype separator: the first line read back would silently
truncate the stored value. Set now rejects a secret containing \r or
\n on darwin before invoking security, rather than storing a
corrupted value. Linux's secret-tool has no such restriction (reads
the whole stdin payload), so it is unaffected.
- Updated the package doc comment, which still described the old
argv-based (process-list-exposed) macOS path this PR replaced.
Added TestKeyringSetRejectsMultilineSecretOnDarwin and
TestKeyringSetAllowsMultilineSecretOnLinux.
* fix(keyring): drive security -i for macOS Set instead of the -w prompt
The trailing -w prompt uses getpass(3), which reads from /dev/tty whenever
the process has a controlling terminal, so the piped secret was ignored in
any interactive session, and getpass's small fixed buffer would truncate
long secrets (like the base64 OAuth blob) when the fallback did engage.
Interactive mode reads whole commands from stdin, keeping argv at just -i.
Arguments are quoted for SecurityTool's split_line parser (backslash and
double quote escaped inside double quotes). The parser is line-based with a
4096-byte buffer, so Set rejects newlines and oversized payloads up front
rather than corrupting the stored value.
* feat(tui): press up to edit queued messages
Pressing up with a message queued pops it back into the composer for
editing, taking priority over history recall. While a message is queued
the empty composer shows a hint placeholder. A second prompt queued
during the same run now stacks under the first instead of replacing it.
* refactor(tui): remove openclaude references from queued_message comments
Removing the WRITE_RESTRICTED flag in #612 made the restricted-SID check
apply to reads as well as writes. Default Windows DACLs grant
BUILTIN\Users rather than any SID in the token's restricted list
(random capability SIDs, logon SID, Everyone), so the sandboxed process
could no longer open any executable or non-KnownDlls DLL: every spawned
command failed with exit 1 and no output, including cmd builtins'
children (gh, git, where.exe). The existing
TestWindowsRestrictedTokenNestedPipeCapture smoke test reproduces the
regression.
Restore WRITE_RESTRICTED, but only when the permission profile has no
DenyRead paths. The flag makes the kernel skip restricted-SID deny ACEs
for reads, which is exactly the DenyRead bypass #612 fixed, so profiles
that configure DenyRead keep the fully restricted token and trade spawn
capability for read-deny enforcement. DenyRead is empty by default, so
the common case regains a working sandbox while #612's guarantee holds
for the profiles that rely on it.
* fix(sandbox): stop blocking git fetch/commit/add by unblocking .git writes
The sandbox denied every write under .git for shell-executed commands,
so any git operation that touches its own metadata (fetch, commit, add,
pull, merge, stash) failed under the default sandbox. Narrow the block
to .git/hooks (auto-executing scripts) and .git/config (remote URLs,
credential.helper, core.hooksPath), the two subpaths that are actually
dangerous to write; the rest of .git stays writable to git subprocesses
since those writes go through git's own invariants.
The app-level auto-allow gate for direct file-tool writes (write_file,
edit_file, apply_patch) is unaffected and still treats all of .git as
protected, since hand-editing git's internals bypasses those invariants.
* test(cli): normalize Windows path separators in sandbox policy golden
The sandbox policy golden is shared across platforms, but readOnlySubpaths
carve-outs are built with filepath.Join and render as backslash paths on
Windows. normalizePortableJSONRootString only converted a bare backslash, so
the Windows smoke run diverged from the forward-slash golden. Replace all
backslashes with slashes during normalization so the shared golden passes on
every runner.
* fix(agent): raise default and deep-mode turn budgets
The default per-run tool-turn budget of 50 was still too low for larger
multi-step tasks that span several files: agents hit the ceiling and
stopped with a "remaining work" summary instead of finishing. Raise the
default from 50 to 80.
The "deep" preset also carried MaxTurns: 50, identical to the default, so
its "larger turn budget" promise no longer held on the turn-budget axis.
Raise it to 160 so deep runs get a genuinely larger budget again.
Users can still override per-session with /turns or --max-turns (bounded
by MaxTurnsCeiling).
* test(cli): expect deep-mode MaxTurns=160 after budget raise
* fix(config): surface unknown/typo'd config fields instead of silently dropping them
json.Unmarshal ignores unknown JSON keys by default, so typos such as
"maxTurn" or "sandbox.network" were silently discarded: the user
believed a setting (e.g. a sandbox hardening option) was active while
Zero used its default. Worse, "zero doctor" reported
"config files parsed and validated successfully" even when fields were dropped.
Add a reflection-based unknown-field scanner
(internal/config/unknownfields.go) that walks the FileConfig JSON schema
and recurses into nested objects, arrays, and maps, reporting each
unknown key with its full JSON path and a near-match suggestion
("did you mean 'maxTurns'?"). The two legacy top-level aliases
(mcpServers, mcp_servers) are allowlisted so they are not flagged,
and detection only inspects json tags, so a config written by a newer
Zero with new fields still loads and merges normally — validate/doctor
just surface the unknown keys as issues rather than rejecting the file.
Wire the scanner into ValidateBytes/ValidateFile, which zero doctor
already uses, so doctor now reports the unknown fields instead of
"validation passed".
Add tests for unknown top-level keys, unknown nested keys, unknown
keys inside a provider entry, and that the legacy aliases are not
flagged.
Fixes#644
* fix(config): address review comments on unknown-field scanner
- Treat JSON keys case-insensitively (matching encoding/json), so a
valid case variant such as "MaxTurns" is not falsely flagged as
unknown. Canonical (correct-case) names are kept only for the
"did you mean" hint.
- Allowlist ProviderProfile's legacy snake_case keys (providerKind,
catalog_id, base_url, api_key, ...) that ProviderProfile.UnmarshalJSON
accepts but which are not fields on the Go type, so existing
configs using them are not false-positive'd in zero doctor.
- Make the near-match suggestion deterministic: sort candidates before
picking so an edit-distance tie always resolves to the same lexical
winner (map-range order was nondeterministic and could flake).
- Make the unknown-field test assertions order-independent (search by
FieldPath instead of assuming issues[0]).
Add regression coverage for legacy provider keys and valid case
variants not being flagged.
* fix(lock): prevent POSIX lock file overwrite and leak on Windows/Unix
During lock reclamation, reclaiming a suspected stale lock renames the file aside and renames it back if not stale. On POSIX systems, the rename back silently overwrites any new lock file created in the gap. On Windows, it fails to rename back and leaks the stale file.
Introduce a cross-platform restoreLockFile helper:
- On POSIX: Use os.Link to link the file back (which atomic-fails if the target already exists), then remove the temporary link.
- On Windows: Use os.Rename (which safely fails if the target exists).
If restoring fails, delete the temporary file immediately to prevent leaks.
* fix(lockutil): fix Windows build by using golang.org/x/sys/windows
Addresses jatmn's follow-up review: the required Windows smoke check was
failing because syscall.MoveFileEx is not exported by the standard
library's syscall package on this platform, so internal/lockutil (and
everything importing it: cron, daemon, hooks, oauth) failed to compile
on Windows.
Switched to golang.org/x/sys/windows, which does export MoveFileEx with
the same signature, already a direct module dependency. Verified on a
real Windows machine that the no-flags call still fails with
ERROR_ALREADY_EXISTS when the destination exists, and that this
satisfies errors.Is(err, os.ErrExist), which cron/daemon/hooks/oauth's
reclaim paths all depend on to decide whether the sidelined lock was
genuinely superseded (safe to discard) versus some other, unrelated
failure (must be preserved).
TestRestoreLockFile already covered both the successful-restore and
fail-when-target-exists cases; it now passes on Windows.
* fix(lockutil): route lock file deletes through RemoveLockFile with Windows retry loop
* fix(lockutil): close restore split-brain and unify RemoveLockFile
RestoreLockFile could fail with errors other than os.ErrExist (hardlink-
incapable filesystems such as FAT or some FUSE/network mounts, ENOSPC,
Windows sharing violations). Callers only clean up on os.ErrExist, so any
other failure left the lock path missing with the live holder's lock
stranded in the sidelined file; the next O_EXCL create then succeeded and
two processes held the lock. RestoreLockFile now falls back to an O_EXCL
copy in that case, which preserves the no-overwrite guarantee, and a
successful restore with a failed sidelined-file cleanup reports nil
instead of a failed restore.
RemoveLockFile now has one cross-platform contract: removing an already
missing file reports nil on POSIX too, matching Windows, and daemon
release() drops its caller-side ErrNotExist filter.
Tests: TestRestoreByCopy and TestRemoveLockFile run on all platforms;
Windows-tagged tests pin the ERROR_ALREADY_EXISTS mapping to os.ErrExist
and exercise the RemoveLockFile retry loop to success and to bounded
exhaustion. The package doc comment documents the shared contract.
* refactor(swarm): extract reclaimStaleLock to mirror other lock callers
The stale-lock reclaim in acquireLock was inline, so its restore path
(a holder rotated a fresh lock in the gap between the stale check and
the rename) could not be regression-tested the way cron, daemon, hooks,
and oauth test theirs. Extract it into reclaimStaleLock with the same
shape as the other callers, behavior unchanged, and add the mirrored
regression test.
* test(hooks,oauth): add stale lock reclaim regression tests
Mirror the cron reclaim test in the two packages that lacked coverage
for reclaimStaleLock: a genuinely stale lock is reclaimed, a fresh lock
(reacquired in the gap) is restored intact rather than stolen, and a
missing lock reports no reclaim. daemon already had equivalent tests in
lock_test.go.
* fix(lock): fail closed and drop sidelined file when restore fails
A non-os.ErrExist error from RestoreLockFile means both the no-replace
restore and its O_EXCL copy fallback failed, so the lock path may be
missing while the sidelined holder is live. Callers previously swallowed
that error and their next O_EXCL create would succeed, breaking mutual
exclusion. reclaimStaleLock in cron, daemon, hooks, oauth, and swarm now
returns an error for that case and the acquire loops fail closed instead
of re-acquiring.
The sidelined .stale.* file is now removed on every restore failure, not
only os.ErrExist: once the restore has failed it has no protocol
function (release only consults the lock path), so keeping it only
leaked files.
Also replace the numeric errno literals in the Windows RemoveLockFile
retry loop with windows.ERROR_SHARING_VIOLATION and
windows.ERROR_ACCESS_DENIED.
* refactor(lockutil): extract shared ReclaimStaleLock helper
Consolidate the duplicated reclaimStaleLock functions from cron, hooks,
oauth, and swarm into lockutil.ReclaimStaleLock. Each caller now passes
an mtime-based isLive predicate to the shared helper, which owns the
rename-aside, restore-if-live, cleanup, ErrExist handling, and
fail-closed error propagation.
Daemon keeps its own reclaimStaleLock because it uses PID-based liveness
(different predicate shape and no staleAfter duration).
The per-package reclaim regression tests are removed since the shared
helper is thoroughly tested in lockutil/reclaim_test.go (dead lock
reclaimed, live lock restored, missing lock, fail-closed on restore
error, sidelined file dropped when new holder wins).
* fix(lockutil): propagate non-contention rename failures from ReclaimStaleLock
A rename-aside failure that is not a lost race previously mapped to the
benign lost-race result, so acquirers spun until their deadline and
reported a timeout instead of the real failure. Keep only a missing
source and the Windows sharing-violation/access-denied contention
errnos benign (neither Windows errno maps to os.ErrExist, and
ERROR_SHARING_VIOLATION does not map to os.ErrPermission either, so the
classification compares raw errnos) and return everything else so
callers fail closed.
Also convert daemon's private reclaimStaleLock to the shared helper so
it picks up the same contract, and reword the caller error messages
from "restore reclaimed" to "reclaim stale" since the error can now
come from the rename aside as well.
* fix(lockutil): publish restore-by-copy atomically and honor lost mailbox reclaims
restoreByCopy created the destination lock file empty before copying into
it, so a crash mid-copy could leave a live holder's lock permanently
unrepresented at the lock path. Stage the copy under a private name and
publish it with the platform's no-replace primitive instead.
swarm's mailbox lock discarded the reclaimed boolean and always retried
immediately, so a lost reclaim race (or Windows sharing-violation
contention) could hot-spin past the lock timeout instead of falling
through to the bounded wait like the other lock callers.
* fix(lockutil): shrink the reclaim/restore window with a fast replacing rename
Restoring a lock that turned out live went through the no-replace path
(plus its copy fallback on some failures) first, which is slow enough
that a fourth process's unrelated O_EXCL create could win lockPath while
it was absent for the whole call. RestoreLockFile's no-replace contract
would then report os.ErrExist and the caller silently dropped the
sidelined lock, orphaning the still-running original holder.
restoreLiveLock now tries a single replacing rename straight back first,
falling back to the no-replace path only if that fails. It shrinks the
window from a whole restore call down to roughly one syscall; it does
not close it, since the fast path can silently overwrite a competing
lock rather than detect it. A fully race-free reclaim would need an
OS-level advisory lock checked non-destructively instead of by moving
the file, which is a larger change than this.
* docs: add Go code quality & security checks and bump Go version to 1.26+
* docs: address CodeRabbit review comments and use pinned versions and Go 1.26.5+
* docs: remove trailing blank lines at end of AGENTS.md
* docs: add missing /v2/ segment to golangci-lint install path
The go install command pointed at the v1 module path
(golangci-lint/cmd/golangci-lint), which does not resolve for
v2.12.2. The go run invocation earlier in the same section already
had the correct v2 path.
* docs: trim AGENTS.md below 8 KiB guideline and clean up README cross-compile header
* docs: address CodeRabbit review feedback on extending guide
When writing credentials or generating user secrets, files are written using a write-to-temp-then-rename pattern. However, neither package called Sync (fsync) on the temporary file before closing and renaming. If a crash or power failure occurred shortly after write, this could result in truncated or 0-byte key/credential files on disk.
Add tmp.Sync() calls to writeNewSecretFile and Store.write to guarantee durability.
* fix(plugins): resolve relative executable paths against plugin root
When a plugin registers a tool or a hook command as a relative path (e.g. ./tools/helper.sh), running it in the caller's workspace CWD allows local execution hijacking by placing a malicious script at that path in the untrusted workspace. Update expandPluginRootPath to resolve relative executable paths containing path separators to absolute paths inside the plugin's install directory.
* fix(plugins): remove ineffectual permission initializer in toolSafety
The initial assignment to permission was always overwritten by one of
the switch arms below it, including a case that just reassigned the
same PermissionPrompt value the initializer already held. Flagged by
ineffassign in CI.
* feat(npm): ship the native binary as platform optionalDependencies
Replace the postinstall downloader with Codex-style platform packages:
suffixed versions of @gitlawb/zero (X.Y.Z-<platform>-<arch>) carrying the
binary, sandbox helpers, and the vendored agent-browser/tuistory tree,
referenced from the wrapper via npm: aliases in optionalDependencies.
The published wrapper has no lifecycle scripts and no dependencies, so
npm/Bun/pnpm installs are silent: no EBADENGINE from agent-browser's
node>=24 engines pin, no allow-scripts warnings, no network outside the
npm registry. The old downloader remains as a checksum-verified first-run
fallback for --omit=optional installs and unsupported platforms.
scripts/npm/build-platform-packages.mjs assembles the payloads from the
release archives at publish time: it materializes npm's symlink .bin
shims into regular sh shims (npm pack silently drops symlinks) and prunes
agent-browser's other-platform binaries (~65 MB per package). The publish
job uploads platform versions first under --tag platform, asserts the
latest dist-tag survived (platform versions are semver prereleases of the
wrapper version), then publishes the wrapper.
See docs/NPM_PACKAGING.md for the architecture and publishing rules.
* fix(npm): address PR review — LICENSE in payloads, dist-tag lookup guard, accurate fallback and platform docs
- Ship the repo LICENSE in both the wrapper and platform payloads (npm only
includes a license file physically present in the packed directory).
- Guard the platform-publish dist-tag lookup with || true so a transient
npm view failure (or a bootstrap publish with no latest tag) cannot abort
the job between the platform and wrapper publishes.
- Gate the wrapper's fallback downloader on the release matrix so platforms
with no asset (windows-arm64 and unknown combos) skip the spawn instead of
attempting it every run; keep deliberate retry-on-failure for supported
platforms and say so honestly in the notice and docs.
- Correct Windows support wording everywhere: x64 only, ARM runs under
emulation (README, README_ZH, INSTALL.md incl. the release-target list).
Interactive TUI always has a session ID, so every completion request
forwarded OpenAI's prompt_cache_key. Strict openai-compatible gateways
(e.g. NVIDIA NIM) reject unknown fields with a 400, while plain zero
exec usually has no session and omits the field.
Disable prompt_cache_key for ProviderKindOpenAICompatible; keep it for
official OpenAI. ZERO_DISABLE_PROMPT_CACHE_KEY remains a global kill
switch.
Fixes#624
Co-authored-by: KRATOS <[email protected]>
When executing a security-critical beforeTool hook, if the hook command fails to launch (e.g. binary not found, missing execute permission), it previously failed open and let tool execution proceed.
Update classifyResult to return AuditBlocked, true when a launch error occurs on a beforeTool hook, enforcing a fail-closed policy. Observational afterTool hooks still fail open.
Inside fireJob, the Mutate transaction callback previously returned the stale in-memory job copy (read at tick start). This clobbered any concurrent updates made to the job (such asExpr, Prompt, or Cwd edits) while the job was running.
Update the callback to apply schedule and status changes directly onto the fresh current job loaded from disk, and return current.
When a run is completed or canceled (agentResponseMsg) while an askUser prompt is pending, or when a stale/superseded askUserRequestMsg is received, the answer callback is now invoked with nil. This unblocks the waiting agent loop goroutine, preventing goroutine leaks and runner hangs.
When parsing CLI flags, positional prompt text placed immediately after flags requiring values (e.g. --auto, --notify, --max-turns) would be greedily consumed as their value by nextFlagValue. Refine nextFlagValue to validate choices/types (autonomy levels, notify modes, integers) to detect invalid values early and fail validation rather than eating positional arguments.
When falling back from a failed PTY start to a pipe execution, the command attributes are reset. Previously, resetExecCommandForPipeFallback set command.SysProcAttr = nil, which stripped custom settings like Windows CmdLine quoting. Update the fallback logic to restore the original SysProcAttr instance, and add TestStartExecProcessPTYFallbackPreservesSysProcAttr to cover.
Using unqualified taskkill / taskkill.exe commands on Windows can allow binary hijacking under certain conditions. Update all process termination callers on Windows to resolve the absolute path to taskkill.exe under System32 (referencing SystemRoot/windir environment variables, falling back to C:\Windows).
On Windows, a concurrent holder's os.Remove leaves the lock file in a 'delete pending' state, causing a racing O_EXCL create to fail with ERROR_ACCESS_DENIED (mapped to os.ErrPermission). Treat os.ErrPermission as lock contention / already-existing lock, matching the collision checks in cron, hooks, oauth, and securefile.
* fix(securefile): reclaim stale lock files to prevent permanent DOS
When createSecretFile fails to acquire the O_EXCL lock because it already exists, check if the file is stale (older than 10 seconds). If so, atomically reclaim it via a rename-aside check, preventing a permanent DOS after abrupt process crashes. Update the lock file to write and verify process-specific tokens, and add TestCreateSecretFileReclaimsStaleLock to cover the stale reclaim flow.
* style(securefile): gofmt const block alignment
truncateHint slices string hints using a raw byte offset (s[:max]), which can split multi-byte UTF-8 characters (like CJK characters, emojis, or accents) in half and produce invalid UTF-8 sequences. Update it to count and slice by runes to guarantee valid UTF-8, and add a unit test TestToolTitleTruncateHintRuneSafe to verify.
Currently, both gitBranchForPrompt (agent) and gitBranch (tui) check for a .git entry directly inside the current working directory. When starting Zero in nested folders/subdirectories of a repository, the check fails and the branch segment is omitted.
Export findProjectGitRoot as FindProjectGitRoot in agent package, and use it in both functions to resolve the correct repository root before checking HEAD and resolving the branch name. Also resolve relative worktree gitdirs relative to the repository root.
On Windows, creating restricted tokens with windowsWriteRestricted (0x08) instructs the kernel to skip checking the restricted SIDs list for read operations. This bypasses DenyRead path policies on NTFS. Remove the flag to enforce restricted SID constraints on both read and write operations, and add integration test assertions to verify that DenyRead paths are correctly blocked.
* fix(agent): scope self-reported incompletion to sentences about the current objective
The detector matched inability stems anywhere in the final message, so a
conversational recap of a past exchange ("You asked if I could work
autonomously ... so I couldn't actually do it at the time") downgraded a
completed run to INCOMPLETE. Match per sentence instead, drop quoted spans,
and skip sentences that retell a past exchange. The real transcript is now a
regression test, and genuine admissions still fire, both present tense and
past tense about the current task.
* feat(exec): add --no-completion-gate for conversational callers
A chat frontend driving zero exec has an operator present, so a final
message that hands the turn back ("the sandbox blocks network egress,
approve it and I'll continue") is a complete conversational answer, not an
unfinished task. The headless completion gate still stamped such runs
INCOMPLETE (exit 4) on the honest inability phrasing, and no phrase
heuristic can fix that case because the admission is true. The new flag
maps to RequireCompletionSignal=false so conversational callers can opt
out. Default behavior is unchanged.
* fix(exec): address review findings on quote stripping and --use-spec combination
stripQuoted now removes only balanced quoted spans: an opening delimiter
that never closes is restored verbatim, so a stray quote cannot swallow
the rest of the message and hide a genuine admission from the detector.
Regression tests cover dangling straight, curly, and backtick delimiters.
--no-completion-gate combined with --use-spec is now rejected at parse
time, mirroring the --self-correct check: the spec-draft path never
consults the completion gate, so the flag would be silently ignored.
* feat(workspacetrust): add exact-match trust store for project config gating
* feat(hooks): add ExcludeProject option to LoadConfig
* feat(plugins): add ExcludeProject option to Load
* feat(cli): gate project hooks and plugins behind workspace trust
Trust check lives inside newHookDispatcherWithExtra and activatePlugins
(fail-closed by construction); trustRoot is the original launch dir so a
--worktree run inherits the source repo's trust. Callers OR the skip
reports and emit one combined stderr notice.
* feat(cli): add zero trust command (trust/list/remove)
* fix(review): key spec-draft trust on the original launch dir; cover trust CLI edge branches
Spec-draft runs (zero exec --use-spec --worktree) passed the reassigned
worktree path as trustRoot, dropping a trusted repo's project hooks
(fail-closed, but breaks worktree-inheritance). Thread the captured
pre-worktree trustRoot into execSpecDraftRun. Add tests for the trust
'remove' too-many-args and --help branches, and a resolveTrust comment
noting the per-chokepoint check must not be hoisted to callers.
* test(cli): end-to-end trust-gate coverage through agent.Run and exec --worktree
Drives the real agent loop with a fake tool-calling provider so a project
beforeTool hook actually fires (or is gated away) through production code:
gate blocks a tool call via agent.Run, and --worktree / --use-spec --worktree
key trust on the original launch dir. All three are load-bearing (RED when the
gate or the trustRoot threading regresses).
* style(cli): gofmt struct alignment in exec_spec.go
* fix(workspacetrust): compare stored trust entries literally, no symlink re-resolution
External adversarial review (codex) found a false positive: IsTrusted
re-normalized stored entries on every check (Abs+EvalSymlinks), so a trusted
path later replaced by a symlink to attacker content re-resolved to the new
target and matched. Stored entries are already canonical (normalized once at
Trust time); compare them as literals and only normalize the incoming query.
Add a regression test for the symlink-retarget case.
* feat(mcp): gate project-scoped MCP servers behind workspace trust
Closes the P0 an external adversarial review (codex) found: project
./.zero/config.json stdio MCP servers spawned before the hook/plugin gate,
an ungated arbitrary-execution path that could also self-trust the workspace.
Add ExcludeProject to config.ResolveMCP (skips the project layer) and thread a
fail-closed trust check into every MCP spawn site (exec, TUI setup+refresh,
mcp tools list, mcp check); reporting sites stay ungated. Verified the sole
server-command exec (mcp/client.go) is reachable only through the now-gated
RegisterTools paths.
* test(cli): cover default-mode beforeTool firing through the trust gate
Assert a project beforeTool hook fires in the default auto and ask permission
modes (not only under unsafe), and is blocked when the workspace is untrusted,
so the gate is verified in the mode a normal run actually uses.
* docs(cli): note MCP servers in zero trust help text and doc comment
The gate covers project hooks, plugins, and MCP servers, but the help string and
runTrust doc comment listed only hooks and plugins.
* test(cli): make workspace-trust tests portable on macOS and Windows
setTrustConfigRoot set HOME on darwin, but config.UserConfigDir is XDG-first
there (=$HOME/.config), so tests wrote trust.json and user hooks.json where the
code never read them; set XDG_CONFIG_HOME on every non-Windows platform instead.
writeMarkerHook shells out to /bin/sh, absent on Windows, so its trusted-case
marker never appeared; skip it there like writeMarkerHookScript already does.
TestRunTrustAddThenList compared `trust list` output (canonicalized via
EvalSymlinks) against the raw cwd, which diverges on the Windows runner's 8.3
short names (RUNNER~1 -> runneradmin); compare the resolved path, and add
TestRunTrustListShowsCanonicalPath to exercise that via a symlink on any platform.
* feat(cli): surface the project MCP skip in the workspace-trust notice
Project MCP servers were dropped silently in an untrusted workspace: the trust
notice covered hooks and plugins but not MCP, and app.go discarded the
resolveTrust error. registerMCPToolsForWorkspace now returns a trustSkip, gated
on a new projectMCPConfigExists check plus the previously-dropped store-read
error, and emitTrustNotice is variadic so the exec, spec-draft, interactive, and
`mcp tools list` paths all fold in the MCP skip.
The skip drives the notice only; the gate stays on resolveTrust/excludeProject,
so a trusted workspace is unaffected and an untrusted one still fails closed.
* fix(cli): print zero trust --help to stdout with a success exit
zero trust -h/--help/help routed usage to stderr with the usage exit code, unlike
every other subcommand (mcp, sandbox, skills, cron, ...) which prints help to
stdout and returns success. Align it; the unknown-subcommand path still writes to
stderr with the usage exit code.
* fix(cli): gate oauth MCP login and surface mcp-check trust notice
Two project-scoped MCP activation sites were left outside the workspace
trust gate.
zero mcp oauth login loaded a project-defined OAuth server from an
untrusted workspace and ran the full discovery/registration/token-exchange
flow against its configured URL. Gate resolveOAuthServer with resolveTrust
so an untrusted clone's ./.zero/config.json OAuth server is dropped before
login, and emit the same one-line trust notice the other spawn sites use.
zero mcp check already gated the project layer but discarded the trust-check
result and never emitted the notice, so a gated project server read as a
bare "not configured". Emit the notice on the not-found branch.
The three remaining ungated resolveMCPConfig(cwd, false) sites (backends
doctor/snapshot and the extensions listing) are enumeration-only and stay
ungated.
* test(cli): adapt MCP startup-skip tests to two-arg resolveMCPConfig
---------
Co-authored-by: Kevin Codex <[email protected]>
* fix(update): clearer error on unsupported release platform (android/termux)
zero update/upgrade fails on Android/Termux because GOOS "android" has
no published release archive. Replace the generic "unsupported release
platform: android" error with a clear, actionable message explaining
that no release builds are published for the platform, listing the
supported platforms, and suggesting a rebuild from source. The command
still fails gracefully (clean stderr message, exit 1, no panic/stack
trace) rather than crashing.
Fixes#598
* fix(update): address review feedback on unsupported-platform error
- Condense the explanatory comment for the unsupported GOOS branch.
- Replace the Unicode em dash with plain ASCII punctuation.
- Align internal/release.ReleasePlatform's error message with the
updater's so both flows give identical guidance.
* fix(update): npm-based upgrade guidance for platforms without release assets
Address review feedback: the previous message implied Android/Termux was an unsupported platform and told users to rebuild from source. Termux runs zero fine via the npm wrapper; it just has no self-updating release archive. Point users at 'npm update -g @gitlawb/zero', the documented Termux install/upgrade path, instead.
* fix(modelregistry): reject oversized models.dev cache responses
Read modelsDevFetchLimit+1 bytes instead of modelsDevFetchLimit. If the
response exceeds the 32 MiB guard, return an error and leave any
existing cache untouched.
Fixes#510
* address review: include byte count in error, make oversized test hermetic
- error message now reports actual bytes read vs the limit
- test payload is limit+1 bytes (matches the reported repro)
- test explicitly clears ZERO_DISABLE_MODELS_FETCH so it exercises
the oversized path regardless of the outer environment
* fix: warn about untracked scratch files left behind after a run
Fixes#551. Headless exec runs could leave behind debug/scratch files
(e.g. _fix_test.py, _debug.py) created by write_file during iteration,
which would then be silently swept up by a later git add -A.
- FileTracker now records brand-new files (RecordCreated/CreatedFiles),
distinct from the existing version-baseline tracking used for
conflict detection.
- write_file records a path as created only on first write, not on
overwrite of an existing file.
- After a headless run completes, exec.go checks git status for any
created file still present and untracked, and surfaces a warning via
the existing writer.warning() (works across text/-o json/stream-json).
No files are deleted automatically — a wrong guess at 'scratch' could
destroy real work, so this only surfaces what git already knows.
Co-authored-by: Copilot App <[email protected]>
* fix: address scratch warning review comments
* test: normalize created file tracker paths
* fix: track apply_patch-created scratch files
* fix: address scratch warning review feedback
* fix: tighten scratch warning lifecycle
---------
Co-authored-by: Copilot App <[email protected]>
* merge: rebase onto main, combine Windows shell guidance with #476's MSYS text
#476 merged into main and rewrote the same "Shell syntax: Windows cmd.exe..."
guidance string in system_prompt.go and shellGuidanceForGOOS in
shell_runtime.go that this branch also edits (this PR's fix for the broken
single-quoted quoting examples). Combined both into one coherent string in
each spot: the double-quote/single-quote metachar rule with correct
examples, plus #476's MSYS coreutils warning and require_escalated
mention. Merged the corresponding test assertions in loop_test.go the
same way.
internal/imageinput/clipboard.go, internal/tools/bash_tool_test.go, and
internal/tools/exec_command_test.go applied cleanly with no overlap.
* test(tools): add host shell syntax description test for exec_command
* chore: retrigger CI after infra flake
* style(tools): gofmt exec_command_test.go
---------
Co-authored-by: Vasanthdev2004 <[email protected]>
* fix(mcp): skip RFC 8414 discovery when OAuth endpoints are preconfigured
resolveAuthorizationServer performed a real network metadata discovery call even when the MCP server config already supplied both the authorization and token endpoints. When discovery blocks (offline, unreachable issuer, or test fixtures pointing at invalid hosts) the whole Login flow hangs and never reaches the browser/loopback step, so mcp oauth login could not complete.
When both endpoints are present, return them directly and skip discovery entirely; discovery remains the fallback for any endpoint the config leaves blank. This also fixes TestRunMCPOAuthLoginStoresTokens, which timed out because the loopback callback was never driven (the URL was never printed).
Removes scratch test files left over from investigation (internal/mcp/scratch_cli_test.go, internal/cli/scratch_oauth_test.go).
* fix: trim fast-path endpoints, restore fallback test, fix picker_test compile error
- Trim AuthorizationEndpoint/TokenEndpoint/RegistrationEndpoint in the
skip-discovery fast path to match the fallback path (P3 from @jatmn).
- Update TestResolveEndpointsFallsBackToConfig to only configure one
endpoint so discovery still runs and fails, exercising the fallback.
- Add TestResolveEndpointsSkipsDiscoveryWhenBothConfigured to verify
discovery is skipped when both endpoints are configured (P2 from @jatmn).
- Fix picker_test.go:771 to capture 4 return values from switchProviderModel
(pre-existing compile error on main blocking CI smoke).
* test(mcp): prove discovery is skipped via request-failing transport
The skip-discovery test passed even without the fast path, because
discovery against the invalid URL failed instantly and the config
fallback produced identical trimmed endpoints. Use a transport that
fails the test on any outbound request so the fast path is actually
what is being verified.
---------
Co-authored-by: gnanam1990 <[email protected]>
* test(config): make Resolve local-control tests env-independent
Resolve reads ZERO_PROVIDER from the environment via applyEnv, then fails with "no active provider configured" when that provider is not present in the config. This made TestResolveLocalControlFromUserConfig, TestResolveLocalControlIgnoresProjectOptIn, TestLocalControlDefaultsBrowserAndTerminalOn, and TestLocalControlExplicitGlobalDisable fail whenever the process environment happened to export ZERO_PROVIDER (e.g. an agent session), even though the tests never intended to exercise provider resolution.
Pass Env: map[string]string{} to the affected Resolve calls so the tests assert only local-control resolution and are unaffected by ambient environment variables. Verified the four tests pass even with ZERO_PROVIDER=gitlawb-opengateway set.
* fix(tui): align picker_test switchProviderModel call with 4-value return
* Fix OpenGateway credential lookup, stream error code mapping, and model catalog
* fix(providers): map semantic OpenAI stream error codes, not just numeric
Addresses jatmn's review (following up on CodeRabbit's original request):
the streamed error classifier only recognized numeric codes ("429", 401,
403 as string/float64/int), so semantic string codes OpenAI-compatible
providers commonly send instead (rate_limit_exceeded, insufficient_quota,
invalid_api_key) fell back to a generic 500 and lost their rate-limit/auth
classification.
Unified the numeric and semantic mappings into one
openAIStreamErrorStatusByCode table so both forms of the same condition
classify identically. insufficient_quota maps to 429, matching OpenAI's
own API behavior for quota-exceeded errors.
Added TestStreamCompletionClassifiesStreamErrorCode covering both forms.
* test(providermodelcatalog): cover the new gitlawb-opengateway catalog IDs
Addresses jatmn's review (completing CodeRabbit's original request):
TestModelsAreProviderScoped only asserted mimo-v2.5-pro and tencent/hy3
for gitlawb-opengateway, missing the other newly curated IDs added in
this PR (xiaomi/mimo-v2.5-pro, minimax/minimax-m3, qwen/qwen3.7-max,
google/gemini-3.1-flash-lite, z-ai/glm-5.2,
nvidia/nemotron-3-ultra-550b-a55b:free). A typo or accidental removal
in any of them would not have failed a test.
* test: cover the last catalog ID and probeProviderHealth's stored-key wrap
Addresses jatmn's second follow-up review on PR #580:
- TestModelsAreProviderScoped still missed xiaomi/mimo-v2.5 (distinct
from xiaomi/mimo-v2.5-pro) among the newly curated gitlawb-opengateway
catalog entries.
- fillAppDeps wraps probeProviderHealth the same way it wraps
newProvider, applying the stored credential-store key before
delegating, but only newProvider's wrap had a regression test
(TestFillAppDepsWrapsNewProviderWithStoredKey). Added the parallel
TestFillAppDepsWrapsProbeProviderHealthWithStoredKey so the health-check
path used by `zero providers check --connectivity`, `zero doctor
--connectivity`, and the TUI doctor panel can't silently regress back
to keyless probes.
* fix(tui): paste protection for Termux char-by-char paste
Termux context menu paste injects characters one at a time, including
newlines as raw KeyEnter events. This caused every pasted multiline
text to be submitted prematurely before the user could review it.
The fix adds a timing-based paste detector that works alongside the
existing bracketed-paste handler (tea.PasteMsg):
- Three new model fields: lastCharTime, lastKeyTime, burstCount
- Timing trackers run before any early return in case tea.KeyPressMsg
- Enter handler checks burstCount > 1 (2+ chars within 100ms = paste)
- Composer check gated with burstCount > 0 to avoid infinite composer lock
- burstCount reset at every intentional action (permission, picker,
submit, wizard, spec review, etc.) so normal typing is unaffected
The 100ms window catches all observed Termux paste speeds while
remaining under typical human inter-key intervals. The burstCount
resets at modal boundaries prevent false triggers in tests and
real usage.
* fix(tui): reset burstCount in Enter handler wizard paths
CodeRabbit review found 3 Enter-handler paths (providerWizard,
mcpAddWizard, mcpManager) that delegated without resetting burstCount.
The equivalent post-switch paths already had the reset but the
Enter-specific branches were missing it, creating a window where
stale burst state could cause false paste detection.
* fix(tui): raise paste burst threshold to >2, use injectable clock
* fix(tui): gate paste detection to Termux only, add bidirectional tests
- Paste burst detection (insert newline on rapid Enter) now only
activates under Termux (TERMUX_VERSION env var), so fast desktop
typing never false-triggers.
- Switched burst tracker from time.Now() to m.now() for deterministic
testing.
- Added 3 tests:
• TestTermuxBurstInsertsNewline — Termux + 3 chars + Enter → newline
• TestTermuxFastTypingSubmits — Termux + 2 chars + Enter → submit
• TestDesktopBurstNotAffected — Desktop + 3 chars + Enter → submit
* test: extract burst test helper, fix assertions, add reset-after-submit test
- Extracted shared burstTestModel helper + typeKeys helper to eliminate
duplication across the 3 tests (CodeRabbit nitpick).
- Added m.pending assertion to submit tests (cmd != nil alone is weak).
- Added TestBurstResetAfterSubmit verifying burstCount is properly
reset after submission — 2 chars + Enter still submits.
* test: add explicit pending check and lastKeyTime reset in burst reset test
* fix(tui): align composer-multiline guard with same burstCount > 2 threshold
* test: add TestMultilineBurstSubmits for multiline composer + 2-char burst
---------
Co-authored-by: PatrickNoFilter <[email protected]>
* fix(update): support safe symlink extraction during updates
Allow extracting symlinks from TarGz and Zip update archives to support Unix npm shim symlinks. Prevent path traversal vulnerabilities by rejecting absolute link targets and verifying that relative link targets do not escape the destination directory.
* revert(update): keep zip symlink entries rejected
Addresses jatmn's review: the stated bug (standalone update extraction
broken on Unix/macOS) is about .tar.gz archives; the zip path handles
Windows release archives. Adding zip symlink support alongside the
tar.gz fix reversed the existing zip symlink-rejection hardening
(TestExtractZipRejectsSymlinkEntry) and failed Windows CI, since
filepath.IsAbs does not reliably reject a slash-rooted target like
"/some/other/path" on Windows (which needs a drive letter or UNC
prefix to be absolute).
Reverted extractZip's symlink handling back to unconditional rejection
and removed the two zip-symlink tests that covered it. The tar.gz
symlink support and its tests (the actual fix for the reported bug)
are unchanged.
* fix(auth): persist OpenRouter API key after login
zero auth openrouter minted a key but only printed it, leaving the
provider unusable unless the user manually exported it or edited
config by hand. Now the key is saved into the credential store, the
provider profile is created/activated, and stale inline/env key
fields are cleared. Auth-failure hints also now point at zero setup
and zero auth openrouter instead of the vaguer zero auth.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(auth): sanitize minted key and roll back orphaned credential on save failure
Addresses PR review comments: trim the OpenRouter key right after
login so both the save path and the manual fallback instructions use
the sanitized value, and delete the just-written credential-store
entry if persisting the stored-key marker in config.json fails, so a
partial failure doesn't leave an orphaned secret.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
* fix(sandbox): give Windows-appropriate suggestions when blocking interactive commands
DetectInteractiveCommand already receives goos, but its remediation
text for pagers (less/more/most), process monitors (top/htop/btop/
btm), and `tail -f` hardcoded POSIX-only alternatives (cat, head,
tail -n N, ps aux) regardless of platform. On Windows none of those
exist either, so blocking e.g. `type file | more` correctly stopped
the interactive command but then pointed the model at three more
commands cmd.exe doesn't have — directly undermining the guidance
this check exists to give. Reported in the wild (screenshot showing
`more` blocked with a "use cat, head, or tail -n N" suggestion on a
Windows session).
Adds an optional windowsSuggestion per program/segment, used in
place of the POSIX suggestion when goos == "windows" — `type` for
pagers, `tasklist` for process monitors, the read_file tool for
tail -f. Non-Windows suggestions are unchanged.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* test(sandbox): cover remaining Windows suggestion overrides and fix GOOS-blind assertion
TestBashToolBlocksInteractiveCommandThroughSandbox asserted the pager
guard message always contains "cat", but on Windows it now correctly
suggests "type" — breaking Smoke (windows-latest) CI. Make the
assertion GOOS-aware. Also extend the safe_command windowsSuggestion
test table to cover most/top/btop/btm, which were missing alongside
the already-tested more/less/htop/tail -f.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
* fix(tools): block MSYS and WSL shells under the Windows sandbox
Git-for-Windows bash.exe and sh.exe are MSYS binaries: the MSYS runtime
secures its signal pipe and shared-memory sections with explicit DACLs
granting only the user, Administrators, and SYSTEM (sigproc.cc
sigproc_init -> sec_user_nih), so under the WRITE_RESTRICTED token the
second access check can never pass and startup dies with "couldn't
create signal pipe" or "CreateFileMapping <SID>.1", Win32 error 5. The
System32 WSL bash launcher fails equivalently: the restricted token
cannot connect to the WSL service (Bash/Service/CreateInstance/
E_ACCESSDENIED). Every executable a bare bash can resolve to fails, so
block bash and sh preflight like the MSYS coreutils from #476, mention
the indirect spawn paths (git hooks, git/gh credential helpers) in the
shared suggestion, detect the WSL launcher's UTF-16LE service-denied
stderr, and document the root cause in the runner's KNOWN LIMITATION
comment beside the Schannel note.
* fix(tui): align picker_test switchProviderModel call with 4-value return
* test(tools): cover sh.exe in Windows shell blocking test
#568 added a bool return to switchProviderModel (model, string, bool,
tea.Cmd) and updated every call site except picker_test.go:771, which
still assigned three values. That broke the internal/tui test build
and turned Smoke red on main. Add the missing receiver here.
* fix: gitignore Windows sandbox helpers and npm version marker
Add three missing build artifacts to .gitignore:
- /zero-windows-command-runner.exe — helper built by cmd/zero-windows-command-runner
- /zero-windows-sandbox-setup.exe — helper built by cmd/zero-windows-sandbox-setup
- /.zero-binary-version — marker file written by scripts/postinstall.mjs during npm install
These are generated files analogous to the already-ignored /zero, /zero.exe,
zero-termux-arm64, and zero-linux-sandbox entries.
* fix: gitignore the Linux zero-seccomp helper too
Addresses jatmn's review: scripts/postinstall.mjs copies zero-seccomp
next to zero-linux-sandbox, and README.md documents building it, but
.gitignore only covered the Windows helpers and zero-linux-sandbox.
A Linux source build or npm postinstall could still leave this
generated helper visible to git status.
* fix(tui): bypass toggleSidebar and toggleMouse global shortcuts when composer is non-empty
Allows standard readline/Emacs cursor movement keybindings (Ctrl+B and Ctrl+E) to work inside the prompt composer when typing a prompt. This prevents these keys from being globally intercepted to toggle the sidebar or mouse mode when the composer is populated.
* fix(tui): remove trailing whitespace, scope composer guard to default chords
Addresses jatmn's review:
- Removed a trailing-whitespace-only blank line in
keybinding_help_test.go that was failing the diff-hygiene CI check.
- The composerValue() == "" guard on toggleMouse/toggleSidebar applied
to the whole keyMatch(...) result, so a user who remaps either
binding to a chord that doesn't conflict with readline navigation
(Ctrl+E/Ctrl+B) lost that shortcut entirely while typing. The guard
now only applies when the match came through the default chord;
a remapped binding fires regardless of composer state.
Added TestRemappedToggleBindingsIgnoreComposerGuard covering both
bindings.
* fix(tui): treat an explicit default-chord config the same as unset
Addresses jatmn's follow-up review: the prior fix checked
!b.isZero() to decide whether a binding is a genuine, non-conflicting
remap, but a user can explicitly configure toggleMouse: "ctrl+e" or
toggleSidebar: "ctrl+b" (the same chord as the built-in default).
parseBinding gives that a non-zero parsedBinding, so isZero() alone
wrongly treated it as safe to bypass the composer guard, letting
Ctrl+E/Ctrl+B hijack keystrokes mid-sentence again.
requiresEmptyComposer now compares the resolved binding against the
canonical default chord directly (parsedBinding is a plain comparable
struct), so both "unset" and "explicitly re-affirmed to the same
chord" require an empty composer; only a binding that resolves to a
genuinely different chord bypasses it.
Added TestExplicitDefaultChordConfigStillRequiresEmptyComposer.
* refactor(tui): dedupe the composer-gated toggle check
CodeRabbit nitpick: the toggleMouse/toggleSidebar dispatch cases both
repeated !requiresEmptyComposer(...) || m.composerValue() == "" inline.
Extracted canFireComposerGatedToggle so both cases share one
expression.
* feat(tui): remember recent provider+model selections in /model picker
The /model picker's "Recent" section only ever showed the currently active
model, making it hard to switch back to a previously used provider+model pair
once you've moved on to something else (especially when the same model name
exists across multiple providers). Track a short automatic history of
provider-qualified switches instead: newest first, capped at 5, deduped by
provider+model pair (not model name alone), and selectable across providers
like any other picker row.
Closes Gitlawb/zero#562
Co-authored-by: Cursor <[email protected]>
* chore: gofmt fixes
* fix(tui): restore favorites one-row-per-model semantics in /model picker
Favorites de-dup had switched to the provider+model key, letting the same
favorited model ID surface once per provider. Revert to per-Value de-dup for
Favorites, and skip any model ID already shown under Favorites from Recent
and Catalog so favorited models don't reappear in a second group. Recent and
Catalog still de-dup among themselves by provider+model so cross-provider
rows remain distinct.
Also correct the recordRecentModel doc comment: in-memory history is updated
even without a user config path (only persistence is skipped), not a no-op.
Addresses Copilot review comments on #568.
* fix(tui): set OwnerProvider on registry catalog rows; dedupe recent-model normalization
- registryModelPickerItem now tags rows with OwnerProvider (the registry
entry's canonical provider), fixing the plain-registry fallback path (no
saved providers resolved any models) where two providers offering the same
model id would collide in pickerItemDedupKey and silently drop a row.
- Export config.NormalizeRecentModels and have
internal/tui/picker.go's normalizeRecentModelEntries delegate to it instead
of maintaining an independent copy of the same trim/dedupe/cap logic, so the
TUI and persisted-config normalization can never drift apart.
- Add regression tests for both.
Addresses remaining CodeRabbit review comments on #568.
* fix(tui): batch recent-model persistence into one write per switch; tighten tests
- Add recordRecentModels(pairs...), a batched form of recordRecentModel that
normalizes and persists exactly once per switch instead of twice (outgoing
+ incoming pair each triggered their own read-modify-write to
userConfigPath). handleModelCommand and switchProviderModel now call it
once with both pairs.
- picker_test.go: capture and assert switchProviderModel's status return in
TestSwitchProviderModelRecordsRecentHistory instead of discarding it, so a
failed switch fails at the actual point of failure.
- picker_test.go: also assert the persisted config (not just in-memory
recentModels) after the reorder/dedupe case in
TestModelCommandRecordsAndPersistsRecentHistory.
- picker_test.go: pin down the surviving Favorites row's OwnerProvider in
TestAssembleModelPickerItemsFavoritesDedupByModelIDAndHideFromOtherGroups,
and drop the tautological otherRecent assertion (other-model was never
added to recent, so it could never fail).
- resolver_test.go: add TestResolveIgnoresProjectOnlyRecentModels, covering
the case where only project config sets preferences.recentModels and user
config omits it — resolved value must stay empty.
Addresses remaining CodeRabbit nitpick comments on #568.
* fix(tui): route unresolvable OwnerProvider rows in-place; align recent-history keys
- choosePicker: only treat a picker row as cross-provider when its
OwnerProvider resolves to an actual saved provider. Registry-fallback
catalog rows (tagged with a provider *kind* string) and stale "Recent"
rows referencing a since-removed provider now fall back to an in-place
model switch instead of erroring "unknown provider".
- handleModelCommand/switchProviderModel: capture and record recent-history
entries using m.providerName (the resolved value recentModelPairsForPicker
pins the active row with) instead of the raw, possibly-empty provider
profile Name, so a no-name provider profile can no longer produce a
duplicate "Recent" row for the same switch.
- newModelPicker: scope the registry-fallback's active-model exclusion by
provider too, consistent with the provider-aware de-dup used elsewhere.
- recentModelPairsForPicker: delegate to config.NormalizeRecentModels
instead of reimplementing the same trim/dedup/cap logic.
- Remove the unused recordRecentModel wrapper.
- assembleModelPickerItems: build Favorites from recent/catalog directly
instead of allocating a concatenated copy of both slices.
---------
Co-authored-by: Cursor <[email protected]>
* fix(mcp): silence startup warning for unconfigured default servers
Firecrawl is seeded as an always-on default MCP server with no
credentials (config.DefaultMCPServers), so every fresh install prints
a loud "warning: MCP server firecrawl unavailable, skipped: ... HTTP
401" line on first launch, even though the user never configured or
touched it.
Add config.IsUnconfiguredDefault to detect a server whose resolved
config is byte-identical to its built-in default, thread it through
mcp.Server and mcp.SkippedServer, and skip the startup warning in
app.go when a skipped server is an untouched default. Servers the
user actually configured still warn loudly on failure.
Fixes#552
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(mcp): address review feedback on #563
- mergeMCPServer now marks disabledSet on the merged config whenever a
disabled toggle was explicitly applied, so an explicit re-enable
(e.g. `zero mcp enable firecrawl`) is no longer indistinguishable
from an untouched default even though the resolved value matches —
the toggle itself is the "configured" signal for IsUnconfiguredDefault.
- Tighten TestRegisterToolsFlagsUnconfiguredDefaultOnSkip to assert
presence in the Skipped() map before checking UnconfiguredDefault,
so a missing entry fails loudly instead of comparing zero values.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(config): close IsUnconfiguredDefault gap for redeclared default values
IsUnconfiguredDefault compared the resolved MCPServerConfig against
DefaultMCPServers() with reflect.DeepEqual. A user who explicitly
redeclares a default's exact field values in their config JSON (e.g.
copying firecrawl's default type/url verbatim, perhaps intending to add
credentials later) produced a resolved value byte-identical to the
default, so the comparison silently treated a real, deliberate config
entry as "unconfigured" — the same trap the disabled/enabled toggle
case was already patched for, just left open for every other field.
Add an unexported `configured` field to MCPServerConfig, set whenever
MCPServerConfig.UnmarshalJSON actually runs for a server key (built-in
defaults are seeded as Go struct literals and never unmarshaled, so
they start unconfigured). IsUnconfiguredDefault now requires both
!configured and the value still matching the default — the value check
remains as a fallback for callers that construct MCPServerConfig
directly rather than through the JSON/merge pipeline, where configured
is always false.
This also lets mergeMCPServer drop the disabledSet-specific
"mark as touched" workaround added for the toggle case, since
`configured` now generalizes it.
Adds TestResolveMCPExplicitRedeclareOfDefaultValuesIsNotUnconfiguredDefault
covering the redeclared-values case.
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
* fix(provider): stop dropping custom no-auth providers on restart
Custom OpenAI/Anthropic-compatible catalog entries hardcode
RequiresAuth as a template default for the setup wizard, not a fact
about the user's actual endpoint. Leaving the credential step blank
(e.g. a local llama.cpp server) still stamped the saved profile with
a guessed OPENAI_API_KEY/ANTHROPIC_API_KEY env var, and that same
static RequiresAuth flag then made the profile look like it was
missing a required credential on the next run, silently filtering it
out of /model.
Add a Custom flag to the catalog descriptor so both the wizard
(write side) and setupMissingCredentialEnv (read side) judge these
profiles by their own explicit APIKeyEnv instead of the catalog's
static default.
Fixes#555
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Co-authored-by: Pierre Bruno <[email protected]>
* fix(provider): self-heal legacy custom profiles stamped by the old wizard bug
CodeRabbit flagged that profiles saved before this fix could already
carry the phantom APIKeyEnv the old wizard stamped on every custom
provider (e.g. OPENAI_API_KEY), and the new custom-branch logic would
keep treating any non-empty APIKeyEnv as an explicit requirement —
leaving already-affected users' providers filtered out of /model even
after upgrading.
Treat an APIKeyEnv that exactly matches the catalog's own guessed
default as indistinguishable from unset, so already-broken saved
profiles self-heal without a config.json migration. A deliberately
different env var name (e.g. via --api-key-env) still means auth is
required.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Co-authored-by: Pierre Bruno <[email protected]>
* fix(provider): centralize custom-endpoint credential logic, fix two more surfaces
Copilot's review of the earlier fix found the same RequiresAuth-as-
authoritative bug in two more places that decide whether a saved
provider needs a credential:
- validateProviderRuntimeReady (zero providers check) would hard-error
a no-auth custom endpoint with "requires API key".
- providerWizardCredentialLabel and providerCredentialRequired (/providers
status, wizard summary) would still claim/warn that one was needed.
Added config.ProviderProfile.MissingCredentialEnv as the single source
of truth for this decision (custom-endpoint self-heal included), and
had setupMissingCredentialEnv and validateProviderRuntimeReady delegate
to it. providerWizardCredentialLabel and providerCredentialRequired
take the same Custom-aware fix directly, since their non-catalog
fallback path uses runtime-resolved provider-kind metadata that
shouldn't be collapsed into the canonical helper.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Co-authored-by: Pierre Bruno <[email protected]>
* fix(ci): request pinned Go toolchain directly
Co-authored-by: Pierre Bruno <[email protected]>
---------
Co-authored-by: Amp <[email protected]>
* Introduce opencode-go-anthropic that uses anthropic SDK for qwen and minimax models.
* Adjust naming to match convention format.
* Adjut naming to match convention.
* Update name for opencode-go-anthropic-compatible
* Update curated models for opencode anthropic and adjust test.
* Adjust filter for opencode anthropic provider and cover with tests.
* Adjust discovery model filtering - tolerate ModlIDAllowedForProvider rules.
* Change model descriptors to lowercase for minimax models.
* CLI call triggers model filtering for opencode-go-anthropic-compatible.
* feat: gate runtime model resolution on provider allowlist (PR #544 P2)
Apply ModelIDAllowedForProvider(profile.CatalogID, model) at profile
resolution and ResolveRuntimeMetadata so TUI /model and headless
--model overrides cannot escape the provider-scoped filter for
catalog-backed profiles such as opencode-go-anthropic-compatible.
resolveProfile is the single funnel for both New() and
ResolveRuntimeMetadata, so a single validation call in each return
path covers every caller (TUI command_center, exec, provider health,
context report, zerocommands). Restricted providers reject; the
default branch keeps unrestricted providers unchanged.
less/more/most now suggest `type` instead of cat/head/tail on Windows,
since those tools don't exist there. Drop `more` from the MSYS sandbox
suggestion too, since more is itself blocked as an interactive pager
and would send the model straight into that guard next.
detectShellCommandIssue also strips double-quoted spans and cmd.exe
caret escapes before matching the bash-style `cd /foo` pattern, so a
cd-shaped string inside a quoted argument (e.g. a commit message) isn't
mistaken for an actual command.
* ## Speech-to-text dictation
Adds voice dictation to Zero. Run `/voice` to enter hold-to-record mode, then hold **Space** to dictate and release to transcribe (run `/voice` again to turn it off so Space types normally). Pick a backend with `/stt-model`.
### Backends
- **Local (sherpa-onnx)** — offline, no API key. Auto-downloads a model via `/stt-model`; the engine binary is fetched once and shared across models. Family auto-detection covers the mainstream offline families (Whisper, transducer/Zipformer, Moonshine, Paraformer, SenseVoice, and the CTC families FireRedASR, NeMo, TeleSpeech, WeNet, Zipformer-CTC, Dolphin, Omnilingual — plus the encoder/decoder families Canary and FireRedASR-AED).
- **Cloud batch** — Groq and OpenAI Whisper. Keys resolve from a configured provider, the encrypted credential store, or the env var; a missing/invalid key is entered inline.
- **Streaming** — a warm local sherpa-onnx websocket server (live transcript as you speak) on desktop, or Deepgram / OpenAI Realtime.
### Highlights
- Live audio-reactive waveform while recording (real mic levels when streaming; a synthesized wave for batch).
- `/stt-model` download chooser: recommended models first, Live/Batch tag per row, size + download state, and a search filter.
- Streaming transcript is inserted live into the composer and committed on stop; batch inserts on stop. Insert-for-review by default (`stt.autoSubmit` to fire straight at the agent).
- The warm streaming server is released when `/voice` is turned off and on exit.
### Implementation notes
- No CGO and no bundled runtimes — every OS integration shells out via `exec.Command` with discrete argv (never a shell string).
- The streaming server manager mirrors the LSP manager's warm-subprocess lifecycle.
- OS-boundary code (recorders, transcribers, server) is unit-tested via injected fakes; real microphone / websocket behavior is covered by env-gated smoke tests.
### Testing
- `go build ./...`, `go vet ./...`, and the config / dictation / cli / tui suites pass.
- Verified end-to-end against real downloaded binaries + models (offline batch, streaming websocket server, and the model auto-download), including the FireRedASR-CTC and NeMo streaming transducer families.
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit
* **New Features**
* Added speech-to-text dictation with both batch and streaming modes, supporting local (sherpa-onnx) and cloud providers, including WAV/M4A detection.
* Introduced voice dictation mode with STT model selection, local engine/model auto-download, and an inline prompt to save cloud API keys.
* Added new TUI commands for voice/STT model selection and enabled F1–F12 key bindings.
* **Bug Fixes**
* Improved streaming dictation to preserve accumulated transcript partials and keep the editor region consistent.
* Refined TUI status/picker rendering, including clearer loading/search behavior and more reliable selection alignment.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
* fix(dictation): address CodeRabbit review feedback
- validateSTTConfig: add NumThreads bounds check
- downloadVerifyExtract: stage extraction into a sibling temp dir,
atomic-rename into destDir on success so a partial extract cannot
leave a truncated engine binary that passes fileExists() on next run
- startProcess: close pipe fds when cmd.Start() fails
- ServerManager.Shutdown: honor the caller's context deadline
- audioSpillDir: tighten any pre-existing directory to 0700
- startVariantDownload: refuse re-entry while a download is in flight
- maybeOpenSTTDownloadPicker: defer Provider=local persistence to
applyEngineComponents (commit-on-success), matching the cloud flow
- fetchSTTModelsCmd: bound with a 30s timeout client+context
- newSTTDownloadPickerFrom.isCurrent: compare path segments via
filepath.Base to avoid substring false-positives
* fix(dictation): harden recorder.Stop, Esc-cancel priority, streaming region
Three correctness issues from PR review:
1) recorder.Stop() panicked (nil-deref) when called on a StartStreaming
session because StartStreaming intentionally leaves r.proc nil. Reject
that misuse with a clear error. Termux's batch path also leaves
r.proc nil by design — keep that path working.
2) Esc was consumed by an active dictation session before the
confirming second Esc could cancel a pending run, so a user
mid-recording who double-Esc's to kill the run found the first Esc
swallowed by dictation. Gate dictation-cancel on the Esc not being
a pending-run confirm.
3) applyStreamingText hard-reset the cursor to regionStart on every
partial, yanking focus away from users editing elsewhere in the
composer, and stored regionStart/End as absolute indices that became
stale the moment the user typed or pasted before the live region.
Preserve the cursor when it's outside the region, and snapshot the
prefix BEFORE the region so the next partial can detect external
edits and shift [start,end) to stay aligned.
* feat(providers): OAuth login profiles and list-first /provider manager
Make multiple providers a first-class workflow: every OAuth login is now a
visible, switchable provider, and /provider manages the whole lifecycle.
OAuth logins become provider profiles:
- zero auth chatgpt / zero auth login <preset> and all four TUI login paths
(wizard + first-run, browser + device-code) now upsert a catalog-backed
profile via config.EnsureCatalogProvider after storing the token — without
stealing the active provider. Previously the token was stored invisibly:
the provider showed up nowhere and 'providers use' failed not-found.
- Switching to an OAuth provider no longer inlines the bearer as an API key,
which suppressed the runtime resolver (no token refresh, no Codex
chatgpt-account-id header → guaranteed 401s). The credential gate now
recognizes stored OAuth logins on keyless profiles.
- providers list / current report truthful credential state: stored keys
count as configured (APIKeySet honors apiKeyStored) and token logins show
'oauth login'; providers check accepts an OAuth login as a credential.
List-first /provider manager (TUI):
- /provider opens the saved-provider list: Enter activates via the shared
switch path, 'a' adds (existing wizard), 'e' opens a field-level editor
(endpoint, model, key, description, and safe rename — the stored key,
activeProvider, and live ZERO_PROVIDER follow the new name), 'd' deletes
with an inline confirm and stored-key cleanup. Esc walks back one level.
- Credential states resolve asynchronously so keychain reads never block
the render loop; 'stored key missing' surfaces broken keychain entries.
- Implemented as manager steps on providerWizardState so every existing
overlay gate, key route, and mouse hook covers it unchanged.
CLI + config parity:
- zero providers remove <name> (deletes the stored key, hands the active
pointer off) and zero providers rename <old> <new>.
- config.RemoveProvider / RenameProvider: read-modify-atomic-write; rename
migrates the credential-store entry write-new-then-delete-old so an
interruption can never lose the key.
* fix(providers): address PR #560 review findings
All six review findings validated as real; fixes with regression tests:
- Persist the apiKeyStored marker when a key is captured onto an existing
profile: mergeProfile deliberately ignores the marker (a project config
must not claim the user's stored keys at resolve time), so the user-config
WRITE path (UpsertProvider) now re-applies it — previously editing a key
onto an env/no-key profile stranded the secret in the credential store
while every ApplyStoredAPIKey gate skipped it. [jatmn P2]
- Let active-pointer recovery select OAuth-login providers:
firstUsableProvider now applies the same providerHasOAuthLogin check as
setupRequired/usableSavedProviders, so a stale activeProvider plus a saved
ChatGPT/xAI login recovers instead of forcing onboarding. [jatmn P2]
- Delete stored keys from the store BESIDE the config being edited:
providers remove and the TUI manager delete now use ProviderKeyStoreAt
(matching setup/rename) instead of the default-path store. [jatmn P2]
- Roll back the key migration when RenameProvider's config write fails, so
the store and config.json can't desync (key under the new name, config
still naming the old one). [CodeRabbit]
- Clear provider descriptions for real: new SetProviderDescription writes
the field verbatim (the upsert merge treats empty as unchanged); the
manager's editor routes description changes through it. [jatmn P3]
- Surface stored-key deletion failures in providers remove (stderr warning
+ keyError in the JSON payload) and the TUI delete status, instead of
letting a lingering secret read as a clean removal. [CodeRabbit]
* fix(providers): second review round — case-only rename, logout hint, test hermeticity
- Skip the credential-store migration on case-only renames (groq -> Groq):
the store normalizes names case-insensitively, so Set(new)+Delete(old)
target ONE entry and the delete removed the key that was just "moved".
The existing entry already serves the new name. [CodeRabbit]
- Point the delete flow's cleanup hint at the stored login that actually
exists: after a rename ({name:"codex", catalogID:"chatgpt"}) the token
lives under the catalog id, and `zero auth logout codex` would delete
nothing. oauthLoginName resolves the same FirstStored candidate the
runtime uses. [jatmn P3]
- Lock in APIKeyEnv semantics after a key edit: the env reference survives
as an explicit override (a SET env var wins at resolve time; the stored
key applies when it is absent). [CodeRabbit nitpick]
- Make main's new firstUsableProvider tests hermetic: they read the REAL
OAuth token store, so any developer with an actual xai/chatgpt login gets
spurious failures (found because this machine has one).
Also rebased onto main; the only conflict was comment wording in
firstUsableProvider, where the audit commit (#481) independently landed the
same OAuth-login fallback check this branch added.
* fix(tui): read stored keys from the active config's credential store
The TUI's write paths (SecureProviderProfile capture, rename migration,
manager delete) all target the store beside m.userConfigPath, but the read
paths still opened the DEFAULT-path store: profileWithCredential (feeding
the /model and manager switch gate), the model picker's discovery
credential lookup, and the manager's async credential-status probe. With a
non-default config path, a provider saved with only apiKeyStored had its
key in the config-adjacent store, so the gate rejected it as having no
usable credential and the manager showed 'stored key missing' for a
healthy profile.
All TUI stored-key reads now resolve through providerKeyStoreForPath
(config-adjacent, default-path fallback for ephemeral sessions), so the UI
reads from the same store it writes to. Regression test covers both the
credential probe and the switch path with a key seeded only beside the
config.
Also consolidates the OAuth test-store seeding on withAuthStore (now pins
ZERO_OAUTH_STORAGE=file so an inherited keyring backend can't bypass the
temp path) per review nitpick.
* fix(providers): resolve self-review findings — atomic edits, key normalization, threading
Findings from a multi-agent code review of this branch (8 finder angles,
independently verified); all confirmed against the code, several with
empirical repros.
Correctness:
- config.EditProvider: one atomic read-modify-write for the manager's Save
(rename + fields + verbatim description + stored-key marker + key
migration with write-failure rollback). Replaces the rename→upsert→
describe sequence that (a) turned a case-only rename (groq → Groq) into a
DUPLICATE profile via UpsertProvider's case-sensitive merge, and (b) could
fail halfway, leaving config.json renamed while savedProviders and
ZERO_PROVIDER still held the old name (retry then failed 'not found').
A rename now also migrates a freshly captured replacement key (captured
under the current name; the migration moves it).
- savedProviders is mutated surgically on manager delete/edit instead of
being replaced with the raw user-config list — the session list is seeded
from the RESOLVED (project-config layered) and usability-FILTERED set, so
wholesale replacement dropped project-contributed providers and
resurrected filtered stubs for the rest of the session.
- oauth.ProviderKey normalizes provider names to lower case at the single
write/lookup choke point: `zero auth login xAI` stored provider:xAI while
every candidate lookup was case-sensitive — a successful login invisible
to the runtime. Map-based presence checks lowercase to match.
- OAuth login persistence moved onto the Update goroutine (message handlers)
— a background tea.Cmd (device-code polls land minutes later) doing an
unlocked config read-modify-write raced UI-thread writes (lost updates).
The write error is now surfaced and the in-memory mirror is skipped on
failure, so a profile that never reached disk can't show as a working
provider that vanishes on restart.
UX / consistency:
- Esc inside a manager-launched add flow walks back to the provider list
from EVERY step (was: first step only; deeper steps destroyed the overlay
and the user's place). In-flight OAuth logins are invalidated on the way
out; footers say 'Esc back'.
- switchProviderModel returns a structured outcome; the manager branches on
it instead of strings.Contains(text, "Switched to") — UI copy is no longer
a control-flow contract (a provider named 'Switched to prod' could close
the manager on a refusal).
- Manager delete's keychain subprocess + OAuth lookup run in a follow-up
tea.Cmd (the confirm keypress no longer blocks the render loop); the
credential probe takes ONE token-store snapshot instead of 2 reads per
row, and resolved states carry over across reloads (no 'checking…' flash).
- Credential-state reporting converges: /provider status recognizes OAuth
logins (was 'api key: not set' + warning for a WORKING provider), doctor
uses the shared HasConfiguredCredential definition plus OAuth awareness
(its 'matching ProviderSnapshot.APIKeySet' claim is true again), and the
manager's classifier falls through a stale stored-key marker to env/OAuth
the way the runtime does.
* Fix provider manager test formatting
* fix: address bugs found in a multi-agent codebase audit
Five parallel review agents each swept a different area (self-update,
TUI keybindings, agent/session handling, sandbox/shell safety, and
auth/credential storage) and surfaced nine concrete defects, all fixed
here with regression tests:
- sandbox: grant_scope path comparisons were case-sensitive, letting a
persisted deny grant be bypassed by spelling a Windows path with
different case
- securefile/oauth: createSecretFile's Windows ERROR_ACCESS_DENIED
handling was only applied to the oauth token store, not the shared
credstore backend; both also masked the real lock-contention error
behind a misleading "file does not exist" timeout
- cli: firstUsableProvider ignored OAuth logins when picking a
fallback provider, forcing unnecessary re-onboarding for an
already-authenticated user
- sessions: exec prompt summarization truncated on a raw byte offset,
which could split a multi-byte UTF-8 rune and embed invalid UTF-8
into a resumed session's prompt
- tui: configurable keybindings had no collision detection, so a
remapped chord could silently make a hardcoded shortcut (or another
configured binding) permanently unreachable
- tools: the Windows shell-syntax pre-flight check wasn't quote-aware
and could block harmless commands whose quoted arguments merely
contained operator-like text
- update: checksum verification didn't cross-check the archive
filename it verified against the one being extracted; extractZip
didn't reject symlink-mode entries like extractTarGz already did;
replaceBinary's restore-on-failure rename had no retry, risking a
permanently missing binary on a transient Windows file lock
* fix: close remaining PR 481 review findings
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
* fix(tools): drop stale ; test case after rebasing onto main's MSYS check
Rebasing fix/bug-hunt-findings onto main pulled in #476, which replaced
this file's regex-based windows_shell_syntax detection with a
segment/word-anchored check and deliberately stopped treating ; as a
command separator on Windows (cmd.exe doesn't split on ; the way bash
does). detectShellCommandIssue itself came out of the rebase unchanged
from main (our quote-masking version was fully superseded by #476's
more thorough segment-based approach), but the quoted-content test
this PR added still asserted the old bash-style semantics for
`echo "foo" ; ls -la`, which now correctly returns no issue since it's
a single echo invocation with literal arguments under cmd.exe, not a
real ls invocation.
---------
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
The home screen orientation line now leads with the build version
(v0.2.0 · cwd · branch · model). Release builds already inject
internal/cli.version via ldflags; dev builds show "dev".
* feat(tui): /loop foundation — command parsing, loop state, safety bounds
First slice of the /loop feature (#501): the self-contained core with no TUI
wiring yet. parseLoopCommand handles the leading-interval, trailing-"every",
bare-self-paced, and list/stop forms; loopState models one session-scoped loop
with doom-loop + iteration-cap fields; interval/self-pace clamps and duration
formatting round it out, with unit tests. Because Zero drives loops off a Go
timer rather than a cron backend, arbitrary intervals (90s, 7m) are supported
with no whitelist limitation.
* feat(tui): wire /loop into the turn lifecycle — dispatch, idle tick, continuation
Second slice of #501. Adds the controller and wires it in:
- /loop command registered (commands.go) and dispatched to handleLoopCommand.
- Loop state on the model (loops, activeLoopID, loopSeq/loopTicking poll guards,
loopNextWake/loopDone self-pace channel).
- An idle poll tick (loopTickMsg, ~1s) fires the earliest due loop only when the
session is idle — mirrors the launchQueuedMessageIfReady guard so loops and a
user's queued prompt never collide; a self-perpetuating tea.Tick that stops when
no loops remain.
- The agentResponseMsg completion seam advances a loop iteration (schedule next
wake for fixed, model delay for self-paced) with doom-loop, iteration-cap, and
done stop conditions, restarting the ticker if other loops remain.
- /loop list|stop|stop all, footer summary + list rendering, and a guard that only
a prompt or custom /command may loop (not a built-in).
Controller tests cover start/advance/self-paced-delay/done/doom/cap/stop/busy.
* feat(tui): /loop self-paced adaptive cadence + footer summary + /clear note
Third slice of #501.
- Self-paced loops now use an adaptive cadence (2m early -> 30m heartbeat as
iterations accrue), with the loopNextWake channel kept as an override for a
future explicit model signal.
- Persistent footer segment "↻ N loops · next 3:05pm" while loops are active.
- /clear notes that loops keep running (it wipes the transcript, not the session)
so a loop never silently fires into a cleared screen.
- Tests for adaptive cadence + footer summary.
* test(tui): drive /loop end-to-end through Update (submit + stale tick)
* docs(readme): add /loop to the slash-command table
* fix(tui): stop looping on repeated failure + clear loop tag on cancel
Addresses CodeRabbit review on #502:
- advanceLoop now counts consecutive failed iterations (loopMaxFailures=3) and
stops the loop, so a loop whose turns keep erroring no longer churns to the
iteration cap (the doom guard only sees identical non-empty answers).
- cancelRun clears activeLoopID and re-arms the interrupted loop for its next
cadence, so a cancelled iteration is not left running forever and the next
unrelated turn is not misattributed as that loop's completion.
* fix(tui): finish /loop v1 — real self-paced completion, session scope, leave gate
Response to the review. The self-paced path was the real gap: it advertised
model-chosen completion but nothing ever wrote the done/next-wake fields, so it
was just adaptive polling dressed up. Replaced that with an actual control
channel — each self-paced iteration runs a short maintenance playbook and ends
its reply with a control line (LOOP: DONE / LOOP: CONTINUE [interval]) that
advanceLoop parses to stop the loop or pick the next wake. The two dead model
fields are gone; done and next-wake now come from the model's output.
Rest of the review:
- loops stop on a session switch (/new, /resume) instead of firing the previous
conversation's prompt into the new one
- validateLoopTarget resolves custom /commands up front and rejects unknowns, so
a mistyped `/loop 5m /nope` isn't scheduled and then re-sent as literal text
- a one-shot confirm gates /clear and /quit while loops are active
- age-based auto-expire (24h) next to the iteration cap
- the two CodeRabbit test nits (HasPrefix footer check, bare `/loop stop` on a
single loop)
Hardening from a self-review pass over the above:
- normalize CRLF and allow a trailing note on the control line, so
`LOOP: DONE — all clean` and \r\n replies still stop the loop
- disarm the leave-confirm before the queue/exit early returns, so an interposed
prompt can't leave it falsely armed
- a loop no longer fires behind an open picker/modal; a run launched behind the
/resume picker would otherwise complete into whatever session you switch to
* fix(tui): address /loop review round 2 — ticker re-arm, self-paced command, resume guard
- stopLoop re-arms the poll ticker after removing one loop, so stopping one of
several no longer strands the rest with no tick scheduled (jatmn).
- a bare self-paced /command is rejected with a pointer to the interval form:
self-paced drives a free-form goal via the playbook + LOOP: control line, which
a custom command's expanded template never carries, so it would silently
degrade to adaptive polling (jatmn).
- /resume only tears loops down on an actual session-id change; /resume latest or
/resume <currentID> can resolve to the already-active session, whose loops are
its own, not a "previous" session's (CodeRabbit).
- tests: skipped-behind-modal loop stays scheduled, footer carries the next-wake
time, the confirmed /clear actually wipes the transcript, plus multi-loop stop
keeping the ticker alive and self-paced-/command rejection.
* docs(readme): clarify /loop runs a prompt or custom command, not built-ins
2026-07-06 11:42:46 +08:00
1554 changed files with 263300 additions and 14372 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.
# Probe the exact URLs postinstall will build (internal/release/release.go
# asset-name scheme) as an anonymous client. This also blocks publishing
# while the repo is still private — private release assets 404 without
# auth, exactly as they would for an installing user.
# Probe the exact URLs the wrapper's first-run downloader builds
# (internal/release/release.go asset-name scheme) as an anonymous client
# — install.sh/install.ps1 and `zero update` read them too. This also
# blocks publishing while the repo is still private: private release
# assets 404 without auth, exactly as they would for an installing user.
- name:Verify release assets are downloadable
shell:bash
run:|
@@ -173,12 +186,30 @@ jobs:
"zero-v${version}-windows-x64.zip"; do
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
if ! curl -fsSLI -o /dev/null "$url"; then
echo "::error::${url} is not publicly downloadable — npm postinstall would fail. Is the repo public and are all assets uploaded?" >&2
echo "::error::${url} is not publicly downloadable — the wrapper's fallback downloader and install.sh would fail. Is the repo public and are all assets uploaded?" >&2
exit 1
fi
done
done
echo "all release assets for v${version} are publicly downloadable."
description:Extending Zero — how to write AGENTS.md files, custom specialists, skills, hooks, MCP servers, and plugins for the open-source CLI coding agent.
Zero is an open-source terminal coding agent. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
If you only want to *use* Zero, the [README](README.md) is enough. This page is for the other three jobs:
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
2. Add new specialist sub-agents.
3. Wire Zero into the rest of your toolchain (MCP, skills, hooks, plugins).
## 1. Drop a project `AGENTS.md`
When Zero starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`).
Accepted file names, in priority order at each level:
| Path | Notes |
| --- | --- |
| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. |
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
```markdown
# Project conventions for <your project>
-Build with `make`, not `go build` directly.
- Tests live next to the source file (`foo_test.go` next to `foo.go`).
- Run `make lint` before opening a PR.
-Never edit files under `third_party/` — those are vendored.
```
Tips:
- Keep each file under ~8 KiB. Zero caps the **total** across all matched files at 32 KiB; everything past the cap is dropped.
- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter".
- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use `config.json` for those.
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
### Personal guidelines, across every project
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\Roaming\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
## 2. Custom specialists
Specialists are Zero's sub-agents. Three scopes, in priority order:
| Scope | Path | Shared? |
| --- | --- | --- |
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
| Project | `./.zero/specialists/*.md` | yes — the repo team |
Project overrides user overrides built-in when names collide.
A specialist is a markdown manifest with frontmatter and a system prompt:
```markdown
---
description:Reviews API changes for breaking-change risk and missing tests.
tools:read-only,plan
---
You review API changes. For every changed hunk in `internal/api/` or any file
that ends in `_api.go`:
1. Confirm the public signature is backward-compatible, or note the breaking
change explicitly with the migration path.
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
the new behaviour is exercised.
3. Flag any new exported symbol without a doc comment.
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
```
CLI management (the prompt is passed inline via `--prompt`):
```bash
zero specialist list
zero specialist show api-reviewer
zero specialist create api-reviewer \
--project \
--description "Reviews API changes"\
--tools read-only,plan \
--prompt "$(cat api-reviewer.md)"
zero specialist edit api-reviewer --project
zero specialist delete api-reviewer --project
zero specialist path # prints the resolved specialists directory
```
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](docs/SPECIALISTS.md).
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
## 3. Skills
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Skills are **user-level only** in this version — there's no project-scoped skill directory yet, so anything you want shared with the team goes in `AGENTS.md` (section 1) or as a hook (section 4).
Discovery root: `$ZERO_SKILLS_DIR` → `$XDG_DATA_HOME/zero/skills` → `~/.local/share/zero/skills/`. A missing directory is fine — Zero just reports "no skills".
```
~/.local/share/zero/skills/
run-benchmarks/
SKILL.md
write-changelog/
SKILL.md
```
`SKILL.md` format:
```markdown
---
description:Run the project's benchmark suite and summarize the deltas.
---
# Run benchmarks
1.`make bench` — captures the wall-clock and RSS before and after.
2.`benchstat before.txt after.txt` — diffs the two.
3. Report any regression > 5% with the function name and the previous value.
```
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. If two skills declare the same name, the one in the lexicographically-first directory wins — duplicates are dropped silently. Plugin-declared skills (section 6) are not yet merged into the loader, so a `skills:` entry inside a plugin's `plugin.json` is not visible to the `skill` tool today.
The `skill` core tool lets the agent load any discovered skill by name.
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
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.
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
## 5. MCP — Model Context Protocol
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
### As a client — configure MCP servers in `config.json`
zero mcp add docs --type stdio -- docs-mcp --port 7777
zero mcp add github --type http --url https://api.example.com/mcp \
--header "Authorization=Bearer YOUR_TOKEN_HERE"
zero mcp list
zero mcp check docs
zero mcp remove github
zero mcp oauth login github
```
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
- A wrapper script that sources the secret and execs the real command.
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
### As a server — expose Zero's tools to another agent
```bash
zero serve --mcp
```
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
## 6. Plugins
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
- User: `~/.config/zero/plugins/<id>/`
- Project: `./.zero/plugins/<id>/`
Each plugin has a `plugin.json` manifest:
```json
{
"id":"github-pr-review",
"name":"GitHub PR Review",
"description":"Adds review tools for GitHub PRs.",
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
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.
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above. Skills declared inside a plugin's `plugin.json` are not yet merged into the `skill` tool's discovery (see section 3).
## 7. Configuration locations
Three layers, applied in order (later layers override earlier ones):
| Layer | Path | Notes |
| --- | --- | --- |
| Built-in defaults | compiled in | Lowest priority. |
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
## 8. End-to-end example
A team that wants every contributor's Zero to behave the same way commits:
* **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))
* **update:** recognise Homebrew installs and add mise, go install docs ([#910](https://github.com/Gitlawb/zero/issues/910)) ([01c3498](https://github.com/Gitlawb/zero/commit/01c3498205e69d26e5f929225f9455b276444f39))
* use Exa as default search provider ([#926](https://github.com/Gitlawb/zero/issues/926)) ([1ec7219](https://github.com/Gitlawb/zero/commit/1ec7219aee7b2840481ab0905f131644e27d2a4e))
* **zerogit:** auto-create a conventional branch before push/pr on default branch ([#671](https://github.com/Gitlawb/zero/issues/671)) ([d065467](https://github.com/Gitlawb/zero/commit/d065467cd3ec3ed91c5dc60554c36fc4b4d6de4c))
### Bug Fixes
* **acp:** three defects a desktop ACP client hits ([#915](https://github.com/Gitlawb/zero/issues/915)) ([a78c6c3](https://github.com/Gitlawb/zero/commit/a78c6c357e1d21146d7cd2e8030a754e06f7f1f5))
* bump Go to 1.26.6 for stdlib vulnerability fixes ([#903](https://github.com/Gitlawb/zero/issues/903)) ([dc15e82](https://github.com/Gitlawb/zero/commit/dc15e822987d8d7de467c62c375518f435b74d8c))
* **daemon:** clean up child after startup timeout ([#774](https://github.com/Gitlawb/zero/issues/774)) ([9c29209](https://github.com/Gitlawb/zero/commit/9c292092a26c9092fd873d072b0af1b0d9d39ef0))
* **dictation:** redact API keys from streaming transcriber errors ([#852](https://github.com/Gitlawb/zero/issues/852)) ([64a783b](https://github.com/Gitlawb/zero/commit/64a783b08caa2c7b21f188033b9ed84a14e00e84))
* **agent:** add PermissionModePlan for interactive read-only planning ([#853](https://github.com/Gitlawb/zero/issues/853)) ([0176ee4](https://github.com/Gitlawb/zero/commit/0176ee49ffddcff6f7edf6ae2ade4967e5b22cc3))
* **providers:** live model lists for OpenRouter and OpenGateway ([#860](https://github.com/Gitlawb/zero/issues/860)) ([fb4f293](https://github.com/Gitlawb/zero/commit/fb4f293c20eb860ca388a10d22fd566ce282ba1f))
* **tools:** give tool results an image channel and add view_image ([#843](https://github.com/Gitlawb/zero/issues/843)) ([cd0eb19](https://github.com/Gitlawb/zero/commit/cd0eb194e4df4b844785796c0701216740a56344))
* **tui:** replace /retitle with local /rename ([#826](https://github.com/Gitlawb/zero/issues/826)) ([5886354](https://github.com/Gitlawb/zero/commit/588635487f22ef4d62d350140a219b7b8bb24119))
### Bug Fixes
* **agent:** end the additional_permissions retry loop ([#864](https://github.com/Gitlawb/zero/issues/864)) ([4ca4fa7](https://github.com/Gitlawb/zero/commit/4ca4fa75d31b139dbe55a3badf17da86892ab001))
* **agent:** tell the user a prefix approval leaves the sandbox ([#885](https://github.com/Gitlawb/zero/issues/885)) ([2ec5c3a](https://github.com/Gitlawb/zero/commit/2ec5c3ae6b8fb543887bdeafdb34c4da057b8d36))
* **cli:** report MCP servers that failed to start ([#822](https://github.com/Gitlawb/zero/issues/822)) ([#827](https://github.com/Gitlawb/zero/issues/827)) ([021281e](https://github.com/Gitlawb/zero/commit/021281ebbab3f2bbe6a77b2dab4bf121bcfe6b52))
* **lsp:** dispatch notifications off read loop ([#759](https://github.com/Gitlawb/zero/issues/759)) ([936aa4e](https://github.com/Gitlawb/zero/commit/936aa4e77cf49738560a89395b8ba4d22263a396))
* **mcp:** name the non-text blocks a tool result drops ([#874](https://github.com/Gitlawb/zero/issues/874)) ([027a670](https://github.com/Gitlawb/zero/commit/027a67080bea511b876c284bc911ef011eceb308))
* **sandbox:** deny reads of Zero credential stores ([#681](https://github.com/Gitlawb/zero/issues/681)) ([55fa05f](https://github.com/Gitlawb/zero/commit/55fa05f0ab5a01b495345d43464606cd19a31b4f))
* **sandbox:** stop the Windows write jail honouring Everyone-granted paths ([#865](https://github.com/Gitlawb/zero/issues/865)) ([91b413c](https://github.com/Gitlawb/zero/commit/91b413c514cc1607d5b4e7936780d40569239814))
* **secrets,worktrees:** fix secret redaction leakage and prune stale worktrees ([#855](https://github.com/Gitlawb/zero/issues/855)) ([ff62f73](https://github.com/Gitlawb/zero/commit/ff62f73b7f4dc5e2472db21acba43cf6140c06d1))
* **specialist:** make overwrites atomic ([#757](https://github.com/Gitlawb/zero/issues/757)) ([30f6d8f](https://github.com/Gitlawb/zero/commit/30f6d8f591a0b81638b4182ff56c86c8a50a5c70))
* suppress gosec G204 warning for trusted local config commands ([#848](https://github.com/Gitlawb/zero/issues/848)) ([c52db05](https://github.com/Gitlawb/zero/commit/c52db055b0b213758a33c5c5627a411e052f8de4))
* **tui:** a copied NUL byte no longer panics the TUI on Windows ([#876](https://github.com/Gitlawb/zero/issues/876)) ([cae0269](https://github.com/Gitlawb/zero/commit/cae0269e77e7a0a493cf799b41c653c9ff9a19de)), closes [#875](https://github.com/Gitlawb/zero/issues/875)
* **tui:** exclude permission waits from turn time ([#820](https://github.com/Gitlawb/zero/issues/820)) ([15eb16f](https://github.com/Gitlawb/zero/commit/15eb16f736f903c94203207032f98aacbcf06318))
* **tui:** fall back to CellMotion where AllMotion is unreliable ([#872](https://github.com/Gitlawb/zero/issues/872)) ([7fbd864](https://github.com/Gitlawb/zero/commit/7fbd864e91433441489902571ea394ebc5b7bd0d))
* **tui:** fill alt-screen frame with theme panel background ([#850](https://github.com/Gitlawb/zero/issues/850)) ([020c28e](https://github.com/Gitlawb/zero/commit/020c28eaf95e2f38853f99fc06a13cd1568bf3c9))
* use OAuth credentials in provider health checks ([#828](https://github.com/Gitlawb/zero/issues/828)) ([d37de92](https://github.com/Gitlawb/zero/commit/d37de9214b1ba86542f4f5ef3b060bb96849763b))
### Performance Improvements
* compact common test output ([#846](https://github.com/Gitlawb/zero/issues/846)) ([edf660a](https://github.com/Gitlawb/zero/commit/edf660ab85131f90a452cee51dc2c3a5cf62d632))
* **providers:** add Fireworks AI provider preset ([#772](https://github.com/Gitlawb/zero/issues/772)) ([58daea7](https://github.com/Gitlawb/zero/commit/58daea7a482927e5ab35afaf97761c60bb35e113))
* **skills:** report hash drift in skill info ([#795](https://github.com/Gitlawb/zero/issues/795)) ([9fbdc34](https://github.com/Gitlawb/zero/commit/9fbdc341551601c13434cf7b9b6f5e4ea4c483b1))
### Bug Fixes
* **acp:** interrupt idle reads on cancellation ([#782](https://github.com/Gitlawb/zero/issues/782)) ([15b7a54](https://github.com/Gitlawb/zero/commit/15b7a5475cfeefddbc59634551451a8872b996b7))
* **cli:** include plugins info in shell completions ([#794](https://github.com/Gitlawb/zero/issues/794)) ([fa8734f](https://github.com/Gitlawb/zero/commit/fa8734f17927de8832dfe041e043cfb86f93d12a))
* **config:** kill provider-command process tree via job object on Windows ([#690](https://github.com/Gitlawb/zero/issues/690)) ([5c58256](https://github.com/Gitlawb/zero/commit/5c58256abc29c185b449f8ce79647eb3fae61882))
* **config:** make the provider-command timeout an upper bound ([#811](https://github.com/Gitlawb/zero/issues/811)) ([#813](https://github.com/Gitlawb/zero/issues/813)) ([1c38e8c](https://github.com/Gitlawb/zero/commit/1c38e8cba50db255f3cda8b7cf4298712b2933e1))
* **config:** stop billing provider-command fixtures to the 5s timeout ([#810](https://github.com/Gitlawb/zero/issues/810)) ([f5fa4ff](https://github.com/Gitlawb/zero/commit/f5fa4ffc64537897aa78a56edca36d8f1dacd021))
* **providers:** retry provably pre-send transport failures ([#447](https://github.com/Gitlawb/zero/issues/447)) ([#750](https://github.com/Gitlawb/zero/issues/750)) ([8acd42a](https://github.com/Gitlawb/zero/commit/8acd42a3bd743a35807d762f23a33fb61c171839))
* **sandbox:** add platform-specific write lock around grant state file ([#752](https://github.com/Gitlawb/zero/issues/752)) ([#755](https://github.com/Gitlawb/zero/issues/755)) ([88fb7b6](https://github.com/Gitlawb/zero/commit/88fb7b6192c1ac8173089967d5e9119f92d7a101))
* **swarm:** close lifecycle admission on shutdown ([#776](https://github.com/Gitlawb/zero/issues/776)) ([bdbda89](https://github.com/Gitlawb/zero/commit/bdbda89f3e42d93e8058ab3e1e9ef402659530cd))
* **tui:** fix rendering corruption over multipass + Windows Terminal ([#709](https://github.com/Gitlawb/zero/issues/709)) ([5bb1bc1](https://github.com/Gitlawb/zero/commit/5bb1bc11c3188dcea7709ff07c8b25dda3541de8))
* **tui:** paste clipboard images with Ctrl+V ([#534](https://github.com/Gitlawb/zero/issues/534)) ([#807](https://github.com/Gitlawb/zero/issues/807)) ([ec3b822](https://github.com/Gitlawb/zero/commit/ec3b8225050eb62f0f41a9582ed2881d2f9dcc8e))
* **tui:** reset quiet clock for new runs ([#799](https://github.com/Gitlawb/zero/issues/799)) ([a4a805a](https://github.com/Gitlawb/zero/commit/a4a805a6c82c51cb255e909bd87ca183c8805cf2))
* **windows:** use PowerShell for command execution ([#804](https://github.com/Gitlawb/zero/issues/804)) ([518d2e5](https://github.com/Gitlawb/zero/commit/518d2e53ba3fa073090a9b41c278face85b59b64))
* **plugins:** add zero plugins info command ([#773](https://github.com/Gitlawb/zero/issues/773)) ([2479884](https://github.com/Gitlawb/zero/commit/2479884dea49580c853a53536c3bbab22ce8a2bf))
* **sandbox:** disable the sandbox via config ([#687](https://github.com/Gitlawb/zero/issues/687)) ([#746](https://github.com/Gitlawb/zero/issues/746)) ([a21a052](https://github.com/Gitlawb/zero/commit/a21a052a4a32ce9cb3c93c94350cd24e138532cd))
* **sandbox:** unify command execution and enforcement ([#781](https://github.com/Gitlawb/zero/issues/781)) ([96859c9](https://github.com/Gitlawb/zero/commit/96859c9bd16f6dad4e332efc7e68178b9116118a))
* **tui:** add /undo as an alias for /rewind ([#698](https://github.com/Gitlawb/zero/issues/698)) ([#747](https://github.com/Gitlawb/zero/issues/747)) ([8c6d302](https://github.com/Gitlawb/zero/commit/8c6d3022bd7801eaad6a0440bb21a27221887297))
* **lsp:** make the real-gopls check opt-in so a broken gopls can't fail the suite ([#684](https://github.com/Gitlawb/zero/issues/684)) ([#766](https://github.com/Gitlawb/zero/issues/766)) ([b1f4173](https://github.com/Gitlawb/zero/commit/b1f41735a7f6b7928a1875e986a4dca39d106cb9))
* make extension installs transactional ([#762](https://github.com/Gitlawb/zero/issues/762)) ([baa4be1](https://github.com/Gitlawb/zero/commit/baa4be13ac5321da4e9f53e864dd1cd395481200))
* **oauth:** refuse redirects on credential POSTs ([#729](https://github.com/Gitlawb/zero/issues/729)) ([#741](https://github.com/Gitlawb/zero/issues/741)) ([974fc03](https://github.com/Gitlawb/zero/commit/974fc036c2f9a194722f2e8fddbb4fdbf797effe))
* **oauth:** validate discovered endpoints before merge/use ([#511](https://github.com/Gitlawb/zero/issues/511)) ([#739](https://github.com/Gitlawb/zero/issues/739)) ([ce4a996](https://github.com/Gitlawb/zero/commit/ce4a996ffac4482e704f0fd61b3e442398fb2401))
* **perfbench:** absolutize the bench binary and make errored tasks first-class ([#730](https://github.com/Gitlawb/zero/issues/730)) ([dbd9443](https://github.com/Gitlawb/zero/commit/dbd94430143df6754d68551d1028ad8f15b82f1b))
* **perfbench:** grant write tools so mutating tasks measure real edits ([#763](https://github.com/Gitlawb/zero/issues/763)) ([e1975c1](https://github.com/Gitlawb/zero/commit/e1975c1b396b3236ed648870230b4f85863b1d03))
* **perfbench:** keep the stamped answer file out of negative oracle greps ([#737](https://github.com/Gitlawb/zero/issues/737)) ([015452c](https://github.com/Gitlawb/zero/commit/015452c1c98a39eabb021324182094e188a8bd47))
* **providers:** stop "provider not found" for env-derived profiles ([#716](https://github.com/Gitlawb/zero/issues/716)) ([4cbd144](https://github.com/Gitlawb/zero/commit/4cbd144d11e5cb67bc5fea46f5b294562dac7a1a))
* **sandbox:** AST second opinion for interactive-command bypasses ([#473](https://github.com/Gitlawb/zero/issues/473)) ([#745](https://github.com/Gitlawb/zero/issues/745)) ([f079b90](https://github.com/Gitlawb/zero/commit/f079b90f82dac7b7ae0864b279dc9094e31a6627))
* **sandbox:** bind Windows elevated ACL setup to one no-follow handle ([#765](https://github.com/Gitlawb/zero/issues/765)) ([4945684](https://github.com/Gitlawb/zero/commit/4945684fa26aa5994eda59dcabedc817423c535d))
* **tools:** give write_stdin's invalid-session errors the same recovery guidance ([#749](https://github.com/Gitlawb/zero/issues/749) follow-up) ([#768](https://github.com/Gitlawb/zero/issues/768)) ([da9fb50](https://github.com/Gitlawb/zero/commit/da9fb50f549f6701eb136fc99f6178d0772d9334))
* **tools:** read_file recovers a backwards line range instead of erroring ([#779](https://github.com/Gitlawb/zero/issues/779)) ([89bdc67](https://github.com/Gitlawb/zero/commit/89bdc6719a1e1b3a3ef0e36b91a7839b3efdfba9))
* **tui:** model rows labelled by id when the description is prose; keep the sidebar under the / palette ([#775](https://github.com/Gitlawb/zero/issues/775)) ([b30c397](https://github.com/Gitlawb/zero/commit/b30c3971b40b06ba52ad649ba9dc0ad560d4be4c))
* **tui:** stop the permission card clashing on cool themes ([#778](https://github.com/Gitlawb/zero/issues/778)) ([722bb31](https://github.com/Gitlawb/zero/commit/722bb3121682d9cd9cd4bc6c127e9014d719262a))
* **aimlapi:** AI/ML API provider with guided onboarding (top-up + key issuance) ([#655](https://github.com/Gitlawb/zero/issues/655)) ([6b9c2f0](https://github.com/Gitlawb/zero/commit/6b9c2f0c083e02bcd3aad68fb7af0501a9e7cd61))
* **cli:** show ZERO wordmark on --version ([#673](https://github.com/Gitlawb/zero/issues/673)) ([9acb411](https://github.com/Gitlawb/zero/commit/9acb4113cc3337b3f361a16278e9cf11ca105e34))
* **tui:** Ctrl+X leader chords and emacs menu navigation ([#699](https://github.com/Gitlawb/zero/issues/699)) ([7f669f4](https://github.com/Gitlawb/zero/commit/7f669f455021be51319ee3b8298cd48a17f745c7))
* **tui:** press up to edit queued messages ([#656](https://github.com/Gitlawb/zero/issues/656)) ([4c986d3](https://github.com/Gitlawb/zero/commit/4c986d327b5ed26338d295c23bea5839681db8d0))
### Bug Fixes
* **acp:** make truncateHint rune-safe ([#614](https://github.com/Gitlawb/zero/issues/614)) ([ddc4927](https://github.com/Gitlawb/zero/commit/ddc4927aac5544bf4dd2c46615aeda5a81c96576))
* **agent,tui:** resolve git branch detection when starting Zero in subdirectories ([#613](https://github.com/Gitlawb/zero/issues/613)) ([0184581](https://github.com/Gitlawb/zero/commit/0184581ec234ea414e0a47bc33e8b0f4ddfb497b))
* **agent:** raise default and deep-mode turn budgets ([#650](https://github.com/Gitlawb/zero/issues/650)) ([635c93a](https://github.com/Gitlawb/zero/commit/635c93af51ebc20f3e0917917e55a79edfe27c35))
* **cli:** prevent consuming positional arguments as flag values ([#619](https://github.com/Gitlawb/zero/issues/619)) ([5b4f48d](https://github.com/Gitlawb/zero/commit/5b4f48d2dcb66402c13bde0c3cfe9c9371da19fb))
* **config:** enforce MCP trust boundary so project config cannot override user disable ([#609](https://github.com/Gitlawb/zero/issues/609)) ([4d8c31c](https://github.com/Gitlawb/zero/commit/4d8c31cf16a3080344e3be7039408fcae71c075d)), closes [#512](https://github.com/Gitlawb/zero/issues/512)
* **config:** surface unknown/typo'd config fields instead of silently dropping them ([#645](https://github.com/Gitlawb/zero/issues/645)) ([893b7b4](https://github.com/Gitlawb/zero/commit/893b7b424cc203a2fcf92327a4e25c84286a90e0))
* **hooks:** fail closed on launch failures for beforeTool hooks ([#629](https://github.com/Gitlawb/zero/issues/629)) ([dc06fe7](https://github.com/Gitlawb/zero/commit/dc06fe72caf45f72d2cba1e8a835c0f5b405c1e8))
* **hooks:** run sessionEnd hooks after Esc/Ctrl+C interrupts ([#606](https://github.com/Gitlawb/zero/issues/606)) ([824ecdb](https://github.com/Gitlawb/zero/commit/824ecdbcf9c467c35ef4e2666770fdadcb5bf402))
* **keyring:** pass generic password via stdin on macOS ([#574](https://github.com/Gitlawb/zero/issues/574)) ([91ea6de](https://github.com/Gitlawb/zero/commit/91ea6ded7503538834a84d090f78670a363c62d3))
* **lock:** prevent POSIX lock file overwrite and leak on Windows/Unix ([#628](https://github.com/Gitlawb/zero/issues/628)) ([da41c3a](https://github.com/Gitlawb/zero/commit/da41c3a75b782d6e0836fe13346321e40a90fbb4))
* **sandbox:** remove windowsWriteRestricted flag to fix DenyRead bypass ([#612](https://github.com/Gitlawb/zero/issues/612)) ([3d96ac7](https://github.com/Gitlawb/zero/commit/3d96ac7e55c760a97f28c0e6ceaf1ec3b4ab717a))
* **sandbox:** scrub dynamic credential env vars ([#682](https://github.com/Gitlawb/zero/issues/682)) ([9043bae](https://github.com/Gitlawb/zero/commit/9043baedcff7776c7373645b57563cac06b31847))
* **sandbox:** scrub sensitive credentials from sandbox environment ([#660](https://github.com/Gitlawb/zero/issues/660)) ([6fc1220](https://github.com/Gitlawb/zero/commit/6fc1220f6ac66fb3ae67b637cbbed7068d2213c0))
* **sandbox:** unblock git fetch/commit/add under the write-restricted sandbox ([#654](https://github.com/Gitlawb/zero/issues/654)) ([5c4815a](https://github.com/Gitlawb/zero/commit/5c4815a66ed07d9cf90b825adfd936d3ac07639d))
* **sandbox:** use WRITE_RESTRICTED token when no DenyRead paths are configured ([#658](https://github.com/Gitlawb/zero/issues/658)) ([a5d2e32](https://github.com/Gitlawb/zero/commit/a5d2e327c8681671aa8a9e5378801215b747edcf))
* **securefile,credstore:** call Sync on temp file before close and rename ([#631](https://github.com/Gitlawb/zero/issues/631)) ([212734a](https://github.com/Gitlawb/zero/commit/212734adf3b5f982e22385161205aa1afe4634fe))
* **securefile:** reclaim stale lock files to prevent permanent DOS ([#615](https://github.com/Gitlawb/zero/issues/615)) ([8536cc8](https://github.com/Gitlawb/zero/commit/8536cc87f7a885f9e436d6ef28f5f325201623dc))
* **swarm:** wait for job.Runs directly in scheduler skip test ([#667](https://github.com/Gitlawb/zero/issues/667)) ([1bb6b57](https://github.com/Gitlawb/zero/commit/1bb6b5745af90321d1e657a12a1976cded5dd1bd))
* **tools:** classify silent wrapped Windows command failures as sandbox denials ([#659](https://github.com/Gitlawb/zero/issues/659)) ([8bd9742](https://github.com/Gitlawb/zero/commit/8bd9742fa95c41b93ea4e718628aed0ff3ae9dd0))
* **tools:** preserve SysProcAttr during PTY fallback ([#618](https://github.com/Gitlawb/zero/issues/618)) ([f78b36c](https://github.com/Gitlawb/zero/commit/f78b36c770daa4577a9f99265b18a354454e36eb))
* **tui:** stop the composer cursor blinking while typing or unfocused ([#672](https://github.com/Gitlawb/zero/issues/672)) ([2b42cd5](https://github.com/Gitlawb/zero/commit/2b42cd567b96d6f7e0818594e53ff31cce1e42e9))
* **windows:** resolve absolute path for taskkill to prevent hijacking ([#617](https://github.com/Gitlawb/zero/issues/617)) ([2db00ee](https://github.com/Gitlawb/zero/commit/2db00ee3d57db97e0fbe23cb8628e8bcb47f6f09))
* **modelregistry:** infer reasoning efforts for Hunyuan and vendor-prefixed model ids ([#599](https://github.com/Gitlawb/zero/issues/599)) ([92a92ce](https://github.com/Gitlawb/zero/commit/92a92ceb29dc63f5216ab140ef9a6dd9afe17df8))
* **tui:** show CLI version on the startup home screen ([#538](https://github.com/Gitlawb/zero/issues/538)) ([fd69233](https://github.com/Gitlawb/zero/commit/fd69233e334f1823a06b5794085a9255b3abdfa8))
* address bugs found in a multi-agent codebase audit ([#481](https://github.com/Gitlawb/zero/issues/481)) ([008bc9b](https://github.com/Gitlawb/zero/commit/008bc9b3f3ba13c7d4822b9559b020f381ff555b))
* **agent:** keep tools exposed for max-turn finalization ([#533](https://github.com/Gitlawb/zero/issues/533)) ([3f0503b](https://github.com/Gitlawb/zero/commit/3f0503bc2312ae29d5ade784d8824dc9a3524958))
* **auth:** persist OpenRouter API key after login ([#595](https://github.com/Gitlawb/zero/issues/595)) ([2a062aa](https://github.com/Gitlawb/zero/commit/2a062aa4014c6cd5e20a57dad4a685f88966f109))
* bump Go to 1.26.5 for crypto/tls fix (GO-2026-5856) ([#607](https://github.com/Gitlawb/zero/issues/607)) ([a7cfb99](https://github.com/Gitlawb/zero/commit/a7cfb99fed7b88ebc09a2f251cb82864d3c2cade))
* gitignore Windows sandbox helpers and npm version marker ([#578](https://github.com/Gitlawb/zero/issues/578)) ([25653f6](https://github.com/Gitlawb/zero/commit/25653f686c016f95b81ceb7ff5d5452d37c4d4f3))
* **sandbox:** Windows-appropriate suggestions when blocking interactive commands ([#414](https://github.com/Gitlawb/zero/issues/414)) ([ba4c007](https://github.com/Gitlawb/zero/commit/ba4c00755dc1c31b3dcca18e50b20f62c6bf5d1f))
* **tools:** block MSYS and WSL shells under the Windows sandbox ([#587](https://github.com/Gitlawb/zero/issues/587)) ([0666818](https://github.com/Gitlawb/zero/commit/066681855b80e3baf5d07d7397610b25f724e353))
* **tools:** platform-specific pager suggestions, quote/caret-safe cd detection ([#543](https://github.com/Gitlawb/zero/issues/543)) ([8b248f4](https://github.com/Gitlawb/zero/commit/8b248f4e1198dc86ab332a697fba4cf520823cbd))
* **tools:** Windows cmd.exe quoting guidance and clipboard escaping fix ([#468](https://github.com/Gitlawb/zero/issues/468)) ([f10ed0c](https://github.com/Gitlawb/zero/commit/f10ed0c893ce6de08923f143d681ba96f0fcfe3a))
* **tui:** bypass toggleSidebar and toggleMouse global shortcuts when composer is non-empty ([#576](https://github.com/Gitlawb/zero/issues/576)) ([c7346fb](https://github.com/Gitlawb/zero/commit/c7346fbcf01fe7e70ed2fcfccbf9965e5985727b))
* **tui:** paste protection for Termux char-by-char paste ([#573](https://github.com/Gitlawb/zero/issues/573)) ([8e9149f](https://github.com/Gitlawb/zero/commit/8e9149f1a296bebebf95cae9dd2a7c5156c9dbb6))
* **tui:** update picker_test for switchProviderModel 4-value return ([#589](https://github.com/Gitlawb/zero/issues/589)) ([8f15650](https://github.com/Gitlawb/zero/commit/8f156506dc92449bd24caa14e36f28477ba00fff))
* **update:** support safe symlink extraction during updates ([#575](https://github.com/Gitlawb/zero/issues/575)) ([ce9cb91](https://github.com/Gitlawb/zero/commit/ce9cb912958a3d9ae0b052bfebb849c28e0e719b))
* warn about untracked scratch files left behind after a run ([#571](https://github.com/Gitlawb/zero/issues/571)) ([062328b](https://github.com/Gitlawb/zero/commit/062328b632a2d27353a6d47c522bbd22d7282539))
### Performance Improvements
* **grep:** stop content scan after head limit ([#601](https://github.com/Gitlawb/zero/issues/601)) ([8a05e64](https://github.com/Gitlawb/zero/commit/8a05e6486f7a63281b869064db03dfc5531e6a04))
* add --auto flag for LLM-generated commit messages ([#423](https://github.com/Gitlawb/zero/issues/423)) ([b0abde7](https://github.com/Gitlawb/zero/commit/b0abde7d0697e808480cd59d69a6f4d0c6320475))
* add zero changes push and pr subcommands, and extra repo-info metrics ([#391](https://github.com/Gitlawb/zero/issues/391)) ([2312abe](https://github.com/Gitlawb/zero/commit/2312abe5ddd95f4c6ef373cfb61cc03092f48cdd))
* agent quality, caching, retry, and tooling upgrades ([#506](https://github.com/Gitlawb/zero/issues/506)) ([3c81fea](https://github.com/Gitlawb/zero/commit/3c81fea22873ee3df7fc97b10cb4f77792706c4b))
* **agent:** curb over-engineering the solution in the editing discipline ([#517](https://github.com/Gitlawb/zero/issues/517)) ([f4c998a](https://github.com/Gitlawb/zero/commit/f4c998ac30c4f07ff313a2d706791e857293be49)), closes [#516](https://github.com/Gitlawb/zero/issues/516)
* **agent:** inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt ([#475](https://github.com/Gitlawb/zero/issues/475)) ([7b10aab](https://github.com/Gitlawb/zero/commit/7b10aab74bf14a01166b2cea22deab79bba9850b))
* **providers:** add `zero providers models` to discover a provider's models ([#386](https://github.com/Gitlawb/zero/issues/386)) ([0bc8074](https://github.com/Gitlawb/zero/commit/0bc8074c97b0310e4a9d70c3f967003ee5e8a59f))
* **providers:** add KiloCode and OpenCode provider support ([#388](https://github.com/Gitlawb/zero/issues/388)) ([b1ccb6d](https://github.com/Gitlawb/zero/commit/b1ccb6d9c1875377f5e5ea81a1304edd1e41ab4f))
* **providers:** split minimax zai into intl cn ([#398](https://github.com/Gitlawb/zero/issues/398)) ([aaad4d2](https://github.com/Gitlawb/zero/commit/aaad4d271270f41af837b6f3b60ae80beba0c645))
* require manual approval before npm publish + drop release-as pin ([#369](https://github.com/Gitlawb/zero/issues/369)) ([bd89a1f](https://github.com/Gitlawb/zero/commit/bd89a1f451643c1b65ec803070abc7b116631ebe))
* **sandbox:** unelevated Windows fallback tier instead of prompts-only degrade ([#427](https://github.com/Gitlawb/zero/issues/427)) ([b9ddd6f](https://github.com/Gitlawb/zero/commit/b9ddd6f42138312a1fee8d8bb67c46c8eb1dea2f))
* support shift enter for composer newlines ([#462](https://github.com/Gitlawb/zero/issues/462)) ([daf65e0](https://github.com/Gitlawb/zero/commit/daf65e0af9a040314d4ab337b0ad59c55416b7bc))
* **tui:** /loop — repeat a prompt or command on an interval or self-paced ([#502](https://github.com/Gitlawb/zero/issues/502)) ([387fe67](https://github.com/Gitlawb/zero/commit/387fe67ee7cd81317c9c969f5906a4437080fea3))
* **tui:** add search/filter to provider picker in setup wizard ([#400](https://github.com/Gitlawb/zero/issues/400)) ([2fcea71](https://github.com/Gitlawb/zero/commit/2fcea71778d23e050c93409c471aef45b68c1621))
* **update:** add zero upgrade command to apply self-updates ([#461](https://github.com/Gitlawb/zero/issues/461)) ([5f36349](https://github.com/Gitlawb/zero/commit/5f36349c1884e81fa9bc66bb5fe813b627e897b7))
### Bug Fixes
* **action:** keep provider key scoped to zero step ([#448](https://github.com/Gitlawb/zero/issues/448)) ([407a927](https://github.com/Gitlawb/zero/commit/407a92739ff508cba32d2c12b3f36f0efcdd54c3))
* add android platform support for Termux npm install ([#455](https://github.com/Gitlawb/zero/issues/455)) ([9bd93c6](https://github.com/Gitlawb/zero/commit/9bd93c62f8d57fb74057284aa66a1b6e1429dcdd)), closes [#449](https://github.com/Gitlawb/zero/issues/449)
* **agent:** reject a malformed additional_permissions payload before prompting ([#453](https://github.com/Gitlawb/zero/issues/453)) ([e4f760e](https://github.com/Gitlawb/zero/commit/e4f760ee8bd57299cd2fcb37e8e23130037c2607))
* allow non-TLS connections to private-network provider endpoints ([#444](https://github.com/Gitlawb/zero/issues/444)) ([1d86384](https://github.com/Gitlawb/zero/commit/1d8638466ca31517eb9db2b9353d3dce1cbeeabc))
* **auth:** route zero auth login chatgpt to the dedicated ChatGPT flow ([#443](https://github.com/Gitlawb/zero/issues/443)) ([305a62c](https://github.com/Gitlawb/zero/commit/305a62c954ca6cec00bc58d5398f933415156aff))
* **config:** fall back to a usable saved provider instead of forcing full re-onboarding ([#410](https://github.com/Gitlawb/zero/issues/410)) ([c60ad87](https://github.com/Gitlawb/zero/commit/c60ad8729f79bb841114d352ee2d2fe29d5d0e41))
* **config:** let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible ([#497](https://github.com/Gitlawb/zero/issues/497)) ([30dd7c3](https://github.com/Gitlawb/zero/commit/30dd7c3112ad22d42fa12b5addd4e38f4beda42a)), closes [#479](https://github.com/Gitlawb/zero/issues/479)
* **config:** unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors ([#385](https://github.com/Gitlawb/zero/issues/385)) ([72eed06](https://github.com/Gitlawb/zero/commit/72eed06b4f94c43d75d31fe54a58d2f566de059e))
* **config:** use ~/.config on macOS and enter setup when no provider ([#371](https://github.com/Gitlawb/zero/issues/371)) ([#372](https://github.com/Gitlawb/zero/issues/372)) ([027a8f2](https://github.com/Gitlawb/zero/commit/027a8f2768b17b89f5c8270887f156e2ccda69ea))
* **install:** persist install dir to user PATH on Windows ([#407](https://github.com/Gitlawb/zero/issues/407)) ([bdb1b0e](https://github.com/Gitlawb/zero/commit/bdb1b0ecd15859b1712a6037d296dace7f9c3c3f))
* **oauth:** treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile ([#445](https://github.com/Gitlawb/zero/issues/445)) ([d05e914](https://github.com/Gitlawb/zero/commit/d05e9148a7f79f67d1d3c31fca2775f21fbd331e))
* **sandbox:** fix nested pipe creation under the Windows restricted token ([#456](https://github.com/Gitlawb/zero/issues/456)) ([563a6db](https://github.com/Gitlawb/zero/commit/563a6dbe91e65d5daeefd7626e8a77e30a6d8fb2))
* **sandbox:** gate /tmp test assertions on GOOS, not path existence ([#426](https://github.com/Gitlawb/zero/issues/426)) ([f653dca](https://github.com/Gitlawb/zero/commit/f653dcac363fb69ad7be5b35e6e0fa6d2bce476d))
* **sandbox:** self-heal a corrupt unelevated setup marker ([#437](https://github.com/Gitlawb/zero/issues/437)) ([8d0c5fe](https://github.com/Gitlawb/zero/commit/8d0c5feccb8bdbfb015df0508aa6e3bcbd1fd0e8))
* **specialist:** cap max specialist nesting depth ([#491](https://github.com/Gitlawb/zero/issues/491)) ([177442c](https://github.com/Gitlawb/zero/commit/177442cfe4015bd8df04cc9894f98b468ee796d4))
* **tools:** Block MSYS coreutils under Windows sandbox ([#476](https://github.com/Gitlawb/zero/issues/476)) ([81aad58](https://github.com/Gitlawb/zero/commit/81aad58d97839e51c068e2f08907618991fdc3fb))
* **tools:** CRLF line ending mismatch in edit_file tool on Windows ([#378](https://github.com/Gitlawb/zero/issues/378)) ([33dc7ae](https://github.com/Gitlawb/zero/commit/33dc7ae2cc82c5389675531e1416856dae7151ce))
* **tools:** flag piped POSIX utilities before running on Windows ([#412](https://github.com/Gitlawb/zero/issues/412)) ([5658a36](https://github.com/Gitlawb/zero/commit/5658a366274fc59a9d5336b06a21019c9c25cbf1))
* **tools:** make grep and glob respect run cancellation ([#464](https://github.com/Gitlawb/zero/issues/464)) ([ba6c026](https://github.com/Gitlawb/zero/commit/ba6c0264697b7d7ed479f6e782fba9700a481e3d))
* **tools:** require permission before web_search requests ([#382](https://github.com/Gitlawb/zero/issues/382)) ([960db96](https://github.com/Gitlawb/zero/commit/960db9660e4e31dc588fe8f7d6f116ff5e225566))
* **tui:** compose help overlay through the viewport overlay pipeline ([#421](https://github.com/Gitlawb/zero/issues/421)) ([5b2b4de](https://github.com/Gitlawb/zero/commit/5b2b4dea1aaf9e0f68baa25e97e83296fb17b1a2))
* **tui:** keep the profile name on /model switch so the stored key resolves ([#441](https://github.com/Gitlawb/zero/issues/441)) ([9134148](https://github.com/Gitlawb/zero/commit/9134148f4df3e4e556fba6c2f8babfdf6fcfeee1)), closes [#440](https://github.com/Gitlawb/zero/issues/440)
* **tui:** resolve every permission request so the agent can't deadlock ([#397](https://github.com/Gitlawb/zero/issues/397)) ([952788f](https://github.com/Gitlawb/zero/commit/952788f72d32957659fe004521fcc8372b9ba9b4))
* **tui:** show an M suffix for million-scale token counts ([#457](https://github.com/Gitlawb/zero/issues/457)) ([0562e3b](https://github.com/Gitlawb/zero/commit/0562e3bef7df2328610a48a1e81632a8da4aec64))
* **tui:** title /model rows by model name, not the catalog description ([#395](https://github.com/Gitlawb/zero/issues/395)) ([cdf9d83](https://github.com/Gitlawb/zero/commit/cdf9d839ae57a729f292f36f7c5b0c67b41b288d))
### Performance Improvements
* cache TUI model registry ([#496](https://github.com/Gitlawb/zero/issues/496)) ([e7d88b4](https://github.com/Gitlawb/zero/commit/e7d88b4b518049733da25a8447c00144bd1da518))
@@ -339,7 +570,39 @@ go run ./cmd/zero-release smoke
go run ./cmd/zero-perf-bench
```
Cross-compile examples:
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
session (background connection prewarm + request-prefix telemetry) for headless
`zero exec` runs against official OpenAI profiles. Off by default; `0`, `false`,
or `off` disable it. A/B-benchmark it by running the same `zero-perf-bench` suite
with the variable unset and set.
Native ChatGPT Responses sessions are enabled by default. Set
`ZERO_CHATGPT_TURN_SESSION=0`, `false`, or `off` to restore stateless HTTP/SSE
transport.
### Code Quality and Security Checks
Before committing any changes, ensure all Go code quality and security checks pass. The `make` targets below pin each tool to this module's Go version, so they load correctly even when your default `go` toolchain is older — running the plain `go run ...@version` form yourself can select the tool module's own (older) toolchain and fail to load this module instead.
1.**Formatting**: Run `make fmt` (or `go fmt ./...`).
2.**Vetting**: Run `make vet` (or `go vet ./...`).
3.**Linting**: Run `make lint-static`.
4.**Vulnerability Scan**: Run `make vulncheck`.
Use the repository-managed targets rather than globally installed binaries:
the targets apply the module's required Go toolchain as well as the reviewed,
pinned tool versions.
The installed binaries land in `$GOBIN` when it is set, otherwise in
`$GOPATH/bin` (default `~/go/bin`). That directory must be on your `PATH` to
_,_=fmt.Fprintln(stderr,"[zero] Turn benchmark failed: every task errored with no accepted benchmark sample (see warnings); the report contains no valid measurements")
Zero is an open-source terminal coding agent. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
If you only want to *use* Zero, the [README](../README.md) is enough. This page is for the other three jobs:
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
2. Add new specialist sub-agents.
3. Wire Zero into the rest of your toolchain (MCP, skills, hooks, plugins).
## 1. Drop a project `AGENTS.md`
When Zero starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`).
Accepted file names, in priority order at each level:
| Path | Notes |
| --- | --- |
| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. |
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
```markdown
# Project conventions for <your project>
- Build with `make`, not `go build` directly.
- Tests live next to the source file (`foo_test.go` next to `foo.go`).
- Run `make lint` before opening a PR.
- Never edit files under `third_party/` — those are vendored.
```
Tips:
- Keep each file under ~8 KiB. Zero caps the **total** across all matched files at 32 KiB; everything past the cap is dropped.
- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter".
- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use `config.json` for those.
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
### Personal guidelines, across every project
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
## 2. Custom specialists
Specialists are Zero's sub-agents. Three scopes, in priority order:
| Scope | Path | Shared? |
| --- | --- | --- |
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
| Project | `./.zero/specialists/*.md` | yes — the repo team |
Project overrides user overrides built-in when names collide.
A specialist is a markdown manifest with frontmatter and a system prompt:
```markdown
---
description:Reviews API changes for breaking-change risk and missing tests.
tools:read-only,plan
---
You review API changes. For every changed hunk in `internal/api/` or any file
that ends in `_api.go`:
1. Confirm the public signature is backward-compatible, or note the breaking
change explicitly with the migration path.
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
the new behaviour is exercised.
3. Flag any new exported symbol without a doc comment.
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
```
CLI management (the prompt is passed inline via `--prompt`):
```bash
zero specialist list
zero specialist show api-reviewer
zero specialist create api-reviewer \
--project \
--description "Reviews API changes"\
--tools read-only,plan \
--prompt "$(cat api-reviewer.md)"
zero specialist edit api-reviewer --project
zero specialist delete api-reviewer --project
zero specialist path # prints the resolved specialists directory
```
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](SPECIALISTS.md).
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
## 3. Skills
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Standalone project skill directories are not supported in this version (shared project-wide skills must go in `AGENTS.md` or as a hook). However, project plugins (section 6) may bundle skills, which are merged into the active run.
Discovery roots (earlier wins on name collisions):
1.**Primary Zero dir** — `$ZERO_SKILLS_DIR` if set, else `$XDG_DATA_HOME/zero/skills`, else `~/.local/share/zero/skills/`
2.**Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
3.**Plugin skill roots** — skills bundled by active plugins (section 6)
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.
description:Run the project's benchmark suite and summarize the deltas.
---
# Run benchmarks
1.`make bench` — captures the wall-clock and RSS before and after.
2.`benchstat before.txt after.txt` — diffs the two.
3. Report any regression > 5% with the function name and the previous value.
```
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. Within a single skills root, duplicate names are resolved by lexicographic directory order. Across roots, Zero loads the primary directory first, then `~/.agents/skills`, then plugin skill roots; earlier roots win name collisions. Plugin-declared skills (section 6) are merged into the active agent run at plugin activation time, so bundled skills appear in the available skills list and can be loaded with the `skill` tool.
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:
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
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.
## 5. MCP — Model Context Protocol
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
### As a client — configure MCP servers in `config.json`
zero mcp add docs --type stdio -- docs-mcp --port 7777
zero mcp add github --type http --url https://api.example.com/mcp \
--header "Authorization=Bearer YOUR_TOKEN_HERE"
zero mcp list
zero mcp check docs
zero mcp remove github
zero mcp oauth login github
```
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
- A wrapper script that sources the secret and execs the real command.
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
Project MCP config remains supported for shared team servers, but a project layer
that changes an existing server's target (`url`, `command`, or `args`) does not
inherit user credentials. Clear or replace `headers`, `env`, or `oauth` explicitly,
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
zero serve --mcp
zero serve --mcp -C /path/to/workspace
zero serve --mcp --add-dir /path/to/extra
```
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
`-C/--cwd` sets the workspace root exposed as MCP resources and used by core tools. `--add-dir` (repeatable) widens that resource/tool scope beyond the workspace without granting the sandbox temp roots used by interactive runs.
## 6. Plugins
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
- User: `~/.config/zero/plugins/<id>/`
- Project: `./.zero/plugins/<id>/`
Each plugin has a `plugin.json` manifest:
```json
{
"id":"github-pr-review",
"name":"GitHub PR Review",
"description":"Adds review tools for GitHub PRs.",
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 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.
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above.
## 7. Configuration locations
Five configuration sources, in precedence order (later sources override earlier ones):
| Source | Path / Key | Notes |
| --- | --- | --- |
| Built-in defaults | compiled in | Lowest priority. |
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
## 8. End-to-end example
A team that wants every contributor's Zero to behave the same way commits:
| “Maybe compact context” | [Context Budget and Compaction](#context-budget-and-compaction) |
| Model requests a tool | [Tool Execution Lifecycle](#tool-execution-lifecycle), then [Tools and Capabilities](#tools-and-capabilities) |
| Tool needs approval or sandbox access | [Permission and Sandbox Model](#permission-and-sandbox-model) |
| Events appear in the UI or stream-JSON | [How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect), [Stream-JSON Protocol](#stream-json-protocol) |
| Resume, fork, rewind, or sub-agent history | [Session Storage and History](#session-storage-and-history) |
## Main Packages
The sections above describe the architecture in concepts. If you want to connect
those concepts to implementation details, use this table as the code map before
| `internal/agent` | Core loop that sends messages to the model, streams output, executes tools, handles permissions, compaction, retries, and final answers. |
`zero update --check` checks the latest GitHub release and compares it with the
local CLI version.
local CLI version.`zero update --apply` (or its shorthand, `zero upgrade`)
downloads, verifies, and installs it.
```bash
zero update --check
zero update --check --json
zero update --check --repo Gitlawb/zero
zero update --check --repo Twigpine/zero
zero update --check --target windows-x64
zero upgrade
zero update --apply
```
The command is intentionally check-only:
`--check` and `--apply` are mutually exclusive. `zero update` requires one of
them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
`--check` is check-only:
- It does not replace the running binary.
- It exits with code `0` when the check succeeds, even when an update is
@@ -19,14 +26,39 @@ The command is intentionally check-only:
-`--json` prints the same result in a machine-readable format for scripts and
CI.
`--apply` installs the update in place:
- npm installs delegate to `npm install -g @twigpine/zero@latest`. An install
of the old `@gitlawb/zero` package refuses, without a release check, and
prints the steps to switch to `@twigpine/zero` (see
[INSTALL.md](INSTALL.md#moving-from-gitlawbzero)); `--check` prints them
too.
- Standalone installs download the release archive, verify its checksum,
extract it, and atomically replace the running binary plus any installed
optional sandbox helpers.
- On Windows, the running executable is renamed aside and cleaned up on the
next `zero update --apply` or `zero upgrade`, since it can't be overwritten
while running.
-`--target` cannot be combined with `--apply`; it only applies to `--check`,
since applying always installs onto the current machine.
-`--repo` and `--endpoint` are ignored when applying to an npm-managed
install: that path delegates to `npm install -g @twigpine/zero@latest` and
takes its release from the npm registry, not from GitHub. They still apply to
`--check` there.
-`--json` serializes Zero's final result. For npm-managed installs, npm may
also write progress output to stdout, so neither
`zero update --apply --json` nor `zero upgrade --json` is guaranteed to
produce a single parseable JSON document. Use `--check --json` for
machine-readable automation.
Useful flags:
| Flag | Purpose |
|---|---|
| `--repo <owner/repo>` | Check another GitHub repository. |
| `--endpoint <url|owner/repo>` | Check a specific release API URL or repository slug. |
| `--repo <owner/repo>` | Use another GitHub repository for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
| `--endpoint <url\|owner/repo>` | Use a specific release API URL or repository slug for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
Installer scripts download the matching release asset for the local platform and
verify its `.sha256` file. If Zero is already installed, run `zero update --check`
before reinstalling.
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
instead of reinstalling.
## Windows recovery state (standalone installs)
This section describes Windows only. On Linux and macOS a standalone update
renames the staged file directly over the executable path through the
installation directory's file descriptor, so the replacement is atomic and no
aside copy, marker, or recovery record is ever created — a failed update leaves
the previous binary in place and nothing to resolve.
On Windows, a running executable cannot be replaced in place, so the previous
binary is moved aside to `<binary>.zero-update-<random>.old` first. The updater
records that exact file — bound to its filesystem identity, in per-user state
outside the installation directory — and deletes only that recorded copy after
the next update is verified in place. Backups it did not create are never
removed, so a file such as `zero.exe.before-manual-patch.old` is left alone. A
recorded copy that something else holds open (an on-access scanner, an editor)
stays recorded and is removed by a later update instead.
An update **refuses to run** while unresolved recovery state exists beside the
binary:
| State on disk | Meaning |
|---|---|
| `<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 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
installation directory can plant `<binary>.old` and `<binary>.old.keep` there
and make every subsequent update refuse until an operator clears them. That is
the safe direction: the alternative is an update that overwrites the only
verified copy of the previous binary. If updates start refusing, inspect those
files before removing them — an installation directory that a lower-privileged
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"{
{"I replaced the placeholder value with the computed key.",false},
{"The retry uses exponential backoff as a fallback.",false},
{"Without proper error handling the function would crash, so I added it.",false},
// real false positive (conversational recap): retelling a PAST exchange is
// not an admission about the current objective — the sentence carries its
// own narrative context ("You asked …", "at the time"):
{"You asked if I could work autonomously on zero if you gave me a GitHub account — I was honest that my sandbox had no repo, no build system, and no network/integration wired up yet, so I couldn't actually do it at the time.",false},
{"When we last spoke, I couldn't reach the GitHub API from the sandbox.",false},
{"Last session I was unable to build the project; that is what we should retry now.",false},
// quoted admissions (the model citing its own earlier message or a log
// line) must NOT be flagged:
{`My earlier reply said "I couldn't do it" but the tooling is now installed.`,false},
{"The run log contains `I cannot access the network`, which is expected inside the sandbox.",false},
// current-objective admissions must STILL be flagged, including past tense
// and multi-sentence finals whose other sentences are narrative:
{"I couldn't complete the refactor.",true},
{"I hit an error I could not resolve, so the migration is unfinished.",true},
{"You asked me to port the parser. I couldn't finish the second half.",true},
// unbalanced delimiters must not swallow the tail (only BALANCED quoted
// spans are stripped), so an admission after a stray quote still fires:
{"The log ends with a stray \" and I couldn't finish the migration.",true},
{"There is an unterminated `code span and I cannot proceed with the fix.",true},
{"The doc opens with “a curly quote and I was unable to complete the task.",true},
{Role:zeroruntime.MessageRoleUser,Content:"Inspect the parser and preserve its current behavior."},
{Role:zeroruntime.MessageRoleAssistant,Content:"I will inspect before editing.",ToolCalls:[]zeroruntime.ToolCall{{ID:"read",Name:"read_file",Arguments:`{"path":"internal/parser.go"}`}}},
{Role:zeroruntime.MessageRoleAssistant,ToolCalls:[]zeroruntime.ToolCall{{ID:"test",Name:"exec_command",Arguments:`{"cmd":"go test ./internal/parser"}`}}},
{Role:zeroruntime.MessageRoleAssistant,ToolCalls:[]zeroruntime.ToolCall{{ID:"ask",Name:"ask_user",Arguments:`{"questions":[{"question":"Which database should remain?"}]}`}}},
{Role:zeroruntime.MessageRoleTool,ToolCallID:"ask",Content:"Which database should remain?: Postgres only; do not touch MySQL."},
for_,want:=range[]string{"Which database should remain?","Postgres only; do not touch MySQL","internal/db.go","internal/api.go","internal/config.go","https://example.com/spec","Inspect the Windows implementation"}{
if!strings.Contains(content,"[previous summary]\nkeep the earlier architecture decision")||!strings.Contains(content,"keep the newest prior decision"){
t.Fatalf("previous summary was lost when the brief tail was capped: %q",content)
}
if!strings.Contains(content,"middle transcript omitted to fit compaction budget"){
t.Fatalf("oversized brief was not head/tail capped: %q",content)
}
if!projection.truncated{
t.Fatal("projection did not structurally report truncation")
// argsPermTool implements tools.ArgsPermissioner; plainTool does not.
Loaded 100 of 1554 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.