Token exchange/refresh and the device authorization/poll POSTs validated only
the initial endpoint, then let the http.Client follow a 307/308 that replays the
form body (code, PKCE verifier, refresh_token, client_secret) to an unvalidated
origin.
Add withoutRedirects(): a shallow client copy whose CheckRedirect refuses every
redirect (ErrUnsafeRedirect). Applied at PostToken (covers ExchangeCode, Refresh,
and MCP which delegate to it), RequestDeviceCode, and pollDeviceOnce. The caller's
client is never mutated; legitimate token endpoints that return 200 are unaffected.
Discovery metadata was merged without the https/loopback endpoint rule that
configured endpoints already pass, letting a malicious issuer downgrade the
authorization/token/device/registration endpoint after config validation.
- Add exported ValidateEndpointURL as the single endpoint-safety rule;
validateTokenEndpoint now delegates to it.
- Backstop in BuildAuthorizationURL (the choke point both the provider and MCP
flows build their browser URL through).
- Validate each discovered endpoint before merge in the provider resolveEndpoints
and validate resolved MCP metadata in both branches; fail closed on insecure.
Co-authored-by: KRATOS <[email protected]>
* perf(agent): execution profiles, --exec-profile, bench passthrough, TUI /profile (PR10b+10c)
* fix(execprofile): harden profiles against review findings
- fast escalates on critical-risk mutations, not high: the sandbox marks
every shell command high before command analysis, so a high trigger
spent the one-shot on the first go test of any task
- escalation can now restore the provider-default effort a profile
displaced (PostureEscalation.RestoreDefaultEffort; a plain empty
target means leave untouched)
- TUI /turns while a profile is active pins the budget: disarms the
escalation turn target (mirroring exec's explicit --max-turns rule)
and profile reverts use touched bits instead of value equality, so
explicit choices survive even when they coincide with profile values
- spec-draft runs get the same escalation policy as the main run
instead of the displaced budget with no safety net
- bench validates and canonicalizes --exec-profile before spawning:
an unknown name previously produced an exit-0 report full of
millisecond usage-error walls
- turn summary prints exec-profile alongside mode; zero exec rejects
an inline-empty --exec-profile=; balanced trace label documented and
pinned as deliberate attribution
* fix(execprofile): explicit effort choices disarm the escalation effort restore
An explicit effort set AFTER profile selection could be cleared by a
mid-run escalation (the effort analog of the /turns pin, introduced by
RestoreDefaultEffort): TUI /effort and Ctrl+T now disarm the restore on
a policy clone, and the spec-draft path only arms it when no explicit
--spec-reasoning-effort governs the draft.
* fix(execprofile): address review findings on the profile surfaces
- --use-spec projects away a profile's self-correct knob instead of
silently arming an option the draft runner ignores (the explicit
--self-correct --use-spec combination is already a parse-time error)
- RunTurnBench canonicalizes and validates ExecProfile at the library
boundary, so a direct caller's casing/whitespace cannot fork report
labels and an unknown name fails before anything runs
- reverting a profile whose displaced budget was zero clears
ZERO_MAX_TURNS (SetMaxTurnsEnv ignores zero, so sub-agents would have
kept the removed profile's budget)
- the TUI effort-disarm test fails instead of skipping when the catalog
stops filling low effort
* fix(tui): reconcile an active profile's effort fill on model switch
The fill is per-model, not a session preference: fast selected on a
model without low fills nothing, and switching to a model that supports
it should behave like selecting the profile there; the reverse switch
must not keep sending a level the destination does not support. Both
switch paths (the /model command and the cross-provider picker) now
re-derive the fill, leaving explicitly touched efforts alone and
keeping the escalation's effort restore in step. Also: the revert-env
test asserts key removal via LookupEnv, and applyProfileTurnBudget's
pinned-budget parameter is named and documented as covering mode
presets, not just the flag.
* fix(tui): void profile effort bookkeeping when the model-switch drop erases it
The /model path's pre-existing unsupported-effort drop ran before the
profile reconciliation and left contradictory state behind: a dropped
touched override kept blocking the profile from governing, and a
dropped profile fill left a stale applied value with the effort restore
still armed. The drop itself is load-bearing (the TUI run path forwards
the effort to the provider unfiltered), so instead of preserving a
known-unsupported level, the voided choice now resets the profile's
effort bookkeeping and the reconcile re-derives from scratch. The whole
sequence lives in one ring-parameterized helper driven by the switch
target's authoritative ring, unit-tested against synthetic rings for
both the void-then-refill arc and the supported-override survival.
* fix(tui): only drop an explicit effort when the target ring is authoritative
A nil ring meant both a catalog model with no effort controls and a
live-discovered custom model the catalog cannot vouch for. The drop now
requires an authoritative ring (a resolved catalog entry, whose ring may
legitimately be empty); an unknown ring preserves an explicit preference,
matching the cross-provider picker's behavior, while the profile's own
fill still applies only where support is known.
* fix(perfbench): absolutize the bench binary and make errored tasks first-class
* fix(perfbench): state that errored tasks count as pass-rate failures
* fix(perfbench): align errored-task wording with the actual tier accounting
* chore(perfbench): never commit generated baseline reports
* fix(doctor): report missing npm native binary in wrapper
Co-authored-by: Cursor <[email protected]>
* fix(doctor): preserve JSON output for missing native binary
* fix(doctor): preserve doctor CLI behavior in wrapper fallback
Co-authored-by: Cursor <[email protected]>
* style: gofmt npmwrapper doctor fallback tests
Co-authored-by: Cursor <[email protected]>
* fix(doctor): reuse shared Bun recovery helpers in wrapper fallback
Extract bunRecoveryParagraph and related helpers so the doctor missing-binary text path and the generic wrapper bail share the same copy. Add regression tests with ZERO_WRAPPER_SIMULATE_BUN to guard against future drift.
Co-authored-by: Cursor <[email protected]>
---------
Co-authored-by: Cursor <[email protected]>
Extract completion decisions from the agent loop into a feature-gated typed policy with complete, incomplete, and uncertain outcomes. Preserve bounded plan-stall nudges and allow at most one semantic acceptance check for self-correcting runs.
Tested: make build
Tested: make test
Tested: go fmt ./...
Tested: go vet ./...
Tested: make lint
Tested: govulncheck ./...
Note: repository-wide pinned golangci-lint reports 36 pre-existing unrelated findings; internal/agent/... reports 0 issues.
Upgrade the parallel tool planner from SideEffectRead probes to the PR5
CapabilitiesOf contract: EffectReadOnly + ThreadSafe + auto-allowed, with
resource-key conflict boundaries so same-path reads stay sequential.
Mark audited pure reads ThreadSafe (read_file, read_minified_file,
list_directory, glob, grep, skill, web_fetch). Glob/grep use scopedScan
directory keys (nil for workspace-wide "." so scans do not false-conflict).
Tests cover the capability gate, key conflicts, extendParallelRun windows,
and catalog ThreadSafe audit for concurrent-safe reads.
Co-authored-by: KRATOS <[email protected]>
* perf(turn-bench): Phase 0 — strengthen oracles so pass rate can't be misread as correctness
The baseline manifest's pass/fail contract had three holes that let a weak
or no-op agent read as a pass:
- edit grep oracles were substring checks: a reworded line (e.g.
fmt.Println("debug: starting") -> fmt.Println("starting up")) passed
`! grep 'debug: starting'`.
- refactor lived in the build tier with a non-positive `go build ./...`,
so a no-op refactor passed.
- the 24 read-only tasks (nav/longproc/longctx/parallel) had no oracle;
a zero-exit only proved the turn ran.
This makes the tiers honest:
- edit/refactor now carry a stamped oracle_test.go compiled by
`go test ./...` — the Go compiler is the structural verifier, so a
no-op refactor, a missing field, or a reworded-but-not-removed line
fails to compile/run. The test is stamped into the fixture copy AFTER
the agent run, so it can't interfere with the agent's own build or be
pre-seen.
- nav gains an answer-oracle: the harness captures the agent's final
answer (.zero-answer.txt) and greps it for determinable facts. Count
tasks (nav-04/05/08) use a structured `count: N` token so the oracle is
tight rather than a loose prose-contains on "0".
- refactor graduates from build to correctness (its oracle is now
positive); nav graduates from latency-only to correctness. The build
tier is empty (buildOnlyClasses: []).
- longproc/longctx/parallel stay permanently latency-only — there is no
deterministic oracle for "report whether the build succeeds" or
"summarize a large file"; documented in MANIFEST.md.
Tier counts: correctness 34 (edit 10 + fix 8 + nav 10 + refactor 6),
build 0, latency 14. Total 48 unchanged.
TurnSchemaVersion 2 -> 3: the result shape is unchanged (only class-list
membership shifts) but correctnessPassRate now means something strictly
stronger, so the bump prevents a v2->v2 cross-version comparison from
misreading the jump as a model improvement.
Prerequisite fix: the mutating fixtures had no go.mod, so `go test ./...`
in the temp copy failed with "cannot find main module". Added go.mod per
fixture (edit/fix/refactor/nav) and moved copyFixture's temp dir one level
below the system temp root — Go ignores go.mod in direct children of the
temp dir (a hijack guard), which would have broken every compiler-backed
oracle. This also fixes the pre-existing fix/refactor oracles, which were
silently broken because the test suite used fake runners that never ran
go test.
Tests: stub-binary gating tests prove each oracle fails the wrong thing
(no-op refactor, missing field, wrong nav answer, reworded debug print) and
passes the right thing (real refactor, added field, correct answer); an
edit-01 test proves the scoped negative grep doesn't false-fail a correct
rename that leaves the doc comment. Aggregation, schema, and unit tests
cover the v3 tier model and the answer-capture helper.
* Address CodeRabbit review on PR #712: tighten oracles + isolation
- nav count oracles (nav-01/04/05/08): anchored line-exact
`^count: N$` with the fixture's exact expected count, so a substring
like "the count: 0" or a wrong count like "count: 3" fails instead
of rubber-stamping. nav-01 added to the structured-count contract.
- stampOracleAndAnswer now errors when a task has an oracle or
verification but no workspace fixture, so verification can't run in
the caller's cwd with the oracle never compiled (false-pass closed).
- copyFixture uses a unique 0700 os.MkdirTemp parent under the temp
root and nests the copy beneath it (grandchild of temp root), so the
Go go.mod hijack guard is satisfied and cleanup removes the whole
parent. Avoids a predictable shared world-readable dir.
- Rejection tests require a non-empty VerifyErr via a shared
assertVerifyFailed helper, proving the failure came from
verification rather than a harness error or a silent non-pass.
- Unify streamJSONExitCode + streamJSONFinalText behind one
parseStreamJSON parser (behavior-preserving; scanner limits and
malformed-event handling now live in one place).
* Address gnanam1990 review on PR #712: close count=0 gameability + stronger oracles
- nav count tasks (nav-04/05/08) had a count=0 ground truth, so an agent
that never opened the workspace and always emitted "count: 0" passed
three correctness tasks -- the exact pass-rate-misread-as-correctness
failure this PR closes. nav-04 now has a real TestGreet (count 1) and
nav-05 a real TODO (count 1); nav-01's file count moves to 5. nav-08
stays at 0 (a real third-party import would need a network fetch and
break the offline suite) but its oracle now requires the answer to name
both stdlib imports (fmt from main.go, testing from main_test.go), so a
blind count:0 or a main.go-only read fails. nav-08's prompt now asks the
agent to enumerate and classify every import, so naming the stdlib
imports is part of a correct answer, not a false-fail.
- refactor-05: stamp a behavioral oracle (TestGreetWrapped) so deleting
both Wrapper and greetWrapped, or stubbing the caller to return "",
fails to compile/run. The negative grep alone passed that wrong edit.
- refactor-04: replace the brittle exact-text shell grep with a stamped
file-reading oracle using a spacing-tolerant regex, so a bare
map[string]int decl under any spacing/:= variant can't dodge it, and
the Stats type must be referenced in main.go.
- edit-05: also assert the "debug: starting" message text is absent, so
a respell onto fmt.Printf / backticks / log.Println / println that
still prints the message fails (the fmt.Println(" check missed those).
- v3 doc comments on ClassSummary / TurnBenchResult /
FormatTurnBenchSummary now describe the v3 tiers (correctness =
edit/fix/nav/refactor, build empty, latency = longproc/longctx/
parallel) instead of the stale v2 model.
- stampOracleAndAnswer failure now mutates the existing outcome instead
of returning a fresh one, so the already-parsed trace and trace issue
survive a stamp error (attribution is preserved, pass/fail unchanged).
- Schema comment provenance: "PR #701" -> neutral "Phase 0 oracle
hardening" (this PR is #712).
- New gating + satisfiable stub tests for refactor-04/05 and edit-05, and
count-oracle zero-guess / inspection-proof tests for nav-04/05/08.
* Fix macOS CI: copyFixture isolation test was slash-sensitive
TestCopyFixtureIsolatesSourceFromMutation compared filepath.Dir(parent)
against os.TempDir() verbatim. On macOS $TMPDIR ends in a trailing slash
("/var/folders/.../T/") while filepath.Dir strips it, so the equality
false-failed even though the parent is genuinely a direct child of the
temp root. Linux (/tmp, no slash) and Windows (no slash in this Go
build) matched by accident.
The production copyFixture nesting is correct on all platforms (the
grandchild copy sits one level below the temp root, satisfying Go's
go.mod hijack guard); only the assertion was slash-sensitive. Normalize
both sides with filepath.Clean.
* Close nav-04/05 blind-count-one gameability (anandh8x review)
nav-04 and nav-05 had a count-only oracle (anchored '^count:1$'). Once the
ground truth became 1, an agent that never inspects the fixture and always
emits 'count: 1' passed both — the blind-count-one surface the prior
non-zero-ground-truth fix opened. Pair the count with a workspace-derived
named fact that proves inspection, mirroring nav-08's fmt+testing:
- nav-04 now also requires 'TestGreet' (the one test function in
main_test.go) — a specific, non-generic name a blind agent can't produce
without reading main.go to learn a greet function exists. Fully closes
the blind-count-one surface.
- nav-05 now also requires 'main.go' (the file holding the one TODO).
main.go is the conventional entrypoint and the most guessable filename, so
this reduces rather than fully closes the surface — a blind 'count: 1, in
main.go' can still pass. Accepted for Phase 0: the blind floor drops below
100% (a bare 'count: 1' fails), so a model still moves the pass rate by
inspecting. MANIFEST.md documents this residual honestly.
Two gating tests per task: RejectsBlindCountOne (bare 'count: 1' now fails)
and, for nav-05, RejectsSubstring (main.go present but unanchored count
still fails — parity with nav-04). Existing accept tests still pass (their
answers already name TestGreet / main.go).
Doc nits also fixed: MANIFEST.md #701 -> #712; refactor-05 removed from the
'no oracleTest' inventory (it now has TestGreetWrapped); the nav-01 named-
fact prose corrected to the three files the oracle actually anchors on;
test comment #701 -> #712.
* fix(sandbox): scrub dynamic credential env vars
* fix(sandbox): tolerate env assignments in configured sensitive keys
A config value mistakenly given as a full assignment (e.g.
"COMPANY_LLM_SECRET=...") would never match the real env key during
scrubbing, silently re-exposing the credential to sandboxed commands.
normalizeSensitiveEnvKeys now keeps only the name part before '=' and
re-trims it, per PR review.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(sandbox): scrub credentials on direct/degraded command plans
directCommandPlan (used when the native sandbox backend is unavailable,
disabled, or not required, including the EnforcementDegraded fallback)
left Env nil whenever spec.Env was nil, so exec.Cmd inherited the full
caller environment unscrubbed. Configured apiKeyEnv credentials and
dynamically named ZERO_OAUTH_<NAME>_CLIENT_SECRET variables leaked
through that path even though the wrapped sandbox plans already
scrubbed them.
Route direct plans through scrubSensitiveEnv the same way wrapped
plans do, snapshotting os.Environ() when spec.Env is nil so scrubbing
still applies. Adds a regression test covering the degraded-fallback
path with both a configured and a dynamically named secret.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
A user-level MCP server disable is now sticky. The project config
(a cloned repo's ./.zero/config.json) cannot re-enable a server the
user disabled, and cannot disable a server the user explicitly enabled.
The previous merge just assigned base.Disabled = next.Disabled, so a repo
that set disabled:true could silently defeat a user's explicit mcp enable
<server>. The guard is now symmetric via a mergeMCPServer canReenable
flag: once a higher-trust scope has explicitly set the disabled flag, a
lower-trust layer may not override that decision in either direction. Only
the CLI override scope (canReenable=true) may lift a sticky user decision.
The prior disabledSet-before-check ordering bug is also fixed: the
higher-trust scope's prior disabledSet/Disabled is captured before folding
in next's flag, so a project layer can still disable a default-enabled
server (e.g. firecrawl) rather than being silently ignored.
Ports the fix onto main's refactored MCP merge (internal/config/mcp_merge.go
and mergeProjectMCPConfig), which splits project-scope merging from the
general mergeMCPConfig path.
Fixes#512
* fix(hooks): run sessionEnd hooks after Esc/Ctrl+C interrupts
On interrupt, Run returns with ctx already canceled. The deferred
dispatchSessionEnd call passed that same ctx into Hooks.Dispatch, whose
context.WithTimeout derives an already-canceled child context, so the
hook process never actually launches (recorded as timed-out/error in
the audit log). Since most sessions end via interrupt, sessionEnd
hooks were effectively unreliable.
Detach the sessionEnd dispatch from the run context's cancellation
with context.WithoutCancel so the dispatcher's own timeout still
applies without being short-circuited by the run's cancellation.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* test(agent): fall back to GOROOT for hook regression
* test(agent): justify GOROOT fallback
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
* feat(aimlapi): AI/ML API guided onboarding (top-up + key issuance)
Add the AI/ML API provider integration to the zero CLI: a guided TUI
onboarding sub-flow that takes an existing key or an email top-up and
writes the issued key into the provider profile, attributed to the
Gitlawb rebate partner.
- internal/aimlapi: OpenAI-compatible client, partner-checkout top-up,
key validation, streaming, and endpoint config (Paths A/B/C).
- providercatalog / providermodelcatalog: register the aimlapi provider
with default headers and models.
- tui: aimlapi onboarding screen wired into the provider wizard and
onboarding flow.
Default endpoints point at production (auth/app/api/pay/aimlapi.com);
each is overridable via AIMLAPI_*_URL env vars.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(aimlapi): address review findings in onboarding flow
- topup: use errors.As for the 5xx retry check so a wrapped APIError
still retries instead of aborting a paid checkout.
- onboarding: a Path-A top-up on an email with no account now lands on a
single coherent "key saved, balance not topped up" screen instead of a
green "ready" plus a red error.
- onboarding: an empty amount field is rejected instead of silently
falling through to the $25 default.
- collapse the two identical top-up prompt constants into MsgTopUpPrompt
and drop the dead amountPrompt() branch.
- reuse stdlib maps.Clone instead of a third bespoke header-copy helper.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(aimlapi): retry transient poll errors and sharpen amount validation
* fix(aimlapi): retry transient poll errors and surface real amount errors
* docs(aimlapi): document exported client API; drop out-of-scope gitignore entry
Addresses the two CodeRabbit PR checks on #655:
- Add doc comments to the exported aimlapi client API (Client, APIError,
the session/checkout/onboarding types and their methods) and
ParseAmountUSD, to satisfy the docstring-coverage check.
- Remove the unrelated /zero.exe~ .gitignore entry that an earlier
review-fix commit added; it is out of scope for the provider work, so
the PR no longer touches .gitignore.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(aimlapi): render aimlapi row like other recommended providers
* fix(aimlapi): resolve onboarding review findings (Path A funding, base URL, request cancellation)
* fix(aimlapi): validate pasted API keys
* test(aimlapi): clarify pasted key validation
* test(aimlapi): cover key-balance low-balance transition
* fix(aimlapi): reject non-finite top-up amounts
* fix(cli): scope and canonicalize provider headers
* fix(tui): distinguish invalid verification codes
* feat(aimlapi): resume account-bound top-ups safely
* feat(tui): offer top-up for existing AIMLAPI keys
* feat(tui): reuse AIMLAPI env key on first run
* feat(tui): reuse configured AIMLAPI profiles
* test(aimlapi): trim redundant flow coverage
* fix(tui): preserve existing AIMLAPI profile metadata
* fix(tui): clone AIMLAPI headers in env fallback
* fix(aimlapi): address remaining review findings
* fix(aimlapi): harden onboarding lifecycle
* fix(aimlapi): preserve validated checkout context
* fix(aimlapi): harden checkout retry state
* fix(tui): clarify AIMLAPI configured choices
* fix(aimlapi): make checkout retries idempotent
* fix(aimlapi): validate checkout responses
* fix(providers): reconcile aimlapi catalog after rebase
---------
Co-authored-by: Lookoff123 <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
* perf(tools): add explicit tool effect metadata
Introduce fail-closed EffectClass and ToolCapabilities on every built-in
tool so a later concurrent read-only batch PR can decide safety from
explicit metadata rather than tool names. Plugin and MCP tools stay
EffectUnknown. No concurrent execution is enabled.
* fix(tools): address review feedback on effect metadata
Harden capability normalization and resource keys before PR6 consumes them:
- Force ThreadSafe=false for every non-ReadOnly effect at read time
- Reject duplicate builtin names in ValidateBuiltinCatalog
- Coerce numeric session ids and accept "session" for process keys
- Strip path/query before userinfo in endpoint host parsing
- Wire apply_patch keys from unified-diff paths + cwd
- Reclassify bash as WorkspaceWrite (one-shot shell mutator)
- Expand regression tests for keys, normalization, and gates
* fix(tools): keep multiFileResourceKeys used by apply_patch keys
Satisfies unused lint after switching apply_patch to applyPatchResourceKeys.
* fix(tools): resolve CodeRabbit residual capability/key issues
- Join apply_patch diff paths under cwd for conflict keys
- Validate constructor-declared capabilities before normalization
- Unify session/process ID key extraction helper
---------
Co-authored-by: KRATOS <[email protected]>
Compute a seven-field SHA-256 fingerprint of the cacheable prompt
prefix (base instructions, confirmation policy, project context, skills,
tools, tool schemas, complete) and emit one prefix_hash event per turn
through the trace recorder. The complete hash is the SHA-256 of the
canonical join of the other six, so any sub-component drift is
observable both individually and in aggregate.
The decomposition exists so a downstream observer can detect which
sub-component of the prefix broke cache stability across turns: a
stable complete_prefix hash means the cacheable prefix is byte-
identical, and the sub-hash that changed names the culprit.
No behavior change for untraced runs. For OpenAI, the existing
prompt_cache_key (session ID) is unchanged; the fingerprint is purely
additive observability. The Anthropic cache_control breakpoint work
already in main is not touched.
Co-authored-by: anandh8x <[email protected]>
* feat(tui): Ctrl+X leader chords and emacs menu navigation
Add Ctrl+X leader shortcuts for common slash commands (with Ctrl+X ?
chord map), and Ctrl+P/N previous/next selection in modals and pickers.
* fix(tui): drop Ctrl+X e /edit leader chord
/edit replaces the composer and would discard an in-progress draft; leave
it unbound so leader shortcuts stay draft-preserving.
* fix(tui): consume idle Ctrl+N as a no-op
Reserve Ctrl+N for emacs menu navigation so it never falls through to
remapped global bindings when no selection surface is open.
* docs: drop manual CHANGELOG entries for TUI shortcuts
* fix(tui): stop the composer cursor blinking while typing or unfocused
The composer's cursor blinked on a fixed timer unconditionally, including
mid-keystroke, which made typing feel janky since the cursor's job is to mark
the current position. Gate the existing blink tick on two states instead:
force solid while a keystroke landed within the last 500ms (reusing the
existing lastCharTime tracker), and hide it entirely while the terminal
window is unfocused (new terminalFocused field, driven by tea.FocusMsg/
BlurMsg). Idle and focused keeps the prior blink behavior unchanged.
Also decouples view.ReportFocus from the notifier (it was previously gated on
m.notifier != nil, which is effectively always true in production but left
cursor-focus behavior fragile if that ever changes) so focus reporting is
requested unconditionally.
* fix(tui): apply caret visibility synchronously and in the argument-hint line
- A keypress, focus, or blur now updates composerCursorVisible in its own
message handler instead of waiting for the next 530ms composerBlinkMsg
tick, which could render a typed character caret-less, leave a visible
caret in an unfocused terminal, or keep it hidden after a refocus.
- commandArgumentHintComposerLine takes the caret visibility state instead
of painting its cursor cell unconditionally, so the required-argument
hint path follows the same focus and blink contract as the main
composer renderer.
- New tests assert the state immediately after each message, with no blink
tick in between, and cover the argument-hint render in both states.
* fix(tui): apply the caret state on composer paste
A paste is the same immediate-input transition as a keypress: routePaste now
refreshes lastCharTime and sets the caret visible for both the bracketed
tea.PasteMsg and right-click clipboardReadMsg paths, so a paste right after
the blink phase hid the caret renders solid immediately instead of waiting
for a tick that would toggle off a stale idle state.
* draft(tui): add theme interface and Claude/Codex presets proposal
Introduces the Theme interface alongside the initial specifications and styling models for the Claude (card-based, warm) and Codex (high-density, split-screen cyberpunk) layout presets. Detailed design plans are documented in docs/THEMES.md.
* fix(tui): implement Claude/Codex themes on the existing palette system
theme.go had replaced the tuiTheme/buildTheme/palette architecture every
renderer depends on with a standalone Theme interface using the wrong
lipgloss import, breaking the build and orphaning the existing theme
registry, /theme command, and picker.
Revert theme.go to the working implementation and add claude and codex
as real palette entries in theme_palettes.go, following the file's own
extension pattern. Both pass the WCAG AA contrast and gray-ramp
invariants asserted across the whole registry, and are picked up
automatically by --theme, ZERO_THEME, /theme, and the picker.
Rewrite docs/THEMES.md to describe the actual palette-registry
architecture instead of the removed interface.
* fix(tui): rename Claude/Codex to neutral Dune/Neon, ensure WCAG AA contrast compliance, and add tests
* style(tui): gofmt theme_select_test.go (drop trailing whitespace)
The ubuntu smoke run fails on gofmt -l; this clears the only unformatted
file in the draft so CI goes green.
* test(tui): cover the CLI/env resolution path for the dune and neon presets
TestNewThemePresetsWired only checked lookupTheme and the IsDark
grouping. Add coverage for the actual --theme/ZERO_THEME precedence
path (resolveThemeMode) and confirm applyTheme swaps zeroTheme to each
preset's own palette rather than a built-in fallback.
* fix(tui): raise Dune/Neon palette contrast and fix theme docs
Darken dune's accent, blue, and red tokens so the selected-row caret,
local-model dot, and diff deletion text clear WCAG AA against selBg and
delBg, including after ANSI-256 downsampling. Also correct THEMES.md's
description of bare /theme (it opens a live-preview picker, not a
list), and add the dune/neon names to the ZERO_THEME/--theme tables in
README.md and README_ZH.md.
* fix(tui): keep Dune and Neon readable after ANSI-256 downsampling
- Dune's accent becomes #724028: still AA on selBg and panel in truecolor
(5.46:1 and 7.03:1), and its xterm-256 quantization (#444444) now clears
AA on quantized selBg at 6.47:1 where the previous #7c4712 quantized to
#875f00 at 3.81:1. Blue already survived quantization at 4.67:1.
- Neon's diff bands move to hexes that quantize to distinct green and red
xterm entries (addBg #083c10 -> #005f00, delBg #3c0810 -> #5f0000,
addBgWord #147828 -> #008700, delBgWord #74202e -> #870000) instead of
collapsing to the same grays, while keeping every truecolor invariant.
- New TestExtendedThemeANSI256Contrast quantizes the palette tokens the way
a 256-color terminal does and asserts the Dune selected-row pairs stay AA
and the Neon bands keep their green/red identity and mutual separation.
- docs/THEMES.md is now linked from the Documentation sections of both
READMEs, and the Unreleased changelog inventory says twelve themes and
names dune and neon.
* fix(tui): keep Neon diff content AA after quantization, fix error border
- faintest becomes #74c468: line numbers now quantize to #87d75f, which
clears AA on the xterm-green addBg (#005f00) where the old #58af69
quantized to 2.95:1, while staying dimmer than faint so the gray ramp
stays monotonic.
- addInk becomes #ecffdc, quantizing to #ffffd7 for 4.60:1 on the word
band's #008700 (was 4.29:1). Both rendered diff-content pairs are now
asserted in the ANSI-256 test instead of documented as omissions.
- cardErr becomes #9a4042: 3.01:1 against the panel (2.43:1 before) and
3.65:1 quantized, meeting the 3:1 non-text component threshold, with the
border surface asserted in both renderings.
- docs/THEMES.md no longer claims the extended invariants run against the
whole registry: it now says new palettes must be added to the
per-palette contrast tests or given equivalent coverage.
* feat(cli): show ZERO wordmark on --version
The TUI's empty-state ZERO ascii art gets exposed via a new tui.Wordmark
helper and printed above the version line for `zero -v`/`--version`.
Coloring only applies when stdout is a real TTY and NO_COLOR isn't set,
so redirected or piped output (scripts parsing the version) stays plain.
* fix(release): match --version output by suffix, not exact string
The release smoke check compared zero --version output to an exact
"zero <version>" string, which broke once --version started printing
the wordmark above the version line. Check for a "version: <version>"
suffix instead so the smoke check doesn't couple to the wordmark art.
* fix(cli,tui): keep --version machine-readable off-TTY, drop wordmark color
- When stdout is not a terminal, --version prints exactly "zero <version>"
again: pipes, redirects, command substitutions, and the release smoke
contract all parse that single record, so the banner is a TTY-only
affordance now.
- The TTY path keeps the banner but restores "zero <version>" as the
version record instead of "version: <version>".
- Wordmark renders uncolored: only newModel resolves --theme/ZERO_THEME,
so painting the global dark palette here was unreadable on light
terminals. The terminal's default foreground works on any background.
- smokeVersion goes back to requiring the exact "zero <version>" output
instead of the suffix match that papered over the banner.
* feat(providers): add AI/ML API preset
* fix(providers): relocate aimlapi preset and drop referral header
Addresses the two review asks on #402:
- Move the AI/ML API descriptor out of catalog slot #2 (it sat above the
first-party OpenAI/Anthropic/Google entries) down next to openrouter in
the aggregator cluster. Re-sync expectedCatalogIDs and the
TransportOpenAICompat order-list to match.
- Drop the always-on X-AIMLAPI-Partner-ID / Integration-Repo /
Integration-Version attribution headers. There's no partner arrangement
behind the preset, so a non-overridable referral tag on every user's
request isn't something to ship on a community preset. aimlapi now rides
through plain CopyHeaders like every other provider. Removes
HeadersForCatalog (it existed only for this special-case), reverts the
factory and discovery call sites to CopyHeaders, and drops the header
test. The factory/discovery tests now assert the endpoint, auth, and the
user's own custom headers survive while no attribution header is sent.
* fix(providers): address CodeRabbit review on #621
- Document AIMLAPIID for docstring coverage.
- Apply providerio.CopyHeaders for Anthropic and Google provider paths,
matching the OpenAI path introduced for aimlapi header isolation.
Co-authored-by: Cursor <[email protected]>
* fix(providers): gofmt factory.go to unblock CI smoke
Remove mixed tab/space indentation in the openai.New options block so
ubuntu-latest fmt-check passes.
---------
Co-authored-by: Dmitry Tumanov <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: KRATOS <[email protected]>
* feat(skills): discover shared ~/.agents/skills with multi-root skill loading
Add ~/.agents/skills as a read-only global discovery root after the
primary
Zero skills dir and before plugin roots. Unify runtime and CLI discovery
on
one multi-root path (DiscoveryRoots / LoadFromRoots / GlobalRoots) and
always
register the multi-root skill tool so bare runs and plugin runs share
the
same load surface.
Install/remove/lock still target only the primary Zero skills directory.
Fixes#610
* docs(skills): note lock stays on the primary write root
Document that install/remove/lock target only the primary Zero skills
directory, matching multi-root discovery write-root isolation.
* fix(skills): bubble primary root load errors from LoadFromRoots
Return non-missing failures from the first non-empty skills root so a
broken primary dir is not reported as an empty skill list. Keep fail-open
behavior for optional roots (~/.agents/skills, plugins).
* fix(skills): treat non-directory skills root as error on Windows
Windows maps ENOTDIR to ErrNotExist, so ReadDir on a regular file looked
like a missing skills dir. Reclassify existing non-directories and assert
the portable load behavior in tests.
* Add per-turn tracing and a turn-benchmark harness (Phase 0 baseline)
Introduce an opt-in `internal/trace` package that attributes a run's wall
time to named spans — prompt build, provider connect/queue, generation,
tool queue/execution, permission wait, verification, compaction,
persistence — plus counters for model requests, tool calls, retries,
reconnects, compactions, completion nudges, acceptance checks, model
switches, and input/cached/output tokens. The recorder is nil-safe (a nil
*Recorder is a no-op), so every stamp site can call it unguarded; an
untraced run is byte-identical to today.
The recorder threads into agent.Run via a new `Options.Trace` field and a
single `trace.WithContext` assignment, so the shared providerio seam
(connect around client.Do, queue around OAuth resolve, first token at the
first non-keepalive SSE payload) reaches it through `trace.FromContext`
without any provider-interface change. All instrumentation is opt-in: when
`Options.Trace` is nil the hot path is untouched.
Headless `zero exec` gains `--trace <path>` (and `ZERO_TRACE=<path>`),
writing an agenteval-contract-compatible NDJSON trace (`-` for stderr).
The trace is pure observation; enabling it does not change behavior.
A new per-turn benchmark harness extends `internal/perfbench` (reusing the
existing TaskSet/NumericStats/subprocess runner shapes): `RunTurnBench`
aggregates per-span median/P95 across iterations, ranks the top three
controllable latency sources by share of attributed time, rolls up per
task class, and totals tokens/counts. A checked-in 42-task manifest across
seven classes (nav, edit, fix, refactor, longproc, longctx, parallel) and
local offline fixtures back it, and `make baseline` runs it against the
built `zero`, writing `internal/perfbench/reports/baseline.json`. The
report is machine-specific and regenerated, not hand-edited.
The manifest is JSON rather than YAML to avoid adding a dependency;
per-task verification targets the specific test (`go test -run <name>`)
so fix tasks are independent. Each task is a fresh `zero exec` process, so
iterations are cold-start samples; a warm in-process path is left for
follow-up.
Verified: `go build ./...`, `go vet ./...`, `gofmt -l` clean on touched
files; `go test -race` green for internal/trace, internal/agent,
internal/perfbench, and cmd/zero-perf-bench.
* test(trace): make TestAttributionRatio clock-independent
The test recorded two synthetic 10ms spans with no real delay and asserted
the attribution ratio stayed <= 1.0. On a precise clock (Linux/macOS CI) the
wall is microseconds while attributed is 20ms, so attributed/wall blows past
1.0 and the test failed — even though the ratio legitimately exceeds 1.0
when spans overlap (parallel tool execution). On Windows the coarse timer
masked it, so the failure only surfaced in CI.
Assert the contract instead: attributed is the deterministic sum of span
durations, and the ratio equals attributed/wall (0 when wall is zero). Add a
zero-wall case for the divide-by-zero guard.
* Address review feedback on the per-turn tracer + benchmark harness
Benchmark runner (internal/perfbench):
- A nonzero agent exit code no longer counts as Passed. The runner set
VerifyErr but then fell through to outcome.Passed = true, so a crashed
run could be recorded as a pass. Return before the verification block
when VerifyErr is already set.
- A task now passes only when every iteration passes. The previous
"any iteration passes" rule let a flaky fail hide behind one good run.
- Each invocation copies its workspace fixture to a fresh temp dir and
runs the agent + verifier there, so mutating tasks (edit/fix/refactor)
can't dirty the checked-in fixtures or bleed into the next iteration.
- Trace load failures (missing/malformed trace file) are surfaced as
result warnings via a new TraceIssue field instead of being swallowed.
Tracer (internal/trace):
- Finish freezes the recorder: Counter, addSpan, and the first-event
stamps are dropped after Finish returns, so a snapshot captures exactly
one turn and late stamps can't mutate it.
- WriteText propagates the first write error instead of silently
swallowing sink failures.
Provider seam (internal/providers/providerio):
- FirstTokenAt is stamped only after a payload is accepted as real model
output, not before handle() decides to drop it as an error payload.
Agent loop (internal/agent):
- The compaction counter increments only when the history actually
shrinks, in both the proactive and reactive paths — paid no-ops no
longer inflate the compaction total.
- Document on Options.Trace that a fresh Recorder is required per Run.
CLI (internal/cli/exec):
- Finish the trace at the agent.Run boundary and defer only the
serialization, so the snapshot covers exactly the turn.
- Reject --trace / ZERO_TRACE for spec-draft runs with a clear error
instead of silently accepting and writing nothing.
Manifest + fixtures:
- Strengthen the edit verifiers: rename and value-bump tasks now assert
the old symbol is gone, and the getter task greps the method signature.
- Add the missing testdata/edit fixture (10 edit tasks referenced a
directory that was never committed, so the whole edit class errored
out at run time).
- The manifest test now stats each fixture so a missing dir fails at
load time instead of at every run.
- longproc benchmark keeps Process live across the loop; README code
fence gets a language tag and documents the per-invocation fixture copy.
- Add tests for the tracing wrapper (nil vs wired recorder, OnUsage
forwarding, token aggregation) and the frozen-recorder contract.
* Address review feedback: exclusive-time attribution + wired spans + fix-suite compile
Rewrite the trace attribution model so concurrent/nested spans no longer
double-count. Spans record wall intervals; the recorder derives a parent for
each by interval containment and computes exclusive time (duration minus the
union of its children). AttributedDuration sums only top-level spans; Coverage
is the union of intervals over wall, capped at 1, the honest ">=95%" metric.
Top-latency ranking uses exclusive time so shares sum to ~1 instead of being
inflated by overlapping provider_connect/generation and nested
permission_wait/tool_execution.
Wire the spans that were named but never stamped: verification around the
self-correct verify pass, and StampFirstVisibleEvent on the first forwarded
text event. Drop the tool_queue/process_wait/persistence constants that had no
stamps, and rename the misnamed near-noise prompt_build span to tool_partition.
Trim RequiredEventKeys to events a healthy short run actually emits; move
conditional phases (tools, permission, compaction, verification) to
OptionalEventKeys so a short trace does not false-fail.
Make ReadNDJSON fail loudly on a corrupt file: require a type:trace header,
error on span/counter lines that appear before it, and error when a header
yields no spans or counters.
Fix the blocking fix-suite compile error: Label's %d format verb on a string
tripped go test's vet before any fix task could verify. Make it a runtime
wrong-prefix bug ("admin-" vs "user-") that still type-checks, so each fix
task is independent again.
Manifest: correct refactor-04's stats.go to main.go, reword fix-07 to the
wrong-prefix bug, add a latency-only description (read-only tasks carry no
verification oracle by design), and frame the report README on exclusive time
and coverage.
Tests updated for the occurrence/exclusive/coverage model; trace, perfbench,
and agent green under -race.
* Fix exclusive-time round-trip, identical-interval nesting, and empty-trace parse
Three correctness bugs in the attribution model found by an adversarial review
of the prior commit, all undermining the no-double-count guarantee:
1. exclusive_ms:0 was overwritten with the inclusive Duration on re-parse. A
parent whose children tile its interval legitimately has exclusive time 0;
WriteNDJSON emits exclusive_ms:0, but ReadNDJSON's "<= 0 means missing"
fallback turned that back into the full duration, so every re-parsed trace
re-introduced the double-counting the model exists to prevent (the benchmark
harness, which ranks re-parsed traces, inherits it). Gate the fallback on
key ABSENCE, so a written 0 is preserved.
2. deriveNesting formed a 2-cycle for two spans with identical [start, end]
intervals: the symmetric containment check made each parent the other,
dropping both from top-level and zeroing AttributedDuration. Break the tie
deterministically — strict containment is an unambiguous parent; for
identical intervals, only the lower-indexed span may parent the other, so
one stays top-level.
3. ReadNDJSON returned a valid empty TurnTrace for empty/blank-only input, so a
run that crashed before emitting a trace (or where --trace was not honored)
masqueraded as a clean zero-attribution sample. Empty input now errors, so
the harness records a TraceIssue.
Also: decode counters via json.Number so int64 values above 2^53 round-trip
exactly instead of losing precision through float64; and drop the harness's
own "exclusive <= 0 -> Duration" ranking fallback, which was the same
double-count on the re-parsed path. Added regression tests covering the
zero-exclusive round-trip, the identical-interval nesting, empty-input
rejection, and counter precision.
* perf(turn-bench): split pass/fail into oracle tiers so the pass rate can't be misread as correctness
The turn benchmark counted any exit-0 task as passed, including the 24
read-only tasks (nav/longproc/longctx/parallel) that carry no verification
oracle — so the JSON tasksPassed / "N/M passed" headline could read as a
correctness verdict it never was. Split pass/fail into three oracle tiers:
- correctness (edit grep + fix go test): the only pass rate that can move
with model quality (tasksVerified / tasksPassed / correctnessPassRate)
- build-only (refactor go build): a non-positive oracle, reported as
buildPassRate and excluded from correctnessPassRate; the manifest declares
buildOnlyClasses: ["refactor"]
- latency-only (no oracle): counted in latencyOnlyTasks, never in any pass
rate
NewTurnExecRunner no longer sets Passed=true for tasks with no
verificationCommand, and the summary headline names each tier explicitly so
an exit-0 read-only run cannot inflate a correctness number. Bumps
TurnSchemaVersion to 2. Class breakdown and tier contract documented in
internal/perfbench/MANIFEST.md and reports/README.md.
Also install the trace first-token/visible-event forwarding handlers in
agent.Run whenever options.Trace is set, not only when a UI OnText /
OnReasoning callback is set, so a headless traced run (zero exec --trace)
still captures FirstTokenAt. forwardedVisibleText stays tied to the user
callback so stall-retry semantics are unchanged. Adds a regression
assertion to the existing traced-run test.
* Address CodeRabbit review on 2bfb3406: omitempty + crash visibility
Three findings from CodeRabbit's review of the 3-tier pass-rate fix:
- BuildPassRate dropped its omitempty so an explicit 0.0 (all refactor
tasks fail to compile) is no longer indistinguishable from 'no build
tasks ran' in the JSON. Matches CorrectnessPassRate's always-serialize
behavior, which is the whole point of the tier split: the report
cannot be misread.
- A crashed sample (outcome.Err) now emits a Warning mirroring the
TraceIssue path, so a run that died every iteration can't look like a
normal benchmark with fewer measurements instead of a failure.
- cmd.Run()'s returned error is preserved on the no-run_end path: when
stderr is empty and Run failed (binary missing, exec permission
denied), the actual OS error is reported instead of collapsing to the
generic 'missing terminal run_end event'.
go build/vet/gofmt clean; go test -race green for perfbench/agent/trace.
* Address CodeRabbit re-review: anti-averaging README + fixture-isolation test
Re-review at d0615249 asked for the pass-rate contract to be loud to
downstream consumers and for the fixture-isolation property to be asserted,
not just read from code.
- reports/README.md gains a prominent 'do not average the tier pass rates /
do not report a single pass rate' warning. The schema offers no headline
pass rate on purpose; a consumer must name the tier it is reporting. This
is the durability fix for the misread the tier split exists to prevent.
- turn_bench_test.go gains TestCopyFixtureIsolatesSourceFromMutation: copies
a fixture, mutates the copy, and asserts the source is byte-identical
(and that a file added to the copy never appears in the source). Proves the
'fixtures stay clean across runs' property without needing the binary.
Items the re-review also raised that were already satisfied: the
oracle-presence-over-declared-list precedence is documented on the
BuildOnlyClasses field (taskbench.go), and the PR body already says
'48-task manifest'. The Share->exclusiveShare rename is tracked in #701.
go build/vet/gofmt clean; go test -race green for perfbench.
* feat(cli): wire MCP serve WorkspaceRoot and --add-dir scope
Pass the resolved workspace into mcp.ServeOptions and honor --add-dir so
resources/list and scoped tools can expose extra roots beyond cwd.
* fix(cli): keep lexical serve --add-dir roots for path matching
Use symlink-resolved paths only for deduplication so Scope roots stay
aligned with the un-evaluated WorkspaceRoot MCP serve already passes.
The launcher recording a spawn and the job's Runs counter incrementing
are sequential but distinct steps in the scheduler goroutine
(fireIfIdle's Spawn call, then run's job.incRuns()). The test polled
only the launcher's recorded count, then asserted Runs immediately
with no wait, so a scheduler goroutine preempted between those two
steps could be observed with a stale Runs value under CI load. Wait
for Runs itself instead of assuming the launcher signal implies it.
* fix(windows): retry atomic file renames on Access is denied / Sharing violation
* test(swarm): add unit tests for rename retry and non-retryable errors
* test(swarm): skip TestMailboxRenameRetry on non-Windows platforms
* fix(sandbox): scrub sensitive provider credentials from sandbox env
* refactor(sandbox): unify environment creation and reduce lines of code
* fix(sandbox): forward workspaceRoot through sandboxEnvironment, gate Windows env on backend
* style(sandbox,swarm): remove stray blank lines flagged by gofmt
* refactor(fsutil): extract shared rename retry helper, derive scrub list from provider catalog
Address CodeRabbit review feedback:
- Add internal/fsutil.RenameWithRetry with an injectable rename override and
move the four duplicated renameWithRetry/isWindowsSharingViolation copies
(cron, sessions, sandbox, swarm) onto it. Mailbox keeps its rename test
seam by passing its field through. Add fsutil tests for the Windows retry
and non-retryable paths.
- Remove the orphaned metadata.json.tmp in cron writeJob when the rename
permanently fails, matching the sibling writers.
- Derive scrubSensitiveEnv keys from providercatalog AuthEnvVars plus a
static list of non-catalog secrets, so new providers are scrubbed
automatically. AWS_PROFILE and GOOGLE_APPLICATION_CREDENTIALS stay
because they are pointers, not secrets.
* fix(sandbox): deny sandboxed reads of cloud credential stores by default
Address CodeRabbit finding that scrubbed env pointers still left the
underlying credential files readable under the read-all workspace posture:
- Add credentialDenyReadPaths: default profile-level deny-read entries for
~/.aws, ~/.config/gcloud, ~/.azure, and the GOOGLE_APPLICATION_CREDENTIALS
target file, wired into both permission profile builders so the seatbelt
and Linux helper backends enforce them. Entries are stat-filtered and a
store nested under a user AllowRead entry is dropped, keeping an explicit
opt-out.
- Keep the defaults out of Policy.DenyRead, whose emptiness gates escalated
execution, and skip Windows so the profile deny list does not flip the
runner off the WRITE_RESTRICTED token path.
- Scrub GOOGLE_APPLICATION_CREDENTIALS from the sandbox env; keep AWS_PROFILE
since the deny-read rule on ~/.aws is the real protection and the SDKs
fall back to the default profile regardless.
- Add credential deny path tests, extend the scrub test, relax the profile
test's exact deny count to containment.
* test(cli): pin HOME in sandbox policy golden test
The default credential-store deny-read entries depend on what exists in the
real home directory (the macOS CI image ships ~/.aws), which leaked host
paths into the policy JSON golden comparison. Point HOME at an empty temp
directory so the golden stays hermetic.
* fix(sandbox): protect GOOGLE_APPLICATION_CREDENTIALS without a home, retry lock violations
Address CodeRabbit review feedback:
- credentialDenyReadPaths no longer returns early when the home lookup
fails; home-based candidates become conditional and the
GOOGLE_APPLICATION_CREDENTIALS target is always considered.
- fsutil.RenameWithRetry also retries ERROR_LOCK_VIOLATION (errno 33),
matching the mcp lock handling; helper renamed to
isWindowsSharingOrLockViolation.
- Extend tests: homeless GOOGLE_APPLICATION_CREDENTIALS case and a lock
violation step in the retry sequence.
* fix(sandbox): tighten env-scrub test assertion and add missing secret keys
TestScrubSensitiveEnv only checked an allowlist plus a length count, so a
duplicate allowed key silently replacing a required one would still pass.
Compare the exact expected key/value map instead.
Also add ZERO_WEBSEARCH_API_KEY and ZERO_DAEMON_REMOTE_TOKEN to
scrubSensitiveEnv's sensitiveKeys list: both are real bearer secrets read
from the environment but were missing from the scrub list, leaving them
readable by sandboxed commands when set in the parent shell.
* fix(tools): classify silent wrapped Windows command failures as sandbox denials
A Windows restricted-token sandbox failure is often completely silent:
when the token cannot open the target executable or its DLLs (or an
MSYS runtime dies during init), the command exits nonzero having
written zero bytes to stdout and stderr. The existing detection is
keyword-based, so these failures were never marked as sandbox denials,
the agent loop never offered the unsandboxed-retry prompt, and the
model was left staring at 'Command completed with no output' until it
gave up.
Treat a wrapped command on the Windows restricted-token or elevated
backend that exits nonzero with completely empty output as a likely
sandbox denial. A command that legitimately fails silently (findstr
with no match, for example) now costs one extra approval prompt; the
inverse misclassification silently strands the session, so the trade
goes to prompting.
* test(tools): cover the elevated Windows backend in the silent-denial test
The heuristic treats windows-elevated identically to
windows-restricted-token, but only the latter was exercised.
Parameterize the silent-failure test over both backends.
* fix(keyring): pass generic password via stdin on macOS
Pass the generic password secret to security add-generic-password via stdin instead of passing it as a command-line argument. This prevents the secret from leaking to the local process list (visible via ps) and aligns the macOS keyring implementation with the Linux secret-tool implementation.
* fix(keyring): pipe secret twice for macOS security password+retype prompt
The macOS security add-generic-password command prompts for both a password and a retype confirmation when -w has no trailing value. The previous stdin approach only piped the secret once, causing a passwords don't match failure. Write the secret twice separated by newlines to satisfy both prompts.
* fix(keyring): reject multiline secrets on macOS instead of corrupting them
Addresses jatmn's review:
- security's -w password+retype prompt reads stdin line by line, so a
secret containing its own newline can't be distinguished from the
password/retype separator: the first line read back would silently
truncate the stored value. Set now rejects a secret containing \r or
\n on darwin before invoking security, rather than storing a
corrupted value. Linux's secret-tool has no such restriction (reads
the whole stdin payload), so it is unaffected.
- Updated the package doc comment, which still described the old
argv-based (process-list-exposed) macOS path this PR replaced.
Added TestKeyringSetRejectsMultilineSecretOnDarwin and
TestKeyringSetAllowsMultilineSecretOnLinux.
* fix(keyring): drive security -i for macOS Set instead of the -w prompt
The trailing -w prompt uses getpass(3), which reads from /dev/tty whenever
the process has a controlling terminal, so the piped secret was ignored in
any interactive session, and getpass's small fixed buffer would truncate
long secrets (like the base64 OAuth blob) when the fallback did engage.
Interactive mode reads whole commands from stdin, keeping argv at just -i.
Arguments are quoted for SecurityTool's split_line parser (backslash and
double quote escaped inside double quotes). The parser is line-based with a
4096-byte buffer, so Set rejects newlines and oversized payloads up front
rather than corrupting the stored value.
* feat(tui): press up to edit queued messages
Pressing up with a message queued pops it back into the composer for
editing, taking priority over history recall. While a message is queued
the empty composer shows a hint placeholder. A second prompt queued
during the same run now stacks under the first instead of replacing it.
* refactor(tui): remove openclaude references from queued_message comments
Removing the WRITE_RESTRICTED flag in #612 made the restricted-SID check
apply to reads as well as writes. Default Windows DACLs grant
BUILTIN\Users rather than any SID in the token's restricted list
(random capability SIDs, logon SID, Everyone), so the sandboxed process
could no longer open any executable or non-KnownDlls DLL: every spawned
command failed with exit 1 and no output, including cmd builtins'
children (gh, git, where.exe). The existing
TestWindowsRestrictedTokenNestedPipeCapture smoke test reproduces the
regression.
Restore WRITE_RESTRICTED, but only when the permission profile has no
DenyRead paths. The flag makes the kernel skip restricted-SID deny ACEs
for reads, which is exactly the DenyRead bypass #612 fixed, so profiles
that configure DenyRead keep the fully restricted token and trade spawn
capability for read-deny enforcement. DenyRead is empty by default, so
the common case regains a working sandbox while #612's guarantee holds
for the profiles that rely on it.
* fix(sandbox): stop blocking git fetch/commit/add by unblocking .git writes
The sandbox denied every write under .git for shell-executed commands,
so any git operation that touches its own metadata (fetch, commit, add,
pull, merge, stash) failed under the default sandbox. Narrow the block
to .git/hooks (auto-executing scripts) and .git/config (remote URLs,
credential.helper, core.hooksPath), the two subpaths that are actually
dangerous to write; the rest of .git stays writable to git subprocesses
since those writes go through git's own invariants.
The app-level auto-allow gate for direct file-tool writes (write_file,
edit_file, apply_patch) is unaffected and still treats all of .git as
protected, since hand-editing git's internals bypasses those invariants.
* test(cli): normalize Windows path separators in sandbox policy golden
The sandbox policy golden is shared across platforms, but readOnlySubpaths
carve-outs are built with filepath.Join and render as backslash paths on
Windows. normalizePortableJSONRootString only converted a bare backslash, so
the Windows smoke run diverged from the forward-slash golden. Replace all
backslashes with slashes during normalization so the shared golden passes on
every runner.
* fix(agent): raise default and deep-mode turn budgets
The default per-run tool-turn budget of 50 was still too low for larger
multi-step tasks that span several files: agents hit the ceiling and
stopped with a "remaining work" summary instead of finishing. Raise the
default from 50 to 80.
The "deep" preset also carried MaxTurns: 50, identical to the default, so
its "larger turn budget" promise no longer held on the turn-budget axis.
Raise it to 160 so deep runs get a genuinely larger budget again.
Users can still override per-session with /turns or --max-turns (bounded
by MaxTurnsCeiling).
* test(cli): expect deep-mode MaxTurns=160 after budget raise
* fix(config): surface unknown/typo'd config fields instead of silently dropping them
json.Unmarshal ignores unknown JSON keys by default, so typos such as
"maxTurn" or "sandbox.network" were silently discarded: the user
believed a setting (e.g. a sandbox hardening option) was active while
Zero used its default. Worse, "zero doctor" reported
"config files parsed and validated successfully" even when fields were dropped.
Add a reflection-based unknown-field scanner
(internal/config/unknownfields.go) that walks the FileConfig JSON schema
and recurses into nested objects, arrays, and maps, reporting each
unknown key with its full JSON path and a near-match suggestion
("did you mean 'maxTurns'?"). The two legacy top-level aliases
(mcpServers, mcp_servers) are allowlisted so they are not flagged,
and detection only inspects json tags, so a config written by a newer
Zero with new fields still loads and merges normally — validate/doctor
just surface the unknown keys as issues rather than rejecting the file.
Wire the scanner into ValidateBytes/ValidateFile, which zero doctor
already uses, so doctor now reports the unknown fields instead of
"validation passed".
Add tests for unknown top-level keys, unknown nested keys, unknown
keys inside a provider entry, and that the legacy aliases are not
flagged.
Fixes#644
* fix(config): address review comments on unknown-field scanner
- Treat JSON keys case-insensitively (matching encoding/json), so a
valid case variant such as "MaxTurns" is not falsely flagged as
unknown. Canonical (correct-case) names are kept only for the
"did you mean" hint.
- Allowlist ProviderProfile's legacy snake_case keys (providerKind,
catalog_id, base_url, api_key, ...) that ProviderProfile.UnmarshalJSON
accepts but which are not fields on the Go type, so existing
configs using them are not false-positive'd in zero doctor.
- Make the near-match suggestion deterministic: sort candidates before
picking so an edit-distance tie always resolves to the same lexical
winner (map-range order was nondeterministic and could flake).
- Make the unknown-field test assertions order-independent (search by
FieldPath instead of assuming issues[0]).
Add regression coverage for legacy provider keys and valid case
variants not being flagged.
* fix(lock): prevent POSIX lock file overwrite and leak on Windows/Unix
During lock reclamation, reclaiming a suspected stale lock renames the file aside and renames it back if not stale. On POSIX systems, the rename back silently overwrites any new lock file created in the gap. On Windows, it fails to rename back and leaks the stale file.
Introduce a cross-platform restoreLockFile helper:
- On POSIX: Use os.Link to link the file back (which atomic-fails if the target already exists), then remove the temporary link.
- On Windows: Use os.Rename (which safely fails if the target exists).
If restoring fails, delete the temporary file immediately to prevent leaks.
* fix(lockutil): fix Windows build by using golang.org/x/sys/windows
Addresses jatmn's follow-up review: the required Windows smoke check was
failing because syscall.MoveFileEx is not exported by the standard
library's syscall package on this platform, so internal/lockutil (and
everything importing it: cron, daemon, hooks, oauth) failed to compile
on Windows.
Switched to golang.org/x/sys/windows, which does export MoveFileEx with
the same signature, already a direct module dependency. Verified on a
real Windows machine that the no-flags call still fails with
ERROR_ALREADY_EXISTS when the destination exists, and that this
satisfies errors.Is(err, os.ErrExist), which cron/daemon/hooks/oauth's
reclaim paths all depend on to decide whether the sidelined lock was
genuinely superseded (safe to discard) versus some other, unrelated
failure (must be preserved).
TestRestoreLockFile already covered both the successful-restore and
fail-when-target-exists cases; it now passes on Windows.
* fix(lockutil): route lock file deletes through RemoveLockFile with Windows retry loop
* fix(lockutil): close restore split-brain and unify RemoveLockFile
RestoreLockFile could fail with errors other than os.ErrExist (hardlink-
incapable filesystems such as FAT or some FUSE/network mounts, ENOSPC,
Windows sharing violations). Callers only clean up on os.ErrExist, so any
other failure left the lock path missing with the live holder's lock
stranded in the sidelined file; the next O_EXCL create then succeeded and
two processes held the lock. RestoreLockFile now falls back to an O_EXCL
copy in that case, which preserves the no-overwrite guarantee, and a
successful restore with a failed sidelined-file cleanup reports nil
instead of a failed restore.
RemoveLockFile now has one cross-platform contract: removing an already
missing file reports nil on POSIX too, matching Windows, and daemon
release() drops its caller-side ErrNotExist filter.
Tests: TestRestoreByCopy and TestRemoveLockFile run on all platforms;
Windows-tagged tests pin the ERROR_ALREADY_EXISTS mapping to os.ErrExist
and exercise the RemoveLockFile retry loop to success and to bounded
exhaustion. The package doc comment documents the shared contract.
* refactor(swarm): extract reclaimStaleLock to mirror other lock callers
The stale-lock reclaim in acquireLock was inline, so its restore path
(a holder rotated a fresh lock in the gap between the stale check and
the rename) could not be regression-tested the way cron, daemon, hooks,
and oauth test theirs. Extract it into reclaimStaleLock with the same
shape as the other callers, behavior unchanged, and add the mirrored
regression test.
* test(hooks,oauth): add stale lock reclaim regression tests
Mirror the cron reclaim test in the two packages that lacked coverage
for reclaimStaleLock: a genuinely stale lock is reclaimed, a fresh lock
(reacquired in the gap) is restored intact rather than stolen, and a
missing lock reports no reclaim. daemon already had equivalent tests in
lock_test.go.
* fix(lock): fail closed and drop sidelined file when restore fails
A non-os.ErrExist error from RestoreLockFile means both the no-replace
restore and its O_EXCL copy fallback failed, so the lock path may be
missing while the sidelined holder is live. Callers previously swallowed
that error and their next O_EXCL create would succeed, breaking mutual
exclusion. reclaimStaleLock in cron, daemon, hooks, oauth, and swarm now
returns an error for that case and the acquire loops fail closed instead
of re-acquiring.
The sidelined .stale.* file is now removed on every restore failure, not
only os.ErrExist: once the restore has failed it has no protocol
function (release only consults the lock path), so keeping it only
leaked files.
Also replace the numeric errno literals in the Windows RemoveLockFile
retry loop with windows.ERROR_SHARING_VIOLATION and
windows.ERROR_ACCESS_DENIED.
* refactor(lockutil): extract shared ReclaimStaleLock helper
Consolidate the duplicated reclaimStaleLock functions from cron, hooks,
oauth, and swarm into lockutil.ReclaimStaleLock. Each caller now passes
an mtime-based isLive predicate to the shared helper, which owns the
rename-aside, restore-if-live, cleanup, ErrExist handling, and
fail-closed error propagation.
Daemon keeps its own reclaimStaleLock because it uses PID-based liveness
(different predicate shape and no staleAfter duration).
The per-package reclaim regression tests are removed since the shared
helper is thoroughly tested in lockutil/reclaim_test.go (dead lock
reclaimed, live lock restored, missing lock, fail-closed on restore
error, sidelined file dropped when new holder wins).
* fix(lockutil): propagate non-contention rename failures from ReclaimStaleLock
A rename-aside failure that is not a lost race previously mapped to the
benign lost-race result, so acquirers spun until their deadline and
reported a timeout instead of the real failure. Keep only a missing
source and the Windows sharing-violation/access-denied contention
errnos benign (neither Windows errno maps to os.ErrExist, and
ERROR_SHARING_VIOLATION does not map to os.ErrPermission either, so the
classification compares raw errnos) and return everything else so
callers fail closed.
Also convert daemon's private reclaimStaleLock to the shared helper so
it picks up the same contract, and reword the caller error messages
from "restore reclaimed" to "reclaim stale" since the error can now
come from the rename aside as well.
* fix(lockutil): publish restore-by-copy atomically and honor lost mailbox reclaims
restoreByCopy created the destination lock file empty before copying into
it, so a crash mid-copy could leave a live holder's lock permanently
unrepresented at the lock path. Stage the copy under a private name and
publish it with the platform's no-replace primitive instead.
swarm's mailbox lock discarded the reclaimed boolean and always retried
immediately, so a lost reclaim race (or Windows sharing-violation
contention) could hot-spin past the lock timeout instead of falling
through to the bounded wait like the other lock callers.
* fix(lockutil): shrink the reclaim/restore window with a fast replacing rename
Restoring a lock that turned out live went through the no-replace path
(plus its copy fallback on some failures) first, which is slow enough
that a fourth process's unrelated O_EXCL create could win lockPath while
it was absent for the whole call. RestoreLockFile's no-replace contract
would then report os.ErrExist and the caller silently dropped the
sidelined lock, orphaning the still-running original holder.
restoreLiveLock now tries a single replacing rename straight back first,
falling back to the no-replace path only if that fails. It shrinks the
window from a whole restore call down to roughly one syscall; it does
not close it, since the fast path can silently overwrite a competing
lock rather than detect it. A fully race-free reclaim would need an
OS-level advisory lock checked non-destructively instead of by moving
the file, which is a larger change than this.
* docs: add Go code quality & security checks and bump Go version to 1.26+
* docs: address CodeRabbit review comments and use pinned versions and Go 1.26.5+
* docs: remove trailing blank lines at end of AGENTS.md
* docs: add missing /v2/ segment to golangci-lint install path
The go install command pointed at the v1 module path
(golangci-lint/cmd/golangci-lint), which does not resolve for
v2.12.2. The go run invocation earlier in the same section already
had the correct v2 path.
* docs: trim AGENTS.md below 8 KiB guideline and clean up README cross-compile header
* docs: address CodeRabbit review feedback on extending guide
When writing credentials or generating user secrets, files are written using a write-to-temp-then-rename pattern. However, neither package called Sync (fsync) on the temporary file before closing and renaming. If a crash or power failure occurred shortly after write, this could result in truncated or 0-byte key/credential files on disk.
Add tmp.Sync() calls to writeNewSecretFile and Store.write to guarantee durability.
* fix(plugins): resolve relative executable paths against plugin root
When a plugin registers a tool or a hook command as a relative path (e.g. ./tools/helper.sh), running it in the caller's workspace CWD allows local execution hijacking by placing a malicious script at that path in the untrusted workspace. Update expandPluginRootPath to resolve relative executable paths containing path separators to absolute paths inside the plugin's install directory.
* fix(plugins): remove ineffectual permission initializer in toolSafety
The initial assignment to permission was always overwritten by one of
the switch arms below it, including a case that just reassigned the
same PermissionPrompt value the initializer already held. Flagged by
ineffassign in CI.
* feat(npm): ship the native binary as platform optionalDependencies
Replace the postinstall downloader with Codex-style platform packages:
suffixed versions of @gitlawb/zero (X.Y.Z-<platform>-<arch>) carrying the
binary, sandbox helpers, and the vendored agent-browser/tuistory tree,
referenced from the wrapper via npm: aliases in optionalDependencies.
The published wrapper has no lifecycle scripts and no dependencies, so
npm/Bun/pnpm installs are silent: no EBADENGINE from agent-browser's
node>=24 engines pin, no allow-scripts warnings, no network outside the
npm registry. The old downloader remains as a checksum-verified first-run
fallback for --omit=optional installs and unsupported platforms.
scripts/npm/build-platform-packages.mjs assembles the payloads from the
release archives at publish time: it materializes npm's symlink .bin
shims into regular sh shims (npm pack silently drops symlinks) and prunes
agent-browser's other-platform binaries (~65 MB per package). The publish
job uploads platform versions first under --tag platform, asserts the
latest dist-tag survived (platform versions are semver prereleases of the
wrapper version), then publishes the wrapper.
See docs/NPM_PACKAGING.md for the architecture and publishing rules.
* fix(npm): address PR review — LICENSE in payloads, dist-tag lookup guard, accurate fallback and platform docs
- Ship the repo LICENSE in both the wrapper and platform payloads (npm only
includes a license file physically present in the packed directory).
- Guard the platform-publish dist-tag lookup with || true so a transient
npm view failure (or a bootstrap publish with no latest tag) cannot abort
the job between the platform and wrapper publishes.
- Gate the wrapper's fallback downloader on the release matrix so platforms
with no asset (windows-arm64 and unknown combos) skip the spawn instead of
attempting it every run; keep deliberate retry-on-failure for supported
platforms and say so honestly in the notice and docs.
- Correct Windows support wording everywhere: x64 only, ARM runs under
emulation (README, README_ZH, INSTALL.md incl. the release-target list).
Interactive TUI always has a session ID, so every completion request
forwarded OpenAI's prompt_cache_key. Strict openai-compatible gateways
(e.g. NVIDIA NIM) reject unknown fields with a 400, while plain zero
exec usually has no session and omits the field.
Disable prompt_cache_key for ProviderKindOpenAICompatible; keep it for
official OpenAI. ZERO_DISABLE_PROMPT_CACHE_KEY remains a global kill
switch.
Fixes#624
Co-authored-by: KRATOS <[email protected]>
When executing a security-critical beforeTool hook, if the hook command fails to launch (e.g. binary not found, missing execute permission), it previously failed open and let tool execution proceed.
Update classifyResult to return AuditBlocked, true when a launch error occurs on a beforeTool hook, enforcing a fail-closed policy. Observational afterTool hooks still fail open.
Inside fireJob, the Mutate transaction callback previously returned the stale in-memory job copy (read at tick start). This clobbered any concurrent updates made to the job (such asExpr, Prompt, or Cwd edits) while the job was running.
Update the callback to apply schedule and status changes directly onto the fresh current job loaded from disk, and return current.
When a run is completed or canceled (agentResponseMsg) while an askUser prompt is pending, or when a stale/superseded askUserRequestMsg is received, the answer callback is now invoked with nil. This unblocks the waiting agent loop goroutine, preventing goroutine leaks and runner hangs.
When parsing CLI flags, positional prompt text placed immediately after flags requiring values (e.g. --auto, --notify, --max-turns) would be greedily consumed as their value by nextFlagValue. Refine nextFlagValue to validate choices/types (autonomy levels, notify modes, integers) to detect invalid values early and fail validation rather than eating positional arguments.
When falling back from a failed PTY start to a pipe execution, the command attributes are reset. Previously, resetExecCommandForPipeFallback set command.SysProcAttr = nil, which stripped custom settings like Windows CmdLine quoting. Update the fallback logic to restore the original SysProcAttr instance, and add TestStartExecProcessPTYFallbackPreservesSysProcAttr to cover.
Using unqualified taskkill / taskkill.exe commands on Windows can allow binary hijacking under certain conditions. Update all process termination callers on Windows to resolve the absolute path to taskkill.exe under System32 (referencing SystemRoot/windir environment variables, falling back to C:\Windows).
On Windows, a concurrent holder's os.Remove leaves the lock file in a 'delete pending' state, causing a racing O_EXCL create to fail with ERROR_ACCESS_DENIED (mapped to os.ErrPermission). Treat os.ErrPermission as lock contention / already-existing lock, matching the collision checks in cron, hooks, oauth, and securefile.
* fix(securefile): reclaim stale lock files to prevent permanent DOS
When createSecretFile fails to acquire the O_EXCL lock because it already exists, check if the file is stale (older than 10 seconds). If so, atomically reclaim it via a rename-aside check, preventing a permanent DOS after abrupt process crashes. Update the lock file to write and verify process-specific tokens, and add TestCreateSecretFileReclaimsStaleLock to cover the stale reclaim flow.
* style(securefile): gofmt const block alignment
truncateHint slices string hints using a raw byte offset (s[:max]), which can split multi-byte UTF-8 characters (like CJK characters, emojis, or accents) in half and produce invalid UTF-8 sequences. Update it to count and slice by runes to guarantee valid UTF-8, and add a unit test TestToolTitleTruncateHintRuneSafe to verify.
Currently, both gitBranchForPrompt (agent) and gitBranch (tui) check for a .git entry directly inside the current working directory. When starting Zero in nested folders/subdirectories of a repository, the check fails and the branch segment is omitted.
Export findProjectGitRoot as FindProjectGitRoot in agent package, and use it in both functions to resolve the correct repository root before checking HEAD and resolving the branch name. Also resolve relative worktree gitdirs relative to the repository root.
On Windows, creating restricted tokens with windowsWriteRestricted (0x08) instructs the kernel to skip checking the restricted SIDs list for read operations. This bypasses DenyRead path policies on NTFS. Remove the flag to enforce restricted SID constraints on both read and write operations, and add integration test assertions to verify that DenyRead paths are correctly blocked.
* fix(agent): scope self-reported incompletion to sentences about the current objective
The detector matched inability stems anywhere in the final message, so a
conversational recap of a past exchange ("You asked if I could work
autonomously ... so I couldn't actually do it at the time") downgraded a
completed run to INCOMPLETE. Match per sentence instead, drop quoted spans,
and skip sentences that retell a past exchange. The real transcript is now a
regression test, and genuine admissions still fire, both present tense and
past tense about the current task.
* feat(exec): add --no-completion-gate for conversational callers
A chat frontend driving zero exec has an operator present, so a final
message that hands the turn back ("the sandbox blocks network egress,
approve it and I'll continue") is a complete conversational answer, not an
unfinished task. The headless completion gate still stamped such runs
INCOMPLETE (exit 4) on the honest inability phrasing, and no phrase
heuristic can fix that case because the admission is true. The new flag
maps to RequireCompletionSignal=false so conversational callers can opt
out. Default behavior is unchanged.
* fix(exec): address review findings on quote stripping and --use-spec combination
stripQuoted now removes only balanced quoted spans: an opening delimiter
that never closes is restored verbatim, so a stray quote cannot swallow
the rest of the message and hide a genuine admission from the detector.
Regression tests cover dangling straight, curly, and backtick delimiters.
--no-completion-gate combined with --use-spec is now rejected at parse
time, mirroring the --self-correct check: the spec-draft path never
consults the completion gate, so the flag would be silently ignored.
* feat(workspacetrust): add exact-match trust store for project config gating
* feat(hooks): add ExcludeProject option to LoadConfig
* feat(plugins): add ExcludeProject option to Load
* feat(cli): gate project hooks and plugins behind workspace trust
Trust check lives inside newHookDispatcherWithExtra and activatePlugins
(fail-closed by construction); trustRoot is the original launch dir so a
--worktree run inherits the source repo's trust. Callers OR the skip
reports and emit one combined stderr notice.
* feat(cli): add zero trust command (trust/list/remove)
* fix(review): key spec-draft trust on the original launch dir; cover trust CLI edge branches
Spec-draft runs (zero exec --use-spec --worktree) passed the reassigned
worktree path as trustRoot, dropping a trusted repo's project hooks
(fail-closed, but breaks worktree-inheritance). Thread the captured
pre-worktree trustRoot into execSpecDraftRun. Add tests for the trust
'remove' too-many-args and --help branches, and a resolveTrust comment
noting the per-chokepoint check must not be hoisted to callers.
* test(cli): end-to-end trust-gate coverage through agent.Run and exec --worktree
Drives the real agent loop with a fake tool-calling provider so a project
beforeTool hook actually fires (or is gated away) through production code:
gate blocks a tool call via agent.Run, and --worktree / --use-spec --worktree
key trust on the original launch dir. All three are load-bearing (RED when the
gate or the trustRoot threading regresses).
* style(cli): gofmt struct alignment in exec_spec.go
* fix(workspacetrust): compare stored trust entries literally, no symlink re-resolution
External adversarial review (codex) found a false positive: IsTrusted
re-normalized stored entries on every check (Abs+EvalSymlinks), so a trusted
path later replaced by a symlink to attacker content re-resolved to the new
target and matched. Stored entries are already canonical (normalized once at
Trust time); compare them as literals and only normalize the incoming query.
Add a regression test for the symlink-retarget case.
* feat(mcp): gate project-scoped MCP servers behind workspace trust
Closes the P0 an external adversarial review (codex) found: project
./.zero/config.json stdio MCP servers spawned before the hook/plugin gate,
an ungated arbitrary-execution path that could also self-trust the workspace.
Add ExcludeProject to config.ResolveMCP (skips the project layer) and thread a
fail-closed trust check into every MCP spawn site (exec, TUI setup+refresh,
mcp tools list, mcp check); reporting sites stay ungated. Verified the sole
server-command exec (mcp/client.go) is reachable only through the now-gated
RegisterTools paths.
* test(cli): cover default-mode beforeTool firing through the trust gate
Assert a project beforeTool hook fires in the default auto and ask permission
modes (not only under unsafe), and is blocked when the workspace is untrusted,
so the gate is verified in the mode a normal run actually uses.
* docs(cli): note MCP servers in zero trust help text and doc comment
The gate covers project hooks, plugins, and MCP servers, but the help string and
runTrust doc comment listed only hooks and plugins.
* test(cli): make workspace-trust tests portable on macOS and Windows
setTrustConfigRoot set HOME on darwin, but config.UserConfigDir is XDG-first
there (=$HOME/.config), so tests wrote trust.json and user hooks.json where the
code never read them; set XDG_CONFIG_HOME on every non-Windows platform instead.
writeMarkerHook shells out to /bin/sh, absent on Windows, so its trusted-case
marker never appeared; skip it there like writeMarkerHookScript already does.
TestRunTrustAddThenList compared `trust list` output (canonicalized via
EvalSymlinks) against the raw cwd, which diverges on the Windows runner's 8.3
short names (RUNNER~1 -> runneradmin); compare the resolved path, and add
TestRunTrustListShowsCanonicalPath to exercise that via a symlink on any platform.
* feat(cli): surface the project MCP skip in the workspace-trust notice
Project MCP servers were dropped silently in an untrusted workspace: the trust
notice covered hooks and plugins but not MCP, and app.go discarded the
resolveTrust error. registerMCPToolsForWorkspace now returns a trustSkip, gated
on a new projectMCPConfigExists check plus the previously-dropped store-read
error, and emitTrustNotice is variadic so the exec, spec-draft, interactive, and
`mcp tools list` paths all fold in the MCP skip.
The skip drives the notice only; the gate stays on resolveTrust/excludeProject,
so a trusted workspace is unaffected and an untrusted one still fails closed.
* fix(cli): print zero trust --help to stdout with a success exit
zero trust -h/--help/help routed usage to stderr with the usage exit code, unlike
every other subcommand (mcp, sandbox, skills, cron, ...) which prints help to
stdout and returns success. Align it; the unknown-subcommand path still writes to
stderr with the usage exit code.
* fix(cli): gate oauth MCP login and surface mcp-check trust notice
Two project-scoped MCP activation sites were left outside the workspace
trust gate.
zero mcp oauth login loaded a project-defined OAuth server from an
untrusted workspace and ran the full discovery/registration/token-exchange
flow against its configured URL. Gate resolveOAuthServer with resolveTrust
so an untrusted clone's ./.zero/config.json OAuth server is dropped before
login, and emit the same one-line trust notice the other spawn sites use.
zero mcp check already gated the project layer but discarded the trust-check
result and never emitted the notice, so a gated project server read as a
bare "not configured". Emit the notice on the not-found branch.
The three remaining ungated resolveMCPConfig(cwd, false) sites (backends
doctor/snapshot and the extensions listing) are enumeration-only and stay
ungated.
* test(cli): adapt MCP startup-skip tests to two-arg resolveMCPConfig
---------
Co-authored-by: Kevin Codex <[email protected]>
* fix(update): clearer error on unsupported release platform (android/termux)
zero update/upgrade fails on Android/Termux because GOOS "android" has
no published release archive. Replace the generic "unsupported release
platform: android" error with a clear, actionable message explaining
that no release builds are published for the platform, listing the
supported platforms, and suggesting a rebuild from source. The command
still fails gracefully (clean stderr message, exit 1, no panic/stack
trace) rather than crashing.
Fixes#598
* fix(update): address review feedback on unsupported-platform error
- Condense the explanatory comment for the unsupported GOOS branch.
- Replace the Unicode em dash with plain ASCII punctuation.
- Align internal/release.ReleasePlatform's error message with the
updater's so both flows give identical guidance.
* fix(update): npm-based upgrade guidance for platforms without release assets
Address review feedback: the previous message implied Android/Termux was an unsupported platform and told users to rebuild from source. Termux runs zero fine via the npm wrapper; it just has no self-updating release archive. Point users at 'npm update -g @gitlawb/zero', the documented Termux install/upgrade path, instead.
* fix(modelregistry): reject oversized models.dev cache responses
Read modelsDevFetchLimit+1 bytes instead of modelsDevFetchLimit. If the
response exceeds the 32 MiB guard, return an error and leave any
existing cache untouched.
Fixes#510
* address review: include byte count in error, make oversized test hermetic
- error message now reports actual bytes read vs the limit
- test payload is limit+1 bytes (matches the reported repro)
- test explicitly clears ZERO_DISABLE_MODELS_FETCH so it exercises
the oversized path regardless of the outer environment
* fix: warn about untracked scratch files left behind after a run
Fixes#551. Headless exec runs could leave behind debug/scratch files
(e.g. _fix_test.py, _debug.py) created by write_file during iteration,
which would then be silently swept up by a later git add -A.
- FileTracker now records brand-new files (RecordCreated/CreatedFiles),
distinct from the existing version-baseline tracking used for
conflict detection.
- write_file records a path as created only on first write, not on
overwrite of an existing file.
- After a headless run completes, exec.go checks git status for any
created file still present and untracked, and surfaces a warning via
the existing writer.warning() (works across text/-o json/stream-json).
No files are deleted automatically — a wrong guess at 'scratch' could
destroy real work, so this only surfaces what git already knows.
Co-authored-by: Copilot App <[email protected]>
* fix: address scratch warning review comments
* test: normalize created file tracker paths
* fix: track apply_patch-created scratch files
* fix: address scratch warning review feedback
* fix: tighten scratch warning lifecycle
---------
Co-authored-by: Copilot App <[email protected]>
* merge: rebase onto main, combine Windows shell guidance with #476's MSYS text
#476 merged into main and rewrote the same "Shell syntax: Windows cmd.exe..."
guidance string in system_prompt.go and shellGuidanceForGOOS in
shell_runtime.go that this branch also edits (this PR's fix for the broken
single-quoted quoting examples). Combined both into one coherent string in
each spot: the double-quote/single-quote metachar rule with correct
examples, plus #476's MSYS coreutils warning and require_escalated
mention. Merged the corresponding test assertions in loop_test.go the
same way.
internal/imageinput/clipboard.go, internal/tools/bash_tool_test.go, and
internal/tools/exec_command_test.go applied cleanly with no overlap.
* test(tools): add host shell syntax description test for exec_command
* chore: retrigger CI after infra flake
* style(tools): gofmt exec_command_test.go
---------
Co-authored-by: Vasanthdev2004 <[email protected]>
* fix(mcp): skip RFC 8414 discovery when OAuth endpoints are preconfigured
resolveAuthorizationServer performed a real network metadata discovery call even when the MCP server config already supplied both the authorization and token endpoints. When discovery blocks (offline, unreachable issuer, or test fixtures pointing at invalid hosts) the whole Login flow hangs and never reaches the browser/loopback step, so mcp oauth login could not complete.
When both endpoints are present, return them directly and skip discovery entirely; discovery remains the fallback for any endpoint the config leaves blank. This also fixes TestRunMCPOAuthLoginStoresTokens, which timed out because the loopback callback was never driven (the URL was never printed).
Removes scratch test files left over from investigation (internal/mcp/scratch_cli_test.go, internal/cli/scratch_oauth_test.go).
* fix: trim fast-path endpoints, restore fallback test, fix picker_test compile error
- Trim AuthorizationEndpoint/TokenEndpoint/RegistrationEndpoint in the
skip-discovery fast path to match the fallback path (P3 from @jatmn).
- Update TestResolveEndpointsFallsBackToConfig to only configure one
endpoint so discovery still runs and fails, exercising the fallback.
- Add TestResolveEndpointsSkipsDiscoveryWhenBothConfigured to verify
discovery is skipped when both endpoints are configured (P2 from @jatmn).
- Fix picker_test.go:771 to capture 4 return values from switchProviderModel
(pre-existing compile error on main blocking CI smoke).
* test(mcp): prove discovery is skipped via request-failing transport
The skip-discovery test passed even without the fast path, because
discovery against the invalid URL failed instantly and the config
fallback produced identical trimmed endpoints. Use a transport that
fails the test on any outbound request so the fast path is actually
what is being verified.
---------
Co-authored-by: gnanam1990 <[email protected]>
* test(config): make Resolve local-control tests env-independent
Resolve reads ZERO_PROVIDER from the environment via applyEnv, then fails with "no active provider configured" when that provider is not present in the config. This made TestResolveLocalControlFromUserConfig, TestResolveLocalControlIgnoresProjectOptIn, TestLocalControlDefaultsBrowserAndTerminalOn, and TestLocalControlExplicitGlobalDisable fail whenever the process environment happened to export ZERO_PROVIDER (e.g. an agent session), even though the tests never intended to exercise provider resolution.
Pass Env: map[string]string{} to the affected Resolve calls so the tests assert only local-control resolution and are unaffected by ambient environment variables. Verified the four tests pass even with ZERO_PROVIDER=gitlawb-opengateway set.
* fix(tui): align picker_test switchProviderModel call with 4-value return
* Fix OpenGateway credential lookup, stream error code mapping, and model catalog
* fix(providers): map semantic OpenAI stream error codes, not just numeric
Addresses jatmn's review (following up on CodeRabbit's original request):
the streamed error classifier only recognized numeric codes ("429", 401,
403 as string/float64/int), so semantic string codes OpenAI-compatible
providers commonly send instead (rate_limit_exceeded, insufficient_quota,
invalid_api_key) fell back to a generic 500 and lost their rate-limit/auth
classification.
Unified the numeric and semantic mappings into one
openAIStreamErrorStatusByCode table so both forms of the same condition
classify identically. insufficient_quota maps to 429, matching OpenAI's
own API behavior for quota-exceeded errors.
Added TestStreamCompletionClassifiesStreamErrorCode covering both forms.
* test(providermodelcatalog): cover the new gitlawb-opengateway catalog IDs
Addresses jatmn's review (completing CodeRabbit's original request):
TestModelsAreProviderScoped only asserted mimo-v2.5-pro and tencent/hy3
for gitlawb-opengateway, missing the other newly curated IDs added in
this PR (xiaomi/mimo-v2.5-pro, minimax/minimax-m3, qwen/qwen3.7-max,
google/gemini-3.1-flash-lite, z-ai/glm-5.2,
nvidia/nemotron-3-ultra-550b-a55b:free). A typo or accidental removal
in any of them would not have failed a test.
* test: cover the last catalog ID and probeProviderHealth's stored-key wrap
Addresses jatmn's second follow-up review on PR #580:
- TestModelsAreProviderScoped still missed xiaomi/mimo-v2.5 (distinct
from xiaomi/mimo-v2.5-pro) among the newly curated gitlawb-opengateway
catalog entries.
- fillAppDeps wraps probeProviderHealth the same way it wraps
newProvider, applying the stored credential-store key before
delegating, but only newProvider's wrap had a regression test
(TestFillAppDepsWrapsNewProviderWithStoredKey). Added the parallel
TestFillAppDepsWrapsProbeProviderHealthWithStoredKey so the health-check
path used by `zero providers check --connectivity`, `zero doctor
--connectivity`, and the TUI doctor panel can't silently regress back
to keyless probes.
* fix(tui): paste protection for Termux char-by-char paste
Termux context menu paste injects characters one at a time, including
newlines as raw KeyEnter events. This caused every pasted multiline
text to be submitted prematurely before the user could review it.
The fix adds a timing-based paste detector that works alongside the
existing bracketed-paste handler (tea.PasteMsg):
- Three new model fields: lastCharTime, lastKeyTime, burstCount
- Timing trackers run before any early return in case tea.KeyPressMsg
- Enter handler checks burstCount > 1 (2+ chars within 100ms = paste)
- Composer check gated with burstCount > 0 to avoid infinite composer lock
- burstCount reset at every intentional action (permission, picker,
submit, wizard, spec review, etc.) so normal typing is unaffected
The 100ms window catches all observed Termux paste speeds while
remaining under typical human inter-key intervals. The burstCount
resets at modal boundaries prevent false triggers in tests and
real usage.
* fix(tui): reset burstCount in Enter handler wizard paths
CodeRabbit review found 3 Enter-handler paths (providerWizard,
mcpAddWizard, mcpManager) that delegated without resetting burstCount.
The equivalent post-switch paths already had the reset but the
Enter-specific branches were missing it, creating a window where
stale burst state could cause false paste detection.
* fix(tui): raise paste burst threshold to >2, use injectable clock
* fix(tui): gate paste detection to Termux only, add bidirectional tests
- Paste burst detection (insert newline on rapid Enter) now only
activates under Termux (TERMUX_VERSION env var), so fast desktop
typing never false-triggers.
- Switched burst tracker from time.Now() to m.now() for deterministic
testing.
- Added 3 tests:
• TestTermuxBurstInsertsNewline — Termux + 3 chars + Enter → newline
• TestTermuxFastTypingSubmits — Termux + 2 chars + Enter → submit
• TestDesktopBurstNotAffected — Desktop + 3 chars + Enter → submit
* test: extract burst test helper, fix assertions, add reset-after-submit test
- Extracted shared burstTestModel helper + typeKeys helper to eliminate
duplication across the 3 tests (CodeRabbit nitpick).
- Added m.pending assertion to submit tests (cmd != nil alone is weak).
- Added TestBurstResetAfterSubmit verifying burstCount is properly
reset after submission — 2 chars + Enter still submits.
* test: add explicit pending check and lastKeyTime reset in burst reset test
* fix(tui): align composer-multiline guard with same burstCount > 2 threshold
* test: add TestMultilineBurstSubmits for multiline composer + 2-char burst
---------
Co-authored-by: PatrickNoFilter <[email protected]>
* fix(update): support safe symlink extraction during updates
Allow extracting symlinks from TarGz and Zip update archives to support Unix npm shim symlinks. Prevent path traversal vulnerabilities by rejecting absolute link targets and verifying that relative link targets do not escape the destination directory.
* revert(update): keep zip symlink entries rejected
Addresses jatmn's review: the stated bug (standalone update extraction
broken on Unix/macOS) is about .tar.gz archives; the zip path handles
Windows release archives. Adding zip symlink support alongside the
tar.gz fix reversed the existing zip symlink-rejection hardening
(TestExtractZipRejectsSymlinkEntry) and failed Windows CI, since
filepath.IsAbs does not reliably reject a slash-rooted target like
"/some/other/path" on Windows (which needs a drive letter or UNC
prefix to be absolute).
Reverted extractZip's symlink handling back to unconditional rejection
and removed the two zip-symlink tests that covered it. The tar.gz
symlink support and its tests (the actual fix for the reported bug)
are unchanged.
* fix(auth): persist OpenRouter API key after login
zero auth openrouter minted a key but only printed it, leaving the
provider unusable unless the user manually exported it or edited
config by hand. Now the key is saved into the credential store, the
provider profile is created/activated, and stale inline/env key
fields are cleared. Auth-failure hints also now point at zero setup
and zero auth openrouter instead of the vaguer zero auth.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(auth): sanitize minted key and roll back orphaned credential on save failure
Addresses PR review comments: trim the OpenRouter key right after
login so both the save path and the manual fallback instructions use
the sanitized value, and delete the just-written credential-store
entry if persisting the stored-key marker in config.json fails, so a
partial failure doesn't leave an orphaned secret.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
* fix(sandbox): give Windows-appropriate suggestions when blocking interactive commands
DetectInteractiveCommand already receives goos, but its remediation
text for pagers (less/more/most), process monitors (top/htop/btop/
btm), and `tail -f` hardcoded POSIX-only alternatives (cat, head,
tail -n N, ps aux) regardless of platform. On Windows none of those
exist either, so blocking e.g. `type file | more` correctly stopped
the interactive command but then pointed the model at three more
commands cmd.exe doesn't have — directly undermining the guidance
this check exists to give. Reported in the wild (screenshot showing
`more` blocked with a "use cat, head, or tail -n N" suggestion on a
Windows session).
Adds an optional windowsSuggestion per program/segment, used in
place of the POSIX suggestion when goos == "windows" — `type` for
pagers, `tasklist` for process monitors, the read_file tool for
tail -f. Non-Windows suggestions are unchanged.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* test(sandbox): cover remaining Windows suggestion overrides and fix GOOS-blind assertion
TestBashToolBlocksInteractiveCommandThroughSandbox asserted the pager
guard message always contains "cat", but on Windows it now correctly
suggests "type" — breaking Smoke (windows-latest) CI. Make the
assertion GOOS-aware. Also extend the safe_command windowsSuggestion
test table to cover most/top/btop/btm, which were missing alongside
the already-tested more/less/htop/tail -f.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
* fix(tools): block MSYS and WSL shells under the Windows sandbox
Git-for-Windows bash.exe and sh.exe are MSYS binaries: the MSYS runtime
secures its signal pipe and shared-memory sections with explicit DACLs
granting only the user, Administrators, and SYSTEM (sigproc.cc
sigproc_init -> sec_user_nih), so under the WRITE_RESTRICTED token the
second access check can never pass and startup dies with "couldn't
create signal pipe" or "CreateFileMapping <SID>.1", Win32 error 5. The
System32 WSL bash launcher fails equivalently: the restricted token
cannot connect to the WSL service (Bash/Service/CreateInstance/
E_ACCESSDENIED). Every executable a bare bash can resolve to fails, so
block bash and sh preflight like the MSYS coreutils from #476, mention
the indirect spawn paths (git hooks, git/gh credential helpers) in the
shared suggestion, detect the WSL launcher's UTF-16LE service-denied
stderr, and document the root cause in the runner's KNOWN LIMITATION
comment beside the Schannel note.
* fix(tui): align picker_test switchProviderModel call with 4-value return
* test(tools): cover sh.exe in Windows shell blocking test
#568 added a bool return to switchProviderModel (model, string, bool,
tea.Cmd) and updated every call site except picker_test.go:771, which
still assigned three values. That broke the internal/tui test build
and turned Smoke red on main. Add the missing receiver here.
* fix: gitignore Windows sandbox helpers and npm version marker
Add three missing build artifacts to .gitignore:
- /zero-windows-command-runner.exe — helper built by cmd/zero-windows-command-runner
- /zero-windows-sandbox-setup.exe — helper built by cmd/zero-windows-sandbox-setup
- /.zero-binary-version — marker file written by scripts/postinstall.mjs during npm install
These are generated files analogous to the already-ignored /zero, /zero.exe,
zero-termux-arm64, and zero-linux-sandbox entries.
* fix: gitignore the Linux zero-seccomp helper too
Addresses jatmn's review: scripts/postinstall.mjs copies zero-seccomp
next to zero-linux-sandbox, and README.md documents building it, but
.gitignore only covered the Windows helpers and zero-linux-sandbox.
A Linux source build or npm postinstall could still leave this
generated helper visible to git status.
* fix(tui): bypass toggleSidebar and toggleMouse global shortcuts when composer is non-empty
Allows standard readline/Emacs cursor movement keybindings (Ctrl+B and Ctrl+E) to work inside the prompt composer when typing a prompt. This prevents these keys from being globally intercepted to toggle the sidebar or mouse mode when the composer is populated.
* fix(tui): remove trailing whitespace, scope composer guard to default chords
Addresses jatmn's review:
- Removed a trailing-whitespace-only blank line in
keybinding_help_test.go that was failing the diff-hygiene CI check.
- The composerValue() == "" guard on toggleMouse/toggleSidebar applied
to the whole keyMatch(...) result, so a user who remaps either
binding to a chord that doesn't conflict with readline navigation
(Ctrl+E/Ctrl+B) lost that shortcut entirely while typing. The guard
now only applies when the match came through the default chord;
a remapped binding fires regardless of composer state.
Added TestRemappedToggleBindingsIgnoreComposerGuard covering both
bindings.
* fix(tui): treat an explicit default-chord config the same as unset
Addresses jatmn's follow-up review: the prior fix checked
!b.isZero() to decide whether a binding is a genuine, non-conflicting
remap, but a user can explicitly configure toggleMouse: "ctrl+e" or
toggleSidebar: "ctrl+b" (the same chord as the built-in default).
parseBinding gives that a non-zero parsedBinding, so isZero() alone
wrongly treated it as safe to bypass the composer guard, letting
Ctrl+E/Ctrl+B hijack keystrokes mid-sentence again.
requiresEmptyComposer now compares the resolved binding against the
canonical default chord directly (parsedBinding is a plain comparable
struct), so both "unset" and "explicitly re-affirmed to the same
chord" require an empty composer; only a binding that resolves to a
genuinely different chord bypasses it.
Added TestExplicitDefaultChordConfigStillRequiresEmptyComposer.
* refactor(tui): dedupe the composer-gated toggle check
CodeRabbit nitpick: the toggleMouse/toggleSidebar dispatch cases both
repeated !requiresEmptyComposer(...) || m.composerValue() == "" inline.
Extracted canFireComposerGatedToggle so both cases share one
expression.
* feat(tui): remember recent provider+model selections in /model picker
The /model picker's "Recent" section only ever showed the currently active
model, making it hard to switch back to a previously used provider+model pair
once you've moved on to something else (especially when the same model name
exists across multiple providers). Track a short automatic history of
provider-qualified switches instead: newest first, capped at 5, deduped by
provider+model pair (not model name alone), and selectable across providers
like any other picker row.
Closes Gitlawb/zero#562
Co-authored-by: Cursor <[email protected]>
* chore: gofmt fixes
* fix(tui): restore favorites one-row-per-model semantics in /model picker
Favorites de-dup had switched to the provider+model key, letting the same
favorited model ID surface once per provider. Revert to per-Value de-dup for
Favorites, and skip any model ID already shown under Favorites from Recent
and Catalog so favorited models don't reappear in a second group. Recent and
Catalog still de-dup among themselves by provider+model so cross-provider
rows remain distinct.
Also correct the recordRecentModel doc comment: in-memory history is updated
even without a user config path (only persistence is skipped), not a no-op.
Addresses Copilot review comments on #568.
* fix(tui): set OwnerProvider on registry catalog rows; dedupe recent-model normalization
- registryModelPickerItem now tags rows with OwnerProvider (the registry
entry's canonical provider), fixing the plain-registry fallback path (no
saved providers resolved any models) where two providers offering the same
model id would collide in pickerItemDedupKey and silently drop a row.
- Export config.NormalizeRecentModels and have
internal/tui/picker.go's normalizeRecentModelEntries delegate to it instead
of maintaining an independent copy of the same trim/dedupe/cap logic, so the
TUI and persisted-config normalization can never drift apart.
- Add regression tests for both.
Addresses remaining CodeRabbit review comments on #568.
* fix(tui): batch recent-model persistence into one write per switch; tighten tests
- Add recordRecentModels(pairs...), a batched form of recordRecentModel that
normalizes and persists exactly once per switch instead of twice (outgoing
+ incoming pair each triggered their own read-modify-write to
userConfigPath). handleModelCommand and switchProviderModel now call it
once with both pairs.
- picker_test.go: capture and assert switchProviderModel's status return in
TestSwitchProviderModelRecordsRecentHistory instead of discarding it, so a
failed switch fails at the actual point of failure.
- picker_test.go: also assert the persisted config (not just in-memory
recentModels) after the reorder/dedupe case in
TestModelCommandRecordsAndPersistsRecentHistory.
- picker_test.go: pin down the surviving Favorites row's OwnerProvider in
TestAssembleModelPickerItemsFavoritesDedupByModelIDAndHideFromOtherGroups,
and drop the tautological otherRecent assertion (other-model was never
added to recent, so it could never fail).
- resolver_test.go: add TestResolveIgnoresProjectOnlyRecentModels, covering
the case where only project config sets preferences.recentModels and user
config omits it — resolved value must stay empty.
Addresses remaining CodeRabbit nitpick comments on #568.
* fix(tui): route unresolvable OwnerProvider rows in-place; align recent-history keys
- choosePicker: only treat a picker row as cross-provider when its
OwnerProvider resolves to an actual saved provider. Registry-fallback
catalog rows (tagged with a provider *kind* string) and stale "Recent"
rows referencing a since-removed provider now fall back to an in-place
model switch instead of erroring "unknown provider".
- handleModelCommand/switchProviderModel: capture and record recent-history
entries using m.providerName (the resolved value recentModelPairsForPicker
pins the active row with) instead of the raw, possibly-empty provider
profile Name, so a no-name provider profile can no longer produce a
duplicate "Recent" row for the same switch.
- newModelPicker: scope the registry-fallback's active-model exclusion by
provider too, consistent with the provider-aware de-dup used elsewhere.
- recentModelPairsForPicker: delegate to config.NormalizeRecentModels
instead of reimplementing the same trim/dedup/cap logic.
- Remove the unused recordRecentModel wrapper.
- assembleModelPickerItems: build Favorites from recent/catalog directly
instead of allocating a concatenated copy of both slices.
---------
Co-authored-by: Cursor <[email protected]>
* fix(mcp): silence startup warning for unconfigured default servers
Firecrawl is seeded as an always-on default MCP server with no
credentials (config.DefaultMCPServers), so every fresh install prints
a loud "warning: MCP server firecrawl unavailable, skipped: ... HTTP
401" line on first launch, even though the user never configured or
touched it.
Add config.IsUnconfiguredDefault to detect a server whose resolved
config is byte-identical to its built-in default, thread it through
mcp.Server and mcp.SkippedServer, and skip the startup warning in
app.go when a skipped server is an untouched default. Servers the
user actually configured still warn loudly on failure.
Fixes#552
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(mcp): address review feedback on #563
- mergeMCPServer now marks disabledSet on the merged config whenever a
disabled toggle was explicitly applied, so an explicit re-enable
(e.g. `zero mcp enable firecrawl`) is no longer indistinguishable
from an untouched default even though the resolved value matches —
the toggle itself is the "configured" signal for IsUnconfiguredDefault.
- Tighten TestRegisterToolsFlagsUnconfiguredDefaultOnSkip to assert
presence in the Skipped() map before checking UnconfiguredDefault,
so a missing entry fails loudly instead of comparing zero values.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(config): close IsUnconfiguredDefault gap for redeclared default values
IsUnconfiguredDefault compared the resolved MCPServerConfig against
DefaultMCPServers() with reflect.DeepEqual. A user who explicitly
redeclares a default's exact field values in their config JSON (e.g.
copying firecrawl's default type/url verbatim, perhaps intending to add
credentials later) produced a resolved value byte-identical to the
default, so the comparison silently treated a real, deliberate config
entry as "unconfigured" — the same trap the disabled/enabled toggle
case was already patched for, just left open for every other field.
Add an unexported `configured` field to MCPServerConfig, set whenever
MCPServerConfig.UnmarshalJSON actually runs for a server key (built-in
defaults are seeded as Go struct literals and never unmarshaled, so
they start unconfigured). IsUnconfiguredDefault now requires both
!configured and the value still matching the default — the value check
remains as a fallback for callers that construct MCPServerConfig
directly rather than through the JSON/merge pipeline, where configured
is always false.
This also lets mergeMCPServer drop the disabledSet-specific
"mark as touched" workaround added for the toggle case, since
`configured` now generalizes it.
Adds TestResolveMCPExplicitRedeclareOfDefaultValuesIsNotUnconfiguredDefault
covering the redeclared-values case.
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
* fix(provider): stop dropping custom no-auth providers on restart
Custom OpenAI/Anthropic-compatible catalog entries hardcode
RequiresAuth as a template default for the setup wizard, not a fact
about the user's actual endpoint. Leaving the credential step blank
(e.g. a local llama.cpp server) still stamped the saved profile with
a guessed OPENAI_API_KEY/ANTHROPIC_API_KEY env var, and that same
static RequiresAuth flag then made the profile look like it was
missing a required credential on the next run, silently filtering it
out of /model.
Add a Custom flag to the catalog descriptor so both the wizard
(write side) and setupMissingCredentialEnv (read side) judge these
profiles by their own explicit APIKeyEnv instead of the catalog's
static default.
Fixes#555
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Co-authored-by: Pierre Bruno <[email protected]>
* fix(provider): self-heal legacy custom profiles stamped by the old wizard bug
CodeRabbit flagged that profiles saved before this fix could already
carry the phantom APIKeyEnv the old wizard stamped on every custom
provider (e.g. OPENAI_API_KEY), and the new custom-branch logic would
keep treating any non-empty APIKeyEnv as an explicit requirement —
leaving already-affected users' providers filtered out of /model even
after upgrading.
Treat an APIKeyEnv that exactly matches the catalog's own guessed
default as indistinguishable from unset, so already-broken saved
profiles self-heal without a config.json migration. A deliberately
different env var name (e.g. via --api-key-env) still means auth is
required.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Co-authored-by: Pierre Bruno <[email protected]>
* fix(provider): centralize custom-endpoint credential logic, fix two more surfaces
Copilot's review of the earlier fix found the same RequiresAuth-as-
authoritative bug in two more places that decide whether a saved
provider needs a credential:
- validateProviderRuntimeReady (zero providers check) would hard-error
a no-auth custom endpoint with "requires API key".
- providerWizardCredentialLabel and providerCredentialRequired (/providers
status, wizard summary) would still claim/warn that one was needed.
Added config.ProviderProfile.MissingCredentialEnv as the single source
of truth for this decision (custom-endpoint self-heal included), and
had setupMissingCredentialEnv and validateProviderRuntimeReady delegate
to it. providerWizardCredentialLabel and providerCredentialRequired
take the same Custom-aware fix directly, since their non-catalog
fallback path uses runtime-resolved provider-kind metadata that
shouldn't be collapsed into the canonical helper.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Co-authored-by: Pierre Bruno <[email protected]>
* fix(ci): request pinned Go toolchain directly
Co-authored-by: Pierre Bruno <[email protected]>
---------
Co-authored-by: Amp <[email protected]>
* Introduce opencode-go-anthropic that uses anthropic SDK for qwen and minimax models.
* Adjust naming to match convention format.
* Adjut naming to match convention.
* Update name for opencode-go-anthropic-compatible
* Update curated models for opencode anthropic and adjust test.
* Adjust filter for opencode anthropic provider and cover with tests.
* Adjust discovery model filtering - tolerate ModlIDAllowedForProvider rules.
* Change model descriptors to lowercase for minimax models.
* CLI call triggers model filtering for opencode-go-anthropic-compatible.
* feat: gate runtime model resolution on provider allowlist (PR #544 P2)
Apply ModelIDAllowedForProvider(profile.CatalogID, model) at profile
resolution and ResolveRuntimeMetadata so TUI /model and headless
--model overrides cannot escape the provider-scoped filter for
catalog-backed profiles such as opencode-go-anthropic-compatible.
resolveProfile is the single funnel for both New() and
ResolveRuntimeMetadata, so a single validation call in each return
path covers every caller (TUI command_center, exec, provider health,
context report, zerocommands). Restricted providers reject; the
default branch keeps unrestricted providers unchanged.
less/more/most now suggest `type` instead of cat/head/tail on Windows,
since those tools don't exist there. Drop `more` from the MSYS sandbox
suggestion too, since more is itself blocked as an interactive pager
and would send the model straight into that guard next.
detectShellCommandIssue also strips double-quoted spans and cmd.exe
caret escapes before matching the bash-style `cd /foo` pattern, so a
cd-shaped string inside a quoted argument (e.g. a commit message) isn't
mistaken for an actual command.
* ## Speech-to-text dictation
Adds voice dictation to Zero. Run `/voice` to enter hold-to-record mode, then hold **Space** to dictate and release to transcribe (run `/voice` again to turn it off so Space types normally). Pick a backend with `/stt-model`.
### Backends
- **Local (sherpa-onnx)** — offline, no API key. Auto-downloads a model via `/stt-model`; the engine binary is fetched once and shared across models. Family auto-detection covers the mainstream offline families (Whisper, transducer/Zipformer, Moonshine, Paraformer, SenseVoice, and the CTC families FireRedASR, NeMo, TeleSpeech, WeNet, Zipformer-CTC, Dolphin, Omnilingual — plus the encoder/decoder families Canary and FireRedASR-AED).
- **Cloud batch** — Groq and OpenAI Whisper. Keys resolve from a configured provider, the encrypted credential store, or the env var; a missing/invalid key is entered inline.
- **Streaming** — a warm local sherpa-onnx websocket server (live transcript as you speak) on desktop, or Deepgram / OpenAI Realtime.
### Highlights
- Live audio-reactive waveform while recording (real mic levels when streaming; a synthesized wave for batch).
- `/stt-model` download chooser: recommended models first, Live/Batch tag per row, size + download state, and a search filter.
- Streaming transcript is inserted live into the composer and committed on stop; batch inserts on stop. Insert-for-review by default (`stt.autoSubmit` to fire straight at the agent).
- The warm streaming server is released when `/voice` is turned off and on exit.
### Implementation notes
- No CGO and no bundled runtimes — every OS integration shells out via `exec.Command` with discrete argv (never a shell string).
- The streaming server manager mirrors the LSP manager's warm-subprocess lifecycle.
- OS-boundary code (recorders, transcribers, server) is unit-tested via injected fakes; real microphone / websocket behavior is covered by env-gated smoke tests.
### Testing
- `go build ./...`, `go vet ./...`, and the config / dictation / cli / tui suites pass.
- Verified end-to-end against real downloaded binaries + models (offline batch, streaming websocket server, and the model auto-download), including the FireRedASR-CTC and NeMo streaming transducer families.
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit
* **New Features**
* Added speech-to-text dictation with both batch and streaming modes, supporting local (sherpa-onnx) and cloud providers, including WAV/M4A detection.
* Introduced voice dictation mode with STT model selection, local engine/model auto-download, and an inline prompt to save cloud API keys.
* Added new TUI commands for voice/STT model selection and enabled F1–F12 key bindings.
* **Bug Fixes**
* Improved streaming dictation to preserve accumulated transcript partials and keep the editor region consistent.
* Refined TUI status/picker rendering, including clearer loading/search behavior and more reliable selection alignment.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
* fix(dictation): address CodeRabbit review feedback
- validateSTTConfig: add NumThreads bounds check
- downloadVerifyExtract: stage extraction into a sibling temp dir,
atomic-rename into destDir on success so a partial extract cannot
leave a truncated engine binary that passes fileExists() on next run
- startProcess: close pipe fds when cmd.Start() fails
- ServerManager.Shutdown: honor the caller's context deadline
- audioSpillDir: tighten any pre-existing directory to 0700
- startVariantDownload: refuse re-entry while a download is in flight
- maybeOpenSTTDownloadPicker: defer Provider=local persistence to
applyEngineComponents (commit-on-success), matching the cloud flow
- fetchSTTModelsCmd: bound with a 30s timeout client+context
- newSTTDownloadPickerFrom.isCurrent: compare path segments via
filepath.Base to avoid substring false-positives
* fix(dictation): harden recorder.Stop, Esc-cancel priority, streaming region
Three correctness issues from PR review:
1) recorder.Stop() panicked (nil-deref) when called on a StartStreaming
session because StartStreaming intentionally leaves r.proc nil. Reject
that misuse with a clear error. Termux's batch path also leaves
r.proc nil by design — keep that path working.
2) Esc was consumed by an active dictation session before the
confirming second Esc could cancel a pending run, so a user
mid-recording who double-Esc's to kill the run found the first Esc
swallowed by dictation. Gate dictation-cancel on the Esc not being
a pending-run confirm.
3) applyStreamingText hard-reset the cursor to regionStart on every
partial, yanking focus away from users editing elsewhere in the
composer, and stored regionStart/End as absolute indices that became
stale the moment the user typed or pasted before the live region.
Preserve the cursor when it's outside the region, and snapshot the
prefix BEFORE the region so the next partial can detect external
edits and shift [start,end) to stay aligned.
* feat(providers): OAuth login profiles and list-first /provider manager
Make multiple providers a first-class workflow: every OAuth login is now a
visible, switchable provider, and /provider manages the whole lifecycle.
OAuth logins become provider profiles:
- zero auth chatgpt / zero auth login <preset> and all four TUI login paths
(wizard + first-run, browser + device-code) now upsert a catalog-backed
profile via config.EnsureCatalogProvider after storing the token — without
stealing the active provider. Previously the token was stored invisibly:
the provider showed up nowhere and 'providers use' failed not-found.
- Switching to an OAuth provider no longer inlines the bearer as an API key,
which suppressed the runtime resolver (no token refresh, no Codex
chatgpt-account-id header → guaranteed 401s). The credential gate now
recognizes stored OAuth logins on keyless profiles.
- providers list / current report truthful credential state: stored keys
count as configured (APIKeySet honors apiKeyStored) and token logins show
'oauth login'; providers check accepts an OAuth login as a credential.
List-first /provider manager (TUI):
- /provider opens the saved-provider list: Enter activates via the shared
switch path, 'a' adds (existing wizard), 'e' opens a field-level editor
(endpoint, model, key, description, and safe rename — the stored key,
activeProvider, and live ZERO_PROVIDER follow the new name), 'd' deletes
with an inline confirm and stored-key cleanup. Esc walks back one level.
- Credential states resolve asynchronously so keychain reads never block
the render loop; 'stored key missing' surfaces broken keychain entries.
- Implemented as manager steps on providerWizardState so every existing
overlay gate, key route, and mouse hook covers it unchanged.
CLI + config parity:
- zero providers remove <name> (deletes the stored key, hands the active
pointer off) and zero providers rename <old> <new>.
- config.RemoveProvider / RenameProvider: read-modify-atomic-write; rename
migrates the credential-store entry write-new-then-delete-old so an
interruption can never lose the key.
* fix(providers): address PR #560 review findings
All six review findings validated as real; fixes with regression tests:
- Persist the apiKeyStored marker when a key is captured onto an existing
profile: mergeProfile deliberately ignores the marker (a project config
must not claim the user's stored keys at resolve time), so the user-config
WRITE path (UpsertProvider) now re-applies it — previously editing a key
onto an env/no-key profile stranded the secret in the credential store
while every ApplyStoredAPIKey gate skipped it. [jatmn P2]
- Let active-pointer recovery select OAuth-login providers:
firstUsableProvider now applies the same providerHasOAuthLogin check as
setupRequired/usableSavedProviders, so a stale activeProvider plus a saved
ChatGPT/xAI login recovers instead of forcing onboarding. [jatmn P2]
- Delete stored keys from the store BESIDE the config being edited:
providers remove and the TUI manager delete now use ProviderKeyStoreAt
(matching setup/rename) instead of the default-path store. [jatmn P2]
- Roll back the key migration when RenameProvider's config write fails, so
the store and config.json can't desync (key under the new name, config
still naming the old one). [CodeRabbit]
- Clear provider descriptions for real: new SetProviderDescription writes
the field verbatim (the upsert merge treats empty as unchanged); the
manager's editor routes description changes through it. [jatmn P3]
- Surface stored-key deletion failures in providers remove (stderr warning
+ keyError in the JSON payload) and the TUI delete status, instead of
letting a lingering secret read as a clean removal. [CodeRabbit]
* fix(providers): second review round — case-only rename, logout hint, test hermeticity
- Skip the credential-store migration on case-only renames (groq -> Groq):
the store normalizes names case-insensitively, so Set(new)+Delete(old)
target ONE entry and the delete removed the key that was just "moved".
The existing entry already serves the new name. [CodeRabbit]
- Point the delete flow's cleanup hint at the stored login that actually
exists: after a rename ({name:"codex", catalogID:"chatgpt"}) the token
lives under the catalog id, and `zero auth logout codex` would delete
nothing. oauthLoginName resolves the same FirstStored candidate the
runtime uses. [jatmn P3]
- Lock in APIKeyEnv semantics after a key edit: the env reference survives
as an explicit override (a SET env var wins at resolve time; the stored
key applies when it is absent). [CodeRabbit nitpick]
- Make main's new firstUsableProvider tests hermetic: they read the REAL
OAuth token store, so any developer with an actual xai/chatgpt login gets
spurious failures (found because this machine has one).
Also rebased onto main; the only conflict was comment wording in
firstUsableProvider, where the audit commit (#481) independently landed the
same OAuth-login fallback check this branch added.
* fix(tui): read stored keys from the active config's credential store
The TUI's write paths (SecureProviderProfile capture, rename migration,
manager delete) all target the store beside m.userConfigPath, but the read
paths still opened the DEFAULT-path store: profileWithCredential (feeding
the /model and manager switch gate), the model picker's discovery
credential lookup, and the manager's async credential-status probe. With a
non-default config path, a provider saved with only apiKeyStored had its
key in the config-adjacent store, so the gate rejected it as having no
usable credential and the manager showed 'stored key missing' for a
healthy profile.
All TUI stored-key reads now resolve through providerKeyStoreForPath
(config-adjacent, default-path fallback for ephemeral sessions), so the UI
reads from the same store it writes to. Regression test covers both the
credential probe and the switch path with a key seeded only beside the
config.
Also consolidates the OAuth test-store seeding on withAuthStore (now pins
ZERO_OAUTH_STORAGE=file so an inherited keyring backend can't bypass the
temp path) per review nitpick.
* fix(providers): resolve self-review findings — atomic edits, key normalization, threading
Findings from a multi-agent code review of this branch (8 finder angles,
independently verified); all confirmed against the code, several with
empirical repros.
Correctness:
- config.EditProvider: one atomic read-modify-write for the manager's Save
(rename + fields + verbatim description + stored-key marker + key
migration with write-failure rollback). Replaces the rename→upsert→
describe sequence that (a) turned a case-only rename (groq → Groq) into a
DUPLICATE profile via UpsertProvider's case-sensitive merge, and (b) could
fail halfway, leaving config.json renamed while savedProviders and
ZERO_PROVIDER still held the old name (retry then failed 'not found').
A rename now also migrates a freshly captured replacement key (captured
under the current name; the migration moves it).
- savedProviders is mutated surgically on manager delete/edit instead of
being replaced with the raw user-config list — the session list is seeded
from the RESOLVED (project-config layered) and usability-FILTERED set, so
wholesale replacement dropped project-contributed providers and
resurrected filtered stubs for the rest of the session.
- oauth.ProviderKey normalizes provider names to lower case at the single
write/lookup choke point: `zero auth login xAI` stored provider:xAI while
every candidate lookup was case-sensitive — a successful login invisible
to the runtime. Map-based presence checks lowercase to match.
- OAuth login persistence moved onto the Update goroutine (message handlers)
— a background tea.Cmd (device-code polls land minutes later) doing an
unlocked config read-modify-write raced UI-thread writes (lost updates).
The write error is now surfaced and the in-memory mirror is skipped on
failure, so a profile that never reached disk can't show as a working
provider that vanishes on restart.
UX / consistency:
- Esc inside a manager-launched add flow walks back to the provider list
from EVERY step (was: first step only; deeper steps destroyed the overlay
and the user's place). In-flight OAuth logins are invalidated on the way
out; footers say 'Esc back'.
- switchProviderModel returns a structured outcome; the manager branches on
it instead of strings.Contains(text, "Switched to") — UI copy is no longer
a control-flow contract (a provider named 'Switched to prod' could close
the manager on a refusal).
- Manager delete's keychain subprocess + OAuth lookup run in a follow-up
tea.Cmd (the confirm keypress no longer blocks the render loop); the
credential probe takes ONE token-store snapshot instead of 2 reads per
row, and resolved states carry over across reloads (no 'checking…' flash).
- Credential-state reporting converges: /provider status recognizes OAuth
logins (was 'api key: not set' + warning for a WORKING provider), doctor
uses the shared HasConfiguredCredential definition plus OAuth awareness
(its 'matching ProviderSnapshot.APIKeySet' claim is true again), and the
manager's classifier falls through a stale stored-key marker to env/OAuth
the way the runtime does.
* Fix provider manager test formatting
* fix: address bugs found in a multi-agent codebase audit
Five parallel review agents each swept a different area (self-update,
TUI keybindings, agent/session handling, sandbox/shell safety, and
auth/credential storage) and surfaced nine concrete defects, all fixed
here with regression tests:
- sandbox: grant_scope path comparisons were case-sensitive, letting a
persisted deny grant be bypassed by spelling a Windows path with
different case
- securefile/oauth: createSecretFile's Windows ERROR_ACCESS_DENIED
handling was only applied to the oauth token store, not the shared
credstore backend; both also masked the real lock-contention error
behind a misleading "file does not exist" timeout
- cli: firstUsableProvider ignored OAuth logins when picking a
fallback provider, forcing unnecessary re-onboarding for an
already-authenticated user
- sessions: exec prompt summarization truncated on a raw byte offset,
which could split a multi-byte UTF-8 rune and embed invalid UTF-8
into a resumed session's prompt
- tui: configurable keybindings had no collision detection, so a
remapped chord could silently make a hardcoded shortcut (or another
configured binding) permanently unreachable
- tools: the Windows shell-syntax pre-flight check wasn't quote-aware
and could block harmless commands whose quoted arguments merely
contained operator-like text
- update: checksum verification didn't cross-check the archive
filename it verified against the one being extracted; extractZip
didn't reject symlink-mode entries like extractTarGz already did;
replaceBinary's restore-on-failure rename had no retry, risking a
permanently missing binary on a transient Windows file lock
* fix: close remaining PR 481 review findings
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
* fix(tools): drop stale ; test case after rebasing onto main's MSYS check
Rebasing fix/bug-hunt-findings onto main pulled in #476, which replaced
this file's regex-based windows_shell_syntax detection with a
segment/word-anchored check and deliberately stopped treating ; as a
command separator on Windows (cmd.exe doesn't split on ; the way bash
does). detectShellCommandIssue itself came out of the rebase unchanged
from main (our quote-masking version was fully superseded by #476's
more thorough segment-based approach), but the quoted-content test
this PR added still asserted the old bash-style semantics for
`echo "foo" ; ls -la`, which now correctly returns no issue since it's
a single echo invocation with literal arguments under cmd.exe, not a
real ls invocation.
---------
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
The home screen orientation line now leads with the build version
(v0.2.0 · cwd · branch · model). Release builds already inject
internal/cli.version via ldflags; dev builds show "dev".
* feat(tui): /loop foundation — command parsing, loop state, safety bounds
First slice of the /loop feature (#501): the self-contained core with no TUI
wiring yet. parseLoopCommand handles the leading-interval, trailing-"every",
bare-self-paced, and list/stop forms; loopState models one session-scoped loop
with doom-loop + iteration-cap fields; interval/self-pace clamps and duration
formatting round it out, with unit tests. Because Zero drives loops off a Go
timer rather than a cron backend, arbitrary intervals (90s, 7m) are supported
with no whitelist limitation.
* feat(tui): wire /loop into the turn lifecycle — dispatch, idle tick, continuation
Second slice of #501. Adds the controller and wires it in:
- /loop command registered (commands.go) and dispatched to handleLoopCommand.
- Loop state on the model (loops, activeLoopID, loopSeq/loopTicking poll guards,
loopNextWake/loopDone self-pace channel).
- An idle poll tick (loopTickMsg, ~1s) fires the earliest due loop only when the
session is idle — mirrors the launchQueuedMessageIfReady guard so loops and a
user's queued prompt never collide; a self-perpetuating tea.Tick that stops when
no loops remain.
- The agentResponseMsg completion seam advances a loop iteration (schedule next
wake for fixed, model delay for self-paced) with doom-loop, iteration-cap, and
done stop conditions, restarting the ticker if other loops remain.
- /loop list|stop|stop all, footer summary + list rendering, and a guard that only
a prompt or custom /command may loop (not a built-in).
Controller tests cover start/advance/self-paced-delay/done/doom/cap/stop/busy.
* feat(tui): /loop self-paced adaptive cadence + footer summary + /clear note
Third slice of #501.
- Self-paced loops now use an adaptive cadence (2m early -> 30m heartbeat as
iterations accrue), with the loopNextWake channel kept as an override for a
future explicit model signal.
- Persistent footer segment "↻ N loops · next 3:05pm" while loops are active.
- /clear notes that loops keep running (it wipes the transcript, not the session)
so a loop never silently fires into a cleared screen.
- Tests for adaptive cadence + footer summary.
* test(tui): drive /loop end-to-end through Update (submit + stale tick)
* docs(readme): add /loop to the slash-command table
* fix(tui): stop looping on repeated failure + clear loop tag on cancel
Addresses CodeRabbit review on #502:
- advanceLoop now counts consecutive failed iterations (loopMaxFailures=3) and
stops the loop, so a loop whose turns keep erroring no longer churns to the
iteration cap (the doom guard only sees identical non-empty answers).
- cancelRun clears activeLoopID and re-arms the interrupted loop for its next
cadence, so a cancelled iteration is not left running forever and the next
unrelated turn is not misattributed as that loop's completion.
* fix(tui): finish /loop v1 — real self-paced completion, session scope, leave gate
Response to the review. The self-paced path was the real gap: it advertised
model-chosen completion but nothing ever wrote the done/next-wake fields, so it
was just adaptive polling dressed up. Replaced that with an actual control
channel — each self-paced iteration runs a short maintenance playbook and ends
its reply with a control line (LOOP: DONE / LOOP: CONTINUE [interval]) that
advanceLoop parses to stop the loop or pick the next wake. The two dead model
fields are gone; done and next-wake now come from the model's output.
Rest of the review:
- loops stop on a session switch (/new, /resume) instead of firing the previous
conversation's prompt into the new one
- validateLoopTarget resolves custom /commands up front and rejects unknowns, so
a mistyped `/loop 5m /nope` isn't scheduled and then re-sent as literal text
- a one-shot confirm gates /clear and /quit while loops are active
- age-based auto-expire (24h) next to the iteration cap
- the two CodeRabbit test nits (HasPrefix footer check, bare `/loop stop` on a
single loop)
Hardening from a self-review pass over the above:
- normalize CRLF and allow a trailing note on the control line, so
`LOOP: DONE — all clean` and \r\n replies still stop the loop
- disarm the leave-confirm before the queue/exit early returns, so an interposed
prompt can't leave it falsely armed
- a loop no longer fires behind an open picker/modal; a run launched behind the
/resume picker would otherwise complete into whatever session you switch to
* fix(tui): address /loop review round 2 — ticker re-arm, self-paced command, resume guard
- stopLoop re-arms the poll ticker after removing one loop, so stopping one of
several no longer strands the rest with no tick scheduled (jatmn).
- a bare self-paced /command is rejected with a pointer to the interval form:
self-paced drives a free-form goal via the playbook + LOOP: control line, which
a custom command's expanded template never carries, so it would silently
degrade to adaptive polling (jatmn).
- /resume only tears loops down on an actual session-id change; /resume latest or
/resume <currentID> can resolve to the already-active session, whose loops are
its own, not a "previous" session's (CodeRabbit).
- tests: skipped-behind-modal loop stays scheduled, footer carries the next-wake
time, the confirmed /clear actually wipes the transcript, plus multi-loop stop
keeping the ticker alive and self-paced-/command rejection.
* docs(readme): clarify /loop runs a prompt or custom command, not built-ins
- Remove unused safeRequestedPrefix, safeShellCommandTokens
(internal/agent/command_prefix.go), and truncateHeadTail
(internal/tools/bash.go): each was a thin wrapper with no remaining
callers.
- Drop an ineffectual assignment in stringListArgWebSearch: every
return path after it already sets the named return explicitly.
- Simplify a negated-and condition in
partition_cache_stable_test.go via De Morgan's law.
- Use strings.Contains instead of strings.Index(...) < 0 in
edit_replacers.go.
- Fix error string style (ST1005): lowercase "unsafe redirect URL"
and drop the trailing period from webFetchPublicOnlyHint.
Skipped the errcheck findings on this pass (unchecked defer
Close()/Write() errors): those are stylistic, not correctness bugs,
and this repo's CI only runs go vet, not golangci-lint.
go vet, gofmt, govulncheck, and deadcode don't catch unused private
functions/assignments reachable within a package but never called, or
staticcheck-style correctness/readability issues. deadcode's whole-program
reachability analysis specifically missed several of these; golangci-lint's
truncateHeadTail nitpick on #476 assumed it would fail CI, but nothing in
this repo actually ran it.
Scoped to unused, ineffassign, and staticcheck rather than the full default
battery, and continue-on-error like deadcode, so this surfaces the existing
findings across the repo (being cleaned up incrementally per #527) without
blocking in-flight PRs on a big-bang adoption.
* fix(tools): block MSYS coreutils under Windows sandbox
Git for Windows usr\bin coreutils (head, grep, cat, ...) are MSYS PE binaries that fail with ACCESS_DENIED and cygheap/signal-pipe errors when Zero runs them in the write-restricted Windows sandbox. Detect and block these commands before execution, surface MSYS runtime failures from stderr, steer agents toward native Zero tools or require_escalated, and exclude MSYS-prone names from known-safe command tails.
Refs #458
* fix(tools): anchor the last MSYS failure heuristic to MSYS markers
CodeRabbit review feedback: the win32-error-5 plus terminating check
had no MSYS-specific anchor, so unrelated access-denied failures could
be mislabeled as windows_msys_sandbox. Require an MSYS marker like the
neighboring checks do.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(tools): let escalation bypass the MSYS guard and make detection quote-aware
Addresses jatmn's review on PR #476:
- bash and exec_command called detectShellCommandIssue before resolving
sandbox_permissions into a command engine, so a require_escalated retry of
a blocked command was still hard-blocked by the same preflight check.
Resolve the command engine first and only bypass the windows_msys_sandbox
issue kind when that resolves to true unsandboxed execution; other issue
kinds (real cmd.exe syntax problems) still block regardless of escalation.
- Unify all MSYS-prone command name checks (MsysProneCommandName, the
preflight scan, and transitively command_prefix.go's known-safe guard)
behind one windowsMsysProneNames set. This fixes expr (in the name set but
missing from the old regex alternations, so never proactively flagged) and
ls (hit the older windows_shell_syntax branch first, so it got different
metadata than every other MSYS-prone name).
- Make the preflight scan quote-aware: split the command into cmd.exe-
operator-separated segments respecting double-quote grouping, and only
check the first word of each segment instead of scanning raw text anywhere.
Fixes false positives on quoted text like commit messages or PR comment
bodies that merely mention a coreutil name.
* test(tools): decouple MSYS-guard bypass tests from real command output
TestBashToolRequireEscalatedMsysGuard and
TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval asserted
shell_issue == "" to prove the require_escalated bypass worked. Once
bypassed, "cat somefile.txt" actually runs unsandboxed, and its real,
PATH-dependent output could trip the unrelated post-execution
detectShellOutputIssue heuristic, failing the assertion for reasons
unrelated to the preflight guard under test.
Assert on the exit_code "-1" sentinel (set only by shellIssueBlockResult)
instead, which directly reflects whether the preflight guard blocked the
command.
* fix(tools): anchor MSYS path detection to command position, fix prefix approval gap
Address jatmn's latest review on PR #476:
- shell_runtime.go: the explicit MSYS binary-path check (usr\bin\,
mingw64\bin\, msys-2.0.dll, cygwin1.dll) ran against the raw command
string, so a quoted mention (e.g. a commit message describing a
usr\bin\head.exe failure) was still hard-blocked. It now runs against
the first word of each operator-separated segment, same as the
coreutil-name check. windowsCommandSegments also stopped treating ;
as a command separator (cmd.exe does not split on ;, unlike bash) and
now respects cmd.exe's ^ escape character, so escaped operators like
echo ^| head are no longer misread as a real head invocation.
- command_prefix.go: proposedCommandPrefix could offer to approve a
single segment (e.g. "ps aux" in "ps aux | head -5") without checking
that every other segment was independently safe. Since approving a
prefix escalates the whole command to bypass the sandbox, an
MSYS-prone or otherwise unsafe segment could ride along uncovered.
It now only proposes a segment as the prefix when every other
segment in the command is known-safe on its own.
Added regression tests for quoted MSYS path mentions, caret-escaped
operators, semicolon text, and the prefix-approval gap.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(tools): treat redirection as a command boundary, stop using command text as output evidence
Addresses jatmn's latest review on PR #476:
- firstCommandWord only split on whitespace, so cmd.exe redirection
attached directly to a command name (head>out.txt, cat<in.txt,
grep>matches.txt) was read as one word and never matched against the
MSYS-prone name set. It now also stops at < and >.
- detectShellOutputIssue scanned command+output together, so a command
whose own argument text merely quoted an MSYS failure string (e.g. a
gh pr comment --body describing the bug) could be mislabeled after
running even though the real stdout/stderr was unrelated. Removed the
command parameter entirely from detectShellOutputIssue and both call
sites (bash.go, exec_command.go), so the command line can never be
used as post-execution evidence again.
* fix(tools): remove unused truncateHeadTail wrapper
CodeRabbit nitpick: truncateHeadTailWithTotal is called directly
everywhere, so this wrapper is dead code that trips golangci-lint's
unused check and fails CI.
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* feat(tools): universal token-based output ceiling with spill-to-disk
Cap every tool result at the registry boundary (16k estimated tokens,
ZERO_TOOL_OUTPUT_CEILING_TOKENS-tunable, 0 disables) unless the tool
manages its own deliberate budget. Head+tail truncation, full output
spilled to the existing redacted spill dir and re-readable via
grep/read_file, so nothing is lost — only deferred.
This closes the three unbounded paths a single call could flood the
context window through (and then re-bill every following turn):
- web_fetch: up to 256KiB default / 2MiB max of non-HTML body
- skill: full SKILL.md returned uncapped
- MCP-served tools: no budget at all
Self-budgeting tools opt out via an unexported marker interface (an MCP
server can never exempt itself): bash, exec_command (model-raisable),
read_file, read_minified_file, grep, glob, list_directory. Browser
snapshots deliberately stay under the net.
bash itself is tightened to match the unified token model: emit budget
96KiB -> 32KiB per stream (~24k -> ~8k tokens worst case per stream),
while capture retention stays at 96KiB head+tail so the new spill file
covers 6x more of the output than the transcript shows.
* perf(agent): collect post-edit LSP diagnostics off the tool-call path
Inline post-edit diagnostics blocked every edit_file/write_file result on
the language server: a >=300ms quiet-period debounce that reset on each
publish, seconds on the first edit per language, capped at 10s — the
largest recurring latency tax in an edit-heavy session.
Diagnostics are now collected in the background: a file is enqueued the
moment a mutating tool finishes, a single worker checks it while the rest
of the batch executes, and the loop drains completed results just before
building the NEXT provider request, appending errors as a user nudge.
The model always issues another request after an edit turn (to read its
tool results), so it still sees introduced errors at the same decision
point — no tool call ever stalls on the language server.
A file re-edited before its check runs is checked once in its final
state (the checker re-reads the file); re-edited after, it is re-queued
and the newer result wins. A drain that finds the worker still busy
gives up after 3s and delivers on a later turn — results stay accurate
because any further edit re-enqueues the file.
The tools-side inline mechanism (tools.RunOptions.Diagnostics) is kept
for direct API callers; the loop just no longer wires it.
* fix: address review — max-turns diagnostics drain, capture-gap marker, test hardening
- Drain background diagnostics before the max-turns final-answer request
so an error introduced by the LAST turn's edit is reported in the
summary instead of silently dropped (+ regression test).
- spillBashStreams marks the head/tail junction with a capture-gap
marker when boundedBuffer dropped the middle of a stream, so a spilled
log never reads as contiguous when it is not; junction position is
asserted in the test.
- Tests override TMP/TEMP alongside TMPDIR (os.TempDir reads TMP/TEMP on
Windows) via a shared setTestTempDir helper.
- TestBudgetBashCaptureReportsTrueTotal now asserts spill_path meta and
spill file content; exemption-list test gains read_minified_file.
* fix(test): use a real workspace root in async diagnostics tests
filepath.IsAbs("/ws") is false on Windows (no drive letter), so the
collector test's workspace-absolute assertion failed on the windows
smoke job. Root the fake workspace at t.TempDir() — absolute on every
platform — and assert against it.
* fix: pattern-scrub spill files and gate finalization on pending diagnostics
Review follow-ups (Vasanthdev2004):
- Spill files now pass the pattern-based secret scanner (AWS keys,
GitHub/Slack/OpenAI tokens, PEM blocks, JWTs) in addition to the
configured-key scrub. A bash spill runs before formatBashOutput's
scan, so without this a spilled file held in cleartext exactly the
credentials the transcript redacts. Applied centrally in
spillTruncatedOutput, covering bash, exec_command, and ceiling spills.
- Finalization diagnostics gate: a no-tool-call final answer now drains
pending post-edit diagnostics with the full inline-era budget (10s,
vs the 3s per-turn wait) before the run is accepted; introduced
errors append the nudge and give the model one more turn instead of
being silently dropped when no later turn exists. The max-turns
summary path uses the same finalization budget. Free for runs that
never edited — an idle collector returns immediately.
* feat(skills): make skill invocation deterministic and discoverable
Skills were model-pulled only: the model had to notice a skill in a
640-byte system-prompt list (~6 skills visible; the rest collapsed into
'…and N more') and call the skill tool on its own. Users could not run a
skill at all — typing /<skill-name> reported 'unknown command'. The two
halves of the fix:
User invocation
- /<skill-name> [args] now runs an installed skill directly: resolved
after builtins and user commands, the skill body is inlined as the
prompt with the typed args appended, and submitted through the same
launch path user commands use.
- /skills lists installed skills with their slash form, live from disk.
Labels are honest: a skill shadowed by a builtin, alias, user command,
or a case-colliding sibling is listed bare with a '(via skill tool)'
note instead of advertising a dead /name.
- The slash palette suggests skills (labeled '(skill)') read through the
same loader dispatch uses, so mid-session installs appear and shadowed
or case-colliding names are never advertised.
- Skill and user-command invocations now apply the plain-prompt run-state
guards: mid-run submissions queue the EXPANDED prompt (the queue flush
submits literal text), compaction-in-flight warns, exiting drops.
- Skills resolve through a lazy plugin-aware loader (default dir merged
with plugin skill roots — the same set the skill tool and the system
prompt see), TTL-cached so palette keystrokes don't re-read SKILL.md
bodies.
Model triggering
- Raise the skills-list budget 640 -> 4096 bytes: the list is the model's
only discovery surface, and the old cap made skills past ~6 invisible —
invocation reliability collapsed exactly for users invested in skills.
- Raise description truncation 100 -> 200 runes: the description carries
the trigger conditions ('use when…'); cutting them is what stopped the
model from firing the skill.
- Strengthen the instruction line: scan the list before acting, call the
matching skill FIRST, don't substitute your own approach.
* feat(tui): /skills opens a searchable skill picker
Replace the static /skills card with the same picker UX /model uses:
type-to-filter (ranked search), arrow-key selection, Enter runs the
chosen skill immediately. Selection is by the skill's exact name, so
skills the slash path cannot reach — names shadowed by a builtin or
user command, or names that don't fit a slash token — are still
runnable from the picker, where the choice is unambiguous. The text
card remains only as the no-skills install hint, and the picker path
applies the same run-state guards as slash invocation.
* feat(tui): ask-first guard for bare skill invocations
A skill invoked with no request submits only its body — instructions
with no target ("review the PR" — which PR?) — and the model improvises
a target instead of asking, which is especially wrong in a fresh
session. Bare invocations (slash and picker) now append a conditional
note: if the instructions need a target or details not already clear
from the conversation, ask first; self-contained skills still just run.
Invocations that carry a request are unchanged.
* fix(tui): review follow-ups — restore typed invocation on compaction guard, cap merged suggestions
- The compaction-in-flight guard for skill/user-command invocations fired
after the dispatch path had already cleared the composer, so the typed
"/name args" was lost and had to be retyped; thread the raw invocation
through and restore it into the composer (the plain-prompt guard keeps
the composer intact by firing before the clear). The picker path has no
typed form and restores nothing.
- Cap the merged autocomplete list at the shared bound: each source
self-caps, but three sources could stack to ~3x the palette limit.
- Tighten the shadowed-skill palette test to assert on the advertised
Name rather than the fallback Desc wording.
* chore: ignore zero-skills local build artifact
A locally built test binary under this name was accidentally committed
(and has been scrubbed from the branch history); ignore the name so it
cannot recur.
* docs: document AGENTS.md/ZERO.md and plugin manifest format
Add an "Extending Zero" section to the README covering the
AGENTS.md/ZERO.md project-instructions lookup chain and the
plugin.json manifest fields, neither of which were previously
documented outside the source.
Closes#521
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
* docs: fix plugin path scope and add per-user ZERO.md guidelines
- Correct project-scope plugin discovery to <cwd>/.zero/plugins, not
the repo root.
- Clarify that user-scope plugin discovery always resolves under
~/.config regardless of OS, separate from config.UserConfigDir()
(which is OS-aware and used for the per-user ZERO.md file).
- Document the per-user ZERO.md guidelines file added in #475.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
* fix(tui): TracerPid PRoot detection, Ctrl+U/D direction, composer guard
- Replace Termux env var detection with isRunningUnderPRoot()
via /proc/self/status TracerPid, cached with sync.OnceValue
- Mouse mode falls back to CellMotion (not AllMotion) under PRoot
to fix broken touch scrolling
- Add Shift+Up/Shift+Down before plain KeyDown/KeyUp for transcript
scroll with permission/askuser cursor + composer guard
- Add Ctrl+U (scroll up) and Ctrl+D (scroll down) with proper
direction: Ctrl+U=move up, Ctrl+D=move down
- Add guards for active modals (wizard, picker, spec review, etc.)
to avoid intercepting their Ctrl+U/D keybindings
- Tests: TestPermissionCursorCtrlU/D (direction regression),
TestShiftUp/DownComposerGuard (composer guard)
- References issue #507
* fix(sandbox): avoid faccessat2 for Termux/Android portability
Replace exec.LookPath with lookupExecutable that uses os.Stat
instead of the faccessat2 syscall. The faccessat2 syscall (439)
is blocked by the Samsung seccomp filter on Android, causing
SIGSYS termination. os.Stat uses the newfstatat/statx syscalls
which are universally permitted.
This is part of broader Termux/Android support for Zero.
* docs(install): add Termux/Android build and configuration guide
Document how to build Zero for native Android/Termux: GOOS=android
to avoid the SIGSYS crash, proot bind-mount for DNS resolution,
PRoot scroll fix, and free-tier provider setup.
* fix(sandbox): skip empty PATH segments, use platform-aware executable check
* feat(openai): forward prompt_cache_key for server-side prefix cache routing
Send the session ID as OpenAI's prompt_cache_key on every completion
request so consecutive turns route to a backend replica that already
holds the conversation prefix in its prompt cache, instead of re-billing
the full ~11k-token system+tools prefix each turn on OpenAI-compatible
endpoints (which get no cache_control breakpoints, unlike Anthropic).
Keyless requests serialize byte-identically to before, and
ZERO_DISABLE_PROMPT_CACHE_KEY=1 suppresses the field for strict
endpoints that reject unknown parameters. The Codex (Responses API)
flavor is deliberately untouched: the ChatGPT backend is a separate,
stricter surface.
* fix(openai): parse prompt-cache-key kill switch as a boolean flag
ZERO_DISABLE_PROMPT_CACHE_KEY=0 or =false previously disabled forwarding
because any non-empty value tripped the check. Parse it the same way
ZERO_FORMAT_ON_WRITE parses boolean flags, so explicitly-falsy values
are a no-op and only truthy values flip the toggle.
The editing discipline already caps the diff size and scope, but a small diff can
still over-build the solution — speculative abstraction, unrequested
configurability, handling for cases that cannot occur. Add a guardrail: solve the
problem as posed, nothing the user did not ask for; if a 200-line solution could
be 50, rewrite it.
Validated with a before/after eval on five over-engineering-prone tasks: 3/5
solutions came out simpler with the guardrail, none more complex, none broke
correctness (avg 9 vs 11 non-blank lines); the baseline gold-plated on two of them
(an unrequested cache-introspection attribute and header-merge config). No effect
on trivial tasks, where the baseline is already minimal.
Closes#516
* fix(tui): strip all ANSI escapes from copied transcript text
renderAssistantMarkdownText embeds bold markers for prose and ANSI color sequences from chroma for fenced code blocks. stripMarkdownRenderControls only stripped the bold markers, leaking ANSI codes into clipboard text. Switch to ansi.Strip() to remove all escape sequences.
* refactor(tui): extract rendering dispatch and add detailed bool to transcriptBodyItems
Extract rowRenderFn and transcriptRowDispatchFn types and refactor rendering methods into *Fn variants parameterized by the render function.
Add detailed bool parameter to transcriptBodyItems to prepare for the detailed transcript viewport. All callers pass false so there is no behavior change yet.
Extract transcriptRowBodyHeightCacheKeyOpts from transcriptRowBodyHeightCacheKey so the body cap can be configured per caller.
* feat(tui): scrollable detailed transcript viewport with sidebar suppression
Enable keyboard scrolling (PgUp/PgDown/Up/Down) in detailed transcript mode by routing through scrollChat instead of swallowing the keys.
Rewrite detailedTranscriptView to use the transcriptBodyItems pipeline so the body renders through the same viewport/scroll engine as the live view.
Implement detailedTranscriptFooter with copy-status and jump-to-bottom hint integration.
Suppress the sidebar in detailed mode so the full chat width is available for reading uncapped tool output.
* test(tui): detailed transcript scroll with PgUp/Down and Up/Down arrows
TestPgUpDownScrollsDetailedTranscript verifies that PgUp scrolls toward older content and PgDown returns to the bottom in detailed mode.
TestUpDownArrowsScrollDetailedTranscript verifies that arrow keys scroll one line at a time and bottom is clamped at offset 0.
* test(tui): ANSI strip verification for markdown rendering controls and selectable text
TestStripMarkdownRenderControlsStripsAllANSI verifies bold markers, color sequences, and combined sequences are stripped.
TestSelectedTranscriptTextStripsANSIFromHighlightedCode verifies that chroma-highlighted code blocks produce clean plain text in the selectable layer.
* fix for coderabit, and better fewer code.
* fix(tui): address PR review feedback dead code, hover clear, and
rebindable key label
* format
* addressing the review issue
1. the ctrl + b in detailed mode, added the guard to not be able to do
it in detailed mode so it affect its state in the normal mode.
2. added a test coverage for keyDown after `keDown then keyUp` so it
covers that.
* fix(tui): close file drill-in when entering detailed transcript
toggleDetailedTranscript now calls exitFileView before returning, so an active file drill-in is automatically closed when the user enters detailed transcript mode.
* test(tui): verify file drill-in closes on detailed toggle
Adds TestDetailedTranscriptClosesFileView and TestDetailedTranscriptStaysClosedOnSecondToggle to guard against the file-view bypass bug.
* fix(tui): block composer hit-test while detailed transcript is active
composerMouseSelectionBlocked and mouseOverComposer now check m.transcriptDetailed so mouse clicks and wheel events in the composer area reach the transcript body instead of being silently consumed.
* test(tui): verify composer mouse is ignored in detailed mode
Adds TestComposerMouseSelectionBlockedInDetailedMode to guard against composer hit-test swallowing mouse events during detailed transcript viewing.
* Add-user-ZERO-guidelines-to-system-prompt
* fix(agent): resolve user ZERO.md under config.UserConfigDir()/zero
Review feedback: every other user-level config (config.json, commands,
specialists) lives under config.UserConfigDir()/zero, so the per-user
guidelines file now resolves there instead of ~/.zero. The basename match
is case-insensitive to mirror the project guideline loader.
Co-Authored-By: Claude Fable 5 <[email protected]>
* refactor(agent): extract shared guideline truncation and lookup helpers
CodeRabbit review feedback: the rune-safe truncation block and the
case-insensitive basename scan were duplicated across the project and
user guideline loaders. Extract truncateGuidelineContent and
findCaseInsensitiveFile and use them in both paths.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(agent): keep truncated guideline content within the byte limit
CodeRabbit review feedback: truncateGuidelineContent appended the
truncation marker after slicing to the limit, so the result could
exceed the requested budget by the marker length. Reserve space for
the marker before choosing the cut point.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(agent): fix small-limit truncation, precedence, and test isolation for user guidelines
Address remaining review feedback on PR #475:
- truncateGuidelineContent still exceeded the limit when limit was
smaller than the truncation marker itself. Fall back to a hard,
rune-safe cut with no marker in that case so the result never
exceeds the caller's budget.
- Reorder buildSystemPrompt so user guidelines are injected before
workspace/project guidelines, and add an explicit precedence note
in the user guidelines section text, so a repo's AGENTS.md/ZERO.md
is the later, more specific instruction and wins on conflict.
- Add a TestMain in the agent package that stubs userConfigDirForPrompt
to an empty temp directory by default, so buildSystemPrompt(Options{})
calls in unrelated tests don't pick up a contributor's real
~/.config/zero/ZERO.md.
- Document the per-user ZERO.md location and its precedence relative
to project guidelines in AGENTS.md.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* feat: agent quality, caching, retry, and tooling upgrades
Ten self-contained improvements to the core agent engine and tools:
- edit_file: fuzzy fallback matching (7-strategy cascade with uniqueness
protection and a disproportion guard) when old_string does not match
byte-for-byte; exact match remains the fast path
- edit_file/write_file: inline error-severity LSP diagnostics in tool
output so the model sees a break it introduced in the same turn
- lsp: expand server registry from 4 to 28 languages; doctor grades only
tier-1 servers and lists the long tail informationally
- agent: consecutive read-only tool calls execute concurrently (bounded
at 8), consumed in original order, never spanning a mutating call
- anthropic: cache_control breakpoints on the last two conversation
messages so the transcript prefix stays a prompt-cache hit
- providerio: retry schedule raised to 6 attempts with exponential
2s-30s backoff when the server sends no Retry-After
- tools: truncated exec/bash output spills to a redacted temp file
referenced in the truncation notice; bash output (previously
unbounded) now shares exec_command's token budget
- web_fetch: dependency-free HTML-to-markdown conversion (format:
auto|raw|markdown) and larger limits (256KiB default, 2MiB max, 30s)
- modelregistry: live models.dev overlay refreshes context limits and
base pricing from a TTL-cached fetch; opt-in at the CLI entrypoint
- tools: opt-in format-on-write (ZERO_FORMAT_ON_WRITE=1) runs standard
formatters across 27 extensions, ordered before the file-tracker
re-baseline so conflict detection is preserved
* fix: address PR review feedback on fuzzy edits, diagnostics, spill
- edit_file: adapt the replacement to the fuzzy-resolved span — when the
span is a uniformly deeper-indented copy of old_string (line-trimmed /
indentation-flexible matches), the same indentation delta is applied to
new_string, and a span ending in a bare CR keeps its CR so CRLF files
never gain a mixed line ending; block-anchor matches with drifted
interiors are deliberately left untouched
- diagnostics: render workspace-relative paths in inline post-edit
diagnostics (base name outside the workspace) so absolute paths never
leak the local username or directory layout into the model prompt or
session transcript
- spill: harden the spill directory for shared temp dirs — per-uid name,
reject symlinked or non-directory paths (MkdirAll follows symlinks),
and on Unix require current-user ownership
- bash: the branch's output cap and truncation-flag threading are
superseded by main's capture-time bounding (boundedBuffer +
budgetBashCapture), which already sets Result.Truncated; this rebase
adopts that design and keeps spill-to-disk on exec_command, where the
full output is still buffered and a spill is genuinely complete
* fix: address review round 2 — permission-event race, fuzzy ambiguity, spill reachability
- agent: serialize OnPermission behind the same mutex as
OnPermissionRequest in the parallel read batch. buildPermissionEvent
fires for batched reads with a non-trivial sandbox decision (e.g. a
grant-matched extra root), and front-end handlers append to shared
session-recording state without their own locking — two batched reads
emitting concurrently was a data race the pre-batch serial loop never
allowed.
- edit_file: reject two or more DISTINCT candidate spans from one fuzzy
strategy as ambiguous instead of silently editing the first. Duplicate
blocks at different indentation each resolve to a unique literal, so
the previous occurrence-uniqueness check passed and wrote the wrong
span. A single candidate occurring at multiple positions still
cascades to stricter strategies as before.
- tools: make spill files actually reachable by the recovery path the
truncation notice advertises — resolveScopedReadPath now recognizes
files inside the per-uid spill dir (containment verified after symlink
resolution, so a planted link cannot smuggle an out-of-scope file
through the gate); read_file/grep can follow the notice instead of
re-running the command.
* fix: surface competing block-anchor spans; make write-barrier test observe cross-probe order
- edit_file: blockAnchorReplacer now returns every span clearing the
similarity threshold (best first) instead of silently picking the
single most-similar block. Hiding competitors from the cascade's
distinct-candidate check could edit the wrong block when two blocks
share anchors and both interiors are plausible; one clear winner still
resolves, two plausible blocks now report ambiguity (covered by a
two-block test that asserts the file is untouched).
- agent tests: the parallel write-barrier test now records both probes
into one mutex-guarded shared log and asserts the cross-probe ordering
it always claimed to (write starts after the first read batch ends;
second read batch starts after the write ends). The previous version
only compared indices within the read probe's own log, which a
regression letting the write overlap a batch could still pass; the
stray no-op mutex assignment is gone.
* fix(sandbox): fix nested pipe creation under the Windows restricted token
Any subprocess spawned FROM WITHIN a process already running under Zero's
write-restricted token, that captures the child's output via a pipe, failed
with ERROR_ACCESS_DENIED. Reproduces with `gh pr list` (gh shells out to git
and captures its output), with a bare Go program doing exec.Command(...).
Output(), and with cmd.exe's own FOR /F ('...') construct, which uses the
identical CreatePipe+CreateProcess pattern internally with no external tools
involved.
Root cause: a WRITE_RESTRICTED token requires a WRITE-type access check to
match BOTH the normal enabled-SID grant and a separate grant to one of the
token's restricted SIDs. CreateRestrictedToken does not modify the default
DACL it inherits from the base token, so a pipe created with a default
security descriptor (the common case; every language runtime's "spawn a
subprocess and capture output" primitive does this) only carries ACEs for
the base identity, none of which are in the restricted-SID list, so the
second check fails. This is the same failure family already documented for
Schannel/SEC_E_NO_CREDENTIALS, but broader in scope: it affects any pipe,
event, mutex, or other kernel object created with default security by a
process running under the token.
Fix: add the token's own logon SID (already unconditionally present in its
restricted-SID list) to the token's default DACL, so objects it creates
without an explicit security descriptor automatically satisfy the extra
check. This does not touch filesystem access: NTFS write-jailing is
enforced by the explicit ACL grants applied to workspace paths, created
WITH an explicit security descriptor, and the token's default DACL is only
consulted when none is supplied. Exposure is bounded to the same logon
session (only other processes running as the same signed-in user could, in
principle, open a NAMED object the sandboxed process creates with default
security; anonymous pipes have no name and are reachable only via an
inherited handle, so this is a no-op for the actual objects this fixes).
This is not specific to the unelevated tier added in #427 - the affected
token-construction code is shared by both the fully elevated and unelevated
restricted-token paths, so this predates that work.
Verified on Windows 11 (before/after, using an unfixed build for
comparison):
- Before: FOR /F fails with Win32 error 5 (ERROR_ACCESS_DENIED), masked
behind cmd.exe's generic "not recognized" message; `gh pr list` fails
with "failed to run git: pipe: Access is denied."; a bare Go nested
exec.Command(...).Output() fails with "pipe: Access is denied."
- After: all three succeed; `gh pr list` returns real PR data.
- Write-jail regression check: a write outside every granted root is still
denied by the OS after the fix (unchanged from before).
New TestWindowsRestrictedTokenNestedPipeCapture (ZERO_SANDBOX_REAL_SMOKE=1)
pins the FOR /F case using only cmd.exe, no external dependency. go vet and
the full internal/sandbox suite are clean (excluding pre-existing failures
already present identically on unmodified main, unrelated to this change).
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(sandbox): fix dangling-buffer use-after-scope in default DACL merge
Addresses the blocker from review on this PR (Vasanthdev2004, gnanam1990,
coderabbitai): windowsTokenDefaultDacl returned a *windows.ACL that points
INTO the local buf byte slice backing TOKEN_DEFAULT_DACL - Windows embeds
the ACL data in the same allocation GetTokenInformation fills. The
function's own runtime.KeepAlive(buf) only covered buf up to that
function's own return; nothing kept it alive across the caller's later use.
In broadenWindowsRestrictedTokenDefaultDacl, the returned ACL pointer was
then dereferenced by native code in setEntriesInACL after intervening
allocations (the EXPLICIT_ACCESS literal, TrusteeValueFromSID) that are
GC-safepoint-eligible - a real use-after-free window in code that
constructs part of the sandbox's own restricted token, even though Go's
current non-moving GC meant it didn't manifest in testing.
windowsTokenDefaultDacl now returns the backing buffer alongside the ACL;
the caller holds runtime.KeepAlive(oldDaclBuf) until after
SetEntriesInAclW (the last point that dereferences it) returns.
Also from review: SetEntriesInAclW reports its Win32 error directly in the
return value, not via GetLastError, so setEntriesInACL now uses that
return value directly instead of the (potentially stale/unrelated) syscall
error. And narrowed the default-DACL grant from GENERIC_ALL to GENERIC_READ
| GENERIC_WRITE - the second WRITE_RESTRICTED access check only needs a
read/write match; GENERIC_ALL also implied DELETE/WRITE_DAC/WRITE_OWNER
that the pipe/event/mutex use case never needed.
Re-verified on Windows 11 with the fixed runner: nested pipe capture still
succeeds (pure Go exec.Command(...).Output(), and TestWindowsRestrictedTokenNestedPipeCapture
via go test itself), and the write-jail regression check (write denied
outside every granted root, allowed inside) is unchanged. Full
internal/sandbox suite green.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* feat(tui): actionable hints on provider/model errors
Classify a failed turn's provider error into auth / rate-limit /
connectivity / model-not-found / context-overflow and render a one-line
next step below the red error row, instead of dumping an identical raw
blob for every failure mode.
The classifier lives in a new internal/errhint package so both the TUI
error row and the CLI exec path can share it (TUIHint references slash
commands, CLIHint references zero subcommands). Classification is a
conservative string heuristic keyed off providerio.ClassifiedError's
prefixes plus lower-level DNS/TLS/timeout/context-length signatures,
since the numeric HTTP status is gone by the time the error reaches a UI
surface.
* feat(tools): budget bash output with head+tail truncation
bash was the one tool that returned command output with no byte cap —
'cat large.log' or a verbose test run could dump megabytes into context
and force compaction, while every other read/search tool already applies
a budget.
Cap stdout and stderr at 96KB each, keeping the head and tail of an
oversized stream (build/test failures usually land at the tail) and
dropping the middle behind a marker that points at redirect-to-file +
read_file. Record raw_bytes/emitted_bytes/estimated_tokens/truncated in
Meta like the other tools. Sandbox-denial and shell-issue detection still
run on the full raw output before budgeting.
* fix(gemini): retry 401 with refreshed OAuth token
The Gemini provider used providerio.SendWithRetry (429/503/529 only),
so an expired OAuth token surfaced as a terminal 'API key not valid'
auth error while OpenAI and Anthropic silently recovered via
SendWithAuthRetry's force-refresh-and-replay on 401.
Plumb OAuthResolver through gemini.Options/Provider and the factory
(exactly as the other two providers already do) and switch the stream
send to SendWithAuthRetry. With a nil resolver (API-key users) behavior
is unchanged.
* fix(errhint): gate classification on provider-origin marker
Address CodeRabbit review on #1: agentResponseMsg.err can also carry
local failures (a tool's 'permission denied', a 'file does not exist',
a config error), and broad substrings would have attached a bogus
/provider or /model hint to those.
Classify now returns Unknown unless the message carries a provider marker
the provider layer always attaches (auth error: / rate limit error: /
provider error: / provider request error: / provider stream error:).
Local errors never draw a provider hint; provider errors sub-classify as
before. Added local-failure test cases proving they stay Unknown.
* feat(cli): actionable hint on text-mode exec provider errors
zero exec printed provider failures as a bare '[zero] <raw error>' with
no next step. Append a one-line hint (reusing the shared errhint
classifier) for recognized provider failures — 'run `zero auth`',
'run `zero doctor`', etc. The provider-origin gate means non-provider
codes (sandbox_error, mcp_error) and JSON/stream-json output are
untouched.
* fix(tools): set Result.Truncated for budgeted bash output
Address CodeRabbit review on #3: budgetBashOutput recorded truncation in
meta["truncated"] but never set the Result.Truncated struct field, so
consumers reading the struct (like glob/web_fetch/read_minified do) would
miss truncated bash results. Return the bool from budgetBashOutput and
set Result.Truncated on every return path.
* perf(tui): reuse one LSP manager across prompts
runAgentWithOptions built a fresh lsp.NewManager per run and shut it down
when the run returned, so gopls (and every other language server)
cold-started on the first edit of every turn — 200ms-2s of latency the
manager's own design (long-lived, reused servers) exists to avoid.
Build one manager per session in newModel (cheap: servers start lazily on
first Check) and reuse it across runs; the SelfCorrector still gets a
fresh checker wrapping the shared manager each run. Torn down with a
short deadline in quit(). Runs fall back to a per-run manager when the
session manager is absent (cwd unknown, or a directly-built test model).
* perf(tui): accumulate streamingText as []byte (O(1) append)
m.streamingText += msg.delta was O(len) per delta -> O(n²) across a long
generation (a 10k-token code gen allocates hundreds of MB of intermediate
strings and the UI gets progressively laggier). Accumulate into a []byte
with append (O(1) amortized) and read via streamingTextString().
A []byte rather than strings.Builder because the TUI model is copied by
value on every Update, which would trip strings.Builder's copy check;
[]byte is nil-safe and copies cleanly like the other slice fields.
* perf(tui): coalesce streamed text deltas to one frame
Every OnText delta was its own tea.Msg, so a fast provider (100+ tok/s)
drove 100+ full Update->View cycles per second, each re-parsing the
growing markdown — visible stutter over SSH and wasted CPU.
Batch agentTextMsg deltas at the runtime sink over a ~16ms frame and
forward them as a single message, decoupling render rate from token rate.
Any non-text message flushes pending text first so ordering with
tool-call / reasoning / row messages is preserved; the final
agentResponseMsg (a tea.Cmd return, not a sink message) is safe because
the model already drops deltas for an inactive runID.
* fix(agent): raise tool-failure stop threshold 4->6
guardrails.go halted a run after 4 consecutive same-error tool failures.
A corrective hint fires at 2, and a model iterating on a genuinely tricky
edit can legitimately fail a couple more times after the hint while
converging — stopping at 4 cut those runs short. Raise to 6; the streak
still resets the instant the tool succeeds or hits a different error, so
only true same-error loops are affected.
* fix(agent): more reconnect retries with jitter; narrow to transport errors
#19: bump maxStreamReconnects 2->4 and add up-to-50% jitter on the
exponential backoff (capped at 8s), so a multi-second network blip is
ridden out instead of killing the run on a 2s hiccup, and concurrent runs
(swarms, cron fleets) don't reconnect in lockstep.
#34: drop the '502'/'503' substring matches from shouldReconnect. 503
already exhausted providerio.SendWithRetry (retrying here is a redundant
double-retry) and 502 is non-idempotent by providerio's rule (the POST
may have been processed). Only genuine transport failures — where no
response was received — reconnect now.
* fix(agent): reduce stall retries 2->1 to bound stuck-session time
A no-output stream stall is detected only after the full stream idle
timeout (~5min) elapses, and each retry can idle again — so 2 retries
left an interactive session frozen for ~15min. Drop to 1 retry: keeps the
common single-hiccup recovery while bounding the worst case to ~2x the
idle timeout.
* fix(tui): serialize coalescer forwarding to preserve message order
Address CodeRabbit review on #7: flush() and the run-switch path drained
the text buffer under c.mu but called forward() after releasing it, so a
timer-fired text flush could race a concurrent non-text send() and land
after the tool-call/reasoning message it should precede.
Hold c.mu across drain AND forward (drainAndForwardLocked), so whoever
holds the lock delivers atomically and the other caller blocks until it
is done — text can never overtake a following non-text message. Added a
concurrency stress test (timer racing an inline send) that passes under
-race.
* feat(tui): accept /compact now
Bare /compact already triggers compaction, but /compact now — what users
reach for when the context gauge climbs — hit the usage-error branch.
Accept the 'now' keyword and advertise it in the command usage.
* feat(tui): add /retry, /edit, /copy, /export commands
/retry resends the last prompt; /edit recalls it into the composer to
tweak and resend — both read a new lastPrompt field captured verbatim
(pre-expansion) in launchPrompt. /copy puts the last answer on the
clipboard via the existing copy machinery; /export writes a plain
role-prefixed transcript to a file (timestamped default, or a given
path). Real gaps for SSH users with no mouse select.
* feat(tools): separator-insensitive tool_search matching
tool_search ranked deferred tools by exact substring, so a model that
typed 'webfetch' (dropping the underscore) matched nothing and looped.
Add a separator-squashing fallback ('web_fetch' -> 'webfetch') scored
below an exact substring match, so precise queries still rank first.
* feat(tools): signal ask_user dismissal distinctly from a blank field
Empty answers all rendered as '(no answer provided)', so the model
couldn't tell a wholesale dismissal (user closed the prompt without
answering anything) from one field left blank amid real answers — and
might invent a default. FormatAskUserAnswers now flags a full dismissal
up front as a skip and marks individual empties '(left blank)' vs
'(skipped)'.
* feat(tui): fuzzy ranking in model/session pickers
The pickers filtered by flat strings.Contains with no ranking, so
finding 'sonnet 4.5' among 50+ models meant an exact prefix or scrolling.
Rank matches (exact < prefix < contains < subsequence, reusing
fuzzySubsequenceGap) so the closest match lands on top, and 'snt45' now
matches 'Sonnet 4.5'. Groups stay contiguous — ordered by their best
match, never split — so the grouped model picker still renders one header
per provider. Covers both /model and /resume (shared commandPicker).
* perf(agent): cache per-tool schema render across turns
partitionTools re-ran the recursive schema->map conversion
(schemaToRuntimeMap) for every tool on every turn — redundant, since a
tool's advertised name/description/schema is stable for the run. Add a
per-run definition cache keyed by tool name (nil-safe; the plain
partitionTools entrypoint and tests pass nil for fresh renders).
The partitioning itself (visibility, deferral, ordering) still recomputes
every turn — it must, because a tool's deferred state can flip mid-run
(swarm tools un-defer once a swarm is active). Only the expensive schema
render is memoized. tool_search is excluded by its callers (dynamic
description) so it never poisons the cache.
* perf(agent): calibrate compaction estimate against real prompt tokens
The byte/4 heuristic (ApproxTextTokens) over-counts code-heavy content
~15-20%, so with triggerRatio=0.7 compaction fired at ~60% of true
capacity — premature summarizer calls that degrade quality. Each turn now
folds (rawEstimate, provider InputTokens) into an EMA calibration ratio,
clamped per-sample to a sane band; maybeCompact and the reactive path
scale their estimates by it. Turn 1 uses the raw estimate (no data yet);
later turns compact near real capacity.
* style: gofmt the new agent test files
The CI gofmt check flagged the #22/#25 test files (struct-field and
switch alignment). No behavior change.
* feat(agent): turn-based plan staleness reminder
The stale-plan nudge only fired after 10 tool calls since the last
update_plan, so a plan that drifts stale across many low-tool-call turns
(one call per turn) took many turns to catch — the 'plan set turn 1 stays
pending forever' case. Add a turn-count complement: after
stalePlanTurnThreshold (8) turns without an update AND with items still
pending, fire the same one-shot reminder. Gated on pending items so a
fully-completed plan is never nagged; the existing tool-call trigger is
unchanged. (The roadmap's fuzzy 'tool calls unrelated to plan items'
relevance match is deliberately omitted — too false-positive-prone.)
* feat(agent): preserve recent-edits summary across compaction
Compaction preserved the plan, loaded skills, tool schemas, and project
instructions, but not file edits — so after the editing turns were elided
the model knew it had changed a file only vaguely, and would re-read to
rediscover its own footprint. Extract write_file/edit_file targets from
the elided middle with a one-line note from each tool result, merge them
across repeated compactions (newer note per path wins), and carry them in
the preserved-state JSON (capped at 20 paths / 160 bytes per note).
* feat(tui): workspace-scoped /resume + fewer event reads
/resume listed sessions from every project globally, and read the full
event file for each of them to test for resumable content. Filter the
picker (and /resume latest) by the current workspace Cwd, checked BEFORE
the per-session read so a large global history no longer pays N full file
reads to build one workspace's list; also skip zero-event sessions via
metadata without any read. Sessions with no recorded Cwd (older runs) and
an unknown current workspace stay visible so nothing is hidden that can't
be confidently placed. Explicit /resume <id> still resolves any session.
* feat(tui): warn on model switch that drops staged images
Staged images were silently dropped at submit when the active model had
no vision support — the user only learned after sending. Chips already
render above the composer (renderAttachmentChips); add an immediate amber
warning line to the /model switch status (both same-provider and
cross-provider paths) when images are staged and the newly selected model
can't accept them, so the drop is surfaced at switch time.
* feat(tui): inline recovery affordance on setup errors
A first-run setup error rendered as a bare red 'error: <text>' with no
pointer to the fix. Append a faint, stage-tailored recovery line (edit
the endpoint/name/key above then Enter, or pick another model) that names
only keys the stage's footer confirms, so the guidance is always accurate.
Empty for stages whose footer already makes recovery obvious.
* fix: address CodeRabbit review (5 findings)
- loop.go: recompute the compaction calibration estimate at calibration
time, not request-build time, so a reactive-compaction reissue that
shrinks the request feeds the EMA the sample that actually completed.
- errhint: match bare HTTP status codes (401/403/429/529) only at digit
boundaries, so an incidental number ('4290ms', 'id 14015') isn't
mis-bucketed as auth/rate-limit.
- bash: drop the stray unmatched ']' in the output-truncation marker.
- tui /retry: apply the same exiting/compactInFlight guards commandPrompt
has, so a retry can't race compactResultMsg's rewrite of session state.
- tui /export: write the transcript 0o600 (it may contain echoed
secrets), not world/group-readable 0o644.
* test(tui): skip export file-mode assertion on Windows
os.WriteFile ignores Unix permission bits on Windows and Stat reports
0666, so the 0o600 assertion failed there. The production 0o600 is
honored on Linux/macOS (where it matters); guard the check with
runtime.GOOS so Windows smoke passes.
* fix(tui): align /resume latest filters + case-insensitive workspace match on Windows
Address CodeRabbit review on #21:
- latestResumableInWorkspace now applies the SAME filters as the picker
(skip zero-event metadata and empty/failed runs), so /resume latest
can't land on a session the picker intentionally hides.
- sessionMatchesWorkspace compares paths case-insensitively on Windows
(its filesystem is), so the same workspace spelled with different casing
no longer hides resumable sessions; other platforms stay case-sensitive.
* fix(tui): /retry resends the last prompt's image/PDF attachments
/retry relaunched only lastPrompt's text, but launchPrompt clears the
pending image/document queues once a turn is sent. A vision- or PDF-backed
prompt that failed would silently retry as text-only and answer a different
task. Snapshot the consumed attachments at launch and re-stage them on
/retry so launchPrompt rebuilds an identical request (document preamble,
images, and the submit-time vision re-check). startNewSession drops the
snapshot so a post-/new /retry cannot leak the previous session's
attachments. Also correct the OAuthResolver factory comment, which omitted
the Gemini provider it is now passed to.
* fix: address jatmn review (reconnect 5xx, /edit attachments, edit-cap, coalescer test)
- reconnect: exclude HTTP 5xx (500/502/503/504) before the transport
substring match so "504 Gateway Timeout" no longer slips through on the
generic "timeout" needle. A gateway timeout is non-idempotent — the
completion POST may already have reached the model — so replaying the
connect risked duplicate billable work. Reuses a digit-boundary status
matcher exported from errhint (HasStatusCode). Test asserts 504/500 stay
non-reconnectable.
- tui /edit: re-stage the remembered image/PDF snapshot alongside the
recalled prompt text, mirroring the /retry fix — editing a vision- or
document-backed prompt no longer silently resends a text-only version.
- compaction recent-edits: order edits by LAST edit, not first, and move a
re-touched path to the newest position on merge, so the maxRecentEdits
tail cap keeps the file the model most recently edited instead of dropping
it. Adds mergeRecentEdits (edit-specific) and a capped-list regression.
- coalescer test: inject the frame timer via afterFunc so
TestCoalescerBatchesDeltas proves buffering deterministically instead of
racing the real 16ms wall-clock timer.
* fix(tools): bound bash output capture in memory, not just model-visible text
Previously stdout/stderr streamed into unbounded bytes.Buffers and were only
truncated to the 96 KiB budget AFTER the command finished, so a runaway command
(cat huge.log, yes) could grow Zero's memory until it stalled or OOMed before
budgetBashOutput ever ran. Replace the capture with boundedBuffer, an io.Writer
that keeps only the head+tail each stream will surface and discards the middle as
it arrives, while counting the true total for the truncation marker. Memory is now
bounded to ~head+2×tail per stream regardless of output size.
truncateHeadTail is refactored onto a total-aware core so the string path (and its
tests) are unchanged, and budgetBashCapture reports the true raw_bytes even though
only a bounded slice was held. Adds boundedBuffer/capture unit tests and an
end-to-end test that streams ~5× the budget and asserts bounded emission with an
accurate raw_bytes.
Setting ANTHROPIC_BASE_URL to a proxy/gateway failed setup with "anthropic
provider anthropic requires official baseURL https://api.anthropic.com".
OPENAI_BASE_URL already auto-promotes a non-official env URL to the
openai-compatible kind, but Anthropic had no equivalent, so the universal
ANTHROPIC_BASE_URL proxy convention was rejected outright.
Mirror the OpenAI behaviour: a non-official ANTHROPIC_BASE_URL from the
environment promotes the profile to anthropic-compatible. This stays env-only
on purpose -- a custom baseURL in a project config.json is still rejected by
Resolve, so an untrusted project can't redirect a first-party key to another
host (TestResolveRejectsAnthropicOfficialProviderCustomBaseURL still passes).
Fixes#479
* fix(specialist): cap max specialist nesting depth
A Task specialist can recursively spawn further specialists via --depth.
Without a ceiling, a runaway or malicious chain of Task calls can recurse
indefinitely, exhausting process and memory resources. Reject any run whose
depth exceeds a hard cap of 8.
Related to #470, not closing it: today's normal specialist/swarm path
cannot reach this recursively, since child sessions are launched with
--tag specialist and shouldRegisterExecSpecialistTools suppresses the Task
tool for that tag (see PR description for details). This is a
defense-in-depth guard, not a fix for an active exploit. Split out of #468
at reviewer request.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(specialist): reject depth cap at the boundary, not past it
Addresses jatmn's review on PR #491:
- Executor.Run checked `CurrentDepth > maxSpecialistDepth`, but this call is
what launches the child at CurrentDepth+1. A parent already AT the cap
passed the check and started a child one level past it, only getting
rejected on that child's own next call. Change to `>=` so the guard fires
before the over-cap child is launched.
- Add TestRunRejectsDepthAtMax to cover the boundary explicitly.
* test(specialist): cover resume boundary in depth-cap guard
The pre-branch depth guard in Run sits before the fresh/resume split, but
the boundary test only exercised the fresh path, leaving a future refactor
free to move the guard without any test catching a resume regression.
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
* fix(tools): fix cmd.exe /S/C corrupting commands with embedded quotes
Any shell command whose own text contains embedded double quotes -
python -c "print(15 / 3)", git commit -m "message", node -e "..." - was
silently corrupted on Windows before reaching the target program. Reported
live: a model ran python -c "print(15 / 3)" and got back a SyntaxError
showing the code Python received as literally "print(15, truncated right
before the /.
Root cause: bash.go passes the full command text as ONE argv element to
cmd.exe /d /s /c, and Go's os/exec escapes it correctly for a standard
CommandLineToArgvW-style parser (wrapping it in quotes, backslash-escaping
the embedded ones). But cmd.exe's own /C remainder parsing does not follow
those rules once /S is present: per its documented behavior, it strips the
FIRST and LAST literal quote character in the whole remainder - not a
matched pair - before handing the (now corrupted) text along to be
re-tokenized. The escaping was correct for one layer of parsing; the
command line actually goes through two.
Fix: on Windows, write commandText to a temp .cmd script file and point
/c at the file's path instead of the raw command text. This sidesteps
cmd.exe's /C-remainder heuristic entirely, since the remainder is then
just a plain path with no quotes to mis-strip. @echo off is prepended
since cmd.exe treats a /c target ending in .cmd as a batch file and
echoes each line it runs by default, which would otherwise pollute
captured stdout (caught by the existing bash tool test suite before this
commit, not left in).
buildBashCommand now returns a cleanup func (in addition to plan.Cleanup)
for the temp file. Both callers updated: bash.go's one-shot run defers it
immediately; exec_command.go's persistent session composes it with
startExecProcess's own cleanup and runs it after the session's process
actually exits, not before.
This fix lives upstream of the sandbox wrapping, in how the Name/Args spec
is constructed before either the direct or sandboxed code path consumes
it, so both benefit without separate sandbox-specific changes - cmd.exe's
own command-line parsing does not depend on what token launched it.
New TestBashToolPreservesEmbeddedQuotesOnWindows pins the exact failure
shape via a self-dispatch test helper (a quoted argument containing a
space and a slash, matching the original bug report's truncation point),
with no dependency on python being installed. Full internal/tools suite
green, excluding 3 failures pre-existing and identical on unmodified main
in my environment (unrelated path-assumption issues under a deeply nested
working directory).
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(tools): pass the shell command line to cmd.exe raw instead of via a temp script
Review on the original fix found real problems with running commandText
from a temporary .cmd file: it silently changed cmd.exe command-line
syntax into batch-file syntax (for example, for %i loops need %%i only
inside an actual .bat/.cmd file), /s was still corrupting the script
path itself if it contained spaces, and the script leaked on sandbox
planning errors.
The actual root cause was narrower than the temp-file approach assumed.
cmd.exe's own /C remainder parsing is not CommandLineToArgvW-compatible:
it strips the first and last literal quote from the remainder whenever
it starts with a quote, without undoing backslash-escaping. Passing
commandText as a normal exec.Cmd argument makes Go wrap it in exactly
that shape (quoted because it has spaces, inner quotes escaped as \")
since it contains embedded quotes, which is what the stripping then
corrupts.
Fixes it at the source instead: commandText now reaches cmd.exe
completely unescaped (via SysProcAttr.CmdLine for the unwrapped path,
and the same treatment inside the sandboxed runner's own process
creation for the wrapped path), exactly as if it had been typed at an
interactive prompt. No outer quotes means the stripping heuristic never
triggers for a command like `python -c "print(15 / 3)"`, and a command
that legitimately starts with a quoted executable path is handled by
cmd.exe's own careful quote-preserving special case for that shape.
Also drops /s, which forced the more aggressive of cmd.exe's two
stripping behaviors. No temp file, so nothing to leak on error, and no
batch-file reinterpretation of the command text.
internal/sandbox/windows_runner.go now owns the shared shell-argv shape
(WindowsShellArgs/WindowsShellCommandLine) so internal/tools/bash.go
and the sandboxed runner's windowsCommandLine build the exact same raw
command line instead of duplicating the flag literals in two places.
* chore: retrigger CI
The Windows smoke job failed on TestLoadOrCreateSecretConcurrentConverges in internal/oauth (a lock-file access-denied flake), unrelated to this change. internal/tools and internal/sandbox, the packages this PR touches, both passed. Retriggering to confirm.
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* fix(tools): make grep and glob respect run cancellation
grep and glob's Run/RunWithSandbox discarded their context parameter
entirely (bound to _) and ran a synchronous filepath.WalkDir with no way
to know it should stop. An unscoped search over a large tree - a broad
parent directory, not just the workspace, which the model can and does
reach for - could run long enough to be effectively unkillable: the TUI's
Ctrl+C/exit path calls the stored CancelFunc and immediately marks the run
"cancelled" in the UI, but the underlying walk kept running regardless,
because nothing in it ever checked ctx.Err(). Worse, the TUI's quit path
explicitly waits for the cancelled run's own response (to drain
flushRunIDs, so /rewind checkpoints stay consistent) before it will
actually exit - so with the walk unable to hear the cancellation, neither
a second Ctrl+C nor /exit could complete. The only way out was killing the
process.
Thread ctx through Run/RunWithSandbox into grepFiles/scanGlob and check
ctx.Err() at the top of each WalkDir callback, ahead of walkErr, so a
cancelled run aborts the walk on its very next entry instead of visiting
every remaining file. Checked the rest of the tools package for the same
_ context.Context pattern: list_directory (single-level os.ReadDir, no
walk), skill, ask_user, and update_plan don't touch the filesystem, so
grep and glob were the only two tools with this gap.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(tools): thread cancellation through grep's match-collection phase
The walk-level fix in grepFiles stopped a cancelled search from visiting
new filesystem entries, but collectGrepMatches still read and
regex-scanned every file the walk had already found, so a search that
had discovered its full file list before cancellation kept running
regardless. Adds the same per-item ctx.Err() check to that phase, plus
a deterministic test that cancels after a fixed number of files instead
of relying on timing.
* test(tools): add mid-walk cancellation coverage and shared helper
Add deterministic mid-walk tests for grepFiles and scanGlob using countingCancelContext, and extract searchCancelledResult so grep/glob share the same cancellation Result mapping.
* style(tools): gofmt search_cancel.go (add missing trailing newline)
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* polish: cut per-turn overhead, stabilize cache, add /new
Context economy
- Compaction: preserveLast 8->6, triggerRatio 0.8->0.7 (both copies in sync).
- System prompt: kill the "ELABORATE summary" directive; scale summary to
the work. Comment discipline moved into the core prompt for all model
families (was Anthropic-only); Minimax/DeepSeek/Qwen/etc. now get it.
- Token-budget regression test ratchets the fixed per-turn overhead (base
prompt + eager tool schemas) using the runtime's own estimator, so silent
prompt/schema growth fails CI.
Response quality
- defaultResponseStyle balanced->concise. Reversible via /style.
Tool discovery
- <available_skills> block in the system prompt lists installed skills
(name + one-line description) so the model loads the right one on the
first call instead of guessing and reading the failure. Budget-capped at
640 bytes with an "...and N more" fallback; renders nothing when no
skills are installed (byte-identical to prior prompt).
Session lifecycle
- /new starts a fresh session in place; previous session stays on disk and
is named in the confirmation for /resume. Guards against mid-run reset.
- /clear now notes that context persists and points to /new.
Performance & caching
- partitionTools no longer alpha-sorts the whole tools array every turn,
which inserted a loaded deferred tool mid-array and shifted the cached
prefix on every load. It now builds append-only regions
[eager sorted] + [tool_search] + [loaded deferred sorted] so a
mid-session load grows the tail instead of busting the eager prefix.
Regression test asserts the eager block precedes any loaded tool.
- /context now shows cache hit rate (flags low rate over >5 turns as a
possible unstable prefix) and rolling average turn latency with
time-to-first-token. Both reset on /new.
Measured baselines (auto-mode, no workspace): base prompt 3162 tokens,
eager tool schemas 2433 across 12 tools. Budget ceilings set ~10% above.
Deliberately deferred (need a decision): inverted safety-policy rewrite
(sandbox doesn't catch ~half the B-tier rules), core-tool/web_fetch
deferral (activation-semantics blocker), aggressive prompt slim.
* fix(tui): clear staged attachments + queued message on /new
CodeRabbit: startNewSession reset conversation/usage/latency state but not
the staged input (pendingImages, pendingImageLabels, pendingDocuments,
queuedMessage), which is only consumed at prompt-submit. Without clearing it,
the fresh session's first prompt silently inherited the previous session's
images/documents/queued text. Test now stages all four and asserts they clear.
* feat(tui): add search/filter to provider picker in setup wizard
Add a search input to the provider step of the setup wizard, matching
the existing model picker search UX. Typing filters the provider list
in real-time via case-insensitive substring matching on name, ID, and
aliases.
Fixes#392
* fix(tui): resolve provider index before clearing search on advance
Clearing providerSearch before using currentProvider() caused the wrong
provider to be selected — selectedProvider is an index into the filtered
slice, and clearing the search swaps to the full unfiltered list.
Also update placeholder to reflect that search matches ID and aliases.
* fix(tui): guard empty-filter Enter and add provider search tests
- Enter on an empty provider filter is now a no-op instead of advancing
with the wrong provider (first full-list entry)
- Add table tests for filteredProviders/name/ID/alias matching
- Add tests for selectedProvider reset on edit, empty-match noop,
Ctrl+U clear restores full list, and filtered advance selection
Addresses review feedback from @Vasanthdev2004 and @gnanam1990 on PR #400
* feat(update): add zero upgrade command to apply self-updates
Extends the existing `zero update --check` (which only reported an
available release) with an actual install step:
- `zero update --apply` / `zero upgrade` download, sha256-verify, and
install the latest release.
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
- Standalone installs download the release archive, verify its
checksum, extract it (zip-slip guarded), and atomically replace the
running binary plus any pre-existing optional sandbox helpers. On
Windows, the running exe is renamed aside (NTFS allows this even
while it's executing) since it can't be overwritten directly; the
leftover ".old" file is cleaned up on a later invocation.
Closes#459.
* fix(update): correct macOS expectations in apply test
The optional-helper assertion in TestApplyStandaloneUpdateReplacesBinary
assumed every non-Windows platform has a linux-style "zero-seccomp"
helper to refresh, but applyStandaloneUpdate correctly ships no optional
helpers on macOS (matching scripts/postinstall.mjs). This made the test
fail on macOS CI. Only assert the helper refresh on linux/windows.
* fix(update): address CodeRabbit review findings on zero upgrade
- Reject --target when combined with --apply/upgrade: applying installs
onto the current machine, so a mismatched --target could replace the
running binary with an incompatible-arch build.
- Bound archive/checksum downloads with a dedicated timeout separate
from the release-metadata check timeout, so a stalled connection
can't hang zero upgrade forever.
- Surface a failed rollback in the Windows replaceBinary path instead
of swallowing it, so a botched install is reported rather than
silently leaving the binary missing.
- Run CleanupStaleBinary regardless of whether an update is available,
so a leftover Windows ".old" file isn't stuck waiting for a future
upgrade that may never come.
- Report optional sandbox-helper refresh failures as warnings on
ApplyResult (surfaced in text and JSON output) instead of discarding
them silently.
* fix(oauth): retry secret-lock on Windows delete-pending race to unblock CI
TestLoadOrCreateSecretConcurrentConverges was failing this PR's Windows
smoke job with "create token secret lock: ...Access is denied." — a
pre-existing flake in internal/oauth, not in the upgrade code. On Windows
a concurrent holder's os.Remove leaves the .lock in a delete-pending
state, so an O_EXCL create races it with ERROR_ACCESS_DENIED
(os.ErrPermission) rather than ErrExist; createSecretFile only treated
ErrExist as contention and hard-failed otherwise.
Treat os.ErrPermission as retryable too, mirroring acquireFileLock in
lock.go. This is the same root-cause fix as #445 (diagnosis by
@gnanam1990); folded in here to make this PR's CI green. Verified on
Windows: the concurrent-converge test passes 30/30.
---------
Co-authored-by: Vasanthdev2004 <[email protected]>
* fix(agent): reject a malformed additional_permissions payload before prompting
A shell tool call that sets sandbox_permissions: with_additional_permissions
but omits (or malforms) the additional_permissions payload was accepted into
the normal permission-prompt flow. buildPermissionEvent silently swallowed
the validation error while rendering the prompt's scope text, so the user
saw an ordinary-looking request, approved it, and only then hit a hard
denial from the same validation running again at grant time (loop.go's
grantInlineAdditionalPermissions), with the confusing message "failed to
grant session additional permissions: missing additional_permissions".
A malformed payload can never succeed regardless of the user's decision, so
run the same check (inlineAdditionalPermissionsProfile, already the single
source of truth for this shape) up front, before any prompt, and return a
plain tool error the driving model can see and retry from. This also covers
the mismatched case (additional_permissions present without the flag).
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(agent): make the additional_permissions rejection message actionable
The rejection added in the previous commit correctly stops a malformed
sandbox_permissions: with_additional_permissions call before any prompt,
but its message ("missing additional_permissions; provide at least one of
network or file_system") gave a model nothing concrete to correct itself
with. Observed in practice: a weaker tool-calling model (xai/grok-build-0.1)
kept re-attaching sandbox_permissions out of habit from an earlier, correct
gh/git call onto later commands that didn't need it (network was already
open via a session-scoped grant), hit this same rejection 4 times in a row
with no change in its retry, and gave up.
Both messages (missing payload, and empty-but-present payload) now include
a concrete, schema-accurate example - {"network": {"enabled": true}} or
{"file_system": {"write": ["/path"]}} - and the missing-payload message
adds the other half of the fix: if the command doesn't actually need
elevated permissions, omit sandbox_permissions entirely and retry.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
The Bun instructions told global installers to run the postinstall script
manually against the global install path, because trustedDependencies only
works in a project package.json. Current Bun has a simpler sanctioned path
that works for both project and global installs: `bun pm trust
@gitlawb/zero` (with -g for globals) runs the blocked postinstall after the
fact. Verified on Bun 1.3.14 on Windows 11: `bun pm -g trust @gitlawb/zero`
fetched the binary and `zero --version` runs.
Lead with that in README.md, README_ZH.md, and docs/INSTALL.md, keep
trustedDependencies and the manual installer as alternatives for project
installs and older Bun versions, and teach the wrapper's missing-binary
message the same commands for the Bun case.
Co-authored-by: Claude Fable 5 <[email protected]>
createSecretFile's secret-lock loop only retried on os.ErrExist. On Windows a
concurrent holder's `defer os.Remove(lockPath)` leaves the lock file in a
"delete pending" state, so a racer's O_EXCL create returns ERROR_ACCESS_DENIED
(os.ErrPermission) rather than ErrExist — which fell through to a hard
"create token secret lock: Access is denied" error instead of retrying. Mirrors
the identical handling already present in acquireFileLock (lock.go, from #261).
Fixes the flaky Windows-CI failure TestLoadOrCreateSecretConcurrentConverges
that was intermittently red-flagging every PR's Windows smoke job.
Verified on macOS: build/vet/gofmt clean, oauth package green, concurrent-
converge test passes under -race (5x). The Windows delete-pending path can't be
reproduced off Windows, so the definitive proof is the Windows CI on this PR.
* fix: allow non-TLS connections to private-network provider endpoints
The provider wizard and health probe rejected `http://` endpoints with a
non-loopback host, so users could not configure a local-network provider
like llama.cpp on another LAN machine (e.g. `http://192.168.1.100:8080/v1`).
Only loopback was relaxed.
Expand the "loopback-only" checks to also cover private-network addresses
(192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12) via `netip.Addr.IsPrivate()`:
- `providerhealth.endpointIsLoopback` → `endpointIsLoopbackOrPrivate`
(also returns true for private IP literals).
- `providerhealth.allowLoopback` → `allowLoopbackOrPrivate` — the flag
now relaxes both loopback and private addresses in `validateEndpoint`
and `safeDialContext`.
- `providerWizardIsLoopbackHost` in the TUI wizard now accepts private
IPs, so `http://192.168.x.y` passes the endpoint URL validation.
- `providerProfileIsLocal` in headless setup recognizes private-network
IPs as local (skip API key for keyless LAN providers).
- `baseURLIsLoopback` in app.go recognizes private-network IPs for
credential-requirement logic.
Link-local (169.254.x.x), cloud metadata, and other special ranges
remain blocked. Redirects to any local/private address remain blocked
regardless of the flag (SSRF protection for 3xx targets is unchanged).
Fixes#431
* chore: fix gofmt indentation in connectivity client test
* fix(auth): route `zero auth login chatgpt` to the dedicated ChatGPT flow
`zero auth login chatgpt` went through the generic OAuth manager loopback
path, which builds a random-port http://127.0.0.1:<port>/callback
redirect_uri with no extra authorize params. OpenAI's ChatGPT client
registration requires exactly http://localhost:1455/auth/callback plus the
id_token_add_organizations, codex_cli_simplified_flow, and originator
params, so the authorize endpoint rejected the request with an
"Authentication Error" before any browser interaction.
Route the chatgpt provider to the existing dedicated ChatGPT login
(provideroauth.ChatGPTLogin), which already binds the fixed port, uses the
localhost host, the /auth/callback path, and sends the required params.
`zero auth login chatgpt` now behaves identically to `zero auth chatgpt`.
--device is rejected with a clear message since ChatGPT has no device-code
path.
Fixes#430
* fix(auth): reject --scope on ChatGPT login instead of silently dropping it
`zero auth login chatgpt --scope <x>` reached the dedicated ChatGPT flow,
which ignores the generic manager's ExtraScopes — the scopes were silently
dropped while --device was explicitly rejected. Reject --scope up front
with a clear message, since the Codex client registration pins a fixed
scope set (incl. api.connectors.*) that custom scopes would risk dropping.
Addresses CodeRabbit review on #443.
normalizeProfileForProvider rewrote profile.Name to the catalog provider.ID
whenever normalizeIdentity was true — which includes any first-party provider
whose saved baseURL matches the catalog default. The credential store is keyed
by profile.Name, and this rewrite runs before profileWithCredential, so the
stored-key lookup missed and the rebuilt provider went out with no key: a 401 on
every /model switch for a profile named differently from its catalog id (e.g.
"my-openai").
Only canonicalize an empty or generic placeholder name now; a real user-provided
name is preserved. CatalogID canonicalization is unchanged (it is not the store
key). Adds regression tests covering a custom-named first-party profile surviving
normalization and placeholder names still canonicalizing.
Fixes#440
* fix(sandbox): gate /tmp test assertions on GOOS, not path existence
TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots and
TestNewScopeNormalizesAndValidatesExtraRoots guard their /tmp assertion with
pathExists("/tmp"), intending to skip it on Windows. But Go resolves the bare
path /tmp against the current drive on Windows, so on any host where C:\tmp
exists the guard passes while the product code (correctly, by design - see
defaultTempWriteRootCandidatesForGOOS) uses %TEMP%/%TMP% instead, and both
tests fail. Gate the assertion on runtime.GOOS != "windows" as well; POSIX
behavior is unchanged.
Co-Authored-By: Claude Fable 5 <[email protected]>
* chore: retrigger CI
The Windows smoke job failed on TestLoadOrCreateSecretConcurrentConverges in internal/oauth, unrelated to this change. Retriggering to confirm it was a flake.
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Make shortcuts reconfigurable via the config file.
* Use sidebarToggleAllowed. Fix fmt errors.
* Fix: combination matching superset.
* Remove non-default shortcut combination from the comment.
* feat: add --auto flag to changes commit for LLM-generated commit messages
* fix: address CodeRabbit review suggestions on auto-commit message generation
* fix: resolve auto-commit message and flag validation tests
* fix: add 60s timeout for LLM call in --auto and surface --dry-run in help
Addresses gnanam1990 non-blocking feedback:
- Wrap provider call with context.WithTimeout (60s) to prevent hung provider
- Note --dry-run in --auto help text as preview escape hatch
* fix(tools): flag piped POSIX utilities (head/tail/grep/...) before running on Windows
Zero always executes the bash tool through native cmd.exe on Windows
(shellExecutable() in bash.go), but the pre-flight Windows-syntax
detector only caught bash-style `cd /...` and bare `ls`. Anything
piped into head/tail/grep/wc/awk/sed/cut/xargs/tr — very common in
model-generated commands like `git log ... | head` — ran straight
through to cmd.exe, which has no such builtins, wasting a turn on a
guaranteed failure. Reported in the wild via `git log ... | head`
and a `git blame ... | grep` pipeline.
detectShellCommandIssue now also flags these before the command
runs, with the same "translate to cmd.exe / use Zero's native tools"
guidance already used for the cd/ls cases.
Note: the existing detectShellOutputIssue path (matching cmd.exe's
error text post-execution) only recognizes English strings and will
miss this on non-English Windows locales — the pre-flight check
above is locale-independent since it inspects the model-generated
command text rather than cmd.exe's translated error output.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(tools): require whitespace/end after POSIX utility name, not just \b
CodeRabbit review on PR #412: windowsPosixUtilityPattern used \b after
the utility alternation, but \b only requires a non-word character
next — so tail-log.sh, grep-cli, and sed.exe would all false-positive
(the hyphen/dot satisfies \b without being a real command boundary).
Match windowsLSCommandPattern's existing convention: require
whitespace or end-of-string after the utility name instead.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
When config.json has providers configured but none marked active
(e.g. a blank/stale activeProvider field, or a config with 2+
providers where none was ever selected), normalizeProviders threw
away the already-normalized provider list on ErrNoActiveProvider.
That meant the interactive TUI's existing "fall back to an
already-configured usable provider" logic (firstUsableProvider)
never got a chance to run for this case — the app instead wiped
resolved to a zero value and forced the user into a brand-new
onboarding wizard, even though a usable, already-saved provider
existed.
- normalizeProviders/Resolve now return the normalized providers
list alongside ErrNoActiveProvider instead of discarding it.
- runInteractiveTUIWithSetup tries firstUsableProvider on that list
before forcing setup; onboarding only runs when no configured
provider is actually usable, matching the existing fallback
already used for a different flavor of this problem
(active-provider-present-but-no-credential).
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Vasanth T <[email protected]>
humanCount (the status-line/sidebar token formatter) only ever produced a K
suffix, so a 1,000,000-token context window rendered as "1000K" instead of
"1M" - visible with any 1M-context model (e.g. xai/grok-build-0.1). Its
sibling formatContextWindow already switches to "M" above a million; extend
the same behavior to humanCount, generalized for arbitrary (non-round)
values rather than formatContextWindow's round-number-only heuristic, since
humanCount also renders live, arbitrary "used" token counts.
Co-authored-by: Claude Fable 5 <[email protected]>
`npm install -g @gitlawb/zero` failed on Android/Termux with EBADPLATFORM
because `package.json` `os` array lacked `"android"`. Termux provides a
Linux userspace — the existing `linux-arm64` binary works natively.
- Add `"android"` to the `os` array in package.json.
- Map `android` platform to `linux` in postinstall.mjs resolvePlatform().
Fixes#449
* fix(install): persist install dir to user PATH on Windows
The PowerShell installer only printed a suggestion to add the
install directory to PATH but never actually did it, so `zero`
was unusable from a fresh terminal after install.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(install): don't abort install if PATH persistence fails
Address CodeRabbit review on PR #407:
- Wrap the [Environment]::SetEnvironmentVariable call in try/catch
so a PATH-persistence failure (e.g. restricted user profile) warns
instead of aborting the whole install under $ErrorActionPreference
= Stop.
- Factor the repeated PATH-contains check for user/session scopes
into a Test-ZeroPathContainsDir helper.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
A malformed/truncated windows-unelevated-setup.json returned a hard error
from loadWindowsUnelevatedSetupMarker, bricking every unelevated command
until the file was hand-deleted, while the unknown-schema path already
reset to an empty marker. Treat corrupt JSON the same way: the marker only
memoizes idempotent ACL applies, so a reset can only cost redundant work,
never skip enforcement.
Addresses the review feedback from coderabbitai and @anandh8x on #427.
Co-authored-by: Claude Fable 5 <[email protected]>
* fix(provider-wizard): allow multiple custom OpenAI-compatible providers
Generic/custom providers (custom-openai-compatible, custom-anthropic-compatible)
all share the same CatalogID. wizardProviderStoredKey matched by CatalogID,
which blocked creating more than one custom provider.
Fix: in advanceProviderWizard, skip the ManageKey diversion for custom
providers and route directly to the endpoint step instead. The user can
overwrite an existing instance by re-entering the same name, or create a
new one with a different name.
Named providers (OpenAI, Anthropic, etc.) are unaffected — ManageKey is
still shown for them.
Fixes#375
* refactor(provider-wizard): deduplicate advance/discovery branch, add anthropic-compatible test
---------
Co-authored-by: Baran3575 <[email protected]>
Before the one-time elevated `zero sandbox setup` has run, Windows dropped
straight from the native sandbox to the in-process policy gate: commands ran
with no OS enforcement at all. But only the WFP network filters actually need
Administrator rights - the write-restricted token and the workspace ACL grants
(DACL edits on user-owned roots) work unelevated.
Add an `unelevated` enforcement tier between native and degraded: when the
elevated setup marker is missing and the profile restricts the filesystem, the
command still wraps through the command runner at a new `unelevated` sandbox
level. The runner applies the workspace ACL plan itself (recording applied
plans by hash in windows-unelevated-setup.json so repeat commands skip the
apply) and launches under the same write-restricted token, so the OS-level
write-jail holds. Network enforcement stays with the per-command approval gate
at this tier, and the downgrade reason says so; profiles that need the sandbox
only for network isolation keep the old degrade behavior, and `--sandbox
require` still errors with the setup hint.
Verified end to end on Windows 11 without elevation: a write inside the
workspace succeeds under the restricted token, a write outside every granted
root is denied by the OS (new ZERO_SANDBOX_REAL_SMOKE integration test, using
cmd.exe because managed PowerShell cannot initialize its crypto provider under
a write-restricted token on some hosts).
Co-authored-by: Claude Fable 5 <[email protected]>
* feat(providers): split minimax zai into intl cn
- Add minimaxi-cn (api.minimaxi.com) sharing model catalog with minimax
- Add zai-cn (open.bigmodel.cn) splitting zai from china to intl
- Decouple env vars: ZAI_API_KEY vs ZHIPU_API_KEY for zai/zai-cn,
MINIMAX_API_KEY vs MINIMAXI_API_KEY for minimax/minimaxi-cn
- Map zai-cn and minimaxi-cn to models.dev providers "zai" and "minimax"
so the model catalog hydrates for both endpoints
* chore(providers): address review feedback on intl/cn split
- Run gofmt on internal/providermodelcatalog/catalog.go (map-value
alignment on the \"zai\" entry was off by one space, fails CI lint)
- Update stale comment in TestResolveAppliesZaiInternationalCatalogDefaults
to say glm-4.5 default (live discovery upgrades to glm-5.2)
- Add missing ProviderKind, APIKeyEnv, and APIKey assertions to
TestResolveAppliesZaiInternationalCatalogDefaults so it would catch
a silent regression in ZAI_API_KEY routing
Add LongCat-2.0 as a first-class OpenAI-compatible provider preset so it can be
selected by name in the setup wizard / `zero providers setup longcat` instead of
hand-entering a custom endpoint.
LongCat-2.0 is Meituan's open, OpenAI- and Anthropic-compatible model. This
wires the OpenAI-compatible transport:
base URL: https://api.longcat.chat/openai (Zero appends /chat/completions)
model: LongCat-2.0
auth: Bearer via LONGCAT_API_KEY
Purely additive — one descriptor plus its catalog/onboarding tests and a README
env-var line; no shared plumbing is touched, so existing providers are
unaffected. Placed with the other OpenAI-compatible third parties (after
moonshot), below the first-party providers.
Verified: go build / vet / test green (catalog + cli onboarding), gofmt clean,
and `zero providers catalog` lists the descriptor with the correct fields.
Endpoint is wired by-spec from LongCat's published docs; a live completion still
needs a real LONGCAT_API_KEY to confirm.
* fix(tui): compose help overlay through the viewport overlay pipeline
exactly like how other popups works.
* fix for the CI/CD of formatting
* Added regression tests for the help overlay
* formatted code
Bun does not run dependency lifecycle scripts by default, so the npm
wrapper's postinstall (which fetches the native Zero binary from GitHub
Releases) is silently skipped. The first run then fails with an
unhelpful "No native binary found" message that points at a source-only
build command, leaving Bun users stuck.
Add a Bun section to README.md and docs/INSTALL.md with the two working
install paths (run postinstall.mjs manually, or add the package to the
project's trustedDependencies). Rewrite the wrapper's missing-binary
error to give an actionable fix for everyone and a Bun-specific hint
when run under Bun, instead of only suggesting a source checkout.
A goroutine dump of a run "stuck for 33 minutes" showed the agent parked
forever in executeToolCall -> requestPermission, blocked on the TUI's
decision channel. Root cause: a shell command whose sandbox decision was
Allow but which requested additional sandbox permissions
(sandbox_permissions: with_additional_permissions) made shouldRequestPermission
block on OnPermissionRequest, yet buildPermissionEvent stamped the event
Action=allow. The TUI's permissionRequestMsg handler only rendered/resolved
Action=prompt requests and silently dropped everything else — so the decide
callback was never called, the agent waited on a channel nothing would signal
(no ctx cancellation on the active run), and the UI showed "Working" with no
prompt and zero network activity. Intermittent and model-dependent: gpt-5.5's
folder-creation command set the additional-permissions flag, glm's did not,
which is exactly why one hung and the other ran clean.
Two-part fix:
- agent (buildPermissionEvent): an additional-permissions request is an
ELEVATION the user must consent to, even over a sandbox Allow, so mark it
Action=prompt (matching shouldRequestPermission's own condition). It now
surfaces as a real prompt instead of a dropped auto-allow.
- tui (permissionRequestMsg handler): enforce the contract that a blocked
agent ALWAYS gets a decision. A non-prompt action resolves immediately and
FAILS CLOSED (deny; cancel stays cancel) so the UI can never silently grant
an unsurfaced permission; a superseded-run (runID mismatch) resolves as
cancel so its parked goroutine unblocks without depending on ctx-cancel
ordering. Matches the ACP handler's always-resolve, fail-closed contract.
Red-green: reverting the tui branch reproduces the deadlock (decide never
called); reverting the agent override reproduces Action=allow. End-to-end
against a mock backend emitting the exact triggering tool call, the real TUI
now renders the permission prompt and the run completes on approval, where
before it parked forever.
The model picker rendered each row's title from the model's description via
modelPickerDisplayName, which returned the description whenever it was non-empty
and not a known-generic placeholder. Catalog/discovery descriptions come from
models.dev as full sentences (e.g. "DeepSeek chat model for instruction
following, coding, and analysis"), and several models share the same sentence —
so the picker showed sentence-long titles and rows that looked like exact
duplicates, with the real model id surviving only in the meta line.
Prefer the (prettified) model id as the row title; fall back to a non-generic
description only when there is no id to name the row. Distinct ids that share a
catalog description now render as distinct rows.
* feat(providers): add KiloCode and OpenCode provider support
Add KiloCode (Kilo Gateway), OpenCode Zen, and OpenCode Go as catalog
entries using the existing openAICompat transport. No new runtime code
needed — both gateways expose standard OpenAI-compatible /chat/completions.
Fixes#383
* fix(providers): swap OpenCode Zen default model to deepseek-v4-flash
gpt-5.5 is served via /responses on Zen, not /chat/completions.
deepseek-v4-flash works with openAICompat transport.
Co-Authored-By: Vasanthdev2004
`zero providers` had no way to list the models a provider actually serves, so a
custom OpenAI-/Anthropic-compatible endpoint (e.g. a self-hosted proxy serving a
dozen models) forced users to hand-write a config object per model. Live model
discovery already existed for the TUI picker but had no headless CLI surface.
Add `zero providers models [name] [--json]`: it probes the provider's live
model-listing endpoint (an OpenAI-compatible /v1/models or the Anthropic /models)
via providermodeldiscovery.Discover, using the profile's base URL and resolved
credential (inline -> stored -> env). This uses the raw discovery primitive with
no curated-catalog merge or coding-model filtering, so a custom provider's full
model list is returned. Configure the provider once, run `zero providers models`
to see what it serves, and use any id with `zero exec --model <id>` (Zero passes
unknown model ids through to the provider) -- no per-model config needed.
Wired through a new appDeps.discoverProviderModels hook (default:
providermodeldiscovery.Discover) so the command is unit-tested hermetically.
Fixes#381.
* fix(config): default missing google/anthropic models from the catalog
A hand-written provider entry like {name: "google", provider_kind:
"google", apiKey: ...} with no model bricked Zero: config resolution
failed with "provider google requires model", and since zero config and
bare zero setup both resolve up front, the only commands that could have
fixed the config died with the same error (reported in the wild — the
one working escape, zero setup google --model <m>, is undiscoverable
from that message).
The openai kind has always defaulted a missing model
(modelregistry.DefaultModelID); google and anthropic were missed. Fall
back to their provider-catalog default models (gemini-2.5-pro /
claude-sonnet-4.5) the same way, behind the same gate: provider-command
configs (normalizeProvidersWithoutModelDefaults) still surface exactly
what the external command returned. An explicitly configured model
always wins, and profiles with a catalogID already got this via
applyCatalogDescriptor — this covers the catalog-less hand-written case.
The residual requires-model error (openai-compatible customs, which
have no catalog default) now carries an actionable hint: add "model" in
config.json or re-run zero setup with --model.
Tests: google/anthropic defaulting (red-green: reverting the google
fallback reproduces the exact reported error), explicit model wins,
provider-command gate still errors with the hint, and validation treats
official-kind model-less profiles as clean while still flagging customs.
* fix(cli): enter setup on a requires-model config and hint zero auth login
Two residual first-run gaps after the catalog model-defaulting fix:
- An active provider whose model has NO catalog default to fall back on
(custom openai-/anthropic-compatible endpoints) still bricked bare
`zero` and `zero setup` — the exact commands that could have repaired
the config. Tag that resolve failure with ErrProviderRequiresModel and
extend the interactive TUI's fixable-error fallback (previously only
ErrNoActiveProvider) to drop into the setup wizard for it. The sentinel
is attached via a message-preserving wrapper (multi-target Unwrap), so
headless commands (zero config / zero exec) print the exact actionable
text unchanged and every other resolve error stays fatal.
- `zero login` / `zero logout` don't exist (it's `zero auth login`);
first-run users try them and got only "unknown command" (reported in
the wild). The unknown-command error now answers: did you mean
"zero auth login"? Unrelated unknown commands get no suggestion.
Tests: the sentinel comes from a REAL Resolve over a real config file
(message-prefix asserted, no key leak); the TUI fallback is red-green
(reverting the condition reproduces the exact brick with the old error);
the existing no-active-provider fallback and other-errors-stay-fatal
tests still pass; login/logout hint with a no-false-positive check.
* fix(tools): CRLF line ending mismatch in edit_file tool on Windows
* fix: resolve gofmt error, add robust CRLF guard, and fix Windows sandbox and OAuth test flakiness
* security(config): project config should not override sandbox.network to allow
* fix(config): use NormalizeNetworkMode in project config merge, per coderabbit review
Every exec against the Google/Gemini provider failed: Zero emits tool
parameter schemas in OpenAI's JSON Schema dialect, which always includes
`additionalProperties` (schemaToRuntimeMap writes it unconditionally).
Google's functionDeclarations schema doesn't recognize that keyword and
400s the whole request — "Unknown name \"additionalProperties\" …
Cannot find field" — so no tool-using call ever reached the model.
Sanitize tool parameters in the Gemini mapper (only there — the field is
valid and wanted for OpenAI strict mode and harmless for Anthropic):
- sanitizeGeminiSchema keeps only the keywords Gemini's Schema type
supports (type, description, enum, properties, required, items, anyOf,
default, nullable, format, and the min/max/length constraints) via an
allowlist, so any future OpenAI-ism or MCP-supplied field can't leak.
- Recurses through properties, items, and anyOf so nested schemas are
cleaned at every depth, not just the top level.
Tests: a unit test on the sanitizer (nesting, legit fields preserved,
nil-safe) and an end-to-end test asserting the outgoing request body's
functionDeclarations[].parameters carry no additionalProperties while the
real schema survives. Bypassing the sanitizer reproduces the reported
payload, so the coverage is red-green.
Fixes#373
On macOS, DefaultUserConfigPath used Go's os.UserConfigDir which returns
~/Library/Application Support, while users (and Claude Code) place config
at ~/.config/zero/config.json. The user config was never read, leaving
activeProvider empty and the launch failing with `active provider "" not
found` on a fresh `go run cmd/zero setup`.
- Add config.UserConfigDir(): on darwin, resolve to ~/.config (honoring
XDG_CONFIG_HOME), matching Linux and Claude Code. DefaultUserConfigPath,
the TUI user-commands dir, and specialist manifests all route through it.
- Add config.ErrNoActiveProvider sentinel; the interactive TUI now drops
into the setup wizard when resolve fails solely due to a missing/
unresolvable active provider, instead of exiting fatally. Other resolve
errors (malformed JSON, directory conflicts) remain fatal.
Note: `zero setup`/`zero auth` already MkdirAll the config dir before
writing, so the directory is auto-created on first write.
Co-authored-by: smallnest <smallnest@gmail>
The publish-npm job now targets the npm-publish environment, whose
required-reviewer rule pauses the job for maintainer approval in the
Actions UI before anything reaches the registry. Also removes the
release-as 0.1.0 pin from release-please-config.json now that v0.1.0
has shipped — leaving it would stamp every future release 0.1.0.
* feat: publish zero to npm via release-please
Adds release-please to automate versioning and releases from
conventional commits on main:
- release-please-config.json / .release-please-manifest.json: node
release type (package.json is the version source of truth), plain
v-prefixed tags, bootstrap-sha at current HEAD, and release-as 0.1.0
to pin the first release (remove after v0.1.0 ships).
- .github/workflows/release-please.yml: maintains the release PR; on
merge it tags + creates the GitHub Release, then dispatches
release-artifacts.yml at the tag (GITHUB_TOKEN-created tags cannot
fire on-push-tags workflows).
- .github/workflows/release-artifacts.yml: tag-gated steps now match
the ref instead of the push event so the dispatched-at-tag run
publishes assets, and a new publish-npm job publishes @gitlawb/zero
after verifying every postinstall asset URL is publicly downloadable
(also blocks publishing while the repo is private).
* Update .github/workflows/release-please.yml
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* feat: publish to npm via OIDC trusted publishing instead of NPM_TOKEN
npm's trusted publishing exchanges the workflow's OIDC token for
publish auth, so no long-lived NPM_TOKEN secret exists to leak, and
provenance is generated automatically. Drops registry-url from
setup-node (its .npmrc line hard-fails on the unset NODE_AUTH_TOKEN)
and updates npm first (OIDC needs npm >= 11.5.1). One-time caveat:
npm only lets you configure a trusted publisher on an existing
package, so the first @gitlawb/zero publish is a manual bootstrap.
* fix: drop npm@latest from the OIDC publish path
Rely on the npm bundled with the pinned Node 24 toolchain (>= 11.5.1,
the trusted-publishing minimum) instead of pulling an unpinned
npm@latest from the registry inside the publish job, and fail fast
with a clear error if a future toolchain change regresses it.
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* fix(auth): propagate credentials to every provider-build surface and pin children to the live provider
Sub-agents, swarm members, ACP sessions, and mid-run escalation switches
could build providers without the credentials the parent was running with.
This consolidates credential resolution so every surface gets the same auth:
- Wrap deps.newProvider once in fillAppDeps to fold the stored API key in
centrally; ACP, exec escalation, and buildProvider no longer patch it
per call site (an apiKeyStored profile previously went out keyless on
those paths and 401'd).
- Export ZERO_PROVIDER at startup (buildProvider) and keep exporting on
every runtime switch (wizard, onboarding, /model, /provider), so spawned
children resolve the parent's live provider instead of whatever another
process last persisted to config.json. Injected via appDeps so tests
never mutate the process environment.
- Share one OAuth login selection end to end: ProviderProfile.
OAuthLoginCandidates (name, then catalog-ID fallback; nothing for a
profile with its own configured credential) + oauth.FirstStored resolve
the login ONCE, and the bound store key is threaded through
providers.Options.OAuthLoginKey so the Codex account-id header always
reads the same login that issued the bearer token. A renamed profile
(name != catalog ID) now finds its login; an explicit API key is never
silently overridden by a same-name or catalog-shared OAuth login.
- Export ProviderProfile.HasConfiguredCredential as the single "profile is
key-authed" predicate (cli/tui/config previously had three copies).
- applyProviderWizard commits live state (provider, profile, name, env
export) only after BOTH the build and the config persist succeed, so a
persist failure no longer leaves the chat running on a provider the
status line and children disagree with.
Covered by red-green tests for each path: stored-key wrap, escalation
switch, catalog-ID fallback (incl. case-variant names), explicit-key
protection, same-login account selection, wizard persist-failure rollback,
onboarding/startup env export.
* test: isolate OAuth/config test storage and fix the flaky MCP response-id test
Review + CI fixes, all test-only:
- factory/cli OAuth tests: pin ZERO_OAUTH_STORAGE=file next to the temp
ZERO_OAUTH_TOKENS_PATH — an inherited "keyring" value sends NewStore to
the OS keychain and ignores the temp path entirely, so the tests could
read (or clobber) the developer's real logins. newOAuthStoreForTest now
owns both pins so a future call site can't forget them.
- TestApplyProviderWizardExportsActiveProviderEnv: point m.userConfigPath
at a temp config and stub newProvider, so the SUCCESS path exercises the
full build+persist+commit sequence in isolation (the default empty path
silently skipped persist; a changed default must never reach the real
user config).
- TestClientRequestWaitsForMatchingResponseID (ubuntu smoke flake): the
responses were pre-loaded into a bytes.Buffer, so the client's reader
goroutine could consume the matching response — and hit EOF — before
request() registered its pending id, failing with "request() error =
EOF" on fast runners. The fake server now sits on pipes and responds
only after reading the outgoing request, matching production ordering;
survives -race -count=50.
* feat(tui): FILES sidebar panel with click-to-select and file drill-in
Add a FILES section to the right context sidebar showing the files the
session has touched — newest first, with A/M status badges, aggregated
+/− diffstats, and a pulsing row for the file being written right now.
Clicking a row selects the file (accent marker, edit cards tint in the
chat, transcript scrolls to the latest edit); a second click opens a
drill-in view: the chat column swaps to the file's stacked edit cards,
`f` toggles to the syntax-highlighted on-disk file with markers on
session-added lines, `d` back to diff, Esc returns to the chat.
The roster is fed from two sources: changedFiles carried by
write_file/edit_file/apply_patch results (persisted per session, so it
survives /resume), and a git sweep — baseline snapshot at startup,
re-check after command tools and at turn end — so files created through
bash/exec_command scaffolding or subagents show up too.
* fix(tui): address FILES sidebar review findings
Review fixes for the FILES panel / drill-in / git sweep:
- touchedFiles: split multi-file diffs per file (perFileDiffStats) so an
apply_patch spanning several files no longer charges the whole patch's
+/- totals to every file it touched; order by lastRowIndex descending
so a re-touched file lists first (reversing first-seen order kept it in
its original slot).
- sidebarFileLines: filter the live-writing row out before computing the
"+N more" trailer so the skipped entry no longer inflates the count.
- git sweep: run `git diff --numstat -z` and parse the NUL-terminated
rename records (counts + preimage + postimage) — plain --numstat
brace-mangles renamed paths ("src/{old => new}"), so their diffstat
never matched a porcelain path and renamed files stayed at 0/0;
gitTouchedFiles now yields newest detection first to match the
roster's ordering.
- file view: stream the full-mode read and stop at fileViewMaxLines
(os.ReadFile loaded a multi-GB file wholesale before truncating — the
exact freeze the cap exists to prevent); re-opening the file already
being viewed is a no-op so a stray re-click can't bounce full mode
back to diff or reset scroll; skip sub-4-char lines in the changed-line
markers so common braces/returns don't mark unrelated occurrences.
- keys: gate the drill-in's d/f and Esc handling on noBlockingModal so a
permission prompt / ask-user / wizard keeps its own key handling.
- sidebar: a live in-flight write counts as content, so the FILES pulse
shows for the session's first mutation.
- tests: numstat test now feeds real -z record shapes and asserts the
rename postimage key; new coverage for per-file stats, re-touch
ordering, live-row overflow, same-file reopen, truncation, modal
gating, live-write content, and a real-repo rename sweep.
---------
Co-authored-by: Gnanam <[email protected]>
* fix: cut the content-stall dead-wait and auto-retry a mid-tool-call stall
Root-caused (via a verified multi-agent audit) the chatgpt/gpt-5.5 "stuck"
reports: the ChatGPT Codex backend pauses real generation mid-stream on
large single tool-call outputs (a ~1000-line write_file) while continuing
SSE keep-alive heartbeats. That is an upstream limitation, not a Zero bug
or a connection-pooling issue (the darwin DisableKeepAlives fix is applied
and confirmed not the cause). Zero already detected it, but two UX gaps
made it feel like a hang:
1. Faster abort. The content-stall watchdog fired at idle*2 = 10 minutes.
A heartbeat-pause stall on gpt-5.x rarely recovers, so 10 minutes of a
frozen token counter was a terrible wait for a doomed turn. Replaced the
StreamContentStallFactor=2 const with ContentStallTimeout(idle)=idle*1.2
(6 minutes at the default). It still only ever fires on a TRUE zero-output
freeze — any real data line resets it — so healthy slow-reasoning runs are
unaffected. Scales with ZERO_STREAM_IDLE_TIMEOUT; returns 0 (disabled)
when idle is disabled.
2. Auto-retry the actual stall. The existing stall-retry only fired when
NOTHING had been forwarded — so the common case (model streamed transient
reasoning + began the big write_file, then froze mid-arguments) was NOT
retried, because the incomplete tool call is collected and transient
output was forwarded. Relaxed the gate to retry when no visible PROSE was
shown (forwardedVisibleText, marked only by OnText) and no final text was
collected — dropping the len(ToolCalls)==0 exclusion, since an incomplete
tool call from a timed-out stream is never executed (the turn returns on
the error before dispatch) and never appended to messages, so the retry
re-sends clean context with no duplication. A visible
"[no output — model stalled; retrying N/max]" notice fires per attempt.
Forwarded prose still blocks the retry (it would duplicate answer text).
Verified: full -race suite green; a 4-dimension root-cause audit and a
3-dimension adversarial review of this diff both passed with no confirmed
defects. New tests: TestRunRetriesStallAfterIncompleteToolCall (the exact
gpt-5.x case), TestRunRetriesStallAfterOnlyReasoning; preserved
TestRunDoesNotRetryStallAfterPartialOutput (prose still blocks retry).
* fix(providerio): make ContentStallTimeout overflow-safe
Address PR #362 review: idleTimeout*6/5 could overflow int64 for a
pathologically large ZERO_STREAM_IDLE_TIMEOUT and wrap to a NEGATIVE
duration, which would arm the content-stall timer to fire immediately and
abort every stream. Compute 1.2× as idle + idle/5 with a clamp to the max
duration on overflow (which just means "effectively no content watchdog",
the sane result for an absurd idle timeout). The normal 1.2× values are
unchanged (5m->6m, 30s->36s, 100ms->120ms).
New test: TestContentStallTimeout covers the scaled values, the
zero/disabled case, and the MaxInt64 overflow guard.
A message typed while a run is active is queued for the next turn. Its
"[queued] …" preview rendered on the line BELOW the input box; move it to
directly ABOVE the box so it reads as "waiting to send," sitting on top of
what you're currently typing, and doesn't get visually detached from the
composer by the status line beneath it. Footer height is unchanged (the
preview line just moves from below to above the box).
New test: TestQueuedPromptPreviewSitsAboveComposer asserts the preview
line index is above the composer box top border.
This codebase already has a documented, previously-diagnosed macOS-only
bug: "sharedHTTPClient... defeats the stale-pooled-connection hang...
reproduced on BOTH chatgpt and ollama... surfaces on macOS far more than
Linux because macOS keeps dead pooled connections around longer." The
existing mitigation (ResponseHeaderTimeout=120s, IdleConnTimeout=30s)
catches a reused connection that never responds at all, or one that sat
idle too long.
Traced a live report that doesn't fit either case: a session accumulated
301 real completion tokens over ~12.5 minutes (~0.4 tok/s) on
chatgpt/gpt-5.5 before being manually cancelled - not silent, just
catastrophically slow, on a retry shortly after a prior attempt on the
same host. That's a third failure mode: a reused connection that's
alive-but-severely-degraded, still delivering real bytes (enough to keep
resetting Zero's stream idle/content-stall watchdogs, which only fire on
true silence) but never at a usable rate. Basic network diagnostics on
the affected machine (DNS, TCP connect, TLS handshake) were all healthy
and routed directly with no VPN/proxy involved, pointing at the same
connection-reuse class of bug rather than a local network misconfig.
DisableKeepAlives removes pooling from the equation entirely rather than
just tightening the timeout window, since a degraded-not-dead connection
isn't reliably distinguishable from genuine backend slowness once inside
the stream. Scoped to darwin only via runtime.GOOS: a fresh TCP+TLS
handshake per request costs low tens of milliseconds (measured ~92ms
against a real host on the affected machine) against potential
minutes-long stalls, and Linux/Windows don't exhibit this OS-level
pooled-connection behavior, so they keep full connection reuse.
New test: TestHTTPClientReturnsStallHardenedSharedClient extended to
assert DisableKeepAlives matches runtime.GOOS == "darwin".
Both failed on windows-latest today, on merges of otherwise-unrelated
PRs (#349, #356) - neither PR touched internal/specialist or
internal/agenteval. Windows GitHub Actions runners have coarser default
timer granularity and more scheduling contention than Linux/macOS, so
tight timing margins that hold on the other two platforms aren't
reliable there.
- TestOutputToolBlocksUntilTaskCompletes: a 100ms poll timeout against a
5ms background write left only 20x margin. Widened the timeout to
2000ms (a ceiling, not a target - PollInterval keeps the test fast in
the success case; it only changes how much CI jitter it tolerates
before failing).
- TestHarnessTimeoutCancelsBlockedAgent: confirmed there's no actual data
race (harness.Agent.Run is a direct synchronous call, not a spawned
goroutine racing the assertions) - the failure was workspace
materialization alone consuming the full 10s budget on a contended
Windows runner, so the agent was never reached. Widened to 30s. This
test's own design blocks for the full timeout to observe cancellation,
so unlike the other fix this directly costs ~20s more wall-clock time,
but that's inherent to what it's testing.
Zero is always in alt-screen mode (useAltScreen() is unconditionally
true), and transcriptView's `if m.altScreen && m.height > 0` gate is what
pads the rendered frame out to fill the full terminal height
(scrollableTranscriptItemsView pads bodyWindow to window.height). If
m.height is ever 0 - Bubble Tea documents an initial WindowSizeMsg as
delivered automatically on program start, but that's the terminal
proactively pushing a size, and nothing else ever asks again if that
push is missed (a slow/unusual terminal, a multiplexer, a startup race)
- Zero falls back to the unpadded, non-fullscreen render path for the
rest of the session: content only occupies as many rows as it needs,
leaving the remaining terminal height as plain unfilled background
instead of the alt-screen's painted panel color.
Fixed by explicitly firing tea.RequestWindowSize in Init(), the same
defensive pattern already used for tea.RequestBackgroundColor right below
it - Zero doesn't rely solely on an unprompted push for either.
Zero already has a content-stall watchdog built specifically for this
exact case (providerio.ErrStreamStalled's own doc comment: "observed on
chatgpt/gpt-5.x and ollama reasoning models") - a heartbeating-but-silent
upstream is aborted after idleTimeout * StreamContentStallFactor (5min *
2 = 10min by default). It works. The gap is that until it fires, the
"still generating... Xs" cue is the ONLY signal the user has, and it
looks completely identical whether real (if slow) content is still
coming or the stream is already dead and just hasn't been caught yet -
confirmed against a live report where the token count sat frozen for
7+ minutes with no way to tell from the UI alone.
Past half the effective idle timeout, quietGenerationHint() now escalates
to name what's actually happening and when Zero will act on its own:
"still generating... Xs - unusually quiet, Zero will auto-recover by
~Ys if it doesn't resume". Exported StreamContentStallFactor from
providerio (was unexported) so the TUI can compute the same ceiling the
watchdog itself uses, instead of duplicating the constant.
New test: TestQuietGenerationHintEscalatesPastHalfIdleTimeout, using
ZERO_STREAM_IDLE_TIMEOUT to shrink the timeout so the test runs fast
while exercising the exact same math the real watchdog uses.
* fix(tui): show the context-fill gauge for custom/local Ollama models
The colored context gauge (used/window · N%) only renders when Zero knows
a model's context window. It resolves that two ways: a curated remote
catalog (how Grok/hosted models are known), or live /v1/models discovery —
but parseModelsResponse only ever extracts id/description from that
response; OpenAI-compatible model listings don't carry context-window
metadata at all. A custom/local Ollama model tag (e.g. a locally-pulled
":cloud"-tagged model) has no curated-catalog entry to borrow one from
either, so it always resolved to an unknown window and the gauge silently
fell back to a bare token count with no color grading.
Ollama does expose this, just via a different, Ollama-native endpoint
(/api/show, not /v1/models) whose model_info map reports it under an
architecture-prefixed key like "qwen2.context_length". Added
DiscoverOllamaContextWindow (providermodeldiscovery) to probe it, wired
through an async command scoped to the local "ollama" catalog ID only
(not "ollama-cloud", a different hosted service this endpoint isn't
assumed to exist on) — fired from Init() for the provider active at
launch, and from switchProviderModel for a provider switched to
mid-session (which previously warmed no discovery at all for the new
provider, a separate gap this also fixes).
New tests: DiscoverOllamaContextWindow's endpoint construction and
model_info parsing (including a mixed-type model_info and a missing
*.context_length key), the discovery command's scoping to local Ollama
only, the Update() handler populating modelContextWindow's fallback, and
switchProviderModel warming discovery for the newly active provider.
Local only — not pushed. I can't run a live Ollama daemon in this
sandbox, so the actual /api/show request/response shape (verified against
Ollama's documented API, not a live instance) needs confirming against a
real daemon before this goes out.
* fix(tui): stop showing the token count twice next to the context gauge
contextWindowSegment (the gauge) and usageStatusSegment (the plain "N tok"
figure) both read the exact same value (latestUsageTokens) — the code
already suppressed the plain figure when the sidebar shows it ("the token
figure shows on both sides"), but never extended that same dedup to the
gauge. So as soon as a model's context window becomes known (e.g. after
the previous commit's Ollama fix), the footer showed the identical number
twice: once inside the gauge, once again right next to it.
Fixed by falling back to cost-only whenever the gauge is shown, same as
the sidebar-active branch already does.
* fix: check collect()'s deadline even while output keeps draining
The wait deadline in execSession.collect() was only checked in the branch
reached when drainString() returns empty. A background process producing
output continuously enough to keep the buffer perpetually non-empty could
theoretically starve that check forever, turning the caller's requested
timeout into an unbounded block regardless of yield_time_ms.
Investigated as part of a cross-provider "stuck" report (multiple sessions
across different models/providers all hanging on the same prompt, each
with a background terminal running and no forward progress). Could not
empirically reproduce a hang via a synthetic continuous-writer test under
Go's real scheduler — the reader won the race often enough in practice
that this doesn't appear to be the actual cause of those reports. Keeping
the fix regardless since it closes a real, if hard-to-trigger, gap: the
deadline is now checked in both branches, so continuous output can never
prevent collect() from returning within its requested window.
The provider wizard offered "Sign in with OAuth" for xAI and Hugging Face, but
clicking did nothing: the login managers were built without an env, so the preset
opt-in (ZERO_OAUTH_ALLOW_PRESETS) read as unset, ResolveConfig errored before any
browser step, and the error was never rendered on the provider list. Only ChatGPT
worked, because its login path force-enables presets.
Add ManagerOptions.AllowPresets, which layers the opt-in onto the manager env
(snapshotting the process environment so ZERO_OAUTH_<NAME>_* overrides survive
envValue's hermetic-map rule) without the operator exporting the flag. Set it on
every user-initiated OAuth manager: the wizard loopback login, the device-code
prepare/complete, the post-login token refresh, the runtime resolver that
authenticates model calls, and `zero auth login`. This fixes xAI end to end (it
ships a public client_id, so the browser now opens).
Also surface a failed OAuth attempt on the provider list (renderProviderStep) so
the click is no longer a silent no-op, with a Hugging Face hint pointing at app
registration + ZERO_OAUTH_HUGGINGFACE_CLIENT_ID (HF ships no public client_id, so
it still requires that one env var by design).
* feat(tui): interactive theme picker + color theme catalog
Bare `/theme` now opens a popup that live-previews each palette as the cursor
moves and applies on select (Esc reverts), matching /model and /effort. Adds ten
built-in color themes alongside the dark/light built-ins — dracula, nord, gruvbox,
tokyo-night, catppuccin, one-dark, solarized-dark, rose-pine, everforest, and
solarized-light — plus a reworked light palette. Every palette is contrast-audited
to WCAG AA; the contrast tests now loop the whole registry.
Palettes and an ordered registry live in a new internal/tui/theme_palettes.go (now
the sole home of hex literals); selection, the picker, /theme <name>, --theme, and
ZERO_THEME all resolve through it. The whole frame is painted with the active
theme's surface (tea.View.BackgroundColor) so every theme is legible on any
terminal — a light theme's dark text no longer lands invisibly on a dark terminal,
and color themes show their real surface.
The chosen theme persists to user config (preferences.theme) and is reapplied at
startup, below the --theme flag and ZERO_THEME.
* fix(tui): address CodeRabbit review on the theme catalog
- CHANGELOG: reconcile the --theme bullet with the named themes (was still
documenting {auto|dark|light}).
- /theme usage string advertises the list and auto forms (/theme list is still
accepted) so /help matches the command surface.
- Theme-persistence test parses the written config and asserts preferences.theme
directly instead of a raw substring, so a wrong-key regression fails.
* fix(tui): require a second Esc press to cancel a running turn
A single stray Esc during an active run cancelled it instantly with no
chance to reconsider - e.g. a press meant to dismiss an overlay that had
already closed. Ctrl+C already guards app-exit with a double-press
confirmation (exitConfirmActive/exitConfirmSeq); Esc now gets its own
independent confirmation (cancelConfirmActive/cancelConfirmSeq) guarding
run-cancellation specifically, since the two are different actions with
different consequences armed by different keys.
Mirrors the existing seq-gated tea.Tick pattern: first Esc arms a 3s
window and shows "Press Esc again to cancel" in the footer; a second Esc
within the window cancels the run; letting it expire, or the run finishing
on its own, disarms without cancelling.
Updated the three existing tests that assumed single-press cancellation
(TestEscCancelsPendingRun -> TestEscRequiresSecondPressToCancelPendingRun,
TestEscCancellationLeavesVisibleMarker, TestEscCancelRecordsSessionError,
TestCancelledRunFlushesCheckpointSessionEvents) and added
TestEscCancelConfirmationExpires mirroring the Ctrl+C expiry test.
* fix: pre-push review findings on the Esc cancel-confirmation gate
Ran an adversarial review before opening the PR. Three independent
reviewers converged on the same root defect: unlike exitConfirmActive
(disarmed by any key other than Ctrl+C), cancelConfirmActive had no
"anything else disarms it" safety net, so it behaved as a plain 3s timer
rather than a true immediate-second-press gate. Concretely:
- Typing, Ctrl+O, or any other non-Esc key left it armed, so a later,
unrelated Esc press could silently cancel the run.
- A mid-turn ask_user or permission prompt lands asynchronously and is
checked earlier in the Esc handler than the cancel-confirm block, so it
silently "stole" the confirming press (denying the prompt instead) while
leaving cancelConfirmActive armed for a subsequent, unrelated Esc to act
on.
- Mouse clicks resolving a permission prompt or entering a subchat had the
same gap.
Fixed by mirroring the Ctrl+C pattern: any non-Esc key disarms it (model.go,
next to the existing exitConfirmAction reset), and any left/right mouse
click disarms it too (mouse.go — scoped to clicks, not hover/motion, since
motion fires continuously and would otherwise defeat the window whenever
the mouse sits over the terminal). Within the Esc handler itself, restructured
to capture whether this press was really a confirm *before* any of the
earlier branches (subchat, MCP wizard, ask-user, permission, picker,
suggestions, queued-message, detailed-transcript) can run, then disarm
unconditionally — so an Esc consumed by any of those no longer leaves a
stale confirmation for a later press to trip.
Also updated two now-stale help strings the review flagged: the `?`
overlay's Esc row and pickerBusyText()'s "press Esc to cancel" hint, both
of which still described the old single-press behavior.
Added TestEscCancelConfirmationDisarmsOnInterveningKey (mirrors the
existing Ctrl+C equivalent) and
TestEscCancelConfirmationDisarmsWhenStolenByAskUser covering the two
confirmed gaps.
* fix: address CodeRabbit review comments on PR #352
- model.go: the first Esc (arming the cancel confirmation) was clearing the
composer draft immediately, even though nothing had actually been
cancelled yet — a leftover from the old single-press behavior, where
clearing the draft and cancelling the run happened atomically as one
action. Now that cancellation is two presses, only the confirming second
Esc (or a plain Esc with no run pending) clears the composer; arming
preserves whatever the user was typing, mirroring how Ctrl+C already
preserves a draft (TestCtrlCClearsComposerBeforeExitConfirmation).
- clipboard.go: routePaste (bracketed paste and right-click paste) never
disarmed a stale cancelConfirmActive — it isn't a tea.KeyPressMsg, so it
bypassed the generic "any non-Esc key disarms it" hook added in the
previous commit. A paste is a deliberate action like typing or clicking,
so it should disarm the confirmation too; otherwise a later, unrelated
Esc could silently cancel the run.
New tests: TestEscArmingCancelConfirmationPreservesComposerDraft,
TestPasteDisarmsCancelConfirmation.
* fix: cap unbounded exec_command output buffer to prevent OOM kill
execOutputBuffer.data accumulated every byte a backgrounded exec_command
session wrote, with no size cap, unlike buffer.recent which is correctly
bounded to recentExecOutputBytes for the /ps preview. drainString() only
clears data when the agent actually polls that session again — so a
long-lived background session nobody polls (e.g. a dev server left running
after the run that started it was cancelled) grows this buffer forever as
long as the process keeps writing, with no ceiling.
Traced this directly to a real crash: a session that had been running for
~5.5 hours (an earlier turn invoking swarm agents got cancelled mid-flight,
per that session's own event log) accumulated to ~42.7GB of compressed
memory, at which point macOS's memorystatus killed the process outright
("killing zero due to low-swap").
Fixed by capping data at maxExecOutputBufferBytes (2MiB), trimming from the
front like recent already does, and surfacing a one-time truncation notice
on the next drain so data loss is visible rather than silent.
* fix: pre-push review findings on the exec_command buffer-cap fix
Ran an adversarial review before opening the PR. It found the truncation
notice added alongside the buffer cap didn't actually work: prefixing
execOutputTruncatedMessage into drainString()'s text meant the notice
always sat ~2MiB before the end of the combined output, but the tool's
own downstream truncateExecOutput() keeps at most a 400KB tail (even at
the maximum allowed max_output_tokens) — so the notice was reliably
swallowed, or chopped into an unreadable fragment at smaller budgets.
Verified with direct reproductions against the real functions.
Fixed by moving the signal out of the text stream entirely:
- execOutputBuffer gains consumeTruncated() (read+reset, called once per
collect()) and peekTruncated() (read-only, for status views) instead of
injecting a marker into the drained bytes.
- collect() now returns (string, bool); both call sites thread the bool
through to execToolResult, which sets meta["output_buffer_truncated"]
and appends the notice to the response body *after* truncateExecOutput
runs, so it can never be re-truncated or land in a discarded region.
- Also flagged: a session torn down without ever being polled again after
truncation loses the signal entirely, since nothing drains/logs it on
teardown. Full audit-trail logging felt like overkill for a soft
transparency signal on top of a hard memory-safety fix, but exposed
peekTruncated() on ExecSessionSnapshot and surfaced it in the /ps row
(background_terminals.go) so it's visible any time a still-running
session is checked — covers the common case (the review's own scenario:
a long-lived dev server nobody's polling but that's still alive) even
though a session reaped before ever being looked at is still a gap.
New tests: reworked TestExecOutputBufferCapsUndrainedData for the
consume/peek API, and TestExecToolResultSurfacesBufferTruncationOutsideByteBudget
reproducing the review's exact failure case (max_output_tokens=1) and
proving the notice now survives it.
Restores accent to #caff3f (the pre-PR#350 value), undoing the three-round
brightness reduction (#ade619 -> #8db620 -> #759523) merged in PR #350. Only
the accent token changes - the hover-highlight color (a separate amber
token) and every other PR #350 fix (subchat selection, drag-to-edge
auto-scroll, the dragging-flag fix) are untouched.
Full gate green. Local only - not pushed.
* fix(local): subchat mouse selection + hidden plan panel + scroll-extend selection
Three bugs in the subagent/swarm drill-in (subchat) view and mouse text selection,
all from the same root: newer features (subchat's row-source swap, wheel-scroll)
were never integrated into the older selection/footer code.
1) Can't copy anything in the subchat view. transcriptLineAtMouse (and
selectedTranscriptText, the copy-time text extraction) always hit-tested against
m.transcript (the parent), never m.subchat.childRows, even while the screen was
showing the child session. New transcriptHitTestSource() mirrors transcriptView's
own branching (nav bar + child rows + full width vs pinned title bar + parent
rows + sidebar-aware width) and is now the single source both functions use.
2) Plan panel shown above the composer inside the subchat view. footerView
unconditionally rendered the PARENT run's pinned plan panel; only pendingAskUser
was gated. Added a !m.subchat.active guard (m.plan belongs to the parent run,
not whatever subagent/swarm session is being viewed).
3) Selection didn't extend across a scroll (reported as "stopped at the end of the
screen"). transcriptSelection.cursor was only ever updated by a mouseMotion
event; a wheel event is a distinct message type that never reached that code,
so scrolling while selecting left the selection frozen at whatever line the
mouse last physically moved over. New scrollChatExtendingSelection scrolls AND
re-evaluates the cursor at the mouse's current position against the post-scroll
viewport. Uses a new nearestTranscriptLineAtMouse (falls back to the closest
visible selectable line) rather than an exact match, since the recomputed
position can land exactly on a blank spacer row between messages after an
odd-length scroll shifts text/spacer parity - an exact-match miss there must
not silently no-op the extension.
Tests: subchat selection resolves the CHILD session's text, not the parent's; the
plan panel is absent from the footer during subchat; a selection's cursor bodyY
moves (extends) after a wheel-scroll instead of staying pinned. Full gate green
(73 pkg, -race).
* feat(local): highlight clickable rows on mouse hover
Moving the cursor over an interactive row now highlights it (accent underline)
before you click, across every clickable surface:
- specialist cards and collapse/expand toggle headers in the transcript
- AGENTS sidebar rows (swarm members)
- PLAN sidebar step rows
- permission-prompt options (reuses the EXISTING keyboard-cursor highlight by
moving pendingPermission.cursor on hover, instead of new styling)
Requires switching the mouse-tracking mode from CellMotion (only reports motion
while a button is held) to AllMotion (reports idle cursor movement too) - the
existing 15ms mouse-event throttle already covers the added motion events.
hover.go: hoverTarget + mouseHover predicate + updateHoverTarget, which reuses
the SAME hit-testers and priority order as the existing click handlers so hover
and click never disagree about what's clickable.
Sidebar hover is resolved by STABLE IDENTITY (sessionID / stepIndex), not a
cached line offset: hoveredSidebarLineOffset re-matches the identity against a
freshly computed hit list on every render, so a row that's disappeared (a
swarm member's linger elapsed, a plan step completed) simply doesn't highlight
rather than a coincidentally-matching unrelated row lighting up. Transcript
hover stays bodyY-keyed (matching the existing selection-highlight convention)
with explicit clearHover() calls wherever bodyY numbering can shift without an
intervening mouse motion: wheel-scroll, PgUp/PgDn, window resize, and
transcript rebuilds (/clear, /resume, /rewind, /compact via resetFlushFrontier).
Adversarial review caught two real bugs before ship, both fixed:
- BLOCKER: plan-step hover stored the step's own index (0,1,2...) but the
sidebar render treated it as a raw line offset into the rendered rows -
every plan-step hover highlighted the wrong row (the AGENTS header/body).
Fixed by resolving fresh at render time via the stable-identity redesign
above, which also closes the related sidebar-drift issue.
- MAJOR: hover wasn't cleared on keyboard PgUp/PgDn scroll.
Tests: predicate correctness; hover resolves the right target across all 4
surfaces (specialist card, toggle row, sidebar agent, plan step, permission
option) with the correct priority order; hover changes the actual render
output (transcript + sidebar); hover clears on wheel-scroll and subchat exit;
a regression test proving hoveredSidebarLineOffset self-heals (no highlight)
when a hovered row's identity no longer resolves - the exact scenario the
blocker fix targets. Full gate green (73 pkg, -race).
* fix(local): soften hover color + auto-scroll a drag past the transcript edge
1) Hover highlight was brand-lime (bright, glaring against the near-black
surface) and re-paints on every mouse movement. Switched to amber (an
existing semantic token, "light orange") and dropped the underline per
feedback.
2) Dragging a text selection past the top/bottom edge of the visible
transcript now auto-scrolls toward that side and extends the selection to
follow, matching the browser/editor "drag past the viewport edge" pattern.
transcriptEdgeScrollDelta detects the drag is vertically outside the body
rect (but still within its horizontal span, so it isn't just "off to the
side" over the sidebar); dragToEdgeScroll scrolls one step and anchors the
cursor to whichever line now sits at that edge of the NEW viewport -
deliberately not a re-resolve of the drag's physical mouse position, which
stays outside the body rect after scrolling (scrolling changes what content
occupies a screen row, not the mouse's screen position) and would keep
finding nothing.
Extracted nearestTranscriptSelectableAt as the shared "closest selectable
line to a target bodyY" core, now used by both nearestTranscriptLineAtMouse
(target derived from a mouse position that may have landed on a spacer row)
and dragToEdgeScroll (target derived directly from the viewport bound, no
mouse position involved at all).
Tests: dragging past the top edge scrolls toward older content and extends the
cursor upward; the symmetric bottom-edge case scrolls toward newer content and
extends downward. Full gate green (73 pkg, -race).
* fix(local): smooth-glide the drag-to-edge auto-scroll instead of jumping
The initial edge-scroll implementation (previous commit) applied a single
chatWheelScrollLines (5-line) jump per raw mouse-motion event, which reads as
sudden/jerky since motion events during a real drag arrive in bursts, and
stops dead the instant the physical mouse holds still even while still parked
past the edge (a terminal only reports motion on actual cursor movement).
Replaced with a self-scheduling tea.Tick chain (dragEdgeScrollTickMsg, gated by
edgeScrollSeq mirroring the existing exitConfirmSeq/copyStatusSeq idiom) that
steps a small, fixed increment (dragEdgeScrollStep=1 line) on a short cadence
(dragEdgeScrollInterval=70ms) for as long as the drag holds past the edge,
independent of whether new raw motion events keep arriving. reducedMotion still
gets the original single-bigger-step-per-event behavior (no animated glide).
Adversarial review of the new self-perpetuating tick chain (the risk being an
orphaned/stuck chain) found one real bug: a keypress mid-drag clears
m.transcriptSelection.active directly (the KeyPressMsg handler in model.go)
without going through stopEdgeScroll, leaving edgeScrollDelta stale. The
in-flight tick itself terminates safely via its own liveness check - the bug
was that the STALE nonzero edgeScrollDelta then fooled startEdgeScroll's
"already running" fast path on the NEXT same-direction drag into silently doing
nothing (no immediate step, no scheduled tick) for that whole hold. Fixed at
the press site (transcript_selection.go's mouseLeftPress case): every fresh
press now resets glide state via stopEdgeScroll, so a new drag always starts
clean regardless of how the previous one ended - a single targeted fix rather
than chasing every place transcriptSelection can be cleared.
Tests: top/bottom edge glide starts and extends the cursor over several ticks
(a single small step can land on a blank spacer row between messages, same as
real usage); the chain stops when the drag returns to the body and a stale
in-flight tick after that is a safe no-op; the keypress-interrupt regression
(reproduces the adversarial review's finding, confirms the fix). Full gate
green (73 pkg, -race).
* fix(local): soften the brand-lime accent color across the whole UI
The accent color (#caff3f) was fully saturated at fairly high lightness
(HSL: L 62%, S 100%) - the kind of value that reads as neon/glaring
everywhere it filled a block or bold-text surface, not just the one spot
originally flagged: the composer's "typed" prompt glyph, the permission
popup's focused-option badge, and the text-selection highlight all use it,
plus the spinner, the brand chip, tool-card prompts, and more.
Rather than patch each of those consumers individually, softened the single
palette source (dark theme only; the light theme's accent is a separate,
already-darkened value for its own contrast needs, untouched) to L 50% / S
80% (#ade619) - still clearly on-brand lime, and contrast against both black
(onAccent text on accent fills) and the near-black panel stays far above
WCAG AA (~14:1 and ~13:1, both comfortably over the 4.5:1 floor).
Updated TestStreamingFadePaletteMonotonic's pinned RGB assertion (it checked
the fade ramp's first bucket equals the literal old accent bytes) to the new
values. Full gate green (73 pkg, -race; make lint/go vet clean except an
unrelated stray file that isn't part of this branch or git history at all).
* fix(local): darken the brand-lime accent further per live feedback
Two prior passes (#ade619 at L50/S80, #8db620 at L42/S70) were each tested
live against the original #caff3f via raw truecolor escape codes and
confirmed too subtle in turn. Settled at L36/S62 (#759523) - a noticeably
darker, more olive lime, confirmed against the original side by side.
Contrast vs black (onAccent) and the near-black panel stays comfortably
above WCAG AA (~6.1:1 and ~5.6:1). Updated the streaming-fade test's pinned
RGB assertion to match.
* fix: address PR #350 review comments and CI smoke test failures
Smoke tests (macOS + Ubuntu): the 4 new edge-glide tests implicitly relied
on m.reducedMotion being false, but mouseTestModel() inherits it from
defaultReducedMotion(), which forces true whenever colorprofile.NoTTY is
detected - true in CI's non-TTY test runners, false in a local interactive
dev shell. Set reducedMotion = false explicitly in each of the 4 tests so
they're deterministic regardless of environment. Verified by re-running
locally under ZERO_REDUCED_MOTION=1 (reproduces the CI condition) - passes.
CodeRabbit actionable comment: handleTranscriptSelectionMouse's mouseMotion
case gated only on transcriptSelection.active, which stays true through the
async copy-command grace window after release (a non-empty selection isn't
reset until transcriptCopiedMsg lands) - so a genuine hover motion arriving
in that window would be misrouted as a drag update. The suggested fix
(gate on msg's own Button field) turned out to be wrong in a way only a
full test run caught: TestTranscriptSelectionUpdatesOnGenericMotion is a
pre-existing, deliberate test - some terminals don't restate the button on
every motion event during a real drag, so trusting the per-event Button
field breaks those terminals. Fixed properly instead: added a `dragging`
field to transcriptSelectionState, tracking the app's own press/release
bracket (true at press, false at release) independent of active and
independent of any single event's Button field. mouseMotion now gates on
dragging.
CodeRabbit nitpicks: routed the two remaining raw `m.hover = hoverTarget{}`
subchat-exit assignments through the existing clearHover() helper, so hover
invalidation stays in one place. Left the third nitpick (hit-test layout
recomputed per hover-motion tick) as a documented follow-up per the
reviewer's own "trivial/poor tradeoff" framing - a proper per-frame memoized
cache needs correct invalidation on scroll/resize/content-change to avoid
reintroducing the exact staleness class of bug already fixed twice in this
branch, and isn't worth rushing.
New tests: TestTranscriptHoverAfterReleaseDoesNotMoveSelection (the actual
bug CodeRabbit found - a post-release hover must not alter the just-copied
selection). Full gate green (73 pkg, -race); TestTranscriptSelectionUpdatesOnGenericMotion
(the test the first fix attempt broke) verified still passing.
* fix(local): defeat the macOS stale-pooled-connection hang (transport + stall-retry)
Root cause (you confirmed it: both chatgpt AND ollama hung, only on macOS): providers
fell back to http.DefaultClient, whose pooled keep-alive connections get reused on a
later turn after the server/NAT silently dropped them. The model call is a POST
(non-idempotent), so Go won't auto-retry it on a fresh conn — it blocks forever.
macOS keeps dead pooled conns around longer than Linux, so it reproduced on macOS only.
1. providerio.HTTPClient now returns a shared, tuned transport (ResponseHeaderTimeout
60s bounds a hung POST that gets no response; IdleConnTimeout 30s stops dead conns
from lingering to be reused). Routed openai/codex through it too (anthropic/gemini
already used HTTPClient). Slow first tokens / long reasoning are unaffected — the
header timeout only bounds time-to-first-response-header, not the stream body.
2. Agent loop now retries a stream idle/stall timeout that produced NO output yet, on
a fresh connection (capped + backoff). A partial-output turn is NOT retried (would
duplicate). Recovers any stall that slips past the transport timeout.
Local only — not pushed.
* fix(local): tolerate concatenated tool-arg JSON + widen response-header timeout
1) Weak models (minimax-m3, glm) pack MULTIPLE concatenated top-level JSON objects
into one tool-call arguments slot ({A}{B}), which json.Unmarshal rejects with
'invalid character {' after top-level value' (~9 failed tool calls/session).
New recoverableToolArguments returns the first JSON value ONLY when the payload is
one value optionally followed by more WHOLE JSON values; it rejects a bad/truncated
first value OR trailing NON-JSON garbage ({"x":1}xyz) so genuine corruption still
errors (adversarial-review fix). decodeToolArguments uses it at the central dispatch
(executeToolCall) so {A}{B} runs the first object; historySafeToolCalls uses the
SAME helper so the replayed transcript records the recovered first object (not {}),
keeping dispatch and history consistent. Truly-malformed JSON still hits the
existing sanitize-to-{} + retry path. Trailing objects are dropped (the model
re-issues on a later turn).
2) Raise the shared transport ResponseHeaderTimeout 60s -> 120s so a slow cloud proxy
(ollama *:cloud) that withholds its 200 header until first token isn't wrongly
aborted; still bounds a truly dead reused connection.
Local only — not pushed.
* feat(local): add /turns command + raise default maxTurns 30->50
Agentic multi-step runs (build + delegate + swarm + MCP + self-verify) routinely hit
the 30-turn ceiling mid-task and never reached the later steps; removing /mode earlier
also left NO interactive way to raise the budget.
- /turns [n]: per-session tool-turn budget setter (commandGroupSession), mirroring
/selfcorrect. Empty/status shows the current budget; a positive int sets
agentOptions.MaxTurns (read by the run at model.go); clamped to maxTurnsCeiling=500
to guard against typos; invalid/non-positive shows usage without changing it.
- defaultMaxTurns 30 -> 50 (matches the old 'deep' preset). Raise per-run with /turns
for long tasks (e.g. /turns 150 for a full acceptance run).
Local only — not pushed.
* fix(local): swarm reports failed members as failed + sub-agents inherit /turns budget
Issue B — a swarm member whose child exited non-zero (e.g. exit 4 / max-turns) was
recorded [done] (swarm_status '0 failed'), so the orchestrator — which can't see the
AGENTS panel — trusted incomplete work. The specialist launcher ignored
res.Result.Status; it now surfaces a StatusError result as a member FAILURE
(errors.New(report)) so the swarm marks it [failed]. Added Coordinator.FailWithSession
and switched the member watch path to it so a failed member keeps its session id
(still drillable) and its report rides along as the failure message.
Issue A — a member hit its OWN turn limit because /turns raised only the parent's
budget; children re-resolved config.json's default. New config.SetMaxTurnsEnv exports
ZERO_MAX_TURNS, read in applyEnv -> cfg.MaxTurns; /turns now exports it so spawned
sub-agents / swarm members (which inherit the env) run with the same budget. A
non-numeric/zero env value never clobbers the configured budget.
Tests: launcher non-zero-exit -> failed (+ session kept); SetMaxTurnsEnv round-trip
+ garbage-env guard; /turns exports + clamps the env. Full gate green (73 pkg).
Local only — not pushed.
* fix(local): corrective error for unknown specialist name + clamp ZERO_MAX_TURNS
1) A model that invents a specialist name (validator-runner, file-creator,
file-writer — none registered) got an opaque 'specialist not found' and kept
spawning doomed sub-agents. loadManifest now lists the available specialists and
notes that 'worker' has shell/edit while explorer/code-review are read-only, so the
model self-corrects to a capable specialist. New availableSpecialistList helper.
2) Defense-in-depth (review nit): applyEnv now clamps an inherited ZERO_MAX_TURNS to
MaxTurnsCeiling, so a raw shell 'export ZERO_MAX_TURNS=999999' can't bypass the
/turns ceiling. Ceiling promoted to config.MaxTurnsCeiling (single source of truth,
referenced by the TUI /turns clamp).
Tests: unknown-name error lists worker/explorer/code-review; applyEnv clamps an
over-ceiling env value. Full gate green (73 pkg).
Local only — not pushed.
* fix(local): surface sub-agent kill signal instead of opaque exit -1
When a sub-agent child is terminated by a signal, command.Wait returns an ExitError
whose ExitCode() is -1, losing the cause. Capture ProcessState.String() (portable,
e.g. "signal: killed") into ChildRunResult.Signal and have BuildFinalResult render
it — "Subagent terminated by a signal (signal: killed) — killed before it finished.
Likely causes: the OS out-of-memory killer ... a timeout, or cancellation." — instead
of an opaque "Subagent failed (exit -1)". The message lists causes rather than
asserting OOM, since timeout/cancellation also SIGKILL the child. A captured signal
always surfaces even if a late run_end reported a clean exit. The background task
path (which can't carry the signal name) emits the same hint for a negative exit code.
* address CodeRabbit review on #349
- loop.go: gate stall-retry on a turn-level forwardedAnything flag (wrapped
callbacks), so a retry never re-streams on top of output already forwarded earlier
in the turn (e.g. across the reactive-compaction retry). New test covers a
reasoning-then-stall (collected.Text empty but output forwarded).
- loop.go: route a reissued stream's non-stall error through the SAME recovery as
the initial stream (image-rejection wrapping / context-limit compaction) via a
shared recoverStreamError closure, instead of returning it raw.
- exec.go: drop the hardcoded worker/explorer/code-review guidance from the
unknown-specialist error; rely on the dynamically-rendered available list (a
custom registry may not have those names).
- streamer.go: kill-signal message lists OOM/timeout/cancellation evenhandedly
instead of asserting OOM (the branch also covers timeouts/cancellations).
- launcher_specialist.go + exec.go: preserve the child SessionID on a post-start
failure too, so FailWithSession keeps those failed members drillable.
- model.go: block /turns while a run is active (mid-run change would make the
inherited ZERO_MAX_TURNS budget inconsistent for later-spawned sub-agents).
* fix(tui): sub-agents inherit the parent's live provider after a /model switch
Swarm members and Task sub-agents run as child 'zero exec' processes that re-resolve
the provider from config.json themselves — they're passed --model but never the
parent's provider. After switching provider/model in the TUI via /model (an in-memory
change), the child still resolved config.json's stale activeProvider, landed on a
provider whose credentials didn't match the live model, and failed auth in ~3s
(exit 3, 0 tool calls, 'API key missing or invalid / can't reach the provider') — so
the spawned agent appeared then instantly disappeared.
Export ZERO_PROVIDER (config.SetActiveProviderEnv) to the live provider name on a
/model provider switch. Child processes inherit the environment, and config
resolution already maps ZERO_PROVIDER -> activeProvider, so the child resolves the
SAME profile and its credentials (env key / stored key / OAuth) — covering all three
auth methods, for both Task and swarm (both spawn via the specialist executor).
Startup needs no export: the parent and child both read config's default, so they
already match; only an in-memory switch diverges.
* fix(config): clear ZERO_PROVIDER when switching to an unnamed/default profile
SetActiveProviderEnv("") now unsets ZERO_PROVIDER instead of leaving the previous
value in the environment. Switching from a named provider back to an unnamed/default
profile otherwise kept exporting the stale provider into child zero exec processes,
which would resolve the old credentials. Extended the round-trip test with the
stale-env regression case: seed a value, switch to blank, assert both os.Getenv and
applyEnv no longer see it.
Addresses CodeRabbit review on #348.
* fix(providerio): bound heartbeat-but-no-output streams (content-stall watchdog)
A stalled-but-alive upstream hung the agent indefinitely: SSE keep-alives reset the
idle watchdog (intentionally — a heartbeating upstream isn't 'dead', and aborting it
killed healthy long requests), so a stream that heartbeats while producing nothing
never tripped the idle timeout and there was no other ceiling. Observed on both
chatgpt/gpt-5.x (Codex) and ollama minimax-m3 — different provider code, same shared
providerio SSE loop — so the fix belongs here, once, for every provider.
Add a second 'content' watchdog alongside the idle one: keep-alives still reset idle
(no regression for heartbeating connections), but only REAL data lines reset the
content watchdog. If no data arrives for streamContentStallFactor (2) × the idle
timeout, abort with ErrStreamStalled instead of hanging. A slow-but-producing stream
(data between heartbeats) keeps resetting content and is never killed.
Contained to internal/providers/providerio — no provider/caller changes.
* fix(providerio): surface ErrStreamStalled in callers + widen flaky test margins
Address review:
- All four provider stream handlers (openai, codex, anthropic, gemini) now treat
ErrStreamStalled in the same branch as ErrStreamIdle, via a new
providerio.StreamTimeoutMessage helper that gives the stall a distinct, accurate
message (it reports the content window and does NOT claim the upstream stopped
sending data — keep-alives were still arriving). Previously a stalled stream fell
through to the generic provider-error path.
- Widen the content-watchdog test timing margins (idle 100ms, data/keepalive every
30ms) so CI/GC jitter can't trip the idle watchdog and mask the content path.
- Add a StreamTimeoutMessage unit test.
TestNewRoutesChatGPTCatalogToCodexProvider asserts the chatgpt-account-id header is
empty when no OAuth login is stored, but never redirected the token store — so on a
developer machine with a real chatgpt login it read that login and failed (it still
passed in CI, where no token exists). Point ZERO_OAUTH_TOKENS_PATH at an empty temp
path, mirroring TestNewRoutesChatGPTCatalogWithStoredAccountID, so the test is
deterministic regardless of the developer's stored credentials.
Two agent-visibility fixes surfaced diagnosing a 20-minute gpt-5.5 step and an
'agents never disappear' report.
A) Codex Responses path streamed no reasoning, so a long thinking phase produced
zero visible output and read as a hang (the activity clock never advanced).
Root cause: the request never asked for a reasoning summary and the parser had
no case for reasoning deltas. Now request reasoning.summary="auto" and forward
response.reasoning_summary_text.delta as StreamEventReasoning — the existing TUI
handler renders it live and refreshes the activity clock.
B) A finished swarm member stayed in the AGENTS panel until the whole turn ended.
Now it drops once ITS OWN task completes (fade over the linger window, then
remove), independent of the overall run — matching how finished specialists
already behave. Running members and mid-flight collect are unaffected.
* fix(swarm): constrain spawn/handoff agent_type to the registered roster via enum
swarm_spawn and swarm_handoff described agent_type / to_agent_type as a free-form
string with only an 'e.g. teammate, subagent' hint, so a model could pass an
unregistered type (e.g. 'worker') and only discover it was invalid from the runtime
'unknown agent type' error — wasting tool calls, especially on weaker models.
Set the parameter's enum dynamically from the live roster (Registry.AgentTypes()),
so the schema advertises the valid set (built-ins plus any user-registered agents)
up front and an invalid type can't be sent. Falls back to a free-form string when
the roster is unavailable, so the tool never advertises an empty, un-satisfiable
enum. The runtime 'available agent types: …' error is kept as a backstop.
* test(swarm): also assert handoff to_agent_type enum picks up a custom roster type
Address review: the custom-type recheck only re-read spawnTool, so a regression
leaving handoffTool on the built-in list would have passed.
Some OpenAI-compatible endpoints (e.g. glm-* on Ollama-cloud) reject a request whose
message 'content' is absent or null with HTTP 400 'invalid message content type:
<nil>'. The chat-completions mapper relied on 'content,omitempty', so a message with
empty text (a tool result, an assistant turn that only makes tool calls, or a
sub-agent that failed with no output) serialized with no content field at all.
- Drop omitempty on chatMessage.Content and always set it (to "" when there is no
text), so contentless messages serialize as "content":"" instead of being
dropped. Standard OpenAI/xAI/groq already tolerated the omission; this makes the
strict servers work too.
- Skip a degenerate assistant turn that has neither text nor tool calls, mirroring
the Anthropic/Gemini mappers (which already drop empty turns).
Fixes the failure seen on glm-5.2:cloud after a sub-agent failed with empty output.
Clarify that Linux source builds need the zero-linux-sandbox helper on PATH for native sandboxing, while macOS does not need an extra helper and Windows can self-dispatch from zero.exe.
Also document optional release-style helper builds for Linux and Windows.
Tested: git diff --check
OpenGateway pointed at gateway.gitlawb.com, which does not resolve
(NXDOMAIN), so every request failed with "no such host". The live gateway
is opengateway.gitlawb.com — a flat OpenAI-compatible endpoint
(/v1/chat/completions, Authorization: Bearer ogw_live_…) that smart-routes
by model id across its upstreams (xiaomi-mimo, minimax, qwen, google,
nvidia, z-ai).
- catalog: fix base URL to https://opengateway.gitlawb.com/v1, default model
to mimo-v2.5-pro; add a Recommended descriptor field and hoist OpenGateway
to the top of the catalog so it is the first/badged pick everywhere.
- models: replace the curated model list with the gateway's real upstream
models; fix the stale model-discovery fallback host.
- providers catalog: sort recommended-first (CLI + snapshot) and render the
★ … (recommended) badge; same badge in the TUI /provider wizard and the
setup onboarding list.
- providerhealth: OpenGateway's flat /v1/models 404s by design, so probe its
public /health endpoint for connectivity instead of reporting a false fail.
- tests: update catalog order golden + transport/discovery expectations; add
coverage for the recommended provider and the health-endpoint override.
Co-authored-by: Kevin Codex <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
* chore(launch): MIT license field, windows-arm64 install guard, TUI TTY guard
Three launch-readiness fixes the docs cleanup doesn't cover:
- package.json: set "license": "MIT" (was the placeholder "SEE LICENSE IN
LICENSE"), matching the LICENSE file and the README badge.
- scripts/postinstall.mjs: skip gracefully on windows-arm64. (win32, arm64)
resolves to a valid platform/arch but the release matrix ships no
windows-arm64 artifact, so the installer would download a 404 and hard-fail
npm install. Treat it like any unsupported target (warnSkip, exit 0) and point
to the x64 build (which runs under emulation on Windows on ARM) or source.
- internal/tui/run.go: fail fast when stdin is not a terminal. The interactive
shell otherwise blocks forever in the Bubble Tea event loop on piped/redirected
input (e.g. `echo "" | zero`); now it prints guidance toward `zero exec` and
exits non-zero. Dependency-free ModeCharDevice check (no x/term added).
Tests: windows-arm64 skips with exit 0; the interactive shell returns non-zero
(not a hang) on non-TTY stdin.
* fix(tui): use term.IsTerminal for the stdin guard; pin exit code in test
Address review on the no-TTY guard:
- Replace the os.Stdin.Stat()/ModeCharDevice heuristic with term.IsTerminal
(github.com/charmbracelet/x/term, already in the module graph via bubbletea).
It is a true TTY check — it rejects pipes, regular files, and non-terminal
char devices like /dev/null — and fails closed: anything not a verified
terminal blocks the interactive shell.
- The test now asserts the documented exit code 2 from the guard, not just a
non-zero result, so it proves the stdin guard fired.
Rewrite the README around the initial public-release user flow, add the logo asset, and refresh install/update documentation.
Remove internal planning, audit, design, and work-split documents from the public docs tree.
Update stale documentation references and workspace seed expectations to point at public docs.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/workspaceseed
Tested: git diff --check
* feat(reasoning): typed per-provider reasoning capability from a models.dev catalog
Providers describe reasoning control with incompatible concepts — OpenAI a
discrete effort enum (split by minor version), Anthropic and Gemini 2.5 a
thinking-token budget, some models only a thinking on/off toggle — which a flat
effort string cannot represent and a model-name heuristic cannot detect.
Add internal/reasoning: a Capability type (a model's reasoning flag plus its
typed Controls — effort/budget_tokens/toggle) and a Catalog that looks capability
up by provider + api model id. The data is an embedded, trimmed snapshot of the
community capability database (first-party providers only; routers excluded since
they carry generic/stale option lists), so detection is offline and exact instead
of guessed.
The package depends only on the standard library, so the model registry and
provider adapters can consume it without an import cycle. This change is purely
additive — nothing is wired into the live resolution path yet.
Tests pin the per-version ground truth (gpt-5 minimal-not-none, gpt-5.1
none-not-minimal, gpt-5.1-codex-max xhigh, gpt-5-pro locked to high; Anthropic
budget-only legacy vs effort-only newer with the budget removed; Gemini 2.5
budget/toggle vs Gemini 3 effort) and that every reasoning model Zero ships today
is covered by the snapshot.
* fix(reasoning): address review — presence-aware budget bounds, deep-copy lookup, first-party-only snapshot
- Control.Min/Max are now *int so an explicit bound is distinct from an omitted
one: Gemini's `min: 0` ("thinking can be disabled") and Anthropic's missing
max (unbounded) are preserved instead of both collapsing to 0. Budget() becomes
BudgetControl() returning the control so callers read the pointers.
- Catalog.Lookup returns a deep copy (clone) of the matched Capability, so a
caller can no longer mutate the shared embedded catalog through the returned
Controls / Values slices or budget pointers.
- Drop the google-vertex section from the snapshot: it re-hosts third-party MaaS
models (Anthropic/Moonshot/OpenAI/DeepSeek/Qwen/Meta), violating first-party
-only. google (AI Studio) already covers first-party Gemini, so the gemini
provider kind resolves there. Snapshot 158 -> 123 models, 16.8 KB.
Tests pin the explicit min: 0 preservation, the nil max on budget-only Anthropic
models, and that a mutation of a Lookup result does not leak into the catalog.
* fix(reasoning): address round-2 review — accessor deep-copy, max tier, provenance
- EffortControl/BudgetControl now return a cloned Control, so a caller holding a
Capability that shares state with the catalog cannot mutate Min/Max pointers or
the Values slice through an accessor result (CodeRabbit). Lookup already deep
-copies; this closes the accessor path too.
- Add modelregistry.ReasoningEffortMax ("max") + accept it in ValidReasoningEffort,
so the snapshot's "max" tier (e.g. gpt-5.1-codex-max, newer Anthropic) has a
constant to map to. No curated model lists it yet.
- Snapshot provenance: add a _fetched date and a regeneration recipe (documented
in catalog.go), so refreshes are reproducible/auditable.
- Document that Lookup takes the API model id (wire name), not the friendly
registry id, and that the match is exact (no case-fold/prefix-strip).
- Record the source-of-truth intent in the package doc: this catalog is the
intended authoritative source; modelregistry's name-pattern inference is demoted
to a fallback when it is wired in (a later change). Rename the `cap` builtin
shadow to `entry`.
Tests pin the accessor deep-copy isolation.
The /mode presets bundled a hardcoded Anthropic/Google model + reasoning effort +
turn budget, so on other providers (Ollama-cloud, xAI, GPT-5, custom) /mode would
switch to a model the user isn't authenticated to. With /model (any provider's
models) and /effort (reasoning effort) covering the same ground flexibly, /mode is
redundant in the TUI.
Removes the /mode slash command, its handler, the mode picker, and the dispatch +
stale comments; updates affected tests. The CLI `--mode` flag and internal/
modelregistry mode presets are kept (still used by headless exec).
* Polish transcript rendering
Tighten transcript spacing, collapse noisy permission/tool rows, and render tool activity with cleaner action-oriented labels.
Improve code and diff rendering with markdown highlighting, cleaner command output blocks, and grouped exploration summaries.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: git diff --check
* Fix grep exploration rendering on Windows
Keep search query text out of path normalization so regex escapes render consistently across platforms.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: git diff --check
* Address transcript rendering review
Cache highlighted streaming markdown so closed code blocks are not re-lexed on every live render.
Tighten bare-code detection to avoid highlighting ordinary prose, restore manual permission audit rows, and make exploration details expandable.
Also align diff content/header predicates and use display-width truncation for compact tool output.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: env GOCACHE=/tmp/zero-go-cache go test -race ./internal/tui
Tested: env GOCACHE=/tmp/zero-go-cache go vet ./...
Tested: git diff --check
The Codex provider speaks the Responses API, where reasoning_effort is nested
under `reasoning.effort` rather than sent as a top-level field. buildResponsesRequest
never set it, so a user's chosen effort was silently dropped for every Codex
model — the request always ran at the model default.
Add a `reasoning` object to the Responses request and populate it from the
request's effort, reusing the chat path's openAIReasoningEffort normalizer so
only API-accepted values (minimal/low/medium/high) are sent and an empty or
unsupported effort omits the field (no empty object, no 400).
The /effort picker and the run-time effort resolver disagreed about which
tiers a model supports. Registry.ReasoningEfforts falls back to name-based
inference (reasoningEffortsForModelName) when the catalog lists no efforts,
so the picker shows controls for proxy-served GPT-5 / Codex / o-series
models. EffectiveReasoningEffort read model.ReasoningEfforts directly and
never saw that fallback, so it coerced the same request to "none" — the
picker advertised a tier the resolver silently dropped.
Route both through a single effectiveReasoningEfforts helper so they can
never diverge. A model the catalog enumerates with no efforts but whose
name is a known reasoning family now has its requested effort honored end
to end; non-reasoning models still resolve to "none".
Also drop the orphaned, stale NOTE above forwardedReasoningEffort claiming
effort "is not yet forwarded" (it is) and restore the reasoningEffortNotice
doc comment to its own function.
* fix(agent): enable auto-compaction for all models, not just registered ones
Auto-compaction keys off the model's context window (enabled = ContextWindow > 0),
resolved only from the curated registry. Unregistered models — GPT-5/Codex, xAI,
Ollama-cloud, custom endpoints — resolved to 0, so both the proactive (~80%) and the
reactive post-overflow compaction were silently disabled and long sessions overflowed.
- TUI modelContextWindow now also consults live-discovered windows (accurate for
proxy/custom models once /model has discovered them); unknown still returns 0 so
display gauges show no guessed denominator.
- New modelregistry.AgentContextWindow applies a positive FallbackContextWindow (200k)
ONLY on the compaction-enable path (TUI run + CLI exec/spec), so compaction is on for
every model while display stays accurate-or-empty. Exact windows always win.
* feat(tui): show context usage (used / total + % gauge) for all models from launch
The sidebar/status-line context gauge only shows a total + fill % when the active
model's context window is known. For proxy/custom models (GPT-5/Codex, xAI, Ollama-
cloud) the window isn't in the registry, so it showed only a used-token count.
Warm model discovery for the active provider in Init() (background, non-blocking) so
the active model's window is learned at launch and the gauge renders 'used / total
tokens' + the fill % for every model — not just catalogued ones. Builds on the
discovered-window resolution from the compaction fix.
* fix(tui): address review — agent fallback on model-switch + active-provider window
- Apply modelregistry.AgentContextWindow to /model and /mode pre-switch compaction
requests (TargetContextWindow), so the 'compaction for all models' behavior also
covers undiscovered/custom switch targets, not just the active run.
- Scope the live-discovered context-window lookup to the active provider first
(then others), so a model ID shared across providers resolves to the provider in
use rather than an arbitrary match.
- Tighten the resolution test to compare against the registry's actual gpt-4.1
window instead of != FallbackContextWindow.
* fix(cli): give provider-discovered context windows precedence in headless exec
Address the remaining review point: CLI exec/spec runs collapsed every uncatalogued
model to the 200k fallback, ignoring its real window. resolveAgentContextWindow now,
on a registry miss, queries the provider's live model list (resolving the credential
from inline/stored/env) to get the model's actual context window before falling back.
Discovery runs only on a registry miss (catalogued models pay no latency), is bounded
by a 5s timeout, and degrades to the fallback on any error so a headless run is never
blocked or failed.
Slash-command/file palettes (and pickers/wizards) were composited directly over the
live transcript with no separation, so the chat bled through around the box and the
overlay was hard to read. Add a scrim: when an overlay is open, dim the transcript
backdrop (strip each line's colors and re-render faint) before compositing the bright
overlay on top. Header and composer render separately and stay bright. Text is
preserved (just dimmed), so context remains readable behind the palette.
The model-switch confirmation printed a 6-7 line block (Switched model. / model: /
provider: / api model: / effort: / saved:). Collapse it to a single summary line —
'<model> · <provider>[ · effort …][ · saved]' — keeping the deprecation/redirect
notice line when present. Tests updated to the new format.
/effort showed no controls for ChatGPT (GPT-5) because those models aren't in the
curated registry and the OpenAI catalog entries carry no reasoning efforts, so
Registry.ReasoningEfforts returned nil even though GPT-5 supports reasoning_effort.
Add a name-based fallback: when a model isn't enumerated with efforts, infer them for
known reasoning families — GPT-5 / gpt-5.x Codex (minimal/low/medium/high) and the
o-series (low/medium/high). Non-reasoning models (GPT-4.1/4o, local models) stay empty,
so /effort still hides controls where they don't apply.
Encrypted credential storage
- internal/securefile: AES-256-GCM at-rest crypter (fail-closed, atomic, locked).
- internal/credstore: provider API-key store, keyring-first with an encrypted-file
fallback; plaintext only via an explicit ZERO_CRED_STORAGE=file opt-out.
- Provider API keys no longer live in config.json: captured straight into the store
from the wizard/CLI setup, and any existing inline plaintext key is migrated to the
store on launch (fail-soft; stripped only after the store write succeeds).
- buildProvider and every model/provider switch reload the credential (stored key,
env var, or OAuth bearer), so a stored-key provider always authenticates.
- `zero auth logout <provider>` now also removes the stored API key and its marker.
/model and /provider
- /model lists every authenticated provider (filtered to those with a usable
credential), grouped per provider under section headers.
- Each provider's models are live-discovered (its real served models) rather than a
static catalog; local providers show only the models actually pulled.
- Selecting a model from another provider switches provider and model together.
- /provider offers keep / replace / remove when a provider already has a stored key.
Gated throughout: go build, go vet ./..., go test ./... -race, lint.
* feat(tui): tabbed ask_user questionnaire rendered in the composer
Redesign the interactive ask_user prompt to match the requested mockup: when a
question is raised the composer text box becomes the questionnaire.
- Multi-question prompts render as a row of TABS (one per question + a trailing
Confirm tab). Tab / Shift+Tab switch questions, answers are given in any order,
and Enter on Confirm submits them all. A single-question prompt submits on
answer (no Confirm step) and shows no tab row.
- Each option can carry a one-line DESCRIPTION (contract: per-option
optionDescriptions, or option objects {label, description}); options render
numbered with the description dimmer underneath and the recommended one tagged.
- Per-question optional `header` gives each tab a short title (falls back to a
trimmed question).
- Rendered in the composer/footer region (footerView), replacing the text box
while active, instead of a scrolling transcript card.
- Keys: Tab move · ↑↓ select · Enter confirm · Esc dismiss; a printable key in
the picker drops into free-text; Esc from "type your own" returns to the picker;
multi-select questions are free-text with the options shown as suggestions.
- Headless unaffected (zero exec never wires OnAskUser).
Removed the old single-question sequential state (cursor/typing/index,
renderFocusedAskUserPrompt, resolveAskUser). Tests rewritten for the tabbed flow
(picker default, type-your-own, multi-question tabs + Confirm, Esc dismiss,
descriptions render). make build / go vet / go test ./... -race / make lint green.
* fix(tui): ask_user questionnaire renders on the black terminal canvas
It was painting a gray panel background (styledBlockFill with zeroTheme.panel +
onPanel-wrapped foregrounds). Since the questionnaire replaces the composer, it
should match it: bare foregrounds on the terminal canvas (black) with the
composer's lineStrong border and no fill. The lime cursor/badge highlights and
the recommended tag stay.
* fix(tui): address PR review — schema/parser alignment, single-question Tab
- ask_user schema: options.items is declared as string, so advertise only the
string form (descriptions go via the parallel optionDescriptions array). The
parser stays object-tolerant defensively, but the schema no longer tells
strict-schema providers to send objects it would reject.
- moveAskUserTab no-ops for single-question prompts (no tab strip / Confirm tab),
so Tab/Shift+Tab can't move the prompt into the hidden Confirm state. Test added.
Selecting text in the transcript lit up in two places: the real selection
plus a copy shifted right by the reading-column gutter (e.g. selecting "help"
also highlighted "today").
Root cause: assistant, user, and reasoning rows self-painted the selection
highlight in their render functions using the UNSHIFTED textStart, but the
selection points (anchor.x/cursor.x) are in the gutter-shifted screen
coordinate the mouse maps to. So the self-paint landed gutter cells off, and
finalizeTranscriptBodyRow then painted the highlight AGAIN at the correct
shifted position — two highlights, one gutter apart. The rendered/toolResult/
specialist rows were already converted to paint-once-at-finalize; these three
were left behind.
Fix: stop self-painting in renderSelectableUserRow, renderSelectableAssistantRow,
and renderSelectableReasoningBlock. They now return the canonical renderRow
output (already line-aligned with their selectable spans) and let
finalizeTranscriptBodyRow be the single painter, in the same shifted coordinate
the mouse uses. Removes the now-dead renderTranscriptSelectableText /
renderTranscriptSelectableMarkdownText helpers.
Regression test TestTranscriptSelectionPaintsHighlightOnceNotTwice selects a
sub-range of an assistant row and asserts the selection style is emitted exactly
once; it fails on the pre-fix code (painted 3x, including the gutter-shifted copy).
* feat(tui): suggested answers with a recommended default for ask_user
When the agent asks a clarifying question mid-task, it can now offer 2-4
suggested answers and mark one recommended; the interactive TUI renders a
selector (arrow keys, lime cursor, "(recommended)" tag) with the cursor resting
on the recommended option, plus a trailing "✎ Type my own answer…" entry that
drops into the existing free-text composer. Whichever the user picks — a
suggested option's text or their typed answer — flows through the same answer
channel the agent loop already waits on.
Optional and backward-compatible: a question with no options renders the plain
free-text prompt exactly as before. Headless `zero exec` is unaffected (it never
wires OnAskUser; the non-interactive best-assumption fallback stands).
- contract: agent.AskUserQuestion / tools.AskUserQuestion gain Recommended;
the tool schema advertises optional options + recommended; parser keeps
recommended only when it resolves to one of the options (canonical text).
- system prompt: tells the model it MAY supply options + a recommended one.
- TUI: pendingAskUserPrompt gains cursor/typing; ask_user_prompt.go holds the
selector logic mirroring permission_prompt.go; renderFocusedAskUserPrompt
renders the picker or the free-text prompt.
Tests: selector defaults to recommended and returns its text; "type my own"
falls back to free-text and returns the typed text; no-options stays plain
free-text. make build / go vet / go test ./... -race / make lint green.
* fix(tui): ask_user — single input, Esc steps back from "type my own"
Two UX fixes from interactive testing:
- No double display: the focused ask card no longer echoes the in-progress
free-text answer (it was shown both inside the card AND in the composer text
box). The composer is now the single input; the card just shows the question
and a hint. renderFocusedAskUserPrompt no longer takes the input string.
- Esc from "type my own" returns to the selector for that question instead of
cancelling the whole questionnaire (escapeAskUser); a question with no options,
or the selector itself, still cancels on Esc as before.
Tests: type-my-own asserts the card does not echo the typed text and the hint
flips to "type your own answer"; new test covers Esc returning to the selector
(no cancel, no answer delivered) then selecting a suggested option.
* fix(tui): ask_user review fixes — multi-select, selector typing, dead code
Address the three confirmed findings from the code review:
- multi-select no longer silently narrowed to one option: a single-pick
selector cannot represent MultiSelect, so a multi-select question now renders
as free-text with its options surfaced as suggestions; the user types one or
more and the answer is returned verbatim (syncQuestionState; escapeAskUser and
the renderer updated to match).
- typing in selector mode no longer discarded: a printable keystroke while the
selector is showing flips into free-text and captures the key (model.go key
capture), instead of accumulating in a hidden input that Enter then overrode
with the highlighted option.
- removed dead submitAskUserAnswer (the Enter handler now routes through
confirmAskUser; the orphan had no callers).
Tests: multi-select falls back to free-text and returns the typed answer;
a printable key in the selector switches to free-text and is captured. Full
gate green (build / vet / go test ./... -race / make lint).
* fix(tui): address PR review — element-wise option compare, clearer Esc copy
- ask_user_test: compare option slices with slices.Equal instead of
strings.Join, so the parser test can't be fooled by ["A,B"] vs ["A","B"]
or nil vs [""].
- ask_user prompt: the Esc hint said "skip", which reads like "skip this
question" on a multi-question prompt even though Esc ends the rest of the
questionnaire (remaining answered by best-assumption). Reworded to "skip the
rest" in the selector and free-text hints; "type my own" keeps "go back".
* Move deferred tool discovery into tool search
Keep deferred tool discovery in the tool_search definition instead of appending it as a per-turn user message, so models do not answer or acknowledge internal discovery metadata.
Preserve deferred loading behavior: hidden tools remain hidden until loaded, tool_search stays reachable for allowlisted deferred tools, and retry paths keep loaded/deferred state.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tools -run 'Test.*Deferred|Test.*ToolSearch|TestRunLoadsDeferredToolThenAdvertisesNextTurn|TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery|TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery|TestAllowlistedDeferredToolsKeepToolSearchReachable|TestDisabledToolSearchFallsBackToEager'
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tools
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tools ./internal/cli
Tested: git diff --check
* Clarify deferred tool search selection
* fix(agent): don't end a run as success on a no-tool-call turn mid-task
A turn that produced text but no tool call was always accepted as the final answer, so the loop reported success even when the model stopped mid-task (e.g. ended on "...Let me check the SSH configuration:" with plan steps still pending).
Add an opt-in completion gate (Options.RequireCompletionSignal): when a turn has no tool call and work clearly remains -- pending update_plan items, or the message ends on a continuation cue -- re-prompt the model to continue instead of finalizing. Bounded by maxContinueNudges and still by MaxTurns/the deadline; once the budget is spent the run finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default off, so the interactive path is byte-identical.
Genuine single-turn completions (no pending plan, no cue) still finalize as success. Covered by internal/agent/completion_gate_test.go.
* feat(exec): report stalled headless runs as INCOMPLETE (exit 4)
Enable the agent completion gate for headless exec (RequireCompletionSignal) and map Result.Incomplete to run_end status "incomplete" with a new exit code 4, so a run that stalled mid-task (model stopped without a tool call while work remained, continue budget exhausted) is no longer reported as success. Interactive callers are unaffected.
* fix(agent): downgrade self-reported non-completion; advisory task-grounded acceptance
Reduce -- not eliminate -- false-success on headless runs. Two DETERMINISTIC, unit-tested gates (with the plan gate from the prior commit):
(a) self-report downgrade: if the final message admits the model guessed or could not meet the objective, finalize INCOMPLETE (exit 4), never success. Inability is matched by first-person STEMS generalized over verb/tense ("I cannot/can't/could not/am unable to/do not have/unable to ...") plus guess/fallback/uncertainty phrases, with a guard so success-y negations ("could not find any issues", "cannot reproduce") are not misread. (b7bc0b8's plan gate already forces INCOMPLETE on pending/in_progress update_plan items at termination.)
(b) task-grounded acceptance is ADVISORY, not a guarantee. When --self-correct is on it demands one bounded acceptance pass that re-derives the task's stated criterion and runs a concrete check, discouraging three false-success patterns (well-formed==correct, existing-tests-pass==objective-met, result==baseline-it-was-told-to-beat). But it is a prompt: a model that ignores it and confidently claims "PASS, all requirements met" still slips, because ZERO has no general oracle to verify correctness against a task's hidden criterion. Empirically (TB-2, qwen3-coder:480b) this reliably catches admissions and incomplete plans and REDUCES false-success, but a confident false PASS on a model-ceiling task is a residual, fundamental gap -- not a tuning miss.
Default off (RequireCompletionSignal); interactive callers are byte-identical. Covered by internal/agent/{acceptance_gate_test.go,completion_gate_test.go}.
* feat(exec): surface the INCOMPLETE reason in run_end and logs
When a headless run finalizes as INCOMPLETE, include Result.IncompleteReason in the session error event and a stderr warning so an honestly-incomplete run (e.g. "the final message admits the objective was not met") is debuggable rather than an opaque exit 4.
* fix(agent): harden completion gate per PR review
Addresses the correctness findings from the PR review.
self-report (#1): drop behavior-describing phrases ("fall back to",
"placeholder value", bare "best guess", "as a fallback", "without proper")
that also match legitimate final answers; keep first-person/uncertainty
admissions only, since these are matched without a context guard.
self-report (#5): scan every occurrence of each inability stem so an early
success-negation ("could not find any examples") no longer masks a later
genuine admission with the same stem ("could not implement it").
continuation cue (#6): require a trailing colon AND an action lead-in on the
final clause; stop flagging recommendations, plain summary colons, and
sign-offs. Still catches the mid-line "...Let me check the config:".
gate order (#8): check the self-report admission BEFORE pending-plan, so an
admitted-impossible task downgrades immediately with the accurate reason
instead of burning continue-nudges.
pending plan (#3): treat a pending/in_progress update_plan item as a
NUDGE-only weak signal -- it no longer forces INCOMPLETE on its own (a
completed run that left stale plan bookkeeping is trusted). Only a
continuation cue or a self-report admission finalizes INCOMPLETE.
max-turns (#4): a run cut off at the MaxTurns ceiling now finalizes
INCOMPLETE under the gate instead of being reported as success.
exec json/cron (#2, #7): for -o json, emit the terminal done with exit 4 on
an incomplete run (final() pre-emits a success done:0 for json that would
otherwise mask it); emit an error event -- not just a warning -- so the cron
failure extractor can recover the reason.
Deferred (noted on the PR): acceptance-only-when-mutated (#9, cost) and
reusing tools.normalizePlanStatus (#10, would widen scope to internal/tools).
Tests: add TestContinuationCueMatching and TestMaxTurnsCutoffIsIncompleteUnderGate,
extend TestSelfReportedIncompletionMatching with the #1/#5 cases, and replace
the in_progress=>incomplete test with TestPendingPlanAloneDoesNotForceIncomplete.
make build / go vet / make lint / go test ./... -race all green.
Retry shell commands outside the sandbox when sandboxed output only reflects the sandbox namespace, and keep network retries sandboxed when a network grant is enough.
Show user-facing permission reasons for escalated shell commands instead of exposing internal sandbox policy text.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tui -run 'TestRunUsesEscalatedJustificationForPermissionPrompt|TestRunUsesUserFacingEscalatedFallbackForPermissionPrompt|TestPermissionPromptMapsEscalatedSandboxReason|TestRunRetriesShellUnsandboxedAfterSandboxNamespaceLimitedOutput'
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tools
Tested: env HOME=/tmp/zero-test-home-clean XDG_CONFIG_HOME=/tmp/zero-test-config-clean GOCACHE=/tmp/zero-go-cache go test ./...
* Align shell approval sandbox execution
Make approved shell command-prefix executions use the escalated sandbox path when denied-read policy allows it, so remembered approvals do not still hit the restrictive sandbox.
Allow auto sandbox mode to degrade to a direct command plan when native command wrapping is unavailable, while strict sandbox require continues to fail closed.
Update regression tests for degraded execution, prefix-approved shell calls, and unavailable native sandbox metadata.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/sandbox
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestRegistryRunsWithDegradedUnavailableNativeSandbox|TestBashToolRunsWithDegradedUnavailableNativeSandbox|TestBashRequireEscalatedBypassesNativeSandboxAfterApproval|TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval'
Tested: GOCACHE=/tmp/zero-go-cache go test ./... -run '^$'
Tested: git diff --check
* Align sandbox command approval behavior
Evaluate reusable shell approvals across plain command segments so prefix grants can cover commands with safe read-only tails without relying on command-specific sandbox exceptions.
Keep process inspection commands sandboxed by default; unsandboxed host access continues to require explicit escalation or a narrow command-prefix approval.
Improve shell tool guidance around sandboxed additional permissions, full escalation, justifications, and narrow reusable prefixes.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/agent
Tested: env HOME=/tmp/zero-test-home-clean XDG_CONFIG_HOME=/tmp/zero-test-config-clean GOCACHE=/tmp/zero-go-cache go test ./...
* Normalize explicit sandbox backend platforms
Infer the platform from a caller-provided sandbox backend name before choosing the manager runtime platform. This keeps Linux, macOS, and Windows backend plans deterministic across host OSes when tests or callers provide a backend without an explicit Platform field.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools
Tested: env HOME=/tmp/zero-test-home-clean XDG_CONFIG_HOME=/tmp/zero-test-config-clean GOCACHE=/tmp/zero-go-cache go test ./...
* ci(release): build all 5 platforms (add Intel macOS + Linux arm64 runners)
zero-release package builds + smoke-tests natively, so each target needs its own
runner. The matrix had only ubuntu-latest/macos-latest/windows-latest (linux-x64,
macos-arm64, windows-x64) — so install.sh/install.ps1 404 for Intel Macs
(macos-x64) and Linux arm64. Adds macos-13 (Intel) and ubuntu-24.04-arm so the
release covers all platforms the installers resolve.
* ci(release): use macos-15-intel for the Intel macOS leg
macos-13 was retired (June 2026) so the matrix leg would never schedule.
macos-15-intel is the current GitHub-hosted Intel x64 runner (supported through
Aug 2027). Addresses CodeRabbit review on #321.
* docs: add MIT LICENSE
Adds the canonical MIT License (Copyright (c) 2026 Gitlawb) at the repo root,
resolving the launch blocker where the project was marketed as open source with
no license file (legally all-rights-reserved).
* docs(readme): declare MIT license + badge, link LICENSE
README's License section said 'being finalized'; now states MIT and links the
LICENSE file. Adds a license badge to the header row.
* Add local control browser and terminal tools
Add configurable local browser, desktop, and terminal control helpers with packaged helper discovery for install and release flows.
Improve sandbox scope classification for local control commands and shared temporary paths.
Add structured browser actions, terminal session line input/key normalization/read support, and artifact capture for local-control outputs.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/localcontrol ./internal/tools ./internal/cli ./internal/config -run 'Test.*LocalControl|TestTerminal|TestBrowser|TestDesktop|TestCapture'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/localcontrol ./internal/tools ./internal/cli ./internal/config ./internal/agent
Tested: tuistory smoke launched bash, sent interactive input, waited for expected output, and captured a no-cursor snapshot
Tested: git diff --cached --check
* Fix npm wrapper helper manifest test on macOS
Canonicalize expected helper paths in the npm wrapper manifest test so macOS /var and /private/var temp path aliases compare correctly.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/npmwrapper
Tested: git diff --check
* Harden local control review fixes
Respect resolved local-control config before tool listing and filter validation while preserving stream-json usage error ordering.
Harden browser, desktop, and artifact tool validation; guard Linux-only app launch behavior; clear stale helper manifests from the npm wrapper.
Make helper packaging lockfile-based with npm ci and add installer/package tests for helper staging.
Tested: focused local-control/tool/CLI/release/install/sandbox suite
Tested: isolated full go test ./...
Tested: macOS and Windows compile-only checks for touched packages
* fix(tui): plan panel reflects live progress + prompt cadence
The plan panel could read as stuck mid-task even while work continued. Three
fixes:
- Header showed steps[0] forever, so the title never advanced as the plan
progressed; show the current step (in_progress, else first incomplete).
- updateFromItems matched steps by content only, so an in-place reword reset a
step's elapsed timer to zero; add positional carry-over when the step count is
unchanged.
- Prompt: tell the model to mark each step completed + the next in_progress
before starting it, so the plan tracks real progress instead of jumping at the
end.
Regression tests included.
* feat(tui): click a plan step to see what was built for it
Each plan step now records the file mutations (write_file/edit_file/apply_patch)
made while it was in_progress, captured from tool-result rows. Clicking a step
row in the context sidebar drops a transcript card listing those changes — the
implementation for that step. Step->line mapping mirrors sidebarAgentSelectables'
offset accounting (one line per step); capture is keyed to the active step and
cleared per run. Read-only and additive. Tests cover the offset math + capture.
* feat(tui): plan-step detail shows diffs + commands
Extend the clickable plan-step detail: capture bash/exec_command runs in addition
to file mutations, store each tool's full output (the diff for edit/apply_patch,
stdout/stderr for commands), and render the card grouped into Changes and
Commands with a short truncated excerpt of each item's diff/output.
* fix(tui): plan-step detail toggles instead of stacking duplicates
Re-clicking a plan step now hides its card (a stable transcript id + drop-by-id),
and clicking a different step replaces it, so at most one detail card is shown.
Previously each click appended a fresh card and they piled up.
* feat(tui): elaborate plan-step detail by status (what we did / will do)
* feat(tui): plan-step card leads with a prose explanation (agent's own narration)
* feat(tui): write plan-step explanations with the model on click
Clicking a PLAN step now shows a fresh, plain-English write-up of that
step, generated on demand by the active model:
- past tense ('what we did') for completed/failed steps, future tense
('what we'll do') for pending/in_progress steps
- immediate feedback: the card drops in showing 'Writing explanation…'
and updates in place (by stable row id) when the text returns
- cached per step (content+status) so re-clicking is instant with no
second model call; re-clicking still toggles the card closed
- the prompt is built from already-captured local data (step text,
status, notes, plus a compact digest of file edits, commands, and the
agent's narration) and asks for short, non-technical prose
- reuses the TUI's existing provider via a one-shot StreamCompletion;
no new client, no hardcoded provider. Falls back to the local summary
when no provider is configured or the request fails.
* fix(tui): minimal plan-step card styling (dim border, status-tinted title)
* feat(tui): auto-hide context sidebar when it has no agents or plan
* feat(tui): widen chat reading column to fill wide terminals with breathing room
* fix(tui): toggle context sidebar silently (no transcript notice)
* fix(tui): mark plan complete when a finished task forgot the final update_plan
* fix(tui): remove the lime streaming-text glow (render responses in static ink)
* fix(tui): remove the shaded panel band behind tool-result cards
* fix(tui): Ctrl+B hides the plan entirely (no pinned-panel fallback)
* style(tui): gofmt-align model struct fields
* feat: narrate the build as a clean story (hide plumbing cards, live plan progress, narration prompt, write_file line counts)
* feat(tui): render agent narration bright (ink) so the build reads as a story
* feat(agent): demand an elaborate, structured closing summary (not a single line)
* feat(tui): mark agent narration with a leading bullet (selection-aligned)
* feat: post-turn recap line (※ recap:) with /config recaps on|off toggle
* feat(tui): show 'still generating…' during quiet stretches + count tool-call streaming
* feat(tui): ACTIVITY panel under PLAN + push the model to step the plan per file
* fix(tui): show the quiet 'still generating' hint only when the sidebar is hidden
* feat(tui): code preview in write/edit/apply cards (card-only, no model token cost)
* fix(tui): address CodeRabbit review — stale plan timestamps, stale explanation, Ctrl+B persistence, UTF-8 truncation
* fix: address CodeRabbit re-review round 2 (7 actionable + 2 nitpicks)
* chore: drop accidentally-committed WIP internal/cli/dryrun_test.go
It is an unrelated, pre-existing dry-run test that does not compile (references a
non-existent agent.Options.DryRun field, an unused import, and out-of-scope
sandbox symbols). It was swept into the prior commit by 'git add -A' and is not
part of this PR's scope.
The prompt told the model "Use tools to act, not to narrate", which also
suppressed the brief upfront "here's my approach" the user relies on to follow
what the CLI is doing — so Zero jumped silently into tool calls. Keep the
per-call anti-spam, but lead a multi-step task with a one- or two-sentence
plain-language preamble. Prompt-only change; the TUI already commits streamed
pre-tool prose as an assistant row, so no render change is needed.
write_file/edit_file only reported "Created X (8462 bytes)" / "edited X",
so the user never saw WHAT changed — no code preview, no red/green. The
TUI already renders a colored diff card whenever a tool's output looks
like a unified diff (diffFirstToolBodyRenderer + looksLikeDiff), so the
fix is purely tool-side: emit a diff.
- edit_file appends a unified diff (old vs new) after its summary line →
red/green change preview.
- write_file appends a unified diff of prior vs new content: a fresh
create previews as all-green additions, an overwrite as red/green. It
reads the prior bytes before writing only when the file existed.
- boundedUnifiedDiff (via the already-vendored go-udiff, now a direct
require) caps the emitted diff at 48KB so a large generated file can't
flood the transcript or balloon persisted session events; past the cap
only the summary shows. The summary stays the first output line so any
consumer that reads it (swarm/Task result bubbling) is unaffected.
Tests: edit emits @@/-/+ ; new-file emits additions-only; overwrite emits
red/green. Full tools/tui/agent/swarm suites green.
Two chat-clarity fixes surfaced by comparing Zero's output to a reference
agent on a "build a website" task.
1. ANSI escape garbage in the final answer. styleAssistantMarkdownLine
treated already-styled input (syntax-highlighted code, headings, tables —
which carry real ANSI) byte-by-byte as runes and re-wrapped the whole line
in another base.Render. That doubled the SGR density and let a downstream
width-truncation slice mid-escape, leaking "[38;2;…" / "[1;4;…" fragments
as literal text next to code blocks. Now real escape sequences pass through
verbatim (via the existing ansiSequenceEnd helper, like truncateStyledLine);
the synthetic bold markers are still matched first so their style switch is
kept. Only styled segments were affected — plain prose never had embedded
escapes to split.
2. Redundant update_plan tool cards. The plan is already shown in the pinned
plan panel AND the PLAN sidebar, so rendering each update_plan call as its
own transcript card was pure duplication (it stacked 3x in the demo).
Generalize the existing Task-card skip into toolCardSuppressedInTranscript
and skip update_plan's call+result rows too; the plan-panel sync and session
events are unchanged.
Tests: TestStyleAssistantMarkdownLinePassesAnsiVerbatim (fails before the fix),
TestToolCardSuppressedInTranscript.
* feat(swarm): let swarm members build (write + sandboxed shell)
Swarm subagents were effectively read-only: a member runs headless at
"--auto low" (PermissionModeAuto), and ToolAdvertised strips every
prompt-requiring tool from the model's list — so write_file/edit_file/
apply_patch/bash were never handed to the member. It only saw read/grep/
glob/plan tools, so it could not build and the orchestrator did all the
writes itself.
Add PermissionModeMemberAuto: a headless mode that ADVERTISES the
in-workspace mutators (SideEffectWrite + SideEffectShell) on top of the
Auto set, while behaving exactly like Auto everywhere else. The sandbox
engine still decides at call time — in-workspace writes and sandbox-backed
shell auto-allow; out-of-workspace writes, network, and destructive
commands prompt and so are denied headless. member-auto normalizes to Auto
in the sandbox layer, so no sandbox authority is widened beyond what an
interactive auto agent already has.
Scope: swarm members only. The launcher sets MemberAutonomy; the executor
emits "--auto member" for a non-unsafe member (unsafe still -> high, plain
specialists still -> low). Task-tool specialists are unchanged.
Windows note: shell only auto-runs when the OS sandbox is active; without
`zero sandbox setup` a member writes files but cannot run builds.
* style: gofmt member_auto_test.go
* feat(tui): keep finished swarm members visible during the run
Finished swarm members vanished from the AGENTS sidebar after a 1.5s
linger, so on a long turn (the orchestrator keeps working after the
subagents finish) they were gone before the user could inspect them —
the panel read "no agents spawned" mid-run and the clickable drill-in
had nothing to click.
Now a finished member stays in the panel (solid check, still clickable
to open its subchat) for as long as the run is in flight, and only fades
out and drops once the turn ends. Spawns are scoped to the active run
(row.runID matches activeRunID) so a previous turn's members do not
reappear when a later turn keeps members visible.
* fix(tui): scope swarm member status to the active run
Address review: swarmSpawnedAgents filtered spawn rows by activeRunID, but
swarmMemberStatus still read swarm_status/swarm_collect rows from any run. With
reused task ids, a stale prior-run status could mark a current member done and
wrongly fade/drop it. Scope swarmMemberStatus to the active run too, and tighten
the tests (non-zero run id; a stale same-id done status from a prior run).
Treat platform temp directories as default writable roots so file tools, shell commands, and sandbox profiles share the same temp view.
On Linux, bind host temp roots instead of mounting an isolated tmpfs at /tmp so artifacts created under /tmp remain visible across tool and shell calls.
Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/cli ./internal/agent
* feat(tui): click a swarm member in the sidebar to open its subchat
Specialists were drillable (click the card → subchat shows the child
session), but swarm members shown in the AGENTS sidebar were not — even
though each member already runs as a `zero exec --init-session-id <sid>`
child that persists a durable session in the same store the TUI reads.
This wires that session through so a member row is clickable.
- swarm: Coordinator records the member's child session id on completion
(CompleteWithSession / Task.SessionID); swarm_collect emits a
task_id → session_id map in its Result.Meta.
- tui: the OnToolResult closure forwards that map via swarmSessionsMsg
into m.swarmSessionMap; swarmAgent carries the session id; the AGENTS
sidebar records a clickable hit per member whose session is known.
- tui: sidebarLineAtMouse maps a left-click in the sidebar column to a
member row (screen-Y == sidebar line index; sidebar starts at
chatColumnWidth + 3), and the existing specialist-card drill-in path
(subchat.enter) is reused verbatim.
Members are clickable only once their session id is known (after
swarm_collect), so in-flight members simply aren't clickable — no dead
clicks. The subchat machinery, Result.Meta transport, and the click
handler are all reused; no new persistence or agent-loop changes.
* test(tui): assert sidebar member click drills into the member session
Address review: the routing test only checked the click was handled, so it
could pass even if the drill-in didn't enter the expected subchat. Seed a real
session in the store and assert subchat.active + childSessionID == the member's
session after the click.
* fix(swarm): make swarm_collect wait for members instead of polling
Swarm spawn was fire-and-forget and the only way to learn a member had
finished was to call swarm_status/swarm_collect, which returned an instant
snapshot. So an orchestrator had to poll in a loop, flooding the chat with
repeated status cards and never getting a clean "done".
swarm_collect now BLOCKS until every member of the team reaches a terminal
state, then returns their results in one call:
- Coordinator gains a change-broadcast channel and WaitSettled(ctx, team),
which blocks until the scope has no pending/running task (or ctx is done).
Every state transition (register/setstatus/finish/reassign) wakes waiters.
- Swarm.CollectWait wraps it; the collect tool calls it bounded by the run
context (user cancel) and a timeout (default 600s, cap 1800s, overridable
via timeout_seconds), so a stuck member returns partial state instead of
hanging forever.
- Tool descriptions now steer the model: spawn then collect once; status is a
one-shot snapshot, not something to poll.
TUI: collapseRepeatedStatusCard drops the previous identical swarm
status/collect card when a check repeats verbatim back-to-back, so any
residual re-checks don't stack duplicate blocks in the chat.
* fix(swarm): address review on blocking collect (race + timeout overflow)
- WaitSettled: take the settled decision and the returned snapshot in the
SAME locked pass. Previously the snapshot was re-read after releasing the
RLock, so a concurrent Register/Reassign could make the call return a
non-terminal task it never waited on — breaking the blocking contract.
Removed the now-dead scopedList; shared sortTasksByCreation with List.
- collectWaitTimeout: clamp timeout_seconds in float space before converting
to time.Duration. A very large value overflowed the int64-ns Duration and
wrapped negative, making context.WithTimeout fire immediately instead of
honoring the cap.
- test: release the gated member via defer in TestCollectBlocksUntilMembersFinish
so a failing assertion can't leak the blocked goroutine; add a
collectWaitTimeout clamp/overflow unit test.
The sidebar cleared the whole swarm roster the moment ANY swarm_collect
result appeared, so calling swarm_collect mid-run (to poll for partial
results) made the AGENTS panel show "no agents spawned" even while every
subagent was still running.
Drop that collect-clears-everything heuristic and instead let the
member-status linger logic remove members only once they actually report
done/failed. swarm_collect results carry the same "[state]" roster as
swarm_status, so swarmMemberStatus now reads both — a mid-flight collect
refreshes the live states instead of wiping them. Each member also
surfaces a non-running state (pending/handoff/...) inline so the panel
reports real status, not just liveness.
* feat(tui): live token counter on the working line
The working line ended with a static "↑ from-to/total" scroll indicator
(which transcript rows the line covered). It looked like a token counter
but never moved meaningfully, so a running turn felt frozen.
Replace it with a live "↑ <n> tok" estimate that climbs as the model
reasons and writes. turnStreamedRunes accumulates every streamed
reasoning+answer rune for the turn and survives the per-segment buffer
clears (a tool call wipes streamingText/Reasoning), so the count is
monotonic across a multi-tool turn and resets to zero on the next turn.
The figure is an estimate (~4 chars/token) because providers only report
exact usage when a step finishes; the authoritative totals stay in the
status line and sidebar.
Removes the now-unused phase_indicator.go (scroll indicator + helpers)
and its test, which were only referenced by the working line.
* fix(tui): keep the working-line token counter visible from turn start
The counter returned empty until the first streamed delta, so during the initial think phase the working line showed no counter at all - a regression from the old always-present row indicator. Show it for the whole turn starting at 0 and climbing.
* docs: drop other-agent names from Zero's description
AGENTS.MD described Zero as "in the same family as Claude Code, Codex,
and Droid". Because AGENTS.MD is injected into the model as project
context, the agent parroted that sentence verbatim when asked who it is,
naming other agents in its own identity.
Reword it to describe Zero on its own terms ("an open-source terminal
coding agent") with no comparison to other products.
* chore: scrub named other-agent references from the codebase
Follow-up to the AGENTS.MD wording fix: sweep the rest of the tree for
comments and docs that named specific other CLIs and replace them with
neutral phrasing ("comparable terminal agents" / generic positioning).
- internal/tui/rendering.go, model.go, session_controls.go (+ test):
drop the named other-agent references from doc comments; the behaviour
and the existing generic "reference agents/TUIs" phrasing are unchanged.
- docs/PRD.md: replace the named competitor in the target/vision with
generic "multi-provider terminal agents".
The ChatGPT Codex provider integration keeps its name — that is a real
product Zero connects to, not a reference to another tool's source.
When the sidebar is open it pins the token readout at its floor, but the
status line also rendered its own token usage segment, so the token figure
appeared twice (status line and sidebar).
The status line already drops the context-fill gauge while the sidebar is
open; do the same for usage. To avoid losing the session cost (which the
sidebar does not show), keep a cost-only segment in the status line when the
sidebar is active, falling back to empty until a priced record lands.
The /model picker rendered a flat list of model names with no provider
context: modelPickerOverlay dropped the Group and provider dot that the
generic picker shows, so models from different providers were
indistinguishable.
Each model row now carries a right-aligned provider slug (anthropic,
deepseek, ollama, ...). The slug is stamped onto every item via
applyProviderPickerMeta from the catalog descriptor, and renderModelPickerRow
right-aligns it with the same gap math the generic picker uses, dropping it
on rows too narrow to keep a clear gap.
Clicking the live 'Thinking…' line expanded the streaming reasoning to its full
length, which filled the terminal as it grew and scrolled the clickable toggle
header off-screen — so it couldn't be collapsed again.
Cap an EXPANDED live reasoning block to ~half the screen height, keeping the
LATEST lines plus a faint '… N earlier lines · Ctrl+O for all' marker, so the
header stays on-screen and clickable. Applied identically to the display and
selectable render paths so the gutter highlighter stays line-aligned; committed
rows (expanded in the scrollable history) are uncapped. Regression test added.
* fix(sandbox): grant ancestor metadata so cd into the workspace works
macOS seatbelt granted file-read* on each read root via (subpath "<root>"),
which covers the root and its descendants but NOT its parent directories. An
absolute cd (and any path the kernel canonicalises) must stat each ancestor to
traverse to a deeply-nested workspace like /Users/me/Downloads/app — those
parents weren't granted, so seatbelt denied the chdir and surfaced it as the
misleading 'cd: <path>: Not a directory' (ENOTDIR). This blocked the agent from
cd-ing into its own workspace (e.g. 'cd /abs/workspace && go test').
Fix: grant file-read-metadata + file-test-existence on the path-ancestors of
each read root (metadata only — ancestor contents stay unreadable). Same pattern
already used for /opt/homebrew, /usr/local, /System/Volumes/Data/private.
Verified end-to-end: 'cd /abs/workspace && ...' now succeeds under the real
generated profile. go build/vet/test green.
* fix(sandbox): skip path-ancestors for filesystem roots (invalid SBPL)
Follow-up to the ancestor-metadata traversal fix. A read root of "/" has no
ancestors, and (path-ancestors "/") is invalid SBPL — sandbox-exec aborts with
exit 65 and the sandboxed command can't launch at all. This was reachable via the
compat builder (ReadRoots=["/"] flipped to restricted under EnforceWorkspace) and
any degenerate "/" workspace.
seatbeltAncestorMetadataRule now skips any root that is its own parent
(filepath.Dir(clean)==clean), neutralizing "/" and volume roots regardless of
entry point. Verified: the compat profile now compiles under sandbox-exec (exit
0). Found by adversarial verification of the prior commit.
* fix(sandbox): grant the CLT toolchain so git/clang/make work
On macOS, /usr/bin/git (and clang, make, etc.) are thin stubs that resolve the
real binary under the active developer dir — usually
/Library/Developer/CommandLineTools. The sandbox's platform read roots didn't
include it, so the stub couldn't reach the real tool and failed with the
misleading 'xcode-select: No developer tools were found' error. Add
/Library/Developer (read-only) to the platform read roots.
* fix(sandbox): allow reading the user's global git config
git reads identity/config from the user's global git config, but the sandbox
confines reads away from HOME, so it failed with 'unable to access ~/.gitconfig:
Operation not permitted'. Grant read-only on the git config FILES specifically
(~/.gitconfig and ~/.config/git/config) — not the ~/.config/git directory, which
can hold an XDG credential store — so git runs while credentials and the rest of
HOME stay unreadable. The path-ancestors traversal grant covers reaching them.
* fix(sandbox): let the agent terminate user-owned processes on request
The seatbelt profile only allowed signaling the agent's own process group
((target self) (target pgrp)), so a process started by a PREVIOUS session — e.g.
a dev server still listening on a port — was in a different group and could not
be killed. The agent would thrash through kill/pkill/osascript/sudo, all denied,
and never stop the process the user asked it to terminate.
Relax to (allow signal). The kernel still enforces UID ownership: a sandboxed
command runs as the user, so it can only signal the user's own processes, never
root's or another user's. Verified end-to-end: a sandboxed command now kills a
different-group user-owned process under the real generated profile.
* fix(sandbox): let the agent find processes + guide it to working tools
Pairs with the signal fix (8b5018d). Two more gaps blocked process management:
- process-info was scoped to (target same-sandbox), so the agent couldn't even
inspect processes outside its own sandbox. Relaxed to (allow process-info*).
- `ps` is setuid root and macOS sandbox-exec refuses to exec setuid binaries
(privilege-escalation guard) — unfixable via profile. `pgrep` needs a blocked
system service. So the agent kept thrashing on ps/pgrep/os.kill.
Add macOS shell guidance pointing the model at the tools that DO work: lsof to
find a process, kill to stop it. With the signal grant, lsof+kill now manages
processes reliably (verified end-to-end).
* fix(sandbox): grant git config at the seatbelt rule, not the shared profile
The git-config read paths (~/.gitconfig, ~/.config/git/config) were added to the
cross-platform permissionProfileReadRoots, so they leaked HOME-dependent absolute
paths into the platform-agnostic 'sandbox policy --json' snapshot and broke the
golden test on every OS (machine-specific paths + Windows separators).
Move the grant to seatbeltReadRule (macOS only, where reads are actually
confined): the PermissionProfile stays platform-agnostic so the golden is stable,
and the seatbelt profile still grants the config files + their ancestor metadata
for traversal. Verified: golden test green, full 'go test ./...' green, and git
still reads ~/.gitconfig under the real generated profile.
* fix(sandbox): grant ancestor metadata for platform read roots too
Addresses CodeRabbit review on #302. seatbeltAncestorMetadataRule was passed only
fs.ReadRoots + gitConfig, on the assumption that platform read roots are top-level
or already covered. /Library/Developer (the CLT toolchain) breaks that: it needs
/Library stat-able as an ancestor, so a chdir-style traversal into it (cd
/Library/Developer/CommandLineTools) failed with ENOTDIR even though reads
succeeded (exec doesn't require ancestor-stat the way chdir does).
Include the platform read roots in ancestorRoots when IncludePlatformRoots is set.
Verified: cd into the CLT toolchain now succeeds under the real profile; golden +
sandbox tests green. Regression test added.
* feat(tui): chat + context sidebar redesign, AGENTS panel, animations, plan-timer fix
Two-column managed-mode layout: the existing chat (scroll engine, reduced width)
on the left + a new right context sidebar — AGENTS (spawned subagents: status
glyph, name, and a live working detail for running ones, with a running/total
count), the live PLAN, and a token readout. The sidebar is suppressed on the
home screen and under overlays/subchat; alt-screen managed mode, wide terminals.
Also: freeze the plan clock while the agent is idle (an in_progress step left
mid-plan when the agent yields stops ticking instead of counting forever), and a
clean-room animation port (phase scroll indicator + a cosine ripple working
line; the spinner was already animated, frozen under ZERO_REDUCED_MOTION).
Reuses the scroll/viewport engine; inline mode unchanged. New unit tests for the
sidebar, plan-clock freeze, and the pure animation helpers; go build/vet/test green.
* feat(swarm): live AGENTS sidebar + un-defer coordination tools
Sidebar AGENTS section now reflects the swarm/team accurately:
- name each member by a short 1-2 word task (not subagent-N)
- a mild, slow cool pulse on live member names (no per-letter flicker)
- members disappear when swarm_status reports them [done]/[failed]
- hide bogus "specialist not found" tool-misroute attempts
- keep the animation tick alive while sidebar agents exist
Plus the deeper fix so a coordinating model stops misrouting swarm calls
to the specialist tool ("specialist swarm_send not found"): the swarm
COORDINATION tools (swarm_send/status/inbox/collect) un-defer once a
swarm is active, while spawn/schedule/handoff stay discoverable via
tool_search. A new DeferralEligible() interface keeps all swarm tools
counting toward DeferThreshold even when exposed, so un-deferring can
never deactivate deferral / force-expose MCP tools in any config.
* feat(tui): polish pass — hierarchy, status line, motion, sidebar toggle
A grounded polish pass across the TUI (17 items):
- color: spread the gray tiers (AA-safe), lime working-ripple (no semantic
green/amber), calm steady ask-label, dim completed plan-step bodies,
bold-muted sidebar headers + stateful PLAN count
- status line: run-state chip (permission mode + effort) replaces the
duplicated provider; composer rule is model-only; context-fill gauge down to
the narrow tier + a sidebar context-% chip
- footer: persistent idle key-hint (managed mode) + a jump-to-bottom cue
- motion: pulsing streaming caret; running specialist spins; swarm/specialist
agents linger with a fading ✓ before removal (no abrupt pop)
- layout: Ctrl+B toggles the sidebar; padded ' │ ' divider; sidebar gated to
the medium tier so it never starves the chat
- home screen: cwd · branch · model orientation + example prompts
- cleanup: drop dead autoTag, fix 'six swarm tools' miscount
go build/vet/test green; -race clean; no hex outside theme.go.
* feat(tui): reading column for the chat transcript
Cap transcript body rows at a readable ~90-col measure and indent them by a
small left gutter, so on a wide terminal the chat text no longer runs
edge-to-edge. Only true transcript rows use the reading column; the frame,
composer, status line, sidebar, title bar, empty state, and prompts keep the
full chatColumnWidth. Streaming text shares the column so it doesn't snap on
finalize. Plus extra vertical spacing before headings and between paragraphs.
Implementation keeps the height cache valid (rows render at contentWidth, which
the width-keyed cache already captures) and the gutter is a horizontal post-pad;
each selectable line's textStart is shifted by the gutter so click-to-select and
the selection highlight stay aligned. Mouse card/toggle hit-testing is Y-based,
so it's unaffected. go build/vet/test green; -race clean.
* fix(tui): plain cancellation marker + copyable card/toggle rows
- 'Run cancelled.' renders as a plain amber '⊘ Run cancelled.' line instead of a
heavy box (other system notices keep their box).
- Copy root cause: the app's text-selection skipped 'button' rows (collapsible
toggles, specialist cards) — they carried no selectable text, so a drag-select
omitted exactly that (dull) content. Fix: those rows now carry their plain text
+ start, so a selection dragged THROUGH them copies their content; a direct
click still toggles/drills (resolved on press, before selection). The selection
highlight no longer excludes them. Permission buttons stay excluded.
- Surface Ctrl+E in the idle hint — it releases the mouse for native terminal
selection, which copies everything (the zero-risk full-copy path).
go build/vet/test green; -race clean.
* fix(tui): render all system notices as plain lines, not boxes
Every system notice (mouse-mode changes, mode set, run cancelled, etc.) now
renders as a plain marked line instead of a bordered box: a faint '·' marker +
muted text for info, amber '⊘' for a run cancellation. Multi-line notices keep
the marker on the first line and indent the rest. Command cards and error rows
keep their boxes. go build/vet/test green.
* fix(tui): separate think→act tool groups with a blank line
Reasoning headers (Thought for X) interleave the tool cards (tool → thought →
tool …), which broke the existing tool-card→tool-card gap rule, so consecutive
list_directory/read_file/update_plan groups packed into a dense wall. Now a
reasoning header that follows a tool card gets a blank line above it, so each
think→act group reads as a distinct block. go build/vet/test green.
* feat(tui): colour markdown emphasis + headings for readability
Assistant prose now reads as three tiers instead of one flat ink colour:
- body text stays ink
- bold / important words render in the brand accent (lime) so they pop
- markdown headings render accent + bold + underline as a clear top tier
go build/vet/test green.
* fix(tui): calmer prose colours + a working-phase label
- Revert the accent colour on bold/important words: emphasis is weight-only now
(no colour), dark-mode-friendly.
- Headings drop the bright lime — distinguished by ink + bold + underline instead
of a saturated accent colour.
- Working status line gains a phase label ('writing' while the answer streams,
'thinking' otherwise) so a long, output-less step reads as live progress
('Working · thinking · 55s') rather than a frozen screen.
go build/vet/test green.
* fix(tui): paint selection highlight in the gutter-shifted coordinate
Mouse text-selection looked like it started in the wrong place: the highlight
landed a few cells right of where you clicked, so selecting/copying felt
impossible to aim.
Cause: the row renderers painted the selection highlight on the UNshifted,
unpadded line, then the body item applied the reading-column gutter (pad lines +
shift selectable textStart) afterward. But the stored selection points are in the
shifted/displayed coordinate (that's how the mouse maps them), so the highlight
was computed against unshifted textStart and landed gutter-cells off. (The copied
text was actually correct — extraction uses the shifted selectable — but the
visual selection was wrong, which is what made it un-aimable.)
Fix: stop highlighting inside the row renderers; add finalizeTranscriptBodyRow,
which pads + shifts THEN paints the highlight, so it's computed in the same
coordinate the mouse uses and lands exactly on the click. Also applies the
highlight uniformly to all rows + the interim block. Regression test added.
* feat(sandbox): online/offline Windows network identity (run approved network commands)
Approved network commands bricked on Windows with "windows sandbox setup is out of
date: network policy changed": the setup marker was locked to network=deny, so any
command granted network (curl, git push, npm install) ran with network=allow and
failed marker validation.
Fix with a two-identity model that fits Zero's restricted-token architecture — no
extra logon users, no DPAPI, no admin at runtime: mint one synthetic
"offline-marker" SID per sandbox home and scope the persistent WFP block filter to
THAT SID only. Network is then gated purely by the restricted token's SID set:
- deny (offline): token = write-capability SIDs + offline-marker SID -> block filter
matches -> no network; writes jailed by the capability SIDs.
- allow (online, approved): token = write-capability SIDs only -> filter never
matches -> network reaches out; writes jailed identically.
The setup marker becomes mode-AGNOSTIC: it fingerprints the provisioned offline
infrastructure (NetworkInfraHash + OfflineFilterSID), not the per-command mode, so
one setup validly serves both an allow and a deny command. Schema bumped 3->4
(forces a clean re-setup; old markers scoped the filter to write SIDs).
- windows_runner.go: Offline SID in WindowsCapabilitySIDs (schema 2, back-compat
upgrade) + WindowsOfflineMarkerSID + windowsRuntimeTokenSIDs.
- windows_network.go: BuildWindowsNetworkInfraPlan + WindowsNetworkInfraHash; drop
the now-dead mode-coupled BuildWindowsNetworkPlan/*Hash helpers.
- windows_setup.go: marker schema 4; validation drops per-command-mode equality.
- windows_setup_windows.go: setup always installs the offline-scoped infra filter.
- windows_command_runner_windows.go: compose the token SID set per network mode.
Cross-platform core fully tested (mode selector, mode-independent infra+hash,
back-compat SID upgrade, one-marker-validates-both-modes regression, schema-3
rejected); sandbox/doctor suites + windows cross-compile green. The WFP
ALE_USER_ID/restricting-SID behavior needs the gated Windows integration smoke test.
* docs(sandbox): note the Schannel HTTPS limitation under the Windows restricted token
Document the known SEC_E_NO_CREDENTIALS limitation at the token-composition site:
an approved online command reaches the network, but HTTPS via Windows Schannel
fails inside the restricted token because Schannel can't acquire its per-user TLS
credential under a WRITE_RESTRICTED/LUA token. Fundamental restricted-token ↔
Schannel incompatibility, unsolved even in the reference sandboxes (codex#17459).
Workarounds: degraded mode or the in-process web_fetch tool. Comment-only.
* docs(sandbox): keep the Schannel-limitation note vendor-neutral
* fix(sandbox): degrade Windows to permission-gating when not set up (stop bricking)
On Windows the sandbox command runner hard-fails EVERY command until a one-time
elevated `zero sandbox setup` writes the setup marker — so a fresh Windows user
can't run a single shell command (the "windows sandbox is not initialized" error).
Linux/macOS already degrade when their sandbox is unavailable; Windows was the
lone outlier that bricks itself.
BuildExecutionRequest now, on Windows when the setup marker is missing:
- default (auto): DEGRADE to EnforcementDegraded — the command runs unwrapped
under the in-process policy gate (workspace path-confinement) + per-command
approval, with a one-time downgrade notice pointing at `zero sandbox setup`
for full WFP/ACL/network isolation.
- `--sandbox require`: still hard-errors with a clear setup hint, so strict
callers keep their guarantee.
- marker present: unchanged (native, wrapped, fully sandboxed).
Matches every reference agent (codex defaults its Windows sandbox off and runs
under approval; opencode/openclaude/vix degrade on Windows) — none brick the CLI
because a setup step hasn't run. Test covers all three paths; sandbox/doctor/cli
suites green; vet/build clean.
* fix(sandbox): don't auto-allow shell commands while Windows is degraded
Follow-up to the degrade fix in this PR. The permission engine's shellSandboxActive
keyed on backend *capabilities* (CommandWrapping && NativeIsolation), which stay
true on Windows even when setup hasn't run. So once the degrade fix let commands
RUN unwrapped, the engine still auto-allowed ordinary shell commands as if
sandboxed — running them with no OS isolation AND no permission prompt, breaking
the per-command-approval floor the degrade is supposed to keep (the function's own
doc comment promises "ordinary shell auto-allow never runs an unsandboxed
command").
shellSandboxActive now returns false on Windows when the sandbox isn't initialized
(mirrors manager.BuildExecutionRequest), so a degraded shell command falls through
to the normal approval prompt. Network/destructive/out-of-workspace checks already
ran earlier and are unaffected; native (post-setup) still auto-allows.
Regression test covers degraded->prompt and native->auto-allow. Sandbox suite green.
* Improve Ctrl+C exit confirmation UX
Make the first Ctrl+C arm a three-second footer confirmation instead of exiting immediately. A second Ctrl+C during that window exits, while active runs still cancel on first press and preserve checkpoint/session flush safety before any deferred quit.
Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/tui -run 'TestCtrlC|TestStatusLineGroups|TestGenericCustomProviderDisplayUsesEndpointName' -count=1
Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/tui -count=1
Tests: git diff --check
* Clear composer before Ctrl+C exit confirmation
Treat Ctrl+C as draft cancellation when the composer has text: clear the composer and suggestions without arming the exit warning. Empty composer behavior still uses the three-second second-press exit confirmation.
Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/tui -run 'TestCtrlC|TestStatusLineGroups|TestGenericCustomProviderDisplayUsesEndpointName' -count=1
Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/tui -count=1
Tests: git diff --check
* Document Ctrl+C deferred quit safety
Restore the reasoning near handleCtrlC for why a confirmed exit waits on flushRunIDs before quitting: cancelled runs may still need to persist checkpoint/session events for rewind safety.
Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/tui -run 'TestCtrlC|TestStatusLineGroups|TestGenericCustomProviderDisplayUsesEndpointName' -count=1
Tests: git diff --check
* Add composer mouse selection
Support normal chat-composer mouse behavior: clicks place the cursor, drags select typed composer text with the existing selection styling, and release copies the selected text before clearing the temporary highlight. Keep this as app-level mouse handling so transcript selection, suggestions, and right-click paste continue to work.
Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/tui -run 'TestComposerMouse|TestTranscriptSelection|TestMouseCapture|TestCtrlC' -count=1
Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/tui -count=1
Tests: git diff --check
* Make transcript tool output copyable
* Tighten TUI exit and composer mouse UX
A scoped (workspace-enforced) exec gave commands a minimal PATH and restricted
reads, so a Homebrew python3/node either resolved to the broken /usr/bin/python3
stub or could not start. Four coordinated allowances on macOS:
- PATH: prepend /opt/homebrew/{bin,sbin} + /usr/local/{bin,sbin} (ensureMacToolPaths).
- read: broaden platform read roots from .../lib to the whole /opt/homebrew and
/usr/local trees so the interpreter and its Cellar/opt dylibs are readable.
- file-map-executable: add /opt/homebrew and /usr/local so those dylibs can load.
- file-read-metadata on path-ancestors of /opt/homebrew and /usr/local: realpath()
/ getcwd() lstat every ancestor (e.g. /opt), which the subpath read rule does not
cover — without it python3 fails at startup with 'realpath: /opt/homebrew/bin/:
Operation not permitted' while node (exec only) works. Root cause confirmed by
reproducing under the real profile via sandbox-exec.
Writes stay confined to the workspace; exec was already allowed (allow process*).
Binding a listening socket (e.g. python3 -m http.server) remains blocked by the
network policy (deny network*) — that is a separate gate. Local build for manual
verification.
DetectInteractiveCommand treated node/python/ruby/... as interactive REPLs unless
an argument was a script file or one of a small allow-list of eval/print flags. So
'node --version' (and -v, --check, --help; 'python3 --version', etc.) — all
print-and-exit, non-interactive — were wrongly blocked before execution with
"Blocked interactive command 'node': node with no script".
Add universal print-and-exit flags (--version/--help) that suppress the guard for
every repl program, plus node's unambiguous short forms (-v, --check, -h). Kept
conservative: --version/--help only universally (e.g. 'mysql -v' is *verbose*, not
version, so it still opens a prompt — covered by a new test). Bare 'node'/'python'
still blocked.
Tests: the exact 'node --version && npm --version' is now allowed; mysql -v stays
blocked.
Make the chat read like a clean status surface (Claude Code / codex /
opencode) instead of a branding billboard. The audit's through-line was
"noise that restates what's already on screen, plus brand/meme flavor with
no off-switch"; the fix is almost entirely deletion:
- Replace the 40-slot meme/brand working-verb ring (gitlawbmaxxing,
openfablemaxxing, maxxing, pilled, aura-farming, vibe-checking,
tomfoolering, booping, ...) with one calm static "Working". Deletes
working_words.go; the spinner + advancing clock are the only motion.
- Quiet the user turn: drop the full-width painted prompt band and its two
padding bands; keep just the ▌ accent gutter with a single plain blank
line as the delimiter.
- Stop stamping "completed in Ns · K tools" under every turn. Only a turn
over 60s earns a faint "worked for ..." bookend; short turns get none
(the next user prompt is the separator). Drops the tool-count segment.
- Remove the per-card [auto] badge (the permission mode is already shown in
the composer divider).
- Drop the duplicate plan progress bar; the header done/total + per-step
icons already convey progress. Deletes renderPlanProgressBar.
- Don't double-mark failed turns: the bordered error note already signals
failure, so drop the extra red done-line.
Tests updated to the clean behavior. Full internal/tui suite green;
gofmt/vet/build/deadcode clean.
Deferred to a follow-up: the empty-state figlet/tagline, the all-caps
reverse-video badges, model/provider/context consolidation, terser
permission labels.
* feat(tui): syntax-highlighted code, word-level diffs, and a reduced-motion gate
The remaining three items from the reference-TUI comparison, focused on
"real content rendering" + motion accessibility.
1. Syntax-highlight fenced code blocks. renderMarkdownCodeBlock passed code
through plain; now the fence language is captured and the block is tokenized
with chroma (new dep, pure Go) and colored with Zero's OWN audited palette —
keyword=accent, string=green, number=amber, comment=faint, function=blue,
punctuation=muted — so it stays on-brand and degrades through the same
lipgloss profile path (truecolor→256→16→plain). Wrapping is token-level so a
color never splits across a wrap. Unknown/missing language falls back to the
old plain rendering, so it is never worse than today.
2. Word-level diff highlighting. diffCardBody tinted whole add/del lines; now an
isolated 1:1 replacement (one "-" followed by one "+") highlights only the
changed span on each side via a brighter changed-span bg (new addBgWord/
delBgWord tokens), after trimming the common prefix/suffix. Gated to <=60%
change so a near-rewrite is not confettied, and block changes keep whole-line
tint. Changed-span colors validated by WCAG ratio (fg >=4.6:1, clear
separation from the base band).
3. One reduced-motion gate. ZERO_REDUCED_MOTION (and no-TTY) now swaps the
animated spinner for a steady dot via m.spinnerGlyph() at every render site
(working line, plan, tool/specialist cards) and forces the fade off — one
switch for all motion, where ZERO_NO_FADE only governed the fade. Liveness is
preserved by the advancing elapsed timer, so a static frame never reads as
frozen.
Tests cover the reduced-motion resolver + static glyph, changedSpan/word-diff
pairing + the near-rewrite fallback, and the highlighter's unknown-language
fallback + wrapping. Full internal/tui suite green; gofmt/vet/build clean.
* fix(tui): address render-polish review — highlight committed rows only
Adversarial review found a real interactive-jank regression and three smaller
issues; all fixed:
- PERF (blocker): the live streaming block re-tokenised the whole accumulated
code via chroma on every frame. renderAssistantMarkdownText now takes
allowHighlight; the interim/streaming path (model.interimBlock) and reasoning
bodies pass false (plain, cheap), and only committed/cached rows
(renderAssistantRow, selectable rows) highlight — so chroma runs once per row,
never per frame.
- PERF: cache language->lexer lookups (including nil) and short-circuit an empty
language before chroma's slow registry Match scan.
- Fences with metadata: take the first whitespace token as the language
("```go title=x" -> "go") instead of the whole info string.
- Reduced motion: freeze the compact and doctor braille rings too (their frame
counters no longer advance under ZERO_REDUCED_MOTION), honoring the one-switch
contract.
Tests: streaming code renders verbatim/plain; diff word-span colors AA on both
themes. Full internal/tui suite green; gofmt/vet/build clean.
* test(tui): opt the doctor/compact animation tests into motion
The reduced-motion gate auto-enables under no-TTY (colorprofile.NoTTY ->
reducedMotion), which is exactly the CI environment. That froze the compact and
doctor status rings, so two existing tests asserting "animates on tick" failed on
the Linux/macOS runners (Windows CI detects a color profile, so it passed there —
the platform split). Both tests exercise the animation mechanism, which is only
active when motion is on, so set m.reducedMotion = false in their setup to test it
deterministically regardless of the runner's detected profile.
Three legibility-focused polish changes from the reference-TUI comparison:
1. Lead tool/specialist cards with the status glyph. The ✓/✗/spinner moved from
the far right edge to the FRONT of the head row, and the tool name is now
colored by state (green done / red failed; running keeps the normal name with
the accent spinner). State reads in the first cell the eye lands on instead of
riding a low-contrast left-rail tint. (rendering.go toolCard/toolCardHead;
specialist cards already led with the glyph.)
2. Brighten the selected-row band in pickers and autocomplete. selBg was so close
to the panel that the highlighted row barely separated — worst on the light
theme, where it was effectively invisible (1.01:1 vs the panel). Dark
#1d2114->#32401b (separation 1.18->1.73) and light #e7f2cd->#cfe78f
(1.01->1.15); the label stays >9:1 (dark) / >12:1 (light). One token, so all
three selection surfaces (autocomplete, picker, model picker) update together.
3. Surface the specialist drill-in affordance. A specialist card opens its subchat
on click or Enter but showed no hint; add a faint "· enter to open".
Tests: lock the glyph-leads-head layout and add a contrast guard asserting the
selected band both separates from the panel (>=1.10) and keeps the label
AA-readable (>=4.5) on both themes — which would have caught the invisible light
band. Full internal/tui suite green; selection colors validated by WCAG ratio.
* feat(acp): JSON-RPC 2.0 ndjson transport for the ACP surface
Bidirectional JSON-RPC 2.0 peer over stdio (newline-delimited JSON): serves
inbound requests/notifications via registered handlers and issues outbound
requests/notifications. Handlers run on their own goroutines so a long-running
request (session/prompt) never blocks delivery of session/cancel or a permission
response. Stdlib-only, no new dependencies.
Derived solely from the public JSON-RPC 2.0 + ACP specs.
* feat(acp): ACP wire types derived from the public spec
Protocol message types for initialize, session/{new,load,prompt,cancel,update,
set_mode,set_config_option}, request_permission, content blocks, tool-call
updates, plan entries, permission options, and session modes — method names and
field shapes taken from the public ACP spec (agentclientprotocol.com schema/v1).
Model selection is exposed both via the spec's session/set_config_option and a
vendor _zero/set_model alias for clients that prefer it.
* feat(acp): translate ZERO agent events to ACP session/update payloads
Pure mappers (unit-tested) from ZERO's agent callbacks to ACP updates: text->
agent_message_chunk, reasoning->agent_thought_chunk, tool call->tool_call(in_progress),
tool result->tool_call_update(completed/failed) with content + changed-file
locations, and plan items->plan entries. A notifier wires the mappers to a live
connection per session.
* feat(acp): map ZERO permission prompts to ACP session/request_permission
Translate ZERO's permission decisions into ACP PermissionOptions (optionId carries
the ZERO action verbatim for a lossless round trip) and map the client's outcome
back to a ZERO PermissionDecision: cancelled->cancel, selected->the chosen action,
anything unrecognized fails closed to deny. Builds the embedded toolCall too.
* feat(acp): ACP Agent server driving ZERO's core
Implements the ACP methods over the JSON-RPC peer: initialize (capability +
version negotiation), session/new, session/load (with history replay for resume),
session/prompt (drives the real agent.Run with streaming callbacks -> session/update,
and an OnPermissionRequest that round-trips through session/request_permission),
session/cancel, session/set_mode (permission/autonomy mode), and model selection
via session/set_config_option plus the vendor _zero/set_model alias. Dependencies
are injected so the editor only hosts the thread while ZERO keeps auth/model/keys
(BYOK). Includes an end-to-end test (initialize+new+prompt over stdio pipes through
agent.Run with a fake provider) asserting the streamed updates.
* feat(acp): wire the 'zero acp' subcommand
Add 'zero acp', which serves ACP over stdio so editors (Zed, JetBrains, Neovim,
...) can drive ZERO as a backend. Reuses ZERO's real deps — config resolution,
provider construction, the core tool registry, and the session store — so
provider/model/keys stay owned by ZERO (BYOK). Registered in the command
dispatch and the help listing.
* fix(acp): drain in-flight handlers on stream close
On stdin EOF the read loop returned before the dispatch goroutines flushed their
responses, so piped/finite ndjson input (and editors that close the stream right
after a request) dropped the reply. Serve now cancels in-flight handlers (so any
blocked outbound Call unblocks via ctx) and waits for them before returning.
* fix(acp): address review — sandbox confinement, mode/cwd/transport hardening
Blockers:
- Confine shell/file tools: runTurn now builds a SCOPED registry + sandbox engine
per session (BuildWorkspace mirrors exec's buildExecSandboxEngine + NewScope) and
passes Options.Sandbox — ACP no longer runs bash/exec_command unconfined.
- Reject PermissionModeUnsafe over ACP (set_mode + advertised modes are auto/ask
only); Unsafe stays gated to the operator, so a client can't self-grant no-prompt
host access.
- Fix data race on acpSession.history: registerSession sets history before
publishing the session and reuses an already-live session instead of overwriting.
- deliver() now deletes the pending entry and sends non-blocking, so a duplicate/
late response frame can't wedge the read loop.
Should-fix:
- Serialize prompt turns per session (turnMu) so concurrent prompts can't interleave
history or clobber the cancel slot.
- Validate client cwd (ResolveWorkspaceRoot mirrors exec; rejects filesystem root +
home) before it becomes the file-tool confinement root.
- Malformed JSON no longer tears down the connection: framing is per-line; a bad
line replies -32700 (id null) and the loop continues. Also validate jsonrpc:"2.0".
- Permission outcome binds to the offered set only (dropped the isKnownDecision
fallback) — a client can't return a broader grant than was presented; fail closed.
- Drop the non-conformant SessionCapabilities advertisement (no resume handler) and
the non-conformant SessionConfigOption model selector; model switching stays
available via the vendor _zero/set_model.
Adds regression tests: sandbox+scoped-registry wiring, invalid-cwd rejection,
Unsafe-mode rejection, and malformed-line resilience. Full go test ./... -race green.
From the 2026-06-21 behavioral audit. Each verified by tests + the real binary.
- exec: accept the advertised `-p`/`-p=` short flag in the exec subcommand parser;
previously only `--prompt` worked even though top-level help advertises `-p`,
so the documented `zero exec -p "..."` form errored (internal/cli/exec_parse.go).
- version: `version --help` prints a usage line instead of the version string
(internal/cli/app.go).
- specialist: `specialist edit <builtin>` explains builtins are read-only instead
of the misleading "specialist not found" (internal/cli/specialist.go).
- cron: `pause`/`resume`/`rm` now print a success confirmation (rm's verb arg was
previously dead) (internal/cli/cron.go).
- update_plan: coerce non-canonical statuses (e.g. "done", "in-progress") to the
nearest canonical value instead of failing the whole call, and keep at most one
in_progress item (internal/tools/update_plan.go + tests).
- build: add a Makefile (build/test/lint/...) backing AGENTS.MD's `make`/`make lint`.
Investigated but intentionally NOT changed (guarded by existing tests):
- write_stdin AdvertiseInAuto — TestCoreToolsExposeShellTools asserts it stays
visible in auto mode for polling/interrupts.
- --list-tools not resolving config (so tool_search is absent there) —
TestRunExecListsMCPToolsWithoutProviderResolution asserts list-tools must not
resolve provider config.
Out of scope: LICENSE (owner decision), provider-resolve startup crash (PR #283),
OAuth default storage + MCP auto-reconnect (design choices).
A long generation could die with "provider stream error: idle timeout after
1m30s (upstream stopped sending data)". The 90s idle watchdog was duplicated as
defaultStreamIdleTimeout in three providers (anthropic, gemini, openai; codex
inherits openai) and wired to no config, so a slow cloud/reasoning backend that
goes silent without SSE keep-alives for >90s was killed mid-stream.
Centralize it in providerio:
- providerio.DefaultStreamIdleTimeout — single source of truth, raised 90s -> 5m
so a genuine silent pause has real headroom. The watchdog still resets on SSE
keep-alive comments, so a heartbeating-but-slow upstream is never aborted; this
only changes how long true silence is tolerated before aborting a hung stream.
- providerio.ResolveStreamIdleTimeout(option): precedence is explicit option >
ZERO_STREAM_IDLE_TIMEOUT env (a Go duration or bare seconds; 0/off/none
disables the watchdog) > default. All providers resolve through it, so one env
var tunes every provider at once.
Removes the three duplicated consts. Existing idle tests (which set an explicit
short StreamIdleTimeout) keep passing because an explicit option still wins. Adds
resolver tests (precedence, env parsing, disable, typo-falls-back-to-default) and
documents ZERO_STREAM_IDLE_TIMEOUT in the README.
Make `npm install -g @gitlawb/zero` deliver a working CLI. bin/zero.js already
execs an adjacent binary, but nothing fetched it and the name `zero` is taken
on npm. This wires up delivery:
- package.json: rename to @gitlawb/zero; add a postinstall hook, files, engines,
os/cpu, repository, and a license placeholder (the LICENSE file is still
pending and tracked separately).
- scripts/postinstall.mjs: detect platform/arch (mapping mirrors
internal/release/release.go — darwin->macos, amd64->x64), download
zero-v{version}-{os}-{arch}.{tar.gz|zip} from the matching GitHub Release,
verify its SHA-256, and extract into place. Extraction goes to a temp dir and
copies only known binary basenames, so a crafted archive cannot escape the
package (no zip-slip). Idempotent via a version marker; ZERO_SKIP_DOWNLOAD and
unsupported platforms are non-fatal skips.
Security hardening (from an adversarial review):
- require https for the download origin (the same-origin .sha256 makes TLS the
load-bearing integrity control); ZERO_ALLOW_INSECURE_DOWNLOAD=1 opts out for
local testing, and an https->http redirect is refused.
- the checksum parser is anchored per line and validates the filename field.
- cap the download size; warn when an optional sandbox helper is absent.
release-artifacts.yml: on a tag push, assert the git tag equals
v{package.json version} before packaging — zero-release names assets from
package.json while the release publishes under the tag, so any drift would 404
every install (install.sh, install.ps1, and npm alike).
Tests (internal/npmwrapper): package.json now requires exactly a postinstall
hook (no repo build scripts) plus name/license/repository/files; new
node-driven tests cover the per-platform asset plan, the skip env, and the
unsupported-platform path. Verified end-to-end against a local mirror:
download -> verify -> extract places the binary, a tampered checksum is
rejected, and http is refused without the opt-in.
Going live still needs two maintainer steps: create the `gitlawb` npm org +
NPM_TOKEN, and publish the first v0.1.0 release so there is an asset to fetch.
normalizeProviders resolved every configured provider and aborted the entire
config load if any one failed (e.g. a profile whose catalogID preset this build
doesn't ship). That bricked the whole app even when the ACTIVE provider was valid:
a clean clone + a real user config with one unknown-catalog provider exited with
'unknown provider "x"' and never started.
Now a single unresolvable NON-active provider is dropped (the rest, including the
active one, load normally); only the ACTIVE provider failing remains fatal. Tests
cover both paths.
Zero already had a complete Windows sandbox (restricted token + workspace ACLs + WFP network filters), but it was unreachable in dev and plain go build: the backend was only selected when the standalone helper exes were found adjacent to the binary or on PATH (release layout only), so every bash command hard-failed with 'Windows sandbox command runner is not available'. Add self-dispatch: the running zero binary acts as its own helper via two hidden subcommands, with three-tier discovery (adjacent exe, PATH, then self-dispatch via os.Executable) mirroring the Linux fallback. Also surface the elevation requirement for 'zero sandbox setup' (WFP/ACL need Administrator) instead of a raw ACCESS_DENIED. Tests cover the three discovery tiers, dispatch routing, and the unavailable edge.
install.ps1 now copies the bundled Windows sandbox helper binaries (zero-windows-command-runner.exe, zero-windows-sandbox-setup.exe) alongside zero.exe, so the tier-1 adjacent-exe discovery finds them after a release install. Previously the installer copied only zero.exe and silently dropped the helpers the release archive already ships, leaving installed users with 'Windows sandbox helper is not available'. Adds Find-ZeroExtractedFile to locate each required binary in the extracted archive and fail loud if one is missing.
Resolve product/UX audit findings for the OSS launch: doctor now fails uncredentialed remote providers (no false green), Redact() no longer mangles the common first-run auth error, SSRF-safe loopback exemption unblocks keyless local models, new validated --theme flag, WCAG-AA contrast fixes, NO_COLOR compliance, clearer first-contact errors, and full OSS scaffolding (SECURITY/CoC/CHANGELOG/templates/release workflow). Audit ledger maps every Fixed claim to a real change with zero overclaims.
* docs(audit): 2026-06-20 deep adversarial audit report
Audit-only deliverable (no source changed). Fresh origin/main (bfbdbb1) deep
read across 13 subsystems with per-finding adversarial re-verification, plus
reconciliation of the 2026-06-10/2026-06-13 audits.
Result: build/vet clean; race detector finds 0 data races (67/68 pkgs; the
3 internal/tools failures are -race-only timing flakes). 35 surviving findings
(1 high, 15 medium, 17 low, 2 info); 64 prior findings fixed, 14 partial,
12 still-open.
* fix(providerhealth): strip credentials on cross-host redirect (AUDIT-M10)
The connectivity probe's CheckRedirect re-validated the redirect host against
the SSRF blocklist but never stripped auth headers. Go's stdlib only auto-strips
Authorization/Cookie/WWW-Authenticate on a host change — not x-api-key,
x-goog-api-key, or custom provider headers — so a baseURL that 3xx-redirects to
an attacker-controlled public host received the provider API key verbatim.
Thread the exact set of auth-bearing header names (kind default + profile
AuthHeader + CustomHeaders keys + well-known) into the client and Header.Del
them whenever the redirect target's host:port differs from the original.
Regression test asserts the creds are dropped cross-host and kept same-host.
* fix(sessions): fsync events.jsonl append for crash durability (AUDIT-M12)
appendEventLocked wrote the event into the page cache and returned success, then
durably wrote (fsync'd) the derived metadata. A crash after the metadata fsync
but before the events.jsonl page flushed left metadata.EventCount ahead of the
log — silently dropping the last appended event, including the checkpoint a
/rewind targets. Add file.Sync() before Close so the log is at least as durable
as the metadata derived from it.
(Note: the fsync itself has no portable unit-test seam; covered by the existing
sessions append/durability suite for no-regression.)
* fix(cron): DST fall-back double-fire + claim double-grant (AUDIT-M4, AUDIT-M5)
M4: fireJob/claimFire computed the next slot from the raw sub-second now(), so
schedule.Next's DST fall-back collapse guard (which only engages for a
minute-aligned 'after') never fired — a daily job in the repeated wall-clock hour
ran twice on the fall-back day. Feed sched.Next a minute-truncated instant at the
claim and the post-exec advance (the due-check still uses the real instant so
sub-minute NextRunAt jobs are unaffected).
M5: claimFire left NextRunAt unchanged when the schedule couldn't advance
(unparseable/exhausted spec), so two concurrent schedulers both saw the job due
and both fired it. Pause the job inside the same locked claim instead: the winner
still fires once and the post-exec keeps it paused; the loser sees a non-active
job and is refused.
Tests: claimFire double-claim on an unadvanceable job (second refused, job paused)
and a DST fall-back daily job advancing past the repeat (not re-firing it).
* fix(daemon): bound the control handshake + track bridge conns for Shutdown (AUDIT-M7, M8, I1)
The remote bridge clears the connection deadline before handing the conn to
Server.ServeConn, and the local socket set none, so handleConn's two ReadControl
handshake calls could block forever — an authenticated-but-idle remote peer
pinned a bridge connection slot (32 of them exhaust the bridge), and the local
path had the same unbounded read. Set a handshake deadline in handleConn and
clear it before the (long-lived) streaming dispatch (M7, I1).
ServeConn also bypassed trackConn, so Shutdown's conns-close sweep couldn't close
a remote conn stalled in that handshake read. Track/untrack it like the local
accept loop does (M8).
Tests: handleConn returns under a shortened handshake deadline when the peer
never sends hello; Shutdown closes a ServeConn'd conn well under the 10s deadline.
* fix(swarm): atomic rename-with-verify mailbox stale-lock break (AUDIT-M13)
The stale-break compared two consecutive reads of the SAME lock file
(string(data) == string(stale)), so the equality was always true and gave no
protection: two waiters could both observe a stale lock and both os.Remove it
while a third recreated it via O_EXCL, leaving two live writers to the same
per-agent inbox (split-brain). Replace with the proven rename-with-verify used by
the cron/hooks/oauth locks: rename the stale file aside (only one racer wins the
rename), re-check the moved file's mtime is still stale before deleting, and
rename it back if a holder rotated a fresh lock in the gap.
Tests: a genuinely stale lock is reclaimed (no .stale.* leftover); a fresh held
lock is never broken. (The double-break race itself is not deterministically
unit-testable; the fix mirrors the cron/hooks/oauth pattern.)
* fix(streamjson): anchor api_key redaction so prose isn't mangled (AUDIT-M1)
The api_key secret pattern used ["'=:\s]+ as the delimiter class, so a bare
space after the marker counted — 'the api_key: foo setting' and 'apiKey value
spans' had the following word replaced with [REDACTED] in every emitted
stream-json event (text deltas, tool output, final answer). Require a real
assignment delimiter (= or :) and a credential-length body, mirroring the
already-anchored sk-/bearer patterns. Prose survives; real api_key=... secrets
still redact.
* fix(tools): snapshot exec-session lastUsedAt under its lock to fix prune race (AUDIT-L15)
sessionToPruneLocked sorted on execSession.lastUsedAt while holding only
manager.mu, but touch() writes lastUsedAt under session.mu — a data race on a
time.Time that could torn-read and mis-select the prune victim. Snapshot each
session's lastUsedAt via a session.mu-guarded getter before sorting (lock order
stays manager.mu -> session.mu; no path takes them the other way). Adds a -race
regression test driving touch() concurrently with the prune selection.
* fix(swarm): release per-job context on MaxRuns completion (AUDIT-L13)
Scheduler.run returned on MaxRuns completion without calling job.cancel(), so the
job's derived context (and the goroutine context.WithCancel spawns to propagate
parent cancellation) leaked until the whole scheduler was cancelled at session
end. Add defer job.cancel() (idempotent with Cancel/Close) so it is released on
every exit path.
* fix(agent): route post-compaction and max-turns connects through streamWithReconnect (AUDIT-L1)
The main turn connect already retries transient disconnects via
streamWithReconnect, but the post-compaction retry connect, the reactive
mid-stream reconnect, and finalAnswerAfterMaxTurns called provider.StreamCompletion
directly — a single transient hiccup on those (e.g. the final summary after a long
autonomous/cron run, or the turn right after a context-limit compaction) failed the
whole run with no retry. All three are pre-content connects, so routing them through
the helper carries no OnText-duplication risk.
* fix(cli): reject trailing args after --skip-permissions-unsafe (AUDIT-L3)
zero --skip-permissions-unsafe "fix bug" silently discarded the prompt and
launched the interactive TUI, so a scripted one-shot unsafe invocation appeared
to hang. Reject any trailing positional arg loudly (after checking for a
misplaced --add-dir first, which keeps its specific error), pointing users at
the working form: zero exec --skip-permissions-unsafe -p "...".
* fix(provideroauth): error on present-but-wrong-shape chatgpt account-id claim (AUDIT-L11)
extractChatGPTAccountID returned ("", nil) both when the id_token had no
account-id claim AND when OpenAI's auth namespace was present but the account id
was missing/empty/non-string. The latter is a claim-shape change: the caller only
warns on a non-nil error, so it silently omitted the chatgpt-account-id header and
every Codex call 401'd with no diagnostic, indistinguishable from an expired
token. Return an error for the present-but-unusable case so the login warning
fires; a genuinely absent claim still soft-skips ("", nil).
* test(tools): widen exec-session poll windows so the suite is -race-clean (AUDIT report §2)
Three exec-session timing tests assumed a 250ms-sleep / immediate child would
finish within a fixed 1000ms yield. Under -race the child (the race-instrumented
test binary) starts slowly enough to overrun it, so the tool correctly returned a
still-running session_id instead of an exit_code and the asserts failed — the
non-green -race suite the audit flagged in §2. collect() returns as soon as the
session completes, so widening the poll window to 30s makes them deterministic
under -race without slowing the passing case. Test-only; no behavior change.
* docs(audit): mark remediation status (fixed/deferred/skipped) for the 2026-06-20 audit
* docs(audit): strip trailing whitespace from the report (fix git diff --check)
* fix(tools): drain PTY output before marking an exec session done (AUDIT-M14)
A TTY exec session's stdout was copied into the buffer by a fire-and-forget
io.Copy goroutine, but startSession's Wait goroutine called markDone (and then
removed the session) as soon as command.Wait() returned — before that goroutine
had necessarily drained the master FD. The command's final output chunk (e.g. a
test runner's last PASS/FAIL line) could be lost on exit, and since the exited
session is removed it could not be polled back. This also surfaced as the flaky
TestExecCommandTTYSessionAcceptsInputOnLinux on loaded CI.
Make the PTY cleanup (called after command.Wait, before markDone) join the copy
goroutine before closing the master: the child has exited so the master EOFs once
io.Copy finishes draining; waiting for that (bounded by bashWaitDelay) guarantees
all output is buffered before the session is marked done. Closing the master
first would truncate the unread tail, so the join precedes the Close.
Linux-only path (exec_pty_linux.go); verified via GOOS=linux build+vet (the TTY
test only runs on linux/CI).
* docs(audit): mark M14 fixed (PTY drain) in remediation status
---------
Co-authored-by: gnanam1990 <[email protected]>
The de-flake infra (resilientTempDir + deterministic <-session.done wait) is
already on main, but the test still called session.terminate() directly before
write_stdin "\x03" — so the session was already reaped when the code under test
(exec_command.go's Ctrl-C branch) ran, and the assertion no longer guarded it:
the test would pass even if that branch were deleted (caught in review).
Capture the session handle before the interrupt (write_stdin removes a finished
session from the manager), then make write_stdin "\x03" the operation that
terminates, then wait on session.done. Keeps the de-flake; restores the
coverage. Verified by mutation: removing session.terminate() from the Ctrl-C
branch makes this test fail. Runs 15x locally without flaking.
* feat(commands): user-defined slash commands from markdown files
Drop a markdown file at `.zero/commands/<name>.md` (project, shared with the
team) or `<userConfigDir>/zero/commands/<name>.md` (personal); typing
`/<name> args` expands its body template and submits it as a normal prompt.
Optional YAML frontmatter (`description:`, `model:`, `agent:`) feeds help text
and routing. This turns Zero's model-pulled skills into user-invokable,
repo-checked-in team workflows (`/release v1.2`, `/fix-flaky <test>`).
New internal/usercommands package: Load() scans the project + user dirs
(project shadows user on name collision, mirroring specialist scope
precedence), validates names to a safe slash-command shape, and parses
frontmatter with a self-contained parser. Expand() substitutes $ARGUMENTS,
$1..$9, and $$ (literal $); a placeholder-free template gets the raw args
appended so a trivial command still receives input.
Wiring: model loads commands at construction; the commandUnknown dispatch
checks the user set before reporting "unknown"; autocomplete lists user
commands alongside builtins. Builtins win on collision (listed first, shadow at
dispatch). Tests cover load/precedence/name-validation, the placeholder
substitution table, and the TUI dispatch + autocomplete + still-unknown paths.
* feat(init): guided /init that generates AGENTS.md seeded with repo facts
`/init` (TUI) and `zero init` (headless) investigate the repo and write a
concise AGENTS.md so future runs start with project context. Zero already has
the AGENTS.md loader and the repoinfo scanner, but the file only existed if a
user hand-authored it — this activates the machinery in one command.
New internal/agentinit.BuildPrompt seeds the bootstrap prompt with
repoinfo.Collect() output (primary language, build/test tools, CI, workspace
layout, git) formatted as a fact block, then instructs the agent to investigate
the gaps and write a tight AGENTS.md (build/test commands, conventions, things
to avoid). Collection failure is non-fatal — the agent investigates from
scratch. This is one better than a pure-prompt /init: the agent starts from
known facts instead of re-deriving them.
TUI: /init command + handleInitCommand reuse launchPrompt (standard run, tools,
AGENTS.md write path). Headless: `zero init` builds the prompt and forwards it
through the existing exec machinery, so all exec flags (--model, -w, --add-dir)
work unchanged. Tests cover FormatFacts (rendered/omitted fields), BuildPrompt,
and the TUI dispatch.
* feat(tools): lsp_navigate — semantic code navigation via the language server
A read-only, model-callable tool exposing LSP navigation: jump-to-definition,
find-all-references, find-implementations, and workspace_symbol search. The
agent can answer "where is X defined / who calls X / what implements this
interface" precisely, where grep collides on common names or misses indirect
references — cutting wasted read/grep turns and wrong-edit risk on large repos.
Zero already runs language servers for diagnostics, so this adds the
request/response navigation methods on top: Manager.Navigate syncs the file
(didOpen) then issues textDocument/definition|references|implementation or
workspace/symbol via the existing client.Call, decoding all three result shapes
(Location, []Location, []LocationLink). 1-based file:line:col in and out
(converting LSP's 0-based positions); the tool has its own lazily-started
manager (servers start on first use, reused across calls).
Opportunistic: a file type with no installed server, or a server lacking the
capability, degrades to a clear "unavailable — fall back to grep" message, not
an error. Registered in CoreReadOnlyToolsScoped. Tests cover the response-shape
decoding (incl. LocationLink + null), symbol decoding/kinds, unknown-op and
unsupported-extension degradation, and the tool's arg validation + read-only
safety.
* feat(agent): prune stale tool output before paid summarization
Long, dump-heavy sessions accumulate large read_file/grep/glob/bash tool
results the model has already acted on; their bodies are dead weight the loop
otherwise keeps paying an LLM summarizer to compress. Add a zero-cost prune
stage inside maybeCompact, before the summarizer: when over threshold, replace
the bodies of OLD, large tool results with a compact placeholder, recompute
size, and only fall through to the paid Compact() if still over.
pruneStaleToolOutput walks newest-first, protects the last preserveLast
messages plus a trailing 40k-token window of tool output (most likely still in
use), and prunes only tool results older than that and larger than 200 tokens —
gated so the whole pass is a no-op unless reclaimable output exceeds 20k tokens.
It keeps the tool message and its ToolCallID (provider replay stays valid; the
model knows what was there and can re-run the tool), never touches non-tool
messages, never re-prunes a placeholder (idempotent), and copies-on-write so it
never mutates the caller's slice. Recent turns are preserved verbatim — no
paraphrase loss, no token or latency cost.
Tests cover the reclaim gate, recent-window protection, idempotence,
non-tool-message safety, ToolCallID/role preservation, and no-input-mutation.
* feat(agent): reconnect on a transient model disconnect instead of ending the run
A long autonomous task (a big refactor, a swarm member, a headless/cron run)
should survive a single transient upstream hiccup rather than dying and
re-burning every token on a restart. When the initial StreamCompletion connect
fails with a disconnect-shaped error, streamWithReconnect re-issues the SAME
request with exponential backoff up to 2 times.
It retries only the connect — before any content is forwarded — so no
already-streamed OnText is ever duplicated. A context-limit error (the
compactor's job), an image rejection, a non-disconnect error, or a cancelled
context is returned immediately without retry, so the reconnect never fights
the existing handlers. Disconnect classification matches EOF / connection
reset|refused|closed / broken pipe / timeout / 502 / 503 / server-closed.
Reconnect attempts surface through OnReasoning ("connection lost —
reconnecting N/max"), a non-content channel that never corrupts the answer
text; silent when there's no reasoning sink.
Tests: recover-from-transient, give-up-after-max, no-retry-on-context-limit,
stop-on-context-cancel, the disconnect-vs-not classifier, backoff growth, and
the notice routing.
* fix(specialist): add lsp_navigate to knownToolNames
The new lsp_navigate tool was added to the core read-only registry but not to
specialist.knownToolNames, which mirrors the core tool set — so
TestKnownToolNamesMatchCoreRegistry failed (the list drifted from the registry).
Add it to the read-only group to match.
* fix(tools): confine lsp_navigate to the workspace (close arbitrary-file-read)
Review caught that lsp_navigate dropped the PathScope every sibling read-only
tool (read_file/glob/grep) carries, and its readFile joined or used the
model-supplied path verbatim — so `path:"/etc/passwd"` (absolute) or
`path:"../../etc/..."` (escaping) read arbitrary files off disk AND opened them
into the language-server subprocess, reachable via indirect prompt injection.
Add NewScopedLSPNavigateTool(workspaceRoot, scope) (NewLSPNavigateTool now
delegates with nil scope), resolve the path through resolveScopedReadPath BEFORE
reading or building the LSP request — the same confinement the neighbouring
tools enforce — and hand the LSP manager the resolved absolute path so it never
opens an out-of-workspace file. Errors echo only the relative path, never an
absolute one. Registered via the scoped constructor in CoreReadOnlyToolsScoped.
Regression test asserts `/etc/passwd` and `../../` are rejected for both the
position and workspace_symbol ops; verified by mutation (bypassing the scope
resolution makes the test fail).
* fix(oauth): add missing api.connectors scopes for ChatGPT OAuth preset
* fix(oauth): add missing authorize params for ChatGPT OAuth (id_token_add_organizations, codex_cli_simplified_flow, originator)
* fix(oauth): use localhost + /auth/callback for ChatGPT redirect URI (matches Codex CLI registration)
* fix(oauth): use fixed port 1455 + localhost/auth/callback for ChatGPT (matches opencode + Codex CLI client registration)
* chore: gofmt chatgpt.go
* refactor(oauth): use RedirectURIWithHost for ChatGPT callback; drop dead Port()
RedirectURIWithHost and Port() were added but never called — chatgpt.go
hardcoded "http://localhost:1455/auth/callback" as a literal, duplicating both
the port and path. Use RedirectURIWithHost("localhost", "/auth/callback") so the
redirect_uri derives its port from the listener (single source of truth via the
new chatgptCallbackPort constant), and remove the unused Port() method.
Also make the fixed-port bind failure actionable: ChatGPT's client registration
requires exactly port 1455, so we can't fall back to a random port — but if 1455
is already bound (a prior Zero/Codex login still open) the error now says so
instead of surfacing a raw OS bind error.
Tests: RedirectURIWithHost host/path formatting, and that the listener captures
the code on the /auth/callback path (not just /callback) — both were previously
uncovered.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* fix(oauth): restore api.connectors scopes for ChatGPT preset (review fix)
The fixed-port commit accidentally reverted internal/oauth/presets.go back to
only openid/profile/email/offline_access, dropping the api.connectors.read and
api.connectors.invoke scopes this PR exists to add — so authorize URLs still
omitted them and the authorize_hydra_invalid_request failure was not actually
fixed (caught in review by anandh8x).
Restore both connector scopes in the chatgpt preset and pin them with tests:
- TestResolveConfigChatGPTPreset now asserts both connector scopes are present
(not just allowed).
- New TestChatGPTAuthorizeURLIncludesConnectorScopes inspects the generated
authorize URL and asserts the scope param carries api.connectors.read and
api.connectors.invoke, so a future revert is caught end-to-end.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* fix(oauth): read chatgpt_account_id from the nested OpenAI auth claim (P0)
extractChatGPTAccountID read chatgpt_account_id as a TOP-LEVEL id_token claim,
but auth.openai.com namespaces it under the "https://api.openai.com/auth" claim
object (the Codex CLI reads only from there). Against a real token the top-level
lookup returned "", so token.Account stayed empty → the codex provider omitted
the required chatgpt-account-id header on every request → Cloudflare/backend 401.
The login itself succeeded (bearer minted), so the failure was silent: the user
authenticated fine, then every Codex API call 401'd. The unit tests passed only
because the fixture put the claim at the top level, masking the bug.
Read the nested path first (id_token[ns][chatgpt_account_id]), matching codex;
keep a bare top-level claim as a forward-compat fallback only. Tests now build
the fixture with the nested shape a real token uses, plus regressions: a
top-level decoy must not shadow the nested value, and the top-level fallback
still works when the namespace is absent.
Found by an adversarial audit against the codex reference (same OAuth client).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
* audit tail: fix remaining medium/low correctness findings
A sweep of the remaining audit findings across CLI, config, OAuth, tools,
LSP, and the TUI. Each fix has a regression test; build/vet/gofmt clean and
the touched packages' suites pass.
CLI
- `zero -p "<prompt>"` now forwards the prompt as the inline `--prompt=<value>`
form so a prompt whose first character is a dash (e.g. `zero -p "-foo"`) is
taken verbatim instead of being rejected as a missing flag value (matches the
cron path).
Config
- mergeProfile no longer drops parseThinkTags across profile merges; an explicit
value carries over and a nil (unset) never clobbers an existing one.
OAuth
- Refresh carries the existing token_type forward when the refresh response omits
it (previously silently lost).
- RefreshScheduler.Stop resets started/done so the scheduler can be started again
instead of being permanently inert after the first Stop.
Tools
- apply_patch header parsing takes the whole post-prefix value (dropping a trailing
tab timestamp and undoing git's C-style quoting) instead of splitting on spaces,
so a diff target whose name contains spaces is recognized.
LSP
- Server.Shutdown honors its context (kills immediately on cancel rather than
waiting out the full grace window), and Manager.Shutdown shuts servers down
concurrently so total shutdown is bounded by one grace window, not N×grace.
TUI
- /theme auto re-probes the terminal background (RequestBackgroundColor) instead
of reusing a stale startup reading, so switching to auto re-detects light/dark.
- Specialist card: wire the tool-call count from progress events (was always 0)
and omit the token segment when the total is 0 rather than show "0 tokens".
- Resume: extract the tool-call row id the same way as the result row and the
specialist pre-pass (toolCallId first), so call/result dedup and the failed-Task
visibility rule key on the same string.
- Plan panel: height takes an injected clock (testable, consistent with render),
and duplicate-content steps inherit distinct prior timestamps positionally.
* tui: test that /theme auto re-probes the terminal background (M17)
The M17 fix lives in handleSubmit's command dispatch (`return m,
tea.RequestBackgroundColor` when the mode is auto), which no test exercised —
only the handleThemeCommand helper was covered. Add a dispatch-level test:
/theme auto must return the background re-probe command, /theme dark must not.
Verified to fail if the dispatch is reverted to `return m, nil`.
* harden background daemon: stdout integrity, lifecycle, single-instance lock
Deep review of internal/daemon (worker pool, launcher, control server,
session fan-out, single-instance lock) found and fixes a stdout-integrity
bug plus several lifecycle/locking hazards. Each fix has a regression test;
the daemon suite passes under -race.
Worker stdout integrity
- Replace the capped bufio.Scanner (64 KiB) on a worker's stdout with an
uncapped bufio.Reader: a legitimately large stream-json line from our own
trusted worker is no longer silently dropped (the 1 MiB cap belongs to
untrusted network frames, not this local pipe).
- Surface a stdout read error as a run failure instead of swallowing it as a
clean success, and kill the worker first so it can't block writing to the
now-unread pipe and hang Wait.
Control-server lifecycle
- Track open connections and close them on Shutdown so a single idle/hostile
client can no longer wedge drain (and SIGTERM cleanup) forever.
- Guard the listener with a mutex and re-check the shutdown signal after bind
so a shutdown requested in the bind window is not lost.
- Honor shutdown during buffered-history replay too.
Session fan-out
- A slow subscriber that drops live lines now receives a namespaced gap
notice when its channel drains, instead of silently losing output.
- Hold the session lock across the (non-blocking) broadcast sends to close a
latent send-on-closed-channel panic when a subscriber cancels mid-send.
Single-instance lock
- Reclaim a stale lock with an atomic rename-with-verify instead of a blind
remove, so two instances starting at once cannot both reclaim and end up
both holding the lock; a holder that reacquires in the gap is restored.
Client + pool
- Bound the control handshake with an I/O deadline so a peer that never
replies can't wedge Dial/NewClientConn.
- Key the active-worker map by a monotonic id rather than PID (the OS reuses
PIDs the instant a worker exits).
- Group-terminate on context cancel so a stuck worker's children are not
orphaned.
* daemon: add -race regression test for the Session.Line lock-hold invariant
Session.Line holds s.mu across its non-blocking subscriber sends so cancel()/
finish() cannot close a subscriber channel mid-send (send-on-closed-channel
panic). That invariant had no concurrency test. Add one that drives Line
concurrently with Subscribe/cancel and finish; it panics with a data race if
the lock-hold is reverted and passes with it (verified both ways under -race).
* providers: surface non-normal terminal turns instead of silent empty completions
Four stream-adapter cases where a non-normal turn was treated as a clean
empty completion, hiding the real outcome. Each ships a regression test.
- openai/codex: a tool call arriving with output_index 0 and no item_id
had its argument deltas dropped (the call dispatched with empty JSON).
output_index is now a *int so a real 0 is distinguishable from absent.
- openai/codex: response.failed / response.completed with a nil payload
returned silently with no terminal event; now failed emits an error and
completed emits done, so a failure is never masked as an empty success.
- gemini: terminal finishReasons (RECITATION, MALFORMED_FUNCTION_CALL,
IMAGE_SAFETY, OTHER, LANGUAGE, UNEXPECTED_TOOL_CALL) were treated as a
normal stop; safety reasons now map to content_filter and the rest
surface the raw reason so the truncation notice fires.
- anthropic: stop_reason "refusal" was treated as a normal completion;
it now maps to content_filter.
* address review: restore Gemini integration/tool-call tests; justify pause_turn
Review follow-ups on the provider silent-failure fixes:
- Gemini: restore the end-to-end finish-reason and dropped-tool-call tests that
the earlier refactor removed, alongside the mapFinishReason unit test. This
re-covers the full SSE -> done-event wiring (MAX_TOKENS -> length, SAFETY ->
content_filter, normal STOP -> "") and the nameless-functionCall drop signal,
and adds a RECITATION integration test that exercises the M3 widening through
the streaming path (not just mapFinishReason in isolation).
- Anthropic: document why pause_turn is classified as a normal stop — it is the
long-running-turn pause (server-side tools) the client resumes by sending the
response back, not a truncation/refusal, so it correctly maps to "" rather than
firing a spurious truncation notice.
gofmt/vet/build clean; gemini/anthropic/openai suites pass.
* codex: surface response.failed even when the payload omits the error object
A response.failed terminal event carrying a non-nil Response whose `error`
object is null/omitted (the backend can report the failure only via `status`)
fell through to a clean StreamEventDone — reporting a real failure as a normal
successful turn. Only the nil-payload path was guarded. Branch on the failed
terminal (event type / Response.Status) before the clean-done fallthrough and
emit StreamEventError. Adds a regression test for the non-nil-payload-without-
error case (verified to fail without the fix).
* fix(agent): abort on image-rejection 400s instead of retrying in a loop
* feat(imageinput): add ReadClipboardImage for OS clipboard image extraction
* feat(tui): clipboard image paste — detect and attach screenshots from OS clipboard
* fix(imageinput): use temp file for Windows clipboard image (PowerShell stdout can't emit raw binary)
* test: handle both image-present and image-absent clipboard states
* fix(vision): add kimi-k2 and qwen-vl to vision-capable name heuristic
* fix(tui): render image errors as red error rows instead of grey system notes
* feat(vision): check models.dev InputModalities for vision support instead of only name heuristics
The vision gate now checks three sources:
1. Curated model registry (catalog authority + name heuristic)
2. Discovered model list from models.dev (InputModalities contains 'image')
3. Name heuristic fallback for unknown models
This means custom/ollama/cloud models (kimi-k2, qwen-vl, etc.) are correctly
detected as vision-capable when models.dev reports them with image input
modality — no more manual name matching needed.
* chore: gofmt clipboard_test.go
* fix(imageinput): remove shell injection in the Linux clipboard reader
The Linux clipboard reader built shell commands by concatenating a
clipboard-derived MIME type into `sh -c`:
exec.Command("sh", "-c", "wl-paste --type "+t+" 2>/dev/null")
exec.Command("sh", "-c", "xclip ... -t "+t+" -o 2>/dev/null")
`t` comes from `wl-paste --list-types` / `xclip TARGETS` and is only filtered by
`HasPrefix(t, "image/")`, so a hostile clipboard offerer could register a target
like `image/png; rm -rf ~` — it passes the prefix check and then executes
through the shell. A real (if low-likelihood) local code-exec vector.
Drop the shell entirely: every helper now runs via exec.Command(prog, args...)
with the MIME type passed as a discrete argument (matching the existing no-shell
pattern in pdf.go). Stderr is discarded by not assigning it. Factored into
runClipboardStdout + imageMIMETypes helpers.
Test: imageMIMETypes filters to image/* lines and returns even an
injection-shaped type verbatim (safe now that it never reaches a shell).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* test(tools): de-flake TestWriteStdinInterruptTerminatesSession
The test had two Windows-CI flakes that reddened unrelated PRs:
1. It relied on write_stdin's 1000ms yield window being long enough for the
async SIGKILL + reap to land before asserting the session was no longer
running. On a loaded runner the reap exceeded that window, so write_stdin
reported "still running" and the assertion failed. Fix: after terminating,
wait on session.done deterministically (30s safety timeout that fails loudly
if the kill genuinely hangs); the common case returns the instant the
process exits.
2. The SIGKILL'd child had the test's t.TempDir() as its cwd, and Windows may
not release the directory handle the instant the process is reaped — so
t.TempDir()'s immediate RemoveAll on cleanup failed with "being used by
another process". Fix: resilientTempDir retries RemoveAll for up to 5s
(best-effort; a leaked temp dir never fails the test).
Verified 20x locally; full internal/tools suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
* feat(tui): unify tool cards onto the left-rule card style
Tool result/running cards were the last transcript surface still drawn as
a full rounded box (╭─ head ─╮ / │ body │ / ╰─ footer ─╯), while specialist
cards (#255) and the reference TUIs (opencode, codex) use a lighter
left-rule card. Mixing two card languages in one transcript reads as
un-designed, so converge tool cards onto the same left-rule shape.
toolCard() now draws a status-tinted "│ " rail down the left edge with the
head on the first line and the status glyph (✓/✗/spinner) right-aligned to
the card edge — no top, right, or bottom borders. The glyph moves out of
toolCardHead onto the rule line.
Width math is preserved: every emitted line is still exactly `width` cells
(the two cells where the right border sat become panel background), and the
inner content budget stays width-4, so the diff/read/bash/grep body
renderers — which assume width-4 — are untouched. The tiny tier keeps its
existing borderless behavior (no leading "│"), so
TestTinyToolCardDropsSideBorders still holds. Click-to-expand/collapse is
unaffected: the toggle attaches to the head line by relative offset, which
is still the first line.
Tests: update TestRunningToolCardShowsHeadAndSpinnerSlot to assert the
left rule (no box corners); add TestToolResultCardRendersAsLeftRule for the
result-card shape + glyph-on-rule; the all-lines-equal-width invariant
(TestToolCardLinesAllSameWidth) is unchanged and still passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* feat(tui): cut tool-call transcript density (paths, auto-approve rows, redundant bodies)
Follow-up to the left-rule card frame in this PR — same surface, the content
inside/around the cards. A run of MCP file ops rendered ~5–7 lines each, with
the same absolute path repeated 3× and the tool name twice. The reference
agents (opencode, codex) collapse a successful call to ~1 line, use
workspace-relative paths, and only surface approval when the user is actually
prompted. Five changes bring tool cards in line:
1. Auto-approved permission rows: the OnPermission handler always recorded the
audit event but only renders a visible row when the event is noteworthy
(a real prompt, a deny/cancel, an explicit durable decision, or a sandbox
block) — not for silent auto-approves. A new permissionEventIsNoteworthy
predicate gates both the live path (model.go) and the resume rebuild
(session.go) so resumed sessions match the live view. The session log is
unchanged — only the rendered row is suppressed.
2. displayPath relativizes a tool card's target to the workspace
(D:\...\examples\calc\calc.go -> examples/calc/calc.go), falls back to
~/... under home, then to …/last-3-segments for external paths. Display
only; the hyperlink still points at the original absolute path, and the
path sent to the tool / stored in the session is untouched.
3. A successful call whose output is a one-line confirmation ("Successfully
created directory …", "Created x (N bytes).") drops its body — the head
already shows the action, target, and ✓. Narrow verb allowlist; errors and
multi-line/real output always keep their body.
4. displayPath's external-path tail (…/segments) — folded into (2).
5. One blank line between consecutive tool cards in a turn. Separators were
only inserted at turn boundaries, so back-to-back cards stacked with no gap
(the dense "wall"). Tool-card↔tool-card only; specialist cards keep their
own grouping.
Net: each MCP file op goes from ~5–7 lines to ~1, paths relative, no
auto-approve noise. Tests cover the predicate (suppress auto / keep real
decisions), displayPath (cwd / home / external tail / relative passthrough),
body collapse (confirmation vs real output vs error), and the inter-card
blank. Two existing permission tests updated for the new intent; full
internal/tui suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* feat(tui): pin the plan panel above the composer
The plan panel was injected inline into the scrolling transcript body, so a
streaming turn pushed it up and out of view exactly when it was most useful.
Pin it in the footer, directly above the composer, so it stays visible while
the transcript scrolls underneath.
- footerView renders the pinned plan above the composer; the scrollable frame
already reserves footer height, so no extra layout plumbing is needed.
- A height budget (pinnedPlanMaxHeight: at most a third of the screen) keeps a
long plan from crowding out the transcript or the input — when the full panel
would exceed the budget it collapses to a one-line summary
(renderPlanSummaryLine: spinner/✓, done/total, current step). Ctrl+P still
expands, but the budget always wins so the composer stays on screen.
- The two inline-injection sites in transcript_selection.go are removed (the
panel no longer lives in the body), along with the now-unused planPanelEmitted
flag.
Tests: full-when-it-fits, collapse-to-summary-when-too-tall, hidden-when-empty,
and footer-renders-plan-above-composer. Existing plan-panel tests unchanged and
green; full internal/tui suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Zero has a rich set of chord bindings — Ctrl+T (cycle effort), Ctrl+P (plan
panel), Shift+Tab (permission mode), Ctrl+O/E/F, click-to-drill specialist
cards — but they were invisible: only discoverable by reading the source. The
reference terminal agents all ship a single-key help overlay for exactly this.
Add `?` (pressed on an empty composer) to open a grouped, framed
keyboard-shortcut overlay; `?`/Esc/q/Enter close it, and while it's open every
other key is swallowed so nothing leaks into the hidden input. `?` typed into a
non-empty prompt still inserts a literal "?" — the overlay only opens when the
composer is empty and nothing modal is up.
The binding list (keybinding_help.go) is declarative and curated to match the
real cases in model.go's Update switch. The empty/first-run screen now advertises
"Press ? for keyboard shortcuts · / for commands" so the feature is actually
discoverable.
Tests cover: open-on-empty, literal-? after text, close on each key, key
swallowing while open, the rendered groups/keys, and data well-formedness.
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
/help, /sessions, /tools, /permissions, /context, /config, /debug, /plan and
the other command-info screens rendered as a wall of dim grey: they were
appended as plain system notes, so renderSystemNote painted every line in the
faintest theme color, and the broken isCommandCardHeading heuristic
mis-styled rows by incidental punctuation (a "|" in the usage flipped a row
between accent-bold and grey at random).
Route them through the styled command-card renderer instead, and give that
renderer structure-aware styling:
- renderCommandOutput (the single choke point for every commandOutput screen)
and helpText now carry the command-card prefix, so they render as a titled
card rather than a grey note. The structured text is unchanged — the prefix
only selects the renderer — so the format tests and the session store stay
byte-for-byte stable.
- renderCommandCardRow now classifies by structure, not punctuation: a
non-indented line is a group header (accent bold); an indented "/cmd … -
desc" row is two-toned (bright command name, muted description); fields and
bullets render in readable ink instead of faint grey.
- The noise "status: ok"/"status: info" line is dropped; only a real
warning/blocked status stays (in its tint). Removed the dead
isCommandCardHeading heuristic.
Tests cover the routing (prefix present), the dropped neutral status, a kept
warning status, and two-tone command rows vs plain field rows.
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
* Add persistent exec command sessions
Introduce exec_command and write_stdin tools backed by a shared session manager so long-running shell commands can remain alive and be polled or interrupted across tool calls.
Wire exec_command through the existing shell permission, network prompt, command-prefix approval, sandbox grant scope, specialist manifest, and prompt/documentation paths.
Keep web_fetch limited to public remote HTTP(S) URLs so localhost/private URLs go through shell commands where sandbox network permission applies.
Tested: git diff --cached --check
* Fix exec session lifecycle review issues
* tui: live code-streaming preview, theme system, plan/Task dedup
- Live "writing <file>" preview: a long write_file/edit_file/apply_patch
now streams the code as the model generates it (path + line count + a
color-coded tail) instead of a frozen spinner. Tool-call argument deltas
are forwarded zeroruntime -> agent -> TUI (OnToolCallStart/Delta).
- Theme system: auto dark/light detection via a terminal-background probe,
a /theme [auto|dark|light] command, a refreshed palette, and streaming
fade auto-disabled on low-color / SSH / tmux terminals.
- Plan/Task transcript dedup: update_plan collapses to a one-line summary
(the live plan panel owns the detail), and Task delegations render only
as the specialist card, not also as raw tool-call/result rows -- in both
the live and resume paths.
Robustness for the streaming preview: tolerate whitespace in tool-call
JSON, extract edit_file's new_string, clear the preview on tool finalize /
cancel / run-end (no stale or duplicate previews; a stale run can't wipe
the active one), color new content green (never red for a leading '-'),
and truncate by display width.
* address M10: keep failed Task delegations on resume
The unconditional Task tool-row skip on resume also hid a Task that failed
before a specialist started (there is no card to replace it). A pre-pass now
collects the tool-call ids that actually started a specialist; the Task
tool-call/result rows are skipped only for those, so a failed delegation and
its error stay visible on resume.
* streaming preview: decode tool-call args incrementally (O(n) not O(n^2))
The live "writing" preview re-scanned the whole accumulated args buffer on
every delta and grew it via string concat, so a large write_file/edit was
quadratic and janked exactly during the long write it targets (M14/L6/L7).
Replace the per-render decode with a streamingDecoder that consumes each
fragment once: it buffers the head until the content value begins (extracting
the path), then decodes the content's JSON-string escapes as they arrive,
keeping only a bounded tail of lines plus a running line count. The view now
reads that pre-computed state in O(tail) per render. Handles whitespace,
escapes split across fragments, \uXXXX, and the closing quote; unit-tested
including byte-by-byte vs whole-buffer equivalence.
Surfaced by a full-tree audit; each ships a regression test where the
behavior is deterministically testable.
- swarm: deliver a handoff's mailbox note BEFORE registering the new task,
so a mailbox failure can't leave a phantom pending task in the coordinator.
- specialist: put a foreground sub-agent child in its own process group and
group-kill on cancel/timeout (+ WaitDelay backstop), so a build/server it
forked dies with it instead of orphaning.
- oauth: serialize on-demand token refresh per key (keyed mutex) and re-load
inside the lock, so parallel callers don't each spend the single-use
refresh token (all but one would otherwise fail at the token endpoint).
- mcp: treat a "source already gone" rename during legacy-token migration as
success, so a concurrent migrator doesn't wrongly drop an OAuth MCP server.
- cron: claim a due job atomically (advance NextRunAt under the per-job lock)
before running it, so two schedulers can't double-fire the same job.
Two robustness/security bugs from a full-tree audit, each with a test.
- providermodeldiscovery: when a live /models probe succeeds (200) but its
model ids don't match the curated catalog, the merge was empty and the whole
live+curated list was discarded — the picker collapsed to the bare built-in
set with a misleading "no model ids" error. Now fall back to the curated
catalog instead of returning an empty list.
- redaction: Authorization / Proxy-Authorization headers carrying an opaque
token or a custom (non-standard) scheme were not redacted in free text (logs,
command/diff output, errors, session search), echoing live credentials back
into model context and rendered output. The scheme is now optional, so any
credential value is redacted to end-of-line; a recognized scheme name is
still kept for readability.
Surfaced by a full-tree audit; all are latent (the suite passed) and live
in race windows / error edges the tests don't exercise. Each ships a
regression test.
- swarm: liveAgents() now counts queued specs, so AdoptOrphans can no longer
re-dispatch (double-execute) a task whose member is merely waiting for a
free slot over the concurrency cap.
- cron/hooks/oauth: stale-lock reclaim is now atomic (rename aside, then
verify-and-restore if it turns out fresh) instead of a blind Remove that
let two racers both reclaim and hold the lock; also falls through to the
bounded wait instead of hot-spinning when a reclaim never wins.
- lsp: a dead language-server session is evicted and restarted instead of
being returned forever, so one server crash no longer permanently breaks
diagnostics (and spuriously fails self-correct every iteration).
- config: an API-key env var no longer overwrites a same-named
compatible-transport provider's kind when no base URL is supplied.
* audit fixes: cron lock, accounting race, O(n^2) hot paths, /style wiring
Fixes five verified findings from a code audit. Two related findings were already
addressed upstream and three need larger/separate work (noted below).
H-1 cron store: cross-process lock for the read-modify-write.
Two schedulers seeing a job due raced the metadata read-modify-write; fireJob
even documented it ("full atomicity needs file locking"). Adds a per-job O_EXCL
lock (mirroring internal/oauth's pattern, stale-reclaim included) and a Mutate
primitive doing the re-read+modify+write atomically. Update/Remove lock too. The
lock is held only for the millisecond RMW, never across a job's exec.
M-5 specialist accounting: close the check-then-append race.
specialistEventExists (unlocked ReadEvents) + a later AppendEvent let two
finishers (TaskOutput poll vs onExit, even cross-process) both pass the check and
write duplicate stop/usage events. Adds sessions.Store.AppendEventUnlessExists
(check + append atomic under the session lock) and routes the stop and usage
paths through it.
M-2 hooks audit: O(1) sequence via tail read.
append re-read and scanned the whole audit log every call to find the max
sequence (O(n^2) over a session). Reads only the file tail now; multi-process
safe (the log is a shared global file), unlike an in-memory counter which would
collide sequences across processes.
M-4 transcript rehydration: O(n^2) -> O(n).
The three resume/rewind/compact rehydration loops called appendTranscriptRow per
event, each re-scanning every existing row for the keyed-row dedup. A bulk
appendTranscriptRowsDedup builds the key set once; semantics are identical.
M-6 /style: actually shape responses.
The TUI /style command stored a style but never threaded it anywhere. Adds
agent.Options.ResponseStyle, a system-prompt directive for concise/explanatory/
review, and wires m.responseStyle through the run. "balanced" is a no-op (prompt
byte-identical to before).
Already addressed upstream (no change):
- M-1 forked-session double-count: Store.Fork already skips EventUsage events,
with a comment naming the exact double-count it prevents.
- M-3 lineage O(N^2): Tree already does a single store.List() scan and indexes
children in memory; no path calls List per node.
Deferred (need larger/separate changes):
- M-7 escalate_model in the TUI needs a ModelSwitcher that builds a provider
mid-run; registering the tool alone yields a tool that errors on call.
- L-1 MCP SSE reconnect is a stream-lifecycle change (low-likelihood after the
8 MiB per-event cap).
- L-2 dead-code removal belongs in a dedicated cleanup sweep.
Tests: per-fix unit tests including concurrency (cron Mutate, sessions
AppendEventUnlessExists) under -race; full go test ./... green.
* fix(windows): treat ERROR_ACCESS_DENIED as lock contention (cron + oauth)
The Windows smoke run failed TestMutateIsAtomicUnderConcurrency with
"...lock: Access is denied" (FireCount 19/20). On Windows a concurrent holder's
os.Remove leaves the lock file in a delete-pending state, so an O_EXCL create
races it with ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist — the
retry loop only treated ErrExist as contention and hard-failed.
Treat os.ErrPermission as contention (retry) in the new cron lock and in the
oauth lock it mirrors (oauth's own concurrent test exposed the same pre-existing
bug under the added load). Unix is unaffected — it returns ErrExist.
* fix: address review — cross-process atomic audit append + AppendRun guard
Addresses the review feedback on this PR.
P1 (blocking): AuditStore.append is now cross-process atomic.
The O(1) tail read observes another process's prior appends, but the read-then-
append was not atomic across processes — two could both read sequence N and write
N+1. Wraps lastSequence()+append in a per-file O_EXCL lock (hooks/lock.go, same
stale-reclaim + Windows ErrPermission handling as cron/oauth); store.mu stays as
the in-process fast path. Adds TestAuditStoreSequenceConcurrent (4 stores x 12
goroutines on one file -> unique dense sequences), which the prior sequential
test could not catch. Walked back the comment that oversold the tail read.
P2: AppendRun no longer resurrects a removed job.
fireJob's AppendRun ran after Mutate released the per-job lock; a Remove in that
window let AppendRun's MkdirAll recreate the dir with an orphaned runs.jsonl.
AppendRun now takes the per-job lock and bails if metadata.json is gone. Adds
TestAppendRunDoesNotResurrectRemovedJob.
P2: lastSequence ReadAt tolerates a short final read.
ReadAt may return io.EOF with n < len(buf) if the file shrank between Stat and
the read; treat EOF as non-fatal and use buf[:n] (matches lastEventSequence).
P3: oauth stale-reclaim no-op removed.
The double os.ReadFile "stale == data" guard compared the file to itself read
twice in a row (always true); staleness is decided by the mtime check, so the
guard is dropped (behavior-preserving).
Tests under -race; cron/hooks/oauth green. A shared filelock helper would DRY the
three near-identical locks — left as a follow-up to avoid churning the
already-green cron/oauth packages here.
* feat(tui): sticky plan panel + specialist task cards + task table overlay
Three new TUI surfaces that give users live visibility into agent progress
and specialist delegation, so long turns and subagent work never look frozen.
Sticky plan panel (plan_panel.go):
- Pinned between the title bar and transcript body, shows the agent's
step-by-step plan from the update_plan tool as a live checklist.
- Header with spinner + progress count + elapsed; text progress bar;
per-step list with status icons (✓ completed, ⠹ in_progress, ○ pending,
✗ failed) and individual step timings.
- Auto-collapses to a one-line green summary 30s after completion.
Ctrl+P toggles expand/collapse.
- Plan state synced from update_plan tool results via CurrentPlan();
timestamps preserved across the tool's full-replacement updates by
matching on content.
Specialist cards (specialist_card.go):
- New rowSpecialist transcript row kind; EventSpecialistStart/Stop
now handled in session.go hydration (previously fell through to
default and were silently dropped on resume).
- Animated card with spinner + name + task + elapsed + token count +
tool call count. Blue while running, green when done, red on error.
- specialistTracker tracks live state; Task tool calls/returns are
intercepted in the OnToolCall/OnToolResult callbacks to start/complete
tracking and emit card rows.
- Height cache marks running specialist rows unstable so the spinner
animates.
Task table overlay (task_table.go):
- Ctrl+G toggles a full-width overlay showing all specialists in the
session as a sortable table: #, Specialist, Task, Status, Time,
Tokens, Tools.
- Summary bar with running/completed/error counts, total tokens,
aggregate elapsed.
- Arrow keys navigate rows; Esc closes the overlay.
Tests: plan_panel_test.go (7 tests), specialist_card_test.go (11 tests),
task_table_test.go (8 tests). All existing TUI tests pass.
* fix(update_plan): expose structured nested item schema so models stop sending bad arguments
The plan property's Items schema was omitted because of an outdated comment
claiming the serializer drops nested Properties/Required. The serializer
(propertyToRuntimeMap) already handles them recursively. Without the nested
schema, weaker models guessed the item structure from a text description and
sent malformed arguments, hitting the 4-strike guardrail halt.
Now exposes Items as an object with content (string, required), status (enum),
and notes (string).
* fix(tui): route plan + specialist updates through message sink, not stale closures
The OnToolCall/OnToolResult callbacks captured model by value, so
m.plan.updateFromItems() and m.specialists.start/complete() mutated stale
copies that never reached the live model. Introduced planUpdateMsg,
specialistStartMsg, and specialistCompleteMsg tea.Msg types sent through
runtimeMessageSink, handled in updateModel() where they mutate the live model.
* feat(tui): subchat drill-in for specialist sessions
Enter on a task table row loads the child session's events and swaps the
transcript view to show the subagent's full conversation (user messages,
agent text, tool calls, results). A nav bar at the top shows the specialist
name/task and a back hint. ArrowUp or Esc pops back to the main chat at the
saved scroll position.
- subchat.go: subchatState with enter/exit, renderSubchatNavBar
- transcript_selection.go: transcriptBodyItemsFromRows renders arbitrary
rows (reuses the same rendering pipeline as the main transcript)
- model.go: Enter on task table row triggers drill-in; ArrowUp/Esc exits;
transcriptView() short-circuits to child rows when subchat is active
* fix(ci): strip CRLF line endings and BOM from plan_tool_test.go and update_plan.go
gofmt and git diff --check failed on ubuntu CI because PowerShell's
Set-Content wrote UTF-8 with BOM and CRLF. Stripped both to plain LF
without BOM.
* feat(tui): add renderLeftRuleCard helper for borderless specialist cards
* feat(tui): switch specialist cards to left-rule rendering, drop hint line
* feat(tui): add renderSpecialistSummary one-line rollup for specialist cards
* fix(tui): correct slice offset in renderSpecialistSummary and add pluralization test
The muted tail was sliced at summary[len(spinnerView)] but the spinner
sits at byte offset 2 (after the 2-space indent), so the slice cut into
the spinner's UTF-8 continuation bytes and dropped the indent. Slice at
2+len(spinnerView) instead. Also add a test asserting '2 errors' is
present and '2 error' (singular) is absent when two specialists errored.
* refactor(tui): remove task table overlay — cards carry everything now
* feat(tui): render specialist summary line above cards in transcript
* feat(tui): mark specialist card lines as clickable selectable rows
* feat(tui): click specialist card to drill into subchat, Enter as keyboard fallback
* tui: clear selectedSpecialistID after Enter drill-in
Fixes Task 7 review issue: selectedSpecialistID persisted after
Enter drill-in, causing a subsequent Enter to re-drill into the
specialist's subchat instead of submitting the composer. Clear it
immediately after subchat.enter succeeds so the keyboard fallback
is one-shot per click; click re-sets it, so click->Enter still works.
* chore: gofmt model.go + test files
* chore: gitignore .superpowers/ and docs/superpowers/ — local skill artifacts
* fix(tui): remove Enter keyboard fallback for subchat drill-in — mouse click only
* fix(tui): reconcile specialist childSessionID from tool call ID to real session ID on completion
* feat(specialist): add Progress callback field to RunOptions and TaskRunOptions
* feat(specialist): stream child stdout line-by-line via Progress callback
* feat(agent): wire OnToolProgress callback for specialist child events
* feat(tui): show live tool-call progress in specialist cards
* fix(swarm): update RunChild mock signature for new progress callback param
* fix(tui): render plan panel inline in transcript, not pinned at top
* chore: gofmt model.go, specialist_card.go, specialist_click_test.go
* fix(tui): address PR review - plan panel without specialists, stream reader safety, resume dedup + payload keys
When the model calls tool_search for a tool it already has eagerly (e.g.
select:Task or select:update_plan), resolveExact/noMatchMessage only ever
consulted the DEFERRED set, so it fell through to a misleading
"No tools matched. Available: <deferred tools>". That implies the tool does not
exist, and weaker models (e.g. cloud deepseek/minimax) loop retrying tool_search
instead of just calling the tool.
tool_search now recognizes eager tools (registered, not deferred, passing the
operator filters) and, for a select: or keyword query that names one:
- with no deferred match: redirect — "<tool> is already in your tool list — call
it directly" instead of "no tools matched";
- mixed with a deferred match: load the deferred tool AND append the note;
- a genuinely-unknown name still gets the honest "no tools matched".
Surgical: only the error/redirect path changed; loading deferred tools is
unchanged. NewToolSearchTool already holds the full registry — it just wasn't
using it to recognize eager tools.
Tests: eager redirect via select: and keyword, mixed load+note, and
unknown-stays-honest; all existing tool_search tests pass.
Two fixes for the raised issues — the idle tool-schema prefix and native web search.
Idle prefix — make the swarm tools deferred-eligible:
- The 7 swarm_* tools are an advanced, rarely-first-move feature the base system
prompt does not name, so they now implement Deferred() and load on demand via
tool_search instead of shipping full schemas in the eager per-request prefix.
Core tools and specialist Task stay eager (no friction on common operations).
- partitionTools keys off the shared tools.IsDeferred check exactly as it does for
MCP tools, so this reuses the existing, proven mechanism. Measured ~470 fewer
prompt tokens per request in a swarm-enabled (--auto high) config, with no
behavioral loss — swarm still works, just loaded when needed.
Web search — keyless SearXNG backend:
- web_search gains a "searxng" provider: ZERO_WEBSEARCH_PROVIDER=searxng with a
SearXNG URL queries GET /search?format=json with NO API key, parsing the
{results:[{title,url,content,score}]} shape. Self-host or point at any SearXNG
instance for native search without baking a shared key or a flaky default.
Tests: swarm tools are deferred-eligible; the SearXNG backend issues the GET, sends
no auth header, maps fields, and respects the limit.
* Prompt for shell network permissions
Route network-risk shell calls through the existing permission prompt instead of hard-denying them at sandbox preflight. Approved network prompts apply the current turn/session request-permissions overlay so the effective sandbox policy is widened only after user approval.
Move network command detection into the shell AST analyzer for package installers, gh/git network subcommands, and python http.server so quoted process patterns such as pkill -f "python3 -m http.server 8000" are not misclassified as network activity.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestAnalyzeCommand|TestClassifyDoesNotFlagQuotedHttpServerPattern|TestEvaluateExemptsNetworkToolsFromDenyByDefault|TestEngineClassifiesNetworkAndDestructiveShellCommands|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestBash|TestRegistryRunsBashThroughSandboxEngine'
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Auto-allow native-sandboxed shell commands
Remove the old hidden bash auto-allow environment/config path. Ordinary shell commands now use the active native sandbox as the permission boundary by default, while network, destructive, and workspace policy checks still run first.
Policy-only and disabled sandbox backends continue to require approval for shell commands, so unsandboxed execution is not silently allowed.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestSandboxedBash|TestShellSandboxActive|TestEvaluateExemptsNetworkToolsFromDenyByDefault|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn|TestAnalyzeCommand|TestClassifyDoesNotFlagQuotedHttpServerPattern'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestBashAuto|TestBashStillPromptsWithoutActiveSandbox|TestRegistryRunsBashThroughSandboxEngine'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestApplyConfiguredSandboxPolicy|TestApplyConfiguredAutonomyCeiling'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/daemon -run 'TestScrubWorkerEnvRemovesReentrancyMarkers'
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Prompt for destructive shell approval
Remove the separate DenyDestructiveShell policy layer from active sandbox decisions. Destructive shell classification now produces an approval prompt in ask/auto flows instead of a sandbox hard-deny, while no-approval paths remain blocked.
Drop the removed destructive-shell policy field from sandbox policy output, backend capabilities, snapshots, and Windows golden data.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPromptsForDestructiveShellInsteadOfSandboxDeny|TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineClassifiesNetworkAndDestructiveShellCommands|TestBackendCapabilitiesReflectDisabledPolicy'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicy|TestEffectiveSandboxPolicy'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/zerocommands -run 'TestSandboxPolicySnapshotFromPolicyFillsEffectiveMode'
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Remove web-tool network policy switch
Remove the old EnforceToolNetwork policy option and the NetworkHostAllowed per-tool gate so sandbox network policy only controls sandboxed shell egress.
Keep web_search and web_fetch on their own tool permission and URL safety paths, with tests proving deny/scoped shell network policy no longer blocks in-process web tools.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy|TestEngineAllowsNetworkSideEffectWhenShellPolicyBlocksNetwork|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn|TestScopedNetworkGateInEvaluate'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestWebSearchRunWithSandbox|TestWebFetchRunWithSandbox|TestRegistryRoutesWebFetchThroughSandboxPath'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant'
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Prompt for outside-workspace file access
Convert recoverable outside-workspace file path violations into permission prompts instead of hard-denying in ask/auto modes.
Approved prompts now grant the requested filesystem scope through the existing request_permissions overlay for the current turn or session, so the follow-up sandbox evaluation and scoped file tools use the same policy path.
Keep unsafe mode, apply_patch escapes, symlink traversal, and non-recoverable path cases hard-denied.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineAutoAllowsWorkspaceFileMutationTools|TestEngineDeniesOutOfWorkspacePaths|TestGrantRequestPermissionsReadDoesNotGrantWrite|TestGrantRequestPermissionsSessionPersists'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPromptsAndAllowsOutsideWorkspaceWrite|TestRunSessionAllowsLaterOutsideWorkspaceWrite|TestRunAppliesSandboxEvenInUnsafeMode'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestRegistryAppliesSandboxBeforeToolExecution|TestRegistrySandboxGatesPathAliasKeys|TestRegistryAllowsPromptToolWithPersistentSandboxGrant'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxCheckJSONDeniesOutOfWorkspaceWrite'
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Fix Windows network prompt test fixture
Use a Windows curl.cmd shim and cmd.exe PATH syntax for TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant while keeping the POSIX fake curl fixture for Linux and macOS.
This keeps the test focused on network prompt approval and turn network grants instead of relying on a POSIX shell script being executable on Windows.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant|TestRunPersistentCommandPrefixStillPromptsForNetwork'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestClassifyASTCatchesNetworkProgramsRegexMisses|TestClassifyFlagsPipedInstallerVariants'
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Remove sandbox autonomy ceilings
Sandbox permission decisions now match grants by tool and scope only, without MaxAutonomy or request autonomy checks. Persistent/session grants, permission approvals, workspace-write auto-allow, and native-sandbox shell auto-allow no longer route through an autonomy ceiling.
Remove the sandbox.maxAutonomy config and CLI surfaces, including grant --auto, policy output, sandbox check autonomy input, and sandbox grant snapshots.
MCP permission autonomy is intentionally unchanged because it is a separate MCP permission model.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tools ./internal/zerocommands ./internal/config ./internal/cli ./internal/tui
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Fail closed without native sandbox
Remove AllowPolicyOnlyRunner from sandbox policy and snapshots. A restricted/managed command plan now errors when native sandbox support is unavailable instead of running through a policy-only direct runner.
Policy-only backend data remains as diagnostics for capability/reporting paths, but it no longer authorizes command execution when a platform sandbox is required.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/zerocommands ./internal/cli ./internal/tui
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tools
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Remove scoped shell network policy
Drop the domain-scoped shell network mode and the local scoped-egress proxy path from the active sandbox model.
Shell network policy now serializes and evaluates only deny or allow. Request-permissions network grants widen to allow without domain lists, and platform profiles consume the binary mode directly.
The Linux helper no longer accepts proxy-route flags, Windows network hashes only the binary mode, and web tool tests assert they remain separate from shell network policy.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/notify
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Align permission grant ordering and prompt text
Move persistent and session grant matching ahead of recoverable sandbox prompts while keeping explicit denies and non-recoverable path violations fail-closed.
Clean TUI permission transcript/card text so normal prompts show action, reason, scope, and readable block details instead of raw permission/risk/violation key-value metadata.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tui ./internal/agent ./internal/tools
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Remove policy-only sandbox terminology
Replace the unavailable native-backend diagnostic path with explicit unavailable support/backend states, and stop presenting missing native isolation as a policy-only fallback.
Rename structured sandbox denial metadata from violation to block across permission events, stream-json, snapshots, TUI rendering, and sandbox-check output so user-facing flow matches blocked action language.
Update README, work split docs, smoke test selectors, and Windows sandbox policy golden output for the new backend/support names and block payloads.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tools ./internal/tui ./internal/cli ./internal/doctor ./internal/zerocommands ./internal/streamjson
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Fix sandbox review follow-ups
Fail closed for unknown network modes, keep network prompts ahead of bash allow grants, preserve v1 grant-file reads, and classify unparseable commands with obvious network programs as network-risk.
Bump stream-json schema to v2 and document the block metadata field after the violation-to-block rename.
Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/streamjson ./internal/specialist ./internal/cli -run 'TestExec.*Stream|TestStreamJSON|TestExecProtocol|TestExecReadsStreamJSONImages|TestExecAcceptsImageOnlyStreamJSONMessage|TestExecInputFormatStreamJSON'
Tests: GOCACHE=/tmp/zero-go-cache go test ./...
Tests: scripts/sandbox-smoke.sh
Tests: git diff --check
* token efficiency: lossless cost accounting + minified file reads
Two complementary improvements to how Zero handles tokens.
ACCOUNTING — make cost measurement lossless and correct:
- Carry all five token dimensions end-to-end (input, cache-read, cache-write,
output, reasoning) in one Usage; add CacheWriteTokens (cache creation), which
the Anthropic adapter previously parsed then discarded.
- Price cache-writes at their premium rate (Anthropic 1.25x input, derived per
model). Regression-safe: a model without the rate bills cache-write at the
input rate exactly as before.
- Persist cache-read/cache-write/reasoning in EventUsage so `zero usage report`
reconstructs the SAME cost as the live tracker instead of over-pricing
cache-heavy sessions. A single EventUsagePayload writer is paired with the
reader so the JSON keys can't drift.
- Add a live context-window gauge to the status bar (used/window + percent,
graded green/amber/red) using the last request's input tokens.
REDUCTION — read_minified_file, a dense token-cheap file view:
- New internal/minify package + read_minified_file tool returning a comment-free,
line-number-free view: Go via the real go/parser AST; C/Java/CSS/SCSS/LESS/
Python via a string-aware comment lexer (handles strings, char literals,
triple-quotes, Java text blocks, Python f-strings incl. 3.12 same-quote
nesting). Every other language falls back to whitespace-only, so the stripper
can never corrupt content.
- ~32% fewer tokens on a realistic multi-file read; +~102 fixed tokens for the
tool schema, recovered many times over by a single subsystem read.
Tests: cache-write pricing + fallback, lossless persistence round-trip, the
context gauge, comment-stripping golden cases per language (comment-chars in
strings, Java text blocks, Python f-string nesting, CSS has no //), and the
unsupported-language safe fallback. gofmt / vet / build / -race all green.
* specialist: register read_minified_file in the tool registry + read-only sets
Smoke caught TestKnownToolNamesMatchCoreRegistry drifting once read_minified_file
joined the core read-only tools: the specialist tool allowlist (knownToolNames),
the read-only/edit/execute categories, and the read-only auto-approve set did not
list it. It is a read-only companion to read_file, so it belongs alongside
read_file in all of them; updated the default-allowlist test accordingly.
* tui: live working status + streaming reasoning preview so long turns never look frozen
A long model stream or reasoning phase made the TUI look stuck: once partial text
streamed only a static cursor showed; during a think, just a static "Thinking"
header; and there was no elapsed time anywhere. On slow turns the user couldn't
tell whether the agent was working or hung.
- Always-on working status line: animated spinner + rotating verb + live elapsed
("8s", "1m04s"), rendered on every pending frame — including once partial text
has streamed. The spinner tick is time-based (~80ms), so the elapsed clock keeps
advancing even when the upstream goes silent mid-stream. Provider-agnostic.
- Live reasoning preview: during a think (before any answer text) the last 1-2
lines of the streaming reasoning are shown dimmed beneath the working line,
tail-truncated so a single growing reasoning line still visibly moves as tokens
arrive. Skipped when the reasoning block is expanded (the full body shows).
Tests: formatWorkingElapsed, previewTail, interim block shows the working line with
streamed text, shows the reasoning preview while thinking, no duplicate preview when
expanded; updated the one reasoning-elapsed clock test for the extra turn-start stamp.
* tui: centralize run-start state in beginRun so spec-mode runs get the elapsed clock
Review follow-up (#254): the /spec draft and implementation launch paths set
pending and started the spinner but never set turnStartedAt, so the new working
status line showed the spinner/verb but no live elapsed time on spec runs (and
the draft path also skipped the working-verb reset the others did).
Extract beginRun(cancel) — runID++, activeRunID, runCancel, pending, turnStartedAt,
and the working-verb reset — and call it from all three launch paths (normal
prompt + spec draft + spec impl) so run-start state can no longer drift.
Test: TestSpecLaunchesSeedElapsedClock asserts both spec launches seed turnStartedAt.
* feat(oauth): add Hugging Face + ChatGPT Codex as first-class built-in OAuth providers
Adds two new built-in OAuth presets that round out the existing OpenRouter and
xAI surfaces, so the TUI `/provider` OAuth chooser and `zero auth login` work
out of the box for these providers.
Hugging Face
- Preset scopes, endpoints, and OIDC issuer are pre-filled; the operator
supplies a "public" OAuth client_id (no secret) via
`ZERO_OAUTH_HUGGINGFACE_CLIENT_ID` after registering an app at
huggingface.co/settings/applications/new.
- Default flow is RFC 8628 device code, so headless/SSH is the first-class
path (matches the existing xAI posture).
- The bearer works against the OpenAI-compatible Inference Providers router
at `https://router.huggingface.co/v1` for hundreds of OSS models.
ChatGPT (Codex backend)
- Uses the publicly-shipped Codex CLI client identity
(`app_EMoamEEZ73f0CkXaXp7hrann`), opt-in via `ZERO_OAUTH_ALLOW_PRESETS=1`,
overridable via `ZERO_OAUTH_CHATGPT_*`.
- The bespoke `provideroauth.ChatGPTLogin` extracts the `chatgpt_account_id`
claim from the OIDC ID token (added as `IDToken` on the `oauth.Token`
struct) and stores it on `Token.Account` so the Codex provider can inject
it as a header on every request.
- A new `internal/providers/openai/codex.go` wraps the openai provider with
the Codex-flavored request shape: `originator: codex_cli_rs`,
`chatgpt-account-id: <claim>`, and a branded User-Agent. The factory
branches off the catalog id (not the provider kind) so other
openai-compatible providers keep the standard openai path unchanged.
- The openai provider gained a `SetRequestExtra` callback so the Codex
wrapper can inject per-request headers without coupling the openai
provider to Codex.
CLI / TUI
- `zero auth chatgpt` is a one-shot login command (analog to
`zero auth openrouter`); the TUI's OAuth chooser now lists both new
providers automatically (the list is built from
`providercatalog.OAuthProviders()`).
- The provider-wizard visible-row cap was bumped from 8 to 10 so the
common providers (OpenAI, Anthropic, Google, Ollama, OpenRouter, the
new Hugging Face, the new ChatGPT, Groq, …) all fit without scrolling
the test fixtures.
Tests
- `internal/oauth/presets_test.go`: presets resolve correctly with the
expected endpoints, scopes, and flows; env overrides win; Hugging Face
preset stays inert without a client_id (the safety posture the existing
xAI tests assert).
- `internal/providercatalog/oauth_test.go` + `catalog_test.go`: catalog
now exposes 4 OAuth providers (`openrouter`, `huggingface`, `chatgpt`,
`xai`); the `chatgpt` descriptor is `OAuth=true` with
`OAuthDeviceFlow=false` (Codex is loopback-only) and `RequiresAuth=true`
via an inline IIFE that sets it after the openAICompat helper.
- `internal/provideroauth/chatgpt_test.go`: ID-token claim extraction
round-trips (happy path, missing claim, missing token, tampered JWS,
non-string claim); the full login flow runs against a stub auth server
and verifies the preset's client_id reaches the token endpoint.
- `internal/providers/openai/codex_test.go`: every request gets the three
Codex headers; the 401-refresh retry path keeps them on the second
attempt; configured baseURL wins; default originator is `codex_cli_rs`;
branded User-Agent wins over the openai default; AccountResolver is
consulted when AccountID is empty; the header is omitted when the
resolver says no.
Out of scope (separate PRs): multi-account / profiles, generic
template-driven OAuth wizard, MCP / provider OAuth store unification,
subscription-proxy autoconfig.
* fix(ci): gofmt chatgpt_test.go and provider_wizard.go
The ubuntu smoke job's "Check formatting" step lists these two files as
unformatted (struct field alignment). The local gofmt run on Windows
also lists ~170 other files, but those are CRLF-vs-LF noise from
checkout and not real gofmt issues on Linux; the CI only flags the
two struct-alignment changes below.
- internal/provideroauth/chatgpt_test.go: align chatgptTestServer fields
- internal/tui/provider_wizard.go: align ChatGPTOptions literal fields
* fix(codex): hit the /responses path and resolve account id per request
Addresses the two findings in anandh8x's CHANGES_REQUESTED review on #245.
The Codex provider previously routed the chat-completions body to
{baseURL}/chat/completions, which is not the path the Codex backend serves
(chatgpt.com/backend-api/codex/responses, the Responses API). Add an
Endpoint field to openai.Options so a flavor can target a sibling path on
the same host; the Codex constructor sets it to {baseURL}/responses and
falls back to the openai transport unchanged for everything else.
The factory was also passing the OAuth token's Account field as a static
AccountID at construction time. A refresh that rotates the token (and
its account claim) would leave a stale value on the wire. Drop the
static AccountID from the factory wiring; the per-request
AccountResolver (which reads the live store) is now the sole source, so
a refresh takes effect on the next outgoing request.
Tests:
- codex_test.go: handler mounted on /responses; expected path updated;
TestCodexProviderResolverIsConsultedOnEveryRequest pins the per-request
resolver behavior (a refresh-style sequence of calls picks up the new
account id on the second attempt).
- factory_test.go: expected request path updated to /responses.
* Addresses the Codex path shape on /responses.
The previous fix (2381197) corrected the URL and the per-request account
resolver, but the Codex provider still spoke chat-completions: it sent
{model, messages, tools, stream, stream_options, max_completion_tokens}
to /responses and parsed data: [DONE]-terminated SSE frames
(choices[].delta). The Codex backend at chatgpt.com/backend-api/codex
serves the Responses API — input items, function_call /
function_call_output, response.output_text.delta /
response.function_call_arguments.delta events — and rejects a
chat-completions body.
The Codex provider now has its own request/stream path that matches the
Responses schema:
- internal/providers/openai/codex_responses.go (new) holds the wire
types (responsesRequest, inputItem, contentItem, responsesTool,
responsesEvent, itemPayload, responsePayload, usagePayload), the
request builder (system → message/role=system, user/assistant →
message with text or content parts, prior tool calls → function_call,
tool results → function_call_output, tools → responsesTool), the
Responses SSE dispatcher, the tool-call accumulator
(response.output_item.added registers the call id + name, the
argument deltas accumulate, response.output_item.done finalizes),
and the terminal-event handler (response.completed emits usage +
done, response.failed surfaces the error after the usage chunk so
token accounting is preserved).
- internal/providers/openai/codex.go: StreamCompletion now marshals a
Responses request and dispatches via the new streamResponses
function. The wrapped openai Provider is still constructed for its
validated endpoint / auth / retry / idle-timeout config and its
SetRequestExtra callback, but the openai chat-completions stream
parser is no longer used for Codex. injectCodexHeaders still runs
on every attempt (including the 401-refresh retry) so the Codex
headers stay attached to the rotated bearer.
The shared providerio plumbing (auth + 401-retry, SSE scanner with
the idle watchdog, error humanization, secret redaction) is reused
verbatim so the OAuth 401-refresh and bearer-rotation paths stay
identical to the rest of the openai-compatible surface. The
stream-end fallback surfaces a StreamEventDone with
FinishReasonLength when the Codex backend closes the stream without
a response.completed (e.g. an internal truncation), so the runtime
can react instead of hanging on a phantom completion.
Tests:
- internal/providers/openai/codex_test.go:
- newCodexTestServer now returns a minimal Responses sequence
(response.created + response.completed); newCodexResponsesServer
takes a custom payload sequence for richer cases.
- The two tests with inline handlers (TestCodexProviderResolverIs-
ConsultedOnEveryRequest, TestCodexProviderRetriesHeadersAfter401)
use a response.completed payload on the second attempt instead of
data: [DONE].
- TestCodexProviderSendsResponsesRequestShape replaces the old
TestCodexProviderSendsRequestBody and pins the Responses shape:
the body MUST carry an `input` array of message / function_call
/ function_call_output items and `tools` of function type, and
MUST NOT carry the chat-completions `messages` field.
- TestCodexProviderParsesResponsesTextDeltas: response.output_text.
delta events surface as StreamEventText in order, the
response.completed usage chunk surfaces as StreamEventUsage
(with input_tokens, output_tokens, cached_input_tokens, and
reasoning_tokens from the optional details blocks), and
StreamEventDone follows.
- TestCodexProviderParsesResponsesToolCalls:
response.output_item.added emits StreamEventToolCallStart with
the right name, response.function_call_arguments.delta events
emit StreamEventToolCallDelta in order, response.output_item.done
emits StreamEventToolCallEnd.
- TestCodexProviderSendsAssistantToolCallsAsInputItems: a runtime
message with content + tool_calls serializes as BOTH the
assistant message and a function_call item; a tool result
serializes as a function_call_output. The expected count is 5
items (user + assistant text + function_call + function_call_
output + user) — earlier this test had 4, which would have
missed the assistant message before the function_call.
- TestCodexProviderEmitsErrorOnResponseErrorEvent: a response.error
event surfaces as a single StreamEventError with the server's
message and stops the scan.
- TestCodexProviderEmitsErrorOnMalformedStream: a payload that
parses as JSON but lacks the `type` field is reported as a
stream error rather than silently dropped.
- TestCodexProviderEmitsLengthFinishWhenStreamEndsWithout-
Completion: a stream that closes without a response.completed
surfaces a StreamEventDone with FinishReasonLength.
Verification:
- `go build ./...` clean (with the user-owned untracked files moved
aside per the project's working-tree convention).
- `go vet ./internal/providers/...` clean.
- `go test ./internal/providers/... ./internal/providercatalog/...
./internal/provideroauth/... ./internal/oauth/...` PASS.
- `gofmt -l` on the three changed files clean.
Add file-backed command prefix grants to the sandbox store and load them through the engine so approved prefixes skip future prompts while still running through sandbox policy checks.
Tighten reusable prefix validation for runner-style launchers and script paths, and classify find -delete as destructive.
Surface persistent prefix choices in the TUI and include approved prefixes in the run prompt context and sandbox grants list.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/sandbox ./internal/tui ./internal/cli
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
Bash permission prompts can now expose a validated session-scoped prefix approval. Prefixes are parsed from simple shell commands or an explicit prefix_rule argument, with redirects, control operators, assignments, expansions, globs, and broad launcher prefixes rejected before the option is shown.
Approved prefixes are stored in the session sandbox engine and only skip future matching bash prompts; commands still execute through the existing sandbox backend.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tui
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/sandbox ./internal/tui ./internal/tools
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Two data-integrity gaps in the session store:
1. Sequence numbers were derived from metadata.EventCount, which is persisted
AFTER the event line (a separate file). A crash between the two writes left
EventCount behind the log, so the next append reused a sequence number -- a
duplicate id that mis-targets /rewind (findRewindTarget returns the first
match). Now the next sequence is derived from the events log itself
(lastEventSequence, an O(1) tail read that ignores a torn trailing partial),
so a stale/desynced EventCount can never cause a reused number; it falls back
to the metadata count only if the log is unreadable.
2. Checkpoint blobs and rewind restores used plain WriteFile+Rename with no
fsync, while session metadata fsyncs the temp file AND the parent dir -- so
the /rewind safety net and restored workspace files were not crash-durable.
They now go through the same writeFileSync + syncDir barrier
(writeFileAtomicSync), preserving the exact-perm chmod on restore.
Tests: stale-low metadata -> no duplicate (the reported repro); stale-high ->
benign gap; lastEventSequence over empty / multi / torn-tail / large-event logs.
* redaction: catch compound secret keys + non-bearer auth schemes; stop scanner over-redaction
Hardens the secret-redaction last line of defense after an audit of
internal/redaction + internal/secrets.
1. Compound secret keys are now redacted. IsSensitiveKey was exact-match only, so
db_password, session_secret, stripe_secret_key, auth_token, csrf_token,
ssh_private_key, backup_api_key, etc. passed through in cleartext. A new
keyLooksSensitive applies conservative structural heuristics (secret /
password / passphrase / credential / apikey segments; a singular trailing
"_token"; api_key / private_key pairs). Critically, the agent's token-COUNT
fields (max_tokens, prompt_tokens, token_count) and ordinary *_key fields
(primary_key, public_key, cache_key) are deliberately NOT redacted.
2. Authorization headers with non-bearer/basic schemes were leaking. The header
pattern now also covers token, apikey, digest, negotiate, oauth, and
aws4-hmac-sha256.
3. The secrets scanner over-redacted ordinary text: its prefixed patterns (sk-,
gh*, AIza, AKIA, xox, github_pat, jwt) lacked the word boundaries that
internal/redaction already uses, so kebab-case words like
"task-management-and-coordination" matched as a fake key. Added \b anchors.
Design note: a free-text "key: value" colon pattern was prototyped but dropped --
it mangled "file.go:line: message" output (a filename's "secret" segment looked
like a sensitive key), the same over-redaction class fix#3 addresses. The
structured paths (RedactValue for maps/structs, plus assign/JSON/query forms in
RedactString) are covered; free-text colon-form remains out of scope.
Tests: compound-key + token-count-safety matrix, auth-scheme redaction, the
file:line guard, and scanner boundary positives/negatives.
* redaction: redact the full auth-header value for multi-part schemes
The broadened header pattern captured only the first token after the scheme, so
parameterized schemes (Digest "…, response=…", AWS4-HMAC-SHA256 "…, Signature=…")
left the actual secret param visible. Redact the entire value to end of line; the
scheme name is kept. Adds tests with realistic Digest + AWS SigV4 headers.
Addresses review feedback.
* Add request permissions flow
Adds a runtime-handled request_permissions tool that can ask for turn or session filesystem/network grants and return the granted profile as structured JSON.
Temporary turn grants update the shared sandbox scope and effective policy, then clean up at run end; session grants persist in memory for the session.
Read grants now stay read-only through tool path resolution, policy evaluation, and native sandbox profile construction.
The TUI renders request_permissions with grant-specific choices, including strict review, and Esc continues without granting permissions.
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Align request permission grants with prompts
Display and return the normalized grant roots that the sandbox actually applies, so file-like permission requests no longer show a narrower path than the directory root being granted.
Also makes the network prompt explicit about all outbound access and relabels the strict option as a model review request instead of implying enforced command review.
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Retry transient OAuth secret read locks
Windows can briefly deny reads of the freshly published token secret while concurrent OAuth secret creation converges. Treat Windows sharing and lock violations as transient and retry those reads before returning an error.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/oauth
Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go test -c ./internal/oauth -o /tmp/zero-oauth-windows.test.exe
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tui ./internal/tools ./internal/specialist ./internal/oauth
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* feat(subagent): proactive specialist delegation + swarm execution fixes
Fixes:
- Task tool dropped the parent's permission mode, so every delegated
sub-agent ran at --auto high regardless of the parent. Forward
PermissionMode so a child never exceeds its parent's authority.
- Swarm members never executed: swarm_spawn accepts agent types
teammate/subagent, but the launcher looked those up as specialist names
(only worker/explorer/code-review exist), so every member failed with
"specialist not found". Run each member from its swarm definition via an
inline manifest on the executor.
Enhancements:
- Auto-delegation: the orchestrator system prompt now lists the available
specialists and when to use each, nudging it to offload read-heavy work to
a sub-agent so large tool output stays out of the main context.
- @-mention: a leading "@<specialist> <task>" in the TUI composer delegates
to that specialist (transcript keeps the verbatim mention); the @ palette
suggests specialists on the first token, file references mid-message.
- swarm.maxTeamSize config caps concurrent members per team (0 => default 8;
negative rejected), honored in user and project config.
- Proactive spawning: read-only specialists (explorer/code-review) auto-
approve via a new args-aware tool permission (tools.ArgsPermissioner), so
the agent delegates exploration off a normal prompt without an approval
prompt; write-capable specialists, resume, and unknown names still prompt.
The exec gate registers specialist tools at medium/high autonomy so a
non-trivial headless run can auto-delegate read-only too.
* fix(specialist): fail-safe child autonomy when PermissionMode is unset
specialistAutonomy mapped an empty PermissionMode to "--auto high" (unsafe),
silently re-introducing the privilege escalation the rest of this PR fixes: an
orchestrator that forgets to wire PermissionMode would get an unsafe child. The
Task tool and swarm both now propagate a resolved mode, so "" only occurs for a
caller that omitted it -- default that case to "low".
Only an explicit unsafe parent now yields --auto high; everything else (incl.
unset/unknown) is fail-safe low. Updates the stale "back-compat" comments and the
tests that documented the old behavior.
Addresses the non-blocking follow-up from PR review.
* tui: image-input UX — drag-drop attach, inline numbered chips, vision fallback, cursor + copy fixes
Improves the image/PDF attachment flow and two long-standing composer papercuts.
- Drag-and-drop: dropping an image/PDF onto the composer attaches it instead of
erroring as an unknown "/…" command. The dropped path is detected on paste and
on submit (absolute/escaped/quoted paths to an existing image or PDF).
- Inline numbered chips: staged attachments render as compact [Image #1] /
[Doc #1] chips INSIDE the input box; the verbose "Attached … (image/png)"
system line is dropped. Backspace on an empty composer removes the last chip
(/image clear still drops all).
- Vision fallback: a real multimodal model absent from the curated catalog
(custom / openai-compatible / local ids) now accepts images via a conservative
name match; text-only ids stay refused, and the catalog stays authoritative.
- Blinking composer cursor, including when the box is empty (previously no caret
was drawn for a focused, empty box).
- Ctrl+E releases/recaptures the mouse so native drag-select + copy works (mouse
capture otherwise intercepts terminal text selection).
Tests: dropped-path detection, vision-by-name + unknown-model fallback,
mouse-release capture toggle, attachment removal, and updated chip-render tests.
* fix(vision): a "vision-less" model name must not match the bare-vision fallback
visionCapableByName treated any id containing "vision" as multimodal, so a model
named "...vision-less..." (i.e. WITHOUT vision) was wrongly classified vision-
capable — which silently skipped the non-vision image gate and broke
TestRunExecDropsImagesOnNonVisionModelWithWarning in internal/cli.
Add mentionsVision, which still matches the "vision" word but excludes the
negated forms (vision-less / visionless / no-vision / non-vision). Used for both
the grok-vision and the general open-multimodal fallback. Added negation cases to
the name table.
* fix(tui): drag-drop path detection corrupted Windows paths (backslash unescape)
unescapeDroppedPath stripped backslashes to undo Unix terminal drag-drop escaping
("\ " -> " "), but on Windows the backslash is the path separator, so a plain
dropped path like C:\Users\x\file.png became C:Usersxfile.png and never resolved
-- TestDroppedAttachmentPath failed on windows-latest.
No-op the unescape on Windows (dropped paths there arrive quoted or plain), and
gate the Unix-only backslash-escaped positive case behind runtime.GOOS.
* perf(tools): lower default deferThreshold 10 -> 3 to stop eager MCP-schema waste
MCP tool schemas are only collapsed into compact tool_search reminder lines once
the deferred-eligible tool count reaches tools.deferThreshold. The default of 10
meant a typical single MCP server (Exa-style, a handful of tools) stayed BELOW it,
so every full MCP schema — 300-600 tokens each — was re-sent on every turn, even
for a one-word message. A small MCP set could add several thousand input tokens
per message.
Lower the default to 3 so a normal MCP server's toolset defers and stays cheap.
Capability is unchanged: every tool is still reachable; the model just loads a
deferred tool's schema via tool_search before its first use, after which it is
loaded for the rest of the run. Fully overridable via tools.deferThreshold
(0 keeps the old always-eager behavior for models without tool_search).
* test(agent): measure the deferral token saving (66% smaller tool payload)
* fix(tokens): count non-whitespace bytes so estimates match the real tokenizer
The token estimate was naive len/4, which overcounted by ~15-20%: `zero context`
reported ~6k for a prompt the provider's tokenizer actually counted as 5,203.
BPE tokenizers (the ones zero targets) fold a run's leading space into the
following token rather than emitting whitespace separately, so counting
NON-whitespace bytes / 4 tracks the real count closely (now ~5.2k, ~1% off the
measured 5,203) while staying dependency-free.
Add agent.ApproxTextTokens and route the context-budget preview
(contextreport), the compaction threshold (agent), and the TUI transcript
estimate through it so every surface uses one accurate scale. The provider's
exact usage is still authoritative on each request; this only sharpens the
pre-request preview and compaction timing.
* Align permission prompt rendering
Remove normal permission prompt risk labels from focused cards, transcript rows, and permission detail text so risk metadata is not presented as the permission UI contract.
Rename the recoverable deny option from a redirect-style prompt to a continue-without-running label that matches its current behavior.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui ./internal/agent ./internal/cli
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Tighten permission prompt fallbacks
Use conservative allow/deny choices when a permission request lacks backend-supplied decisions, avoiding impossible session or persistent grant options.
Document that a failed session-grant recording attempt must not deny the current user-approved tool call; it only means a later matching call prompts again.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui ./internal/agent
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Split permission deny and cancel decisions
Add an explicit cancel permission decision that aborts the active run while keeping deny as a recoverable tool refusal.
Expose cancel choices for command and apply-patch approval prompts, keep apply-patch off the recoverable deny path, and stop offering persistent always approvals for command/apply-patch prompts until scoped prefix approval exists.
Classify cancel decisions as permission-decision events for TUI session logs and exec JSON output.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tui ./internal/cli
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Stabilize OAuth secret creation
Publish encrypted OAuth token secrets atomically under a short-lived creation lock so concurrent first-run callers never observe a half-written secret file.
This fixes the Zero Review failure in TestLoadOrCreateSecretConcurrentConverges.
Tested: go test ./internal/oauth -run TestLoadOrCreateSecretConcurrentConverges -count=100
Tested: go test ./internal/oauth
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tui ./internal/cli
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Align permission session approvals
Add in-memory session grants, workspace-write auto-allow for built-in file mutation tools, and dynamic permission choices for once/session/future approvals.
Persist future approvals only when a grant store is configured, while keeping session approvals scoped to the active engine.
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Guard workspace write auto-allow
Exclude protected workspace metadata from built-in file-tool auto-allow so writes under .git, .zero, and .agents re-prompt instead of silently running.
Derive apply_patch targets from patch headers inside the sandbox engine so patch-body escapes and symlink traversal are denied before workspace auto-allow.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineAutoAllowsWorkspaceFileMutationTools|TestEngineDoesNotAutoAllowProtectedMetadataWrites|TestEngineDeniesApplyPatchEscapesFromPatchBody|TestEngineSessionGrantDoesNotMatchSiblingFile|TestEngineDeniesAutoAllowedSymlinkEscape|TestEngineDeniesWorkspaceSymlinkTraversal|TestDeriveScope'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tui ./internal/tools
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
Treat hosted web_search as an allowed read-only network discovery tool so normal searches do not stop on a permission prompt, while retaining sandbox network enforcement when tool-network policy is enabled.
Add host-scoped persistent grants for web_fetch URLs so an always-allow decision is bounded to the requested host instead of becoming a blanket tool-wide fetch grant.
Update permission prompt and transcript rendering to show network targets, scoped always labels, and preserved scope metadata across live rows, render caching, and session hydration.
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Add sandbox architecture baseline
Record target backend names, enforcement metadata, legacy sandbox entrypoints, and policy JSON baseline coverage for the sandbox rewrite.
Add a focused sandbox smoke script with cross-platform compile checks and an opt-in real backend smoke path.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestTargetBackendForPlatformBaseline|TestLegacySandboxEntrypointsAreExplicitAndExist|TestBackendPlanCarriesPhase0ManagerFields|TestCommandPlanCarriesSandboxMetadata|TestPolicyOnlyDisabledFailClosedForTargetPlatforms|TestSelectBackendChoosesPlatformAdapterWithFallback|TestBackendBuildPlanDocumentsBestEffortIsolation|TestBackendCapabilitiesReflectDisabledPolicy'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicyInspectTextAndJSON|TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields|TestRunSandboxPolicyEffectiveTextAndJSON'
Tested: scripts/sandbox-smoke.sh
Tested: git diff --cached --check
* Introduce sandbox manager and permission profile
Add a normalized permission profile and sandbox manager execution request that owns target backend selection, enforcement metadata, and fail-closed validation.
Route command planning and sandbox policy output through the manager boundary while keeping legacy adapters explicit as temporary integration points.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestPermissionProfileFromPolicyBuildsWorkspaceWriteProfile|TestPermissionProfileFromDisabledPolicyDoesNotRequirePlatformSandbox|TestSandboxManagerBuildsExecutionRequestFromProfile|TestSandboxManagerFailsClosedWhenNativeRequiredAndPolicyOnlyDisabled|TestTargetBackendForPlatformBaseline|TestBackendPlanCarriesPhase0ManagerFields|TestCommandPlanCarriesSandboxMetadata|TestPolicyOnlyDisabledFailClosedForTargetPlatforms'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicyInspectTextAndJSON|TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields|TestRunSandboxPolicyEffectiveTextAndJSON'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestRegistryRunsBashThroughSandboxEngine|TestBashToolReportsDisabledPolicyOnlyRunner|TestBashToolBuildsWrappedSandboxExecCommand'
Tested: scripts/sandbox-smoke.sh
Tested: git diff --cached --check
* Move sandbox backend selection into manager
Make SandboxManager own platform backend selection and keep SelectBackend as a compatibility shim for existing CLI and TUI wiring.
Add tests proving manager-owned selection and the compatibility wrapper agree.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestSandboxManagerSelectsPlatformBackend|TestSelectBackendDelegatesToSandboxManagerSelection|TestSelectBackendChoosesPlatformAdapterWithFallback|TestLegacySandboxEntrypointsAreExplicitAndExist|TestSandboxManagerBuildsExecutionRequestFromProfile|TestSandboxManagerFailsClosedWhenNativeRequiredAndPolicyOnlyDisabled'
Tested: scripts/sandbox-smoke.sh
Tested: git diff --cached --check
* Route command planning through sandbox manager
Make SandboxManager build command plans from sandbox execution requests, with backend-specific wrapping isolated behind a temporary adapter.
Move Engine.BuildCommandPlan to a single manager boundary and mark buildLegacyCommandPlan as the explicit delete target for later backend work.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestSandboxManagerBuildsCommandPlanThroughTemporaryAdapter|TestSandboxManagerBuildsDegradedPolicyOnlyCommandPlan|TestBuildCommandPlanWrapsBubblewrap|TestBuildCommandPlanWrapsSandboxExec|TestBuildCommandPlanUsesPolicyOnlyFallback|TestBuildCommandPlanCanRejectPolicyOnlyFallback|TestLegacySandboxEntrypointsAreExplicitAndExist|TestSandboxManagerBuildsExecutionRequestFromProfile|TestSandboxManagerFailsClosedWhenNativeRequiredAndPolicyOnlyDisabled'
Tested: scripts/sandbox-smoke.sh
Tested: git diff --cached --check
* Add Linux sandbox helper command boundary
Introduce the zero-linux-sandbox command surface and typed permission-profile argv builder used by the upcoming Linux helper rewrite.
Keep the helper fail-closed until enforcement is wired, and add parser tests plus smoke compilation for the new helper package.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestBuildLinuxSandboxCommandArgsSerializesPermissionProfile|TestParseLinuxSandboxHelperArgs|TestRunLinuxSandboxHelperFailsClosedUntilEnforcementIsWired'
Tested: scripts/sandbox-smoke.sh
Tested: git diff --cached --check
* Fix sandbox policy golden path aliases
Normalize both original and symlink-resolved temp paths before comparing sandbox policy JSON goldens, so macOS /private/var aliases do not fail the smoke job.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields|TestRunSandboxPolicyInspectTextAndJSON|TestRunSandboxPolicyEffectiveTextAndJSON'
Tested: scripts/sandbox-smoke.sh
Tested: git diff --cached --check
* Fix Windows sandbox smoke test assumptions
Normalize JSON-escaped Windows paths in the sandbox policy golden test and mark the fake bubblewrap backend as Linux in the command metadata test.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields|TestRunSandboxPolicyInspectTextAndJSON|TestRunSandboxPolicyEffectiveTextAndJSON'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestCommandPlanCarriesSandboxMetadata|TestBackendPlanCarriesPhase0ManagerFields|TestPolicyOnlyDisabledFailClosedForTargetPlatforms'
Tested: scripts/sandbox-smoke.sh
Tested: git diff --cached --check
* Stabilize sandbox policy golden test
Compare the Windows sandbox policy golden as decoded JSON after path tokenization so platform-specific byte formatting does not fail smoke tests while preserving the same field-level regression coverage.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields|TestRunSandboxPolicyInspectTextAndJSON|TestRunSandboxPolicyEffectiveTextAndJSON'
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Route Linux sandboxing through helper
Move Linux command planning to invoke zero-linux-sandbox with the permission profile, command cwd, and command argv instead of exposing bubblewrap construction from the runner boundary.
The helper now builds the bubblewrap argv, binds its own executable for the inner stage, applies the seccomp/no-new-privs step before exec, and preserves the original command cwd path.
Package Linux releases with zero-linux-sandbox next to zero so release builds use the helper directly, while local development can fall back to go run for the helper.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli ./internal/tools ./cmd/zero-linux-sandbox
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/release ./cmd/zero-release
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Normalize Linux helper cwd test paths
Compare native paths in the helper cwd regression test so Windows smoke does not fail on path separator formatting while preserving the same helper behavior assertion.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run TestLinuxHelperPlanPreservesRealExtraRootCwd
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Add Linux Landlock helper enforcement
Implement the zero-linux-sandbox Landlock helper path with fail-closed profile validation, Landlock filesystem rules, and seccomp network-deny enforcement for the fallback backend.
Broaden the Linux real smoke coverage to cover helper write allow/deny, deny-read, temp writes, metadata protection, network deny, and direct Landlock fallback behavior.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: scripts/sandbox-smoke.sh
Tested: GOCACHE=/tmp/zero-go-cache go test ./cmd/zero-linux-sandbox ./internal/cli ./internal/tools ./internal/release ./cmd/zero-release
Tested: git diff --check
* Route Seatbelt profiles through permission profiles
Build macOS sandbox-exec profiles from the shared PermissionProfile so read roots, write roots, temp access, deny-read, deny-write, and protected metadata are expressed by the platform profile instead of legacy write-root-only inputs.
Keep the existing sandbox-exec adapter path as a compatibility wrapper while adding target macos-seatbelt capability handling.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: scripts/sandbox-smoke.sh
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli ./internal/tools
Tested: git diff --check
* Stabilize Seatbelt write carveouts
Emit protected metadata and read-only write-root carveouts as explicit Seatbelt deny rules after the broad write allow. This preserves deny precedence while avoiding fragile allow-rule predicates in real sandbox-exec runs.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Expand macOS Seatbelt runtime allowances
Restore the platform startup allowances needed by sandbox-exec-wrapped commands under restricted filesystem profiles: executable mapping, system metadata reads, pty/device access, preferences, IPC, and standard library/runtime roots.
This keeps project writes constrained to the PermissionProfile roots while preventing macOS shells from aborting before user commands run.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Normalize Seatbelt deny expectations in tests
Build Seatbelt deny-rule expectations from normalized profile paths so the metadata ordering test matches production behavior on Windows and Unix hosts.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: scripts/sandbox-smoke.sh
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: git diff --check
* Add Windows sandbox runner command planning
Select the windows-restricted-token backend only when the Windows sandbox command runner is discoverable, and keep the missing-runner path as an explicit policy-only downgrade.
Add the Windows runner argument contract for command cwd, workspace roots, permission profile JSON, child environment JSON, restricted-token level, and payload command parsing.
Update sandbox policy output and tests to describe the missing Windows runner instead of an unimplemented adapter.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: scripts/sandbox-smoke.sh
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Add Windows sandbox command runner
Add the zero-windows-command-runner executable and wire windows-restricted-token plans to pass sandbox home, command cwd, workspace roots, permission profile JSON, child env JSON, sandbox level, and command argv.
Persist Windows capability SIDs under the resolved sandbox home, select read-only or writable-root capability SIDs from the permission profile, and create a restricted token before launching the payload with CreateProcessAsUserW.
Package the Windows runner next to zero.exe and extend sandbox smoke coverage to cross-build it.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/release ./cmd/zero-windows-command-runner ./cmd/zero-release
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: scripts/sandbox-smoke.sh
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Plan Windows sandbox ACL operations
Derive a deterministic Windows ACL operation list from the restricted permission profile, including writable root allows, protected metadata deny-write entries, explicit deny-write paths, and deny-read materialization targets.
The plan maps workspace and extra writable roots onto the persisted capability SIDs so the elevated setup helper can apply the same contract without reinterpreting the profile.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Add Windows sandbox setup boundary
Add the zero-windows-sandbox-setup command, setup argument parsing, deterministic ACL plan hashing, and a setup marker contract that can detect profile changes requiring refresh.
Make Windows native backend discovery require both the command runner and setup helper, package the setup helper with Windows releases, and include it in sandbox smoke builds.
The setup command remains fail-closed until the Windows ACL applier is wired, so it cannot create a setup-complete marker without applying enforcement.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/release ./cmd/zero-windows-sandbox-setup
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Apply Windows sandbox ACL setup
Implement the Windows ACL setup path with grouped native DACL updates, deny-read materialization, existing DACL snapshots, and rollback for partial application failures.
Write the setup marker only after ACL setup succeeds, roll back applied ACLs if marker persistence fails, and make the Windows command runner validate the setup marker before creating the restricted token.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./cmd/zero-windows-sandbox-setup ./cmd/zero-windows-command-runner
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Fail closed for Windows network policies
Add Windows network policy validation that allows only explicit network-allow until native Windows network enforcement is implemented.
Make the Windows command runner reject deny, scoped, and unset network modes before launching a restricted-token process so default network-deny cannot silently run open.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./cmd/zero-windows-command-runner
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Track Windows network setup state
Add a canonical Windows network-policy hash and persist it in the Windows sandbox setup marker so setup refresh can detect network mode, domain, and proxy-requirement changes.
Bump the setup marker schema and add tests for domain-order normalization plus network-change marker invalidation.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Add sandbox setup CLI command
Add zero sandbox setup to resolve the active sandbox policy, derive the Windows setup helper next to the command runner, and invoke it with the shared Windows setup argument contract.
Return text or JSON setup results, no-op cleanly for non-Windows native backends, and add tests using an injected helper runner.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli ./internal/sandbox
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/cli -o /tmp/zero-cli.test.exe
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe
Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: scripts/sandbox-smoke.sh
Tested: git diff --check
* Stabilize Windows ACL canonical path test
* Add TUI sandbox setup command
* Gate TUI sandbox setup command
* Clarify sandbox doctor setup status
* Add Windows WFP network setup
Adds a Windows network enforcement plan with stable WFP provider, sublayer, and filter specs for deny-mode sandbox identities. The setup helper now installs or removes those filters transactionally before writing the setup marker, and the marker tracks the network enforcement plan so stale filters require refresh.
Scoped Windows network mode remains fail-closed until the proxy exception path is implemented, so it cannot silently become open network.
Diagnostics now report native Windows network enforcement when the restricted-token backend is active.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestValidateWindowsNetworkPolicyAllowsNativeModes|TestValidateWindowsNetworkPolicyFailsClosedForScoped|TestBuildWindowsNetworkPlanForDenyUsesCapabilityIdentity|TestBuildWindowsNetworkPlanFailsClosedForScoped|TestWindowsNetworkPlanHashIsStableAcrossEntryOrder|TestWindowsSandboxSetupMarkerRefreshesWhenNetworkChanges|TestSelectBackendChoosesPlatformAdapterWithFallback'
Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/cli ./internal/doctor
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: GOCACHE=/tmp/zero-go-cache scripts/sandbox-smoke.sh
Tested: git diff --check
* Add Windows sandbox network smoke
Adds a gated Windows real sandbox smoke test that runs the compiled setup helper and command runner, verifies sandboxed writes still work, and proves network=deny blocks a loopback TCP connection from a restricted-token command.
The sandbox smoke script now runs this test on Windows using the helper binaries it builds in its temporary directory.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestValidateWindowsNetworkPolicyAllowsNativeModes|TestBuildWindowsNetworkPlanForDenyUsesCapabilityIdentity'
Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go test -c -o /tmp/zero-windows-sandbox.test ./internal/sandbox
Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
Tested: GOCACHE=/tmp/zero-go-cache scripts/sandbox-smoke.sh
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Remove legacy sandbox runner paths
Drop the old direct bubblewrap adapter, the standalone zero-seccomp wrapper, legacy adapter metadata, and the Phase 0 legacy inventory now that command planning goes through the sandbox manager backends.
Switch platform selection and tests to target backend names: linux-bwrap, macos-seatbelt, windows-restricted-token, and explicit policy-only degradation.
Keep policy as the shared permission/profile and diagnostics surface, but stop exposing legacy adapter fields in CLI policy output and tool metadata.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/cli ./internal/tools ./internal/zerocommands ./internal/doctor
Tested: GOCACHE=/tmp/zero-go-cache scripts/sandbox-smoke.sh
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: git diff --check
* Address sandbox baseline review blockers
Restore sandbox.blockUnixSockets as a resolved config and policy field, route it into the Linux helper as --block-unix-sockets, and keep zero-seccomp as a Linux compatibility wrapper packaged with release artifacts.
Add the missing legacy-entrypoint test referenced by the smoke script, extend helper/release/config tests, and make CLI startup tests hermetic by stubbing MCP registration where they assert quiet output.
Document the compatibility path, release helper scope, and the already-resolved #237 prompt-file overlap in the PR body.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/cli ./internal/config ./internal/release ./cmd/zero-seccomp -run 'TestLegacySandboxEntrypointsAreExplicitAndExist|TestSandboxManagerBuildsCommandPlanThroughLinuxHelper|TestBuildLinuxSandboxCommandArgsSerializesPermissionProfile|TestParseLinuxSandboxHelperArgs|TestBuildLinuxSandboxBwrapArgsWrapsInnerSeccompStage|TestApplyConfiguredSandboxPolicyDiagnosticsFlags|TestResolveSandboxBlockUnixSocketsFromFile|TestCopyPackageFilesStagesLinuxSandboxHelper'
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
Tested: GOCACHE=/tmp/zero-go-cache scripts/sandbox-smoke.sh
Tested: git diff --cached --check
Tested: local leak scan found no matches
* fix(mcp): skip an unreachable MCP server at startup instead of aborting
RegisterTools connected to every server eagerly and failed the whole batch if any
server could not be reached or list its tools — and the caller aborts startup with
exit 1 on that error. So a single unreachable MCP server prevented zero from
launching at all and disabled every other (working) server.
Make registration best-effort across servers but still atomic within a server:
each server's tools are validated as a unit before anything commits, and a server
that cannot be connected, returns a nameless tool, or conflicts is SKIPPED —
recorded on Runtime.Skipped(), not fatal. The caller surfaces each skip as a
warning and keeps launching. The "no dead tools" and no-partial-tool-set
guarantees are preserved.
* feat(mcp): ship keyless Firecrawl as a default web search/scrape server
Seed an enabled-by-default Firecrawl MCP server (keyless: 1,000 free credits/month,
no API key, no setup) into ResolveMCP before user/project config is merged, so web
search and scraping work out of the box for open-source users. A user can override
any field (self-hosted URL, bring-your-own-key header) or disable it entirely;
`zero mcp disable firecrawl` writes the override even though the default lives in
code rather than the config file.
The built-in web_fetch tool stays the always-local floor. Firecrawl is AGPL-3.0,
but zero only calls it over the network, so the license never reaches zero's own
code. README documents disable / self-host / bring-your-own-key / the privacy note
that keyless routes through firecrawl.dev.
Depends on the resilient-startup change: on an offline machine the default is
skipped with a warning, not fatal.
* perf(mcp): connect MCP servers concurrently with a per-server startup timeout
RegisterTools connected servers serially with a 30s handshake timeout, so startup
waited for the SUM of all servers and one slow/unreachable server (e.g. a hosted
endpoint blocked by the local network) blocked every other server AND the first
model response — often 10-30s of dead time per launch.
Connect all servers CONCURRENTLY, each bounded by a per-server ConnectTimeout
(default 8s). A server that doesn't connect+list in time is abandoned (its context
cancelled to tear down the half-open connection/subprocess) and skipped, so total
startup cost is now the slowest REACHABLE server, not the sum. The concurrent
phase does only connect+list (no shared state); validation, conflict detection,
and registration stay in a deterministic serial phase, so the outcome is identical
regardless of completion order. A kept server's context is released at Close — a
stdio subprocess is tied to its context, so it must outlive the connect.
Pairs with the resilient skip-on-failure behavior already in this branch.
* docs: rewrite AGENTS.md as a user-facing 'Extending Zero' guide
The previous AGENTS.md (a 19-line Go-specific instruction set) was replaced
with a user-facing extension guide for Zero as an open-source CLI agent
product. Covers:
- Project-level AGENTS.md (where to drop one, format, tips)
- Custom specialists (droids): scopes, manifest format, CLI
- Skills: discovery, SKILL.md format, ZERO_SKILLS_DIR override
- Hooks: lifecycle events, JSON config locations, exit code semantics
- MCP: as client (config.json mcp.servers) and as server (zero serve --mcp)
- Plugins: plugin.json manifest, install/enable/disable/remove
- Configuration locations: built-in < user < project < CLI < env
- End-to-end example: what a team commits vs what a contributor adds
- Reference links to README and docs/SPECIALISTS.md, docs/PRD.md, etc.
This positions AGENTS.md as the entry point for end users who want to
customize Zero, mirroring the role that similar files play in Claude Code
and Codex.
* docs(AGENTS): align Extending Zero with actual loader behavior + D-style features
Fix six claims that were wrong about Zero itself, document the new
upward path-walking the loader now does, and surface three roadmap
gaps in the relevant sections.
What changed in the doc:
- Section 1 (Drop a project AGENTS.md): rewrote the file table to
match the real priority order [AGENTS.md, ZERO.md, .zero/AGENTS.md]
and added the case-insensitive basename callout (git tracks
AGENTS.MD uppercase on case-sensitive filesystems).
- Section 1: documented the per-file (8 KiB) and total (32 KiB) caps
and the eager-load + restart-required caveat.
- Section 1: added the monorepo tip and the path-walking explanation
("walks from cwd up to git root, general-to-specific order").
- Section 2 (Custom specialists): added a roadmap note about the
planned /droids in-UI manager.
- Section 3 (Skills): corrected the discovery-root chain
(ZERO_SKILLS_DIR -> XDG_DATA_HOME/zero/skills -> ~/.local/share/zero/skills),
noted that project-level skills aren't supported yet, and called
out the plugin-declared-skill gap.
- Section 4 (Hooks): roadmap note about the planned /hooks in-UI
manager.
- Section 6 (Plugins): roadmap note about the planned /plugins in-UI
manager; cross-link to the skills gap.
- Frontmatter globs broadened to include *.json / *.toml / *.yaml /
*.yml, matching the actual file types Zero reads.
* feat(agent): make AGENTS.md loader case-insensitive + add path-walking
Two changes to the project guideline loader that bring Zero closer
in line with D's DynamicContextDiscovery and fix a real bug.
1. Case-insensitive basename matching. The git-tracked filename is
AGENTS.MD (uppercase MD), but the loader was looking for AGENTS.md
(lowercase md). On a case-sensitive filesystem (Linux, the WSL
filesystem, or a CI runner) this means the file was invisible to
the loader. The new findProjectContextFile uses ReadDir + ToLower
to resolve the on-disk filename and matches AGENTS.MD, Agents.md,
agents.md, etc. to the same entry. On Windows/macOS the visible
filename in the prompt label is now the actual on-disk name
(AGENTS.MD), not the lookup name.
2. Upward path-walking. The loader previously did a single
first-match-wins lookup at cwd. The new projectGuidelines walks
from cwd up to the nearest git root, finds the first matching
project context file at each level, and injects them in
general-to-specific order (git root first, cwd last). A monorepo
with a root AGENTS.md and per-service AGENTS.md files now sees
both, with the most specific rules last. This mirrors what D
does per-Read on the dynamic path, just at session start.
Cap behavior:
- Per-file cap is unchanged: 8 KiB.
- New total cap of 32 KiB across all matched files, enforced via a
rolling counter; when the budget is exhausted, further files are
dropped (and the per-file limit is the lesser of the per-file cap
and what's left of the total).
- Truncation marker is unchanged: \n… (truncated).
New helpers and tests:
- projectGuidelineDirs / projectGuidelineLabel / findProjectContextFile
/ resolveDirCaseInsensitive / findProjectGitRoot.
- 8 new tests covering case-insensitive matching, monorepo
path-walking, ZERO.md fallback, .zero/AGENTS.md fallback, total
cap truncation, and the helper functions.
Existing TestBuildSystemPromptInjectsWorkspaceContext still passes;
the label for a single-file repo is still AGENTS.md / AGENTS.MD as
appropriate to the on-disk name.
* docs(AGENTS): drop syntax that doesn't exist in the current code
Per the review on #237, the previous "Extending Zero" guide documented
several commands, flags, and conventions that the runtime does not
implement. Concretely:
- `zero plugins install` / `enable` / `disable` are not subcommands.
`runPlugins` (internal/cli/extensions.go) only dispatches
`list` / `add` / `remove`(`rm`). Replace the example with `add` and
note that enable/disable are not CLI verbs today (the plugin is
enabled by being present and disabled by being removed, or by
setting `"enabled": false` in its manifest).
- `zero specialist create ... --prompt-file api-reviewer.md` is wrong:
there is no `--prompt-file` flag. `runSpecialistCreate` takes the
prompt inline via `--prompt`. Show a `$(cat ...)` shell substitution
as the practical way to load a file, and add `zero specialist path`
to the example.
- `"Authorization": "Bearer ${env:GITHUB_TOKEN}"` implies env-var
expansion that does not exist: MCP header values are sent verbatim
(internal/mcp/permissions.go, client.go mergeProcessEnv). Use a
literal placeholder and document the three real ways to keep the
token out of the file (wrapper script, command substitution, server
reading its own env).
- Hook `args: ["${tool}", "${input}"]` is the same mistake plus a
shape mismatch: the actual hook payload (event, matcher, tool call
id, tool input/output, status) is delivered as JSON on stdin
(internal/hooks/dispatch.go `execCommandRunner`), not via `${...}`
substitution. Drop the placeholders, add a short bash handler that
reads stdin, and show the `args` field as verbatim CLI args.
- "use `zero doctor` to surface them" — the doctor backend
(internal/doctor/doctor.go) has no hook check; its checks are
runtime / config / provider / connectivity / sandbox / lsp.
Reword to point at the audit log instead.
Secondary: drop the `globs:` and `alwaysApply:` example and the
"Use globs: to scope which files the rules apply to" tip — the loader
injects the whole file (frontmatter included) as prompt text and does
not parse those keys. Add a one-liner noting the frontmatter is
preserved verbatim. Also drop the parenthetical "(droids)" on
§2 to match the "specialists" terminology the rest of the codebase
(and the CLI) actually use, and reword the §2/§4/§6 roadmaps that
promised in-UI managers via non-existent `/droids`, `/hooks`,
`/plugins` slash commands.
* feat(providers): add `zero providers detect` for local-runtime onboarding
Wire the previously-unwired provideronboarding detect/advice surface into the
CLI. `zero providers detect` probes for running local OpenAI-compatible runtimes
(Ollama, LM Studio) on their default ports and prints a no-key adopt command for
each, followed by the next-step actions (use / check / set key) for every
configured provider. Supports --json.
This connects DetectLocalRuntimes, LocalRuntimeCandidates,
DetectedLocalRuntime.SetupAction, ProviderActions, MissingCredentialAction,
SetupCommand, and ProviderState.Actions — a fully built, tested feature that had
no caller. Adds an injectable detectLocalRuntimes appDep for hermetic tests.
* feat(sandbox): add `zero sandbox check` to evaluate a decision as JSON
Wire the previously-unwired zerocommands sandbox-snapshot contract into the CLI.
`zero sandbox check <tool>` evaluates what the sandbox engine would decide for a
hypothetical tool action against the resolved policy, and emits the active plan
(policy + backend + restrictions), the decision (allow/prompt/deny with risk and
any violation), and any persistent grant matching the tool. Supports --json;
grant reasons are redacted.
This connects SandboxPlanSnapshotFromPlan, SandboxPolicySnapshotFromPolicy,
SandboxBackendSnapshotFromBackend, SandboxDecisionSnapshotFromDecision,
SandboxRiskSnapshotFromRisk, SandboxViolationSnapshotFromViolation, and
SandboxGrantMatchSnapshotFromLookup — a fully built, tested JSON contract that
had no production consumer. The existing `sandbox policy` output is unchanged.
* test(providermodelcatalog): cover model predicates and remote fetch layer
Add targeted tests for the audit-identified coverage gaps: the pure logic
(LooksLikeCodingModelID, modelMatchesProvider, defaultedOpenGatewayURL,
modelSortLabel/sortModels) and the HTTP fetch layer (fetchJSON, FetchModelsDev,
FetchOpenGateway, FetchRemote routing) via httptest. Package coverage 48.9% ->
79.5%. cmd/zero (main shim) and browser.OpenURL (exec wrapper) are intentionally
left to CI smoke — their only logic seams (openCommand) are already covered.
* fix(sandbox): address CodeRabbit review on `sandbox check`
- Pass the configured write-root scope (NewScope with AdditionalWriteRoots) into
the check engine, so its decision matches real enforcement and a stale
extra-root config surfaces here too instead of a workspace-only fallback.
- Validate --side-effect and --autonomy against their advertised value sets and
return a usage error on a typo, rather than letting the engine normalize it.
- Reword the unmatched-grant line to "no grant matched this action" — Lookup only
checks this tool/scope/autonomy, not whether any grant exists for the tool.
- Add tests: invalid-flag rejection and a matched-grant reason-redaction
regression.
* test(sandbox): make the out-of-workspace check test path OS-portable
Smoke (windows-latest) failed: "/etc/passwd" is not absolute on Windows, so the
check joined it into the workspace and returned "prompt" instead of "deny".
Build the out-of-workspace path under a separate temp dir so it is absolute and
outside the workspace on every OS.
* fix(providers): surface a clear error when a model host is unreachable
A local Ollama daemon serving a "-cloud" model answers on localhost but
returns HTTP 502 carrying an opaque proxied transport error (e.g.
`Post "https://ollama.com:443/...": net/http: TLS handshake timeout`) when it
cannot reach its own cloud backend. A direct hosted endpoint blocked by the
local network fails the same way at the transport layer. Both previously
surfaced as raw strings ("provider error:" / "provider stream error:") that
read like a zero bug and gave the user nothing to act on.
Add providerio.UpstreamUnreachable: it detects a transport-level connectivity
failure (TLS handshake timeout, context deadline exceeded, connection refused,
no such host, network unreachable, i/o timeout) bound to a concrete host and
rewrites it into an actionable message naming the host and explaining the
request never reached the model. It requires both a marker and a host, so the
agent's own request-deadline cancellations are left untouched.
Wire it into the OpenAI-compatible adapter's gateway-error and transport-error
paths, covering both the local daemon proxy and direct hosted providers;
secrets are still redacted. Covered by unit tests over the real error shapes
and an end-to-end 502 adapter test.
* fix(providers): don't humanize a caller-context timeout as an upstream outage
Address CodeRabbit review: in the OpenAI-compatible adapter's transport-error
path, a caller-driven cancel/deadline surfaces as an error string containing
"context deadline exceeded" plus the request host, which UpstreamUnreachable
would mislabel as "upstream unreachable" — the host is reachable; the caller's
clock ran out. Check the parent ctx.Err() first and surface it verbatim, so only
genuine connect failures (ctx still live) are humanized.
Adds a deadline regression test; the existing cancel test used Cancel, whose
"context canceled" is not a marker, so it never covered this path.
* feat(tui): Ctrl+T cycle + divider display for reasoning effort
Reasoning effort was already fully wired (per-model supported lists,
DefaultReasoningEffort, the EffectiveReasoningEffort clamp, /effort and
/mode), but it was invisible in the UI and reachable only by typing a
command. Add the opencode-style one-key fast path:
- Ctrl+T cycles the active model's effort ring (auto -> low -> medium ->
high -> auto), gated by the same modal condition shift+tab uses and a
silent no-op on models with no effort controls.
- The composer divider shows the active effort in the brand lime when
set; the segment is omitted on "auto", so its presence/absence is
itself the auto-state feedback.
Zero per-frame registry lookups: the cycle runs only on the rare
keypress and the divider branches purely on m.reasoningEffort != ""
(DefaultRegistry rebuilds the whole catalog on every call, so it must
never be touched from the render path).
Tests cover every cycle branch (auto->first, mid-ring advance,
last->auto wrap, unknown->auto, no-op on unsupported model) and the
divider show/omit paths.
Co-Authored-By: Claude <[email protected]>
* fix(tui): address CodeRabbit review on Ctrl+T effort cycle
- Block Ctrl+T effort cycling inside the detailed transcript (matches the
shift+tab gate) so state changes don't happen while the user is reading
a frozen view.
- Use a guaranteed-unplaceable sentinel in the unknown-effort reset test
instead of ReasoningEffortMinimal, so the test stays correct if Minimal
becomes a supported ring level later.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* fix(tui): extract shared noBlockingModal() for shortcut gates
The 7-term modal gate was duplicated verbatim between the shift+tab and
Ctrl+T cases. Fold it into a single noBlockingModal() helper so the two
shortcuts can't drift the day a new modal is added to one and not the
other. Pure refactor, no behavior change.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* fix(tui): hide divider effort segment on models without effort controls
The divider showed whatever m.reasoningEffort was set to, even when the
active model had no effort controls (e.g. gpt-4.1). Switching from
claude-sonnet-4.5 (low/medium/high) to gpt-4.1 would leave a stale
"high" floating in the divider even though it has no effect.
Make the segment selective: only render it when the active model exposes
availableReasoningEfforts, matching the same appearing/disappearing
state feedback the divider already uses for the auto case. The lookup
hits a hard-coded static catalog, not a per-frame rebuild, so it's
safe on the render path.
handleModelCommand already resets an unsupported effort preference on
model switch, so no changes are needed there.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* fix(tui): drop model-aware effort gate in divider; /effort picker already selective
The previous commit added `&& len(m.availableReasoningEfforts()) > 0` to
the divider's effort-segment gate so a stale value couldn't be shown
after switching to a model without effort controls. Reverting that
change: the underlying state mutation is already gated elsewhere
(handleModelCommand clears an unsupported preference on switch, and the
/effort picker only opens for models that actually expose effort
controls), so the divider doesn't need its own check.
The /effort picker (newEffortPicker, pickerEffort kind) is already
fully wired and tested — /effort with no args on a supported model
opens the picker; on a model with no effort controls, the text path
returns "Active model does not expose reasoning effort controls."
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* fix(tui): open effort picker even when active model has no effort controls
Previously newEffortPicker returned nil for any model not in the hard-coded
catalog (e.g. ollama-cloud providers serving glm-5.1 or similar). The
case commandEffort dispatch fell through to handleEffortCommand, which
rendered a grey "Effort / status: warning / available: none for active
model" status card -- the very static panel users were reporting.
Now newEffortPicker always returns a picker: a single "auto" option when
the active model exposes no effort controls, or "auto" plus the model's
known efforts otherwise. handleEffortCommand already returns the "Active
model does not expose reasoning effort controls" message if any
non-auto value is later set, so no other gate is needed.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* feat(tui): render description hint below composer for single slash match
Claude-code-style: when the user types a slash command that resolves to
exactly one command in the autocomplete palette, surface that command's
description on a muted line just below the composer box. Multiple
matches keep the existing dropdown as the right affordance; once the
user starts typing arguments the hint clears; the @file palette keeps
its own rows and the hint stays scoped to slash commands.
The inline argument hint ([low|medium|high|auto]) still renders inside
the composer box after the slash, so both UIs coexist.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* feat(tui): render /effort output as a polished command card
The /effort command used to push its status text through renderCommandOutput,
which renders as a thin grey panel (faint text on panel background, neutral
border). Replace that path with renderCommandCardTranscript so the result
renders the same way as /tools, /mcp, /permissions, etc.: a lime-bordered
titled card with a key/value "State" section and a footer of next-step actions.
Three call sites updated:
- handleEffortCommand("auto") -> sets effort to auto, shows confirmation card
- handleEffortCommand(<value>) -> sets effort, shows confirmation card
- handleEffortCommand(<bad>) -> unknown / unsupported / not exposed -> shows
the same card with the relevant detail line
effortText() (the no-arg / list / picker-dismiss path) follows the same
pattern. The "status: warning" line and grey panel are gone; the warning
case instead keeps the lime accent and surfaces "no reasoning controls on
this model" in the summary, matching the rest of the TUI's visual language.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
---------
Co-authored-by: Claude <[email protected]>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* tui: interactive /resume session picker + resume fixes
- /resume (no arg) now opens an interactive picker like /model and /provider:
one row per resumable session showing the title and session id (+ relative
age); arrow to highlight, type to filter, enter to load. /resume <id> and
/resume latest still resolve directly.
- Resume now loads the rehydrated (compaction-aware) event view instead of the
raw log, so a resumed session honors a prior /compact — matching the CLI's
`zero exec --resume` and the in-TUI /compact reload. Falls back to raw on a
rehydrate error.
- The resume summary reports the model/provider the continuation will actually
use (the active ones), noting the session's recorded model/provider when it
differs, instead of implying a switch that doesn't happen.
- Drop the dead, self-contradictory resumeText(arg) branch (it was unreachable
and its hint was circular); resumeText is now the no-session/error fallback.
Tests: session picker opens with title+id rows and hydrates on selection;
resume honors prior compaction (rehydrated, not raw events).
* tui: lead /resume picker rows with a precise timestamp
Same-titled sessions (e.g. the same first prompt run several times) looked like
duplicates because the row showed only the identical title plus a long id that
truncated the age away. Lead each row's label with a precise local timestamp
(HH:MM:SS today, "Jan _2 15:04" earlier this year, else the date) so distinct
sessions are obviously distinct; the full id stays as the right-aligned meta and
is what selection resolves. sessionWhen parses the RFC3339 UpdatedAt.
* tui: hide empty/no-output sessions from the /resume picker
Most of a heavily-retried prompt's sessions are empty failed runs — just the
prompt plus the no-output guardrail stop ("Agent stopped … with no output …"),
with no assistant text and no tool calls. They have nothing to resume and flood
the picker with identical-looking rows. Skip any session with no resumable
content (no tool call/result and no real non-user message) from the picker; they
stay on disk and are never deleted. Adds agent.IsNoProgressStop to recognize the
stop message from a recorded event, and a test that the picker hides an empty
run while keeping a real one.
* feat(tui): model-generated session titles (auto + /retitle backfill)
Sessions were titled from their first user message, so prompts that
started the same way (or empty/failed runs) looked identical in the
/resume picker. Generate a concise, specific title with the active
provider instead.
- sessions.Store.UpdateTitle(id, title): rewrites only Title under the
per-session lock, re-reading the latest metadata first; leaves
UpdatedAt untouched (a retitle is not activity and must not reorder
the resumable list), rejects a blank title, and no-ops an unchanged
one.
- Title generator: a bounded digest of the conversation (user/assistant
text + tool names, per-message and total caps, skipping the no-output
guardrail stop) is sent as a one-shot completion; the response is
cleaned (first content line, quotes/markup/"Title:" label stripped,
word- and rune-capped) before storing.
- Auto-title going forward: after a successful turn, a session still
carrying its default first-message title gets a title in the
background, at most once per session.
- /retitle: backfills existing resumable sessions that still have a
first-message title, one at a time, skipping empty/failed runs and
already-named sessions, with kickoff and completion status lines.
Generation runs off the Update goroutine; failures are non-fatal (the
first-message title simply stays). Adds store + TUI unit tests
(UpdateTitle semantics, digest/cleaning, auto-title one-shot, backfill
candidate selection). gofmt/vet/build(host+linux+windows)/test -race/
staticcheck all green.
* address review: tighten no-progress detection, fix fallback errors + nil guard
Three review findings on the /resume work:
- IsNoProgressStop matched any content CONTAINING the no-output marker
substring (strings.Contains), so a legitimate assistant/tool message that
quoted the marker could be misread as a failed empty run — wrongly hiding a
real session from the /resume picker and skipping its title generation. Now
it requires the full answer structure (prefix + marker + suffix); a quote no
longer matches.
- resumeEvents returned the earlier rehydration error when the raw ReadEvents
fallback itself failed, masking the actual fallback failure. Returns rawErr.
- resumeText dereferenced m.sessionStore unconditionally; a nil store (fallback/
test paths) would panic. Now renders a safe "store unavailable" message.
(newSessionPicker already guarded nil.)
Tests: IsNoProgressStop accepts the real answer and rejects a quoting message /
bare marker / prefix- or suffix-only; resumeText with a nil store returns the
safe message instead of panicking. Full gate green.
* address review (Vasanth): structural no-progress check, retryable auto-title, nil-store guard, deep resume assertion
- guardrails: IsNoProgressStop now matches the exact structure noOutputStopAnswer
emits — prefix + "<int> turns " + marker + " " + suffix — instead of
prefix && contains(marker) && suffix. A genuine message that merely quotes the
marker amid other prose is no longer misclassified as a failed empty run (which
would wrongly hide a real session from /resume and skip its title generation).
Adds the hostile-input regression cases (quoted marker, text between
marker/suffix, non-integer turn count).
- session_title: release the optimistic titledSessions gate when a generation
FAILS (provider error, empty title, store write error) so a later turn or
/retitle can retry; success keeps the one-shot gate. Kept the optimistic mark
(rather than mark-on-success) to preserve the no-double-fire guarantee while a
generation is in flight.
- session_title: guard maybeAutoTitleActiveSession against a nil sessionStore
(the title cmd calls store.UpdateTitle) — mirrors the resume nil-store guards.
- model_test: assert resumed sessionEvents with reflect.DeepEqual against the
rehydrated context, not just slice length, so a reordered/substituted-but-
same-length regression is caught.
* fix(tui): right-click pastes the clipboard directly (no menu)
A right-click now pastes the OS clipboard straight into the focused input —
composer, provider/MCP wizard, or setup — with no context menu, mirroring the
bracketed-paste behavior. Keyboard and selection copy/paste are unchanged.
- clipboard.go: pasteFromClipboardCmd reads the clipboard off the Update
goroutine; routePaste is the shared router for both bracketed paste (PasteMsg)
and right-click paste (clipboardReadMsg), so the two behave identically.
- mouse.go: a right-click returns pasteFromClipboardCmd; input_compat.go adds the
mouseRightPress helper.
- model.go: PasteMsg now routes through routePaste; clipboardReadMsg inserts on
success or shows a brief "Paste failed" status when the clipboard can't be read
(e.g. no clipboard utility on a remote session).
Tests: right-click returns the read command (chat + wizard), clipboard content
routes to the focused field, read errors surface a status, empty is a no-op.
* fix(tui): right-click paste in setup mode + scope the paste test
Setup mode routes mouse events to handleSetupMouse, which only handled
left-click and wheel — right-clicks were swallowed, so paste never fired
during onboarding. Add the same mouseRightPress -> pasteFromClipboardCmd
branch handleMouse uses; routePaste already targets the setup field.
Test: assert the right-click contract at handleMouse/handleSetupMouse
scope instead of via Update (which batches unrelated commands and masks a
regression), and cover the new setup-mode path.
Addresses review on #231: mouse.go (setup paste, critical) and
clipboard_test.go (weak Update-level assertion, minor).
The permission prompt now has a moving cursor and clickable options, on top of
the existing a/y/d hotkeys (which still resolve directly):
- ↑/↓ and Tab/Shift+Tab move the highlight across allow once / always / deny
(resting default: allow once); Enter confirms the highlighted option.
- each option is rendered on its own row, and a left-click on a row resolves
that choice directly.
- permission_prompt.go: permissionOptions + cursor move/clamp/confirm helpers.
- model.go: pendingPermissionPrompt gains a cursor; Enter confirms it, ↑/↓/Tab
move it; a/y/d are unchanged.
- rendering.go: renderFocusedPermissionPrompt renders per-option rows with the
cursor highlighted (▸ + reverse label) and returns each option's card-relative
Y offset so the caller can register clickable rows.
- transcript_selection.go: transcriptSelectableLine gains permOption/permChoice;
the pending-permission item registers each option row as clickable, and a
left-click on one resolves the permission.
Tests: cursor default/move/wrap, Enter confirms the highlight, hotkeys still
resolve directly, render emits highlighted clickable offsets. Existing card/
submit-block tests updated for the new per-option layout + Enter-confirms.
Add a bounded per-model transcript body height cache keyed by the existing row render fingerprint, then use measured spans to render only the visible alt-screen item window instead of building the full body string every frame.
Mouse hit testing now uses the same visible item layout, while inline rendering and selection copy keep the full layout path for behavior parity.
Tests cover height cache reuse, visible-only item rendering, absolute selectable body coordinates, and output parity with the previous full-layout renderer.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: git diff --check
Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."
* Centralize TUI frame layout
Add a reusable transcript frame snapshot with terminal regions for header, body, footer, composer, and status lines. Mouse composer hit testing, overlay positioning, and transcript viewport mapping now consume the same frame geometry instead of recalculating pinned-header and clipped-footer offsets separately.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
* Route TUI mouse hits through local regions
Return overlay hit coordinates relative to the overlay block and derive transcript hit rows from the shared body rect. This keeps the current scroll behavior while making mouse handling consume component-local geometry from the frame layout.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
* Cover TUI frame geometry
Add focused tests for the shared frame snapshot: pinned header/body/footer regions, tiny-terminal footer clipping, composer mouse hit regions, overlay centering, and transcript mouse row mapping through the body region.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
* Extract transcript viewport line window
Introduce a line-based transcript viewport adapter that preserves Zero's existing bottom-anchored scroll offset. Render slicing, chat scroll metrics, scrollChat, and transcript selection viewport mapping now share the same clamping and visible-window calculation.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
* Build transcript body from layout items
Introduce a compatibility transcript body item layer for title, empty state, separators, transcript rows, pending prompts, streaming interim content, and spec review prompts. The body still renders to the same line/selectable output, but layout now records item spans and local selectable offsets for the next visible-range step.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."
* Address TUI layout review findings
Clamp overlay mouse hit regions to the visible transcript body and block transcript selection while the MCP add wizard is open. Strengthen layout and viewport edge-case tests, including full composer containment and degenerate dimensions.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."
* Make MCP wizard overlay test hit transcript text
CodeRabbit caught that the MCP add wizard blocking test clicked at x=0, which could miss selectable transcript text and pass without proving the overlay guard. This updates the test helper to return both textStart and y, then clicks that known selectable point before asserting the wizard blocks transcript selection behind the modal.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: git diff --check
Tested: GOCACHE=/tmp/zero-go-cache go test ./...
* Route transcript windows through body layout
Thread the structured transcriptBodyLayout through alt-screen rendering, scroll metrics, and transcript mouse lookup instead of repeatedly joining and splitting the rendered body string. This preserves the current line-based viewport behavior while making the body layout the source of truth for visible windows and selectable line lookup.
This is an incremental step toward visible-item rendering and item-level caching; it does not change the transcript visual output or input flow.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: git diff --check
Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."
* Reuse row cache for selectable transcript rows
Selectable user and assistant rows need selectable metadata for mouse interactions, but when no transcript selection is active their rendered strings are identical to the normal row render path. Route those rendered strings through renderRow so stable rows reuse the existing row cache while selection-active frames still render highlight-aware output.
This keeps transcript behavior unchanged and adds a regression test that verifies selectable assistant rows produce stable metadata while hitting the render cache on the second render.
Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: git diff --check
Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."
* feat(tui): rotating "working word" for the liveness spinner
The assistant interim block used to show a static "working…" placeholder
while the model was generating. That felt dead on long runs. Replace it
with a rotating gerund tuned for the Gitlawb / Zero brand: project-name
verbs (gitlawbmaxxing, zeroling, …) weighted 1.5x so the brand word
anchors the rotation, plus feature-as-verbs, gen-Z crowd-pleasers, and
a few Claude-Code originals for variety.
The rotation is a tiny ring buffer (working_words.go) advanced on each
spinner.Tick so the verb and the glyph stay in lockstep. Brand words
are duplicated in the ring, not randomized, so the cadence is
deterministic and the first frame is always "gitlawbmaxxing" — easy
to test, easy to reason about, and the first thing a long-running user
sees is the project name. Reset() rewinds the ring when a new run
starts (model.go: submit, spec_mode.go: spec approval → impl).
Lowercase throughout to brand-differentiate from Claude Code's Title
Case defaults. No config wiring (hardcoded list); can be a follow-up
if users ask for customisation.
Tests: 7 new tests in working_words_test.go (first frame, tick,
wrap, reset, brand weighting, base-list integrity, nil safety) plus
the existing interim-block test updated to assert "gitlawbmaxxing"
as the new default. Full internal/tui/ suite green in 2.7s.
* Address review feedback on working-words PR
Three review threads to close out:
**Cadence (~12 words/sec → ~1/sec)**
@anandh8x and @gnanam1990 both flagged the on-every-tick advance as
flicker. The spinner glyph should still spin fast (it conveys liveness),
but the word needs to be readable. Solution: the model owns a
`workingVerbTicks` counter and gates the advance at
`WorkingWordsStepEvery = 12` spinner ticks (~960ms at 80ms cadence).
The `workingWords` ring stays a dumb "advance one slot" type so unit
tests can tick it rapidly without waiting for a real spinner. New test
`TestWorkingVerbAdvancesOnStepEverySpinnerTicks` locks the cadence
behaviour in.
**Brand-name "inconsistency" (intentional)**
@gnanam1990 noted the code says "Gitlawb / OpenFable" while the repo,
binary, and PR description say "Zero". Author's call: the working-words
PR ships the rebrand-ahead-of-the-rebrand verbs on purpose. Kept the
existing strings (`gitlawbmaxxing`, `openfablemaxxing`, …) and tidied
the comments so the trade-off is explicit ("a planned rename; the
spinner brand is intentionally ahead of the rest of the project").
Also fixed a stale `model.go` comment that still said "zeroling"
(superseded by `openfablemaxxing` in the previous force-push).
**Taste call (pilled / aura-farming / maxxing)**
Both reviewers flagged these as user-facing with no config escape.
Author's call: ship as-is, with a comment on `vibeVerbs` calling out
the trade-off explicitly so the next reader can see it was a
deliberate decision rather than an oversight.
**weightedRing comment vs implementation**
CodeRabbit and @gnanam1990 both caught that the comment claimed brand
verbs are "interleaved" but the implementation appends them as a
sequential block at the end of the ring. Fixed the comment to match
the implementation and added a note on how to implement true
interleaving if it's ever wanted.
**CodeRabbit nits**
- `Reset()` now also checks `len(w.weighted) == 0` for consistency
with `Tick()` and `Current()`.
- `weightedRing` body simplified to `append(ring, brandVerbs...)`
instead of the conditional loop (same result, clearer intent).
**Competitor naming in comments**
@gnanam1990 asked to trim the few "Claude Code" mentions in package
comments. Replaced the explicit "Claude Code" references with neutral
phrasing ("upstream Claude-Code-compatible spinners", "a few classic
gerunds") that keeps the design rationale without naming the
competitor. The user-facing verb list is untouched.
Rebased onto current main (1e25fde) and resolved the spec_mode.go
merge conflict by keeping both the fade-seed and the working-verb
reset on the spec-impl path.
Build + vet + gofmt clean. Full internal/tui/ test suite green except
for the pre-existing env-fragile `TestModelCommandPersistsSelectedModelToUserConfig`
from PR #220 (fails here only because of local env, not this PR).
Co-Authored-By: Claude <[email protected]>
* Fix cadence test post-advance window (CodeRabbit)
The final block of `TestWorkingVerbAdvancesOnStepEverySpinnerTicks`
had two issues caught by CodeRabbit:
1. A `_ = m` no-op statement that asserted nothing.
2. A tautological check — it compared the post-advance verb against
the *original* `before` value, which is always going to differ
after the previous block already advanced.
The real intent was to verify that the cadence counter *resets* after
each advance (rather than carrying over), so a follow-up advance
lands one full `WorkingWordsStepEvery` window later — not one window
early due to a missed reset.
Rewrote the trailing block to:
- Capture the post-advance value explicitly (`afterAdvance`).
- Drive another (WorkingWordsStepEvery - 1) ticks after the advance.
- Assert the verb stayed at `afterAdvance` — catching the "counter
doesn't reset" regression the original test was meant to guard
against.
This now meaningfully tests three regressions:
- Gate removed (verb advances every tick) — caught by the first block.
- Gate set to 1 (same effect) — caught by the first block.
- Counter doesn't reset after advance (verb advances too eagerly) —
caught by the new third block.
Updated the test docstring to call out all three failure modes.
Co-Authored-By: Claude <[email protected]>
---------
Co-authored-by: Claude <[email protected]>
* feat(tui): age-based fade for streaming assistant text
While the assistant reply streams in, the latest line appears in the
brand lime (\#caff3f) and gradually settles to the standard off-white
ink over ~1.2 seconds, line by line. The effect mirrors the lime
spinner glyph in the liveness block: a quiet visual signal that the
model is still emitting, not a stylized neon glow.
Design choices (per line, not per rune):
* 12-step palette interpolated in linear sRGB from colorAccent to
colorInk, pre-computed at init() so per-frame lookups are a
struct-field access, not a hex parse.
* 1.2s fade duration; 150ms tick (independent of the 80ms spinner).
* Hard stop at stream end: the agentResponseMsg handler flips
fadeActive = false and the next streamingFadeTickMsg short-circuits.
* Reset on terminal resize (visual line count changes) and on cancel.
* The last visual line uses lastStreamActivity (always freshest) so
the user can see exactly where the model is currently typing.
Test fixtures that pre-populate m.streamingText without going through
the agentTextMsg branch keep rendering identically to before — the
fade code is defensive when lineAges is nil.
The spec-impl run (which calls runAgent directly, bypassing the
normal launchPrompt path) seeds the fade state explicitly so its
streaming text fades the same way regular runs do.
* fix(tui): address CodeRabbit review on streaming-fade PR
Four findings from CodeRabbit's review of #223, applied:
1. model.go agentTextMsg: schedule streamingFadeTick only on the
inactive->active transition instead of on every delta. The tick
is self-perpetuating (the streamingFadeTickMsg case schedules the
next one), so re-scheduling on every incoming delta is redundant
and would create multiple concurrent tick chains during long
streams.
2. model.go agentResponseMsg: call m.resetStreamingFade() instead of
setting only fadeActive = false. Leaving lineAges and
lastStreamActivity populated allowed stale age data to carry over
to the next turn and made lineAges grow indefinitely across many
runs.
3. streaming_fade.go streamingLineBornAt: when the visual index is
beyond the lineAges slice (a wrapped middle line), clamp to the
last known logical age instead of returning time.Time{}. Wrapped
continuation lines now keep fading in step with their siblings
instead of snapping to base ink mid-paragraph.
4. streaming_fade_test.go TestStreamingFadePaletteHandlesBadHex:
assert that every bucket's foreground is the unparseable base
string instead of asserting on len(palette), which is a constant
for a fixed-size array (the original assertion passed regardless
of behavior and triggered SA4006). Also split the
OutOfRangeReturnsZero test into ClampsToLast (for the new clamp
behavior) and EmptyLineAgesReturnsZero (for the truly-empty case
that should still return zero).
---------
Co-authored-by: Claude <[email protected]>
* Migrate TUI to Bubble Tea v2
Move the TUI stack to charm.land Bubble Tea, Bubbles, and Lip Gloss v2, including key, paste, mouse, and view handling changes for the v2 APIs.
Add compatibility helpers for the new input event shapes and update TUI tests accordingly.
Refresh related Go dependencies and make the PDF truncation fixture deterministic across poppler and pure-Go extraction.
Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/tui
Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/imageinput -count=1 -v
Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./...
* Address provider wizard OAuth review feedback
Remove the unused mouseButton compatibility helper flagged by lint.
Correlate provider wizard OAuth and device-code async results with the active provider and attempt id so stale results from abandoned attempts are ignored.
Add regression coverage for stale browser OAuth and stale device-code results.
Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/tui -run 'ProviderWizard.*OAuth|ProviderWizard.*Device|ApplyProviderWizardOAuth|RenderCredentialStepShowsOAuth' -count=1 -v
Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/tui
Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./...
Tested: git diff --check
* Smooth TUI mouse scrolling
* providers: authenticate model calls with an OAuth login when present
Foundation for account/subscription login: when a user is logged in via
`zero auth login <name>`, the OpenAI and Anthropic providers now authenticate
model requests with that OAuth bearer token, falling back to the API key when
there is no login. Auto-select, per the agreed design — no config needed.
- providerio.SendWithAuthRetry: issues a request with an OAuth bearer (from a
TokenResolver) or the API key, and retries ONCE with a force-refreshed token
after a 401. A 401 means the server rejected the request without processing a
completion, so replay-after-refresh is safe — the same no-duplicate-work basis
SendWithRetry uses for 429/503. Never sends API key and bearer together.
- openai + anthropic providers take an optional OAuthResolver and use
SendWithAuthRetry. When OAuth is active the credential goes on Authorization
(so Anthropic's x-api-key is correctly omitted). Gemini is unchanged.
- providers.New threads Options.OAuthResolver to those two providers.
- cli.oauthResolverForProfile builds the resolver from the unified oauth store:
it attaches a resolver ONLY when a login already exists for the provider
name, so API-key users incur no per-request store lookups. The resolver uses
Manager.GetFresh (refresh near expiry) and Manager.Handle401 (force refresh on
the 401 retry).
- oauth: added ErrNoToken sentinel so "not logged in" is distinguishable from a
real refresh failure (login removed mid-session → clean API-key fallback).
Tests (httptest): API-key fallback (nil resolver / ok=false), bearer wins +
never-both, 401→force-refresh→retry, stops after one retry, resolver error
surfaced. Local run check (real binary + mock OAuth + mock OpenAI-compatible LLM
requiring the bearer): with a login, `zero exec` sends Bearer <oauth-token> and
gets a completion; without a login it falls back to the (wrong) API key and the
mock returns 401 — proving the OAuth path.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
* providers: add ChatGPT-subscription-via-proxy preset + OAuth/subscriptions docs
Follows verified research (cross-checked across ~30 sources) into using ChatGPT
and Claude subscriptions: a subscription OAuth token does NOT work as a standard
bearer on the public APIs. OpenAI's token only works against the Cloudflare-gated
ChatGPT backend (headless clients get cf-mitigated 403); Anthropic rejects
third-party subscription OAuth, requires Claude-Code identity spoofing, 400s on
tool calls (Max overage lane), and is contractually banned with active
enforcement. So zero does NOT call those backends or spoof those clients.
Instead, the supported pattern is a local proxy that holds the subscription
session and exposes a clean OpenAI/Anthropic-compatible endpoint on 127.0.0.1;
zero points at it (it already supports custom base URLs, and C1 supplies OAuth
bearer for gateways that issue standard tokens).
- providercatalog: add `chatgpt-proxy` ("ChatGPT (local OAuth proxy)") — an
OpenAI-compatible, local, no-API-key preset defaulting to the established
proxy port (http://localhost:10531/v1), base URL overridable. Claude uses the
existing `custom-anthropic-compatible` entry (no canonical Claude-proxy port
to hardcode — not invented).
- docs/oauth-subscriptions.md: full recipe — built-in OAuth login (C1), the
verified reason subscriptions need a proxy, copy-paste config for ChatGPT and
Claude via a local proxy, and the sanctioned alternatives (API key; spawning
the real `claude` CLI for Anthropic subscription automation).
Local run check: a config using `catalogID: chatgpt-proxy` (base URL pointed at a
mock OpenAI-compatible proxy) routes `zero exec` through the proxy with no auth
header and returns a completion.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(clean)/govulncheck(0)/
deadcode(no new) all pass. No new module deps.
* provideroauth: OpenRouter browser PKCE login (mints an API key) + `zero auth openrouter`
Adds the cleanest real OAuth login surfaced by the provider survey: OpenRouter's
loopback PKCE flow. It needs no client_id (public), uses S256, and the exchange
mints a normal OpenRouter API key — so no token-spoofing or vendor-backend gating
is involved (unlike ChatGPT/Claude subscriptions).
- internal/provideroauth.OpenRouterLogin: binds a 127.0.0.1 loopback, opens/prints
https://openrouter.ai/auth?callback_url=…&code_challenge=…&code_challenge_method=S256,
captures the code, and POSTs it + the PKCE verifier to
/api/v1/auth/keys → {key}. PKCE binds the code, so no separate CSRF state is
needed. Injectable base URL / HTTP client / browser-opener for tests.
- cli: `zero auth openrouter` runs the flow and prints the minted key with usage
guidance. ZERO_OPENROUTER_BASE_URL overrides the endpoint (self-hosted/tests).
Tests: full flow over httptest (mint, missing-code, non-2xx exchange, empty key,
browser-error). Local run check via the real binary against a mock: the command
emits the correct PKCE authorize URL, the loopback callback is captured, and the
key is minted end-to-end.
First of the in-wizard OAuth providers; the wizard "Log in with OAuth" option
(next commit) calls this same flow and saves the key to the profile.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass. No new module deps.
* oauth: built-in provider presets + xAI (Grok) preset
Adds an overridable preset layer to the env-driven OAuth registry so well-known
providers work without per-field env configuration. ResolveConfig now seeds each
field from a baked-in preset and lets the matching ZERO_OAUTH_<NAME>_* env var
override it (env wins); flow falls back to the preset then loopback.
First preset: xAI (Grok) — standard OIDC, PUBLIC client (no secret), loopback +
device. The access token is accepted directly as a bearer on api.x.ai/v1 (an
OpenAI-compatible endpoint), so the existing C1 wiring uses it for an xai provider
profile — no header/identity spoofing. `zero auth login xai` now works out of the
box. CAVEATS (documented in code + help): the client_id is a public Grok-CLI
client not formally documented by xAI (may change; override via env), and use
requires a SuperGrok / X Premium+ subscription.
Tests: preset resolution, env-overrides-preset (per field + flow), and that a
provider with neither preset nor env still errors. Local run check: `zero auth
login xai --device` (endpoints pointed at a mock, client_id NOT overridden) logs
in and the mock confirms the preset client_id b1a00492-… was sent.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(no new)/govulncheck(0)/
deadcode(no new) all pass. No new module deps.
* tui: in-wizard "Log in with OAuth" option (OpenRouter) + browser opener
Adds the credential-step OAuth login the setup wizard was missing. For a provider
that supports a usable browser OAuth flow (currently OpenRouter), the credential
screen now offers "ctrl+o to log in with OAuth in the browser (no key needed)"
alongside pasting a key.
Flow: ctrl+o → the wizard marks itself oauthPending and dispatches an async
tea.Cmd that runs provideroauth.OpenRouterLogin (opens the browser via the new
internal/browser opener, captures the loopback callback, mints a key). While it
runs the step shows "Opening your browser… (or run: zero auth openrouter)";
Esc cancels. On success the minted key fills the credential and the wizard
advances to model selection; on failure the redacted error is shown and the user
can retry or paste a key.
- internal/browser: cross-platform OpenURL (open / xdg-open / rundll32) with a
pure, unit-tested per-OS command builder.
- provider_wizard: oauthPending/oauthErr state, providerWizardSupportsOAuth
(OpenRouter only — ChatGPT/Claude use the proxy preset, others paste a key),
the async cmd + result msg, and credential-step render/keys.
- model.Update routes the OAuth result message.
Tests: openCommand per-OS; supportsOAuth (openrouter yes / openai no); ctrl+o
starts OAuth for OpenRouter and is a no-op otherwise; apply-success advances +
applies the key; apply-error stays + surfaces the message; render shows the hint
and the pending state. The interactive browser flow itself is the same one C3
verified end-to-end against a mock.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new — the
SA1019 mouse nits are pre-existing on origin/main)/govulncheck(0)/deadcode(no new).
No new module deps.
* docs: cover the built-in OpenRouter + xAI OAuth logins
Document the two turnkey OAuth providers added in this branch (OpenRouter browser
key-mint via `zero auth openrouter` / wizard ctrl+o; xAI via `zero auth login
xai`), alongside the existing subscription-proxy and custom-provider guidance.
* tui: method-first provider setup — a prominent "Sign in with OAuth" path
Reworks `/provider` setup to lead with a connect-method chooser, modeled on Codex
/ Claude Code / gemini-cli (verified via a UX survey), so OAuth is a first-class,
separate option instead of a key buried at the credential step.
New first step "How do you want to connect?":
❯ Sign in with OAuth (default when any OAuth provider exists)
One-click browser login, no API key to copy (OpenRouter, xAI).
Paste an API key / browse providers
Any of 20+ providers, a local model, or a subscription via proxy.
- OAuth path → a dedicated list of ONLY OAuth-capable providers
(providercatalog.OAuthProviders()), each with a mode badge; selecting one
runs the browser login (OpenRouter mints a key; xAI/preset providers store a
refreshable token via the engine) and jumps straight to model selection,
skipping the key/endpoint steps. A waiting screen shows progress + a CLI
fallback; Esc cancels; errors are redacted and retryable.
- Browse path → the existing full catalog → key/endpoint/model, unchanged.
- ChatGPT/Claude are NOT offered as in-app OAuth (they can't work) — they remain
reachable as the proxy/custom providers under "browse".
- providercatalog: Descriptor gains OAuth/OAuthMintsKey/OAuthDeviceFlow; openrouter
+ xai are flagged; OAuthProviders() is the single source of truth.
- wizard: new method step + OAuth provider list + per-provider OAuth dispatch
(reuses provideroauth.OpenRouterLogin and the oauth engine login), waiting/
render/keys/step-line/retreat all updated; mouse + keys ignored while a login
is in flight.
Tests: catalog OAuth flags + OAuthProviders(); method chooser OAuth vs browse
paths; OAuth dispatch from the list; retreat→method; render shows the chooser +
provider list; existing wizard tests updated for the new first step. All tui tests
pass under -race.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(no new)/govulncheck(0)/
deadcode(no new) all pass. No new module deps.
* docs: document the /provider "Sign in with OAuth" method chooser
* tui: mirror the "Sign in with OAuth" method chooser in first-run onboarding
First-run setup (onboarding.go) was a separate flow that still showed its bare
provider list. Add the same connect-method chooser so new users get the prominent
OAuth path too, keeping /provider and first-run as one mental model.
- New setupStageMethod (after Welcome): "How do you want to connect?" with
"Sign in with OAuth" (default) and "Paste an API key / browse providers".
- OAuth path filters the provider list to OAuth-capable providers, runs the same
browser login (OpenRouter mints a key → captured as the setup credential; xAI
stores a refreshable token and the profile is named after the provider so the
runtime resolver attaches the bearer), shows a waiting screen, then jumps to
model selection (skipping endpoint/name/credentials). Esc cancels; errors are
redacted.
- Browse path is the existing flow, unchanged. setupStages()/navigation/back/
progress/footer/mouse all updated; OAuth result routed via model.Update.
Tests: method chooser OAuth path (OAuth-only list + retreat clears it) and
apply-OAuth-success advances to model with the minted key; existing onboarding
tests updated for the new step (pressSetupContinue transparently skips it). All
tui tests pass under -race.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(no new)/govulncheck(0)/
deadcode(no new) all pass. No new module deps.
* catalog+tui: show ChatGPT/Claude in the OAuth chooser as local-proxy entries
The "Sign in with OAuth" chooser listed only the real-OAuth providers (OpenRouter,
xAI), so ChatGPT and Claude looked missing. They can't do real in-app OAuth — a
subscription token is Cloudflare-gated (ChatGPT) / ToS-restricted for third-party
use (Claude) — so instead of hiding them or faking a login, list them honestly as
"subscription · needs a local proxy" entries that route into proxy setup.
- catalog: new Descriptor.OAuthProxy flag + OAuthProxyProviders(); mark
chatgpt-proxy and a new claude-proxy (local Anthropic-compatible, keyless)
entry. localAnthropic() helper. Proxy entries are not OAuth (no fake login).
- wizard + onboarding: the OAuth list now appends the proxy entries after the
real OAuth providers (catalog order keeps real ones first). Selecting a proxy
entry leaves OAuth mode and drops into the normal browse flow with the proxy
provider selected and its default URL/model pre-filled, so the user just
points Zero at the local proxy they run. No browser login is launched.
- providerWizardNeedsEndpoint now true for proxy entries (the local port
varies); faint badge "subscription · needs a local proxy".
- docs/oauth-subscriptions.md: chooser now shows the four entries and explains
the proxy routing.
Tests: catalog classification (OAuthProviders vs OAuthProxyProviders, flags);
wizard + onboarding proxy-entry routing (leaves OAuth mode, starts no login,
lands on endpoint with the URL pre-filled). Full suite green under -race;
staticcheck/govulncheck/deadcode clean. No new deps.
* oauth+tui: add xAI device-code (RFC 8628) login to the OAuth chooser
The OAuth chooser only did browser/loopback login, which can't work on a headless
or SSH box (no browser to open). Add device-code as a first-class option for
providers that support it (xAI).
- oauth.Manager: PrepareDeviceLogin (request code) + CompleteDeviceLogin (poll +
store), splitting the monolithic device flow so the UI can display the
user_code + verification URI while it waits. CLI Login(Device:true) unchanged.
- tui (shared oauth_device.go): oauthPreferDeviceFlow() picks device by default
on SSH / headless Linux (no DISPLAY) or when ZERO_OAUTH_DEVICE is set;
oauthDevicePrepare/Complete drive the two phases off the UI goroutine.
- /provider wizard + first-run onboarding: device-capable providers show
"Enter sign in · d device code" — Enter uses the browser (or device if
headless), "d" forces device. The waiting screen shows the code + URL to
enter elsewhere, then polls; success advances to model selection. Esc cancels;
errors are redacted; device state is cleared on success/cancel/back.
- docs: document the device-code path and ZERO_OAUTH_DEVICE.
Tests: manager Prepare/Complete two-phase; wizard + onboarding 'd' shortcut,
device-code display + poll dispatch, error surfacing, and success clearing device
state. Full suite green under -race; staticcheck/govulncheck/deadcode clean. No
new deps.
* tui: authenticate model discovery with the OAuth token so the live list shows
After an xAI sign-in the bearer lives in the token store, not as a pasted key, so
the post-login model step ran /models with no credential → it always fell back to
the curated list (with a discovery error). Now the user picks from the real live
model list after OAuth, like other CLIs.
- oauthStoredToken() resolves a fresh stored token for a token-login provider.
- wizard + onboarding discovery resolve and inject that token into the discovery
profile inside the async goroutine (token refresh is network I/O); OpenRouter
still uses its minted key, API-key users are unchanged. Discovery timeout
bumped 8s→12s to cover an on-demand refresh.
- The resolved token is added to the redaction secret set so it can never leak
into a surfaced discovery error. If discovery still fails (offline), the
curated per-provider fallback list is shown as before — the user always gets a
pickable list.
Tests: oauthStoredToken reads a seeded login / returns empty when absent;
wizard discovery injects the stored token into the /models profile. Hermetic via
ZERO_OAUTH_TOKENS_PATH. Full suite green under -race; staticcheck/govulncheck/
deadcode clean. No new deps.
* catalog+tui: drop ChatGPT/Claude from the OAuth chooser — OpenRouter + xAI only
Reverts the proxy-entry listing: the "Sign in with OAuth" chooser now shows only
the providers that do real OAuth (OpenRouter, xAI). ChatGPT/Claude looked
confusing there since they can't actually sign in — they remain reachable via
"Paste an API key / browse providers" + the local-proxy recipe in
docs/oauth-subscriptions.md.
- catalog: remove the Descriptor.OAuthProxy flag, OAuthProxyProviders(), the
oauthProxyProvider/localAnthropic helpers, and the claude-proxy entry.
chatgpt-proxy stays as a plain browse/config entry (its pre-existing role).
- wizard + onboarding: OAuth list = OAuthProviders() again; removed the
proxy→browse routing, the OAuthProxy badge, and the OAuthProxy endpoint rule.
- device-code + OAuth-token model discovery (the later features) are untouched.
- docs + tests updated to the two-entry chooser.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck/deadcode all
clean. No new deps.
* cli: recognize a stored OAuth login as a provider credential
A provider authenticated via OAuth (e.g. `zero auth login xai`) has no inline
key or env var, so setupRequired treated it as unconfigured and forced onboarding
on every launch — and the active provider could be passed over for a different
one. setupRequired now also counts a stored OAuth login (read from the token
store) as a credential, so an OAuth-logged-in provider stays usable without
onboarding.
Tests: providerHasOAuthLogin (by name/catalog id); setupRequired returns false
for an OAuth-logged-in provider, true for a keyless one with no login.
* Address review feedback on OAuth provider login
Resolve CodeRabbit review comments on this PR:
- PrepareDeviceLogin: self-bound the network prepare (endpoint discovery +
device-code request) with opts.Timeout (default 5m) like Login, so a caller
passing an unbounded context still gets the timeout guarantee.
- CompleteDeviceLogin: bound the poll by the device code's own ExpiresAt so an
unbounded caller context cannot leave an in-flight request hanging past the
code's lifetime (PollDeviceToken already re-checks between polls).
- OAuthProviders(): return cloned descriptors, matching All()/the other
accessors, so callers can't mutate the shared catalog backing slices.
- Onboarding applySetupOAuth / applySetupOAuthDeviceCode: ignore a stale result
for a provider the user has since switched away from, so a late login can't
capture a credential against the wrong provider.
- Onboarding: surface a failed OAuth login on the provider list the user lands
on (the waiting screen that owned the error is gone once oauthPending clears),
so the failure isn't silent and the user can retry.
- Onboarding: hide the "Sign in with OAuth" method when this setup's own
provider list has no OAuth-capable entry, so it can't be selected into an
empty provider list (setupMethodOptions).
- docs/oauth-subscriptions.md: label the bare example fences (text) and update
the Anthropic policy section for the April 4 2026 third-party cutoff.
Adds tests for the descriptor-clone isolation and the method-option filtering.
* oauth: gate built-in presets behind ZERO_OAUTH_ALLOW_PRESETS
The xAI preset baked a third-party OAuth client_id into the default
credential path, which breaks the engine's documented invariant that no
third-party OAuth client identity is used unless the operator configures
it. Rather than drop the convenience entirely, make presets opt-in:
- ResolveConfig only consults builtinOAuthPresets when ZERO_OAUTH_ALLOW_PRESETS
is truthy (presetsAllowed); by default every field must come from
ZERO_OAUTH_<NAME>_* env, so the binary's preset client_id is never used
without an explicit opt-in.
- The "not configured" error now points the user at ZERO_OAUTH_ALLOW_PRESETS
when a preset exists, so the wizard/CLI failure is self-documenting.
- Refresh the package, Registry, and preset docs to describe the opt-in;
env still overrides any preset field.
- docs/oauth-subscriptions.md: document the flag for one-click xAI sign-in.
Keeps both: invariant-clean by default, one-click xAI for opted-in users.
Tests: preset resolution now requires the opt-in; added a gate-off test
asserting xAI stays inert (and the error names the flag) without it.
* oauth: read a stored token without resolving provider config
GetFresh resolved the provider OAuth config for every read via loadForKey,
even when the stored token was still valid and no refresh would happen.
With presets gated behind ZERO_OAUTH_ALLOW_PRESETS, that made a stored xAI
login unreadable (and stalled the proactive refresh scheduler) unless the
opt-in flag was set at read time — config that is only needed to refresh.
Split loadForKey into loadToken (token only) and resolveConfigForKey
(refresh-only). GetFresh now returns a still-valid token without touching
the config; only an actual refresh (or Handle401) resolves endpoints +
client. The scheduler's load uses loadToken too, so proactive refresh
survives a gated preset and only the refresh attempt needs the config.
Fixes the Smoke failures TestOAuthStoredTokenReadsStoredLogin and
TestProviderWizardDiscoveryUsesOAuthToken (a fresh stored token must be
readable without the preset opt-in).
* Address CodeRabbit re-review round 2 on OAuth provider login
- providerio SendWithAuthRetry: resolve the auth headers BEFORE dispatching, so
a resolver error returns immediately instead of letting SendWithRetry send an
unauthenticated request (leaking the path/body).
- tui handleMouse: ignore all mouse input (clicks + wheel) while a provider-wizard
OAuth login is pending, so a stray scroll can't change the selected provider
mid-login (mirrors the keyboard pending guard; the setup mouse handler already
did this).
- cli runAuthOpenRouter: reject unexpected args/flags (e.g. --json) instead of
silently running the login, matching the other auth subcommands.
- cli auth help: align the preset wording with the opt-in gate — openrouter works
out of the box; xai needs ZERO_OAUTH_ALLOW_PRESETS=1 or ZERO_OAUTH_XAI_*.
- onboarding_test: compute selectedMethod from m.setupMethodOptions() (the
setup-filtered list) instead of providerWizardMethodOptions(), which could index
past the end when OAuth is hidden.
- docs/oauth-subscriptions.md: sharpen the Anthropic timeline — Feb 19 2026 docs
update, then the April 4 2026 enforcement that stopped subscription OAuth tokens
working in third-party harnesses; current path is an API key or pay-as-you-go.
Adds tests for the no-dispatch-on-resolver-error path and openrouter arg rejection.
* Address CodeRabbit re-review round 3 on OAuth provider login
Two findings missed in the previous round:
- oauth manager resolveConfigForKey: resolve issuer metadata before refreshing.
It returned ResolveConfig's result without calling resolveEndpoints, so a
provider configured with only ZERO_OAUTH_<NAME>_ISSUER_URL (no TOKEN_URL) had
an empty token endpoint and could not refresh on GetFresh/Handle401. Thread the
context through and run discovery; adds a test that an issuer-only provider
refreshes via the discovered token endpoint.
- provider_wizard_oauth_test: assert openrouter is actually present in the OAuth
list before selecting it, so the test can't silently pass against a different
provider if openrouter is renamed/removed.
(The re-flagged PrepareDeviceLogin timeout is already handled — it self-bounds
with opts.Timeout, same as Login.)
* Address CodeRabbit re-review round 4 (nitpicks) on OAuth provider login
- presets scopesOrPreset: return a copy of the preset scopes so a caller
appending to cfg.Scopes can't mutate the shared global preset slice.
- provideroauth/openrouter: use bytes.NewReader(payload) instead of
strings.NewReader(string(payload)) (avoid a needless []byte->string copy).
- onboarding setupCredentialSummary: show "OAuth token" on the Ready screen for
an OAuth token-login provider (e.g. xAI) instead of "env var XAI_API_KEY" — it
is authenticated by a stored token, not a key. Key-minting OAuth (OpenRouter)
still resolves to a saved key.
(The flagged "Registry doc says no built-ins" was already corrected in the
preset opt-in commit — the doc now describes the ZERO_OAUTH_ALLOW_PRESETS gate.)
Adds tests for the scope-slice copy and the OAuth-token credential summary.
* tui: migrate off the deprecated Bubble Tea mouse Type API (SA1019)
The mouse classification helpers checked both the current Button/Action pair and
the deprecated msg.Type / tea.Mouse* constants. Bubble Tea's parser always
populates Button+Action and only DERIVES Type from them, so the Type fallbacks
were pure redundancy (a left-button drag is Action==Motion, already covered by
mouseMotion). Drop them — clears all 11 SA1019 findings in internal/tui with no
behavior change. The left-drag test now asserts the Action==Motion path directly.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(SA1019 0)/
deadcode(no new) all pass.
* notify: wire the webhook sink behind an opt-in env var
The WebhookSink (Slack / generic JSON POST on turn completion) was fully
implemented, redaction-safe, and tested — but never attached to a Notifier, so
deadcode flagged its entire machinery (NewWebhookSink, Emit, text, redactLinks,
log, eventType, readSnippet) as unreachable.
Wire it via a small, testable helper, MaybeAddWebhookSink(n, env, logf):
- reads ZERO_NOTIFY_WEBHOOK_URL (+ optional ZERO_NOTIFY_WEBHOOK_SUMMARY)
- no-op when the URL is unset/blank, so it is safe to call unconditionally
- sourcing the URL from the environment keeps the secret token out of any
on-disk config file
Attached at both notifier construction sites:
- headless exec: failed deliveries log to stderr (never stdout); the sink
redacts before logging
- TUI: logf=nil so a delivery failure can't corrupt the alt-screen
The sink stays gated by the existing Mode/focus policy (verified by
TestNotifierOffSuppressesSinks), so a webhook only fires when notifications are
enabled (e.g. --notify both) — no change to default behavior. Opt-in and
fail-closed: with the env var unset nothing is attached.
deadcode: 100 -> 93 unreachable funcs (the 7 WebhookSink entries become
reachable); no new dead code. gofmt/vet/build(host+linux+windows)/test -race/
staticcheck(no new)/govulncheck(0) all pass.
* swarm: add opt-in recurring-spawn scheduler (wakeup + daily)
Adds a dependency-free Scheduler to internal/swarm so a member can be spawned on
a recurring schedule, plus a swarm_schedule tool to drive it.
- Schedule is interval-based ("wakeup", every >= 1s) with an optional first
delay and a MaxRuns cap; a daily "HH:MM" ("cron") time is expressed as a 24h
interval whose first delay lands on the next occurrence.
- Each fire spawns a fresh member through the existing Swarm.Spawn path, so
members inherit the recorded policy and are bounded by the team slot cap +
queue. Non-overlapping by default: a fire is skipped while the job's previous
spawn is still running, preventing queue pile-up.
- Scheduler is lazily created (Swarm.Scheduler()), opt-in (nothing runs until a
job is added), and tied to the Swarm's base context — Swarm.Close() stops the
scheduler first so no spawn fires during shutdown.
- swarm_schedule tool: action add|list|cancel; prompt-gated like swarm_spawn;
add validates schedule + agent type up front (fail fast).
The timing loop takes an injectable ticker, so the tests drive fires
deterministically with no real time (fire/skip/cap/cancel/close, plus pure
nextDailyDelay/parseClock/swarmInt). All swarm tests pass 3x under -race.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(clean)/govulncheck(0)/
deadcode(no new) all pass.
* daemon/remote: SessionLink + git-bundle upload over the bridge
Adds an opt-in way to ship a local repo's git history to a remote daemon and
link a remote working tree to it — without a shared filesystem or a network git
remote. The daemon core (protocol/server) is untouched; all changes live in the
remote package plus a CLI hook.
Wire:
- The auth handshake gains an optional Mode field ("" / "session" => a daemon
session as before; "bundle" => a one-shot git-bundle upload). Unknown modes,
or bundle mode when uploads are disabled, are denied (fail closed).
- Bundle transfer reuses the daemon frame codec: a header frame (link id +
exact size), then the bundle bytes as KindData frames, then a result frame.
Server (Bridge):
- BridgeOptions.BundleDir enables uploads (empty => refused). A received bundle
is size-capped (default 256 MiB), staged to a temp file, `git bundle verify`-d,
then `git clone`-d into a staging dir and atomically renamed over
<BundleDir>/<link-id>. Link ids are sanitized to a single path component and
containment is re-checked, so an upload can never escape the bundle dir.
Client:
- UploadRepoBundle creates `git bundle create --all` of a work tree, hashes it
(sha256), streams it over an authenticated bundle-mode TLS connection, and
returns a SessionLink {address, link id, remote path, bundle sha}. SessionLink
persists as a 0600 atomic JSON file (token never stored) with Save/Load/Validate.
CLI:
- `daemon serve-remote --bundle-dir <d>` enables uploads.
- `daemon link --remote <addr> --repo <dir> --id <name> [--out <file>]` uploads
and prints the remote path (+ a ready run command); `daemon link --show <file>`
prints a saved link.
Tests: git bundle create/verify/clone roundtrip; link-id sanitization + path
containment; SessionLink save/load/validate (0600); and a full bridge upload E2E
over real TLS (extract + content check + re-upload-replaces + disabled + bad
token). All remote tests pass 2x under -race; CLI smoke verified via the binary.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
* mcp: unify OAuth token storage onto the shared oauth store
MCP server tokens lived in their own mcp-oauth-tokens.json with a bespoke
codec/lock, separate from the provider-login store (internal/oauth). The oauth
store already reserved the "mcp:" key namespace for exactly this. Unify them.
- mcp.TokenStore now delegates to internal/oauth.Store, keying each server's
token as "mcp:<server>". Its public API (StoredToken/TokenStatus/Save/Load/
Delete/Status/FilePath) is unchanged, so callers (network client, CLI) are
untouched. The bespoke read/write/lock code is removed in favor of the shared,
cross-process-locked, atomic store.
- Transparent, non-destructive migration on first construction: a legacy
mcp-oauth-tokens.json is imported into the unified store (only entries not
already present — a newer unified token is never overwritten), then renamed to
a ".migrated" backup so it is imported at most once and stays recoverable.
A missing / unreadable / unknown-schema legacy file is left untouched and
never blocks startup. An explicit FilePath suppresses the default-path
migration so tests/overrides can't touch the real user config.
- Server names that can't form a valid namespaced key (oauth.ValidateKey) are
rejected on save and skipped during migration. Added oauth.Store.FilePath().
The token format was already identical (access/refresh/type/scopes/expires), so
this is a key rename, not a format change. Existing TokenStore tests pass
unchanged (they exercise the API); added migration + namespacing + newer-wins
tests. Local run check: `zero mcp oauth status` migrates a seeded legacy file
into oauth-tokens.json under mcp:demo, renames the legacy to .migrated, and leaks
no token material.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(clean)/
govulncheck(0)/deadcode(no new) all pass.
* oauth: add an opt-in OS keyring storage backend
Introduces a pluggable storage backend for the unified oauth token store and a
dependency-free OS keyring implementation, selected with
ZERO_OAUTH_STORAGE=keyring (default stays the 0600 file).
- internal/keyring: a small, third-party-dep-free secret store backed by the OS
tooling — `security` on macOS, `secret-tool` (libsecret) on Linux; other
platforms report unsupported. On Linux the secret is passed via stdin (never
the argv); on macOS `security` takes it as an argument (briefly visible in the
process list) — documented, and a reason to prefer the file backend there.
A runner seam makes the per-OS command logic fully testable without a real
keychain.
- internal/oauth: Store now persists its JSON blob through a blobStore backend.
fileBlob preserves the exact prior behavior (0600, atomic write, cross-process
lock file). keyringBlob stores the blob as one base64 entry (keeps multi-line
JSON a single control-char-free value) via a KeyringClient. NewStore resolves
the backend from StoreOptions.Storage or ZERO_OAUTH_STORAGE; unknown storage
and an unavailable keyring fail closed with a clear error.
Because both `zero auth` and the (now unified) MCP token store construct their
store through oauth.NewStore, the keyring backend is honored by both with no
call-site changes. Added oauth.Store.FilePath() returns a "keyring:" identifier
for that backend.
No new module dependency (go.mod unchanged). Tests: keyring round-trip/not-found/
unsupported/missing-binary/stdin-not-argv via an injected runner; store keyring
round-trip + base64-at-rest + storage selection; all existing file-backend tests
pass unchanged. Local run check (macOS): validated the exact `security`
add/find/delete shapes and the not-found exit code (44) against the real binary,
and `mcp oauth status` with ZERO_OAUTH_STORAGE=keyring reads the real keychain
cleanly.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
* all: prune unused unexported functions (dead-code sweep)
Removes 15 unexported functions/types/consts that are unreachable even from
tests (every one flagged by staticcheck U1000), clearing the U1000 noise the v16
audit surfaced. Conservative by design: only unexported, test-unused symbols are
removed — exported internal API (which may be an intentional surface or used only
by tests) is deliberately left in place.
Removed:
- agenteval: validateExpectedChangedFiles
- providers/gemini: const providerName
- tui: stripMarkdownInline, removeTrailingAtToken,
noopModelSwitchCompactionPolicy (+ BeforeModelSwitch), deleteComposerLineBefore,
deleteComposerLineAfter, deleteCompletedFileMentionBefore, mcpViewStatus,
mcpServerLines, fitCommandOutput, (*commandPicker).clearQuery,
tuiTheme.onPanel2, indentText
No behavior change (the symbols had no callers). staticcheck U1000 is now empty;
deadcode drops accordingly with no genuinely-new unreachable functions (verified
by a name-based diff vs origin/main). gofmt/vet/build(host+linux+windows)/full
test all pass.
* Address review feedback on the audit backlog
Resolve CodeRabbit review comments on this PR:
- keyring isNotFound: match the specific not-found exit codes (macOS `security`
44 = errSecItemNotFound; `secret-tool` 1) instead of treating any non-zero
exit as "no entry", so a real failure (locked keychain, permissions) surfaces
instead of being masked as absent.
- swarm scheduler daily_at: recompute each fire as the next local HH:MM rather
than adding a fixed 24h, so a wall-clock daily schedule does not drift across
DST. Adds Daily/Hour/Minute to Schedule and a clock to the Scheduler.
- swarm scheduler: reject Add when the scheduler context is already canceled
(not just when closed); re-check cancellation after a tick so a tick+cancel
race can't spawn one extra member after Cancel/Close.
- swarm schedule_tool swarmInt: reject non-integer / non-finite JSON numbers
(max_runs=1.9 errors instead of silently truncating to 1).
- mcp migrateLegacy: resolve a relative LegacyPath to absolute before the
same-file guard so it can't be bypassed by a relative spelling.
- oauth keyring store: serialize the keyring read-modify-write with a
cross-process lock file (best-effort, beside the config dir) so concurrent
processes don't clobber each other's tokens; previously withLock was a no-op.
Adds tests for the non-not-found keyring error, the daily delay recompute, the
Add-after-cancel guard, and non-integer swarmInt.
* swarm: make nextDailyDelay roll the day, not add 24h (DST)
CodeRabbit follow-up: the run loop already recomputes a daily job's delay each
cycle, but nextDailyDelay itself rolled "passed today" to the next occurrence
with Add(24*time.Hour). On a DST changeover day (23h/25h) that fires an hour
early/late. Use time.Date(..., day+1, hour, minute, ...) so Go normalizes the
date and applies the correct local offset, holding the wall-clock HH:MM across
DST. Adds an America/New_York spring-forward test (skips if tzdata is absent).
* oauth: opt-in AES-256-GCM encrypted-at-rest token storage
OAuth Phase 2: add an encrypted token-store backend. The default backend still
writes the 0600 plaintext JSON unchanged; setting ZERO_OAUTH_STORAGE=encrypted-file
encrypts the token file with AES-256-GCM under a per-user random 32-byte secret
persisted 0600 beside the token file (<tokens>.secret).
- internal/oauth/encrypt.go: aesGCMCrypter (nonce||ciphertext, GCM tamper
detection) + load-or-create secret (atomic 0600 write). Pure stdlib, no dep.
- Store gains an optional crypter: readState decrypts before unmarshal, writeState
encrypts after marshal. A missing secret, a short blob, or a failed auth tag all
fail closed (no silent empty/plaintext fallback).
- CLI: newAuthManager selects the backend from ZERO_OAUTH_STORAGE; `zero auth`
help documents it.
Tests: crypter round-trip + tamper, secret create/persist/0600/wrong-size,
store-through-encrypted (ciphertext on disk, round-trip, Status/Delete, tamper +
missing-secret fail-closed). Gates: gofmt/vet/build(host+linux+windows)/test
-race/staticcheck(no new)/govulncheck(0)/deadcode(no new) all pass. Local run
check: device login under ZERO_OAUTH_STORAGE=encrypted-file writes a ciphertext
file (no plaintext token), .secret is 0600, and `zero auth status` reads it back.
* Address review feedback on encrypted token storage
Resolve CodeRabbit review comments on this PR:
- newAuthManager: reject an unsupported ZERO_OAUTH_STORAGE value instead of
silently downgrading to plaintext. A mistyped non-empty value now fails fast
("invalid ZERO_OAUTH_STORAGE ... (supported: encrypted-file)") so a user who
believes encryption is on is never left writing plaintext.
- loadOrCreateSecret: fix the first-run TOCTOU race. os.Rename publishes by
clobbering on POSIX, so two concurrent processes could each generate a secret
and the loser would silently orphan every token it then encrypts. Create the
secret exclusively (O_CREATE|O_EXCL); the loser adopts the winner's on-disk
secret. Factored the read+size-validate into readSecretFile and dropped the
now-unused clock plumbing from the crypter.
- encrypt_test: handle os.Stat / os.ReadFile errors before dereferencing the
result (mode/index) so a real I/O failure surfaces instead of a panic.
Adds a test asserting an invalid ZERO_OAUTH_STORAGE fails fast.
* oauth: retry the secret read when losing the first-run create race
CodeRabbit follow-up: the O_EXCL create path read the on-disk secret
immediately after EEXIST, but the winner may not have finished writing it
yet, so a loser could observe a short file and fail transiently. Retry the
read briefly (bounded) so concurrent first-run invocations converge on the
winner's secret. Adds a -race concurrency test asserting convergence.
* web_search: fix#209 follow-ups (tolerant score, render gate, redaction, FQDN)
Verified-and-fixed defects from a review of #209:
- Tolerant score decode (lenientScore): a stringified/odd/null "score" no longer
makes json.Unmarshal discard the entire response; it degrades to absent.
- Score render gate rounds first (>= 0.01) so tiny positives and negatives no
longer print a noisy "score 0.00".
- Domain-filter error now goes through redactWebSearchText, matching the other
error paths.
- Trailing-dot FQDN symmetry: "react.dev." and "react.dev" normalize the same
on both the allowlist and result sides.
Left as-is (intended, tested): scheme-less host:port allowlist entries. Includes
mixed-type domains-array tests.
* web_search: only register the built-in tool when a backend is configured
When no search backend is set (ZERO_WEBSEARCH_BASE_URL / ZERO_WEBSEARCH_BACKEND),
CoreNetworkTools no longer offers the built-in web_search. Previously it was always
registered, so a model with an MCP search tool (e.g. Exa) would burn several calls
+ high-risk permission prompts on the built-in tool — which can only return
"no backend configured" — before falling back to the working MCP tool.
Now: no backend -> only web_fetch (+ any MCP search tool) is offered, so the model
uses the MCP search directly. A configured backend behaves exactly as before.
Tests: registry/web_search/specialist tests that asserted web_search is always in
CoreTools now configure a backend; added a test that it is omitted when none is set.
(Pre-existing, unrelated: internal/cli doctor/exec tests fail in the sandbox on
origin/main too — network/provider-dependent, not touched here.)
* tui: copy selected transcript text to the native OS clipboard
Selecting transcript text auto-copies on mouse release, but the copy went only
through OSC52 (ansi.SetSystemClipboard), which macOS Terminal.app doesn't support
at all and iTerm2 / some Windows Terminal builds have off by default — so the
selection silently never reached the clipboard.
Copy via the native clipboard (pbcopy / clip.exe / xclip) first, which works on
local terminals regardless of OSC52, and fall back to OSC52 for remote/SSH
sessions where no local clipboard utility is reachable. atotto/clipboard was
already in the module graph (indirect); promoted to a direct import — no new
dependency.
Gates: gofmt/vet/build(host+linux+windows)/tui test -race/staticcheck all pass.
* tui: collapse long tool output by default with click-to-expand
Long, noisy tool output (web-search / MCP / read dumps) flooded the transcript.
Now a tool result whose output exceeds the live body cap renders collapsed by
default — head + a "▸ N lines — click to expand" footer — and you click the card
(while it is live) to expand (▾) / collapse. Mirrors the existing reasoning-row
collapse, so collapsed rows also flush to scrollback clean.
Scoped to avoid hiding what matters:
- diff tools (edit_file / apply_patch / write_file) never collapse — their body
must stay reviewable (matches the deeper flush cap intent);
- the uncapped detailed transcript view (Ctrl+O, bodyCap==0) never collapses —
it still shows full output;
- short output (<= cardBodyMaxLines) stays inline as before.
Reuses transcriptRow.expanded + the row toggle (toggleReasoningRow generalized to
toggleTranscriptRow); the card head is a clickable toggle line. Running tool
cards (spinner) are unchanged.
Tests: collapse/expand, short-output inline, diff tools never collapse, toggle,
and the head exposes a click toggle; existing diff + detailed-view tests updated.
Gates: gofmt/vet/build(host+linux+windows)/tui -race/staticcheck all clean.
* cli: fall back to a usable saved provider instead of re-running onboarding
If the active provider lacked a credential (e.g. it relies on an env var that
isn't set, or an OAuth login that isn't stored), the TUI forced first-run
onboarding on every launch — even when other saved providers were fully
configured. Each onboarding then appended yet another provider.
Now, when the active provider isn't usable, fall back to the first usable saved
provider (prefer a remote keyed one over a local endpoint) for the session, and
skip onboarding. Onboarding only runs when no configured provider is usable — a
genuinely fresh setup. Saved logins persist; switch the active provider with
`zero provider use <name>`.
Tests: firstUsableProvider prefers remote keyed, falls back to local, accepts a
keyless local proxy, and reports none-usable. Gates: gofmt/vet/build
(host+linux+windows)/staticcheck clean; the only failing cli tests
(doctor/exec, network-dependent) are pre-existing on origin/main.
* tui: hold the scroll position while output streams in
The chat viewport scroll offset is measured from the bottom, so when the
transcript grew (a streaming turn) the window followed the new bottom and yanked
the user off whatever they had scrolled up to read.
syncChatScroll now pins the viewport: while the user is scrolled up, it bumps the
offset by however many lines the body grew, so the absolute view holds where they
are reading; at the bottom (offset 0) it follows the tail as before. Hooked into
the single Update funnel; only the scrolled-up path renders the body, so the
common (at-bottom) case stays cheap.
Tests: pins by the growth delta while scrolled up; resets/follows at the bottom.
Gates: gofmt/vet/build(host+linux+windows)/tui -race/staticcheck clean.
* agent: search the web proactively instead of answering "I don't know"
When a web search/fetch tool is available and the user asks about something
external the model is unsure of or that may be current (a product, library,
company, price, version, recent event), the system prompt now tells it to search
and read results before answering, rather than replying that it doesn't know
without checking. Addresses the model saying "I don't know about that" until the
user explicitly asked it to search.
Tests: prompt includes the new guidance ("search the web and read"). Existing
prompt assertions use Contains, so the addition is non-breaking.
* tui: show clean labels for MCP tool cards (mcp_exa_web_search_exa → web search)
* agent: sharpen web-search guidance (disambiguate, refine, scale)
Strengthen the search rule so the model searches before answering about an
external entity/product/library/model/version/release it doesn't recognize,
doesn't assume the most common meaning of an ambiguous name (disambiguates by the
conversation's domain), and refines + re-searches when results look off-topic —
rather than confidently answering the wrong interpretation (e.g. reading "Fable 5"
as a video game instead of an AI model). Also: don't web-search timeless facts or
workspace-answerable questions (use the file tools), keep queries short, and scale
the number of searches to the question.
Tests: prompt includes the new guidance ("search the web before answering",
"do not recognize"). Existing assertions use Contains, so non-breaking.
* test: gofmt setup_fallback_test.go (comment alignment)
* Address review feedback on web_search + chat UX
Resolve CodeRabbit review comments on this PR:
- firstUsableProvider: skip a profile whose CatalogID no longer resolves
*and* that has no explicit BaseURL (no endpoint → unusable). A stale
CatalogID with a BaseURL still works as a custom endpoint and is kept.
- providerProfileIsLocal: classify by parsed URL hostname (exact
localhost / 127.0.0.1 / ::1) instead of substring matching, so hosts
like "notlocalhost.com" are no longer misread as local.
- lenientScore: reject non-finite scores (ParseFloat accepts "NaN"/"Inf")
so the documented score filter actually holds and the renderer never
emits a non-finite value.
- syncChatScroll: shift the pinned from-bottom offset by the signed line
delta so the view also holds when the body shrinks (card collapse,
transcript clear), clamped at zero; previously only growth was handled.
- copyTranscriptSelectionCmd: report "Copy failed" when both the native
clipboard and the OSC52 fallback fail, instead of claiming success;
keep the selection so the user can retry.
- Drop vestigial ZERO_WEBSEARCH_BACKEND test env: the native backend was
removed (MCP path), so the var is read by no product code and the
references misrepresented it as a config knob.
Adds tests for the local-host parsing, the catalog/BaseURL skip, and the
non-finite score rejection.
* Polish TUI title bar
* Show PR diff stats in TUI title bar
Add a small PR status service that detects the active GitHub PR or GitLab merge request via gh/glab, computes diff stats against the PR base with git diff --shortstat, and publishes updates to the TUI.
Render the resulting +N -N #PR segment in the title bar next to the branch, linked to the PR URL, while keeping the composer footer unchanged.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: git diff --check
* Make submitted prompt bubble terminal-safe
Replace half-block top/bottom rows in submitted user prompts with background-filled padding rows. macOS Terminal renders the half-block glyphs as visible horizontal artifacts, while filled-space padding keeps the prompt bubble height without relying on ambiguous block glyph behavior.
Update selectable transcript rendering to use the same padding helper and remove the now-unused half-block theme style.
Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui
Tested: git diff --check
* Address PR status review feedback
* feat(daemon): TLS remote bridge — authenticated network access to the daemon
Add internal/daemon/remote: an OPT-IN, TLS-only network bridge on top of the
local daemon. A remote client drives the SAME SessionManager/Pool over the
network behind bearer-token auth, reusing the daemon's framing and dispatch
unchanged. The local Unix-socket daemon stays the default and is unchanged.
- Bridge: per accepted TLS connection, authenticate THEN hand to the daemon's
control dispatch via the new Server.ServeConn seam — a remote session runs
under the same sandbox/risk model as a local one (never bypasses it).
- Authenticator + TokenAuthenticator: constant-time bearer-token check, token
from $ZERO_DAEMON_REMOTE_TOKEN or a file; optional attestation hook (no-op
default). Auth happens before any control frame; failure closes the connection
after a small backoff; tokens are never logged.
- RemoteClient (DialRemote): verified TLS dial (never InsecureSkipVerify) + auth
handshake, then the existing daemon.Client via the new NewClientConn seam, so
remote and local share one protocol.
- Fail-closed: TLS mandatory (refuses without cert/key), a configurable
min-protocol-version floor, the 1 MiB frame cap reused, and a bounded
concurrent-connection cap that refuses overflow.
- CLI: `zero daemon serve-remote --addr --tls-cert --tls-key`; `run`/`attach`
gain `--remote <addr> [--token --ca-cert --server-name]`.
Daemon seams added (additive, behavior unchanged): Server.ServeConn,
NewClientConn. SessionLink (event POST) and git-bundle repo transfer are
documented follow-ups.
Tests cover auth accept/reject (env + file), TLS-required, cert verification,
version floor, run/status/attach round-trip, re-attach resumption, connection
cap, and ListenAndServeTLS/Close. Gates: gofmt / vet / build (host+linux+
windows) / test -race / staticcheck (no new) / govulncheck (0) / deadcode
(no new) all pass.
* swarm: de-flake TestMailboxConcurrentSends on Windows CI
The 200-way concurrent-send stress test inherited from the swarm package timed
out on Windows CI (103/200 sends hit the 10s lock timeout): the lock's 20ms
retry sleep starves waiters when Windows file ops are slow under heavy
contention. Shorten the retry to 2ms (a freed lock is re-acquired promptly) and
raise the test's lock timeout to 60s so a legitimately-slow CI never times a send
out. Passes repeatedly under -race. (CI de-flake of a test that surfaced on
#210's Windows smoke; functionally unrelated to the OAuth changes.)
* remote: address CodeRabbit re-review (TLS dial + Close idempotency)
- client: replace the deprecated, non-cancellation-aware tls.DialWithDialer with
a context-aware tls.Dialer.DialContext (resolves the golangci-lint noctx CI
blocker; the context bounds the dial + TLS handshake).
- bridge: Close now clears b.listener under the lock, so a repeat Close is a
no-op instead of returning a closed-listener error (matches its doc). Test
asserts a double Close succeeds.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
* feat(oauth): reusable provider OAuth engine + `zero auth` CLI
Add internal/oauth: a transport/identity-agnostic OAuth 2.0 engine that powers
provider login alongside the MCP-server OAuth zero already has. It is additive
and provider-neutral — NO providers, endpoints, or client identities are baked
into the binary; every provider is configured entirely via ZERO_OAUTH_<NAME>_*
env vars, so the engine works with any OAuth 2.0 / OIDC server.
Engine (internal/oauth):
- PKCE (S256 mandatory; "plain" refused) + per-flow CSRF state.
- Authorization-code flow: BuildAuthorizationURL / ExchangeCode / Refresh, with
an https-only token-endpoint guard (loopback exempt) and error bodies that
carry only error/error_description (never token material).
- Loopback callback server: 127.0.0.1-only, OS-assigned port, single use, state
verified, then closed.
- Device-authorization grant (RFC 8628): RequestDeviceCode + PollDeviceToken
honoring authorization_pending / slow_down / interval / expiry (headless/SSH).
- RFC 8414 + OIDC discovery (issuer-driven endpoint resolution).
- Namespaced token store ("provider:<name>" / "mcp:<name>"), 0600, file-locked
(ownership-aware), atomic writes, malformed-fails-closed; GetFresh (on-demand
refresh) + Handle401 (forced refresh).
- Opt-in proactive RefreshScheduler.
CLI (zero auth):
- login <provider> [--device] [--scope ...], logout, status [provider],
refresh <provider> [--watch]. Status/output never print token material.
- Registered in app.go dispatch + top-level help.
MCP OAuth (internal/mcp) is left byte-for-byte unchanged (zero regression);
sharing the engine with MCP and encrypted-at-rest/keyring storage are documented
follow-ups. Tests cover every path (PKCE, auth URL, https-guard + redaction,
exchange/refresh, loopback capture + state-mismatch + timeout, device pending/
slow_down/expired, store namespacing/0600/locking, provider env resolution,
manager login (loopback+device)/GetFresh/Handle401, scheduler). Gates: gofmt /
vet / build (host+linux+windows) / test -race / staticcheck (no new) /
govulncheck (0) / deadcode (no new) all pass.
* oauth: fix Windows smoke — use OS-appropriate path in store-path test
TestResolveStorePathHonorsOverride hardcoded a unix "/tmp/custom/tok.json"
literal, which is not absolute on Windows (no drive), so ResolveStorePath
correctly resolves it against the current drive and the verbatim comparison
failed (D:\tmp\custom\tok.json). The production code is correct; the test now
builds the override with filepath.Join(t.TempDir(), ...) so it is absolute and
clean on every OS.
* oauth: address CodeRabbit review (11 findings)
Security / correctness:
- flow: caller-supplied ExtraAuthParams/extraParams can no longer override the
reserved OAuth/PKCE fields (state, redirect_uri, code_challenge[_method],
response_type, client_id) — e.g. forcing method=plain.
- device: a device-authorization response without expires_in now gets a bounded
default lifetime (fail closed), and the poll loop re-checks expiry AFTER the
interval sleep so it never polls past the deadline (e.g. after slow_down).
- lock: handle WriteString/Close errors on the token lock file — a partial write
no longer strands an undeletable lock.
- store: a non-nil env map is now authoritative (hermetic) — a controlled map
never falls back to ambient ZERO_OAUTH_* / HOME / XDG_CONFIG_HOME.
- scheduler: a transient loadForKey error backs off and retries (bounded) instead
of permanently stopping proactive refresh.
- cli auth: reject flags a subcommand does not accept (e.g. login --watch,
status --device) and reject empty --scope values — fail fast.
Lint blockers:
- manager: restructured the discovery fallback so the non-nil-err branch no longer
returns nil (nilerr).
- providers: dropped the ineffectual flow initializer (ineffassign).
Tests: assert Save/Load errors in the scheduler test; the slow_down device test
no longer depends on post-expiry polling. Added tests for reserved-param
stripping, hermetic env, device default-expiry, and CLI flag validation. The
store-path test was already made OS-portable in the Windows-smoke fix.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
* swarm: de-flake TestMailboxConcurrentSends on Windows CI
The 200-way concurrent-send stress test inherited from the swarm package timed
out on Windows CI (103/200 sends hit the 10s lock timeout): the lock's 20ms
retry sleep starves waiters when Windows file ops are slow under heavy
contention. Shorten the retry to 2ms (a freed lock is re-acquired promptly) and
raise the test's lock timeout to 60s so a legitimately-slow CI never times a send
out. Passes repeatedly under -race. (CI de-flake of a test that surfaced on
#210's Windows smoke; functionally unrelated to the OAuth changes.)
* oauth: address CodeRabbit re-review (4 findings)
- loopback (security): NewLoopbackListener refuses an empty CSRF state — an empty
state would match a callback carrying no state at all, defeating the check.
- device (correctness): RequestDeviceCode now sends client_secret when set, so a
confidential client authenticates on the device-authorization endpoint too
(consistent with the token poll).
- cli auth: document --watch in the help Flags section.
- scheduler test: assert the seed Save error instead of ignoring it.
Also scrubbed a stray third-party reference from a loopback.go comment.
Tests added: empty-state rejection, device client_secret is sent. Gates:
gofmt/vet/build/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new)
all pass.
* mcp(oauth): delegate the OAuth engine to internal/oauth (dedup) (#212)
OAuth Phase 2: the MCP OAuth code now reuses the shared internal/oauth engine
instead of carrying its own copy of the transport/identity-agnostic primitives.
internal/mcp/oauth.go drops ~110 lines (526 -> 418): newPKCE, newState,
discoverAuthorizationServer, the authorize-URL builder, token exchange/refresh,
the token-response parser, and joinWellKnown now delegate to internal/oauth.
Behavior-preserving: MCP keeps its own OAuthConfig/StoredToken types,
LoginOptions/Login orchestration, loopback handling, registerClient (dynamic
client registration — not part of the shared engine), CLI output, and on-disk
token format (mcp-oauth-tokens.json, raw server-name keys). The existing mcp
oauth tests pass UNCHANGED. The only behavioral delta is a hardening inherited
from the shared engine: the token endpoint must be https (loopback exempt) —
a credential is no longer sent to a plaintext non-loopback endpoint.
Stacks on #210 (the internal/oauth package). Gates: gofmt/vet/build(host+
linux+windows)/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new
vs base) all pass; `zero mcp oauth status` output is identical.
* web_search: add domains allowlist + score field
The web_search tool now accepts an optional 'domains' array that filters
results to the listed hostnames before they reach the model. This is the
zero-only prompt-injection defense that none of the major CLIs ship: a
model that the user has constrained to a known-good set of domains cannot
be tricked into fetching arbitrary pages from search results.
Also surfaces the provider's optional 'score' field on each result row
when present (zero/absent scores are not rendered, to keep the common
case tidy). The 'score' field is parsed from the generic JSON shape
already understood by the httpSearchBackend.
Filters are applied after the search returns, before the empty-result
short-circuit, so a non-empty allowlist that ate every hit surfaces as a
clear 'no results matched domains' error rather than a misleading
'no results' message.
Co-Authored-By: Claude <[email protected]>
* web_search: fail closed on all-invalid domains, strip :port on canonicalize
CodeRabbit review fixes:
1. canonicalizeWebSearchHost: switch parsed.Host -> parsed.Hostname() so
an allowlist entry like 'https://react.dev:443/path' normalizes the same
as 'react.dev'. The previous code left ':443' in the string, which
silently broke every match against a result URL on the default port.
2. stringListArgWebSearch: return a 'provided' bool so Run can distinguish
'no allowlist' from 'allowlist that normalized to empty'. The previous
code returned len(domains)==0 in both cases, and Run fell through to
the unfiltered path — defeating the prompt-injection defense the
parameter exists to provide. Now we error fail-closed.
3. Tests:
- TestWebSearchDomainsFilterRejectsAllInvalidInputs: locks in the
fail-closed contract for all-whitespace, all-empty, embedded-space,
and mixed-invalid allowlists.
- TestWebSearchDomainsFilterAcceptsHostPort: locks in the
Hostname()-strips-port fix for the scheme'd case and asserts that
scheme-less 'host:port' is rejected.
- TestCanonicalizeWebSearchHostStripsPort: the direct unit-level
regression test for canonicalizeWebSearchHost.
Co-Authored-By: Claude <[email protected]>
---------
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude <[email protected]>
* feat(swarm): multi-agent swarm over the specialist sub-agent
Add internal/swarm so an orchestrator can spawn and coordinate MULTIPLE
specialist members concurrently under one team, communicate via per-agent
mailboxes, hand work off, and adopt orphaned tasks. Additive and opt-in: the
single Task tool is unchanged; swarm tools only act when invoked.
- Registry/Definition: agent roster (built-in teammate/subagent + user-defined).
- Mailbox: per-team/per-agent JSON inbox, owner-only 0600, file-locked with
stale-break, size-capped, path-confined, and malformed-fails-closed.
- Coordinator: in-memory task registry with terminal-state guards and stable
per-agent colors.
- Team: bounded concurrent members per team with a FIFO overflow queue that
drains one-per-slot.
- Lifecycle: spawn / handoff / orphan-adoption with bounded relaunch on
temporary failures; members run under the Swarm's base context so they
outlive the spawn call.
- Tools: swarm_spawn, swarm_send, swarm_inbox, swarm_status, swarm_handoff,
swarm_collect.
- Wiring: each member is launched through specialist.Executor and inherits the
orchestrator's model + permission mode (never widened) and runs under the same
sandbox/policy. Mailbox state lives under the workspace so writes fall within
the sandbox write rules. Swarm lifetime is paired with the specialist runtime.
Scheduling (cron/wakeup) and daemon-pool-backed members are deferred follow-ups.
* swarm: address CodeRabbit review (7 findings)
- coordinator: SetStatus rejects unknown TaskStatus values (fail closed) so a
task can never enter a state Summarize/lifecycle don't reason about.
- permission inheritance ENFORCED end-to-end (was resolved but discarded):
thread PermissionMode through specialist.TaskRunOptions -> BuildArgs/
BuildResumeArgs. A headless specialist child now runs "--auto high" (unsafe)
ONLY when the parent is unsafe or no mode is given (preserves the Task tool's
behavior); any other parent mode yields non-unsafe "--auto low", so a member
never gains more authority than its orchestrator. Also fixed the swarm
permission vocabulary to the real agent runtime modes (ask/spec-draft/auto/
unsafe) instead of TUI display names, and the launcher substitutes "auto" for
an empty mode so a member can't fall into the empty-means-high path.
- mailbox: symlink-aware confinement (EvalSymlinks) rejects an inbox dir that
resolves outside BaseDir; dirs tightened to 0700 even if pre-existing with
broad perms; lock release is ownership-token-aware so a stale-broken writer
can't delete a newer writer's lock (split-brain).
- member: recover panics inside a member goroutine -> fails that member only,
never crashes the orchestrator.
- tools: collapse() truncates on rune boundaries (no invalid UTF-8).
Tests added for every fix. Existing specialist behavior is unchanged (empty
PermissionMode keeps --auto high). Gates: gofmt/vet/build (host+linux)/test
-race/staticcheck(no new)/govulncheck(0)/deadcode(no new) all pass.
* swarm: address review round 2 (Windows lock + non-blocking notes)
Blocking (Vasanth): mailbox acquireLock now treats Windows ERROR_ACCESS_DENIED
/ ERROR_SHARING_VIOLATION as lock-contended (retry/wait) instead of a fatal
error, via a platform-aware isLockContended (lock_windows.go / lock_other.go).
A contended lock under Windows file-sharing semantics no longer fails a
send/read spuriously. TestMailboxConcurrentSends raised to 200 goroutines with a
10s lock timeout and now asserts zero send failures (regression guard).
Non-blocking notes addressed:
- Removed third-party reference-project names from doc comments (clean-room +
provider-neutral); the Go code is original.
- Documented the inbox-file size bound's ×2 (pretty-print overhead) constant.
- Documented Send's O(N) rewrite cost + when to switch to append-only.
- permissionRank: spec-draft now ranks below ask (it never widens authority).
- Documented watch's per-member-id retry binding (Member.ID == MemberSpec.ID).
- handoff Safety.Reason now also mentions the inbox write.
Gates: gofmt · vet · build (host+linux+windows) · test -race · staticcheck
(no new) · govulncheck (0) · deadcode (no new) all pass.
* feat(sandbox): complete sandbox breadth — WSL fallback + opt-in MITM TLS inspection
Adds the two remaining sandbox features on top of the strict fail-closed core
(the other four — SOCKS5 egress, re-entrancy guard, fine-grained path lists,
sandbox-aware ripgrep — already shipped and are untouched). Both new features are
opt-in and the DEFAULT policy is unchanged (enforce + network deny, InspectTLS
off; AllowPolicyOnlyRunner keeps its existing default).
1) WSL detection + safe fail-closed fallback
- parseWSL/detectWSL (wsl.go + wsl_linux.go reading /proc/version; wsl_other.go
stub) classify WSL/WSL2.
- SelectBackend: on Linux without bubblewrap, if running under WSL it selects a
new BackendWSL instead of silently degrading to a plain policy-only runner.
- BuildCommandPlan(BackendWSL): FAILS CLOSED unless Policy.AllowPolicyOnlyRunner
is set (errWSLPolicyOnlyDisabled explains how to opt in). When allowed it runs
the policy-only fallback that still routes egress through the filtering proxy
(Backend.ProxyEgress => EnforcesScopedEgress, best-effort HTTP+SOCKS), sets
ZERO_SANDBOXED=1 / ZERO_SANDBOX_BACKEND=wsl, and records an auditable
least-privilege downgrade note (CommandPlan.Notes). Never runs unsandboxed
silently.
2) Optional gated MITM TLS inspection (Policy.InspectTLS, default OFF)
- Off => the egress proxy is a pure CONNECT passthrough, byte-for-byte
unchanged (no CA generated).
- On => the proxy terminates TLS toward the sandboxed client with an EPHEMERAL,
locally generated ECDSA CA (mitm.go), minting per-host leaves on the fly, so
it can re-check the DECRYPTED request Host through the SAME
authorize()/domainAllowed() gate (deny-wins, empty allowlist => deny) — MITM
widens visibility, never authority. The UPSTREAM dial is ALWAYS validated
against the system roots (never InsecureSkipVerify; tests inject a known root
pool to exercise validation). The CA's PUBLIC cert is written owner-only and
surfaced via ZERO_SANDBOX_CA_CERT; MITM only runs on backends that already
permit reading it (sandbox-exec allows file-read; WSL shares the host fs), so
no new bind path is introduced. Trust implications documented loudly on the
field. (No "raw passthrough only" flag exists in zero, so the mutual-exclusion
case is N/A.)
Tests: parseWSL table; WSL fail-closed-without-opt-in and policy-only-with-proxy
(markers + ProxyEnvWithSocks) + deny-mode markers-no-proxy; MITM passthrough-by-
default (no CA), CA mints a chaining leaf, full TLS-terminate-and-forward of an
allowlisted host (upstream validated via an explicit root pool), and decrypted-
Host deny. Gates: gofmt, go vet (host + GOOS=linux), go build (host/linux/
windows), go test (incl -race), staticcheck (no new, host + linux), govulncheck
(0), deadcode -test=false under GOOS=linux (no new unreachable funcs).
* sandbox: address review on #206 (WSL + MITM)
- mitm: bound the upstream transport's TCP connect (DialContext 30s) so a
blackholed upstream can't park a goroutine; disable upstream HTTP/2 and
normalize the response to HTTP/1.1 before writing to the HTTP/1.1 client.
- mitm: mint leaves for the AUTHORIZED CONNECT host (not arbitrary client SNI)
and cap the leaf cache (512) so SNI churn can't drive unbounded keygen/memory.
- mitm: re-check the decrypted Host with the FULL authorize gate
(authorizeTarget) so a DomainPrompt-allowed host is honored consistently with
the CONNECT check, not just domainAllowed.
- runner (WSL): preserve the caller's env in the direct-run WSL plan (it is not
OS-wrapped), appending the sandbox markers last; the downgrade note says
"with proxy egress" only when a proxy was actually started.
- tests: WSL caller-env preservation; deny-mode asserts no HTTP/HTTPS/ALL_PROXY
leak + the downgrade note; MITM forward asserts HTTP 200; mitmConnect sets an
I/O deadline.
Gates: gofmt, vet (host+linux), build (host/linux/windows), test (incl -race),
staticcheck (no new), govulncheck (0), deadcode -test=false (GOOS=linux) no new.
* Convert /permissions output to bounded command card
Replace status: ok style with commandCard format used by /tools and /context.
Summary shows mode and grant count; grants render as bullet rows.
Co-Authored-By: Claude <[email protected]>
* Address CodeRabbit nitpick: add error path test for /permissions
Adds TestPermissionsCommandCardHandlesGrantListError and a grantLister
interface so tests can inject a stub store without a real file path.
Co-Authored-By: Claude <[email protected]>
* Fix gofmt formatting in permissions card
Tabs alignment only; no functional change.
Co-Authored-By: Claude <[email protected]>
---------
Co-authored-by: Claude <[email protected]>
* feat(daemon): add `zero daemon` — worker pool + session supervisor over a local control socket
Adds internal/daemon + `zero daemon start|stop|status|run|attach`: a long-running
local service that supervises a pool of headless `zero exec` workers and routes
multiple agent sessions to them over an owner-only Unix-domain control socket.
Additive — interactive and one-shot exec are unchanged.
Reuses zero's building blocks:
- internal/background: ConfigureChildProcessGroup (process-group setup) +
TerminateProcess (cross-platform terminate) for worker kill/drain. The pool
does its own os/exec and uses background only for those helpers.
- internal/streamjson: the worker speaks stream-json on stdout; the daemon
forwards those lines to clients.
- internal/cli exec: a worker is `zero exec --output-format stream-json`.
Pieces (reimplemented in Go from a reference daemon):
- protocol.go: framed control codec — 4-byte BE length + 1 kind byte, 1 MiB cap,
JSON control messages, version negotiation.
- pool.go + backoff.go: bounded worker pool — at-least-once dispatch with
exponential backoff+jitter retry capped by MaxAttempts, EXIT_TEMPFAIL(75)/
EXIT_PERMANENT(76) handling, graceful drain + kill-timeout. zero's exec is
one-shot, so workers are on-demand (one session per worker = the lease), not
warm long-lived slots (a persistent worker mode is a follow-up).
- session.go: SessionManager — sessionID->worker routing, lease (one active
request per worker), queue when the pool is full, per-session output buffer for
attach + metrics.
- server.go + lock.go + socket.go + paths.go: single-instance PID-file lock with
stale-lock (dead-PID) recovery, status file, owner-only socket, accept loop;
cleans up lock/socket/status on exit.
- launcher.go: production worker launcher. client.go: control-socket client.
Security model:
- Control socket is LOCAL-ONLY (AF_UNIX loopback file, never a TCP port) and
owner-only: socket 0600 + parent dir 0700 on POSIX (Windows relies on the
per-user profile ACL; a dedicated pipe ACL is a follow-up).
- Workers stay sandboxed: the launcher SCRUBS the sandbox re-entrancy markers
(ZERO_SANDBOXED / ZERO_SANDBOX_BACKEND) from each worker's env so the worker
re-establishes its OWN sandbox instead of inheriting a pass-through plan, while
propagating the rest of the policy config/env. The daemon never bypasses the
sandbox.
- Control frames over the 1 MiB cap are rejected before allocation.
Robustness: a crashed worker never takes down the daemon (backoff+jitter retry,
MaxAttempts cap, EXIT_PERMANENT stops retry); drain gives a grace window then
force-kills stragglers; a stale lock from an unclean exit is reclaimed.
Out of scope (follow-up): remote session-linking; warm pre-spawned persistent
workers; Windows named-pipe ACL.
Tests: protocol round-trip + oversize/unknown-kind rejection; backoff bounds;
pool spawn/retry-backoff/permanent/cap/queue/drain; session lease/route/queue/
attach; lock single-instance + stale recovery; launcher env-scrub (incl. an
end-to-end check that the spawned worker process receives scrubbed env); server
e2e start->run->stream-json->status->attach->stop with a fake worker; CLI
usage/validation/not-running paths. Live-verified start/status/stop (0600 socket,
0700 dir, file cleanup). Platform files behind explicit //go:build tags.
* daemon: address review — help listing, extra-arg rejection, lock write error, bounded sessions
- cli/app.go: list `daemon` in `zero --help` so the command is discoverable.
- cli/daemon: `stop`/`status` reject extra arguments; `attach` rejects more than
one session id (fail fast on typos instead of silently ignoring tokens).
- daemon/lock: check the PID fmt.Fprintf error — a partial/failed write would
leave a malformed lock file that another process reads as stale and wrongly
reclaims, breaking the single-instance guarantee; on write failure remove the
file and return the error.
- daemon/session: bound the registry with MaxSessions (default 256) — evict the
oldest FINISHED sessions once past the cap so a long-running daemon doesn't
accumulate sessions without limit; running/queued sessions are never evicted, so
attach-after-finish for recent sessions is preserved.
Tests: CLI extra-arg rejection; finished-over-cap eviction + running-never-evicted.
Gates: gofmt, vet, build, test (incl -race), staticcheck (no new), govulncheck (0),
deadcode (no new), GOOS=linux+windows cross-compile.
Follow-up to the EnforceToolNetwork change: web_search/web_fetch were still
hard-denied under NetworkDeny because the engine-level gate in Evaluate
(netMode==NetworkDeny && risk has "network") fired BEFORE the tool's
RunWithSandbox/NetworkHostAllowed ever ran. The earlier change only relaxed
NetworkHostAllowed, so the user-facing path (Evaluate -> registry) still blocked
the tool with "network access is blocked by sandbox policy".
Fix: Evaluate now skips the network-deny for a request that declares
SideEffectNetwork (the first-party in-process tools, web_search/web_fetch) when
EnforceToolNetwork is off — mirroring the NetworkHostAllowed exemption so the two
gates agree. A SHELL command merely classified as network (SideEffectShell) is
NOT exempt, so shell egress stays blocked under deny. EnforceToolNetwork restores
the strict deny for the tools.
Tests: TestEvaluateExemptsNetworkToolsFromDenyByDefault covers the full path
(default-exempt, granted->allow, EnforceToolNetwork->deny, shell stays denied);
the two existing gate tests now opt into EnforceToolNetwork to keep testing the
strict path. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new),
govulncheck (0), deadcode (no new unreachable funcs).
* feat(sandbox): harden internal/sandbox with four opt-in capabilities
All four are off by default and fail closed; existing behavior is unchanged
when the new policy fields are empty / ZERO_SANDBOXED is unset.
1. Re-entrancy guard — ZERO_SANDBOXED=1 marks every wrapped command; a nested
BuildCommandPlan returns a pass-through plan (no double bwrap/sandbox-exec,
no second egress proxy). New: EnvSandboxed, IsAlreadySandboxed().
2. SOCKS5 egress — the scoped-egress proxy also serves SOCKS5 CONNECT on a
second loopback port through the SAME authorize()/domainAllowed() gate.
ProxyEnvWithSocks points ALL_PROXY at socks5://<addr>; HTTP(S)_PROXY stay on
the HTTP listener; the sandbox-exec profile allows both proxy ports.
Bubblewrap scoped still collapses to deny.
3. Sandbox-aware search — ReadExclusionGlobs(policy, scope) returns ripgrep
--glob '!<pat>' for DenyRead subtrees; grep/glob skip read-denied subtrees
at walk time via Engine.ReadPathExcluded/ReadDirExcluded when sandboxed.
4. Fine-grained path lists — Policy.AllowRead/DenyRead/AllowWrite/DenyWrite.
Read = deny-then-allow (more-specific AllowRead re-includes); Write =
DenyWrite > workspace/scope > AllowWrite > deny. AllowWrite is reflected in
the OS write binds; DenyWrite is emitted as sandbox-exec deny rules. Each
entry is home-expanded, made absolute, and symlink-resolved.
Tests: internal/sandbox/{reentrancy,egress_socks,pathlists}_test.go and
internal/tools/read_exclusions_test.go.
Gates: gofmt, go vet, go build, go test (incl. -race), staticcheck (no new
findings), govulncheck (0), deadcode -test=false (no new unreachable funcs).
* sandbox: address review — SOCKS5 method negotiation + cache read-exclusion roots
- egress_socks: respect SOCKS5 method negotiation (RFC 1928). The proxy now
inspects the client's offered methods and replies 0xFF ("no acceptable
methods") when no-auth was not offered, instead of forcing no-auth.
- pathlists/engine: resolve DenyRead/AllowRead roots ONCE per search walk via a
new ReadExclusions matcher (Engine.ReadExclusions), replacing the per-path
ReadPathExcluded/ReadDirExcluded that re-ran Abs/EvalSymlinks for every visited
path during a grep/glob walk.
Re-entrancy guard left as-is by design: ZERO_SANDBOXED is an internal marker set
only on commands zero wraps; cross-process re-entrancy detection legitimately
relies on the inherited env, and a manually-exported flag is a self-opt-out, not
an external bypass.
Tests: SOCKS no-acceptable-methods rejection; nested-AllowRead descent via the
matcher. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new
findings), govulncheck (0), deadcode (no new unreachable funcs).
* sandbox: fix windows smoke test + symlink-resolve DirExcluded prefix
- pathlists_test: TestSandboxExecProfileEmitsDenyWriteRule now builds the
expected deny clause via sandboxProfileString(secret), so it matches the
profile's own escaping (Windows doubles backslashes). Fixes the
windows-latest smoke failure; macOS/Linux unaffected (slash paths, no-op
escaping).
- pathlists: ReadExclusions.DirExcluded now EvalSymlinks-resolves the candidate
path before the nested-AllowRead prefix check, matching the resolution applied
to allowRoots — otherwise a denied dir reached THROUGH a symlink fails to match
a nested AllowRead root and is wrongly skipped, dropping a re-included subtree.
Added TestDirExcludedResolvesSymlinkPrefix (skips where symlinks are
unavailable).
Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings),
govulncheck (0), deadcode (no new unreachable funcs).
* sandbox: strengthen re-entrancy guard with a corroborating backend marker
Addresses review feedback that the re-entrancy guard trusted a single ambient
env var. IsAlreadySandboxed now requires BOTH correlated markers that
sandboxEnvironment sets together on every wrapped command — ZERO_SANDBOXED=1 AND
a non-empty ZERO_SANDBOX_BACKEND (new EnvSandboxBackend const) — so a lone
stray/hand-exported ZERO_SANDBOXED=1 no longer forces an unsandboxed
pass-through. Pass-through (direct) plans set neither marker, so genuine nesting
still detected and double-wrapping still avoided.
Tests updated: TestIsAlreadySandboxed now asserts each marker alone is
insufficient and both together are required; the re-entrancy and wrapping plan
tests set/clear both markers explicitly.
Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings),
govulncheck (0), deadcode (no new unreachable funcs).
* sandbox: don't subject first-party web tools to the network policy by default
The sandbox was reported as too strict: web_search (and web_fetch) were blocked
with "network access is disabled by the sandbox policy" because the default
policy is network=deny and the in-process web tools were gated by the same
NetworkHostAllowed check as the sandboxed shell's egress.
That network policy exists to confine the sandboxed SHELL's egress, which these
in-process tools don't use. New Policy.EnforceToolNetwork (off by default) makes
NetworkHostAllowed exempt web_search/web_fetch unless an operator opts in. The
sandboxed-shell egress decision (Evaluate / effectiveNetworkMode) is unchanged,
and the tools keep their own SSRF/private-IP, port, redirect, and redaction
safeguards. Set EnforceToolNetwork to hold the tools to the allow/scoped/deny
policy as before.
Tests: engine default-exempt + enforced-table cases; web_search default-allows
and enforced-when-flag; web_fetch enforced cases set the flag. Gates: gofmt, vet,
build, test (incl. -race), staticcheck (no new findings), govulncheck (0),
deadcode (no new unreachable funcs).
* sandbox: re-review fixes — disabled policy inert; path lists independent of EnforceWorkspace
- engine: ReadExclusions() / ReadExclusionGlobs() now return inert results under
ModeDisabled, so a disabled policy never filters grep/glob (parity with
Evaluate, which already allows everything when disabled).
- engine/pathlists: the fine-grained path lists (DenyRead/DenyWrite, with
AllowRead/AllowWrite) now apply in Evaluate whenever the sandbox is enforcing,
independent of EnforceWorkspace — matching the grep/glob path that already
honors DenyRead directly. The workspace boundary (scope.validate) stays gated
by EnforceWorkspace via a param threaded through validatePathWithPolicy /
validateWritePath; DenyWrite wins regardless.
- reentrancy_test: TestSandboxEnvironmentMarksSandboxed now asserts BOTH
correlated markers (ZERO_SANDBOXED=1 and ZERO_SANDBOX_BACKEND) so dropping
either would fail the test.
Tests added: path lists enforced with EnforceWorkspace off; ReadExclusions inert
under ModeDisabled. Gates: gofmt, vet, build, test (incl. -race), staticcheck
(no new findings), govulncheck (0), deadcode (no new unreachable funcs).
* sandbox: enforce path lists even without a workspace root
Re-review follow-up: the Evaluate path-validation loop was still guarded by
request.WorkspaceRoot != "", so an engine built without a workspace root skipped
DenyRead/DenyWrite/AllowRead/AllowWrite even for absolute paths. The loop now
runs unconditionally; the workspace boundary (scope.validate) stays gated on
having a root (enforceWorkspace = EnforceWorkspace && WorkspaceRoot != ""), while
the path lists match absolute paths regardless. An unanchorable relative path
(no workspace root) fails closed when anything is configured to enforce, and is a
no-op otherwise (unchanged from prior behavior).
Test: TestEvaluatePathListsWithoutWorkspaceRoot. Gates: gofmt, vet, build, test
(incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new
unreachable funcs).
* sandbox: base no-workspace fail-closed on normalized (enforceable) path lists
policyHasPathLists now resolves the lists (resolvePolicyPaths/
resolveWriteRootPaths) instead of counting raw config, matching how the rest of
the file normalizes them. A typo or non-existent entry (dropped during
resolution) therefore no longer makes validatePathWithPolicy fail-close every
relative request when there is no workspace root.
Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings),
govulncheck (0), deadcode (no new unreachable funcs).
* Harden sandbox command risk by wiring the AST analyzer
The AST-based command analyzer (sandbox/analyzer.go) was built and tested but never
reached at runtime — the live risk classifier used only the regex detectors. Wire
AnalyzeCommand into classifyWithScope as a second opinion. It walks the parsed shell tree,
so it catches destructive/network programs the regexes miss (shred, fdisk, parted; telnet,
ftp, sftp; and commands hidden behind sudo/env wrappers or a sh -c launcher) and flags an
unparseable (obfuscated) script as elevated risk.
Purely additive — it only ADDs risk categories, so a benign, parseable command classifies
exactly as before, and a program name inside a quoted argument is not flagged (AST
precision). Revives the dormant analyzer (sandbox unreachable funcs 13 -> 6). New tests cover
the regex-missed destructive/network programs, nested launchers, the unparseable signal, the
quoted-name no-false-positive case, and benign-stays-clean. Full suite + -race green.
* Wire sandbox auto-allow-bash env opt-in; drop orphaned batch-autonomy
Two dormant sandbox surfaces resolved:
- WIRE: AutoAllowBashWhenSandboxed was enforced live (engine.go) but never set — no
config field, and the ZERO_SANDBOX_AUTO_ALLOW_BASH env overlay (ApplyAutoAllowBashEnv)
was unwired, so the documented opt-in did nothing. Wire it through the central policy
builder (applyConfiguredSandboxPolicy, used by all engine constructions). Fail-safe: only
turns ON when the env is truthy, only honored when the shell sandbox is actually active,
default stays prompt, and an explicit config opt-in is preserved. Revives
AutoAllowBashEnvEnabled + ApplyAutoAllowBashEnv; adds a policy-builder test.
- DELETE: RequiredAutonomyForBatch/riskToAutonomy (batch.go + test) had no consumer — the
agent loop enforces autonomy per tool call (loop.go), so a batch-ceiling helper was
speculative and redundant. Wiring it would have meant fabricating a batch model.
Sandbox now has no dormant funcs; the only remaining unreachable entries are the
cross-platform seccomp pair (unixSocketBlockFilter is live on Linux; ApplyUnixSocketBlock
is the intentional non-Linux no-op stub).
* Assert risk level (and add wrapper cases) in AST analyzer tests
Per review (Vasanth + CodeRabbit): the AST hardening tests checked only the risk category,
not the escalation level, so a regression that downgraded destructive/network shell commands
while preserving the category would slip through. Assert risk.Level too — Critical for
destructive/network, High for unparseable — matching the pattern used throughout the file.
Add sudo/env wrapper cases (sudo/env shred, sudo telnet) to cover effectiveProgram's wrapper
unwrapping.
if [ "v$pkg_version" != "$GITHUB_REF_NAME" ]; then
echo "::error::tag $GITHUB_REF_NAME does not match package.json version v$pkg_version — bump package.json before tagging so the release assets and the npm package agree." >&2
exit 1
fi
echo "tag $GITHUB_REF_NAME matches package.json version v$pkg_version."
- name:Package release artifact
run:go run ./cmd/zero-release package
@@ -55,3 +79,164 @@ jobs:
name:zero-${{ runner.os }}-${{ runner.arch }}
path:dist/release/*
if-no-files-found:error
# On a `v*` tag ref (pushed manually, or dispatched at the tag by the
# release-please workflow), publish the packaged archive + .sha256 as GitHub
# Release assets so the documented `scripts/install.sh` / `install.ps1` path
# (which reads /releases/latest) works. Uses the preinstalled `gh` (no new
# action dependency); each matrix OS creates the release if absent
# (best-effort — release-please normally creates it first) then uploads its
# own assets with --clobber. Tags come from merging the release-please PR.
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
if ! curl -fsSLI -o /dev/null "$url"; then
echo "::error::${url} is not publicly downloadable — the wrapper's fallback downloader and install.sh would fail. Is the repo public and are all assets uploaded?" >&2
exit 1
fi
done
done
echo "all release assets for v${version} are publicly downloadable."
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)."
* **aimlapi:** AI/ML API provider with guided onboarding (top-up + key issuance) ([#655](https://github.com/Gitlawb/zero/issues/655)) ([6b9c2f0](https://github.com/Gitlawb/zero/commit/6b9c2f0c083e02bcd3aad68fb7af0501a9e7cd61))
* **cli:** show ZERO wordmark on --version ([#673](https://github.com/Gitlawb/zero/issues/673)) ([9acb411](https://github.com/Gitlawb/zero/commit/9acb4113cc3337b3f361a16278e9cf11ca105e34))
* **tui:** Ctrl+X leader chords and emacs menu navigation ([#699](https://github.com/Gitlawb/zero/issues/699)) ([7f669f4](https://github.com/Gitlawb/zero/commit/7f669f455021be51319ee3b8298cd48a17f745c7))
* **tui:** press up to edit queued messages ([#656](https://github.com/Gitlawb/zero/issues/656)) ([4c986d3](https://github.com/Gitlawb/zero/commit/4c986d327b5ed26338d295c23bea5839681db8d0))
### Bug Fixes
* **acp:** make truncateHint rune-safe ([#614](https://github.com/Gitlawb/zero/issues/614)) ([ddc4927](https://github.com/Gitlawb/zero/commit/ddc4927aac5544bf4dd2c46615aeda5a81c96576))
* **agent,tui:** resolve git branch detection when starting Zero in subdirectories ([#613](https://github.com/Gitlawb/zero/issues/613)) ([0184581](https://github.com/Gitlawb/zero/commit/0184581ec234ea414e0a47bc33e8b0f4ddfb497b))
* **agent:** raise default and deep-mode turn budgets ([#650](https://github.com/Gitlawb/zero/issues/650)) ([635c93a](https://github.com/Gitlawb/zero/commit/635c93af51ebc20f3e0917917e55a79edfe27c35))
* **cli:** prevent consuming positional arguments as flag values ([#619](https://github.com/Gitlawb/zero/issues/619)) ([5b4f48d](https://github.com/Gitlawb/zero/commit/5b4f48d2dcb66402c13bde0c3cfe9c9371da19fb))
* **config:** enforce MCP trust boundary so project config cannot override user disable ([#609](https://github.com/Gitlawb/zero/issues/609)) ([4d8c31c](https://github.com/Gitlawb/zero/commit/4d8c31cf16a3080344e3be7039408fcae71c075d)), closes [#512](https://github.com/Gitlawb/zero/issues/512)
* **config:** surface unknown/typo'd config fields instead of silently dropping them ([#645](https://github.com/Gitlawb/zero/issues/645)) ([893b7b4](https://github.com/Gitlawb/zero/commit/893b7b424cc203a2fcf92327a4e25c84286a90e0))
* **hooks:** fail closed on launch failures for beforeTool hooks ([#629](https://github.com/Gitlawb/zero/issues/629)) ([dc06fe7](https://github.com/Gitlawb/zero/commit/dc06fe72caf45f72d2cba1e8a835c0f5b405c1e8))
* **hooks:** run sessionEnd hooks after Esc/Ctrl+C interrupts ([#606](https://github.com/Gitlawb/zero/issues/606)) ([824ecdb](https://github.com/Gitlawb/zero/commit/824ecdbcf9c467c35ef4e2666770fdadcb5bf402))
* **keyring:** pass generic password via stdin on macOS ([#574](https://github.com/Gitlawb/zero/issues/574)) ([91ea6de](https://github.com/Gitlawb/zero/commit/91ea6ded7503538834a84d090f78670a363c62d3))
* **lock:** prevent POSIX lock file overwrite and leak on Windows/Unix ([#628](https://github.com/Gitlawb/zero/issues/628)) ([da41c3a](https://github.com/Gitlawb/zero/commit/da41c3a75b782d6e0836fe13346321e40a90fbb4))
* **sandbox:** remove windowsWriteRestricted flag to fix DenyRead bypass ([#612](https://github.com/Gitlawb/zero/issues/612)) ([3d96ac7](https://github.com/Gitlawb/zero/commit/3d96ac7e55c760a97f28c0e6ceaf1ec3b4ab717a))
* **sandbox:** scrub dynamic credential env vars ([#682](https://github.com/Gitlawb/zero/issues/682)) ([9043bae](https://github.com/Gitlawb/zero/commit/9043baedcff7776c7373645b57563cac06b31847))
* **sandbox:** scrub sensitive credentials from sandbox environment ([#660](https://github.com/Gitlawb/zero/issues/660)) ([6fc1220](https://github.com/Gitlawb/zero/commit/6fc1220f6ac66fb3ae67b637cbbed7068d2213c0))
* **sandbox:** unblock git fetch/commit/add under the write-restricted sandbox ([#654](https://github.com/Gitlawb/zero/issues/654)) ([5c4815a](https://github.com/Gitlawb/zero/commit/5c4815a66ed07d9cf90b825adfd936d3ac07639d))
* **sandbox:** use WRITE_RESTRICTED token when no DenyRead paths are configured ([#658](https://github.com/Gitlawb/zero/issues/658)) ([a5d2e32](https://github.com/Gitlawb/zero/commit/a5d2e327c8681671aa8a9e5378801215b747edcf))
* **securefile,credstore:** call Sync on temp file before close and rename ([#631](https://github.com/Gitlawb/zero/issues/631)) ([212734a](https://github.com/Gitlawb/zero/commit/212734adf3b5f982e22385161205aa1afe4634fe))
* **securefile:** reclaim stale lock files to prevent permanent DOS ([#615](https://github.com/Gitlawb/zero/issues/615)) ([8536cc8](https://github.com/Gitlawb/zero/commit/8536cc87f7a885f9e436d6ef28f5f325201623dc))
* **swarm:** wait for job.Runs directly in scheduler skip test ([#667](https://github.com/Gitlawb/zero/issues/667)) ([1bb6b57](https://github.com/Gitlawb/zero/commit/1bb6b5745af90321d1e657a12a1976cded5dd1bd))
* **tools:** classify silent wrapped Windows command failures as sandbox denials ([#659](https://github.com/Gitlawb/zero/issues/659)) ([8bd9742](https://github.com/Gitlawb/zero/commit/8bd9742fa95c41b93ea4e718628aed0ff3ae9dd0))
* **tools:** preserve SysProcAttr during PTY fallback ([#618](https://github.com/Gitlawb/zero/issues/618)) ([f78b36c](https://github.com/Gitlawb/zero/commit/f78b36c770daa4577a9f99265b18a354454e36eb))
* **tui:** stop the composer cursor blinking while typing or unfocused ([#672](https://github.com/Gitlawb/zero/issues/672)) ([2b42cd5](https://github.com/Gitlawb/zero/commit/2b42cd567b96d6f7e0818594e53ff31cce1e42e9))
* **windows:** resolve absolute path for taskkill to prevent hijacking ([#617](https://github.com/Gitlawb/zero/issues/617)) ([2db00ee](https://github.com/Gitlawb/zero/commit/2db00ee3d57db97e0fbe23cb8628e8bcb47f6f09))
* **modelregistry:** infer reasoning efforts for Hunyuan and vendor-prefixed model ids ([#599](https://github.com/Gitlawb/zero/issues/599)) ([92a92ce](https://github.com/Gitlawb/zero/commit/92a92ceb29dc63f5216ab140ef9a6dd9afe17df8))
* **tui:** show CLI version on the startup home screen ([#538](https://github.com/Gitlawb/zero/issues/538)) ([fd69233](https://github.com/Gitlawb/zero/commit/fd69233e334f1823a06b5794085a9255b3abdfa8))
* address bugs found in a multi-agent codebase audit ([#481](https://github.com/Gitlawb/zero/issues/481)) ([008bc9b](https://github.com/Gitlawb/zero/commit/008bc9b3f3ba13c7d4822b9559b020f381ff555b))
* **agent:** keep tools exposed for max-turn finalization ([#533](https://github.com/Gitlawb/zero/issues/533)) ([3f0503b](https://github.com/Gitlawb/zero/commit/3f0503bc2312ae29d5ade784d8824dc9a3524958))
* **auth:** persist OpenRouter API key after login ([#595](https://github.com/Gitlawb/zero/issues/595)) ([2a062aa](https://github.com/Gitlawb/zero/commit/2a062aa4014c6cd5e20a57dad4a685f88966f109))
* bump Go to 1.26.5 for crypto/tls fix (GO-2026-5856) ([#607](https://github.com/Gitlawb/zero/issues/607)) ([a7cfb99](https://github.com/Gitlawb/zero/commit/a7cfb99fed7b88ebc09a2f251cb82864d3c2cade))
* gitignore Windows sandbox helpers and npm version marker ([#578](https://github.com/Gitlawb/zero/issues/578)) ([25653f6](https://github.com/Gitlawb/zero/commit/25653f686c016f95b81ceb7ff5d5452d37c4d4f3))
* **sandbox:** Windows-appropriate suggestions when blocking interactive commands ([#414](https://github.com/Gitlawb/zero/issues/414)) ([ba4c007](https://github.com/Gitlawb/zero/commit/ba4c00755dc1c31b3dcca18e50b20f62c6bf5d1f))
* **tools:** block MSYS and WSL shells under the Windows sandbox ([#587](https://github.com/Gitlawb/zero/issues/587)) ([0666818](https://github.com/Gitlawb/zero/commit/066681855b80e3baf5d07d7397610b25f724e353))
* **tools:** platform-specific pager suggestions, quote/caret-safe cd detection ([#543](https://github.com/Gitlawb/zero/issues/543)) ([8b248f4](https://github.com/Gitlawb/zero/commit/8b248f4e1198dc86ab332a697fba4cf520823cbd))
* **tools:** Windows cmd.exe quoting guidance and clipboard escaping fix ([#468](https://github.com/Gitlawb/zero/issues/468)) ([f10ed0c](https://github.com/Gitlawb/zero/commit/f10ed0c893ce6de08923f143d681ba96f0fcfe3a))
* **tui:** bypass toggleSidebar and toggleMouse global shortcuts when composer is non-empty ([#576](https://github.com/Gitlawb/zero/issues/576)) ([c7346fb](https://github.com/Gitlawb/zero/commit/c7346fbcf01fe7e70ed2fcfccbf9965e5985727b))
* **tui:** paste protection for Termux char-by-char paste ([#573](https://github.com/Gitlawb/zero/issues/573)) ([8e9149f](https://github.com/Gitlawb/zero/commit/8e9149f1a296bebebf95cae9dd2a7c5156c9dbb6))
* **tui:** update picker_test for switchProviderModel 4-value return ([#589](https://github.com/Gitlawb/zero/issues/589)) ([8f15650](https://github.com/Gitlawb/zero/commit/8f156506dc92449bd24caa14e36f28477ba00fff))
* **update:** support safe symlink extraction during updates ([#575](https://github.com/Gitlawb/zero/issues/575)) ([ce9cb91](https://github.com/Gitlawb/zero/commit/ce9cb912958a3d9ae0b052bfebb849c28e0e719b))
* warn about untracked scratch files left behind after a run ([#571](https://github.com/Gitlawb/zero/issues/571)) ([062328b](https://github.com/Gitlawb/zero/commit/062328b632a2d27353a6d47c522bbd22d7282539))
### Performance Improvements
* **grep:** stop content scan after head limit ([#601](https://github.com/Gitlawb/zero/issues/601)) ([8a05e64](https://github.com/Gitlawb/zero/commit/8a05e6486f7a63281b869064db03dfc5531e6a04))
* add --auto flag for LLM-generated commit messages ([#423](https://github.com/Gitlawb/zero/issues/423)) ([b0abde7](https://github.com/Gitlawb/zero/commit/b0abde7d0697e808480cd59d69a6f4d0c6320475))
* add zero changes push and pr subcommands, and extra repo-info metrics ([#391](https://github.com/Gitlawb/zero/issues/391)) ([2312abe](https://github.com/Gitlawb/zero/commit/2312abe5ddd95f4c6ef373cfb61cc03092f48cdd))
* agent quality, caching, retry, and tooling upgrades ([#506](https://github.com/Gitlawb/zero/issues/506)) ([3c81fea](https://github.com/Gitlawb/zero/commit/3c81fea22873ee3df7fc97b10cb4f77792706c4b))
* **agent:** curb over-engineering the solution in the editing discipline ([#517](https://github.com/Gitlawb/zero/issues/517)) ([f4c998a](https://github.com/Gitlawb/zero/commit/f4c998ac30c4f07ff313a2d706791e857293be49)), closes [#516](https://github.com/Gitlawb/zero/issues/516)
* **agent:** inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt ([#475](https://github.com/Gitlawb/zero/issues/475)) ([7b10aab](https://github.com/Gitlawb/zero/commit/7b10aab74bf14a01166b2cea22deab79bba9850b))
* **providers:** add `zero providers models` to discover a provider's models ([#386](https://github.com/Gitlawb/zero/issues/386)) ([0bc8074](https://github.com/Gitlawb/zero/commit/0bc8074c97b0310e4a9d70c3f967003ee5e8a59f))
* **providers:** add KiloCode and OpenCode provider support ([#388](https://github.com/Gitlawb/zero/issues/388)) ([b1ccb6d](https://github.com/Gitlawb/zero/commit/b1ccb6d9c1875377f5e5ea81a1304edd1e41ab4f))
* **providers:** split minimax zai into intl cn ([#398](https://github.com/Gitlawb/zero/issues/398)) ([aaad4d2](https://github.com/Gitlawb/zero/commit/aaad4d271270f41af837b6f3b60ae80beba0c645))
* require manual approval before npm publish + drop release-as pin ([#369](https://github.com/Gitlawb/zero/issues/369)) ([bd89a1f](https://github.com/Gitlawb/zero/commit/bd89a1f451643c1b65ec803070abc7b116631ebe))
* **sandbox:** unelevated Windows fallback tier instead of prompts-only degrade ([#427](https://github.com/Gitlawb/zero/issues/427)) ([b9ddd6f](https://github.com/Gitlawb/zero/commit/b9ddd6f42138312a1fee8d8bb67c46c8eb1dea2f))
* support shift enter for composer newlines ([#462](https://github.com/Gitlawb/zero/issues/462)) ([daf65e0](https://github.com/Gitlawb/zero/commit/daf65e0af9a040314d4ab337b0ad59c55416b7bc))
* **tui:** /loop — repeat a prompt or command on an interval or self-paced ([#502](https://github.com/Gitlawb/zero/issues/502)) ([387fe67](https://github.com/Gitlawb/zero/commit/387fe67ee7cd81317c9c969f5906a4437080fea3))
* **tui:** add search/filter to provider picker in setup wizard ([#400](https://github.com/Gitlawb/zero/issues/400)) ([2fcea71](https://github.com/Gitlawb/zero/commit/2fcea71778d23e050c93409c471aef45b68c1621))
* **update:** add zero upgrade command to apply self-updates ([#461](https://github.com/Gitlawb/zero/issues/461)) ([5f36349](https://github.com/Gitlawb/zero/commit/5f36349c1884e81fa9bc66bb5fe813b627e897b7))
### Bug Fixes
* **action:** keep provider key scoped to zero step ([#448](https://github.com/Gitlawb/zero/issues/448)) ([407a927](https://github.com/Gitlawb/zero/commit/407a92739ff508cba32d2c12b3f36f0efcdd54c3))
* add android platform support for Termux npm install ([#455](https://github.com/Gitlawb/zero/issues/455)) ([9bd93c6](https://github.com/Gitlawb/zero/commit/9bd93c62f8d57fb74057284aa66a1b6e1429dcdd)), closes [#449](https://github.com/Gitlawb/zero/issues/449)
* **agent:** reject a malformed additional_permissions payload before prompting ([#453](https://github.com/Gitlawb/zero/issues/453)) ([e4f760e](https://github.com/Gitlawb/zero/commit/e4f760ee8bd57299cd2fcb37e8e23130037c2607))
* allow non-TLS connections to private-network provider endpoints ([#444](https://github.com/Gitlawb/zero/issues/444)) ([1d86384](https://github.com/Gitlawb/zero/commit/1d8638466ca31517eb9db2b9353d3dce1cbeeabc))
* **auth:** route zero auth login chatgpt to the dedicated ChatGPT flow ([#443](https://github.com/Gitlawb/zero/issues/443)) ([305a62c](https://github.com/Gitlawb/zero/commit/305a62c954ca6cec00bc58d5398f933415156aff))
* **config:** fall back to a usable saved provider instead of forcing full re-onboarding ([#410](https://github.com/Gitlawb/zero/issues/410)) ([c60ad87](https://github.com/Gitlawb/zero/commit/c60ad8729f79bb841114d352ee2d2fe29d5d0e41))
* **config:** let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible ([#497](https://github.com/Gitlawb/zero/issues/497)) ([30dd7c3](https://github.com/Gitlawb/zero/commit/30dd7c3112ad22d42fa12b5addd4e38f4beda42a)), closes [#479](https://github.com/Gitlawb/zero/issues/479)
* **config:** unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors ([#385](https://github.com/Gitlawb/zero/issues/385)) ([72eed06](https://github.com/Gitlawb/zero/commit/72eed06b4f94c43d75d31fe54a58d2f566de059e))
* **config:** use ~/.config on macOS and enter setup when no provider ([#371](https://github.com/Gitlawb/zero/issues/371)) ([#372](https://github.com/Gitlawb/zero/issues/372)) ([027a8f2](https://github.com/Gitlawb/zero/commit/027a8f2768b17b89f5c8270887f156e2ccda69ea))
* **install:** persist install dir to user PATH on Windows ([#407](https://github.com/Gitlawb/zero/issues/407)) ([bdb1b0e](https://github.com/Gitlawb/zero/commit/bdb1b0ecd15859b1712a6037d296dace7f9c3c3f))
* **oauth:** treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile ([#445](https://github.com/Gitlawb/zero/issues/445)) ([d05e914](https://github.com/Gitlawb/zero/commit/d05e9148a7f79f67d1d3c31fca2775f21fbd331e))
* **sandbox:** fix nested pipe creation under the Windows restricted token ([#456](https://github.com/Gitlawb/zero/issues/456)) ([563a6db](https://github.com/Gitlawb/zero/commit/563a6dbe91e65d5daeefd7626e8a77e30a6d8fb2))
* **sandbox:** gate /tmp test assertions on GOOS, not path existence ([#426](https://github.com/Gitlawb/zero/issues/426)) ([f653dca](https://github.com/Gitlawb/zero/commit/f653dcac363fb69ad7be5b35e6e0fa6d2bce476d))
* **sandbox:** self-heal a corrupt unelevated setup marker ([#437](https://github.com/Gitlawb/zero/issues/437)) ([8d0c5fe](https://github.com/Gitlawb/zero/commit/8d0c5feccb8bdbfb015df0508aa6e3bcbd1fd0e8))
* **specialist:** cap max specialist nesting depth ([#491](https://github.com/Gitlawb/zero/issues/491)) ([177442c](https://github.com/Gitlawb/zero/commit/177442cfe4015bd8df04cc9894f98b468ee796d4))
* **tools:** Block MSYS coreutils under Windows sandbox ([#476](https://github.com/Gitlawb/zero/issues/476)) ([81aad58](https://github.com/Gitlawb/zero/commit/81aad58d97839e51c068e2f08907618991fdc3fb))
* **tools:** CRLF line ending mismatch in edit_file tool on Windows ([#378](https://github.com/Gitlawb/zero/issues/378)) ([33dc7ae](https://github.com/Gitlawb/zero/commit/33dc7ae2cc82c5389675531e1416856dae7151ce))
* **tools:** flag piped POSIX utilities before running on Windows ([#412](https://github.com/Gitlawb/zero/issues/412)) ([5658a36](https://github.com/Gitlawb/zero/commit/5658a366274fc59a9d5336b06a21019c9c25cbf1))
* **tools:** make grep and glob respect run cancellation ([#464](https://github.com/Gitlawb/zero/issues/464)) ([ba6c026](https://github.com/Gitlawb/zero/commit/ba6c0264697b7d7ed479f6e782fba9700a481e3d))
* **tools:** require permission before web_search requests ([#382](https://github.com/Gitlawb/zero/issues/382)) ([960db96](https://github.com/Gitlawb/zero/commit/960db9660e4e31dc588fe8f7d6f116ff5e225566))
* **tui:** compose help overlay through the viewport overlay pipeline ([#421](https://github.com/Gitlawb/zero/issues/421)) ([5b2b4de](https://github.com/Gitlawb/zero/commit/5b2b4dea1aaf9e0f68baa25e97e83296fb17b1a2))
* **tui:** keep the profile name on /model switch so the stored key resolves ([#441](https://github.com/Gitlawb/zero/issues/441)) ([9134148](https://github.com/Gitlawb/zero/commit/9134148f4df3e4e556fba6c2f8babfdf6fcfeee1)), closes [#440](https://github.com/Gitlawb/zero/issues/440)
* **tui:** resolve every permission request so the agent can't deadlock ([#397](https://github.com/Gitlawb/zero/issues/397)) ([952788f](https://github.com/Gitlawb/zero/commit/952788f72d32957659fe004521fcc8372b9ba9b4))
* **tui:** show an M suffix for million-scale token counts ([#457](https://github.com/Gitlawb/zero/issues/457)) ([0562e3b](https://github.com/Gitlawb/zero/commit/0562e3bef7df2328610a48a1e81632a8da4aec64))
* **tui:** title /model rows by model name, not the catalog description ([#395](https://github.com/Gitlawb/zero/issues/395)) ([cdf9d83](https://github.com/Gitlawb/zero/commit/cdf9d839ae57a729f292f36f7c5b0c67b41b288d))
### Performance Improvements
* cache TUI model registry ([#496](https://github.com/Gitlawb/zero/issues/496)) ([e7d88b4](https://github.com/Gitlawb/zero/commit/e7d88b4b518049733da25a8447c00144bd1da518))
* publish zero to npm via release-please ([#367](https://github.com/Gitlawb/zero/issues/367)) ([8eccc26](https://github.com/Gitlawb/zero/commit/8eccc2669887bc38d35bc16a315c888e4d9ec43a))
* **tui:** FILES sidebar panel with click-to-select and file drill-in ([#365](https://github.com/Gitlawb/zero/issues/365)) ([142c548](https://github.com/Gitlawb/zero/commit/142c548c89a8652ce300e64ddf1228ee36df7606))
### Bug Fixes
* **auth:** propagate credentials to every provider-build surface and pin children to the live provider ([#366](https://github.com/Gitlawb/zero/issues/366)) ([6e0a665](https://github.com/Gitlawb/zero/commit/6e0a665118fe0e09c4b07d482dd18f86045acd2b))
## [Unreleased]
### Added
- Shared multi-agent skills discovery: when present, `~/.agents/skills` is searched after the primary
Zero skills dir (and before plugin skill roots). `zero skills list` / `info` and the runtime `skill`
tool share one multi-root discovery path; install/remove/lock still target only the Zero skills directory.
-`SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
GitHub issue/PR templates.
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
and applies on select (Esc reverts).
- Twelve built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
Zero is an AI coding agent that lives in your terminal. It runs a full agentic loop — reading, searching, editing, and executing in your repo — against **whatever model you choose**: frontier APIs, fast cloud inference, or a local model on your own machine. One interface, no vendor lock-in, no telemetry phoning home.
Zero is an AI coding agent for your local terminal. It can inspect a repository,
edit files, run commands, use browser/terminal helpers, and keep durable local
sessions while you choose the model and the permission level.
```bash
zero# interactive TUI
zero exec"fix the failing test in ./pkg"# headless one-shot
zero exec -o stream-json < turns.jsonl# programmatic, for scripts & CI
zero
zero exec"fix the failing test in ./pkg"
zero exec --output-format stream-json < turns.jsonl
```
> Zero treats the **model as a swappable, per-task choice** and **never mutates your system without a permission decision**.
## Why Zero
-🔌 **25+ providers, one interface** — OpenAI, Anthropic, Gemini, Ollama (local & cloud), LM Studio, OpenRouter, Groq, DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, and any OpenAI- or Anthropic-compatible endpoint. Switch mid-session with `/model`.
- 🖥️ **A TUI that feels premium** — truecolor Bubble Tea interface with a first-run setup wizard, searchable live model picker, scrollback, themes, image input, and slash commands for everything.
- 🤖 **Headless & scriptable** — `zero exec` with `text` / `json` / `stream-json` I/O, session resume & fork, isolated `--worktree` runs, and meaningful exit codes. Built for CI.
-🧠 **Subagents** — delegate to built-in `worker`, `explorer`, and `code-review` specialists (or generate your own) that run as real background tasks, even out-of-process.
- 📋 **Spec mode** — have the agent draft a spec first, review and approve it, *then* let it build. No more runaway sessions.
-📈 **Mid-run model escalation** — start cheap, and let the agent request a stronger model only when it hits a wall (`--allow-escalation`).
- 🗺️ **Repo intelligence** — deterministic repo maps, workspace indexing, and context-budget reports keep the agent grounded in *your* codebase, not hallucinations.
- ⏰ **Scheduled agents** — `zero cron` runs file-backed, dependency-free agent jobs on a schedule.
-🛡️ **Safe by default** — permission-gated mutations, autonomy ceilings, sandbox policy (writes stay inside the workspace unless you grant extra directories with `--add-dir` / `/add-dir`), and secret redaction everywhere. Unsafe mode is an explicit, loudly-labeled opt-in.
- 💾 **Durable sessions** — append-only local event store with full-text search, resume, fork, and rewind. Your history never leaves your disk.
- 🧩 **Extensible** — skills, plugins, hooks, and MCP (Zero is both an MCP client *and* an MCP server).
-**Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, 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.
-**Works in the terminal.** The TUI has model/provider pickers, image input,
slash commands, live plan/tool rendering, scrollback, themes, and resume/fork
support.
-**Works without the TUI.** `zero exec` is scriptable, supports text/JSON/
stream-JSON I/O, isolated worktrees, spec-first runs, and meaningful exit
codes for CI.
- **Keeps context local.** Sessions are stored on disk, searchable, resumable,
and never uploaded as telemetry by Zero.
- **Extensible when you need it.** Use MCP servers, skills, plugins, hooks, and
specialist subagents from the same CLI.
## Quick start
## Install
### npm
```bash
# run from source (requires Go 1.24+)
npm install -g @gitlawb/zero
zero
```
The npm package is a small wrapper whose platform build (Linux and macOS on
x64/arm64, Windows on x64 — including the browser/terminal control helpers)
installs as an optional dependency straight from the npm registry — no install
scripts, no downloads outside npm. Bun, pnpm, and yarn work the same way with
no trust or approval steps. Installs that skip optional dependencies
(`--omit=optional`) still work: the wrapper fetches the binary from the
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
The model registry tracks each model's capabilities, context window, and cost — and the live model picker discovers what your provider actually serves.
- **Surface-agnostic core** — the agent loop streams text + tool calls and emits one typed event stream consumed identically by the TUI, `exec`, the MCP server, and cron.
- **Edges are interfaces** — `Provider`, `Tool`, `SessionStore`, and the permission policy are swappable.
- **Model is data** — capabilities, cost, and routing live in the registry, never hard-coded.
- **Pure Go** — one static binary per platform; the npm wrapper just delegates to it.
Meaning does not rely on color alone; diffs, permissions, and statuses also use
text or glyph markers.
## Development
```bash
go test ./...# full test suite (200+ test files)
go run ./cmd/zero-release build# compile the release binary
go run ./cmd/zero-release smoke# smoke-test it
go run ./cmd/zero-perf-bench# perf benchmarks (docs/PERFORMANCE.md)
go test ./...
go run ./cmd/zero-release build
go run ./cmd/zero-release smoke
go run ./cmd/zero-perf-bench
```
# cross-compile
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
session (background connection prewarm + request-prefix telemetry) for headless
`zero exec` runs against official OpenAI profiles. Off by default; `0`/`false`
disable. A/B-benchmark it by running the same `zero-perf-bench` suite with the
variable unset and set.
### Code Quality and Security Checks
Before committing any changes, ensure all Go code quality and security checks pass. Pinned `go run` commands matching CI constraints can be used directly without prior installation:
1.**Formatting**: Run `go fmt ./...` (or `make fmt`).
2.**Vetting**: Run `go vet ./...` (or `make vet`).
3.**Linting**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
4.**Vulnerability Scan**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
If you prefer to install these tools globally on your path, you can run:
```bash
# Install golangci-lint
go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
_,_=fmt.Fprintln(stderr,"[zero] Turn benchmark failed: every task errored with no accepted benchmark sample (see warnings); the report contains no valid measurements")
Zero is an open-source terminal coding agent. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
If you only want to *use* Zero, the [README](../README.md) is enough. This page is for the other three jobs:
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
2. Add new specialist sub-agents.
3. Wire Zero into the rest of your toolchain (MCP, skills, hooks, plugins).
## 1. Drop a project `AGENTS.md`
When Zero starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`).
Accepted file names, in priority order at each level:
| Path | Notes |
| --- | --- |
| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. |
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
```markdown
# Project conventions for <your project>
- Build with `make`, not `go build` directly.
- Tests live next to the source file (`foo_test.go` next to `foo.go`).
- Run `make lint` before opening a PR.
- Never edit files under `third_party/` — those are vendored.
```
Tips:
- Keep each file under ~8 KiB. Zero caps the **total** across all matched files at 32 KiB; everything past the cap is dropped.
- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter".
- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use `config.json` for those.
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
### Personal guidelines, across every project
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
## 2. Custom specialists
Specialists are Zero's sub-agents. Three scopes, in priority order:
| Scope | Path | Shared? |
| --- | --- | --- |
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
| Project | `./.zero/specialists/*.md` | yes — the repo team |
Project overrides user overrides built-in when names collide.
A specialist is a markdown manifest with frontmatter and a system prompt:
```markdown
---
description:Reviews API changes for breaking-change risk and missing tests.
tools:read-only,plan
---
You review API changes. For every changed hunk in `internal/api/` or any file
that ends in `_api.go`:
1. Confirm the public signature is backward-compatible, or note the breaking
change explicitly with the migration path.
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
the new behaviour is exercised.
3. Flag any new exported symbol without a doc comment.
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
```
CLI management (the prompt is passed inline via `--prompt`):
```bash
zero specialist list
zero specialist show api-reviewer
zero specialist create api-reviewer \
--project \
--description "Reviews API changes"\
--tools read-only,plan \
--prompt "$(cat api-reviewer.md)"
zero specialist edit api-reviewer --project
zero specialist delete api-reviewer --project
zero specialist path # prints the resolved specialists directory
```
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](SPECIALISTS.md).
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
## 3. Skills
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Standalone project skill directories are not supported in this version (shared project-wide skills must go in `AGENTS.md` or as a hook). However, project plugins (section 6) may bundle skills, which are merged into the active run.
Discovery roots (earlier wins on name collisions):
1.**Primary Zero dir** — `$ZERO_SKILLS_DIR` if set, else `$XDG_DATA_HOME/zero/skills`, else `~/.local/share/zero/skills/`
2.**Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
3.**Plugin skill roots** — skills bundled by active plugins (section 6)
A missing directory is fine — Zero just omits it. Management commands (`zero skills add` / `remove` / `lock`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`.
description:Run the project's benchmark suite and summarize the deltas.
---
# Run benchmarks
1.`make bench` — captures the wall-clock and RSS before and after.
2.`benchstat before.txt after.txt` — diffs the two.
3. Report any regression > 5% with the function name and the previous value.
```
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. Within a single skills root, duplicate names are resolved by lexicographic directory order. Across roots, Zero loads the primary directory first, then `~/.agents/skills`, then plugin skill roots; earlier roots win name collisions. Plugin-declared skills (section 6) are merged into the active agent run at plugin activation time, so bundled skills appear in the available skills list and can be loaded with the `skill` tool.
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
## 5. MCP — Model Context Protocol
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
### As a client — configure MCP servers in `config.json`
zero mcp add docs --type stdio -- docs-mcp --port 7777
zero mcp add github --type http --url https://api.example.com/mcp \
--header "Authorization=Bearer YOUR_TOKEN_HERE"
zero mcp list
zero mcp check docs
zero mcp remove github
zero mcp oauth login github
```
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
- A wrapper script that sources the secret and execs the real command.
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
Project MCP config remains supported for shared team servers, but a project layer
that changes an existing server's target (`url`, `command`, or `args`) does not
inherit user credentials. Clear or replace `headers`, `env`, or `oauth` explicitly,
or use a new server name. OAuth tokens are bound to the resolved server identity,
so changing a server target can require `zero mcp oauth login <server>` again.
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
### As a server — expose Zero's tools to another agent
```bash
zero serve --mcp
zero serve --mcp -C /path/to/workspace
zero serve --mcp --add-dir /path/to/extra
```
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
`-C/--cwd` sets the workspace root exposed as MCP resources and used by core tools. `--add-dir` (repeatable) widens that resource/tool scope beyond the workspace without granting the sandbox temp roots used by interactive runs.
## 6. Plugins
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
- User: `~/.config/zero/plugins/<id>/`
- Project: `./.zero/plugins/<id>/`
Each plugin has a `plugin.json` manifest:
```json
{
"id":"github-pr-review",
"name":"GitHub PR Review",
"description":"Adds review tools for GitHub PRs.",
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins remove github-pr-review # alias: rm
```
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above.
## 7. Configuration locations
Five configuration sources, in precedence order (later sources override earlier ones):
| Source | Path / Key | Notes |
| --- | --- | --- |
| Built-in defaults | compiled in | Lowest priority. |
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
## 8. End-to-end example
A team that wants every contributor's Zero to behave the same way commits:
> provider-neutral: `api-key-env` is the environment variable name the chosen
> provider reads its key from (for example `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`,
> `OPENROUTER_API_KEY`), and `api-key` is the secret value. The action exports
> that variable for the run and never prints it. If your repository already
> commits a `.zero/config.json` with an active provider, you can omit `provider`.
> that variable only for the ZERO step and never prints it. If your repository
> already commits a `.zero/config.json` with an active provider, you can omit
> `provider`.
## Inputs
@@ -51,8 +52,8 @@ jobs:
| `prompt` | one of `prompt`/`prompt-file` | `""` | The instruction for ZERO to execute. |
| `prompt-file` | one of `prompt`/`prompt-file` | `""` | Path (relative to `working-directory`) to a file whose contents are the prompt. |
| `provider` | no | `""` | Provider id to activate (e.g. `openai`, `anthropic`, `gemini`, `ollama`, or any compatible endpoint). |
| `api-key` | no | `""` | The provider API key. Pass from a secret; never logged. |
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. |
| `api-key` | no | `""` | The provider API key. Pass from a secret; exported only for the ZERO step and never logged. |
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported only for the ZERO step when used with `api-key`. |
| `model` | no | `""` | Model id. Defaults to the resolved provider's default. |
| `mode` | no | `""` | Run mode (`zero exec --mode`), e.g. `smart`, `deep`, `fast`. |
| `auto` | no | `low` | Autonomy ceiling (`zero exec --auto`): `low`, `medium`, or `high`. Conservative by default. |
@@ -162,9 +163,10 @@ jobs:
## Security notes
- **Secrets are passed as Action secrets and never logged.** The action exports
`api-key` (under `api-key-env`) and`slack-webhook-url` into the step
environment only. They are never echoed, and ZERO redacts secret-shaped
strings from its own output.
`api-key` (under `api-key-env`) only for the ZERO step.`slack-webhook-url`
is passed only to the Slack post step. They are never echoed or persisted to
later workflow steps, and ZERO redacts secret-shaped strings from its own
output.
- **The sandbox is always active.** This action never passes
`--skip-permissions-unsafe`, so writes stay inside the checked-out repository
(plus any roots you grant with `add-dir`). Unsafe mode is never enabled
@@ -198,5 +200,4 @@ webhook:
- **Redaction:** the message, summary, links, and the webhook URL itself are run
through ZERO's redaction before being sent or logged, so tokens never leak.
- **Egress/proxy:** the notifier uses the default HTTP transport, which honors
`HTTP_PROXY`/`HTTPS_PROXY`. In a sandboxed run the scoped-egress proxy is
respected, so a webhook host that is not on the allowlist is denied.
`HTTP_PROXY`/`HTTPS_PROXY` when a proxy is configured.
| “Maybe compact context” | [Context Budget and Compaction](#context-budget-and-compaction) |
| Model requests a tool | [Tool Execution Lifecycle](#tool-execution-lifecycle), then [Tools and Capabilities](#tools-and-capabilities) |
| Tool needs approval or sandbox access | [Permission and Sandbox Model](#permission-and-sandbox-model) |
| Events appear in the UI or stream-JSON | [How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect), [Stream-JSON Protocol](#stream-json-protocol) |
| Resume, fork, rewind, or sub-agent history | [Session Storage and History](#session-storage-and-history) |
## Main Packages
The sections above describe the architecture in concepts. If you want to connect
those concepts to implementation details, use this table as the code map before
| `internal/agent` | Core loop that sends messages to the model, streams output, executes tools, handles permissions, compaction, retries, and final answers. |
Ship the first production-shaped headless command for Zero:
```sh
zero exec"inspect this repo and summarize the next fix"
```
This command should be useful for local scripts, CI smoke runs, and later VS Code integration without pulling in the full future protocol, session, MCP, sandbox, or plugin roadmap.
## Non-Goals
- No daemon mode.
- No JSON-RPC input protocol.
- No MCP, plugin, marketplace, or subagent support.
- No session resume/fork/search.
- No new provider adapters in this slice.
- No architecture rewrite of the agent loop.
## User Stories
1. As a CLI user, I can run one prompt headlessly and receive the assistant answer on stdout.
2. As a script author, I can choose `text` or JSONL output.
3. As a reviewer, I can see tool activity without corrupting text stdout.
4. As a model tester, I can override the configured model for one run.
5. As a cautious user, prompt-gated tools stay disabled unless I pass an explicit unsafe flag.
## CLI Contract
```sh
zero exec[prompt...][options]
```
Options for M1:
-`-f, --file <path>`: read the prompt from a file.
-`-m, --model <model>`: override the configured model for this run.
-`-C, --cwd <path>`: run from another working directory.
-`--skip-permissions-unsafe`: grant prompt-gated tools for this run.
The existing `zero -p "prompt"` shortcut remains as a compatibility path and should call the same runner.
## Output Contract
Text mode:
- stdout contains assistant text only.
- stderr contains tool rows, warnings, and errors.
JSON mode:
- stdout is newline-delimited JSON events.
- Required event types for this slice: `run_start`, `text`, `tool_call`, `tool_result`, `final`, `warning`, `error`, `done`.
## Exit Codes
-`0`: success
-`1`: unexpected crash
-`2`: usage error
-`3`: provider/config/runtime error
-`4`: reserved for future tool failure policy
-`5`: reserved for future permission-denied policy
## Permission Behavior
Default headless mode advertises only tools marked `permission: "allow"`.
`--skip-permissions-unsafe` advertises tools that are not marked `deny` and grants prompt-gated tools through `ToolRegistry.run`. It must not bypass schema validation or direct tool execution safeguards.
## Acceptance Criteria
-`go test ./...` passes.
-`go run ./cmd/zero-release build` passes.
-`go run ./cmd/zero-release smoke` passes.
-`zero exec --help` documents the M1 flags.
- Usage errors do not initialize providers.
- Provider failures return exit code `3`.
- Normal mode does not advertise `bash`, `write_file`, `edit_file`, or `apply_patch`.
- Unsafe mode still uses the registry execution path.
{"schemaVersion":1,"type":"permission_request","runId":"run_20260603_abc123","id":"call_2","name":"write_file","action":"prompt","permission":"prompt","permissionMode":"ask","sideEffect":"write","reason":"Creates or overwrites files."}
{"schemaVersion":1,"type":"permission_decision","runId":"run_20260603_abc123","id":"call_2","name":"write_file","action":"allow","permission":"prompt","permissionGranted":true,"decisionReason":"approved in TUI"}
{"schemaVersion":2,"type":"permission_request","runId":"run_20260603_abc123","id":"call_2","name":"write_file","action":"prompt","permission":"prompt","permissionMode":"ask","sideEffect":"write","reason":"Creates or overwrites files."}
{"schemaVersion":2,"type":"permission_decision","runId":"run_20260603_abc123","id":"call_2","name":"write_file","action":"allow","permission":"prompt","permissionGranted":true,"decisionReason":"approved in TUI"}
`zero update --check` checks the latest GitHub release for `Gitlawb/zero` and compares it with the local CLI version.
`zero update --check` checks the latest GitHub release and compares it with the
local CLI version.
```bash
zero update --check
zero update --check --json
zero update --check --repo Gitlawb/zero
zero update --check --endpoint https://api.github.com/repos/Gitlawb/zero/releases/latest
zero update --check --timeout 3s
zero update --check --target windows-x64
```
For M2 this command is intentionally check-only:
The command is intentionally check-only:
- It does not replace the running binary.
- It exits with code `0` when the check succeeds, even when an update is available.
- It exits with code `0` when the check succeeds, even when an update is
available.
- It exits with code `1` when the release check cannot be completed.
-`--json` prints the same result in a machine-readable format for scripts and CI.
-`--repo <owner/repo>` checks a different GitHub repository when no endpoint is provided.
-`--endpoint <url|owner/repo>` checks a specific release API URL or repository slug.
-`--timeout <duration>` overrides the default release check timeout.
-`--target <platform-arch>` validates release metadata for another supported target from the current machine.
- Release checks time out after 5 seconds by default.
- It validates that the latest release includes the expected archive and matching `.sha256` asset for the selected platform target.
-`--json` prints the same result in a machine-readable format for scripts and
CI.
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`, `windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current platform.
Useful flags:
The release endpoint resolves in this order:
| Flag | Purpose |
|---|---|
| `--repo <owner/repo>` | Check another GitHub repository. |
| `--endpoint <url|owner/repo>` | Check a specific release API URL or repository slug. |
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`,
`windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current
platform.
`--endpoint`, `Options.Endpoint`, and `ZERO_UPDATE_RELEASE_URL` may be a full URL or an `owner/repo` slug.
Endpoint resolution order:
Installer scripts download the matching release asset for the local platform and verify its `.sha256` file. If Zero is already installed, use this command before re-running the installer.
# Zero WorkSplit PRD v3: Go-Native Product Ownership Plan
Status: Draft v3.0
Date: 2026-06-06
Timeline: Active sprint plan toward v0.1, then v1.0 hardening
Team:
- Vasanth
- Gnanam
- Anandan
## 1. Product Goal
Zero is a terminal-first AI coding agent built in Go.
The goal is not just to remove the old TypeScript runtime. The goal is to make Zero feel like a mature daily-driver CLI agent:
- fast startup
- strong command UX
- safe autonomy
- clear permission handling
- useful local tools
- reliable provider/runtime behavior
- installable, updateable, and testable on real developer machines
This document replaces the older migration-era work split. The Go runtime is now the main product baseline, so future work should focus on product depth rather than repeating the old M0 migration plan.
## 2. Current Baseline On Main
The current main branch is Go-native for the runtime and CLI.
Implemented baseline:
-`cmd/zero` is the primary CLI entrypoint.
-`cmd/zero-pr-review` exists for PR automation workflows.
- Core runtime packages live under `internal/`.
- Current CLI surface includes `exec`, `config`, `models`, `providers`, `doctor`, `search`, `sessions`, `plugins`, `hooks`, `mcp`, `sandbox`, `update`, `worktrees`, `verify`, `changes`, `serve`, `help`, and `version`.
- Core areas exist for agent flow, provider/model registry, tools, TUI, sessions, search, sandbox, self-verification, stream-json, usage, redaction, hooks, MCP, plugins, update, worktrees, and git/change handling.
- TypeScript is no longer part of the repository baseline. The npm package keeps a small Node wrapper that delegates to the Go binary.
Still needed before Zero feels production-ready:
- Permission and sandbox UX must become clearer and harder to misuse.
- TUI command flows need polish beyond basic rendering.
- Platform behavior, especially Windows sandbox/update/install paths, needs stronger product ownership.
- Review automation should produce useful, repeatable output for maintainers.
| `mcp`, `hooks`, `plugins`, `serve` | Discoverability and UI | Backend lifecycle and contracts | Automation/docs after behavior exists |
| `sessions`, `search` | Resume/search UX | Storage, query, and event model | Stream-json and automation consumers |
## 10. Definition Of Done
For every code PR:
- Add or update tests for the changed behavior.
- Run `go test ./...` when Go files changed.
- Run the relevant build command when CLI entrypoints changed.
- Run package smoke checks when install/update/npm wrapper files changed.
- Include manual CLI output or screenshots only when they prove a user-facing flow.
For docs-only PRs:
- State clearly that no runtime behavior changed.
- Do not use docs-only PRs as a substitute for platform product work.
For Anandan's PRs specifically:
- Must include one of:
- user-runnable command behavior
- install/update/release artifact verification
- platform adapter behavior
- CI that executes real installed Zero commands
- Docs/CI can support the PR, but cannot be the entire value.
## 11. Review Rotation
- Vasanth PRs:
- Gnanam reviews runtime contracts.
- Anandan reviews platform/release impact when touched.
- Gnanam PRs:
- Vasanth reviews user-facing contract impact.
- Anandan reviews platform/protocol impact when touched.
- Anandan PRs:
- Vasanth reviews user-facing CLI behavior.
- Gnanam reviews runtime contract and safety impact.
## 12. Retired Historical Plan
The old M0-M6 TypeScript-to-Go migration schedule is now historical.
Do not assign new work from the old migration next-steps section. The Go migration foundation has already landed on main. Future work should move Zero from "ported" to "mature terminal coding agent."
> Pending by severity: **high 2, medium 42, low 34**. Counts above reflect this re-verification
> plus this branch's own fixes (scanner.go:81, compaction.go:58, worktrees.go:247).
| Sev | Status | Location | Evidence |
|---|---|---|---|
| critical | fixed | internal/search/search.go:346 | search.go:363-428 findMatch maps lowered offsets back via lowerWithOffsets + snapRuneStart clamps context; /tmp probe of audit repro (100x'Ⱥ'+' hello') returns match {201 206}, valid UTF-8, no panic |
| critical | fixed | internal/mcp/protocol.go:69 | 82b9c8d: const maxMessageBytes=64MiB (protocol.go:18) bounds both framing paths — Content-Length values above the cap are rejected (:90-92) and the newline-delimited readLine errors past the cap (:78-80); stdio transport switched to newline-delimited JSON. Covered by protocol_test.go; go test green |
| high | fixed | internal/cli/usage.go:121 | usage.go:123-131: diff defaults to zerogit.DiffStat{}; resolveWorkspaceRoot/inspectChanges errors are ignored (degrade to net-LOC 0) instead of returning |
| high | fixed | internal/tui/transcript.go:137 | transcript.go:121-142 keys are run-scoped ("%d:%d:%s" with row.runID) plus effectiveToolRowID ordinal suffix; model.go:1501/1553/1572/1584 set runID on rows |
| high | fixed | internal/tui/model.go:853 | model.go:1034-1043 commandExit now calls cancelRun(), sets exiting, and defers tea.Quit while flushRunIDs is non-empty; deferred quit fires after flush at model.go:645-647 |
| high | fixed | internal/tui/model.go:599 | internal/tui/flush.go:11-30 settled-row flush frontier prints history to native scrollback via tea.Println; model.go transcriptView() renders only live tail from m.flushed; covered by internal/tui/flush_test.go |
| high | fixed | internal/tui/model.go:512 | model.go:614,633-637 flush goes to flushSessionID recorded at cancel time (model.go:1334) via appendSessionEventsTo (tui/session.go:76), not the active session |
| high | fixed | internal/tui/session_controls.go:176 | session_controls.go:182-184 blocks /rewind while len(m.flushRunIDs) > 0 ('cannot rewind while a cancelled run is still flushing'); covered by tui/session_test.go |
| high | fixed | internal/tui/model.go:584 | flush.go:71-120 settles rows into native scrollback via tea.Println; View()/transcriptView renders only rows from m.flushed (model.go:751-784) |
| high | fixed | internal/tui/transcript.go:137 | transcript.go:121-141 bakes runID into every key; within-run repeats get ordinal suffix via effectiveToolRowID (transcript.go:148, used at model.go:1493-1496 with callSeq) |
| high | fixed | internal/tui/rendering.go:525 | live cap 16 kept for tidy tail only; flushed scrollback cards use flushCardBodyMaxLines=400 (rendering.go:154-158) and Ctrl+O detailed view is uncapped (renderRowDetailed bodyCap:0, rendering.go:165; capCardLines treats <=0 as no cap, rendering.go:718-720) |
| high | fixed | internal/tui/transcript.go:135 | transcript.go:121-141 transcriptRowKey bakes runID into every key ("%d:%d:%s"); model.go:1501/1553 set row.runID and effectiveToolRowID suffixes within-run repeats |
| high | fixed | internal/tui/model.go:512 | model.go:614-637 — flushRunIDs maps cancelled run -> session id captured at cancel time (model.go:1334); flush goes via appendSessionEventsTo(flushSessionID,…) (session.go:76) when active session differs |
| high | fixed | internal/tui/run.go:26 | flush.go:71-120 settled-row frontier prints history to native scrollback via tea.Println; model.go:769 View renders only the unflushed live tail |
| high | fixed | internal/tui/transcript.go:139 | transcript.go:125 key is now "%d:%d:%s" (kind:runID:id) for all keyed kinds; transcript.go:148 effectiveToolRowID adds per-run ordinal suffix for within-run repeats |
| high | fixed | internal/tui/model.go:311 | model.go:355-360 Ctrl+C sets exiting and returns nil (no Quit) while flushRunIDs non-empty; deferred tea.Quit fires at model.go:645-647 once the cancelled run's session events flush |
| high | fixed | internal/tui/model.go:853 | model.go:1034-1043 commandExit now calls cancelRun(), sets m.exiting, and defers tea.Quit while len(m.flushRunIDs) > 0 — same protection as Ctrl+C |
| high | fixed | internal/cli/extensions.go:186 | internal/cli/extensions.go:191 passes Servers through redactMCPServerConfigs (:205-224) which replaces every env/header value with [REDACTED]; asserted by extensions_test.go:108 (go test ok) |
| high | fixed | internal/mcp/protocol.go:42 | 82b9c8d: read() now accepts newline-delimited JSON frames (isJSONStart→decodeMessage) and only falls back to Content-Length headers for back-compat; write() emits one JSON message per line. Covered by protocol_test.go; go test green |
| high | fixed | internal/streamjson/streamjson.go:310 | 3142de6: secretPatterns now anchored — sk keys require a word boundary plus a 16-char body and bearer requires a 16-char token — so 'task-list' and prose like 'bearer of bad news' are no longer redacted while real keys/tokens still are. Covered by streamjson_test.go |
| high | fixed | internal/cli/cron.go:112 | 7a47059: cronAdd now consumes an explicit positional expr (cron.go:113-118) BEFORE applying recipe defaults, so a schedule given alongside --recipe is honored; extra positionals error. Covered by cron_test.go |
| high | fixed | internal/providerhealth/providerhealth.go:285 | 600057d: default probe now uses newConnectivityClient — CheckRedirect re-validates every hop (capped at 5) and DialContext validates+pins the resolved IP, dialing the IP literal to close the DNS-rebinding TOCTOU. Covered by connectivity_client_test.go |
| high | fixed | internal/sessions/store.go:547 | 12a3f62 + 8c75074: ReadEvents now drops a torn final record (last non-empty line) only when the file lacks a trailing newline (tornTailPossible); a fully-flushed corrupt line, or any malformed earlier line, still fails loudly. Covered by store_test.go |
| high | partial | internal/cli/cron_run.go:173 | 7a47059: fireJob now re-reads the job before persisting and preserves an external pause/removal mid-run (cron_run.go:170-181), closing the pause-clobber. But internal/cron/store.go still has no flock/CAS, so the concurrent-scheduler race (two schedulers both seeing NextRunAt due) remains — single-scheduler is the supported model |
| high | fixed | internal/tools/bash.go:104 | 0dbb5e0: hardenProcessLifetime (bash_proc_unix.go) sets Setpgid, a Cancel that SIGKILLs the whole process group (negative pid), and a WaitDelay backstop so a backgrounded child cannot outlive the timeout or hang Run(); Windows sets WaitDelay only. Covered by bash_tool_test.go |
| high | fixed | internal/sandbox/runner.go:262 | 2eeaea7: sandbox-exec profile write rule now also allows the standard device nodes (/dev/null, …) and temp trees (/tmp, /private/var/folders, …) for parity with bubblewrap's --dev/--tmpfs; the workspace stays the only writable project location. Covered by runner_test.go |
| high | fixed | internal/tools/apply_patch.go:161 | b6182bf: patch headers are now parsed with hunk line-counting (git-apply semantics), so a hunk-body line like '-- /etc/old' is no longer mistaken for a file header and a valid patch is accepted. Covered by write_tools_test.go |
| high | fixed | internal/tools/grep.go:224 | b6182bf: glob now matches against the path relative to the SEARCH directory (ripgrep semantics) via filepath.Rel(target,path); a single explicit file matches by base name. probe path=subdir glob=*.go now matches subdir/a.go. Covered by file_tools_test.go |
| high | fixed | internal/providers/openai/types.go:3 | 46d495c (+7044d36/6664f71): chatCompletionRequest gained reasoning_effort; zeroruntime.CompletionRequest.ReasoningEffort is threaded through the agent loop and mapped per provider — OpenAI reasoning_effort, Anthropic extended thinking, Gemini thinking budget; CLI gates the value against the resolved model. Covered by openai/anthropic/gemini provider_test.go |
| high | fixed | internal/specialist/exec.go:166 | 39a9c55: BuildArgs always prefixes the session title with the specialist name and falls back to the bare name when description is empty, so AgentName is non-empty and a description-less run resumes. Covered by specialist/exec_test.go |
| high | fixed | internal/agent/loop.go:233 | 95a6a8e: Run now sets result.FinishReason from collected.FinishReason (and from the after-max-turns final answer); Result.Truncated()/TruncationNotice() drive an exec-writer warning and a TUI system row when a response was cut off. Covered by loop_test.go |
| high | partial | internal/hooks/hooks.go:372 | 17a91d9: a Dispatcher that runs configured hooks and audits each via NewAuditStore now exists and is tested (dispatch.go + dispatch_test.go), closing the 'zero non-test callers' gap for Select/audit. But it has ZERO production callers — not wired into the beforeTool/afterTool tool lifecycle — and runHooks still supports only `list` (extensions.go:94); no dispatch/edit CLI surface |
| low | fixed | internal/cli/exec_writer.go:412 | exec_writer.go:415 and :404 now call cutRuneBoundary (:420-431) which backs up to the last UTF-8 rune start before slicing; added in audit-fix commit 6fb0231 |
| low | fixed | internal/search/search.go:309 | search.go:318-322: resolveSessions now returns fmt.Errorf("zero session not found: %s", sessionID) when store.Get returns nil instead of an empty success |
| low | fixed | internal/doctor/doctor.go:134 | doctor.go:129-141: providerConfigCheck now reports apiKey presence ("set"/"not set") based on profile.APIKey != "", with comment explaining the change; doctor tests pass |
| low | fixed | internal/tui/transcript.go:303 | truncateTUIOutput cuts via rune-boundary cutRunes (transcript.go:302,307-318); titles use cutRunes (session.go:112, spec_mode.go:305) and cutRuneBoundary (exec_sessions.go:90, exec_writer.go:420) |
| low | fixed | go.mod:10 | go.mod:7-12 direct requires are now only bubbles/bubbletea/lipgloss/x-sys; glamour is gone entirely and x/ansi (line 18) and termenv (line 31) are demoted to // indirect |
| low | fixed | internal/tui/model.go:525 | agentResponseMsg completion path now invokes m.runCancel() before nil-ing it (model.go:651-658, with explanatory comment about the per-prompt leak) |
| low | fixed | internal/imageinput/imageinput.go:58 | imageinput.go:56-66 os.Open failure now wraps real cause ("cannot open image %s: %w") and read failure "cannot read image %s: %w"; only a stat miss reports "not found" (:38) |
| low | fixed | internal/tui/session.go:93 | All three paths rune-safe: internal/tui/session.go:112 cutRunes(..., tuiSessionTitleLimit), internal/tui/spec_mode.go:305 cutRunes, internal/cli/exec_sessions.go:90 cutRuneBoundary(title, 80) |
| low | fixed | internal/cli/exec_writer.go:412 | internal/cli/exec_writer.go:404 (truncateForStatus) and :415 (truncateForStreamJSONOutput) both use cutRuneBoundary defined at exec_writer.go:420-431 (backs up to utf8.RuneStart) |
| low | fixed | internal/sessions/replay.go:237 | replay.go:245-256 cutPromptRuneBoundary (utf8.RuneStart backoff) now used at replay.go:174-177 and 238; titles rune-safe via cutRuneBoundary (cli/exec_sessions.go:90) and cutRunes (tui/session.go:112) |
| low | fixed | internal/tui/rendering.go:68 | footerText/commandFooterText/formatCommandFooterText/defaultCommandFooterText (present at b144cf9~1 rendering.go) are absent at HEAD; dead actionAppendToolCall/ToolResult enum values also removed from transcript.go |
| low | fixed | internal/tui/transcript.go:303 | cutRunes lands cuts on rune boundaries (transcript.go:305-318), used by truncateTUIOutput for tool-result text (transcript.go:302) and session titles (session.go:112, spec_mode.go:305) |
| low | fixed | internal/tui/model.go:853 | commandExit cancels the run, sets exiting, and defers quit while flushRunIDs is non-empty (model.go:1034-1043); deferred tea.Quit fires when the last flush drains (model.go:645-647), and new prompts are gated while exiting (model.go:1012-1014) |
| low | fixed | internal/tui/session.go:93 | cutRunes (transcript.go:307-318) cuts on rune boundaries; used by tuiSessionTitle (session.go:112) and truncateTUIOutput (transcript.go:302) for tool-result text |
| low | fixed | internal/tui/rendering.go:482 | rendering.go:526 — permission card action row now renders "[esc] cancel run", matching the KeyEsc fall-through to m.cancelRun() (model.go:391-393) |
| low | fixed | internal/cli/update.go:37 | internal/update/update.go:136-142: normalizeVersionTag failure now falls back to currentVersion="0.0.0" so the check proceeds to the network call (landed in #158) |
| low | fixed | internal/notify/notify.go:134 | notify.go:137 now drops `(r >= 0x80 && r <= 0x9f)` with comment that C1 includes single-byte CSI/OSC/ST introducers (landed in #158) |
| low | fixed | internal/tui/model.go:512 | model.go:616-624 the flushRunIDs (cancelled-run) branch of agentResponseMsg now records each usage event via recordUsageEvent → m.usageTracker.Record (session_controls.go:294) |
| low | fixed | internal/tui/rendering.go:490 | rendering.go:560-564 the ask card now echoes the in-progress answer: "❯ " + fill(zeroTheme.ink).Render(input) + accent cursor, appended to the card lines |
| low | fixed | internal/tui/view.go:227 | view.go:230-235 now matches `path == home` or strings.HasPrefix(path, home+os.PathSeparator) — a path-boundary check with a comment citing the /Users/alice2 sibling case |
| low | fixed | internal/sandbox/risk.go:53 | defect not present: risk.go:11-50 patterns are package-level regexp.MustCompile compiled once at init (identical at audit commit b144cf9); per call only MatchString runs |
| low | fixed | internal/modelregistry/catalog.go:36 | catalog.go:36 claude-haiku-3.5 extra capabilities now []ModelCapability{ModelCapabilityPromptCache} — ModelCapabilityVision removed (base withBaseCapabilities adds no vision), so SupportsVision is false |
| low | fixed | internal/providers/providerio/retry.go:107 | retry.go:108-110 ShouldRetryStatus now returns true for `code == 529` alongside 429/503 (doc comment lines 99-107 updated to cover Anthropic overloaded), consistent with ClassifiedError's 529 rate-limit classification at providerio.go:224 |
| low | fixed | internal/tui/spec_mode.go:301 | spec_mode.go:305 and session.go:112 now use cutRunes (transcript.go:307-318 backs off to utf8.RuneStart boundary); no byte-index title slicing remains |
| low | fixed | internal/agent/system_prompt.go:89 | system_prompt.go:102-107 now walks cut back with `for cut > 0 && !utf8.RuneStart(content[cut])` before slicing, so truncation lands on a rune boundary (matches audit doc's rune-safe truncation fix claim) |
| low | fixed | internal/config/writer.go:53 | internal/config/writer.go:55-74 — writeConfigFile now does os.CreateTemp + Chmod(0600) + write + os.Rename (atomic temp+rename, with explanatory comment at :53); go test ./internal/config -run Upsert passes |
| low | fixed | internal/cli/exec_sessions.go:88 | exec_sessions.go:87-91: title = cutRuneBoundary(title, 80); helper (exec_writer.go:420-431) verified rune-safe by /tmp probe |
| low | fixed | internal/cli/exec_writer.go:412 | exec_writer.go:404 (truncateForStatus/stderr) and :415 (truncateForStreamJSONOutput) both use cutRuneBoundary, which backs up to a utf8.RuneStart boundary |
| low | open | internal/streamjson/streamjson.go:30 | repo-wide grep finds EventRestore only at its declaration (streamjson.go:30) — no emitter and not even test references; streamjson.go untouched by 6fb0231 or later merges |
| low | open | internal/cli/cron_run.go:150 | cron_run.go:145-151: rec.Error still set only from errBuf on code!=0; outBuf (where stream-json error events land) is captured but never read |
| low | open | internal/cli/exec_sessions.go:55 | exec_sessions.go:55 still constructs sessions.NewStore(sessions.StoreOptions{}) directly, and exec.go:375-389 PrepareExecOptions still omits the Store field; deps.newSessionStore is used by spec/usage/observability but not the exec run path |
| low | fixed | internal/secrets/scanner.go:35 | [reverified 2026-06-13] already fixed on main: scanner.go:37 pattern is now `sk-[A-Za-z0-9_-]{20,}` (allows -/_ so sk-proj-/sk-svcacct- match), with comment at :35-36. Covered by scanner_test.go TestScanDetectsModernPrefixedOpenAIKeys |
| low | open | internal/redaction/redaction.go:222 | redaction.go:221-235 pointer/map ptr added to context.seen and never deleted after subtree recursion, so a second (sibling) visit returns CircularReference |
| low | open | internal/redaction/redaction.go:174 | redaction.go:174 `interface{ StackTrace() fmt.Stringer }` unchanged; grep finds no implementer in the module and go.mod has no pkg/errors or similar dep |
| low | open | internal/zerogit/zerogit.go:417 | zerogit.go:418-420 fallback EnvRunner adapter still discards env (`_ []string`), so stagedSnapshotDiff's GIT_INDEX_FILE isolation is lost with a caller-supplied Runner |
| low | open | internal/tui/commands_test.go:46 | internal/tui/commands_test.go:46 still defines commandTestStringSliceContains; repo-wide grep finds no callers, only the definition |
| low | open | internal/tui/session_test.go:215 | internal/tui/session_test.go:215 'runtimeMessages := []tea.Msg{}' appended in RuntimeMessageSink at line 226 but never read in the test body (only runtimeMessageCh is consumed) |
| low | open | internal/sessions/store.go:555 | store.go:554-556 timestamp() still time.RFC3339 (second precision); List ties broken lexically by SessionID (store.go:331-336), not actual recency, so Latest() can still pick the wrong session on same-second ties |
| low | open | internal/tui/session_controls.go:165 | session_controls.go:165 still only increments compactRequests; sole reads are its own status display (lines 266, 280) with "Backend: state: pending integration" — no compaction is ever triggered by it |
| low | open | internal/tui/model.go:837 | model.go:1012-1014 silently drops prompt submits while m.exiting (return m, nil — no feedback row); no exiting/flushRunIDs indicator anywhere in statusLine (view.go:113-139), composerLine, or any render path |
| low | open | internal/tui/command_center.go:17 | tui/command_center.go:17-21 passes only Now/Runtime/Provider (no UserConfig/ProjectConfig) vs cli/observability.go:64-71; empty paths warn at doctor.go:120 and :273 |
| low | open | internal/background/process_posix.go:38 | process_posix.go:39-47 still polls signal-0 on os.FindProcess(pid) while the Wait goroutine (specialist/exec.go:653) reaps the child; line 50 Kill() can hit a recycled pid |
| low | open | internal/tools/registry.go:108 | repo-wide grep: OnSandboxDecision only at registry.go:19,108,116 — no caller or test ever sets it |
| low | open | internal/sandbox/grants.go:146 | grants.go unchanged since #77: Lookup trims key (grants.go:146) but readState validates raw file keys via trimming ValidateToolName (grants.go:225-227,273) and keeps the raw map key, so a whitespace-padded key validates yet never matches |
| low | open | internal/tools/read_file.go:89 | probe: max_lines=5 -> Truncated=true with no marker beyond '(lines 1-5 of 20)' header (read_file.go:102-111); rune-width sub-claim not reproducible (ASCII strconv.Itoa padding) |
| low | open | internal/providers/factory.go:118 | factory.go:115 still `if !explicitProvider \|\| isImplicitOpenAI(...)`; isImplicitOpenAI (factory.go:159-164) requires TrimSpace(ProviderKind)=="" && TrimSpace(Provider)=="", which contradicts explicitProvider==true (factory.go:147-157), so it can never return true at its sole call site |
| low | open | internal/providercatalog/catalog.go:46 | catalog.go:46 Public still declared; no constructor or descriptor ever sets it (always false); the only read is the vacuous guard in catalog_test.go:105, which already existed before the audit (added in 4accec5/#141) — no production read or write |
| low | open | internal/verify/verify.go:215 | verify.go:215 RunLoop still defined, sole caller verify_test.go:207; production loop is selfverify.Run (selfverify.go:53-74) used by cli/workflows.go |
| low | open | internal/specialist/output_tool.go:244 | output_tool.go:244 formatTaskOutput has zero callers (prod or tests); only formatTaskOutputSummary is used (output_tool.go:154) |
| low | open | internal/specialist/exec.go:346 | exec.go:347-350: on SetPID error calls cleanupBackgroundPromptFile (runtime.go:73 deletes file/dir) and returns err with no recordSpecialistStop/UpdateStatus while child keeps running |
| low | open | internal/agent/loop.go:188 | helpers.go:85 sets collected.Error = ctx.Err().Error(); loop.go:189-191 returns errors.New(collected.Error) before the ctx.Err() identity return at loop.go:193-195, so errors.Is(err, context.Canceled) still fails for that path |
| low | open | internal/agent/types.go:183 | types.go:183 OnContext field and loop.go:102-103 emit site exist, but repo-wide grep finds no "OnContext:" assignment and no MeasureContext/ContextBreakdown use in cli/tui/cmd/contextreport (only tests) |
| low | open | internal/agent/loop.go:1072 | loop.go:1120-1122 and loop.go:1129-1131 both still assign schema["items"] = propertyToRuntimeMap(*property.Items) |
| low | open | internal/agent/loop.go:473 | loop.go:530-531 set requestEvent.GrantMatched/Grant but requestEvent is never read after the switch (post-run event rebuilt at loop.go:558); fallback at loop.go:510-511 unreachable since buildPermissionEvent always returns ok=true under the shouldRequestPermission gate (Prompt-permission tools, loop.go:751-758 vs 877-894) |
| low | open | internal/agent/loop.go:183 | loop.go:184-186 retry CollectStreamWithOptions still passes only OnUsage; comment at loop.go:178-183 documents the OnText omission as deliberate anti-duplication, but streaming surfaces still receive only the aborted partial text |
| low | open | internal/zeroruntime/helpers.go:104 | helpers.go:104 still does `collected.Text += event.Content` per StreamEventText (no strings.Builder); unchanged since audit commit b144cf9 |
| low | open | internal/zerocommands/sandbox_snapshots.go:104 | grep for SandboxPlan/Decision/Policy/Backend/Risk/Violation/Hook/Plugin/MCPServer snapshots finds no consumers outside internal/zerocommands + tests; `zero sandbox policy --json` serializes raw sandbox.Policy/Backend/BackendPlan instead (internal/cli/sandbox.go:90-105) |
| low | open | internal/config/contracts.go:12 | internal/config/contracts.go still defines ContractGap/DefaultContractGaps/FindContractGapsByMilestone; only reference repo-wide is internal/config/contracts_test.go |
| low | open | internal/skills/skills.go:91 | skills.Duplicates (skills.go:91) and skills.Get (skills.go:217) have zero callers outside internal/skills; cli/skills.go uses List and tools/skill.go uses Load, so collisions are still dropped without any user-facing warning (dedup is now documented/deterministic but unsurfaced) |
| low | open | internal/plugins/plugins.go:893 | internal/plugins/plugins.go:893 allows only beforeTool/afterTool/sessionStart/sessionEnd, while internal/hooks/hooks.go:812 also accepts specialistStart/specialistStop — sets still disagree |
| low | open | internal/config/resolver.go:136 | internal/config/resolver.go:143, :173 and :502 all gate on `MaxTurns > 0` with no error path, so a configured maxTurns of 0 or negative is still silently ignored (falls back to default 30) |
| low | open | internal/cli/exec.go:353 | exec.go:375 PrepareExec called without Store (defaults to NewStore in sessions/exec_session.go:58), exec_sessions.go:55 direct NewStore, exec.go:396 time.Now() instead of deps.now |
| low | open | internal/zerocommands/contracts.go:166 | contracts.go:166 APIKeySet checks only profile.APIKey, ignoring AuthHeaderValue (valid sole credential per config/types.go:49-51); rendered "not set" at cli/command_center.go:328 |
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed via cutRuneBoundary(p,47) at cron.go:275, but cronTruncate at cron_run.go:179-184 still returns s[:max]+"…" (byte slice, can split a rune in persisted run errors) |
| low | partial | internal/tui/options.go:28 | app.go:413 production caller still sets none of the three, but newModel now self-defaults a usage.Tracker when nil (model.go:239-242) so usage works; effort/style are settable at runtime via /effort and /style |
| low | partial | internal/tui/rendering.go:62 | Footer system, actionAppendToolCall/Result, and dead theme tokens (selRow/statusOk/statusErr/line2) are removed, but listCommandNames/formatCommandHelpLines remain test-only (commands.go:297,306), resumeText's args!="" branch is still unreachable (command_center.go:44-54; sole caller session.go:122 passes ""), OnContext is still wired by no production caller (agent/loop.go:102), and stale footer comments persist (commands.go:254, autocomplete.go:64) |
| low | partial | internal/zerogit/zerogit.go:206 | truncateSubject now rune-based (zerogit.go:464-472, no invalid UTF-8), but ValidateMessage still byte-based `len(firstLine) > 72` (zerogit.go:207) so valid non-ASCII subjects are rejected |
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed (cron.go:275 uses cutRuneBoundary) but error helper cronTruncate still byte-slices: internal/cli/cron_run.go:183 'return s[:max] + "…"', used for run errors at cron_run.go:150 |
| low | partial | internal/tui/command_center.go:44 | Footer helpers now render (rendering.go:690-700, bash/grep footers 892-905/969-971), but resumeText's args!="" branch (command_center.go:43-54) is still unreachable (sole caller session.go:122 passes "") and stale footer comments persist (autocomplete.go:64, commands.go:254) |
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed via cutRuneBoundary (cron.go:275), but cronTruncate still byte-slices `s[:max]` at cron_run.go:182, splitting runes in run-record error text |
| low | partial | internal/tui/rendering.go:62 | footer builders (footerText/commandFooterText/etc.) and the four theme tokens (selRow/statusOk/statusErr/line2/panel2) are fully removed (zero grep hits), but formatCommandHelpLines/listCommandNames (commands.go:297-308) remain production code referenced only by tests, and stale "footer advertises" comments persist (commands.go:254, autocomplete.go:64) |
| medium | fixed | internal/update/update.go:135 | update.go:136-142: normalizeVersionTag error now falls back to currentVersion="0.0.0" (commented for dev builds) instead of returning the error |
| medium | fixed | internal/tui/session.go:190 | session.go:219-227 rehydrates role "ask_user"/"ask_user_answers" rows via askUserRequestFromPayload (session.go:349) and askUserAnswersText (session.go:380) |
| medium | fixed | internal/tui/model.go:1067 | cancelRun appends rowSystem "Run cancelled." plus any partial streamed answer (model.go:1336-1343); asserted by flush_test.go:105 |
| medium | fixed | internal/search/search.go:345 | search.go:363-407 findMatch maps lowered offsets back via lowerWithOffsets offset table; snapRuneStart (:417) guards context slicing; tests pass |
| medium | fixed | internal/search/search.go:108 | search.go:111-118 LoadIndex error now `result.SkippedSessions++; continue` instead of returning; SkippedSessions reported in Result (:61) |
| medium | fixed | .github/workflows/ci.yml:31 | .github/workflows/ci.yml:31-44 adds 'Check formatting' (gofmt -l, fails on output) and 'Vet' (go vet ./...) steps; gofmt -l . is clean at HEAD |
| medium | fixed | internal/tui/transcript.go:303 | internal/tui/transcript.go:302 truncateTUIOutput now calls cutRunes (rune-boundary cut, transcript.go:307-318); non-ASCII test TestCutRunesNeverSplitsUTF8 at flush_test.go:158 |
| medium | fixed | internal/cli/usage.go:46 | usage.go:47-51 collectUsageData now does set.skipped++ and continue on ReadEvents failure, so a corrupt session is skipped instead of aborting |
| medium | fixed | internal/tui/model.go:1081 | cancelRun appends partial answer then rowSystem "Run cancelled." marker (model.go:1336-1343), shared by Esc (model.go:392) and Ctrl+C (model.go:355) paths |
| medium | fixed | internal/tui/model.go:227 | WindowSizeMsg sets m.input.Width = maxInt(20, chatWidth(msg.Width)-14) so long input scrolls with cursor visible (model.go:556-558) |
| medium | fixed | internal/tui/view.go:394 | view.go:412-431 now requires a hunk header match or BOTH '--- ' and '+++ ' file headers; a lone '---' line no longer triggers the diff renderer |
| medium | fixed | internal/tui/rendering.go:261 | rendering.go:286-330 preserves explicit newlines and each line's leading indent (tabs→4 spaces), continuations keep the indent; only intra-line space runs collapse |
| medium | fixed | internal/tui/model.go:456 | WindowSizeMsg resizes composer and prints title bar at real width (model.go:553-566); flush refuses to freeze history before a real width arrives (flush.go:72-77); width/tier recomputed every render (view.go:17-19) and history is native scrollback, so only the ephemeral pre-size frame uses the 96-col default |
| medium | fixed | internal/tui/session_controls.go:176 | session_controls.go:182-184 — handleRewindCommand returns "cannot rewind while a cancelled run is still flushing" when len(m.flushRunIDs) > 0 |
| medium | fixed | internal/tui/session.go:190 | model.go:1461-1476 persists ask_user question payload AND ask_user_answers; session.go:218-227 rehydrates both via askUserRequestFromPayload/askUserAnswersText (session.go:349, 380) |
| medium | fixed | internal/tui/model.go:853 | model.go:1034-1043 — commandExit calls cancelRun(), sets exiting, defers tea.Quit until flushRunIDs drains (model.go:645); spec_mode.go:25 gates /spec on m.pending \|\| m.exiting |
| medium | fixed | internal/tui/spec_mode.go:22 | spec_mode.go:25 handleSpecCommand gates on `if m.pending \|\| m.exiting` and refuses to start, with comment citing the post-Ctrl+C flush window |
| medium | fixed | internal/tui/model.go:1081 | model.go:1340-1343 cancelRun appends the partial streamed answer plus rowSystem "Run cancelled." to m.transcript (live), in addition to the session-log marker |
| medium | fixed | internal/tui/model.go:610 | model.go:769 transcriptView loops only `for index := m.flushed` (live tail; settled rows flushed once to scrollback per flush.go), and rendering.go:184-192 memoizes stable row renders in the LRU staticRenderCache (render_cache.go:19) |
| medium | fixed | internal/tui/session.go:93 | session.go:112 tuiSessionTitle uses cutRunes; transcript.go:302/307-318 truncateTUIOutput cuts on the last rune boundary at or before the byte limit |
| medium | fixed | internal/tui/model.go:227 | model.go:558 WindowSizeMsg handler sets `m.input.Width = maxInt(20, chatWidth(msg.Width)-14)` so long input scrolls horizontally with the cursor visible |
| medium | fixed | internal/providers/providerio/providerio.go:184 | providerio.go:142-154 onComment now forwards a keepAlive marker to the consumer, which calls resetIdle() at providerio.go:203 before the keepAlive continue (line 204-205); providerio tests pass |
| medium | fixed | internal/modelregistry/catalog.go:27 | catalog.go:27 gpt-4.1 entry now reads ContextLimits{ContextWindow: 1_047_576, MaxOutputTokens: 32_768}, matching mini/nano |
| medium | fixed | internal/tui/run.go:41 | run.go:42-46 now prints `zero: tui error: <err>` to os.Stderr before returning 1 |
| medium | open | internal/mcp/client.go:302 | client.go:295-317 readLoop dispatches any id-bearing message to pending[id] without checking message.Method; grep confirms no 'ping' handling anywhere in client.go |
| medium | open | internal/mcp/client.go:246 | client.go:246 `client.writer.write(...)` is a blocking pipe write executed while client.mu is held (:228-:255) with no ctx awareness; file unchanged since the audit (only the response wait, which predates the audit, is ctx-aware) |
| medium | open | internal/mcp/client.go:98 | client.go:98-99 `var stderr bytes.Buffer; cmd.Stderr = &stderr` — plain unbounded buffer attached for the process lifetime, only read on initialize failure |
| medium | open | internal/mcp/network_client.go:621 | network_client.go:603-632 decodeSSERPCMessage stops at the first non-empty 'message' event (sets found, returns false); caller at :210 errors on id mismatch instead of scanning for the matching response — unchanged since audit |
| medium | open | internal/mcp/network_client.go:637 | network_client.go:637 `scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)`; ErrTooLong propagates via readStream->failPending (:578-:592) setting a permanent streamErr with no reconnect, so request() (:234) fails forever |
| medium | open | internal/cli/cron_run.go:115 | cron_run.go:102-126 reconcileOverdue still reschedules any active job with NextRunAt before the current minute without firing; no RunNow/RunOnce sentinel exists in internal/cron, and the file is unchanged since pre-audit commit 0f8ea50 |
| medium | open | internal/sessions/lineage.go:140 | lineage.go:140 tree() still calls ListChildren per node, which calls store.List() (lineage.go:74) re-reading every metadata.json from disk (store.go:312-327, no cache); file unchanged since pre-audit commits |
| medium | open | internal/cli/exec_sessions.go:114 | exec_sessions.go:117-123 still early-returns once recorder.err is set and stores AppendEvent errors there; recorder.err is never read anywhere (exec.go/exec_spec.go check only writer.err) |
| medium | open | internal/tui/session_controls.go:117 | m.responseStyle still only displayed (command_views.go:155); /theme //input-style render "does not have a backend setting yet" (rendering.go:47); /compact shows "state: pending integration" (session_controls.go:272); all still in commandDefinitions help/autocomplete (commands.go:189-230) |
| medium | fixed | internal/tui/session_controls.go:42 | 46d495c/7044d36/6664f71: zeroruntime.CompletionRequest now carries ReasoningEffort, threaded from agent.Options through every request the loop builds and mapped per provider (OpenAI reasoning_effort, Anthropic extended thinking with thinking-block replay, Gemini thinkingBudget). Covered by provider tests |
| medium | fixed | internal/secrets/scanner.go:81 | [reverified+fixed 2026-06-13, fix/audit-batch] Redact now replaces longest matches first (copies findings, sorts by len(Match) desc) so a containing PRIVATE KEY block is redacted before any nested shorter match corrupts its exact string. Covered by scanner_test.go TestRedactNestedSecretStillRemovesWholeBlock (fails pre-fix: leaks BEGIN/END header) |
| medium | fixed | internal/redaction/redaction.go:362 | [reverified 2026-06-13] already fixed on main: urlWithCredsPattern is a package-level var (redaction.go:367) and redactURLPasswords (:369) uses it via ReplaceAllStringFunc — no per-call regexp.MustCompile remains |
| medium | open | internal/zerogit/zerogit.go:212 | zerogit.go:223 `path := strings.TrimSpace(line[3:])` — no C-quote unescaping and no `old -> new` rename split; no -z porcelain used |
| medium | fixed | internal/worktrees/worktrees.go:247 | [reverified+fixed 2026-06-13, fix/audit-batch] defaultRunGit now captures stdout/stderr into separate bytes.Buffers and returns CommandResult{Stdout, Stderr}; git warnings no longer pollute parsed rev-parse output and error messages get real stderr. Covered by worktrees_test.go TestDefaultRunGitSeparatesStdoutAndStderr |
| medium | open | internal/sessions/store.go:632 | store.go:632-635 writeMetadata still os.WriteFile temp + os.Rename with no file/dir Sync; zero Sync() calls anywhere in internal/sessions; appendEventLocked (store.go:512-517) also unsynced |
| medium | open | internal/tui/session_controls.go:235 | session_controls.go:244 returns lastCheckpoint-1 while the tool_call is persisted immediately before its checkpoint (model.go:1505 then 1527), so truncate (rewind.go:205 keeps seq<=target) retains the dangling tool_call |
| medium | open | internal/sessions/store.go:387 | store.go:387-391 Fork re-appends every parent event (no provider_usage skip) via AppendEvent which stamps a fresh CreatedAt (store.go:495-501); BuildReport has no event-id dedupe |
| medium | open | internal/usage/report.go:17 | report.go:17-21 usageEventPayload has only token fields and report.go:102 prices from session Metadata.ModelID, while exec.go:510-512 still persists payload["model"] under --allow-escalation — never read |
| medium | open | internal/cli/exec_sessions.go:115 | exec_sessions.go:117-121 append still early-returns once recorder.err is set, and grep shows sessionRecorder.err is never read in exec.go (no surfacing at run end) |
| medium | open | internal/tui/session_controls.go:117 | m.responseStyle (session_controls.go:117) is only echoed in /style and /config text (command_views.go:155); grep shows it never reaches agent.Options/system prompt (runAgentWithOptions model.go:1366-1397 sets no style) |
| medium | open | internal/cron/schedule.go:171 | Probe at HEAD: Next("30 1 ** *", 2026-11-01 01:30 EDT) = 01:30 EST same day (repeated hour); schedule.go:171-201 guards only spring-forward gaps |
| medium | open | internal/cli/cron.go:112 | cron.go:112-114 sets expr=r.Expr while expr is still ""; cron.go:123 only uses positional[0] if expr=="" — user's expr silently ignored, no warning |
| medium | open | internal/background/process_posix.go:50 | process_posix.go:29/50 signal only pid (no kill(-pid)); launchBackgroundProcess (internal/specialist/exec.go:642) sets no Setpgid; Windows still tree-kills via taskkill /T (process_windows.go:12) |
| medium | open | internal/background/manager.go:462 | manager.go:462-471 normalizeLoadedTask rewrites Status==running to error/PID=0/ExitCode=-1; loadTasks persists it to disk at manager.go:419 |
| medium | open | internal/tui/session_controls.go:117 | session_controls.go:117 still only stores m.responseStyle; sole readers are display strings (command_views.go:155, session_controls.go:120/132) — runAgentWithOptions (model.go:1366+) never threads it into agent.Options or the system prompt, so /style still changes nothing about responses |
| medium | open | internal/tools/registry.go:224 | registry.go:190-236 Core* sets still omit escalate_model; only exec --allow-escalation registers it (cli/exec.go:157, predates audit), TUI path (cli/app.go:449) never does |
| medium | open | internal/sandbox/safe_command.go:138 | file unchanged since #109; splitShellSegments (safe_command.go:431) is quote-unaware — probe: `git commit -m "use top \| less"` flagged as 'less', `echo "a; vim b"` flagged as 'vim' |
| medium | open | internal/providers/gemini/types.go:88 | gemini/types.go:88-93 usageMetadata still has only Prompt/Candidates/Thoughts/Total token counts (no cachedContentTokenCount); grep shows CachedInputTokens is set only in openai/provider.go:253 and anthropic/provider.go:274, never in gemini |
| medium | open | internal/providers/openai/tool_state.go:95 | tool_state.go:95-100 closeOpen still emits StreamEventToolCallDropped for calls with empty id (and applyDelta:58 never streams them); zeroruntime/helpers.go:141-207 empty-ID/synthetic-key collector support remains unreachable from the adapter |
| medium | open | internal/specialist/exec.go:150 | exec.go:150 and :195 still hardcode "--auto","high"; cli/exec_tools.go:84-85 maps high -> agent.PermissionModeUnsafe |
| medium | open | internal/specialist/accounting.go:72 | accounting.go:31/80 check (ReadEvents) then :47/91 append under separate store locks; concurrent callers remain at exec.go:336 (onExit) and output_tool.go:150 (TaskOutput); no serializing mutex in package |
| medium | open | internal/specialist/streamer.go:43 | streamer.go:43 still caps scanner at 1024*1024; ErrTooLong fails ParseStream -> runChildProcess err (exec.go:624-627) aborts whole run; tool_call Args still emitted untruncated (cli/exec_writer.go:99) |
| medium | open | internal/agent/loop.go:468 | loop.go:525-528 AlwaysAllow still returns deniedPermissionResult on persistPermissionGrant error; loop.go:778-779 still errors unconditionally when options.Sandbox == nil |
| medium | partial | internal/agent/compaction.go:58 | [reverified+fixed 2026-06-13, fix/audit-batch] estimateTokens now adds len(message.Images)*imageTokenEstimate (flat per-image cost, ~1k; raw byte size is a poor proxy for vision tiles) — Images are no longer uncounted. Covered by compaction_test.go TestEstimateTokensCountsImages. REMAINING: maybeCompact still has no tool-definition term in its estimate |
| medium | open | internal/agent/compaction.go:376 | compaction.go:376 summarizeMessagesOnce uses zeroruntime.CollectStream with no OnUsage; comment at compaction.go:314-315 documents OnText/OnUsage are intentionally not forwarded |
| medium | open | internal/config/resolver.go:95 | ResolvedConfig.MCP set at resolver.go:102 has no readers (grep: zero non-test uses); MCP registration uses ResolveMCP (resolver.go:109-124) which ignores options.ProviderCommand, so ZERO_PROVIDER_COMMAND MCP servers are still dropped |
| medium | open | internal/hooks/hooks.go:503 | internal/hooks/hooks.go:503 — append still calls store.ReadEvents() (full file read + JSON parse) on every append just to compute the next Sequence |
| medium | open | internal/cli/app.go:149 | app.go:177-190: comment says trailing non-flag args "still are" ignored; rest scanned only for --add-dir then dropped before runInteractiveTUI |
| medium | open | internal/cli/exec.go:500 | exec.go:524-532: interrupted branch emits errorEvent/runEnd only for execOutputStreamJSON; json path prints "Interrupted." to stderr, no terminal JSON event |
| medium | open | internal/cli/exec.go:182 | exec.go:186-193: only stream-json is special-cased; -o json falls to writeExecToolList plain text (exec_tools.go:100-110 "Tools visible to model:") |
| medium | partial | internal/tui/transcript.go:113 | Full-slice copy removed — in-place append, unkeyed rows skip the scan (transcript.go:92-101) — but hasTranscriptRow still linearly scans all rows per keyed tool/permission/ask append (transcript.go:103-114), so tool-heavy rehydration stays O(n²) |
| medium | partial | internal/tui/transcript.go:113 | O(n) full-slice copy removed (in-place append, transcript.go:96-100), but hasTranscriptRow (transcript.go:103-114) still linearly scans all rows per keyed (tool/permission) row and m.transcript is never pruned, so tool-heavy rehydration remains O(n^2) |
| medium | partial | internal/tui/model.go:569 | transcript half fixed: interim text appended as non-final assistant row on tool call/error/cancel (model.go:710-715, 681-683, 1340-1342); but session log still records only FinalAnswer (model.go:1638-1644) — interim segments never persisted, so /resume loses them |
| medium | partial | internal/tui/transcript.go:117 | re-render gone (View renders only unflushed tail, model.go:769; render_cache.go memoizes) and copy-on-append removed (transcript.go:96-100); but hasTranscriptRow still scans the whole transcript per keyed append (transcript.go:103-114) — O(n²) cumulative for tool rows |
| medium | partial | internal/tui/transcript.go:113 | Copy fixed: transcript.go:96-100 appends in place; but hasTranscriptRow (transcript.go:103-114) still linear-scans all rows per keyed (tool/permission/ask) row, so tool-heavy /resume rehydration remains O(N²) |
| medium | partial | internal/tui/transcript.go:117 | transcript.go:100 full-slice copy removed (in-place append; non-keyed rows now O(1) via empty-key fast path), but hasTranscriptRow transcript.go:108-113 still linear-scans all rows per tool/permission/ask append — tool-heavy appends/rehydration remain quadratic |
| medium | partial | internal/tui/rendering.go:261 | rendering.go:286-303 now preserves explicit newlines and each line's leading indentation (code blocks survive), but rendering.go:305 still runs strings.Fields(body) per line, collapsing intra-line space runs so mid-line column alignment is still lost |
## Already fixed on `main` (reclassify to fixed) (9)
| Sev | Location (current) | Rationale |
|---|---|---|
| medium | `internal/cli/cron.go:106-128` | The original defect (recipe set `expr=r.Expr` before the positional was consumed, silently ignoring a user-supplied expr) is gone: cronAdd now consumes the positional expr first at cron.go:113-114 (`if len(positional)==1 && expr=="" { expr=positional[0] }`), and the recipe only fills expr if still empty (cron.go:122-123); extra positionals error. Confirmed by TestCronAddExplicitExprOverridesRecipe (cron_test.go:95-118). |
| low | `internal/cli/cron_run.go:198-205` | The original gap was that cronTruncate byte-sliced `s[:max]+"…"` and could split a multi-byte rune; current cronTruncate (cron_run.go:204) now returns `cutRuneBoundary(s, max) + "…"`, and cutRuneBoundary (exec_writer.go:420-431) backs up to a utf8.RuneStart boundary, so the rune-split defect is gone. promptExcerpt (cron.go:278) was already rune-safe. |
| low | `internal/cli/cron_run.go:198-205` | Duplicate of the same underlying issue: the cited byte-slice `return s[:max] + "…"` for run errors (used at cron_run.go:151) no longer exists; cronTruncate now calls cutRuneBoundary(s, max) which lands on a UTF-8 rune boundary, so persisted run-error text can no longer be split mid-rune. |
| low | `internal/cli/cron_run.go:198-205` | Duplicate of the same underlying issue: cronTruncate no longer byte-slices `s[:max]`; it uses the rune-safe cutRuneBoundary helper (cron_run.go:204), with a comment explaining the cut lands on a UTF-8 rune boundary, closing the run-record invalid-UTF-8 gap. |
| low | `internal/redaction/redaction.go:217-231 (pointer), :232-252 (map)` | The pointer case now does context.seen[ptr]=struct{}{} (line 225) then delete(context.seen, ptr) after redactReflect returns (line 230); the map case mirrors this (line 240 add, line 251 delete). Comments at lines 226-228 explicitly state the pointer is dropped after recursion so a shared non-cyclic reference reached via a sibling branch is not mistaken for a cycle, exactly the originally-described defect, which is gone. |
| low | `internal/sandbox/grants.go:392-406` | readState now rebuilds a new `normalized` map keyed by `key := strings.TrimSpace(name)` (grants.go:394, stored at :403) instead of retaining the raw padded file key, and Lookup indexes with `strings.TrimSpace(toolName)` (grants.go:172), so a whitespace-padded file key both validates AND matches. The ledger's premise ('unchanged since #77') is stale — this normalization was added in commit 3f3de1a (#170). |
| low | `internal/tui/session_controls.go:196-241` | handleCompactCommand no longer just increments compactRequests: when sessionCompactor or hasSessionBackedCompactor() is available it sets compactInFlight and returns tea.Batch(m.runCompact(), ...), and runCompact()->compactActiveSession() actually calls sessionStore.PlanCompaction/RecordCompaction (replay.go:145/181). The 'pending integration' status string is gone (only a negative-assertion test references it), and sessionStore is always non-nil (model.go:272-274), so real compaction is triggered. |
| low | `internal/zerocommands/contracts.go:169` | ProviderSnapshotFromProfile now sets APIKeySet via `strings.TrimSpace(profile.APIKey) != "" \|\| strings.TrimSpace(profile.AuthHeaderValue) != ""` (line 169) with a comment (lines 166-168) explaining auth-header-only profiles must not render as 'not set'; config/types.go:51 confirms AuthHeaderValue is a valid sole credential, and command_center.go:317/334 render apiKeyState(provider.APIKeySet) off this fixed boolean. |
| low | `internal/zerogit/zerogit.go:209` | The remaining gap in the partial finding (ValidateMessage still byte-based `len(firstLine) > 72`) is gone: line 209 now uses `utf8.RuneCountInString(firstLine) > 72` with an explanatory comment, and truncateSubject (line 466) is already rune-based, so valid non-ASCII subjects are no longer rejected. |
## Genuinely partial (a real gap remains) (12)
| Sev | Location (current) | Rationale |
|---|---|---|
| high | `internal/cli/cron_run.go:176-188 (re-read fix) + internal/cron/store.go:170-178 (Update, no lock)` | fireJob does re-read via store.Get and preserves an external pause (current.Status == StatusPaused at cron_run.go:186-187) and handles ErrJobNotFound/transient errors, closing the pause-clobber; but cron/store.go Update still just calls writeJob (temp+rename) with no flock/CAS anywhere in the package, so the concurrent-scheduler read-modify-write race remains exactly as the ledger noted. |
| high | `internal/hooks/dispatch.go:112 (Dispatch); wired at internal/agent/loop.go:584,620 and constructed at internal/cli/hook_dispatch.go:51` | The primary gap is closed: the Dispatcher now has production callers and IS wired into the tool lifecycle - constructed in app.go/exec.go/exec_spec.go, assigned to agent Options.Hooks, and invoked at loop.go:584 (beforeTool, with veto via blockedByHookResult) and loop.go:620 (afterTool feedback). But the original finding's CLI-surface gap remains: runHooks in internal/cli/extensions.go:88-135 still supports only `list`/help with no dispatch/edit subcommand. |
| medium | `internal/agent/compaction.go:280 (maybeCompact) / 83-96 (estimateTokens)` | The image-counting fix is present (estimateTokens line 92 adds len(message.Images)*imageTokenEstimate), but the remaining gap stands: maybeCompact's trigger uses estimateTokens(messages), which has no tool-definition term, and is called at loop.go:85 with no tools (the exposed tool defs are built afterward at loop.go:87). The tool-definition estimator estimateToolTokens exists only in the separate MeasureContext reporting path (context_measurement.go:53), not in the compaction trigger. |
| medium | `internal/tui/rendering.go:292-336 (gap at :311)` | wrapPlainText now preserves explicit newlines (split on \n, :297) and each line's leading indentation (TrimLeft + reconstructed indent, :302-305), so code blocks survive. But the word loop at :311 still iterates strings.Fields(body), which collapses intra-line space runs, so mid-line column alignment within a line is still lost exactly as the finding describes. |
| medium | `internal/tui/transcript.go:103-114` | The full-slice copy is genuinely gone (in-place append at transcript.go:100 with a comment about the old O(n²)), but hasTranscriptRow (transcript.go:108-112) still linear-scans every existing row computing transcriptRowKey per keyed tool/permission/ask append, and m.transcript is never pruned, so rehydration via appendTranscriptRow (session.go:145-146) remains O(n²). |
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the same finding: copy-on-append removed (transcript.go:96-100) but hasTranscriptRow (transcript.go:108-112) still linearly scans all rows per keyed row and the transcript is never bounded, so tool-heavy rehydration stays O(n²). |
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the transcript.go:113 finding: append is in-place (transcript.go:100), but hasTranscriptRow (transcript.go:108-112) still linear-scans all rows per keyed (tool/permission/ask) row, so tool-heavy /resume rehydration remains O(n²). |
| medium | `internal/tui/transcript.go:103-114` | View re-render and copy-on-append are fixed (in-place append at transcript.go:100, empty-key fast path at transcript.go:105-107), but hasTranscriptRow (transcript.go:108-112) still scans the whole transcript per keyed append — O(n²) cumulative for tool rows during rehydration. |
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the transcript.go:117 finding: the full-slice copy is removed and non-keyed rows are O(1) via the empty-key fast path (transcript.go:105-107), but hasTranscriptRow (transcript.go:108-112) still linear-scans all rows per tool/permission/ask append, so those appends/rehydration remain quadratic. |
| low | `internal/agent/loop.go:553-580, 611-616` | The main "requestEvent never read after the switch" claim is now fixed (requestEvent is passed to emitDeniedPermission/deniedPermissionResult on the denial paths at 571-572,577-578), but requestEvent.GrantMatched/Grant set at 574-575 in the AlwaysAllow success case remain dead writes (the post-run event at 611-616 is rebuilt fresh) and the fallbackPermissionEvent at 555 is still unreachable because shouldRequestPermission only fires for PermissionPrompt tools, for which buildPermissionEvent never returns ok=false (false only at 1071/1076 for non-Prompt/Allow). |
| low | `internal/tui/commands.go:297,306; internal/tui/command_center.go:43-55; internal/agent/loop.go:105-106; internal/tui/commands.go:254` | The main fix landed: footer builders, theme tokens (selRow/statusOk/statusErr/line2/panel2), and actionAppendToolCall/Result are all gone (zero grep hits). But the cited gaps remain: listCommandNames (commands.go:297) and formatCommandHelpLines (commands.go:306) are referenced only by test files; resumeText's args!="" branch (command_center.go:45-55) is still unreachable since its sole non-test caller session.go:122 passes ""; OnContext (agent/loop.go:105-106) is emitted but has no production assignment; and the stale footer comment persists at commands.go:254. |
| low | `internal/tui/commands.go:297,306; internal/tui/command_center.go:43-55; internal/agent/loop.go:105-106; internal/tui/commands.go:254` | Duplicate of the other rendering.go:62 row; same underlying code. Footer builders and the theme tokens are fully removed, but formatCommandHelpLines/listCommandNames (commands.go:297-308) remain production-defined yet referenced only by tests, and a stale 'footer advertises' comment persists at commands.go:254 (the autocomplete.go:64 stale comment cited in the older row is now gone, but commands.go:254 still has one). |
## Genuinely still open (66)
| Sev | Location (current) | Rationale |
|---|---|---|
| medium | `internal/agent/compaction.go:422 (summarizeMessagesOnce)` | summarizeMessagesOnce still calls plain zeroruntime.CollectStream (compaction.go:422), which is CollectStreamWithOptions with empty CollectOptions{} (helpers.go:78), so no OnUsage callback fires; the collected Usage is discarded and the deliberate non-forwarding is now documented at summarizeClosure (compaction.go:359-361). Compaction-call token usage is still not surfaced/tracked. |
| medium | `internal/agent/loop.go:567-573, 928-931` | The AlwaysAllow case still calls deniedPermissionResult/emitDeniedPermission when persistPermissionGrant errors (loop.go:569-573), and persistPermissionGrant (now at 928-931) still errors unconditionally when options.Sandbox == nil, so choosing AlwaysAllow without a configured sandbox denies the already-approved call; both sub-claims persist (only relocated from the stale 778-779 reference). |
| medium | `internal/background/manager.go:462-471 (and persistTaskLocked call at :419)` | normalizeLoadedTask still unconditionally rewrites task.Status==StatusRunning to StatusError, sets PID=0 and ExitCode=-1, and marks changed=true; loadTasks then persists this to disk via persistTaskLocked at line 419, with no process-liveness check or reconciliation. The defect is present essentially as the auditor described. |
| medium | `internal/background/process_posix.go:29,50` | SIGTERM (line 29) and Kill() (line 50) still target only the single pid with no kill(-pid)/negative-pid group signal, and launchBackgroundProcess (specialist/exec.go:650) sets no SysProcAttr{Setpgid:true} (grep finds zero process-group setup), while Windows still tree-kills via taskkill /T /F (process_windows.go:12). All three claims hold. |
| medium | `internal/cli/app.go:200-220` | In the `--skip-permissions-unsafe` case, the code re-splits args[1:] via splitLeadingAddDirFlags and only iterates `rest` to reject a misplaced --add-dir (lines 215-219); it then calls runInteractiveTUI with merged addDirs only, never forwarding `rest`, so any trailing non-flag args are still silently dropped. The in-code comment at lines 208-209 explicitly confirms this ('were ignored on this path... and still are'), matching the original finding verbatim. |
| medium | `internal/cli/cron_run.go:103-128` | reconcileOverdue still reschedules any strictly-overdue active job (NextRunAt before nowMin) to sched.Next without firing it (lines 116-126); grep confirms no RunNow/RunOnce/FireNow sentinel exists in internal/cron, so the only way to fire backlog is the --catch-up flag that skips reconcileOverdue entirely. |
| medium | `internal/cli/exec.go:191-199` | The --list-tools branch special-cases only execOutputStreamJSON (exec.go:192-194); -o json (execOutputJSON) falls through to writeExecToolList, which calls formatExecToolList and prints the plain-text "Tools visible to model:" listing (exec_tools.go:100-110) rather than any JSON output. |
| medium | `internal/cli/exec.go:531-545` | The interrupted branch still emits writer.errorEvent/runEnd only when outputFormat == execOutputStreamJSON (exec.go:535-540); for -o json it just does fmt.Fprintln(stderr, "Interrupted.") at exec.go:542 and returns exitInterrupted, producing no terminal JSON event on stdout (unlike the provider-error path which writes type:error/type:done for json). |
| medium | `internal/cli/exec_sessions.go:116-124` | execSessionRecorder.append still early-returns when recorder.err != nil (line 117) and latches AppendEvent failures into recorder.err (line 120); a package-wide grep shows recorder.err/sessionRecorder.err is read only inside append itself, never by exec.go or exec_spec.go (which check only writer.err), so persistence errors are silently swallowed at run end. |
| medium | `internal/cli/exec_sessions.go:116-124` | Duplicate of the same recorder.err defect: append (lines 116-124) early-returns once recorder.err is set and stores AppendEvent errors there, but sessionRecorder.err is never read in exec.go or exec_spec.go, so a failed session append is never surfaced to the user at run end. |
| medium | `internal/config/resolver.go:102, 110-124` | ResolvedConfig.MCP is still set at resolver.go:102 but has zero readers: every `.MCP` access in internal/cli is the config.MCPConfig type or the separate resolveMCPConfig/ResolveMCP path, and no resolveConfig caller reads resolved.MCP. MCP registration runs through ResolveMCP (resolver.go:110-124), which only merges config-file paths and Overrides and never calls LoadProviderCommand/touches options.ProviderCommand (asserted intentional by TestResolveMCPDoesNotRunProviderCommand), so ZERO_PROVIDER_COMMAND MCP servers are still dropped. |
| medium | `internal/cron/schedule.go:171-201` | Empirically confirmed: on the 2026-11-01 US fall-back day Next("30 1 ** *") returns 01:30 EDT and then 01:30 EST again, firing the daily job twice during the repeated wall-clock hour. The Next loop and its forward-progress guard (schedule.go:182-198) only handle spring-forward non-existent instants and contain no de-duplication for the ambiguous repeated fall-back hour. |
| medium | `internal/hooks/hooks.go:503` | AuditStore.append still calls store.ReadEvents() at line 503, which os.ReadFile's the entire JSONL audit file and json.Unmarshal's every line on every single append, solely to scan for the highest existing Sequence (lines 507-513); no cached/in-memory sequence counter was introduced, so the O(n) full re-read per append is unchanged. |
| medium | `internal/mcp/client.go:246` | client.writer.write(...) at line 246 still executes while client.mu is held (locked at :228, unlocked at :255), and writer.write (protocol.go:148-163) does a blocking Write+Flush on the stdin pipe with no ctx awareness; only the response wait at :257 is ctx-aware, unchanged from the audit. |
| medium | `internal/mcp/client.go:295-318` | readLoop still dispatches any id-bearing message to pending[id] (lines 302-316) with no check of message.Method; grep finds no 'ping' or Method handling anywhere in client.go, so server-initiated requests are still ignored/misrouted exactly as described. |
| medium | `internal/mcp/client.go:98-99` | Still 'var stderr bytes.Buffer; cmd.Stderr = &stderr' — a plain unbounded buffer (no LimitReader/cap), never reset after startup, attached for the whole process lifetime and only read on initialize failure at line 114. |
| medium | `internal/mcp/network_client.go:612-642 (decodeSSERPCMessage), caller at :219` | decodeSSERPCMessage still decodes the first non-empty 'message' SSE event, sets found=true and returns false (stops scanning) without checking the message ID; the synchronous caller networkClient.request at line 219 does `if !rpcIDMatches(message.ID, id)` and errors on mismatch instead of scanning for the matching response. Defect unchanged (caller line is now :219, was the stale :210). |
| medium | `internal/mcp/network_client.go:646 (scanSSEEvents), failPending at :587-593, request gate at :243` | scanSSEEvents still calls scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) with the 1MiB cap; a bufio.Scanner ErrTooLong flows through readStream into failPending (:593) which sets a permanent client.streamErr, and request() returns that streamErr forever (:243-246) with no reconnect path (openStream is only called once at connect, :77). |
| medium | `internal/providers/gemini/types.go:103-108` | The usageMetadata struct still declares only PromptTokenCount, CandidatesTokenCount, ThoughtsTokenCount, and TotalTokenCount with no cachedContentTokenCount field; provider.go:225-228 reads only those into input/output/reasoning tokens and emitDone (provider.go:282-286) builds TokenUsage without CachedInputTokens, which is set only in openai/provider.go:253 and anthropic/provider.go:330. |
| medium | `internal/providers/openai/tool_state.go:95-101 (and applyDelta:58)` | applyDelta still returns early when call.id=="" (line 58), and closeOpen still only emits StreamEventToolCallDropped for empty-id/empty-name calls (lines 95-99) instead of streaming them with a synthetic key; every Start/Delta/End the adapter emits carries call.id, so the zeroruntime collector's empty-ID/synthetic-key support (helpers.go start/delta/end at ~173-216) is never reached from the OpenAI adapter. The provider_test still asserts such calls are dropped and never started, confirming the behavior is unchanged, and tool_state.go's last commit (4d2ce6b) predates the 2026-06-10 audit. |
| medium | `internal/sandbox/safe_command.go:431` | splitShellSegments still uses a strings.NewReplacer that blindly turns \|, ;, &&, \|\|, $(, ), backtick into segment boundaries with zero quote awareness; a live probe confirms `git commit -m "use top \| less"` is flagged as 'less' (interactive=true) and `echo "a; vim b"` is flagged as 'vim', exactly as the original finding described. The file is unchanged since commit #109 and contains no quote-state parsing. |
| medium | `internal/sessions/lineage.go:140` | tree() (lineage.go:128-154) still calls store.ListChildren(sessionID) at lineage.go:140 once per node, and ListChildren calls store.List() at lineage.go:74; List() (store.go:312-338) re-reads the whole sessions dir via os.ReadDir+Get->readMetadata (store.go:674-684, a fresh os.ReadFile+json.Unmarshal with no cache), so an N-node tree re-reads every metadata.json N times. The defect is present exactly as described. |
| medium | `internal/sessions/store.go:428-432 (Fork copy loop), 526-567 (appendEventLocked); internal/usage/report.go:74-119 (BuildReport)` | Fork's loop at store.go:428-431 re-appends every parent event (no provider_usage/EventUsage skip) via AppendEvent, which stamps a fresh CreatedAt/ID in appendEventLocked (537-543), and BuildReport (report.go:74-119) aggregates all EventUsage events with no event-id dedupe, so forked usage events are double-counted. |
| medium | `internal/sessions/store.go:686-701 (writeMetadata), 549-559 (appendEventLocked write path)` | writeMetadata still does os.WriteFile to a temp file + os.Rename (store.go:693-696) with no file or directory fsync, and a repo-wide grep finds zero Sync() calls anywhere in internal/sessions/; appendEventLocked's append write (549-557) is likewise unsynced. |
| medium | `internal/specialist/accounting.go:80-91 (appendSpecialistUsageRollup) and :31-47 (recordSpecialistStop)` | The dedup is still a non-atomic check-then-append: specialistEventExists calls store.ReadEvents (which acquires NO lock, store.go:569) and only the later AppendEvent takes the per-session lock for its own write, so concurrent callers (exec.go:343 onExit and output_tool.go:150 TaskOutput) can both pass the existence check and each append a duplicate event. No serializing mutex spans the check and append in the specialist package, and the store's lockSession/appendEventLocked machinery is not used by the accounting path. |
| medium | `internal/specialist/exec.go:150 (and :202)` | Both BuildArgs (:150) and BuildResumeArgs (:202) still hardcode args append "--auto","high", and internal/cli/exec_tools.go:84-85 maps "high" -> agent.PermissionModeUnsafe, so specialist children always run in unsafe permission mode. |
| medium | `internal/specialist/streamer.go:43` | ParseStream still calls scanner.Buffer(make([]byte,0,64*1024), 1024*1024), so a line over 1MB yields bufio.ErrTooLong; exec.go:631-634 returns that error from runChildProcess aborting the whole run, and the producer at cli/exec_writer.go:99 (parseToolCallArgs) still emits tool_call Args with no length cap. All three elements of the original finding are unchanged. |
| medium | `internal/tools/registry.go:198-245` | The Core* tool sets (CoreReadOnlyTools/CoreWriteTools/CoreShellTools at :224/CoreNetworkTools/CoreTools) still omit escalate_model; it is registered only under exec --allow-escalation (cli/exec.go:156-157), while the TUI registry built by newCoreRegistryScoped (cli/app.go:436,531-537) never registers it — app.go only mentions escalate_model in help text at :733. |
| medium | `internal/tui/model.go:2022-2028 (session log) vs model.go:854/802/1721 (transcript)` | Interim narration is appended to the live transcript on tool call (model.go:852-857), error (801-803), and cancel (1720-1722), but the session log only persists result.FinalAnswer via EventMessage (model.go:2022-2028); onText (1781-1786) and sendAgentText (2064-2069) record no session event, so the gap the ledger flagged as 'partial' is unchanged — interim segments are still never persisted and /resume loses them. |
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | m.responseStyle is still only stored by handleStyleCommand (session_controls.go:156) and echoed in display strings (session_controls.go:159/171, command_views.go:275); runAgentWithOptions threads ReasoningEffort/SystemPrompt/PermissionMode into options but never references responseStyle, and agent.Options has no style field (no non-display ResponseStyle reader exists), so /style still changes nothing about responses. |
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | Duplicate of the responseStyle finding: m.responseStyle is set at session_controls.go:156 but runAgentWithOptions (model.go:1746-1778) never assigns it into agent.Options or the system prompt; the only readers are display strings (command_views.go:275, session_controls.go:159/171), so the style preference is inert. |
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | Duplicate of the responseStyle finding verified once: handleStyleCommand only stores m.responseStyle and grep shows no non-display consumer; runAgentWithOptions sets ReasoningEffort/SystemPrompt/etc. on agent.Options but never the style, so /style remains cosmetic. |
| medium | `internal/tui/session_controls.go:318 (resolveRewindTarget); internal/sessions/rewind.go:205` | resolveRewindTarget still returns lastCheckpoint-1, while OnToolCall appends EventToolCall then the EventSessionCheckpoint right after it (model.go:1886-1911) so the tool_call gets the immediately-preceding sequence; TruncateEvents keeps ev.Sequence<=target (rewind.go:205), so the tool_call (seq==target) is retained while its checkpoint (seq>target) is dropped, leaving the dangling tool_call described in the finding. |
| medium | `internal/usage/report.go:17-21 (struct) and :102 (pricing); writer at internal/cli/exec.go:521-524` | usageEventPayload (report.go:17-21) still has only PromptTokens/CompletionTokens/TotalTokens with no model field, and BuildReport at report.go:102 still prices via modelBySession[event.SessionID] from session Metadata.ModelID; meanwhile exec.go:521-522 still persists payload["model"]=currentModel under --allow-escalation, and a repo-wide grep confirms no consumer reads that per-event model key (the only usageEventPayload reader silently drops it on unmarshal). |
| medium | `internal/zerogit/zerogit.go:225` | parseStatus still does `path := strings.TrimSpace(line[3:])` with no C-quote unescaping and no `old -> new` rename split, and the caller at line 119 runs `status --short --untracked-files=all` without `-z`/porcelain, so quoted or renamed paths are still mis-parsed exactly as described. |
| low | `internal/agent/loop.go:1305-1316` | propertyToRuntimeMap still assigns schema["items"] = propertyToRuntimeMap(*property.Items) twice for the same property.Items != nil guard (lines 1305-1307 and 1314-1316); the second assignment is a redundant duplicate writing an identical value. |
| low | `internal/agent/loop.go:189-191` | The reactive-retry CollectStreamWithOptions call still passes only OnUsage and omits OnText (comment at 183-188 documents this as deliberate anti-duplication), so streaming surfaces still receive only the aborted partial text on the retried turn exactly as the finding describes. |
| low | `internal/agent/loop.go:194-201` | When ctx is canceled, internal/zeroruntime/helpers.go:89 sets collected.Error = ctx.Err().Error(); loop.go:194-197 returns errors.New(collected.Error) before the ctx.Err() identity return at 198-201, so the returned error no longer wraps the sentinel and errors.Is(err, context.Canceled) still fails on that path. |
| low | `internal/agent/types.go:196 (field); internal/agent/loop.go:105-106 (emit site); internal/agent/context_measurement.go:31 (MeasureContext/ContextBreakdown)` | The OnContext callback field (types.go:196) and its emit site (loop.go:105-106 calling MeasureContext) still exist, but a repo-wide grep finds ZERO 'OnContext:' assignments anywhere (test or prod) and no use of MeasureContext/ContextBreakdown outside internal/agent tests; cli/tui/cmd and the contextreport package never reference them (contextreport only calls agent.BuildSystemPromptPreview), so the callback remains permanently a no-op exactly as the original finding described. |
| low | `internal/background/process_posix.go:38-50` | terminateProcess still does os.FindProcess(pid) (line 24) and polls liveness via signal-0 in processAlive (lines 39-47, 59-61), while launchBackgroundProcess (specialist/exec.go:659-673) still reaps the child in a concurrent command.Wait() goroutine, so the PID can be reaped and recycled before process.Kill() at line 50 fires. Defect unchanged. |
| low | `internal/cli/cron_run.go:146-152` | rec.Error is still set only from errBuf (cronTruncate(strings.TrimSpace(errBuf.String()),500) when code!=0); outBuf is declared and passed to exec at line 146-147 but never read anywhere in fireJob, so stream-json error events on stdout are still discarded. |
| low | `internal/cli/exec.go:383-398 (PrepareExec call), exec.go:404 (time.Now), internal/cli/exec_sessions.go:55 (direct NewStore)` | All three sub-claims hold: the sessions.PrepareExec call at exec.go:383-398 omits the Store field so PrepareExec falls back to NewStore(StoreOptions{}) at exec_session.go:58-61; preflightExecSession still does sessions.NewStore(StoreOptions{}) directly at exec_sessions.go:55; and runExec uses streamjson.CreateRunID(time.Now()) at exec.go:404 instead of the injectable deps.now (which is available and used at exec.go:142/186). |
| low | `internal/cli/exec_sessions.go:55` | preflightExecSession still calls sessions.NewStore(sessions.StoreOptions{}) directly at line 55, and exec.go's PrepareExec call (exec.go:383-397) omits the Store field so sessions/exec_session.go:58-60 defaults to NewStore again; deps.newSessionStore is used by spec/usage/observability/sessions paths but never by the exec run path in exec.go. |
| low | `internal/config/contracts.go:5,12,65` | ContractGap (line 5), DefaultContractGaps (line 12), and FindContractGapsByMilestone (line 65) are all still defined, and a repo-wide grep for these symbols (plus ContractOwnerRuntime) finds references ONLY in internal/config/contracts_test.go. No production caller exists, so the dead-code defect persists exactly as described. |
| low | `internal/config/resolver.go:144,177,506` | All three MaxTurns sites (mergeConfig :144, mergeProjectConfig :177, applyOverrides :506) still gate purely on `if src/overrides.MaxTurns > 0` with no error path, and Resolve() has no MaxTurns validation, so a configured 0 or negative maxTurns is silently dropped and falls back to defaultMaxTurns=30 (resolver.go:16,22). A negative-value error path exists for tools.deferThreshold (:57-59) but none was added for MaxTurns. |
| low | `internal/plugins/plugins.go:894` | parseHookEvent's switch (plugins.go:894) still accepts only HookBeforeTool/HookAfterTool/HookSessionStart/HookSessionEnd and the HookEvent consts (plugins.go:39-42) omit specialist events entirely, whereas hooks.go parseEvent (hooks.go:812) accepts those four plus EventSpecialistStart/EventSpecialistStop, so the two validation sets still disagree exactly as described. |
| low | `internal/providercatalog/catalog.go:46` | The `Public bool` field is still declared on Descriptor (catalog.go:46) but is never assigned anywhere in the codebase: there are zero `Public:` struct-literal assignments and zero `.Public =` writes. The only read remains the vacuous guard at catalog_test.go:106 (`if descriptor.Public && !descriptor.RequiresAuth`), which is dead code since Public is always false. |
| low | `internal/providers/factory.go:115` | At the sole call site (line 115) the `\|\|` short-circuits so isImplicitOpenAI is only evaluated when explicitProvider==true, which (per explicitProviderKind at 147-157) requires ProviderKind!="" or Provider!=""; but isImplicitOpenAI (159-164) requires both ProviderKind=="" and Provider=="", so it can never return true there — the term remains dead code exactly as described. |
| low | `internal/redaction/redaction.go:174` | Line 174 still declares `var stackTracer interface{ StackTrace() fmt.Stringer }`; a repo-wide grep finds StackTrace only at redaction.go:174 and :176 (no implementer in the module), and go.mod has no pkg/errors-style dependency, so this errors.As branch is dead code that can never match exactly as the finding described. |
| low | `internal/sessions/store.go:615-617 (timestamp), 331-336 (List), 340-346 (Latest)` | timestamp() at store.go:616 still formats with time.RFC3339 (second precision), and List() at 332-334 breaks UpdatedAt ties lexically by SessionID, so Latest() (line 345 returns sessions[0]) can still pick the wrong session on same-second ties exactly as described. |
| low | `internal/skills/skills.go:91 (Duplicates); consumers: internal/cli/skills.go:62, internal/plugins/activate.go:376` | skills.Duplicates and skills.Get still have zero callers outside internal/skills (Get has zero callers anywhere, even tests), and the duplicate-collision data is never surfaced: cli/skills.go runSkillsList calls skills.List with no warning, and the skill tool discards the dups at activate.go:376 with `merged, _ := MergedSkillsLoaded(...)`. Although plugins.mergeSkills now computes a parallel DuplicateName slice that MergedSkills returns, every call site drops it, so shadowed/dropped skills are still resolved silently with no user-facing warning, exactly the defect described. |
| low | `internal/specialist/exec.go:353-358` | On manager.SetPID failure the code only calls executor.cleanupBackgroundPromptFile and returns the error; it does not call recordSpecialistStop or manager.UpdateStatus(StatusError) while the already-launched child keeps running, unlike the launch-error path at :347-352 which does record a stop. |
| low | `internal/specialist/output_tool.go:244` | formatTaskOutput is still dead code: a codebase-wide grep finds only its own definition at :244 and no callers in prod or tests; the only used formatter is formatTaskOutputSummary, called from readOutput at :154. |
| low | `internal/streamjson/streamjson.go:30` | Repo-wide grep for EventRestore (and the literal "restore" EventType) returns only the declaration at streamjson.go:30 — no emitter and no test reference, whereas the sibling EventCheckpoint is emitted at cli/exec_writer.go:119 and referenced in streamjson_test.go:435. The constant remains dead exactly as the original finding described. |
| low | `internal/tools/read_file.go:98-121` | The truncation path is unchanged from main (empty git diff): when max_lines truncates, it sets Result.Truncated=true (line 120) but the only in-output signal is the '(lines 1-5 of 20)' range header (line 114) — no distinct truncation marker is appended to Output (line 119). The rune-width sub-claim remains non-reproducible because line-number padding uses ASCII digits via strconv.Itoa (lines 105-109), exactly as the original finding concluded. |
| low | `internal/tools/registry.go:19` | Repo-wide grep finds OnSandboxDecision only at registry.go:19 (declaration), :113 (nil check), and :121 (invocation) — zero struct-literal (OnSandboxDecision:) or dot-assignment (.OnSandboxDecision =) sites anywhere in non-test or test code, so the field is still never set by any caller. |
| low | `internal/tui/command_center.go:45-55` | resumeText's args!="" branch (command_center.go:45-55) is still dead: the only production caller, handleResumeCommand at session.go:121-122, passes "" (the non-empty case is handled separately via resolveResumeSession at session.go:125), so the branch remains unreachable; one stale 'footer advertises' comment also persists at commands.go:254 (the autocomplete.go one is gone). |
| low | `internal/tui/command_center.go:17-24` | doctorText() still constructs doctor.Run(doctor.Options{Now, Runtime:"go", Provider}) with no UserConfig/ProjectConfig (command_center.go:18-22), unlike the CLI path at observability.go:64-71; so configFilesCheck (doctor.go:111-123) gets empty paths and emits the StatusWarn 'No explicit Zero config files were inspected.' exactly as the finding describes. |
| low | `internal/tui/commands_test.go:46` | The function commandTestStringSliceContains is still defined at line 46, and a repo-wide grep across all *.go files returns only the definition itself with zero call sites, matching the original dead-helper finding exactly. |
| low | `internal/tui/model.go:1363-1365` | handleSubmit still drops a prompt while m.exiting with a bare `return m, nil` and no feedback row, and no render path surfaces an exiting/flushRunIDs indicator: statusLine (view.go:137-156) shows only provider+usage, and composerLine's hint switch (model.go:1130-1134) only shows 'esc stop' when m.pending. |
| low | `internal/tui/options.go:34,39-40 (UsageTracker/ReasoningEffort/ResponseStyle); internal/cli/app.go:484-516; internal/tui/model.go:277-279` | The exact residual gap the ledger evidence describes is still present verbatim: the production caller's tui.Options{...} block in app.go (484-516) sets none of UsageTracker, ReasoningEffort, or ResponseStyle. The mitigations cited also still hold — newModel self-defaults a usage.Tracker when options.UsageTracker is nil (model.go:277-279) and effort/style remain runtime-settable via /effort (session_controls.go:81) and /style (session_controls.go:156) — so the code matches the partial state described, with the same unset-fields gap unchanged. |
| low | `internal/tui/session_test.go:215` | The local slice `runtimeMessages := []tea.Msg{}` (line 215) is still only appended to in the RuntimeMessageSink closure (line 226) and never read or asserted anywhere in the test body; the test exclusively consumes the channel runtimeMessageCh via receiveRuntimeMessage, leaving the slice write-only exactly as the finding described. |
| low | `internal/verify/verify.go:215` | RunLoop is still defined at verify.go:215 and a repo-wide grep finds only two references: the definition and its sole caller verify_test.go:207. The production retry loop is selfverify.Run (selfverify.go:53), which re-implements the attempt loop calling verify.Run directly (selfverify.go:75) and is the one wired into cli/workflows.go, so RunLoop plus LoopOptions/LoopReport remain dead production code. |
| low | `internal/zerocommands/sandbox_snapshots.go:82-104 (SandboxPlanSnapshot/SandboxDecisionSnapshot); raw serialization at internal/cli/sandbox.go:92-94,146-148` | Grep confirms SandboxPlanSnapshot/Decision/Policy/Backend/Risk/Violation snapshots are referenced only inside internal/zerocommands and its tests (no production consumer; the only new consumers in tui/command_views.go are SandboxGrantSnapshots and ProviderSnapshot, which are different types). `zero sandbox policy --json` in internal/cli/sandbox.go still builds its payload from raw zeroSandbox.Policy/Backend/BackendPlan (lines 92-94 and 146-148) instead of the snapshot constructors, exactly as the original evidence described. |
| low | `internal/zerogit/zerogit.go:419-423` | The fallback EnvRunner adapter at lines 420-422 is still `func(ctx, dir, _ []string, args ...string)` which discards the env slice and calls `runGit(ctx, dir, args...)`, so a caller-supplied Runner with no RunGitEnv still loses stagedSnapshotDiff's GIT_INDEX_FILE isolation. |
| low | `internal/agent/loop.go:194-201 (origin internal/zeroruntime/helpers.go:89)` | helpers.go:89 still sets collected.Error = ctx.Err().Error() (stringifying the cancellation, losing identity), and loop.go:194-196 returns errors.New(collected.Error) BEFORE the identity-preserving `return result, ctx.Err()` at loop.go:198-200, so on the stream-cancellation path the returned error is a plain string error and errors.Is(err, context.Canceled) still fails. |
# Agent Eval Benchmark Harness Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a local, offline-testable benchmark harness that copies eval fixtures, runs an agent command per task, scores the resulting workspace, and reports aggregate results.
**Architecture:** Keep `internal/agenteval.Runner` as the scoring primitive. Add a `Harness` layer that materializes fixture workspaces into a work directory, initializes a clean Git baseline, invokes an injectable `AgentRunner`, then calls `Runner.Run`. The CLI exposes this as `zero eval bench`, while `zero eval run` remains the lower-level scorer for an already-mutated workspace.
**Tech Stack:** Go 1.24 standard library, existing `internal/agenteval`, existing `internal/cli`, Git CLI for workspace baselines.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Upgrade the local agent eval harness from a smoke benchmark into a quality signal that can compare models, score traces, catch context mistakes, and preserve regression artifacts.
**Architecture:** Keep the existing `internal/agenteval` harness as the core. Add small schema fields to `Task`, score them through `Score` and `Harness.Run`, then expose model matrix and full benchmark report data through `zero eval bench`. Avoid provider calls or UI work.
**Tech Stack:** Go 1.24, standard library JSON/process/git helpers, existing `go test ./...` validation.
Add tests that load and normalize `tags`, `difficulty`, and `forbiddenChangedFiles`, reject malformed forbidden paths, and fail scoring when a forbidden file is touched.
- [ ] **Step 2: Run tests to verify failure**
Run: `go test ./internal/agenteval -run "LoadSuite|Validate|Forbidden" -count=1`
- [ ] **Step 3: Implement minimal schema and scoring**
Update the sample suite test to require richer coverage count and metadata fields.
- [ ] **Step 2: Run tests to verify failure**
Run: `go test ./internal/agenteval -run SampleSuite -count=1`
- [ ] **Step 3: Expand the fixture suite and docs**
Add more tasks using the existing `zero-mini` fixture, plus examples for `--model`, `{model}`, `requiredTraceEvents`, `contextChecks`, and report artifacts.
- [ ] **Step 4: Verify green**
Run: `go test ./internal/agenteval ./internal/cli -count=1`
### Final Verification
- [ ] Run `gofmt -l internal/agenteval internal/cli`
- [ ] Run `git diff --check`
- [ ] Run `go vet ./...`
- [ ] Run `go test ./...`
- [ ] Run `go run ./cmd/zero eval --suite internal/agenteval/testdata/sample_suite.json`
// real chess re-run wording that slipped a fixed phrase list:
{"…which I cannot do without proper image analysis capabilities.",true},
{"I cannot visually analyze the chess board image.",true},
{"I could not recover the original values, so this is my best guess.",true},
{"I was unable to complete the optimization.",true},
{"The result may not be correct.",true},
// multi-occurrence: an early guarded use ("could not find any") must NOT mask a
// later genuine admission with the same stem (review #5):
{"I could not find any examples in the codebase, so I could not implement the feature.",true},
// genuine successes that must NOT be flagged:
{"I implemented the function and all tests pass.",false},
{"I verified the fix; I could not find any remaining issues.",false},
{"I cannot reproduce the bug, so the fix holds.",false},
{"Done — the output matches the required format and values.",false},
// behavior descriptions that must NOT be flagged — these phrases describe
// implemented behavior, not an admission (review #1, removed from the list):
{"The parser will fall back to UTF-8 when no encoding is set.",false},
{"I replaced the placeholder value with the computed key.",false},
{"The retry uses exponential backoff as a fallback.",false},
{"Without proper error handling the function would crash, so I added it.",false},
// real false positive (conversational recap): retelling a PAST exchange is
// not an admission about the current objective — the sentence carries its
// own narrative context ("You asked …", "at the time"):
{"You asked if I could work autonomously on zero if you gave me a GitHub account — I was honest that my sandbox had no repo, no build system, and no network/integration wired up yet, so I couldn't actually do it at the time.",false},
{"When we last spoke, I couldn't reach the GitHub API from the sandbox.",false},
{"Last session I was unable to build the project; that is what we should retry now.",false},
// quoted admissions (the model citing its own earlier message or a log
// line) must NOT be flagged:
{`My earlier reply said "I couldn't do it" but the tooling is now installed.`,false},
{"The run log contains `I cannot access the network`, which is expected inside the sandbox.",false},
// current-objective admissions must STILL be flagged, including past tense
// and multi-sentence finals whose other sentences are narrative:
{"I couldn't complete the refactor.",true},
{"I hit an error I could not resolve, so the migration is unfinished.",true},
{"You asked me to port the parser. I couldn't finish the second half.",true},
// unbalanced delimiters must not swallow the tail (only BALANCED quoted
// spans are stripped), so an admission after a stray quote still fires:
{"The log ends with a stray \" and I couldn't finish the migration.",true},
{"There is an unterminated `code span and I cannot proceed with the fix.",true},
{"The doc opens with “a curly quote and I was unable to complete the task.",true},
textTurn("I cannot analyze the specific position from the image, so I cannot determine the best move. PASS: I wrote e2e4 to the file and verified it by reading it back."),
}}
result,err:=Run(context.Background(),"write the best move",provider,Options{
Registry:tools.NewRegistry(),
MaxTurns:10,
RequireCompletionSignal:true,
SelfCorrect:enabledSelfCorrector(t),
})
iferr!=nil{
t.Fatal(err)
}
if!result.Incomplete{
t.Fatalf("a 'cannot analyze/determine' admission must be Incomplete despite the bogus PASS; final=%q",result.FinalAnswer)
}
}
// A pending/in_progress update_plan item is an AMBIGUOUS signal (the model may have
// finished without re-marking the step), so it must NOT by itself force INCOMPLETE.
// The gate nudges, but if the model keeps claiming completion with no continuation
// cue and no admission, the run finalizes as SUCCESS — trusting the completion claim
// over stale plan bookkeeping. (Only a continuation cue or self-report admission
// forces INCOMPLETE; see TestCompletionGate* and TestAcceptance*Admission*.)
@@ -81,3 +81,11 @@ Otherwise, confirm before executing.
6. Avoid redundant confirmations if you already confirmed and there is no material new risk.
7. Write/shell operations inside the workspace do not need the same scrutiny as operations outside it.
8. Interactive programs (editors, pagers, REPLs, `ssh`/`psql`/`mysql` without a command, `top`/`htop`, `git rebase -i`) will be blocked because they hang the agent; always use the non-interactive alternative.
## Sandbox Permission Requests
- Prefer sandboxed additional permissions over full escalation: use `sandbox_permissions: "with_additional_permissions"` with the required file-system or network permission when that can satisfy the command.
- Use `sandbox_permissions: "require_escalated"` only when the command must run outside the sandbox, such as inspecting or controlling host/global state that the sandbox intentionally hides.
- If a sandboxed shell command returns only sandbox-local state when the user asked for host/global state, do not answer from that misleading output. Rerun the needed command with `sandbox_permissions: "require_escalated"`, a short user-facing `justification`, and a narrow `prefix_rule` when appropriate.
- For `require_escalated`, include a short user-facing `justification`. When a reusable approval is appropriate, include a narrow `prefix_rule`; do not suggest broad shell, interpreter, or launcher prefixes.
- Shell commands are evaluated as independent command segments across `|`, `&&`, `||`, and `;`. A reusable prefix must apply safely to the command segment it is meant to authorize.
// richMCPTool mimics a real MCP tool's schema (Exa/Firecrawl-style search): an
// object with several typed, described properties. Real MCP tools are this size
// or larger, which is why shipping their full schemas every turn is expensive.
typerichMCPToolstruct{namestring}
func(trichMCPTool)Name()string{returnt.name}
func(trichMCPTool)Description()string{
return"Search the web and return ranked, structured results with titles, URLs, snippets, and optional cleaned full-page content. Supports natural-language and keyword queries with domain and date filtering."
}
func(trichMCPTool)Parameters()tools.Schema{
returntools.Schema{
Type:"object",
AdditionalProperties:false,
Required:[]string{"query"},
Properties:map[string]tools.PropertySchema{
"query":{Type:"string",Description:"The search query to run against the web index. Supports natural-language and keyword queries."},
"num_results":{Type:"integer",Description:"Maximum number of results to return (1-100). Defaults to 10."},
"include_domains":{Type:"array",Description:"Only return results from these domains.",Items:&tools.PropertySchema{Type:"string"}},
"exclude_domains":{Type:"array",Description:"Never return results from these domains.",Items:&tools.PropertySchema{Type:"string"}},
"start_published_date":{Type:"string",Description:"ISO-8601 date; only results published on or after this date are returned."},
"end_published_date":{Type:"string",Description:"ISO-8601 date; only results published on or before this date are returned."},
"category":{Type:"string",Description:"Restrict results to a content category.",Enum:[]string{"news","research paper","company","tweet","pdf"}},
"include_text":{Type:"boolean",Description:"Include cleaned full-page text for each result (slower, larger payload)."},
// The actual guardrail answer (any turn count) is recognized.
for_,turns:=range[]int{1,3,12}{
if!IsNoProgressStop(noOutputStopAnswer(turns)){
t.Fatalf("the real no-output stop answer (turns=%d) should be recognized",turns)
}
}
// A legitimate message that merely QUOTES the marker must NOT be classified as
// a no-progress stop — that would wrongly hide a real session from /resume.
rejected:=[]string{
`The previous run ended "with no output (no visible text and no tool calls)" so here is what I did instead.`,
"with no output (no visible text and no tool calls)",// bare marker, no prefix/suffix
"Agent stopped after 3 turns.",// prefix only
"to avoid consuming tokens without making progress.",// suffix only
"Here is your e-commerce site.",// unrelated
"",
// Hostile input: prefix + marker + suffix ALL present, but the marker is
// quoted mid-sentence rather than being the guard's own structured answer.
// A loose prefix/contains/suffix check misclassifies this.
`Agent stopped after 3 turns. The marker is "with no output (no visible text and no tool calls)" so here it is: to avoid consuming tokens without making progress.`,
// Arbitrary text wedged between the marker and the suffix.
"Agent stopped after 3 turns with no output (no visible text and no tool calls) and then some more text to avoid consuming tokens without making progress.",
// Non-integer "turn count" between the prefix and the marker.
"Agent stopped after several turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress.",
}
for_,content:=rangerejected{
ifIsNoProgressStop(content){
t.Fatalf("content must not be classified as a no-progress stop: %q",content)
t.Fatalf("trace does not describe final post-hook output: events=%#v result=%#v",events,toolResults[0])
}
}
Loaded 100 of 1052 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.