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.
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.
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).
* 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]>
2026-06-15 18:27:21 +05:30
676 changed files with 88692 additions and 18167 deletions
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,100 @@ 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.
description:Extending Zero — how to write AGENTS.md files, custom specialists, skills, hooks, MCP servers, and plugins for the open-source CLI coding agent.
- Use `go run ./cmd/zero` to launch the local CLI.
- Use `go test ./...` for the repository test suite.
- Use `go run ./cmd/zero-release build` to build the release-facing binary.
- Use `go run ./cmd/zero-release smoke` to smoke-test the built binary.
- Use `go run ./cmd/zero-release package` and`go run ./cmd/zero-release verify` for release artifact checks.
- Use `go run ./cmd/zero-perf-bench` for performance smoke work.
- Do not add Bun or TypeScript build/test dependencies for repository validation.
- Use Node only when directly exercising the npm wrapper at `bin/zero.js`.
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` (uppercase `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.
## 2. Custom specialists
Specialists are Zero's sub-agents. Three scopes, in priority order:
| Scope | Path | Shared? |
| --- | --- | --- |
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
| Project | `./.zero/specialists/*.md` | yes — the repo team |
Project overrides user overrides built-in when names collide.
A specialist is a markdown manifest with frontmatter and a system prompt:
```markdown
---
description:Reviews API changes for breaking-change risk and missing tests.
tools:read-only,plan
---
You review API changes. For every changed hunk in `internal/api/` or any file
that ends in `_api.go`:
1. Confirm the public signature is backward-compatible, or note the breaking
change explicitly with the migration path.
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
the new behaviour is exercised.
3. Flag any new exported symbol without a doc comment.
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
```
CLI management (the prompt is passed inline via `--prompt`):
```bash
zero specialist list
zero specialist show api-reviewer
zero specialist create api-reviewer \
--project \
--description "Reviews API changes"\
--tools read-only,plan \
--prompt "$(cat api-reviewer.md)"
zero specialist edit api-reviewer --project
zero specialist delete api-reviewer --project
zero specialist path # prints the resolved specialists directory
```
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](docs/SPECIALISTS.md).
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
## 3. Skills
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Skills are **user-level only** in this version — there's no project-scoped skill directory yet, so anything you want shared with the team goes in `AGENTS.md` (section 1) or as a hook (section 4).
Discovery root: `$ZERO_SKILLS_DIR` → `$XDG_DATA_HOME/zero/skills` → `~/.local/share/zero/skills/`. A missing directory is fine — Zero just reports "no skills".
```
~/.local/share/zero/skills/
run-benchmarks/
SKILL.md
write-changelog/
SKILL.md
```
`SKILL.md` format:
```markdown
---
description:Run the project's benchmark suite and summarize the deltas.
---
# Run benchmarks
1.`make bench` — captures the wall-clock and RSS before and after.
2.`benchstat before.txt after.txt` — diffs the two.
3. Report any regression > 5% with the function name and the previous value.
```
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. If two skills declare the same name, the one in the lexicographically-first directory wins — duplicates are dropped silently. Plugin-declared skills (section 6) are not yet merged into the loader, so a `skills:` entry inside a plugin's `plugin.json` is not visible to the `skill` tool today.
The `skill` core tool lets the agent load any discovered skill by name.
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
## 5. MCP — Model Context Protocol
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
### As a client — configure MCP servers in `config.json`
zero mcp add docs --type stdio -- docs-mcp --port 7777
zero mcp add github --type http --url https://api.example.com/mcp \
--header "Authorization=Bearer YOUR_TOKEN_HERE"
zero mcp list
zero mcp check docs
zero mcp remove github
zero mcp oauth login github
```
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
- A wrapper script that sources the secret and execs the real command.
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
### As a server — expose Zero's tools to another agent
```bash
zero serve --mcp
```
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
## 6. Plugins
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
- User: `~/.config/zero/plugins/<id>/`
- Project: `./.zero/plugins/<id>/`
Each plugin has a `plugin.json` manifest:
```json
{
"id":"github-pr-review",
"name":"GitHub PR Review",
"description":"Adds review tools for GitHub PRs.",
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins remove github-pr-review # alias: rm
```
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above. Skills declared inside a plugin's `plugin.json` are not yet merged into the `skill` tool's discovery (see section 3).
## 7. Configuration locations
Three layers, applied in order (later layers override earlier ones):
| Layer | Path | Notes |
| --- | --- | --- |
| Built-in defaults | compiled in | Lowest priority. |
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
## 8. End-to-end example
A team that wants every contributor's Zero to behave the same way commits:
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 installs a small wrapper plus the matching Zero binary for your
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
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.
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`
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)
{Type:zeroruntime.StreamEventToolCallDelta,ToolCallID:"grant-1",ArgumentsFragment:`{"reason":"Need to write outside the workspace.","permissions":{"file_system":{"write":[`+quoteJSONString(target)+`]}}}`},
return"Response style: concise. Lead with the result and keep answers short and direct; omit preamble, restating the question, and any explanation not needed to be correct."
case"explanatory":
return"Response style: explanatory. Explain the reasoning behind your answers and changes, surface relevant trade-offs, and add brief context a learner would want — while staying on task and not padding."
case"review":
return"Response style: review. Work like a critical reviewer: call out risks, edge cases, and unstated assumptions, flag anything questionable, and prefer precise, evidence-backed statements over reassurance."
return"## Approved Command Prefixes\n\nThe following command prefixes have already been approved and do not need another permission prompt:\n"+strings.Join(lines,"\n")
}
// specialistDelegationContext nudges the orchestrator to offload read-heavy or
// parallelizable work to a specialist sub-agent via the Task tool, keeping large
// tool outputs out of the main context. It renders only when specialists are
// known (which is only where the Task tool is actually registered), so a run with
// no delegatable specialists produces the previous prompt unchanged.
b.WriteString("Delegate focused or read-heavy work to a specialist sub-agent with the Task tool instead of doing it inline. ")
b.WriteString("When a request matches a specialist's purpose, delegate to it proactively — you do not need the user to ask first. ")
b.WriteString("This keeps large tool outputs — searches, file dumps, multi-step exploration — out of your own context, so you stay fast and token-efficient. ")
b.WriteString("Prefer delegating codebase search and exploration; for independent subtasks, launch several specialists in parallel. Handle small, direct edits yourself.\n")
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
b.WriteString("Shell syntax: Windows cmd.exe syntax for bash tool; prefer the cwd argument instead of cd when changing directories.\n")
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
}else{
b.WriteString("Shell syntax: /bin/sh syntax for bash tool; prefer the cwd argument instead of cd when changing directories.\n")
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
constpromptHeader=`Generate an AGENTS.md for this repository so future agent runs start with the right context.
`
constpromptInstructions=`
Now investigate the repository to fill in what the facts above don't cover, then write a concise, high-signal AGENTS.md at the repo root. Steps:
1. Use the pre-computed facts as your starting point — don't re-derive what's already stated; investigate the GAPS (conventions, how to build/test/lint, architecture, what to avoid).
2. Read the key entry points, the build/test config, and any existing README or CONTRIBUTING for project-specific rules.
3. Write AGENTS.md with these sections, omitting any that don't apply:
- A one-line description of what the project is.
- Build / test / lint commands (the exact commands a contributor runs).
- Project conventions (naming, file layout, idioms the code follows).
- Things to avoid (vendored dirs, generated files, gotchas).
4. Keep it tight — aim for under ~60 lines. Prefer imperative, specific rules ("Run make test", not "you could test"). No secrets, no environment-specific paths.
5. If a critical fact is genuinely ambiguous (e.g. two plausible test commands), ask the user ONE batched question; otherwise just write the file.
// This path launches the interactive TUI, which takes no positional prompt or
// subcommand. Reject any remaining trailing arg loudly instead of silently
// dropping it, so `zero --skip-permissions-unsafe "fix bug"` doesn't appear to
// hang in the TUI with the prompt discarded. (AUDIT-L3)
for_,arg:=rangerest{
ifstrings.TrimSpace(arg)!=""{
returnwriteAppError(stderr,"--skip-permissions-unsafe launches the interactive TUI and takes no prompt or subcommand; for a one-shot unsafe run use `zero exec --skip-permissions-unsafe -p \"...\"`",1)
if_,err:=fmt.Fprintf(stdout,"\nOpenRouter login complete — new API key minted.\nUse it with zero, e.g.:\n export OPENROUTER_API_KEY=%s\n(or add it to a provider profile with catalogID \"openrouter\").\n",key);err!=nil{
returnexitCrash
}
returnexitSuccess
}
// runAuthChatGPT runs the ChatGPT (Codex) browser PKCE login, persists the
// bearer + chatgpt-account-id claim under the "chatgpt" provider key, and
// prints a status block. The bearer routes to chatgpt.com/backend-api/codex
// for ChatGPT Plus/Pro/Business/Enterprise subscribers; a successful login
// makes the agent use the chatgpt catalog entry with the OAuth bearer.
Loaded 100 of 676 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.