Compare commits

...
Author SHA1 Message Date
jatmnandClaude Opus 5.5 4d7bd8b97c test(tui): drive persistence and completion-model checks through real paths
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]>
2026-11-07 11:58:37 -08:00
jatmnandClaude Opus 5.5 ce4d3a2691 fix(plan): start a task's stall clock when its child launches
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]>
2026-11-07 11:57:14 -08:00
jatmnandClaude Opus 5.5 acfc011d7c fix(tools): release the sandboxed shell probe's command plan
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]>
2026-11-07 11:55:13 -08:00
jatmnandClaude Opus 5.5 0dd689f941 test(cli): route sandbox self-dispatch in the cli test binary
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]>
2026-11-07 11:54:23 -08:00
jatmnandClaude Opus 5.5 be207465fb fix(tui): describe the plan budget admission will enforce on the approval card
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]>
2026-11-07 11:49:44 -08:00
jatmnandClaude Opus 5.5 781737f466 fix(specialist): report a background child's tool calls, not its tool names
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]>
2026-11-07 11:48:04 -08:00
jatmnandClaude Opus 5.5 6c68047431 fix(specialist): count only admitted sub-agents against the session
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]>
2026-11-07 11:46:53 -08:00
jatmnandClaude Opus 5.5 bc56e1e6ac fix(plan): fingerprint dependency order for resume identity
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]>
2026-11-07 11:46:09 -08:00
jatmnandClaude Opus 5.5 76649ea190 fix(plan): let a write-capable plan run again after it finishes
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]>
2026-11-07 11:44:58 -08:00
jatmnandClaude Opus 5.5 30769f720c fix(specialist): propagate the run's read grants to every Task launch and resume
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]>
2026-11-07 11:20:00 -08:00
jatmnandClaude Opus 5.5 500f250fdf fix(specialist): keep turn-scoped write grants out of children that outlive the turn
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]>
2026-11-07 11:20:00 -08:00
jatmnandClaude Opus 5.5 5e48344b88 fix(specialist): withhold write roots overlapping the parent tree from isolated children
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]>
2026-11-07 11:20:00 -08:00
jatmnandClaude Opus 5.5 86a314cd2d fix(tui): stage restart and resume plans without owning user plan names
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]>
2026-11-07 11:14:58 -08:00
jatmnandClaude Opus 5.5 0fd0d14c10 fix(execution): expire completed session records with their retention
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]>
2026-11-07 11:14:58 -08:00
jatmnandClaude Opus 5.5 1c1f8e6087 refactor(plan): drop the dead budgetLeft store and its stale comments
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]>
2026-11-07 11:14:06 -08:00
jatmnandClaude Opus 5.5 f9136002c1 fix(plan): report a plan with genuine failures as failed, not cancelled
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]>
2026-11-07 11:14:06 -08:00
jatmnandClaude Opus 5.5 1851b61153 fix(plan): stop non-stall retries from spending the stall budget
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]>
2026-11-07 11:14:06 -08:00
jatmnandClaude Opus 5.5 d190306594 fix(plan): bound max_tokens_per_task across a task's retries
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]>
2026-11-07 11:14:06 -08:00
jatmnandClaude Opus 5.5 83325fc626 fix(plan): count every usage event on the live plan meter
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]>
2026-11-07 11:14:06 -08:00
jatmnandClaude Opus 5.5 cb4bc4de8f fix(plan): refuse a per-task cap whose total overflows int
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]>
2026-11-07 11:14:06 -08:00
jatmnandClaude Opus 5.5 ec68fa65e8 fix(plan): decode integer budget fields exactly, in one place
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]>
2026-11-07 11:14:05 -08:00
jatmnandClaude Opus 5.5 a2e55a1211 fix(plan): reject present non-array depends_on and tools containers
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]>
2026-11-07 11:14:05 -08:00
jatmnandClaude Opus 5.5 2f39c31ac0 fix(plan): keep failed task output off the shared scratchpad
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]>
2026-11-07 11:11:51 -08:00
jatmnandClaude Opus 5.5 c6cc4efe47 fix(plan): reject task ids that differ only in case
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]>
2026-11-07 11:11:51 -08:00
jatmnandClaude Opus 5.5 7cb9056502 fix(plan): create scratchpad records exclusively and correct the invariant
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]>
2026-11-07 11:11:51 -08:00
jatmnandClaude Opus 5.5 4f8156b689 fix(plan): suspend the wall-budget watcher while dispatch is paused
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]>
2026-11-07 11:11:51 -08:00
jatmnandClaude Opus 5.5 38c906ff2e test(zeromaxing): claim package timings in measurement fixtures
#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]>
2026-11-07 11:04:23 -08:00
jatmnandClaude Opus 5.5 11b1c20a95 chore(deps): resync memory and measurements with #897 and #909
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]>
2026-11-07 11:04:23 -08:00
jatmnandClaude Opus 5.5 c4aa2192ba Merge #829 (feat/zeromaxing) onto current main
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]>
2026-11-07 10:59:36 -08:00
e034a93815 perf(tui): bounded file read, LRU rendering cache, and size caps for full-file view (#1203)
* 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]>
2026-11-07 10:28:29 -08:00
2ca84cc127 fix(hooks): dispatch specialistStart/Stop lifecycle hooks (#1204)
* 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]>
2026-11-07 10:19:56 -08:00
05c1b5e160 feat(plugins): add CLI enable/disable for plugin.json (#1202)
* 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]>
2026-11-07 09:56:16 -08:00
16ccd78765 feat(mcp): add AI/ML API as an opt-in built-in server (#1193)
* 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]>
2026-11-04 20:13:53 -08:00
c9f179482a feat(web-fetch): add an opt-in AI/ML API Toolbox scraping backend (#1192)
* 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]>
2026-11-04 19:20:17 -08:00
98736457bd feat(web-search): add AI/ML API Toolbox as an opt-in backend (#1189)
* 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]>
2026-11-04 18:24:09 -08:00
ShryukGrandhi e59632406b test(agent): isolate user home, config and cache dirs for the agent tests (#1184)
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]>
2026-11-04 12:27:48 +08:00
github-actions[bot] 7c55a09c07 chore(main): release 0.12.0 (#1150)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-11-03 08:10:03 +08:00
Octopusandocto-patch d99f92b81a feat(providers): add MiniMax-M3.1-Flash to catalog (#1180)
* feat(providers): add MiniMax-M3.1-Flash to catalog

* fix(providers): forward MiniMax adaptive effort

---------

Co-authored-by: octo-patch <[email protected]>
2026-11-02 18:28:17 +08:00
zzucs 5aa54fe1d2 fix(agent): mark headless guardrail stops incomplete (#1182)
* 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.
2026-11-02 14:25:40 +08:00
oniisan f912f80d00 fix(chatgpt): refresh subscription defaults and compatibility version (#1179)
* 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
2026-11-02 14:25:00 +08:00
ShryukGrandhi 9c4e3693e7 test(providerio): widen the idle window in the content-reset test (#1172)
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]>
2026-11-02 14:24:24 +08:00
zzucs 63962081cf fix(sessions): capture freeform apply_patch checkpoints (#1183)
* 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.
2026-11-02 14:23:58 +08:00
Vasanth T 2e10a4b60b test(swarm): wait for every slot to be released before checking the drain (#1181)
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.
2026-10-29 06:30:41 -07:00
Vasanth T 4247a07aba test(tools): stop the tty session before removing its root (#1175)
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.
2026-10-27 06:25:41 -07:00
Vasanth T b837448dd4 docs: list Vercel under Partners (#1176)
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.
2026-10-27 15:29:59 +08:00
Vasanth T a0b036a3c7 docs: add Vercel OSS Program badge (#1174)
* 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.
2026-10-27 12:30:50 +05:30
Vasanth T 164150633a feat(skills): install a skill's bundled files and point the skill tool at them (#1167)
* 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.
2026-10-25 07:24:59 -07:00
Vasanth T 5d080ef46b test(tui): pin the cwd in tests that assert text is absent from the view (#1170)
* 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.
2026-10-22 06:52:51 -07:00
Vasanth T 1d233c0a9b test(modelregistry): clear ZERO_DISABLE_MODELS_FETCH in the models.dev tests (#1169)
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.
2026-10-21 07:33:36 -07:00
Vasanth T de1443b2ed fix(deps): refresh the lockfile to hono 4.13.13 (#1171)
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.
2026-10-21 07:12:22 -07:00
Felix-Ayush 84c1b15540 fix(oauth): reuse the crash-safe secret store and sync encrypted publications (#1165)
* 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.
2026-10-21 18:41:38 +08:00
Vasanth T 6393a90ed6 fix: bump Go to 1.26.10 for GO-2026-6809 (#1168)
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.
2026-10-21 16:37:49 +08:00
Vasanth T cd3e0b0a9f test(cli): isolate the exec image tests from the models.dev refresh (#1161)
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.
2026-10-18 10:08:09 +08:00
Felix-Ayush aa9ba8412e fix(tools): grep stops where a file's line breaks stop (#1160)
* 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.
2026-10-18 10:07:34 +08:00
Felix-Ayush a97db8832f fix(redaction): redact credentials whose trigger a greedy span swallowed (#1158)
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.
2026-10-18 10:07:04 +08:00
Vasanth T ef4da1323f fix(workspaceindex): import gitsafe under the Twigpine module path (#1153)
#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.
2026-10-17 16:59:52 +08:00
zzucs 1e848492e5 fix(sessions): preserve image attachment summaries on resume (#1152)
* 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
2026-10-17 16:59:21 +08:00
Vasanth T 2ffe1831b0 fix(workspaceindex): run Scan's git calls through gitsafe (#1149)
* 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.
2026-10-17 07:14:03 +08:00
PierrunoYTandClaude Opus 5.5 13e2596b2e chore: move repository references to Twigpine/zero (#1105)
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]>
2026-10-17 07:13:21 +08:00
HazyLabandcl-ment 8c4e759798 feat(sandbox): support self-exec pattern to eliminate separate helper binary (#946)
* 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]>
2026-10-16 14:09:28 +08:00
github-actions[bot] d597f0e095 chore(main): release 0.11.0 (#1146)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-16 11:28:23 +08:00
HazyLabandcl-ment ce2fcffaf1 perf(redaction): add fast-path byte checks to eliminate regex allocations on clean strings (#945)
* 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]>
2026-10-15 21:15:42 +08:00
Kevin Codex 61b09364f0 feat(providers): add Concentrate provider preset (#1147)
* 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.
2026-10-15 21:14:16 +08:00
fchengyan d1a0d048ee fix(cli): let providers subcommands run with no active provider (#1143)
* 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.
2026-10-15 15:40:43 +08:00
Fabio Tavares Leitão c88c77bf70 fix(workspaceindex): skip git-ignored and build-cache dirs in Scan only (#1117)
* 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.
2026-10-15 15:34:28 +08:00
Vasanth T 640d936bfb fix(tui,cli): stop a repository's git config from running programs in Zero's own git calls (#1133)
* 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.
2026-10-15 15:33:32 +08:00
Kevin Codex 8e363fcaa5 fix(npm): wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps (#1145)
* 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.
2026-10-15 13:30:11 +08:00
github-actions[bot] 856919bcaa chore(main): release 0.10.0 (#1061)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-15 12:14:04 +08:00
Vasanth T 883e5bec24 fix(sessions): keep a session lease for the life of the process, not of the Store (#1144)
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.
2026-10-15 12:05:04 +08:00
Kevin Codex 530563a0bb feat(npm): publish as @twigpine/zero (#1142)
* 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.
2026-10-15 11:05:51 +08:00
Vasanth T c56a4c4e6e fix(deps): refresh the lockfile to patched shell-quote (#1131)
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.
2026-10-15 10:13:27 +08:00
Fabio Tavares Leitão 300cf6275f fix(acp): persist token usage events for ACP sessions (#1137)
* 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
2026-10-15 09:44:28 +08:00
Fabio Tavares Leitão 106c0b77f9 test(cli): pin sandbox policy golden temp roots (#1139)
* 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
2026-10-15 09:21:16 +08:00
zzucs 846d30f57f test(cli): give the local adoption probe a test-sized timeout (#1136)
* 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.
2026-10-14 22:31:06 +08:00
zzucs bb96206c27 fix(acp): persist the session permission mode across restarts (#1134)
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
2026-10-14 22:30:25 +08:00
PierrunoYTandClaude Sonnet 5.5 e0fb65f003 test(sessions): run the session lock test on Windows and add a cross-process one (#1115)
* 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]>
2026-10-14 22:29:36 +08:00
PierrunoYTandClaude Opus 5.5 a3d4edef8f test(tui): isolate user home, config and cache dirs for the TUI tests (#1114)
* 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]>
2026-10-14 22:28:58 +08:00
PierrunoYTandClaude Sonnet 5.5 a195e8848c fix(tui): stop held peer message Allow/Deny from deadlocking Update (#1111)
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]>
2026-10-14 22:28:03 +08:00
PierrunoYTandClaude Sonnet 5.5 a0979fb756 test(tools): poll for listening address in foreground server test (#1110)
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]>
2026-10-14 22:21:12 +08:00
Kevin Codex 4dc603f622 refactor(config): drop the unreachable SetNotify wrapper (#1128)
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.
2026-10-14 16:45:00 +08:00
4d2e9f0d08 fix(config): sync atomic config replacements (#1093)
* 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.

Fixes Gitlawb/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]>
2026-10-14 16:39:10 +08:00
Kevin Codex 1335ca6671 feat(providers): add GMI Cloud provider preset and partner listing (#1127)
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.
2026-10-14 12:55:29 +08:00
Fabio Tavares LeitãoandClaude Sonnet 5.5 ddc45f2c81 feat(providers): opt-in ZERO_RESPONSE_HEADER_TIMEOUT override for slow first-byte deployments (#1106)
* 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]>
2026-10-13 12:49:55 +08:00
Lortu | AEGIS Core eebb384676 feat(runtime): add process-local per-model RPM admission limits (#1116)
* feat(runtime): add process-local per-model RPM admission limits

* fix(config): preserve model RPM through provider fallback

* test(config): pin provider-command RPM cap precedence

* style(config): gofmt provider-command RPM test
2026-10-13 10:15:11 +08:00
Kevin Codex 39d6560137 docs(readme): add Trendshift badges and Partners section (#1126)
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.
2026-10-13 09:40:10 +08:00
PierrunoYTandAmp bb3346a828 fix(execution): reserve process capacity before launching (#1092)
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]>
2026-10-12 15:03:04 +08:00
HazyLabandhazyhaar ac5fe0d218 fix(security): harden shell flag parsing, write roots, and formatters (#1066)
* 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]>
2026-10-12 15:02:17 +08:00
zzucs 0c6f179a84 fix(tools): hint when Windows resolves POSIX paths under root (#1063)
* 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.
2026-10-12 15:01:44 +08:00
feb992757c fix(tui): bound live dictation regions (#985)
* fix(tui): bound live dictation regions

Amp-Thread-ID: https://ampcode.com/threads/T-01a0448f-5860-721c-8a47-5119fc57f685
Co-authored-by: Amp <[email protected]>

* fix(tui): preserve user edits across dictation partials

* fix(tui): reanchor stale dictation regions

Amp-Thread-ID: https://ampcode.com/threads/T-01a04c92-2d1d-7508-91bc-416341b7e8b0
Co-authored-by: Amp <[email protected]>

* fix(tui): bound live dictation regions

Amp-Thread-ID: https://ampcode.com/threads/T-01a0448f-5860-721c-8a47-5119fc57f685
Co-authored-by: Amp <[email protected]>
Co-authored-by: Pierre Bruno <[email protected]>

* fix(tui): preserve user edits across dictation partials

Co-authored-by: Pierre Bruno <[email protected]>

* fix(tui): reanchor stale dictation regions

Amp-Thread-ID: https://ampcode.com/threads/T-01a04c92-2d1d-7508-91bc-416341b7e8b0
Co-authored-by: Amp <[email protected]>
Co-authored-by: Pierre Bruno <[email protected]>

* fix(tui): preserve dictation separators after reanchoring

Amp-Thread-ID: https://ampcode.com/threads/T-01a07cfc-4f4e-773c-b42e-6229ef568a01
Co-authored-by: Pierre Bruno <[email protected]>

* fix(acp): reject Unicode whitespace in browser title origins

Cover encoded whitespace before permission translation and through wire payloads. Pin parent reasoning-effort forwarding at the specialist resume call site.

Amp-Thread-ID: https://ampcode.com/threads/T-01a097c8-242d-732f-bf6e-7321d8cf732c
Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Amp <[email protected]>
Co-authored-by: Anandan <[email protected]>
2026-10-12 15:01:09 +08:00
Vasanth T 23c27e0778 feat(sessions): add zero sessions prune, which never removes a session another process has open (#1089)
* 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.
2026-10-12 14:26:29 +08:00
Kevin Codex 2c418fc8ff fix: unbreak main CI (cron claim tests, Go 1.26.9) (#1124)
* 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.
2026-10-12 10:46:27 +08:00
PierrunoYTandClaude Sonnet 5.5 571af701bd fix(mcp): refresh OAuth tokens for discovered endpoints and registered clients (#1113)
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]>
2026-10-12 09:58:49 +08:00
2eb9d94fac fix(cron): preserve newer claims when overlapping runs complete (#1090)
* 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.

Fixes Gitlawb/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]>
2026-10-12 09:56:39 +08:00
PierrunoYTandClaude Sonnet 5.5 eefa978f61 fix(daemon): do not retry workers that finished with a final exit code (#1112)
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]>
2026-10-12 09:55:55 +08:00
Shubham bea2230633 chore: remove unused test exports and dead shell-highlight/theme/upstream helpers (#1096) 2026-10-12 09:55:24 +08:00
Marlowe df67edec13 fix(redaction): redact adjacent AWS keys glued end to end (#1095)
* 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.
2026-10-12 09:54:52 +08:00
42eb6ab5fb fix(cron): bound run history and read recent records from the tail (#1094)
* 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.

Fixes Gitlawb/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]>
2026-10-12 09:45:29 +08:00
PierrunoYTandAmp 8a68660161 fix(daemon): bound pending sessions and prune on completion (#1091)
Reject excess unfinished sessions before registration and dispatch, independently of retained history. Release admission and prune on every terminal path.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0dcbe-87ec-74e6-bdd0-6341b0c26d9a

Co-authored-by: Amp <[email protected]>
2026-10-12 09:40:41 +08:00
Vasanth T ab018b3098 fix(daemon): stop worker process groups by their launch-time identity (#1088)
* 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
2026-10-12 09:38:26 +08:00
1697b7a5ce fix(tui): show provider ownership in Recent and Favorites (#1083)
* 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.

Fixes Gitlawb/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]>
2026-10-12 09:37:30 +08:00
Vasanth T 6b5852dc18 fix(sandbox): say when a session's sandbox enforcement is degraded (#1082)
* 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.
2026-10-12 09:36:52 +08:00
Vasanth T aa52040498 fix(deps): refresh the lockfile to patched hono and @hono/node-server (#1080)
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.
2026-10-12 09:33:58 +08:00
Vasanth T 547330197b fix(oauth): give every loopback callback server the bounded shutdown (#1079)
* 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.
2026-10-12 09:33:10 +08:00
Vasanth T 7f11491078 fix(acp): publish sub-run sessions loaded over ACP as view-only (#1078)
* 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.
2026-10-12 09:32:40 +08:00
Marlowe 0324c1d410 fix(mcp): forward image tool results on the existing image channel (#1077)
* 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.
2026-10-12 09:31:17 +08:00
Marlowe b9ef1d271a fix(agent): retry mid-stream transport aborts before tool dispatch (#1076)
* 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.
2026-10-12 09:30:19 +08:00
Vasanth T 9520f2bad7 fix(search): redact every text field of session metadata, not ten of thirty (#1073)
* 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.
2026-10-12 09:29:36 +08:00
Vasanth T e2d18c50f0 test(config,tui): stop two tests reading the developer's environment and checkout path (#1072)
`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.
2026-10-12 09:28:11 +08:00
Vasanth T e20b762426 fix(redaction): redact credentials split by an invisible character (#1067)
* 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.
2026-10-12 09:24:15 +08:00
Gaurav Bhatia 7f8e5f08bb feat(notify): make permission-prompt alerts on by default and discoverable (#1001)
* 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.
2026-10-12 09:23:37 +08:00
Anandan 99721c762f fix(providers): send stable session headers to OpenCode (#1060) 2026-09-16 07:58:40 +08:00
Fayupable dcf229fecd refactor(tests): replace duplicated wait-for helpers with testutil.WaitFor (#1039)
Fixes #1023
2026-09-15 13:16:54 +08:00
github-actions[bot] 3fae0de2ea chore(main): release 0.9.0 (#1059)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-15 13:05:02 +08:00
github-actions[bot] 4ba112a0eb chore(main): release 0.9.0 (#958)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-15 12:59:18 +08:00
euxaristiaandeuxaristia b76c764b77 fix(providerio): close idle connections periodically to prevent HTTP/2 PROTOCOL_ERROR (#1058)
* 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]>
2026-09-15 12:58:58 +08:00
Anandan d73ec81e2d feat(tui): interactive terminal attach for tty exec sessions (#1055)
* 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
2026-09-15 08:40:47 +08:00
Vladimir Babin 2479d946c1 fix(mcp): reject a server-initiated request as a streamable-HTTP response (#1054)
* 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]>
2026-09-15 08:40:10 +08:00
Pujitha PaladuguandPujitha Paladugu d76f7f6931 ci: run the action-summary test suite in zero-action-smoke (#1053)
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]>
2026-09-15 08:39:40 +08:00
Pujitha PaladuguandPujitha Paladugu 37d69c96ce ci: disable persisted checkout credentials on release workflows (#1052)
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]>
2026-09-15 08:39:05 +08:00
Pujitha PaladuguandPujitha Paladugu 176f5a242b fix(oauth): force-close loopback callback connections past the shutdown deadline (#1051)
* 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]>
2026-09-15 08:37:58 +08:00
Anandan 9e609c5242 feat(exec): default headless runs to the turn-budget ceiling (#1049)
* 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
2026-09-15 08:37:09 +08:00
Anandan 2f531178f6 feat(tui): richer /resume session browser (#1047)
* 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
2026-09-15 08:36:37 +08:00
Vasanth T 3b43c43eca fix(sandbox): keep a derived ACL target inside the write root it came from (#1040)
* 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.
2026-09-15 08:35:54 +08:00
KRATOS faec8ee201 feat(acp): emit structured file changes in tool updates (#999)
* feat(tools): retain bounded structured file diffs

* feat(acp): emit structured file changes in tool updates

* fix(acp): harden structured file diff transport

* fix(tools): reject unsafe rich diff previews

* fix(acp): preserve safe rich diff semantics

* fix(tools): bind structured diffs to committed changes

* fix(acp): preserve structured diff identity

* fix(tools): close rich diff race gaps

* fix(tools): report partial patch paths from workspace

* fix(tools): finalize structured diff evidence

* fix(tools): separate unverified patch writes

* test(tools): cover failed formatter recovery

* fix(acp): serialize file deletions as nullable diffs

* fix(tools): recreate files after formatter failure

* test(acp): require nullable diff fields on wire
2026-09-15 08:31:39 +08:00
KRATOS c1937dfac7 feat(acp): add standard session list and resume (#914)
* 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.
2026-09-12 18:18:39 +05:30
KRATOS 90014210d2 fix(daemon): publish status files atomically (#949)
* fix(daemon): publish status files atomically

* fix(daemon): bind status publication to directory handle

* fix(daemon): validate status directory access

* fix(daemon): accept current token directory owner

* fix(daemon): migrate owned runtime directories safely

* fix(daemon): open Windows security handle relatively

* fix(daemon): use current NT directory object

* fix(observability): harden existing crash directories

* fix(security): bind crash report creation to private root

* fix(observability): atomically publish crash reports

* fix(observability): preserve committed crash reports

* fix(observability): revalidate committed crash path

* fix(daemon): secure runtime lifecycle boundaries

* fix(daemon): bind status ownership through cleanup

* fix(daemon): preserve Windows status security

* fix(daemon): retain trusted runtime root for lifecycle

* fix(daemon): roll back redirected socket binds

* fix(daemon): address status publication review
2026-09-12 18:18:12 +05:30
Vasanth T 6937a309cf fix(tools): say when format-on-write ran out of time (#1044)
* 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.
2026-09-12 08:54:58 +08:00
Vasanth T da244bcbfc fix(sessions): render resume context fields in key order (#1043)
* 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.
2026-09-12 08:54:33 +08:00
Vasanth T e34c8d2252 test(cli): skip the serve-scope symlink test when the platform refuses the link (#1042)
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
2026-09-12 08:53:51 +08:00
Vasanth T 91d921f4c5 feat(tui): offer mid-run model escalation behind --allow-escalation (#1019)
* 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.
2026-09-12 08:51:42 +08:00
Vasanth T 6ae8396f02 fix(sessions): carry an interrupted turn's work into the resume prompt (#1016)
* 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.
2026-09-12 08:51:10 +08:00
beardthelion 2837f5efb3 fix(installtxn): put back an install a killed commit left behind (#997)
* 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.
2026-09-12 08:46:32 +08:00
HazyLab 08fbea7a32 fix(redaction): split camelCase keys in normalizeKey (#994)
* 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.
2026-09-12 08:45:58 +08:00
cairn-internandcairn-intern 82ae3a8809 docs: name the five supported targets and drop Plan 9 as a review gate (#986)
* 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.

Fixes Gitlawb/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]>
2026-09-12 08:45:36 +08:00
488def68dc fix(config): preserve unknown top-level fields (#984)
* fix(config): preserve unknown top-level fields

Amp-Thread-ID: https://ampcode.com/threads/T-01a0448f-5860-721c-8a47-5119fc57f685
Co-authored-by: Amp <[email protected]>

* fix(config): match JSON Unicode case folding

Amp-Thread-ID: https://ampcode.com/threads/T-01a044e2-92ad-774b-9a86-094e2e5293bf
Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Amp <[email protected]>
2026-09-12 08:45:10 +08:00
b04fe96e9e fix(config): lock config read-modify-write across processes (#960)
* 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]>
2026-09-12 08:44:43 +08:00
79fa1422ef fix(providers): register Atomic Chat as local OpenAI-compatible runtime (#550)
* 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]>
2026-09-12 08:43:46 +08:00
cairn-internandcairn-intern 1aacf38b72 fix(background): treat POSIX zombie leader as already-exited (#981)
* 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]>
2026-09-12 08:41:52 +08:00
HazyLabandcl-ment 3063593129 ci: harden GitHub Actions permissions, add job timeouts, concurrency and coverage reporting (#951)
* 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]>
2026-09-12 08:41:14 +08:00
HazyLabandcl-ment d6b25fc303 perf(providers): parallelize isolated provider unit tests and shrink retry backoffs (#955)
* 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]>
2026-09-12 08:40:36 +08:00
7c50a9fb98 security(mcp): prevent server requests from matching client pending responses (#942)
* 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]>
2026-09-12 08:34:42 +08:00
euxaristiaandeuxaristia 275e248669 fix(oauth): Split an oversized keyring token blob across entries (#1007)
* 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]>
2026-09-12 08:33:44 +08:00
0207f2b269 fix(sandbox): keep Windows restricted-token SIDs narrow and fail closed on DenyRead (#1006)
* fix(sandbox): keep Windows restricted-token SIDs narrow and fail closed on DenyRead

* fix(sandbox): address CodeRabbit review feedback on PR 640

* refactor(sandbox): Address CodeRabbit nitpicks on PR 640.

Co-Authored-By: cairn-code <[email protected]>

* fix(sandbox): skip inherited ACEs and reject DenyRead upfront during setup

Skip inherited ACEs when preserving read deny entries, correct syntax in the command runner error message, and evaluate unsupported DenyRead profiles before the elevation check in Windows sandbox setup.

Refs #640

* Reject unsupported DenyRead profiles upfront during sandbox setup

Refs #640

* Migrate legacy Windows DenyWrite ACEs, randomize smoke probes, and clean dead descendant machinery.

Refs #640

* Use /d /c for denied-write probe command and verify ownership on cleanup.

Refs #640

* Honor NoInherit in legacy ACL migration and verify probe ownership.

Refs #639

* Preserve DenyRead bits when migrating combined legacy deny ACEs.

Refs #639

---------

Co-authored-by: euxaristia <[email protected]>
Co-authored-by: cairn-code <[email protected]>
2026-09-12 08:33:06 +08:00
PierrunoYTandAmp 8b3d1b1597 fix(tools): reject fuzzy edits with drifted content (#983)
Amp-Thread-ID: https://ampcode.com/threads/T-01a0448f-5860-721c-8a47-5119fc57f685

Co-authored-by: Amp <[email protected]>
2026-09-12 08:32:36 +08:00
cairn-internandcairn-intern a3852bac59 fix(update): keep Windows failed-restore state in trusted recovery records (#979)
* 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]>
2026-09-12 08:29:11 +08:00
euxaristiaandeuxaristia a3e7446681 chore(tui): remove unused test exports GetLocalDiffStats and WatchPRState (#975)
Refs #904

Co-authored-by: euxaristia <[email protected]>
2026-09-12 08:28:31 +08:00
KRATOS 0021475d4c fix(swarm): stop original member before handoff (#957)
* fix(swarm): stop source before handoff

* fix(swarm): make handoff recovery fail closed

* test(swarm): remove handoff timing assumptions
2026-09-12 08:28:01 +08:00
HazyLabandcl-ment deba3adfca security(update): prevent tar extraction from traversing escaping symlinks (#943)
* 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]>
2026-09-12 08:27:31 +08:00
euxaristiaandeuxaristia 501f32160f ci: Run the race detector on pull requests (#940)
* 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]>
2026-09-12 08:24:18 +08:00
Vasanth Tandbaoyu0 1e4db7c664 fix: let the configured forward proxy through the loopback guard (#1025)
* 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]>
2026-09-10 21:50:26 +05:30
Vasanth T f9092668a8 fix(agent): stop the tool-schema ratchet meaning a different thing per host (#1017)
* 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.
2026-09-10 21:43:58 +05:30
KRATOS 7f5e5af7bb fix(acp): make configured MCP tools callable (#1003)
* fix(acp): register configured MCP tools per turn

* fix(acp): address MCP review findings
2026-09-10 20:17:26 +05:30
f30f550e60 fix(sandbox): protect worktree git pointers (#805)
* 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]>
2026-09-08 07:54:22 +08:00
Vasanth T aadb4a27e9 fix(specialist): keep the pinned model when a specialist is resumed (#1009)
* 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.
2026-09-07 16:02:39 +05:30
KRATOS a6ec0435e3 feat(acp): surface safe browser tool metadata (#1000)
* feat(acp): surface safe browser tool metadata

* fix(acp): align browser permission titles

* fix(acp): namespace browser metadata

* fix(acp): reject unsafe browser title text
2026-09-07 15:50:49 +05:30
KRATOS 6916f85366 Merge split measurements dependency for zeromaxing 2026-08-29 17:14:48 +05:30
KRATOS 2e22868e88 Merge split memory dependency for zeromaxing 2026-08-29 17:14:42 +05:30
KRATOS 070aac427d fix(tui): preserve saved plans during resume 2026-08-29 17:14:30 +05:30
KRATOS 55dbc9cc51 fix(measurements): reject ambiguous timing claims 2026-08-28 23:57:20 +05:30
KRATOS b6b382387a refactor(tools): remove stale host-only shell guidance 2026-08-28 23:29:51 +05:30
KRATOS c14662920d fix(tools): unify sandbox shell guidance 2026-08-28 23:27:26 +05:30
KRATOS b1ff408104 fix(tui): retain admission persistence failures 2026-08-28 23:25:25 +05:30
KRATOS 65643b24bd test(zeromaxing): satisfy static analysis 2026-08-28 23:24:06 +05:30
KRATOS b92f8fd9fc style(tui): format plan completion message 2026-08-28 23:19:21 +05:30
KRATOS 3e66a5ed0d fix(zeromaxing): close launch and cache identity gaps 2026-08-28 23:16:54 +05:30
KRATOS 299982c1c1 test(zeromaxing): cover reviewer lifecycle regressions 2026-08-28 23:14:15 +05:30
KRATOS 9f9f47e01b fix(zeromaxing): harden plan lifecycle boundaries 2026-08-28 23:11:04 +05:30
KRATOS 40ac694ece fix(measurements): preserve unicode name boundaries 2026-08-28 22:54:17 +05:30
KRATOS fd32005058 fix(measurements): preserve structured identities 2026-08-28 22:17:01 +05:30
KRATOS 085ec169dc test(zeromaxing): use trusted measurement events 2026-08-28 22:00:31 +05:30
KRATOS 532845dc54 Merge remote-tracking branch 'origin/main' into codex/pr829-fix 2026-08-28 21:58:04 +05:30
KRATOS 6159a2d755 Merge remote-tracking branch 'origin/main' into codex/pr909-fix 2026-08-28 21:50:21 +05:30
KRATOS 297d9fc556 fix(measurements): trust structured test events 2026-08-28 21:50:21 +05:30
KRATOS b630dc19a6 fix(zeromaxing): finish current-main integration 2026-08-28 21:27:18 +05:30
Anandan 1b5db17656 fix(mcp): discover OAuth through protected resource metadata (#991)
* fix(mcp): discover OAuth through protected resource metadata

* fix(mcp): refuse OAuth discovery redirects

* fix(mcp): harden OAuth metadata discovery

* fix(mcp): protect discovered OAuth endpoints
2026-08-28 21:17:08 +05:30
KRATOS 75afc62c84 Merge remote-tracking branch 'origin/main' into codex/pr829-fix
# Conflicts:
#	internal/agent/loop_test.go
#	internal/cli/app.go
#	internal/cli/exec.go
#	internal/cli/exec_writer.go
#	internal/config/types.go
#	internal/providers/openai/provider_test.go
#	internal/sandbox/scope.go
#	internal/sandbox/scope_extra_read_test.go
#	internal/sandbox/scope_temp_refcount_test.go
#	internal/sandbox/scope_temporary_test.go
#	internal/tools/edit_file.go
#	internal/tools/file_tracker.go
#	internal/tui/files_panel.go
#	internal/tui/hover.go
#	internal/tui/hover_test.go
#	internal/tui/keybinding_help.go
#	internal/tui/model.go
#	internal/tui/mouse.go
#	internal/tui/options.go
#	internal/tui/sidebar.go
#	internal/tui/transcript_selection.go
2026-08-28 21:13:32 +05:30
KRATOS 0f2c5f5d7c fix(measurements): keep threshold tokenization lint-clean 2026-08-28 20:42:42 +05:30
KRATOS acfd4182e4 Merge remote-tracking branch 'origin/main' into codex/pr909-fix 2026-08-28 20:37:51 +05:30
KRATOS b5dd87547d fix(measurements): preserve claim and run ownership 2026-08-28 20:37:51 +05:30
Anandan eeea3308fe fix(tui): move voice capture to Ctrl+Space (#968)
* fix(tui): keep voice mode from swallowing spaces

* fix(tui): clarify voice capture controls

* fix(tui): align active voice status with capture key
2026-08-28 10:27:33 +05:30
KRATOS 358555135e fix(measurements): one duration token, every mention, and packages as subjects
@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
2026-08-24 20:53:02 +05:30
KRATOS 81472e527e fix(memory): the index decides whether a local note is private, not the ignore file
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
2026-08-24 14:19:38 +05:30
KRATOS 81b1364950 fix(memory): the delete door takes the clash guard the other two already had
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
2026-08-24 14:19:09 +05:30
KRATOS 887043a387 fix(memory): one resolution order, a named listing order, and each sentinel doc on its own error
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
2026-08-24 14:19:05 +05:30
KRATOS d37dbd72d2 fix(memory): make the unscoped round trip return what it wrote, and stop a case clash destroying a note
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
2026-08-24 14:19:00 +05:30
KRATOS 9098bc1c09 test(memory): close the probe handle that turned the Windows skip into a failure
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
2026-08-24 14:18:56 +05:30
KRATOS 18627b9de4 fix(memory): decide refusal on what the handle can OPEN, not on what it can Lstat
@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
2026-08-24 14:18:51 +05:30
KRATOS 512b9ce7ac fix(memory): a refused ancestor is reported as a refusal, not as an empty store
@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
2026-08-24 14:18:47 +05:30
KRATOS 52452fafc5 test(memory): assert that a linked ignore is refused, not which error says so
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
2026-08-24 14:18:42 +05:30
KRATOS af6ca75f60 fix(memory): the ignore gate follows git's rules, and refuses a linked ignore
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
2026-08-24 14:18:37 +05:30
KRATOS 416ad9a302 test(memory): exercise the ancestor guard against a Windows junction
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
2026-08-24 14:18:33 +05:30
KRATOS 36d894b33f fix(memory): refuse a link at any store ancestor, and fail closed on privacy
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
2026-08-24 14:18:28 +05:30
KRATOS b3b7f449c2 test(memory): pin the extension rule, the resolved scope, and partial listing
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
2026-08-24 14:18:24 +05:30
KRATOS 0595d34742 fix(memory): the description cap is linear, and it runs where the description is read
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
2026-08-24 14:18:19 +05:30
KRATOS 02130d1056 feat(memory): durable note store and memory tools
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
2026-08-24 14:18:15 +05:30
KRATOS bca8262c36 fix(measurements): a following subject, a non-duration unit, and a real
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
2026-08-24 14:14:01 +05:30
KRATOS 5d975161de fix(measurements): a zero-value ledger survives a contradiction, not just a record
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
2026-08-24 14:13:37 +05:30
KRATOS 772add3c90 fix(measurements): a decimal duration is one number, not its remainder
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
2026-08-24 14:13:32 +05:30
KRATOS 85c31508e5 fix(measurements): the clause scan must see every duration this package parses
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
2026-08-24 14:13:28 +05:30
KRATOS 0aacd65971 fix(measurements): read the hour form, and re-arm a test this PR had disarmed
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
2026-08-24 14:13:23 +05:30
KRATOS 80fb1d2e87 fix(measurements): a separator ends a clause only when a new subject follows it
@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
2026-08-24 14:13:19 +05:30
KRATOS 81de2477c3 fix(measurements): a full stop ends a clause, and a merged result names no single command
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
2026-08-24 14:13:14 +05:30
KRATOS 8d19677dfc fix(measurements): the cross-run report was nondeterministic in what it deduped and what it quoted
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
2026-08-24 14:13:10 +05:30
KRATOS 029af716e3 fix(measurements): the name-shape bound was dead code, and its tests could not tell
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
2026-08-24 14:13:05 +05:30
KRATOS af95c53e90 fix(measurements): an unrecorded neighbour ends the clause too, and the prefix fixture can now fail
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
2026-08-24 14:13:01 +05:30
KRATOS 6fed918224 feat(measurements): a cross-run check for the caller that cannot name the run
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
2026-08-24 14:12:56 +05:30
KRATOS 8a9a266d37 fix(measurements): bind a duration to its own clause, its own run, and its own value
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
2026-08-24 14:12:51 +05:30
KRATOS 90b645fe9b test(measurements): assert the parent test's own duration, not only the subtest
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
2026-08-24 14:12:47 +05:30
KRATOS 15896340e4 fix(measurements): the nearest duration is the claim, not the minute-shaped one
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
2026-08-24 14:12:42 +05:30
KRATOS c30a830a64 feat(measurements): check a report's timings against the run's own
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
2026-08-24 14:12:38 +05:30
KRATOS 1b1ce2db07 test: re-sync after the refusal-reporting and polarity fixes
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
2026-08-17 12:02:45 +05:30
KRATOS 101276b89f test: re-sync measurements after the clause-separator fix
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.
2026-08-17 09:59:20 +05:30
KRATOS 90fb0f7c22 test: re-sync memory after the Windows test fix
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
2026-08-16 23:04:07 +05:30
KRATOS df22877058 test: re-sync after the ignore-gate, clause-terminator and failure-state fixes
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
2026-08-16 22:51:41 +05:30
KRATOS 2318710a87 test: re-sync measurements after the cross-run determinism fix
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
2026-08-16 20:38:42 +05:30
KRATOS a7bb1c7902 test: re-sync measurements after the name-shape bound was fixed
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
2026-08-16 20:22:20 +05:30
KRATOS cfb8d06d4c test: re-sync after the ancestor, clause and consequence fixes
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
2026-08-16 20:04:35 +05:30
KRATOS 559a119b0f fix: adopt the reviewed split heads, and give the ledger the command that printed each timing
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
2026-08-16 16:41:17 +05:30
KRATOS da56b13693 test: re-sync the sandbox and agent packages after their review fixes
#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
2026-08-16 15:59:39 +05:30
KRATOS 9cd7c688af test: carry the split branches' new regressions into this branch too
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
2026-08-16 15:07:46 +05:30
KRATOS 9d7ba432c3 fix: adopt the reviewed heads of all five split packages
@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
2026-08-16 15:01:16 +05:30
KRATOS b3d54e5f9e fix(agent): the incompletion allowance yields when the work is blocked
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
2026-08-16 09:24:00 +05:30
KRATOS 272f693b55 fix: adopt the reviewed pathjail, credstore and memory from #891 and #897
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
2026-08-16 09:24:00 +05:30
Vasanthdev2004 99dceee59c fix(cli): keep the captured profile when nothing configures a provider
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
2026-08-16 09:24:00 +05:30
Vasanthdev2004 9f96272191 fix(memory,specialist): confine the note and plan stores to a handle
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
2026-08-16 09:23:59 +05:30
Vasanthdev2004 19cd2d44c9 fix(config): carry keepFinishedAgents through mergeConfig
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
2026-08-16 09:22:55 +05:30
KRATOS bd279603fe fix(tools): probe the Windows shell inside the sandbox that will run it
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
2026-08-16 09:22:55 +05:30
KRATOS 673da0e6c2 test(agent): move the tool-definitions golden for main's #867
#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
2026-08-16 09:22:55 +05:30
KRATOS 72deca444c test(agent): restore the tool-definitions golden the rebase reverted
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
2026-08-16 09:22:54 +05:30
KRATOS df72efb0c5 fix(tui): cancel settles foreground children even while a background plan runs
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
2026-08-16 09:22:54 +05:30
KRATOS a3edf73ac7 fix(specialist): the shortlist keeps both ends and version text is not a size
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
2026-08-16 09:22:54 +05:30
KRATOS bfb4a98fdc fix(specialist): the shortlist never drops a model whose size it cannot read
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
2026-08-16 09:22:53 +05:30
KRATOS d56b6e1e21 fix: six more defects a high-effort review found in the posture work
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
2026-08-16 09:22:53 +05:30
KRATOS 38dd75d27e fix(specialist): the appended verifier cannot refuse or fail the author's plan
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
2026-08-16 09:22:53 +05:30
KRATOS 6504be3d0a fix(specialist): the model shortlist never narrows a priced catalogue
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
2026-08-16 09:22:53 +05:30
KRATOS b7122fb4d7 fix(specialist): a plan task killed by the provider gets one more attempt
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
2026-08-16 09:22:52 +05:30
KRATOS 7a7799fccb feat(specialist): a plan routes to the top-ranked models, not the whole field
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
2026-08-16 09:22:52 +05:30
KRATOS 23f70804db feat(specialist): the posture appends a verifier to a plan that has none
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
2026-08-16 09:22:52 +05:30
KRATOS e54512ab79 fix(specialist): a trailing full stop is not part of a Windows path
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
2026-08-16 09:22:52 +05:30
KRATOS ac349daeed chore: move test-only shims to export_test.go and drop dead code
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
2026-08-16 09:22:51 +05:30
KRATOS c8008b0655 fix(specialist): the workspace-reach guard works on Windows paths
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
2026-08-16 09:22:51 +05:30
KRATOS 07ff1a9eb0 fix(tui): saved-plan summaries pass the same sanitizer as live ones
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
2026-08-16 09:22:51 +05:30
KRATOS b0dd616c4a fix(tui): /effort auto cannot leave the zeromaxing posture mid-run
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
2026-08-16 09:22:51 +05:30
KRATOS 9eb46f4a69 fix(cli): a headless run's children get only the granted extra write roots
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
2026-08-16 09:22:50 +05:30
KRATOS 021209fe6b refactor(tui): /profile answers in one line
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
2026-08-16 09:22:50 +05:30
KRATOS 8ab727d7e1 feat(tui): dracula's status signals move to the scheme's own cyan/pink
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
2026-08-16 09:22:50 +05:30
KRATOS e5a14d5daf feat(tui): the zeromaxing skin — MODELS panel, electric gradient, state-aware animation, settled cancels
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
2026-08-16 09:22:50 +05:30
KRATOS f6ba9ae354 feat(specialist): per-role model auto-assignment for delegated sub-agents
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
2026-08-16 09:22:49 +05:30
KRATOS 06cf8a2843 refactor(specialist): drop the dead PlanProgress.Done/Remaining helpers
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
2026-08-16 09:22:49 +05:30
KRATOS 276539918e feat(zeromaxing): resumable plans, read-grant propagation, size-aware routing
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
2026-08-16 09:22:49 +05:30
KRATOS 564dad961b fix(specialist): a plan task's answer no longer carries the child's session id
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
2026-08-16 09:22:48 +05:30
KRATOS 5bd07de48a fix(tui): the AGENTS section stops contradicting its own header
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
2026-08-16 09:22:48 +05:30
KRATOS 64bac3c4ea fix(tui): the zeromaxing chip is hit-tested on the status line only
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
2026-08-16 09:22:48 +05:30
KRATOS a457f08a37 fix(specialist): the budget schema declares the bounds it enforces
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
2026-08-16 09:22:48 +05:30
KRATOS 7f6e928fdd fix(specialist): the bundled research plan takes its subject as a parameter
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
2026-08-16 09:22:47 +05:30
KRATOS 813abbbf5f feat(cli): wire the new bounds and capabilities into the TUI
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
2026-08-16 09:22:47 +05:30
KRATOS a6f0f0b31a feat: four bounds and capabilities a plan run showed were missing
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
2026-08-16 09:22:47 +05:30
KRATOS 8d7793c85d fix(execution): remember a finished session for as long as a poll may wait
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
2026-08-16 09:22:47 +05:30
KRATOS 84516c3e12 fix(specialist): a plan task's numbers and its unfinished work both survive
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
2026-08-16 09:22:46 +05:30
KRATOS 809ffb6142 fix(specialist): a plan task must not report a number it never measured
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
2026-08-16 09:22:46 +05:30
KRATOS 32c99455e2 feat(zeromaxing): raise the turn ceiling and bound what a run may spend
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
2026-08-16 09:22:46 +05:30
KRATOS b703628aca feat(agent): bound a run by what it spends, not only by its turn count
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
2026-08-16 09:22:46 +05:30
KRATOS eccad2fe5b feat(specialist): a saved plan can be run against a different target
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
2026-08-16 09:22:45 +05:30
KRATOS 483879e8d2 fix(prompt): ask the provider which model family it serves
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
2026-08-16 09:22:45 +05:30
KRATOS c7881c37e4 test(specialist): do not assert Windows rename semantics we cannot run
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
2026-08-16 09:22:45 +05:30
KRATOS 8abea09b5c fix(tools): an edit must not erase the reads it did not disturb
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
2026-08-16 09:22:45 +05:30
KRATOS e8bfb3d4b7 feat(zeromaxing): make evidence the posture's job, not the prompt's
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
2026-08-16 09:22:44 +05:30
KRATOS de7f9df6b4 fix: repair the Windows smoke break and the review findings behind it
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
2026-08-16 09:22:44 +05:30
KRATOS 00b9271945 feat(specialist): reserve budget for later work, and declare the plan schema
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
2026-08-16 09:22:44 +05:30
KRATOS 441c99acf5 feat(specialist): a plan no longer dies because its inputs were cut short
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
2026-08-16 09:22:43 +05:30
KRATOS d53a9af196 fix(config): project maxTurns may tighten only
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
2026-08-16 09:22:43 +05:30
KRATOS 043261fc81 feat(specialist): give a plan with no bound at all a wall backstop, and say when spend cannot be measured
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
2026-08-16 09:22:43 +05:30
KRATOS 7fded2070a feat(specialist): teach the verify convention, and show what happens before a plan exists
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
2026-08-16 09:22:43 +05:30
KRATOS 74caacbc93 fix(specialist): five admission and lifecycle defects from review
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
2026-08-16 09:22:42 +05:30
KRATOS 11b87eb250 feat(tui): show which model ran a task, and what a plan left undone
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
2026-08-16 09:22:42 +05:30
KRATOS 9f0ceba48c feat(cli): wire model discovery, proving and child scope to the live run
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
2026-08-16 09:22:42 +05:30
KRATOS 445b7a0671 feat(specialist): choose a model per task, prove it runs, and bound what a plan spends
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
2026-08-16 09:22:41 +05:30
KRATOS e34ba53299 feat(config): per-role plan model preferences, and cache counts a child can carry
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
2026-08-16 09:22:41 +05:30
Vasanthdev2004 81ebe38020 fix(specialist): let a task granted write tools actually use them
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
2026-08-16 09:22:41 +05:30
Vasanthdev2004 4178f02d58 fix(specialist): make max_wall_seconds bound a concurrent plan
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
2026-08-16 09:22:41 +05:30
Vasanthdev2004andClaude Opus 5 b5c3ba00ed fix(specialist): reject negative plan timeouts, correct two false claims
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
2026-08-16 09:22:40 +05:30
Vasanthdev2004andClaude Opus 5 35074643bc fix(tui): guard the zeromaxing effort switch and two mouse hit-testers
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
2026-08-16 09:22:40 +05:30
Vasanthdev2004andClaude Opus 5 102359b362 fix(sandbox): release a temporary root under one lock hold
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
2026-08-16 09:22:40 +05:30
Vasanthdev2004andClaude Opus 5 75862b3f82 fix(tui): resume the named plan, sanitize task text, keep background panels
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
2026-08-16 09:22:40 +05:30
KRATOS a12b99635f test(cli): neutralise XDG_CONFIG_HOME in the sandbox policy golden
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
2026-08-16 09:22:39 +05:30
KRATOS 41e7d02ec5 test(specialist): measure concurrency against the host, not against the request
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
2026-08-16 09:22:39 +05:30
KRATOS 669623b931 fix(specialist): one plan per surface, on the path the model drives too
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
2026-08-16 09:22:39 +05:30
KRATOS ee5834d75e fix(tui): a sidebar row that was never drawn must not be clickable
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
2026-08-16 09:22:39 +05:30
KRATOS 37e077f916 feat(tui): a toggle for finished agents, and each one opens on what it produced
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
2026-08-16 09:22:39 +05:30
KRATOS a345bead0d fix(tui): a running task is work underway, not progress — the bar must not fill for it
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
2026-08-16 09:22:38 +05:30
KRATOS b87df0b8d5 fix(tui): the PLAN section holds both plans, so the footer line can finally go
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
2026-08-16 09:22:38 +05:30
KRATOS b0cc06e3fa fix(tui): an agent with no card key must not expand itself and shove PLAN off the column
"" 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
2026-08-16 09:22:38 +05:30
KRATOS b2ed2dbece feat(tui): click an agent in AGENTS to open its brief, spend and reason
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
2026-08-16 09:22:38 +05:30
KRATOS 51bbfb3106 feat(tui): one plan, one surface — the inline panel stands down for the sidebar
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
2026-08-16 09:22:37 +05:30
KRATOS 9ab96015cf fix(tui): a finished plan task must close its OWN card, not the last dispatched one
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
2026-08-16 09:22:37 +05:30
KRATOS 8bbc9e2295 feat(tui): the posture reads lowercase and its hover flows
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
2026-08-16 09:22:37 +05:30
KRATOS 04d699501f feat(tui): the posture is a live word, not a coloured box
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
2026-08-16 09:22:37 +05:30
KRATOS ed4d18e326 feat(tui): the posture chip leaves amber and shimmers under the cursor
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
2026-08-16 09:22:37 +05:30
KRATOS 013055a79e fix(specialist): an orchestrate approval must never be remembered
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
2026-08-16 09:22:37 +05:30
KRATOS 19add57a57 fix(sandbox): a temporary grant is refcounted, so a sibling's cleanup cannot revoke it
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
2026-08-16 09:22:23 +05:30
KRATOS 88c3ebe085 test: prove resume survives a partial concurrent run (gap report §5.1, closing)
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
2026-08-16 09:10:52 +05:30
KRATOS b64745533d feat(tui): each plan task's progress lands on its own card (gap report §5.1, 2/2)
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
2026-08-16 09:10:52 +05:30
KRATOS 3bc6c0ceb4 feat(specialist): run independent plan tasks in parallel (gap report §5.1)
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
2026-08-16 09:10:51 +05:30
KRATOS 20eec3b072 fix(specialist): tell a plan task to USE its tools, not merely that it has them
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
2026-08-16 09:10:51 +05:30
KRATOS 175c5c7f01 feat(specialist): a plan task may write, if it asks and is isolated (write-task arc, 3/3)
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
2026-08-16 09:10:51 +05:30
KRATOS c677c1bb4d feat(specialist): a write-capable plan gets a worktree, or does not run (write-task arc, 2/3)
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
2026-08-16 09:10:50 +05:30
KRATOS 42513ea9e8 feat(tui): a permission prompt can show what it is approving (write-task arc, 1/3)
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
2026-08-16 09:10:50 +05:30
KRATOS 80acf0d92a fix(specialist): drop update_plan from the plan grant, which cost every task ~6.5KB
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
2026-08-16 09:10:50 +05:30
KRATOS b3c8cd2282 feat: run a plan in the background and report it on a later turn (gap report §5.3)
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
2026-08-16 09:10:50 +05:30
KRATOS 1cddf58e2f feat(tui): /plans restart runs the last plan from the beginning (gap report §5.5)
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
2026-08-16 09:10:49 +05:30
KRATOS 382f7e7624 refactor(specialist): delete the orchestrate limits override, which nothing set
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
2026-08-16 09:10:49 +05:30
KRATOS d4278824e0 fix(cli): the model was never told the orchestrate tool exists
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
2026-08-16 09:10:49 +05:30
KRATOS 8c2e8448ef feat(specialist): ship one worked plan in the binary (gap report §5.14)
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
2026-08-16 09:10:48 +05:30
KRATOS feb3c5a03f feat(specialist): resume a plan from where it stopped (gap report §5.2, read half)
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
2026-08-16 09:10:48 +05:30
KRATOS 8d51afa75e feat(specialist): save a plan that worked and run it again (gap report §5.7, §5.8)
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
2026-08-16 09:10:48 +05:30
KRATOS f6d8ebfcb7 fix(tui): the plan list follows the running task instead of pinning to the head
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
2026-08-16 09:10:47 +05:30
KRATOS d06c9189b7 feat(tui): stop or pause a plan without stopping the turn (gap report §5.5)
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
2026-08-16 09:10:47 +05:30
KRATOS 596921f0ed feat(specialist): a stalled plan task is retried, and only a stalled one (gap report §5.10)
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
2026-08-16 09:10:47 +05:30
KRATOS a026c2b19b feat(config): the plan-size ceiling is a configurable tier (gap report §5.9)
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
2026-08-16 09:10:46 +05:30
KRATOS 191bdc5bb7 feat(tui): a plan run in the TUI is durable (gap report §5.2)
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
2026-08-16 09:10:46 +05:30
KRATOS 29e1829ea0 fix(specialist): resuming a task must not widen its authority
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
2026-08-16 09:10:46 +05:30
KRATOS 27f9626962 feat(specialist): per-task stall watchdog (gap report §5.10)
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
2026-08-16 09:10:46 +05:30
KRATOS 17274cb760 feat(tui): hover on the posture chip and the plan rows, and the chip is clickable
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
2026-08-16 09:10:45 +05:30
KRATOS ea8f4d165b feat(tui): the zeromaxing posture glows, in the footer and where it is offered
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
2026-08-16 09:10:45 +05:30
KRATOS f7bcc42501 feat(tui): the plan owns the right column — progress bar, task list, live detail
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
2026-08-16 09:10:45 +05:30
KRATOS 03f6f2a896 feat(tui): the running plan fills the sidebar's PLAN section, in colour
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
2026-08-16 09:10:45 +05:30
KRATOS 08cb41fbb2 feat(tui): the plan detail view — phases left, live agent detail right
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
2026-08-16 09:10:44 +05:30
KRATOS bd65420fe0 fix(tui): the plan budget line counts while the plan runs
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
2026-08-16 09:10:44 +05:30
KRATOS 75be3de8b5 fix(tui): five reporting defects a real plan run exposed
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
2026-08-16 09:10:44 +05:30
KRATOS e9f033baf6 fix(tui): the effort picker offers what /effort actually accepts
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
2026-08-16 09:10:43 +05:30
KRATOS 35d1e7eecd feat(tui): fade finished plan tasks, and say what /effort accepts
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
2026-08-16 09:10:43 +05:30
KRATOS ff33fee35d feat(specialist,tui): unbounded plans by default, collapsed plan panel
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
2026-08-16 09:10:43 +05:30
KRATOS 8f89068a9c perf(agent): drop the confirmation policy from a run that cannot act
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
2026-08-16 09:10:42 +05:30
KRATOS a13a6e9350 fix(specialist): the two read-only tool sets agree again
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
2026-08-16 09:10:42 +05:30
KRATOS dfcaf0aba5 fix(specialist): a resumed specialist keeps the run's model
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
2026-08-16 09:10:42 +05:30
KRATOS 3df079f71a fix(specialist): a plan task inherits the parent's model
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
2026-08-16 09:10:42 +05:30
KRATOS 86c104e484 feat(tui): add the /plans panel with the plan's dependency shape
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
2026-08-16 09:10:41 +05:30
KRATOS 3213670d53 feat(tui): make a running plan visible
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
2026-08-16 09:10:41 +05:30
KRATOS 3a67302d98 test(specialist): compare the two grant enforcement points against each other
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
2026-08-16 09:10:41 +05:30
KRATOS 7ad6e2fbb4 fix(cli): measure the run's real depth for plan admission
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
2026-08-16 09:10:41 +05:30
KRATOS 56e6e46ffc fix(cli): record the failed plan task's session and spend
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
2026-08-16 09:10:40 +05:30
KRATOS a7ff20c351 fix(tui): make both effort doors ask the same question
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
2026-08-16 09:10:40 +05:30
KRATOS b1e00efb20 fix(specialist): make the plan tool's parent grant real and empty mean empty
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
2026-08-16 09:10:40 +05:30
KRATOS ae45e6cd1c feat(tui): flip the orchestrate posture gate from the real handlers
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
2026-08-16 09:10:39 +05:30
KRATOS defd33f569 feat(cli): register the orchestrate tool and map a partial plan to exit 4
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
2026-08-16 09:10:39 +05:30
KRATOS 6c7e221e72 feat(specialist): capture and sequentially execute a declared plan
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
2026-08-16 09:10:39 +05:30
KRATOS aac9f9a8f6 fix(tui): treat an unlisted model as unknown, not as having no reasoning controls
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
2026-08-16 09:10:39 +05:30
KRATOS c2a06694a8 fix(tui): apply the posture's effort fill on models the catalog cannot vouch for
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
2026-08-16 09:10:38 +05:30
KRATOS 2499c37669 fix(execprofile): describe the posture's delta against the caller's own state
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
2026-08-16 09:10:38 +05:30
KRATOS 5702c0bd06 feat(tui): make /effort zeromaxing the entry point on both selection paths
/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
2026-08-16 09:10:38 +05:30
KRATOS 2337f1448c feat(agent): inject the zeromaxing reminders below the cache breakpoint
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
2026-08-16 09:10:38 +05:30
KRATOS 96140a9486 feat(execprofile): add the zeromaxing posture rung
zeromaxing is thorough with double the turn budget again: 320 tool-turns, effort
high, self-correction armed. It fills only what the caller left unset, exactly
as the other profiles do, so an explicit flag and a --mode preset both still win
— precedence stays enforced by ordering, not by a new check.

ONE NAME. /effort zeromaxing, /profile zeromaxing, --exec-profile zeromaxing and
--reasoning-effort zeromaxing all name the same thing, and nothing else does.
No aliases: "max", "deep" and "deepmode" resolve to nothing, asserted by test,
because a second spelling is a second thing to keep in step.

"max" is RESERVED and untouched. ValidReasoningEffort still accepts
ReasoningEffortMax, so /effort max and --reasoning-effort max behave exactly as
they did — parsed as a raw provider level, rejected by the flag parser, and
unsupported by every current model. That spelling stays free for a real provider
rung; claiming it for a Zero posture would burn it.

HONEST DELTA, stated to the user rather than left in a commit message: the turn
budget is the ONLY mechanical change over thorough. Effort is already at the
ceiling and self-correction is already on. execprofile.Delta carries that
sentence to /effort, /profile and the exec selection notice.

ReasoningEffort is "high", pinned by test rather than by comment.
TestZeromaxingReasoningEffortStaysHigh explains why: every level above "high"
falls through the providers' effort mappers into their default arm — anthropic
and gemini both compute a thinking budget of 0 (extended thinking DISABLED) and
the openai mapper drops the field entirely. Raising it would silently turn
reasoning off while the UI claimed a higher posture. Raising the real ceiling
means teaching the providers those tiers first.

The raised budget propagates to spawned sub-agents. Deliberate — this is a
maximal posture — so it is asserted by test and stated in Delta rather than left
to be discovered.

SelectionRefusal is the single authority both selection paths consult. The
headless exec path and the TUI paths already apply a profile's knobs through
different code with different state; letting each decide SELECTION independently
is how a rule gets applied to one call path and omitted from its sibling. Config
gating follows mergeProjectConfig's tighten-only template: a project
.zero/config.json may set profiles.disableZeromaxing (DISABLE) but can never
clear it, so a cloned repo cannot switch a cost multiplier on for whoever opens
it — an attempted enable is dropped silently, exactly like an ignored network
"allow".

Origin-Session: local-de382f | Claude Code | 3 prompts
Origin-Snapshot: c28c274718fa
2026-08-16 09:10:38 +05:30
1167 changed files with 132212 additions and 5049 deletions

No files matched your search

+3
View File
@@ -29,6 +29,9 @@ reviews:
- path: "tests/**/*.ts"
instructions: |
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.
tools:
github-checks:
timeout_ms: 300000
+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Questions and setup help
url: https://github.com/Gitlawb/zero/discussions/categories/q-a
url: https://github.com/Twigpine/zero/discussions/categories/q-a
about: Ask in Discussions Q&A. Include zero --version, your OS, provider/model, and zero doctor output for setup problems.
- name: Feature ideas
url: https://github.com/Gitlawb/zero/discussions/categories/ideas
url: https://github.com/Twigpine/zero/discussions/categories/ideas
about: Propose and discuss ideas in Discussions before opening a PR (Zero is in controlled development, see CONTRIBUTING.md).
- name: Security vulnerability
url: https://github.com/Gitlawb/zero/security/advisories/new
url: https://github.com/Twigpine/zero/security/advisories/new
about: Report security issues privately via GitHub Security Advisories (see SECURITY.md). Do not file a public issue.
+1 -1
View File
@@ -5,7 +5,7 @@ body:
- type: markdown
attributes:
value: |
New feature ideas start in [Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
New feature ideas start in [Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
not here. This form is for the step after that: an idea that gained traction in Discussions,
or one a maintainer asked to have tracked as an issue. Please link the discussion below.
Do not begin implementation until the issue has been reviewed and marked with the
+153 -75
View File
@@ -6,10 +6,109 @@ on:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
quality:
name: Code Quality & Lint
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
with:
go-version-file: go.mod
cache: true
- name: Check formatting
run: make fmt-check
- name: Vet
run: go vet ./...
- name: deadcode (advisory)
continue-on-error: true
run: make deadcode
- name: golangci-lint (advisory)
continue-on-error: true
run: make lint-static
test:
name: Unit Tests & Coverage
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
with:
go-version-file: go.mod
cache: true
- name: Test with coverage
run: |
go test -count=1 -coverprofile=coverage.out -covermode=atomic ./...
- name: Publish coverage summary
if: always()
shell: bash
run: |
if [ -f coverage.out ]; then
go tool cover -func=coverage.out | awk '
BEGIN {
total = "unknown"
rows = ""
}
$1 == "total:" {
total = $3
next
}
{
rows = rows sprintf("| `%s` | `%s` | **%s** |\n", $1, $2, $3)
}
END {
print "### 📊 Test Coverage Summary: **" total "**"
print "<details><summary>Coverage per function</summary>\n"
print "| File | Function | Coverage |"
print "| :--- | :--- | :--- |"
printf "%s", rows
print "</details>"
}' >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: zero-coverage-report
path: coverage.out
if-no-files-found: warn
smoke:
name: Smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
@@ -18,64 +117,22 @@ jobs:
- macos-latest
- windows-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
with:
go-version-file: go.mod
cache: true
- name: Install Make on Windows
if: matrix.os == 'windows-latest'
shell: pwsh
run: choco install make --no-progress -y
- name: Validate quality targets with stock macOS Make
if: matrix.os == 'macos-latest'
env:
GIT_CONFIG_COUNT: 1
GIT_CONFIG_KEY_0: grep.patternType
GIT_CONFIG_VALUE_0: fixed
run: make -n vulncheck
- name: Check vulnerabilities through native Windows Make
if: matrix.os == 'windows-latest'
shell: cmd
run: make vulncheck
- name: Check dead code through native Windows Make
if: matrix.os == 'windows-latest'
continue-on-error: true
shell: cmd
run: make deadcode
- name: Run static lint through native Windows Make
if: matrix.os == 'windows-latest'
continue-on-error: true
shell: cmd
run: make lint-static
- name: Check formatting
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "gofmt needed on:" >&2
echo "$unformatted" >&2
exit 1
fi
- name: Vet
if: matrix.os == 'ubuntu-latest'
run: go vet ./...
- name: Test
- name: Test (Fast Non-Race)
run: go test ./...
- name: Build binary
@@ -84,9 +141,15 @@ jobs:
- name: Smoke binary
run: go run ./cmd/zero-release smoke
performance:
name: Performance Smoke
race:
name: Race Detector
runs-on: ubuntu-latest
permissions:
contents: read
# A race the detector newly reports can present as a deadlock rather than a
# failure, and the default job timeout is six hours. This job is expected to
# be the longest in the workflow, so the ceiling is generous but finite.
timeout-minutes: 30
steps:
- name: Checkout
@@ -100,55 +163,70 @@ jobs:
go-version-file: go.mod
cache: true
# Invoke the Makefile target rather than repeating its flags. `make test`
# is already `go test ./... -race -count=1`, so running it here is what
# stops the project's declared test command and what CI actually runs
# from drifting apart again. The race detector needs cgo, which the
# hosted ubuntu runner supplies without an extra setup step.
- name: Test with the race detector
run: make test
performance:
name: Performance Smoke
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
with:
go-version-file: go.mod
cache: true
- name: Build binary
run: go run ./cmd/zero-release build
- name: Performance smoke
run: go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
run: |
mkdir -p dist/perf
go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
- name: Upload performance report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: zero-performance-smoke
path: dist/perf/perf-bench.json
if-no-files-found: warn
security:
name: Security & code health
name: Security & Code Health
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
with:
go-version-file: go.mod
cache: true
# Hard gate: fails the build when code reaches a known vulnerability. A stdlib
# CVE is cleared by a toolchain bump (see go.mod). May also flag a newly
# published advisory on an unrelated PR — intentional: do not ship known vulns.
- name: govulncheck
- name: govulncheck (linux)
run: make vulncheck
# Advisory: reports functions unreachable from any cmd/* main so dormant
# code is visible in CI. Non-blocking while the dormant subsystems are
# still being wired or removed.
- name: deadcode (advisory)
continue-on-error: true
run: make deadcode
# Advisory: catches what deadcode's whole-program reachability analysis
# doesn't, unused private functions/assignments reachable within a
# package but never actually called, plus staticcheck-style correctness
# and readability issues. Scoped to a few linters rather than the full
# default battery, and non-blocking, while the existing findings across
# the repo are cleaned up incrementally (see #527).
- name: golangci-lint (advisory)
continue-on-error: true
run: make lint-static
- name: govulncheck (windows)
run: make vulncheck-windows
+1
View File
@@ -24,6 +24,7 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
+28 -55
View File
@@ -34,12 +34,15 @@ jobs:
- name: Checkout
if: ${{ github.event_name != 'workflow_dispatch' || inputs.ref == '' }}
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Checkout requested ref
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }}
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
@@ -97,19 +100,21 @@ jobs:
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
# Publishes @gitlawb/zero to npm: first the five platform payloads (versions
# of the SAME package at suffixed versions like X.Y.Z-linux-x64, carrying the
# native binary + vendored helpers), then the wrapper at X.Y.Z whose
# optionalDependencies alias those platform versions. Platform versions MUST
# publish first — the wrapper pins them exactly, so the reverse order would
# open a window where installs resolve aliases that 404. See
# docs/NPM_PACKAGING.md for the full contract.
# Publishes the npm package named in package.json (@twigpine/zero): first
# the five platform payloads (versions of the SAME package at suffixed
# versions like X.Y.Z-linux-x64, carrying the native binary + vendored
# helpers), then the wrapper at X.Y.Z whose optionalDependencies alias those
# platform versions. Platform versions MUST publish first — the wrapper pins
# them exactly, so the reverse order would open a window where installs
# resolve aliases that 404. See docs/NPM_PACKAGING.md for the full contract.
#
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
# npmjs.com settings must list this repo + workflow file as a trusted
# publisher, and provenance is then generated automatically. Note npm only
# allows configuring a trusted publisher on an EXISTING package, so the very
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
# npmjs.com settings must list this repo + workflow file + the npm-publish
# environment as a trusted publisher, and provenance is then generated
# automatically. npm checks provenance against package.json `repository`,
# so that must name this repository. Note npm only allows configuring a
# trusted publisher on an EXISTING package, so a new package name starts
# with a one-time manual `npm publish` of an empty 0.0.0 placeholder.
# Because the platform payloads are versions of the same package, they reuse
# that single trusted-publisher configuration.
publish-npm:
@@ -122,13 +127,15 @@ jobs:
# maintainer approves it in the Actions UI before anything reaches npm.
environment:
name: npm-publish
url: https://www.npmjs.com/package/@gitlawb/zero
url: https://www.npmjs.com/package/@twigpine/zero
permissions:
contents: read
id-token: write # OIDC token exchange for npm trusted publishing + provenance
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
# No registry-url here on purpose: setup-node's registry-url writes an
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
@@ -196,47 +203,13 @@ jobs:
- name: Assemble npm packages
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
# Platform versions are semver PRERELEASES of the wrapper version
# (X.Y.Z-linux-x64), so they must never publish under the default
# `latest` dist-tag — that would make `npm install @gitlawb/zero`
# deliver a platform payload as the CLI. Publish them under a dedicated
# tag and assert `latest` survived before the wrapper goes out.
- name: Publish platform packages to npm
# Platform payloads first under the `platform` dist-tag, then the wrapper,
# with the `latest` dist-tag checked before and after (platform versions
# are semver PRERELEASES of the wrapper version and must never become
# `latest`). npm applies publishes asynchronously, so the script waits
# until npm shows every platform version before the wrapper goes out,
# and until `latest` moves after it. Versions already on the registry
# are skipped, so "Re-run failed jobs" is safe after a partial publish.
- name: Publish npm packages
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
for dir in dist/npm/platforms/*/; do
echo "publishing ${dir}"
(cd "$dir" && npm publish --access public --tag platform)
done
# Tolerate a failed lookup (registry lag, or no latest tag yet on a
# bootstrap publish): under set -e a bare failure here would abort
# AFTER the platform payloads went out, leaving no wrapper and a
# re-run that fails on republishing existing versions. An empty
# value falls through the guard below.
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
case "$latest" in
"${version}-"*)
echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2
exit 1
;;
esac
echo "dist-tag latest is ${latest} (unchanged by platform publishes)."
- name: Publish wrapper to npm
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
(cd dist/npm/wrapper && npm publish --access public)
# Confirm the wrapper owns `latest` (retry: registry metadata can lag
# a publish by a few seconds).
for attempt in 1 2 3 4 5; do
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
if [ "$latest" = "$version" ]; then
echo "dist-tag latest is ${version}."
exit 0
fi
sleep 5
done
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
exit 1
run: scripts/npm/publish-packages.sh dist/npm
+15
View File
@@ -5,6 +5,8 @@ on:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- scripts/action-summary.mjs
- scripts/action-summary.test.mjs
- .github/workflows/zero-action-smoke.yml
push:
branches:
@@ -12,8 +14,13 @@ on:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- scripts/action-summary.mjs
- scripts/action-summary.test.mjs
- .github/workflows/zero-action-smoke.yml
permissions:
contents: read
jobs:
validate:
name: Validate action.yml
@@ -24,6 +31,14 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
- name: Run action-summary test suite
run: node --test scripts/action-summary.test.mjs
- name: Parse action.yml and assert documented inputs
shell: bash
run: |
+1 -1
View File
@@ -1 +1 @@
{".":"0.8.0"}
{".":"0.12.0"}
+7
View File
@@ -81,6 +81,13 @@ These classes drive multi-round reviews. Fix them before requesting review:
assert raw `t.TempDir()` spellings (`/var` vs `/private/var` on macOS, short
paths on Windows). Prefer one cross-platform function with small `GOOS`
checks over duplicated helpers that drift.
- **Supported targets only:** CONTRIBUTING.md names the five native prebuilt
release artifacts (`linux-x64`, `linux-arm64`, `macos-arm64`, `macos-x64`,
`windows-x64`) and the additional supported Android/Termux source-build and
artifact-fallback path. Other `GOOS`/`GOARCH` combinations, including
`plan9`, are unsupported and are not a review criterion. Do not block a PR
for failing to compile on an unsupported `GOOS`/`GOARCH`. Tests may use
`plan9` as a fixture for "unsupported".
- **Security edges:** Keep secrets out of argv, env dumps, and logs. Redact
success and error paths (including stderr). Fail closed on ownership, lease,
and permission checks. Do not rely on pre-open path resolution
+143
View File
@@ -6,6 +6,149 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.12.0](https://github.com/Twigpine/zero/compare/v0.11.0...v0.12.0) (2026-11-02)
### Features
* **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))
* **redaction:** redact credentials whose trigger a greedy span swallowed ([#1158](https://github.com/Twigpine/zero/issues/1158)) ([a97db88](https://github.com/Twigpine/zero/commit/a97db8832fdba077f2d31bdf03ecef8d7c774fe1))
* **sessions:** capture freeform apply_patch checkpoints ([#1183](https://github.com/Twigpine/zero/issues/1183)) ([6396208](https://github.com/Twigpine/zero/commit/63962081cf130c2171407936ce3beb7021d238b8))
* **sessions:** preserve image attachment summaries on resume ([#1152](https://github.com/Twigpine/zero/issues/1152)) ([1e84849](https://github.com/Twigpine/zero/commit/1e848492e5c1919d187fb5e1eb5e7ef9bc6140dc)), closes [#1135](https://github.com/Twigpine/zero/issues/1135)
* **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))
## [0.11.0](https://github.com/Twigpine/zero/compare/v0.10.0...v0.11.0) (2026-10-15)
### Features
* **providers:** add Concentrate provider preset ([#1147](https://github.com/Twigpine/zero/issues/1147)) ([61b0936](https://github.com/Twigpine/zero/commit/61b09364f0f45b0fa8aebaf0a6c3d1c55dbe01c3))
### Bug Fixes
* **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))
## [0.10.0](https://github.com/Twigpine/zero/compare/v0.9.0...v0.10.0) (2026-10-15)
### Features
* **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))
* **providers:** add GMI Cloud provider preset and partner listing ([#1127](https://github.com/Twigpine/zero/issues/1127)) ([1335ca6](https://github.com/Twigpine/zero/commit/1335ca6671023ecda81a3cfaae7e9f2ff5d6e0de))
* **providers:** opt-in ZERO_RESPONSE_HEADER_TIMEOUT override for slow first-byte deployments ([#1106](https://github.com/Twigpine/zero/issues/1106)) ([ddc45f2](https://github.com/Twigpine/zero/commit/ddc45f2c8152fdd04b45dc11ed20d246fe7740a5))
* **runtime:** add process-local per-model RPM admission limits ([#1116](https://github.com/Twigpine/zero/issues/1116)) ([eebb384](https://github.com/Twigpine/zero/commit/eebb384676fb567acc1f5a20dcbb17df8a03dc42))
* **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)
* **acp:** persist token usage events for ACP sessions ([#1137](https://github.com/Twigpine/zero/issues/1137)) ([300cf62](https://github.com/Twigpine/zero/commit/300cf6275f8c94f06da3f00197d21cb8771842d9)), closes [#1118](https://github.com/Twigpine/zero/issues/1118)
* **acp:** publish sub-run sessions loaded over ACP as view-only ([#1078](https://github.com/Twigpine/zero/issues/1078)) ([7f11491](https://github.com/Twigpine/zero/commit/7f11491078662b2d389c278f3ce5d41d956670c4))
* **agent:** retry mid-stream transport aborts before tool dispatch ([#1076](https://github.com/Twigpine/zero/issues/1076)) ([b9ef1d2](https://github.com/Twigpine/zero/commit/b9ef1d271aa6ebc6d9adad2ef4ff0d695bb40da5))
* **config:** sync atomic config replacements ([#1093](https://github.com/Twigpine/zero/issues/1093)) ([4d2e9f0](https://github.com/Twigpine/zero/commit/4d2e9f0d08472f7304652ef7896df587e1cc6c21))
* **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))
* **cron:** preserve newer claims when overlapping runs complete ([#1090](https://github.com/Twigpine/zero/issues/1090)) ([2eb9d94](https://github.com/Twigpine/zero/commit/2eb9d94fac56d91d749ea4f96edcf1c87f79b48c))
* **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))
## [0.9.0](https://github.com/Gitlawb/zero/compare/v0.8.0...v0.9.0) (2026-09-15)
### Features
* **acp:** add standard session list and resume ([#914](https://github.com/Gitlawb/zero/issues/914)) ([c1937df](https://github.com/Gitlawb/zero/commit/c1937dfac72e6ad0e5ade6e48e2d9c17d9c3e5d6))
* **acp:** emit structured file changes in tool updates ([#999](https://github.com/Gitlawb/zero/issues/999)) ([faec8ee](https://github.com/Gitlawb/zero/commit/faec8ee2017fdd6d0c2c347add892cb73eb413d7))
* **acp:** surface safe browser tool metadata ([#1000](https://github.com/Gitlawb/zero/issues/1000)) ([a6ec043](https://github.com/Gitlawb/zero/commit/a6ec0435e36b9bea9793b2fd97f2ecd30e3a0f9b))
* **exec:** default headless runs to the turn-budget ceiling ([#1049](https://github.com/Gitlawb/zero/issues/1049)) ([9e609c5](https://github.com/Gitlawb/zero/commit/9e609c52420fd322f047fc238c04ac81dd623fdd))
* **tui:** interactive terminal attach for tty exec sessions ([#1055](https://github.com/Gitlawb/zero/issues/1055)) ([d73ec81](https://github.com/Gitlawb/zero/commit/d73ec81e2d3facb5377bc9ad0fe2aa314628a0c0))
* **tui:** offer mid-run model escalation behind --allow-escalation ([#1019](https://github.com/Gitlawb/zero/issues/1019)) ([91d921f](https://github.com/Gitlawb/zero/commit/91d921f4c5b8289a9e437f786218f1b48f2add5a))
* **tui:** richer /resume session browser ([#1047](https://github.com/Gitlawb/zero/issues/1047)) ([2f53117](https://github.com/Gitlawb/zero/commit/2f531178f614e587b629e4f81e0470adda647fbd))
### Bug Fixes
* **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))
* **config:** preserve unknown top-level fields ([#984](https://github.com/Gitlawb/zero/issues/984)) ([488def6](https://github.com/Gitlawb/zero/commit/488def68dcc6d36701fdf171e94a74a436b090fc))
* **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:** protect worktree git pointers ([#805](https://github.com/Gitlawb/zero/issues/805)) ([f30f550](https://github.com/Gitlawb/zero/commit/f30f550e6037c53f8521ba2d59c984ae6c574917))
* **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))
### Performance Improvements
* **agent:** cheap compaction — dedicated summarizer model, input cap… ([#532](https://github.com/Gitlawb/zero/issues/532)) ([6645f97](https://github.com/Gitlawb/zero/commit/6645f9744b37dda4d52ecc4631578d62cefc89de))
* **providers:** parallelize isolated provider unit tests and shrink retry backoffs ([#955](https://github.com/Gitlawb/zero/issues/955)) ([d6b25fc](https://github.com/Gitlawb/zero/commit/d6b25fc3039a6b7d72a5703a87a097449792b102))
* reduce startup and turn overhead ([#932](https://github.com/Gitlawb/zero/issues/932)) ([1fde941](https://github.com/Gitlawb/zero/commit/1fde941803c4295c11a4a14559f3c5217d1a9e69))
## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21)
+30 -5
View File
@@ -9,9 +9,9 @@ reports, feature requests, reproduction details, logs, and discussion on existin
issues.
Questions and setup help belong in
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions) (see
[GitHub Discussions](https://github.com/Twigpine/zero/discussions) (see
[SUPPORT.md](SUPPORT.md)), not in the issue tracker. Feature ideas start in the
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) category;
[Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) category;
an idea that gains traction there is the path to an approved issue and,
eventually, an accepted PR.
@@ -41,7 +41,7 @@ There are three primary ways to contribute:
1. Open a bug report with clear reproduction steps.
2. Propose a feature in
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
[Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
describing the problem, use case, and expected behavior. A feature-request
issue is filed only after the idea gains traction there or a maintainer asks
for one.
@@ -126,6 +126,31 @@ not enough. Dependency changes need a clear project benefit, such as fixing a
specific bug, addressing a security issue, improving compatibility, or supporting
an approved feature.
## Supported platforms
Zero publishes five native prebuilt artifacts from
`.github/workflows/release-artifacts.yml`:
- `linux-x64` (`linux/amd64`)
- `linux-arm64` (`linux/arm64`)
- `macos-arm64` (`darwin/arm64`)
- `macos-x64` (`darwin/amd64`)
- `windows-x64` (`windows/amd64`)
The shell installer and npm wrapper resolve the applicable artifacts from that
list. The PowerShell installer supports the published `windows-x64` artifact;
there is no native Windows ARM64 artifact. Windows on ARM users can run the x64
build under emulation or build from source.
Android/Termux is also supported even though it has no distinct release
artifact. The npm wrapper maps Android to the Linux artifact, and
`docs/INSTALL.md` documents the native `android/arm64` source build needed on
devices whose seccomp policy blocks the Linux build's syscall path.
Other `GOOS`/`GOARCH` values, including Plan 9, are unsupported. They are not a
review criterion. Do not block a pull request for failing to compile on an
unsupported target. Tests may use `plan9` as a fixture for an unsupported OS.
## What We Prioritize
The core team will prioritize:
@@ -168,7 +193,7 @@ Clear bug reports are the most useful way to help the project during this phase.
## Submitting a Feature Request
Feature requests start as a thread in
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
[Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
not as an issue. The feature-request issue form exists for the step AFTER that:
when an idea has gained traction in Discussions or a maintainer has asked for an
issue to track it. Whether in the discussion or the eventual issue, please
@@ -197,7 +222,7 @@ contribution phase.
General questions, setup help, and "is this a bug or my config" uncertainty all
go to
[Discussions Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a).
[Discussions Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a).
If you are unsure whether a proposed code change is appropriate, raise it in
Discussions Ideas or on the relevant issue first and ask. Please keep discussion
focused, specific, and respectful of maintainer time.
+9 -3
View File
@@ -7,7 +7,7 @@ DEADCODE_VERSION := v0.46.0
GOLANGCI_LINT_VERSION := v2.12.2
GOVULNCHECK_VERSION := v1.3.0
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck tidy clean baseline help
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck vulncheck-windows tidy clean baseline help
# Build the main CLI binary into ./zero.
build:
@@ -44,7 +44,8 @@ lint: fmt-check vet
# the possibly stale Go toolchain or consulting a multi-module GOWORK. git grep
# works with both POSIX shells and cmd.exe, including GNU Make 3.81 on macOS.
# The target-specific export is shell-independent.
lint-static deadcode vulncheck: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
lint-static deadcode vulncheck vulncheck-windows: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
lint-static deadcode vulncheck vulncheck-windows: export GOWORK = off
lint-static:
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
@@ -55,6 +56,11 @@ deadcode:
vulncheck:
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
vulncheck-windows:
mkdir -p "$(CURDIR)/.cache/gobin"
GOBIN="$(CURDIR)/.cache/gobin" go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
GOOS=windows "$(CURDIR)/.cache/gobin/govulncheck" ./...
tidy:
go mod tidy
@@ -77,4 +83,4 @@ baseline: build
--output internal/perfbench/reports/baseline.json
help:
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, tidy, clean, baseline"
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, vulncheck-windows, tidy, clean, baseline"
+224 -23
View File
@@ -4,11 +4,18 @@
<p align="center"><strong>A terminal coding agent you own.</strong></p>
<p align="center">
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/daily?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/weekly?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/70679?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/70679" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
</p>
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
<br>
<strong>English</strong> | <a href="README_ZH.md">中文</a>
</p>
@@ -23,10 +30,83 @@ zero exec "fix the failing test in ./pkg"
zero exec --output-format stream-json < turns.jsonl
```
## Partners
<table align="center">
<tr>
<td align="center" width="150" height="80">
<a href="https://twigpine.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://twigpine.com/logo-dark.png">
<img src="https://twigpine.com/logo.png" alt="Twigpine logo" width="72">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://mimo.mi.com">
<img src="https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg" alt="Xiaomi MiMo logo" width="136">
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://novita.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/novita-logo-dark.svg">
<img src="docs/assets/novita-logo.svg" alt="Novita AI logo" width="136">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://concentrate.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/concentrate-logo-dark.svg">
<img src="docs/assets/concentrate-logo.svg" alt="Concentrate logo" width="64">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://exa.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/exa-logo-dark.svg">
<img src="docs/assets/exa-logo.svg" alt="Exa logo" width="110">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://twigpine.com"><strong>Twigpine</strong></a></td>
<td align="center"><a href="https://mimo.mi.com"><strong>Xiaomi MiMo</strong></a></td>
<td align="center"><a href="https://novita.ai/"><strong>Novita AI</strong></a></td>
<td align="center"><a href="https://concentrate.ai/"><strong>Concentrate</strong></a></td>
<td align="center"><a href="https://exa.ai/"><strong>Exa</strong></a></td>
</tr>
<tr>
<td align="center" width="150" height="80">
<a href="https://www.gmicloud.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/gmi-cloud-logo-dark.svg">
<img src="docs/assets/gmi-cloud-logo.svg" alt="GMI Cloud logo" width="120">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://vercel.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
</tr>
</table>
## Why Zero
- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, or any
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, Atomic Chat, or any
OpenAI-/Anthropic-compatible endpoint.
- **Stay in control.** File writes, shell commands, network access, and
out-of-workspace writes go through Zero's permission and sandbox policy.
@@ -46,7 +126,7 @@ zero exec --output-format stream-json < turns.jsonl
### npm
```bash
npm install -g @gitlawb/zero
npm install -g @twigpine/zero
zero
```
@@ -60,26 +140,38 @@ matching GitHub Release whenever it is missing. Windows on ARM runs the x64
build under emulation. See [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md) for
how the package is put together.
Installed Zero as `@gitlawb/zero`? The package moved to `@twigpine/zero` in
0.10.0 and the old name gets no further releases. Both packages provide the
`zero` command, so quit any running `zero`, then remove the old package first:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
Your config, sessions, and logins are kept; they live outside the package. For
Bun, pnpm, and yarn, see [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero).
### Install scripts
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
```
### From source
Source builds require Go 1.26.6+.
Source builds require Go 1.26.10+.
```bash
git clone https://github.com/Gitlawb/zero.git
git clone https://github.com/Twigpine/zero.git
cd zero
go run ./cmd/zero
```
@@ -91,17 +183,18 @@ If you are testing before the first public release, build from source:
go build -o zero ./cmd/zero
```
On Linux, build the sandbox helper too if you want native sandboxing:
On Linux, native sandboxing needs Bubblewrap. The main `zero` binary re-executes
itself as the helper when `zero-linux-sandbox` is absent. A separate helper next
to `zero` or on `PATH` still takes precedence:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # optional
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
```
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`
(`~/.local/bin` is a good default). macOS does not need an extra helper binary.
Windows source builds can use the main `zero.exe` as their sandbox helper; release
archives still ship standalone Windows helper executables.
Put `zero` on `PATH` (`~/.local/bin` is a good default). macOS does not need an
extra helper binary. Windows source builds can use the main `zero.exe` as their
sandbox helper; release archives still ship standalone Windows helper executables.
More install details: [docs/INSTALL.md](docs/INSTALL.md).
@@ -133,6 +226,8 @@ export GEMINI_API_KEY=...
export AIMLAPI_API_KEY=...
export LONGCAT_API_KEY=...
export FIREWORKS_API_KEY=...
export GMI_API_KEY=...
export CONCENTRATE_API_KEY=...
export MINIMAX_API_KEY=...
export MINIMAXI_API_KEY=...
```
@@ -155,6 +250,18 @@ To configure Fireworks AI directly, run:
zero providers setup fireworks --set-active
```
To configure GMI Cloud directly, run:
```bash
zero providers setup gmicloud --set-active
```
To configure Concentrate directly, run:
```bash
zero providers setup concentrate --set-active
```
MiniMax presets use the Anthropic-compatible endpoints for the global and China
regions:
@@ -182,8 +289,12 @@ zero providers add custom-openai-compatible \
--set-active
```
For local models, run Ollama or LM Studio and then use `zero setup` or
`zero providers detect`.
For local models, run Ollama, LM Studio, or the [Atomic Chat](https://atomic.chat)
desktop app, then use `zero setup` or `zero providers detect`. For Atomic Chat,
load a model and enable its local OpenAI-compatible API (default
`http://127.0.0.1:1337/v1`). Choose `atomic-chat-local`; detection includes the
loaded model ID in the add command. If no usable ID is discovered, load a model
and retry. Model IDs requiring shell-specific quoting use interactive setup.
## Daily Use
@@ -273,6 +384,89 @@ zero sandbox grants list
Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs,
and MCP support for additional tools.
`web_fetch` refuses loopback, private and other special-use addresses: it checks
the URL before asking permission, resolves the host, and dials the address it
validated so a name cannot resolve to something else in between.
If `HTTP_PROXY`/`HTTPS_PROXY` is set, `web_fetch` and the provider connectivity
probe use it, and that last step changes: the proxy is dialed and the target
hostname is sent to it, so the proxy decides which address the request actually
reaches. The URL is still checked and resolved locally first, but a proxy that
answers differently can reach a private service. A forward proxy already sees
and can rewrite every request through it, so this is the trust you accept by
configuring one. Leave the variables unset for the checks to be enforced end to
end.
The built-in `web_search` tool is registered only when a search backend is
configured: `ZERO_WEBSEARCH_BASE_URL` (with an optional `ZERO_WEBSEARCH_API_KEY`)
for a generic JSON endpoint, `ZERO_WEBSEARCH_PROVIDER=searxng` with a SearXNG
base URL, or the AI/ML API Toolbox:
```bash
export ZERO_WEBSEARCH_PROVIDER=aimlapi
```
The AI/ML API backend runs the Toolbox tool `web/search`, and every search is
billed to your AI/ML API balance: about $0.0013 per result, with a documented
minimum of $0.0091 per search. Each search is sent with a $0.025 spending cap,
which the Toolbox enforces. It is used only when
`ZERO_WEBSEARCH_PROVIDER` is set to `aimlapi`; an AI/ML API key alone never
enables it. The key is read from your user configuration only; provider
profiles in a project `.zero` config are not used. It is the aimlapi profile's
key (the active profile when it is aimlapi, otherwise the first aimlapi
profile), including a key in the credential store, or `AIMLAPI_API_KEY` when no
aimlapi profile exists. It is resolved again on every search;
`ZERO_WEBSEARCH_API_KEY` is not used. When any aimlapi profile, or any profile
that reads `AIMLAPI_API_KEY`, points at a base URL other than
`https://api.aimlapi.com/v1`, or `AIMLAPI_INFERENCE_URL` is overridden, the key
may belong to a proxy, so it is not sent and the search fails without a request.
Requests go only to
`https://tools.aimlapi.com` (`ZERO_WEBSEARCH_BASE_URL` is ignored and redirects
are refused) and carry `X-AIMLAPI-Source: agent/zero` and an
`X-AIMLAPI-Partner-ID` header. The `domains` argument is sent to the Toolbox as
`include_domains`, and results are still filtered locally.
### AI/ML API Toolbox backend for `web_fetch`
`web_fetch` can fetch pages through the AI/ML API Toolbox tool
`search1api/crawl` instead of a direct HTTP GET. It is off unless you choose it:
```bash
export ZERO_WEBFETCH_PROVIDER=aimlapi
```
Having an AI/ML API key never switches it on by itself, and any other value (or
none) keeps the direct fetch. The variable is read on every fetch.
- **Billing:** Toolbox runs are billed to your AI/ML API balance, about
$0.0013 per run. Each run is sent with `max_cost_usd` 0.005, so a run
priced above that is refused before it starts and not charged. Rate-limited
or gateway-failed requests are retried with the same idempotency key, so a
retry is never billed twice.
- **Key:** the key comes from your `aimlapi` provider profile (including a key
in the credential store), or from `AIMLAPI_API_KEY` when there is no such
profile. It is withheld, and `web_fetch` returns an error, when any `aimlapi`
profile or any profile using `AIMLAPI_API_KEY` points at a base URL other than
`https://api.aimlapi.com/v1`, or when `AIMLAPI_INFERENCE_URL` is overridden:
the key may then belong to a proxy.
- **Destination:** requests go only to the fixed `https://tools.aimlapi.com`
endpoint, with `Authorization: Bearer <key>` and the attribution headers
`X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`. Redirects are
refused. `HTTP_PROXY`/`HTTPS_PROXY` apply to these requests as to Zero's other
aimlapi.com calls.
- **No fallback:** without a usable key, or when the run fails, `web_fetch`
returns an error and does not fetch the page directly. Unset the variable to
go back to direct fetches.
- **Remote fetch:** the page is fetched by a remote service, which may serve a
cached copy, and the full URL (query string included) is sent to it. The URL
checks above still run first, so loopback, private and metadata URLs are
never handed to it. The result is Markdown with a
`Backend: aimlapi (search1api/crawl)` line; `max_bytes` cuts the Markdown,
and `format: "raw"` is refused because the original response is not
available. The complete Toolbox run response is limited to 8 MiB before JSON
decoding; larger responses return an error even if `max_bytes` is smaller.
The remote run may already have been billed when this limit is detected.
For local dev servers, use shell commands such as `curl` through `exec_command`
so the normal sandbox and permission policy applies. Long-running commands stay
attached to a background terminal session and can be listed or stopped from the
@@ -296,7 +490,7 @@ zero context context-budget report
zero repo-map deterministic repository map
zero repo-info local repository summary
zero search | find search local session history
zero sessions inspect, resume, fork, and rewind sessions
zero sessions inspect, resume, fork, rewind, and prune sessions
zero spec manage spec-mode drafts
zero specialist manage specialist subagents
zero skills manage markdown instruction skills
@@ -314,6 +508,13 @@ zero update --check check for newer releases
zero upgrade download, verify, and install the latest release
```
Cron keeps the newest 1,000 run outcomes per job in `runs.jsonl`. Each new run
atomically replaces the history with the retained tail; existing larger histories
are trimmed on their next run. Archive the file before that run if you need older
outcomes. This retention does not delete session directories or reset fire counts.
Malformed records and legacy lines of 1 MiB or more are skipped when reading or
compacting history; new records of that size are rejected.
## Extending Zero
### Project and personal instructions
@@ -425,19 +626,19 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
Questions, setup help, ideas, and sharing all live in
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
| Category | Use it for |
|---|---|
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
For a good Q&A answer fast, include `zero --version`, your OS and install
method, the provider/model in use, and `zero doctor` output. See
[SUPPORT.md](SUPPORT.md). Bugs belong in
[issues](https://github.com/Gitlawb/zero/issues/new/choose); security reports
[issues](https://github.com/Twigpine/zero/issues/new/choose); security reports
follow [SECURITY.md](SECURITY.md), never a public thread.
## Contributing
+125 -18
View File
@@ -4,11 +4,18 @@
<p align="center"><strong>一个独属于你的终端编码智能体。</strong></p>
<p align="center">
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/daily?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/weekly?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/70679?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/70679" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
</p>
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
<br>
<a href="README.md">English</a> | <strong>中文</strong>
</p>
@@ -21,9 +28,82 @@ zero exec "修复 ./pkg 中失败的测试"
zero exec --output-format stream-json < turns.jsonl
```
## 合作伙伴
<table align="center">
<tr>
<td align="center" width="150" height="80">
<a href="https://twigpine.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://twigpine.com/logo-dark.png">
<img src="https://twigpine.com/logo.png" alt="Twigpine logo" width="72">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://mimo.mi.com">
<img src="https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg" alt="Xiaomi MiMo logo" width="136">
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://novita.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/novita-logo-dark.svg">
<img src="docs/assets/novita-logo.svg" alt="Novita AI logo" width="136">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://concentrate.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/concentrate-logo-dark.svg">
<img src="docs/assets/concentrate-logo.svg" alt="Concentrate logo" width="64">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://exa.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/exa-logo-dark.svg">
<img src="docs/assets/exa-logo.svg" alt="Exa logo" width="110">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://twigpine.com"><strong>Twigpine</strong></a></td>
<td align="center"><a href="https://mimo.mi.com"><strong>Xiaomi MiMo</strong></a></td>
<td align="center"><a href="https://novita.ai/"><strong>Novita AI</strong></a></td>
<td align="center"><a href="https://concentrate.ai/"><strong>Concentrate</strong></a></td>
<td align="center"><a href="https://exa.ai/"><strong>Exa</strong></a></td>
</tr>
<tr>
<td align="center" width="150" height="80">
<a href="https://www.gmicloud.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/gmi-cloud-logo-dark.svg">
<img src="docs/assets/gmi-cloud-logo.svg" alt="GMI Cloud logo" width="120">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://vercel.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
</tr>
</table>
## 为什么选择 Zero
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio、Atomic Chat,或任何 OpenAI/Anthropic 兼容端点。
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
@@ -35,32 +115,41 @@ zero exec --output-format stream-json < turns.jsonl
### npm
```bash
npm install -g @gitlawb/zero
npm install -g @twigpine/zero
zero
```
npm 包是一个小型包装器,其平台构建(Linux 和 macOS 的 x64/arm64、Windows 的 x64,包含浏览器/终端控制辅助工具)作为可选依赖直接从 npm registry 安装——没有安装脚本,也不会从 npm 之外下载任何内容。Bun、pnpm 和 yarn 的行为完全一致,无需任何信任或批准步骤。跳过可选依赖的安装(`--omit=optional`)也能工作:只要二进制文件缺失,包装器就会从对应的 GitHub Release 下载。Windows on ARM 通过模拟运行 x64 构建。详见 [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md)。
之前通过 `@gitlawb/zero` 安装过 Zero?从 0.10.0 起,npm 包已迁移到 `@twigpine/zero`,旧包名不再发布新版本。两个包都提供 `zero` 命令,所以请先退出正在运行的 `zero`,再卸载旧包:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
配置、会话和登录信息都会保留,它们存放在包之外。Bun、pnpm 和 yarn 的命令见 [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero)。
### 安装脚本
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
```
### 从源码构建
源码构建需要 Go 1.26.6+。
源码构建需要 Go 1.26.10+。
```bash
git clone https://github.com/Gitlawb/zero.git
git clone https://github.com/Twigpine/zero.git
cd zero
go run ./cmd/zero
```
@@ -71,14 +160,14 @@ go run ./cmd/zero
go build -o zero ./cmd/zero
```
在 Linux 上,如果你需要原生沙箱,还需要构建沙箱辅助程序:
在 Linux 上,原生沙箱需要 Bubblewrap。主程序 `zero` 在找不到 `zero-linux-sandbox` 时会以自身作为辅助进程重新执行。同目录或 PATH 上的独立 helper 仍优先:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # 可选
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
```
将 `zero` 和 `zero-linux-sandbox` 放在 `PATH` 上的同一目录中(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
将 `zero` 放在 `PATH` 上(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
更多安装细节:[docs/INSTALL.md](docs/INSTALL.md)。
@@ -106,6 +195,8 @@ export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
export GMI_API_KEY=...
export CONCENTRATE_API_KEY=...
```
要直接配置美团 LongCat(LongCat-2.0),运行:
@@ -114,7 +205,23 @@ export LONGCAT_API_KEY=...
zero providers setup longcat --set-active
```
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
要直接配置 GMI Cloud,运行:
```bash
zero providers setup gmicloud --set-active
```
要直接配置 Concentrate,运行:
```bash
zero providers setup concentrate --set-active
```
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
检测生成的添加命令会包含已加载的模型 ID;如果未发现可用的 ID,请加载模型后重试。
对于需要特定 Shell 转义的模型 ID,请使用交互式设置。
## 日常使用
@@ -214,7 +321,7 @@ zero context 上下文预算报告
zero repo-map 确定性仓库映射
zero repo-info 本地仓库摘要
zero search | find 搜索本地会话历史
zero sessions 检查、恢复、分叉和回滚会话
zero sessions 检查、恢复、分叉、回滚和清理会话
zero spec 管理规范模式草稿
zero specialist 管理专业子智能体
zero skills 管理 Markdown 指令技能
@@ -277,14 +384,14 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
实时交流在 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 进行。
提问、安装帮助、想法和分享都在
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
| 分类 | 用途 |
|---|---|
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
## 贡献
+13 -1
View File
@@ -33,4 +33,16 @@ Please include, where possible:
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
out of scope — but please report anything you are unsure about.
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`). The built-in
AI/ML API MCP server (`https://mcp.aimlapi.com/mcp`) is off until you run
`zero mcp enable aimlapi`. Once enabled, the Bearer key follows the aimlapi
profile resolution order (inline key, then the profile's `apiKeyEnv` — default
`AIMLAPI_API_KEY` — then the credential store), or `AIMLAPI_API_KEY` alone when
no aimlapi profile exists; a non-empty `AIMLAPI_API_KEY` can override a stored
profile key for MCP billing. The automatic key is withheld if an aimlapi profile
(or a profile reading `AIMLAPI_API_KEY`) uses a base URL other than
`https://api.aimlapi.com/v1`, `AIMLAPI_INFERENCE_URL` points elsewhere, a
non-empty user Authorization header is configured, OAuth is selected, project
config has touched the server, or the key is missing or not a single token
(including Unicode whitespace). See
[docs/EXTENDING.md](docs/EXTENDING.md#built-in-servers).
+5 -5
View File
@@ -2,8 +2,8 @@
## Questions, setup help, configuration
Ask in [GitHub Discussions](https://github.com/Gitlawb/zero/discussions). Use
[Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) so answers can
Ask in [GitHub Discussions](https://github.com/Twigpine/zero/discussions). Use
[Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) so answers can
be marked and found by the next person.
The fastest way to a useful answer is to include:
@@ -18,19 +18,19 @@ The fastest way to a useful answer is to include:
## Bugs
If Zero does something wrong (crashes, wrong output, broken behavior), file a
[bug report](https://github.com/Gitlawb/zero/issues/new/choose). If you are not
[bug report](https://github.com/Twigpine/zero/issues/new/choose). If you are not
sure whether it is a bug or a setup problem, start in Discussions and we will
promote it to an issue if it is one.
## Feature ideas
Start a thread in
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas). Zero is
[Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas). Zero is
in controlled development (see CONTRIBUTING.md), so discussing an idea first is
also the path to getting a PR accepted.
## Security issues
Never file a public issue or discussion. Report privately via
[GitHub Security Advisories](https://github.com/Gitlawb/zero/security/advisories/new)
[GitHub Security Advisories](https://github.com/Twigpine/zero/security/advisories/new)
(see SECURITY.md).
+2 -2
View File
@@ -2,7 +2,7 @@ name: ZERO
description: >-
Run the ZERO coding agent headlessly in a GitHub workflow. ZERO is
model- and provider-agnostic: you supply the provider and API key as inputs.
author: Gitlawb
author: Twigpine
branding:
icon: terminal
@@ -110,7 +110,7 @@ inputs:
zero-repo:
description: Repository to install the ZERO release from (owner/repo).
required: false
default: Gitlawb/zero
default: Twigpine/zero
outputs:
exit-code:
+63 -13
View File
@@ -1,11 +1,63 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { existsSync, readFileSync, writeSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const CURRENT_PACKAGE_NAME = '@twigpine/zero';
// The npm package moved from @gitlawb/zero to @twigpine/zero in 0.10.0. That
// release was also published once under the old name, so 0.9.0's `zero
// upgrade` and anyone still installing the old name land on a working build
// that says where the package went (docs/NPM_PACKAGING.md). Nothing is
// published under the old name after that.
const LEGACY_PACKAGE_NAME = '@gitlawb/zero';
// The npm name this wrapper was published under, read from its own
// package.json so the name lives in one place. The platform payload alias and
// every reinstall hint below derive from it.
function readPackageName(root) {
try {
const { name } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
if (typeof name === 'string' && name !== '') return name;
} catch {
// An unreadable manifest must not stop the CLI from launching.
}
return CURRENT_PACKAGE_NAME;
}
const packageName = readPackageName(packageRoot);
// Same note as legacyPackageSwitchMessage in internal/update/installmethod.go.
// Uninstalling first is the only safe order: both packages own the `zero` bin,
// so npm refuses to install the new one over the old (EEXIST), and forcing it
// lets a later uninstall of the old package delete the shared bin.
function legacyPackageNotice() {
return (
`[zero] Zero has moved to ${CURRENT_PACKAGE_NAME} on npm, and ${LEGACY_PACKAGE_NAME} gets no further releases.\n` +
'To keep getting updates, quit zero and run:\n' +
` npm uninstall -g ${LEGACY_PACKAGE_NAME}\n` +
` npm install -g ${CURRENT_PACKAGE_NAME}\n` +
'Uninstall first: both packages provide the `zero` command. With bun, pnpm or yarn, use its global remove and add commands instead.\n'
);
}
// Printed when the wrapper exits rather than before zero starts, because the
// TUI takes over the screen and would hide anything printed first. Only on a
// terminal, so scripts that capture stderr see nothing new. `zero update` and
// `zero upgrade` print the same note themselves.
if (packageName === LEGACY_PACKAGE_NAME && !['update', 'upgrade'].includes(process.argv[2])) {
process.on('exit', () => {
if (process.stderr.isTTY || process.env.ZERO_WRAPPER_SIMULATE_TTY === '1') {
// Synchronous: an exit handler cannot wait for an asynchronous write.
writeSync(2, legacyPackageNotice());
}
});
}
function zeroBinaryName(platform = process.platform) {
return platform === 'win32' ? 'zero.exe' : 'zero';
}
@@ -81,10 +133,10 @@ function bunRecoveryParagraph() {
return (
'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. Trust the package to run the blocked postinstall:\n' +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
` bun pm trust ${packageName} (project install)\n` +
` bun pm -g trust ${packageName} (global install)\n` +
'On Bun versions without `bun pm trust`, add\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
` "trustedDependencies": ["${packageName}"]\n` +
'to your project package.json and reinstall.\n' +
'\n'
);
@@ -93,14 +145,14 @@ function bunRecoveryParagraph() {
function buildFromSourceParagraph() {
return (
'If this platform has no prebuilt binary, build from source:\n' +
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).'
'https://github.com/Twigpine/zero (go run ./cmd/zero, requires Go 1.26+).'
);
}
function missingBinaryContextParagraph() {
return (
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
`Normally npm installs it as an optional dependency of ${packageName}\n` +
`(${packageName}-${process.platform}-${process.arch}), and the wrapper can\n` +
'also download it from the GitHub Release when it is missing.'
);
}
@@ -113,7 +165,7 @@ function missingNativeRecoveryParagraphs(postinstallScript) {
'\n' +
'Things to try:\n' +
' - reinstall without omitting optional dependencies:\n' +
' npm install -g @gitlawb/zero\n' +
` npm install -g ${packageName}\n` +
' - run the downloader manually (needs write access to the package dir):\n' +
` node "${postinstallScript}"\n` +
'\n' +
@@ -182,12 +234,12 @@ function missingNativeDoctorTextReport(postinstallScript) {
);
}
// The platform payload is a version of @gitlawb/zero itself, installed under
// an npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// The platform payload is a version of this same package, installed under an
// npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// process.platform/process.arch, so an unsupported platform simply fails to
// resolve and we fall through to the downloader.
function platformPackageBinary() {
const aliasedName = `@gitlawb/zero-${process.platform}-${process.arch}`;
const aliasedName = `${packageName}-${process.platform}-${process.arch}`;
let manifestPath;
try {
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
@@ -227,8 +279,6 @@ function downloadMissingBinary(packageRoot) {
});
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
function resolveNativeBinary() {
const fromPlatformPackage = platformPackageBinary();
if (fromPlatformPackage) return fromPlatformPackage;
+1 -1
View File
@@ -5,7 +5,7 @@ package main
import (
"os"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/perfbench"
"github.com/Twigpine/zero/internal/perfbench"
)
const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/perfbench"
"github.com/Twigpine/zero/internal/perfbench"
)
func TestParseArgsUsesCliAndEnvOverrides(t *testing.T) {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/perfbench"
"github.com/Twigpine/zero/internal/perfbench"
)
// taskOptions configures the `zero-perf-bench tasks` subcommand: the reproducible
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/perfbench"
"github.com/Twigpine/zero/internal/execprofile"
"github.com/Twigpine/zero/internal/perfbench"
)
// turnOptions configures the `zero-perf-bench turn` subcommand: the per-turn
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"os"
"strings"
"github.com/Gitlawb/zero/internal/review"
"github.com/Twigpine/zero/internal/review"
)
func main() {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/release"
"github.com/Twigpine/zero/internal/release"
)
func main() {
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"os/exec"
"syscall"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"os"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"os"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
func main() {
+5 -1
View File
@@ -3,9 +3,13 @@ package main
import (
"os"
"github.com/Gitlawb/zero/internal/cli"
"github.com/Twigpine/zero/internal/cli"
"github.com/Twigpine/zero/internal/sandbox"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == sandbox.LinuxSandboxHelperSubcommand {
os.Exit(sandbox.RunLinuxSandboxHelper(os.Args[1:], os.Stderr))
}
os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr))
}
+65 -2
View File
@@ -110,6 +110,8 @@ Discovery roots (earlier wins on name collisions):
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
For a skill installed since Zero started installing bundled files, `skill info` also reports drift in any file installed with it.
```text
~/.local/share/zero/skills/ # primary (install/remove target)
run-benchmarks/
@@ -140,6 +142,15 @@ Only `name` and `description` are recognized in the frontmatter today. The `name
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).
A skill can ship files beside its `SKILL.md`, in the [agentskills.io](https://agentskills.io/specification) layout: `scripts/`, `references/`, `assets/` or anything else the instructions refer to. `zero skill add` installs the whole skill directory, leaving out hidden entries such as `.git` and not following or copying symlinks (it lists any it skipped). A skill larger than 1,000 files or 32 MiB is refused. When the `skill` tool loads a skill that has such files, its output ends with the skill's directory and a list of the files, so instructions like "run `scripts/fill.py`" can be followed. Running a bundled script goes through the normal tool permissions; installing or loading a skill never runs anything.
```text
pdf/
SKILL.md
scripts/fill.py
references/form-fields.md
```
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
@@ -192,7 +203,11 @@ Events the agent emits (in dispatch order):
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
| `specialistStop` | A sub-agent ends | yes (specialist name) |
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
For swarm members the matcher subject is the **agent type** (`subagent`, `teammate`, …), not a registered specialist profile name — swarm members run from an inline manifest keyed by agent type.
`specialistStart` fires once the child process has actually started; a launch that fails before exec (missing binary, spawn error) records no public lifecycle hooks. `specialistStop` fires once per terminal stop: completion, error, explicit stop of a recovered task, and a background task reaped by a later `TaskOutput` all share one durable claim, so a hook that notifies or cleans up does not run twice for the same sub-agent run.
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). `sessionStart` / `sessionEnd` / `specialistStart` / `specialistStop` hooks are advisory (failures are audited but do not interrupt the run). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
@@ -246,6 +261,52 @@ or use a new server name. OAuth tokens are bound to the resolved server identity
so changing a server target can require `zero mcp oauth login <server>` again.
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
#### Built-in servers
Zero seeds two MCP servers before your config is merged. Both show up in
`zero mcp list`, and any field can be overridden in your user config.
- `exa` (`https://mcp.exa.ai/mcp`) is on by default for keyless web search.
`zero mcp disable exa` turns it off.
- `aimlapi` (`https://mcp.aimlapi.com/mcp`, the AI/ML API Toolbox, model
discovery, image/video/audio generation, chat, embeddings and balance tools)
is **off by default**, because its tool runs and generations are billed to
your AI/ML API balance. Turn it on with `zero mcp enable aimlapi`, which
writes only `"disabled": false` to your user config, and off again with
`zero mcp disable aimlapi`. A project config cannot turn it on.
When `aimlapi` connects, Zero sends your AI/ML API key as
`Authorization: Bearer <key>`. The key is resolved again on every request and
is never written to `config.json`. With an aimlapi.com provider profile
(`zero providers setup aimlapi`), the key is chosen in this order:
1. An inline key on the profile (if set in config).
2. Otherwise the environment variable named by the profile's `apiKeyEnv`
(the catalog default is `AIMLAPI_API_KEY`).
3. Otherwise the key held in the credential store when the profile uses
`apiKeyStored`.
When there is no aimlapi profile, Zero uses `AIMLAPI_API_KEY` alone. If both
`AIMLAPI_API_KEY` and a stored profile key exist, step 2 wins over the store
whenever the inline key is empty — MCP requests may bill the environment key's
account, not the stored key.
No automatic key is sent when an aimlapi profile, or any profile reading
`AIMLAPI_API_KEY`, uses a base URL other than `https://api.aimlapi.com/v1`, or
when `AIMLAPI_INFERENCE_URL` is overridden, because that credential may belong
to a proxy. No key is sent when the resolved value is not a single token (for
example a comma- or newline-separated pool), when you set your own
non-empty `Authorization` header, when the server uses `"auth": "oauth"`, or
when project config touched the server. Without a key the server answers 401.
To sign in with your aimlapi.com account instead, set `"auth": "oauth"` on the
server and run `zero mcp oauth login aimlapi`.
Every request to exactly `https://mcp.aimlapi.com` (https, default port,
whatever the server is named, including one you add by hand) also carries the
attribution headers `X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`,
and gets the key under the rules above. Other MCP servers never receive these
headers or the key.
### As a server — expose Zero's tools to another agent
```bash
@@ -290,10 +351,12 @@ Install and manage:
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
zero plugins disable github-pr-review # sets "enabled": false in plugin.json
zero plugins enable github-pr-review # sets "enabled": true in plugin.json
zero plugins remove github-pr-review # alias: rm
```
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
A plugin is active when it is present under a plugins directory and its `plugin.json` has `"enabled": true` (the default when the field is omitted). Use `zero plugins disable <id>` / `zero plugins enable <id>` to toggle that field without removing the plugin. Pass `--user` to target only user plugins and ignore `./.zero/plugins`; without it, a project plugin with the same id takes precedence. The toggle edits the `enabled` value in place — the rest of the manifest keeps its formatting, field order, and numeric literals — and serializes with `plugins add` / `plugins remove` on the same plugins root. Zero reads plugins when it starts, so the change applies to the next `zero exec` run or interactive session; an interactive session that is already running keeps the plugin tools, hooks, and skills it started with until you restart it.
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
+5 -5
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Gitlawb/zero@v1
- uses: Twigpine/zero@v1
with:
prompt: ${{ inputs.task }}
provider: openai
@@ -66,7 +66,7 @@ jobs:
| `github-token` | no | `${{ github.token }}` | Token used to post a PR comment. Requires `pull-requests: write`. |
| `working-directory` | no | `${{ github.workspace }}` | Directory to run ZERO in. |
| `zero-version` | no | (action ref → `latest`) | ZERO release version/tag to install, e.g. `v1.2.3` or `latest`. |
| `zero-repo` | no | `Gitlawb/zero` | Repository to install the ZERO release from. |
| `zero-repo` | no | `Twigpine/zero` | Repository to install the ZERO release from. |
## Outputs
@@ -101,7 +101,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Gitlawb/zero@v1
- uses: Twigpine/zero@v1
with:
prompt: |
Investigate this issue and propose a fix.
@@ -134,7 +134,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Gitlawb/zero@v1
- uses: Twigpine/zero@v1
id: zero
with:
prompt-file: .github/zero/upgrade-deps.md
@@ -176,7 +176,7 @@ jobs:
- **Least-privilege tokens.** Grant only the permissions the workflow needs
(`contents: write` to edit files, `pull-requests: write` to comment). The
default `GITHUB_TOKEN` is scoped to the repository.
- **Pin the action.** Reference a tag (`Gitlawb/zero@v1`) or a commit SHA so a
- **Pin the action.** Reference a tag (`Twigpine/zero@v1`) or a commit SHA so a
workflow run uses a known ZERO version. `zero-version` lets you pin the
installed binary independently of the action ref.
- **Linux and macOS runners** are supported; Windows runners are rejected with a
+13 -3
View File
@@ -302,8 +302,9 @@ Conceptually, one requested tool goes through these phases:
the model can inspect it and decide what to do next.
6. **Render and persist** — the result is surfaced to the TUI, stream-JSON
writer, or ACP client. TUI and exec record rich tool/session events for
resume/history; ACP currently persists conversational messages and streams
tool/permission updates to the client.
resume/history; ACP persists conversational messages and completed tool-call
starts/results for load replay. Live streaming details and permission
exchanges remain client notifications rather than durable history.
Tool success and failure are both informative context. A successful `read_file`
result gives the model file contents; a failed `edit_file` result gives the model
@@ -627,7 +628,16 @@ Core tool groups include:
skills, `ask_user`, permission requests.
- **Write tools**: `write_file`, `edit_file`, `apply_patch`, `update_plan`.
- **Shell tools**: `exec_command`, `write_stdin`, legacy `bash`.
- **Network tools**: `web_fetch` and optional web search backend.
- **Network tools**: `web_fetch` and optional web search backend. `web_fetch`
validates the URL before the permission prompt, refuses loopback/private/
special-use addresses, and dials the address it resolved. With
`HTTP_PROXY`/`HTTPS_PROXY` set, the proxy is dialed instead and resolves the
target itself, so destination enforcement ends at the proxy; see the note in
the README.
With `ZERO_WEBFETCH_PROVIDER=aimlapi` (opt-in; a key alone does not select
it), the same URL checks run and the page is then fetched as Markdown by the
billed AI/ML API Toolbox tool `search1api/crawl`, with no fallback to a
direct fetch; see the README.
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
and artifact helpers backed by packages such as `internal/localcontrol` and
`internal/browser`.
+78 -30
View File
@@ -5,11 +5,11 @@ blessed one.
| Method | Command | Self-update |
| --- | --- | --- |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @twigpine/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Twigpine/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Twigpine/zero` | `mise upgrade` |
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
| Source | `go build -o zero ./cmd/zero` | rebuild |
@@ -29,7 +29,7 @@ Release and verify its checksum.
## npm
```bash
npm install -g @gitlawb/zero
npm install -g @twigpine/zero
zero
```
@@ -56,15 +56,47 @@ cannot write to keeps failing — rerun the install or the command below with
sufficient permissions). To trigger the fetch manually:
```bash
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
node "$(npm root -g)/@twigpine/zero/scripts/postinstall.mjs"
```
### Moving from @gitlawb/zero
Zero was published as `@gitlawb/zero` up to 0.9.0. From 0.10.0 it is
`@twigpine/zero`, and the old name gets no further releases.
Both packages provide the `zero` command, so npm refuses to install the new
one while the old one is installed (`EEXIST`). Forcing it is worse: a later
uninstall of the old package removes the shared `zero` command. Quit any
running `zero` (on Windows a running `zero.exe` cannot be removed), then
uninstall the old package before installing the new one:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
With another package manager, the same two steps:
```bash
bun remove -g @gitlawb/zero && bun add -g @twigpine/zero
pnpm remove -g @gitlawb/zero && pnpm add -g @twigpine/zero
yarn global remove @gitlawb/zero && yarn global add @twigpine/zero
```
Config, sessions, logins, and plugins live in your user config and home
directories, not in the package, so they carry over unchanged.
0.10.0 was also published once as `@gitlawb/zero`, so an old install that
updates lands on a working build. That build prints these steps when `zero`
exits in a terminal, and `zero upgrade` and `zero update --check` print them
too.
## Linux And macOS Script
Install the latest release:
```bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
```
From a checkout:
@@ -89,7 +121,7 @@ scripts/install.sh --install-dir "$HOME/bin"
Defaults:
- Repository: `Gitlawb/zero`
- Repository: `Twigpine/zero`
- Version: latest GitHub release
- Install path: `~/.local/bin/zero`
@@ -100,7 +132,7 @@ Requirements: Bash, `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
Install the latest release:
```powershell
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
```
From a checkout:
@@ -123,7 +155,7 @@ powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:U
Defaults:
- Repository: `Gitlawb/zero`
- Repository: `Twigpine/zero`
- Version: latest GitHub release
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
@@ -134,13 +166,13 @@ archives through its `ubi` backend, so there is nothing extra to publish and no
registry in the middle:
```bash
mise use -g ubi:Gitlawb/zero
mise use -g ubi:Twigpine/zero
```
Pin a version the same way you would any other tool:
```bash
mise use -g ubi:Gitlawb/[email protected]
mise use -g ubi:Twigpine/[email protected]
```
Updates come from `mise upgrade`. `zero upgrade` also works, because a
@@ -153,13 +185,19 @@ describe a version that is no longer on disk. Prefer `mise upgrade`.
go install github.com/Gitlawb/zero/cmd/zero@latest
```
This builds from source, so it needs Go 1.26.6+ and it does not go through the
Every release so far declares the module path `github.com/Gitlawb/zero`, so
`go install` has to use that path; `github.com/Twigpine/zero/cmd/zero@latest`
fails with "module declares its path as" until a release is tagged with the new
module path.
This builds from source, so it needs Go 1.26.10+ and it does not go through the
release archives. Two consequences worth knowing before you pick it:
- On Linux you also need the sandbox helper, which is a separate binary and is
not installed by this command. See
- On Linux, Bubblewrap is still required for native sandboxing. Zero resolves
the helper in this order: an executable `zero-linux-sandbox` next to `zero`,
then `PATH`, then self-exec of the main binary. Standalone helpers remain
optional and preferred. See
[Sandbox Helpers For Source Builds](#sandbox-helpers-for-source-builds).
Without it, native sandboxing is unavailable.
- `zero upgrade` treats the result as a standalone install and will replace the
binary with a release build rather than rebuilding from source. If you chose
`go install` deliberately, rerun it instead.
@@ -169,7 +207,7 @@ macOS and Windows need no extra helper.
## From Source
```bash
git clone https://github.com/Gitlawb/zero.git
git clone https://github.com/Twigpine/zero.git
cd zero
go run ./cmd/zero
```
@@ -180,25 +218,32 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.26.6+.
Source builds require Go 1.26.10+.
### Sandbox Helpers For Source Builds
Release archives include the platform sandbox helpers. If you build directly
from source, build the helpers you need:
Release archives include the platform sandbox helpers. A source build of the
main `zero` binary is enough for Linux native sandboxing. Zero resolves the
helper in this order:
Linux:
1. an executable `zero-linux-sandbox` next to `zero`
2. then `PATH`
3. then self-exec of the main binary
Bubblewrap must still be installed. Standalone helpers remain optional and
preferred.
A separate helper remains supported if it sits next to `zero` or on `PATH`:
```bash
go build -o zero ./cmd/zero
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # optional
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
```
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`, for example
`~/.local/bin`. `zero-seccomp` is kept as a compatibility wrapper; the sandbox
helper applies the Unix-socket filter itself when that sandbox option is enabled.
Linux native sandboxing also requires Bubblewrap to be installed.
Put `zero` on `PATH`, for example `~/.local/bin`. `zero-seccomp` is kept as a
compatibility wrapper; the sandbox helper applies the Unix-socket filter itself
when that sandbox option is enabled.
macOS uses the system sandbox and does not need an extra helper binary.
@@ -213,7 +258,7 @@ seccomp filter on Android:
pkg install golang
# Build Zero for Android
git clone https://github.com/Gitlawb/zero.git
git clone https://github.com/Twigpine/zero.git
cd zero
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
@@ -302,7 +347,10 @@ See the [update guide](UPDATE.md) for update modes, flags, and platform details.
overwriting a binary a package manager owns leaves that manager describing a
version that is no longer there:
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
- **npm**: runs `npm install -g @twigpine/zero@latest` for you. An install of
the old `@gitlawb/zero` package refuses instead and prints the steps in
[Moving from @gitlawb/zero](#moving-from-gitlawbzero): that name gets no
further releases, so npm would only reinstall the same version.
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
keg binary directly would be reverted by the next `brew upgrade` or
`brew reinstall`.
+26
View File
@@ -0,0 +1,26 @@
# Local per-model completion limits
Add `modelRPM` to the existing Zero config file:
```json
{"modelRPM": {"gpt-4.1": 30, "claude-sonnet-4.5": 15}}
```
Keys resolve through the model registry; custom IDs match exactly after trimming
whitespace. Missing keys and zero mean unlimited. Negative limits and conflicting
aliases are configuration errors. Project config may add or tighten a user cap,
but cannot relax it. Changes take effect on the next Zero process start.
For `zero exec` and interactive agent turns, admission happens immediately before
the wrapped turn session's `Stream`. N admissions fit in any sliding 60-second
window; N+1 returns a local rate-limit error before entering that method. The
error reports when a slot expires. Exec uses its existing provider-error exit
code plus a local-limit hint. There is no sleep or hidden retry. Failed admitted
requests still consume a slot.
Sessions and model switches share a limiter in one process. Separate processes
have independent windows; restarting clears them. This is not an account-wide
cap. It counts Stream admissions, not all HTTP requests: setup/prewarm,
compaction, provider-internal retries, discovery and direct calls outside that
boundary are not counted. No TPM, cost budgets, persistence, cross-process
coordination or new runtime/Python dependency is introduced.
+77 -18
View File
@@ -1,6 +1,6 @@
# npm packaging
How `@gitlawb/zero` is put together on npm, why it is shaped this way, and the
How `@twigpine/zero` is put together on npm, why it is shaped this way, and the
rules the release pipeline must follow. Read this before touching
`package.json`, `bin/zero.js`, `scripts/postinstall.mjs`,
`scripts/npm/build-platform-packages.mjs`, or the npm-publish steps of
@@ -8,7 +8,7 @@ rules the release pipeline must follow. Read this before touching
## Goals
A `npm install -g @gitlawb/zero` must be **silent and self-contained**:
A `npm install -g @twigpine/zero` must be **silent and self-contained**:
- No `EBADENGINE` warnings — ours or from any package in the dependency tree.
- No install scripts anywhere in the tree, so npm's `allow-scripts` gating,
@@ -25,15 +25,15 @@ The model is the one used by Codex, esbuild, and Biome: a tiny wrapper package
plus per-platform payloads carrying the native binaries.
```
@gitlawb/zero <- wrapper: bin/zero.js + optionalDependencies
├─ @gitlawb/zero-darwin-arm64 -> npm:@gitlawb/zero@{version}-darwin-arm64
├─ @gitlawb/zero-darwin-x64 -> npm:@gitlawb/zero@{version}-darwin-x64
├─ @gitlawb/zero-linux-arm64 -> npm:@gitlawb/zero@{version}-linux-arm64
├─ @gitlawb/zero-linux-x64 -> npm:@gitlawb/zero@{version}-linux-x64
└─ @gitlawb/zero-win32-x64 -> npm:@gitlawb/zero@{version}-win32-x64
@twigpine/zero <- wrapper: bin/zero.js + optionalDependencies
├─ @twigpine/zero-darwin-arm64 -> npm:@twigpine/zero@{version}-darwin-arm64
├─ @twigpine/zero-darwin-x64 -> npm:@twigpine/zero@{version}-darwin-x64
├─ @twigpine/zero-linux-arm64 -> npm:@twigpine/zero@{version}-linux-arm64
├─ @twigpine/zero-linux-x64 -> npm:@twigpine/zero@{version}-linux-x64
└─ @twigpine/zero-win32-x64 -> npm:@twigpine/zero@{version}-win32-x64
```
- The platform "packages" are **versions of the same `@gitlawb/zero` package**,
- The platform "packages" are **versions of the same `@twigpine/zero` package**,
published at suffixed versions (`0.4.0-linux-x64`) and referenced through
`npm:` aliases in `optionalDependencies`. One package name means one npm
trusted-publisher configuration — no new publish credentials per platform.
@@ -56,7 +56,7 @@ plus per-platform payloads carrying the native binaries.
### Binary resolution in `bin/zero.js`
1. Resolve `@gitlawb/zero-<platform>-<arch>` and exec the `zero` binary from
1. Resolve `@twigpine/zero-<platform>-<arch>` and exec the `zero` binary from
it. This wins over any previously downloaded copy — the platform version is
pinned to the wrapper release.
2. Fall back to a binary previously downloaded next to the wrapper.
@@ -103,10 +103,22 @@ dependencies of the wrapper:
platform binary (plus the musl variant on linux, which its launcher detects
at runtime) — about 65 MB saved per platform package.
### One package name
The npm name lives in `package.json` and nothing else repeats it:
`scripts/npm/build-platform-packages.mjs` names the wrapper, the platform
versions, the alias names, and the alias targets from it, and `bin/zero.js`
reads its own `package.json` to derive the alias it resolves. The Go side
needs the name as a constant (`npmPackageName` in
`internal/update/installmethod.go`), and a test holds it equal to
`package.json`.
## Publishing rules (release pipeline)
These are the invariants `release-artifacts.yml` must hold. Breaking the first
one is user-visible immediately.
one is user-visible immediately. `scripts/npm/publish-packages.sh` implements
them, and `internal/npmwrapper/publish_test.go` runs it against a fake
registry.
1. **Platform versions must never become `latest`.** `0.4.0-linux-x64` is a
semver *prerelease* of `0.4.0`; publishing it without an explicit
@@ -118,20 +130,67 @@ one is user-visible immediately.
bootstrap publish) — a missing value must not abort the job between the
platform and wrapper publishes, which would strand a half-published
release.
2. **Platform versions publish before the wrapper.** The wrapper's
`optionalDependencies` pin exact suffixed versions; publishing the wrapper
first would create a window where installs resolve aliases that 404.
2. **Platform versions are on the registry before the wrapper.** The
wrapper's `optionalDependencies` pin exact suffixed versions; publishing
the wrapper first would create a window where installs resolve aliases
that 404. Ordering the uploads is not enough: npm applies a publish
asynchronously, and in 0.10.0 a platform version appeared ten minutes
after its upload returned, after the wrapper. The publish script waits
until npm shows every platform version before it publishes the wrapper,
and waits the same way for `latest` afterwards (up to 30 minutes in all).
3. **Exact-version pinning.** The wrapper at `X.Y.Z` references platform
versions `X.Y.Z-<platform>-<arch>` exactly — never ranges — so a wrapper
and its binaries can never skew.
and its binaries can never skew. The publish script refuses to start when
the wrapper's aliases do not match the platform payloads beside it.
4. All publishes go through the existing npm OIDC trusted-publishing flow and
the `npm-publish` environment gate; no tokens. Because the platform
payloads are versions of the same package, they reuse the single
trusted-publisher configuration.
trusted-publisher configuration. npm checks provenance against
`package.json` `repository`, so it must name the repository the workflow
runs in.
5. **Re-runs are safe.** A version already on the registry is skipped rather
than republished, so re-running a job that failed between the platform and
wrapper publishes finishes the release. Only npm's E404 counts as "not
published"; a lookup that fails any other way is retried and then stops the
job before anything is published.
`zero update` keeps working unchanged: it detects an npm install by finding a
`package.json` named `@gitlawb/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @gitlawb/zero@latest`.
`package.json` named `@twigpine/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @twigpine/zero@latest`.
## The move from `@gitlawb/zero`
Zero was published as `@gitlawb/zero` up to 0.9.0 and as `@twigpine/zero`
from 0.10.0. There is no in-place migration: both packages own the `zero`
bin, so npm refuses to install one over the other (`EEXIST`), and forcing it
lets a later uninstall of the old package delete the shared bin. The user
has to uninstall the old package and install the new one, in that order.
Deprecating the old package alone would not have reached existing users.
0.9.0's `zero upgrade` runs `npm install -g @gitlawb/zero@latest`; with
nothing newer under that name, npm reinstalls nothing, prints no deprecation
warning (it only warns when it installs something), and Zero reports the
update as done. So 0.10.0 was published once more under the old name, as a
bridge:
- The release job assembled the same verified artifacts a second time with
`build-platform-packages.mjs --package-name @gitlawb/zero` and published
them with the same script, after the `@twigpine/zero` publish. The steps
that did this ran once and have been removed from `release-artifacts.yml`.
- The bridge build knows its name from the `package.json` beside it. The Go
binary reports it as `InstallMethodNpmLegacy`: `zero upgrade` refuses
with the switch steps instead of reinstalling it, and `zero update --check`
adds them. `bin/zero.js` prints the same steps after `zero` exits, on a
terminal only. This handling stays in the code, since only a build
installed under the old name ever triggers it.
- Updating from 0.9.0 installs that bridge build, so npm also shows the
deprecation message.
`@gitlawb/zero` is deprecated on every version and no longer has a trusted
publisher, so this release workflow cannot publish it again; another version
would take a maintainer publishing by hand. Its versions stay on the
registry: existing installs resolve their platform payload through them, so
they must never be unpublished.
## Runbook: bumping the vendored helpers
+10 -9
View File
@@ -2,7 +2,8 @@
Run this checklist when a PR changes npm distribution files such as
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
Go build or release tooling, or the npm `bin` wrapper.
`scripts/npm/publish-packages.sh`, Go build or release tooling, or the npm
`bin` wrapper.
## Required Checks
@@ -37,24 +38,24 @@ path:
```bash
# platform-package path
mkdir -p /tmp/zero-sim/node_modules/@gitlawb
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@gitlawb/zero
mkdir -p /tmp/zero-sim/node_modules/@twigpine
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@twigpine/zero
cp -R dist/npm/platforms/zero-<platform>-<arch> \
"/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
"/tmp/zero-sim/node_modules/@twigpine/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
# first-run download fallback (delete the platform package first)
rm -rf "/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
rm -rf "/tmp/zero-sim/node_modules/@twigpine/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
```
## Checklist
- `package.json` has the expected package name (`@gitlawb/zero`), version,
- `package.json` has the expected package name (`@twigpine/zero`), version,
`bin.zero` entry, and NO `scripts` entries — the published package must be
free of lifecycle scripts (see NPM_PACKAGING.md).
- `scripts/npm/build-platform-packages.mjs` emits platform payloads whose
`package.json` is `@gitlawb/zero@<version>-<platform>-<arch>` with matching
`package.json` is `@twigpine/zero@<version>-<platform>-<arch>` with matching
`os`/`cpu`, contains the executable binary and the vendored `helpers/` tree
with regular-file (non-symlink) `.bin` shims, and a wrapper payload with no
scripts, no dependencies, and the full five-alias `optionalDependencies`
+1
View File
@@ -6,6 +6,7 @@ Zero.
## User Docs
- [Install](INSTALL.md)
- [Local per-model completion limits](MODEL_RPM.md)
- [Update flow](UPDATE.md)
- [OAuth logins and subscription-backed providers](oauth-subscriptions.md)
+21 -7
View File
@@ -7,7 +7,7 @@ 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
@@ -28,7 +28,11 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
`--apply` installs the update in place:
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
- 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.
@@ -38,7 +42,7 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
- `--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 @gitlawb/zero@latest` and
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
@@ -65,7 +69,7 @@ Endpoint resolution order:
1. `--endpoint`
2. `ZERO_UPDATE_RELEASE_URL`
3. `--repo`
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
4. `https://api.github.com/repos/Twigpine/zero/releases/latest`
Installer scripts download the matching release asset for the local platform and
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
@@ -93,14 +97,24 @@ binary:
| State on disk | Meaning |
|---|---|
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. |
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker, or a per-user update-recovery record naming that copy | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. The `.keep` marker is in the installation directory and can be deleted; the identity-bound record is not, so deleting the marker alone does not end the refusal. |
| `<binary>.…old.<suffix>.recovery` | A previous update could not even write the marker, so it moved the last verified binary to that name. |
| The binary is missing and one or more `*.old` files exist | The previous attempt was interrupted between moves. |
The refusal names the paths involved and the two moves that end the state:
either move the recovery binary back over the executable path, or — if the
installed binary is the one you want — delete the `.keep` marker (or the
`.recovery` copy, once you have verified the installed binary) and update again.
installed binary is the one you want — delete the recovery copy itself (not
only the `.keep` marker) after verifying the installed binary, then update
again. Deleting the marker alone is not enough: a failed restore also records
that copy in per-user state outside the installation directory, and the next
update consults that record even if the marker is gone.
That trusted record lives under `%AppData%\zero\update-recovery` (per-user).
The recovery copy and its `.keep` marker sit in the installation directory
(per-machine). A second Windows account on the same machine does not see the
first account's unresolved record; the sibling marker is the machine-visible
signal for every account. There is no separate machine-wide store: writing one
would need a shared writable location and ACL rules the updater does not own.
This is deliberately fail-closed and differs from older releases, which deleted
`<binary>.old` and proceeded. The trade-off is that anyone who can write in the
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="80" height="80">
<circle cx="6.4" cy="6.4" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="6.4" r="1.3440793933950872" fill="#FFFFFF"/><circle cx="25.6" cy="6.4" r="2.03724340327418" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="6.4" r="2.5983501502741575" fill="#FFFFFF"/><circle cx="44.8" cy="6.4" r="3.087883576356698" fill="#FFFFFF"/><circle cx="54.4" cy="6.4" r="3.530261898078346" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="6.4" r="3.938362368365323" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="6.4" r="4.32" fill="#FFFFFF"/><circle cx="6.4" cy="16" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="16" r="1.2253406720005793" fill="#FFFFFF"/><circle cx="25.6" cy="16" r="1.8572691561702617" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="16" r="2.3688065860361402" fill="#FFFFFF"/><circle cx="44.8" cy="16" r="2.8150936284760535" fill="#FFFFFF"/><circle cx="54.4" cy="16" r="3.2183913448763177" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="16" r="3.5904393853139163" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="16" r="3.9383623683653233" fill="#FFFFFF"/><circle cx="6.4" cy="25.6" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="25.6" r="1.0983685811330859" fill="#FFFFFF"/><circle cx="25.6" cy="25.6" r="1.6648154545579388" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="25.6" r="2.1233464197636582" fill="#FFFFFF"/><circle cx="44.8" cy="25.6" r="2.5233883646560065" fill="#FFFFFF"/><circle cx="54.4" cy="25.6" r="2.8848956178295655" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="25.6" r="3.2183913448763177" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="25.6" r="3.530261898078346" fill="#FFFFFF"/><circle cx="6.4" cy="35.199999999999996" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="35.199999999999996" r="0.9607316398574451" fill="#FFFFFF"/><circle cx="25.6" cy="35.199999999999996" r="1.456196862502631" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="35.199999999999996" r="1.8572691561702614" fill="#FFFFFF"/><circle cx="44.8" cy="35.199999999999996" r="2.2071817085957024" fill="#FFFFFF"/><circle cx="54.4" cy="35.199999999999996" r="2.5233883646560065" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="35.199999999999996" r="2.815093628476053" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="35.199999999999996" r="3.087883576356698" fill="#FFFFFF"/><circle cx="6.4" cy="44.8" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="44.8" r="0.8084233550482692" fill="#FFFFFF"/><circle cx="25.6" cy="44.8" r="1.2253406720005793" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="44.8" r="1.56282951468281" fill="#FFFFFF"/><circle cx="44.8" cy="44.8" r="1.8572691561702617" fill="#FFFFFF"/><circle cx="54.4" cy="44.8" r="2.1233464197636582" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="44.8" r="2.3688065860361402" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="44.8" r="2.5983501502741584" fill="#FFFFFF"/><circle cx="6.4" cy="54.4" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="54.4" r="0.633846499460857" fill="#FFFFFF"/><circle cx="25.6" cy="54.4" r="0.9607316398574451" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="54.4" r="1.225340672000579" fill="#FFFFFF"/><circle cx="44.8" cy="54.4" r="1.456196862502631" fill="#FFFFFF"/><circle cx="54.4" cy="54.4" r="1.6648154545579388" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="54.4" r="1.8572691561702614" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="54.4" r="2.03724340327418" fill="#FFFFFF"/><circle cx="6.4" cy="63.99999999999999" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="63.99999999999999" r="0.4181827351271541" fill="#FFFFFF"/><circle cx="25.6" cy="63.99999999999999" r="0.6338464994608571" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="63.99999999999999" r="0.8084233550482695" fill="#FFFFFF"/><circle cx="44.8" cy="63.99999999999999" r="0.9607316398574454" fill="#FFFFFF"/><circle cx="54.4" cy="63.99999999999999" r="1.0983685811330863" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="63.99999999999999" r="1.2253406720005795" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="63.99999999999999" r="1.3440793933950876" fill="#FFFFFF"/><circle cx="6.4" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="25.6" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="44.8" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="54.4" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="80" height="80">
<circle cx="6.4" cy="6.4" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="6.4" r="1.3440793933950872" fill="#0D0E10"/><circle cx="25.6" cy="6.4" r="2.03724340327418" fill="#0D0E10"/><circle cx="35.199999999999996" cy="6.4" r="2.5983501502741575" fill="#0D0E10"/><circle cx="44.8" cy="6.4" r="3.087883576356698" fill="#0D0E10"/><circle cx="54.4" cy="6.4" r="3.530261898078346" fill="#0D0E10"/><circle cx="63.99999999999999" cy="6.4" r="3.938362368365323" fill="#0D0E10"/><circle cx="73.60000000000001" cy="6.4" r="4.32" fill="#0D0E10"/><circle cx="6.4" cy="16" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="16" r="1.2253406720005793" fill="#0D0E10"/><circle cx="25.6" cy="16" r="1.8572691561702617" fill="#0D0E10"/><circle cx="35.199999999999996" cy="16" r="2.3688065860361402" fill="#0D0E10"/><circle cx="44.8" cy="16" r="2.8150936284760535" fill="#0D0E10"/><circle cx="54.4" cy="16" r="3.2183913448763177" fill="#0D0E10"/><circle cx="63.99999999999999" cy="16" r="3.5904393853139163" fill="#0D0E10"/><circle cx="73.60000000000001" cy="16" r="3.9383623683653233" fill="#0D0E10"/><circle cx="6.4" cy="25.6" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="25.6" r="1.0983685811330859" fill="#0D0E10"/><circle cx="25.6" cy="25.6" r="1.6648154545579388" fill="#0D0E10"/><circle cx="35.199999999999996" cy="25.6" r="2.1233464197636582" fill="#0D0E10"/><circle cx="44.8" cy="25.6" r="2.5233883646560065" fill="#0D0E10"/><circle cx="54.4" cy="25.6" r="2.8848956178295655" fill="#0D0E10"/><circle cx="63.99999999999999" cy="25.6" r="3.2183913448763177" fill="#0D0E10"/><circle cx="73.60000000000001" cy="25.6" r="3.530261898078346" fill="#0D0E10"/><circle cx="6.4" cy="35.199999999999996" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="35.199999999999996" r="0.9607316398574451" fill="#0D0E10"/><circle cx="25.6" cy="35.199999999999996" r="1.456196862502631" fill="#0D0E10"/><circle cx="35.199999999999996" cy="35.199999999999996" r="1.8572691561702614" fill="#0D0E10"/><circle cx="44.8" cy="35.199999999999996" r="2.2071817085957024" fill="#0D0E10"/><circle cx="54.4" cy="35.199999999999996" r="2.5233883646560065" fill="#0D0E10"/><circle cx="63.99999999999999" cy="35.199999999999996" r="2.815093628476053" fill="#0D0E10"/><circle cx="73.60000000000001" cy="35.199999999999996" r="3.087883576356698" fill="#0D0E10"/><circle cx="6.4" cy="44.8" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="44.8" r="0.8084233550482692" fill="#0D0E10"/><circle cx="25.6" cy="44.8" r="1.2253406720005793" fill="#0D0E10"/><circle cx="35.199999999999996" cy="44.8" r="1.56282951468281" fill="#0D0E10"/><circle cx="44.8" cy="44.8" r="1.8572691561702617" fill="#0D0E10"/><circle cx="54.4" cy="44.8" r="2.1233464197636582" fill="#0D0E10"/><circle cx="63.99999999999999" cy="44.8" r="2.3688065860361402" fill="#0D0E10"/><circle cx="73.60000000000001" cy="44.8" r="2.5983501502741584" fill="#0D0E10"/><circle cx="6.4" cy="54.4" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="54.4" r="0.633846499460857" fill="#0D0E10"/><circle cx="25.6" cy="54.4" r="0.9607316398574451" fill="#0D0E10"/><circle cx="35.199999999999996" cy="54.4" r="1.225340672000579" fill="#0D0E10"/><circle cx="44.8" cy="54.4" r="1.456196862502631" fill="#0D0E10"/><circle cx="54.4" cy="54.4" r="1.6648154545579388" fill="#0D0E10"/><circle cx="63.99999999999999" cy="54.4" r="1.8572691561702614" fill="#0D0E10"/><circle cx="73.60000000000001" cy="54.4" r="2.03724340327418" fill="#0D0E10"/><circle cx="6.4" cy="63.99999999999999" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="63.99999999999999" r="0.4181827351271541" fill="#0D0E10"/><circle cx="25.6" cy="63.99999999999999" r="0.6338464994608571" fill="#0D0E10"/><circle cx="35.199999999999996" cy="63.99999999999999" r="0.8084233550482695" fill="#0D0E10"/><circle cx="44.8" cy="63.99999999999999" r="0.9607316398574454" fill="#0D0E10"/><circle cx="54.4" cy="63.99999999999999" r="1.0983685811330863" fill="#0D0E10"/><circle cx="63.99999999999999" cy="63.99999999999999" r="1.2253406720005795" fill="#0D0E10"/><circle cx="73.60000000000001" cy="63.99999999999999" r="1.3440793933950876" fill="#0D0E10"/><circle cx="6.4" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="25.6" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="35.199999999999996" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="44.8" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="54.4" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="63.99999999999999" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="73.60000000000001" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="128" height="40" viewBox="0 0 413 129" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.417 9.30741C106.417 9.57392 106.324 9.83227 106.155 10.0388L61.5395 64.5L106.155 118.96C106.324 119.166 106.417 119.425 106.417 119.691V127.843C106.417 128.482 105.897 129 105.255 129H1.16175C0.520132 129 0 128.482 0 127.843V1.15695C0 0.517982 0.520131 0 1.16174 0H105.255C105.897 0 106.417 0.517984 106.417 1.15695V9.30741ZM19.0364 116.382H87.6921L53.363 74.4795L19.0364 116.382ZM12.6699 104.192L40.1426 70.6585H12.6699V104.192ZM12.6699 58.0409H39.8964L12.6699 24.8064V58.0409ZM53.363 54.5193L87.6921 12.6176H19.0364L53.363 54.5193Z" fill="white"/>
<path d="M176.198 117.65C166.094 117.65 157.323 115.553 149.886 111.36C142.588 107.167 136.905 101.017 132.835 92.911C128.906 84.8045 126.941 75.0208 126.941 63.5599C126.941 53.3569 128.976 44.2022 133.046 36.0957C137.256 27.9892 143.01 21.6298 150.307 17.0175C157.744 12.4052 166.024 10.099 175.146 10.099C185.109 10.099 193.599 12.3353 200.616 16.8078C207.633 21.1406 212.965 27.3602 216.614 35.4667C220.263 43.4334 222.087 52.8677 222.087 63.7695V66.1766C222.087 66.8156 221.567 67.3336 220.925 67.3336H145.044C145.185 74.881 146.588 81.3802 149.254 86.8311C152.061 92.282 155.78 96.475 160.411 99.4101C165.182 102.205 170.515 103.603 176.409 103.603C183.285 103.603 189.179 102.066 194.091 98.9908C198.779 95.9225 201.741 91.008 202.977 84.2471C203.08 83.6853 203.565 83.267 204.139 83.267H219.737C220.429 83.267 220.969 83.8669 220.871 84.5494C220.038 90.3376 217.988 95.6406 214.72 100.458C211.211 105.63 206.159 109.823 199.564 113.037C193.108 116.112 185.32 117.65 176.198 117.65ZM202.721 54.5449C202.721 45.3203 200.265 37.9825 195.354 32.5316C190.582 27.0807 183.846 24.3552 175.146 24.3552C166.866 24.3552 160.06 27.2205 154.727 32.9509C149.395 38.6813 146.377 45.8794 145.676 54.5449H202.721Z" fill="white"/>
<path d="M364.585 117.65C358.13 117.65 352.236 116.392 346.903 113.876C341.571 111.36 337.361 107.796 334.273 103.184C331.186 98.4317 329.642 92.9808 329.642 86.8311C329.642 77.7462 332.73 70.688 338.904 65.6564C345.219 60.485 353.359 57.0607 363.322 55.3835L385.004 52.0291C387.67 51.6098 389.705 51.0507 391.108 50.3519C392.652 49.5133 393.704 48.465 394.266 47.2071C394.967 45.8095 395.318 44.0624 395.318 41.9659C395.318 36.515 393.424 32.2521 389.635 29.1772C385.846 25.9626 380.934 24.3552 374.9 24.3552C367.322 24.3552 361.498 26.2421 357.428 30.0158C353.595 33.4387 351.443 38.1016 350.971 44.0046C350.922 44.622 350.412 45.1106 349.79 45.1106H334.014C333.353 45.1106 332.823 44.5605 332.872 43.9032C333.312 38.0771 335.042 32.6792 338.062 27.7096C341.29 22.3985 345.921 18.1356 351.955 14.921C358.13 11.7063 365.287 10.099 373.426 10.099C381.706 10.099 388.793 11.6364 394.687 14.7113C400.721 17.7862 405.282 22.1888 408.369 27.9193C411.456 33.6497 413 40.4284 413 48.2554V113.348C413 113.987 412.48 114.505 411.838 114.505H396.901C396.259 114.505 395.739 113.987 395.739 113.348V109.473C395.739 108.076 395.739 106.678 395.739 105.28C395.739 103.743 395.739 102.275 395.739 100.878C393.213 106.189 389.214 110.312 383.741 113.247C378.408 116.182 372.023 117.65 364.585 117.65ZM367.111 103.603C372.444 103.603 377.215 102.485 381.425 100.249C385.775 97.8727 389.143 94.6581 391.529 90.6048C394.055 86.4118 395.318 81.6597 395.318 76.3486V61.4634C394.757 62.302 393.564 63.0707 391.74 63.7695C389.915 64.4684 387.389 65.1672 384.162 65.866L369.427 68.5915C362.971 69.8494 357.849 71.9459 354.06 74.881C350.271 77.8161 348.377 81.7995 348.377 86.8311C348.377 91.8627 350.131 95.916 353.639 98.9908C357.288 102.066 361.779 103.603 367.111 103.603Z" fill="white"/>
<path d="M285.673 62.9573L325.127 112.948C325.726 113.707 325.183 114.82 324.213 114.82H305.929C305.572 114.82 305.235 114.657 305.015 114.378L275.053 76.414L245.093 114.378C244.872 114.657 244.536 114.82 244.179 114.82H225.893C224.924 114.82 224.381 113.707 224.98 112.948L264.433 62.9573L227.177 15.7512C226.578 14.9921 227.121 13.8793 228.091 13.8793H246.375C246.732 13.8793 247.068 14.0423 247.289 14.3214L275.053 49.5006L302.818 14.3214C303.038 14.0423 303.375 13.8793 303.731 13.8793H322.016C322.985 13.8793 323.528 14.9921 322.929 15.7512L285.673 62.9573Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="128" height="40" viewBox="0 0 413 129" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.417 9.30741C106.417 9.57392 106.324 9.83227 106.155 10.0388L61.5395 64.5L106.155 118.96C106.324 119.166 106.417 119.425 106.417 119.691V127.843C106.417 128.482 105.897 129 105.255 129H1.16175C0.520132 129 0 128.482 0 127.843V1.15695C0 0.517982 0.520131 0 1.16174 0H105.255C105.897 0 106.417 0.517984 106.417 1.15695V9.30741ZM19.0364 116.382H87.6921L53.363 74.4795L19.0364 116.382ZM12.6699 104.192L40.1426 70.6585H12.6699V104.192ZM12.6699 58.0409H39.8964L12.6699 24.8064V58.0409ZM53.363 54.5193L87.6921 12.6176H19.0364L53.363 54.5193Z" fill="#0143D9"/>
<path d="M176.198 117.65C166.094 117.65 157.323 115.553 149.886 111.36C142.588 107.167 136.905 101.017 132.835 92.911C128.906 84.8045 126.941 75.0208 126.941 63.5599C126.941 53.3569 128.976 44.2022 133.046 36.0957C137.256 27.9892 143.01 21.6298 150.307 17.0175C157.744 12.4052 166.024 10.099 175.146 10.099C185.109 10.099 193.599 12.3353 200.616 16.8078C207.633 21.1406 212.965 27.3602 216.614 35.4667C220.263 43.4334 222.087 52.8677 222.087 63.7695V66.1766C222.087 66.8156 221.567 67.3336 220.925 67.3336H145.044C145.185 74.881 146.588 81.3802 149.254 86.8311C152.061 92.282 155.78 96.475 160.411 99.4101C165.182 102.205 170.515 103.603 176.409 103.603C183.285 103.603 189.179 102.066 194.091 98.9908C198.779 95.9225 201.741 91.008 202.977 84.2471C203.08 83.6853 203.565 83.267 204.139 83.267H219.737C220.429 83.267 220.969 83.8669 220.871 84.5494C220.038 90.3376 217.988 95.6406 214.72 100.458C211.211 105.63 206.159 109.823 199.564 113.037C193.108 116.112 185.32 117.65 176.198 117.65ZM202.721 54.5449C202.721 45.3203 200.265 37.9825 195.354 32.5316C190.582 27.0807 183.846 24.3552 175.146 24.3552C166.866 24.3552 160.06 27.2205 154.727 32.9509C149.395 38.6813 146.377 45.8794 145.676 54.5449H202.721Z" fill="black"/>
<path d="M364.585 117.65C358.13 117.65 352.236 116.392 346.903 113.876C341.571 111.36 337.361 107.796 334.273 103.184C331.186 98.4317 329.642 92.9808 329.642 86.8311C329.642 77.7462 332.73 70.688 338.904 65.6564C345.219 60.485 353.359 57.0607 363.322 55.3835L385.004 52.0291C387.67 51.6098 389.705 51.0507 391.108 50.3519C392.652 49.5133 393.704 48.465 394.266 47.2071C394.967 45.8095 395.318 44.0624 395.318 41.9659C395.318 36.515 393.424 32.2521 389.635 29.1772C385.846 25.9626 380.934 24.3552 374.9 24.3552C367.322 24.3552 361.498 26.2421 357.428 30.0158C353.595 33.4387 351.443 38.1016 350.971 44.0046C350.922 44.622 350.412 45.1106 349.79 45.1106H334.014C333.353 45.1106 332.823 44.5605 332.872 43.9032C333.312 38.0771 335.042 32.6792 338.062 27.7096C341.29 22.3985 345.921 18.1356 351.955 14.921C358.13 11.7063 365.287 10.099 373.426 10.099C381.706 10.099 388.793 11.6364 394.687 14.7113C400.721 17.7862 405.282 22.1888 408.369 27.9193C411.456 33.6497 413 40.4284 413 48.2554V113.348C413 113.987 412.48 114.505 411.838 114.505H396.901C396.259 114.505 395.739 113.987 395.739 113.348V109.473C395.739 108.076 395.739 106.678 395.739 105.28C395.739 103.743 395.739 102.275 395.739 100.878C393.213 106.189 389.214 110.312 383.741 113.247C378.408 116.182 372.023 117.65 364.585 117.65ZM367.111 103.603C372.444 103.603 377.215 102.485 381.425 100.249C385.775 97.8727 389.143 94.6581 391.529 90.6048C394.055 86.4118 395.318 81.6597 395.318 76.3486V61.4634C394.757 62.302 393.564 63.0707 391.74 63.7695C389.915 64.4684 387.389 65.1672 384.162 65.866L369.427 68.5915C362.971 69.8494 357.849 71.9459 354.06 74.881C350.271 77.8161 348.377 81.7995 348.377 86.8311C348.377 91.8627 350.131 95.916 353.639 98.9908C357.288 102.066 361.779 103.603 367.111 103.603Z" fill="black"/>
<path d="M285.673 62.9573L325.127 112.948C325.726 113.707 325.183 114.82 324.213 114.82H305.929C305.572 114.82 305.235 114.657 305.015 114.378L275.053 76.414L245.093 114.378C244.872 114.657 244.536 114.82 244.179 114.82H225.893C224.924 114.82 224.381 113.707 224.98 112.948L264.433 62.9573L227.177 15.7512C226.578 14.9921 227.121 13.8793 228.091 13.8793H246.375C246.732 13.8793 247.068 14.0423 247.289 14.3214L275.053 49.5006L302.818 14.3214C303.038 14.0423 303.375 13.8793 303.731 13.8793H322.016C322.985 13.8793 323.528 14.9921 322.929 15.7512L285.673 62.9573Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 34 718 165" width="718" height="165" fill="#fff">
<path d="M123.292 132.486h-33.1c-2.644 0-4.808-2.395-4.808-5.363v-21.436c0-2.95 2.148-5.363 4.809-5.363h33.099c2.645 0 4.808-2.395 4.808-5.363V73.525c0-2.95 2.147-5.363 4.808-5.363h33.099c2.646 0 4.809-2.395 4.809-5.363V41.363c0-2.95-2.148-5.363-4.809-5.363H90.208c-2.645 0-4.808 2.395-4.808 5.363V62.8c0 2.95-2.147 5.363-4.808 5.363h-33.1c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363H4.808c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.1c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.099c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.148 5.363 4.809 5.363h75.798c2.646 0 4.809-2.395 4.809-5.363v-21.436c0-2.95-2.148-5.363-4.809-5.363H132.94c-2.646 0-4.809-2.395-4.809-5.363v-21.436c0-2.95-2.147-5.363-4.808-5.363z"/>
<path d="M251.408 100.341c2.646 0 4.809 2.395 4.809 5.363v21.436c0 2.95-2.148 5.363-4.809 5.363H218.31c-2.646 0-4.809 2.395-4.809 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363h-33.099c-2.645 0-4.808-2.395-4.808-5.363v-53.58c0-2.951 2.147-5.364 4.808-5.364h75.799zM510.68 40.105h-28.795v153.33h28.795zM717.43 40.105h-28.795v153.33h28.795z"/>
<path d="M532.41 40.105h-28.83l35.391 153.33H567.8zM653.242 40.105h-28.795v153.33h28.795z"/>
<path d="M596.594 193.435h-28.795l35.39-153.33h28.795zM452.028 114.062v27.042s-.139.174-.191.261h-83.069V114.08h83.295-.035z"/>
<path d="M452.028 141.347v35.651c-17.479 12.306-39.834 19.769-62.953 19.769-59.673 0-97.858-35.981-97.858-80.362s34.54-80.362 97.858-80.362c22.511 0 45.127 7.689 62.953 20.36v35.685c-13.105-16.42-37.856-28.743-62.953-28.743-44.052 0-70.695 20.846-70.695 53.077 0 32.232 28.691 53.077 70.695 53.077 25.323 0 49.727-11.906 62.744-28.1h.191v-.035z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 34 718 165" width="718" height="165" fill="#0D0E10">
<path d="M123.292 132.486h-33.1c-2.644 0-4.808-2.395-4.808-5.363v-21.436c0-2.95 2.148-5.363 4.809-5.363h33.099c2.645 0 4.808-2.395 4.808-5.363V73.525c0-2.95 2.147-5.363 4.808-5.363h33.099c2.646 0 4.809-2.395 4.809-5.363V41.363c0-2.95-2.148-5.363-4.809-5.363H90.208c-2.645 0-4.808 2.395-4.808 5.363V62.8c0 2.95-2.147 5.363-4.808 5.363h-33.1c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363H4.808c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.1c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.099c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.148 5.363 4.809 5.363h75.798c2.646 0 4.809-2.395 4.809-5.363v-21.436c0-2.95-2.148-5.363-4.809-5.363H132.94c-2.646 0-4.809-2.395-4.809-5.363v-21.436c0-2.95-2.147-5.363-4.808-5.363z"/>
<path d="M251.408 100.341c2.646 0 4.809 2.395 4.809 5.363v21.436c0 2.95-2.148 5.363-4.809 5.363H218.31c-2.646 0-4.809 2.395-4.809 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363h-33.099c-2.645 0-4.808-2.395-4.808-5.363v-53.58c0-2.951 2.147-5.364 4.808-5.364h75.799zM510.68 40.105h-28.795v153.33h28.795zM717.43 40.105h-28.795v153.33h28.795z"/>
<path d="M532.41 40.105h-28.83l35.391 153.33H567.8zM653.242 40.105h-28.795v153.33h28.795z"/>
<path d="M596.594 193.435h-28.795l35.39-153.33h28.795zM452.028 114.062v27.042s-.139.174-.191.261h-83.069V114.08h83.295-.035z"/>
<path d="M452.028 141.347v35.651c-17.479 12.306-39.834 19.769-62.953 19.769-59.673 0-97.858-35.981-97.858-80.362s34.54-80.362 97.858-80.362c22.511 0 45.127 7.689 62.953 20.36v35.685c-13.105-16.42-37.856-28.743-62.953-28.743-44.052 0-70.695 20.846-70.695 53.077 0 32.232 28.691 53.077 70.695 53.077 25.323 0 49.727-11.906 62.744-28.1h.191v-.035z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg width="151" height="30" viewBox="0 0 151 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_novita_dark)">
<path d="M17.2639 0.640625V12.1297L0.03125 29.363H17.2639V17.8739H17.7376L28.7529 29.363H45.9875L17.2639 0.640625Z" fill="white"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.329 25.5459H131.778V28.9895H127.454C124.427 28.9895 122.523 27.3247 122.523 24.2256V15.0698H119.438V11.5974H122.523L123.417 7.03393H126.3V11.5974H131.778V15.0698H126.329V25.5459ZM61.9158 6.0293L69.8449 23.7083H70.0753V6.0293H74.1983V28.9895H67.97L60.0409 11.3105H59.8105V28.9895H55.6875V6.0293H61.9158ZM84.78 29.334C79.734 29.334 76.5052 25.804 76.5052 20.3223C76.5052 14.8406 79.6764 11.2529 84.78 11.2529C89.8836 11.2529 92.9971 14.6966 92.9971 20.3223C92.9971 25.948 89.826 29.334 84.78 29.334ZM84.78 25.8904C87.0293 25.8904 88.903 25.115 88.9606 22.9053V17.7393C88.9606 15.4719 87.0581 14.6966 84.78 14.6966C82.5019 14.6966 80.5418 15.4719 80.5418 17.7393V22.9053C80.5994 25.1438 82.5307 25.8904 84.78 25.8904ZM97.4094 11.5686L101.907 25.3443H102.137L106.635 11.5686H110.787L104.587 28.9607H99.4547L93.284 11.5686H97.407H97.4094ZM112.922 11.5974H116.728V28.9895H112.922V11.5974ZM112.69 6.0293H116.957V9.30127H112.69V6.0293ZM141.12 29.3628C136.477 29.3628 133.508 25.8328 133.508 20.3223C133.508 14.8118 136.42 11.2817 141.12 11.2817C143.312 11.2817 145.099 12.1423 146.166 13.7495H146.397L147.147 11.5974H150.03V28.9895H147.147L146.397 26.8938H146.166C145.013 28.4146 143.081 29.3616 141.12 29.3616V29.3628ZM141.898 25.8904C144.176 25.8904 146.165 25.1726 146.223 22.9053V17.7393C146.223 15.4143 144.146 14.7542 141.812 14.7542C139.477 14.7542 137.545 15.4719 137.545 17.7393V22.9053C137.602 25.2014 139.592 25.8904 141.898 25.8904Z" fill="white"/>
<defs>
<clipPath id="clip0_novita_dark">
<rect width="45.9562" height="28.7223" fill="white" transform="translate(0.0292969 0.640625)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg width="151" height="30" viewBox="0 0 151 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_125_11573)">
<path d="M17.2639 0.640625V12.1297L0.03125 29.363H17.2639V17.8739H17.7376L28.7529 29.363H45.9875L17.2639 0.640625Z" fill="black"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.329 25.5459H131.778V28.9895H127.454C124.427 28.9895 122.523 27.3247 122.523 24.2256V15.0698H119.438V11.5974H122.523L123.417 7.03393H126.3V11.5974H131.778V15.0698H126.329V25.5459ZM61.9158 6.0293L69.8449 23.7083H70.0753V6.0293H74.1983V28.9895H67.97L60.0409 11.3105H59.8105V28.9895H55.6875V6.0293H61.9158ZM84.78 29.334C79.734 29.334 76.5052 25.804 76.5052 20.3223C76.5052 14.8406 79.6764 11.2529 84.78 11.2529C89.8836 11.2529 92.9971 14.6966 92.9971 20.3223C92.9971 25.948 89.826 29.334 84.78 29.334ZM84.78 25.8904C87.0293 25.8904 88.903 25.115 88.9606 22.9053V17.7393C88.9606 15.4719 87.0581 14.6966 84.78 14.6966C82.5019 14.6966 80.5418 15.4719 80.5418 17.7393V22.9053C80.5994 25.1438 82.5307 25.8904 84.78 25.8904ZM97.4094 11.5686L101.907 25.3443H102.137L106.635 11.5686H110.787L104.587 28.9607H99.4547L93.284 11.5686H97.407H97.4094ZM112.922 11.5974H116.728V28.9895H112.922V11.5974ZM112.69 6.0293H116.957V9.30127H112.69V6.0293ZM141.12 29.3628C136.477 29.3628 133.508 25.8328 133.508 20.3223C133.508 14.8118 136.42 11.2817 141.12 11.2817C143.312 11.2817 145.099 12.1423 146.166 13.7495H146.397L147.147 11.5974H150.03V28.9895H147.147L146.397 26.8938H146.166C145.013 28.4146 143.081 29.3616 141.12 29.3616V29.3628ZM141.898 25.8904C144.176 25.8904 146.165 25.1726 146.223 22.9053V17.7393C146.223 15.4143 144.146 14.7542 141.812 14.7542C139.477 14.7542 137.545 15.4719 137.545 17.7393V22.9053C137.602 25.2014 139.592 25.8904 141.898 25.8904Z" fill="black"/>
<defs>
<clipPath id="clip0_125_11573">
<rect width="45.9562" height="28.7223" fill="white" transform="translate(0.0292969 0.640625)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="white" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:white;fill-opacity:1;"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="black" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:black;fill-opacity:1;"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+3 -2
View File
@@ -1,6 +1,6 @@
module github.com/Gitlawb/zero
module github.com/Twigpine/zero
go 1.26.6
go 1.26.10
require (
charm.land/bubbles/v2 v2.1.1
@@ -18,6 +18,7 @@ require (
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
golang.org/x/image v0.45.0
golang.org/x/sys v0.47.0
gopkg.in/yaml.v3 v3.0.1
mvdan.cc/sh/v3 v3.13.1
)
+4
View File
@@ -70,5 +70,9 @@ golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
+758 -76
View File
File diff suppressed because it is too large. Load diff
+2232 -24
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"fmt"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Twigpine/zero/internal/agent"
)
// ---- an offered option is an acceptable answer ----
+181
View File
@@ -0,0 +1,181 @@
package acp
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestACPImageAttachmentHistory(t *testing.T) {
for _, tc := range []struct {
name string
text string
images int
withoutVision bool
want string
}{
{name: "image only", images: 1, want: "[1 image]"},
{name: "text and images", text: "compare these", images: 2, want: "compare these [2 images]"},
{name: "ignored by model", images: 1, withoutVision: true, want: ""},
} {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
modalities := []string{"text", "image"}
if tc.withoutVision {
modalities = []string{"text"}
}
return []providermodeldiscovery.Model{{ID: "fake-model", InputModalities: modalities}}, nil
}
type observedTurn struct {
prompt string
images int
}
turns := make(chan observedTurn, 8)
deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
turns <- observedTurn{prompt: prompt, images: len(opts.Images)}
return agent.Result{FinalAnswer: "received"}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
h := newHarness(t, deps)
defer h.stop()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace}, &created); err != nil {
t.Fatal(err)
}
const imageData = "image-history-bytes-not-for-storage"
encoded := base64.StdEncoding.EncodeToString([]byte(imageData))
var blocks []ContentBlock
if tc.text != "" {
blocks = append(blocks, TextBlock(tc.text))
}
for range tc.images {
blocks = append(blocks, ImageBlock(encoded, "image/png"))
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: blocks}, &PromptResult{}); err != nil {
t.Fatal(err)
}
live := <-turns
wantLiveImages := tc.images
if tc.withoutVision {
wantLiveImages = 0
}
if live.images != wantLiveImages {
t.Fatalf("live images = %d, want %d", live.images, wantLiveImages)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil || len(events) < 2 {
t.Fatalf("read completed turn: events=%+v err=%v", events, err)
}
var user struct {
Role string `json:"role"`
Content string `json:"content"`
Attachments struct {
Images int `json:"images"`
} `json:"attachments"`
}
if err := json.Unmarshal(events[0].Payload, &user); err != nil {
t.Fatal(err)
}
if user.Role != "user" || user.Content != tc.text || user.Attachments.Images != wantLiveImages {
t.Errorf("stored user event = %s, want original text and attachments.images=%d", events[0].Payload, wantLiveImages)
}
for _, event := range events {
if bytes.Contains(event.Payload, []byte(encoded)) || bytes.Contains(event.Payload, []byte(imageData)) {
t.Errorf("image data leaked into session event: %s", event.Payload)
}
}
followUp := func(t *testing.T, client *clientHarness) {
t.Helper()
if err := client.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("what did I send?")},
}, &PromptResult{}); err != nil {
t.Fatal(err)
}
got := <-turns
if want := "User: " + tc.want + "\n"; !strings.Contains(got.prompt, want) {
t.Errorf("seeded prompt missing user history %q: %q", want, got.prompt)
}
if got.images != 0 {
t.Errorf("historical images reattached: %d", got.images)
}
}
followUp(t, h)
h.stop()
// Fresh agents and stores reproduce a process restart, with no shared
// in-memory history to hide a missing attachment in the event log.
for _, method := range []string{MethodSessionLoad, MethodSessionResume} {
t.Run(method, func(t *testing.T) {
restoredDeps := deps
restoredDeps.Store = sessions.NewStore(sessions.StoreOptions{RootDir: deps.Store.RootDir})
loader := newHarness(t, restoredDeps)
defer loader.stop()
if err := loader.client.Call(ctx, method, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace}, &LoadSessionResult{}); err != nil {
t.Fatal(err)
}
if method == MethodSessionLoad {
select {
case update := <-loader.notifications:
if update.SessionUpdate != UpdateUserMessageChunk || update.Content.Text != tc.want {
t.Errorf("replayed user = %+v, want %q", update, tc.want)
}
case <-ctx.Done():
t.Fatal("user turn was not replayed")
}
} else {
select {
case update := <-loader.notifications:
t.Errorf("resume replayed transcript: %+v", update)
default:
}
}
followUp(t, loader)
})
}
})
}
}
func TestACPAttachmentHistoryReadsExistingSessionMessages(t *testing.T) {
deps := testDeps(t)
meta, err := deps.Store.Create(sessions.CreateInput{SessionID: "attachment-compatibility", Cwd: t.TempDir()})
if err != nil {
t.Fatal(err)
}
for i, payload := range []string{
`{"role":"user","content":"old text"}`,
`{"role":"user","content":"","attachments":{"images":1,"documents":2}}`,
`{"role":"user","content":"still text","attachments":{"images":-1,"documents":1.5}}`,
`{"role":"assistant","content":"answer","attachments":{"images":1}}`,
} {
if _, err := deps.Store.AppendEvent(meta.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: json.RawMessage(payload)}); err != nil {
t.Fatalf("append message %d: %v", i, err)
}
}
a := &Agent{deps: deps}
_, messages, _, err := a.loadHistory(meta.SessionID, false)
if err != nil {
t.Fatal(err)
}
want := []string{"old text", "[1 image] [2 documents]", "still text", "answer"}
if len(messages) != len(want) {
t.Fatalf("messages = %+v", messages)
}
for i, text := range want {
if messages[i].content != text {
t.Errorf("message %d = %q, want %q", i, messages[i].content, text)
}
}
}
+43 -10
View File
@@ -89,6 +89,13 @@ type Conn struct {
handlers map[string]HandlerFunc
notifiers map[string]NotifyFunc
// Notifications for one method are an ordered stream: session/update in
// particular is stateful, so dispatching every frame in an unrelated
// goroutine can make a later chunk overtake an earlier one. Different
// methods retain independent tails, keeping session/cancel responsive while
// an update handler is busy.
notifyMu sync.Mutex
notifyTails map[string]chan struct{}
mu sync.Mutex
nextID int64
@@ -106,11 +113,12 @@ type Conn struct {
// lifetime.
func NewConn(r io.Reader, w io.Writer) *Conn {
return &Conn{
rawReader: r,
w: w,
handlers: make(map[string]HandlerFunc),
notifiers: make(map[string]NotifyFunc),
pending: make(map[int64]chan rpcMessage),
rawReader: r,
w: w,
handlers: make(map[string]HandlerFunc),
notifiers: make(map[string]NotifyFunc),
notifyTails: make(map[string]chan struct{}),
pending: make(map[int64]chan rpcMessage),
}
}
@@ -303,11 +311,7 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
}(msg)
case msg.isNotify():
if fn := c.notifiers[msg.Method]; fn != nil {
c.wg.Add(1)
go func(m rpcMessage) {
defer c.wg.Done()
fn(ctx, m.Params)
}(msg)
c.dispatchNotification(ctx, msg, fn)
}
default:
// Malformed frame; reply only if we can identify a request id.
@@ -317,6 +321,35 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
}
}
// dispatchNotification preserves wire order within one method without making
// unrelated notification methods wait for each other. The read loop installs
// each tail before starting its goroutine, so goroutine scheduling cannot
// reorder the chain it observes.
func (c *Conn) dispatchNotification(ctx context.Context, msg rpcMessage, fn NotifyFunc) {
c.notifyMu.Lock()
previous := c.notifyTails[msg.Method]
done := make(chan struct{})
c.notifyTails[msg.Method] = done
c.notifyMu.Unlock()
c.wg.Add(1)
go func() {
defer c.wg.Done()
defer func() {
close(done)
c.notifyMu.Lock()
if c.notifyTails[msg.Method] == done {
delete(c.notifyTails, msg.Method)
}
c.notifyMu.Unlock()
}()
if previous != nil {
<-previous
}
fn(ctx, msg.Params)
}()
}
func (c *Conn) dispatchRequest(ctx context.Context, msg rpcMessage) {
fn := c.handlers[msg.Method]
if fn == nil {
+80
View File
@@ -360,6 +360,86 @@ func TestConnNotification(t *testing.T) {
}
}
func TestConnNotificationsForOneMethodStayInWireOrder(t *testing.T) {
a, b, stop := connPair(t)
defer stop()
firstStarted := make(chan struct{})
releaseFirst := make(chan struct{})
delivered := make(chan int, 2)
b.HandleNotify("update", func(_ context.Context, params json.RawMessage) {
var in struct{ Sequence int }
_ = json.Unmarshal(params, &in)
if in.Sequence == 1 {
close(firstStarted)
<-releaseFirst
}
delivered <- in.Sequence
})
if err := a.Notify("update", map[string]int{"Sequence": 1}); err != nil {
t.Fatalf("first notify: %v", err)
}
select {
case <-firstStarted:
case <-time.After(2 * time.Second):
t.Fatal("first notification did not start")
}
if err := a.Notify("update", map[string]int{"Sequence": 2}); err != nil {
t.Fatalf("second notify: %v", err)
}
select {
case sequence := <-delivered:
t.Fatalf("notification %d overtook the blocked first notification", sequence)
case <-time.After(25 * time.Millisecond):
}
close(releaseFirst)
for want := 1; want <= 2; want++ {
select {
case got := <-delivered:
if got != want {
t.Fatalf("notification order = ...,%d; want %d", got, want)
}
case <-time.After(2 * time.Second):
t.Fatalf("notification %d was not delivered", want)
}
}
}
func TestConnDifferentNotificationMethodsStayConcurrent(t *testing.T) {
a, b, stop := connPair(t)
defer stop()
updateStarted := make(chan struct{})
releaseUpdate := make(chan struct{})
cancelDelivered := make(chan struct{})
b.HandleNotify("update", func(context.Context, json.RawMessage) {
close(updateStarted)
<-releaseUpdate
})
b.HandleNotify("cancel", func(context.Context, json.RawMessage) {
close(cancelDelivered)
})
if err := a.Notify("update", nil); err != nil {
t.Fatalf("update notify: %v", err)
}
select {
case <-updateStarted:
case <-time.After(2 * time.Second):
t.Fatal("update notification did not start")
}
if err := a.Notify("cancel", nil); err != nil {
t.Fatalf("cancel notify: %v", err)
}
select {
case <-cancelDelivered:
case <-time.After(2 * time.Second):
t.Fatal("different notification method was blocked behind update")
}
close(releaseUpdate)
}
func TestConnMethodNotFound(t *testing.T) {
a, _, stop := connPair(t)
defer stop()
+213
View File
@@ -0,0 +1,213 @@
package acp
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// captureAgentOptions makes every turn record the agent.Options it was built
// with, so a test can assert the permission mode the next prompt actually runs
// with rather than only what the wire reports.
func captureAgentOptions(deps *Deps) *agent.Options {
var captured agent.Options
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
captured = opts
return agent.Result{FinalAnswer: "ok"}, nil
}
return &captured
}
func newHarnessT(t *testing.T, deps Deps) *clientHarness {
t.Helper()
h := newHarness(t, deps)
t.Cleanup(h.stop)
return h
}
func mustCall(t *testing.T, ctx context.Context, h *clientHarness, method string, params any, result any) {
t.Helper()
if err := h.client.Call(ctx, method, params, result); err != nil {
t.Fatalf("%s: %v", method, err)
}
}
// restartHarness replaces the live agent with a fresh one over the same store,
// which is the process restart the persisted mode has to survive.
func restartHarness(t *testing.T, h *clientHarness, deps Deps) *clientHarness {
t.Helper()
h.stop()
return newHarnessT(t, deps)
}
func modeOptionValue(t *testing.T, options []SessionConfigOption) string {
t.Helper()
for _, option := range options {
if option.ID == configIDMode {
return option.CurrentValue
}
}
t.Fatalf("no mode config option in %+v", options)
return ""
}
func assertModeReported(t *testing.T, result LoadSessionResult, want agent.PermissionMode) {
t.Helper()
if result.Modes == nil || result.Modes.CurrentModeID != string(want) {
t.Fatalf("modes = %+v, want currentModeId %q", result.Modes, want)
}
if got := modeOptionValue(t, result.ConfigOptions); got != string(want) {
t.Fatalf("mode config option = %q, want %q", got, want)
}
}
func TestACPModePersistsAcrossRestartLoadAndResume(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
mustCall(t, ctx, h, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
meta, err := deps.Store.Get(created.SessionID)
if err != nil || meta == nil {
t.Fatalf("store.Get: %v", err)
}
if meta.PermissionMode != string(agent.PermissionModePlan) {
t.Fatalf("stored mode = %q, want plan", meta.PermissionMode)
}
// LOAD after a restart: the response must report the stored mode, and the
// next prompt must actually run with it.
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, agent.PermissionModePlan)
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("continue")}}, &PromptResult{})
if captured.PermissionMode != agent.PermissionModePlan {
t.Fatalf("after load, agent.Options.PermissionMode = %q, want plan", captured.PermissionMode)
}
// RESUME after another restart reports the same.
h = restartHarness(t, h, deps)
var resumed ResumeSessionResult
mustCall(t, ctx, h, MethodSessionResume, ResumeSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &resumed)
assertModeReported(t, resumed, agent.PermissionModePlan)
}
func TestACPModePersistsViaSetConfigOption(t *testing.T) {
deps := testDeps(t)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
var configured SetSessionConfigOptionResult
mustCall(t, ctx, h, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: created.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAsk),
}, &configured)
if got := modeOptionValue(t, configured.ConfigOptions); got != string(agent.PermissionModeAsk) {
t.Fatalf("configured mode = %q, want ask", got)
}
meta, err := deps.Store.Get(created.SessionID)
if err != nil || meta == nil {
t.Fatalf("store.Get: %v", err)
}
if meta.PermissionMode != string(agent.PermissionModeAsk) {
t.Fatalf("stored mode = %q, want ask", meta.PermissionMode)
}
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, agent.PermissionModeAsk)
}
// TestACPModeRestoreFallback pins the restore boundary: no stored value keeps
// the historical auto default, and a stored value outside the three offered
// modes must never come back weaker than ask (and never unsafe).
func TestACPModeRestoreFallback(t *testing.T) {
cases := []struct {
name string
seed string // "" means metadata written before modes were persisted
want agent.PermissionMode
}{
{name: "unset keeps auto", want: agent.PermissionModeAuto},
{name: "ask preserved", seed: string(agent.PermissionModeAsk), want: agent.PermissionModeAsk},
{name: "unknown falls back to ask", seed: "bogus", want: agent.PermissionModeAsk},
{name: "unsafe never restored", seed: string(agent.PermissionModeUnsafe), want: agent.PermissionModeAsk},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
if tc.seed != "" {
if _, err := deps.Store.UpdateMode(created.SessionID, tc.seed); err != nil {
t.Fatalf("seed mode: %v", err)
}
}
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, tc.want)
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("go")}}, &PromptResult{})
if captured.PermissionMode != tc.want {
t.Fatalf("next prompt PermissionMode = %q, want %q", captured.PermissionMode, tc.want)
}
})
}
}
// TestACPSetModeWriteFailureKeepsLiveMode pins the store-first order: when the
// mode cannot be saved the request fails and the live session is untouched.
func TestACPSetModeWriteFailureKeepsLiveMode(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &created)
// Remove the metadata file so UpdateMode fails but the live session stays
// promptable (turn persistence only warns when the file is gone).
metadataPath := filepath.Join(deps.Store.RootDir, created.SessionID, sessions.MetadataFile)
if err := os.Remove(metadataPath); err != nil {
t.Fatalf("remove metadata: %v", err)
}
err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
var rpcErr *rpcError
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInternalError {
t.Fatalf("set_mode with an unsavable mode = %v, want internal error", err)
}
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("still auto")}}, &PromptResult{})
if captured.PermissionMode != agent.PermissionModeAuto {
t.Fatalf("failed set_mode changed the live mode: PermissionMode = %q, want auto", captured.PermissionMode)
}
}
+4 -2
View File
@@ -3,7 +3,7 @@ package acp
import (
"encoding/json"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Twigpine/zero/internal/agent"
)
// permission.go maps ZERO's permission prompt model onto ACP's
@@ -142,13 +142,15 @@ func actionOffered(optionID string, offered []PermissionOption) bool {
// session/request_permission request from a ZERO permission request.
func permissionToolCall(req agent.PermissionRequest) ToolCallUpdate {
args := marshalArgs(req.Args)
return ToolCallUpdate{
upd := ToolCallUpdate{
ToolCallID: req.ToolCallID,
Title: toolTitle(req.ToolName, string(args)),
Kind: toolKindFor(req.ToolName),
Status: ToolStatusPending,
RawInput: rawInputBytes(args),
}
attachBrowserToolDetails(&upd, req.ToolName)
return upd
}
func marshalArgs(args map[string]any) []byte {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Twigpine/zero/internal/agent"
)
func escalationRequest(escalates bool) agent.PermissionRequest {
+15 -1
View File
@@ -3,7 +3,7 @@ package acp
import (
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Twigpine/zero/internal/agent"
)
func TestBuildPermissionOptions(t *testing.T) {
@@ -82,3 +82,17 @@ func TestPermissionToolCall(t *testing.T) {
t.Error("expected rawInput from args")
}
}
func TestPermissionToolCallKeepsTheBrowserDescriptor(t *testing.T) {
call := permissionToolCall(agent.PermissionRequest{
ToolCallID: "browser-1",
ToolName: "browser_connect",
Args: map[string]any{"target": "127.0.0.1:9222"},
})
if got := browserDescriptor(t, call); got != (BrowserToolDetails{Version: 1, Command: "connect"}) {
t.Fatalf("browser descriptor = %#v", got)
}
if call.Title != "browser connect" {
t.Fatalf("title = %q", call.Title)
}
}
+172 -8
View File
@@ -2,11 +2,14 @@ package acp
import (
"encoding/json"
"net/url"
"path/filepath"
"strings"
"unicode"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
)
// translate.go maps ZERO's agent events onto ACP session/update payloads. The
@@ -17,6 +20,14 @@ func agentMessageChunk(delta string) ContentChunk {
return ContentChunk{SessionUpdate: UpdateAgentMessageChunk, Content: TextBlock(delta)}
}
func replayMessageChunk(role, messageID, text string) ContentChunk {
update := UpdateAgentMessageChunk
if role == "user" {
update = UpdateUserMessageChunk
}
return ContentChunk{SessionUpdate: update, MessageID: messageID, Content: TextBlock(text)}
}
func agentThoughtChunk(delta string) ContentChunk {
return ContentChunk{SessionUpdate: UpdateAgentThoughtChunk, Content: TextBlock(delta)}
}
@@ -44,12 +55,125 @@ func toolKindFor(name string) string {
// toolTitle builds a concise human title, e.g. "read_file src/main.go".
func toolTitle(name, rawArgs string) string {
if browser, ok := browserToolDetails(name); ok {
return browserToolTitle(browser.Command, rawArgs)
}
if hint := primaryArgHint(rawArgs); hint != "" {
return name + " " + hint
}
return name
}
// browserToolDetails identifies ZERO's local browser helpers without treating
// similarly named MCP tools as browser automation. The descriptor intentionally
// contains no request data: ACP tool input is already protocol-visible, but a
// durable UI must not need to retain text, local CDP targets, or full URLs just
// to recognise the browser operation.
func browserToolDetails(name string) (*BrowserToolDetails, bool) {
const prefix = "browser_"
command, ok := strings.CutPrefix(name, prefix)
if !ok {
return nil, false
}
switch command {
case "install", "launch", "connect", "open", "snapshot", "click", "type", "press", "action":
return &BrowserToolDetails{Version: 1, Command: command}, true
default:
return nil, false
}
}
const zeroBrowserMetaKey = "github.com/Twigpine/zero/browser"
// attachBrowserToolDetails stores ZERO's browser descriptor in ACP's reserved
// extension channel. Keeping this in one helper prevents start, result, and
// permission payloads from drifting onto different wire shapes.
func attachBrowserToolDetails(update *ToolCallUpdate, name string) {
browser, ok := browserToolDetails(name)
if !ok {
return
}
raw, err := json.Marshal(browser)
if err != nil {
return
}
update.Meta = map[string]json.RawMessage{zeroBrowserMetaKey: raw}
}
// browserToolTitle avoids putting browser_type text, an attached DevTools
// endpoint, or a URL query/fragment in a tool-card title. Those values can
// carry credentials or session data; the UI only needs the operation and, for
// navigation, a human-recognisable origin.
func browserToolTitle(command, rawArgs string) string {
switch command {
case "action":
action, ok := exactJSONStringArg(rawArgs, "command")
if !ok {
return "browser action"
}
if action, ok := tools.NormalizedBrowserActionCommand(action); ok {
return "browser action " + action
}
return "browser action"
case "open":
rawURL, ok := exactJSONStringArg(rawArgs, "url")
if !ok {
return "browser open"
}
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
if err != nil {
return "browser open"
}
u, err := url.Parse(normalized)
if err != nil || u.Scheme == "" || u.Host == "" {
return "browser open"
}
origin := u.Scheme + "://" + u.Host
if !browserTitleTextSafe(origin) {
return "browser open"
}
return "browser open " + truncateHint(origin)
default:
return "browser " + command
}
}
// browserTitleTextSafe validates text after URL parsing has decoded escaped
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: whitespace, control,
// format/bidi, and line/paragraph separator runes can reorder or split the
// permission label shown to a user. The execution URL remains unchanged.
func browserTitleTextSafe(text string) bool {
if !utf8.ValidString(text) {
return false
}
for _, r := range text {
if unicode.IsSpace(r) || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
return false
}
}
return true
}
// exactJSONStringArg mirrors ZERO's map-based tool argument decoding: only the
// exact JSON key is considered, and a non-string value is invalid. In
// particular, an incidental "URL" key must not change a permission title when
// browser_open will only read "url".
func exactJSONStringArg(rawArgs, key string) (string, bool) {
var args map[string]json.RawMessage
if json.Unmarshal([]byte(rawArgs), &args) != nil {
return "", false
}
raw, ok := args[key]
if !ok {
return "", false
}
var value string
if json.Unmarshal(raw, &value) != nil {
return "", false
}
return value, true
}
// primaryArgHint extracts the most relevant argument (path/pattern/command) from
// raw JSON arguments. Best-effort; returns "" when it can't parse.
func primaryArgHint(rawArgs string) string {
@@ -89,7 +213,7 @@ func rawInput(args string) json.RawMessage {
// toolCallStart maps an advertised ZERO tool call to the initial ACP "tool_call"
// update (status in_progress — ZERO executes immediately after advertising).
func toolCallStart(call agent.ToolCall) ToolCallUpdate {
return ToolCallUpdate{
upd := ToolCallUpdate{
SessionUpdate: UpdateToolCall,
ToolCallID: call.ID,
Title: toolTitle(call.Name, call.Arguments),
@@ -97,6 +221,8 @@ func toolCallStart(call agent.ToolCall) ToolCallUpdate {
Status: ToolStatusInProgress,
RawInput: rawInput(call.Arguments),
}
attachBrowserToolDetails(&upd, call.Name)
return upd
}
// toolCallResult maps a finished ZERO tool result to a "tool_call_update".
@@ -116,26 +242,64 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
if locs := toolResultLocations(result); len(locs) > 0 {
upd.Locations = locs
}
attachBrowserToolDetails(&upd, result.Name)
return upd
}
func toolResultContent(result agent.ToolResult) []ToolCallContent {
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
text := strings.TrimRight(result.Output, "\n")
if text == "" {
text = result.Display.Summary
}
if text == "" {
return nil
return appendToolResultDiffs(content, result.FileDiffs)
}
return []ToolCallContent{ToolContent(TextBlock(text))}
content = append(content, ToolContent(TextBlock(text)))
return appendToolResultDiffs(content, result.FileDiffs)
}
func appendToolResultDiffs(content []ToolCallContent, diffs []tools.FileDiff) []ToolCallContent {
for _, diff := range diffs {
if !filepath.IsAbs(diff.Path) || (!diff.OldExists && !diff.NewExists) {
continue
}
var oldText *string
if diff.OldExists {
old := diff.OldText
oldText = &old
}
var newText *string
if diff.NewExists {
updated := diff.NewText
newText = &updated
}
content = append(content, ToolCallContent{Type: "diff", Path: diff.Path, OldText: oldText, NewText: newText})
}
return content
}
func toolResultLocations(result agent.ToolResult) []ToolCallLocation {
locs := make([]ToolCallLocation, 0, len(result.ChangedFiles))
for _, f := range result.ChangedFiles {
if strings.TrimSpace(f) == "" {
locs := make([]ToolCallLocation, 0, len(result.FileDiffs)+len(result.ChangedFiles))
seen := make(map[string]bool, len(result.FileDiffs)+len(result.ChangedFiles))
for _, diff := range result.FileDiffs {
path := diff.Path
if path == "" || seen[path] {
continue
}
seen[path] = true
locs = append(locs, ToolCallLocation{Path: path})
}
// FileDiff.Path is canonical absolute path data while ChangedFiles is
// normally workspace-relative. Without the trusted workspace root these
// coordinate systems cannot be correlated safely: a suffix match would let
// /workspace/sub/a.go consume the fallback for a distinct root a.go. The
// shared seen set deduplicates only identities already exactly comparable.
for _, f := range result.ChangedFiles {
if f == "" || seen[f] {
continue
}
seen[f] = true
locs = append(locs, ToolCallLocation{Path: f})
}
return locs
+427 -5
View File
@@ -1,14 +1,30 @@
package acp
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"unicode"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
)
func browserDescriptor(t *testing.T, update ToolCallUpdate) BrowserToolDetails {
t.Helper()
raw, ok := update.Meta[zeroBrowserMetaKey]
if !ok {
t.Fatalf("browser metadata = %#v, want %q", update.Meta, zeroBrowserMetaKey)
}
var details BrowserToolDetails
if err := json.Unmarshal(raw, &details); err != nil {
t.Fatalf("decode browser metadata: %v", err)
}
return details
}
func TestAgentMessageAndThoughtChunks(t *testing.T) {
m := agentMessageChunk("hello")
if m.SessionUpdate != UpdateAgentMessageChunk || m.Content.Type != "text" || m.Content.Text != "hello" {
@@ -56,6 +72,223 @@ func TestToolTitleAndHint(t *testing.T) {
}
}
func TestBrowserToolUpdatesAreStructuredAndPresentationSafe(t *testing.T) {
start := toolCallStart(agent.ToolCall{
ID: "browser-1",
Name: "browser_open",
Arguments: `{"url":"https://example.com/settings?token=not-for-a-title#account"}`,
})
if got := browserDescriptor(t, start); got != (BrowserToolDetails{Version: 1, Command: "open"}) {
t.Fatalf("browser descriptor = %#v, want open", got)
}
if start.Title != "browser open https://example.com" {
t.Fatalf("browser title = %q", start.Title)
}
if strings.Contains(start.Title, "token=") || strings.Contains(start.Title, "#account") {
t.Fatalf("browser title leaked URL-sensitive data: %q", start.Title)
}
encoded, err := json.Marshal(start)
if err != nil {
t.Fatal(err)
}
var wire struct {
Meta map[string]json.RawMessage `json:"_meta"`
}
if err := json.Unmarshal(encoded, &wire); err != nil {
t.Fatal(err)
}
if _, ok := wire.Meta[zeroBrowserMetaKey]; !ok {
t.Fatalf("browser wire metadata = %#v", wire.Meta)
}
typed := toolCallStart(agent.ToolCall{
ID: "browser-2",
Name: "browser_type",
Arguments: `{"ref":"email","text":"[email protected]"}`,
})
if got := browserDescriptor(t, typed); got.Command != "type" {
t.Fatalf("browser type descriptor = %#v", got)
}
if typed.Title != "browser type" || strings.Contains(typed.Title, "[email protected]") {
t.Fatalf("browser type title = %q", typed.Title)
}
action := toolCallStart(agent.ToolCall{
ID: "browser-3",
Name: "browser_action",
Arguments: `{"command":"keyboard_insert_text","args":["[email protected]"]}`,
})
if action.Title != "browser action keyboard_insert_text" {
t.Fatalf("browser action title = %q", action.Title)
}
result := toolCallResult(agent.ToolResult{
ToolCallID: "browser-2",
Name: "browser_type",
Status: tools.StatusOK,
})
if got := browserDescriptor(t, result); got.Command != "type" {
t.Fatalf("browser result descriptor = %#v", got)
}
}
func TestBrowserDescriptorSurvivesProtocolShapedRoundTrip(t *testing.T) {
updates := []ToolCallUpdate{
toolCallStart(agent.ToolCall{
ID: "start",
Name: "browser_open",
Arguments: `{"url":"https://user:[email protected]/private?token=secret#fragment"}`,
}),
toolCallResult(agent.ToolResult{
ToolCallID: "result",
Name: "browser_type",
Status: tools.StatusOK,
}),
permissionToolCall(agent.PermissionRequest{
ToolCallID: "permission",
ToolName: "browser_connect",
Args: map[string]any{"target": "127.0.0.1:9222"},
}),
}
type protocolToolCallUpdate struct {
SessionUpdate string `json:"sessionUpdate,omitempty"`
ToolCallID string `json:"toolCallId"`
Title string `json:"title,omitempty"`
Kind string `json:"kind,omitempty"`
Status string `json:"status,omitempty"`
RawInput json.RawMessage `json:"rawInput,omitempty"`
Content []ToolCallContent `json:"content,omitempty"`
Locations []ToolCallLocation `json:"locations,omitempty"`
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
}
for _, update := range updates {
encoded, err := json.Marshal(update)
if err != nil {
t.Fatal(err)
}
var root map[string]json.RawMessage
if err := json.Unmarshal(encoded, &root); err != nil {
t.Fatal(err)
}
if _, ok := root["browser"]; ok {
t.Fatalf("browser descriptor escaped ACP _meta: %s", encoded)
}
var protocol protocolToolCallUpdate
if err := json.Unmarshal(encoded, &protocol); err != nil {
t.Fatal(err)
}
forwarded, err := json.Marshal(protocol)
if err != nil {
t.Fatal(err)
}
var roundTripped ToolCallUpdate
if err := json.Unmarshal(forwarded, &roundTripped); err != nil {
t.Fatal(err)
}
details := browserDescriptor(t, roundTripped)
if details.Version != 1 || details.Command == "" {
t.Fatalf("round-tripped browser descriptor = %#v", details)
}
descriptorJSON := string(roundTripped.Meta[zeroBrowserMetaKey])
for _, secret := range []string{"password", "private", "token", "fragment", "127.0.0.1", "9222"} {
if strings.Contains(descriptorJSON, secret) {
t.Fatalf("browser descriptor leaked %q: %s", secret, descriptorJSON)
}
}
}
}
func TestBrowserPermissionTitlesMirrorSafeToolArguments(t *testing.T) {
if got := browserToolTitle("open", `{"url":"evil.example.test/pay?token=hidden#fragment"}`); got != "browser open https://evil.example.test" {
t.Fatalf("bare-host title = %q", got)
}
if got := browserToolTitle("open", `{"URL":"https://different.example.test"}`); got != "browser open" {
t.Fatalf("case-variant URL title = %q", got)
}
if got := 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)
}
if got := browserToolTitle("action", `{"command":"not an action"}`); got != "browser action" {
t.Fatalf("unknown browser action title = %q", got)
}
longHost := "https://" + strings.Repeat("a", 200) + ".example.test/path?token=hidden"
title := browserToolTitle("open", `{"url":"`+longHost+`"}`)
if !utf8.ValidString(title) || utf8.RuneCountInString(title) > len("browser open ")+61 || strings.Contains(title, "token=") {
t.Fatalf("bounded browser origin title = %q", title)
}
}
func TestBrowserOpenTitlesRejectDecodedUnicodePresentationControls(t *testing.T) {
for _, rawURL := range []string{
"https://safe.example%C2%A0evil.test/path",
"https://safe.example%E2%80%83evil.test/path",
"https://safe.example%E3%80%80evil.test/path",
"https://safe.example%E2%80%AEevil.test/path",
"https://safe.example%E2%81%A6evil.test/path",
"https://safe.example%C2%85evil.test/path",
"https://safe.example%E2%80%A8evil.test/path",
"https://safe.example%E2%80%A9evil.test/path",
} {
t.Run(rawURL, func(t *testing.T) {
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
if err != nil {
t.Fatalf("execution URL rejected: %v", err)
}
if normalized != rawURL {
t.Fatalf("execution URL = %q, want unchanged %q", normalized, rawURL)
}
args, err := json.Marshal(map[string]any{"url": rawURL})
if err != nil {
t.Fatal(err)
}
if title := browserToolTitle("open", string(args)); title != "browser open" {
t.Errorf("unsafe origin accepted before permission translation: %q", title)
}
updates := []ToolCallUpdate{
toolCallStart(agent.ToolCall{ID: "start", Name: "browser_open", Arguments: string(args)}),
permissionToolCall(agent.PermissionRequest{ToolCallID: "permission", ToolName: "browser_open", Args: map[string]any{"url": rawURL}}),
}
for _, update := range updates {
encoded, err := json.Marshal(update)
if err != nil {
t.Fatal(err)
}
var decoded ToolCallUpdate
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Title != "browser open" {
t.Fatalf("unsafe browser title survived wire round trip: %q", decoded.Title)
}
for _, r := range decoded.Title {
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
t.Fatalf("browser title contains unsafe presentation rune %U: %q", r, decoded.Title)
}
}
}
})
}
}
func TestBrowserDescriptorDoesNotClaimSimilarlyNamedMCPTools(t *testing.T) {
start := toolCallStart(agent.ToolCall{ID: "mcp-1", Name: "browser_plugin_open", Arguments: `{}`})
if len(start.Meta) != 0 {
t.Fatalf("MCP-like tool received built-in browser metadata: %#v", start.Meta)
}
encoded, err := json.Marshal(start)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), `"browser"`) {
t.Fatalf("non-browser tool encoded browser field: %s", encoded)
}
}
func TestToolCallStart(t *testing.T) {
upd := toolCallStart(agent.ToolCall{ID: "tc1", Name: "read_file", Arguments: `{"path":"a.go"}`})
if upd.SessionUpdate != UpdateToolCall {
@@ -74,21 +307,26 @@ func TestToolCallStart(t *testing.T) {
}
func TestToolCallResult(t *testing.T) {
path := filepath.Join(t.TempDir(), "a.go")
ok := toolCallResult(agent.ToolResult{
ToolCallID: "tc1",
Name: "edit_file",
Status: tools.StatusOK,
Output: "applied\n",
ChangedFiles: []string{"a.go", ""},
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before\n", NewText: "after\n"}},
})
if ok.SessionUpdate != UpdateToolCallUpdate || ok.Status != ToolStatusCompleted {
t.Fatalf("unexpected ok result: %+v", ok)
}
if len(ok.Content) != 1 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
if len(ok.Content) != 2 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
t.Fatalf("unexpected content: %+v", ok.Content)
}
if len(ok.Locations) != 1 || ok.Locations[0].Path != "a.go" {
t.Fatalf("blank changed files should be dropped, got %+v", ok.Locations)
if diff := ok.Content[1]; diff.Type != "diff" || diff.Path != path || diff.OldText == nil || *diff.OldText != "before\n" || diff.NewText == nil || *diff.NewText != "after\n" {
t.Fatalf("unexpected diff content: %+v", diff)
}
if len(ok.Locations) != 2 || ok.Locations[0].Path != path || ok.Locations[1].Path != "a.go" {
t.Fatalf("unproven absolute/relative aliases must both remain visible, got %+v", ok.Locations)
}
failed := toolCallResult(agent.ToolResult{ToolCallID: "tc2", Status: tools.StatusError, Output: "boom"})
@@ -97,6 +335,190 @@ func TestToolCallResult(t *testing.T) {
}
}
func TestToolCallDiffJSONDistinguishesEmptyFilesAndDeletion(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty.txt")
content := appendToolResultDiffs(nil, []tools.FileDiff{
{Path: path, OldExists: false, NewExists: true, NewText: ""},
{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: ""},
{Path: path, OldExists: true, NewExists: false, OldText: "before"},
})
if len(content) != 3 {
t.Fatalf("diff content = %#v", content)
}
for index, diff := range content {
encoded, err := json.Marshal(diff)
if err != nil {
t.Fatal(err)
}
var wire map[string]any
if err := json.Unmarshal(encoded, &wire); err != nil {
t.Fatal(err)
}
if wire["path"] != path {
t.Fatalf("wire diff %d = %s", index, encoded)
}
switch index {
case 0:
oldText, present := wire["oldText"]
if !present || oldText != nil || wire["newText"] != "" {
t.Fatalf("create diff = %s, want null oldText and empty newText", encoded)
}
case 1:
if wire["oldText"] != "before" || wire["newText"] != "" {
t.Fatalf("empty replacement diff = %s", encoded)
}
case 2:
newText, present := wire["newText"]
if wire["oldText"] != "before" || !present || newText != nil {
t.Fatalf("deletion diff = %s, want oldText and null newText", encoded)
}
}
}
}
func TestToolResultLocationsPreserveDistinctPathIdentities(t *testing.T) {
root := t.TempDir()
rootPath := filepath.Join(root, "a.go")
nestedPath := filepath.Join(root, "sub", "a.go")
diff := func(path string) tools.FileDiff {
return tools.FileDiff{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}
}
for _, tc := range []struct {
name string
diffs []tools.FileDiff
want []string
}{
{name: "both rich", diffs: []tools.FileDiff{diff(rootPath), diff(nestedPath)}, want: []string{rootPath, nestedPath, "a.go", filepath.Join("sub", "a.go")}},
{name: "root rich", diffs: []tools.FileDiff{diff(rootPath)}, want: []string{rootPath, "a.go", filepath.Join("sub", "a.go")}},
{name: "nested rich", diffs: []tools.FileDiff{diff(nestedPath)}, want: []string{nestedPath, "a.go", filepath.Join("sub", "a.go")}},
} {
t.Run(tc.name, func(t *testing.T) {
locations := toolResultLocations(agent.ToolResult{
ChangedFiles: []string{"a.go", filepath.Join("sub", "a.go")},
FileDiffs: tc.diffs,
})
if len(locations) != len(tc.want) {
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
}
for index := range tc.want {
if locations[index].Path != tc.want[index] {
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
}
}
})
}
}
func TestToolCallResultPreservesWhitespaceInFilePaths(t *testing.T) {
relativePath := " report.txt "
absolutePath := filepath.Join(t.TempDir(), relativePath)
update := toolCallResult(agent.ToolResult{
ChangedFiles: []string{relativePath},
FileDiffs: []tools.FileDiff{{
Path: absolutePath, OldExists: true, NewExists: true, OldText: "before", NewText: "after",
}},
})
if len(update.Content) != 1 || update.Content[0].Path != absolutePath {
t.Fatalf("diff content path = %#v, want %q", update.Content, absolutePath)
}
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
t.Fatalf("locations = %#v, want exact paths %q and %q", update.Locations, absolutePath, relativePath)
}
}
func TestToolResultLocationsDeduplicateOnlyExactPaths(t *testing.T) {
path := filepath.Join(t.TempDir(), "a.go")
locations := toolResultLocations(agent.ToolResult{
ChangedFiles: []string{path, path},
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}},
})
if len(locations) != 1 || locations[0].Path != path {
t.Fatalf("exact duplicate locations = %#v", locations)
}
}
func TestDeletedFileEmitsDiffAndKeepsLocations(t *testing.T) {
relativePath := "deleted.go"
absolutePath := filepath.Join(t.TempDir(), relativePath)
update := toolCallResult(agent.ToolResult{
ChangedFiles: []string{relativePath},
FileDiffs: []tools.FileDiff{{
Path: absolutePath, OldExists: true, NewExists: false, OldText: "before",
}},
})
if len(update.Content) != 1 || update.Content[0].OldText == nil || *update.Content[0].OldText != "before" || update.Content[0].NewText != nil {
t.Fatalf("deleted file diff = %#v, want oldText with null newText", update.Content)
}
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
t.Fatalf("deleted file locations = %#v", update.Locations)
}
}
func TestToolCallResultEmitsOnlyRedactedFileDiffs(t *testing.T) {
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
path := filepath.Join(t.TempDir(), "secret.txt")
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{{
Path: path, OldExists: true, NewExists: true, OldText: "token=" + secret, NewText: "safe",
}}})
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusError, FileDiffs: scrubbed.FileDiffs})
if len(update.Content) != 1 || update.Content[0].OldText == nil || strings.Contains(*update.Content[0].OldText, secret) {
t.Fatalf("ACP content leaked unredacted diff: %#v", update.Content)
}
}
func TestToolCallResultOmitsSemanticallyUnchangedRedactedDiff(t *testing.T) {
oldSecret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
newSecret := "ghp_9876543210ZYXWVUTSRQPONMLKJIHGFEDCBA"
scrubbed := tools.ScrubResultSecrets(tools.Result{
ChangedFiles: []string{"credentials.txt"},
FileDiffs: []tools.FileDiff{{
Path: filepath.Join(t.TempDir(), "credentials.txt"), OldExists: true, NewExists: true,
OldText: "token=" + oldSecret, NewText: "token=" + newSecret,
}},
})
update := toolCallResult(agent.ToolResult{
ToolCallID: "call", Status: tools.StatusOK,
ChangedFiles: scrubbed.ChangedFiles, FileDiffs: scrubbed.FileDiffs,
})
if len(update.Content) != 0 {
t.Fatalf("ACP emitted semantically unchanged redacted diff: %#v", update.Content)
}
if len(update.Locations) != 1 || update.Locations[0].Path != "credentials.txt" {
t.Fatalf("ACP path fallback = %#v", update.Locations)
}
}
func TestToolCallResultOmitsDefaultIgnorableSplitSecretsOnEitherSide(t *testing.T) {
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
for name, separator := range map[string]string{
"combining grapheme joiner": "\u034f",
"variation selector": "\ufe0f",
} {
for _, side := range []string{"old", "new"} {
t.Run(name+" "+side, func(t *testing.T) {
obfuscated := secret[:20] + separator + secret[20:]
diff := tools.FileDiff{
Path: filepath.Join(t.TempDir(), "secret.txt"), OldExists: true, NewExists: true,
OldText: "safe old", NewText: "safe new",
}
if side == "old" {
diff.OldText = obfuscated
} else {
diff.NewText = obfuscated
}
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{diff}})
if !scrubbed.Redacted || len(scrubbed.FileDiffs) != 0 {
t.Fatalf("registry boundary retained an obfuscated secret: %#v", scrubbed)
}
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusOK, FileDiffs: scrubbed.FileDiffs})
if len(update.Content) != 0 {
t.Fatalf("ACP content retained an obfuscated secret: %#v", update.Content)
}
})
}
}
}
func TestPlanUpdateAndStatus(t *testing.T) {
upd := planUpdate([]tools.PlanItem{
{Content: "step a", Status: "completed"},
+93 -8
View File
@@ -12,6 +12,8 @@ const (
MethodAuthenticate = "authenticate"
MethodSessionNew = "session/new"
MethodSessionLoad = "session/load"
MethodSessionList = "session/list"
MethodSessionResume = "session/resume"
MethodSessionPrompt = "session/prompt"
MethodSessionCancel = "session/cancel" // notification
MethodSessionUpdate = "session/update" // notification (agent -> client)
@@ -62,8 +64,16 @@ type PromptCapabilities struct {
}
type AgentCapabilities struct {
LoadSession bool `json:"loadSession"`
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
LoadSession bool `json:"loadSession"`
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
SessionCapabilities *SessionCapabilities `json:"sessionCapabilities,omitempty"`
}
// Empty capability objects are presence flags in ACP v1. Pointers preserve
// the wire distinction between an advertised `{}` and an omitted capability.
type SessionCapabilities struct {
List *struct{} `json:"list,omitempty"`
Resume *struct{} `json:"resume,omitempty"`
}
type AuthMethod struct {
@@ -117,9 +127,14 @@ type McpServer struct {
}
type NewSessionParams struct {
Cwd string `json:"cwd"`
McpServers []McpServer `json:"mcpServers"`
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
Cwd string `json:"cwd"`
McpServers []McpServer `json:"mcpServers"`
// AdditionalDirectories is declared by the protocol and consumed nowhere in
// this repo yet. When it is wired up it must go through requestedWorkspace
// like Cwd does: these are client-supplied paths with no absoluteness rule of
// their own, which is the same shape as the resume-cwd hole that made
// {"sessionId":"known"} activate a session against this process's directory.
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
}
type NewSessionResult struct {
@@ -140,6 +155,39 @@ type LoadSessionResult struct {
Modes *SessionModeState `json:"modes,omitempty"`
}
// ListSessionsParams and the following types implement ACP v1 session/list.
// Transcript contents stay behind session/load; this method returns metadata
// only and leaves the optional pagination cursor opaque.
type ListSessionsParams struct {
Cwd string `json:"cwd,omitempty"`
Cursor string `json:"cursor,omitempty"`
}
type SessionInfoMeta struct {
ModelID string `json:"modelId,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
}
type SessionInfo struct {
SessionID string `json:"sessionId"`
Cwd string `json:"cwd"`
Title string `json:"title,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
Meta *SessionInfoMeta `json:"_meta,omitempty"`
}
type ListSessionsResult struct {
Sessions []SessionInfo `json:"sessions"`
NextCursor string `json:"nextCursor,omitempty"`
}
// session/resume takes session/load's wire shape, so its cwd is a plain string
// too and an omitted one decodes exactly like an empty one. Sharing the type is
// safe only because activatePersistedSession requires an absolute cwd from the
// request itself — nothing downstream may supply a default for either method.
type ResumeSessionParams = LoadSessionParams
type ResumeSessionResult = LoadSessionResult
// ---- prompt turn ----
type PromptParams struct {
@@ -174,6 +222,7 @@ type SessionNotification struct {
// ContentBlock under "content"; the variant is set via SessionUpdate.
type ContentChunk struct {
SessionUpdate string `json:"sessionUpdate"`
MessageID string `json:"messageId,omitempty"`
Content ContentBlock `json:"content"`
}
@@ -210,6 +259,21 @@ type ToolCallUpdate struct {
RawInput json.RawMessage `json:"rawInput,omitempty"`
Content []ToolCallContent `json:"content,omitempty"`
Locations []ToolCallLocation `json:"locations,omitempty"`
// Meta is ACP's extension channel. ZERO-owned values must remain beneath a
// namespaced key so protocol-shaped clients can preserve them while decoding
// and re-encoding a tool call.
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
}
// BrowserToolDetails identifies the browser helper operation behind a tool
// call. Version is the schema version for this optional ZERO extension;
// Command is one of install, launch, connect, open, snapshot, click, type,
// press, or action. Future fields must remain display-safe and must not
// include browser profile data, cookies, typed text, URL paths/queries, or
// DevTools endpoints.
type BrowserToolDetails struct {
Version int `json:"version"`
Command string `json:"command"`
}
// ToolCallContent is a tool call's rendered output. ZERO emits "content" (a
@@ -220,9 +284,30 @@ type ToolCallContent struct {
// type == "content"
Content *ContentBlock `json:"content,omitempty"`
// type == "diff"
Path string `json:"path,omitempty"`
OldText string `json:"oldText,omitempty"`
NewText string `json:"newText,omitempty"`
Path string `json:"path,omitempty"`
OldText *string `json:"oldText,omitempty"`
NewText *string `json:"newText,omitempty"`
}
// MarshalJSON preserves ACP's discriminated content union. A diff always has
// path, while nullable oldText/newText distinguish creation, deletion, and an
// existing file replaced with empty content. Other content variants omit all
// diff fields rather than serializing irrelevant nulls.
func (content ToolCallContent) MarshalJSON() ([]byte, error) {
if content.Type == "diff" {
return json.Marshal(struct {
Type string `json:"type"`
Path string `json:"path"`
OldText *string `json:"oldText"`
NewText *string `json:"newText"`
}{
Type: content.Type, Path: content.Path, OldText: content.OldText, NewText: content.NewText,
})
}
return json.Marshal(struct {
Type string `json:"type"`
Content *ContentBlock `json:"content,omitempty"`
}{Type: content.Type, Content: content.Content})
}
func ToolContent(block ContentBlock) ToolCallContent {
+309
View File
@@ -0,0 +1,309 @@
package acp
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// `zero exec` persists every provider usage report as a session usage event, so
// `zero usage report --session <id>` can total a run. The ACP agent did not set
// OnUsage, which dropped the token counts of every ACP session. A turn with two
// provider requests (a tool round trip) must leave two usage events, in the shape
// the usage report reads, next to the turn's messages.
func TestACPPromptPersistsProviderUsage(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{FinalAnswer: "done"}, nil
}
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
opts.OnUsage(agent.Usage{InputTokens: 1300, OutputTokens: 21, CachedInputTokens: 900})
return agent.Result{FinalAnswer: "done"}, nil
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
var result PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("count my tokens")},
}, &result); err != nil {
t.Fatalf("session/prompt: %v", err)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
type usagePayload struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
CachedInputTokens int `json:"cachedInputTokens"`
}
var got []usagePayload
messages := 0
for _, event := range events {
switch event.Type {
case sessions.EventUsage:
var payload usagePayload
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("usage payload %s: %v", event.Payload, err)
}
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
got = append(got, payload)
case sessions.EventMessage:
messages++
}
}
want := []usagePayload{
{PromptTokens: 1200, CompletionTokens: 34, TotalTokens: 1234},
{PromptTokens: 1300, CompletionTokens: 21, TotalTokens: 1321, CachedInputTokens: 900},
}
if len(got) != len(want) {
t.Fatalf("usage events = %+v, want %+v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("usage event %d = %+v, want %+v", i, got[i], want[i])
}
}
if messages != 2 {
t.Fatalf("message events = %d, want the user and assistant messages of the turn", messages)
}
}
// Usage joins the turn's buffered batch, so a hard run failure that aborts the
// turn leaves nothing durable, exactly as it does for the turn's messages.
func TestACPHardTurnFailureDropsItsUsageWithTheTurn(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{}, errors.New("injected hard run failure")
}
opts.OnUsage(agent.Usage{InputTokens: 500, OutputTokens: 5})
return agent.Result{}, errors.New("injected hard run failure")
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failing turn")},
}, &PromptResult{}); err == nil {
t.Fatal("hard RunAgent failure was reported as a successful turn")
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
if len(events) != 0 {
t.Fatalf("aborted turn left durable events: %+v", events)
}
}
// StopCancelled is a successful ACP outcome: the turn commits. Tokens the
// provider already reported were spent, so they stay in the session log even
// though the client cancelled. A hard error (the test above) is the path that
// drops them.
func TestACPCancelledTurnKeepsReportedUsage(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{}, context.Canceled
}
opts.OnUsage(agent.Usage{InputTokens: 80, OutputTokens: 3})
return agent.Result{FinalAnswer: "partial"}, context.Canceled
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
var result PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("cancelled turn")},
}, &result); err != nil {
t.Fatalf("cancelled turn returned an RPC error: %v", err)
}
if result.StopReason != StopCancelled {
t.Fatalf("stop reason = %q, want %q", result.StopReason, StopCancelled)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
usageEvents := 0
for _, event := range events {
if event.Type != sessions.EventUsage {
continue
}
usageEvents++
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
var payload struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
}
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload.PromptTokens != 80 || payload.CompletionTokens != 3 || payload.TotalTokens != 83 {
t.Fatalf("cancelled-turn usage = %+v, want 80/3/83", payload)
}
}
if usageEvents != 1 {
t.Fatalf("usage events = %d, want the one report from the cancelled turn", usageEvents)
}
}
// Usage events sit in the same log session/load and session/resume read, but
// history reconstruction skips them. The wire replay stays the conversation,
// and the next turn's prompt stays the conversation: token counts never become
// context and never cross the ACP wire.
func TestACPLoadAndResumeIgnoreUsageEvents(t *testing.T) {
const userLine = "unique-acp-user-line"
const answer = "unique-acp-answer"
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{FinalAnswer: answer}, nil
}
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
return agent.Result{FinalAnswer: answer}, nil
}
workspace := t.TempDir()
h := newHarness(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace}, &created); err != nil {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock(userLine)},
}, &PromptResult{}); err != nil {
t.Fatalf("session/prompt: %v", err)
}
h.stop()
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
sawUsage := false
for _, event := range events {
if event.Type == sessions.EventUsage {
sawUsage = true
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
}
}
if !sawUsage {
t.Fatal("session log has no usage event to ignore")
}
loader := newHarness(t, deps)
if err := loader.client.Call(ctx, MethodSessionLoad, LoadSessionParams{
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load: %v", err)
}
var replayed []string
for {
select {
case update := <-loader.notifications:
replayed = append(replayed, update.Content.Text)
case <-time.After(150 * time.Millisecond):
goto drained
}
}
drained:
loader.stop()
if len(replayed) != 2 || replayed[0] != userLine || replayed[1] != answer {
t.Fatalf("session/load replay = %#v, want the user line and the answer only", replayed)
}
for _, text := range replayed {
if strings.Contains(text, "promptTokens") || strings.Contains(text, "1200") {
t.Fatalf("session/load replayed usage onto the wire: %q", text)
}
}
var gotPrompt string
deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, _ agent.Options) (agent.Result, error) {
gotPrompt = prompt
return agent.Result{FinalAnswer: "continued"}, nil
}
resumer := newHarness(t, deps)
defer resumer.stop()
if err := resumer.client.Call(ctx, MethodSessionResume, ResumeSessionParams{
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
}, &ResumeSessionResult{}); err != nil {
t.Fatalf("session/resume: %v", err)
}
if err := resumer.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("carry on")},
}, &PromptResult{}); err != nil {
t.Fatalf("resumed session/prompt: %v", err)
}
if !strings.Contains(gotPrompt, userLine) || !strings.Contains(gotPrompt, answer) {
t.Fatalf("resumed prompt = %q, want the prior conversation", gotPrompt)
}
if strings.Contains(gotPrompt, "promptTokens") || strings.Contains(gotPrompt, "1200") {
t.Fatalf("resumed prompt contains persisted usage: %q", gotPrompt)
}
}
// usagePayloadAllowedKeys is the non-escalating EventUsagePayload shape. ACP
// has no model escalation, and the writer must not grow a prompt, tool
// argument, or other raw field.
var usagePayloadAllowedKeys = map[string]bool{
"promptTokens": true,
"completionTokens": true,
"totalTokens": true,
"cachedInputTokens": true,
"cacheWriteTokens": true,
"reasoningTokens": true,
}
func assertUsagePayloadIsTokenCountsOnly(t *testing.T, payload json.RawMessage) {
t.Helper()
var raw map[string]json.RawMessage
if err := json.Unmarshal(payload, &raw); err != nil {
t.Fatalf("usage payload %s: %v", payload, err)
}
if len(raw) == 0 {
t.Fatalf("usage payload is empty: %s", payload)
}
for key := range raw {
if !usagePayloadAllowedKeys[key] {
t.Fatalf("usage payload key %q is not a token count: %s", key, payload)
}
}
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func enabledSelfCorrector(t *testing.T) *SelfCorrector {
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
)
type normalizationRecordingShellTool struct {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// providerCallingAskUserThenAnswer returns a mock provider whose first turn calls
+8 -20
View File
@@ -8,8 +8,9 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/testutil"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
@@ -111,7 +112,11 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"a.go"})
waitForIdle(t, diagnostics)
testutil.WaitFor(t, "worker idle", func() bool {
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
return diagnostics.working == nil
})
mu.Lock()
response = "ERR new"
mu.Unlock()
@@ -123,23 +128,6 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
}
}
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
diagnostics.mu.Lock()
busy := diagnostics.working
diagnostics.mu.Unlock()
if busy == nil {
return
}
if time.Now().After(deadline) {
t.Fatal("worker never went idle")
}
time.Sleep(2 * time.Millisecond)
}
}
// changedFilesTool is a fake mutating tool reporting a changed file without
// touching the filesystem.
type changedFilesTool struct{}
+34
View File
@@ -0,0 +1,34 @@
package agent
import "github.com/Twigpine/zero/internal/tools"
// CheckpointTargets returns the workspace-relative paths a tool call will mutate,
// resolving its arguments exactly as executeToolCall does. Freeform apply_patch
// input is normalized through the registered built-in tool, so absolute headers
// are rewritten relative to the selected write root and cwd is set: checkpoint
// capture and execution therefore agree on the files at stake.
//
// Best-effort: nil when the call has no capture targets or its arguments cannot
// be resolved. Checkpoint capture never fails a run.
func CheckpointTargets(workspaceRoot string, registry *tools.Registry, call ToolCall) []string {
if registry == nil {
return nil
}
args := map[string]any{}
if call.Freeform {
tool, ok := registry.Get(call.Name)
if !ok || !tools.IsBuiltInApplyPatch(tool) {
return nil
}
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
if err != nil {
return nil
}
args = prepared
} else if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return nil
}
}
return tools.MutationTargets(workspaceRoot, call.Name, args)
}
+99
View File
@@ -0,0 +1,99 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"testing"
"github.com/Twigpine/zero/internal/tools"
)
func TestCheckpointTargets(t *testing.T) {
root := t.TempDir()
// Absolute headers must be compared against the physical root: t.TempDir
// differs from it on macOS (/var -> /private/var) and via Windows short names.
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
relativePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** End Patch\n"
absolutePatch := "*** Begin Patch\n*** Update File: " + filepath.Join(root, "main.txt") + "\n@@\n-before\n+after\n*** End Patch\n"
multiFilePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** Add File: notes.txt\n+new\n*** End Patch\n"
jsonArgs, err := json.Marshal(map[string]any{"patch": relativePatch})
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
unbuiltinRegistry := tools.NewRegistry()
unbuiltinRegistry.Register(&customApplyPatchTool{})
for _, tc := range []struct {
name string
registry *tools.Registry
call ToolCall
want []string
}{
{
name: "freeform relative header",
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
want: []string{"main.txt"},
},
{
name: "freeform absolute header stays workspace-relative",
call: ToolCall{Name: "apply_patch", Arguments: absolutePatch, Freeform: true},
want: []string{"main.txt"},
},
{
name: "freeform multi-file patch",
call: ToolCall{Name: "apply_patch", Arguments: multiFilePatch, Freeform: true},
want: []string{"main.txt", "notes.txt"},
},
{
name: "json arguments",
call: ToolCall{Name: "apply_patch", Arguments: string(jsonArgs)},
want: []string{"main.txt"},
},
{
name: "freeform traversal is rejected",
call: ToolCall{Name: "apply_patch", Arguments: "*** Begin Patch\n*** Update File: ../escape.txt\n@@\n-x\n+y\n*** End Patch\n", Freeform: true},
want: nil,
},
{
name: "freeform custom apply_patch is not prepared",
registry: unbuiltinRegistry,
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
want: nil,
},
{
name: "unknown freeform tool",
call: ToolCall{Name: "read_file", Arguments: "raw", Freeform: true},
want: nil,
},
{
name: "malformed json arguments",
call: ToolCall{Name: "apply_patch", Arguments: "{"},
want: nil,
},
} {
t.Run(tc.name, func(t *testing.T) {
reg := registry
if tc.registry != nil {
reg = tc.registry
}
got := CheckpointTargets(root, reg, tc.call)
if !slices.Equal(got, tc.want) {
t.Fatalf("CheckpointTargets() = %#v, want %#v", got, tc.want)
}
})
}
if got := CheckpointTargets(root, nil, ToolCall{Name: "apply_patch", Arguments: relativePatch}); got != nil {
t.Fatalf("CheckpointTargets(nil registry) = %#v, want nil", got)
}
}
+135
View File
@@ -0,0 +1,135 @@
package agent
import (
"context"
"testing"
"github.com/Twigpine/zero/internal/streamjson"
"github.com/Twigpine/zero/internal/tools"
)
// progressProbeTool records whether the loop handed it a progress callback.
// streams mirrors what a real tool declares via tools.ChildProgressStreamer.
type progressProbeTool struct {
name string
streams bool
got *bool
}
func (t *progressProbeTool) Name() string { return t.name }
func (t *progressProbeTool) Description() string { return "probe" }
func (t *progressProbeTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (t *progressProbeTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (t *progressProbeTool) Run(context.Context, map[string]any) tools.Result {
*t.got = false
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func (t *progressProbeTool) RunWithOptions(_ context.Context, _ map[string]any, options tools.RunOptions) tools.Result {
*t.got = options.Progress != nil
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
// StreamsChildProgress makes this type implement tools.ChildProgressStreamer;
// the flag drives the answer. A tool that never declares at all is modelled by
// silentProbeTool below, which genuinely does not implement the interface.
func (t *progressProbeTool) StreamsChildProgress() bool { return t.streams }
// silentProbeTool does NOT implement tools.ChildProgressStreamer at all — the
// state every tool in the tree is in today except Task and orchestrate.
type silentProbeTool struct {
name string
got *bool
}
func (t *silentProbeTool) Name() string { return t.name }
func (t *silentProbeTool) Description() string { return "probe" }
func (t *silentProbeTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (t *silentProbeTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (t *silentProbeTool) Run(context.Context, map[string]any) tools.Result {
*t.got = false
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func (t *silentProbeTool) RunWithOptions(_ context.Context, _ map[string]any, options tools.RunOptions) tools.Result {
*t.got = options.Progress != nil
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func runProbe(t *testing.T, tool tools.Tool, got *bool, withSink bool) bool {
t.Helper()
registry := tools.NewRegistry()
registry.Register(tool)
options := Options{}
if withSink {
options.OnToolProgress = func(string, streamjson.Event) {}
}
result, err := executeToolCall(context.Background(),
registry,
ToolCall{ID: "call_1", Name: tool.Name(), Arguments: "{}"},
PermissionModeUnsafe,
options)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status == "" {
t.Fatalf("probe produced no result")
}
return *got
}
// THE PARITY OBLIGATION for un-gating the progress path.
//
// The relationship asserted here is EQUALITY, per RULES.md §3: a tool's
// progress wiring before and after this change must be the same for every tool
// that already had it and every tool that already lacked it. Only a tool that
// newly DECLARES the interface may change.
//
// The name gate is gone, so the guarantee cannot be "Task still works" — it has
// to be stated in terms of the declaration, which is what these cases do.
func TestProgressCallbackFollowsTheDeclarationNotTheName(t *testing.T) {
t.Run("a declaring tool receives it (Task's behaviour, preserved)", func(t *testing.T) {
var got bool
// Named something other than "Task" ON PURPOSE: under the old name gate
// this case failed, which is the whole point of the change.
if !runProbe(t, &progressProbeTool{name: "spawner", streams: true, got: &got}, &got, true) {
t.Fatal("a tool declaring StreamsChildProgress must receive the callback")
}
})
t.Run("a tool named Task that does NOT declare gets nothing", func(t *testing.T) {
var got bool
// The inverse of the old behaviour, and the reason a name is the wrong
// key: identity is not capability.
if runProbe(t, &silentProbeTool{name: "Task", got: &got}, &got, true) {
t.Fatal("the callback must follow the declaration, not the name")
}
})
t.Run("a non-declaring tool receives nothing (every other tool, unchanged)", func(t *testing.T) {
var got bool
if runProbe(t, &silentProbeTool{name: "read_file", got: &got}, &got, true) {
t.Fatal("un-gating must not start handing a callback to tools that never had one")
}
})
t.Run("a declaring tool that answers false gets nothing", func(t *testing.T) {
var got bool
if runProbe(t, &progressProbeTool{name: "spawner", streams: false, got: &got}, &got, true) {
t.Fatal("StreamsChildProgress() == false must be honoured")
}
})
t.Run("no sink means no callback, whatever the tool declares", func(t *testing.T) {
var got bool
if runProbe(t, &progressProbeTool{name: "spawner", streams: true, got: &got}, &got, false) {
t.Fatal("without OnToolProgress there is nothing to forward to")
}
})
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"runtime"
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"mvdan.cc/sh/v3/syntax"
)
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"runtime"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
)
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Session compaction.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// compactableHistory returns a history whose middle is large enough to trip
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Compaction preservation.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// stateConversation is a long enough conversation that Compact elides a middle
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"regexp"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestClipBytesHandlesTinyLimitsOnRuneBoundaries(t *testing.T) {
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// recentEdits extracts each mutated file's path and a one-line note from the
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"sync/atomic"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
var markerPattern = regexp.MustCompile(`msg-\d+`)
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// --- Pure Compact() tests -------------------------------------------------
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// someRequestContains reports whether any message sent to the provider across all
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"encoding/json"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Context budget.
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestMeasureContextSplitsByCategory(t *testing.T) {
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
type contextPlannerConfig struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestContextPlannerPreservesProviderRequest(t *testing.T) {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/tools"
)
// richMCPTool mimics a real MCP tool's schema (Exa/Firecrawl-style search): an
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestOptionsDeferThresholdFieldExists(t *testing.T) {
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
)
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/tools"
)
// argsPermTool implements tools.ArgsPermissioner; plainTool does not.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// TestOptionsModelSwitcherFieldExists asserts the escalation hook is an
+15 -2
View File
@@ -2,8 +2,9 @@
package agent
import (
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/specialist"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func equalStringSlices(left []string, right []string) bool {
@@ -39,3 +40,15 @@ func parsePreservedState(summaryContent string) (string, []skillEntry) {
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
}
// Phase 2 additivity-proof seam. The identity test uses the REAL orchestrate
// tool rather than a stub, so it exercises the actual Deferred() contract that
// enforces the posture-off constraint. internal/specialist does not import
// internal/agent, so this direction creates no cycle.
const phase2ToolName = specialist.OrchestrateToolName
// registerPhase2ToolForTest registers the real tool with the posture OFF, which
// is the condition the identity test is about.
func registerPhase2ToolForTest(registry *tools.Registry) {
registry.Register(&specialist.OrchestrateTool{PostureActive: func() bool { return false }})
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Twigpine/zero/internal/lsp"
)
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
type customApplyPatchTool struct {
+436 -4
View File
@@ -1,10 +1,11 @@
package agent
import (
"regexp"
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Guardrail thresholds for the agent loop. These keep a runaway model from
@@ -168,7 +169,24 @@ var selfReportPhrases = []string{
var inabilityStems = []string{
"i cannot ", "i can't ", "i can not ", "i could not ", "i couldn't ",
"i am unable to", "i'm unable to", "i was unable to", "i wasn't able to",
"i was not able to", "i do not have", "i don't have", "unable to ",
"i was not able to", "i do not have", "i don't have",
"we are unable to", "we were unable to",
// THE SUBJECTLESS STEM IS KEPT, and the heading it fired on is handled where
// the heading is, not by deleting the stem.
//
// It was removed once because a completed audit's own section heading —
// "**Unable to verify (1):** - MCP #3 claim was truncated" — reads as an
// admission. But it is the ONLY stem that catches an admission with no
// first-person subject, and removing it lost every one of those:
//
// "Unable to complete the task; the build never succeeded."
// "The agent was unable to finish the migration."
// "Unable to verify the fix, so the change is unverified."
//
// None name "i" or "we", so no other stem sees them. That traded one false
// positive for three false negatives, in the direction this guard exists to
// prevent. countedLabelSentence drops the heading shape instead.
"unable to ",
"without being able to",
}
@@ -179,6 +197,19 @@ var inabilityStems = []string{
var successNegationTails = []string{
"find any", "found any", "find a ", "see any", "detect any", "identify any",
"reproduce", "spot any", "locate any",
// A NEGATIVE SEARCH RESULT IS THE ANSWER, not a failure to produce one.
//
// The list above already encodes this — "I could not find any remaining
// issues" is success — but only for the "any" phrasings. A finder reporting
// "I could NOT find where AllowManifestToolAutoApproval is set to true in
// production code" was marked INCOMPLETE after 53 tool calls and a 19,145
// character audit, for doing precisely the job it was given: establishing
// that something is not there.
"find where", "find the", "find it", "find that", "find this",
"found where", "found the",
"locate where", "locate the", "locate it",
"determine where", "identify where", "see where",
"reproduce ", "confirm any", "observe any",
}
// narrativeMarkers flag a sentence as RETELLING a past exchange rather than
@@ -247,11 +278,401 @@ func admissionSentences(lower string) []string {
// retells a past exchange (narrativeMarkers) is skipped entirely — an admission
// must be the model's own report about the CURRENT objective, not general
// language that merely resembles one.
// toolGrantMarkers flag a sentence as reporting WHICH TOOLS this run was given,
// not whether the work was done.
//
// A read-only plan task is SUPPOSED to say this. One wrote "I don't have an
// update_plan tool available in this specialist context (only read-only
// exploration tools were provided)" and then delivered the complete answer —
// helper name, file, line 214, full source — and was marked INCOMPLETE on the
// "i don't have" stem. The prompt asks tasks to name their limits plainly; a
// detector that punishes exactly that teaches the opposite.
//
// NARROW ON PURPOSE: the sentence must name a TOOL or a GRANT. "I do not have
// enough evidence" is still an admission and still fires.
// NARROWED AFTER AN AUDIT OF THIS VERY FIX. The first version listed a bare
// " tool", which exempts any inability sentence that merely mentions one —
// measured at 5/5 on ordinary phrasings:
//
// "I cannot run the build tool, so the change is unverified"
// "I could not use the migration tool and the data is untouched"
// "I was unable to invoke the formatting tool on the output"
//
// Those are genuine admissions, and silently exempting them is the WORSE
// direction: a false positive costs a re-run, a false negative reports
// unfinished work as done. The markers now have to be about what the run WAS
// GIVEN, not about a tool being mentioned at all.
var toolGrantMarkers = []string{
"tool available", "tools available", "no such tool", "not available in this",
"read-only tools", "read only tools", "only read-only", "only read only",
"tools were provided", "tools were given", "toolset provided",
"in this specialist context", "in this context only",
"is not in my toolset", "not in my toolset", "not in this toolset",
}
// objectiveFailureMarkers name the OBJECTIVE rather than a capability. A
// sentence carrying one is about whether the job got done, so the tool-grant
// exemption above does not apply to it however many tools it mentions.
// VERB-ANCHORED, not bare nouns. "this task" alone was too crude: a task that
// finished wrote "so i could not record a plan; the task is a single
// read-and-report step and is now complete" — it names the task in order to
// report SUCCESS, and a bare-noun override read that as failure. The marker has
// to be the objective NOT BEING DONE, which needs the verb.
var objectiveFailureMarkers = []string{
"complete this task", "complete the task", "completing this task", "completing the task",
"finish this task", "finish the task", "finishing this task",
"complete it", "completing it", "finish it", "finishing it",
// The objective and the assignment need the verb too, for the same reason
// "this task" did. "the objective is met" and "the assignment is complete"
// name the objective in order to report SUCCESS, and a bare noun read both
// as failure — so a finished answer carrying a tool caveat was told it had
// not finished, which is the worst thing this detector can do.
"complete the objective", "completing the objective",
"finish the objective", "finishing the objective",
"meet the objective", "meeting the objective", "achieve the objective",
"complete the assignment", "completing the assignment",
"finish the assignment", "finishing the assignment",
"as requested", "what was asked",
"do this task", "perform this task", "carry out this task",
}
// blockedWorkMarkers are what turns an absence-establishing sentence back into
// an admission of failure.
//
// MEASURED, NOT ARGUED. The successNegationTails list above exists 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 whole sentence regardless of how it 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 entire purpose defeated — it is the last thing between a
// stalled run and a report that reads like success.
//
// So the allowance now yields when the sentence ALSO says the work is blocked.
// "I could not find where X is set in production code" still passes, because
// nothing in it claims the objective was left undone; the three above do not.
// strongAbsenceTails are the allowance tails carrying an explicit "any": the
// model asserting it looked and found NOTHING. That is a finding whatever the
// rest of the sentence says, so blockedWorkMarkers does not override them.
var strongAbsenceTails = []string{
"find any", "found any", "see any", "detect any", "identify any",
"spot any", "locate any", "confirm any", "observe any",
// The OBSERVATION family. Looking for a failure and not producing one is the
// same kind of result as looking for an issue and not finding one, and
// leaving these out meant "I could not reproduce any failure in the parser"
// was not a strong absence — so an unrelated blocked statement in the next
// sentence flipped a clean negative result into an admission.
"reproduce any", "trigger any", "produce any", "hit any",
"encounter any", "provoke any", "surface any", "measure any",
}
// strongAbsenceObjects are the things whose ABSENCE IS THE RESULT: you go
// looking for them precisely so you can report there are none, and finding none
// is the work succeeding.
//
// WHAT FOLLOWS "any" DECIDES, and treating every "find any" as success was too
// broad:
//
// "I could not find any remaining issues" -> a finding, the search succeeded
// "I could not find any solution" -> an admission, the work did not
//
// Both carry the explicit "any". Only the object separates them, so only the
// object can classify them.
//
// AN ALLOW-LIST, because this grants the exemption. A deny-list of deliverables
// would have to anticipate every noun a model might reach for, and everything
// forgotten would be waved through as success — the failure direction this
// detector exists to prevent. An unrecognised object is simply not strong, which
// leaves the sentence to the ordinary blocked-work handling rather than
// flagging it outright.
var strongAbsenceObjects = []string{
"issue", "issues", "problem", "problems", "bug", "bugs", "defect", "defects",
"error", "errors", "failure", "failures", "regression", "regressions",
"evidence", "example", "examples", "occurrence", "occurrences",
"instance", "instances", "reference", "references", "match", "matches",
"caller", "callers", "usage", "usages", "use", "uses", "case", "cases",
"vulnerability", "vulnerabilities", "leak", "leaks", "race", "races",
"sign", "signs", "trace", "traces", "mention", "mentions", "difference", "differences",
"blocker", "blockers", "gap", "gaps", "omission", "omissions", "discrepancy", "discrepancies",
"conflict", "conflicts", "violation", "violations", "warning", "warnings",
"call", "calls", "site", "sites", "callsite", "callsites", "consumer", "consumers",
"dependency", "dependencies", "user", "users", "path", "paths",
}
// absenceQualifiers sit between "any" and the object without changing it.
var absenceQualifiers = []string{
"remaining", "other", "further", "more", "additional", "obvious", "such",
"outstanding", "new", "existing", "actual", "real", "clear", "direct",
}
// strongAbsence reports whether tail is an "any"-family absence whose OBJECT
// makes finding nothing the result rather than the shortfall.
func strongAbsence(tail string) bool {
for _, prefix := range strongAbsenceTails {
if !strings.HasPrefix(tail, prefix) {
continue
}
rest := strings.TrimSpace(tail[len(prefix):])
for trimmed := true; trimmed; {
trimmed = false
for _, qualifier := range absenceQualifiers {
if word, remainder, ok := cutFirstWord(rest); ok && word == qualifier {
rest, trimmed = remainder, true
break
}
}
}
word, _, ok := cutFirstWord(rest)
if !ok {
continue
}
for _, object := range strongAbsenceObjects {
if word == object {
return true
}
}
}
return false
}
// cutFirstWord returns the first bare word of text, lowercased by the caller's
// own normalisation, with surrounding punctuation removed.
func cutFirstWord(text string) (word string, rest string, ok bool) {
text = strings.TrimLeft(text, " \t")
end := 0
for end < len(text) {
c := text[end]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' {
end++
continue
}
break
}
if end == 0 {
return "", text, false
}
return text[:end], text[end:], true
}
var blockedWorkMarkers = []string{
"unverified", "not verified", "cannot verify", "could not verify",
"someone else", "will need to", "needs someone", "handed off", "hand off",
// "someone else" and "will need to" are the two that also appear in
// SUCCESSFUL reports, describing somebody else's future work — "I could not
// find any remaining issues, though a follow-up will need to cover the
// Windows path" is a finding. They are kept because they do carry a genuine
// handoff-because-blocked ("someone else will need to pick this up"), and the
// strongAbsenceTails exemption above is what stops them flipping a finding.
"ran out of", "run out of", "out of time", "in the time available",
"stopped there", "stopping here", "gave up", "giving up",
"nothing was modified", "no changes were made", "left unchanged", "left undone",
"may be inert", "is unresolved", "remains unresolved", "still broken",
"so i cannot", "so i could not",
// Saying the work is BLOCKED, in as many words. "I could not find the root
// cause, so the work is blocked" carries the statement in the same sentence
// and still passed, because every marker above names a symptom of being
// blocked and none named the thing itself.
"is blocked", "are blocked", "remains blocked", "stays blocked", "still blocked",
"cannot proceed", "could not proceed", "can not proceed", "unable to proceed",
"is unfinished", "remains unfinished", "left unfinished", "still unfinished",
"is incomplete", "remains incomplete",
"still unresolved", "still unverified",
}
// bareInabilityStems are the two entries above that are STEMS rather than
// descriptions of a blocked state. The stem loop below already scans them with
// its own tail logic, and a sentence can carry one on its way to reporting
// success: "so i could not record a plan; the task is a single read-and-report
// step and is now complete" is a finished task that did not need the plan.
var bareInabilityStems = []string{"so i cannot", "so i could not"}
// unambiguousFailureStates say the work is in a bad state, with no reading on
// which it is a result.
//
// AN OBJECT CANNOT OUTRANK AN EXPLICIT STATE. strongAbsence returns true for
// "evidence", and that suppressed every blocked-work marker in the sentence — so
// "I could not find any evidence supporting the fix, so it remains unverified"
// reported success while saying in as many words that the work is unverified.
// The absence protection exists for ownership and follow-up wording, where "I
// could not find any remaining issues, though a follow-up will need to cover the
// Windows path" really is a finding. It was never meant to cover a sentence that
// states the outcome.
//
// So this is the SHORT list: "unverified" and "still broken" have one reading,
// while "someone else", "will need to" and "nothing was modified" have two and
// stay ambiguous. Same-sentence only — a state in the NEXT sentence may belong
// to another subject, which is what the lookahead's topic-shift guard is for.
var unambiguousFailureStates = []string{
"unverified", "not verified",
"is unresolved", "remains unresolved",
"still broken",
"is blocked", "are blocked", "remains blocked", "stays blocked", "still blocked",
"is unfinished", "remains unfinished", "left unfinished", "still unfinished",
"still unresolved", "still unverified",
"is incomplete", "remains incomplete",
"cannot proceed", "could not proceed", "unable to proceed",
"gave up", "giving up", "ran out of", "run out of",
"left undone",
}
// consequenceBoundaries separate what was looked for from what followed.
//
// THE STATE HAS TO BE THE OUTCOME, NOT PART OF WHAT WAS NEGATED. Matching
// "is unresolved" anywhere in the sentence read the negated PROPOSITION as the
// reported result:
//
// "I could not find any evidence that the issue is unresolved."
//
// That is a successful negative finding — there is no evidence the issue remains
// unresolved — and it was marked incomplete, which is the opposite polarity of
// the case the override was added for. What separates them is position: after a
// consequence boundary the state is being asserted, inside a "that…" clause it is
// the thing being denied.
var consequenceBoundaries = []string{", so ", "; ", ", but ", ", therefore", ", leaving ", ", which "}
// reportedConsequence returns the part of a sentence that states the OUTCOME,
// or "" when the sentence never turns to one. Everything before the first
// boundary is what was searched for.
func reportedConsequence(sentence string) string {
earliest := -1
width := 0
for _, boundary := range consequenceBoundaries {
if index := strings.Index(sentence, boundary); index >= 0 && (earliest < 0 || index < earliest) {
earliest, width = index, len(boundary)
}
}
if earliest < 0 {
return ""
}
return sentence[earliest+width:]
}
// blockedStateMarkers describe WORK LEFT BLOCKED — unverified, unresolved,
// handed off, abandoned for time. Only these break the tool-grant exemption:
// they say something about the state the work is in, which a tool caveat cannot
// excuse, whereas a bare stem says only that one step did not happen.
//
// DERIVED, not copied. Two hand-maintained lists that must stay in step drift,
// and the drift here would be silent in both directions.
var blockedStateMarkers = func() []string {
bare := make(map[string]bool, len(bareInabilityStems))
for _, stem := range bareInabilityStems {
bare[stem] = true
}
out := make([]string, 0, len(blockedWorkMarkers))
for _, marker := range blockedWorkMarkers {
if !bare[marker] {
out = append(out, marker)
}
}
return out
}()
// carriesTheConsequence reports whether the sentence after an allowance should
// be read as that allowance's consequence.
//
// The lookahead exists because the consequence usually IS the next sentence, but
// "usually" is not "always" — a message can turn to something else, and reading
// a blocked statement about a different subject as this one's consequence is the
// cost of the lookahead. A sentence that announces the change of subject, or
// disclaims the thing as out of scope, is taken at its word.
//
// This does not catch every unrelated follow-on, and deliberately errs toward
// reading the next sentence: an admission reported as success is the failure this
// guard exists to prevent, and a message that says something is unverified has
// said it whether or not it is the same something.
func carriesTheConsequence(next string) bool {
return !containsAny(next, topicShiftMarkers)
}
// topicShiftMarkers say the message has moved on to something else.
var topicShiftMarkers = []string{
"separately", "unrelatedly", "unrelated", "as an aside", "aside from",
"out of scope", "outside the scope", "not in scope", "for a different",
"in a different", "on another", "elsewhere in", "in other news",
}
// countedLabelSentence reports whether a sentence is a markdown LABEL that
// introduces and counts a bucket of findings, rather than a claim about the
// objective.
//
// "**Unable to verify (1):** - MCP #3 claim was truncated" is a section heading
// in a COMPLETED audit. Read as a sentence it is indistinguishable from an
// admission, which is why the subjectless stem was once deleted to silence it.
//
// NARROW ON PURPOSE: the sentence must BEGIN with the inability phrase, after
// markdown emphasis, AND carry a parenthesised count. "Unable to complete the
// task; the build never succeeded" begins the same way and has no count, so it
// still fires — which is the whole reason this is preferable to dropping the
// stem.
func countedLabelSentence(sentence string) bool {
trimmed := strings.TrimLeft(strings.TrimSpace(sentence), "-*#> \t")
if !strings.HasPrefix(trimmed, "unable to ") {
return false
}
return countedLabelSuffix.MatchString(sentence)
}
var countedLabelSuffix = regexp.MustCompile(`\(\s*\d+\s*\)`)
func selfReportedIncompletion(text string) string {
for _, sentence := range admissionSentences(strings.ToLower(stripQuoted(text))) {
sentences := admissionSentences(strings.ToLower(stripQuoted(text)))
for index, sentence := range sentences {
// THE CONSEQUENCE IS OFTEN THE NEXT SENTENCE. The blocked-work override
// only ever saw the sentence the allowance fired in, so the same
// admission escaped or was caught purely on its punctuation:
//
// "I could not reproduce the crash, so the fix is unverified." caught
// "I could not reproduce the crash. The fix is unverified." missed
//
// A full stop is not a claim that the work finished, and writing the
// consequence as its own sentence is how most people write. The
// blocked-work question is asked of this sentence AND the one after it;
// everything else is still decided on the sentence alone, so a stem in
// one sentence cannot be paired with an allowance tail in another.
blockedContext := sentence
if index+1 < len(sentences) && carriesTheConsequence(sentences[index+1]) {
blockedContext += " " + sentences[index+1]
}
if containsAny(sentence, narrativeMarkers) {
continue
}
// A counted label is a heading, not a claim about the objective.
if countedLabelSentence(sentence) {
continue
}
// A sentence about the tool grant is about CAPABILITY, not about the
// objective — UNLESS it also says the task itself could not be done.
//
// THE OVERRIDE IS NOT OPTIONAL. Without it the exemption swallowed a
// genuine failure: "I am unable to complete this task with the current
// tool set … Only write_file is enabled … so I cannot inspect the
// codebase." That task really did fail, and it mentions tools, so a bare
// tool-marker check waved it through. Naming the task is what separates
// "I lack a tool I did not need" from "I lack the tools this needed".
// BLOCKED WORK BREAKS THE EXEMPTION TOO, not just a named objective. The
// override above asks whether the sentence names the task, which a whole
// class of real admissions never does:
//
// "No write tool is available in this context, so the fix is unverified."
// "There is no edit tool available here, so the change remains unapplied."
// "Write tools are not available in this setup, so the tests were never run."
//
// Every one mentions tools, none names the objective, and all four were
// waved through. The tool caveat is the REASON the work is blocked, not a
// reason to stop reading — so a sentence that also says something is
// unverified, unapplied or untested goes on to the blocked-work handling
// below instead of being exempted here.
if containsAny(sentence, toolGrantMarkers) &&
!containsAny(sentence, objectiveFailureMarkers) &&
!containsAny(sentence, blockedStateMarkers) {
continue
}
for _, phrase := range selfReportPhrases {
if strings.Contains(sentence, phrase) {
return selfReportReason(phrase)
@@ -268,7 +689,18 @@ func selfReportedIncompletion(text string) string {
}
abs := start + rel
tail := strings.TrimSpace(sentence[abs+len(stem):])
if !hasAnyPrefix(tail, successNegationTails) {
// The allowance yields to a sentence that also says the work is
// blocked: "could not reproduce the crash" is a finding, "could not
// reproduce the crash, so the fix is unverified" is an admission,
// and the tail prefix alone cannot tell them apart.
strong := strongAbsence(tail)
// The object decides whether finding nothing is a result; an
// explicit failure state in the SAME sentence decides that it is
// not, whatever the object.
if strong && containsAny(reportedConsequence(sentence), unambiguousFailureStates) {
strong = false
}
if !hasAnyPrefix(tail, successNegationTails) || (!strong && containsAny(blockedContext, blockedWorkMarkers)) {
return selfReportReason(strings.TrimSpace(stem) + " …")
}
start = abs + len(stem)
@@ -0,0 +1,458 @@
package agent
import "testing"
// FOUR COMPLETED TASKS WERE MARKED INCOMPLETE, and the sentences below are
// verbatim from those sessions.
//
// The detector reads message text — the one place in this feature that does —
// and it is supposed to catch a model admitting it did not do the job. Instead
// it caught four tasks that HAD done the job and were honestly naming a limit,
// which is exactly what the plan-task prompt asks them to do. Two of them had
// made 53 and 60 tool calls; one had written files.
//
// The cost of a false positive here is not cosmetic: the task is reported
// failed, retried on another model, and rendered red in the plan panel.
func TestAnHonestCaveatInsideDeliveredWorkIsNotAnAdmission(t *testing.T) {
for _, tc := range []struct {
name string
text string
}{
{
// 53 tool calls, a 19,145-character audit. Establishing that
// something is NOT there is the finding, not a failure to find one.
name: "a negative search result is the answer",
text: "I traced every code path where repo-committed files influence tool approval. " +
"I could NOT find where `AllowManifestToolAutoApproval` is set to true in production code paths.",
},
{
// A completed verification task categorising its own findings. The
// bare "unable to" stem had no first-person subject and fired on a
// markdown heading.
name: "a report section heading",
text: "## Verification Table\n\n**Verified (4):** all confirmed against source.\n" +
"**Unable to verify (1):** - MCP #3 claim was truncated in the input.",
},
{
// Delivered helper name, file, line 214 and full source. The task was
// read-only by design and said so.
name: "a statement about the tool grant",
text: "I don't have an `update_plan` tool available in this specialist context " +
"(only read-only exploration tools were provided). " +
"The task is a single read-only assessment and I've already gathered all the needed evidence.",
},
{
name: "read-only tools named plainly",
text: "I did not run the tests because my tools are read-only, " +
"so I can report only what the code and tests say statically.",
},
} {
t.Run(tc.name, func(t *testing.T) {
if reason := selfReportedIncompletion(tc.text); reason != "" {
t.Fatalf("a completed task was marked incomplete: %s", reason)
}
})
}
}
// AND THE DETECTOR MUST STILL BITE. Widening it to stop punishing honesty must
// not turn it off — these are the admissions it exists for, several of them
// verbatim from the same set of sessions.
func TestGenuineAdmissionsAreStillCaught(t *testing.T) {
for _, tc := range []struct {
name string
text string
}{
{
name: "the measured workspace failure",
text: "I cannot complete this task. The target directory `/Users/kratos/zm-lab/pkg/execprofile` " +
"is outside the workspace boundary.",
},
{
name: "an empty worktree",
text: "This task cannot be completed in the current workspace because the relevant source tree is absent. " +
"I cannot find BuildFinalResult anywhere.",
},
{
name: "guessing",
text: "I guessed at the retry semantics because the code was not reachable.",
},
{
name: "fabrication",
text: "I fabricated the line numbers to fill the table.",
},
{
name: "stated doubt about the result",
text: "The patch may not be correct — I did not run it.",
},
{
name: "not enough evidence is still an admission",
text: "I do not have enough evidence to answer the question.",
},
{
name: "first-person unable, with a subject",
text: "I was unable to determine which branch actually runs.",
},
} {
t.Run(tc.name, func(t *testing.T) {
if reason := selfReportedIncompletion(tc.text); reason == "" {
t.Fatalf("a real admission was missed: %q", tc.text)
}
})
}
}
// The tool-grant exemption is NARROW. A sentence has to be about tools or the
// grant; "not enough evidence" is not, and must keep firing.
func TestTheToolGrantExemptionDoesNotSwallowRealAdmissions(t *testing.T) {
if reason := selfReportedIncompletion("I could not verify the claim and I do not have enough evidence."); reason == "" {
t.Fatal("the tool-grant exemption swallowed an evidence admission")
}
if reason := selfReportedIncompletion("I don't have the file contents, so the answer is a guess."); reason == "" {
t.Fatal("an admission about missing content was exempted as a tool statement")
}
}
// THE EXEMPTION MUST NOT SWALLOW A TASK THAT REALLY COULD NOT BE DONE.
//
// Verbatim from a session the first version of this fix regressed: the task
// mentions tools, so a bare tool-marker check waved it through — but it names
// the TASK as the thing that failed, which is the whole distinction.
func TestATaskThatCouldNotBeDoneForLackOfToolsStillFails(t *testing.T) {
for _, tc := range []struct {
name string
text string
}{
{
name: "the measured regression",
text: "I am unable to complete this task with the current tool set. " +
"Only `write_file` is enabled, so I cannot inspect the codebase to collect scan findings.",
},
{
name: "named differently",
text: "I could not finish the objective because no read tools were provided.",
},
{
name: "cannot complete it",
text: "I don't have the tools to complete it as requested.",
},
} {
t.Run(tc.name, func(t *testing.T) {
if reason := selfReportedIncompletion(tc.text); reason == "" {
t.Fatalf("a task that genuinely could not be done was passed as complete: %q", tc.text)
}
})
}
// And the exemption must still work for the case it exists for: a tool that
// was not needed, alongside delivered work.
exempt := "I don't have an `update_plan` tool available in this specialist context " +
"(only read-only exploration tools were provided)."
if reason := selfReportedIncompletion(exempt); reason != "" {
t.Fatalf("the override broke the exemption it guards: %s", reason)
}
}
// NAMING THE TASK TO REPORT SUCCESS IS NOT NAMING IT TO REPORT FAILURE.
//
// Verbatim from the session that the first override regressed. A task that had
// finished wrote a footnote about a tool it did not need, in the same sentence
// as its own completion statement — and a bare-noun override read "the task" as
// an admission. The marker has to be the objective NOT being done.
func TestNamingTheTaskWhileReportingSuccessIsNotAnAdmission(t *testing.T) {
done := "- **note:** the update_plan tool is not available in my current toolset " +
"(only read-only file tools), so i could not record a plan; " +
"the task is a single read-and-report step and is now complete"
if reason := selfReportedIncompletion(done); reason != "" {
t.Fatalf("a finished task was marked incomplete for saying so: %s", reason)
}
// The failure form, which must still fire, differs only in the verb.
failed := "the update_plan tool is not available, so i could not complete this task"
if reason := selfReportedIncompletion(failed); reason == "" {
t.Fatal("a task that could not be completed was passed as complete")
}
}
// AN INABILITY THAT MERELY MENTIONS A TOOL IS STILL AN ADMISSION.
//
// THE AUDIT FINDING THIS PINS. The first version of the exemption listed a bare
// " tool", so any inability sentence mentioning one escaped — measured at 5/5 on
// ordinary phrasings. That is the worse direction: a false positive costs a
// re-run, a false negative reports unfinished work as done.
func TestMentioningAToolDoesNotExemptAnAdmission(t *testing.T) {
for _, text := range []string{
"I cannot run the build tool, so the change is unverified",
"I could not use the migration tool and the data is untouched",
"I was unable to invoke the formatting tool on the output",
"I don't have a working compiler tool here",
"I cannot finish because the deploy tools are broken",
} {
if reason := selfReportedIncompletion(text); reason == "" {
t.Errorf("a genuine admission was silently exempted: %q", text)
}
}
}
// And the exemption still covers what it was built for: a run stating which
// tools it was GIVEN, alongside delivered work.
func TestTheGrantExemptionStillCoversWhatItWasBuiltFor(t *testing.T) {
for _, text := range []string{
"I don't have an `update_plan` tool available in this specialist context (only read-only exploration tools were provided).",
"I did not run the tests because only read-only tools were provided, so I report what the code says statically.",
"update_plan is not in my toolset, so I could not record a plan.",
} {
if reason := selfReportedIncompletion(text); reason != "" {
t.Errorf("a run naming its own grant was marked incomplete: %q -> %s", text, reason)
}
}
}
// NAMING THE OBJECTIVE TO REPORT SUCCESS, same trap as naming the task.
//
// "the objective" and "the assignment" were bare nouns in objectiveFailureMarkers
// while "this task" had already been verb-anchored for exactly this reason. A
// message that mentions a tool it lacked and then says the objective IS met was
// read as saying the objective was not met — a finished answer told it had not
// finished, which is the worst outcome this detector has.
func TestNamingTheObjectiveWhileReportingSuccessIsNotAnAdmission(t *testing.T) {
for _, done := range []string{
"I do not have write tools available, but the objective is met: the config already sets the flag.",
"No write tool is available to me, so the objective was achieved by reading alone.",
"The assignment is complete; no shell was needed.",
"I have no browser tool available here, yet the assignment is complete.",
} {
if reason := selfReportedIncompletion(done); reason != "" {
t.Errorf("a finished answer was marked incomplete for naming its objective: %q -> %s", done, reason)
}
}
// The failure forms differ only in the verb, and must still fire.
for _, failed := range []string{
"I could not complete the objective because the build never succeeded.",
"I was unable to finish the assignment.",
} {
if reason := selfReportedIncompletion(failed); reason == "" {
t.Errorf("an objective that was not met was passed as complete: %q", failed)
}
}
}
// A TOOL CAVEAT IS THE REASON WORK IS BLOCKED, NOT A REASON TO STOP READING.
//
// The tool-grant exemption asked only whether the sentence named the objective,
// so an admission that named none was waved through on the strength of
// mentioning tools:
//
// "No write tools available, so I could not verify the change."
//
// It breaks now on a blocked STATE — unverified, unresolved, handed off — but
// deliberately not on the two bare inability stems in that list, because a
// sentence carrying one can still be on its way to reporting success (see
// TestNamingTheTaskWhileReportingSuccessIsNotAnAdmission, which this broke when
// the whole list was used).
func TestAToolCaveatDoesNotExcuseBlockedWork(t *testing.T) {
for _, admission := range []string{
"No write tools available, so I could not verify the change.",
"There is no edit tool available here, so I could not verify the fix.",
} {
if reason := selfReportedIncompletion(admission); reason == "" {
t.Errorf("a tool caveat excused blocked work: %q", admission)
}
}
// The exemption still covers what it exists for: a tool that was not needed.
for _, exempt := range []string{
"No update_plan tool available in this specialist context, so I proceeded directly.",
"There is no update_plan tool available in this specialist context (only read-only tools), so I have written the plan into this answer instead.",
} {
if reason := selfReportedIncompletion(exempt); reason != "" {
t.Errorf("the blocked-state check broke the exemption it guards: %q -> %s", exempt, reason)
}
}
}
// WHAT FOLLOWS "any" DECIDES WHETHER FINDING NOTHING IS THE RESULT.
//
// The "any"-family was treated as a finding whatever the object, so an admission
// wearing the same words walked through:
//
// "I could not find any remaining issues" -> a finding, the search succeeded
// "I could not find any solution" -> an admission, the work did not
//
// Both carry the explicit "any"; only the object separates them. The object list
// is an ALLOW-LIST because it grants the exemption — a deny-list of deliverables
// would wave through every noun nobody thought of, which is the wrong direction
// for this detector to fail in.
func TestAnAbsenceIsAFindingOnlyWhenTheObjectMakesItOne(t *testing.T) {
for _, admission := range []string{
"I could not find any solution, so the migration remains unresolved.",
"I could not find any fix, so the build is still broken.",
"I could not find any workaround; someone else will need to pick this up.",
"I could not identify any approach, so this is unverified.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("an admission wearing the words of a finding passed as complete: %q", admission)
}
}
// The findings the allowance exists for are untouched, including the ones
// carrying a blocked-work marker about somebody else's future work.
for _, finding := range []string{
"I could not find any remaining issues, though a follow-up will need to cover the Windows path.",
"I could not find any blockers; someone else can take the release from here.",
"I could not find any evidence of a leak, so nothing was modified.",
"I did not see any further regressions, so someone else can ship it.",
"I could not detect any races, and nothing was modified.",
} {
if reason := selfReportedIncompletion(finding); reason != "" {
t.Errorf("a finding was reported as an admission: %q -> %s", finding, reason)
}
}
}
// A FULL STOP IS NOT A CLAIM THAT THE WORK FINISHED.
//
// The blocked-work override only ever saw the sentence the allowance fired in,
// so the SAME admission was caught or missed on its punctuation alone:
//
// "I could not reproduce the crash, so the fix is unverified." caught
// "I could not reproduce the crash. The fix is unverified." missed
//
// Writing the consequence as its own sentence is how most people write, and half
// of a reviewer's corpus of ordinary admissions escaped through it.
//
// Only the blocked-work question looks ahead. A stem in one sentence is still
// never paired with an allowance tail in another.
func TestTheConsequenceMayBeTheNextSentence(t *testing.T) {
for _, admission := range []string{
"I could not reproduce the crash, so the fix is unverified.",
"I could not reproduce the crash. The fix is unverified.",
"I could not locate the source of the regression and have run out of ideas.",
"I could not locate the source of the regression. I have run out of ideas.",
// "the work is blocked" says it in as many words, and every marker was a
// SYMPTOM of being blocked rather than the thing itself.
"I could not find the root cause, so the work is blocked.",
"I could not find the root cause. The work is blocked.",
"I could not get the integration test to run. The behaviour is therefore unverified.",
"I could not apply the patch cleanly. Nothing was modified.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("an admission escaped by putting its consequence in a second sentence: %q", admission)
}
}
// THE LOOKAHEAD'S OWN COST, guarded. A message that turns to other work must
// not have that work's blocked state read as this result's consequence.
for _, finding := range []string{
"I searched the tree and could not find any other call sites. The rename is complete.",
"I could not find any remaining usages of the deprecated helper. A future cleanup is blocked until the API freeze lifts.",
"I could not reproduce any failure in the parser. The CI flake in the network suite remains unresolved and belongs to another team.",
"I could not find any issues in the diff. Documentation for the new flag is unfinished, which was never part of this request.",
"I could not reproduce the crash. Separately, the deploy step is unverified because it needs prod access.",
"I could not find any other call sites. The follow-up work is blocked on a design decision, which is out of scope here.",
} {
if reason := selfReportedIncompletion(finding); reason != "" {
t.Errorf("the lookahead read another subject's blocked state as this result's: %q -> %s", finding, reason)
}
}
}
// AN UNAMBIGUOUS STATE THAT IS NOT ALSO A MARKER IS DEAD.
//
// unambiguousFailureStates only decides whether a recognised absence object
// stops protecting the sentence; something still has to FIRE, and that is
// blockedWorkMarkers. Adding "still blocked" to the first list and not the
// second left it doing nothing at all, and the case looked handled because the
// phrase appeared in the code.
//
// Two hand-maintained lists that must agree is the shape that drifts, so the
// agreement is asserted rather than remembered.
func TestEveryUnambiguousStateIsAlsoABlockedWorkMarker(t *testing.T) {
for _, state := range unambiguousFailureStates {
found := false
for _, marker := range blockedWorkMarkers {
if state == marker {
found = true
break
}
}
if !found {
t.Errorf("%q outranks a strong absence but is not a blockedWorkMarker, so nothing fires on it", state)
}
}
}
// EXPLICIT FAILURE STATES OUTRANK A RECOGNISED ABSENCE OBJECT.
//
// strongAbsence returns true for objects like "evidence", and that suppressed
// every blocked-work marker in the sentence — so a message saying in as many
// words that the work is unverified reported success. The absence protection is
// for ownership and follow-up wording; it was never meant to cover a sentence
// that states the outcome.
func TestAnExplicitFailureStateOutranksTheAbsenceObject(t *testing.T) {
for _, admission := range []string{
"I could not find any evidence supporting the fix, so it remains unverified.",
"I could not find any evidence for the cause, so the bug is unresolved.",
"I could not find any way to make it work, so I gave up.",
"I could not find any working approach; it is still broken.",
"I could not find any issues, but the migration is still blocked.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("an explicit failure state was overruled by the absence object: %q", admission)
}
}
// AMBIGUOUS wording still yields to the absence, which is the whole reason
// the protection exists: ownership and follow-up read two ways, an explicit
// state reads one.
for _, finding := range []string{
"I could not find any evidence that the flag is read in production.",
"I could not find any remaining issues, though a follow-up will need to cover the Windows path.",
"I could not find any blockers; someone else can take the release from here.",
"I could not find any evidence of a leak, so nothing was modified.",
"I could not detect any races, and nothing was modified.",
// A state in the NEXT sentence may belong to another subject, so
// same-sentence only.
"I could not reproduce any failure in the parser. The CI flake in the network suite remains unresolved and belongs to another team.",
"I could not find any issues in the diff. Documentation for the new flag is unfinished, which was never part of this request.",
} {
if reason := selfReportedIncompletion(finding); reason != "" {
t.Errorf("a finding was flagged by ambiguous or another subject's wording: %q -> %s", finding, reason)
}
}
}
// THE STATE MUST BE THE OUTCOME, NOT PART OF WHAT WAS NEGATED.
//
// The override matched a failure state anywhere in the sentence, which read the
// negated PROPOSITION as the reported result:
//
// "I could not find any evidence that the issue is unresolved."
//
// That is a successful negative finding — there is no evidence the issue remains
// unresolved — and it was marked incomplete. Exactly the opposite polarity of the
// case the override was added for, and the two have to be tested together or
// fixing one just moves the error.
func TestAStateInsideTheNegatedPropositionIsNotTheOutcome(t *testing.T) {
for _, finding := range []string{
"I could not find any evidence that the issue is unresolved.",
"I could not find any evidence that the build is still broken.",
"I could not find any evidence that the migration is incomplete.",
"I could not find any sign that the deploy is blocked.",
} {
if reason := selfReportedIncompletion(finding); reason != "" {
t.Errorf("a negated proposition was read as the reported outcome: %q -> %s", finding, reason)
}
}
// Both polarities together: after a consequence boundary the state IS the
// outcome and must still fire.
for _, admission := range []string{
"I could not find any evidence supporting the fix, so it remains unverified.",
"I could not find any evidence for the cause, so the bug is unresolved.",
"I could not find any way to make it work, so I gave up.",
"I could not find any working approach; it is still broken.",
"I could not find any issues, but the migration is still blocked.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("an outcome stated after a consequence boundary was missed: %q", admission)
}
}
}
+154 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// emptyTurn is a stream that produces no visible text and no tool calls.
@@ -80,6 +80,9 @@ func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
if result.FinalAnswer == maxTurnsAnswer {
t.Fatalf("no-output guard must stop before reaching maxTurns, got max-turns answer")
}
if result.Incomplete {
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
}
}
func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
@@ -513,6 +516,22 @@ func repeatedFlakyTurns(n int) [][]zeroruntime.StreamEvent {
return turns
}
// alwaysSucceedingTool is the successful counterpart to alwaysFailingTool, used
// to put a productive tool turn before an empty-turn streak.
type alwaysSucceedingTool struct{}
func (alwaysSucceedingTool) Name() string { return "ok" }
func (alwaysSucceedingTool) Description() string { return "always succeeds for testing" }
func (alwaysSucceedingTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (alwaysSucceedingTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (alwaysSucceedingTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
@@ -529,6 +548,51 @@ func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
if len(provider.requests) != toolFailureStopAt {
t.Fatalf("expected stop at %d failures, made %d requests", toolFailureStopAt, len(provider.requests))
}
if result.Incomplete {
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
}
}
// Under the headless completion gate both guardrail stops are unfinished runs,
// so they must set Result.Incomplete and a reason; exec maps that to exit 4.
// Thresholds and stop answers stay pinned by the two tests above.
func TestHeadlessGuardStopsAreIncomplete(t *testing.T) {
for _, tc := range []struct {
name string
turns [][]zeroruntime.StreamEvent
wantTurns int
wantReason string
}{
{"empty_turns", [][]zeroruntime.StreamEvent{emptyTurn(), emptyTurn(), emptyTurn()}, maxEmptyTurns, "3 consecutive turns with no output"},
{"empty_turns_after_progress", [][]zeroruntime.StreamEvent{
toolTurn("ok-1", "ok", "{}"),
emptyTurn(), emptyTurn(), emptyTurn(),
}, maxEmptyTurns + 1, "3 consecutive turns with no output"},
{"repeated_tool_failures", repeatedFlakyTurns(10), toolFailureStopAt, "flaky"},
} {
t.Run(tc.name, func(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
registry.Register(alwaysSucceedingTool{})
result, err := Run(context.Background(), "go", &mockProvider{turns: tc.turns}, Options{
Registry: registry,
MaxTurns: 12,
RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if result.Turns != tc.wantTurns {
t.Fatalf("guard did not trigger: turns=%d want=%d", result.Turns, tc.wantTurns)
}
if !result.Incomplete {
t.Fatalf("guardrail stop under the headless gate must be Incomplete; final=%q", result.FinalAnswer)
}
if !strings.Contains(result.IncompleteReason, tc.wantReason) {
t.Fatalf("IncompleteReason = %q, want it to mention %q", result.IncompleteReason, tc.wantReason)
}
})
}
}
func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
@@ -554,3 +618,91 @@ func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
t.Fatalf("expected a tool-failure hint on the 3rd turn, messages: %+v", provider.requests[2].Messages)
}
}
// An absence-establishing sentence is a finding; the same sentence that also
// says the work is blocked is an admission. The allowance stems ("find the",
// "reproduce ", "confirm any" …) head both, so the tail prefix alone cannot
// separate them — measured against real phrasings, TEN of eleven genuine
// admissions passed the detector before blockedWorkMarkers existed.
func TestIncompletionAllowanceYieldsToBlockedWork(t *testing.T) {
// Must FIRE: the inability is reported as leaving work undone.
for _, admission := range []string{
"I could not reproduce the crash, so the fix is unverified.",
"I was unable to reproduce the reported behaviour and stopped there.",
"I could not find the root cause; someone else will need to pick this up.",
"I could not find the bug in the time available.",
"I did not manage to reproduce the failure, so I cannot confirm the patch works.",
"I could not locate the source of the regression and have run out of ideas.",
"I could not find where to apply the change, so nothing was modified.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("admission passed the detector: %q", admission)
}
}
// Must NOT fire: establishing that something is absent is the job, and the
// motivating case is the audit that spent 53 tool calls proving a negative.
for _, finding := range []string{
"I could not find any remaining issues.",
"I could NOT find where AllowManifestToolAutoApproval is set to true in production code.",
"I could not find the flag being set anywhere outside tests, so the concern does not apply.",
"I could not reproduce the reported exploit, which confirms the guard holds.",
"I could not observe any regression across the suite.",
"I could not confirm any leak; every path is bounded.",
// Next steps and ownership belong in successful reports too. Both of
// these were flagged when blockedWorkMarkers could override an explicit
// "any", which is the model asserting exhaustive absence.
"I could not find any remaining issues, though a follow-up will need to cover the Windows path.",
"I could not find any blockers; someone else can take the release from here.",
} {
if reason := selfReportedIncompletion(finding); reason != "" {
t.Errorf("finding wrongly flagged as incomplete: %q -> %q", finding, reason)
}
}
// NOT CAUGHT, and recorded rather than hidden. An explicit "any" is treated
// as exhaustive absence, so an admission phrased that way passes:
//
// "I could not observe any effect, so the change may be inert."
//
// Measured across eleven admissions, four still pass, all of them either
// "any"-phrased or single-clause with no blocked-work signal ("I failed to
// reproduce it locally"). Catching those needs a different signal than
// substring matching; tightening this list to reach them re-broke the
// findings above every way I tried.
}
// An admission with NO first-person subject must still fire. The subjectless
// "unable to " stem was once deleted to silence a completed audit's section
// heading, which also lost every impersonal admission — none of these names "i"
// or "we", so no other stem sees them.
func TestImpersonalAdmissionsAreStillCaught(t *testing.T) {
for _, admission := range []string{
"Unable to complete the task; the build never succeeded.",
"The agent was unable to finish the migration.",
"Unable to verify the fix, so the change is unverified.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("an impersonal admission passed the detector: %q", admission)
}
}
}
// The heading that motivated deleting the stem must stay exempt. It is a label
// counting a bucket of findings in a COMPLETED audit, not a claim about the
// objective — which is why it is recognised by shape rather than by removing a
// stem that catches real admissions.
func TestACountedHeadingIsNotAnAdmission(t *testing.T) {
for _, heading := range []string{
"**Unable to verify (1):** - MCP #3 claim was truncated",
"Unable to reproduce (3):",
} {
if reason := selfReportedIncompletion(heading); reason != "" {
t.Errorf("a counted heading was read as an admission: %q -> %q", heading, reason)
}
}
// But the same opening WITHOUT a count is a real admission.
if selfReportedIncompletion("Unable to verify the deployment; it never started.") == "" {
t.Error("a subjectless admission with no count was exempted as a heading")
}
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
)
func TestAppendHookFeedbackFormatsOutput(t *testing.T) {
Loaded 100 of 1167 files, more files were not shown because too many files have changed in this diff. Show more