From 94a33a6c09c5ddbce1c3be9ce3a673141d26a7cd Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Thu, 2 Jul 2026 09:15:53 +0800 Subject: [PATCH 1/2] feat(tui): FILES sidebar section with click-to-select and file drill-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The right context sidebar gains a FILES section between PLAN and ACTIVITY: the workspace files the session has touched, newest first, with an A/M/✗ badge, an aggregated +added/−removed diffstat, and a pulsing row for the file whose write is streaming right now. The roster is recovered on demand from tool-result rows' changedFiles (persisted to the session payload and restored on rehydrate), so it survives /resume and can never drift from the transcript — the same derive-from-rows pattern the swarm AGENTS roster uses. Clicking a row selects the file: its row gets an accent ▸, its edit cards tint accent in the chat, and the transcript scrolls to the most recent one. A second click (or any FILES click while the view is open) opens a drill-in reusing the subchat pattern — the chat column body swaps to the file's stacked full-depth edit cards ("edit N of M"), f toggles to the syntax-highlighted on-disk file with ▎ markers on session-added lines, d back to diff, Esc returns to the chat at its saved scroll position; a second Esc clears the selection. The body swap happens inside transcriptBodyItems and the nav bar replaces the pinned title bar, so the scroll engine, viewport, and mouse hit-testing stay consistent for free. fix(oauth): make xAI / Hugging Face OAuth actually launch from the wizard (#355) 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__* 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 (#354) * 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 , --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 (#352) * 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 (#353) * 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. revert(local): restore the original bright-lime accent color (#351) 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(tui): subchat mouse selection, hover highlighting, and drag-to-edge auto-scroll (#350) * 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: agent runtime resilience — stream stalls, weak-model tool args, and sub-agent reliability (#349) * 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 (#348) * 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 so they can't hang forever (#347) * 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. test(providers): isolate the OAuth store in the chatgpt Codex routing test (#346) 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. fix: stream Codex reasoning + drop swarm agents on their own completion (#345) 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 agent_type to the registered roster via a dynamic enum (#344) * 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. fix(openai): always send message content so strict OpenAI-compatible servers accept it (#343) 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: '. 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. Document source-build sandbox helpers (#342) 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 fix(opengateway): correct gateway host, make it the recommended default (#341) 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 Co-authored-by: Claude Opus 4.8 (1M context) chore(launch): MIT license, windows-arm64 install guard, TUI TTY guard (#340) * 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. Clean up public release docs (#339) 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 (#338) * 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. feat(tui): remove the /mode command (superseded by /model + /effort) (#337) 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 TUI transcript rendering (#334) * 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 fix(openai): forward reasoning effort to the Codex Responses API (#336) 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). fix(modelregistry): one source of truth for a model's reasoning efforts (#335) 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. feat: context-aware compaction + usage gauge for all models (#332) * 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. feat(tui): dim the transcript backdrop behind overlays for visibility (#333) 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. feat(tui): compact one-line /model switch confirmation (#331) 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 — ' · [ · effort …][ · saved]' — keeping the deprecation/redirect notice line when present. Tests updated to the new format. fix(model): expose reasoning effort for GPT-5 / Codex / o-series (#330) /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. feat: encrypted provider credentials + provider-aware /model (#329) 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 ` 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 in the composer with suggested answers (#327) * 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. fix(tui): paint transcript selection once, not twice (#328) 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): ask_user suggested answers with a recommended default (#326) * 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 (#324) * 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): gate headless run completion on honesty (no false-success on no-tool-call / self-reported-incomplete turns) (#325) * 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. Improve sandbox permission handling (#323) 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 sandbox command approval behavior (#322) * 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 (Intel macOS + Linux arm64) (#321) * 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. chore: launch-readiness — add MIT LICENSE + reconcile README (#320) * 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 (#319) * 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 feat(tui): clickable plan-step detail + plan/sidebar UX & rendering polish (#315) * 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. feat(agent): preamble before multi-step tasks for a readable chat (#317) 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. feat(tools): show red/green diff preview on write_file and edit_file (#318) 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. fix(tui): stop ANSI escape leaks + drop redundant update_plan cards (#316) 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) (#313) * 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 (#312) * 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). Fix shared temp roots in sandbox scope (#314) 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 (#311) * 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 ` 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 (#310) * 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. fix(tui): keep running swarm subagents in the AGENTS sidebar (#309) 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 (#308) * 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 "↑ 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. chore: scrub named other-CLI references from the codebase (#307) * 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. fix(tui): stop showing the token count on both sides (#306) 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. feat(tui): show provider on each model-picker row (#305) 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. Fix token accounting and usage display (#304) fix(sandbox): retry approved network shell commands (#301) * fix(sandbox): retry approved network shell commands * test(sandbox): fix portable smoke expectations * fix(sandbox): restore Windows offline egress block fix(tui): cap the expanded live reasoning to ~half screen (#303) 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 (#302) * fix(sandbox): grant ancestor metadata so cd into the workspace works macOS seatbelt granted file-read* on each read root via (subpath ""), 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: : 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 (#300) * 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. fix: update ChatGPT Codex OAuth request handling (#299) feat(sandbox): online/offline Windows network identity — run approved network commands sandboxed (#297) * 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): stop bricking Windows when the sandbox isn't set up (degrade like Linux/macOS) (#295) * 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. Fix TUI UX issues (#290) * 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 fix(sandbox): let macOS sandbox run Homebrew/usr-local tools (node, python3) (#296) 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. fix(sandbox): stop the interactive guard from blocking 'node --version' & friends (#294) 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. feat(tui): declutter the chat — drop the gimmicky billboard chrome (#291) 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, reduced-motion gate (#289) * 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. feat(tui): make tool results and selections easier to scan at a glance (#287) 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): Agent Client Protocol surface — drive ZERO as an editor backend (#288) * 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. fix: behavioral-audit follow-ups (exec -p, cron/version/specialist UX, update_plan coercion, Makefile) (#286) 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 ` 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). fix(providers): make the stream idle timeout global, configurable, and less aggressive (#285) 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. feat(npm): publish @gitlawb/zero with a postinstall binary installer (#284) 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. fix(config): skip an unresolvable non-active provider instead of crashing startup (#283) 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. fix(sandbox): self-dispatch the Windows sandbox helpers so they work in dev (#280) 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 Windows sandbox helpers (#278) 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. fix: resolve product/UX audit findings for OSS launch (#282) 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. fix: resolve 15 findings from the 2026-06-20 deep audit (#281) * 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 test(tools): make write_stdin "\x03" the trigger in the interrupt test (#273) 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: five capability features from the reference-agent comparison (#276) * feat(commands): user-defined slash commands from markdown files Drop a markdown file at `.zero/commands/.md` (project, shared with the team) or `/zero/commands/.md` (personal); typing `/ 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 `). 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): make ChatGPT (Codex) login work end-to-end (#264) * 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. * 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. * 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) Fix custom provider model discovery (#277) Audit tail: remaining medium/low correctness fixes (CLI, config, OAuth, tools, LSP, TUI) (#275) * 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 ""` now forwards the prompt as the inline `--prompt=` 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, and single-instance lock (#274) * 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 (#263) * 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). Add managed background terminals (#270) * Add managed background terminal sessions * Stop background terminals on TUI exit * Cover approved local network sandbox mode * Polish background terminal rendering * Tighten exec session cleanup guidance * Address background terminal review feedback * Kill exec session trees on Windows * Trigger CI for Windows cleanup feat: clipboard image paste + provider 400 handler for image rejection (#268) * 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). * 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) feat(tui): transcript polish — left-rule cards, tool-call density, pinned plan (#269) * 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. * 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. * 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) feat(tui): `?` keyboard-shortcut overlay (#271) 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) fix(tui): give command-info screens real contrast (not flat grey) (#272) /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) Add persistent exec command sessions (#267) * 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 transcript dedup (#262) * tui: live code-streaming preview, theme system, plan/Task dedup - Live "writing " 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. fix five concurrency/leak bugs (swarm, specialist, oauth, mcp, cron) (#265) 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. fix model-discovery fallback + redact opaque Authorization tokens (#266) 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. audit: fix four high-severity concurrency/correctness bugs (#261) 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²) hot paths, /style wiring (#260) * 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 (#255) * 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 tools: tool_search redirects eager-tool lookups instead of "no tools matched" (#259) 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: ". 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 — " 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. tools: defer swarm tool schemas + keyless SearXNG web_search backend (#258) 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. Align sandbox permission blocks and backend states (#253) * 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 (read_minified_file) (#256) * 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 (elapsed) + streaming reasoning preview so long turns never look frozen (#254) * 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): Hugging Face + ChatGPT Codex as first-class built-in OAuth presets (#245) * 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: `, 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. Persist command prefix approvals (#252) 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 Add session command prefix approvals (#250) 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 ./... sessions: make the events log authoritative for sequencing + fsync checkpoints/rewind (#251) 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 (#249) * 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 (#248) * 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 (#243) * 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 "@ " 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 (#247) * 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 to stop eager MCP-schema token waste (#241) * 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 (#246) * 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 (#244) * 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 Align permission prompts for web tools (#242) 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 (#240) * 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 feat: free out-of-the-box web (keyless Firecrawl default) + resilient MCP startup (#239) * 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 user-facing 'Extending Zero' guide (#237) * 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: add `providers detect` and `sandbox check` (wire audit-found unwired features) + coverage (#238) * 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 ` 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): clear error when a model host is unreachable (#233) * 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 (#229) * 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. * 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() -> sets effort, shows confirmation card - handleEffortCommand() -> 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 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Improve /resume: interactive session picker + model-generated titles (#224) * 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 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 + " 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. tui: right-click pastes the clipboard directly (no menu) (#231) * 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). fix(tui): selectable + clickable permission popup (#232) 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. Render transcript viewport from measured items (#227) 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 ./..." Harden TUI layout and mouse routing (#226) * 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 (#221) * 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). * 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 feat(tui): age-based fade for streaming assistant text (#223) * 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 Fix provider setup paste handling (#225) TUI polish and fixes (#220) * Persist TUI model switches * Pin TUI title bar in chat view Migrate TUI to Bubble Tea v2 (#222) * 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 OAuth / account login for providers (OpenRouter, xAI) + subscription-proxy support (#217) * 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 `, 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 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__* 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__* 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__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. Audit backlog: tui mouse API, notify webhook, swarm scheduling, daemon-remote bundles, MCP/oauth store unification, OS keyring, dead-code prune (#216) * 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 /. 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 ` enables uploads. - `daemon link --remote --repo --id [--out ]` uploads and prints the remote path (+ a ready run command); `daemon link --show ` 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:". 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 (#213) * 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 (.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 robustness + chat UX: collapse, clipboard, scroll-pin, proactive search (#218) * 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 `. 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 (#219) * 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 Polish assistant response transcript (#204) * Polish assistant response transcript * Keep copy feedback above composer feat(daemon): TLS remote bridge — authenticated network access to the daemon (#211) * 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 [--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 (#210) * 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__* 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:" / "mcp:"), 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 [--device] [--scope ...], logout, status [provider], refresh [--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 (#209) * 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. * 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: Vasanthdev2004 Co-authored-by: Claude feat(swarm): multi-agent swarm over the specialist sub-agent (#207) * feat(swarm): multi-agent swarm over the specialist sub-agent Add internal/swarm so an orchestrator can spawn and coordinate MULTIPLE specialist members concurrently under one team, communicate via per-agent mailboxes, hand work off, and adopt orphaned tasks. Additive and opt-in: the single Task tool is unchanged; swarm tools only act when invoked. - Registry/Definition: agent roster (built-in teammate/subagent + user-defined). - Mailbox: per-team/per-agent JSON inbox, owner-only 0600, file-locked with stale-break, size-capped, path-confined, and malformed-fails-closed. - Coordinator: in-memory task registry with terminal-state guards and stable per-agent colors. - Team: bounded concurrent members per team with a FIFO overflow queue that drains one-per-slot. - Lifecycle: spawn / handoff / orphan-adoption with bounded relaunch on temporary failures; members run under the Swarm's base context so they outlive the spawn call. - Tools: swarm_spawn, swarm_send, swarm_inbox, swarm_status, swarm_handoff, swarm_collect. - Wiring: each member is launched through specialist.Executor and inherits the orchestrator's model + permission mode (never widened) and runs under the same sandbox/policy. Mailbox state lives under the workspace so writes fall within the sandbox write rules. Swarm lifetime is paired with the specialist runtime. Scheduling (cron/wakeup) and daemon-pool-backed members are deferred follow-ups. * swarm: address CodeRabbit review (7 findings) - coordinator: SetStatus rejects unknown TaskStatus values (fail closed) so a task can never enter a state Summarize/lifecycle don't reason about. - permission inheritance ENFORCED end-to-end (was resolved but discarded): thread PermissionMode through specialist.TaskRunOptions -> BuildArgs/ BuildResumeArgs. A headless specialist child now runs "--auto high" (unsafe) ONLY when the parent is unsafe or no mode is given (preserves the Task tool's behavior); any other parent mode yields non-unsafe "--auto low", so a member never gains more authority than its orchestrator. Also fixed the swarm permission vocabulary to the real agent runtime modes (ask/spec-draft/auto/ unsafe) instead of TUI display names, and the launcher substitutes "auto" for an empty mode so a member can't fall into the empty-means-high path. - mailbox: symlink-aware confinement (EvalSymlinks) rejects an inbox dir that resolves outside BaseDir; dirs tightened to 0700 even if pre-existing with broad perms; lock release is ownership-token-aware so a stale-broken writer can't delete a newer writer's lock (split-brain). - member: recover panics inside a member goroutine -> fails that member only, never crashes the orchestrator. - tools: collapse() truncates on rune boundaries (no invalid UTF-8). Tests added for every fix. Existing specialist behavior is unchanged (empty PermissionMode keeps --auto high). Gates: gofmt/vet/build (host+linux)/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new) all pass. * swarm: address review round 2 (Windows lock + non-blocking notes) Blocking (Vasanth): mailbox acquireLock now treats Windows ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION as lock-contended (retry/wait) instead of a fatal error, via a platform-aware isLockContended (lock_windows.go / lock_other.go). A contended lock under Windows file-sharing semantics no longer fails a send/read spuriously. TestMailboxConcurrentSends raised to 200 goroutines with a 10s lock timeout and now asserts zero send failures (regression guard). Non-blocking notes addressed: - Removed third-party reference-project names from doc comments (clean-room + provider-neutral); the Go code is original. - Documented the inbox-file size bound's ×2 (pretty-print overhead) constant. - Documented Send's O(N) rewrite cost + when to switch to append-only. - permissionRank: spec-draft now ranks below ask (it never widens authority). - Documented watch's per-member-id retry binding (Member.ID == MemberSpec.ID). - handoff Safety.Reason now also mentions the inbox write. Gates: gofmt · vet · build (host+linux+windows) · test -race · staticcheck (no new) · govulncheck (0) · deadcode (no new) all pass. feat(sandbox): complete sandbox breadth — WSL fallback + opt-in MITM TLS inspection (#206) * feat(sandbox): complete sandbox breadth — WSL fallback + opt-in MITM TLS inspection Adds the two remaining sandbox features on top of the strict fail-closed core (the other four — SOCKS5 egress, re-entrancy guard, fine-grained path lists, sandbox-aware ripgrep — already shipped and are untouched). Both new features are opt-in and the DEFAULT policy is unchanged (enforce + network deny, InspectTLS off; AllowPolicyOnlyRunner keeps its existing default). 1) WSL detection + safe fail-closed fallback - parseWSL/detectWSL (wsl.go + wsl_linux.go reading /proc/version; wsl_other.go stub) classify WSL/WSL2. - SelectBackend: on Linux without bubblewrap, if running under WSL it selects a new BackendWSL instead of silently degrading to a plain policy-only runner. - BuildCommandPlan(BackendWSL): FAILS CLOSED unless Policy.AllowPolicyOnlyRunner is set (errWSLPolicyOnlyDisabled explains how to opt in). When allowed it runs the policy-only fallback that still routes egress through the filtering proxy (Backend.ProxyEgress => EnforcesScopedEgress, best-effort HTTP+SOCKS), sets ZERO_SANDBOXED=1 / ZERO_SANDBOX_BACKEND=wsl, and records an auditable least-privilege downgrade note (CommandPlan.Notes). Never runs unsandboxed silently. 2) Optional gated MITM TLS inspection (Policy.InspectTLS, default OFF) - Off => the egress proxy is a pure CONNECT passthrough, byte-for-byte unchanged (no CA generated). - On => the proxy terminates TLS toward the sandboxed client with an EPHEMERAL, locally generated ECDSA CA (mitm.go), minting per-host leaves on the fly, so it can re-check the DECRYPTED request Host through the SAME authorize()/domainAllowed() gate (deny-wins, empty allowlist => deny) — MITM widens visibility, never authority. The UPSTREAM dial is ALWAYS validated against the system roots (never InsecureSkipVerify; tests inject a known root pool to exercise validation). The CA's PUBLIC cert is written owner-only and surfaced via ZERO_SANDBOX_CA_CERT; MITM only runs on backends that already permit reading it (sandbox-exec allows file-read; WSL shares the host fs), so no new bind path is introduced. Trust implications documented loudly on the field. (No "raw passthrough only" flag exists in zero, so the mutual-exclusion case is N/A.) Tests: parseWSL table; WSL fail-closed-without-opt-in and policy-only-with-proxy (markers + ProxyEnvWithSocks) + deny-mode markers-no-proxy; MITM passthrough-by- default (no CA), CA mints a chaining leaf, full TLS-terminate-and-forward of an allowlisted host (upstream validated via an explicit root pool), and decrypted- Host deny. Gates: gofmt, go vet (host + GOOS=linux), go build (host/linux/ windows), go test (incl -race), staticcheck (no new, host + linux), govulncheck (0), deadcode -test=false under GOOS=linux (no new unreachable funcs). * sandbox: address review on #206 (WSL + MITM) - mitm: bound the upstream transport's TCP connect (DialContext 30s) so a blackholed upstream can't park a goroutine; disable upstream HTTP/2 and normalize the response to HTTP/1.1 before writing to the HTTP/1.1 client. - mitm: mint leaves for the AUTHORIZED CONNECT host (not arbitrary client SNI) and cap the leaf cache (512) so SNI churn can't drive unbounded keygen/memory. - mitm: re-check the decrypted Host with the FULL authorize gate (authorizeTarget) so a DomainPrompt-allowed host is honored consistently with the CONNECT check, not just domainAllowed. - runner (WSL): preserve the caller's env in the direct-run WSL plan (it is not OS-wrapped), appending the sandbox markers last; the downgrade note says "with proxy egress" only when a proxy was actually started. - tests: WSL caller-env preservation; deny-mode asserts no HTTP/HTTPS/ALL_PROXY leak + the downgrade note; MITM forward asserts HTTP 200; mitmConnect sets an I/O deadline. Gates: gofmt, vet (host+linux), build (host/linux/windows), test (incl -race), staticcheck (no new), govulncheck (0), deadcode -test=false (GOOS=linux) no new. Polish /permissions command output into a bounded card (#205) * Convert /permissions output to bounded command card Replace status: ok style with commandCard format used by /tools and /context. Summary shows mode and grant count; grants render as bullet rows. * Address CodeRabbit nitpick: add error path test for /permissions Adds TestPermissionsCommandCardHandlesGrantListError and a grantLister interface so tests can inject a stub store without a real file path. * Fix gofmt formatting in permissions card Tabs alignment only; no functional change. --------- Co-authored-by: Claude feat(daemon): zero daemon — worker pool + session supervisor over a local control socket (#203) * feat(daemon): add `zero daemon` — worker pool + session supervisor over a local control socket Adds internal/daemon + `zero daemon start|stop|status|run|attach`: a long-running local service that supervises a pool of headless `zero exec` workers and routes multiple agent sessions to them over an owner-only Unix-domain control socket. Additive — interactive and one-shot exec are unchanged. Reuses zero's building blocks: - internal/background: ConfigureChildProcessGroup (process-group setup) + TerminateProcess (cross-platform terminate) for worker kill/drain. The pool does its own os/exec and uses background only for those helpers. - internal/streamjson: the worker speaks stream-json on stdout; the daemon forwards those lines to clients. - internal/cli exec: a worker is `zero exec --output-format stream-json`. Pieces (reimplemented in Go from a reference daemon): - protocol.go: framed control codec — 4-byte BE length + 1 kind byte, 1 MiB cap, JSON control messages, version negotiation. - pool.go + backoff.go: bounded worker pool — at-least-once dispatch with exponential backoff+jitter retry capped by MaxAttempts, EXIT_TEMPFAIL(75)/ EXIT_PERMANENT(76) handling, graceful drain + kill-timeout. zero's exec is one-shot, so workers are on-demand (one session per worker = the lease), not warm long-lived slots (a persistent worker mode is a follow-up). - session.go: SessionManager — sessionID->worker routing, lease (one active request per worker), queue when the pool is full, per-session output buffer for attach + metrics. - server.go + lock.go + socket.go + paths.go: single-instance PID-file lock with stale-lock (dead-PID) recovery, status file, owner-only socket, accept loop; cleans up lock/socket/status on exit. - launcher.go: production worker launcher. client.go: control-socket client. Security model: - Control socket is LOCAL-ONLY (AF_UNIX loopback file, never a TCP port) and owner-only: socket 0600 + parent dir 0700 on POSIX (Windows relies on the per-user profile ACL; a dedicated pipe ACL is a follow-up). - Workers stay sandboxed: the launcher SCRUBS the sandbox re-entrancy markers (ZERO_SANDBOXED / ZERO_SANDBOX_BACKEND) from each worker's env so the worker re-establishes its OWN sandbox instead of inheriting a pass-through plan, while propagating the rest of the policy config/env. The daemon never bypasses the sandbox. - Control frames over the 1 MiB cap are rejected before allocation. Robustness: a crashed worker never takes down the daemon (backoff+jitter retry, MaxAttempts cap, EXIT_PERMANENT stops retry); drain gives a grace window then force-kills stragglers; a stale lock from an unclean exit is reclaimed. Out of scope (follow-up): remote session-linking; warm pre-spawned persistent workers; Windows named-pipe ACL. Tests: protocol round-trip + oversize/unknown-kind rejection; backoff bounds; pool spawn/retry-backoff/permanent/cap/queue/drain; session lease/route/queue/ attach; lock single-instance + stale recovery; launcher env-scrub (incl. an end-to-end check that the spawned worker process receives scrubbed env); server e2e start->run->stream-json->status->attach->stop with a fake worker; CLI usage/validation/not-running paths. Live-verified start/status/stop (0600 socket, 0700 dir, file cleanup). Platform files behind explicit //go:build tags. * daemon: address review — help listing, extra-arg rejection, lock write error, bounded sessions - cli/app.go: list `daemon` in `zero --help` so the command is discoverable. - cli/daemon: `stop`/`status` reject extra arguments; `attach` rejects more than one session id (fail fast on typos instead of silently ignoring tokens). - daemon/lock: check the PID fmt.Fprintf error — a partial/failed write would leave a malformed lock file that another process reads as stale and wrongly reclaims, breaking the single-instance guarantee; on write failure remove the file and return the error. - daemon/session: bound the registry with MaxSessions (default 256) — evict the oldest FINISHED sessions once past the cap so a long-running daemon doesn't accumulate sessions without limit; running/queued sessions are never evicted, so attach-after-finish for recent sessions is preserved. Tests: CLI extra-arg rejection; finished-over-cap eviction + running-never-evicted. Gates: gofmt, vet, build, test (incl -race), staticcheck (no new), govulncheck (0), deadcode (no new), GOOS=linux+windows cross-compile. sandbox: exempt first-party network tools from the Evaluate network-deny too (#202) Follow-up to the EnforceToolNetwork change: web_search/web_fetch were still hard-denied under NetworkDeny because the engine-level gate in Evaluate (netMode==NetworkDeny && risk has "network") fired BEFORE the tool's RunWithSandbox/NetworkHostAllowed ever ran. The earlier change only relaxed NetworkHostAllowed, so the user-facing path (Evaluate -> registry) still blocked the tool with "network access is blocked by sandbox policy". Fix: Evaluate now skips the network-deny for a request that declares SideEffectNetwork (the first-party in-process tools, web_search/web_fetch) when EnforceToolNetwork is off — mirroring the NetworkHostAllowed exemption so the two gates agree. A SHELL command merely classified as network (SideEffectShell) is NOT exempt, so shell egress stays blocked under deny. EnforceToolNetwork restores the strict deny for the tools. Tests: TestEvaluateExemptsNetworkToolsFromDenyByDefault covers the full path (default-exempt, granted->allow, EnforceToolNetwork->deny, shell stays denied); the two existing gate tests now opt into EnforceToolNetwork to keep testing the strict path. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new), govulncheck (0), deadcode (no new unreachable funcs). Polish TUI transcript and reasoning display (#200) * Polish TUI transcript and reasoning display * Address provider and transcript review fixes * Gate inline think tag parsing * Cover disabled think tag parsing config Add command dashboard cards (#201) * Add context command dashboard card * Render tools command as dashboard card * Render command cards as styled TUI cards * Fix command card action rendering * fix: harden command card edge cases feat: add doctor fix flow (#198) * feat: add doctor fix flow * fix: animate doctor connectivity status * fix: make doctor report problem first * fix: address doctor review feedback feat(sandbox): harden internal/sandbox — re-entrancy guard, SOCKS5 egress, sandbox-aware search, fine-grained path lists (#199) * feat(sandbox): harden internal/sandbox with four opt-in capabilities All four are off by default and fail closed; existing behavior is unchanged when the new policy fields are empty / ZERO_SANDBOXED is unset. 1. Re-entrancy guard — ZERO_SANDBOXED=1 marks every wrapped command; a nested BuildCommandPlan returns a pass-through plan (no double bwrap/sandbox-exec, no second egress proxy). New: EnvSandboxed, IsAlreadySandboxed(). 2. SOCKS5 egress — the scoped-egress proxy also serves SOCKS5 CONNECT on a second loopback port through the SAME authorize()/domainAllowed() gate. ProxyEnvWithSocks points ALL_PROXY at socks5://; HTTP(S)_PROXY stay on the HTTP listener; the sandbox-exec profile allows both proxy ports. Bubblewrap scoped still collapses to deny. 3. Sandbox-aware search — ReadExclusionGlobs(policy, scope) returns ripgrep --glob '!' for DenyRead subtrees; grep/glob skip read-denied subtrees at walk time via Engine.ReadPathExcluded/ReadDirExcluded when sandboxed. 4. Fine-grained path lists — Policy.AllowRead/DenyRead/AllowWrite/DenyWrite. Read = deny-then-allow (more-specific AllowRead re-includes); Write = DenyWrite > workspace/scope > AllowWrite > deny. AllowWrite is reflected in the OS write binds; DenyWrite is emitted as sandbox-exec deny rules. Each entry is home-expanded, made absolute, and symlink-resolved. Tests: internal/sandbox/{reentrancy,egress_socks,pathlists}_test.go and internal/tools/read_exclusions_test.go. Gates: gofmt, go vet, go build, go test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode -test=false (no new unreachable funcs). * sandbox: address review — SOCKS5 method negotiation + cache read-exclusion roots - egress_socks: respect SOCKS5 method negotiation (RFC 1928). The proxy now inspects the client's offered methods and replies 0xFF ("no acceptable methods") when no-auth was not offered, instead of forcing no-auth. - pathlists/engine: resolve DenyRead/AllowRead roots ONCE per search walk via a new ReadExclusions matcher (Engine.ReadExclusions), replacing the per-path ReadPathExcluded/ReadDirExcluded that re-ran Abs/EvalSymlinks for every visited path during a grep/glob walk. Re-entrancy guard left as-is by design: ZERO_SANDBOXED is an internal marker set only on commands zero wraps; cross-process re-entrancy detection legitimately relies on the inherited env, and a manually-exported flag is a self-opt-out, not an external bypass. Tests: SOCKS no-acceptable-methods rejection; nested-AllowRead descent via the matcher. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new unreachable funcs). * sandbox: fix windows smoke test + symlink-resolve DirExcluded prefix - pathlists_test: TestSandboxExecProfileEmitsDenyWriteRule now builds the expected deny clause via sandboxProfileString(secret), so it matches the profile's own escaping (Windows doubles backslashes). Fixes the windows-latest smoke failure; macOS/Linux unaffected (slash paths, no-op escaping). - pathlists: ReadExclusions.DirExcluded now EvalSymlinks-resolves the candidate path before the nested-AllowRead prefix check, matching the resolution applied to allowRoots — otherwise a denied dir reached THROUGH a symlink fails to match a nested AllowRead root and is wrongly skipped, dropping a re-included subtree. Added TestDirExcludedResolvesSymlinkPrefix (skips where symlinks are unavailable). Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new unreachable funcs). * sandbox: strengthen re-entrancy guard with a corroborating backend marker Addresses review feedback that the re-entrancy guard trusted a single ambient env var. IsAlreadySandboxed now requires BOTH correlated markers that sandboxEnvironment sets together on every wrapped command — ZERO_SANDBOXED=1 AND a non-empty ZERO_SANDBOX_BACKEND (new EnvSandboxBackend const) — so a lone stray/hand-exported ZERO_SANDBOXED=1 no longer forces an unsandboxed pass-through. Pass-through (direct) plans set neither marker, so genuine nesting still detected and double-wrapping still avoided. Tests updated: TestIsAlreadySandboxed now asserts each marker alone is insufficient and both together are required; the re-entrancy and wrapping plan tests set/clear both markers explicitly. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new unreachable funcs). * sandbox: don't subject first-party web tools to the network policy by default The sandbox was reported as too strict: web_search (and web_fetch) were blocked with "network access is disabled by the sandbox policy" because the default policy is network=deny and the in-process web tools were gated by the same NetworkHostAllowed check as the sandboxed shell's egress. That network policy exists to confine the sandboxed SHELL's egress, which these in-process tools don't use. New Policy.EnforceToolNetwork (off by default) makes NetworkHostAllowed exempt web_search/web_fetch unless an operator opts in. The sandboxed-shell egress decision (Evaluate / effectiveNetworkMode) is unchanged, and the tools keep their own SSRF/private-IP, port, redirect, and redaction safeguards. Set EnforceToolNetwork to hold the tools to the allow/scoped/deny policy as before. Tests: engine default-exempt + enforced-table cases; web_search default-allows and enforced-when-flag; web_fetch enforced cases set the flag. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new unreachable funcs). * sandbox: re-review fixes — disabled policy inert; path lists independent of EnforceWorkspace - engine: ReadExclusions() / ReadExclusionGlobs() now return inert results under ModeDisabled, so a disabled policy never filters grep/glob (parity with Evaluate, which already allows everything when disabled). - engine/pathlists: the fine-grained path lists (DenyRead/DenyWrite, with AllowRead/AllowWrite) now apply in Evaluate whenever the sandbox is enforcing, independent of EnforceWorkspace — matching the grep/glob path that already honors DenyRead directly. The workspace boundary (scope.validate) stays gated by EnforceWorkspace via a param threaded through validatePathWithPolicy / validateWritePath; DenyWrite wins regardless. - reentrancy_test: TestSandboxEnvironmentMarksSandboxed now asserts BOTH correlated markers (ZERO_SANDBOXED=1 and ZERO_SANDBOX_BACKEND) so dropping either would fail the test. Tests added: path lists enforced with EnforceWorkspace off; ReadExclusions inert under ModeDisabled. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new unreachable funcs). * sandbox: enforce path lists even without a workspace root Re-review follow-up: the Evaluate path-validation loop was still guarded by request.WorkspaceRoot != "", so an engine built without a workspace root skipped DenyRead/DenyWrite/AllowRead/AllowWrite even for absolute paths. The loop now runs unconditionally; the workspace boundary (scope.validate) stays gated on having a root (enforceWorkspace = EnforceWorkspace && WorkspaceRoot != ""), while the path lists match absolute paths regardless. An unanchorable relative path (no workspace root) fails closed when anything is configured to enforce, and is a no-op otherwise (unchanged from prior behavior). Test: TestEvaluatePathListsWithoutWorkspaceRoot. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new unreachable funcs). * sandbox: base no-workspace fail-closed on normalized (enforceable) path lists policyHasPathLists now resolves the lists (resolvePolicyPaths/ resolveWriteRootPaths) instead of counting raw config, matching how the rest of the file normalizes them. A typo or non-existent entry (dropped during resolution) therefore no longer makes validatePathWithPolicy fail-close every relative request when there is no workspace root. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new unreachable funcs). feat: add TUI diagnostics center (#197) Solidify sandbox: harden command analysis + wire/clean dormant policy surfaces (#196) * Harden sandbox command risk by wiring the AST analyzer The AST-based command analyzer (sandbox/analyzer.go) was built and tested but never reached at runtime — the live risk classifier used only the regex detectors. Wire AnalyzeCommand into classifyWithScope as a second opinion. It walks the parsed shell tree, so it catches destructive/network programs the regexes miss (shred, fdisk, parted; telnet, ftp, sftp; and commands hidden behind sudo/env wrappers or a sh -c launcher) and flags an unparseable (obfuscated) script as elevated risk. Purely additive — it only ADDs risk categories, so a benign, parseable command classifies exactly as before, and a program name inside a quoted argument is not flagged (AST precision). Revives the dormant analyzer (sandbox unreachable funcs 13 -> 6). New tests cover the regex-missed destructive/network programs, nested launchers, the unparseable signal, the quoted-name no-false-positive case, and benign-stays-clean. Full suite + -race green. * Wire sandbox auto-allow-bash env opt-in; drop orphaned batch-autonomy Two dormant sandbox surfaces resolved: - WIRE: AutoAllowBashWhenSandboxed was enforced live (engine.go) but never set — no config field, and the ZERO_SANDBOX_AUTO_ALLOW_BASH env overlay (ApplyAutoAllowBashEnv) was unwired, so the documented opt-in did nothing. Wire it through the central policy builder (applyConfiguredSandboxPolicy, used by all engine constructions). Fail-safe: only turns ON when the env is truthy, only honored when the shell sandbox is actually active, default stays prompt, and an explicit config opt-in is preserved. Revives AutoAllowBashEnvEnabled + ApplyAutoAllowBashEnv; adds a policy-builder test. - DELETE: RequiredAutonomyForBatch/riskToAutonomy (batch.go + test) had no consumer — the agent loop enforces autonomy per tool call (loop.go), so a batch-ceiling helper was speculative and redundant. Wiring it would have meant fabricating a batch model. Sandbox now has no dormant funcs; the only remaining unreachable entries are the cross-platform seccomp pair (unixSocketBlockFilter is live on Linux; ApplyUnixSocketBlock is the intentional non-Linux no-op stub). * Assert risk level (and add wrapper cases) in AST analyzer tests Per review (Vasanth + CodeRabbit): the AST hardening tests checked only the risk category, not the escalation level, so a regression that downgraded destructive/network shell commands while preserving the category would slip through. Assert risk.Level too — Critical for destructive/network, High for unparseable — matching the pattern used throughout the file. Add sudo/env wrapper cases (sudo/env shred, sudo telnet) to cover effectiveProgram's wrapper unwrapping. feat: add backend doctor diagnostics (#194) Hooks management command + build & supply-chain hardening (#193) * Bump toolchain to go1.26.4 to clear reachable stdlib CVEs go.mod pinned toolchain go1.24.13, whose bundled standard library has 9 govulncheck-reachable CVEs (crypto/tls KeyUpdate DoS, net/http2 loop, crypto/x509 x3, net, net/url, os, net/textproto). CI and release build via go-version-file: go.mod, so shipped binaries inherited them. Bumping the toolchain directive to go1.26.4 clears all nine — govulncheck ./... now reports 0 reachable (minimum that clears them is go1.25.11; go1.26.4 matches the team's local Go). The go directive stays at 1.24.2 (minimum language version). Verified: go build/vet/gofmt clean, full go test ./... green. * Add govulncheck CI gate and SHA-pin GitHub Actions - ci.yml: new 'security' job runs govulncheck as a hard gate (fails on a reachable vulnerability; passes now that the toolchain is bumped) plus an advisory deadcode step that surfaces dormant code without blocking. - Pin every GitHub Action to a full commit SHA (checkout, upload-artifact, github-script) with a version comment, across all four workflows — previously only setup-go was pinned, leaving the mutable v4/v7 tags as a supply-chain risk. * Add hooks management command (add/remove/enable/disable) Wires the previously-dormant hooks.ConfigStore (10 unreachable funcs -> 0) into the CLI: zero hooks add --event --command [--name --description --matcher --arg --user --json] zero hooks remove|enable|disable [--user --json] Writes the project hook config by default (/.zero/hooks.json) or the user config with --user, reusing the store's locking + atomic writes + validation (normalizeDefinition). New hooks are enabled; state is managed via enable/disable. JSON output is secret-scrubbed via redaction. Mirrors the existing 'zero mcp add/remove/enable/disable' shape. Covered by add/remove/toggle round-trip, validation, unknown-event, and JSON-redaction tests. * Fix govulncheck CI job: pin GOTOOLCHAIN to the go.mod toolchain go run govulncheck@latest selected the toolchain from govulncheck's own go.mod (downgrading to go1.25.11), which then could not load our go1.26-requiring packages (fips140only_go1.26.go), failing the security job. Resolve GOTOOLCHAIN from go.mod's toolchain line so govulncheck and deadcode both run under go1.26.4. * Address review: harden CI checkout, pin tool versions, validate hook event CodeRabbit findings on #193: - ci.yml: persist-credentials: false on all checkout steps (smoke/performance/security) — these jobs run repo code and never push, so don't keep the token in git config. - ci.yml: pin govulncheck@v1.3.0 and deadcode@v0.46.0 instead of @latest, so the security gate is deterministic and not subject to supply-chain drift. - hooks: add exported IsValidEvent/KnownEvents (single source); parseEvent reuses it, and 'zero hooks add' now rejects an invalid --event with a usage error at parse time instead of falling through to the Upsert app-error crash path. feat: add backend lifecycle status command (#192) Wire LSP diagnostics into self-correct (exec + TUI) (#191) * Wire LSP diagnostics into headless --self-correct newExecSelfCorrector now backs the --self-correct loop with both halves: the workspace test plan AND an lsp.Manager-based diagnostics checker (IncludeLSP=true). The manager is lazy (Manager.Check degrades a missing language server to nil,nil), and a deferred cleanup shuts it down at run end, terminating any spawned sessions. This revives the internal/lsp client (44 -> 3 unreachable funcs); it was dead because the only consumer passed a nil checker with IncludeLSP=false. Add TestManagerCheckRealGopls: drives NewManager -> Check against a real gopls and asserts a real diagnostic; skips when gopls is absent, so it stays CI-safe. * Wire self-correct into the interactive TUI (on by default) runAgentWithOptions now builds a SelfCorrector for every turn, so post-edit verification (workspace test plan + LSP diagnostics) runs by default in the TUI. The spec-draft planning path is excluded, matching exec. A per-turn lsp.Manager is created and torn down when the run returns. Auto-fix vs report-only follows the active permission mode (unsafe->high, auto->medium, ask/other->low) via selfCorrectAutonomyForMode. * Keep TUI self-correct fast by default; gate the test half behind /selfcorrect The TUI default now runs only the LSP-diagnostics half of self-correct — cheap and scoped to the changed files — so it never adds the whole-repo test plan's latency to a turn (and a pre-existing failure can't hijack the agent). The project test plan is opt-in per session via a new /selfcorrect [on|off] command (alias /sc); IncludeTests reads m.selfCorrectTests, default false. exec keeps both halves. * Remove dead session.didClose from lsp session.didClose sent a textDocument/didClose notification but was never called (the manager only closes documents via Shutdown), so it was unreachable even from tests and flagged by staticcheck U1000. Removing it takes internal/lsp to 0 truly-dead funcs and 0 U1000 findings; the 2 remaining unreachable-from-main funcs (Available, URIToPath) are public, test-covered API. * Align /selfcorrect help with accepted arguments The usage/description advertised only [on|off] but the handler also accepts status, list, tests, full, and lsp. Surface the primary verbs (on|off|status) and clarify the on/off behavior so command discovery matches the handler (CodeRabbit). * Make /selfcorrect usage strings match the handler exactly Both the command definition and the invalid-argument error message now list the full accepted set [status|on|off|tests|full|lsp]; the redundant 'list' alias is dropped so docs and handler agree exactly. Addresses Vasanth's review (the error-message usage at session_controls.go was still [on|off]) and CodeRabbit's help-text finding. * Clarify /selfcorrect off semantics in help text Reword the description so 'off/lsp' reads as 'disable tests, LSP-only' rather than the ambiguous 'return to LSP-only', which could be misread as disabling the feature entirely (CodeRabbit). Polish model status and reasoning streams (#190) * Polish model status and reasoning streams * Polish custom provider setup flow * Clean up pending composer hints * Address custom provider setup review * Polish composer overflow handling * Fix composer paste and wheel edge cases * Use line counts for paste previews * Revert "Use line counts for paste previews" This reverts commit 4feb814d09725f2d1602257b6e12f18b883e2e6f. * Fix composer paste preview deletion * Disable textinput ctrl-v paste binding * Preserve paste previews through file suggestions * Clarify composer paste preview behavior * Keep OpenAI reasoning out of answer text feat: add MCP manager UX (#188) * feat: add MCP manager UX * fix: harden MCP manager UX * test: stabilize agent eval timeout on Windows * fix: preserve MCP server metadata * fix: clarify empty MCP view * feat: make MCP command a manager view * fix: make MCP manager usable in TUI * fix: make MCP manager selectable in TUI * fix: simplify MCP manager popup * feat: add searchable MCP marketplace * fix: address MCP review issues * fix: address MCP follow-up review * feat: add MCP setup wizard * feat: detect MCP setup requests in chat * fix: address MCP manager review feedback * fix: avoid hijacking MCP chat prompts * fix: persist MCP view cache fallback Deep-audit remediation + sandbox hardening (batches 1–35) (#189) * audit batch 1: redact nested secrets fully; count image tokens in compaction estimate - secrets.Redact: replace longest matches first so a containing PRIVATE KEY block is redacted before a nested shorter match corrupts its exact string (audit medium internal/secrets/scanner.go:81) + regression test. - agent compaction estimateTokens: add a flat per-image token cost so an image-heavy context trends toward compaction instead of reading as ~0 (audit medium internal/agent/compaction.go:58) + test. - ledger: re-verify against current main; mark scanner.go:35 and redaction.go:362 already-fixed-on-main, record this batch's fixes. * audit batch 2: worktrees defaultRunGit separates stdout/stderr Capture git stdout and stderr into separate buffers instead of CombinedOutput, so parsed rev-parse output is not polluted by git warnings and CommandResult.Stderr carries the real error text (audit medium internal/worktrees/worktrees.go:247) + test. * audit: re-verify all 87 pending findings against current main Independent agent per file re-checked each open/partial finding against the actual current code (the 06-11 ledger was verified on a since-diverged branch), with an adversarial second pass on every already_fixed verdict. Authoritative result: 78 genuinely pending (66 still_open + 12 partial); 9 rows were already fixed on main and are reclassified. Full table in 2026-06-13-reverification.md. * audit batch 3 (agent): cancellation identity, always-allow w/o sandbox, compaction usage, dedup - loop.go: check ctx.Err() before collected.Error so a cancelled run returns context.Canceled (errors.Is holds) instead of a plain stringified error. - loop.go: 'Always allow' with no sandbox engine now allows THIS call instead of denying it (persistence is skipped when there's nowhere to persist). - loop.go: remove a duplicate schema["items"] assignment in propertyToRuntimeMap. - compaction.go: forward OnUsage (token accounting) through the summarizer while still omitting OnText, so compaction's token cost is counted in usage/budgets. - tests for all three behavioral fixes (each fails pre-fix). * audit batch 4 (cli): list-tools -o json, interrupted -o json terminal, surface session-record failures - exec --list-tools -o json now emits a JSON tool listing instead of falling through to the human-readable text (audit medium exec.go:182) + test. - interrupted run with -o json now emits a terminal error+done on stdout instead of only printing to stderr, so a JSON consumer sees a clean end (medium exec.go:500) + test. - execSessionRecorder: surface a latched session-append failure to stderr once at run end (deferred) instead of silently dropping it (medium exec_sessions.go:114/115) + test. - left app.go trailing-arg ignore as-is: documented-intentional, TUI has no argv prompt. * audit batch 5 (zerogit): status -z porcelain (renames + non-ASCII paths); document env-drop on plain Runner - parseStatus: switch `git status` to `--porcelain -z` and parse the NUL-delimited form (audit medium zerogit.go:212). Previously the default --short output was split on newlines, so a rename `R old -> new` was kept as one bogus path and a non-ASCII/whitespace filename arrived C-quoted+escaped (e.g. `"caf\303\251.txt"`). The -z form never quotes paths and emits a rename/copy as `XY \0`, so we now record the destination verbatim and consume the source field. + TestParseStatusZHandlesRenamesAndSpecialPaths. - resolveRunners: document that the plain-Runner EnvRunner adapter intentionally drops env (audit low zerogit.go:417). The branch is reached only when a caller supplies a custom Runner without a RunGitEnv — in practice fake-Runner tests. Production leaves both nil and gets defaultRunGit/defaultRunGitEnv, which honor GIT_INDEX_FILE, so snapshot-diff isolation holds. Routing env calls to real git instead would break the fake-Runner test pattern and fix nothing in production. - update Inspect/Commit mock tests to the -z status format and the new command. * audit batch 6 (config): reject negative maxTurns; drop dead contract-gap helpers - FileConfig.UnmarshalJSON now rejects a negative maxTurns instead of letting the `MaxTurns > 0` merge gates silently drop it and fall back to the default, hiding the misconfiguration (audit medium resolver.go:136). 0 is left as the "unset" sentinel (omitempty) and still falls back to the default; the CLI --max-turns flag continues to reject 0 too since there it is distinguishable from absent. + TestResolveRejectsNegativeMaxTurns / ...ZeroFallsBackToDefault. - remove dead config/contracts.go (ContractGap, DefaultContractGaps, FindContractGapsByMilestone, ContractOwnerRuntime) and its test — referenced only by that test, never by production (audit low contracts.go:12). - NOT changed: ResolveMCP intentionally does not run the provider command (TestResolveMCPDoesNotRunProviderCommand guards this), so the "ZERO_PROVIDER_COMMAND MCP servers dropped" row is by-design, not a defect. * audit batch 7 (mcp): bound captured MCP server stderr connectStdio attached a plain bytes.Buffer as cmd.Stderr that is only ever read on initialize failure, yet it kept growing for the entire process lifetime as a long-lived server logged to stderr (audit medium client.go:98). Replace it with a concurrency-safe boundedBuffer capped at 64 KiB that keeps the head (enough for init-error diagnostics) and discards the rest, reporting full write lengths so os/exec never sees a short write. + TestBoundedBufferCapsRetainedBytes. Left as-is (not defects worth the risk in the MCP client): - client.go writer.write under client.mu: already designed to release the lock before the unbounded response wait; the residual concern is a server that stops draining stdin, an involved async rewrite for a theoretical deadlock. - readLoop not answering server-initiated requests (ping): responding safely needs re-locking around the shared writer; no configured server relies on it. * audit batch 8 (tools/providers): read_file truncation marker; drop dead provider branch - read_file: when a max_lines cut occurs, append an explicit "[truncated: N more line(s)... set start_line=X to continue]" marker to the output. Previously only the Result.Truncated flag was set, which is invisible in the rendered text, so the model could not distinguish a cut read from a complete one (audit low read_file.go:89). + TestReadFileToolMarksTruncation. - providers/factory: simplify `if !explicitProvider || isImplicitOpenAI(...)` to `if !explicitProvider` and delete the now-unused isImplicitOpenAI. The clause was dead: explicitProvider==true implies ProviderKind or Provider is set, but isImplicitOpenAI required both empty, so it could never contribute a case (audit low factory.go:118). Behavior-preserving; providers tests still green. * audit batch 9 (sessions): Tree builds from a single List() snapshot (no N+1 disk scans) Store.Tree recursed via ListChildren per node, and each ListChildren ran a full store.List() that re-read every metadata.json from disk — O(nodes * total-sessions) reads for one tree (audit medium lineage.go:140). Snapshot all sessions once, index children by parent in memory, and recurse over that. Child ordering is factored into sortChildSessions so ListChildren and Tree stay identical; path-scoped cycle detection (delete(seen,...)) is preserved. Existing tree/lineage tests still pass. Left as-is: store.go timestamp() second precision (RFC3339) — switching to RFC3339Nano would break lexical UpdatedAt ordering against existing second-precision records ("...00.5Z" sorts before "...00Z"), so it is not a safe one-line change. * audit batch 10 (specialist): serialize accounting dedup-then-append recordSpecialistStop and appendSpecialistUsageRollup checked for an existing event (specialistEventExists → ReadEvents) and then appended under SEPARATE store locks, so two concurrent finishers — a foreground onExit racing a TaskOutput poll, or a background reaper — could both pass the dedup check and write duplicate stop/usage events (audit medium accounting.go:72). Guard each check-then-append pair with a package-level accountingMu (Executor is used by value, so a struct field would not be shared). + TestRecordSpecialistStopDedupesUnderConcurrency (passes under -race). * audit batch 11 (zeroruntime/gemini): O(1) text accumulation; gemini cached-token counts - zeroruntime CollectStreamWithOptions accumulated assistant text with `collected.Text += event.Content`, reallocating the whole string on every chunk (O(n^2) over a long streamed response). Accumulate in a strings.Builder on the collector and materialize it once in flush, the single finalization point hit before every return (audit low helpers.go:104). Existing collect tests still pass. - gemini decoded usageMetadata without cachedContentTokenCount, so a cached-prompt response reported zero cached input tokens while the other providers report them (audit medium gemini/types.go:88). Decode the field and thread it through streamState into NormalizeUsage as CachedInputTokens. + extended TestStreamCompletionEmitsTextUsageAndReasoningTokens. * audit batch 12 (specialist): parse child stream without a line-length cap; drop dead helper - ParseStream used a bufio.Scanner capped at 1 MiB, so one large stream-json line from a child (a big tool result or final answer) hit bufio.ErrTooLong and aborted the entire specialist run (audit medium streamer.go:43). Switch to a bufio.Reader with ReadString, which has no per-line limit; the child is our own trusted subprocess, so its output length is the legitimate bound. Line numbering and blank-line/EOF handling are preserved. + TestParseStreamHandlesLineLargerThan1MiB. - remove dead formatTaskOutput (output_tool.go:244): zero callers and zero tests; it only forwarded to formatTaskOutputSummary, which is the one actually used. * audit batch 13 (background/specialist): kill background tasks as a process group Background children were launched in the parent's process group and terminated by signalling only the single PID, so any process the task forked was orphaned and outlived a cancel; the signal also risked hitting a recycled PID after the child was reaped (audit medium process_posix.go:38/50). - ConfigureChildProcessGroup (Setpgid on POSIX, no-op on Windows where taskkill /T already tree-kills) is applied at launch so the child leads its own group. - terminateProcess now SIGTERM/SIGKILLs the negative PID (the whole group), with a pid>1 guard so a bogus 0/1 can never expand to "our own group" or "everything", and treats ESRCH as already-gone. Liveness polls the group. Targeting a group (rarely recycled vs. a bare PID) also shrinks the reaped-then-recycled window. - specialist launchBackgroundProcess opts every child into its own group; on a SetPID failure it now kills the child + group, marks the task errored, and records the stop (deduped) instead of leaking an untracked orphan. - tests launch as group leaders; new TestTerminateProcessKillsForkedChildren proves a forked grandchild dies with the group. * audit batch 14 (sandbox): quote-aware shell segment splitting splitShellSegments used a quote-unaware strings.Replacer, so a shell operator inside quotes was treated as a separator: `git commit -m "use top | less"` split into a `less` segment and `echo "a; vim b"` into a `vim` segment, both falsely flagged as interactive (audit medium safe_command.go:138). Replace it with a quote-aware scanner that mirrors the shell: single quotes make everything literal; double quotes keep $(...)/`...` substitution active but treat |, ;, && as literal; unquoted text splits on the same operator set as before (;, |, ||, &&) plus substitution boundaries. Real unquoted operators still split, so an interactive program behind a separator — or inside a live `"$(...)"` substitution — is still caught (no new false negatives, the security-relevant direction). + TestDetectInteractiveQuoteAwareSeparators covering both directions. * audit batch 15 (cron): collapse DST fall-back repeated hour to one fire On a fall-back day the clock repeats an hour (e.g. 01:30 EDT then 01:30 EST), so Next("30 1 * * *", <01:30 EDT>) returned the same-day 01:30 EST — firing a daily job twice (audit medium schedule.go:171, which previously guarded only the spring-forward gap). Next now skips a match whose local wall-clock minute equals `after`'s but whose absolute time is later (the repeated-hour duplicate) and keeps searching. Normal forward search always advances the wall-clock, so this only triggers on the fall-back repeat; when the scheduler passes the last fire time as `after`, the repeated hour collapses to a single fire. + TestNextDSTFallBackDoesNotRepeatHour (spring-forward termination test still passes). Note: Next is stateless, so it dedupes relative to `after`; a caller that passes an arbitrary mid-hour "now" instead of the last fire time is out of scope here. * audit batch 16 (mcp): skip SSE notifications before the response; larger SSE buffer - decodeSSERPCMessage returned the first non-empty `message` event unconditionally, so a server-initiated notification/request arriving before the response made the caller see an id mismatch and fail (audit medium network_client.go:621). It now skips any message carrying a method (notifications/requests have one; a response does not) and returns the first real response; the caller's rpcIDMatches still validates the id. + TestDecodeSSERPCMessageSkipsNotifications. - raise the per-event SSE scanner cap from 1 MiB to a bounded 8 MiB so a large but legitimate MCP message no longer hits bufio.ErrTooLong and fails the request (audit medium network_client.go:637); still bounded against an unbounded server. Out of scope (kept): full reconnect of a persistent SSE stream after a fatal read error — a larger change than this remediation. * audit batch 17 (sessions): fsync session metadata before the atomic rename writeMetadata wrote the temp file with os.WriteFile (no sync) and renamed it into place, so a crash right after the rename could expose a metadata file whose bytes were never flushed — a torn or empty file that corrupts the whole session (audit medium store.go:632). Write+fsync the temp via a new writeFileSync helper before renaming, closing that window. + TestWriteFileSyncRoundTrips. Deliberately scoped: - Event-log appends (appendEventLocked) are NOT fsynced per append: that would add a disk flush to every event (every tool call), and a hard-crash loss of the log's unsynced tail is acceptable, whereas torn METADATA is not. Metadata is the integrity-critical part and is what this syncs. - Directory fsync is omitted for cross-platform simplicity (it errors on Windows); the file fsync already prevents the torn-contents failure, the serious case. Left for follow-up (in this bucket but not safe/clear wins here): - hooks append sequence still re-reads the file per append; the in-memory cache is cross-process-unsafe and the safe last-line read is fiddly for a low-volume log. - Fork event re-append/de-dup is nuanced usage-accounting and needs its own change. * audit batch 18 (agent): count tool-defs in compaction estimate; don't deny an always-allow on persist failure - maybeCompact's estimate omitted the tool definitions, which ride on every request, so the real input could exceed the model limit while the message-only estimate stayed under threshold and compaction never fired (audit medium compaction.go:58). partitionTools is now computed before maybeCompact (it does not depend on the messages) and the exposed tools' estimated tokens are added to both the threshold check and the post-compaction shrink check. + estimateToolDefTokens and TestEstimateToolDefTokensCountsDefinitions. - PermissionDecisionAlwaysAllow denied the call when persistPermissionGrant failed, overriding what the user explicitly allowed (audit medium loop.go:468). Persisting is now best-effort: a failure (or no sandbox engine) just means the grant is not remembered and the user is re-prompted next time, not denied now. This also drops the dead requestEvent.GrantMatched/Grant writes (audit low loop.go:473) — the emitted event derives them from the sandbox decision via buildPermissionEvent. Left intentional (documented, not defects): the reactive-retry path omits OnText to avoid duplicating mid-stream text (loop.go:183); OnContext/MeasureContext is a complete, tested context-breakdown scaffold awaiting a consumer (types.go:183). * audit batch 19 (usage): price escalated usage from the event's own model exec persists payload["model"] on --allow-escalation runs (the model can change mid-run), but BuildReport priced every event from the session's Metadata.ModelID and never read the payload, so usage from an escalated model was mis-priced at the base model (audit medium report.go:17). usageEventPayload now carries Model and BuildReport prefers it, falling back to the session model when absent (older events stay byte-identical and behave as before). + TestBuildReportPricesFromEventModelWhenPresent. * audit batch 20 (sessions): don't copy usage events into a fork (no double-count) Fork re-appended every parent event, including provider_usage, into the child, so a usage report aggregating the parent and the fork double-counted the parent's usage (audit medium store.go:387). Skip EventUsage when copying — it already counted against the parent and is not part of the conversation history a fork replays — and record the actual copied count in the fork marker. + TestStoreForkSkipsUsageEvents. * audit batch 21 (cli/skills): warn on duplicate skill names instead of silently dropping skills.Load deduplicates same-named skills (first directory wins) and records the dropped collisions via skills.Duplicates, but nothing surfaced them, so a shadowed skill just vanished with no signal — and Duplicates had no production caller (audit low skills.go:91). `skills list` now reports each collision to stderr (keeping stdout/--json clean), using Duplicates. + TestRunSkillsListWarnsOnDuplicateNames. Note: skills.Get is kept — it is a by-name accessor used throughout the package's tests, not dead code. * audit batch 22 (cli/cron): capture the run error from stream-json stdout Cron jobs run exec with --output-format stream-json, so a failure is reported as an `error` event on STDOUT, but the run record's Error was set only from stderr — so a failed job often recorded an empty/uninformative error while the real message sat unread in outBuf (audit low cron_run.go:150). On a non-zero exit, scan the stdout stream-json for the error event's message and prefer it, falling back to stderr. + extractStreamJSONError and TestExtractStreamJSONError. * audit batch 23 (sandbox): richer macOS seatbelt profile (signal + mach-lookup) The generated sandbox-exec profile was default-deny with only process/sysctl/file allowances, so two real gaps remained on macOS (sandbox quality report H1): - A sandboxed script could not signal the children it spawned — `kill` returned "Operation not permitted" under seatbelt, breaking common `cmd & ... kill`, test-runner, and timeout patterns. Add `(allow signal (target self) (target pgrp))`, which stays scoped to the command's own process group. - XPC to system daemons was denied, so keychain (securityd/trustd), user/group lookup (opendirectoryd), preferences (cfprefsd), network config (SystemConfiguration), launch services, and the pasteboard all failed. Add a curated `(allow mach-lookup ...)` allowlist (`sandboxMachServices`). Neither touches the file-write or network rules, so the workspace boundary is unchanged — verified on a real macOS host: `security`/`scutil`/`pbpaste`/self-kill now succeed while writes to $HOME and /etc are still denied. iokit-open was tried and dropped as unnecessary. + unit assertions (TestSandboxExecProfileGrantsSignalAndMachLookup) and extended the darwin integration test with a self-kill case and an outside-workspace deny check. * audit batch 24 (sandbox/tools): remove dead OnSandboxDecision hook RunOptions.OnSandboxDecision had no setter anywhere, yet registry.RunWithOptions spawned a goroutine (with panic recovery) for it on every sandboxed tool call — pure per-call overhead for an unreachable callback (sandbox report M1). The sandbox decision is already surfaced to observers via the permission event (buildPermissionEvent reads the decision), so this hook is redundant; delete the field and its call site. * audit batch 25 (sandbox/cli): exact-path grants — create and revoke by path The engine already matched ScopeFile grants and DeriveScope already produced them from path/file tool args, but the CLI could neither create one explicitly nor revoke a single one (sandbox report L3 / prompt 7). Add: - GrantStore.RevokePath(tool, path): revokes only the grant whose scope matches the given file/dir, leaving tool-wide and other-path grants intact (path canonicalized to absolute like stored scopes). + TestGrantStoreRevokePathRemovesOnlyMatchingScope. - `zero sandbox grants allow --path

`: persists an exact-file (ScopeFile) grant, path resolved to absolute so it matches lookups and revokes. - `zero sandbox grants revoke --path

`: revokes just that grant (default still revokes all grants for the tool). + TestRunSandboxGrantsCreateAndRevokeByPath. The TUI permission-card "Allow always (exact path)" option is intentionally left for the TUI feature branches (it overlaps #187/#188); the engine + CLI now cover the full create/match/revoke round-trip for file-scoped grants. * audit batch 26 (sandbox): Engine.Precheck — report violations before execution Add Engine.Precheck(ctx, request) []Violation, which reports the sandbox violations that would block a tool request before it runs (sandbox report / prompt 6). It reuses Evaluate so policy is not duplicated: an allowed or merely-prompted request yields no violations; a denied request yields its violation (via the shared violationsFromDecision helper, with a ViolationPolicyDenied fallback for a deny that carries no structured violation). A nil engine yields nothing. + TestEnginePrecheckReportsViolationsBeforeExecution. Wiring Precheck into the batch-confirmation prompt is intentionally left to the TUI branch: it touches prompt rendering, and short-circuiting the agent loop's deny path would skip the before/after-tool hooks that currently fire around a policy-denied call. The engine API is in place for that consumer. * audit batch 27 (sandbox): scoped-egress domain prompt with timeout + caching The scoped-egress proxy failed closed on any host outside the allowlist. Add an opt-in DomainPrompt callback (prompt 8): an UNKNOWN host (neither allowed nor explicitly denied) is authorized at request time via the callback instead of being refused outright. An explicitly denied host is still refused without prompting; the callback is bounded by PromptTimeout (default 60s, timeout/error => deny); decisions are cached for the proxy's lifetime so a host is prompted at most once. With no callback set the strict fail-closed behavior is unchanged. Wired through both the CONNECT and HTTP handlers via authorizeTarget. + TestEgressProxyAuthorizeDomainPrompt / ...TimeoutDenies / ...NilPromptFailsClosed. The user-facing prompt that supplies the callback is left to the permission UI (TUI) branch; the egress mechanism (callback + timeout + cache) is in place and tested. * audit batch 28 (sandbox): AST-based shell analysis (AnalyzeCommand) Add AnalyzeCommand(script) AnalysisResult, a static shell analyzer built on the mvdan.cc/sh/v3 parser (prompt 12). Walking the parsed command tree makes it a more precise second opinion than the regex detector: a program name only counts when it is an actual command, so `echo "vim ..."`, `printf 'open with vim'`, and `git commit -m "rm -rf /"` are clean, while real commands are flagged — interactive (editors/pagers + REPLs, with the same -e/script suppression as nonInteractiveREPLFlags), destructive (rm -rf/-fr, dd, mkfs, shred, ...), and network (curl/wget/ssh/nc/...). An unparseable script yields TooComplex so callers can treat it as higher-risk. + TestAnalyzeCommand (16 cases) / ...EmptyIsClean. Dependency: mvdan.cc/sh/v3 pinned to v3.11.0 — compatible with the existing go 1.24 directive (the latest v3.13.1 would have bumped the module to go 1.25). It is kept as a standalone analyzer for callers to consult; feeding it into Engine.Evaluate is deliberately not done here, since that would change allow/deny classification. * audit batch 29 (sandbox): unify proxy-env generation (ProxyEnv) Export ProxyEnv as the single source of truth for proxy-env injection (prompt 9 req 1), replacing the unexported scopedProxyEnv used by the sandboxed-shell path. + TestProxyEnv. Investigation that bounds the rest of prompt 9 (documented in the ProxyEnv doc): - web_fetch already honors HTTP_PROXY/HTTPS_PROXY — its transport clones http.DefaultTransport, whose Proxy is http.ProxyFromEnvironment. - MCP children already inherit the parent env (mergeProcessEnv appends os.Environ), so they pick up any proxy vars the agent has. So the only missing link to scope FetchUrl/MCP is a SESSION-level egress proxy whose address reaches the agent process. That is deliberately NOT wired here: doing it safely requires allowlisting the active LLM provider's own domain first, or the agent's provider calls would route through the scoped proxy and be denied — a correctness pitfall that also can't be verified end-to-end on this host. Flagged rather than shipped blind. * audit batch 30 (sandbox): Linux seccomp Unix-socket block — UNVERIFIED, needs Linux CI Adds a pure-Go (no cgo, no vendored binary) seccomp BPF filter that denies socket(AF_UNIX) with EPERM on x86-64/arm64, closing the Unix-socket gap bubblewrap's filesystem/network isolation leaves open (prompt 11): - internal/sandbox/seccomp.go: the classic-BPF program as a platform-neutral []sockFilter, built and UNIT-TESTED (structure + every jump offset in range — the classic way a hand-written BPF filter goes silently wrong). - seccomp_linux.go: ApplyUnixSocketBlock installs it via prctl(NO_NEW_PRIVS) + prctl(SET_SECCOMP, MODE_FILTER). seccomp_other.go is a no-op everywhere else. - cmd/zero-seccomp: a tiny linux-only exec wrapper that applies the filter then execs the real command (the standard way to install seccomp in a child with os/exec); degrades gracefully (warns + runs) if the kernel lacks seccomp. ⚠️ HONEST CAVEAT: I developed this on macOS and CANNOT run the actual AF_UNIX block here. darwin build, `GOOS=linux` cross-compile, vet, and the structure test all pass, but the unit test verifies the program's SHAPE, not that the kernel actually blocks AF_UNIX — that MUST be verified on Linux CI (create a Unix socket under the wrapper and assert EPERM). Safe to ship as-is: it is linux-only, no-op on every other platform, and not wired into the bwrap command yet (opt-in prefixing the sandboxed command with zero-seccomp is the remaining integration), so it cannot regress any current path. * audit batch 31 (sandbox): macOS denial log monitor (opt-in, default off) Re-adds the macOS seatbelt-denial monitor as an opt-in feature (prompt 10). When Policy.MonitorDenials is set, the sandbox-exec profile tags its default-deny with a marker and bash tails `log stream` for that tag during the command, appending any captured denials as a block to the command's stderr so the model can see what was blocked. - DenialMonitor (darwin) tails/parses tagged denials with a capped, deduped ring buffer and noise filtering; no-op stub on other platforms. parseSandboxDenyLine is unit-tested. + profile-tag test + appendSandboxViolations test. - Default OFF: with MonitorTag empty, StartDenialMonitor is a no-op and the command path is byte-for-byte unchanged, so there is no regression or per-command overhead unless enabled. CAVEAT: on at least one macOS host (verified live) seatbelt denials are NOT delivered to the `log stream`-queryable unified log, so the monitor captures nothing there. It is the standard mechanism and works on macOS versions that do log denials; shipping it default-off means it never affects whether a command runs. * audit batch 32 (sandbox): wire seccomp Unix-socket block into bubblewrap (opt-in) Completes prompt 11: the zero-seccomp helper (which installs the AF_UNIX seccomp filter) is now actually invoked by the sandbox instead of only existing as a standalone binary. - bubblewrapCommandPlan, when Policy.BlockUnixSockets is set, ro-binds the discovered zero-seccomp helper into the sandbox at its host path and prefixes the command with it so the filter is installed before the real argv runs. - findSeccompHelper discovers the helper next to the running executable first, then on PATH; it returns "" when absent, so the sandbox degrades gracefully (runs without the extra filter — bubblewrap's fs/net isolation still applies) rather than failing the command. Exposed as a package var so the wiring is unit-tested on any OS. - Reachable from config: SandboxConfig.BlockUnixSockets and .MonitorDenials now map onto the policy in applyConfiguredSandboxPolicy (opt-in only — config can turn a hardening flag on, never off). Both default OFF. - Tests: helper-prefix present when enabled / absent by default; config flags apply independently and stay off when omitted. Default OFF and Linux-only effect; the runtime seccomp enforcement still needs a Linux host/CI to exercise end-to-end (the wiring and BPF program are unit-tested). * audit batch 33 (sandbox): enforce scoped/deny network policy in web_fetch & web_search Completes prompt 9: the built-in network tools now honor the SAME allow/deny/scoped policy the sandboxed shell egress proxy enforces, instead of bypassing it. Before this, web_fetch/web_search would reach any public host even when the policy was NetworkDeny or NetworkScoped — the egress proxy only constrained shell commands. - New shared gate Engine.NetworkHostAllowed(host) reuses the egress domain matcher (domainAllowed/normalizeDomains/effectiveNetwork) as the single source of truth: allow → all hosts; deny → none; scoped → only AllowedDomains minus DeniedDomains (empty allowlist collapses to deny). Disabled policy / nil engine impose nothing. - web_fetch is now sandbox-aware: the host is checked before the request AND on every redirect, so a denied/unlisted host fails closed before any dial. Plumbed via a networkPolicyGate so the no-sandbox path is byte-for-byte unchanged. - web_search is sandbox-aware: the configured backend's endpoint host is checked before the query leaves the machine; blocked under deny, and under scoped unless the backend host is allowlisted. - The registry already routes sandbox-aware tools through RunWithSandbox when an engine is present, so this activates automatically wherever the sandbox is wired. - Tests: engine gate matrix (deny/allow/scoped/denylist/empty/disabled/nil + host:port); web_fetch & web_search block denied/unlisted hosts without dialing and allow listed ones; endpoint-host parsing. * audit batch 34 (sandbox): prove network-policy wiring end-to-end + document opt-in flags Verification follow-up to batches 31-33; no behaviour change. - Adds a registry-level test that exercises the FULL chain for the scoped-network enforcement: registry.RunWithOptions -> engine.Evaluate (passes a scoped, egress-capable sandbox-exec backend) -> routes web_fetch through RunWithSandbox -> per-host allowlist blocks an unlisted host before any dial. This proves the Tool-interface value actually satisfies sandboxAwareTool at the registry boundary, not just in a direct method call. - Documents the two opt-in hardening flags (sandbox.blockUnixSockets, sandbox.monitorDenials) and the scoped web_fetch/web_search behaviour in the README so the config keys are discoverable. Both remain off by default. Confirmed wiring: agent loop forwards options.Sandbox to the registry; the registry routes sandbox-aware tools (web_fetch, web_search) through RunWithSandbox when an engine is present; MonitorTag is set only on the macOS sandbox-exec plan; the seccomp helper prefix is added only on the bubblewrap plan when BlockUnixSockets is set; both config flags map onto the policy via applyConfiguredSandboxPolicy. * audit batch 35 (sessions): fix Windows CI failure in TestWriteFileSyncRoundTrips The exact-permission assertion (perm == 0o600) added in batch 17 fails on windows-latest because Windows does not honor Unix permission bits: Go reports 0o666 for a writable file regardless of the mode passed to OpenFile. This was the sole cause of the failing Smoke (windows-latest) CI job; macOS and Ubuntu passed. Guard the perm check with runtime.GOOS != "windows" so it still verifies the mode on the platforms that enforce it, while the round-trip (write + read-back) is still asserted on every platform. * audit batch 36: address CodeRabbit review (5 findings) - background/process_posix: after SIGKILL, poll until the process group is actually gone (SIGKILL is async) and return an error if it survives past a second deadline, instead of returning nil while descendants still race. - sandbox/safe_command: only treat ')' as a substitution boundary when it closes an active $(...). A literal ')' (e.g. echo "a) less") no longer splits into a fake `less` segment and is no longer misflagged as interactive. Depth-tracked so nested $(...) still isolate their inner command. Direct splitShellSegments test (fails pre-fix: yields ["echo \"a", "less\""]). - cron/schedule: the DST fall-back collapse now only applies when `after` sits exactly on the minute boundary (its "last fire time" form). With sub-minute precision (e.g. 01:30:30 EDT) the first 01:30 fire already passed, so the repeated 01:30 EST is the legitimate next fire and is returned — restoring strictly-after semantics. New test fails pre-fix (skipped to next day). - config/types: maxTurns error message now reads "must be >= 0" to match the check (0 is accepted; only < 0 is rejected). Negative-maxTurns test asserts the message. - docs/audit: header date/branch metadata now matches the body (2026-06-13, fix/audit-batch) instead of the stale 2026-06-11/fix/audit-critical-high snapshot. Verified: gofmt clean; go vet clean; host + linux + windows builds; affected suites green. Each behavioural fix ships with a test confirmed to fail without the change. * audit batch 37: address CodeRabbit re-review (12 findings) Findings on the audit code from batches 21-36; each behavioural fix ships with a regression test (several confirmed to fail without the change). sandbox/analyzer (AnalyzeCommand): - Unwrap launcher prefixes (sudo/env/nice/bash -c/...) and recurse into `sh -c` payloads so `sudo rm -rf`, `env curl …`, `bash -c 'vim x'` classify on the real command, not the launcher. Mirrors DetectInteractiveCommand. - Honor rm's `--` end-of-options marker so `rm -- -rf` (delete a file named -rf) is not flagged destructive. sandbox/engine: NetworkHostAllowed now shares one backend-aware effective-mode helper with Evaluate, so scoped collapses to deny when the backend can't enforce scoped egress — the per-tool gate can no longer permit traffic the engine-level decision would fail closed. sandbox/egress: cache domain-prompt decisions by host:port, not host, so one "allow" can't authorize other ports on the same host. sandbox/safe_command: the segment splitter is now escape-aware (\| , \" ) so an escaped operator/quote can't manufacture a fake interactive segment. sandbox/log_monitor (darwin): Wait() the `log stream` child after draining stdout so the cancelled process isn't leaked as a zombie. tools/web_search: confine the search backend to same-host redirects (with a limit) so a redirect chain can't egress to a host the scoped/deny policy never authorized. cli/sandbox: an explicit empty --path (`--path=` / `--path ""`) now fails closed instead of silently widening an allow to tool-wide or a revoke to all-grants. cli/skills: warn on stderr when duplicate-skill detection fails instead of swallowing the error. specialist/exec: don't mask a failed orphan-kill — join the TerminateProcess error with the SetPID error so a surviving process is visible. background/process_posix: resolve the PGID and only group-signal when pid leads its own group; otherwise signal the individual PID. Avoids both a false-success on a non-leader ESRCH and the hazard of signalling an unrelated (possibly our own) group. Verified: gofmt + go vet clean; host + linux + windows builds; full go test ./... green. * audit batch 38: address CodeRabbit re-review round 2 (3 findings) Follow-ups to batch 37, each with a regression test: - safe_command (splitter): a ')' is now only a substitution boundary when it closes an active, UNQUOTED $(...). A quote-state stack saved at each '$(' lets a substitution run in a fresh quoting context and restores the outer quoting on its matching ')'. Fixes `echo $(printf "a) less")` splitting into a fake `less` segment, while still isolating the inner command of `echo "$(vim x)"`. - egress: deduplicate in-flight domain prompts per host:port. Concurrent requests for the same unknown target now wait on the first prompt and reuse its cached decision instead of each firing a prompt (a prompt storm) and racing to write the final decision. Race-tested with 25 concurrent authorizers → one prompt. - analyzer + safe_command: wrapper option-value consumption is now wrapper-specific (wrapperValueOptionsByProg) instead of a global option set. A valueless flag like `sudo -n` no longer swallows the real payload, so `sudo -n rm -rf` / `sudo -n curl …` are correctly classified destructive/network; `sudo -u root vim` still consumes its value. Shared by the AST analyzer and the regex detector so they can't diverge. Verified: gofmt + go vet clean; host + linux + windows builds; go test ./... and -race on sandbox/background all green. * audit batch 39: address CodeRabbit re-review round 3 (12 findings) All verified against current code; each behavioural fix has a regression test. Security / correctness: - tools/web_search: redirect policy now also refuses a scheme change, blocking a same-host https→http downgrade that would send the query + bearer token over plaintext. - sandbox/safe_command: a ')' is only a substitution boundary when it closes an active UNQUOTED $(...), and backticks now get the same quote-state save/restore as $( — so `echo "`a | less`"` and `echo "$(true | less)"` no longer hide the inner pager from interactive detection. - sandbox/analyzer + safe_command: wrapper option-value consumption stays wrapper-specific, and unwrapping no longer aborts on a dynamic ($x) wrapper arg once a wrapper is active, so `env "$opts" curl` / `sudo "$x" rm -rf` are still classified. - sandbox/egress: in-flight prompts deduped per host:port (carried from batch 38); the prompt callback is now cancellable (context cancelled on timeout) so a stuck prompt can abort instead of leaking; signature threaded through. - sandbox/runner: per-plan unique denial tag (pid+counter) so concurrent monitored sandbox-exec runs can't ingest each other's denials; helper selection now requires an executable regular file so a non-exec sibling degrades gracefully. - mcp/network_client: aggregate SSE event-size cap (not just per-line) so a server can't grow memory with many unterminated data: lines. - specialist/accounting: a previously-recorded stop/usage with an empty runId is a catch-all and now suppresses a later runId-bearing duplicate. - sessions/lineage: Tree fetches the root via Get() first so a corrupt root surfaces its real error instead of degrading to "not found". Docs/tests: - background/terminate: docstring now states the leader-only group-kill (PID-only fallback for non-leaders); test child-liveness probe uses ESRCH specifically. - cli/sandbox: grant usage strings include --path. Verified: gofmt + go vet clean; host + linux + windows builds; go test ./... and -race on sandbox/background all green. * audit batch 40: address CodeRabbit re-review round 4 (8 fixed, 1 skipped) Verified each against current code. Fixed: - sessions/lineage: PANIC FIX — store.Get returns (nil, nil) for a missing session, so the batch-39 root prefetch could nil-deref. Guard root == nil and return a not-found error. Regression test added (was a panic). - agent/compaction: the reactive recover() now records lowWaterMark in the SAME combined (messages + tool-defs) token domain maybeCompact uses, so the proactive shrink-guard compares like with like. recover() takes tools; callers pass request.Tools. - mcp/network_client: SSE aggregate counter no longer counts a joining newline for the first data line (only when prior content exists), avoiding premature rejects. - sessions/store: fsync the parent directory after the metadata rename so the rename is durable across a crash (no-op on Windows, which can't fsync a dir). - sandbox/egress: parse the target port with strconv.Atoi instead of net.LookupPort (no context-unaware resolver use). - sandbox/runner: escape the per-plan denial tag through sandboxProfileString when embedding it in the seatbelt profile. - sandbox/seccomp_linux: guard against an empty filter program before taking &kernelFilters[0] (defensive; the program is fixed non-empty today). - cli/cron_run_test: assert last-error-wins with two error events. - background/terminate, cli/sandbox usage, process_posix_test ESRCH (carried). Skipped (with reason): - zerogit worktree-rename (Y='R') test: git porcelain v1 -z only emits the rename-source field when the INDEX column is R/C; the worktree column never carries 'R' with a source field in v1, so the fixture would test output git doesn't produce, and making parseStatus consume on code[1]=='R' would risk mis-consuming the following entry for a plain ` R` status. Left as-is. Verified: gofmt + go vet clean; host + linux + windows builds; go test ./... and -race on sandbox/background all green. * audit batch 41: address CodeRabbit re-review round 5 (4 findings) All verified against current code; code fixes have regression tests. - agent/compaction: summarizeWithFallback's reduce pass no longer swallows ALL errors. A context-limit error still falls back to the joined partial summaries (documented "extreme" case), but a non-context failure (auth/network/provider) now surfaces unchanged, per the function's contract. Two tests (propagate vs. joined-fallback); the propagate test fails without the fix. - sandbox/safe_command + analyzer: wrapper value-consuming options now include the long spellings (sudo --user root, env --unset NAME, timeout --signal …), so a long flag's value is no longer mis-read as the program (interactive/destructive/ network detection bypass). A `--flag=value` token carries its own value and never consumes the next token. Tests for space and = forms in both the AST analyzer and the regex detector. - docs/audit: the status-ledger summary table's open/partial cells (64/14) were the lone outlier — the ledger's own narrative and 2026-06-13-reverification.md both state 66/12. Corrected to 66/12 (66+12+97 = 175); derivation: body 69 open + 18 partial minus the 9 already-fixed-on-main (3 open + 6 partial) = 66/12 = 78 pending, fixed 88+9 = 97. Added a comment documenting the summary-vs-body relationship. The reverification doc already states 66/12, so both now agree. Verified: gofmt + go vet clean; host + linux + windows builds; full go test ./... green. * audit batch 42: address CodeRabbit re-review round 6 (1 fixed, 1 kept w/ proof) CodeRabbit re-surfaced two "duplicate" refinements; CI was already green (its 6th pass was a COMMENT with no new findings). - cli/sandbox_test (TestRunSandboxGrantsRejectsEmptyPath): now seeds a tool-wide grant and asserts grant-store immutability after EACH rejected empty-`--path` call (reflect.DeepEqual before/after), so a mutation in one call can't be masked by a later one — and a buggy "revoke all" is actually observable (it's a no-op on an empty store). Stronger than the suggested patch. - zerogit.parseStatus: KEPT consuming the rename source on the index column only (code[0]), with an explanatory comment. Verified empirically that git porcelain v1 -z reports a rename/copy (and emits the extra source field) only in the index column: `git mv` → "R new\0old\0"; a worktree-only rename → " D old\0?? new\0" (delete + untracked, never "R" in code[1]). Consuming on code[1]=='R'/'C' would match no real git output and would only risk mis-consuming the next entry on malformed input, so the suggestion is declined as incorrect for v1. Verified: gofmt + go vet clean; host + linux + windows builds; affected suites green. Render assistant Markdown in TUI (#187) * Render assistant Markdown in TUI Add a lightweight assistant Markdown renderer for streaming and completed transcript rows, including inline bold, code/emphasis marker cleanup, pipe tables, adaptive row separators for wrapped tables, and plain-text output for selection metadata. Tests cover streaming markdown, completed rows, selection metadata, table wrapping, compact tables, inline markers, and width bounds with neutral fixtures. * Refine TUI Markdown table rendering Add outer borders for rendered assistant tables, add row separators for larger comparison tables even on wide terminals, and normalize common HTML line breaks inside table cells. Tests cover bordered tables, wide multi-row tables, compact tables, and HTML break normalization. * Fix Markdown inline parser edge cases Preserve literal stars, prevent code spans from inheriting bold state, and avoid inserting spaces before punctuation split by inline marker boundaries. Add regression checks for plain/selectable ANSI-free text and final-row done metadata on rendered Markdown rows. * Preserve coding literals in TUI Markdown Tighten inline delimiter parsing so code spans, Python dunder names, globs, and arithmetic stars stay literal while normal bold/emphasis still renders. Add regressions for the reviewed code-span, dunder, glob, and operator examples. Expand agent quality eval module (#178) wedge stage 13: launch readiness (first-run + benchmark) (#186) * stage 13: launch readiness (first-run + benchmark) First-run, model-agnostic-first: - provideronboarding: detect local OpenAI-compatible runtimes (Ollama/LM Studio) on their default ports and offer them with no API key required - provideronboarding: ClassifySetupProbe maps a live connectivity probe to a specific, fixable error class (bad key, wrong base URL, model not found, rate limit, config) instead of a stack trace; never panics - zero setup --verify: probe the provider after saving and report the fixable error on failure; print a concrete 'try this' headless example on success zero doctor hardening: - new sandbox.backend check (bubblewrap/sandbox-exec) with a platform-specific install remedy; warn (not fail) on policy-only fallback - new lsp.servers check listing missing language servers with per-binary install remedies; injectable GOOS/LookupExecutable for deterministic tests Self-correct surface + benchmark: - zero exec --self-correct wires the post-edit verify-and-correct loop via the project verifier; nil corrector when off keeps the agent loop byte-identical - perfbench task harness: reproducible Terminal-Bench-style runner over headless zero exec + stream-json; records model + commit + self-correct flag + date - zero-perf-bench tasks subcommand (with --dry-run) and a sample task set - docs/BENCHMARK.md: the number, the exact command, the model, and the with/without-self-correct methodology * stage 13: address review (verify-state, fixture paths, run_end exit, 404 classification) * stage 13: verifier inherits process environment (PATH/HOME/toolchain) wedge stage 10: distribution (skill/plugin install, tools make) (#184) * stage 10: distribution (skill/plugin install, tools make) Add a file-backed distribution surface so skills and plugins can be installed from a git URL or local path, and new plugin-tools can be scaffolded. No central registry: a source is just a git URL or path. - internal/skills/install.go: Install/Remove/Info + skills.lock. Validates SKILL.md frontmatter via the existing parser, copies it into the skills dir, and records a sha256 content hash. Reinstall surfaces the hash change; a clash with a different source is refused without --force. - internal/plugins/install.go: same flow validated against the plugin manifest schema, copying the whole plugin tree (minus .git) so the plugin is activatable. plugins.lock records source + hash. - internal/tools/scaffold.go: generate a prompt-gated plugin-tool skeleton (manifest + runnable shell/node/python entry stub) in the toolbox dir. - CLI: zero skill {add,info,remove}, zero plugin {add,remove}, and a new zero tools {make,list}; wired in app.go/extensions.go/skills.go. Safety: fetch honors the process environment (proxy/egress) and disables git credential prompts; install never executes fetched content (skills are markdown; plugin install scripts are never run) and installed plugins still go through normal activation with permission gating. * stage 10: make no-usable-name install test cross-platform The test used a whitespace-only source dir name to force the no-usable-name rejection, which is not creatable on Windows (Smoke failure). Switched to a dir base that validSkillName rejects on every platform (contains "..") while staying creatable on Windows; the blank frontmatter still falls back to the unusable dir base, exercising the same rejection. * stage 10: address review (tree hash, tools-list diagnostics, --json guards) * stage 10: make canonical-source tests cross-platform (Windows) Two Windows Smoke failures from the canonical-source review fix: - TestInstallSameLocalSourceDifferentSpellingIsNotAClash (skills + plugins) used filepath.Rel(cwd, tempdir), which errors on Windows when the temp dir (C:) and repo (D:) are on different drives. Switched to a same-directory alternate spelling (redundant /./), which still exercises canonicalSource without a cross-drive relative path. - TestRunSkillAddInfoRemove asserted the raw source path, but canonicalSource resolves symlinks/8.3-short-paths, so the stored+displayed source differs on Windows. Normalize the source via EvalSymlinks up front so the assertion matches on every platform. * stage 10: address CodeRabbit nitpicks (variadic append, header comment) wedge stage 11: GitHub Action + Slack/webhook notifier (#182) * stage 11: GitHub Action + Slack/webhook notifier Add a distribution channel and an output sink for unattended ZERO runs. internal/notify: - Add a Sink interface and Notifier fan-out. Notify now emits the terminal bell/OSC-9 sequence AND forwards every eligible event to attached sinks. Sinks fire even with no terminal writer (headless CI) and are gated by the same mode/focus policy. Sink invocation happens outside the lock and a sink panic is isolated, so a misbehaving or slow sink can never crash or stall the run. New(w, cfg) stays backward compatible. - Add a WebhookSink (slack.go) that POSTs a JSON payload {text, type, message, summary?, links?} to a Slack incoming webhook or any generic webhook. Delivery is best-effort and fails soft: non-2xx and transport errors are logged (redacted) and swallowed. The message, summary, links, and the webhook URL are run through redaction before send/log so tokens never leak. The default HTTP transport honors HTTP(S)_PROXY, so a sandboxed run's scoped-egress policy is respected. An empty URL is inert. action.yml: - Composite GitHub Action wrapping zero exec. Provider-agnostic: provider, api-key, and api-key-env are inputs (no provider is hardcoded). Installs the pinned ZERO release via the bundled checksum-verifying installer, runs the prompt in the checked-out repo, surfaces ZERO's exit code as the step status, captures stdout, and can post a summary to the PR or to Slack. Safety defaults: auto=low, sandbox always active, never passes --skip-permissions-unsafe; secrets are passed via env and never echoed. docs/GITHUB_ACTION.md: input/output tables, copy-paste workflows (issue-labeled triage and nightly dep-upgrade PR), security notes, and the notifier docs. .github/workflows/zero-action-smoke.yml: validates action.yml parses, declares the documented inputs/outputs, keeps the conservative defaults, never enables unsafe mode, and shell-syntax-checks the embedded run blocks. * stage 11: reword action.yml comment so the unsafe-flag validation passes The composite step's shell comment contained the literal '--skip-permissions-unsafe', which the action-yml validation (assert the string never appears in the serialized doc, to prove the flag is never passed) flagged even though it was only descriptive. Reworded the comment to drop the literal; the action still never passes the flag and the check stays strict. * stage 11: address review for action env/inputs and webhook URL redaction - action.yml: reject prompt+prompt-file when both set; trim only surrounding whitespace from add-dir entries (preserve internal spaces); export api-key in-process (validated env name) so the run sees it; treat a commit-SHA action_ref as not-a-tag and fall back to latest. - notify: redact the webhook URL in outbound payload fields (not just logs); copy caller-owned Links/ExtraSecrets slices in the constructor. * stage 11: disable credential persistence in action smoke checkout wedge stage 09: finish plugin wiring (tools/hooks/skills) (#185) * stage 09: finish plugin wiring (tools/hooks/skills) Activate resolved plugin manifests so their declared tools, hooks, and skills take effect at runtime instead of being parsed-but-unconsumed. - internal/plugins/activate.go: Activate() registers each plugin tool into the tools.Registry via a command-runner adapter (JSON args on stdin, stdout/exit -> tools.Result, ${AGENT_PLUGIN_ROOT} expansion + env), builds hooks.Definition values for plugin hooks, and exposes plugin skill search roots. Permission maps allow/prompt/deny -> tools.Safety; allow only survives load-time clamping when manifest auto-approval is enabled, so mutating plugin tools are not auto-approved by default. A plugin-aware skill tool merges the default skills dir with plugin roots (recording duplicate names, never crashing). Malformed plugins/extensions are skipped with a warning; activation order is deterministic. - internal/cli: activatePlugins() invokes activation in both bootstrap paths (interactive TUI in app.go and one-shot exec in exec.go), after core/specialist/MCP registration. newHookDispatcherWithExtra folds plugin hooks into the dispatcher's hook set. Fails open: a bad plugin warns and is skipped, never wedging startup. - internal/plugins/plugins.go: update the stale 'not yet registered' note. * stage 09: normalize plugin command path separators for Windows expandPluginRoot kept the manifest's forward slashes, so a ${AGENT_PLUGIN_ROOT}/bin/x command became a mixed-separator path on Windows (C:\...\001/bin/x), failing the path tests. Added expandPluginRootPath (filepath.FromSlash on the placeholder-expanded executable path only; args keep their slashes since they may be URLs/flags) and used it for the tool and hook command paths. * stage 09: address plugin wiring review - surface per-plugin load diagnostics on stderr (not just top-level errors) - skip plugin tools whose name collides with an already-registered tool - resolve manifest-relative plugin skill paths against the plugin dir * stage 09: fall back to plugin path/id when manifest path unknown in load diagnostic wedge stage 12: PDF ingestion (#183) * stage 12: PDF ingestion Add PDF ingestion to internal/imageinput so specs, design docs, and tickets that arrive as PDF can be attached the same way images are. - internal/imageinput/pdf.go: LoadDocument detects PDFs by magic bytes (%PDF-, never extension alone), enforces a 32 MiB raw-file cap and a 256 KiB extracted-text cap (truncated with a marker, not rejected), and extracts the text layer in pure Go via github.com/ledongthuc/pdf. The parser can panic on malformed structures, so extraction is wrapped in a recover that turns a bad PDF into a clean error. A textless PDF with no rasterizer returns an explicit "no extractable text; OCR not available" error rather than silent empty success. - Dependency posture: default build is pure-Go text extraction (no CGO, no transitive deps) so the static binary still cross-compiles. The optional poppler tools (pdftotext / pdftoppm) are used for richer extraction and page rasterization ONLY when present on PATH (same posture as the LSP language servers); when absent, extraction degrades to the pure-Go text path and never errors. - Vision path: with a vision model and pdftoppm available, the first N pages (default 10) render to PNG and flow through the existing image pipeline (NormalizeImageMediaType, per-image cap, allow-list). Without a rasterizer it degrades to the text layer. - TUI integration: /image routes to the document path. The text layer attaches for any model and is prepended to the next prompt as a labeled preamble; rendered pages attach as images for vision models. The chip row and /image clear cover documents too. Tests cover magic-byte detection, text extraction on a generated fixture, both size caps, the no-text/no-raster message, malformed-input safety, the pure-Go fallback, and the TUI attach/submit/clear wiring. go build, go test, and gofmt are clean; CGO_ENABLED=0 cross-compiles for linux/amd64, windows/arm64, and darwin/arm64. * stage 12: address review (pdf page count, text cap, content-based routing) wedge M2: MCP server resources/prompts + sandbox scoped egress + MCP OAuth (#179) * wedge stage 06: MCP server resources + prompts Advertise resources and prompts capabilities in the initialize response and handle resources/list, resources/read, prompts/list, and prompts/get alongside the existing tools methods. Resources enumerate in-scope workspace files via the shared workspace scanner (reusing its gitignore-style exclusions) and read them read-only with strict scope enforcement: traversal and out-of-scope absolute paths are rejected with a JSON-RPC error and no contents, and binary files come back as base64 blobs. Prompts expose a curated set of templates with simple {{arg}} substitution; unknown prompt names return an error. * wedge stage 07: sandbox scoped egress + auto-allow-bash Add a middle-ground network mode between full allow and full deny: - types.go: NetworkScoped network mode; Policy.AllowedDomains / Policy.DeniedDomains; Policy.AutoAllowBashWhenSandboxed plus its ZERO_SANDBOX_AUTO_ALLOW_BASH env surface (off by default). - egress.go: in-process loopback (127.0.0.1:0) filtering proxy handling HTTP CONNECT (HTTPS tunnel) and plain HTTP. Allows only AllowedDomains minus DeniedDomains via exact-or-subdomain matching (github.com matches api.github.com but not notgithub.com or github.com.evil.com); deny wins. Denied targets get 403 / refused CONNECT. Decisions are logged through the repo redaction so query tokens never hit the audit trail. Fails closed: empty allowlist is rejected at construction. - runner.go: NetworkScoped starts the proxy and routes the sandboxed process through it (HTTP(S)_PROXY/ALL_PROXY + NO_PROXY into the sandbox env; Linux keeps --unshare-net; macOS profile denies general network but permits localhost:proxyPort). Empty-allowlist scoped collapses to deny (effectiveNetwork). Any proxy-start error denies the build rather than falling back to open network. NetworkAllow/NetworkDeny unchanged. - engine.go / registry.go / bash.go: honor AutoAllowBashWhenSandboxed - auto-allow bash without a prompt only when a wrapping sandbox is active for the command; ignore the flag when the sandbox is inactive. Preflight network gate now uses effectiveNetwork so a misconfigured scoped policy still fails closed. Bash defers plan.Cleanup() to shut the proxy down. Default behaviour is unchanged for existing NetworkAllow / NetworkDeny users and for the off-by-default auto-allow flag. * wedge stage 08: MCP OAuth client Add an OAuth 2.0 + PKCE authorization-code flow so ZERO can connect to remote MCP servers that require OAuth. - internal/mcp/oauth.go: authorization-server metadata discovery with config fallback/override, S256 PKCE, loopback redirect listener, state (CSRF) validation, code+verifier token exchange, refresh, and optional dynamic client registration. Tokens are never logged. - internal/mcp/oauth_store.go: per-server token persistence in a 0600 JSON file under the config dir, with a redaction-safe status summary that omits the token material. - internal/mcp/network_client.go: OAuth-configured servers get a round tripper that attaches the bearer token and, on 401, refreshes once and retries; refresh failure surfaces an actionable re-login message. - internal/mcp/config.go + internal/config: an MCP server entry may declare auth: "oauth" with optional client registration / explicit endpoints. Non-OAuth servers are unaffected. - zero mcp oauth {login,logout,status} wired into the CLI surface; status reports presence/expiry, never the token. * wedge M2: address review — 2 security fixes, portability, robustness (#179) Security (Critical): - resources: resolve symlinks BEFORE the scope check (and resolve roots) so an in-workspace symlink to an out-of-scope file (e.g. link -> /etc/passwd) is no longer readable via resources/read. resources/read echoes the requested URI (not the resolved path) so list/read stay consistent. - sandbox egress: authorize the dialed URL host, not the client Host header, so 'GET http://denied/ ... Host: allowed' can't bypass the allowlist. Robustness/correctness: - prompts: reject prompts/get that omits a required argument; tokenized substitution so {{...}} inside supplied values is preserved (not stripped). - resources: percent-encode/decode file URIs via net/url (spaces, #, %). - oauth: one timeout-scoped context bounds discovery + registration + callback + exchange; joinWellKnown preserves issuer path segments (RFC 8414). - cli: 'mcp oauth login' rejects --json instead of silently ignoring it; the login test is bounded so a missed callback fails fast instead of hanging. Portability: XDG token-store path test uses t.TempDir() so it passes on Windows (fixes the Windows Smoke failure). Adds regression tests for the symlink escape, Host-header bypass, required-arg rejection, and placeholder preservation. Full go test ./... + -race green. * wedge M2: fail closed on unenforceable scoped egress (review #179) Addresses the two P1 scoped-egress enforcement gaps from review: 1. Policy-only fallback ran NetworkScoped with unrestricted networking. On Windows / any host without a native backend, 'network: scoped' with allowedDomains silently got open host networking because Evaluate permitted network-risk tools (expecting a proxy) while BuildCommandPlan ran a direct plan with no proxy. Evaluate now collapses scoped to deny when the active backend cannot enforce egress, so network-risk tools fail closed. 2. Bubblewrap 'scoped egress' could not reach the proxy. --unshare-net isolates the netns and there is no bridge to the host-loopback proxy (the comments claimed one; no code added it), so allowlisted egress never worked. Bubblewrap no longer starts an unreachable proxy: scoped collapses to deny (--unshare-net, no proxy env), matching Evaluate. Comments corrected; a real relay (e.g. slirp4netns) is left as future work. Mechanism: new Backend.ScopedEgress capability (true only for sandbox-exec, which shares the host net under a seatbelt profile restricting outbound to the proxy port) + Backend.EnforcesScopedEgress(). sandbox-exec scoped egress is unchanged. Regression tests cover enforcing vs non-enforcing backends in Evaluate and the bubblewrap fail-closed plan. wedge build: web_search + LSP client/diagnostics (stages 01-03) (#175) * stage 01: web_search tool Adds a web_search tool alongside web_fetch: discovers URLs via a pluggable searchBackend (env-configured generic JSON backend; swappable for Exa/You/ Tavily/Brave) and formats ranked results as a numbered list. Registered in CoreNetworkTools(). Deviation from the stage spec: the spec said readOnlySafety, but the repo enforces (TestCoreNetworkToolsExposePromptMetadata) that every core network tool is prompt-gated network egress — so web_search matches web_fetch's posture instead of being auto-allowed. Necessary fallout from adding the tool: knownToolNames (+web_search), the network-tools metadata test (now covers both), and a stale 'unknown tool' fixture that had used web_search. * stage 02: LSP client foundation New internal/lsp package: a direct (self-spawned, stdio) Language Server Protocol client so unattended runs see real compiler diagnostics without an open editor. - client.go: JSON-RPC 2.0 with LSP Content-Length framing; concurrent Call/Notify, id-matched responses, server->client request auto-reply, notification handler, context cancellation, read-loop teardown. - server.go: process lifecycle (spawn via os/exec, initialize/initialized handshake, graceful shutdown+exit with kill fallback). Handshake factored out (performInitialize) for pipe-based testing. - registry.go: extension -> server command (.go/.ts/.tsx/.js/.jsx/.py/.rs), languageId map, PATH availability via exec.LookPath. - types.go: minimal LSP types + PathToURI/URIToPath (Windows-drive aware). Stdlib-only; tested over in-memory pipes with a stub server (no real gopls), including -race for the concurrent id router. * stage 03: LSP document sync + diagnostics Makes the LSP client produce actionable signal so ZERO can ask 'did my edit compile?' headlessly. - documents.go: per-server session with document lifecycle (didOpen/didChange full-sync/didClose), a URI->diagnostics store fed by publishDiagnostics, and WaitForDiagnostics with a publish-baseline + 300ms quiet debounce (servers never signal 'analysis complete'). - diagnostics.go: FormatDiagnostics (path:line:col: severity: message, 1-based), HasErrors, FilterBySeverity, severity labels. - manager.go: Manager — one lazily-started, reused server per language (keyed by binary), routes a file to the right server, Check(ctx,path,text) -> settled diagnostics, graceful Shutdown; unknown extension -> (nil,nil), not an error. Concurrency-safe; tested under -race over in-memory pipes with a stub server (no real gopls). FormatDiagnostics takes the path explicitly since a Diagnostic carries only a range, not its document. * wedge: address review on #175 (LSP robustness + web_search provider) - lsp/client.go: Close() now disables the client — Call/Notify reject after close instead of leaking a pending entry / writing to a dead conn. - lsp/documents.go: serialize same-document syncs through the Notify write via a per-URI lock and commit version/open only on success; drop delayed publishDiagnostics for a superseded (older positive) version; WaitForDiagnostics now reports whether a fresh publish arrived. - lsp/manager.go: Check returns (nil,nil) when no publish newer than baseline arrives (don't return a stale prior result for new text); Shutdown joins and returns per-server shutdown errors instead of always reporting success. - lsp/diagnostics.go: FormatDiagnostics collapses embedded whitespace so each diagnostic stays one physical line. - tools/web_search.go: forward ZERO_WEBSEARCH_PROVIDER in the request so the config knob isn't inert. Adds tests: close-disables-client, stale-version drop, multi-line message collapse, Shutdown error propagation, httptest backend (provider+parse). * wedge: degrade gracefully when an LSP server binary is missing (#175 review) Manager.Check translated a missing-binary start error (exec.ErrNotFound) into (nil,nil) so a configured extension on a machine without gopls/ pyright/rust-analyzer degrades exactly like an unsupported extension — LSP diagnostics are an opportunistic layer, not a hard dependency. Other start failures still surface. Adds a test injecting an exec.ErrNotFound starter. * wedge stage 04: self-correct loop (#176) * stage 04: self-correct loop After a successful mutating tool call, verify the changed files (LSP diagnostics + project tests) and feed failures back to the model to fix — bounded and autonomy-gated, so an unattended run fixes its own mistakes without looping forever. - internal/agent/selfcorrect.go: SelfCorrector with injectable diagnosticsChecker (lsp.Manager adapter) and projectVerifier (verify.DetectPlan+Run); AfterEdit produces a redacted, model-facing corrective message; per-run attempt ceiling; low autonomy reports without auto-attempting; medium/high auto-correct. - internal/agent/loop.go: invoke AfterEdit after a successful mutating tool call (ChangedFiles non-empty), appending feedback to messages. nil SelfCorrect (the default) is a no-op — read-only tools never trigger it. - internal/agent/types.go: add Options.SelfCorrect *SelfCorrector (the explicit options-struct change). Single-pass verify per call (the agent loop + model edits provide the multi- attempt cycle, not selfverify's same-command re-runs). Tested with fakes (no gopls/shell): fail-then-pass, max-attempts abort, read-only skip, low-autonomy report-only, LSP-only failure, secret redaction; -race clean. CLI activation (a --self-correct flag building the SelfCorrector in exec.go) is intentionally left out to honor this stage's file scope — the engine is exported and ready to wire. * wedge: propagate file-read error in self-correct LSP checker (#176 review) CodeRabbit/golangci-lint (nilerr) flagged lspDiagnosticsChecker.Check returning (nil, nil) after os.ReadFile fails. Propagate the error instead; the sole caller (inspect) already skips files whose Check errors, so a deleted/unreadable file still degrades quietly while future callers can decide. Adds a regression test. * wedge: surface non-degradable verify errors in self-correct (#175 review) SelfCorrector.inspect silently continued on every checker.Check error and ignored verifier errors, so a real LSP startup/sync failure, an unreadable changed file, or a failed plan detection produced report.Failed=false -> OutcomePassed with no signal. Manager.Check already degrades missing servers to (nil,nil) and DetectPlan returns an empty plan (not an error) when nothing is detected, so a non-nil error from either is a genuine failure. Record such errors in CorrectionReport.InspectErrors, fail the pass, and surface them in the model feedback. Adds regression tests for both paths. * wedge: drop third-party search-provider names from web_search comments Keep code/comments provider-neutral: describe the backend interface as 'any hosted search API' rather than naming specific services. * wedge: defer self-correct feedback until after the tool-call batch (#175 review) Self-correct feedback was appended as a user message inside the per-tool-call loop, so a multi-tool-call turn whose first (mutating) call failed verification produced: assistant tool_use batch -> tool_result 1 -> user feedback -> tool_result 2. A user message interleaved between tool_results breaks strict provider replay and later session replay/compaction. Accumulate feedback during the batch and append it once after the loop (same after-batch pattern already used for mid-run model escalation), so every advertised tool call keeps its tool_result contiguous. Adds a two-tool-call regression test asserting the feedback lands after both tool_results. * wedge: batch self-correct AfterEdit once per turn over union of changes (#175 review) Deferring only the feedback emission still ran SelfCorrect.AfterEdit once per mutating tool call, which (a) consumes the per-run attempt budget multiple times in a single turn and (b) verifies an intermediate edit that a later call in the same batch already superseded — AfterEdit is documented as one per-run instance holding attempt state, so this is a correctness issue, not just transcript shape. Aggregate every mutating tool's ChangedFiles during the batch and call AfterEdit once afterward over the deduped union, appending its feedback once (still after all tool_results, preserving contiguity). Adds dedupeStrings and a regression test asserting two writes in one turn consume exactly one attempt and emit one feedback message. Add agent quality eval foundation (#177) * Add agent quality eval foundation * Fix agent eval review issues * Address agent eval review feedback audit: fix isolated remaining findings (redaction, secrets, rune-safety, credential status) (#174) * audit: fix redaction sibling-DAG false cycle, hoist URL regex, widen OpenAI key pattern - redaction: seen-set now tracks only the current DFS path (delete pointer after recursing) so a shared reference reached via a sibling branch is no longer reported as CircularReference; true cycles still detected. (audit redaction.go:222) - redaction: redactURLPasswords compiles its regexp once at package scope instead of per call. (audit redaction.go:362) - secrets: openai_key body allows - and _ so sk-proj-/sk-svcacct- keys match, not just legacy sk-. (audit scanner.go:35) * audit: rune-safe cron run-error truncation and commit-subject length - cronTruncate now cuts on a UTF-8 rune boundary (via cutRuneBoundary) so a persisted cron run-error excerpt can't end in a split rune. (audit cron_run.go:179) - zerogit ValidateMessage counts runes, not bytes, so a valid non-ASCII commit subject under 72 chars is no longer rejected. (audit zerogit.go:207) * audit: report auth-header-only profiles as having a credential ProviderSnapshot.APIKeySet was true only for a non-empty APIKey, so a profile authenticated solely via AuthHeaderValue rendered as 'not set' in the command center. Treat either credential as set. (audit contracts.go:166) * audit: address review on #174 (credential-presence coverage + consistency) - Add ProviderSnapshotFromProfile test covering APIKeySet for api-key-only, auth-header-only, both, and neither (the AuthHeaderValue path CodeRabbit flagged as untested). - doctor.go: report the provider 'apiKey' diagnostic as set when EITHER APIKey or AuthHeaderValue is present, matching ProviderSnapshot.APIKeySet — closing the same auth-header-only gap at the other credential-check site. - scanner_test: also assert the [REDACTED:openai_key] placeholder is present (matches the pattern used by the existing redaction test). * audit: trim credentials + surface doctor credential presence (#174 review) - ProviderSnapshot test: add whitespace-only api-key / auth-header cases so the TrimSpace behavior stays covered. - doctor.go: trim both APIKey and AuthHeaderValue before the presence check (matches ProviderSnapshot.APIKeySet) AND move the indicator off the sensitive 'apiKey' detail key — check() runs RedactValue, so 'apiKey' was always scrubbed to [REDACTED], leaving the set/not-set indicator invisible. Now reported under 'credentialConfigured' so it actually surfaces; added a doctor table test. No consumer read the old detail key. * audit: honor AuthHeaderValue in model discovery + base-URL redaction (#174 review) Two credential-presence consistency gaps flagged on #174: - providermodeldiscovery: the /models probe gated on (and sent Authorization from) profile.APIKey only, so an auth-header-only profile was skipped/sent unauthenticated. Gate via discoveryHasCredential (APIKey OR AuthHeaderValue) and build the request with providerio.ApplyAuthHeaders, matching how the live providers authenticate (honors AuthHeaderValue, AuthHeader, AuthScheme, CustomHeaders). APIKey-only behavior is unchanged. - zerocommands.redactProviderBaseURL now also redacts AuthHeaderValue if it leaks into the base URL (variadic secrets, empties dropped). Adds regression tests for auth-header-only discovery, discoveryHasCredential, and auth-header redaction in the base URL. * audit: doctor credential-presence test parity with contracts_test (#174 review) Add the 'both' and 'whitespace auth header only' cases so TestProviderConfigCheckCredentialPresence matches the six cases in contracts_test.go's TestProviderSnapshotAPIKeySetCountsAuthHeaderValue. Polish main TUI surface (#168) * Polish main TUI surface Simplify the empty chat surface, replace starter prompt chips with a cleaner brand state, move model and permission state into the composer frame, quiet the footer status, and improve composer word-editing keybindings. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Polish autocomplete command palette * Polish TUI file mention picker * Address TUI review feedback * Keep autocomplete anchored above composer * Center autocomplete over chat viewport * Layer autocomplete overlays over transcript * Prefill argument commands from palette * Show argument hints for command completions * Polish TUI provider setup * Polish TUI picker and composer metadata * Add two-step mouse selection in TUI * Polish TUI mouse selection flows * Make provider wizard navigation test hermetic * Fix TUI review edge cases Edit safety: file read/write tracking, external-modification detection, post-apply hooks (#172) * Edit safety: file version tracking, conflict detection, hook wiring Phase 1 — FileTracker (internal/tools/file_tracker.go): per-session map[absPath]{sha256, size, mtime}; CheckConflict flags drift only against a known baseline, so first-touch writes are unaffected. nil = no-op. Phase 2 — external-modification detection: read_file records the whole-file baseline; edit_file and write_file --overwrite refuse to clobber a file whose on-disk content drifted from it and tell the model to re-read first, then re-baseline to what they wrote. apply_patch forgets the files it rewrote (git apply has its own context guard). Threaded via RunOptions/agent.Options FileTracker, built once per session in the exec, spec, and TUI paths. Phase 3 — post-apply validation via hooks: the dormant hooks.Dispatcher is wired into executeToolCall. beforeTool hooks can veto a call (DenialHookBlocked); afterTool hook output is scrubbed and surfaced to the model. DispatchOutcome now collects hook stdout/stderr. Per-session dispatcher built from merged user+project hooks.json; fails open and runs nothing until a hook is configured. * edit-safety: address review — fail closed on read, redact hook reason - write_file overwrite: if a tracked file cannot be re-read to verify it, fail CLOSED (refuse the overwrite) instead of silently proceeding — the read error previously skipped the conflict check and could clobber an externally modified or replaced file. - blockedByHookResult: scrub outcome.Reason through redaction before placing it in model-visible ToolResult.Output; this intercepted path bypassed the registry's normal output-redaction boundary. - TestHashContentIsStableAndDistinguishing: compare separately stored hashes so the stability check is not a same-expression comparison (SA4000). * edit-safety: flag ToolResult.Redacted when hook output is scrubbed appendHookFeedback now reports whether scrubbing changed the afterTool feedback, and blockedByHookResult reports whether it scrubbed the reason; both set ToolResult.Redacted so the hook-intercepted paths match the registry's redaction contract. Adds tests asserting a secret-bearing hook reason/feedback is scrubbed and flips Redacted. permissions: scope-enforced grants (permission-UX Phase 0 + 1) (#170) * permissions: show grant scope on the permission card and decided rows The permission card only named the tool, so 'always' read as a blind tool-wide yes. Derive a concise scope from the call's args (the file path, directory, or working dir it touches) and show it on the card and the persisted decision row, so a user can see exactly what an allow covers. Phase 0 of the permission-UX work: presentation only, no storage change. permissionScope + scope plumbing through PermissionEvent/PermissionRequest; covered by TestPermissionScope. * permissions: spec for Phase 1 scope-enforced grants * permissions: enforce grant scope so "always allow" covers only what the card showed Phase 0 displayed the scope a tool call touches but the persisted grant was still tool-wide: an "always allow" on a write to one file silently authorized every write. This scopes the grant to exactly that file or directory. sandbox/scope.go centralizes scope derivation (DeriveScope), absolute-path resolution (resolveScopeAbs, anchored to the workspace so a grant never leaks across projects), and matching (grantCovers: file = exact, dir = subtree, empty = tool-wide; a tool-wide request is never covered by a narrower grant). Grants are now stored per-tool as a list (schema v2, with v1 files migrated as tool-wide grants). Lookup takes the request's absolute scope and applies deny-wins / most-specific-allow precedence. engine.Grant anchors a relative scope to the workspace; engine.Decide derives and matches the request scope. agent.persistPermissionGrant forwards the scope, and permissionScope now shares DeriveScope so the card and the stored grant can never diverge. Canonicalizing tool keys on read also closes the whitespace-padded-key lookup miss. Covered by sandbox scope/grant/engine tests and the agent/TUI always-allow tests. * sandbox: fix Windows-only TestResolveScopeAbs by using truly-absolute paths A leading separator (\proj\a) is rooted but not absolute on Windows (filepath.IsAbs requires a volume like C:), so the absolute-passthrough case was treated as relative and anchored onto the workspace root, producing a doubled path. Build the root via filepath.Abs so the test exercises a genuinely-absolute path on every platform. * test: isolate session store in exec/tui tests so go test stops writing to $HOME TestRunExecStreamJSONRunStartUsesResolvedAPIModel, TestRunExecReadsStreamJSONPromptFromStdin, and TestPromptSubmitInjectsLiveSessionModelContext ran real exec sessions but only isolated cwd (t.TempDir + getwd), not the session store, so the default store resolved sessions.DefaultRoot() off the real $HOME and persisted into ~/.local/share/zero/sessions on every run. Set XDG_DATA_HOME to a per-test temp dir (the existing convention in exec_test.go / exec_scope_test.go) so each run is fully isolated. * permissions: address review — clean root-equiv scopes, label+fit scope rows - DeriveScope: filepath.Clean the path before the root check so ./, ./., and a/.. collapse to the workspace root and surface as tool-wide instead of a narrower directory grant (which re-prompted inconsistently). Adds regression cases. - renderPermissionRow: prefix the scope segment as scope: across all three branches and fitStyledLine the allow branch (it returned unfit and could overflow narrow terminals). - TestGrantReplacesSameScope: assert the setup store.Grant errors so a setup failure fails at the real cause, not a stale-state assertion. * docs: point permission-scope spec at grant_scope.go The design spec named internal/sandbox/scope.go as the source of truth, but DeriveScope/resolveScopeAbs/grantCovers live in grant_scope.go (scope.go is the unrelated write-roots Scope from #162). Fix the heading and the test reference (grant_scope_test). Add session context compaction flow (#166) * Add session context compaction flow * Animate manual compaction flow * Simplify compact progress copy * Render compact progress as compression card * Render compact completion as success card * Fix compaction review findings * Fix compaction resume fallback test: isolate session store in exec/tui tests so go test stops writing to $HOME (#171) TestRunExecStreamJSONRunStartUsesResolvedAPIModel, TestRunExecReadsStreamJSONPromptFromStdin, and TestPromptSubmitInjectsLiveSessionModelContext ran real exec sessions but only isolated cwd (t.TempDir + getwd), not the session store, so the default store resolved sessions.DefaultRoot() off the real $HOME and persisted into ~/.local/share/zero/sessions on every run. Set XDG_DATA_HOME to a per-test temp dir (the existing convention in exec_test.go / exec_scope_test.go) so each run is fully isolated. tui/resume: list only standalone conversations, not sub-runs (#169) The /resume picker called sessions.Store.List() unfiltered, so it showed every session directory — including the child (specialist/sub-agent) and spec draft/impl sessions an agent spawns by the dozen per conversation. That flooded the picker (the reported '… 779 more · /resume ') even though the TUI creates exactly one session per conversation. Add Store.ListResumable()/LatestResumable() (and an IsResumableKind predicate) that keep only regular and user-fork sessions, and use them for the picker and for '/resume latest' so latest never lands on a newer child/spec sub-run. An explicit '/resume ' still resolves any session by id. Covered by TestListAndLatestResumableExcludeSubRuns and TestResumePickerExcludesSubRunSessions. Additional write roots: --add-dir flag, global config key, /add-dir TUI command (#162) * docs: spec for additional write roots (--add-dir / /add-dir) * docs: implementation plan for additional write roots * sandbox: add Scope type for multi-root write access * docs: write roots honored from global config only, union merge semantics * sandbox: fix multi-root violation reporting and pin prefix-alias behavior - validate: prefer ViolationSymlinkTraversal over ViolationOutsideWorkspace when all roots deny; return original requestedPath in violation; append --add-dir hint only on outside_workspace results; extend doc comment to state that a symlink resolving inside any root is allowed. - normalizePrefixForRoot: dedupe insideRoot check via pathWithinRoot (Fix 3); widen EvalSymlinks comment to cover jump-over case; add POSIX-only note. - WorkspaceRoot: add doc comment noting immutability/lock safety (Fix 4). - Tests: pin multi-root traversal-preferred behavior, deterministic alias test (TestValidateResolvesAliasedPathPrefixes), Add symlink normalization test, and tilde-expansion error path test. * sandbox: make engine path validation and risk scope-aware Thread *Scope through EngineOptions/Engine so Evaluate and Classify use multi-root scope validation instead of single-root validateWorkspacePaths, letting extra write roots (--add-dir / /add-dir) be honoured by the policy gate and risk classifier. Remove now-dead validateWorkspacePaths helper. * sandbox: pin override-root scope isolation, tidy Evaluate scope handling * sandbox: widen seatbelt/bubblewrap profiles and command cwd to scope roots * sandbox: pin extra-root chdir behavior and document bind invariants * tools: resolve paths against the shared write scope Add PathScope interface and scoped resolver helpers to workspace.go, then thread a scope field through all 8 file tools (read_file, list_directory, glob, grep, write_file, edit_file, apply_patch, bash). Each existing constructor delegates to a new NewScoped* variant; nil scope is byte-identical to the original behavior. Registry gains CoreReadOnlyToolsScoped, CoreWriteToolsScoped, CoreShellToolsScoped, and CoreToolsScoped. * tools: restore fail-closed write-target symlink checks, share prefix normalization * tools: report absolute paths for extra-root changes, pin scoped traversal denial - resolveScopedPath and resolveScopedTargetPath now return the absolute path as the second value when the matched root is an extra (non-workspace) root, eliminating ambiguity in ChangedFiles, cwd meta, and display summaries downstream - Add TestScopedWriteRefusesSameRootSymlinkTraversal to pin that same-root symlink traversal within an extra root is denied (pre-existing behavior) - Add TestScopedWriteReportsAbsolutePathForExtraRoot to enforce the new absolute-path contract for ChangedFiles on extra-root writes - Extend doc comments on PathScope, ChangedFiles, changedFilesFromPatch, and both scoped resolvers to capture the workspace-vs-absolute invariant * config: add sandbox.additionalWriteRoots (global config only, union merge) * cli: add repeatable --add-dir flag wiring scope into registry and engine * cli: forward --add-dir across dispatch paths, pin scope wiring with tests * cli: pin --add-dir dispatch forwarding end-to-end, close guard gaps Review fixes for the --add-dir dispatch commit: - Add TestRunAddDirDispatchForwardsGrantIntoExecScope: drives runWithDeps through both the "exec" and "-p" dispatch shapes with a provider that calls write_file inside the extra root, asserting the grant reaches the exec sandbox scope (and a negative control without --add-dir is denied fail-closed). Mutation-verified: dropping addDirFlagArgs forwarding from either case fails the test. - Remove help/version from the --add-dir allowlist so any non-TUI/ non-exec leading --add-dir hard-errors, matching the stated requirement; extend the rejection test with those cases. - Fail loud when --add-dir is hidden behind a stray non-flag arg on the --skip-permissions-unsafe path instead of silently dropping the grant. - Reject flag-like values in the inline --add-dir= spelling to match the space form (a directory named -foo stays reachable as ./-foo). - Move TestExecScopeReRegistrationSwapsCoreToolsByName out of the parse test file into the new exec_scope_test.go alongside the e2e test. * tui: add /add-dir command for session write-root grants * sandbox: surface write roots in zero sandbox status and plan snapshots * cli: surface write-roots error in --effective --json, normalize fallback root Review fixes for the Task8 observability range: - runSandboxPolicyEffective JSON payload gains writeRootsError (omitempty) so --json consumers see the same fail-soft signal as the text write_roots_error line instead of a misleading workspace-only writeRoots list. - The fail-soft fallback now derives its write roots from a workspace-only Scope (which cannot fail) so the error path renders the same symlink-resolved root as the success path. - TestRunSandboxPolicyEffectiveWriteRootsFailSoft gains a --json leg asserting writeRootsError names the stale root with a workspace-only fallback; the configured-roots test asserts the key is absent for valid roots. - SandboxPlanSnapshot doc comment reworded to match the converter: no current builder populates WriteRoots. * sandbox: seatbelt integration test for extra write roots * docs: document --add-dir and /add-dir write-root grants * sandbox: pass volume-qualified paths through prefix normalization verbatim NormalizePrefixForRoot's POSIX component walk mangled Windows drive paths (C:\Users -> C:Users), which downstream single-root checks treated as relative and joined under the workspace — failing the policy gate OPEN on Windows. Volume-qualified paths now bypass the walk entirely (the macOS /var alias problem it solves has no Windows equivalent) and windows-gated tests pin both the verbatim pass-through and the engine denial. * sandbox: make prefix normalization volume-aware for Windows alias resolution The previous commit disabled NormalizePrefixForRoot on Windows, which broke it the other direction: a workspace created under an 8.3 short path (C:\Users\RUNNER~1\...) resolves via EvalSymlinks to its long form (runneradmin), so raw short-form requests escaped the long-form root and legitimate extra-root writes were denied. This is the same alias problem as macOS /var -> /private/var, which the helper already solves. Start the component walk from the volume root (C:\ or //host/share/) instead of "/" so it resolves the prefix on Windows too; POSIX VolumeName is empty so the walk reduces to the original "/"-rooted behavior byte-for-byte. Windows test now pins allow-inside-root + deny-outside both directions. * sandbox: address PR review findings for additional write roots Resolve the CHANGES_REQUESTED review on PR #162: - engine: derive workspaceRoot from scope.Roots()[0] when NewEngine is given a Scope without an explicit WorkspaceRoot, so Evaluate's EnforceWorkspace/classification guards no longer silently skip (+ regression test). - tools/workspace: scopedRoots now fails closed (returns an error) when a non-nil PathScope exposes empty Roots(), instead of returning success with an empty path; threaded through the scoped resolve/recheck helpers and resolveGrepRoot. - tools/glob: emit absolute matches when cwd resolves into an extra root so results can't be fed back and resolve to a same-named workspace file (+ regression test). - tools/bash,grep,apply_patch: schema descriptions mention granted extra roots (relative -> workspace, absolute -> granted root). - docs/plan: fix markdownlint heading increment, malformed rule+heading, and bare code fence. * tools/grep, tui: address PR review for additional write roots - grep now emits absolute, symlink-resolved paths for matches in a granted extra (non-workspace) root, mirroring the glob fix. A bare workspace-relative name like "report.go" otherwise resolves back under the workspace when fed to read_file/edit_file and hits the wrong file when the name exists in both roots. Covers content and files_with_matches output. - tui: only reset chatScrollOffset for a real submission, not an empty Enter (a no-op), so the viewport is no longer yanked to the bottom when nothing was submitted. Real submissions still snap back. - glob: schema description now mentions granted extra roots, matching the bash/grep/apply_patch wording so the --add-dir/-add-dir flow is discoverable. Regression tests: TestScopedGrepReturnsAbsoluteMatchesForExtraRoot, TestEmptySubmitKeepsChatScrollOffset. * docs: add /add-dir to the TUI commands quick-reference table The command was documented further down but missing from the scannable commands table, so users browsing the table wouldn't discover the additional-write-roots flow. --------- Co-authored-by: Claude Fable 5 providerhealth: assert the last-error contract in dialValidatedAddrs test Use distinct per-attempt errors and assert errors.Is(err, lastErr) plus the attempt count, so a regression returning the first failure (or short-circuiting) is caught instead of slipping past a bare non-nil check. audit: address PR review findings (security, correctness, tests) Fixes from the code review on the critical/high audit branch: - tools/apply_patch: parseHunkCounts now reads only the range section between the opening and closing "@@". A crafted section heading like "@@ -1,1 +1,1 @@ +1,999999" could otherwise overwrite the line count, wedge the parser in hunk mode, and swallow later file headers so they escaped validatePatchPaths / recheckPatchWriteTargets — a workspace-confinement bypass. - providerhealth: the SSRF-hardened dial now tries every validated address in order instead of pinning addrs[0], so a dual-stack/multi-record provider isn't failed by a single dead record. Extracted dialValidatedAddrs for coverage. - hooks/dispatch: a beforeTool hook killed by its deadline now fails CLOSED (vetoes the tool) instead of fail-open. A launch failure (missing binary) still fails open so a misconfigured hook can't wedge every tool call. - cron: Store.Get returns a distinct ErrJobNotFound sentinel; cron_run only treats that as "removed during run" and still records/persists on a transient read error instead of silently dropping the run. - providers/anthropic: closeOpen finalizes thinking buffers still open at stream end (SSE ended after thinking_delta/signature_delta but before content_block_stop), so they survive in the done event's ReasoningBlocks for replay. Finalized in index order. - providers/gemini: corrected the stale thinkingConfig comment (no IncludeThoughts field; thought summaries are suppressed by skipping part.Thought parts). Tests: regression coverage for each fix, plus the requested nitpicks — EOF-terminated MCP line, recipe-prompt propagation assertion, checked file.Close() in session store tests, and a shortened bash process-group timeout test via the bashWaitDelay hook. Note: declined the suggestion to replay a "signature" on redacted_thinking blocks — Anthropic's redacted_thinking carries only an opaque "data" field; the signature is for regular thinking blocks, and the stream never populates one for redacted blocks. hooks: add a Dispatcher that runs and audits configured hooks The package had all the primitives (Select, ConfigStore, AuditStore) but no caller ever ran a hook. Add a Dispatcher that selects the hooks for a lifecycle event, runs each command (no shell) with the event payload on stdin under a timeout, and records started/completed audit events. A beforeTool hook that exits non-zero vetoes the tool and stops the chain; other events are advisory. A hook that cannot be launched is recorded as an error but never blocks. The command runner is injectable for testing. sessions: only tolerate a torn tail when the final line is unterminated The torn-tail tolerance dropped any malformed final line, including a complete-but-corrupt line ending in a newline — silently swallowing real corruption. A genuine torn tail is an incomplete write with no trailing newline; gate the tolerance on that so a fully-flushed corrupt line fails loudly again, while a truncated final line is still recovered. providers/gemini: forward reasoning effort as a thinking budget Map a requested reasoning effort to a Gemini thinkingBudget (capped at the lowest per-model ceiling). Capture the thoughtSignature each functionCall part carries and preserve it on the tool call (via a new StreamEvent/ToolCall signature field), then replay it on the functionCall part in later turns so multi-turn function calling with thinking is not rejected. Thought-summary parts are skipped so reasoning never leaks into the answer. No effort = no thinking config, so default runs are unchanged. providers/anthropic: forward reasoning effort as extended thinking Map a requested reasoning effort to an Anthropic thinking budget and enable extended thinking, raising max_tokens so the budget plus a response both fit. Crucially, capture the streamed thinking / redacted_thinking blocks (text + signature) and preserve them on the assistant message so they are replayed verbatim at the start of the next turn — without this, Anthropic rejects every tool-using follow-up. Adds zeroruntime carriers (Message.Reasoning, ToolCall. Signature, ReasoningBlock) shared with the upcoming Gemini work. No effort = no thinking, so default runs are unchanged. providers/openai: forward reasoning effort to the chat completions request zeroruntime.CompletionRequest had no effort field, so agent.Options.Reasoning Effort never reached the provider (cli/exec.go even noted it was 'not yet forwarded'). Add CompletionRequest.ReasoningEffort, thread it through every request the agent loop builds, and map it to OpenAI's reasoning_effort. The CLI gates the forwarded value against the resolved model: known non-reasoning models send nothing (matching the existing 'ignoring' advisory), known reasoning models send their effective level, and unknown custom endpoints forward the requested value as-is. Anthropic/Gemini mapping is handled separately. agent: surface response truncation (finish_reason) to the user Providers already normalized finish_reason into collected.FinishReason, but the agent loop never read it, so a final answer cut off at the output token cap (or by a content filter) was silently presented as complete. Carry the turn's finish reason into agent.Result, add Truncated()/TruncationNotice(), and emit a warning from the exec writer and a system row in the TUI when the response was truncated. sandbox: allow /dev nodes and temp dirs in the sandbox-exec profile The macOS sandbox-exec profile made only the workspace writable, so ubiquitous operations failed with 'Operation not permitted' — `> /dev/null` and mktemp among them. The bubblewrap backend already grants these via --dev /dev and --tmpfs /tmp; bring the sandbox-exec profile to parity by allowing the standard device nodes and the system temp trees. The workspace remains the only writable project location. providerhealth: harden default connectivity probe against SSRF The default probe used http.DefaultClient, which (1) followed redirects without revalidating the target, so a 3xx to an internal address bypassed the pre-flight endpoint check, and (2) re-resolved DNS at dial time, leaving a rebinding TOCTOU window after validateEndpoint. Build a dedicated client that revalidates every redirect hop (capped at 5) and validates + pins the resolved IP at dial time, dialing the IP literal so the kernel cannot re-resolve to an unsafe address. tools/bash: kill the shell as a process group so timeouts don't leak children On timeout the context cancelled only the shell, leaving backgrounded children (e.g. 'sleep 3 & wait') orphaned. Worse, those children inherited the stdout/stderr pipes, so Run() blocked until they exited — far past the timeout. Set Setpgid and a Cancel that SIGKILLs the whole process group on POSIX, plus a WaitDelay backstop so a lingering child can't hang Wait. On Windows, set WaitDelay only (no process groups). specialist: keep name in session title so description-less runs resume A specialist run with no description set --session-title to just the description (empty), leaving AgentName empty on resume and making the session unresumable. Always prefix the title with the specialist name and fall back to the bare name when no description is given. cli/cron: honor explicit expr over recipe; don't clobber pause mid-run - `cron add "" --recipe X` silently dropped the explicit schedule because the recipe set expr before the positional arg was consumed. Consume the positional expr first so it overrides the recipe default. - cron_run persisted the job copy read at tick start, so a pause or removal that happened during the run was clobbered back to active. Re-read before persisting and preserve an external pause/removal (best-effort; full atomicity needs a lock). sessions: tolerate a torn tail when reading the event log ReadEvents returned an error on any malformed JSONL line, so a single interrupted append (truncated final line) made resume hard-fail and lose the whole session. Drop only a malformed final line (a torn tail); malformed lines earlier in the file are real corruption and still error. streamjson: stop over-redacting prose in secret scrubbing The sk- and bearer patterns were unanchored, so "sk-" inside "task-list" and the word after a bare "bearer" were replaced with [REDACTED], corrupting legitimate output. Anchor sk- on a word boundary and require a credential-length body for both sk- and bearer tokens; real keys/tokens are still redacted. tools: fix grep glob scope and apply_patch hunk-body misparse - grep matched the glob against the workspace-relative path, so "*.go" with path="subdir" found nothing while "**/*.go" matched subdir/a.go. Match the glob relative to the search directory (ripgrep semantics); a single explicit file is matched by its base name. - apply_patch parsed file paths per-line, so a hunk-body line removing content that starts with "-- " (appearing as "--- ...") was mistaken for a file header and a valid patch was rejected. Parse headers with hunk line-counting, matching how git apply delimits hunks. mcp: cap framed message size and use newline-delimited stdio framing - protocol.go read() rejected no upper bound on Content-Length, so a hostile peer could drive make([]byte, n) with n up to ~9.2e18. Cap every framed message at 64 MiB (both the Content-Length and newline-delimited paths). - The MCP stdio transport is newline-delimited JSON, not LSP Content-Length framing. write() now emits one JSON message per line; read() accepts newline-delimited frames and still tolerates Content-Length for back-compat, so Zero interoperates with spec-compliant MCP servers and clients. Add first-run model picker (#165) * Add first-run model picker * Guard setup model discovery generations docs: overhaul README to match current product surface (#164) Rewrite the README around what main actually ships today: - ANSI Shadow wordmark matching the TUI splash, centered header + badges - 25+ provider catalog (frontier, fast-inference, local, compatible endpoints) - new features: spec mode, mid-run model escalation, cron, repo-map/repo-info, workspace index, context report, image input, deferred tool loading, notifications - full command reference (25 subcommands), exec flag summary, TUI slash commands - updated tools table (web_fetch, ask_user, skill, tool_search, escalate_model) - refreshed architecture diagram and project layout for the 47-package tree Address onboarding review blockers Use Bubble Tea mouse button values for wheel handling instead of the deprecated mouse type field, and remove the unused onboarding helper. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui -run 'TestMouseWheelScrollsChatWithoutRecallingInputHistory' -count=1 Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: GOCACHE=/tmp/zero-go-cache go vet ./... Tested: git diff --check Update empty-state ZERO wordmark Render the startup logo as the full ZERO wordmark while keeping the O in the existing lime accent and the ZER prefix in the normal ink color. Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check Add first-run provider setup Introduce zero setup plus first-run onboarding for missing provider credentials, backed by the provider catalog and config save path. Make the TUI fullscreen in interactive mode, keep setup transitions inside the chat surface, and add managed transcript scrolling so wheel/PageUp/PageDown do not recall composer history. Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check Resolve provider wizard env credentials Address provider onboarding review feedback Persist provider wizard credentials Expose active provider model to agent Normalize detected provider on model switch Search and apply live model picker entries Refresh model picker and toggle favorites Scope model picker to active provider Hide provider model source label Clean provider model search placeholder Add searchable provider model picker Split Ollama cloud and local providers Filter provider picker to coding models Use remote model catalogs in provider wizard Discover provider models live Own provider model catalog Scope provider wizard models Use provider catalog in TUI onboarding Accept pasted provider API keys Open provider onboarding in TUI Add provider onboarding UX Add TUI interaction foundation (#160) * add tui interaction foundation * fix tui review findings Add shared workspace index foundation (#159) * Add shared workspace index foundation * fix workspace index review findings * fix workspace depth on non-windows runners TUI scrollback re-architecture, Lime chat surface, and deep-audit fixes (#158) * docs(design): add Lime TUI prototype and rebuild spec * tui: remove the zeroline skin system One design remains in the tree ahead of the Lime rebuild: the zeroline package, its TUI view/skin plumbing (Options.Skin/ThemeVariant/ThemeDark, model skin branches, theme picker), and the zeroline CLI subcommand are gone. /theme keeps its shell-only message. * tui: replace the cyan palette with the Lime token table theme.go is rebuilt around the Lime design tokens (docs/design/ zero_tui_lime.html): near-black panels, one lime accent, solid tint stand-ins for the prototype's rgba overlays. Existing render sites map onto the new role styles (ink/line/userPrompt/toolName/toolArg) without layout changes; no hex exists outside theme.go. * tui: build the Lime chat-surface components Title bar with brand badge + context window, empty state replacing the deleted splash (block-art 0, tagline, 1-3 starter suggestions), stream blocks (user prompt gutter, muted interim with cursor, final-answer accent rail, done line, bordered notes), tool cards with diff/read/ bash/grep bodies and MiniDot run spinner, borderless composer with run-state hints, and the grouped status line. Final answers are marked at append time; resolved tool calls collapse into their result cards. * tui: fix Stage 2 review findings Adversarial review of the chat-surface commit confirmed 15 issues, all fixed here: - wrapPlainText sliced wide runes by rune count: CJK/emoji prose could panic the TUI or emit lines ~2x the measure; split at width instead - a cancelled run's streamingText leaked into the next turn's interim block; cancelRun now clears it - orphaned tool-call cards (cancelled/errored turns) re-animated with the spinner on any later run; the glyph is now scoped to the call's own runID - starter-suggestion digit capture was active while the chips were off screen (/clear mid-run); the guard now mirrors the render condition - prompt submits while a deferred Ctrl+C quit is draining could start a run the quit would orphan; submission is blocked while exiting - the running placeholder advertised ctrl+c (which quits) as the interrupt key; it now says esc - read-card parser expected 'N: text' but read_file emits 'N | text', so the gutter and L-range never rendered on real output - toolCard borders were one column short of the body rows - diff tint bands now span the full row; NEW FILE renders green on addBg; preamble/no-newline lines are unnumbered meta and no longer shift hunk numbering - card bodies and system notes paint the panel surface; the head gains the separate faintest arg column (grep target + pattern) - title-bar overflow now truncates the minimal candidate instead of rendering the deleted design's 'ZERO … READY' line - untracked the stray snake-game.html (left on disk, ignored) * tui: restyle the interactive surfaces for Lime Permission prompts render as the amber permission card (PERMISSION badge, risk readout, key chips); resolved prompts collapse to one line (allowed once/always/denied · tool) and unprompted allows fold into the tool card's [auto] tag. Ask-user and spec-review prompts share the card language with line borders. Autocomplete and picker overlays move onto the panel/selection tints with the accent ❯ marker; model-picker rows gain the provider dot and ctx · KEY_ENV metadata from the registry and provider catalog. /resume renders its list as stacked session cards (id + age, title, meta). Key handling is untouched everywhere. * tui: adaptive width tiers, doc updates, final polish Width tiers re-evaluate from the live width: ≥100 renders the full spec; 80–99 drops the tool-arg column, header ctx, and the status 'interactive' group; 58–79 drops the diff/read gutters, bares the badge, and keeps provider+tokens+mode; <58 renders a single-segment header, rail-less cards, and a mode-only status line. Table-driven tests cover {58,70,80,100,120} plus a frame-wide invariant that no emitted line exceeds the terminal at any tier. Card bodies expand tabs so width math holds. Dead splash-chip machinery (startupCommandNames, startupOrder) is gone; README's TUI section now describes Lime. * tui: fix final-review findings across Stages 3-4 A second adversarial review confirmed 14 issues, all addressed: - permission collapse and tool-card pairing are now run-scoped (rcKey): providers with repeating synthetic ToolCallIDs (Gemini gemini_tool_N) could attribute a decision or result to a different run's call - rehydrated always-grants no longer mislabel as 'allowed once' (GrantMatched fallback) - the width invariant now actually holds everywhere: empty-state tagline/hint, ask-user rows, permission detail blobs, pending image chips, and the sessions trailing hint are wrapped or fitted; the invariant test renders the empty state and all of those rows at widths 24-120 - every card (permission, ask-user, spec-review, sessions, notes) drops side borders on tiny terminals, not just tool cards - /resume card fields are sanitized against separator bytes from user-controlled titles - the picker's selected row paints its gap so the selBg band is solid - dead splash helpers (normalizedStartupWidth, countLines, nonEmpty, unreachable rowWelcome branch) and stale /theme comments are gone - README no longer claims transcript scrolling keys or Tab-accept behavior that never existed; chatWidth documents its 24-col floor Tag tui-lime-s4 moves to this commit. * audit: deep-audit report, TUI scrollback re-architecture, and cross-module fixes TUI: settled-row flush frontier prints history to native terminal scrollback (tea.Println, ordered + ack-serialized); View() renders only the live tail. Full diffs (400-line flush cap) and OSC 8 file:// links land in history; streamed interim text, cancellation markers, ask_user exchanges, and composer input history are preserved; run-scoped dedup keys fix Gemini's repeating tool-call ids; /exit, Ctrl+C-after-Esc, and cross-session late flushes no longer orphan or contaminate checkpoints. Cross-module: zero search panic + offset mapping, usage report robustness, 529 retry, SSE keep-alive watchdog, registry corrections, update --check on dev builds, MCP ping + mcp list --json secret redaction, sandbox.network config knob, doctor apiKey presence, atomic config writes, C1-safe notifications, rune-safe truncation everywhere, gofmt + vet CI gates, dead glamour dependency dropped. Full audit report: docs/audit/2026-06-10-deep-audit.md (175 findings). * tui: hide malformed ask_user tool errors * fix tui malformed tool retries * fix max-turn finalization * upgrade tui agent guardrails * fix coderabbit guardrail findings --------- Co-authored-by: Claude Fable 5 Co-authored-by: Vasanthdev2004 Add repo intelligence map (#157) * feat: add repo intelligence map * fix: address repo map review feedback * fix: refine repo map scan limits zero cron: dep-free file-backed scheduled agents (foreground runner) (#155) * cron: dep-free 5-field cron expression parser * cron: next-run evaluator with Vixie DOM/DOW semantics * cron: preset recipes + loop.md prompt resolution (cap + symlink reject) * cron: file-backed job store (metadata.json + runs.jsonl) * cli: zero cron add/list/rm/pause/resume over the cron store * cli: zero cron run foreground scheduler (--once/--catch-up, exec per fire) * cron: fix adversarial-review findings (DST loop, traversal, runaway re-fire, +) - Next: fix infinite loop on DST spring-forward gaps (advance hours by absolute addition + a forward-progress guard); widen the search window to 9 years so a Feb-29 schedule across a century non-leap year (2096->2104) isn't reported as impossible. - store: reject path-traversal job ids (Get/Update/Remove/AppendRun) so 'cron rm ../..' can't delete outside the store; List now surfaces corrupt jobs as a warning instead of silently dropping them. - runner: reject impossible schedules at add even with --run-now; auto-pause a job whose schedule can no longer advance (was re-firing every tick); only skip-reschedule STRICTLY-overdue jobs on startup (keep exactly-due ones); capture stderr into RunRecord.Error on non-zero exit; pass the prompt via the inline --prompt= form so dash-leading prompts aren't misparsed. - Regression tests for each (DST, leap gap, traversal, corrupt-list, pause, reconcile, dash-prompt). * cron: address #155 review (store-error surfacing, resume guard, Runs traversal, +) - fireJob/reconcile/cronRun now surface AppendRun/Update failures to stderr instead of discarding them (a failed write no longer silently re-fires/loses history); fireJob takes stderr. - cron add rejects extra positional arguments instead of silently ignoring them. - cronResume rejects an unparseable/impossible schedule instead of reactivating a job with a stale/zero NextRunAt. - store.AppendRun returns the Close() error (buffered-write failures surface on close); store.Runs now validates the id (path-traversal guard, matching the other store methods). - tests: Runs traversal rejection, cron add extra-args, resume-impossible. * cron: check store errors in cron CLI tests CodeRabbit (critical): the cron CLI tests ignored errors from store.List/Add/Get, so a store failure would surface as a misleading assertion on a zero-value job/slice instead of a clear failure. Check every store op's error across the test file (not only the two flagged sites). * cron: DefaultRoot falls back to os.UserHomeDir when HOME is unset Vasanth (P1): cron's DefaultRoot mirrored neither sessions.DefaultRoot nor reality — with no XDG_DATA_HOME and no HOME it produced a RELATIVE ".local/share/zero/cron" under the caller's cwd, so on Windows/restricted shells cron jobs scattered per working directory and could write repo-local data. Now falls back to os.UserHomeDir() like sessions.DefaultRoot. Adds TestDefaultRootEmptyHomeFallsBackToUserHome (asserts the path stays absolute). Add provider health diagnostics (#151) * Add provider health diagnostics * Fix provider health auth review findings * Fix provider health test handler assertion * Harden provider health connectivity probes zero repo-info: local (network-free) repository characterizer (#150) * repoinfo: language map + build/test/CI + workspace detection helpers * repoinfo: Collect repo characterization from local git (network-free) * cli: zero repo-info command (text + --json) over the repoinfo package * repoinfo: derive AgeDays from the root commit, not git log --reverse -1 git applies -1 before --reverse, so the old call returned the LATEST commit (age always ~0). Use --max-parents=0 (root commits) and take the oldest. * repoinfo: count passthrough dirs + unicode-safe ls-tree (-z); harden tests Adversarial-review fixes: - DirectoryCount now counts every directory on the path to a file (expand each file's ancestors), so directories holding only subdirectories are included. git ls-tree -r lists blobs only, so the previous parent-of-file count undercounted. Verified against 'git ls-tree -r -t | awk $2==tree'. - ls-tree now uses -z (NUL-terminated, unquoted paths) instead of newline scanning, so non-ASCII/special filenames (which git C-quotes by default) are parsed correctly instead of being silently dropped. Drops the bufio scanner. - Tests: assert DirectoryCount/MaxDepth; add an age-from-oldest-root-commit test (would catch the old log --reverse -1 bug); make the CLI JSON test hermetic via a temp git repo. * repoinfo: strip credentials from remote URL before storing/printing Addresses #150 review (@Vasanthdev2004 P1): a git remote can embed secrets (e.g. https://x-access-token:ghp_...@github.com/o/r.git), which would leak in both text and --json output. sanitizeRemoteURL strips userinfo from URL forms and the leading user@ from scp-like forms. Tests: unit table for the sanitizer, Collect-level strip, and end-to-end CLI text + JSON assertions that the credential never appears. Sound / completion notifier: terminal bell + OSC-9 on turn completion & awaiting-input (#149) * notify: dep-free terminal bell / OSC-9 completion notifier * config: notify.mode + notify.focusMode (validated, default off) * exec: --notify/--no-notify + completion notify on stderr * tui: focus reporting + completion/awaiting-input notifications * notify: fire on exec --use-spec completion; headless ignores focus; gate focus-reporting on enabled * notify: validate --notify value, skip notify on empty ask_user, deterministic resolver tests Addresses #149 review (CodeRabbit + @Vasanthdev2004): - parseExecArgs rejects an invalid --notify value (only off/bell/notify/both), so a CLI typo errors early instead of silently running with no notification. - TUI OnAskUser fires AwaitingInput only when len(request.Questions) > 0 — a request with no questions auto-resolves without prompting, so the bell/desktop notification was a false positive. - notify resolver tests pass Env: map[string]string{} so Resolve does not fall back to os.Getenv (deterministic, not ambient-env dependent). Deferred (lazy) tool loading: tool_search + auto threshold for large MCP tool sets (#148) * Deferred (lazy) tool loading: tool_search + auto threshold for large MCP tool sets Adds deferred tool loading so a large MCP tool set no longer sends every tool's full JSON schema each turn. When the number of visible MCP tools crosses a threshold (config tools.deferThreshold, default 10; 0 disables), their schemas are withheld and advertised compactly in a per-turn system-reminder; the model pulls a tool's full schema on demand via a new tool_search tool. Strictly additive: below the threshold behaviour is byte-identical to today. No new module dependencies. - Eligibility: MCP tools opt in via an optional Deferred() bool interface (tools.IsDeferred); built-ins stay eager. No change to the core Tool interface. - Formatting (internal/tools/deferred.go): compact one-liners wrapped in a by BuildDeferredReminder; server label via MCPServerName(). - tool_search (internal/tools/tool_search.go): SideEffectNone/PermissionAllow/AdvertiseInAuto; select:Name1,Name2 (exact) or keyword (ranked); returns full schemas and signals the loop via Meta["load_tools"]. Operator-filter-aware (never lists tools hidden by --enabled-tools/--disabled-tools). - Agent loop: partitionTools is the single source of truth; eligible counts only ToolVisible deferred tools; Meta["load_tools"] lifts to ToolResult.LoadedTools and unions into a per-run loaded-set advertised next turn. Reminder appended only to the per-turn request copy (never persisted), including both reactive-compaction retry paths. tool_search stays reachable when deferral is active (exempt from the EnabledTools allowlist). - Config + wiring: tools.deferThreshold (default 10; 0 disables; negative rejected); tool_search registered and DeferThreshold threaded in exec and TUI, gated on the same visible-deferred count the loop uses. Recreated on top of current main; resolves the SideEffectNone clash with #146 (escalate_model). Supersedes #147. * deferred-tools: activate deferral only when tool_search is runnable; drop dead deferredTools() Addresses #148 review: - partitionTools gates 'active' on a registered, non-disabled, mode-advertised tool_search (mirrors the executeToolCall dispatch gate). When the loader is unavailable (explicitly disabled, missing, or not advertised e.g. spec-draft), fall back to the eager/inactive path instead of hiding deferred tools behind a loader the model can never call (the dead-end). - Flip TestDisabledToolSearch... to assert the eager fallback; register a real tool_search in the active-path tests that previously relied on activation without a usable loader. - Remove unused toolSearchTool.deferredTools(). * deferred-tools: disable deferral in exec when tool_search disabled; drop unused toolDefinitions shim Further #148 review: - exec computes one effective DeferThreshold, forced to 0 when tool_search is in --disabled-tools, and feeds it to both registerToolSearchIfEligible and agent.Options.DeferThreshold — so the registration gate and the loop partition gate agree, and a disabled loader never enables deferral. - Remove the unused toolDefinitions shim (golangci-lint unused; no callers). Add spec mode review flow (#145) Mid-run model escalation: escalate_model tool + --allow-escalation (#146) * modelregistry: add UpgradeTargetID and Registry.UpgradeTarget escalation lookup * modelregistry: seed escalation chains in catalog decoration * modelregistry: verification gate for escalation upgrade map * agent: add ModelSwitcher option and ToolResult.RequestedModel field * agent: lift escalate_to_model meta into ToolResult.RequestedModel * agent: perform at-most-one mid-run model switch in the turn loop * tools: add SideEffectNone for control-only tools * tools: add escalate_model control tool (metadata + args) * cli: parse --allow-escalation exec flag * cli: document --allow-escalation in exec help * cli: register escalate_model tool under --allow-escalation * cli: wire model switcher for exec escalation * cli: guard byte-identical exec run without escalation flag * cli: attribute post-switch usage to the escalated model * tools: accept an explicit empty escalate_model reason reason is an optional, informational-only arg, but the previous arg parsing rejected an explicit reason:"" (non-empty-string check), failing the whole escalation. Switch to stringArgWithEmpty so an absent OR empty reason is accepted while a non-string value is still rejected. Add a test covering both the empty and absent forms. * agent: test (nil,nil) escalation switcher keeps original provider When a ModelSwitcher returns (nil, nil) — no error, no replacement — the loop's `else if newProvider != nil` guard already keeps the active provider, so the run continues on the original model. Add an agent-layer test that proves this (it panics if the nil guard is dropped), closing the gap the adversarial review flagged. Also note the deferred compaction limitation near the switch: the compactor's context-window budget is fixed at run start and not updated on a mid-run switch (needs a ModelSwitcher contract change to fix). * cli: gate usage model attribution on the escalation flag and harden tests FIX A (back-compat): the OnUsage closure wrote a "model" key into every persisted EventUsage payload unconditionally, diverging a non-escalation run from the byte-identical origin/main baseline. The model can only change mid-run under --allow-escalation, so include the "model" key only then. A new flag-OFF test asserts the persisted usage payload carries no "model" key (mutation-proven). Test hardening for the CLI escalation boundary: - Wire test: each newProvider build returns a DISTINCT provider with its own turn counter; assert the source provider served exactly the escalation turn and the escalated provider served exactly the post-switch answer. Fails if `provider = newProvider` is dropped. - Mixed usage attribution: emit usage pre- and post-switch and assert the first attributes to the original model, the second to the escalated target (the loop's ordering guarantee). - Switch-error path: a failed rebuild is non-fatal and post-error usage stays attributed to the original model (currentModel unchanged). - Top-tier decline end-to-end: a flag-on run that escalates while already top-tier performs no switch (newProvider built exactly once) and succeeds. * modelregistry: validate UpgradeTargetID resolves at registry construction * cli: update currentModel only when the switched provider is non-nil * sandbox: make 'none' a first-class side effect (control-only tools) --------- Co-authored-by: Gnanam Add spec mode foundation (#144) Image (multimodal) input: exec --image, stream-json images, TUI /image across all 3 providers (#143) * zeroruntime: add ImageBlock type and NormalizeImageMediaType * zeroruntime: add optional Images field to Message * zeroruntime: add SeedMessagesWithImages, delegate SeedMessages with nil * Emit Anthropic image source blocks for user image turns * Add inlineData part type to Gemini request schema * Serialize user image attachments as Gemini inlineData parts * test(openai): pin text-only chat request serialization before content refactor * feat(openai): allow structured multimodal content parts in chat request * feat(openai): map image attachments to image_url content parts * test(openai): assert image content parts serialize over the request body * modelregistry: add SupportsVision capability gate * Add shared image-file loader package * cli: parse repeatable exec --image flag into imagePaths * cli: resolve exec image attachments via shared loader with sniff and size cap * agent: thread image attachments through Options into the seeded user turn * cli: resolve and vision-gate exec image attachments into the agent run * cli: document exec --image flag in help output * streamjson: add InputImage type and InputEvent.Images field * streamjson: whitelist images field on message events only * streamjson: allow empty content on message events that carry images * streamjson: add ResolveImages to decode, normalize and cap input images * streamjson: cover image parse-and-resolve round-trip and text-only invariance * Add /image command definition to TUI * Add synchronous vision check for TUI model * Implement /image attach, clear, and non-vision refusal in TUI * Render pending image chips above the input in both skins * Thread pending images into the agent turn and clear on submit * Wire stream-json images through to the agent run Stream-json message images were parsed and validated but never reached agent.Options.Images: streamjson.ResolveImages had no production caller, so images sent over stream-json input were silently dropped. resolveExecPrompt now also returns the resolved stream-json images (nil for text input and for image-free stream-json input). The exec runner merges them with --image attachments before the vision gate, so both input sources flow through the same drop+warn and the same agent.Options.Images wiring. Adds an exec-level end-to-end test that feeds a base64 image on a stream-json message event via stdin on a vision model and asserts the image reaches the agent run (the provider request's user turn). * Re-check TUI vision support at submit, not only at /image attach A user could attach an image on a vision model and then switch to a non-vision model via /model before submitting; the images were threaded unconditionally into the agent turn, reaching a model that rejects them. The commandPrompt submit path now re-runs the vision check against the current effective model. When the active model can't accept images, the pending images are dropped (not threaded) and an inline notice mirroring the headless drop+warn wording is appended. Pending image state is still cleared after submit either way. Adds a test that attaches on a vision model, switches the model id to a non-vision one, submits, and asserts the run received no images, an inline notice was appended, and pending state was cleared. * Bound memory with an os.Stat size pre-check in imageinput.LoadFile LoadFile read the whole file into memory before checking the size cap, so a multi-gigabyte file allocated a huge buffer only to be discarded. Add an os.Stat size pre-check before os.ReadFile: a file whose size exceeds MaxImageBytes is rejected with the same 10 MiB-limit error before any read; a missing file surfaces the same not-found error. The existing post-read len(data) check is kept as a TOCTOU backstop in case the file grows between Stat and ReadFile. * OpenAI: only the user role emits image content-parts The Anthropic and Gemini providers only attach images in their user branches, but OpenAI funnels every role through a single mapMessage, which would emit image content-parts for any role carrying Images. Guard the content-parts path to the user role: a non-user message that happens to carry Images keeps the plain string/nil content path. Pins the invariant with TestMapMessageNonUserRolesNeverCarryImages, asserting assistant/tool/system messages with Images set still serialize plain content (no content-parts array). * Deep-copy image buffers so attachments are never aliased Add a shared CloneImageBlocks helper in zeroruntime that deep-copies a slice of ImageBlock including each Data byte slice (returning nil for nil/empty input). Use it in SeedMessagesWithImages so a seeded user turn owns an independent copy of the caller's image bytes, and in the agent loop's copyMessages so image bytes are deep-copied alongside ToolCalls instead of staying aliased across history/request/result copies. Tests assert that mutating the caller's original Data bytes after seeding leaves the seeded message unchanged, and that copyMessages produces independent image bytes. * Accept image-only stream-json turns and bound base64 image size Resolve stream-json images before the prompt in the exec input path and tolerate an empty prompt when images are present, so an image-only message event (empty content + one image) proceeds with prompt="" and the image reaches the agent run instead of being rejected. A turn carrying neither text nor images still errors. In ResolveImages, reject an oversized payload from its ENCODED length (DecodedLen) BEFORE the base64 decode so a huge blob never allocates a decode buffer just to be capped afterward; the post-decode length check stays as a backstop. Tests cover the image-only exec turn reaching the agent and the pre-decode oversize rejection (asserting the size gate fires before any decode). * Bound the image read in imageinput.LoadFile with a LimitReader Replace os.ReadFile with os.Open + io.ReadAll over an io.LimitReader(f, MaxImageBytes+1). The os.Stat pre-check is only a fast-path hint (a non-regular file reports a misleading size and the file can grow between stat and read); the LimitReader is the real bound, so at most one byte past the cap is ever buffered and an oversized or unbounded source can never allocate a multi-gigabyte buffer. The file is closed via defer and the existing size message is reused. * Fix RenderChat frame-height off-by-one with image chips cmdRegion emits TWO rows when ImageChips is set (a chip row above the input line), but the body/overlay height accounting assumed a one-row command region, overflowing the fixed frame by one line. Compute the fixed-row count from the actual command-region height (1 or 2 rows) so the composed frame stays exactly Height rows in both cases. A RenderChat test asserts the rendered frame line-count equals the requested height both with and without ImageChips set. * Reject an empty inline --image= value Route the inline --image= form through requiredInlineFlagValue, the same empty-rejection the other inline flags use, so --image= errors with "--image requires a value" instead of appending an empty image path. A parse test covers the empty inline value. * imageinput: reject non-regular files (FIFO/dir) before opening --------- Co-authored-by: KRATOS Co-authored-by: Gnanam CLI: doctor config validation, autonomy ceiling, changes --base, usage report (#142) * config: add ValidateFile for structured config diagnostics * doctor: add offsetToLineCol helper for JSON error positions * doctor: validate config files in config.validation check * cli: thread config paths into doctor and degrade on bad JSON * doctor: inline read, single-parse config validation + utf8 col test - Remove the osReadFile wrapper; call os.ReadFile directly at its one call site - Add config.ValidateBytes([]byte) so each config file is read and JSON-parsed once in configValidationCheck instead of twice (once for position, once via ValidateFile); ValidateFile now reads then delegates to its own unmarshal for the path-prefixed error message, keeping its public signature unchanged - Fix the misleading skip comment in configValidationCheck: configFilesCheck only checks path-string presence; the skip is a defensive guard for the unreachable case where DefaultResolveOptions passed a non-empty path that still fails to read - Add utf8 multibyte case to TestOffsetToLineCol documenting byte-column semantics (column counts bytes, not runes) * sandbox: add Policy.MaxAutonomy ceiling defaulting to high * sandbox: normalize both operands in autonomyAllowed to fail closed * sandbox: clamp grant-allow and unsafe escalation to policy autonomy ceiling * sandbox: add RequiredAutonomyForBatch helper with fail-closed mapping * config: add sandbox.maxAutonomy field to file and resolved config * config: merge sandbox.maxAutonomy across layers, env, and overrides * cli: thread configured autonomy ceiling into sandbox engine construction * cli: surface max_autonomy in sandbox policy formatters * zerocommands: carry max autonomy in sandbox policy snapshot * sandbox: default empty policy ceiling to high + reason const Evaluate now treats an empty Policy.MaxAutonomy (from a directly-constructed Policy that bypasses DefaultPolicy) as High instead of letting it normalize to Low, which would silently clamp every Medium/High decision to Prompt. The ceiling is a no-op unless explicitly configured. Also extracts the "above policy ceiling" clamp reason into a package const referenced at both engine clamp sites and the related tests, and adds a non-vacuous engine test proving the empty-ceiling trap is closed. * zerogit: add base-ref three-dot diff to changes inspect * zerogit: cover base-ref backward-compat and real-git diff * zerogit: propagate base ref through change snapshot * cli: parse changes --base ref and reject on commit * cli: thread base ref into changes inspect output and help * changes: redact base in cli summary + guard --base= + rename test - redactChangeSummary now redacts the Base field alongside Root/Branch/Commit/etc. - --base= equals-form rejects leading-dash values via flagValueLooksLikeOption, closing the option-smuggling gap - TestParseNameStatusRenameAndCopy asserts rename/copy three-field lines use the new/destination path with correct status, and confirms two-field modify is unaffected * Add ParseDiffStat helper for git diff --stat summaries * Add usage report aggregation over persisted session usage events * Add zero usage report command over persisted session usage * Wire usage command into CLI dispatch and help * usage: validate --since format, fix redaction comments, add tests Fix 1 (BLOCKING): parseUsageArgs now validates --since with time.Parse against YYYY-MM-DD; returns exitUsage + "invalid --since %q: expected YYYY-MM-DD" for bare strings like "foo", unpadded "2026-6-1", or wrong-separator "06/01/2026". Valid dates are stored unchanged for the existing lexical comparison. Fix 2: Replace false "pre-redaction stat" claims in diffstat.go and usage.go with accurate comments: the --stat summary line carries no secret-bearing tokens so parsing the already-redacted DiffStat is safe. Fix 3: Add three tests to usage_test.go — - TestRunUsageDaysFilter: stubs deps.now, seeds events on two dates, asserts --days 3 includes the recent date and excludes the old one. - TestRunUsageInvalidSince: table-driven; verifies "foo", "2026-6-1", "06/01/2026" each return exitUsage + validation message, while "2026-06-01" returns exitSuccess. - TestRunUsageEmptyStore: zero usage events → exitSuccess, output contains header and "total" row, no panic. * doctor: surface type-mismatch offsets and drop colliding flat line/col keys Probe config validation against config.FileConfig instead of a bare any so a structurally-valid document with a wrong field type (e.g. maxTurns as a string) surfaces a *json.UnmarshalTypeError carrying the offset, instead of losing line/col. Remove the flat top-level details["line"]/["col"] keys that were overwritten by the last malformed file when several were present; keep only the unambiguous per-path map entry. Tighten the existing malformed test to assert concrete per-path line/col and add a type-mismatch test that exercises the previously-dead UnmarshalTypeError branch. * sandbox: fail closed on invalid configured autonomy ceiling An invalid non-empty sandbox.maxAutonomy (e.g. "moderate") previously survived Resolve (only trimmed, never validated), failed to normalize at the sandbox bridge, and left the policy unchanged at the default High ceiling. A typo therefore silently disabled the admin's intended ceiling: fail-open on a security boundary. Validate at resolve time (fail loud): Resolve now rejects a non-empty maxAutonomy that does not normalize, returning a clear error. As defense in depth, applyConfiguredAutonomyCeiling now clamps an unrecognised non-empty value to the most restrictive ceiling (low) instead of returning the policy unchanged, so a bad value can never widen the ceiling even via direct use. Empty/unset stays valid and keeps the default High ceiling. Add resolver tests for invalid-fails / valid-resolves and bridge tests for the fail-closed clamp and valid mappings. * sandbox: fail closed on malformed autonomy and surface policy resolve errors Treat a genuinely-invalid request autonomy as the highest tier in the engine so it exceeds a Medium/Low ceiling and clamps to Prompt instead of being sanitized to Low and auto-allowing on the grant/unsafe path. Surface resolveConfig failures in the policy command instead of silently falling back to the permissive DefaultPolicy, which would misreport trust posture. * usage: bucket and filter by UTC calendar day, reject empty --session Normalize RFC3339 timestamps to their UTC calendar date before day bucketing (report) and before the --since/--days cutoff comparison (CLI) so offset timestamps land on the correct UTC day and the bucket and cutoff agree; malformed timestamps fall back to the leading-10 slice. Reject empty --session/--session-id values with a value-required error matching the --since validation style. * doctor: fail on unreadable config files and stop fabricating positions Surface a present-but-unreadable config path (permissions, is-a-directory) as a failing per-path validation detail instead of silently skipping it; a genuinely-missing path stays a skip. Return ok=false for a JSON parse error that carries no offset so it routes through the no-position ValidateBytes path rather than fabricating a (1,1) line/col. Assert empty stderr on the JSON doctor failure path. * config: make sandbox maxAutonomy resolver tests hermetic Pass an explicit empty Env to the maxAutonomy Resolve tests so host environment variables cannot leak in through the nil-Env os.Getenv fallback. * sandbox: preserve raw autonomy for ceiling check so invalid clamps under default ceiling Defaulting invalid request.Autonomy to High only clamped under a Medium/Low ceiling; under the default High ceiling autonomyAllowed(High, High) returned true and still auto-allowed (fail-open). Keep the raw requested value for the ceiling check so autonomyAllowed's unknown-tier guard fails it closed under ANY ceiling. Adds default-High-ceiling regression tests for the unsafe and grant-allow paths. --------- Co-authored-by: KRATOS Co-authored-by: Vasanthdev2004 Add provider catalog foundation (#141) * feat: add provider catalog foundation * feat: add provider onboarding checks * fix: address provider readiness review * fix: clear provider catalog review threads * fix: accept direct auth values in provider checks * fix: harden provider catalog config * fix: guard project provider credential binding Add context budget report (#139) * add context budget report * fix context report review findings zeroline TUI: fix slash palette, token readout, @ files, ! bash (#138) * zeroline TUI: fix slash palette, token readout, @ files, ! bash The zeroline home footer advertised affordances the code didn't keep. Fixes: - '/' palette: autocomplete.go suppressed a bare '/' ('/' alone showed nothing). Now a bare '/' lists the full command palette (capped at 8). The footer's '/ commands' works. - Token readout: the zeroline header was built without Cost/TotalTokens, so the bars always showed $0.00 and no token total. zerolineHeader() now threads m.usageTracker.Summary() (TotalCost + TotalTokens, with the unpriced-token fallback); the bottom bar shows a cumulative 'tok ' segment (zeroline.Header gains TotalTokens; humanTokens formats it). - '@ files': '@' input now drives a workspace file picker in the existing overlay (trailingAtToken/fileSuggestions/replaceTrailingAtToken; bounded, skips .git/node_modules/vendor/dist/hidden). Selecting inserts the @path, preserving preceding prompt text. Previously '@…' was sent to the agent as a chat prompt. - '! bash': '!cmd' now runs as a shell escape (commandBash) — executed async with a 30s timeout in the workspace, output shown in the transcript. Previously '!cmd' was sent to the agent as chat. - '1-5 theme': left as-is — it works when the input is empty (the home screen's state); gating on empty input keeps digits typable in prompts. Tests: bare-/ palette, @-token detection/replacement, file listing+filter+skip-dirs, !cmd parsing, header usage. build/vet/-race/full-suite + GOOS=windows green; no new deps. * zeroline TUI: emit forward-slash @file paths (Windows fix) fileSuggestions used filepath.Rel, which returns backslash paths on Windows, so the inserted @path token and the test assertions diverged (TestFileSuggestionsListsAndFiltersWorkspaceFiles failed on the Windows smoke). Normalize with filepath.ToSlash so @paths are forward-slash on every platform. * zeroline TUI: gate !cmd behind unsafe mode + test humanTokens CodeRabbit major: the !cmd shell escape ran bash -c directly with no permission check, bypassing the sandbox. It now runs ONLY in unsafe permission mode; in auto/ask it is not executed and the user is told to relaunch with --skip-permissions-unsafe. Adds TestBashEscapeGatedByPermissionMode (auto/ask gated, unsafe runs). CodeRabbit minor: adds TestHumanTokens covering negative clamp, 0, sub-1k, 1k trim, fractional, and the 999999->1000k rounding edge. * zeroline TUI: bound @file walk by entry count; humanTokens M-scale Review fixes for #138: - fileSuggestions now counts every WalkDir entry (directories included), not just files, and stops at the budget; a directory-heavy tree could otherwise be traversed in full each keystroke and stall the TUI. Extracted fileSuggestionsBounded so the per-keystroke budget is unit-tested. - humanTokens switches to "M" notation past 1,000,000 instead of rendering "1000k"+ on large-context models; tests extended to the M boundaries. * zeroline TUI: make unsafe mode reachable so the ! shell escape works The "!cmd" shell escape is gated behind unsafe permission mode, but the interactive TUI hardcoded Ask and shift+tab only cycles auto<->ask, so unsafe was unreachable and the escape was dead code. Worse, the gate's own remediation message ("Relaunch with --skip-permissions-unsafe") pointed at a flag the interactive launch did not parse: "zero --skip-permissions-unsafe" fell through to the unknown-command path. Honor --skip-permissions-unsafe at interactive launch (both "zero" and "zero zeroline"), mirroring exec, so unsafe mode -- and the ! escape -- is reachable via an explicit opt-in. Thread the resolved mode through runInteractiveTUI(WithSkin); the default stays Ask. Document the flag in "zero --help" and add launch tests for both entrypoints. * zeroline TUI: run the ! escape via the platform shell, not hardcoded bash Now that --skip-permissions-unsafe makes the "!" escape reachable on every platform, hardcoding "bash -c" left it broken on stock Windows (no bash on PATH) and anywhere bash is not at a predictable path -- the advertised "! bash" footer would just print an exec error. Resolve the shell the same way the agent bash tool does: cmd.exe /d /s /c on Windows, /bin/sh -c elsewhere. Add a platform test asserting the command is wrapped correctly. --------- Co-authored-by: KRATOS Test: raise the npm-wrapper Windows spawn timeout to de-flake CI (#140) TestNodeWrapperReportsMissingNativeBinary intermittently hit its 30s Windows deadline with empty output on loaded CI runners (cold node spawn), then passed in ~2.5s on the next run — a runner flake, not a wrapper bug. Raise the Windows timeout to 90s so a slow cold start has headroom; non-Windows stays at 10s. Co-authored-by: KRATOS Tool quality: per-tool denial categorization + secret scanning (#137) * [LOCAL PR-Z] Agent: structured per-tool denial categorization Adds ToolResult.DenialReason (DenialCategory: filtered / permission_denied / sandbox_violation) so a surface can tell precisely why a call was blocked instead of parsing Output. Set in the not-enabled path and deniedPermissionResult (sandbox-violation denials categorized distinctly from approval-declined). Tested. * [LOCAL PR-Z] Secrets: scan + redact leaked secrets in bash output New internal/secrets package: a dependency-free, precision-first scanner for high-confidence secret shapes (AWS/Google keys, GitHub/Slack tokens, private-key blocks, JWTs). bash output now runs through secrets.Redact so a command that prints a secret has it replaced with a typed placeholder + a redaction notice, instead of echoing it back into the model context (additive to the configured-key scrub at the registry boundary). A built-in regex scanner fits the CLI without the heavy gitleaks dependency. Tested: detection, no-false-positives on ordinary text, redaction, and the bash wiring. * Tool quality: redact full private-key blocks + use DenialReason in retry classification CodeRabbit critical: the private_key_block pattern matched only the BEGIN header, so Redact left the PEM/OpenSSH key BODY in the output — defeating the redaction. It now matches the entire BEGIN…END block (body included) so the key material is removed. Test asserts the body is gone. CodeRabbit minor: isRetriableToolError now treats a structured ToolResult.DenialReason (filtered/permission/sandbox) as non-retriable, independent of message wording; the text checks remain a fallback for results lacking the field. --------- Co-authored-by: KRATOS Model harness: per-model system prompts + summarizer fallback (#135) * [LOCAL PR-X] Agent: per-model system-prompt specialization Appends a family-specific guidance block (OpenAI: markdown+native-tool preference+persistence; Gemini: tool preference+conciseness; Anthropic: comment discipline) chosen by model id. Builds on the #129 system-prompt builder. Tests for classification + addendum selection. * [LOCAL PR-X] Agent: chunked summarization fallback on context-limit If the elided middle is itself too large to summarize in one call, summarizeWithFallback splits it in half recursively and joins the partial summaries, so compaction keeps working past the summarizer's own input window. Non-context errors and unsplittable single messages surface unchanged. Builds on #131. Tests cover split-and-join, non-context propagation, and the single-message edge. * Summarizer: re-summarize partial summaries instead of persisting a blob CodeRabbit major: summarizeWithFallback joined the two partial summaries and Compact stored that as one message; if that single message later exceeded the summarizer window, the guard couldn't split it (a single message) and compaction failed again for long sessions. After summarizing the halves, the partials are now re-summarized into ONE compressible unit. If even the combined partials don't fit (extreme), it falls back to the joined text (still the already-compacted halves, better than failing). Adds TestSummarizeWithFallbackReSummarizesPartialsIntoOne. build/-race/full-suite + GOOS=windows green. --------- Co-authored-by: KRATOS Real-world hardening: graceful shutdown + crash capture (#136) * [LOCAL PR-Y] CLI: graceful shutdown of headless exec runs (Ctrl+C) zero exec wrapped agent.Run in context.Background(), so Ctrl+C/SIGTERM killed the process abruptly — leaking the in-flight provider request and any spawned children. runExec now wraps the run in a signal-aware context (signalContext): a signal cancels the run, the loop returns cleanly, and exec reports 'Interrupted.' with exit 130 (stream-json: an 'interrupted' run-end). Scoped to runExec — no dispatch-wide signature churn. NOTE: SIGINT runtime delivery needs a manual Ctrl+C check before PR; unit tests cover the helper + the no-regression of the exec suite. * [LOCAL PR-Y] Observability: crash capture (panic -> saved report) New internal/observability package: a recovered top-level panic is written to a local crash report (timestamp, label, full stack) under ~/.zero/crashes and surfaced as a brief notice instead of a raw stack trace. Wired into runWithDeps via a named-return defer. Fail-open and dependency-free; this is the hook a Sentry/OTEL adapter attaches to for remote reporting (deferred, needs a real endpoint to verify). Tested: report formatting/writing, panic capture sets the crash exit code + writes one report, and the no-panic path is a no-op. --------- Co-authored-by: KRATOS Background: escalate process termination from SIGTERM to SIGKILL (#134) * Background: escalate process termination from SIGTERM to SIGKILL terminateProcess (POSIX) sent only SIGTERM, so a background process that traps or ignores it kept running after a Kill/KillRunning — a leak, e.g. on Ctrl+C shutdown. It now sends SIGTERM (letting the process clean up), polls liveness, and force-kills with SIGKILL if the process is still alive after a grace period. - internal/background/process_posix.go: SIGTERM -> poll (signal 0) until a 3s grace deadline -> SIGKILL; treats an already-exited process as success (ESRCH / ErrProcessDone). Grace/poll are vars so tests run fast. Windows already force-kills via taskkill /F, so it is unchanged. Tests: a SIGTERM-ignoring process (trap, with a readiness handshake to avoid signalling before the trap installs) is killed only after the grace period; a well-behaved process dies on SIGTERM well before escalation; an already-exited pid is a no-op. -race + full-suite + GOOS=windows build green; no new deps. * Background test: assert the terminating signal (SIGTERM vs SIGKILL) The graceful and escalation tests now check the actual signal that killed the process (via WaitStatus.Signal()), not just timing — so a regression to an immediate SIGKILL (or to never escalating) is caught. Graceful path must be SIGTERM; the SIGTERM-ignoring process must be SIGKILL. * Background: keep user kill intent when the exit waiter reaps during terminate Vasanthdev P1: terminateProcess now blocks until the child exits, so the background Wait-goroutine can reap the process and MarkExited the task to 'error' BEFORE Kill marked it 'killed' — leaving a user-initiated stop persisted as 'error' even though TaskStop returned killed. Manager.Kill now records the kill intent (markKilledIfStillRunning) BEFORE terminating. MarkExited only acts on a running task, so once the task is killed the waiter's MarkExited is a no-op and the status stays killed. If terminateProcess fails, restoreRunningAfterFailedKill reverts the optimistic mark so a still-running task isn't falsely reported killed (preserves TestManagerDoesNotMarkKilledWhenKillFails). Adds TestManagerKillStaysKilledWhenExitWaiterReapsDuringTerminate (cross-platform: a KillProcess that simulates the waiter reaping mid-terminate; asserts final status stays killed). build/-race/full-suite + GOOS=windows green. * Background: don't signal a stale pid + surface rollback failures in Kill Two follow-ups to the kill-ordering fix (CodeRabbit): 1. markKilledIfStillRunning now returns whether it actually marked. If the task exited (or was reused) between the running check and the mark, Kill returns without calling killProcess — so it never signals a possibly-stale pid in the exit/reuse race. 2. restoreRunningAfterFailedKill returns its persistence error; on a failed terminate, Kill joins the kill error and the rollback error (errors.Join) instead of swallowing a failed rollback that would leave the task wrongly marked killed. Adds TestMarkKilledIfStillRunningReportsWhetherItMarked. build/-race/full-suite + GOOS=windows green. --------- Co-authored-by: KRATOS Agent: preserve active plan + loaded skills across compaction (#131) * Agent: preserve active plan + loaded skills across compaction Compaction summarized the elided middle into prose, which silently dropped two pieces of structured state the model needs to keep working: the active plan (update_plan) and any loaded skill instructions (skill tool). Now Compact extracts them from the elided middle and appends them VERBATIM to the injected summary, so they survive a compaction exactly instead of being paraphrased away. - internal/agent/compaction_preserve.go (new): extractLatestPlan (most recent update_plan, rendered as status-tagged bullets), extractLoadedSkills (skill tool calls matched to their result bodies by id, latest-per-name, 2KiB-capped per skill so a large skill can't defeat the compaction), and appendPreservedState which folds both into the summary under clear labels. - internal/agent/compaction.go: Compact appends the preserved state; summaryInstructions now tell the summarizer to integrate any earlier summary block (delta-aware summarization) so re-compaction never drops older facts. Tests: plan + skill preservation through Compact, no sections when absent, latest-plan-wins, and skill calls without a result body are skipped. build/vet/-race/full-suite + GOOS=windows build green; no new deps. * Fix capBody to honor the byte budget (UTF-8 safe) capBody sliced by RUNE count against maxPreservedSkillBytes (a BYTE budget), so multibyte UTF-8 could preserve far more than 2 KiB, and it could append the truncation note without actually truncating. Now it cuts to the byte budget on a UTF-8 rune boundary (never splitting a rune), reserves room for the note within the budget, and only adds the note on real truncation. Adds tests: short-body unchanged, multibyte over-budget stays <= cap and valid UTF-8, and the rune-countcap case. * Compaction: carry preserved plan/skills across repeated compactions Vasanthdev P1: after the first compaction the preserved plan/skill sections live only as text in the injected summary message; on a SECOND compaction that summary is in the elided middle with no real update_plan/skill tool calls left, so the structured state was lost unless the summarizer happened to copy it (non-deterministic). appendPreservedState now carries forward existing '## Active plan' / '## Loaded skills' sections parsed from the most recent prior summary in middle, with fresh tool calls overriding per name (skills merged by name; plan: fresh update_plan wins, else carried forward). Adds TestCompactCarriesPreservedStateAcrossRepeatedCompaction — a second compaction whose summarizer drops the sections still keeps the plan + skill body. build/vet/-race/full-suite + GOOS=windows build green. * Compaction: serialize preserved state as JSON (lossless for arbitrary bodies) CodeRabbit major: the carry-forward format parsed markdown delimiters (section ends at the next '## ', skills split on '### '), so a verbatim skill body containing headings (## Usage, ### Examples) or code fences was truncated or split into bogus extra skills on the next compaction — breaking the repeated-compaction guarantee. The preserved plan + skills are now written as a single labelled line of JSON (formatPreservedState) and recovered with json.Unmarshal (parsePreservedState). JSON escapes everything, so any body — markdown headings, fences, quotes, multibyte — round-trips losslessly. Removed the markdown section/skill parsers; fresh tool calls still override the carried-forward copy by name. Adds TestCompactPreservesSkillBodyWithMarkdownHeadings: a body with ## / ### / a code fence stays byte-identical across two compactions. build/vet/-race/full-suite + GOOS=windows green. * Compaction test: guard compacted shape before indexing out[1] The round-trip helper indexed out[1] without checking len(out)>=2 / role first; add the guard so a regression in Compact fails cleanly instead of panicking. --------- Co-authored-by: KRATOS Agent: per-category context token budget (system / tools / messages) (#133) Adds MeasureContext, which breaks a request's estimated token footprint into the categories that compete for the window — system prompt, tool definitions, and conversation messages — plus the used fraction of the model context window. This gives the agent the data to report context utilization and reason about what to compact. - internal/agent/context_measurement.go: ContextBreakdown + MeasureContext (reuses estimateTokens for messages; estimateToolTokens for tool defs on the same ~4-chars/token scale). - Options.OnContext: opt-in per-turn callback (mirrors OnText/OnUsage) emitted with the request's budget so a surface (TUI/CLI) can show utilization; nil is a no-op. Tests: per-category split + total, system-dominant proportion, used-fraction math, unknown-window and no-system/no-tools edge cases. build/vet/-race/full-suite + GOOS=windows build green; no new deps. Co-authored-by: KRATOS Providers: unified transient-failure retry (network, 429, 5xx) (#132) * Providers: unified transient-failure retry (network, 429, 5xx) across all providers Only the OpenAI provider retried before; Anthropic and Gemini surfaced the first failure, so a dropped connection or an intermittent gateway 5xx/429 failed the whole turn. Centralizes the policy so every provider behaves consistently. - internal/providers/providerio/retry.go (new): SendWithRetry rebuilds the request each attempt (replay-safe) and retries network errors + retryable statuses (429 and 5xx) up to N attempts, backing off between tries and honoring a server Retry-After header and context cancellation. Only the INITIAL request is retried — a partially consumed stream is never replayed. Plus Backoff, ShouldRetryStatus, and RetryAfter helpers. - anthropic/gemini: replace the bare http.Do (no retry) with SendWithRetry. - openai: replace its bespoke retry loop + backoff func with the shared helper (now also covers 429 + Retry-After, which it didn't before). Net code reduction. Tests: retry-then-succeed, no-retry on 400, exhausted-retries surface the last response, Retry-After parsing, and backoff context cancellation. build/vet/-race/full-suite + GOOS=windows build green; no new deps. * Retry: don't replay transport-failed POSTs (only retry 429/5xx) A network/transport error on a completion POST does not mean the server didn't receive it — the request may have arrived and be generating a billable, non-idempotent completion while only the response/connection failed. Replaying it could duplicate that work. SendWithRetry now returns transport errors immediately and retries ONLY server responses (429 and 5xx), where the request was explicitly rejected without effect. Adds TestSendWithRetryDoesNotReplayTransportErrors (a transport error is attempted exactly once). Docs updated. -race + providers suite + GOOS=windows build green. * Retry: narrow retryable statuses to 429 + 503 (not generic 5xx) Retrying any 5xx replayed non-idempotent completion POSTs whose effect is unknown: a 500/502/504 (esp. a 504 gateway timeout) can follow an upstream that already produced a billable completion, so a retry risks duplicate work. Limit retries to 429 (rate-limited) and 503 (service unavailable) — the statuses where the server explicitly did NOT accept the request, so a replay is safe. Other 5xx now surface to the caller's HTTP-error path. Also close the response in the transport-error test (bodyclose). Tests updated: ShouldRetryStatus maps 500/502/504 -> false, 503/429 -> true; the retry-then-succeed test uses 503. * Retry test: check the response Body.Close() error (errcheck) The transport-error test dropped resp.Body.Close()'s error, which errcheck flags and can break lint-gated CI. Now the close error is checked. --------- Co-authored-by: KRATOS Agent: rich system prompt (coding-craft + workspace context) (#129) * Agent: rich system prompt (coding-craft + workspace context) Replaces the one-sentence system prompt — our single biggest agentic-quality gap — with a real coding-craft instruction set adapted to our Go toolset. - internal/agent/system_prompt.md (embedded): identity, autonomy/persistence (bias for action, end-to-end), workflow (understand -> plan -> implement -> verify -> summarize), editing discipline (prefer native read/grep/glob/edit/apply_patch over shell, one tool call per file, minimal diffs, match style), a MANDATORY testing gate (run validators after edits; never claim done while failing), tool-use guidance, communication/markdown style. - internal/agent/system_prompt.go: buildSystemPrompt(options) assembles core + dynamic workspace context (: cwd, git branch, first AGENTS.md/ZERO.md/.zero/AGENTS.md doc, 8KiB-capped) + the existing safety confirmation policy. Omitted when cwd is unset, keeping headless/test runs deterministic. - loop.go: prompt decls moved to system_prompt.go; callsite passes options so cwd reaches the builder. Tests: updated the two prompt assertions; added TestBuildSystemPromptInjectsWorkspaceContext. build/vet/-race/full-suite + GOOS=windows build green; no new deps. * Resolve relative worktree gitdir against cwd in gitBranchForPrompt In worktree mode .git is a file like 'gitdir: ../.git/worktrees/' with a RELATIVE path; the code joined it directly so HEAD lookup resolved against the process working directory and failed, dropping the Git branch from the workspace context. Now non-absolute gitdir paths are resolved against cwd. Adds TestGitBranchForPromptResolvesRelativeWorktreeGitdir (fails without the fix). build/-race/full-suite + GOOS=windows build green. --------- Co-authored-by: KRATOS Provider: prompt caching on system + tools, with cache-token usage (#130) Caches the stable per-run prefix (system prompt + tool definitions) so it is not re-billed or re-processed every turn — a direct latency and token-cost win on multi-turn sessions, which matters now that the system prompt is substantial. - types.go: system can be sent as []systemBlock (Anthropic accepts string or blocks); add cacheControl + cache_control on the last system block and the last tool definition (two cache breakpoints covering system + tools); add cache_read_input_tokens / cache_creation_input_tokens to the usage struct. - provider.go: buildRequest emits the cacheable system block + tags the last tool; stream usage now captures cache read/creation tokens. Anthropic reports input_tokens (uncached), cache_read, and cache_creation separately, but the runtime models cached as a subset of input (clamps cached<=input), so we report the FULL prompt as InputTokens and the cache hits as CachedInputTokens. - Caching is GA on anthropic-version 2023-06-01 (no beta header); non-caching providers are unaffected. Tests: updated the request-shape assertions (system block + tool cache_control); added TestStreamCompletionReportsCacheTokens. build/vet/-race/full-suite + GOOS=windows build green; no new deps. Co-authored-by: KRATOS CLI wiring: skills/zeroline commands, --mode presets, exec checkpoints, sandbox --effective, sessions rewind (#128) * CLI wiring: skills/zeroline commands, --mode presets, exec checkpoints, sandbox --effective, sessions rewind Final module of the runtime-core split. Wires the new runtime features into the cli, PRESERVING main's merged specialist cli (the source branch predates specialist and drops it; this keeps it via a 3-way merge from the common base, adding the reskin features as ours/theirs-only changes). - skills command (internal/cli/skills.go) listing internal/skills; zeroline-skin TUI launcher (internal/cli/zeroline.go) via runInteractiveTUIWithSkin. - exec: --mode presets (smart/deep/fast/large/precise) via applyExecMode; model-registry alias resolution + deprecation/effort notices; ContextWindow sizing (modelContextWindow); before-mutation checkpoint recording in OnToolCall; enhanced tool-result output (ChangedFiles/Redacted/Display); permission-mode validation fix. - sandbox policy --effective (resolved guards); sessions rewind (executes the rewind plan); interactive TUI defaults to Ask mode (advertises write/edit/bash + gates via prompts). - internal/streamjson: EventCheckpoint/EventRestore + CheckpointInfo for structured checkpoint output. Specialist cli preserved (specialist.go + flags + runtime + tests all intact). build/vet/-race/full-suite + GOOS=windows build green; no new deps. * Address #128 review: make sessions rewind reachable + honor --exclude-target, mode/notice/redaction fixes - sessions: add "rewind" to the parser whitelist (isSessionsCommand) — it was wired in dispatch + help but parseSessionsArgs rejected it as 'unknown sessions command', so it was unreachable. Regression test TestRunSessionsRewindIsReachableAndHonorsExcludeTarget. - sessions rewind: honor --exclude-target — ApplyRewind keeps THROUGH a sequence, so when KeepTarget is false apply through TargetSequence-1, matching rewind-plan (previously rewind always applied TO the target, disagreeing with the plan). The test asserts --exclude-target keeps fewer events than the default. - sessions rewind: redact the session id in the success + not-found messages (consistent with every other human-readable path). - exec_parse: reject empty inline --mode= via requiredInlineFlagValue (was a silent no-op running defaults). - exec: notice writes (model-deprecation + reasoning-effort advisories) now treat write failures as exitCrash, consistent with the rest of the command. build/vet/-race/full-suite + GOOS=windows build green. --------- Co-authored-by: KRATOS TUI shell: ask_user modal, pickers, autocomplete, zeroline skin (#127) * TUI shell: ask_user modal, pickers, autocomplete, zeroline skin Lands the interactive TUI shell on main. (The earlier PR for this content targeted the stacked agent-runtime branch and never reached main, which already has the agent runtime via #125; this re-targets the same reviewed content onto main.) - ask_user: interactive questionnaire modal wired to agent.OnAskUser via an answer channel; transcript rows + session payloads; degrades gracefully without an interactive surface. - compaction sizing: modelContextWindow() sets the agent ContextWindow from the active model's registry entry (unknown models -> 0, compaction off). - pickers (model/theme/effort/mode) + slash-command autocomplete overlay; zeroline skin rendering (boot/home/chat) behind Options.Skin. - checkpoint recording: before each mutating tool the run batches an EventSessionCheckpoint in order and flushes it at end-of-run and on cancel; /rewind reloads in-memory session state so dropped events don't re-reach the agent. Exposes sessions.SnapshotForCheckpoint with an orphan-blob safe-usage contract for the batch-in-order path. build/vet/-race/full-suite + GOOS=windows build green; 116 tui tests; no new deps. * Address #127 review (CodeRabbit): validate checkpoint session id, fix mode toggle, harden ask_user/rewind/flush - sessions.SnapshotForCheckpoint validates the session id (ValidSessionID), matching CaptureToolCheckpoint, so an exported caller can't route blob writes through an invalid session path. - nextPermissionMode: Unsafe now folds to Ask (the stricter landing), not Auto — toggling an Unsafe session must never make it less strict. Test added. - ask_user: a request with zero questions resolves immediately instead of opening a prompt that stalls the run. - cancelled-run flush: surface appendSessionEvents persist-failure rows instead of discarding them (a silently-failed checkpoint flush would degrade /rewind). - /rewind: handle post-rewind reload failures explicitly — on ReadEvents error, clear in-memory context and report, so stale rewound-away events can't reach the next prompt. - transcript dedupe: key ask_user rows on row.id (survives rehydration when row.askUser is nil). - ask_user Esc test: replaced the empty branch with a real assertion (run stays pending after Esc cancels only the prompt). build/vet/-race/full-suite + GOOS=windows build green. * Address #127 re-review: mirror normal flow for zero-question ask_user The zero-question fast path now records the (empty) request in the transcript and answers with an empty slice ([]string{}) instead of nil, so it matches the normal resolveAskUser flow and downstream sees a consistent Answers shape. (Skipped the suggested 'defer Ctrl+C quit until flush failures are acknowledged': blocking quit on Ctrl+C contradicts prompt-exit expectations, is a non-minimal UX change, and the edge is rare with graceful impact — the failure rows are already appended for the common non-exit cancel, and a missed checkpoint only makes that one /rewind unavailable.) --------- Co-authored-by: KRATOS Agent runtime: ask_user interception, context compaction, guardrails (#125) * Agent runtime: ask_user interception, context compaction, guardrails Ports the agent-runtime slices from the reskin branch onto main, MINUS the blueprint's own sub-agent/task interception (main already has the merged specialist Task implementation, which this preserves). - ask_user (S6): AskUserRequest/AskUserResponse/AskUserQuestion types + Options.OnAskUser; the loop intercepts ask_user and routes to the interactive front-end, degrading gracefully (non-interactive fallback) when no handler is set. Answers are scrubbed through the redaction boundary. - compaction (S8): Options.ContextWindow + CompactionPreserveLast; proactive compaction when the estimated history crosses a fraction of the window, and reactive recovery on context-limit errors (summarizes the old middle, keeps system + last N). ContextWindow=0 disables it, so every existing caller/test behaves identically. - guardrails: empty-turn stop, repeated-tool-failure hint/stop, and a stale plan reminder; an aborted-placeholder is appended for unexecuted tool calls on guard stop. confirmation_policy.md is embedded into the system prompt. Preserved from main's specialist work: the RunWithOptions tool-context fields (ToolCallID/SessionID/Model/ReasoningEffort/Depth/Cwd), propertyToRuntimeMap nested Items, and ToolAdvertised AdvertiseInAuto. Dropped Options.Provider (it existed only for the blueprint task; compaction uses Run's provider arg). build/vet/-race/full-suite + GOOS=windows build green; no new deps. Unblocks the TUI shell + cli wiring PRs. * Address #125 review (CodeRabbit + @Vasanthdev2004): scope failure hint, fix ask_user fallback/cancel - Repeated-failure guard now fires only on RETRIABLE tool errors (bad args / execution failures), not policy refusals (disabled tool, permission denial, sandbox violation) — a 'match this schema' hint there misdirects the model or nudges it to retry blocked behavior. New isRetriableToolError classifier (keys off permission_action meta + the known policy-error messages). Test: TestIsRetriableToolError. - Headless ask_user fallback now goes through registry.RunWithOptions with the full run context (ToolCallID/SessionID/Model/ReasoningEffort/Depth/Cwd, sandbox/permission) and copies the full result fields (Meta/ChangedFiles/Display), so it behaves like every other tool path instead of the bare registry.Run that dropped them. - A canceled / timed-out ask_user prompt (OnAskUser returns context.Canceled/DeadlineExceeded) now ABORTS the run and returns that error instead of fabricating a synthetic 'no interactive user' answer and continuing to mutate the transcript. executeToolCall/executeAskUser return an abort error that the loop honors (closing out remaining advertised calls for replay validity). Test: TestRunAskUserCancellationAbortsRun. - compaction_test: corrected the comment to match the assertion (retried text must NOT be re-streamed to OnText). build/vet/-race/full-suite + GOOS=windows build green. * gofmt internal/agent/guardrails_test.go The file carried over from the reskin branch was not gofmt-clean; format it so the gofmt/lint check is green. --------- Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) MCP hardening: nested-schema passthrough + hung-server timeout (#124) Module of the runtime-core split (off main). internal/mcp had no drift on main, so this is a clean extract of the zenline MCP improvements. Deps (config, tools) are on main; uses tools.PropertySchema.Properties/Required from #119. No new deps. - schema.go: serialize nested object Properties/Required (and object-typed Items) when converting tools.PropertySchema <-> MCP JSON schema, so tools with nested args are advertised faithfully to/from MCP hosts instead of being flattened (audit H6). - client.go/server.go: bound MCP stdio handshake/calls so a hung or non-responsive MCP server can't block the caller forever; surface a timeout instead (audit H9). hang_test.go covers it. - registry.go: recognize the hardened client/schema paths; registry_test/schema_test added. build/vet/-race/full-suite + GOOS=windows build green. Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) Skills & plugins: skill tool, skills loader, plugin manifests (#123) * Skills & plugins: skill tool, skills loader, plugin manifests Module of the runtime-core split (off main, after #119 tools merged). Clean extract — internal/skills is new; internal/plugins had no drift on main. No new deps. - internal/skills: loads */SKILL.md frontmatter from the skills dir (resolves its own XDG dir); no YAML dependency. - skill tool (internal/tools/skill.go), read-only, registered in CoreReadOnlyTools (so it's in the agent core + the MCP read-only default). Path-safe: it Load()s all skills and matches by exact name — it never builds a path from the model-supplied arg, so there is no traversal surface. knownToolNames gains "skill" for the specialist cross-package invariant; CoreReadOnlyTools count test bumped 4->5. - internal/plugins: plugin manifest metadata enrichment (Author/License/Keywords/Interface). FORWARD code — the cli plugin-loading wiring lands later. MCP hardening (nested-schema + hung-server fix) is a SEPARATE follow-up PR. build/vet/-race/full-suite + GOOS=windows build green. * Address #123 review (CodeRabbit): pointer metadata, skill schema alias, dir fallback, List test - plugins: Author/Interface are now *PluginAuthor/*PluginInterface so omitempty actually omits them — a non-pointer struct is never empty to encoding/json, so the old form emitted author:{}/interface:{} and changed the serialized shape of plugins that don't set them. parseAuthor/parseInterface return nil when all fields are empty; formatAuthor takes the pointer. (Major) - skill: schema now declares the 'skill' alias alongside 'name' (and drops strict Required) so the alias survives schema validators that reject unknown keys (AdditionalProperties:false); Run still enforces exactly-one-of via aliasedStringArg. (Major) - skills.DefaultDir: return "" instead of a relative ".local/share/..." path when neither XDG_DATA_HOME nor a resolvable HOME exists, so skills can't bind to the process CWD (load("") already no-ops). (Minor) - skills_test: TestListReturnsNamesAndDescriptions now asserts List strips Content so a listing can't silently leak full skill bodies. (Minor) build/vet/-race/full-suite + GOOS=windows build green. * Address #123 review (@Vasanthdev2004): confine skill SKILL.md to the skills root skill is a permission-allow read-only core/MCP tool, so the loader must not follow a symlinked SKILL.md (or skill dir) out of the skills root and become an arbitrary-file reader. load() now resolves the skills root via EvalSymlinks and, for each entry, resolves SKILL.md through symlinks and verifies the real path stays under the root (confineSkillPath); an escaping or unreadable path is skipped rather than read. Mirrors the grep/read_file confinement. Legit skills under a symlinked root (e.g. macOS /tmp) still load because the root is resolved too. Regression: TestLoadSkipsSymlinkedSkillFileEscapingRoot (a SKILL.md symlinked to a secret outside the root is skipped, not read). build/vet/-race/full-suite + GOOS=windows build green. * Address #123 re-review (CodeRabbit): reject non-regular SKILL.md, single-trim metadata - skills: confineSkillPath now Lstats the resolved path and skips non-regular targets (directory/FIFO/device/socket). A FIFO/device named SKILL.md (or an in-root symlink to one) would otherwise make os.ReadFile block indefinitely — skill is a permission-allow tool over a user-controlled dir. Regression: TestLoadSkipsNonRegularSkillFile (unix-tagged; mkfifo SKILL.md, asserts Load skips it within a timeout rather than hanging). (Major) - plugins: coerceMetaStringSlice trims each item once into a local var instead of calling strings.TrimSpace twice. (Nitpick) build/vet/-race/full-suite + GOOS=windows build green. --------- Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) Add guarded web fetch tool (#121) * feat: add guarded web fetch tool * fix: harden web fetch DNS validation * fix: tighten web fetch SSRF guardrails Add specialist lifecycle registry test (#122) Tools: arg-tolerance, ask_user, secret scrubbing, structured results, hardening (#119) * Tools: arg-tolerance, ask_user, secret scrubbing, structured results, hardening First module of the tools->agent->tui chain that leads to the TUI shell (off main). Scope: internal/tools (+ a 1-line specialist vocabulary update). 3-way merged onto main's specialist drift (RunOptions tool-context fields, Safety.AdvertiseInAuto, optionsAwareTool dispatch, PropertySchema, base structured Result) and grafted the runtime-core additions: - Shared arg-tolerance layer (argtolerance.go: alias-aware/type-strict arg resolution + slice coercion) adopted across all tools so weak models' non-canonical arg shapes are accepted; bool/int coercion in args.go. - ask_user tool (interactive clarifying questions) wired into CoreReadOnlyTools; nested PropertySchema (Properties/Required) for its object questions + update_plan. - Secret scrubbing at the registry boundary (scrubResultSecrets via internal/redaction) applied to every tool-execution path, incl. the optionsAwareTool dispatch; structured Result (Display/Redacted/ChangedFiles). - mutation_targets (paths a mutating tool will write, for session checkpointing) + hardened grep (symlink confinement), apply_patch, bash, edit/write. EXCLUDED per the module split: the blueprint task tool (subagents — another dev's area; the merged specialist Task is separate) and the skill tool (ships with the later skills module); plus their subagent-only registry helpers (Without/isolateForChild). ask_user is now a core tool, so internal/specialist knownToolNames adds "ask_user" to keep the TestKnownToolNamesMatchCoreRegistry cross-package invariant. No new deps; build/vet/-race/windows/full-suite green. * Address #119 review (CodeRabbit): scrub Display, validate apply_patch mutation paths, narrow test skips - registry.go: scrubResultSecrets now redacts Result.Display.Summary too (not just Output), so a caller preferring Display can't bypass the boundary redaction. - mutation_targets.go: apply_patch targets now run through validatePatchPaths(applyRoot, ...) before being returned, so a traversal patch (../x) can't yield an out-of-workspace checkpoint target. Regression case added to TestMutationTargetsRejectsEscapingPaths. - argtolerance_test/write_tools_test: the apply_patch tests no longer Skipf on ANY non-OK result (which masked real diff/apply regressions); they skip ONLY when the git binary is unavailable (gitApplyUnavailable helper checks exec's 'executable file not found') and otherwise Fatalf. build/vet/-race/full-suite + GOOS=windows build green. * Address #119 re-review (CodeRabbit): best-effort multiSelect + Display.Summary scrub coverage - ask_user: an uncoercible multiSelect no longer fails the whole call — it's a UI hint, so it defaults to false (best-effort, mirroring the lenient options path). Test: TestParseAskUserQuestionsLenientOptions extended. - registry_test: TestRunWithOptionsScrubsSecretsForAllCallers now also leaks a secret via Result.Display.Summary and asserts it is redacted, locking in the boundary scrub for the structured-result path (secretTool gained a display field). build/vet/-race/full-suite + GOOS=windows build green. * Address #119 review (@Vasanthdev2004): scrub ALL registry return paths + harden intArg [blocker] RunWithOptions now scrubs every return path, not just tool-execution: a named return + a single 'defer scrubResultSecrets(result)' covers the unknown-tool, sandbox-deny, sandbox-approval-required, permission-required and permission-denied early returns too. Previously a sandbox denial that echoed a secret-bearing path/arg returned it raw with Redacted=false. Regression: TestRunWithOptionsScrubsSecretsOnDenialPaths (permission-denied reason carrying a token -> scrubbed + Redacted). [non-blocking] intArg fails closed on NaN/Inf/out-of-range floats (and their string forms) via a floatToInt helper, before any implementation-defined cast. Tests added for NaN/Inf/NaN-string/non-integral. build/vet/-race/full-suite + GOOS=windows build green. * Address #119 review (CodeRabbit grep TOCTOU + anandh8x dependency blockers) - grep: close the confinement TOCTOU — confineGrepFile now returns the symlink-RESOLVED path and collectGrepMatches reads THAT (not the raw candidate), so a symlink swapped in between check and read can't escape the workspace. - ask_user: removed from CoreReadOnlyTools. It needs the agent loop's interactive intercept (OnAskUser), which is in the (unmerged) agent module; without it the tool only returns the non-interactive fallback. The tool still ships (NewAskUserTool); the agent module registers it in core + wires the intercept together. Reverted the specialist knownToolNames 'ask_user' entry + the ask_user-in-core test accordingly. - update_plan: reverted the plan schema to a flat array (item structure documented in the description). The structured nested-object Items schema depends on the agent's PropertySchema serializer (propertyToRuntimeMap) passing nested Properties/Required through to providers, which is in the agent module; a nested schema here would be silently dropped. PropertySchema.Properties/Required fields are kept (used by ask_user.go's own schema + the later MCP module). build/vet/-race/full-suite + GOOS=windows build green; no new deps. * Harden #119 from adversarial self-review: sandbox alias-gate, Meta scrub, intArg 2^63 A multi-dimension adversarial review of the tools diff surfaced 3 real issues beyond the reviewer rounds: - [major/security] write_file/edit_file path aliases file_path/filename/filepath bypassed the sandbox workspace+symlink gate: sandbox.requestPaths only inspected path/cwd/file/dir, so an aliased target skipped enforcement (same class as audit H4). Aligned requestPaths with the tools' path-alias lists (internal/sandbox/risk.go). The tools' own resolveWorkspaceTargetPath still confined, but the sandbox LAYER/audit/symlink-check was silently skipped. Regression: TestRegistrySandboxGatesPathAliasKeys. - [minor] scrubResultSecrets skipped Result.Meta, which is forwarded to the transcript and carries model-controlled strings (glob pattern, bash cwd). Now scrubs Meta values too. Covered in TestRunWithOptionsScrubsSecretsForAllCallers. - [minor] floatToInt used f > float64(MaxInt); that constant rounds up to 2^63 so exactly 2^63 slipped to an out-of-range cast. Changed to >=. Test added for 2^63 float+string. build/vet/-race/full-suite + GOOS=windows build green; no new deps. --------- Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) Add specialist lifecycle accounting (#120) * Add specialist lifecycle accounting Record specialist start and stop events in parent sessions and roll child stream-json usage into one parent usage event. Background TaskOutput performs the same roll-up idempotently for persisted completed tasks. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/specialist Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Harden npm wrapper smoke test on Windows Clear inherited NODE_OPTIONS when executing the Node wrapper fixture and allow a longer Windows timeout for hosted-runner cold starts. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/npmwrapper ./internal/specialist Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Address specialist accounting review feedback Normalize mixed usage event totals before roll-up, preserve started child exit codes on execution errors, and make accounting payload assertions fail loudly on missing or mistyped fields. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/specialist ./internal/npmwrapper Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check Persist background specialist tasks (#118) * Persist background specialist tasks * Harden background task reload Add specialist authoring tools (#117) * Add specialist authoring tools * Harden specialist authoring boundaries Harden specialist background lifecycle (#116) * Harden specialist background lifecycle * Preserve killed background task state TUI rendering (zeroline): glamour markdown, syntax highlighting, themed surfaces (#114) * TUI rendering (zeroline): glamour markdown, syntax highlighting, themed surfaces Module of the runtime-core split (off main). Renamed the reskin package from the working name 'zenline' to **zeroline** (dropping the zenline name; this becomes ZERO's default polished terminal surface). Scope: internal/zeroline only — pure presentation, no internal deps. - glamour-rendered markdown (with a bounded mdCache to avoid per-frame re-render cost) and chroma syntax highlighting (transitive via glamour). - colored diffs, full-bleed background, a Zen home page + a vim/powerline-style statusline chat page sharing 5 switchable color themes (theme.go). - callers (the TUI model) build the data structs from live agent state; this package turns them into styled frames. It is FORWARD code — wired by the TUI module in a later PR; exercised here by render/markdown tests. Deps: adds github.com/charmbracelet/glamour v1.0.0 (pulls chroma/v2, goldmark, bluemonday, x/net, x/term transitively; lipgloss/bubbletea/termenv already present). go.sum updated via go mod tidy; build/vet/-race/full-suite green; no behavior change to existing packages. * Address #114 review (CodeRabbit + @anandh8x + @Vasanthdev2004): TUI render-contract fixes - AskUser suppresses overlays like Perm does: overlayRegion now returns early on d.AskUser != nil so suggestions/picker rows can't consume bodyH and push the focused questionnaire offscreen. Test: TestOverlaySuppressedDuringAskUser. - Permission modal narrow-width: derived permMinBoxWidth=47 (43-cell button row + border/padding); PermLayout disables the hitboxes when the box is narrower (buttons would overflow/clip and stop matching), mirroring the vertical-clip guard. Rewrote TestPermLayoutMatchesRenderClamped: narrow widths -> inactive, wide+height-clamped -> lockstep. - wrap() now budgets by display width (lipgloss.Width) instead of byte length, so CJK/emoji don't break the width budget. Test: TestWrapBudgetsByDisplayWidth. - Plain/streaming path now clips each wrapped prose line: wrap doesn't break a single over-long token (long URL / unbroken CJK), so renderAssistantPlain clips wl to the budget. Test: TestPlainAssistantClipsLongUnbrokenToken. - Streaming verbatim test strengthened to assert the literal 'partial **incomplete' survives (proves glamour is not applied while streaming). build/vet/-race/full-suite + GOOS=windows build green. * Address #114 re-review (@Vasanthdev2004): keep narrow perm modal + all chrome within the frame - permModalLines: when bw < permMinBoxWidth (too narrow for the 43-cell button row) it no longer renders the full button row (which overflowed the modal frame); it renders a compact keyboard-only hint clipped to the content width. Render and hit-test now stay aligned: PermLayout already disables the mouse hitboxes at this width. - RenderChat: added clampFrameWidth — a final frame-safety pass that clips every composed line to the frame width, so chrome (top/bottom bars) and the modal/body can never overflow the terminal at narrow widths (fixes the header/statusline-footer overflow too). Width-aware + ANSI-safe; a no-op at normal widths. - Regression test TestPermModalFitsFrameAtNarrowWidth: RenderChat at Width:40 with a Perm prompt asserts every line stays within 40 cells while PermLayout(40,24).Active == false. build/vet/-race/full-suite + GOOS=windows build green. * fix: apply CodeRabbit auto-fixes Fixed 2 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --------- Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit Add specialist background task tools (#115) * Add specialist background task tools * Address specialist background review feedback Session checkpoints & safe rewind: content-addressed blobs, atomic restore, file locking (#112) * Session checkpoints & safe rewind: content-addressed blobs, atomic restore, file locking Module 4 of the runtime-core split. Scope: internal/sessions only. 3-way merged cleanly onto main's #106 Tag/Depth work (preserved); no new deps (filelock uses stdlib syscall); build/vet/-race/windows-cross-compile/full-suite green. - CaptureToolCheckpoint snapshots a tool's target files into content-addressed (sha256) blobs before a mutating tool runs; SnapshotForCheckpoint records file mode; oversize files are skipped, identical content deduped. - RestoreToSequence reverts to a target sequence: applies only the closest-to-target checkpoint per path, verifies each blob's sha256 before writing, preserves file mode, and confines paths with EvalSymlinks (no symlink escape). ApplyRewind runs restore+truncate+prune+marker atomically under one session lock. - Cross-process file lock (flock) serializes session mutations; Fork copies checkpoint blobs so a fork can rewind; writeMetadata uses a unique tmp suffix. FORWARD: this is the persistence layer; the CLI 'zero sessions rewind' and the TUI '/rewind' + pre-tool checkpoint capture wiring land in later modules. Exercised here by checkpoint_test.go / rewind_test.go. * Address #112 review: capture-side confinement, atomic-only checkpoint API, Windows lock, test hygiene - [critical/human blocker] snapshotForCheckpoint now confines every capture target with resolveWithinWorkspace (same EvalSymlinks/no-".." guard as restore) BEFORE stat/read, so a traversal/symlink target can't read files outside the workspace into a blob. os.Stat failures are classified correctly: only os.IsNotExist -> Absent (restore deletes); permission/IO/symlink-loop -> Skipped (restore leaves alone), instead of treating every failure as Absent. Regression test added (TestCaptureRejectsTraversalTargets). - [major] SnapshotForCheckpoint unexported -> snapshotForCheckpoint: the blob writes and the referencing EventSessionCheckpoint must be committed atomically under one session lock, which only CaptureToolCheckpoint guarantees. Removes the unsafe snapshot-only external entry point (blob/event gap). - [major] Windows: real LockFileEx/UnlockFileEx (filelock_windows.go) replacing the no-op, so cross-process/cross-Store session serialization holds on Windows too. No new deps (x/sys already required for the unix flock). - [minor] checkpoint_test hygiene: check os.WriteFile/ReadDir/ReadFile/ReadEvents errors via helpers, add bounds checks before p.Files[0]/events[...] so setup failures fail at the source instead of panicking or passing spuriously. - rewind restore: documented the residual symlink-swap TOCTOU (boundary re-resolved immediately before the mutation; full closure needs openat2 RESOLVE_BENEATH / per-component O_NOFOLLOW, tracked for the CLI/TUI rewind-wiring work). build/vet/-race/full-suite + GOOS=windows build all green. * Address #112 re-review: fail rewind on undecodable checkpoint payload [major] restoreToSequenceLocked silently continued past a checkpoint whose payload failed to decode, which let a NEWER checkpoint win for that path and restore a later state than the caller requested. Treat corruption as a hard rewind error. Regression test TestRestoreFailsOnCorruptCheckpointPayload (fails-open before, errors now). Re-review note: the earlier capture-target-validation (critical) and test bounds (minor) were already addressed in 600337b (resolveWithinWorkspace guard + len checks); those re-anchored threads are stale. The restore-side symlink-swap TOCTOU remains a documented heavy-lift residual (needs openat2/descriptor traversal), tracked for the rewind-wiring work. * Address #112 re-review: dedupe rewind on resolved path, not raw path [major] restoreToSequenceLocked deduped the per-path closest-to-target short-circuit on the RAW checkpoint path, so equivalent paths (./a.txt, dir/../a.txt, a symlink alias) keyed differently and a NEWER checkpoint could overwrite the closest-to-target snapshot for the same underlying file. Now resolve/confine FIRST and dedupe on the resolved workspace path (unresolvable targets dedupe on raw path). Regression test TestRestoreDedupesEquivalentRawPaths. This + the prior commit (24e9ba2, fail-hard on undecodable payload) address both correctness blockers @Vasanthdev2004 raised after CodeRabbit's pass. build/vet/-race/full-suite + GOOS=windows build green. --------- Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) Add specialist Task runtime (#113) * Add specialist Task runtime * Harden specialist resume identity Add specialist execution foundation (#111) * Add specialist execution foundation * Normalize specialist session ids Sandbox hardening: destructive/network/installer classification + interactive-command detection (#109) * Sandbox hardening: destructive/network/installer classification + interactive-command detection Module 3 of the runtime-core split (off main, after #107). Scope: internal/sandbox only — clean additive extract; no new deps; whole tree builds + full suite/-race green. LIVE (Classify is already called by sandbox.Evaluate and agent/loop.go, so this hardens existing behavior immediately): - rm -rf targeting / $HOME ~ * — now tolerant of surrounding quotes, ${HOME} braces, combined/reordered -r/-f flags, and an optional -- separator; chmod 777 only flagged when recursive or root-targeted (single-file chmod no longer false-positives). - network-command + piped-installer detection (curl|sh incl |zsh and other shells); command resolved across command/cmd/script/shell aliases so an alias key can't bypass the gate. FORWARD (compiles now, wired by the later tools/bash module): DetectInteractiveCommand flags interactive programs (vim/less/...) behind wrappers (sudo/nice/timeout, and sh -c/bash -c payloads), hardened against absolute-path/quote/escape/substitution bypasses. * Address #109 review (CodeRabbit): fix sandbox detection over/under-reach - [critical] rm long-flag root: --no-preserve-root -rf -- "/" and ... -rf "/" now caught (quote + -- handling restored across short AND long flags). - chmod 777 abs-path narrowed to root or a sensitive SYSTEM tree (/, /etc, /usr, …); a single-file abs path like chmod 777 /tmp/build.sh is no longer a false positive. - piped_installer now requires a remote fetch (curl/wget/fetch/aria2c) before the pipe; a local 'cat x | bash' / 'printf | sh' is no longer mis-flagged (engine_test updated to assert the corrected semantics). - nonInteractiveREPLFlags adds mongo/mongosh (--eval/-f/--file) so 'mongo --eval ...' isn't flagged interactive. - programIndex now normalizes tokens (basename/quote-strip/lowercase) like firstProgram, so full-path invocations (/usr/bin/python script.py, /bin/bash -c 'vim …') classify correctly (no false positives / missed nested detections). TDD for each; build/vet/-race/windows cross-compile/full-suite green. --------- Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) Add specialist extends resolution (#110) Add background task manager (#108) * Add background task manager * Fix background manager test on Windows * Guard background task kill races Model registry: deprecation fallback, mode presets, reasoning-effort, alias patterns (#107) Module 2 of the runtime-core split (off main, after #105). Scope: internal/modelregistry only — fully additive (the whole tree builds unchanged against it). - Regex MatchPatterns aliases + pattern-based Resolve. - DeprecationRule with FallbackID; ResolveWithFallback returns the active fallback model + a user notice for deprecated ids; NewRegistry validates every Deprecation.FallbackID resolves (fail-fast on misconfig). - ReasoningEffort support: per-model ReasoningEfforts + DefaultReasoningEffort (validated to be a member of the model's set) + EffectiveReasoningEffort(). - Mode presets (modes.go: smart/deep/fast/large/precise -> model id + reasoning effort). These are the registry-capability layer; the CLI/agent adoption (--model deprecation-fallback, --mode, -r/--reasoning-effort, enhanced 'zero models') lands in a later module to keep PRs reviewable. Build/vet/full-suite/-race green; no new deps. Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) Add specialist lifecycle metadata (#106) Streaming reliability: SSE idle-watchdog, dropped-call handling, finish_reason, usage (#105) * Streaming reliability: SSE idle-watchdog, dropped-call handling, finish_reason, usage First reviewable slice of the runtime-core work (split out of the large integration branch). Scope: internal/zeroruntime + internal/providers only — fully backward-compatible/additive (the rest of the tree builds unchanged against it). - SSE idle-timeout watchdog (reader goroutine + idle timer + ctx-cancel) across OpenAI/Anthropic/Gemini via providerio.ScanSSEDataWithContext, so a stalled-but-open upstream no longer blocks the agent forever. - Streamed tool-call assembly hardened: nameless/dropped tool calls reported (StreamEventToolCallDropped), start-order preserved, distinct empty-id calls no longer merge. - Terminal finish/stop reason surfaced (CollectedStream.FinishReason + Truncated()) so length/content-filter responses aren't mistaken for normal completions; OpenAI requests stream_options.include_usage (token usage no longer 0); OpenAI delegates error/redact to providerio (503/529 classified) and joins multi-line SSE data; max_completion_tokens wired. Build/vet/full-suite/-race green. * Address #105 review (CodeRabbit): ctx honored when idle watchdog off; cancel during retry backoff; test robustness providerio: ScanSSEDataWithContext now runs the goroutine+select loop even when idleTimeout<=0 (idle case via a nil channel), so ctx cancellation is honored with the watchdog disabled (was a blocker: it short-circuited to the non-ctx-aware ScanSSEData). openai: a ctx cancellation during 5xx retry backoff is reported as the cancellation instead of being misclassified as the upstream 5xx. Tests: finish-reason/tool-call tests now assert the done/start event is actually emitted (not vacuously pass); new providerio regression test asserts cancel is honored with idleTimeout=0. Build/vet/-race green. --------- Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) feat(specialist): add exec metadata flags (#104) * Add specialist exec metadata flags * Address exec metadata review feat(specialist): add profile management (#100) * Add specialist profile management * Address specialist profile review * Address specialist validation review Polish update target output (#99) feat(tui): polish command output (#98) * feat(tui): polish command output * fix(tui): address command output review feat(update): verify release metadata by target (#97) * Add update check target selection * Reject empty update target flag feat(update): expose release check target options (#96) * Expose update check target options * Reject non-positive update timeouts feat: add runtime permission decisions (#95) Add sandbox backend capability reporting (#94) feat(zerocommands): add typed sandbox policy, risk, violation, and decision snapshots (#92) * feat(zerocommands): add typed sandbox policy, risk, violation, and decision snapshots The merged permission and verification event contracts (#82) added typed snapshots for config, providers, models, and sessions, and PR #89 added the typed snapshot for the persistent sandbox grant store, but the live sandbox policy, the live risk classification, the live violation, and the live decision were still exposed as the raw backend structs. The TUI render path, the headless 'zero sandbox policy --json' and 'zero sandbox decide' commands, the audit log, and PR/CI automation all need typed snapshots so they do not hand-roll map[string]any payloads and so they do not have to re-implement the runtime default-mode translation themselves. This commit adds five typed snapshot constructors and a bundled plan snapshot to internal/zerocommands. SandboxPolicySnapshot - mode, network, enforceWorkspace, denyDestructiveShell, and allowPolicyOnlyRunner are copied verbatim from the underlying sandbox.Policy. - effectiveMode is the resolved policy mode. An empty Mode in the input falls back to ModeEnforce, which is the runtime default. Consumers can read EffectiveMode to render the 'what is actually in effect' line without re-implementing the default. - effectiveMode is JSON-omitted when empty so the snapshot shape stays stable for downstream consumers. SandboxRiskSnapshot - level, categories, reason. - categories is copied and sorted alphabetically so the JSON output is deterministic. - reason is trimmed. SandboxViolationSnapshot - code, toolName, action, path, reason, recoverable. - The pointer-returning helper returns nil for a nil input so callers can chain it through SandboxDecisionSnapshot without nil checks at the call site. SandboxBackendSnapshot - name, available, platform, fallback, commandWrapping, nativeIsolation, executable, message. - executable and message are trimmed. SandboxPlanSnapshot - bundles policy, backend, restrictions, and workspaceRoot into one payload so 'zero sandbox policy --json' can return one typed result describing the full sandbox posture. - each entry of restrictions is trimmed, whitespace-only entries are dropped, and the result is sorted alphabetically. - workspaceRoot is trimmed and JSON-omitted when empty. SandboxDecisionSnapshot - action, reason, risk, grantMatched, grant, violation. - The optional grant pointer is converted with SandboxGrantSnapshotFromGrant (from PR #89) so the JSON shape matches the persistent-grant snapshot exactly. - The optional violation pointer is converted with SandboxViolationSnapshotFromViolation so the snapshot always carries the same shape regardless of the decision outcome. - When grant or violation is nil, the field is JSON-omitted. Tests - TestSandboxPolicySnapshotFromPolicyFillsEffectiveMode: verifies EffectiveMode is enforced for an empty Mode, set to ModeDisabled for an explicit disabled mode, and preserved for an explicit enforced mode. - TestSandboxRiskSnapshotFromRiskSortsCategoriesAndTrimsReason: verifies alphabetical sort, trimmed reason, and that a nil Categories input produces a nil output (no spurious empty slice). - TestSandboxViolationSnapshotFromViolationHandlesNilAndTrims: verifies the nil-input contract and the trim behaviour for every string field. - TestSandboxBackendSnapshotFromBackendCopiesAllFields: verifies the boolean and string fields round-trip and the trim behaviour for the executable and message. - TestSandboxPlanSnapshotFromPlanSortsRestrictionsAndTrimsRoot: verifies alphabetical sort, per-entry trim, and workspaceRoot trim. - TestSandboxDecisionSnapshotFromDecisionAllowBranchHasNoViolation: verifies the allow path has no grant and no violation. - TestSandboxDecisionSnapshotFromDecisionPersistentDenyCarriesGrantAndViolation: verifies the deny path carries both the resolved grant and the violation, and that the grant snapshot matches the persistent-grant snapshot shape from PR #89. - TestSandboxDecisionSnapshotJSONShapeIsStable: verifies the stable keys and the nil-pointer omission. - TestSandboxPolicySnapshotJSONOmitsEmptyEffectiveMode: verifies the omitempty tag. Verification - go build ./... - go vet ./... - go test -count=1 -p 1 ./internal/zerocommands/... \ ./internal/sandbox/... ./internal/redaction/... DRI: Gnanam (runtime core owner per WORK_SPLIT_PRD.md §4). Closes the typed-snapshot gap for the live sandbox surface that the TUI render path, the 'zero sandbox' headless commands, the audit log, and PR/CI automation consume. * fix(npmwrapper): make copyWrapperFixture create package.json with type:module This ensures the isolated wrapper .js fixture is always treated as ESM (matching real package installs). Previously, without it, Node treated the .js as CJS on some platforms/runners (especially Windows), causing SyntaxError before the missing-native console.error path. This was causing TestNodeWrapperReportsMissingNativeBinary to get empty output + timeout on windows-latest smoke jobs (e.g. in PR 92). The test now reliably hits the error path and captures the expected message on all platforms. Also improves robustness of the npm wrapper smoke test added in #88. --------- Co-authored-by: Gnanam Co-authored-by: KRATOS feat(tui): surface sandbox permission events (#93) * feat(tui): surface sandbox permission events * fix(tui): stream permission rows live feat(zerocommands): add typed MCP, hooks, and plugins snapshots (#90) * feat(zerocommands): add typed MCP, hooks, and plugins snapshots The merged permission and verification event contracts added typed snapshots for config, providers, models, sessions, and the sandbox grant store, but the MCP server config, hooks config, and loaded plugin manifests still expose only the raw backend structs. The TUI render path, the headless 'zero mcp', 'zero hooks', and 'zero plugins' commands, and PR/CI automation all need typed snapshots so they do not hand-roll map[string]any payloads and do not accidentally leak the secret material that lives in MCP server Env and Headers. This commit adds three typed snapshot constructors plus a bundled backend lifecycle snapshot to internal/zerocommands. MCPServerSnapshot - name, type, identity, url, command are copied verbatim and trimmed. They are non-secret metadata the operator needs to see when triaging an MCP failure. - argCount, envKeyCount, headerCount replace the slices and maps with counts. Secret material in Env and Headers is never serialized. - toolCount, allowGranted, denyGranted are optional runtime counts that the snapshot can carry when the caller has a live tool registry and a live permission store. A nil counts struct leaves the snapshot at zero. HookSnapshot - id, name, event, matcher, command, args, enabled, source. - command and args are run through the standard redaction pipeline. Whitespace-only args are dropped so JSON output is tight. PluginSnapshot - id, name, version, description, enabled, source, root, pluginDir, manifestPath are copied verbatim and trimmed so the operator can see where the manifest came from. - toolCount, promptCount, skillCount, hookCount replace the full extension slices with counts so the headless JSON output stays small. BackendLifecycleSnapshot - bundles the three slices into a single payload so 'zero doctor' and 'zero config' can return one typed result that describes the full extensibility surface. - all three inner slices are always non-nil, so JSON output is always '[]' and never 'null'. Tests - TestMCPServerSnapshotFromServerStripsSecretsAndCountsMaps: verifies env and header values never appear in the JSON output, and that the count fields record the maps' sizes. - TestMCPServerSnapshotWithCountsMergesRuntimeCounts: verifies the optional counts bundle is merged and that a nil bundle leaves the snapshot at zero. - TestMCPServerSnapshotsSortsAndReturnsEmptySliceForEmptyInput: verifies alphabetical sort and that nil input returns a non-nil empty slice whose JSON encoding is '[]'. - TestHookSnapshotFromDefinitionRedactsCommandAndTrimsFields: verifies command and args are redacted, and the surrounding fields are trimmed. - TestHookSnapshotsSortsByIDAndEvent: verifies the deterministic (id, event) ordering. - TestHookSnapshotsWithSourceTagsEverySnapshot: verifies the helper that tags every snapshot with the same source string. - TestPluginSnapshotFromPluginCollapsesSlicesToCounts: verifies the path fields are preserved verbatim and the count fields record the slices' sizes. - TestPluginSnapshotsSortsByIDAndReturnsEmptySliceForEmptyInput: verifies id-based sort and empty-slice stability. - TestNewBackendLifecycleSnapshotBundlesAndDefaultsNilsToEmpty: verifies the bundled payload, that nil inputs produce empty slices in the JSON, and that a populated input round-trips. Verification - go build ./... - go vet ./... - go test -count=1 -p 1 ./internal/zerocommands/... \ ./internal/mcp/... ./internal/hooks/... \ ./internal/plugins/... ./internal/redaction/... DRI: Gnanam (runtime core owner per WORK_SPLIT_PRD.md §4). Closes the typed-snapshot gap for the three backend surfaces in §7.E that the TUI render path, the headless commands, and PR/CI automation consume. * fix(zerocommands): strip URL credentials and preserve redacted arg position Addresses the three actionable review items left on PR #90 by CodeRabbit. 1. MCPServerSnapshotFromServer now strips userinfo from the URL field so credentials embedded in an MCP server URL (https://user:token@host) never reach the headless JSON output. The new stripURLCredentialsFromURL helper uses net/url.Parse to remove the User field, then returns the sanitized URL. A URL that fails to parse is returned trimmed (not empty) so the operator still sees the configured endpoint when triaging a malformed MCP configuration. A blank input returns blank. 2. redactStringSlice now preserves position. Previously the helper appended to the output slice and skipped any element whose redacted value was empty, which meant a fully-redacted secret in the middle of a hook command line would shift every following argument by one position. The helper now allocates the output slice to the input length and assigns each element by index, so a fully-redacted element becomes an empty string in the output rather than disappearing. Whitespace-only inputs also become empty strings by index, which matches the previous drop behaviour but without shifting positions. A nil or empty input still returns nil so the JSON output omits the field entirely. 3. Test coverage is extended for both changes: - TestHookSnapshotFromDefinitionRedactsCommandAndTrimsFields now asserts len(snapshot.Args) == len(def.Args) so a future regression that drops or shifts redacted args is caught. - TestHookSnapshotFromDefinitionPreservesPositionForRedactedArgs verifies that a secret in the middle of an arg list ends up as an empty-string element at the same index, while the surrounding non-secret args round-trip verbatim. - TestMCPServerSnapshotStripsURLCredentials verifies that an https://admin:secret123@host URL becomes the credential-free form and that the username, password, and the '@' separator do not appear anywhere in the snapshot. - TestMCPServerSnapshotPreservesURLWithoutCredentials verifies that a URL without userinfo passes through unchanged. - TestMCPServerSnapshotKeepsUnparseableURLInsteadOfEmpty verifies the tolerance contract: a malformed URL is returned trimmed rather than empty, so the operator still sees the configured endpoint when triaging a broken configuration. Verification - go build ./... - go vet ./... - go test -count=1 -p 1 ./internal/zerocommands/... \ ./internal/mcp/... ./internal/hooks/... \ ./internal/plugins/... ./internal/redaction/... DRI: Gnanam. --------- Co-authored-by: Gnanam Remove Bun installer test dependency (#91) * Remove Bun installer test dependency * Harden installer smoke tests * Stabilize npm wrapper subprocess tests * Make npm wrapper fixture explicit ESM feat(zerocommands): add typed SandboxGrantSnapshot contract (#89) The merged permission and verification event contracts added typed snapshots for config, providers, models, and sessions, but the persistent sandbox grant store (internal/sandbox/grants.go) still exposes only sandbox.Grant directly. The TUI render path, the headless 'zero sandbox' command, and PR/CI automation all need a typed snapshot so they do not hand-roll map[string]any payloads and do not accidentally leak grant reasons that contain API keys or tokens. This commit adds SandboxGrantSnapshot alongside the existing ProviderSnapshot, ModelSnapshot, SessionSnapshot, and SessionTreeSnapshot in internal/zerocommands. The snapshot mirrors the format of the existing typed contracts: - ToolName, Decision, MaxAutonomy, ApprovedAt are copied verbatim from the underlying grant. They are non-secret metadata. - Reason is run through the standard redaction pipeline before it is copied, so any secret material the user typed when granting is masked before the snapshot leaves the runtime. - All string fields are trimmed. An empty or whitespace-only Reason becomes an empty Reason in the snapshot so JSON output omits the field. Two constructors are provided: - SandboxGrantSnapshotFromGrant(grant) for a single grant. - SandboxGrantSnapshots(grants) for a slice. Output is sorted alphabetically by ToolName so consumers (TUI, headless, JSON output) see a deterministic ordering. An empty input returns a non-nil empty slice so JSON output is always '[]' and never 'null'. A matched/unmatched helper is also added: - SandboxGrantMatchSnapshotFromLookup(toolName, lookup) pairs the typed snapshot with a Matched boolean. When the lookup did not match, the returned snapshot has Matched=false and a nil Grant pointer, so consumers can render the absence without inspecting an error or a typed zero value. Tests - TestSandboxGrantSnapshotFromGrantRedactsReasonAndTrimsFields: verifies the secret in the reason is masked and the surrounding fields are trimmed. - TestSandboxGrantSnapshotFromGrantEmptyReasonBecomesEmpty: verifies whitespace-only reasons normalize to the empty string. - TestSandboxGrantSnapshotsSortsByToolNameAndReturnsEmptySliceForEmptyInput: verifies alphabetical sort and that nil input returns a non-nil empty slice whose JSON encoding is '[]'. - TestSandboxGrantMatchSnapshotFromLookupMatchedAndUnmatched: verifies the matched and unmatched code paths. - TestSandboxGrantSnapshotJSONShapeIsStable: verifies every JSON key is present so downstream consumers can rely on the shape. Verification - go build ./... - go vet ./... - go test -count=1 -p 1 ./internal/zerocommands/... \ ./internal/sandbox/... ./internal/redaction/... DRI: Gnanam (runtime core owner per WORK_SPLIT_PRD.md §4). Connects: 'zero sandbox' command rendering, TUI grants panel, and PR/CI automation consumers to the persistent grant store without leaking grant reasons. Co-authored-by: Gnanam Replace npm wrapper with Node entrypoint (#88) * Replace npm wrapper with Node entrypoint * Fix npm wrapper test portability feat(tui): premium splash + working-view redesign (#83) * feat(tui): premium splash + working-view redesign Centralized truecolor theme and a visual-first session UI. Splash: - Two-tone ANSI Shadow ZERO wordmark (bright blocks / dim shadow) - Replace Enter/Tab/Ctrl+C keycaps with a permission-mode status line - Rounded boxes for header and prompt Working view (3 zones): - Header bar: cwd, git branch, provider/model, run state - Role-gutter rows (you / zero), tool rows with ok/err status and arg hints - Colorized diff cards for patch/edit output - Pinned status line: mode (left) + model, tokens, cost (right) Internals: - Centralized palette in theme.go (role/status/diff/mode styles) - Enrich transcriptRow with tool/status/detail for structured rendering - gitBranch resolves .git/HEAD including worktrees * fix: stabilize tui startup rendering * fix: preserve tui tool row metadata * test: document tui event rendering contract Add runtime permission and verification event contracts (#82) * feat: add runtime permission event contract * feat: stream permission events from exec * feat: add runtime command snapshots * feat: add session rewind compaction plans * feat: add verification event contracts * fix: harden runtime contract review gaps * test: assert approved exec stderr contract * fix: address CodeRabbit CHANGES_REQUESTED items for #82 - Make OnSandboxDecision callback async + panic-safe (non-blocking, recovered) in tools/registry.go per CR. - Add explicit regression test for ConfigSnapshot when ResolveRuntimeMetadata errors (no secret leak in Message/BaseURL). - (Many prior items like shapedPayloadPreview for permission/tool events, dual-target conflict test, stderr empty on approved path, Kind/Framework preservation, redactCommand empty slice, etc. were already landed on this branch.) All relevant tests now pass. * fix(agent): make sandbox decision available synchronously on Result for permission events The recent change to invoke OnSandboxDecision asynchronously (to satisfy non-blocking requirement) broke the agent's immediate capture of the decision for building PermissionEvent after registry.RunWithOptions. - Add SandboxDecision *sandbox.Decision to tools.Result (not serialized). - Populate it on all sandbox-involved return paths in registry.RunWithOptions (sync, before/while firing async callback for observers). - In agent executeToolCall, use result.SandboxDecision for buildPermissionEvent (reliable ordering) instead of side-effect capture via the callback. - The async callback is still supported for any external OnSandboxDecision consumers. This fixes the two failing agent tests that were causing the cross-platform 'Smoke' CI jobs to fail on this PR: - TestRunEmitsPermissionEventForPersistentSandboxGrant - TestRunAppliesSandboxEvenInUnsafeMode All go tests now pass. --------- Co-authored-by: KRATOS Move performance benchmark to Go (#87) * Move performance benchmark to Go * Address perf benchmark review feedback * Fix perf benchmark pipe drain order Move build smoke tooling to Go (#86) Move release packaging to Go (#85) * Move release packaging to Go * Handle release archive close errors * Guard release output cleanup paths Verify update release assets (#84) Report sandbox platform capabilities (#81) docs: update work split ownership plan (#80) feat: harden sandbox command execution (#79) * feat: add sandbox command runner * feat: route shell tools through sandbox engine * test: cover sandboxed shell execution Remove legacy TypeScript runtime (#78) * chore: remove legacy TypeScript runtime * fix: align perf benchmark semantics * fix: clarify harness rss benchmark feat: add M6 sandbox backend (#77) * feat: add sandbox policy backend * feat: enforce sandbox decisions in tool runtime * feat: add sandbox policy and grants CLI * fix: honor sandbox grants in registry feat: add M5 self verification backend (#76) * feat: add self verification backend * feat: wire verify attempts to self verification Generate PR automation summary in Go (#75) feat: add M5 test runner backend (#74) * feat: add test runner backend * feat: surface test summaries in verify * fix: address test runner review feedback feat: add M5 git workflow backend (#72) * feat: add Zero git change backend * feat: add self verification summaries * feat: expose git workflow CLI * fix: address M5 review feedback * fix: clean up zerogit review nits feat: make Go runtime the app path (#71) * feat: make go runtime the app path * fix: harden update semver parsing feat: add Go worktree and verification backend (#70) * feat: add Zero worktree isolation backend * feat: add Zero verification runner backend * feat: wire worktree and verification commands * fix: address worktree verification review feedback * test: tighten worktree base dir assertion * fix: redact workflow CLI output paths * fix: keep workflow diagnostics useful while redacted feat: persist TUI sessions (#69) * feat: persist TUI sessions * fix: address TUI session review fix: address session lineage review feedback feat: add Go session lineage backend fix: preserve MCP response close errors fix: guard concurrent MCP client close fix: gracefully close stdio MCP clients test: address MCP transport review feedback feat: add Go MCP network transports fix: narrow TUI usage fallback feat: add TUI session controls feat: expose MCP server CLI feat: add MCP server protocol test: assert MCP env replacement precisely test: clarify MCP overlay replacement coverage fix: address MCP review feedback [codex] expose MCP tools in Go CLI [codex] add Go MCP stdio tool bridge [codex] add Go MCP config resolution feat: add Go command center (#63) * feat: add Go command center * fix: address command center review * fix: redact command center base urls * test: cover command center redaction streams [codex] add Go extension backend commands (#61) * feat(go): add plugin manifest backend * feat(go): add extension policy stores * feat(go): expose extension backend commands * fix(go): reject rooted plugin paths cross-platform * fix(go): address extension backend review * fix(go): harden extension backend persistence * fix(go): preserve extension usage and hook enabled semantics * fix(go): use process-released MCP permission locks * fix(plugins): close symlink missing-leaf escapes * fix(plugins): fail closed on manifest realpath errors * fix(tests): keep plugin realpath regression portable * fix: enable CodeRabbit verdict workflow --------- Co-authored-by: KRATOS feat: add Go headless protocol sessions (#60) * feat: add Go headless protocol sessions * test: harden stream-json tool list filtering * fix: resolve stream-json review followups * chore: enable coderabbit review verdicts Enable request_changes_workflow in CodeRabbit config (#62) [codex] add Go observability backend commands (#59) * feat(go): add observability backend primitives * feat(go): add session search and doctor backends * feat(go): expose observability cli commands * fix(go): address observability review feedback --------- Co-authored-by: KRATOS [codex] add Go provider runtime adapters (#58) * feat(go): accept anthropic and google provider config * feat(go): add provider stream io helpers * feat(go): add anthropic streaming provider * feat(go): add gemini streaming provider * feat(go): wire provider factory to registry models * fix(go): address provider runtime review feedback feat: advance Go-first TUI and npm wrapper (#57) * feat: advance Go-first TUI and npm wrapper * fix: address wrapper and footer review feedback * feat: deepen Go TUI model and exec controls * fix: address coderabbit exec and model list feedback Add Go model registry catalog and cost helpers Summary: - Add Go model catalog metadata and registry filters for provider, capability, aliases, and reasoning effort. - Add tier-aware cost estimation and USD formatting helpers with normalized usage handling. - Cover model validation, catalog filtering, defensive cloning, pricing tiers, and Windows command-timeout CI behavior. Validation: - go test ./internal/config -run TestLoadProviderCommandTimeout -count=3 -v - go test ./... - bun run typecheck - bun test ./tests --timeout 15000 - bun run build - bun run smoke:build - bun run build:go - bun run smoke:go - ./zero --help - git diff --check feat: wire Go CLI to real provider flow (#54) * feat: wire go cli to real provider flow * fix: address exec review feedback * test: harden exec provider assertions * chore: prevent coderabbit blocking reviews Build release binary with Go [codex] add Go runtime contract foundations (#55) * feat: add Go runtime usage contracts * feat: add Go model registry contracts * feat: add Go config contract gaps * fix: address Go contract review gaps * fix(go): validate model entries in registry fix: address coderabbit review feedback feat: add go provider config and tui foundations feat: unify go runtime provider contracts fix: reject empty exec prompts feat: add go agent runtime Harden Go stream collection contract Add Go runtime provider contracts Add Go M0 bash tool (#49) * feat: add go bash tool * fix: avoid quoted helper path on windows * fix: make bash cwd test portable Add Go M0 write tools (#48) * feat: add go write tools * fix: address write tool review feedback * fix: recheck write target resolver [codex] add Go read-only core tools (#47) * feat: add go read-only core tools * fix: harden go read-only tools Add Go M0 validation scripts (#46) * Add Go M0 validation scripts * Validate Go smoke package version * Align README with Go migration [codex] add Go module entrypoint (#45) * feat: add go module entrypoint * fix: address go entrypoint review Update PRD for Go-native Zero direction (#44) * docs: update zero prd for go migration * docs: clarify go migration validation criteria * docs: reset m0 plan for go migration feat: add Zero hook backend (#43) * feat: add Zero hook backend * fix: harden Zero hook review paths feat: add local plugin manifest loader (#42) * feat: add local plugin manifest loader * test: harden plugin manifest loading Polish TUI theme experience (#38) * feat: polish TUI theme experience * Restore initial TUI polish layout * Restore startup ASCII logo * Fix TUI theme review blockers * Revert "Restore startup ASCII logo" This reverts commit ffc9b6b4b3b0aa5a54abc8814d0560c3cbb8e25e. * Restore saved TUI polish details * Fix startup logo redraw * Align startup rendering with saved TUI * fix: align tui startup logo * Fix TUI input style polish * Fix TUI review interactions * Fix startup logo visibility * Validate OpenGateway model input --------- Co-authored-by: Vasanthdev2004 feat: add MCP permission grants (#40) Configure CodeRabbit review decisions (#41) * ci: tune coderabbit review workflow * ci: anchor coderabbit artifact filters ci: configure coderabbit auto reviews (#39) feat: add Zero MCP client backend (#37) * feat: add Zero MCP client backend * fix: make MCP tool names collision-safe feat: add session search indexing (#36) Add a persistent per-session search index that stores redacted event text and rebuilds when session metadata changes. Route zero search through the index and add --reindex for explicit cache repair. Cover index persistence, stale rebuilds, forced reindexing, and CLI output. feat: add config validation diagnostics (#35) * feat: add config validation diagnostics Report invalid config layers with source paths and field paths while keeping loaded layers non-blocking. Wire config inspection and CLI JSON output to emit structured validation details without import-time config warnings. Add loader, inspection, and CLI coverage for invalid file and env diagnostics. * test: make config CLI home fixtures portable Set USERPROFILE alongside HOME so Windows smoke resolves the same temporary config path during zero config CLI tests. Add automated PR review workflow (#34) * ci: add automated pr review * ci: keep pr review posting trusted docs: comprehensive project README (#33) * docs: write a comprehensive project README Replace the stub README with a full overview: highlights, quick start, provider/model config, TUI + headless usage, command reference, tool table, architecture, project layout, dev/release, and docs index. * docs: align TUI shortcuts with implementation * docs: fix README to match implemented TUI interactions Address review: the TUI input handler only implements Enter (send), Tab (accept slash suggestion), arrow/PgUp-PgDn/Home-End scrolling when empty, and Ctrl+C. Remove advertised @ file refs, ! shell, shift+tab cycling, and Esc interrupt (those are UI affordances not yet wired). Also correct 'collapsible tool rows' to 'compact tool-call rows' to match the current renderer. feat: add Zero exec resume and fork sessions (#32) * feat: add Zero exec session resume fork backend * feat: wire Zero exec resume fork flags Add runtime command flow foundation (#30) * Add TUI tool approval flow * Remove startup shortcut hints * Add runtime command flow foundation * fix: serialize prompt-gated tool approvals feat: add Zero stream-json protocol (#31) * feat: add Zero stream-json protocol schema * feat: wire stream-json exec mode * fix: read piped stream-json stdin fix: lazy load tui for cli commands fix: restore startup shortcut hints feat(tui): animate live dot and running tool rows Add a pulsing LiveDot in the footer (heartbeat while the agent works, steady otherwise) and an animated spinner on tool rows while they're running (resolves to a static dot on completion). Each is an isolated leaf component so only it repaints per tick. Thinking spinner already existed. feat(tui): redesign in-session working view Header now shows the active file + git branch/ahead-behind; footer shows model · tokens · cost · ctx% · live. Activity rows are mockup-style (dot + tool + summary: Read N lines / Grep "q" N matches / Edit +A -B), and edits render an inline DiffCard (red/green). Usage/git are derived/estimated for now; live token usage + structured diffs are follow-ups. feat(tui): show slash-command menu on splash, drop keycap hints Render CommandSuggestions on the startup splash (TuiShell only showed it in the in-session view, so typing / on the splash showed nothing). Remove the Enter/Tab/Ctrl+C keycap line; the mode line + command menu cover the affordances. feat(tui): add Claude-Code-style mode status line to splash New ModeStatus shows the active mode (build/plan/auto-accept/bypass) with a chevron indicator, a shift+tab cycle affordance, and a /-command hint; color-coded per mode. Presentational for now — shell wires the actual cycling later. feat(tui): simplify shortcut hints to a clean single line Drop the boxed keycaps (heavy/noisy); render keys in cyan with dim labels and a faint middot separator. Wraps on narrow terminals. feat(tui): switch splash wordmark to figlet Rebel with two-tone cyan Use the Rebel 3D figlet font for ZERO; render solid front faces in bright cyan and the shaded drop-shadow dim, so the 3D depth reads correctly. Generated from figlet, padded to equal width for clean centering. feat(tui): use figlet Electronic wordmark for ZERO splash logo Outlined glyphs with a dotted fill — closer to the splash mockup than the solid block. Generated from figlet (not hand-drawn); lines padded to equal width so the block stays centered. feat(tui): rebuild startup splash into reusable components Replace the inlined StartupScreen with composable Header, ZeroLogo, CommandChips, PromptBox and ShortcutHints under src/tui/startup, plus a self-contained theme. Keeps the same props so TuiShell is unchanged; typecheck clean, shell render test passing. feat: add premium startup splash fix: align tui with compact agent stream fix: redesign tui as agent console fix: strengthen tui shell visual hierarchy feat: redesign tui shell feat: add Zero config inspection (#29) feat: add Zero doctor backend (#28) feat: add Zero session search (#27) * feat: add Zero session search backend * feat: expose Zero session search CLI * fix: redact Zero search output feat: add Zero secret redaction (#26) feat: add M2 release checksum verification (#25) * Add M2 release checksum verification * Cover release checksum edge cases feat: add tui model selector (#22) feat: add Zero usage tracking backend (#23) * feat: add Zero usage tracking backend Adds normalized token usage records, per-model cost summaries, and compact usage formatting backed by the existing model registry pricing. * feat: expose agent usage events Adds an onUsage callback to runAgent and covers provider usage forwarding with an agent-loop regression test. feat: add M2 performance benchmark harness (#20) * Add M2 performance benchmark harness * Polish performance benchmark CLI feat: add Zero session event store (#21) * feat: add Zero session event store * test: cover Zero session search limits * test: make session root assertions portable feat: add Zero Gemini provider (#18) * feat: add Zero Gemini stream provider * feat: wire Zero Gemini runtime * fix: merge Gemini user turns after tool results feat: add M2 install scripts (#19) * Add M2 install scripts * Harden install archive extraction * Fix install script runtime test platform feat: add m1 headless exec surface (#17) feat: add M2 update check (#16) * Add M2 update check command * Harden update check version and timeout feat: add Zero Anthropic provider (#15) * feat: add Zero Anthropic stream provider * feat: wire Zero Anthropic runtime * fix: address Anthropic provider review feedback Add M1 release packaging workflow (#14) feat: add Zero provider runtime resolver (#12) Resolve provider runtime inputs through the Zero model registry, enforce dispatch-safe OpenAI-compatible gateway URLs, normalize official OpenAI base URLs, and keep provider source metadata in the config boundary. Unify config typing on the loader schema, make provider-command parsing bounded and validated, improve headless adapter errors, and add regression coverage for requested-change cases. [codex] p0 foundation tools (#11) * p0 foundation tools * fix p0 foundation review blockers * fix review feedback on foundation tools * fix: hide prompt-gated tools from agent loop docs: add final Zero PRD and work split (#13) Add M0 CI scripts baseline (#10) * Add M0 CI scripts baseline * Address M0 CI review feedback feat: add Zero model registry (#9) * feat: add Zero model registry Add a Zero-branded model registry with provider metadata, aliases, capabilities, context limits, pricing source metadata, and cost helpers. Cover registry lookup, filters, reasoning effort metadata, deprecated-model handling, tiered pricing, and cost formatting with Bun tests. * fix: address model registry review feedback Remove speculative model entries and unstable latest-style aliases from the Zero model registry. Use verified public model metadata, default to gpt-4.1, document stable alias semantics, and tighten cached-token cost handling. Expand tests for deprecated filtering, alias stability, provider mismatch errors, direct model cost inputs, unsupported cache pricing, and invalid cost formatting. --------- Co-authored-by: KRATOS M0: foundation — ToolBase, layered config, write_file, line ranges, edit uniqueness (#6) * M0: foundation — ToolBase, layered config, write_file, line ranges, edit uniqueness Adds the core architecture for the M0 milestone (Weeks 1-2 of the PRD): - src/tools/base.ts: ToolBase abstract class with Zod-validated parameters, run() that converts invalid args / thrown errors into friendly strings, and built-in JSON Schema generation for providers. - src/tools/types.ts: introduce a generic Tool interface alongside the duck-typed contract the registry already accepts. - src/providers/base.ts: re-export the Provider interface and add seedMessages() + collectStream() helpers used by tests and (later) any non-streaming consumer. - src/config/loader.ts: layered config (defaults -> user config -> project config -> env vars -> CLI overrides), Zod-validated, with a layer-source report for /config-style introspection. - src/tools/read_file.ts: line-numbered output plus optional start_line / end_line / max_lines range slicing. - src/tools/write_file.ts: create-new-file with overwrite safety, auto-creates parent directories. - src/tools/edit_file.ts: exact-string replace with a uniqueness check (old_string must match exactly one place unless replace_all is true) to prevent accidental global edits. - src/tools/{read,edit}-file.ts: kept as re-exports of the new snake_case files so existing imports and the registry wiring continue to work. Tests: - 27 new tests across tests/tool-base.test.ts, tests/config-loader.test.ts, tests/provider-base.test.ts, and the expanded tests/file-tools.test.ts. - Full suite: 47 pass, 0 fail. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * fix: resolve strict TypeScript errors for go-live - src/agent/loop.ts: guard firstTool against undefined - src/config/loader.ts: make providers field optional in schema - src/tui/AddProvider.tsx: remove invalid marginTop prop from Text - src/tui/App.tsx: guard parts[0] against undefined - src/tui/MessageRenderer.tsx: use non-null assertion for matches[i] inside length check - src/tui/ProviderPicker.tsx: guard selected against undefined - src/tui/highlighter.ts: shiki v3 dropped codeToAnsi, fall back to plain text - tests/tool-base.test.ts: explicit Promise return type for FailingTool.execute Validation: tsc clean, 47/47 tests pass, build succeeds. * fix: address review feedback for M0 go-live - src/tools/write_file.ts (P1): use stat() for existence check so zero-byte existing files are not silently overwritten; previous guard used existing.length > 0 which treated empty files as new. - src/tools/types.ts + src/agent/loop.ts (P2): add optional Tool.run path; the agent loop now prefers tool.run() when present so the schema validation and thrown-error handling from ToolBase.run() are honored for ToolBase subclasses. Plain object-literal tools fall back to execute() unchanged. - tests/file-tools.test.ts: add regression tests for the empty-file overwrite guard. Validation: tsc clean, 49/49 tests pass (was 47, +2 regression tests). P2 finding about the layered config loader not being wired into runtime is acknowledged as out of scope for this PR; that work is tracked in a follow-up issue. --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> fix plan mode and add test (#2) * fix plan mode and add test * Address review: wire plan mode, harden test script, restore friendly errors - Wire /plan into agent behavior: add PLAN_MODE_SYSTEM_PROMPT and a planMode option on runAgent; App passes isPlanMode through so the agent actually plans without editing instead of only changing UI color. - Harden test script to "bun test ./tests --timeout 15000" for deterministic discovery and a bounded runtime so it always exits. - Restore friendly provider-setup/auth/network error mapping via toFriendlyError(); debug mode still records the full error object. - Add tests/agent-loop.test.ts covering prompt selection and the tool-call -> result -> final-answer loop with a mock provider. --------- Co-authored-by: Kevin Codex Co-authored-by: Claude Opus 4.8 (1M context) Add contributing guidelines (#1) initial commit --- internal/tui/file_view.go | 219 +++++++++++++++++ internal/tui/file_view_test.go | 213 +++++++++++++++++ internal/tui/files_panel.go | 337 +++++++++++++++++++++++++++ internal/tui/files_panel_test.go | 232 ++++++++++++++++++ internal/tui/hover.go | 7 + internal/tui/model.go | 48 +++- internal/tui/render_cache.go | 3 + internal/tui/rendering.go | 13 ++ internal/tui/session.go | 35 ++- internal/tui/sidebar.go | 22 ++ internal/tui/transcript.go | 5 + internal/tui/transcript_selection.go | 23 ++ 12 files changed, 1144 insertions(+), 13 deletions(-) create mode 100644 internal/tui/file_view.go create mode 100644 internal/tui/file_view_test.go create mode 100644 internal/tui/files_panel.go create mode 100644 internal/tui/files_panel_test.go diff --git a/internal/tui/file_view.go b/internal/tui/file_view.go new file mode 100644 index 000000000..667692ad0 --- /dev/null +++ b/internal/tui/file_view.go @@ -0,0 +1,219 @@ +// file_view.go is the drill-in view for a touched file, opened from the FILES +// sidebar section (files_panel.go). It reuses the subchat pattern: while +// active, the chat column's body swaps to this file's content — the sidebar, +// composer, and scroll engine keep working unchanged (transcriptBodyItems is +// the single source the viewport, renderer, and hit-testers all read, so +// swapping there keeps every consumer consistent). Two modes: +// +// diff (default) — the file's edit cards from this session, full-depth +// full — the file as it stands on disk, syntax highlighted, with +// gutter markers on the lines this session's diffs added +// +// d/f switch modes (with an empty composer), Esc returns to the chat at the +// scroll position it was left at. +package tui + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// fileViewMaxLines caps the full-file mode so a giant generated file can't +// freeze a render; the tail collapses into a "… N more lines" trailer. +const fileViewMaxLines = 4000 + +const ( + fileViewDiff = iota + fileViewFull +) + +// fileViewState manages the drill-in view for a touched file. When active, the +// transcript body swaps to the file's diff/content instead of the chat rows. +type fileViewState struct { + active bool + path string // workspace-relative, as carried by changedFiles + mode int // fileViewDiff | fileViewFull + // parentScrollOffset preserves the chat scroll position so closing the view + // returns to the same spot (mirrors subchatState). + parentScrollOffset int +} + +// openFileView activates the drill-in for path in diff mode. Opening from an +// already-open view (clicking another FILES row) keeps the original saved chat +// scroll position rather than saving the file view's own offset as "parent". +func (m model) openFileView(path string) model { + if !m.fileView.active { + m.fileView.parentScrollOffset = m.chatScrollOffset + } + m.fileView.active = true + m.fileView.path = path + m.fileView.mode = fileViewDiff + m.chatScrollOffset = 0 + m = m.clearHover() // bodyY numbering differs between the file body and the chat + return m +} + +// exitFileView deactivates the view and restores the chat scroll position. +func (m model) exitFileView() model { + if !m.fileView.active { + return m + } + m.chatScrollOffset = m.fileView.parentScrollOffset + m.fileView = fileViewState{} + m = m.clearHover() + return m +} + +// setFileViewMode switches diff/full, resetting the scroll to the bottom-anchored +// start since the two bodies have unrelated heights. +func (m model) setFileViewMode(mode int) model { + if !m.fileView.active || m.fileView.mode == mode { + return m + } + m.fileView.mode = mode + m.chatScrollOffset = 0 + return m +} + +// fileViewNavBar renders the single-line header shown in place of the pinned +// title bar while the view is active: the path plus the key hints. One line +// exactly, so every scrollableTranscriptFrame computed against it agrees with +// the title bar's geometry. +func (m model) fileViewNavBar(width int) string { + mode := "diff" + other := "f full" + if m.fileView.mode == fileViewFull { + mode = "full" + other = "d diff" + } + left := zeroTheme.accent.Render("← "+truncatePathLeft(m.fileView.path, maxInt(8, width/2))) + + zeroTheme.faint.Render(" · "+mode) + right := zeroTheme.faint.Render(other + " · esc back") + return fitStyledLine(joinHeaderLine(left, right, width), width) +} + +// fileViewBodyItems builds the body items the transcript machinery renders +// while the view is active — one pre-rendered block, so scrolling and height +// accounting flow through the exact same path as chat rows. +func (m model) fileViewBodyItems(width int) []transcriptBodyItem { + var block string + if m.fileView.mode == fileViewFull { + block = m.renderFileViewFull(width) + } else { + block = m.renderFileViewDiff(width) + } + return []transcriptBodyItem{transcriptBlockBodyItem(transcriptBodyItemRow, -1, block)} +} + +// fileViewResultRows returns the transcript's tool-result rows that touched the +// viewed file, in chronological order. +func (m model) fileViewResultRows() []transcriptRow { + var rows []transcriptRow + for _, row := range m.transcript { + if row.kind != rowToolResult { + continue + } + for _, p := range row.changedFiles { + if p == m.fileView.path { + rows = append(rows, row) + break + } + } + } + return rows +} + +// renderFileViewDiff renders the session's edits to the file as its full-depth +// tool cards (bodyCap 0, the detailed-view depth), stacked chronologically — +// the same cards the chat shows, so the diffs read identically in both places. +func (m model) renderFileViewDiff(width int) string { + rows := m.fileViewResultRows() + if len(rows) == 0 { + return zeroTheme.faint.Render("No recorded edits for this file in this session.") + } + rc := buildRowContext(m.transcript) + opts := cardRenderOptions{bodyCap: 0, cwd: m.cwd} + var b strings.Builder + for i, row := range rows { + if i > 0 { + b.WriteString("\n\n") + } + if len(rows) > 1 { + b.WriteString(zeroTheme.faint.Render(fmt.Sprintf("edit %d of %d", i+1, len(rows)))) + b.WriteString("\n") + } + b.WriteString(m.renderRowModeUncached(row, width, rc, opts)) + } + return b.String() +} + +// renderFileViewFull renders the file as it currently stands on disk, syntax +// highlighted, with a line-number gutter and an accent ▎ marker on the lines +// this session's diffs added (matched by exact text — an approximation that +// tolerates later drift; a stale marker just doesn't highlight). +func (m model) renderFileViewFull(width int) string { + target := m.fileView.path + if !filepath.IsAbs(target) { + target = filepath.Join(m.cwd, target) + } + data, err := os.ReadFile(target) + if err != nil { + return zeroTheme.faint.Render("Could not read file: " + err.Error()) + } + lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") + truncated := 0 + if len(lines) > fileViewMaxLines { + truncated = len(lines) - fileViewMaxLines + lines = lines[:fileViewMaxLines] + } + + changed := m.fileViewChangedLines() + gutterW := len(fmt.Sprintf("%d", len(lines))) + textBudget := maxInt(8, width-gutterW-3) // gutter + space + marker column + // Highlight with an effectively-infinite measure so the highlighter never + // wraps — output lines stay 1:1 with file lines and the gutter numbering + // can't desync. Each line is then truncated to the column budget below. + display, ok := highlightCodeForPath(lines, m.fileView.path, 1<<20, nil) + if !ok || len(display) != len(lines) { + display = lines // no lexer for this path: render plain + } + + var b strings.Builder + for i, line := range display { + line = fitStyledLine(line, textBudget) + if i > 0 { + b.WriteString("\n") + } + marker := " " + if changed[strings.TrimSpace(lines[i])] { + marker = zeroTheme.accent.Render("▎") + } + b.WriteString(zeroTheme.faintest.Render(fmt.Sprintf("%*d ", gutterW, i+1))) + b.WriteString(marker) + b.WriteString(line) + } + if truncated > 0 { + b.WriteString("\n") + b.WriteString(zeroTheme.faint.Render(fmt.Sprintf("… %d more lines (file truncated for display)", truncated))) + } + return b.String() +} + +// fileViewChangedLines collects the trimmed text of every line the session's +// diffs ADDED to the viewed file, for the full-mode gutter markers. +func (m model) fileViewChangedLines() map[string]bool { + changed := map[string]bool{} + for _, row := range m.fileViewResultRows() { + for _, line := range strings.Split(row.detail, "\n") { + if !strings.HasPrefix(line, "+") || strings.HasPrefix(line, "+++") { + continue + } + if text := strings.TrimSpace(strings.TrimPrefix(line, "+")); text != "" { + changed[text] = true + } + } + } + return changed +} diff --git a/internal/tui/file_view_test.go b/internal/tui/file_view_test.go new file mode 100644 index 000000000..9136346ec --- /dev/null +++ b/internal/tui/file_view_test.go @@ -0,0 +1,213 @@ +package tui + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/tools" +) + +// TestFileViewOpenExitRestoresScroll: opening saves the chat scroll position, +// resets it for the file body, and Esc restores it; switching files while open +// keeps the ORIGINAL saved position (not the file view's own). +func TestFileViewOpenExitRestoresScroll(t *testing.T) { + m := filesPanelTestModel() + m.chatScrollOffset = 12 + + m = m.openFileView("web/app.js") + if !m.fileView.active || m.fileView.mode != fileViewDiff { + t.Fatalf("open should activate in diff mode: %+v", m.fileView) + } + if m.chatScrollOffset != 0 || m.fileView.parentScrollOffset != 12 { + t.Fatalf("open should reset scroll and save the parent offset: offset=%d saved=%d", m.chatScrollOffset, m.fileView.parentScrollOffset) + } + + m.chatScrollOffset = 5 // scrolled within the file body + m = m.openFileView("internal/tui/sidebar.go") + if m.fileView.parentScrollOffset != 12 { + t.Fatalf("switching files must keep the original parent offset, got %d", m.fileView.parentScrollOffset) + } + + m = m.exitFileView() + if m.fileView.active || m.chatScrollOffset != 12 { + t.Fatalf("exit should restore the chat scroll: active=%v offset=%d", m.fileView.active, m.chatScrollOffset) + } +} + +// TestFileViewEscAndModeKeys: Esc exits the view via the model's key handler; +// d/f switch modes while the composer is empty and never while typing. +func TestFileViewEscAndModeKeys(t *testing.T) { + m := filesPanelTestModel() + m = m.openFileView("web/app.js") + + updated, _ := m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"}) + m = updated.(model) + if m.fileView.mode != fileViewFull { + t.Fatal("f should switch to full mode") + } + updated, _ = m.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + m = updated.(model) + if m.fileView.mode != fileViewDiff { + t.Fatal("d should switch back to diff mode") + } + + // With text in the composer, d/f type as normal characters. + m.input.SetValue("say") + updated, _ = m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"}) + m = updated.(model) + if m.fileView.mode != fileViewDiff { + t.Fatal("f while typing must not hijack the composer") + } + m.input.SetValue("") + + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = updated.(model) + if m.fileView.active { + t.Fatal("Esc should exit the file view") + } +} + +// TestFileViewDiffBody: diff mode stacks the file's edit cards chronologically +// with "edit N of M" labels; a file with no recorded edits shows the quiet +// placeholder. +func TestFileViewDiffBody(t *testing.T) { + m := filesPanelTestModel() + m = m.openFileView("internal/tui/sidebar.go") + body := plainRender(t, m.renderFileViewDiff(78)) + if !strings.Contains(body, "edit 1 of 2") || !strings.Contains(body, "edit 2 of 2") { + t.Fatalf("expected chronological edit labels:\n%s", body) + } + if !strings.Contains(body, "added one") || !strings.Contains(body, "three") { + t.Errorf("expected both diffs' content:\n%s", body) + } + + m.fileView.path = "never/touched.go" + if got := plainRender(t, m.renderFileViewDiff(78)); !strings.Contains(got, "No recorded edits") { + t.Errorf("untouched file should show the placeholder, got:\n%s", got) + } +} + +// TestFileViewFullBody: full mode shows the on-disk content with line numbers +// and marks session-added lines with the gutter marker; a missing file degrades +// to a readable error line. +func TestFileViewFullBody(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("let a = 1\nlet untouched = 0\n"), 0o644); err != nil { + t.Fatal(err) + } + m := filesPanelTestModel() + m.cwd = dir + m.transcript = append(m.transcript, transcriptRow{ + kind: rowToolResult, tool: "write_file", id: "w9", status: tools.StatusOK, + detail: "+let a = 1", + changedFiles: []string{"app.js"}, + }) + m = m.openFileView("app.js") + m = m.setFileViewMode(fileViewFull) + + body := m.renderFileViewFull(78) + plain := plainRender(t, body) + if !strings.Contains(plain, "1 ") || !strings.Contains(plain, "let untouched = 0") { + t.Fatalf("full view should show numbered file content:\n%s", plain) + } + lines := strings.Split(plain, "\n") + if len(lines) != 2 { + t.Fatalf("one rendered line per file line, got %d:\n%s", len(lines), plain) + } + if !strings.Contains(lines[0], "▎") { + t.Errorf("session-added line should carry the gutter marker: %q", lines[0]) + } + if strings.Contains(lines[1], "▎") { + t.Errorf("untouched line must not carry the marker: %q", lines[1]) + } + + m.fileView.path = "gone.js" + if got := plainRender(t, m.renderFileViewFull(78)); !strings.Contains(got, "Could not read file") { + t.Errorf("missing file should degrade to an error line, got:\n%s", got) + } +} + +// TestFileViewSwapsTranscriptBody: while active, transcriptBodyItems returns +// the file body (a single block) instead of the chat rows, and the pinned +// title bar swaps to the one-line nav bar — the geometry every frame consumer +// relies on. +func TestFileViewSwapsTranscriptBody(t *testing.T) { + m := filesPanelTestModel() + m = m.openFileView("internal/tui/sidebar.go") + + items := m.transcriptBodyItems(m.chatColumnWidth(), "") + if len(items) != 1 { + t.Fatalf("file view should swap the body to a single block item, got %d items", len(items)) + } + nav := plainRender(t, m.pinnedTitleBar(m.chatColumnWidth())) + if !strings.Contains(nav, "sidebar.go") || !strings.Contains(nav, "esc back") { + t.Fatalf("nav bar should show the path and key hints: %q", nav) + } + if lines := len(viewLines(m.fileViewNavBar(m.chatColumnWidth()))); lines != 1 { + t.Fatalf("nav bar must be exactly one line (title-bar geometry), got %d", lines) + } + + // The whole view renders without panicking in both modes and shows the nav. + if view := plainRender(t, m.transcriptView()); !strings.Contains(view, "esc back") { + t.Fatal("transcript view should carry the file nav bar") + } +} + +// TestSubchatEntryClosesFileView: drilling into an AGENTS row while a file view +// is open closes the file view first (the subchat owns the single-column view). +func TestSubchatEntryClosesFileView(t *testing.T) { + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + if _, err := store.Create(sessions.CreateInput{SessionID: "sess-1"}); err != nil { + t.Fatal(err) + } + m := filesPanelTestModel() + m.sessionStore = store + m.swarmSessionMap = map[string]string{"subagent-1": "sess-1"} + m.transcript = append(m.transcript, + transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "build it", runID: 1}, + transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default.", runID: 1}, + ) + m.activeRunID = 1 + m = m.openFileView("web/app.js") + + width := sidebarWidth(m.width) + agents := m.sidebarAgentSelectables(width) + if len(agents) == 0 { + t.Fatal("expected a clickable agent row") + } + click := testMouseClick(tea.MouseLeft, m.chatColumnWidth()+3, agents[0].lineOffset) + updated, _, handled := m.handleTranscriptSelectionMouse(click) + if !handled { + t.Fatal("agent row click should be handled") + } + if updated.fileView.active { + t.Fatal("entering the subchat should close the file view") + } + if !updated.subchat.active { + t.Fatal("subchat should be active") + } +} + +// TestChangedFilesRehydration: a persisted tool-result payload's changedFiles +// restores onto the rehydrated transcript row, so the FILES panel survives +// /resume. +func TestChangedFilesRehydration(t *testing.T) { + events := []sessions.Event{{ + Type: sessions.EventToolResult, + Payload: json.RawMessage(`{"toolCallId":"t1","name":"edit_file","status":"ok","output":"+x","changedFiles":["pkg/a.go","pkg/b.go"]}`), + }} + rows := transcriptRowsFromSessionEvents(events) + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + got := rows[0].changedFiles + if len(got) != 2 || got[0] != "pkg/a.go" || got[1] != "pkg/b.go" { + t.Fatalf("changedFiles not rehydrated: %v", got) + } +} diff --git a/internal/tui/files_panel.go b/internal/tui/files_panel.go new file mode 100644 index 000000000..f162b531a --- /dev/null +++ b/internal/tui/files_panel.go @@ -0,0 +1,337 @@ +// files_panel.go renders the FILES section of the right context sidebar: the +// workspace files this session has touched, newest first, with an A/M badge and +// a +added/−removed diffstat per file, plus a pulsing row for the file whose +// write is streaming right now. Like the swarm roster (sidebar.go), the touched +// set is not separate model state — it is recovered on demand from the +// transcript's tool-result rows (their changedFiles), so it survives resume for +// free and can never drift from what the chat shows. +// +// Interaction (see handleTranscriptSelectionMouse): the first click on a row +// SELECTS the file — its edit cards tint in the chat and the transcript scrolls +// to the most recent one; a second click (or a click while the drill-in is +// already open) opens the file view (file_view.go). Esc clears the selection. +package tui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/tools" +) + +// maxSidebarFiles caps the FILES rows so the section stays a glanceable set, +// not a scrolling log; older files collapse into a "+N more" trailer. +const maxSidebarFiles = 6 + +// touchedFile is one workspace file the session has mutated, aggregated across +// every tool-result row that touched it. +type touchedFile struct { + path string + adds int // total added lines across its diffs + dels int // total removed lines across its diffs + edits int // number of mutations + created bool // first touch created the file (write_file "Created …") + failed bool // the latest touch errored + lastRowIndex int // transcript index of the most recent result touching it +} + +// touchedFiles recovers the session's touched-file roster from the transcript, +// most recently touched first. Recomputed on demand (the value-receiver model +// can't persist a registry from View), mirroring swarmSpawnedAgents; the scan is +// a single pass over the transcript per render, same order as the sidebar's +// other sections. +func (m model) touchedFiles() []touchedFile { + var files []touchedFile + index := map[string]int{} + for i, row := range m.transcript { + if row.kind != rowToolResult || len(row.changedFiles) == 0 { + continue + } + adds, dels := planDiffStat(row.detail) + for _, path := range row.changedFiles { + if path == "" { + continue + } + at, seen := index[path] + if !seen { + index[path] = len(files) + files = append(files, touchedFile{ + path: path, + created: resultRowCreatedFile(row), + lastRowIndex: i, + }) + at = len(files) - 1 + } + files[at].adds += adds + files[at].dels += dels + files[at].edits++ + files[at].failed = row.status == tools.StatusError + files[at].lastRowIndex = i + } + } + // Most recently touched first: the file being worked on now belongs at the + // top, like the sidebar's ACTIVITY feed. + for left, right := 0, len(files)-1; left < right; left, right = left+1, right-1 { + files[left], files[right] = files[right], files[left] + } + return files +} + +// resultRowCreatedFile reports whether a tool-result row reads as a file +// creation ("tool result: write_file ok Created x (…)"), so the FILES row can +// badge it A rather than M. Heuristic on the confirmation text write_file +// already emits; a miss just shows M, never breaks anything. +func resultRowCreatedFile(row transcriptRow) bool { + return strings.Contains(row.text, " Created ") +} + +// liveEditingPath is the path of the file whose mutating tool call is streaming +// its arguments RIGHT NOW (the same live write streamingToolCallView previews), +// or "" when no mutation is in flight. It renders as a pulsing row pinned atop +// the FILES section. +func (m model) liveEditingPath() string { + if m.streamCallDecoder == nil { + return "" + } + switch m.streamCallName { + case "write_file", "edit_file", "apply_patch": + return m.streamCallDecoder.path + } + return "" +} + +// fileHit marks a rendered FILES row (by its ABSOLUTE index inside the rendered +// sidebar) that is clickable, carrying the file it refers to. +type fileHit struct { + lineOffset int + path string +} + +// sidebarFilesHeader renders the FILES section header with the touched count. +func (m model) sidebarFilesHeader(width int) string { + n := len(m.touchedFiles()) + if n == 0 { + return sidebarHeader("FILES", width) + } + return sidebarHeaderWithCount("FILES", fmt.Sprintf("%d", n), zeroTheme.muted, width) +} + +// sidebarFileLines renders the FILES section body: the live "writing" pulse row +// first, then the touched files newest-first, capped at maxSidebarFiles with a +// "+N more" trailer. Returns nil when the session has touched nothing (the +// caller shows a placeholder). The paired hits slice records each clickable +// row's index WITHIN the returned lines. +func (m model) sidebarFileLines(width int) ([]string, []fileHit) { + files := m.touchedFiles() + live := m.liveEditingPath() + if len(files) == 0 && live == "" { + return nil, nil + } + room := maxInt(4, width-3) + var lines []string + var hits []fileHit + + // The file being written this instant: an accent pulse, not yet clickable + // (its result row — the diff — doesn't exist until the call completes). + if live != "" { + lines = append(lines, " "+zeroTheme.accent.Render("●")+" "+ + zeroTheme.ink.Render(truncatePathLeft(live, room))) + } + + shown := 0 + for _, f := range files { + if f.path == live { + continue // already shown as the live row + } + if shown >= maxSidebarFiles { + lines = append(lines, " "+zeroTheme.faint.Render(fmt.Sprintf("+%d more", len(files)-shown))) + break + } + shown++ + hits = append(hits, fileHit{lineOffset: len(lines), path: f.path}) + lines = append(lines, m.renderFileRow(f, room)) + } + return lines, hits +} + +// renderFileRow renders one touched file: status badge, left-truncated path, +// and the +/− diffstat, with the selected file carrying an accent marker so the +// selection reads in the sidebar as well as in the chat tint. +func (m model) renderFileRow(f touchedFile, room int) string { + badge := zeroTheme.muted.Render("M") + switch { + case f.failed: + badge = zeroTheme.red.Render("✗") + case f.created: + badge = zeroTheme.green.Render("A") + } + // Reserve the diffstat's width so the path truncates around it. + stat := "" + if f.adds > 0 || f.dels > 0 { + stat = fmt.Sprintf(" +%d −%d", f.adds, f.dels) + } + pathRoom := maxInt(4, room-len(stat)) + pathStyle := zeroTheme.muted + lead := " " + if m.selectedFile == f.path { + lead = zeroTheme.accent.Render("▸") + pathStyle = zeroTheme.ink + } + line := lead + badge + " " + pathStyle.Render(truncatePathLeft(f.path, pathRoom)) + if stat != "" { + line += zeroTheme.faintest.Render(stat) + } + return line +} + +// sidebarFileSelectables returns the clickable FILES rows with their ABSOLUTE +// index inside the rendered sidebar. The FILES section renders after PLAN in +// renderContextSidebar: AGENTS header + body, blank + PLAN header + body, then +// blank + FILES header, then the file rows. The offset accounting mirrors +// sidebarPlanSelectables exactly, extended one section down. +func (m model) sidebarFileSelectables(width int) []fileHit { + lines, hits := m.sidebarFileLines(width) + if len(lines) == 0 { + return nil + } + agentBody := len(m.sidebarAgentLines(width)) + if agentBody == 0 { + agentBody = 1 // the "no agents spawned" placeholder occupies one line + } + planBody := len(m.sidebarPlanLines(width)) + if planBody == 0 { + planBody = 1 // the "no active plan" placeholder occupies one line + } + base := 1 + agentBody + 2 + planBody + 2 // sections above + (blank + FILES header) + for i := range hits { + hits[i].lineOffset += base + } + return hits +} + +// fileRowAtMouse maps a left-click in the context sidebar to a touched file, +// mirroring planStepAtMouse's column/x gate. Rows truncated away by the +// sidebar's height budget (or colliding with the token floor line) never match. +func (m model) fileRowAtMouse(msg tea.MouseMsg) (string, bool) { + if !m.sidebarActive() { + return "", false + } + if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() { + return "", false + } + sidebarW := sidebarWidth(m.width) + if sidebarW <= 0 { + return "", false + } + x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns + x, y := mouseX(msg), mouseY(msg) + if x < x0 || x >= x0+sidebarW { + return "", false + } + for _, hit := range m.sidebarFileSelectables(sidebarW) { + if hit.lineOffset == y && hit.lineOffset < m.height-1 && hit.path != "" { + return hit.path, true + } + } + return "", false +} + +// rowTouchesSelectedFile reports whether a transcript row's mutation touched +// the currently selected file, so its card renders with the selection tint. +func (m model) rowTouchesSelectedFile(row transcriptRow) bool { + if m.selectedFile == "" || row.kind != rowToolResult { + return false + } + for _, path := range row.changedFiles { + if path == m.selectedFile { + return true + } + } + return false +} + +// lastRowIndexForFile returns the transcript index of the most recent +// tool-result row that touched path, or -1. +func (m model) lastRowIndexForFile(path string) int { + for i := len(m.transcript) - 1; i >= 0; i-- { + row := m.transcript[i] + if row.kind != rowToolResult { + continue + } + for _, p := range row.changedFiles { + if p == path { + return i + } + } + } + return -1 +} + +// selectFile marks path as the selected file and scrolls the transcript so its +// most recent edit card is in view; the card tint comes from the renderers +// reading selectedFile (rowTouchesSelectedFile). +func (m model) selectFile(path string) model { + m.selectedFile = path + if offset, ok := m.scrollOffsetForTranscriptRow(m.lastRowIndexForFile(path)); ok { + m.chatScrollOffset = offset + if offset == 0 { + m.chatBodyLines = 0 + } + } + return m +} + +// scrollOffsetForTranscriptRow computes the chatScrollOffset that places the +// given transcript row's first rendered line at the top of the viewport (with +// one line of breathing room), using the same layout metrics the scroll engine +// itself uses. ok is false outside alt-screen or when the row isn't rendered +// (e.g. skipped/collapsed). +func (m model) scrollOffsetForTranscriptRow(rowIndex int) (int, bool) { + if rowIndex < 0 || !m.altScreen || m.height <= 0 { + return 0, false + } + width := m.chatColumnWidth() + items := m.transcriptBodyItems(width, "") + metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights) + startY := -1 + for _, span := range metrics.spans { + if span.kind == transcriptBodyItemRow && span.rowIndex == rowIndex { + startY = span.startY + break + } + } + if startY < 0 { + return 0, false + } + frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width)) + viewport := transcriptViewportForLayout(metrics, frame, m.chatScrollOffset) + // The offset counts lines below the fold: window.start == total-height-offset, + // so placing startY at the window top (minus one context line) means + // offset = total - height - (startY - 1), clamped to the scroll range. + offset := metrics.totalLines() - frame.bodyRect.height - (startY - 1) + return clampInt(offset, 0, viewport.maxOffset()), true +} + +// truncatePathLeft fits a path into room cells by dropping leading components +// ("internal/tui/sidebar.go" → "…/tui/sidebar.go"), keeping the filename — the +// part that identifies the file — intact for as long as possible. +func truncatePathLeft(path string, room int) string { + if room <= 0 { + return "" + } + if len(path) <= room { + return path + } + const ellipsis = "…/" + parts := strings.Split(path, "/") + for start := 1; start < len(parts); start++ { + candidate := ellipsis + strings.Join(parts[start:], "/") + if len(candidate) <= room { + return candidate + } + } + // Even "…/name" overflows: fall back to a right-truncated basename. + return truncateRunes(ellipsis+parts[len(parts)-1], room) +} diff --git a/internal/tui/files_panel_test.go b/internal/tui/files_panel_test.go new file mode 100644 index 000000000..8c9b96e3f --- /dev/null +++ b/internal/tui/files_panel_test.go @@ -0,0 +1,232 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/tools" +) + +// filesPanelTestModel is sidebarTestModel plus a couple of touched files: one +// created, one edited twice (the second touch most recent). +func filesPanelTestModel() model { + m := sidebarTestModel() + m.transcript = append(m.transcript, + transcriptRow{kind: rowToolResult, tool: "write_file", id: "f1", status: tools.StatusOK, + text: "tool result: write_file ok Created web/app.js (10 lines).", + detail: "+let a = 1\n+let b = 2", + changedFiles: []string{"web/app.js"}}, + transcriptRow{kind: rowToolResult, tool: "edit_file", id: "f2", status: tools.StatusOK, + text: "tool result: edit_file ok Edited internal/tui/sidebar.go.", + detail: "+added one\n-removed one", + changedFiles: []string{"internal/tui/sidebar.go"}}, + transcriptRow{kind: rowToolResult, tool: "edit_file", id: "f3", status: tools.StatusOK, + text: "tool result: edit_file ok Edited internal/tui/sidebar.go.", + detail: "+two\n+three\n-gone", + changedFiles: []string{"internal/tui/sidebar.go"}}, + ) + return m +} + +// TestTouchedFilesAggregates: the roster is recovered from tool-result rows, +// newest-touch first, with per-file diffstat totals, edit counts, and the +// created badge from write_file's confirmation text. +func TestTouchedFilesAggregates(t *testing.T) { + m := filesPanelTestModel() + files := m.touchedFiles() + if len(files) != 2 { + t.Fatalf("expected 2 touched files, got %d: %+v", len(files), files) + } + // sidebar.go was touched last, so it lists first. + if files[0].path != "internal/tui/sidebar.go" { + t.Fatalf("most recently touched file should list first, got %q", files[0].path) + } + if files[0].adds != 3 || files[0].dels != 2 || files[0].edits != 2 { + t.Errorf("sidebar.go diffstat = +%d −%d over %d edits, want +3 −2 over 2", files[0].adds, files[0].dels, files[0].edits) + } + if files[0].created { + t.Error("edited file must not read as created") + } + if !files[1].created { + t.Error("write_file 'Created …' result should mark the file created") + } + if files[0].lastRowIndex != len(m.transcript)-1 { + t.Errorf("lastRowIndex = %d, want the final transcript row", files[0].lastRowIndex) + } +} + +// TestSidebarFileLinesRenderAndOverflow: rows carry the badge + left-truncated +// path + diffstat; beyond maxSidebarFiles the tail collapses into "+N more"; +// hits index only real file rows. +func TestSidebarFileLinesRenderAndOverflow(t *testing.T) { + m := filesPanelTestModel() + lines, hits := m.sidebarFileLines(34) + joined := plainRender(t, strings.Join(lines, "\n")) + if !strings.Contains(joined, "sidebar.go") || !strings.Contains(joined, "app.js") { + t.Fatalf("file rows missing paths:\n%s", joined) + } + if !strings.Contains(joined, "+3 −2") { + t.Errorf("expected aggregated diffstat +3 −2:\n%s", joined) + } + if !strings.Contains(joined, "A") || !strings.Contains(joined, "M") { + t.Errorf("expected A and M badges:\n%s", joined) + } + if len(hits) != 2 { + t.Fatalf("expected 2 clickable hits, got %d", len(hits)) + } + if hits[0].path != "internal/tui/sidebar.go" { + t.Errorf("first hit should be the newest file, got %q", hits[0].path) + } + + // Overflow: 8 distinct files -> maxSidebarFiles rows + a "+2 more" trailer. + over := sidebarTestModel() + for _, name := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} { + over.transcript = append(over.transcript, transcriptRow{ + kind: rowToolResult, tool: "edit_file", id: name, status: tools.StatusOK, + changedFiles: []string{name + ".go"}, + }) + } + overLines, overHits := over.sidebarFileLines(34) + if len(overHits) != maxSidebarFiles { + t.Fatalf("expected %d clickable rows, got %d", maxSidebarFiles, len(overHits)) + } + if got := plainRender(t, overLines[len(overLines)-1]); !strings.Contains(got, "+2 more") { + t.Errorf("expected '+2 more' trailer, got %q", got) + } +} + +// TestSidebarFilesLivePulseRow: while a mutating tool call streams its args, +// the file it is writing shows as a pinned (unclickable) pulse row. +func TestSidebarFilesLivePulseRow(t *testing.T) { + m := filesPanelTestModel() + m.streamCallName = "write_file" + m.streamCallDecoder = newStreamingDecoder() + m.streamCallDecoder.feed(`{"path":"web/new.css","content":"body{}`) + + lines, hits := m.sidebarFileLines(34) + if got := plainRender(t, lines[0]); !strings.Contains(got, "web/new.css") { + t.Fatalf("live write should pin its path atop FILES, got %q", got) + } + for _, hit := range hits { + if hit.path == "web/new.css" { + t.Error("the live row must not be clickable (no result exists yet)") + } + } + // A non-mutating streaming call shows no pulse row. + m.streamCallName = "read_file" + if m.liveEditingPath() != "" { + t.Error("read_file must not read as a live edit") + } +} + +// TestSidebarFileSelectablesMatchRenderedRows: the hit offsets computed by +// sidebarFileSelectables must land exactly on the rendered FILES rows in +// renderContextSidebar's output — the same invariant the mouse hit-test relies +// on. Asserted against the real render, not a re-derivation of the math. +func TestSidebarFileSelectablesMatchRenderedRows(t *testing.T) { + m := filesPanelTestModel() + width := sidebarWidth(m.width) + rendered := m.renderContextSidebar(width, m.height) + hits := m.sidebarFileSelectables(width) + if len(hits) == 0 { + t.Fatal("expected clickable FILES rows") + } + for _, hit := range hits { + if hit.lineOffset >= len(rendered) { + t.Fatalf("hit offset %d beyond rendered sidebar (%d lines)", hit.lineOffset, len(rendered)) + } + line := plainRender(t, rendered[hit.lineOffset]) + base := hit.path[strings.LastIndex(hit.path, "/")+1:] + if !strings.Contains(line, base) { + t.Errorf("hit for %q points at line %d which reads %q", hit.path, hit.lineOffset, line) + } + } +} + +// TestFileRowClickSelectsThenOpens: the first sidebar click on a file selects +// it (tint + scroll state, no view swap); the second click on the same file +// opens the drill-in; clicking another file while the view is open switches it. +func TestFileRowClickSelectsThenOpens(t *testing.T) { + m := filesPanelTestModel() + width := sidebarWidth(m.width) + hits := m.sidebarFileSelectables(width) + if len(hits) < 2 { + t.Fatal("expected two clickable FILES rows") + } + x := m.chatColumnWidth() + 3 + click := func(hit fileHit) tea.MouseMsg { return testMouseClick(tea.MouseLeft, x, hit.lineOffset) } + + m1, _, handled := m.handleTranscriptSelectionMouse(click(hits[0])) + if !handled { + t.Fatal("FILES row click should be handled") + } + if m1.selectedFile != hits[0].path || m1.fileView.active { + t.Fatalf("first click should select (not open): selected=%q active=%v", m1.selectedFile, m1.fileView.active) + } + + m2, _, _ := m1.handleTranscriptSelectionMouse(click(hits[0])) + if !m2.fileView.active || m2.fileView.path != hits[0].path { + t.Fatalf("second click should open the file view: %+v", m2.fileView) + } + + m3, _, _ := m2.handleTranscriptSelectionMouse(click(hits[1])) + if !m3.fileView.active || m3.fileView.path != hits[1].path { + t.Fatalf("clicking another file with the view open should switch it: %+v", m3.fileView) + } + if m3.selectedFile != hits[1].path { + t.Errorf("switching the view should move the selection too, got %q", m3.selectedFile) + } +} + +// TestRowTouchesSelectedFileTint: only tool-result rows whose changedFiles +// carry the selected path tint, and the render cache key differs across +// selection states so the tint can't be served stale. +func TestRowTouchesSelectedFileTint(t *testing.T) { + m := filesPanelTestModel() + row := m.transcript[len(m.transcript)-1] + if m.rowTouchesSelectedFile(row) { + t.Fatal("nothing selected: no row should tint") + } + m.selectedFile = "internal/tui/sidebar.go" + if !m.rowTouchesSelectedFile(row) { + t.Fatal("selected file's edit row should tint") + } + other := m.transcript[len(m.transcript)-3] // web/app.js + if m.rowTouchesSelectedFile(other) { + t.Fatal("an unrelated file's row must not tint") + } + + rc := buildRowContext(m.transcript) + opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd} + selectedKey, _ := m.renderRowCacheKey(row, 80, rc, opts, false) + m.selectedFile = "" + plainKey, _ := m.renderRowCacheKey(row, 80, rc, opts, false) + if selectedKey == plainKey { + t.Error("render cache key must change with the selection state") + } +} + +// TestEscClearsFileSelectionThenNothing: Esc drops an active file selection +// (before any run-level action) and is consumed doing so. +func TestEscClearsFileSelectionThenNothing(t *testing.T) { + m := filesPanelTestModel() + m.selectedFile = "web/app.js" + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if got := updated.(model).selectedFile; got != "" { + t.Fatalf("Esc should clear the file selection, still %q", got) + } +} + +func TestTruncatePathLeft(t *testing.T) { + if got := truncatePathLeft("internal/tui/sidebar.go", 40); got != "internal/tui/sidebar.go" { + t.Errorf("short path should pass through, got %q", got) + } + if got := truncatePathLeft("internal/tui/sidebar.go", 18); got != "…/tui/sidebar.go" { + t.Errorf("want …/tui/sidebar.go, got %q", got) + } + if got := truncatePathLeft("internal/tui/sidebar.go", 14); got != "…/sidebar.go" { + t.Errorf("want …/sidebar.go, got %q", got) + } +} diff --git a/internal/tui/hover.go b/internal/tui/hover.go index ae142a4de..5ec530e1a 100644 --- a/internal/tui/hover.go +++ b/internal/tui/hover.go @@ -15,6 +15,9 @@ const ( // hoverPlanStep: a plan step row in the sidebar's PLAN section, identified by // step index. hoverPlanStep + // hoverFileRow: a touched-file row in the sidebar's FILES section, identified + // by path. + hoverFileRow ) // hoverTarget identifies the single clickable row (if any) currently under the @@ -34,6 +37,7 @@ type hoverTarget struct { bodyY int // hoverTranscript sessionID string // hoverSidebarAgent stepIndex int // hoverPlanStep + filePath string // hoverFileRow } // mouseHover reports whether msg is a plain cursor-movement event with NO button @@ -59,6 +63,9 @@ func (m model) updateHoverTarget(msg tea.MouseMsg) model { if stepIndex, ok := m.planStepAtMouse(msg); ok { return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex}) } + if path, ok := m.fileRowAtMouse(msg); ok { + return m.withHover(hoverTarget{kind: hoverFileRow, filePath: path}) + } if line, ok := m.transcriptLineAtMouse(msg); ok { // A permission option reuses its OWN existing keyboard-cursor highlight // (see hoverPermissionOption) rather than the m.hover mechanism, so there's diff --git a/internal/tui/model.go b/internal/tui/model.go index 7119414bb..8e6ef5a96 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -209,6 +209,14 @@ type model struct { // sidebar; when set, the chat reflows to full width. Distinct from the // availability conditions in sidebarAvailable (geometry / mode / overlays). sidebarHidden bool + // selectedFile is the touched file selected by clicking its FILES sidebar + // row: its edit cards tint in the chat (rowTouchesSelectedFile) and a second + // click opens the drill-in file view. "" when nothing is selected; Esc clears. + selectedFile string + // fileView is the drill-in view for a touched file (file_view.go): while + // active the chat column's body shows the file's diff/content instead of the + // transcript, mirroring the subchat drill-in. + fileView fileViewState // swarmDoneAt records when each swarm member was first seen finished (done/ // failed) in a swarm_status report, so the sidebar can linger it briefly with a // fading ✓ before dropping it (a smooth exit, not an abrupt pop). Stamped in the @@ -1030,6 +1038,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleCtrlC() case keyCtrl(msg, 'o'): return m.toggleDetailedTranscript(), nil + case m.fileView.active && m.composerValue() == "" && (keyText(msg) == "d" || keyText(msg) == "f"): + // Mode toggle for the file drill-in, only while the composer is empty + // so mid-sentence typing is never hijacked. + if keyText(msg) == "f" { + return m.setFileViewMode(fileViewFull), nil + } + return m.setFileViewMode(fileViewDiff), nil case keyCtrl(msg, 'e'): // Release/recapture the mouse so the user can drag-select and copy text // natively (mouse capture otherwise intercepts terminal selection). @@ -1054,6 +1069,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript return m, nil } + // File drill-in exits on Esc (returns to the chat at its saved scroll + // position); the file stays selected so a second Esc clears that. + if m.fileView.active { + return m.exitFileView(), nil + } if m.mcpCommandCancel != nil { m.cancelMCPCommand() if m.mcpAddWizard != nil { @@ -1110,6 +1130,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.suggestionsActive() { return m.dismissSuggestions(), nil } + // A selected FILES row clears before anything run-related: the + // selection is a passive highlight, so Esc dropping it is cheap and + // expected (mirrors how editors clear selection on Esc). + if m.selectedFile != "" { + m.selectedFile = "" + return m, nil + } if m.hasQueuedMessage() { return m.clearQueuedMessage(), nil } @@ -2162,6 +2189,12 @@ func (m model) pinnedTitleBar(width int) string { if !m.altScreen || m.height <= 0 { return "" } + // The file drill-in replaces the title bar with its nav line (path + key + // hints). Both are exactly one line, and every frame computation routes + // through here, so the swap never desyncs the viewport geometry. + if m.fileView.active { + return m.fileViewNavBar(width) + } return m.titleBar(width) } @@ -4245,13 +4278,14 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str } } row := transcriptRow{ - kind: rowToolResult, - id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]), - text: toolResultRowText(result), - tool: result.Name, - status: result.Status, - detail: toolResultDetail(result), - runID: runID, + kind: rowToolResult, + id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]), + text: toolResultRowText(result), + tool: result.Name, + status: result.Status, + detail: toolResultDetail(result), + runID: runID, + changedFiles: result.ChangedFiles, } // A Task result is shown by the specialist card, and update_plan by the // plan panel/sidebar, so skip both redundant transcript rows. diff --git a/internal/tui/render_cache.go b/internal/tui/render_cache.go index 18885d79a..0c8937392 100644 --- a/internal/tui/render_cache.go +++ b/internal/tui/render_cache.go @@ -164,6 +164,9 @@ func (m model) renderRowCacheKey(row transcriptRow, width int, rc rowContext, op appendRenderCacheField(&b, strconv.FormatBool(row.final)) appendRenderCacheField(&b, strconv.Itoa(row.turnTools)) appendRenderCacheField(&b, strconv.FormatInt(int64(row.turnElapsed), 10)) + // The FILES selection tints this row's card border, so selecting/deselecting + // a file must miss the cache entry rendered under the other state. + appendRenderCacheField(&b, strconv.FormatBool(m.rowTouchesSelectedFile(row))) key := rcKey(row.runID, row.id) appendRenderCacheField(&b, rc.hints[key]) diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index 2a9e37f9f..48e34da7c 100644 --- a/internal/tui/rendering.go +++ b/internal/tui/rendering.go @@ -167,6 +167,10 @@ type cardRenderOptions struct { bodyCap int cwd string expanded bool + // fileSelected marks a tool-result card whose mutation touched the file + // selected in the FILES sidebar; the card border tints accent so the + // selection reads in the transcript. + fileSelected bool } // flushCardBodyMaxLines is the body cap for cards flushed to scrollback. The @@ -210,6 +214,10 @@ func (m model) renderRowMode(row transcriptRow, width int, rc rowContext, flush } func (m model) renderRowModeUncached(row transcriptRow, width int, rc rowContext, opts cardRenderOptions) string { + // Resolved per-row (not at the opts construction sites) so every render + // path — live, flush, detailed — carries the FILES selection tint; the + // cache key includes the same predicate (renderRowCacheKey). + opts.fileSelected = m.rowTouchesSelectedFile(row) switch row.kind { case rowUser: return renderUserRow(row, width) @@ -1390,6 +1398,11 @@ func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts card glyph := zeroTheme.green.Render("•") nameStyle := zeroTheme.green borderStyle := zeroTheme.line + if opts.fileSelected { + // The selected FILES row's edit card: accent border, same as the + // sidebar's ▸ marker, so click → highlight reads as one gesture. + borderStyle = zeroTheme.accent + } if failed { glyph = zeroTheme.red.Render("•") nameStyle = zeroTheme.red diff --git a/internal/tui/session.go b/internal/tui/session.go index bd047a79f..4332f7600 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -444,12 +444,13 @@ func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow { } output := payloadString(payload, "output") rows = append(rows, transcriptRow{ - kind: rowToolResult, - id: effectiveToolRowID(id, callSeq[id]), - text: fmt.Sprintf("tool result: %s %s %s", name, status, truncateTUIOutput(output, tuiToolOutputLimit)), - tool: name, - status: status, - detail: output, + kind: rowToolResult, + id: effectiveToolRowID(id, callSeq[id]), + text: fmt.Sprintf("tool result: %s %s %s", name, status, truncateTUIOutput(output, tuiToolOutputLimit)), + tool: name, + status: status, + detail: output, + changedFiles: payloadStringSlice(payload, "changedFiles"), }) case sessions.EventError: if message := payloadString(payload, "message"); message != "" { @@ -619,6 +620,28 @@ func payloadString(payload map[string]any, key string) string { } } +// payloadStringSlice reads a []string persisted into a session payload (JSON +// round-trips it as []any), skipping non-string entries. Nil when absent. +func payloadStringSlice(payload map[string]any, key string) []string { + switch typed := payload[key].(type) { + case []string: + return typed + case []any: + out := make([]string, 0, len(typed)) + for _, v := range typed { + if s, ok := v.(string); ok && s != "" { + out = append(out, s) + } + } + if len(out) == 0 { + return nil + } + return out + default: + return nil + } +} + func payloadBool(payload map[string]any, key string) bool { value := payload[key] switch typed := value.(type) { diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index 9755dd156..a38ca4239 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -97,6 +97,9 @@ func (m model) sidebarHasContent() bool { if len(m.sidebarSpecialists()) > 0 || len(m.swarmSpawnedAgents()) > 0 { return true } + if len(m.touchedFiles()) > 0 { + return true + } return !m.plan.isEmpty() } @@ -587,6 +590,19 @@ func (m model) renderContextSidebar(width, height int) []string { lines = append(lines, planLines...) } + // FILES section: the files this session has touched (files_panel.go). + // Rendered BELOW the plan steps so it never shifts sidebarPlanSelectables' + // click offsets; its own hits (sidebarFileSelectables) account for the + // sections above it. + add("") + add(m.sidebarFilesHeader(width)) + fileLines, _ := m.sidebarFileLines(width) + if len(fileLines) == 0 { + add(sidebarPlaceholder("no files touched", width)) + } else { + lines = append(lines, fileLines...) + } + // ACTIVITY section: recent completed work + a live "generating…" pulse. Shown // BELOW the plan steps so it never shifts sidebarPlanSelectables' click offsets, // and budgeted (height-1 minus what's used) so it clips ITSELF from the bottom @@ -651,6 +667,12 @@ func (m model) hoveredSidebarLineOffset(width int) (int, bool) { return hit.lineOffset, true } } + case hoverFileRow: + for _, hit := range m.sidebarFileSelectables(width) { + if hit.path == m.hover.filePath { + return hit.lineOffset, true + } + } } return 0, false } diff --git a/internal/tui/transcript.go b/internal/tui/transcript.go index 0dd3505ba..431af311a 100644 --- a/internal/tui/transcript.go +++ b/internal/tui/transcript.go @@ -41,6 +41,11 @@ type transcriptRow struct { askUser *agent.AskUserRequest expanded bool // collapsible transcript rows, e.g. provider thoughts + // changedFiles lists the workspace-relative paths a mutating tool result + // wrote (from tools.Result.ChangedFiles; restored from the session payload on + // resume). The sidebar FILES section derives its roster from these. + changedFiles []string + // specialistInfo holds the specialist card data for rowSpecialist rows. // Nil for all other row kinds. specialistInfo *specialistInfo diff --git a/internal/tui/transcript_selection.go b/internal/tui/transcript_selection.go index 35e5f4aa8..f858da4a4 100644 --- a/internal/tui/transcript_selection.go +++ b/internal/tui/transcript_selection.go @@ -211,6 +211,12 @@ func (m model) renderHoverHighlight(rendered string, selectable []transcriptSele } func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptBodyItem { + // File drill-in: the chat column's body swaps to the viewed file's + // diff/content. Swapping HERE (the single source every consumer reads) keeps + // the viewport, scroll engine, renderer, and mouse hit-tests consistent. + if m.fileView.active { + return m.fileViewBodyItems(width) + } items := []transcriptBodyItem{} // Transcript ROWS render at the full chat width; row/status glyphs provide // structure without adding another body margin. Block items (title bar, empty @@ -1242,6 +1248,9 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd, // session, reusing the specialist-card subchat path. Checked before the // transcript hit-test since the sidebar is outside the chat column. if hit, ok := m.sidebarLineAtMouse(msg); ok { + // The subchat drill-in owns the whole (single-column) view; a file + // drill-in can't meaningfully stay open behind it. + m = m.exitFileView() if errMsg := m.subchat.enter(m.sessionStore, hit.sessionID, hit.title, m.chatScrollOffset); errMsg != "" { m = m.appendSystemNotice(errMsg) } @@ -1252,10 +1261,24 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd, // A click on a PLAN step row drops a transcript card listing the file // changes captured while that step was in progress. if stepIndex, ok := m.planStepAtMouse(msg); ok { + // The card lands in the chat transcript; close the file drill-in so + // it isn't appended invisibly behind the swapped body. + m = m.exitFileView() var cmd tea.Cmd m, cmd = m.openPlanStepDetail(stepIndex) return m, cmd, true } + // A click on a FILES row: first click selects the file (its edit cards + // tint and the chat scrolls to the most recent one); a click on the + // already-selected file — or any FILES click while the drill-in is open — + // opens/switches the file view. + if path, ok := m.fileRowAtMouse(msg); ok { + if m.fileView.active || m.selectedFile == path { + m.selectedFile = path + return m.openFileView(path), nil, true + } + return m.selectFile(path), nil, true + } line, ok := m.transcriptLineAtMouse(msg) if !ok { if m.transcriptSelection.active { From 1fdbcc0e682b7a82a726c77e4172af6b4311c11c Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Thu, 2 Jul 2026 09:52:12 +0800 Subject: [PATCH 2/2] feat(tui): git sweep so shell-created files reach the FILES sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FILES roster was fed only by changedFiles on write_file/edit_file/ apply_patch results — but agents that scaffold through exec_command/bash (npm create, heredoc writes) or delegate to subagents produce no such results, so a whole build could touch dozens of files and the panel stayed empty. Ask git instead: Init takes a baseline `git status --porcelain --untracked-files=all` snapshot (pre-existing dirt never shows), and a re-sweep after each command-tool result and at turn end merges any newly dirty paths into the roster, with a diffstat from `git diff HEAD --numstat` for tracked files. Untracked-files=all matters: plain porcelain collapses a new directory to "dir/", which is useless as a FILES row. Sweeps are single-flight, time-boxed, and latch off in non-git workspaces. A git-only file has no edit cards, so its drill-in opens directly on the full-file view. feat(tui): FILES sidebar section with click-to-select and file drill-in The right context sidebar gains a FILES section between PLAN and ACTIVITY: the workspace files the session has touched, newest first, with an A/M/✗ badge, an aggregated +added/−removed diffstat, and a pulsing row for the file whose write is streaming right now. The roster is recovered on demand from tool-result rows' changedFiles (persisted to the session payload and restored on rehydrate), so it survives /resume and can never drift from the transcript — the same derive-from-rows pattern the swarm AGENTS roster uses. Clicking a row selects the file: its row gets an accent ▸, its edit cards tint accent in the chat, and the transcript scrolls to the most recent one. A second click (or any FILES click while the view is open) opens a drill-in reusing the subchat pattern — the chat column body swaps to the file's stacked full-depth edit cards ("edit N of M"), f toggles to the syntax-highlighted on-disk file with ▎ markers on session-added lines, d back to diff, Esc returns to the chat at its saved scroll position; a second Esc clears the selection. The body swap happens inside transcriptBodyItems and the nav bar replaces the pinned title bar, so the scroll engine, viewport, and mouse hit-testing stay consistent for free. fix(oauth): make xAI / Hugging Face OAuth actually launch from the wizard (#355) 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__* 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 (#354) * 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 , --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 (#352) * 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 (#353) * 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. revert(local): restore the original bright-lime accent color (#351) 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(tui): subchat mouse selection, hover highlighting, and drag-to-edge auto-scroll (#350) * 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: agent runtime resilience — stream stalls, weak-model tool args, and sub-agent reliability (#349) * 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 (#348) * 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 so they can't hang forever (#347) * 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. test(providers): isolate the OAuth store in the chatgpt Codex routing test (#346) 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. fix: stream Codex reasoning + drop swarm agents on their own completion (#345) 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 agent_type to the registered roster via a dynamic enum (#344) * 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. fix(openai): always send message content so strict OpenAI-compatible servers accept it (#343) 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: '. 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. Document source-build sandbox helpers (#342) 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 fix(opengateway): correct gateway host, make it the recommended default (#341) 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 Co-authored-by: Claude Opus 4.8 (1M context) chore(launch): MIT license, windows-arm64 install guard, TUI TTY guard (#340) * 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. Clean up public release docs (#339) 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 (#338) * 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. feat(tui): remove the /mode command (superseded by /model + /effort) (#337) 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 TUI transcript rendering (#334) * 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 fix(openai): forward reasoning effort to the Codex Responses API (#336) 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). fix(modelregistry): one source of truth for a model's reasoning efforts (#335) 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. feat: context-aware compaction + usage gauge for all models (#332) * 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. feat(tui): dim the transcript backdrop behind overlays for visibility (#333) 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. feat(tui): compact one-line /model switch confirmation (#331) 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 — ' · [ · effort …][ · saved]' — keeping the deprecation/redirect notice line when present. Tests updated to the new format. fix(model): expose reasoning effort for GPT-5 / Codex / o-series (#330) /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. feat: encrypted provider credentials + provider-aware /model (#329) 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 ` 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 in the composer with suggested answers (#327) * 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. fix(tui): paint transcript selection once, not twice (#328) 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): ask_user suggested answers with a recommended default (#326) * 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 (#324) * 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): gate headless run completion on honesty (no false-success on no-tool-call / self-reported-incomplete turns) (#325) * 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. Improve sandbox permission handling (#323) 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 sandbox command approval behavior (#322) * 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 (Intel macOS + Linux arm64) (#321) * 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. chore: launch-readiness — add MIT LICENSE + reconcile README (#320) * 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 (#319) * 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 feat(tui): clickable plan-step detail + plan/sidebar UX & rendering polish (#315) * 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. feat(agent): preamble before multi-step tasks for a readable chat (#317) 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. feat(tools): show red/green diff preview on write_file and edit_file (#318) 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. fix(tui): stop ANSI escape leaks + drop redundant update_plan cards (#316) 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) (#313) * 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 (#312) * 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). Fix shared temp roots in sandbox scope (#314) 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 (#311) * 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 ` 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 (#310) * 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. fix(tui): keep running swarm subagents in the AGENTS sidebar (#309) 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 (#308) * 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 "↑ 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. chore: scrub named other-CLI references from the codebase (#307) * 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. fix(tui): stop showing the token count on both sides (#306) 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. feat(tui): show provider on each model-picker row (#305) 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. Fix token accounting and usage display (#304) fix(sandbox): retry approved network shell commands (#301) * fix(sandbox): retry approved network shell commands * test(sandbox): fix portable smoke expectations * fix(sandbox): restore Windows offline egress block fix(tui): cap the expanded live reasoning to ~half screen (#303) 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 (#302) * fix(sandbox): grant ancestor metadata so cd into the workspace works macOS seatbelt granted file-read* on each read root via (subpath ""), 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: : 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 (#300) * 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. fix: update ChatGPT Codex OAuth request handling (#299) feat(sandbox): online/offline Windows network identity — run approved network commands sandboxed (#297) * 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): stop bricking Windows when the sandbox isn't set up (degrade like Linux/macOS) (#295) * 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. Fix TUI UX issues (#290) * 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 fix(sandbox): let macOS sandbox run Homebrew/usr-local tools (node, python3) (#296) 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. fix(sandbox): stop the interactive guard from blocking 'node --version' & friends (#294) 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. feat(tui): declutter the chat — drop the gimmicky billboard chrome (#291) 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, reduced-motion gate (#289) * 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. feat(tui): make tool results and selections easier to scan at a glance (#287) 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): Agent Client Protocol surface — drive ZERO as an editor backend (#288) * 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. fix: behavioral-audit follow-ups (exec -p, cron/version/specialist UX, update_plan coercion, Makefile) (#286) 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 ` 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). fix(providers): make the stream idle timeout global, configurable, and less aggressive (#285) 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. feat(npm): publish @gitlawb/zero with a postinstall binary installer (#284) 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. fix(config): skip an unresolvable non-active provider instead of crashing startup (#283) 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. fix(sandbox): self-dispatch the Windows sandbox helpers so they work in dev (#280) 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 Windows sandbox helpers (#278) 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. fix: resolve product/UX audit findings for OSS launch (#282) 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. fix: resolve 15 findings from the 2026-06-20 deep audit (#281) * 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 test(tools): make write_stdin "\x03" the trigger in the interrupt test (#273) 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: five capability features from the reference-agent comparison (#276) * feat(commands): user-defined slash commands from markdown files Drop a markdown file at `.zero/commands/.md` (project, shared with the team) or `/zero/commands/.md` (personal); typing `/ 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 `). 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): make ChatGPT (Codex) login work end-to-end (#264) * 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. * 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. * 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) Fix custom provider model discovery (#277) Audit tail: remaining medium/low correctness fixes (CLI, config, OAuth, tools, LSP, TUI) (#275) * 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 ""` now forwards the prompt as the inline `--prompt=` 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, and single-instance lock (#274) * 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 (#263) * 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). Add managed background terminals (#270) * Add managed background terminal sessions * Stop background terminals on TUI exit * Cover approved local network sandbox mode * Polish background terminal rendering * Tighten exec session cleanup guidance * Address background terminal review feedback * Kill exec session trees on Windows * Trigger CI for Windows cleanup feat: clipboard image paste + provider 400 handler for image rejection (#268) * 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). * 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) feat(tui): transcript polish — left-rule cards, tool-call density, pinned plan (#269) * 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. * 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. * 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) feat(tui): `?` keyboard-shortcut overlay (#271) 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) fix(tui): give command-info screens real contrast (not flat grey) (#272) /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) Add persistent exec command sessions (#267) * 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 transcript dedup (#262) * tui: live code-streaming preview, theme system, plan/Task dedup - Live "writing " 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. fix five concurrency/leak bugs (swarm, specialist, oauth, mcp, cron) (#265) 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. fix model-discovery fallback + redact opaque Authorization tokens (#266) 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. audit: fix four high-severity concurrency/correctness bugs (#261) 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²) hot paths, /style wiring (#260) * 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 (#255) * 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 tools: tool_search redirects eager-tool lookups instead of "no tools matched" (#259) 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: ". 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 — " 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. tools: defer swarm tool schemas + keyless SearXNG web_search backend (#258) 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. Align sandbox permission blocks and backend states (#253) * 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 (read_minified_file) (#256) * 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 (elapsed) + streaming reasoning preview so long turns never look frozen (#254) * 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): Hugging Face + ChatGPT Codex as first-class built-in OAuth presets (#245) * 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: `, 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. Persist command prefix approvals (#252) 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 Add session command prefix approvals (#250) 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 ./... sessions: make the events log authoritative for sequencing + fsync checkpoints/rewind (#251) 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 (#249) * 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 (#248) * 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 (#243) * 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 "@ " 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 (#247) * 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 to stop eager MCP-schema token waste (#241) * 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 (#246) * 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 (#244) * 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 Align permission prompts for web tools (#242) 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 (#240) * 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 feat: free out-of-the-box web (keyless Firecrawl default) + resilient MCP startup (#239) * 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 user-facing 'Extending Zero' guide (#237) * 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: add `providers detect` and `sandbox check` (wire audit-found unwired features) + coverage (#238) * 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 ` 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): clear error when a model host is unreachable (#233) * 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 (#229) * 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. * 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() -> sets effort, shows confirmation card - handleEffortCommand() -> 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 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Improve /resume: interactive session picker + model-generated titles (#224) * 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 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 + " 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. tui: right-click pastes the clipboard directly (no menu) (#231) * 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). fix(tui): selectable + clickable permission popup (#232) 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. Render transcript viewport from measured items (#227) 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 ./..." Harden TUI layout and mouse routing (#226) * 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 (#221) * 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). * 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 feat(tui): age-based fade for streaming assistant text (#223) * 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 Fix provider setup paste handling (#225) TUI polish and fixes (#220) * Persist TUI model switches * Pin TUI title bar in chat view Migrate TUI to Bubble Tea v2 (#222) * 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 OAuth / account login for providers (OpenRouter, xAI) + subscription-proxy support (#217) * 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 `, 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 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__* 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__* 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__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. Audit backlog: tui mouse API, notify webhook, swarm scheduling, daemon-remote bundles, MCP/oauth store unification, OS keyring, dead-code prune (#216) * 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 /. 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 ` enables uploads. - `daemon link --remote --repo

--id [--out ]` uploads and prints the remote path (+ a ready run command); `daemon link --show ` 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:". 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 (#213) * 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 (.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 robustness + chat UX: collapse, clipboard, scroll-pin, proactive search (#218) * 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 `. 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 (#219) * 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 Polish assistant response transcript (#204) * Polish assistant response transcript * Keep copy feedback above composer feat(daemon): TLS remote bridge — authenticated network access to the daemon (#211) * 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 [--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 (#210) * 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__* 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:" / "mcp:"), 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 [--device] [--scope ...], logout, status [provider], refresh [--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 (#209) * 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. * 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: Vasanthdev2004 Co-authored-by: Claude feat(swarm): multi-agent swarm over the specialist sub-agent (#207) * feat(swarm): multi-agent swarm over the specialist sub-agent Add internal/swarm so an orchestrator can spawn and coordinate MULTIPLE specialist members concurrently under one team, communicate via per-agent mailboxes, hand work off, and adopt orphaned tasks. Additive and opt-in: the single Task tool is unchanged; swarm tools only act when invoked. - Registry/Definition: agent roster (built-in teammate/subagent + user-defined). - Mailbox: per-team/per-agent JSON inbox, owner-only 0600, file-locked with stale-break, size-capped, path-confined, and malformed-fails-closed. - Coordinator: in-memory task registry with terminal-state guards and stable per-agent colors. - Team: bounded concurrent members per team with a FIFO overflow queue that drains one-per-slot. - Lifecycle: spawn / handoff / orphan-adoption with bounded relaunch on temporary failures; members run under the Swarm's base context so they outlive the spawn call. - Tools: swarm_spawn, swarm_send, swarm_inbox, swarm_status, swarm_handoff, swarm_collect. - Wiring: each member is launched through specialist.Executor and inherits the orchestrator's model + permission mode (never widened) and runs under the same sandbox/policy. Mailbox state lives under the workspace so writes fall within the sandbox write rules. Swarm lifetime is paired with the specialist runtime. Scheduling (cron/wakeup) and daemon-pool-backed members are deferred follow-ups. * swarm: address CodeRabbit review (7 findings) - coordinator: SetStatus rejects unknown TaskStatus values (fail closed) so a task can never enter a state Summarize/lifecycle don't reason about. - permission inheritance ENFORCED end-to-end (was resolved but discarded): thread PermissionMode through specialist.TaskRunOptions -> BuildArgs/ BuildResumeArgs. A headless specialist child now runs "--auto high" (unsafe) ONLY when the parent is unsafe or no mode is given (preserves the Task tool's behavior); any other parent mode yields non-unsafe "--auto low", so a member never gains more authority than its orchestrator. Also fixed the swarm permission vocabulary to the real agent runtime modes (ask/spec-draft/auto/ unsafe) instead of TUI display names, and the launcher substitutes "auto" for an empty mode so a member can't fall into the empty-means-high path. - mailbox: symlink-aware confinement (EvalSymlinks) rejects an inbox dir that resolves outside BaseDir; dirs tightened to 0700 even if pre-existing with broad perms; lock release is ownership-token-aware so a stale-broken writer can't delete a newer writer's lock (split-brain). - member: recover panics inside a member goroutine -> fails that member only, never crashes the orchestrator. - tools: collapse() truncates on rune boundaries (no invalid UTF-8). Tests added for every fix. Existing specialist behavior is unchanged (empty PermissionMode keeps --auto high). Gates: gofmt/vet/build (host+linux)/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new) all pass. * swarm: address review round 2 (Windows lock + non-blocking notes) Blocking (Vasanth): mailbox acquireLock now treats Windows ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION as lock-contended (retry/wait) instead of a fatal error, via a platform-aware isLockContended (lock_windows.go / lock_other.go). A contended lock under Windows file-sharing semantics no longer fails a send/read spuriously. TestMailboxConcurrentSends raised to 200 goroutines with a 10s lock timeout and now asserts zero send failures (regression guard). Non-blocking notes addressed: - Removed third-party reference-project names from doc comments (clean-room + provider-neutral); the Go code is original. - Documented the inbox-file size bound's ×2 (pretty-print overhead) constant. - Documented Send's O(N) rewrite cost + when to switch to append-only. - permissionRank: spec-draft now ranks below ask (it never widens authority). - Documented watch's per-member-id retry binding (Member.ID == MemberSpec.ID). - handoff Safety.Reason now also mentions the inbox write. Gates: gofmt · vet · build (host+linux+windows) · test -race · staticcheck (no new) · govulncheck (0) · deadcode (no new) all pass. feat(sandbox): complete sandbox breadth — WSL fallback + opt-in MITM TLS inspection (#206) * feat(sandbox): complete sandbox breadth — WSL fallback + opt-in MITM TLS inspection Adds the two remaining sandbox features on top of the strict fail-closed core (the other four — SOCKS5 egress, re-entrancy guard, fine-grained path lists, sandbox-aware ripgrep — already shipped and are untouched). Both new features are opt-in and the DEFAULT policy is unchanged (enforce + network deny, InspectTLS off; AllowPolicyOnlyRunner keeps its existing default). 1) WSL detection + safe fail-closed fallback - parseWSL/detectWSL (wsl.go + wsl_linux.go reading /proc/version; wsl_other.go stub) classify WSL/WSL2. - SelectBackend: on Linux without bubblewrap, if running under WSL it selects a new BackendWSL instead of silently degrading to a plain policy-only runner. - BuildCommandPlan(BackendWSL): FAILS CLOSED unless Policy.AllowPolicyOnlyRunner is set (errWSLPolicyOnlyDisabled explains how to opt in). When allowed it runs the policy-only fallback that still routes egress through the filtering proxy (Backend.ProxyEgress => EnforcesScopedEgress, best-effort HTTP+SOCKS), sets ZERO_SANDBOXED=1 / ZERO_SANDBOX_BACKEND=wsl, and records an auditable least-privilege downgrade note (CommandPlan.Notes). Never runs unsandboxed silently. 2) Optional gated MITM TLS inspection (Policy.InspectTLS, default OFF) - Off => the egress proxy is a pure CONNECT passthrough, byte-for-byte unchanged (no CA generated). - On => the proxy terminates TLS toward the sandboxed client with an EPHEMERAL, locally generated ECDSA CA (mitm.go), minting per-host leaves on the fly, so it can re-check the DECRYPTED request Host through the SAME authorize()/domainAllowed() gate (deny-wins, empty allowlist => deny) — MITM widens visibility, never authority. The UPSTREAM dial is ALWAYS validated against the system roots (never InsecureSkipVerify; tests inject a known root pool to exercise validation). The CA's PUBLIC cert is written owner-only and surfaced via ZERO_SANDBOX_CA_CERT; MITM only runs on backends that already permit reading it (sandbox-exec allows file-read; WSL shares the host fs), so no new bind path is introduced. Trust implications documented loudly on the field. (No "raw passthrough only" flag exists in zero, so the mutual-exclusion case is N/A.) Tests: parseWSL table; WSL fail-closed-without-opt-in and policy-only-with-proxy (markers + ProxyEnvWithSocks) + deny-mode markers-no-proxy; MITM passthrough-by- default (no CA), CA mints a chaining leaf, full TLS-terminate-and-forward of an allowlisted host (upstream validated via an explicit root pool), and decrypted- Host deny. Gates: gofmt, go vet (host + GOOS=linux), go build (host/linux/ windows), go test (incl -race), staticcheck (no new, host + linux), govulncheck (0), deadcode -test=false under GOOS=linux (no new unreachable funcs). * sandbox: address review on #206 (WSL + MITM) - mitm: bound the upstream transport's TCP connect (DialContext 30s) so a blackholed upstream can't park a goroutine; disable upstream HTTP/2 and normalize the response to HTTP/1.1 before writing to the HTTP/1.1 client. - mitm: mint leaves for the AUTHORIZED CONNECT host (not arbitrary client SNI) and cap the leaf cache (512) so SNI churn can't drive unbounded keygen/memory. - mitm: re-check the decrypted Host with the FULL authorize gate (authorizeTarget) so a DomainPrompt-allowed host is honored consistently with the CONNECT check, not just domainAllowed. - runner (WSL): preserve the caller's env in the direct-run WSL plan (it is not OS-wrapped), appending the sandbox markers last; the downgrade note says "with proxy egress" only when a proxy was actually started. - tests: WSL caller-env preservation; deny-mode asserts no HTTP/HTTPS/ALL_PROXY leak + the downgrade note; MITM forward asserts HTTP 200; mitmConnect sets an I/O deadline. Gates: gofmt, vet (host+linux), build (host/linux/windows), test (incl -race), staticcheck (no new), govulncheck (0), deadcode -test=false (GOOS=linux) no new. Polish /permissions command output into a bounded card (#205) * Convert /permissions output to bounded command card Replace status: ok style with commandCard format used by /tools and /context. Summary shows mode and grant count; grants render as bullet rows. * Address CodeRabbit nitpick: add error path test for /permissions Adds TestPermissionsCommandCardHandlesGrantListError and a grantLister interface so tests can inject a stub store without a real file path. * Fix gofmt formatting in permissions card Tabs alignment only; no functional change. --------- Co-authored-by: Claude feat(daemon): zero daemon — worker pool + session supervisor over a local control socket (#203) * feat(daemon): add `zero daemon` — worker pool + session supervisor over a local control socket Adds internal/daemon + `zero daemon start|stop|status|run|attach`: a long-running local service that supervises a pool of headless `zero exec` workers and routes multiple agent sessions to them over an owner-only Unix-domain control socket. Additive — interactive and one-shot exec are unchanged. Reuses zero's building blocks: - internal/background: ConfigureChildProcessGroup (process-group setup) + TerminateProcess (cross-platform terminate) for worker kill/drain. The pool does its own os/exec and uses background only for those helpers. - internal/streamjson: the worker speaks stream-json on stdout; the daemon forwards those lines to clients. - internal/cli exec: a worker is `zero exec --output-format stream-json`. Pieces (reimplemented in Go from a reference daemon): - protocol.go: framed control codec — 4-byte BE length + 1 kind byte, 1 MiB cap, JSON control messages, version negotiation. - pool.go + backoff.go: bounded worker pool — at-least-once dispatch with exponential backoff+jitter retry capped by MaxAttempts, EXIT_TEMPFAIL(75)/ EXIT_PERMANENT(76) handling, graceful drain + kill-timeout. zero's exec is one-shot, so workers are on-demand (one session per worker = the lease), not warm long-lived slots (a persistent worker mode is a follow-up). - session.go: SessionManager — sessionID->worker routing, lease (one active request per worker), queue when the pool is full, per-session output buffer for attach + metrics. - server.go + lock.go + socket.go + paths.go: single-instance PID-file lock with stale-lock (dead-PID) recovery, status file, owner-only socket, accept loop; cleans up lock/socket/status on exit. - launcher.go: production worker launcher. client.go: control-socket client. Security model: - Control socket is LOCAL-ONLY (AF_UNIX loopback file, never a TCP port) and owner-only: socket 0600 + parent dir 0700 on POSIX (Windows relies on the per-user profile ACL; a dedicated pipe ACL is a follow-up). - Workers stay sandboxed: the launcher SCRUBS the sandbox re-entrancy markers (ZERO_SANDBOXED / ZERO_SANDBOX_BACKEND) from each worker's env so the worker re-establishes its OWN sandbox instead of inheriting a pass-through plan, while propagating the rest of the policy config/env. The daemon never bypasses the sandbox. - Control frames over the 1 MiB cap are rejected before allocation. Robustness: a crashed worker never takes down the daemon (backoff+jitter retry, MaxAttempts cap, EXIT_PERMANENT stops retry); drain gives a grace window then force-kills stragglers; a stale lock from an unclean exit is reclaimed. Out of scope (follow-up): remote session-linking; warm pre-spawned persistent workers; Windows named-pipe ACL. Tests: protocol round-trip + oversize/unknown-kind rejection; backoff bounds; pool spawn/retry-backoff/permanent/cap/queue/drain; session lease/route/queue/ attach; lock single-instance + stale recovery; launcher env-scrub (incl. an end-to-end check that the spawned worker process receives scrubbed env); server e2e start->run->stream-json->status->attach->stop with a fake worker; CLI usage/validation/not-running paths. Live-verified start/status/stop (0600 socket, 0700 dir, file cleanup). Platform files behind explicit //go:build tags. * daemon: address review — help listing, extra-arg rejection, lock write error, bounded sessions - cli/app.go: list `daemon` in `zero --help` so the command is discoverable. - cli/daemon: `stop`/`status` reject extra arguments; `attach` rejects more than one session id (fail fast on typos instead of silently ignoring tokens). - daemon/lock: check the PID fmt.Fprintf error — a partial/failed write would leave a malformed lock file that another process reads as stale and wrongly reclaims, breaking the single-instance guarantee; on write failure remove the file and return the error. - daemon/session: bound the registry with MaxSessions (default 256) — evict the oldest FINISHED sessions once past the cap so a long-running daemon doesn't accumulate sessions without limit; running/queued sessions are never evicted, so attach-after-finish for recent sessions is preserved. Tests: CLI extra-arg rejection; finished-over-cap eviction + running-never-evicted. Gates: gofmt, vet, build, test (incl -race), staticcheck (no new), govulncheck (0), deadcode (no new), GOOS=linux+windows cross-compile. sandbox: exempt first-party network tools from the Evaluate network-deny too (#202) Follow-up to the EnforceToolNetwork change: web_search/web_fetch were still hard-denied under NetworkDeny because the engine-level gate in Evaluate (netMode==NetworkDeny && risk has "network") fired BEFORE the tool's RunWithSandbox/NetworkHostAllowed ever ran. The earlier change only relaxed NetworkHostAllowed, so the user-facing path (Evaluate -> registry) still blocked the tool with "network access is blocked by sandbox policy". Fix: Evaluate now skips the network-deny for a request that declares SideEffectNetwork (the first-party in-process tools, web_search/web_fetch) when EnforceToolNetwork is off — mirroring the NetworkHostAllowed exemption so the two gates agree. A SHELL command merely classified as network (SideEffectShell) is NOT exempt, so shell egress stays blocked under deny. EnforceToolNetwork restores the strict deny for the tools. Tests: TestEvaluateExemptsNetworkToolsFromDenyByDefault covers the full path (default-exempt, granted->allow, EnforceToolNetwork->deny, shell stays denied); the two existing gate tests now opt into EnforceToolNetwork to keep testing the strict path. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new), govulncheck (0), deadcode (no new unreachable funcs). Polish TUI transcript and reasoning display (#200) * Polish TUI transcript and reasoning display * Address provider and transcript review fixes * Gate inline think tag parsing * Cover disabled think tag parsing config Add command dashboard cards (#201) * Add context command dashboard card * Render tools command as dashboard card * Render command cards as styled TUI cards * Fix command card action rendering * fix: harden command card edge cases feat: add doctor fix flow (#198) * feat: add doctor fix flow * fix: animate doctor connectivity status * fix: make doctor report problem first * fix: address doctor review feedback feat(sandbox): harden internal/sandbox — re-entrancy guard, SOCKS5 egress, sandbox-aware search, fine-grained path lists (#199) * feat(sandbox): harden internal/sandbox with four opt-in capabilities All four are off by default and fail closed; existing behavior is unchanged when the new policy fields are empty / ZERO_SANDBOXED is unset. 1. Re-entrancy guard — ZERO_SANDBOXED=1 marks every wrapped command; a nested BuildCommandPlan returns a pass-through plan (no double bwrap/sandbox-exec, no second egress proxy). New: EnvSandboxed, IsAlreadySandboxed(). 2. SOCKS5 egress — the scoped-egress proxy also serves SOCKS5 CONNECT on a second loopback port through the SAME authorize()/domainAllowed() gate. ProxyEnvWithSocks points ALL_PROXY at socks5://; HTTP(S)_PROXY stay on the HTTP listener; the sandbox-exec profile allows both proxy ports. Bubblewrap scoped still collapses to deny. 3. Sandbox-aware search — ReadExclusionGlobs(policy, scope) returns ripgrep --glob '!' for DenyRead subtrees; grep/glob skip read-denied subtrees at walk time via Engine.ReadPathExcluded/ReadDirExcluded when sandboxed. 4. Fine-grained path lists — Policy.AllowRead/DenyRead/AllowWrite/DenyWrite. Read = deny-then-allow (more-specific AllowRead re-includes); Write = DenyWrite > workspace/scope > AllowWrite > deny. AllowWrite is reflected in the OS write binds; DenyWrite is emitted as sandbox-exec deny rules. Each entry is home-expanded, made absolute, and symlink-resolved. Tests: internal/sandbox/{reentrancy,egress_socks,pathlists}_test.go and internal/tools/read_exclusions_test.go. Gates: gofmt, go vet, go build, go test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode -test=false (no new unreachable funcs). * sandbox: address review — SOCKS5 method negotiation + cache read-exclusion roots - egress_socks: respect SOCKS5 method negotiation (RFC 1928). The proxy now inspects the client's offered methods and replies 0xFF ("no acceptable methods") when no-auth was not offered, instead of forcing no-auth. - pathlists/engine: resolve DenyRead/AllowRead roots ONCE per search walk via a new ReadExclusions matcher (Engine.ReadExclusions), replacing the per-path ReadPathExcluded/ReadDirExcluded that re-ran Abs/EvalSymlinks for every visited path during a grep/glob walk. Re-entrancy guard left as-is by design: ZERO_SANDBOXED is an internal marker set only on commands zero wraps; cross-process re-entrancy detection legitimately relies on the inherited env, and a manually-exported flag is a self-opt-out, not an external bypass. Tests: SOCKS no-acceptable-methods rejection; nested-AllowRead descent via the matcher. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new unreachable funcs). * sandbox: fix windows smoke test + symlink-resolve DirExcluded prefix - pathlists_test: TestSandboxExecProfileEmitsDenyWriteRule now builds the expected deny clause via sandboxProfileString(secret), so it matches the profile's own escaping (Windows doubles backslashes). Fixes the windows-latest smoke failure; macOS/Linux unaffected (slash paths, no-op escaping). - pathlists: ReadExclusions.DirExcluded now EvalSymlinks-resolves the candidate path before the nested-AllowRead prefix check, matching the resolution applied to allowRoots — otherwise a denied dir reached THROUGH a symlink fails to match a nested AllowRead root and is wrongly skipped, dropping a re-included subtree. Added TestDirExcludedResolvesSymlinkPrefix (skips where symlinks are unavailable). Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new unreachable funcs). * sandbox: strengthen re-entrancy guard with a corroborating backend marker Addresses review feedback that the re-entrancy guard trusted a single ambient env var. IsAlreadySandboxed now requires BOTH correlated markers that sandboxEnvironment sets together on every wrapped command — ZERO_SANDBOXED=1 AND a non-empty ZERO_SANDBOX_BACKEND (new EnvSandboxBackend const) — so a lone stray/hand-exported ZERO_SANDBOXED=1 no longer forces an unsandboxed pass-through. Pass-through (direct) plans set neither marker, so genuine nesting still detected and double-wrapping still avoided. Tests updated: TestIsAlreadySandboxed now asserts each marker alone is insufficient and both together are required; the re-entrancy and wrapping plan tests set/clear both markers explicitly. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new unreachable funcs). * sandbox: don't subject first-party web tools to the network policy by default The sandbox was reported as too strict: web_search (and web_fetch) were blocked with "network access is disabled by the sandbox policy" because the default policy is network=deny and the in-process web tools were gated by the same NetworkHostAllowed check as the sandboxed shell's egress. That network policy exists to confine the sandboxed SHELL's egress, which these in-process tools don't use. New Policy.EnforceToolNetwork (off by default) makes NetworkHostAllowed exempt web_search/web_fetch unless an operator opts in. The sandboxed-shell egress decision (Evaluate / effectiveNetworkMode) is unchanged, and the tools keep their own SSRF/private-IP, port, redirect, and redaction safeguards. Set EnforceToolNetwork to hold the tools to the allow/scoped/deny policy as before. Tests: engine default-exempt + enforced-table cases; web_search default-allows and enforced-when-flag; web_fetch enforced cases set the flag. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new unreachable funcs). * sandbox: re-review fixes — disabled policy inert; path lists independent of EnforceWorkspace - engine: ReadExclusions() / ReadExclusionGlobs() now return inert results under ModeDisabled, so a disabled policy never filters grep/glob (parity with Evaluate, which already allows everything when disabled). - engine/pathlists: the fine-grained path lists (DenyRead/DenyWrite, with AllowRead/AllowWrite) now apply in Evaluate whenever the sandbox is enforcing, independent of EnforceWorkspace — matching the grep/glob path that already honors DenyRead directly. The workspace boundary (scope.validate) stays gated by EnforceWorkspace via a param threaded through validatePathWithPolicy / validateWritePath; DenyWrite wins regardless. - reentrancy_test: TestSandboxEnvironmentMarksSandboxed now asserts BOTH correlated markers (ZERO_SANDBOXED=1 and ZERO_SANDBOX_BACKEND) so dropping either would fail the test. Tests added: path lists enforced with EnforceWorkspace off; ReadExclusions inert under ModeDisabled. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new unreachable funcs). * sandbox: enforce path lists even without a workspace root Re-review follow-up: the Evaluate path-validation loop was still guarded by request.WorkspaceRoot != "", so an engine built without a workspace root skipped DenyRead/DenyWrite/AllowRead/AllowWrite even for absolute paths. The loop now runs unconditionally; the workspace boundary (scope.validate) stays gated on having a root (enforceWorkspace = EnforceWorkspace && WorkspaceRoot != ""), while the path lists match absolute paths regardless. An unanchorable relative path (no workspace root) fails closed when anything is configured to enforce, and is a no-op otherwise (unchanged from prior behavior). Test: TestEvaluatePathListsWithoutWorkspaceRoot. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new unreachable funcs). * sandbox: base no-workspace fail-closed on normalized (enforceable) path lists policyHasPathLists now resolves the lists (resolvePolicyPaths/ resolveWriteRootPaths) instead of counting raw config, matching how the rest of the file normalizes them. A typo or non-existent entry (dropped during resolution) therefore no longer makes validatePathWithPolicy fail-close every relative request when there is no workspace root. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new unreachable funcs). feat: add TUI diagnostics center (#197) Solidify sandbox: harden command analysis + wire/clean dormant policy surfaces (#196) * Harden sandbox command risk by wiring the AST analyzer The AST-based command analyzer (sandbox/analyzer.go) was built and tested but never reached at runtime — the live risk classifier used only the regex detectors. Wire AnalyzeCommand into classifyWithScope as a second opinion. It walks the parsed shell tree, so it catches destructive/network programs the regexes miss (shred, fdisk, parted; telnet, ftp, sftp; and commands hidden behind sudo/env wrappers or a sh -c launcher) and flags an unparseable (obfuscated) script as elevated risk. Purely additive — it only ADDs risk categories, so a benign, parseable command classifies exactly as before, and a program name inside a quoted argument is not flagged (AST precision). Revives the dormant analyzer (sandbox unreachable funcs 13 -> 6). New tests cover the regex-missed destructive/network programs, nested launchers, the unparseable signal, the quoted-name no-false-positive case, and benign-stays-clean. Full suite + -race green. * Wire sandbox auto-allow-bash env opt-in; drop orphaned batch-autonomy Two dormant sandbox surfaces resolved: - WIRE: AutoAllowBashWhenSandboxed was enforced live (engine.go) but never set — no config field, and the ZERO_SANDBOX_AUTO_ALLOW_BASH env overlay (ApplyAutoAllowBashEnv) was unwired, so the documented opt-in did nothing. Wire it through the central policy builder (applyConfiguredSandboxPolicy, used by all engine constructions). Fail-safe: only turns ON when the env is truthy, only honored when the shell sandbox is actually active, default stays prompt, and an explicit config opt-in is preserved. Revives AutoAllowBashEnvEnabled + ApplyAutoAllowBashEnv; adds a policy-builder test. - DELETE: RequiredAutonomyForBatch/riskToAutonomy (batch.go + test) had no consumer — the agent loop enforces autonomy per tool call (loop.go), so a batch-ceiling helper was speculative and redundant. Wiring it would have meant fabricating a batch model. Sandbox now has no dormant funcs; the only remaining unreachable entries are the cross-platform seccomp pair (unixSocketBlockFilter is live on Linux; ApplyUnixSocketBlock is the intentional non-Linux no-op stub). * Assert risk level (and add wrapper cases) in AST analyzer tests Per review (Vasanth + CodeRabbit): the AST hardening tests checked only the risk category, not the escalation level, so a regression that downgraded destructive/network shell commands while preserving the category would slip through. Assert risk.Level too — Critical for destructive/network, High for unparseable — matching the pattern used throughout the file. Add sudo/env wrapper cases (sudo/env shred, sudo telnet) to cover effectiveProgram's wrapper unwrapping. feat: add backend doctor diagnostics (#194) Hooks management command + build & supply-chain hardening (#193) * Bump toolchain to go1.26.4 to clear reachable stdlib CVEs go.mod pinned toolchain go1.24.13, whose bundled standard library has 9 govulncheck-reachable CVEs (crypto/tls KeyUpdate DoS, net/http2 loop, crypto/x509 x3, net, net/url, os, net/textproto). CI and release build via go-version-file: go.mod, so shipped binaries inherited them. Bumping the toolchain directive to go1.26.4 clears all nine — govulncheck ./... now reports 0 reachable (minimum that clears them is go1.25.11; go1.26.4 matches the team's local Go). The go directive stays at 1.24.2 (minimum language version). Verified: go build/vet/gofmt clean, full go test ./... green. * Add govulncheck CI gate and SHA-pin GitHub Actions - ci.yml: new 'security' job runs govulncheck as a hard gate (fails on a reachable vulnerability; passes now that the toolchain is bumped) plus an advisory deadcode step that surfaces dormant code without blocking. - Pin every GitHub Action to a full commit SHA (checkout, upload-artifact, github-script) with a version comment, across all four workflows — previously only setup-go was pinned, leaving the mutable v4/v7 tags as a supply-chain risk. * Add hooks management command (add/remove/enable/disable) Wires the previously-dormant hooks.ConfigStore (10 unreachable funcs -> 0) into the CLI: zero hooks add --event --command [--name --description --matcher --arg --user --json] zero hooks remove|enable|disable [--user --json] Writes the project hook config by default (/.zero/hooks.json) or the user config with --user, reusing the store's locking + atomic writes + validation (normalizeDefinition). New hooks are enabled; state is managed via enable/disable. JSON output is secret-scrubbed via redaction. Mirrors the existing 'zero mcp add/remove/enable/disable' shape. Covered by add/remove/toggle round-trip, validation, unknown-event, and JSON-redaction tests. * Fix govulncheck CI job: pin GOTOOLCHAIN to the go.mod toolchain go run govulncheck@latest selected the toolchain from govulncheck's own go.mod (downgrading to go1.25.11), which then could not load our go1.26-requiring packages (fips140only_go1.26.go), failing the security job. Resolve GOTOOLCHAIN from go.mod's toolchain line so govulncheck and deadcode both run under go1.26.4. * Address review: harden CI checkout, pin tool versions, validate hook event CodeRabbit findings on #193: - ci.yml: persist-credentials: false on all checkout steps (smoke/performance/security) — these jobs run repo code and never push, so don't keep the token in git config. - ci.yml: pin govulncheck@v1.3.0 and deadcode@v0.46.0 instead of @latest, so the security gate is deterministic and not subject to supply-chain drift. - hooks: add exported IsValidEvent/KnownEvents (single source); parseEvent reuses it, and 'zero hooks add' now rejects an invalid --event with a usage error at parse time instead of falling through to the Upsert app-error crash path. feat: add backend lifecycle status command (#192) Wire LSP diagnostics into self-correct (exec + TUI) (#191) * Wire LSP diagnostics into headless --self-correct newExecSelfCorrector now backs the --self-correct loop with both halves: the workspace test plan AND an lsp.Manager-based diagnostics checker (IncludeLSP=true). The manager is lazy (Manager.Check degrades a missing language server to nil,nil), and a deferred cleanup shuts it down at run end, terminating any spawned sessions. This revives the internal/lsp client (44 -> 3 unreachable funcs); it was dead because the only consumer passed a nil checker with IncludeLSP=false. Add TestManagerCheckRealGopls: drives NewManager -> Check against a real gopls and asserts a real diagnostic; skips when gopls is absent, so it stays CI-safe. * Wire self-correct into the interactive TUI (on by default) runAgentWithOptions now builds a SelfCorrector for every turn, so post-edit verification (workspace test plan + LSP diagnostics) runs by default in the TUI. The spec-draft planning path is excluded, matching exec. A per-turn lsp.Manager is created and torn down when the run returns. Auto-fix vs report-only follows the active permission mode (unsafe->high, auto->medium, ask/other->low) via selfCorrectAutonomyForMode. * Keep TUI self-correct fast by default; gate the test half behind /selfcorrect The TUI default now runs only the LSP-diagnostics half of self-correct — cheap and scoped to the changed files — so it never adds the whole-repo test plan's latency to a turn (and a pre-existing failure can't hijack the agent). The project test plan is opt-in per session via a new /selfcorrect [on|off] command (alias /sc); IncludeTests reads m.selfCorrectTests, default false. exec keeps both halves. * Remove dead session.didClose from lsp session.didClose sent a textDocument/didClose notification but was never called (the manager only closes documents via Shutdown), so it was unreachable even from tests and flagged by staticcheck U1000. Removing it takes internal/lsp to 0 truly-dead funcs and 0 U1000 findings; the 2 remaining unreachable-from-main funcs (Available, URIToPath) are public, test-covered API. * Align /selfcorrect help with accepted arguments The usage/description advertised only [on|off] but the handler also accepts status, list, tests, full, and lsp. Surface the primary verbs (on|off|status) and clarify the on/off behavior so command discovery matches the handler (CodeRabbit). * Make /selfcorrect usage strings match the handler exactly Both the command definition and the invalid-argument error message now list the full accepted set [status|on|off|tests|full|lsp]; the redundant 'list' alias is dropped so docs and handler agree exactly. Addresses Vasanth's review (the error-message usage at session_controls.go was still [on|off]) and CodeRabbit's help-text finding. * Clarify /selfcorrect off semantics in help text Reword the description so 'off/lsp' reads as 'disable tests, LSP-only' rather than the ambiguous 'return to LSP-only', which could be misread as disabling the feature entirely (CodeRabbit). Polish model status and reasoning streams (#190) * Polish model status and reasoning streams * Polish custom provider setup flow * Clean up pending composer hints * Address custom provider setup review * Polish composer overflow handling * Fix composer paste and wheel edge cases * Use line counts for paste previews * Revert "Use line counts for paste previews" This reverts commit 4feb814d09725f2d1602257b6e12f18b883e2e6f. * Fix composer paste preview deletion * Disable textinput ctrl-v paste binding * Preserve paste previews through file suggestions * Clarify composer paste preview behavior * Keep OpenAI reasoning out of answer text feat: add MCP manager UX (#188) * feat: add MCP manager UX * fix: harden MCP manager UX * test: stabilize agent eval timeout on Windows * fix: preserve MCP server metadata * fix: clarify empty MCP view * feat: make MCP command a manager view * fix: make MCP manager usable in TUI * fix: make MCP manager selectable in TUI * fix: simplify MCP manager popup * feat: add searchable MCP marketplace * fix: address MCP review issues * fix: address MCP follow-up review * feat: add MCP setup wizard * feat: detect MCP setup requests in chat * fix: address MCP manager review feedback * fix: avoid hijacking MCP chat prompts * fix: persist MCP view cache fallback Deep-audit remediation + sandbox hardening (batches 1–35) (#189) * audit batch 1: redact nested secrets fully; count image tokens in compaction estimate - secrets.Redact: replace longest matches first so a containing PRIVATE KEY block is redacted before a nested shorter match corrupts its exact string (audit medium internal/secrets/scanner.go:81) + regression test. - agent compaction estimateTokens: add a flat per-image token cost so an image-heavy context trends toward compaction instead of reading as ~0 (audit medium internal/agent/compaction.go:58) + test. - ledger: re-verify against current main; mark scanner.go:35 and redaction.go:362 already-fixed-on-main, record this batch's fixes. * audit batch 2: worktrees defaultRunGit separates stdout/stderr Capture git stdout and stderr into separate buffers instead of CombinedOutput, so parsed rev-parse output is not polluted by git warnings and CommandResult.Stderr carries the real error text (audit medium internal/worktrees/worktrees.go:247) + test. * audit: re-verify all 87 pending findings against current main Independent agent per file re-checked each open/partial finding against the actual current code (the 06-11 ledger was verified on a since-diverged branch), with an adversarial second pass on every already_fixed verdict. Authoritative result: 78 genuinely pending (66 still_open + 12 partial); 9 rows were already fixed on main and are reclassified. Full table in 2026-06-13-reverification.md. * audit batch 3 (agent): cancellation identity, always-allow w/o sandbox, compaction usage, dedup - loop.go: check ctx.Err() before collected.Error so a cancelled run returns context.Canceled (errors.Is holds) instead of a plain stringified error. - loop.go: 'Always allow' with no sandbox engine now allows THIS call instead of denying it (persistence is skipped when there's nowhere to persist). - loop.go: remove a duplicate schema["items"] assignment in propertyToRuntimeMap. - compaction.go: forward OnUsage (token accounting) through the summarizer while still omitting OnText, so compaction's token cost is counted in usage/budgets. - tests for all three behavioral fixes (each fails pre-fix). * audit batch 4 (cli): list-tools -o json, interrupted -o json terminal, surface session-record failures - exec --list-tools -o json now emits a JSON tool listing instead of falling through to the human-readable text (audit medium exec.go:182) + test. - interrupted run with -o json now emits a terminal error+done on stdout instead of only printing to stderr, so a JSON consumer sees a clean end (medium exec.go:500) + test. - execSessionRecorder: surface a latched session-append failure to stderr once at run end (deferred) instead of silently dropping it (medium exec_sessions.go:114/115) + test. - left app.go trailing-arg ignore as-is: documented-intentional, TUI has no argv prompt. * audit batch 5 (zerogit): status -z porcelain (renames + non-ASCII paths); document env-drop on plain Runner - parseStatus: switch `git status` to `--porcelain -z` and parse the NUL-delimited form (audit medium zerogit.go:212). Previously the default --short output was split on newlines, so a rename `R old -> new` was kept as one bogus path and a non-ASCII/whitespace filename arrived C-quoted+escaped (e.g. `"caf\303\251.txt"`). The -z form never quotes paths and emits a rename/copy as `XY \0`, so we now record the destination verbatim and consume the source field. + TestParseStatusZHandlesRenamesAndSpecialPaths. - resolveRunners: document that the plain-Runner EnvRunner adapter intentionally drops env (audit low zerogit.go:417). The branch is reached only when a caller supplies a custom Runner without a RunGitEnv — in practice fake-Runner tests. Production leaves both nil and gets defaultRunGit/defaultRunGitEnv, which honor GIT_INDEX_FILE, so snapshot-diff isolation holds. Routing env calls to real git instead would break the fake-Runner test pattern and fix nothing in production. - update Inspect/Commit mock tests to the -z status format and the new command. * audit batch 6 (config): reject negative maxTurns; drop dead contract-gap helpers - FileConfig.UnmarshalJSON now rejects a negative maxTurns instead of letting the `MaxTurns > 0` merge gates silently drop it and fall back to the default, hiding the misconfiguration (audit medium resolver.go:136). 0 is left as the "unset" sentinel (omitempty) and still falls back to the default; the CLI --max-turns flag continues to reject 0 too since there it is distinguishable from absent. + TestResolveRejectsNegativeMaxTurns / ...ZeroFallsBackToDefault. - remove dead config/contracts.go (ContractGap, DefaultContractGaps, FindContractGapsByMilestone, ContractOwnerRuntime) and its test — referenced only by that test, never by production (audit low contracts.go:12). - NOT changed: ResolveMCP intentionally does not run the provider command (TestResolveMCPDoesNotRunProviderCommand guards this), so the "ZERO_PROVIDER_COMMAND MCP servers dropped" row is by-design, not a defect. * audit batch 7 (mcp): bound captured MCP server stderr connectStdio attached a plain bytes.Buffer as cmd.Stderr that is only ever read on initialize failure, yet it kept growing for the entire process lifetime as a long-lived server logged to stderr (audit medium client.go:98). Replace it with a concurrency-safe boundedBuffer capped at 64 KiB that keeps the head (enough for init-error diagnostics) and discards the rest, reporting full write lengths so os/exec never sees a short write. + TestBoundedBufferCapsRetainedBytes. Left as-is (not defects worth the risk in the MCP client): - client.go writer.write under client.mu: already designed to release the lock before the unbounded response wait; the residual concern is a server that stops draining stdin, an involved async rewrite for a theoretical deadlock. - readLoop not answering server-initiated requests (ping): responding safely needs re-locking around the shared writer; no configured server relies on it. * audit batch 8 (tools/providers): read_file truncation marker; drop dead provider branch - read_file: when a max_lines cut occurs, append an explicit "[truncated: N more line(s)... set start_line=X to continue]" marker to the output. Previously only the Result.Truncated flag was set, which is invisible in the rendered text, so the model could not distinguish a cut read from a complete one (audit low read_file.go:89). + TestReadFileToolMarksTruncation. - providers/factory: simplify `if !explicitProvider || isImplicitOpenAI(...)` to `if !explicitProvider` and delete the now-unused isImplicitOpenAI. The clause was dead: explicitProvider==true implies ProviderKind or Provider is set, but isImplicitOpenAI required both empty, so it could never contribute a case (audit low factory.go:118). Behavior-preserving; providers tests still green. * audit batch 9 (sessions): Tree builds from a single List() snapshot (no N+1 disk scans) Store.Tree recursed via ListChildren per node, and each ListChildren ran a full store.List() that re-read every metadata.json from disk — O(nodes * total-sessions) reads for one tree (audit medium lineage.go:140). Snapshot all sessions once, index children by parent in memory, and recurse over that. Child ordering is factored into sortChildSessions so ListChildren and Tree stay identical; path-scoped cycle detection (delete(seen,...)) is preserved. Existing tree/lineage tests still pass. Left as-is: store.go timestamp() second precision (RFC3339) — switching to RFC3339Nano would break lexical UpdatedAt ordering against existing second-precision records ("...00.5Z" sorts before "...00Z"), so it is not a safe one-line change. * audit batch 10 (specialist): serialize accounting dedup-then-append recordSpecialistStop and appendSpecialistUsageRollup checked for an existing event (specialistEventExists → ReadEvents) and then appended under SEPARATE store locks, so two concurrent finishers — a foreground onExit racing a TaskOutput poll, or a background reaper — could both pass the dedup check and write duplicate stop/usage events (audit medium accounting.go:72). Guard each check-then-append pair with a package-level accountingMu (Executor is used by value, so a struct field would not be shared). + TestRecordSpecialistStopDedupesUnderConcurrency (passes under -race). * audit batch 11 (zeroruntime/gemini): O(1) text accumulation; gemini cached-token counts - zeroruntime CollectStreamWithOptions accumulated assistant text with `collected.Text += event.Content`, reallocating the whole string on every chunk (O(n^2) over a long streamed response). Accumulate in a strings.Builder on the collector and materialize it once in flush, the single finalization point hit before every return (audit low helpers.go:104). Existing collect tests still pass. - gemini decoded usageMetadata without cachedContentTokenCount, so a cached-prompt response reported zero cached input tokens while the other providers report them (audit medium gemini/types.go:88). Decode the field and thread it through streamState into NormalizeUsage as CachedInputTokens. + extended TestStreamCompletionEmitsTextUsageAndReasoningTokens. * audit batch 12 (specialist): parse child stream without a line-length cap; drop dead helper - ParseStream used a bufio.Scanner capped at 1 MiB, so one large stream-json line from a child (a big tool result or final answer) hit bufio.ErrTooLong and aborted the entire specialist run (audit medium streamer.go:43). Switch to a bufio.Reader with ReadString, which has no per-line limit; the child is our own trusted subprocess, so its output length is the legitimate bound. Line numbering and blank-line/EOF handling are preserved. + TestParseStreamHandlesLineLargerThan1MiB. - remove dead formatTaskOutput (output_tool.go:244): zero callers and zero tests; it only forwarded to formatTaskOutputSummary, which is the one actually used. * audit batch 13 (background/specialist): kill background tasks as a process group Background children were launched in the parent's process group and terminated by signalling only the single PID, so any process the task forked was orphaned and outlived a cancel; the signal also risked hitting a recycled PID after the child was reaped (audit medium process_posix.go:38/50). - ConfigureChildProcessGroup (Setpgid on POSIX, no-op on Windows where taskkill /T already tree-kills) is applied at launch so the child leads its own group. - terminateProcess now SIGTERM/SIGKILLs the negative PID (the whole group), with a pid>1 guard so a bogus 0/1 can never expand to "our own group" or "everything", and treats ESRCH as already-gone. Liveness polls the group. Targeting a group (rarely recycled vs. a bare PID) also shrinks the reaped-then-recycled window. - specialist launchBackgroundProcess opts every child into its own group; on a SetPID failure it now kills the child + group, marks the task errored, and records the stop (deduped) instead of leaking an untracked orphan. - tests launch as group leaders; new TestTerminateProcessKillsForkedChildren proves a forked grandchild dies with the group. * audit batch 14 (sandbox): quote-aware shell segment splitting splitShellSegments used a quote-unaware strings.Replacer, so a shell operator inside quotes was treated as a separator: `git commit -m "use top | less"` split into a `less` segment and `echo "a; vim b"` into a `vim` segment, both falsely flagged as interactive (audit medium safe_command.go:138). Replace it with a quote-aware scanner that mirrors the shell: single quotes make everything literal; double quotes keep $(...)/`...` substitution active but treat |, ;, && as literal; unquoted text splits on the same operator set as before (;, |, ||, &&) plus substitution boundaries. Real unquoted operators still split, so an interactive program behind a separator — or inside a live `"$(...)"` substitution — is still caught (no new false negatives, the security-relevant direction). + TestDetectInteractiveQuoteAwareSeparators covering both directions. * audit batch 15 (cron): collapse DST fall-back repeated hour to one fire On a fall-back day the clock repeats an hour (e.g. 01:30 EDT then 01:30 EST), so Next("30 1 * * *", <01:30 EDT>) returned the same-day 01:30 EST — firing a daily job twice (audit medium schedule.go:171, which previously guarded only the spring-forward gap). Next now skips a match whose local wall-clock minute equals `after`'s but whose absolute time is later (the repeated-hour duplicate) and keeps searching. Normal forward search always advances the wall-clock, so this only triggers on the fall-back repeat; when the scheduler passes the last fire time as `after`, the repeated hour collapses to a single fire. + TestNextDSTFallBackDoesNotRepeatHour (spring-forward termination test still passes). Note: Next is stateless, so it dedupes relative to `after`; a caller that passes an arbitrary mid-hour "now" instead of the last fire time is out of scope here. * audit batch 16 (mcp): skip SSE notifications before the response; larger SSE buffer - decodeSSERPCMessage returned the first non-empty `message` event unconditionally, so a server-initiated notification/request arriving before the response made the caller see an id mismatch and fail (audit medium network_client.go:621). It now skips any message carrying a method (notifications/requests have one; a response does not) and returns the first real response; the caller's rpcIDMatches still validates the id. + TestDecodeSSERPCMessageSkipsNotifications. - raise the per-event SSE scanner cap from 1 MiB to a bounded 8 MiB so a large but legitimate MCP message no longer hits bufio.ErrTooLong and fails the request (audit medium network_client.go:637); still bounded against an unbounded server. Out of scope (kept): full reconnect of a persistent SSE stream after a fatal read error — a larger change than this remediation. * audit batch 17 (sessions): fsync session metadata before the atomic rename writeMetadata wrote the temp file with os.WriteFile (no sync) and renamed it into place, so a crash right after the rename could expose a metadata file whose bytes were never flushed — a torn or empty file that corrupts the whole session (audit medium store.go:632). Write+fsync the temp via a new writeFileSync helper before renaming, closing that window. + TestWriteFileSyncRoundTrips. Deliberately scoped: - Event-log appends (appendEventLocked) are NOT fsynced per append: that would add a disk flush to every event (every tool call), and a hard-crash loss of the log's unsynced tail is acceptable, whereas torn METADATA is not. Metadata is the integrity-critical part and is what this syncs. - Directory fsync is omitted for cross-platform simplicity (it errors on Windows); the file fsync already prevents the torn-contents failure, the serious case. Left for follow-up (in this bucket but not safe/clear wins here): - hooks append sequence still re-reads the file per append; the in-memory cache is cross-process-unsafe and the safe last-line read is fiddly for a low-volume log. - Fork event re-append/de-dup is nuanced usage-accounting and needs its own change. * audit batch 18 (agent): count tool-defs in compaction estimate; don't deny an always-allow on persist failure - maybeCompact's estimate omitted the tool definitions, which ride on every request, so the real input could exceed the model limit while the message-only estimate stayed under threshold and compaction never fired (audit medium compaction.go:58). partitionTools is now computed before maybeCompact (it does not depend on the messages) and the exposed tools' estimated tokens are added to both the threshold check and the post-compaction shrink check. + estimateToolDefTokens and TestEstimateToolDefTokensCountsDefinitions. - PermissionDecisionAlwaysAllow denied the call when persistPermissionGrant failed, overriding what the user explicitly allowed (audit medium loop.go:468). Persisting is now best-effort: a failure (or no sandbox engine) just means the grant is not remembered and the user is re-prompted next time, not denied now. This also drops the dead requestEvent.GrantMatched/Grant writes (audit low loop.go:473) — the emitted event derives them from the sandbox decision via buildPermissionEvent. Left intentional (documented, not defects): the reactive-retry path omits OnText to avoid duplicating mid-stream text (loop.go:183); OnContext/MeasureContext is a complete, tested context-breakdown scaffold awaiting a consumer (types.go:183). * audit batch 19 (usage): price escalated usage from the event's own model exec persists payload["model"] on --allow-escalation runs (the model can change mid-run), but BuildReport priced every event from the session's Metadata.ModelID and never read the payload, so usage from an escalated model was mis-priced at the base model (audit medium report.go:17). usageEventPayload now carries Model and BuildReport prefers it, falling back to the session model when absent (older events stay byte-identical and behave as before). + TestBuildReportPricesFromEventModelWhenPresent. * audit batch 20 (sessions): don't copy usage events into a fork (no double-count) Fork re-appended every parent event, including provider_usage, into the child, so a usage report aggregating the parent and the fork double-counted the parent's usage (audit medium store.go:387). Skip EventUsage when copying — it already counted against the parent and is not part of the conversation history a fork replays — and record the actual copied count in the fork marker. + TestStoreForkSkipsUsageEvents. * audit batch 21 (cli/skills): warn on duplicate skill names instead of silently dropping skills.Load deduplicates same-named skills (first directory wins) and records the dropped collisions via skills.Duplicates, but nothing surfaced them, so a shadowed skill just vanished with no signal — and Duplicates had no production caller (audit low skills.go:91). `skills list` now reports each collision to stderr (keeping stdout/--json clean), using Duplicates. + TestRunSkillsListWarnsOnDuplicateNames. Note: skills.Get is kept — it is a by-name accessor used throughout the package's tests, not dead code. * audit batch 22 (cli/cron): capture the run error from stream-json stdout Cron jobs run exec with --output-format stream-json, so a failure is reported as an `error` event on STDOUT, but the run record's Error was set only from stderr — so a failed job often recorded an empty/uninformative error while the real message sat unread in outBuf (audit low cron_run.go:150). On a non-zero exit, scan the stdout stream-json for the error event's message and prefer it, falling back to stderr. + extractStreamJSONError and TestExtractStreamJSONError. * audit batch 23 (sandbox): richer macOS seatbelt profile (signal + mach-lookup) The generated sandbox-exec profile was default-deny with only process/sysctl/file allowances, so two real gaps remained on macOS (sandbox quality report H1): - A sandboxed script could not signal the children it spawned — `kill` returned "Operation not permitted" under seatbelt, breaking common `cmd & ... kill`, test-runner, and timeout patterns. Add `(allow signal (target self) (target pgrp))`, which stays scoped to the command's own process group. - XPC to system daemons was denied, so keychain (securityd/trustd), user/group lookup (opendirectoryd), preferences (cfprefsd), network config (SystemConfiguration), launch services, and the pasteboard all failed. Add a curated `(allow mach-lookup ...)` allowlist (`sandboxMachServices`). Neither touches the file-write or network rules, so the workspace boundary is unchanged — verified on a real macOS host: `security`/`scutil`/`pbpaste`/self-kill now succeed while writes to $HOME and /etc are still denied. iokit-open was tried and dropped as unnecessary. + unit assertions (TestSandboxExecProfileGrantsSignalAndMachLookup) and extended the darwin integration test with a self-kill case and an outside-workspace deny check. * audit batch 24 (sandbox/tools): remove dead OnSandboxDecision hook RunOptions.OnSandboxDecision had no setter anywhere, yet registry.RunWithOptions spawned a goroutine (with panic recovery) for it on every sandboxed tool call — pure per-call overhead for an unreachable callback (sandbox report M1). The sandbox decision is already surfaced to observers via the permission event (buildPermissionEvent reads the decision), so this hook is redundant; delete the field and its call site. * audit batch 25 (sandbox/cli): exact-path grants — create and revoke by path The engine already matched ScopeFile grants and DeriveScope already produced them from path/file tool args, but the CLI could neither create one explicitly nor revoke a single one (sandbox report L3 / prompt 7). Add: - GrantStore.RevokePath(tool, path): revokes only the grant whose scope matches the given file/dir, leaving tool-wide and other-path grants intact (path canonicalized to absolute like stored scopes). + TestGrantStoreRevokePathRemovesOnlyMatchingScope. - `zero sandbox grants allow --path

`: persists an exact-file (ScopeFile) grant, path resolved to absolute so it matches lookups and revokes. - `zero sandbox grants revoke --path

`: revokes just that grant (default still revokes all grants for the tool). + TestRunSandboxGrantsCreateAndRevokeByPath. The TUI permission-card "Allow always (exact path)" option is intentionally left for the TUI feature branches (it overlaps #187/#188); the engine + CLI now cover the full create/match/revoke round-trip for file-scoped grants. * audit batch 26 (sandbox): Engine.Precheck — report violations before execution Add Engine.Precheck(ctx, request) []Violation, which reports the sandbox violations that would block a tool request before it runs (sandbox report / prompt 6). It reuses Evaluate so policy is not duplicated: an allowed or merely-prompted request yields no violations; a denied request yields its violation (via the shared violationsFromDecision helper, with a ViolationPolicyDenied fallback for a deny that carries no structured violation). A nil engine yields nothing. + TestEnginePrecheckReportsViolationsBeforeExecution. Wiring Precheck into the batch-confirmation prompt is intentionally left to the TUI branch: it touches prompt rendering, and short-circuiting the agent loop's deny path would skip the before/after-tool hooks that currently fire around a policy-denied call. The engine API is in place for that consumer. * audit batch 27 (sandbox): scoped-egress domain prompt with timeout + caching The scoped-egress proxy failed closed on any host outside the allowlist. Add an opt-in DomainPrompt callback (prompt 8): an UNKNOWN host (neither allowed nor explicitly denied) is authorized at request time via the callback instead of being refused outright. An explicitly denied host is still refused without prompting; the callback is bounded by PromptTimeout (default 60s, timeout/error => deny); decisions are cached for the proxy's lifetime so a host is prompted at most once. With no callback set the strict fail-closed behavior is unchanged. Wired through both the CONNECT and HTTP handlers via authorizeTarget. + TestEgressProxyAuthorizeDomainPrompt / ...TimeoutDenies / ...NilPromptFailsClosed. The user-facing prompt that supplies the callback is left to the permission UI (TUI) branch; the egress mechanism (callback + timeout + cache) is in place and tested. * audit batch 28 (sandbox): AST-based shell analysis (AnalyzeCommand) Add AnalyzeCommand(script) AnalysisResult, a static shell analyzer built on the mvdan.cc/sh/v3 parser (prompt 12). Walking the parsed command tree makes it a more precise second opinion than the regex detector: a program name only counts when it is an actual command, so `echo "vim ..."`, `printf 'open with vim'`, and `git commit -m "rm -rf /"` are clean, while real commands are flagged — interactive (editors/pagers + REPLs, with the same -e/script suppression as nonInteractiveREPLFlags), destructive (rm -rf/-fr, dd, mkfs, shred, ...), and network (curl/wget/ssh/nc/...). An unparseable script yields TooComplex so callers can treat it as higher-risk. + TestAnalyzeCommand (16 cases) / ...EmptyIsClean. Dependency: mvdan.cc/sh/v3 pinned to v3.11.0 — compatible with the existing go 1.24 directive (the latest v3.13.1 would have bumped the module to go 1.25). It is kept as a standalone analyzer for callers to consult; feeding it into Engine.Evaluate is deliberately not done here, since that would change allow/deny classification. * audit batch 29 (sandbox): unify proxy-env generation (ProxyEnv) Export ProxyEnv as the single source of truth for proxy-env injection (prompt 9 req 1), replacing the unexported scopedProxyEnv used by the sandboxed-shell path. + TestProxyEnv. Investigation that bounds the rest of prompt 9 (documented in the ProxyEnv doc): - web_fetch already honors HTTP_PROXY/HTTPS_PROXY — its transport clones http.DefaultTransport, whose Proxy is http.ProxyFromEnvironment. - MCP children already inherit the parent env (mergeProcessEnv appends os.Environ), so they pick up any proxy vars the agent has. So the only missing link to scope FetchUrl/MCP is a SESSION-level egress proxy whose address reaches the agent process. That is deliberately NOT wired here: doing it safely requires allowlisting the active LLM provider's own domain first, or the agent's provider calls would route through the scoped proxy and be denied — a correctness pitfall that also can't be verified end-to-end on this host. Flagged rather than shipped blind. * audit batch 30 (sandbox): Linux seccomp Unix-socket block — UNVERIFIED, needs Linux CI Adds a pure-Go (no cgo, no vendored binary) seccomp BPF filter that denies socket(AF_UNIX) with EPERM on x86-64/arm64, closing the Unix-socket gap bubblewrap's filesystem/network isolation leaves open (prompt 11): - internal/sandbox/seccomp.go: the classic-BPF program as a platform-neutral []sockFilter, built and UNIT-TESTED (structure + every jump offset in range — the classic way a hand-written BPF filter goes silently wrong). - seccomp_linux.go: ApplyUnixSocketBlock installs it via prctl(NO_NEW_PRIVS) + prctl(SET_SECCOMP, MODE_FILTER). seccomp_other.go is a no-op everywhere else. - cmd/zero-seccomp: a tiny linux-only exec wrapper that applies the filter then execs the real command (the standard way to install seccomp in a child with os/exec); degrades gracefully (warns + runs) if the kernel lacks seccomp. ⚠️ HONEST CAVEAT: I developed this on macOS and CANNOT run the actual AF_UNIX block here. darwin build, `GOOS=linux` cross-compile, vet, and the structure test all pass, but the unit test verifies the program's SHAPE, not that the kernel actually blocks AF_UNIX — that MUST be verified on Linux CI (create a Unix socket under the wrapper and assert EPERM). Safe to ship as-is: it is linux-only, no-op on every other platform, and not wired into the bwrap command yet (opt-in prefixing the sandboxed command with zero-seccomp is the remaining integration), so it cannot regress any current path. * audit batch 31 (sandbox): macOS denial log monitor (opt-in, default off) Re-adds the macOS seatbelt-denial monitor as an opt-in feature (prompt 10). When Policy.MonitorDenials is set, the sandbox-exec profile tags its default-deny with a marker and bash tails `log stream` for that tag during the command, appending any captured denials as a block to the command's stderr so the model can see what was blocked. - DenialMonitor (darwin) tails/parses tagged denials with a capped, deduped ring buffer and noise filtering; no-op stub on other platforms. parseSandboxDenyLine is unit-tested. + profile-tag test + appendSandboxViolations test. - Default OFF: with MonitorTag empty, StartDenialMonitor is a no-op and the command path is byte-for-byte unchanged, so there is no regression or per-command overhead unless enabled. CAVEAT: on at least one macOS host (verified live) seatbelt denials are NOT delivered to the `log stream`-queryable unified log, so the monitor captures nothing there. It is the standard mechanism and works on macOS versions that do log denials; shipping it default-off means it never affects whether a command runs. * audit batch 32 (sandbox): wire seccomp Unix-socket block into bubblewrap (opt-in) Completes prompt 11: the zero-seccomp helper (which installs the AF_UNIX seccomp filter) is now actually invoked by the sandbox instead of only existing as a standalone binary. - bubblewrapCommandPlan, when Policy.BlockUnixSockets is set, ro-binds the discovered zero-seccomp helper into the sandbox at its host path and prefixes the command with it so the filter is installed before the real argv runs. - findSeccompHelper discovers the helper next to the running executable first, then on PATH; it returns "" when absent, so the sandbox degrades gracefully (runs without the extra filter — bubblewrap's fs/net isolation still applies) rather than failing the command. Exposed as a package var so the wiring is unit-tested on any OS. - Reachable from config: SandboxConfig.BlockUnixSockets and .MonitorDenials now map onto the policy in applyConfiguredSandboxPolicy (opt-in only — config can turn a hardening flag on, never off). Both default OFF. - Tests: helper-prefix present when enabled / absent by default; config flags apply independently and stay off when omitted. Default OFF and Linux-only effect; the runtime seccomp enforcement still needs a Linux host/CI to exercise end-to-end (the wiring and BPF program are unit-tested). * audit batch 33 (sandbox): enforce scoped/deny network policy in web_fetch & web_search Completes prompt 9: the built-in network tools now honor the SAME allow/deny/scoped policy the sandboxed shell egress proxy enforces, instead of bypassing it. Before this, web_fetch/web_search would reach any public host even when the policy was NetworkDeny or NetworkScoped — the egress proxy only constrained shell commands. - New shared gate Engine.NetworkHostAllowed(host) reuses the egress domain matcher (domainAllowed/normalizeDomains/effectiveNetwork) as the single source of truth: allow → all hosts; deny → none; scoped → only AllowedDomains minus DeniedDomains (empty allowlist collapses to deny). Disabled policy / nil engine impose nothing. - web_fetch is now sandbox-aware: the host is checked before the request AND on every redirect, so a denied/unlisted host fails closed before any dial. Plumbed via a networkPolicyGate so the no-sandbox path is byte-for-byte unchanged. - web_search is sandbox-aware: the configured backend's endpoint host is checked before the query leaves the machine; blocked under deny, and under scoped unless the backend host is allowlisted. - The registry already routes sandbox-aware tools through RunWithSandbox when an engine is present, so this activates automatically wherever the sandbox is wired. - Tests: engine gate matrix (deny/allow/scoped/denylist/empty/disabled/nil + host:port); web_fetch & web_search block denied/unlisted hosts without dialing and allow listed ones; endpoint-host parsing. * audit batch 34 (sandbox): prove network-policy wiring end-to-end + document opt-in flags Verification follow-up to batches 31-33; no behaviour change. - Adds a registry-level test that exercises the FULL chain for the scoped-network enforcement: registry.RunWithOptions -> engine.Evaluate (passes a scoped, egress-capable sandbox-exec backend) -> routes web_fetch through RunWithSandbox -> per-host allowlist blocks an unlisted host before any dial. This proves the Tool-interface value actually satisfies sandboxAwareTool at the registry boundary, not just in a direct method call. - Documents the two opt-in hardening flags (sandbox.blockUnixSockets, sandbox.monitorDenials) and the scoped web_fetch/web_search behaviour in the README so the config keys are discoverable. Both remain off by default. Confirmed wiring: agent loop forwards options.Sandbox to the registry; the registry routes sandbox-aware tools (web_fetch, web_search) through RunWithSandbox when an engine is present; MonitorTag is set only on the macOS sandbox-exec plan; the seccomp helper prefix is added only on the bubblewrap plan when BlockUnixSockets is set; both config flags map onto the policy via applyConfiguredSandboxPolicy. * audit batch 35 (sessions): fix Windows CI failure in TestWriteFileSyncRoundTrips The exact-permission assertion (perm == 0o600) added in batch 17 fails on windows-latest because Windows does not honor Unix permission bits: Go reports 0o666 for a writable file regardless of the mode passed to OpenFile. This was the sole cause of the failing Smoke (windows-latest) CI job; macOS and Ubuntu passed. Guard the perm check with runtime.GOOS != "windows" so it still verifies the mode on the platforms that enforce it, while the round-trip (write + read-back) is still asserted on every platform. * audit batch 36: address CodeRabbit review (5 findings) - background/process_posix: after SIGKILL, poll until the process group is actually gone (SIGKILL is async) and return an error if it survives past a second deadline, instead of returning nil while descendants still race. - sandbox/safe_command: only treat ')' as a substitution boundary when it closes an active $(...). A literal ')' (e.g. echo "a) less") no longer splits into a fake `less` segment and is no longer misflagged as interactive. Depth-tracked so nested $(...) still isolate their inner command. Direct splitShellSegments test (fails pre-fix: yields ["echo \"a", "less\""]). - cron/schedule: the DST fall-back collapse now only applies when `after` sits exactly on the minute boundary (its "last fire time" form). With sub-minute precision (e.g. 01:30:30 EDT) the first 01:30 fire already passed, so the repeated 01:30 EST is the legitimate next fire and is returned — restoring strictly-after semantics. New test fails pre-fix (skipped to next day). - config/types: maxTurns error message now reads "must be >= 0" to match the check (0 is accepted; only < 0 is rejected). Negative-maxTurns test asserts the message. - docs/audit: header date/branch metadata now matches the body (2026-06-13, fix/audit-batch) instead of the stale 2026-06-11/fix/audit-critical-high snapshot. Verified: gofmt clean; go vet clean; host + linux + windows builds; affected suites green. Each behavioural fix ships with a test confirmed to fail without the change. * audit batch 37: address CodeRabbit re-review (12 findings) Findings on the audit code from batches 21-36; each behavioural fix ships with a regression test (several confirmed to fail without the change). sandbox/analyzer (AnalyzeCommand): - Unwrap launcher prefixes (sudo/env/nice/bash -c/...) and recurse into `sh -c` payloads so `sudo rm -rf`, `env curl …`, `bash -c 'vim x'` classify on the real command, not the launcher. Mirrors DetectInteractiveCommand. - Honor rm's `--` end-of-options marker so `rm -- -rf` (delete a file named -rf) is not flagged destructive. sandbox/engine: NetworkHostAllowed now shares one backend-aware effective-mode helper with Evaluate, so scoped collapses to deny when the backend can't enforce scoped egress — the per-tool gate can no longer permit traffic the engine-level decision would fail closed. sandbox/egress: cache domain-prompt decisions by host:port, not host, so one "allow" can't authorize other ports on the same host. sandbox/safe_command: the segment splitter is now escape-aware (\| , \" ) so an escaped operator/quote can't manufacture a fake interactive segment. sandbox/log_monitor (darwin): Wait() the `log stream` child after draining stdout so the cancelled process isn't leaked as a zombie. tools/web_search: confine the search backend to same-host redirects (with a limit) so a redirect chain can't egress to a host the scoped/deny policy never authorized. cli/sandbox: an explicit empty --path (`--path=` / `--path ""`) now fails closed instead of silently widening an allow to tool-wide or a revoke to all-grants. cli/skills: warn on stderr when duplicate-skill detection fails instead of swallowing the error. specialist/exec: don't mask a failed orphan-kill — join the TerminateProcess error with the SetPID error so a surviving process is visible. background/process_posix: resolve the PGID and only group-signal when pid leads its own group; otherwise signal the individual PID. Avoids both a false-success on a non-leader ESRCH and the hazard of signalling an unrelated (possibly our own) group. Verified: gofmt + go vet clean; host + linux + windows builds; full go test ./... green. * audit batch 38: address CodeRabbit re-review round 2 (3 findings) Follow-ups to batch 37, each with a regression test: - safe_command (splitter): a ')' is now only a substitution boundary when it closes an active, UNQUOTED $(...). A quote-state stack saved at each '$(' lets a substitution run in a fresh quoting context and restores the outer quoting on its matching ')'. Fixes `echo $(printf "a) less")` splitting into a fake `less` segment, while still isolating the inner command of `echo "$(vim x)"`. - egress: deduplicate in-flight domain prompts per host:port. Concurrent requests for the same unknown target now wait on the first prompt and reuse its cached decision instead of each firing a prompt (a prompt storm) and racing to write the final decision. Race-tested with 25 concurrent authorizers → one prompt. - analyzer + safe_command: wrapper option-value consumption is now wrapper-specific (wrapperValueOptionsByProg) instead of a global option set. A valueless flag like `sudo -n` no longer swallows the real payload, so `sudo -n rm -rf` / `sudo -n curl …` are correctly classified destructive/network; `sudo -u root vim` still consumes its value. Shared by the AST analyzer and the regex detector so they can't diverge. Verified: gofmt + go vet clean; host + linux + windows builds; go test ./... and -race on sandbox/background all green. * audit batch 39: address CodeRabbit re-review round 3 (12 findings) All verified against current code; each behavioural fix has a regression test. Security / correctness: - tools/web_search: redirect policy now also refuses a scheme change, blocking a same-host https→http downgrade that would send the query + bearer token over plaintext. - sandbox/safe_command: a ')' is only a substitution boundary when it closes an active UNQUOTED $(...), and backticks now get the same quote-state save/restore as $( — so `echo "`a | less`"` and `echo "$(true | less)"` no longer hide the inner pager from interactive detection. - sandbox/analyzer + safe_command: wrapper option-value consumption stays wrapper-specific, and unwrapping no longer aborts on a dynamic ($x) wrapper arg once a wrapper is active, so `env "$opts" curl` / `sudo "$x" rm -rf` are still classified. - sandbox/egress: in-flight prompts deduped per host:port (carried from batch 38); the prompt callback is now cancellable (context cancelled on timeout) so a stuck prompt can abort instead of leaking; signature threaded through. - sandbox/runner: per-plan unique denial tag (pid+counter) so concurrent monitored sandbox-exec runs can't ingest each other's denials; helper selection now requires an executable regular file so a non-exec sibling degrades gracefully. - mcp/network_client: aggregate SSE event-size cap (not just per-line) so a server can't grow memory with many unterminated data: lines. - specialist/accounting: a previously-recorded stop/usage with an empty runId is a catch-all and now suppresses a later runId-bearing duplicate. - sessions/lineage: Tree fetches the root via Get() first so a corrupt root surfaces its real error instead of degrading to "not found". Docs/tests: - background/terminate: docstring now states the leader-only group-kill (PID-only fallback for non-leaders); test child-liveness probe uses ESRCH specifically. - cli/sandbox: grant usage strings include --path. Verified: gofmt + go vet clean; host + linux + windows builds; go test ./... and -race on sandbox/background all green. * audit batch 40: address CodeRabbit re-review round 4 (8 fixed, 1 skipped) Verified each against current code. Fixed: - sessions/lineage: PANIC FIX — store.Get returns (nil, nil) for a missing session, so the batch-39 root prefetch could nil-deref. Guard root == nil and return a not-found error. Regression test added (was a panic). - agent/compaction: the reactive recover() now records lowWaterMark in the SAME combined (messages + tool-defs) token domain maybeCompact uses, so the proactive shrink-guard compares like with like. recover() takes tools; callers pass request.Tools. - mcp/network_client: SSE aggregate counter no longer counts a joining newline for the first data line (only when prior content exists), avoiding premature rejects. - sessions/store: fsync the parent directory after the metadata rename so the rename is durable across a crash (no-op on Windows, which can't fsync a dir). - sandbox/egress: parse the target port with strconv.Atoi instead of net.LookupPort (no context-unaware resolver use). - sandbox/runner: escape the per-plan denial tag through sandboxProfileString when embedding it in the seatbelt profile. - sandbox/seccomp_linux: guard against an empty filter program before taking &kernelFilters[0] (defensive; the program is fixed non-empty today). - cli/cron_run_test: assert last-error-wins with two error events. - background/terminate, cli/sandbox usage, process_posix_test ESRCH (carried). Skipped (with reason): - zerogit worktree-rename (Y='R') test: git porcelain v1 -z only emits the rename-source field when the INDEX column is R/C; the worktree column never carries 'R' with a source field in v1, so the fixture would test output git doesn't produce, and making parseStatus consume on code[1]=='R' would risk mis-consuming the following entry for a plain ` R` status. Left as-is. Verified: gofmt + go vet clean; host + linux + windows builds; go test ./... and -race on sandbox/background all green. * audit batch 41: address CodeRabbit re-review round 5 (4 findings) All verified against current code; code fixes have regression tests. - agent/compaction: summarizeWithFallback's reduce pass no longer swallows ALL errors. A context-limit error still falls back to the joined partial summaries (documented "extreme" case), but a non-context failure (auth/network/provider) now surfaces unchanged, per the function's contract. Two tests (propagate vs. joined-fallback); the propagate test fails without the fix. - sandbox/safe_command + analyzer: wrapper value-consuming options now include the long spellings (sudo --user root, env --unset NAME, timeout --signal …), so a long flag's value is no longer mis-read as the program (interactive/destructive/ network detection bypass). A `--flag=value` token carries its own value and never consumes the next token. Tests for space and = forms in both the AST analyzer and the regex detector. - docs/audit: the status-ledger summary table's open/partial cells (64/14) were the lone outlier — the ledger's own narrative and 2026-06-13-reverification.md both state 66/12. Corrected to 66/12 (66+12+97 = 175); derivation: body 69 open + 18 partial minus the 9 already-fixed-on-main (3 open + 6 partial) = 66/12 = 78 pending, fixed 88+9 = 97. Added a comment documenting the summary-vs-body relationship. The reverification doc already states 66/12, so both now agree. Verified: gofmt + go vet clean; host + linux + windows builds; full go test ./... green. * audit batch 42: address CodeRabbit re-review round 6 (1 fixed, 1 kept w/ proof) CodeRabbit re-surfaced two "duplicate" refinements; CI was already green (its 6th pass was a COMMENT with no new findings). - cli/sandbox_test (TestRunSandboxGrantsRejectsEmptyPath): now seeds a tool-wide grant and asserts grant-store immutability after EACH rejected empty-`--path` call (reflect.DeepEqual before/after), so a mutation in one call can't be masked by a later one — and a buggy "revoke all" is actually observable (it's a no-op on an empty store). Stronger than the suggested patch. - zerogit.parseStatus: KEPT consuming the rename source on the index column only (code[0]), with an explanatory comment. Verified empirically that git porcelain v1 -z reports a rename/copy (and emits the extra source field) only in the index column: `git mv` → "R new\0old\0"; a worktree-only rename → " D old\0?? new\0" (delete + untracked, never "R" in code[1]). Consuming on code[1]=='R'/'C' would match no real git output and would only risk mis-consuming the next entry on malformed input, so the suggestion is declined as incorrect for v1. Verified: gofmt + go vet clean; host + linux + windows builds; affected suites green. Render assistant Markdown in TUI (#187) * Render assistant Markdown in TUI Add a lightweight assistant Markdown renderer for streaming and completed transcript rows, including inline bold, code/emphasis marker cleanup, pipe tables, adaptive row separators for wrapped tables, and plain-text output for selection metadata. Tests cover streaming markdown, completed rows, selection metadata, table wrapping, compact tables, inline markers, and width bounds with neutral fixtures. * Refine TUI Markdown table rendering Add outer borders for rendered assistant tables, add row separators for larger comparison tables even on wide terminals, and normalize common HTML line breaks inside table cells. Tests cover bordered tables, wide multi-row tables, compact tables, and HTML break normalization. * Fix Markdown inline parser edge cases Preserve literal stars, prevent code spans from inheriting bold state, and avoid inserting spaces before punctuation split by inline marker boundaries. Add regression checks for plain/selectable ANSI-free text and final-row done metadata on rendered Markdown rows. * Preserve coding literals in TUI Markdown Tighten inline delimiter parsing so code spans, Python dunder names, globs, and arithmetic stars stay literal while normal bold/emphasis still renders. Add regressions for the reviewed code-span, dunder, glob, and operator examples. Expand agent quality eval module (#178) wedge stage 13: launch readiness (first-run + benchmark) (#186) * stage 13: launch readiness (first-run + benchmark) First-run, model-agnostic-first: - provideronboarding: detect local OpenAI-compatible runtimes (Ollama/LM Studio) on their default ports and offer them with no API key required - provideronboarding: ClassifySetupProbe maps a live connectivity probe to a specific, fixable error class (bad key, wrong base URL, model not found, rate limit, config) instead of a stack trace; never panics - zero setup --verify: probe the provider after saving and report the fixable error on failure; print a concrete 'try this' headless example on success zero doctor hardening: - new sandbox.backend check (bubblewrap/sandbox-exec) with a platform-specific install remedy; warn (not fail) on policy-only fallback - new lsp.servers check listing missing language servers with per-binary install remedies; injectable GOOS/LookupExecutable for deterministic tests Self-correct surface + benchmark: - zero exec --self-correct wires the post-edit verify-and-correct loop via the project verifier; nil corrector when off keeps the agent loop byte-identical - perfbench task harness: reproducible Terminal-Bench-style runner over headless zero exec + stream-json; records model + commit + self-correct flag + date - zero-perf-bench tasks subcommand (with --dry-run) and a sample task set - docs/BENCHMARK.md: the number, the exact command, the model, and the with/without-self-correct methodology * stage 13: address review (verify-state, fixture paths, run_end exit, 404 classification) * stage 13: verifier inherits process environment (PATH/HOME/toolchain) wedge stage 10: distribution (skill/plugin install, tools make) (#184) * stage 10: distribution (skill/plugin install, tools make) Add a file-backed distribution surface so skills and plugins can be installed from a git URL or local path, and new plugin-tools can be scaffolded. No central registry: a source is just a git URL or path. - internal/skills/install.go: Install/Remove/Info + skills.lock. Validates SKILL.md frontmatter via the existing parser, copies it into the skills dir, and records a sha256 content hash. Reinstall surfaces the hash change; a clash with a different source is refused without --force. - internal/plugins/install.go: same flow validated against the plugin manifest schema, copying the whole plugin tree (minus .git) so the plugin is activatable. plugins.lock records source + hash. - internal/tools/scaffold.go: generate a prompt-gated plugin-tool skeleton (manifest + runnable shell/node/python entry stub) in the toolbox dir. - CLI: zero skill {add,info,remove}, zero plugin {add,remove}, and a new zero tools {make,list}; wired in app.go/extensions.go/skills.go. Safety: fetch honors the process environment (proxy/egress) and disables git credential prompts; install never executes fetched content (skills are markdown; plugin install scripts are never run) and installed plugins still go through normal activation with permission gating. * stage 10: make no-usable-name install test cross-platform The test used a whitespace-only source dir name to force the no-usable-name rejection, which is not creatable on Windows (Smoke failure). Switched to a dir base that validSkillName rejects on every platform (contains "..") while staying creatable on Windows; the blank frontmatter still falls back to the unusable dir base, exercising the same rejection. * stage 10: address review (tree hash, tools-list diagnostics, --json guards) * stage 10: make canonical-source tests cross-platform (Windows) Two Windows Smoke failures from the canonical-source review fix: - TestInstallSameLocalSourceDifferentSpellingIsNotAClash (skills + plugins) used filepath.Rel(cwd, tempdir), which errors on Windows when the temp dir (C:) and repo (D:) are on different drives. Switched to a same-directory alternate spelling (redundant /./), which still exercises canonicalSource without a cross-drive relative path. - TestRunSkillAddInfoRemove asserted the raw source path, but canonicalSource resolves symlinks/8.3-short-paths, so the stored+displayed source differs on Windows. Normalize the source via EvalSymlinks up front so the assertion matches on every platform. * stage 10: address CodeRabbit nitpicks (variadic append, header comment) wedge stage 11: GitHub Action + Slack/webhook notifier (#182) * stage 11: GitHub Action + Slack/webhook notifier Add a distribution channel and an output sink for unattended ZERO runs. internal/notify: - Add a Sink interface and Notifier fan-out. Notify now emits the terminal bell/OSC-9 sequence AND forwards every eligible event to attached sinks. Sinks fire even with no terminal writer (headless CI) and are gated by the same mode/focus policy. Sink invocation happens outside the lock and a sink panic is isolated, so a misbehaving or slow sink can never crash or stall the run. New(w, cfg) stays backward compatible. - Add a WebhookSink (slack.go) that POSTs a JSON payload {text, type, message, summary?, links?} to a Slack incoming webhook or any generic webhook. Delivery is best-effort and fails soft: non-2xx and transport errors are logged (redacted) and swallowed. The message, summary, links, and the webhook URL are run through redaction before send/log so tokens never leak. The default HTTP transport honors HTTP(S)_PROXY, so a sandboxed run's scoped-egress policy is respected. An empty URL is inert. action.yml: - Composite GitHub Action wrapping zero exec. Provider-agnostic: provider, api-key, and api-key-env are inputs (no provider is hardcoded). Installs the pinned ZERO release via the bundled checksum-verifying installer, runs the prompt in the checked-out repo, surfaces ZERO's exit code as the step status, captures stdout, and can post a summary to the PR or to Slack. Safety defaults: auto=low, sandbox always active, never passes --skip-permissions-unsafe; secrets are passed via env and never echoed. docs/GITHUB_ACTION.md: input/output tables, copy-paste workflows (issue-labeled triage and nightly dep-upgrade PR), security notes, and the notifier docs. .github/workflows/zero-action-smoke.yml: validates action.yml parses, declares the documented inputs/outputs, keeps the conservative defaults, never enables unsafe mode, and shell-syntax-checks the embedded run blocks. * stage 11: reword action.yml comment so the unsafe-flag validation passes The composite step's shell comment contained the literal '--skip-permissions-unsafe', which the action-yml validation (assert the string never appears in the serialized doc, to prove the flag is never passed) flagged even though it was only descriptive. Reworded the comment to drop the literal; the action still never passes the flag and the check stays strict. * stage 11: address review for action env/inputs and webhook URL redaction - action.yml: reject prompt+prompt-file when both set; trim only surrounding whitespace from add-dir entries (preserve internal spaces); export api-key in-process (validated env name) so the run sees it; treat a commit-SHA action_ref as not-a-tag and fall back to latest. - notify: redact the webhook URL in outbound payload fields (not just logs); copy caller-owned Links/ExtraSecrets slices in the constructor. * stage 11: disable credential persistence in action smoke checkout wedge stage 09: finish plugin wiring (tools/hooks/skills) (#185) * stage 09: finish plugin wiring (tools/hooks/skills) Activate resolved plugin manifests so their declared tools, hooks, and skills take effect at runtime instead of being parsed-but-unconsumed. - internal/plugins/activate.go: Activate() registers each plugin tool into the tools.Registry via a command-runner adapter (JSON args on stdin, stdout/exit -> tools.Result, ${AGENT_PLUGIN_ROOT} expansion + env), builds hooks.Definition values for plugin hooks, and exposes plugin skill search roots. Permission maps allow/prompt/deny -> tools.Safety; allow only survives load-time clamping when manifest auto-approval is enabled, so mutating plugin tools are not auto-approved by default. A plugin-aware skill tool merges the default skills dir with plugin roots (recording duplicate names, never crashing). Malformed plugins/extensions are skipped with a warning; activation order is deterministic. - internal/cli: activatePlugins() invokes activation in both bootstrap paths (interactive TUI in app.go and one-shot exec in exec.go), after core/specialist/MCP registration. newHookDispatcherWithExtra folds plugin hooks into the dispatcher's hook set. Fails open: a bad plugin warns and is skipped, never wedging startup. - internal/plugins/plugins.go: update the stale 'not yet registered' note. * stage 09: normalize plugin command path separators for Windows expandPluginRoot kept the manifest's forward slashes, so a ${AGENT_PLUGIN_ROOT}/bin/x command became a mixed-separator path on Windows (C:\...\001/bin/x), failing the path tests. Added expandPluginRootPath (filepath.FromSlash on the placeholder-expanded executable path only; args keep their slashes since they may be URLs/flags) and used it for the tool and hook command paths. * stage 09: address plugin wiring review - surface per-plugin load diagnostics on stderr (not just top-level errors) - skip plugin tools whose name collides with an already-registered tool - resolve manifest-relative plugin skill paths against the plugin dir * stage 09: fall back to plugin path/id when manifest path unknown in load diagnostic wedge stage 12: PDF ingestion (#183) * stage 12: PDF ingestion Add PDF ingestion to internal/imageinput so specs, design docs, and tickets that arrive as PDF can be attached the same way images are. - internal/imageinput/pdf.go: LoadDocument detects PDFs by magic bytes (%PDF-, never extension alone), enforces a 32 MiB raw-file cap and a 256 KiB extracted-text cap (truncated with a marker, not rejected), and extracts the text layer in pure Go via github.com/ledongthuc/pdf. The parser can panic on malformed structures, so extraction is wrapped in a recover that turns a bad PDF into a clean error. A textless PDF with no rasterizer returns an explicit "no extractable text; OCR not available" error rather than silent empty success. - Dependency posture: default build is pure-Go text extraction (no CGO, no transitive deps) so the static binary still cross-compiles. The optional poppler tools (pdftotext / pdftoppm) are used for richer extraction and page rasterization ONLY when present on PATH (same posture as the LSP language servers); when absent, extraction degrades to the pure-Go text path and never errors. - Vision path: with a vision model and pdftoppm available, the first N pages (default 10) render to PNG and flow through the existing image pipeline (NormalizeImageMediaType, per-image cap, allow-list). Without a rasterizer it degrades to the text layer. - TUI integration: /image routes to the document path. The text layer attaches for any model and is prepended to the next prompt as a labeled preamble; rendered pages attach as images for vision models. The chip row and /image clear cover documents too. Tests cover magic-byte detection, text extraction on a generated fixture, both size caps, the no-text/no-raster message, malformed-input safety, the pure-Go fallback, and the TUI attach/submit/clear wiring. go build, go test, and gofmt are clean; CGO_ENABLED=0 cross-compiles for linux/amd64, windows/arm64, and darwin/arm64. * stage 12: address review (pdf page count, text cap, content-based routing) wedge M2: MCP server resources/prompts + sandbox scoped egress + MCP OAuth (#179) * wedge stage 06: MCP server resources + prompts Advertise resources and prompts capabilities in the initialize response and handle resources/list, resources/read, prompts/list, and prompts/get alongside the existing tools methods. Resources enumerate in-scope workspace files via the shared workspace scanner (reusing its gitignore-style exclusions) and read them read-only with strict scope enforcement: traversal and out-of-scope absolute paths are rejected with a JSON-RPC error and no contents, and binary files come back as base64 blobs. Prompts expose a curated set of templates with simple {{arg}} substitution; unknown prompt names return an error. * wedge stage 07: sandbox scoped egress + auto-allow-bash Add a middle-ground network mode between full allow and full deny: - types.go: NetworkScoped network mode; Policy.AllowedDomains / Policy.DeniedDomains; Policy.AutoAllowBashWhenSandboxed plus its ZERO_SANDBOX_AUTO_ALLOW_BASH env surface (off by default). - egress.go: in-process loopback (127.0.0.1:0) filtering proxy handling HTTP CONNECT (HTTPS tunnel) and plain HTTP. Allows only AllowedDomains minus DeniedDomains via exact-or-subdomain matching (github.com matches api.github.com but not notgithub.com or github.com.evil.com); deny wins. Denied targets get 403 / refused CONNECT. Decisions are logged through the repo redaction so query tokens never hit the audit trail. Fails closed: empty allowlist is rejected at construction. - runner.go: NetworkScoped starts the proxy and routes the sandboxed process through it (HTTP(S)_PROXY/ALL_PROXY + NO_PROXY into the sandbox env; Linux keeps --unshare-net; macOS profile denies general network but permits localhost:proxyPort). Empty-allowlist scoped collapses to deny (effectiveNetwork). Any proxy-start error denies the build rather than falling back to open network. NetworkAllow/NetworkDeny unchanged. - engine.go / registry.go / bash.go: honor AutoAllowBashWhenSandboxed - auto-allow bash without a prompt only when a wrapping sandbox is active for the command; ignore the flag when the sandbox is inactive. Preflight network gate now uses effectiveNetwork so a misconfigured scoped policy still fails closed. Bash defers plan.Cleanup() to shut the proxy down. Default behaviour is unchanged for existing NetworkAllow / NetworkDeny users and for the off-by-default auto-allow flag. * wedge stage 08: MCP OAuth client Add an OAuth 2.0 + PKCE authorization-code flow so ZERO can connect to remote MCP servers that require OAuth. - internal/mcp/oauth.go: authorization-server metadata discovery with config fallback/override, S256 PKCE, loopback redirect listener, state (CSRF) validation, code+verifier token exchange, refresh, and optional dynamic client registration. Tokens are never logged. - internal/mcp/oauth_store.go: per-server token persistence in a 0600 JSON file under the config dir, with a redaction-safe status summary that omits the token material. - internal/mcp/network_client.go: OAuth-configured servers get a round tripper that attaches the bearer token and, on 401, refreshes once and retries; refresh failure surfaces an actionable re-login message. - internal/mcp/config.go + internal/config: an MCP server entry may declare auth: "oauth" with optional client registration / explicit endpoints. Non-OAuth servers are unaffected. - zero mcp oauth {login,logout,status} wired into the CLI surface; status reports presence/expiry, never the token. * wedge M2: address review — 2 security fixes, portability, robustness (#179) Security (Critical): - resources: resolve symlinks BEFORE the scope check (and resolve roots) so an in-workspace symlink to an out-of-scope file (e.g. link -> /etc/passwd) is no longer readable via resources/read. resources/read echoes the requested URI (not the resolved path) so list/read stay consistent. - sandbox egress: authorize the dialed URL host, not the client Host header, so 'GET http://denied/ ... Host: allowed' can't bypass the allowlist. Robustness/correctness: - prompts: reject prompts/get that omits a required argument; tokenized substitution so {{...}} inside supplied values is preserved (not stripped). - resources: percent-encode/decode file URIs via net/url (spaces, #, %). - oauth: one timeout-scoped context bounds discovery + registration + callback + exchange; joinWellKnown preserves issuer path segments (RFC 8414). - cli: 'mcp oauth login' rejects --json instead of silently ignoring it; the login test is bounded so a missed callback fails fast instead of hanging. Portability: XDG token-store path test uses t.TempDir() so it passes on Windows (fixes the Windows Smoke failure). Adds regression tests for the symlink escape, Host-header bypass, required-arg rejection, and placeholder preservation. Full go test ./... + -race green. * wedge M2: fail closed on unenforceable scoped egress (review #179) Addresses the two P1 scoped-egress enforcement gaps from review: 1. Policy-only fallback ran NetworkScoped with unrestricted networking. On Windows / any host without a native backend, 'network: scoped' with allowedDomains silently got open host networking because Evaluate permitted network-risk tools (expecting a proxy) while BuildCommandPlan ran a direct plan with no proxy. Evaluate now collapses scoped to deny when the active backend cannot enforce egress, so network-risk tools fail closed. 2. Bubblewrap 'scoped egress' could not reach the proxy. --unshare-net isolates the netns and there is no bridge to the host-loopback proxy (the comments claimed one; no code added it), so allowlisted egress never worked. Bubblewrap no longer starts an unreachable proxy: scoped collapses to deny (--unshare-net, no proxy env), matching Evaluate. Comments corrected; a real relay (e.g. slirp4netns) is left as future work. Mechanism: new Backend.ScopedEgress capability (true only for sandbox-exec, which shares the host net under a seatbelt profile restricting outbound to the proxy port) + Backend.EnforcesScopedEgress(). sandbox-exec scoped egress is unchanged. Regression tests cover enforcing vs non-enforcing backends in Evaluate and the bubblewrap fail-closed plan. wedge build: web_search + LSP client/diagnostics (stages 01-03) (#175) * stage 01: web_search tool Adds a web_search tool alongside web_fetch: discovers URLs via a pluggable searchBackend (env-configured generic JSON backend; swappable for Exa/You/ Tavily/Brave) and formats ranked results as a numbered list. Registered in CoreNetworkTools(). Deviation from the stage spec: the spec said readOnlySafety, but the repo enforces (TestCoreNetworkToolsExposePromptMetadata) that every core network tool is prompt-gated network egress — so web_search matches web_fetch's posture instead of being auto-allowed. Necessary fallout from adding the tool: knownToolNames (+web_search), the network-tools metadata test (now covers both), and a stale 'unknown tool' fixture that had used web_search. * stage 02: LSP client foundation New internal/lsp package: a direct (self-spawned, stdio) Language Server Protocol client so unattended runs see real compiler diagnostics without an open editor. - client.go: JSON-RPC 2.0 with LSP Content-Length framing; concurrent Call/Notify, id-matched responses, server->client request auto-reply, notification handler, context cancellation, read-loop teardown. - server.go: process lifecycle (spawn via os/exec, initialize/initialized handshake, graceful shutdown+exit with kill fallback). Handshake factored out (performInitialize) for pipe-based testing. - registry.go: extension -> server command (.go/.ts/.tsx/.js/.jsx/.py/.rs), languageId map, PATH availability via exec.LookPath. - types.go: minimal LSP types + PathToURI/URIToPath (Windows-drive aware). Stdlib-only; tested over in-memory pipes with a stub server (no real gopls), including -race for the concurrent id router. * stage 03: LSP document sync + diagnostics Makes the LSP client produce actionable signal so ZERO can ask 'did my edit compile?' headlessly. - documents.go: per-server session with document lifecycle (didOpen/didChange full-sync/didClose), a URI->diagnostics store fed by publishDiagnostics, and WaitForDiagnostics with a publish-baseline + 300ms quiet debounce (servers never signal 'analysis complete'). - diagnostics.go: FormatDiagnostics (path:line:col: severity: message, 1-based), HasErrors, FilterBySeverity, severity labels. - manager.go: Manager — one lazily-started, reused server per language (keyed by binary), routes a file to the right server, Check(ctx,path,text) -> settled diagnostics, graceful Shutdown; unknown extension -> (nil,nil), not an error. Concurrency-safe; tested under -race over in-memory pipes with a stub server (no real gopls). FormatDiagnostics takes the path explicitly since a Diagnostic carries only a range, not its document. * wedge: address review on #175 (LSP robustness + web_search provider) - lsp/client.go: Close() now disables the client — Call/Notify reject after close instead of leaking a pending entry / writing to a dead conn. - lsp/documents.go: serialize same-document syncs through the Notify write via a per-URI lock and commit version/open only on success; drop delayed publishDiagnostics for a superseded (older positive) version; WaitForDiagnostics now reports whether a fresh publish arrived. - lsp/manager.go: Check returns (nil,nil) when no publish newer than baseline arrives (don't return a stale prior result for new text); Shutdown joins and returns per-server shutdown errors instead of always reporting success. - lsp/diagnostics.go: FormatDiagnostics collapses embedded whitespace so each diagnostic stays one physical line. - tools/web_search.go: forward ZERO_WEBSEARCH_PROVIDER in the request so the config knob isn't inert. Adds tests: close-disables-client, stale-version drop, multi-line message collapse, Shutdown error propagation, httptest backend (provider+parse). * wedge: degrade gracefully when an LSP server binary is missing (#175 review) Manager.Check translated a missing-binary start error (exec.ErrNotFound) into (nil,nil) so a configured extension on a machine without gopls/ pyright/rust-analyzer degrades exactly like an unsupported extension — LSP diagnostics are an opportunistic layer, not a hard dependency. Other start failures still surface. Adds a test injecting an exec.ErrNotFound starter. * wedge stage 04: self-correct loop (#176) * stage 04: self-correct loop After a successful mutating tool call, verify the changed files (LSP diagnostics + project tests) and feed failures back to the model to fix — bounded and autonomy-gated, so an unattended run fixes its own mistakes without looping forever. - internal/agent/selfcorrect.go: SelfCorrector with injectable diagnosticsChecker (lsp.Manager adapter) and projectVerifier (verify.DetectPlan+Run); AfterEdit produces a redacted, model-facing corrective message; per-run attempt ceiling; low autonomy reports without auto-attempting; medium/high auto-correct. - internal/agent/loop.go: invoke AfterEdit after a successful mutating tool call (ChangedFiles non-empty), appending feedback to messages. nil SelfCorrect (the default) is a no-op — read-only tools never trigger it. - internal/agent/types.go: add Options.SelfCorrect *SelfCorrector (the explicit options-struct change). Single-pass verify per call (the agent loop + model edits provide the multi- attempt cycle, not selfverify's same-command re-runs). Tested with fakes (no gopls/shell): fail-then-pass, max-attempts abort, read-only skip, low-autonomy report-only, LSP-only failure, secret redaction; -race clean. CLI activation (a --self-correct flag building the SelfCorrector in exec.go) is intentionally left out to honor this stage's file scope — the engine is exported and ready to wire. * wedge: propagate file-read error in self-correct LSP checker (#176 review) CodeRabbit/golangci-lint (nilerr) flagged lspDiagnosticsChecker.Check returning (nil, nil) after os.ReadFile fails. Propagate the error instead; the sole caller (inspect) already skips files whose Check errors, so a deleted/unreadable file still degrades quietly while future callers can decide. Adds a regression test. * wedge: surface non-degradable verify errors in self-correct (#175 review) SelfCorrector.inspect silently continued on every checker.Check error and ignored verifier errors, so a real LSP startup/sync failure, an unreadable changed file, or a failed plan detection produced report.Failed=false -> OutcomePassed with no signal. Manager.Check already degrades missing servers to (nil,nil) and DetectPlan returns an empty plan (not an error) when nothing is detected, so a non-nil error from either is a genuine failure. Record such errors in CorrectionReport.InspectErrors, fail the pass, and surface them in the model feedback. Adds regression tests for both paths. * wedge: drop third-party search-provider names from web_search comments Keep code/comments provider-neutral: describe the backend interface as 'any hosted search API' rather than naming specific services. * wedge: defer self-correct feedback until after the tool-call batch (#175 review) Self-correct feedback was appended as a user message inside the per-tool-call loop, so a multi-tool-call turn whose first (mutating) call failed verification produced: assistant tool_use batch -> tool_result 1 -> user feedback -> tool_result 2. A user message interleaved between tool_results breaks strict provider replay and later session replay/compaction. Accumulate feedback during the batch and append it once after the loop (same after-batch pattern already used for mid-run model escalation), so every advertised tool call keeps its tool_result contiguous. Adds a two-tool-call regression test asserting the feedback lands after both tool_results. * wedge: batch self-correct AfterEdit once per turn over union of changes (#175 review) Deferring only the feedback emission still ran SelfCorrect.AfterEdit once per mutating tool call, which (a) consumes the per-run attempt budget multiple times in a single turn and (b) verifies an intermediate edit that a later call in the same batch already superseded — AfterEdit is documented as one per-run instance holding attempt state, so this is a correctness issue, not just transcript shape. Aggregate every mutating tool's ChangedFiles during the batch and call AfterEdit once afterward over the deduped union, appending its feedback once (still after all tool_results, preserving contiguity). Adds dedupeStrings and a regression test asserting two writes in one turn consume exactly one attempt and emit one feedback message. Add agent quality eval foundation (#177) * Add agent quality eval foundation * Fix agent eval review issues * Address agent eval review feedback audit: fix isolated remaining findings (redaction, secrets, rune-safety, credential status) (#174) * audit: fix redaction sibling-DAG false cycle, hoist URL regex, widen OpenAI key pattern - redaction: seen-set now tracks only the current DFS path (delete pointer after recursing) so a shared reference reached via a sibling branch is no longer reported as CircularReference; true cycles still detected. (audit redaction.go:222) - redaction: redactURLPasswords compiles its regexp once at package scope instead of per call. (audit redaction.go:362) - secrets: openai_key body allows - and _ so sk-proj-/sk-svcacct- keys match, not just legacy sk-. (audit scanner.go:35) * audit: rune-safe cron run-error truncation and commit-subject length - cronTruncate now cuts on a UTF-8 rune boundary (via cutRuneBoundary) so a persisted cron run-error excerpt can't end in a split rune. (audit cron_run.go:179) - zerogit ValidateMessage counts runes, not bytes, so a valid non-ASCII commit subject under 72 chars is no longer rejected. (audit zerogit.go:207) * audit: report auth-header-only profiles as having a credential ProviderSnapshot.APIKeySet was true only for a non-empty APIKey, so a profile authenticated solely via AuthHeaderValue rendered as 'not set' in the command center. Treat either credential as set. (audit contracts.go:166) * audit: address review on #174 (credential-presence coverage + consistency) - Add ProviderSnapshotFromProfile test covering APIKeySet for api-key-only, auth-header-only, both, and neither (the AuthHeaderValue path CodeRabbit flagged as untested). - doctor.go: report the provider 'apiKey' diagnostic as set when EITHER APIKey or AuthHeaderValue is present, matching ProviderSnapshot.APIKeySet — closing the same auth-header-only gap at the other credential-check site. - scanner_test: also assert the [REDACTED:openai_key] placeholder is present (matches the pattern used by the existing redaction test). * audit: trim credentials + surface doctor credential presence (#174 review) - ProviderSnapshot test: add whitespace-only api-key / auth-header cases so the TrimSpace behavior stays covered. - doctor.go: trim both APIKey and AuthHeaderValue before the presence check (matches ProviderSnapshot.APIKeySet) AND move the indicator off the sensitive 'apiKey' detail key — check() runs RedactValue, so 'apiKey' was always scrubbed to [REDACTED], leaving the set/not-set indicator invisible. Now reported under 'credentialConfigured' so it actually surfaces; added a doctor table test. No consumer read the old detail key. * audit: honor AuthHeaderValue in model discovery + base-URL redaction (#174 review) Two credential-presence consistency gaps flagged on #174: - providermodeldiscovery: the /models probe gated on (and sent Authorization from) profile.APIKey only, so an auth-header-only profile was skipped/sent unauthenticated. Gate via discoveryHasCredential (APIKey OR AuthHeaderValue) and build the request with providerio.ApplyAuthHeaders, matching how the live providers authenticate (honors AuthHeaderValue, AuthHeader, AuthScheme, CustomHeaders). APIKey-only behavior is unchanged. - zerocommands.redactProviderBaseURL now also redacts AuthHeaderValue if it leaks into the base URL (variadic secrets, empties dropped). Adds regression tests for auth-header-only discovery, discoveryHasCredential, and auth-header redaction in the base URL. * audit: doctor credential-presence test parity with contracts_test (#174 review) Add the 'both' and 'whitespace auth header only' cases so TestProviderConfigCheckCredentialPresence matches the six cases in contracts_test.go's TestProviderSnapshotAPIKeySetCountsAuthHeaderValue. Polish main TUI surface (#168) * Polish main TUI surface Simplify the empty chat surface, replace starter prompt chips with a cleaner brand state, move model and permission state into the composer frame, quiet the footer status, and improve composer word-editing keybindings. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Polish autocomplete command palette * Polish TUI file mention picker * Address TUI review feedback * Keep autocomplete anchored above composer * Center autocomplete over chat viewport * Layer autocomplete overlays over transcript * Prefill argument commands from palette * Show argument hints for command completions * Polish TUI provider setup * Polish TUI picker and composer metadata * Add two-step mouse selection in TUI * Polish TUI mouse selection flows * Make provider wizard navigation test hermetic * Fix TUI review edge cases Edit safety: file read/write tracking, external-modification detection, post-apply hooks (#172) * Edit safety: file version tracking, conflict detection, hook wiring Phase 1 — FileTracker (internal/tools/file_tracker.go): per-session map[absPath]{sha256, size, mtime}; CheckConflict flags drift only against a known baseline, so first-touch writes are unaffected. nil = no-op. Phase 2 — external-modification detection: read_file records the whole-file baseline; edit_file and write_file --overwrite refuse to clobber a file whose on-disk content drifted from it and tell the model to re-read first, then re-baseline to what they wrote. apply_patch forgets the files it rewrote (git apply has its own context guard). Threaded via RunOptions/agent.Options FileTracker, built once per session in the exec, spec, and TUI paths. Phase 3 — post-apply validation via hooks: the dormant hooks.Dispatcher is wired into executeToolCall. beforeTool hooks can veto a call (DenialHookBlocked); afterTool hook output is scrubbed and surfaced to the model. DispatchOutcome now collects hook stdout/stderr. Per-session dispatcher built from merged user+project hooks.json; fails open and runs nothing until a hook is configured. * edit-safety: address review — fail closed on read, redact hook reason - write_file overwrite: if a tracked file cannot be re-read to verify it, fail CLOSED (refuse the overwrite) instead of silently proceeding — the read error previously skipped the conflict check and could clobber an externally modified or replaced file. - blockedByHookResult: scrub outcome.Reason through redaction before placing it in model-visible ToolResult.Output; this intercepted path bypassed the registry's normal output-redaction boundary. - TestHashContentIsStableAndDistinguishing: compare separately stored hashes so the stability check is not a same-expression comparison (SA4000). * edit-safety: flag ToolResult.Redacted when hook output is scrubbed appendHookFeedback now reports whether scrubbing changed the afterTool feedback, and blockedByHookResult reports whether it scrubbed the reason; both set ToolResult.Redacted so the hook-intercepted paths match the registry's redaction contract. Adds tests asserting a secret-bearing hook reason/feedback is scrubbed and flips Redacted. permissions: scope-enforced grants (permission-UX Phase 0 + 1) (#170) * permissions: show grant scope on the permission card and decided rows The permission card only named the tool, so 'always' read as a blind tool-wide yes. Derive a concise scope from the call's args (the file path, directory, or working dir it touches) and show it on the card and the persisted decision row, so a user can see exactly what an allow covers. Phase 0 of the permission-UX work: presentation only, no storage change. permissionScope + scope plumbing through PermissionEvent/PermissionRequest; covered by TestPermissionScope. * permissions: spec for Phase 1 scope-enforced grants * permissions: enforce grant scope so "always allow" covers only what the card showed Phase 0 displayed the scope a tool call touches but the persisted grant was still tool-wide: an "always allow" on a write to one file silently authorized every write. This scopes the grant to exactly that file or directory. sandbox/scope.go centralizes scope derivation (DeriveScope), absolute-path resolution (resolveScopeAbs, anchored to the workspace so a grant never leaks across projects), and matching (grantCovers: file = exact, dir = subtree, empty = tool-wide; a tool-wide request is never covered by a narrower grant). Grants are now stored per-tool as a list (schema v2, with v1 files migrated as tool-wide grants). Lookup takes the request's absolute scope and applies deny-wins / most-specific-allow precedence. engine.Grant anchors a relative scope to the workspace; engine.Decide derives and matches the request scope. agent.persistPermissionGrant forwards the scope, and permissionScope now shares DeriveScope so the card and the stored grant can never diverge. Canonicalizing tool keys on read also closes the whitespace-padded-key lookup miss. Covered by sandbox scope/grant/engine tests and the agent/TUI always-allow tests. * sandbox: fix Windows-only TestResolveScopeAbs by using truly-absolute paths A leading separator (\proj\a) is rooted but not absolute on Windows (filepath.IsAbs requires a volume like C:), so the absolute-passthrough case was treated as relative and anchored onto the workspace root, producing a doubled path. Build the root via filepath.Abs so the test exercises a genuinely-absolute path on every platform. * test: isolate session store in exec/tui tests so go test stops writing to $HOME TestRunExecStreamJSONRunStartUsesResolvedAPIModel, TestRunExecReadsStreamJSONPromptFromStdin, and TestPromptSubmitInjectsLiveSessionModelContext ran real exec sessions but only isolated cwd (t.TempDir + getwd), not the session store, so the default store resolved sessions.DefaultRoot() off the real $HOME and persisted into ~/.local/share/zero/sessions on every run. Set XDG_DATA_HOME to a per-test temp dir (the existing convention in exec_test.go / exec_scope_test.go) so each run is fully isolated. * permissions: address review — clean root-equiv scopes, label+fit scope rows - DeriveScope: filepath.Clean the path before the root check so ./, ./., and a/.. collapse to the workspace root and surface as tool-wide instead of a narrower directory grant (which re-prompted inconsistently). Adds regression cases. - renderPermissionRow: prefix the scope segment as scope: across all three branches and fitStyledLine the allow branch (it returned unfit and could overflow narrow terminals). - TestGrantReplacesSameScope: assert the setup store.Grant errors so a setup failure fails at the real cause, not a stale-state assertion. * docs: point permission-scope spec at grant_scope.go The design spec named internal/sandbox/scope.go as the source of truth, but DeriveScope/resolveScopeAbs/grantCovers live in grant_scope.go (scope.go is the unrelated write-roots Scope from #162). Fix the heading and the test reference (grant_scope_test). Add session context compaction flow (#166) * Add session context compaction flow * Animate manual compaction flow * Simplify compact progress copy * Render compact progress as compression card * Render compact completion as success card * Fix compaction review findings * Fix compaction resume fallback test: isolate session store in exec/tui tests so go test stops writing to $HOME (#171) TestRunExecStreamJSONRunStartUsesResolvedAPIModel, TestRunExecReadsStreamJSONPromptFromStdin, and TestPromptSubmitInjectsLiveSessionModelContext ran real exec sessions but only isolated cwd (t.TempDir + getwd), not the session store, so the default store resolved sessions.DefaultRoot() off the real $HOME and persisted into ~/.local/share/zero/sessions on every run. Set XDG_DATA_HOME to a per-test temp dir (the existing convention in exec_test.go / exec_scope_test.go) so each run is fully isolated. tui/resume: list only standalone conversations, not sub-runs (#169) The /resume picker called sessions.Store.List() unfiltered, so it showed every session directory — including the child (specialist/sub-agent) and spec draft/impl sessions an agent spawns by the dozen per conversation. That flooded the picker (the reported '… 779 more · /resume ') even though the TUI creates exactly one session per conversation. Add Store.ListResumable()/LatestResumable() (and an IsResumableKind predicate) that keep only regular and user-fork sessions, and use them for the picker and for '/resume latest' so latest never lands on a newer child/spec sub-run. An explicit '/resume ' still resolves any session by id. Covered by TestListAndLatestResumableExcludeSubRuns and TestResumePickerExcludesSubRunSessions. Additional write roots: --add-dir flag, global config key, /add-dir TUI command (#162) * docs: spec for additional write roots (--add-dir / /add-dir) * docs: implementation plan for additional write roots * sandbox: add Scope type for multi-root write access * docs: write roots honored from global config only, union merge semantics * sandbox: fix multi-root violation reporting and pin prefix-alias behavior - validate: prefer ViolationSymlinkTraversal over ViolationOutsideWorkspace when all roots deny; return original requestedPath in violation; append --add-dir hint only on outside_workspace results; extend doc comment to state that a symlink resolving inside any root is allowed. - normalizePrefixForRoot: dedupe insideRoot check via pathWithinRoot (Fix 3); widen EvalSymlinks comment to cover jump-over case; add POSIX-only note. - WorkspaceRoot: add doc comment noting immutability/lock safety (Fix 4). - Tests: pin multi-root traversal-preferred behavior, deterministic alias test (TestValidateResolvesAliasedPathPrefixes), Add symlink normalization test, and tilde-expansion error path test. * sandbox: make engine path validation and risk scope-aware Thread *Scope through EngineOptions/Engine so Evaluate and Classify use multi-root scope validation instead of single-root validateWorkspacePaths, letting extra write roots (--add-dir / /add-dir) be honoured by the policy gate and risk classifier. Remove now-dead validateWorkspacePaths helper. * sandbox: pin override-root scope isolation, tidy Evaluate scope handling * sandbox: widen seatbelt/bubblewrap profiles and command cwd to scope roots * sandbox: pin extra-root chdir behavior and document bind invariants * tools: resolve paths against the shared write scope Add PathScope interface and scoped resolver helpers to workspace.go, then thread a scope field through all 8 file tools (read_file, list_directory, glob, grep, write_file, edit_file, apply_patch, bash). Each existing constructor delegates to a new NewScoped* variant; nil scope is byte-identical to the original behavior. Registry gains CoreReadOnlyToolsScoped, CoreWriteToolsScoped, CoreShellToolsScoped, and CoreToolsScoped. * tools: restore fail-closed write-target symlink checks, share prefix normalization * tools: report absolute paths for extra-root changes, pin scoped traversal denial - resolveScopedPath and resolveScopedTargetPath now return the absolute path as the second value when the matched root is an extra (non-workspace) root, eliminating ambiguity in ChangedFiles, cwd meta, and display summaries downstream - Add TestScopedWriteRefusesSameRootSymlinkTraversal to pin that same-root symlink traversal within an extra root is denied (pre-existing behavior) - Add TestScopedWriteReportsAbsolutePathForExtraRoot to enforce the new absolute-path contract for ChangedFiles on extra-root writes - Extend doc comments on PathScope, ChangedFiles, changedFilesFromPatch, and both scoped resolvers to capture the workspace-vs-absolute invariant * config: add sandbox.additionalWriteRoots (global config only, union merge) * cli: add repeatable --add-dir flag wiring scope into registry and engine * cli: forward --add-dir across dispatch paths, pin scope wiring with tests * cli: pin --add-dir dispatch forwarding end-to-end, close guard gaps Review fixes for the --add-dir dispatch commit: - Add TestRunAddDirDispatchForwardsGrantIntoExecScope: drives runWithDeps through both the "exec" and "-p" dispatch shapes with a provider that calls write_file inside the extra root, asserting the grant reaches the exec sandbox scope (and a negative control without --add-dir is denied fail-closed). Mutation-verified: dropping addDirFlagArgs forwarding from either case fails the test. - Remove help/version from the --add-dir allowlist so any non-TUI/ non-exec leading --add-dir hard-errors, matching the stated requirement; extend the rejection test with those cases. - Fail loud when --add-dir is hidden behind a stray non-flag arg on the --skip-permissions-unsafe path instead of silently dropping the grant. - Reject flag-like values in the inline --add-dir= spelling to match the space form (a directory named -foo stays reachable as ./-foo). - Move TestExecScopeReRegistrationSwapsCoreToolsByName out of the parse test file into the new exec_scope_test.go alongside the e2e test. * tui: add /add-dir command for session write-root grants * sandbox: surface write roots in zero sandbox status and plan snapshots * cli: surface write-roots error in --effective --json, normalize fallback root Review fixes for the Task8 observability range: - runSandboxPolicyEffective JSON payload gains writeRootsError (omitempty) so --json consumers see the same fail-soft signal as the text write_roots_error line instead of a misleading workspace-only writeRoots list. - The fail-soft fallback now derives its write roots from a workspace-only Scope (which cannot fail) so the error path renders the same symlink-resolved root as the success path. - TestRunSandboxPolicyEffectiveWriteRootsFailSoft gains a --json leg asserting writeRootsError names the stale root with a workspace-only fallback; the configured-roots test asserts the key is absent for valid roots. - SandboxPlanSnapshot doc comment reworded to match the converter: no current builder populates WriteRoots. * sandbox: seatbelt integration test for extra write roots * docs: document --add-dir and /add-dir write-root grants * sandbox: pass volume-qualified paths through prefix normalization verbatim NormalizePrefixForRoot's POSIX component walk mangled Windows drive paths (C:\Users -> C:Users), which downstream single-root checks treated as relative and joined under the workspace — failing the policy gate OPEN on Windows. Volume-qualified paths now bypass the walk entirely (the macOS /var alias problem it solves has no Windows equivalent) and windows-gated tests pin both the verbatim pass-through and the engine denial. * sandbox: make prefix normalization volume-aware for Windows alias resolution The previous commit disabled NormalizePrefixForRoot on Windows, which broke it the other direction: a workspace created under an 8.3 short path (C:\Users\RUNNER~1\...) resolves via EvalSymlinks to its long form (runneradmin), so raw short-form requests escaped the long-form root and legitimate extra-root writes were denied. This is the same alias problem as macOS /var -> /private/var, which the helper already solves. Start the component walk from the volume root (C:\ or //host/share/) instead of "/" so it resolves the prefix on Windows too; POSIX VolumeName is empty so the walk reduces to the original "/"-rooted behavior byte-for-byte. Windows test now pins allow-inside-root + deny-outside both directions. * sandbox: address PR review findings for additional write roots Resolve the CHANGES_REQUESTED review on PR #162: - engine: derive workspaceRoot from scope.Roots()[0] when NewEngine is given a Scope without an explicit WorkspaceRoot, so Evaluate's EnforceWorkspace/classification guards no longer silently skip (+ regression test). - tools/workspace: scopedRoots now fails closed (returns an error) when a non-nil PathScope exposes empty Roots(), instead of returning success with an empty path; threaded through the scoped resolve/recheck helpers and resolveGrepRoot. - tools/glob: emit absolute matches when cwd resolves into an extra root so results can't be fed back and resolve to a same-named workspace file (+ regression test). - tools/bash,grep,apply_patch: schema descriptions mention granted extra roots (relative -> workspace, absolute -> granted root). - docs/plan: fix markdownlint heading increment, malformed rule+heading, and bare code fence. * tools/grep, tui: address PR review for additional write roots - grep now emits absolute, symlink-resolved paths for matches in a granted extra (non-workspace) root, mirroring the glob fix. A bare workspace-relative name like "report.go" otherwise resolves back under the workspace when fed to read_file/edit_file and hits the wrong file when the name exists in both roots. Covers content and files_with_matches output. - tui: only reset chatScrollOffset for a real submission, not an empty Enter (a no-op), so the viewport is no longer yanked to the bottom when nothing was submitted. Real submissions still snap back. - glob: schema description now mentions granted extra roots, matching the bash/grep/apply_patch wording so the --add-dir/-add-dir flow is discoverable. Regression tests: TestScopedGrepReturnsAbsoluteMatchesForExtraRoot, TestEmptySubmitKeepsChatScrollOffset. * docs: add /add-dir to the TUI commands quick-reference table The command was documented further down but missing from the scannable commands table, so users browsing the table wouldn't discover the additional-write-roots flow. --------- Co-authored-by: Claude Fable 5 providerhealth: assert the last-error contract in dialValidatedAddrs test Use distinct per-attempt errors and assert errors.Is(err, lastErr) plus the attempt count, so a regression returning the first failure (or short-circuiting) is caught instead of slipping past a bare non-nil check. audit: address PR review findings (security, correctness, tests) Fixes from the code review on the critical/high audit branch: - tools/apply_patch: parseHunkCounts now reads only the range section between the opening and closing "@@". A crafted section heading like "@@ -1,1 +1,1 @@ +1,999999" could otherwise overwrite the line count, wedge the parser in hunk mode, and swallow later file headers so they escaped validatePatchPaths / recheckPatchWriteTargets — a workspace-confinement bypass. - providerhealth: the SSRF-hardened dial now tries every validated address in order instead of pinning addrs[0], so a dual-stack/multi-record provider isn't failed by a single dead record. Extracted dialValidatedAddrs for coverage. - hooks/dispatch: a beforeTool hook killed by its deadline now fails CLOSED (vetoes the tool) instead of fail-open. A launch failure (missing binary) still fails open so a misconfigured hook can't wedge every tool call. - cron: Store.Get returns a distinct ErrJobNotFound sentinel; cron_run only treats that as "removed during run" and still records/persists on a transient read error instead of silently dropping the run. - providers/anthropic: closeOpen finalizes thinking buffers still open at stream end (SSE ended after thinking_delta/signature_delta but before content_block_stop), so they survive in the done event's ReasoningBlocks for replay. Finalized in index order. - providers/gemini: corrected the stale thinkingConfig comment (no IncludeThoughts field; thought summaries are suppressed by skipping part.Thought parts). Tests: regression coverage for each fix, plus the requested nitpicks — EOF-terminated MCP line, recipe-prompt propagation assertion, checked file.Close() in session store tests, and a shortened bash process-group timeout test via the bashWaitDelay hook. Note: declined the suggestion to replay a "signature" on redacted_thinking blocks — Anthropic's redacted_thinking carries only an opaque "data" field; the signature is for regular thinking blocks, and the stream never populates one for redacted blocks. hooks: add a Dispatcher that runs and audits configured hooks The package had all the primitives (Select, ConfigStore, AuditStore) but no caller ever ran a hook. Add a Dispatcher that selects the hooks for a lifecycle event, runs each command (no shell) with the event payload on stdin under a timeout, and records started/completed audit events. A beforeTool hook that exits non-zero vetoes the tool and stops the chain; other events are advisory. A hook that cannot be launched is recorded as an error but never blocks. The command runner is injectable for testing. sessions: only tolerate a torn tail when the final line is unterminated The torn-tail tolerance dropped any malformed final line, including a complete-but-corrupt line ending in a newline — silently swallowing real corruption. A genuine torn tail is an incomplete write with no trailing newline; gate the tolerance on that so a fully-flushed corrupt line fails loudly again, while a truncated final line is still recovered. providers/gemini: forward reasoning effort as a thinking budget Map a requested reasoning effort to a Gemini thinkingBudget (capped at the lowest per-model ceiling). Capture the thoughtSignature each functionCall part carries and preserve it on the tool call (via a new StreamEvent/ToolCall signature field), then replay it on the functionCall part in later turns so multi-turn function calling with thinking is not rejected. Thought-summary parts are skipped so reasoning never leaks into the answer. No effort = no thinking config, so default runs are unchanged. providers/anthropic: forward reasoning effort as extended thinking Map a requested reasoning effort to an Anthropic thinking budget and enable extended thinking, raising max_tokens so the budget plus a response both fit. Crucially, capture the streamed thinking / redacted_thinking blocks (text + signature) and preserve them on the assistant message so they are replayed verbatim at the start of the next turn — without this, Anthropic rejects every tool-using follow-up. Adds zeroruntime carriers (Message.Reasoning, ToolCall. Signature, ReasoningBlock) shared with the upcoming Gemini work. No effort = no thinking, so default runs are unchanged. providers/openai: forward reasoning effort to the chat completions request zeroruntime.CompletionRequest had no effort field, so agent.Options.Reasoning Effort never reached the provider (cli/exec.go even noted it was 'not yet forwarded'). Add CompletionRequest.ReasoningEffort, thread it through every request the agent loop builds, and map it to OpenAI's reasoning_effort. The CLI gates the forwarded value against the resolved model: known non-reasoning models send nothing (matching the existing 'ignoring' advisory), known reasoning models send their effective level, and unknown custom endpoints forward the requested value as-is. Anthropic/Gemini mapping is handled separately. agent: surface response truncation (finish_reason) to the user Providers already normalized finish_reason into collected.FinishReason, but the agent loop never read it, so a final answer cut off at the output token cap (or by a content filter) was silently presented as complete. Carry the turn's finish reason into agent.Result, add Truncated()/TruncationNotice(), and emit a warning from the exec writer and a system row in the TUI when the response was truncated. sandbox: allow /dev nodes and temp dirs in the sandbox-exec profile The macOS sandbox-exec profile made only the workspace writable, so ubiquitous operations failed with 'Operation not permitted' — `> /dev/null` and mktemp among them. The bubblewrap backend already grants these via --dev /dev and --tmpfs /tmp; bring the sandbox-exec profile to parity by allowing the standard device nodes and the system temp trees. The workspace remains the only writable project location. providerhealth: harden default connectivity probe against SSRF The default probe used http.DefaultClient, which (1) followed redirects without revalidating the target, so a 3xx to an internal address bypassed the pre-flight endpoint check, and (2) re-resolved DNS at dial time, leaving a rebinding TOCTOU window after validateEndpoint. Build a dedicated client that revalidates every redirect hop (capped at 5) and validates + pins the resolved IP at dial time, dialing the IP literal so the kernel cannot re-resolve to an unsafe address. tools/bash: kill the shell as a process group so timeouts don't leak children On timeout the context cancelled only the shell, leaving backgrounded children (e.g. 'sleep 3 & wait') orphaned. Worse, those children inherited the stdout/stderr pipes, so Run() blocked until they exited — far past the timeout. Set Setpgid and a Cancel that SIGKILLs the whole process group on POSIX, plus a WaitDelay backstop so a lingering child can't hang Wait. On Windows, set WaitDelay only (no process groups). specialist: keep name in session title so description-less runs resume A specialist run with no description set --session-title to just the description (empty), leaving AgentName empty on resume and making the session unresumable. Always prefix the title with the specialist name and fall back to the bare name when no description is given. cli/cron: honor explicit expr over recipe; don't clobber pause mid-run - `cron add "" --recipe X` silently dropped the explicit schedule because the recipe set expr before the positional arg was consumed. Consume the positional expr first so it overrides the recipe default. - cron_run persisted the job copy read at tick start, so a pause or removal that happened during the run was clobbered back to active. Re-read before persisting and preserve an external pause/removal (best-effort; full atomicity needs a lock). sessions: tolerate a torn tail when reading the event log ReadEvents returned an error on any malformed JSONL line, so a single interrupted append (truncated final line) made resume hard-fail and lose the whole session. Drop only a malformed final line (a torn tail); malformed lines earlier in the file are real corruption and still error. streamjson: stop over-redacting prose in secret scrubbing The sk- and bearer patterns were unanchored, so "sk-" inside "task-list" and the word after a bare "bearer" were replaced with [REDACTED], corrupting legitimate output. Anchor sk- on a word boundary and require a credential-length body for both sk- and bearer tokens; real keys/tokens are still redacted. tools: fix grep glob scope and apply_patch hunk-body misparse - grep matched the glob against the workspace-relative path, so "*.go" with path="subdir" found nothing while "**/*.go" matched subdir/a.go. Match the glob relative to the search directory (ripgrep semantics); a single explicit file is matched by its base name. - apply_patch parsed file paths per-line, so a hunk-body line removing content that starts with "-- " (appearing as "--- ...") was mistaken for a file header and a valid patch was rejected. Parse headers with hunk line-counting, matching how git apply delimits hunks. mcp: cap framed message size and use newline-delimited stdio framing - protocol.go read() rejected no upper bound on Content-Length, so a hostile peer could drive make([]byte, n) with n up to ~9.2e18. Cap every framed message at 64 MiB (both the Content-Length and newline-delimited paths). - The MCP stdio transport is newline-delimited JSON, not LSP Content-Length framing. write() now emits one JSON message per line; read() accepts newline-delimited frames and still tolerates Content-Length for back-compat, so Zero interoperates with spec-compliant MCP servers and clients. Add first-run model picker (#165) * Add first-run model picker * Guard setup model discovery generations docs: overhaul README to match current product surface (#164) Rewrite the README around what main actually ships today: - ANSI Shadow wordmark matching the TUI splash, centered header + badges - 25+ provider catalog (frontier, fast-inference, local, compatible endpoints) - new features: spec mode, mid-run model escalation, cron, repo-map/repo-info, workspace index, context report, image input, deferred tool loading, notifications - full command reference (25 subcommands), exec flag summary, TUI slash commands - updated tools table (web_fetch, ask_user, skill, tool_search, escalate_model) - refreshed architecture diagram and project layout for the 47-package tree Address onboarding review blockers Use Bubble Tea mouse button values for wheel handling instead of the deprecated mouse type field, and remove the unused onboarding helper. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui -run 'TestMouseWheelScrollsChatWithoutRecallingInputHistory' -count=1 Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: GOCACHE=/tmp/zero-go-cache go vet ./... Tested: git diff --check Update empty-state ZERO wordmark Render the startup logo as the full ZERO wordmark while keeping the O in the existing lime accent and the ZER prefix in the normal ink color. Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check Add first-run provider setup Introduce zero setup plus first-run onboarding for missing provider credentials, backed by the provider catalog and config save path. Make the TUI fullscreen in interactive mode, keep setup transitions inside the chat surface, and add managed transcript scrolling so wheel/PageUp/PageDown do not recall composer history. Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check Resolve provider wizard env credentials Address provider onboarding review feedback Persist provider wizard credentials Expose active provider model to agent Normalize detected provider on model switch Search and apply live model picker entries Refresh model picker and toggle favorites Scope model picker to active provider Hide provider model source label Clean provider model search placeholder Add searchable provider model picker Split Ollama cloud and local providers Filter provider picker to coding models Use remote model catalogs in provider wizard Discover provider models live Own provider model catalog Scope provider wizard models Use provider catalog in TUI onboarding Accept pasted provider API keys Open provider onboarding in TUI Add provider onboarding UX Add TUI interaction foundation (#160) * add tui interaction foundation * fix tui review findings Add shared workspace index foundation (#159) * Add shared workspace index foundation * fix workspace index review findings * fix workspace depth on non-windows runners TUI scrollback re-architecture, Lime chat surface, and deep-audit fixes (#158) * docs(design): add Lime TUI prototype and rebuild spec * tui: remove the zeroline skin system One design remains in the tree ahead of the Lime rebuild: the zeroline package, its TUI view/skin plumbing (Options.Skin/ThemeVariant/ThemeDark, model skin branches, theme picker), and the zeroline CLI subcommand are gone. /theme keeps its shell-only message. * tui: replace the cyan palette with the Lime token table theme.go is rebuilt around the Lime design tokens (docs/design/ zero_tui_lime.html): near-black panels, one lime accent, solid tint stand-ins for the prototype's rgba overlays. Existing render sites map onto the new role styles (ink/line/userPrompt/toolName/toolArg) without layout changes; no hex exists outside theme.go. * tui: build the Lime chat-surface components Title bar with brand badge + context window, empty state replacing the deleted splash (block-art 0, tagline, 1-3 starter suggestions), stream blocks (user prompt gutter, muted interim with cursor, final-answer accent rail, done line, bordered notes), tool cards with diff/read/ bash/grep bodies and MiniDot run spinner, borderless composer with run-state hints, and the grouped status line. Final answers are marked at append time; resolved tool calls collapse into their result cards. * tui: fix Stage 2 review findings Adversarial review of the chat-surface commit confirmed 15 issues, all fixed here: - wrapPlainText sliced wide runes by rune count: CJK/emoji prose could panic the TUI or emit lines ~2x the measure; split at width instead - a cancelled run's streamingText leaked into the next turn's interim block; cancelRun now clears it - orphaned tool-call cards (cancelled/errored turns) re-animated with the spinner on any later run; the glyph is now scoped to the call's own runID - starter-suggestion digit capture was active while the chips were off screen (/clear mid-run); the guard now mirrors the render condition - prompt submits while a deferred Ctrl+C quit is draining could start a run the quit would orphan; submission is blocked while exiting - the running placeholder advertised ctrl+c (which quits) as the interrupt key; it now says esc - read-card parser expected 'N: text' but read_file emits 'N | text', so the gutter and L-range never rendered on real output - toolCard borders were one column short of the body rows - diff tint bands now span the full row; NEW FILE renders green on addBg; preamble/no-newline lines are unnumbered meta and no longer shift hunk numbering - card bodies and system notes paint the panel surface; the head gains the separate faintest arg column (grep target + pattern) - title-bar overflow now truncates the minimal candidate instead of rendering the deleted design's 'ZERO … READY' line - untracked the stray snake-game.html (left on disk, ignored) * tui: restyle the interactive surfaces for Lime Permission prompts render as the amber permission card (PERMISSION badge, risk readout, key chips); resolved prompts collapse to one line (allowed once/always/denied · tool) and unprompted allows fold into the tool card's [auto] tag. Ask-user and spec-review prompts share the card language with line borders. Autocomplete and picker overlays move onto the panel/selection tints with the accent ❯ marker; model-picker rows gain the provider dot and ctx · KEY_ENV metadata from the registry and provider catalog. /resume renders its list as stacked session cards (id + age, title, meta). Key handling is untouched everywhere. * tui: adaptive width tiers, doc updates, final polish Width tiers re-evaluate from the live width: ≥100 renders the full spec; 80–99 drops the tool-arg column, header ctx, and the status 'interactive' group; 58–79 drops the diff/read gutters, bares the badge, and keeps provider+tokens+mode; <58 renders a single-segment header, rail-less cards, and a mode-only status line. Table-driven tests cover {58,70,80,100,120} plus a frame-wide invariant that no emitted line exceeds the terminal at any tier. Card bodies expand tabs so width math holds. Dead splash-chip machinery (startupCommandNames, startupOrder) is gone; README's TUI section now describes Lime. * tui: fix final-review findings across Stages 3-4 A second adversarial review confirmed 14 issues, all addressed: - permission collapse and tool-card pairing are now run-scoped (rcKey): providers with repeating synthetic ToolCallIDs (Gemini gemini_tool_N) could attribute a decision or result to a different run's call - rehydrated always-grants no longer mislabel as 'allowed once' (GrantMatched fallback) - the width invariant now actually holds everywhere: empty-state tagline/hint, ask-user rows, permission detail blobs, pending image chips, and the sessions trailing hint are wrapped or fitted; the invariant test renders the empty state and all of those rows at widths 24-120 - every card (permission, ask-user, spec-review, sessions, notes) drops side borders on tiny terminals, not just tool cards - /resume card fields are sanitized against separator bytes from user-controlled titles - the picker's selected row paints its gap so the selBg band is solid - dead splash helpers (normalizedStartupWidth, countLines, nonEmpty, unreachable rowWelcome branch) and stale /theme comments are gone - README no longer claims transcript scrolling keys or Tab-accept behavior that never existed; chatWidth documents its 24-col floor Tag tui-lime-s4 moves to this commit. * audit: deep-audit report, TUI scrollback re-architecture, and cross-module fixes TUI: settled-row flush frontier prints history to native terminal scrollback (tea.Println, ordered + ack-serialized); View() renders only the live tail. Full diffs (400-line flush cap) and OSC 8 file:// links land in history; streamed interim text, cancellation markers, ask_user exchanges, and composer input history are preserved; run-scoped dedup keys fix Gemini's repeating tool-call ids; /exit, Ctrl+C-after-Esc, and cross-session late flushes no longer orphan or contaminate checkpoints. Cross-module: zero search panic + offset mapping, usage report robustness, 529 retry, SSE keep-alive watchdog, registry corrections, update --check on dev builds, MCP ping + mcp list --json secret redaction, sandbox.network config knob, doctor apiKey presence, atomic config writes, C1-safe notifications, rune-safe truncation everywhere, gofmt + vet CI gates, dead glamour dependency dropped. Full audit report: docs/audit/2026-06-10-deep-audit.md (175 findings). * tui: hide malformed ask_user tool errors * fix tui malformed tool retries * fix max-turn finalization * upgrade tui agent guardrails * fix coderabbit guardrail findings --------- Co-authored-by: Claude Fable 5 Co-authored-by: Vasanthdev2004 Add repo intelligence map (#157) * feat: add repo intelligence map * fix: address repo map review feedback * fix: refine repo map scan limits zero cron: dep-free file-backed scheduled agents (foreground runner) (#155) * cron: dep-free 5-field cron expression parser * cron: next-run evaluator with Vixie DOM/DOW semantics * cron: preset recipes + loop.md prompt resolution (cap + symlink reject) * cron: file-backed job store (metadata.json + runs.jsonl) * cli: zero cron add/list/rm/pause/resume over the cron store * cli: zero cron run foreground scheduler (--once/--catch-up, exec per fire) * cron: fix adversarial-review findings (DST loop, traversal, runaway re-fire, +) - Next: fix infinite loop on DST spring-forward gaps (advance hours by absolute addition + a forward-progress guard); widen the search window to 9 years so a Feb-29 schedule across a century non-leap year (2096->2104) isn't reported as impossible. - store: reject path-traversal job ids (Get/Update/Remove/AppendRun) so 'cron rm ../..' can't delete outside the store; List now surfaces corrupt jobs as a warning instead of silently dropping them. - runner: reject impossible schedules at add even with --run-now; auto-pause a job whose schedule can no longer advance (was re-firing every tick); only skip-reschedule STRICTLY-overdue jobs on startup (keep exactly-due ones); capture stderr into RunRecord.Error on non-zero exit; pass the prompt via the inline --prompt= form so dash-leading prompts aren't misparsed. - Regression tests for each (DST, leap gap, traversal, corrupt-list, pause, reconcile, dash-prompt). * cron: address #155 review (store-error surfacing, resume guard, Runs traversal, +) - fireJob/reconcile/cronRun now surface AppendRun/Update failures to stderr instead of discarding them (a failed write no longer silently re-fires/loses history); fireJob takes stderr. - cron add rejects extra positional arguments instead of silently ignoring them. - cronResume rejects an unparseable/impossible schedule instead of reactivating a job with a stale/zero NextRunAt. - store.AppendRun returns the Close() error (buffered-write failures surface on close); store.Runs now validates the id (path-traversal guard, matching the other store methods). - tests: Runs traversal rejection, cron add extra-args, resume-impossible. * cron: check store errors in cron CLI tests CodeRabbit (critical): the cron CLI tests ignored errors from store.List/Add/Get, so a store failure would surface as a misleading assertion on a zero-value job/slice instead of a clear failure. Check every store op's error across the test file (not only the two flagged sites). * cron: DefaultRoot falls back to os.UserHomeDir when HOME is unset Vasanth (P1): cron's DefaultRoot mirrored neither sessions.DefaultRoot nor reality — with no XDG_DATA_HOME and no HOME it produced a RELATIVE ".local/share/zero/cron" under the caller's cwd, so on Windows/restricted shells cron jobs scattered per working directory and could write repo-local data. Now falls back to os.UserHomeDir() like sessions.DefaultRoot. Adds TestDefaultRootEmptyHomeFallsBackToUserHome (asserts the path stays absolute). Add provider health diagnostics (#151) * Add provider health diagnostics * Fix provider health auth review findings * Fix provider health test handler assertion * Harden provider health connectivity probes zero repo-info: local (network-free) repository characterizer (#150) * repoinfo: language map + build/test/CI + workspace detection helpers * repoinfo: Collect repo characterization from local git (network-free) * cli: zero repo-info command (text + --json) over the repoinfo package * repoinfo: derive AgeDays from the root commit, not git log --reverse -1 git applies -1 before --reverse, so the old call returned the LATEST commit (age always ~0). Use --max-parents=0 (root commits) and take the oldest. * repoinfo: count passthrough dirs + unicode-safe ls-tree (-z); harden tests Adversarial-review fixes: - DirectoryCount now counts every directory on the path to a file (expand each file's ancestors), so directories holding only subdirectories are included. git ls-tree -r lists blobs only, so the previous parent-of-file count undercounted. Verified against 'git ls-tree -r -t | awk $2==tree'. - ls-tree now uses -z (NUL-terminated, unquoted paths) instead of newline scanning, so non-ASCII/special filenames (which git C-quotes by default) are parsed correctly instead of being silently dropped. Drops the bufio scanner. - Tests: assert DirectoryCount/MaxDepth; add an age-from-oldest-root-commit test (would catch the old log --reverse -1 bug); make the CLI JSON test hermetic via a temp git repo. * repoinfo: strip credentials from remote URL before storing/printing Addresses #150 review (@Vasanthdev2004 P1): a git remote can embed secrets (e.g. https://x-access-token:ghp_...@github.com/o/r.git), which would leak in both text and --json output. sanitizeRemoteURL strips userinfo from URL forms and the leading user@ from scp-like forms. Tests: unit table for the sanitizer, Collect-level strip, and end-to-end CLI text + JSON assertions that the credential never appears. Sound / completion notifier: terminal bell + OSC-9 on turn completion & awaiting-input (#149) * notify: dep-free terminal bell / OSC-9 completion notifier * config: notify.mode + notify.focusMode (validated, default off) * exec: --notify/--no-notify + completion notify on stderr * tui: focus reporting + completion/awaiting-input notifications * notify: fire on exec --use-spec completion; headless ignores focus; gate focus-reporting on enabled * notify: validate --notify value, skip notify on empty ask_user, deterministic resolver tests Addresses #149 review (CodeRabbit + @Vasanthdev2004): - parseExecArgs rejects an invalid --notify value (only off/bell/notify/both), so a CLI typo errors early instead of silently running with no notification. - TUI OnAskUser fires AwaitingInput only when len(request.Questions) > 0 — a request with no questions auto-resolves without prompting, so the bell/desktop notification was a false positive. - notify resolver tests pass Env: map[string]string{} so Resolve does not fall back to os.Getenv (deterministic, not ambient-env dependent). Deferred (lazy) tool loading: tool_search + auto threshold for large MCP tool sets (#148) * Deferred (lazy) tool loading: tool_search + auto threshold for large MCP tool sets Adds deferred tool loading so a large MCP tool set no longer sends every tool's full JSON schema each turn. When the number of visible MCP tools crosses a threshold (config tools.deferThreshold, default 10; 0 disables), their schemas are withheld and advertised compactly in a per-turn system-reminder; the model pulls a tool's full schema on demand via a new tool_search tool. Strictly additive: below the threshold behaviour is byte-identical to today. No new module dependencies. - Eligibility: MCP tools opt in via an optional Deferred() bool interface (tools.IsDeferred); built-ins stay eager. No change to the core Tool interface. - Formatting (internal/tools/deferred.go): compact one-liners wrapped in a by BuildDeferredReminder; server label via MCPServerName(). - tool_search (internal/tools/tool_search.go): SideEffectNone/PermissionAllow/AdvertiseInAuto; select:Name1,Name2 (exact) or keyword (ranked); returns full schemas and signals the loop via Meta["load_tools"]. Operator-filter-aware (never lists tools hidden by --enabled-tools/--disabled-tools). - Agent loop: partitionTools is the single source of truth; eligible counts only ToolVisible deferred tools; Meta["load_tools"] lifts to ToolResult.LoadedTools and unions into a per-run loaded-set advertised next turn. Reminder appended only to the per-turn request copy (never persisted), including both reactive-compaction retry paths. tool_search stays reachable when deferral is active (exempt from the EnabledTools allowlist). - Config + wiring: tools.deferThreshold (default 10; 0 disables; negative rejected); tool_search registered and DeferThreshold threaded in exec and TUI, gated on the same visible-deferred count the loop uses. Recreated on top of current main; resolves the SideEffectNone clash with #146 (escalate_model). Supersedes #147. * deferred-tools: activate deferral only when tool_search is runnable; drop dead deferredTools() Addresses #148 review: - partitionTools gates 'active' on a registered, non-disabled, mode-advertised tool_search (mirrors the executeToolCall dispatch gate). When the loader is unavailable (explicitly disabled, missing, or not advertised e.g. spec-draft), fall back to the eager/inactive path instead of hiding deferred tools behind a loader the model can never call (the dead-end). - Flip TestDisabledToolSearch... to assert the eager fallback; register a real tool_search in the active-path tests that previously relied on activation without a usable loader. - Remove unused toolSearchTool.deferredTools(). * deferred-tools: disable deferral in exec when tool_search disabled; drop unused toolDefinitions shim Further #148 review: - exec computes one effective DeferThreshold, forced to 0 when tool_search is in --disabled-tools, and feeds it to both registerToolSearchIfEligible and agent.Options.DeferThreshold — so the registration gate and the loop partition gate agree, and a disabled loader never enables deferral. - Remove the unused toolDefinitions shim (golangci-lint unused; no callers). Add spec mode review flow (#145) Mid-run model escalation: escalate_model tool + --allow-escalation (#146) * modelregistry: add UpgradeTargetID and Registry.UpgradeTarget escalation lookup * modelregistry: seed escalation chains in catalog decoration * modelregistry: verification gate for escalation upgrade map * agent: add ModelSwitcher option and ToolResult.RequestedModel field * agent: lift escalate_to_model meta into ToolResult.RequestedModel * agent: perform at-most-one mid-run model switch in the turn loop * tools: add SideEffectNone for control-only tools * tools: add escalate_model control tool (metadata + args) * cli: parse --allow-escalation exec flag * cli: document --allow-escalation in exec help * cli: register escalate_model tool under --allow-escalation * cli: wire model switcher for exec escalation * cli: guard byte-identical exec run without escalation flag * cli: attribute post-switch usage to the escalated model * tools: accept an explicit empty escalate_model reason reason is an optional, informational-only arg, but the previous arg parsing rejected an explicit reason:"" (non-empty-string check), failing the whole escalation. Switch to stringArgWithEmpty so an absent OR empty reason is accepted while a non-string value is still rejected. Add a test covering both the empty and absent forms. * agent: test (nil,nil) escalation switcher keeps original provider When a ModelSwitcher returns (nil, nil) — no error, no replacement — the loop's `else if newProvider != nil` guard already keeps the active provider, so the run continues on the original model. Add an agent-layer test that proves this (it panics if the nil guard is dropped), closing the gap the adversarial review flagged. Also note the deferred compaction limitation near the switch: the compactor's context-window budget is fixed at run start and not updated on a mid-run switch (needs a ModelSwitcher contract change to fix). * cli: gate usage model attribution on the escalation flag and harden tests FIX A (back-compat): the OnUsage closure wrote a "model" key into every persisted EventUsage payload unconditionally, diverging a non-escalation run from the byte-identical origin/main baseline. The model can only change mid-run under --allow-escalation, so include the "model" key only then. A new flag-OFF test asserts the persisted usage payload carries no "model" key (mutation-proven). Test hardening for the CLI escalation boundary: - Wire test: each newProvider build returns a DISTINCT provider with its own turn counter; assert the source provider served exactly the escalation turn and the escalated provider served exactly the post-switch answer. Fails if `provider = newProvider` is dropped. - Mixed usage attribution: emit usage pre- and post-switch and assert the first attributes to the original model, the second to the escalated target (the loop's ordering guarantee). - Switch-error path: a failed rebuild is non-fatal and post-error usage stays attributed to the original model (currentModel unchanged). - Top-tier decline end-to-end: a flag-on run that escalates while already top-tier performs no switch (newProvider built exactly once) and succeeds. * modelregistry: validate UpgradeTargetID resolves at registry construction * cli: update currentModel only when the switched provider is non-nil * sandbox: make 'none' a first-class side effect (control-only tools) --------- Co-authored-by: Gnanam Add spec mode foundation (#144) Image (multimodal) input: exec --image, stream-json images, TUI /image across all 3 providers (#143) * zeroruntime: add ImageBlock type and NormalizeImageMediaType * zeroruntime: add optional Images field to Message * zeroruntime: add SeedMessagesWithImages, delegate SeedMessages with nil * Emit Anthropic image source blocks for user image turns * Add inlineData part type to Gemini request schema * Serialize user image attachments as Gemini inlineData parts * test(openai): pin text-only chat request serialization before content refactor * feat(openai): allow structured multimodal content parts in chat request * feat(openai): map image attachments to image_url content parts * test(openai): assert image content parts serialize over the request body * modelregistry: add SupportsVision capability gate * Add shared image-file loader package * cli: parse repeatable exec --image flag into imagePaths * cli: resolve exec image attachments via shared loader with sniff and size cap * agent: thread image attachments through Options into the seeded user turn * cli: resolve and vision-gate exec image attachments into the agent run * cli: document exec --image flag in help output * streamjson: add InputImage type and InputEvent.Images field * streamjson: whitelist images field on message events only * streamjson: allow empty content on message events that carry images * streamjson: add ResolveImages to decode, normalize and cap input images * streamjson: cover image parse-and-resolve round-trip and text-only invariance * Add /image command definition to TUI * Add synchronous vision check for TUI model * Implement /image attach, clear, and non-vision refusal in TUI * Render pending image chips above the input in both skins * Thread pending images into the agent turn and clear on submit * Wire stream-json images through to the agent run Stream-json message images were parsed and validated but never reached agent.Options.Images: streamjson.ResolveImages had no production caller, so images sent over stream-json input were silently dropped. resolveExecPrompt now also returns the resolved stream-json images (nil for text input and for image-free stream-json input). The exec runner merges them with --image attachments before the vision gate, so both input sources flow through the same drop+warn and the same agent.Options.Images wiring. Adds an exec-level end-to-end test that feeds a base64 image on a stream-json message event via stdin on a vision model and asserts the image reaches the agent run (the provider request's user turn). * Re-check TUI vision support at submit, not only at /image attach A user could attach an image on a vision model and then switch to a non-vision model via /model before submitting; the images were threaded unconditionally into the agent turn, reaching a model that rejects them. The commandPrompt submit path now re-runs the vision check against the current effective model. When the active model can't accept images, the pending images are dropped (not threaded) and an inline notice mirroring the headless drop+warn wording is appended. Pending image state is still cleared after submit either way. Adds a test that attaches on a vision model, switches the model id to a non-vision one, submits, and asserts the run received no images, an inline notice was appended, and pending state was cleared. * Bound memory with an os.Stat size pre-check in imageinput.LoadFile LoadFile read the whole file into memory before checking the size cap, so a multi-gigabyte file allocated a huge buffer only to be discarded. Add an os.Stat size pre-check before os.ReadFile: a file whose size exceeds MaxImageBytes is rejected with the same 10 MiB-limit error before any read; a missing file surfaces the same not-found error. The existing post-read len(data) check is kept as a TOCTOU backstop in case the file grows between Stat and ReadFile. * OpenAI: only the user role emits image content-parts The Anthropic and Gemini providers only attach images in their user branches, but OpenAI funnels every role through a single mapMessage, which would emit image content-parts for any role carrying Images. Guard the content-parts path to the user role: a non-user message that happens to carry Images keeps the plain string/nil content path. Pins the invariant with TestMapMessageNonUserRolesNeverCarryImages, asserting assistant/tool/system messages with Images set still serialize plain content (no content-parts array). * Deep-copy image buffers so attachments are never aliased Add a shared CloneImageBlocks helper in zeroruntime that deep-copies a slice of ImageBlock including each Data byte slice (returning nil for nil/empty input). Use it in SeedMessagesWithImages so a seeded user turn owns an independent copy of the caller's image bytes, and in the agent loop's copyMessages so image bytes are deep-copied alongside ToolCalls instead of staying aliased across history/request/result copies. Tests assert that mutating the caller's original Data bytes after seeding leaves the seeded message unchanged, and that copyMessages produces independent image bytes. * Accept image-only stream-json turns and bound base64 image size Resolve stream-json images before the prompt in the exec input path and tolerate an empty prompt when images are present, so an image-only message event (empty content + one image) proceeds with prompt="" and the image reaches the agent run instead of being rejected. A turn carrying neither text nor images still errors. In ResolveImages, reject an oversized payload from its ENCODED length (DecodedLen) BEFORE the base64 decode so a huge blob never allocates a decode buffer just to be capped afterward; the post-decode length check stays as a backstop. Tests cover the image-only exec turn reaching the agent and the pre-decode oversize rejection (asserting the size gate fires before any decode). * Bound the image read in imageinput.LoadFile with a LimitReader Replace os.ReadFile with os.Open + io.ReadAll over an io.LimitReader(f, MaxImageBytes+1). The os.Stat pre-check is only a fast-path hint (a non-regular file reports a misleading size and the file can grow between stat and read); the LimitReader is the real bound, so at most one byte past the cap is ever buffered and an oversized or unbounded source can never allocate a multi-gigabyte buffer. The file is closed via defer and the existing size message is reused. * Fix RenderChat frame-height off-by-one with image chips cmdRegion emits TWO rows when ImageChips is set (a chip row above the input line), but the body/overlay height accounting assumed a one-row command region, overflowing the fixed frame by one line. Compute the fixed-row count from the actual command-region height (1 or 2 rows) so the composed frame stays exactly Height rows in both cases. A RenderChat test asserts the rendered frame line-count equals the requested height both with and without ImageChips set. * Reject an empty inline --image= value Route the inline --image= form through requiredInlineFlagValue, the same empty-rejection the other inline flags use, so --image= errors with "--image requires a value" instead of appending an empty image path. A parse test covers the empty inline value. * imageinput: reject non-regular files (FIFO/dir) before opening --------- Co-authored-by: KRATOS Co-authored-by: Gnanam CLI: doctor config validation, autonomy ceiling, changes --base, usage report (#142) * config: add ValidateFile for structured config diagnostics * doctor: add offsetToLineCol helper for JSON error positions * doctor: validate config files in config.validation check * cli: thread config paths into doctor and degrade on bad JSON * doctor: inline read, single-parse config validation + utf8 col test - Remove the osReadFile wrapper; call os.ReadFile directly at its one call site - Add config.ValidateBytes([]byte) so each config file is read and JSON-parsed once in configValidationCheck instead of twice (once for position, once via ValidateFile); ValidateFile now reads then delegates to its own unmarshal for the path-prefixed error message, keeping its public signature unchanged - Fix the misleading skip comment in configValidationCheck: configFilesCheck only checks path-string presence; the skip is a defensive guard for the unreachable case where DefaultResolveOptions passed a non-empty path that still fails to read - Add utf8 multibyte case to TestOffsetToLineCol documenting byte-column semantics (column counts bytes, not runes) * sandbox: add Policy.MaxAutonomy ceiling defaulting to high * sandbox: normalize both operands in autonomyAllowed to fail closed * sandbox: clamp grant-allow and unsafe escalation to policy autonomy ceiling * sandbox: add RequiredAutonomyForBatch helper with fail-closed mapping * config: add sandbox.maxAutonomy field to file and resolved config * config: merge sandbox.maxAutonomy across layers, env, and overrides * cli: thread configured autonomy ceiling into sandbox engine construction * cli: surface max_autonomy in sandbox policy formatters * zerocommands: carry max autonomy in sandbox policy snapshot * sandbox: default empty policy ceiling to high + reason const Evaluate now treats an empty Policy.MaxAutonomy (from a directly-constructed Policy that bypasses DefaultPolicy) as High instead of letting it normalize to Low, which would silently clamp every Medium/High decision to Prompt. The ceiling is a no-op unless explicitly configured. Also extracts the "above policy ceiling" clamp reason into a package const referenced at both engine clamp sites and the related tests, and adds a non-vacuous engine test proving the empty-ceiling trap is closed. * zerogit: add base-ref three-dot diff to changes inspect * zerogit: cover base-ref backward-compat and real-git diff * zerogit: propagate base ref through change snapshot * cli: parse changes --base ref and reject on commit * cli: thread base ref into changes inspect output and help * changes: redact base in cli summary + guard --base= + rename test - redactChangeSummary now redacts the Base field alongside Root/Branch/Commit/etc. - --base= equals-form rejects leading-dash values via flagValueLooksLikeOption, closing the option-smuggling gap - TestParseNameStatusRenameAndCopy asserts rename/copy three-field lines use the new/destination path with correct status, and confirms two-field modify is unaffected * Add ParseDiffStat helper for git diff --stat summaries * Add usage report aggregation over persisted session usage events * Add zero usage report command over persisted session usage * Wire usage command into CLI dispatch and help * usage: validate --since format, fix redaction comments, add tests Fix 1 (BLOCKING): parseUsageArgs now validates --since with time.Parse against YYYY-MM-DD; returns exitUsage + "invalid --since %q: expected YYYY-MM-DD" for bare strings like "foo", unpadded "2026-6-1", or wrong-separator "06/01/2026". Valid dates are stored unchanged for the existing lexical comparison. Fix 2: Replace false "pre-redaction stat" claims in diffstat.go and usage.go with accurate comments: the --stat summary line carries no secret-bearing tokens so parsing the already-redacted DiffStat is safe. Fix 3: Add three tests to usage_test.go — - TestRunUsageDaysFilter: stubs deps.now, seeds events on two dates, asserts --days 3 includes the recent date and excludes the old one. - TestRunUsageInvalidSince: table-driven; verifies "foo", "2026-6-1", "06/01/2026" each return exitUsage + validation message, while "2026-06-01" returns exitSuccess. - TestRunUsageEmptyStore: zero usage events → exitSuccess, output contains header and "total" row, no panic. * doctor: surface type-mismatch offsets and drop colliding flat line/col keys Probe config validation against config.FileConfig instead of a bare any so a structurally-valid document with a wrong field type (e.g. maxTurns as a string) surfaces a *json.UnmarshalTypeError carrying the offset, instead of losing line/col. Remove the flat top-level details["line"]/["col"] keys that were overwritten by the last malformed file when several were present; keep only the unambiguous per-path map entry. Tighten the existing malformed test to assert concrete per-path line/col and add a type-mismatch test that exercises the previously-dead UnmarshalTypeError branch. * sandbox: fail closed on invalid configured autonomy ceiling An invalid non-empty sandbox.maxAutonomy (e.g. "moderate") previously survived Resolve (only trimmed, never validated), failed to normalize at the sandbox bridge, and left the policy unchanged at the default High ceiling. A typo therefore silently disabled the admin's intended ceiling: fail-open on a security boundary. Validate at resolve time (fail loud): Resolve now rejects a non-empty maxAutonomy that does not normalize, returning a clear error. As defense in depth, applyConfiguredAutonomyCeiling now clamps an unrecognised non-empty value to the most restrictive ceiling (low) instead of returning the policy unchanged, so a bad value can never widen the ceiling even via direct use. Empty/unset stays valid and keeps the default High ceiling. Add resolver tests for invalid-fails / valid-resolves and bridge tests for the fail-closed clamp and valid mappings. * sandbox: fail closed on malformed autonomy and surface policy resolve errors Treat a genuinely-invalid request autonomy as the highest tier in the engine so it exceeds a Medium/Low ceiling and clamps to Prompt instead of being sanitized to Low and auto-allowing on the grant/unsafe path. Surface resolveConfig failures in the policy command instead of silently falling back to the permissive DefaultPolicy, which would misreport trust posture. * usage: bucket and filter by UTC calendar day, reject empty --session Normalize RFC3339 timestamps to their UTC calendar date before day bucketing (report) and before the --since/--days cutoff comparison (CLI) so offset timestamps land on the correct UTC day and the bucket and cutoff agree; malformed timestamps fall back to the leading-10 slice. Reject empty --session/--session-id values with a value-required error matching the --since validation style. * doctor: fail on unreadable config files and stop fabricating positions Surface a present-but-unreadable config path (permissions, is-a-directory) as a failing per-path validation detail instead of silently skipping it; a genuinely-missing path stays a skip. Return ok=false for a JSON parse error that carries no offset so it routes through the no-position ValidateBytes path rather than fabricating a (1,1) line/col. Assert empty stderr on the JSON doctor failure path. * config: make sandbox maxAutonomy resolver tests hermetic Pass an explicit empty Env to the maxAutonomy Resolve tests so host environment variables cannot leak in through the nil-Env os.Getenv fallback. * sandbox: preserve raw autonomy for ceiling check so invalid clamps under default ceiling Defaulting invalid request.Autonomy to High only clamped under a Medium/Low ceiling; under the default High ceiling autonomyAllowed(High, High) returned true and still auto-allowed (fail-open). Keep the raw requested value for the ceiling check so autonomyAllowed's unknown-tier guard fails it closed under ANY ceiling. Adds default-High-ceiling regression tests for the unsafe and grant-allow paths. --------- Co-authored-by: KRATOS Co-authored-by: Vasanthdev2004 Add provider catalog foundation (#141) * feat: add provider catalog foundation * feat: add provider onboarding checks * fix: address provider readiness review * fix: clear provider catalog review threads * fix: accept direct auth values in provider checks * fix: harden provider catalog config * fix: guard project provider credential binding Add context budget report (#139) * add context budget report * fix context report review findings zeroline TUI: fix slash palette, token readout, @ files, ! bash (#138) * zeroline TUI: fix slash palette, token readout, @ files, ! bash The zeroline home footer advertised affordances the code didn't keep. Fixes: - '/' palette: autocomplete.go suppressed a bare '/' ('/' alone showed nothing). Now a bare '/' lists the full command palette (capped at 8). The footer's '/ commands' works. - Token readout: the zeroline header was built without Cost/TotalTokens, so the bars always showed $0.00 and no token total. zerolineHeader() now threads m.usageTracker.Summary() (TotalCost + TotalTokens, with the unpriced-token fallback); the bottom bar shows a cumulative 'tok ' segment (zeroline.Header gains TotalTokens; humanTokens formats it). - '@ files': '@' input now drives a workspace file picker in the existing overlay (trailingAtToken/fileSuggestions/replaceTrailingAtToken; bounded, skips .git/node_modules/vendor/dist/hidden). Selecting inserts the @path, preserving preceding prompt text. Previously '@…' was sent to the agent as a chat prompt. - '! bash': '!cmd' now runs as a shell escape (commandBash) — executed async with a 30s timeout in the workspace, output shown in the transcript. Previously '!cmd' was sent to the agent as chat. - '1-5 theme': left as-is — it works when the input is empty (the home screen's state); gating on empty input keeps digits typable in prompts. Tests: bare-/ palette, @-token detection/replacement, file listing+filter+skip-dirs, !cmd parsing, header usage. build/vet/-race/full-suite + GOOS=windows green; no new deps. * zeroline TUI: emit forward-slash @file paths (Windows fix) fileSuggestions used filepath.Rel, which returns backslash paths on Windows, so the inserted @path token and the test assertions diverged (TestFileSuggestionsListsAndFiltersWorkspaceFiles failed on the Windows smoke). Normalize with filepath.ToSlash so @paths are forward-slash on every platform. * zeroline TUI: gate !cmd behind unsafe mode + test humanTokens CodeRabbit major: the !cmd shell escape ran bash -c directly with no permission check, bypassing the sandbox. It now runs ONLY in unsafe permission mode; in auto/ask it is not executed and the user is told to relaunch with --skip-permissions-unsafe. Adds TestBashEscapeGatedByPermissionMode (auto/ask gated, unsafe runs). CodeRabbit minor: adds TestHumanTokens covering negative clamp, 0, sub-1k, 1k trim, fractional, and the 999999->1000k rounding edge. * zeroline TUI: bound @file walk by entry count; humanTokens M-scale Review fixes for #138: - fileSuggestions now counts every WalkDir entry (directories included), not just files, and stops at the budget; a directory-heavy tree could otherwise be traversed in full each keystroke and stall the TUI. Extracted fileSuggestionsBounded so the per-keystroke budget is unit-tested. - humanTokens switches to "M" notation past 1,000,000 instead of rendering "1000k"+ on large-context models; tests extended to the M boundaries. * zeroline TUI: make unsafe mode reachable so the ! shell escape works The "!cmd" shell escape is gated behind unsafe permission mode, but the interactive TUI hardcoded Ask and shift+tab only cycles auto<->ask, so unsafe was unreachable and the escape was dead code. Worse, the gate's own remediation message ("Relaunch with --skip-permissions-unsafe") pointed at a flag the interactive launch did not parse: "zero --skip-permissions-unsafe" fell through to the unknown-command path. Honor --skip-permissions-unsafe at interactive launch (both "zero" and "zero zeroline"), mirroring exec, so unsafe mode -- and the ! escape -- is reachable via an explicit opt-in. Thread the resolved mode through runInteractiveTUI(WithSkin); the default stays Ask. Document the flag in "zero --help" and add launch tests for both entrypoints. * zeroline TUI: run the ! escape via the platform shell, not hardcoded bash Now that --skip-permissions-unsafe makes the "!" escape reachable on every platform, hardcoding "bash -c" left it broken on stock Windows (no bash on PATH) and anywhere bash is not at a predictable path -- the advertised "! bash" footer would just print an exec error. Resolve the shell the same way the agent bash tool does: cmd.exe /d /s /c on Windows, /bin/sh -c elsewhere. Add a platform test asserting the command is wrapped correctly. --------- Co-authored-by: KRATOS Test: raise the npm-wrapper Windows spawn timeout to de-flake CI (#140) TestNodeWrapperReportsMissingNativeBinary intermittently hit its 30s Windows deadline with empty output on loaded CI runners (cold node spawn), then passed in ~2.5s on the next run — a runner flake, not a wrapper bug. Raise the Windows timeout to 90s so a slow cold start has headroom; non-Windows stays at 10s. Co-authored-by: KRATOS Tool quality: per-tool denial categorization + secret scanning (#137) * [LOCAL PR-Z] Agent: structured per-tool denial categorization Adds ToolResult.DenialReason (DenialCategory: filtered / permission_denied / sandbox_violation) so a surface can tell precisely why a call was blocked instead of parsing Output. Set in the not-enabled path and deniedPermissionResult (sandbox-violation denials categorized distinctly from approval-declined). Tested. * [LOCAL PR-Z] Secrets: scan + redact leaked secrets in bash output New internal/secrets package: a dependency-free, precision-first scanner for high-confidence secret shapes (AWS/Google keys, GitHub/Slack tokens, private-key blocks, JWTs). bash output now runs through secrets.Redact so a command that prints a secret has it replaced with a typed placeholder + a redaction notice, instead of echoing it back into the model context (additive to the configured-key scrub at the registry boundary). A built-in regex scanner fits the CLI without the heavy gitleaks dependency. Tested: detection, no-false-positives on ordinary text, redaction, and the bash wiring. * Tool quality: redact full private-key blocks + use DenialReason in retry classification CodeRabbit critical: the private_key_block pattern matched only the BEGIN header, so Redact left the PEM/OpenSSH key BODY in the output — defeating the redaction. It now matches the entire BEGIN…END block (body included) so the key material is removed. Test asserts the body is gone. CodeRabbit minor: isRetriableToolError now treats a structured ToolResult.DenialReason (filtered/permission/sandbox) as non-retriable, independent of message wording; the text checks remain a fallback for results lacking the field. --------- Co-authored-by: KRATOS Model harness: per-model system prompts + summarizer fallback (#135) * [LOCAL PR-X] Agent: per-model system-prompt specialization Appends a family-specific guidance block (OpenAI: markdown+native-tool preference+persistence; Gemini: tool preference+conciseness; Anthropic: comment discipline) chosen by model id. Builds on the #129 system-prompt builder. Tests for classification + addendum selection. * [LOCAL PR-X] Agent: chunked summarization fallback on context-limit If the elided middle is itself too large to summarize in one call, summarizeWithFallback splits it in half recursively and joins the partial summaries, so compaction keeps working past the summarizer's own input window. Non-context errors and unsplittable single messages surface unchanged. Builds on #131. Tests cover split-and-join, non-context propagation, and the single-message edge. * Summarizer: re-summarize partial summaries instead of persisting a blob CodeRabbit major: summarizeWithFallback joined the two partial summaries and Compact stored that as one message; if that single message later exceeded the summarizer window, the guard couldn't split it (a single message) and compaction failed again for long sessions. After summarizing the halves, the partials are now re-summarized into ONE compressible unit. If even the combined partials don't fit (extreme), it falls back to the joined text (still the already-compacted halves, better than failing). Adds TestSummarizeWithFallbackReSummarizesPartialsIntoOne. build/-race/full-suite + GOOS=windows green. --------- Co-authored-by: KRATOS Real-world hardening: graceful shutdown + crash capture (#136) * [LOCAL PR-Y] CLI: graceful shutdown of headless exec runs (Ctrl+C) zero exec wrapped agent.Run in context.Background(), so Ctrl+C/SIGTERM killed the process abruptly — leaking the in-flight provider request and any spawned children. runExec now wraps the run in a signal-aware context (signalContext): a signal cancels the run, the loop returns cleanly, and exec reports 'Interrupted.' with exit 130 (stream-json: an 'interrupted' run-end). Scoped to runExec — no dispatch-wide signature churn. NOTE: SIGINT runtime delivery needs a manual Ctrl+C check before PR; unit tests cover the helper + the no-regression of the exec suite. * [LOCAL PR-Y] Observability: crash capture (panic -> saved report) New internal/observability package: a recovered top-level panic is written to a local crash report (timestamp, label, full stack) under ~/.zero/crashes and surfaced as a brief notice instead of a raw stack trace. Wired into runWithDeps via a named-return defer. Fail-open and dependency-free; this is the hook a Sentry/OTEL adapter attaches to for remote reporting (deferred, needs a real endpoint to verify). Tested: report formatting/writing, panic capture sets the crash exit code + writes one report, and the no-panic path is a no-op. --------- Co-authored-by: KRATOS Background: escalate process termination from SIGTERM to SIGKILL (#134) * Background: escalate process termination from SIGTERM to SIGKILL terminateProcess (POSIX) sent only SIGTERM, so a background process that traps or ignores it kept running after a Kill/KillRunning — a leak, e.g. on Ctrl+C shutdown. It now sends SIGTERM (letting the process clean up), polls liveness, and force-kills with SIGKILL if the process is still alive after a grace period. - internal/background/process_posix.go: SIGTERM -> poll (signal 0) until a 3s grace deadline -> SIGKILL; treats an already-exited process as success (ESRCH / ErrProcessDone). Grace/poll are vars so tests run fast. Windows already force-kills via taskkill /F, so it is unchanged. Tests: a SIGTERM-ignoring process (trap, with a readiness handshake to avoid signalling before the trap installs) is killed only after the grace period; a well-behaved process dies on SIGTERM well before escalation; an already-exited pid is a no-op. -race + full-suite + GOOS=windows build green; no new deps. * Background test: assert the terminating signal (SIGTERM vs SIGKILL) The graceful and escalation tests now check the actual signal that killed the process (via WaitStatus.Signal()), not just timing — so a regression to an immediate SIGKILL (or to never escalating) is caught. Graceful path must be SIGTERM; the SIGTERM-ignoring process must be SIGKILL. * Background: keep user kill intent when the exit waiter reaps during terminate Vasanthdev P1: terminateProcess now blocks until the child exits, so the background Wait-goroutine can reap the process and MarkExited the task to 'error' BEFORE Kill marked it 'killed' — leaving a user-initiated stop persisted as 'error' even though TaskStop returned killed. Manager.Kill now records the kill intent (markKilledIfStillRunning) BEFORE terminating. MarkExited only acts on a running task, so once the task is killed the waiter's MarkExited is a no-op and the status stays killed. If terminateProcess fails, restoreRunningAfterFailedKill reverts the optimistic mark so a still-running task isn't falsely reported killed (preserves TestManagerDoesNotMarkKilledWhenKillFails). Adds TestManagerKillStaysKilledWhenExitWaiterReapsDuringTerminate (cross-platform: a KillProcess that simulates the waiter reaping mid-terminate; asserts final status stays killed). build/-race/full-suite + GOOS=windows green. * Background: don't signal a stale pid + surface rollback failures in Kill Two follow-ups to the kill-ordering fix (CodeRabbit): 1. markKilledIfStillRunning now returns whether it actually marked. If the task exited (or was reused) between the running check and the mark, Kill returns without calling killProcess — so it never signals a possibly-stale pid in the exit/reuse race. 2. restoreRunningAfterFailedKill returns its persistence error; on a failed terminate, Kill joins the kill error and the rollback error (errors.Join) instead of swallowing a failed rollback that would leave the task wrongly marked killed. Adds TestMarkKilledIfStillRunningReportsWhetherItMarked. build/-race/full-suite + GOOS=windows green. --------- Co-authored-by: KRATOS Agent: preserve active plan + loaded skills across compaction (#131) * Agent: preserve active plan + loaded skills across compaction Compaction summarized the elided middle into prose, which silently dropped two pieces of structured state the model needs to keep working: the active plan (update_plan) and any loaded skill instructions (skill tool). Now Compact extracts them from the elided middle and appends them VERBATIM to the injected summary, so they survive a compaction exactly instead of being paraphrased away. - internal/agent/compaction_preserve.go (new): extractLatestPlan (most recent update_plan, rendered as status-tagged bullets), extractLoadedSkills (skill tool calls matched to their result bodies by id, latest-per-name, 2KiB-capped per skill so a large skill can't defeat the compaction), and appendPreservedState which folds both into the summary under clear labels. - internal/agent/compaction.go: Compact appends the preserved state; summaryInstructions now tell the summarizer to integrate any earlier summary block (delta-aware summarization) so re-compaction never drops older facts. Tests: plan + skill preservation through Compact, no sections when absent, latest-plan-wins, and skill calls without a result body are skipped. build/vet/-race/full-suite + GOOS=windows build green; no new deps. * Fix capBody to honor the byte budget (UTF-8 safe) capBody sliced by RUNE count against maxPreservedSkillBytes (a BYTE budget), so multibyte UTF-8 could preserve far more than 2 KiB, and it could append the truncation note without actually truncating. Now it cuts to the byte budget on a UTF-8 rune boundary (never splitting a rune), reserves room for the note within the budget, and only adds the note on real truncation. Adds tests: short-body unchanged, multibyte over-budget stays <= cap and valid UTF-8, and the rune-countcap case. * Compaction: carry preserved plan/skills across repeated compactions Vasanthdev P1: after the first compaction the preserved plan/skill sections live only as text in the injected summary message; on a SECOND compaction that summary is in the elided middle with no real update_plan/skill tool calls left, so the structured state was lost unless the summarizer happened to copy it (non-deterministic). appendPreservedState now carries forward existing '## Active plan' / '## Loaded skills' sections parsed from the most recent prior summary in middle, with fresh tool calls overriding per name (skills merged by name; plan: fresh update_plan wins, else carried forward). Adds TestCompactCarriesPreservedStateAcrossRepeatedCompaction — a second compaction whose summarizer drops the sections still keeps the plan + skill body. build/vet/-race/full-suite + GOOS=windows build green. * Compaction: serialize preserved state as JSON (lossless for arbitrary bodies) CodeRabbit major: the carry-forward format parsed markdown delimiters (section ends at the next '## ', skills split on '### '), so a verbatim skill body containing headings (## Usage, ### Examples) or code fences was truncated or split into bogus extra skills on the next compaction — breaking the repeated-compaction guarantee. The preserved plan + skills are now written as a single labelled line of JSON (formatPreservedState) and recovered with json.Unmarshal (parsePreservedState). JSON escapes everything, so any body — markdown headings, fences, quotes, multibyte — round-trips losslessly. Removed the markdown section/skill parsers; fresh tool calls still override the carried-forward copy by name. Adds TestCompactPreservesSkillBodyWithMarkdownHeadings: a body with ## / ### / a code fence stays byte-identical across two compactions. build/vet/-race/full-suite + GOOS=windows green. * Compaction test: guard compacted shape before indexing out[1] The round-trip helper indexed out[1] without checking len(out)>=2 / role first; add the guard so a regression in Compact fails cleanly instead of panicking. --------- Co-authored-by: KRATOS Agent: per-category context token budget (system / tools / messages) (#133) Adds MeasureContext, which breaks a request's estimated token footprint into the categories that compete for the window — system prompt, tool definitions, and conversation messages — plus the used fraction of the model context window. This gives the agent the data to report context utilization and reason about what to compact. - internal/agent/context_measurement.go: ContextBreakdown + MeasureContext (reuses estimateTokens for messages; estimateToolTokens for tool defs on the same ~4-chars/token scale). - Options.OnContext: opt-in per-turn callback (mirrors OnText/OnUsage) emitted with the request's budget so a surface (TUI/CLI) can show utilization; nil is a no-op. Tests: per-category split + total, system-dominant proportion, used-fraction math, unknown-window and no-system/no-tools edge cases. build/vet/-race/full-suite + GOOS=windows build green; no new deps. Co-authored-by: KRATOS Providers: unified transient-failure retry (network, 429, 5xx) (#132) * Providers: unified transient-failure retry (network, 429, 5xx) across all providers Only the OpenAI provider retried before; Anthropic and Gemini surfaced the first failure, so a dropped connection or an intermittent gateway 5xx/429 failed the whole turn. Centralizes the policy so every provider behaves consistently. - internal/providers/providerio/retry.go (new): SendWithRetry rebuilds the request each attempt (replay-safe) and retries network errors + retryable statuses (429 and 5xx) up to N attempts, backing off between tries and honoring a server Retry-After header and context cancellation. Only the INITIAL request is retried — a partially consumed stream is never replayed. Plus Backoff, ShouldRetryStatus, and RetryAfter helpers. - anthropic/gemini: replace the bare http.Do (no retry) with SendWithRetry. - openai: replace its bespoke retry loop + backoff func with the shared helper (now also covers 429 + Retry-After, which it didn't before). Net code reduction. Tests: retry-then-succeed, no-retry on 400, exhausted-retries surface the last response, Retry-After parsing, and backoff context cancellation. build/vet/-race/full-suite + GOOS=windows build green; no new deps. * Retry: don't replay transport-failed POSTs (only retry 429/5xx) A network/transport error on a completion POST does not mean the server didn't receive it — the request may have arrived and be generating a billable, non-idempotent completion while only the response/connection failed. Replaying it could duplicate that work. SendWithRetry now returns transport errors immediately and retries ONLY server responses (429 and 5xx), where the request was explicitly rejected without effect. Adds TestSendWithRetryDoesNotReplayTransportErrors (a transport error is attempted exactly once). Docs updated. -race + providers suite + GOOS=windows build green. * Retry: narrow retryable statuses to 429 + 503 (not generic 5xx) Retrying any 5xx replayed non-idempotent completion POSTs whose effect is unknown: a 500/502/504 (esp. a 504 gateway timeout) can follow an upstream that already produced a billable completion, so a retry risks duplicate work. Limit retries to 429 (rate-limited) and 503 (service unavailable) — the statuses where the server explicitly did NOT accept the request, so a replay is safe. Other 5xx now surface to the caller's HTTP-error path. Also close the response in the transport-error test (bodyclose). Tests updated: ShouldRetryStatus maps 500/502/504 -> false, 503/429 -> true; the retry-then-succeed test uses 503. * Retry test: check the response Body.Close() error (errcheck) The transport-error test dropped resp.Body.Close()'s error, which errcheck flags and can break lint-gated CI. Now the close error is checked. --------- Co-authored-by: KRATOS Agent: rich system prompt (coding-craft + workspace context) (#129) * Agent: rich system prompt (coding-craft + workspace context) Replaces the one-sentence system prompt — our single biggest agentic-quality gap — with a real coding-craft instruction set adapted to our Go toolset. - internal/agent/system_prompt.md (embedded): identity, autonomy/persistence (bias for action, end-to-end), workflow (understand -> plan -> implement -> verify -> summarize), editing discipline (prefer native read/grep/glob/edit/apply_patch over shell, one tool call per file, minimal diffs, match style), a MANDATORY testing gate (run validators after edits; never claim done while failing), tool-use guidance, communication/markdown style. - internal/agent/system_prompt.go: buildSystemPrompt(options) assembles core + dynamic workspace context (: cwd, git branch, first AGENTS.md/ZERO.md/.zero/AGENTS.md doc, 8KiB-capped) + the existing safety confirmation policy. Omitted when cwd is unset, keeping headless/test runs deterministic. - loop.go: prompt decls moved to system_prompt.go; callsite passes options so cwd reaches the builder. Tests: updated the two prompt assertions; added TestBuildSystemPromptInjectsWorkspaceContext. build/vet/-race/full-suite + GOOS=windows build green; no new deps. * Resolve relative worktree gitdir against cwd in gitBranchForPrompt In worktree mode .git is a file like 'gitdir: ../.git/worktrees/' with a RELATIVE path; the code joined it directly so HEAD lookup resolved against the process working directory and failed, dropping the Git branch from the workspace context. Now non-absolute gitdir paths are resolved against cwd. Adds TestGitBranchForPromptResolvesRelativeWorktreeGitdir (fails without the fix). build/-race/full-suite + GOOS=windows build green. --------- Co-authored-by: KRATOS Provider: prompt caching on system + tools, with cache-token usage (#130) Caches the stable per-run prefix (system prompt + tool definitions) so it is not re-billed or re-processed every turn — a direct latency and token-cost win on multi-turn sessions, which matters now that the system prompt is substantial. - types.go: system can be sent as []systemBlock (Anthropic accepts string or blocks); add cacheControl + cache_control on the last system block and the last tool definition (two cache breakpoints covering system + tools); add cache_read_input_tokens / cache_creation_input_tokens to the usage struct. - provider.go: buildRequest emits the cacheable system block + tags the last tool; stream usage now captures cache read/creation tokens. Anthropic reports input_tokens (uncached), cache_read, and cache_creation separately, but the runtime models cached as a subset of input (clamps cached<=input), so we report the FULL prompt as InputTokens and the cache hits as CachedInputTokens. - Caching is GA on anthropic-version 2023-06-01 (no beta header); non-caching providers are unaffected. Tests: updated the request-shape assertions (system block + tool cache_control); added TestStreamCompletionReportsCacheTokens. build/vet/-race/full-suite + GOOS=windows build green; no new deps. Co-authored-by: KRATOS CLI wiring: skills/zeroline commands, --mode presets, exec checkpoints, sandbox --effective, sessions rewind (#128) * CLI wiring: skills/zeroline commands, --mode presets, exec checkpoints, sandbox --effective, sessions rewind Final module of the runtime-core split. Wires the new runtime features into the cli, PRESERVING main's merged specialist cli (the source branch predates specialist and drops it; this keeps it via a 3-way merge from the common base, adding the reskin features as ours/theirs-only changes). - skills command (internal/cli/skills.go) listing internal/skills; zeroline-skin TUI launcher (internal/cli/zeroline.go) via runInteractiveTUIWithSkin. - exec: --mode presets (smart/deep/fast/large/precise) via applyExecMode; model-registry alias resolution + deprecation/effort notices; ContextWindow sizing (modelContextWindow); before-mutation checkpoint recording in OnToolCall; enhanced tool-result output (ChangedFiles/Redacted/Display); permission-mode validation fix. - sandbox policy --effective (resolved guards); sessions rewind (executes the rewind plan); interactive TUI defaults to Ask mode (advertises write/edit/bash + gates via prompts). - internal/streamjson: EventCheckpoint/EventRestore + CheckpointInfo for structured checkpoint output. Specialist cli preserved (specialist.go + flags + runtime + tests all intact). build/vet/-race/full-suite + GOOS=windows build green; no new deps. * Address #128 review: make sessions rewind reachable + honor --exclude-target, mode/notice/redaction fixes - sessions: add "rewind" to the parser whitelist (isSessionsCommand) — it was wired in dispatch + help but parseSessionsArgs rejected it as 'unknown sessions command', so it was unreachable. Regression test TestRunSessionsRewindIsReachableAndHonorsExcludeTarget. - sessions rewind: honor --exclude-target — ApplyRewind keeps THROUGH a sequence, so when KeepTarget is false apply through TargetSequence-1, matching rewind-plan (previously rewind always applied TO the target, disagreeing with the plan). The test asserts --exclude-target keeps fewer events than the default. - sessions rewind: redact the session id in the success + not-found messages (consistent with every other human-readable path). - exec_parse: reject empty inline --mode= via requiredInlineFlagValue (was a silent no-op running defaults). - exec: notice writes (model-deprecation + reasoning-effort advisories) now treat write failures as exitCrash, consistent with the rest of the command. build/vet/-race/full-suite + GOOS=windows build green. --------- Co-authored-by: KRATOS TUI shell: ask_user modal, pickers, autocomplete, zeroline skin (#127) * TUI shell: ask_user modal, pickers, autocomplete, zeroline skin Lands the interactive TUI shell on main. (The earlier PR for this content targeted the stacked agent-runtime branch and never reached main, which already has the agent runtime via #125; this re-targets the same reviewed content onto main.) - ask_user: interactive questionnaire modal wired to agent.OnAskUser via an answer channel; transcript rows + session payloads; degrades gracefully without an interactive surface. - compaction sizing: modelContextWindow() sets the agent ContextWindow from the active model's registry entry (unknown models -> 0, compaction off). - pickers (model/theme/effort/mode) + slash-command autocomplete overlay; zeroline skin rendering (boot/home/chat) behind Options.Skin. - checkpoint recording: before each mutating tool the run batches an EventSessionCheckpoint in order and flushes it at end-of-run and on cancel; /rewind reloads in-memory session state so dropped events don't re-reach the agent. Exposes sessions.SnapshotForCheckpoint with an orphan-blob safe-usage contract for the batch-in-order path. build/vet/-race/full-suite + GOOS=windows build green; 116 tui tests; no new deps. * Address #127 review (CodeRabbit): validate checkpoint session id, fix mode toggle, harden ask_user/rewind/flush - sessions.SnapshotForCheckpoint validates the session id (ValidSessionID), matching CaptureToolCheckpoint, so an exported caller can't route blob writes through an invalid session path. - nextPermissionMode: Unsafe now folds to Ask (the stricter landing), not Auto — toggling an Unsafe session must never make it less strict. Test added. - ask_user: a request with zero questions resolves immediately instead of opening a prompt that stalls the run. - cancelled-run flush: surface appendSessionEvents persist-failure rows instead of discarding them (a silently-failed checkpoint flush would degrade /rewind). - /rewind: handle post-rewind reload failures explicitly — on ReadEvents error, clear in-memory context and report, so stale rewound-away events can't reach the next prompt. - transcript dedupe: key ask_user rows on row.id (survives rehydration when row.askUser is nil). - ask_user Esc test: replaced the empty branch with a real assertion (run stays pending after Esc cancels only the prompt). build/vet/-race/full-suite + GOOS=windows build green. * Address #127 re-review: mirror normal flow for zero-question ask_user The zero-question fast path now records the (empty) request in the transcript and answers with an empty slice ([]string{}) instead of nil, so it matches the normal resolveAskUser flow and downstream sees a consistent Answers shape. (Skipped the suggested 'defer Ctrl+C quit until flush failures are acknowledged': blocking quit on Ctrl+C contradicts prompt-exit expectations, is a non-minimal UX change, and the edge is rare with graceful impact — the failure rows are already appended for the common non-exit cancel, and a missed checkpoint only makes that one /rewind unavailable.) --------- Co-authored-by: KRATOS Agent runtime: ask_user interception, context compaction, guardrails (#125) * Agent runtime: ask_user interception, context compaction, guardrails Ports the agent-runtime slices from the reskin branch onto main, MINUS the blueprint's own sub-agent/task interception (main already has the merged specialist Task implementation, which this preserves). - ask_user (S6): AskUserRequest/AskUserResponse/AskUserQuestion types + Options.OnAskUser; the loop intercepts ask_user and routes to the interactive front-end, degrading gracefully (non-interactive fallback) when no handler is set. Answers are scrubbed through the redaction boundary. - compaction (S8): Options.ContextWindow + CompactionPreserveLast; proactive compaction when the estimated history crosses a fraction of the window, and reactive recovery on context-limit errors (summarizes the old middle, keeps system + last N). ContextWindow=0 disables it, so every existing caller/test behaves identically. - guardrails: empty-turn stop, repeated-tool-failure hint/stop, and a stale plan reminder; an aborted-placeholder is appended for unexecuted tool calls on guard stop. confirmation_policy.md is embedded into the system prompt. Preserved from main's specialist work: the RunWithOptions tool-context fields (ToolCallID/SessionID/Model/ReasoningEffort/Depth/Cwd), propertyToRuntimeMap nested Items, and ToolAdvertised AdvertiseInAuto. Dropped Options.Provider (it existed only for the blueprint task; compaction uses Run's provider arg). build/vet/-race/full-suite + GOOS=windows build green; no new deps. Unblocks the TUI shell + cli wiring PRs. * Address #125 review (CodeRabbit + @Vasanthdev2004): scope failure hint, fix ask_user fallback/cancel - Repeated-failure guard now fires only on RETRIABLE tool errors (bad args / execution failures), not policy refusals (disabled tool, permission denial, sandbox violation) — a 'match this schema' hint there misdirects the model or nudges it to retry blocked behavior. New isRetriableToolError classifier (keys off permission_action meta + the known policy-error messages). Test: TestIsRetriableToolError. - Headless ask_user fallback now goes through registry.RunWithOptions with the full run context (ToolCallID/SessionID/Model/ReasoningEffort/Depth/Cwd, sandbox/permission) and copies the full result fields (Meta/ChangedFiles/Display), so it behaves like every other tool path instead of the bare registry.Run that dropped them. - A canceled / timed-out ask_user prompt (OnAskUser returns context.Canceled/DeadlineExceeded) now ABORTS the run and returns that error instead of fabricating a synthetic 'no interactive user' answer and continuing to mutate the transcript. executeToolCall/executeAskUser return an abort error that the loop honors (closing out remaining advertised calls for replay validity). Test: TestRunAskUserCancellationAbortsRun. - compaction_test: corrected the comment to match the assertion (retried text must NOT be re-streamed to OnText). build/vet/-race/full-suite + GOOS=windows build green. * gofmt internal/agent/guardrails_test.go The file carried over from the reskin branch was not gofmt-clean; format it so the gofmt/lint check is green. --------- Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) MCP hardening: nested-schema passthrough + hung-server timeout (#124) Module of the runtime-core split (off main). internal/mcp had no drift on main, so this is a clean extract of the zenline MCP improvements. Deps (config, tools) are on main; uses tools.PropertySchema.Properties/Required from #119. No new deps. - schema.go: serialize nested object Properties/Required (and object-typed Items) when converting tools.PropertySchema <-> MCP JSON schema, so tools with nested args are advertised faithfully to/from MCP hosts instead of being flattened (audit H6). - client.go/server.go: bound MCP stdio handshake/calls so a hung or non-responsive MCP server can't block the caller forever; surface a timeout instead (audit H9). hang_test.go covers it. - registry.go: recognize the hardened client/schema paths; registry_test/schema_test added. build/vet/-race/full-suite + GOOS=windows build green. Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) Skills & plugins: skill tool, skills loader, plugin manifests (#123) * Skills & plugins: skill tool, skills loader, plugin manifests Module of the runtime-core split (off main, after #119 tools merged). Clean extract — internal/skills is new; internal/plugins had no drift on main. No new deps. - internal/skills: loads */SKILL.md frontmatter from the skills dir (resolves its own XDG dir); no YAML dependency. - skill tool (internal/tools/skill.go), read-only, registered in CoreReadOnlyTools (so it's in the agent core + the MCP read-only default). Path-safe: it Load()s all skills and matches by exact name — it never builds a path from the model-supplied arg, so there is no traversal surface. knownToolNames gains "skill" for the specialist cross-package invariant; CoreReadOnlyTools count test bumped 4->5. - internal/plugins: plugin manifest metadata enrichment (Author/License/Keywords/Interface). FORWARD code — the cli plugin-loading wiring lands later. MCP hardening (nested-schema + hung-server fix) is a SEPARATE follow-up PR. build/vet/-race/full-suite + GOOS=windows build green. * Address #123 review (CodeRabbit): pointer metadata, skill schema alias, dir fallback, List test - plugins: Author/Interface are now *PluginAuthor/*PluginInterface so omitempty actually omits them — a non-pointer struct is never empty to encoding/json, so the old form emitted author:{}/interface:{} and changed the serialized shape of plugins that don't set them. parseAuthor/parseInterface return nil when all fields are empty; formatAuthor takes the pointer. (Major) - skill: schema now declares the 'skill' alias alongside 'name' (and drops strict Required) so the alias survives schema validators that reject unknown keys (AdditionalProperties:false); Run still enforces exactly-one-of via aliasedStringArg. (Major) - skills.DefaultDir: return "" instead of a relative ".local/share/..." path when neither XDG_DATA_HOME nor a resolvable HOME exists, so skills can't bind to the process CWD (load("") already no-ops). (Minor) - skills_test: TestListReturnsNamesAndDescriptions now asserts List strips Content so a listing can't silently leak full skill bodies. (Minor) build/vet/-race/full-suite + GOOS=windows build green. * Address #123 review (@Vasanthdev2004): confine skill SKILL.md to the skills root skill is a permission-allow read-only core/MCP tool, so the loader must not follow a symlinked SKILL.md (or skill dir) out of the skills root and become an arbitrary-file reader. load() now resolves the skills root via EvalSymlinks and, for each entry, resolves SKILL.md through symlinks and verifies the real path stays under the root (confineSkillPath); an escaping or unreadable path is skipped rather than read. Mirrors the grep/read_file confinement. Legit skills under a symlinked root (e.g. macOS /tmp) still load because the root is resolved too. Regression: TestLoadSkipsSymlinkedSkillFileEscapingRoot (a SKILL.md symlinked to a secret outside the root is skipped, not read). build/vet/-race/full-suite + GOOS=windows build green. * Address #123 re-review (CodeRabbit): reject non-regular SKILL.md, single-trim metadata - skills: confineSkillPath now Lstats the resolved path and skips non-regular targets (directory/FIFO/device/socket). A FIFO/device named SKILL.md (or an in-root symlink to one) would otherwise make os.ReadFile block indefinitely — skill is a permission-allow tool over a user-controlled dir. Regression: TestLoadSkipsNonRegularSkillFile (unix-tagged; mkfifo SKILL.md, asserts Load skips it within a timeout rather than hanging). (Major) - plugins: coerceMetaStringSlice trims each item once into a local var instead of calling strings.TrimSpace twice. (Nitpick) build/vet/-race/full-suite + GOOS=windows build green. --------- Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) Add guarded web fetch tool (#121) * feat: add guarded web fetch tool * fix: harden web fetch DNS validation * fix: tighten web fetch SSRF guardrails Add specialist lifecycle registry test (#122) Tools: arg-tolerance, ask_user, secret scrubbing, structured results, hardening (#119) * Tools: arg-tolerance, ask_user, secret scrubbing, structured results, hardening First module of the tools->agent->tui chain that leads to the TUI shell (off main). Scope: internal/tools (+ a 1-line specialist vocabulary update). 3-way merged onto main's specialist drift (RunOptions tool-context fields, Safety.AdvertiseInAuto, optionsAwareTool dispatch, PropertySchema, base structured Result) and grafted the runtime-core additions: - Shared arg-tolerance layer (argtolerance.go: alias-aware/type-strict arg resolution + slice coercion) adopted across all tools so weak models' non-canonical arg shapes are accepted; bool/int coercion in args.go. - ask_user tool (interactive clarifying questions) wired into CoreReadOnlyTools; nested PropertySchema (Properties/Required) for its object questions + update_plan. - Secret scrubbing at the registry boundary (scrubResultSecrets via internal/redaction) applied to every tool-execution path, incl. the optionsAwareTool dispatch; structured Result (Display/Redacted/ChangedFiles). - mutation_targets (paths a mutating tool will write, for session checkpointing) + hardened grep (symlink confinement), apply_patch, bash, edit/write. EXCLUDED per the module split: the blueprint task tool (subagents — another dev's area; the merged specialist Task is separate) and the skill tool (ships with the later skills module); plus their subagent-only registry helpers (Without/isolateForChild). ask_user is now a core tool, so internal/specialist knownToolNames adds "ask_user" to keep the TestKnownToolNamesMatchCoreRegistry cross-package invariant. No new deps; build/vet/-race/windows/full-suite green. * Address #119 review (CodeRabbit): scrub Display, validate apply_patch mutation paths, narrow test skips - registry.go: scrubResultSecrets now redacts Result.Display.Summary too (not just Output), so a caller preferring Display can't bypass the boundary redaction. - mutation_targets.go: apply_patch targets now run through validatePatchPaths(applyRoot, ...) before being returned, so a traversal patch (../x) can't yield an out-of-workspace checkpoint target. Regression case added to TestMutationTargetsRejectsEscapingPaths. - argtolerance_test/write_tools_test: the apply_patch tests no longer Skipf on ANY non-OK result (which masked real diff/apply regressions); they skip ONLY when the git binary is unavailable (gitApplyUnavailable helper checks exec's 'executable file not found') and otherwise Fatalf. build/vet/-race/full-suite + GOOS=windows build green. * Address #119 re-review (CodeRabbit): best-effort multiSelect + Display.Summary scrub coverage - ask_user: an uncoercible multiSelect no longer fails the whole call — it's a UI hint, so it defaults to false (best-effort, mirroring the lenient options path). Test: TestParseAskUserQuestionsLenientOptions extended. - registry_test: TestRunWithOptionsScrubsSecretsForAllCallers now also leaks a secret via Result.Display.Summary and asserts it is redacted, locking in the boundary scrub for the structured-result path (secretTool gained a display field). build/vet/-race/full-suite + GOOS=windows build green. * Address #119 review (@Vasanthdev2004): scrub ALL registry return paths + harden intArg [blocker] RunWithOptions now scrubs every return path, not just tool-execution: a named return + a single 'defer scrubResultSecrets(result)' covers the unknown-tool, sandbox-deny, sandbox-approval-required, permission-required and permission-denied early returns too. Previously a sandbox denial that echoed a secret-bearing path/arg returned it raw with Redacted=false. Regression: TestRunWithOptionsScrubsSecretsOnDenialPaths (permission-denied reason carrying a token -> scrubbed + Redacted). [non-blocking] intArg fails closed on NaN/Inf/out-of-range floats (and their string forms) via a floatToInt helper, before any implementation-defined cast. Tests added for NaN/Inf/NaN-string/non-integral. build/vet/-race/full-suite + GOOS=windows build green. * Address #119 review (CodeRabbit grep TOCTOU + anandh8x dependency blockers) - grep: close the confinement TOCTOU — confineGrepFile now returns the symlink-RESOLVED path and collectGrepMatches reads THAT (not the raw candidate), so a symlink swapped in between check and read can't escape the workspace. - ask_user: removed from CoreReadOnlyTools. It needs the agent loop's interactive intercept (OnAskUser), which is in the (unmerged) agent module; without it the tool only returns the non-interactive fallback. The tool still ships (NewAskUserTool); the agent module registers it in core + wires the intercept together. Reverted the specialist knownToolNames 'ask_user' entry + the ask_user-in-core test accordingly. - update_plan: reverted the plan schema to a flat array (item structure documented in the description). The structured nested-object Items schema depends on the agent's PropertySchema serializer (propertyToRuntimeMap) passing nested Properties/Required through to providers, which is in the agent module; a nested schema here would be silently dropped. PropertySchema.Properties/Required fields are kept (used by ask_user.go's own schema + the later MCP module). build/vet/-race/full-suite + GOOS=windows build green; no new deps. * Harden #119 from adversarial self-review: sandbox alias-gate, Meta scrub, intArg 2^63 A multi-dimension adversarial review of the tools diff surfaced 3 real issues beyond the reviewer rounds: - [major/security] write_file/edit_file path aliases file_path/filename/filepath bypassed the sandbox workspace+symlink gate: sandbox.requestPaths only inspected path/cwd/file/dir, so an aliased target skipped enforcement (same class as audit H4). Aligned requestPaths with the tools' path-alias lists (internal/sandbox/risk.go). The tools' own resolveWorkspaceTargetPath still confined, but the sandbox LAYER/audit/symlink-check was silently skipped. Regression: TestRegistrySandboxGatesPathAliasKeys. - [minor] scrubResultSecrets skipped Result.Meta, which is forwarded to the transcript and carries model-controlled strings (glob pattern, bash cwd). Now scrubs Meta values too. Covered in TestRunWithOptionsScrubsSecretsForAllCallers. - [minor] floatToInt used f > float64(MaxInt); that constant rounds up to 2^63 so exactly 2^63 slipped to an out-of-range cast. Changed to >=. Test added for 2^63 float+string. build/vet/-race/full-suite + GOOS=windows build green; no new deps. --------- Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) Add specialist lifecycle accounting (#120) * Add specialist lifecycle accounting Record specialist start and stop events in parent sessions and roll child stream-json usage into one parent usage event. Background TaskOutput performs the same roll-up idempotently for persisted completed tasks. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/specialist Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Harden npm wrapper smoke test on Windows Clear inherited NODE_OPTIONS when executing the Node wrapper fixture and allow a longer Windows timeout for hosted-runner cold starts. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/npmwrapper ./internal/specialist Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Address specialist accounting review feedback Normalize mixed usage event totals before roll-up, preserve started child exit codes on execution errors, and make accounting payload assertions fail loudly on missing or mistyped fields. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/specialist ./internal/npmwrapper Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check Persist background specialist tasks (#118) * Persist background specialist tasks * Harden background task reload Add specialist authoring tools (#117) * Add specialist authoring tools * Harden specialist authoring boundaries Harden specialist background lifecycle (#116) * Harden specialist background lifecycle * Preserve killed background task state TUI rendering (zeroline): glamour markdown, syntax highlighting, themed surfaces (#114) * TUI rendering (zeroline): glamour markdown, syntax highlighting, themed surfaces Module of the runtime-core split (off main). Renamed the reskin package from the working name 'zenline' to **zeroline** (dropping the zenline name; this becomes ZERO's default polished terminal surface). Scope: internal/zeroline only — pure presentation, no internal deps. - glamour-rendered markdown (with a bounded mdCache to avoid per-frame re-render cost) and chroma syntax highlighting (transitive via glamour). - colored diffs, full-bleed background, a Zen home page + a vim/powerline-style statusline chat page sharing 5 switchable color themes (theme.go). - callers (the TUI model) build the data structs from live agent state; this package turns them into styled frames. It is FORWARD code — wired by the TUI module in a later PR; exercised here by render/markdown tests. Deps: adds github.com/charmbracelet/glamour v1.0.0 (pulls chroma/v2, goldmark, bluemonday, x/net, x/term transitively; lipgloss/bubbletea/termenv already present). go.sum updated via go mod tidy; build/vet/-race/full-suite green; no behavior change to existing packages. * Address #114 review (CodeRabbit + @anandh8x + @Vasanthdev2004): TUI render-contract fixes - AskUser suppresses overlays like Perm does: overlayRegion now returns early on d.AskUser != nil so suggestions/picker rows can't consume bodyH and push the focused questionnaire offscreen. Test: TestOverlaySuppressedDuringAskUser. - Permission modal narrow-width: derived permMinBoxWidth=47 (43-cell button row + border/padding); PermLayout disables the hitboxes when the box is narrower (buttons would overflow/clip and stop matching), mirroring the vertical-clip guard. Rewrote TestPermLayoutMatchesRenderClamped: narrow widths -> inactive, wide+height-clamped -> lockstep. - wrap() now budgets by display width (lipgloss.Width) instead of byte length, so CJK/emoji don't break the width budget. Test: TestWrapBudgetsByDisplayWidth. - Plain/streaming path now clips each wrapped prose line: wrap doesn't break a single over-long token (long URL / unbroken CJK), so renderAssistantPlain clips wl to the budget. Test: TestPlainAssistantClipsLongUnbrokenToken. - Streaming verbatim test strengthened to assert the literal 'partial **incomplete' survives (proves glamour is not applied while streaming). build/vet/-race/full-suite + GOOS=windows build green. * Address #114 re-review (@Vasanthdev2004): keep narrow perm modal + all chrome within the frame - permModalLines: when bw < permMinBoxWidth (too narrow for the 43-cell button row) it no longer renders the full button row (which overflowed the modal frame); it renders a compact keyboard-only hint clipped to the content width. Render and hit-test now stay aligned: PermLayout already disables the mouse hitboxes at this width. - RenderChat: added clampFrameWidth — a final frame-safety pass that clips every composed line to the frame width, so chrome (top/bottom bars) and the modal/body can never overflow the terminal at narrow widths (fixes the header/statusline-footer overflow too). Width-aware + ANSI-safe; a no-op at normal widths. - Regression test TestPermModalFitsFrameAtNarrowWidth: RenderChat at Width:40 with a Perm prompt asserts every line stays within 40 cells while PermLayout(40,24).Active == false. build/vet/-race/full-suite + GOOS=windows build green. * fix: apply CodeRabbit auto-fixes Fixed 2 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --------- Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit Add specialist background task tools (#115) * Add specialist background task tools * Address specialist background review feedback Session checkpoints & safe rewind: content-addressed blobs, atomic restore, file locking (#112) * Session checkpoints & safe rewind: content-addressed blobs, atomic restore, file locking Module 4 of the runtime-core split. Scope: internal/sessions only. 3-way merged cleanly onto main's #106 Tag/Depth work (preserved); no new deps (filelock uses stdlib syscall); build/vet/-race/windows-cross-compile/full-suite green. - CaptureToolCheckpoint snapshots a tool's target files into content-addressed (sha256) blobs before a mutating tool runs; SnapshotForCheckpoint records file mode; oversize files are skipped, identical content deduped. - RestoreToSequence reverts to a target sequence: applies only the closest-to-target checkpoint per path, verifies each blob's sha256 before writing, preserves file mode, and confines paths with EvalSymlinks (no symlink escape). ApplyRewind runs restore+truncate+prune+marker atomically under one session lock. - Cross-process file lock (flock) serializes session mutations; Fork copies checkpoint blobs so a fork can rewind; writeMetadata uses a unique tmp suffix. FORWARD: this is the persistence layer; the CLI 'zero sessions rewind' and the TUI '/rewind' + pre-tool checkpoint capture wiring land in later modules. Exercised here by checkpoint_test.go / rewind_test.go. * Address #112 review: capture-side confinement, atomic-only checkpoint API, Windows lock, test hygiene - [critical/human blocker] snapshotForCheckpoint now confines every capture target with resolveWithinWorkspace (same EvalSymlinks/no-".." guard as restore) BEFORE stat/read, so a traversal/symlink target can't read files outside the workspace into a blob. os.Stat failures are classified correctly: only os.IsNotExist -> Absent (restore deletes); permission/IO/symlink-loop -> Skipped (restore leaves alone), instead of treating every failure as Absent. Regression test added (TestCaptureRejectsTraversalTargets). - [major] SnapshotForCheckpoint unexported -> snapshotForCheckpoint: the blob writes and the referencing EventSessionCheckpoint must be committed atomically under one session lock, which only CaptureToolCheckpoint guarantees. Removes the unsafe snapshot-only external entry point (blob/event gap). - [major] Windows: real LockFileEx/UnlockFileEx (filelock_windows.go) replacing the no-op, so cross-process/cross-Store session serialization holds on Windows too. No new deps (x/sys already required for the unix flock). - [minor] checkpoint_test hygiene: check os.WriteFile/ReadDir/ReadFile/ReadEvents errors via helpers, add bounds checks before p.Files[0]/events[...] so setup failures fail at the source instead of panicking or passing spuriously. - rewind restore: documented the residual symlink-swap TOCTOU (boundary re-resolved immediately before the mutation; full closure needs openat2 RESOLVE_BENEATH / per-component O_NOFOLLOW, tracked for the CLI/TUI rewind-wiring work). build/vet/-race/full-suite + GOOS=windows build all green. * Address #112 re-review: fail rewind on undecodable checkpoint payload [major] restoreToSequenceLocked silently continued past a checkpoint whose payload failed to decode, which let a NEWER checkpoint win for that path and restore a later state than the caller requested. Treat corruption as a hard rewind error. Regression test TestRestoreFailsOnCorruptCheckpointPayload (fails-open before, errors now). Re-review note: the earlier capture-target-validation (critical) and test bounds (minor) were already addressed in 600337b (resolveWithinWorkspace guard + len checks); those re-anchored threads are stale. The restore-side symlink-swap TOCTOU remains a documented heavy-lift residual (needs openat2/descriptor traversal), tracked for the rewind-wiring work. * Address #112 re-review: dedupe rewind on resolved path, not raw path [major] restoreToSequenceLocked deduped the per-path closest-to-target short-circuit on the RAW checkpoint path, so equivalent paths (./a.txt, dir/../a.txt, a symlink alias) keyed differently and a NEWER checkpoint could overwrite the closest-to-target snapshot for the same underlying file. Now resolve/confine FIRST and dedupe on the resolved workspace path (unresolvable targets dedupe on raw path). Regression test TestRestoreDedupesEquivalentRawPaths. This + the prior commit (24e9ba2, fail-hard on undecodable payload) address both correctness blockers @Vasanthdev2004 raised after CodeRabbit's pass. build/vet/-race/full-suite + GOOS=windows build green. --------- Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) Add specialist Task runtime (#113) * Add specialist Task runtime * Harden specialist resume identity Add specialist execution foundation (#111) * Add specialist execution foundation * Normalize specialist session ids Sandbox hardening: destructive/network/installer classification + interactive-command detection (#109) * Sandbox hardening: destructive/network/installer classification + interactive-command detection Module 3 of the runtime-core split (off main, after #107). Scope: internal/sandbox only — clean additive extract; no new deps; whole tree builds + full suite/-race green. LIVE (Classify is already called by sandbox.Evaluate and agent/loop.go, so this hardens existing behavior immediately): - rm -rf targeting / $HOME ~ * — now tolerant of surrounding quotes, ${HOME} braces, combined/reordered -r/-f flags, and an optional -- separator; chmod 777 only flagged when recursive or root-targeted (single-file chmod no longer false-positives). - network-command + piped-installer detection (curl|sh incl |zsh and other shells); command resolved across command/cmd/script/shell aliases so an alias key can't bypass the gate. FORWARD (compiles now, wired by the later tools/bash module): DetectInteractiveCommand flags interactive programs (vim/less/...) behind wrappers (sudo/nice/timeout, and sh -c/bash -c payloads), hardened against absolute-path/quote/escape/substitution bypasses. * Address #109 review (CodeRabbit): fix sandbox detection over/under-reach - [critical] rm long-flag root: --no-preserve-root -rf -- "/" and ... -rf "/" now caught (quote + -- handling restored across short AND long flags). - chmod 777 abs-path narrowed to root or a sensitive SYSTEM tree (/, /etc, /usr, …); a single-file abs path like chmod 777 /tmp/build.sh is no longer a false positive. - piped_installer now requires a remote fetch (curl/wget/fetch/aria2c) before the pipe; a local 'cat x | bash' / 'printf | sh' is no longer mis-flagged (engine_test updated to assert the corrected semantics). - nonInteractiveREPLFlags adds mongo/mongosh (--eval/-f/--file) so 'mongo --eval ...' isn't flagged interactive. - programIndex now normalizes tokens (basename/quote-strip/lowercase) like firstProgram, so full-path invocations (/usr/bin/python script.py, /bin/bash -c 'vim …') classify correctly (no false positives / missed nested detections). TDD for each; build/vet/-race/windows cross-compile/full-suite green. --------- Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) Add specialist extends resolution (#110) Add background task manager (#108) * Add background task manager * Fix background manager test on Windows * Guard background task kill races Model registry: deprecation fallback, mode presets, reasoning-effort, alias patterns (#107) Module 2 of the runtime-core split (off main, after #105). Scope: internal/modelregistry only — fully additive (the whole tree builds unchanged against it). - Regex MatchPatterns aliases + pattern-based Resolve. - DeprecationRule with FallbackID; ResolveWithFallback returns the active fallback model + a user notice for deprecated ids; NewRegistry validates every Deprecation.FallbackID resolves (fail-fast on misconfig). - ReasoningEffort support: per-model ReasoningEfforts + DefaultReasoningEffort (validated to be a member of the model's set) + EffectiveReasoningEffort(). - Mode presets (modes.go: smart/deep/fast/large/precise -> model id + reasoning effort). These are the registry-capability layer; the CLI/agent adoption (--model deprecation-fallback, --mode, -r/--reasoning-effort, enhanced 'zero models') lands in a later module to keep PRs reviewable. Build/vet/full-suite/-race green; no new deps. Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) Add specialist lifecycle metadata (#106) Streaming reliability: SSE idle-watchdog, dropped-call handling, finish_reason, usage (#105) * Streaming reliability: SSE idle-watchdog, dropped-call handling, finish_reason, usage First reviewable slice of the runtime-core work (split out of the large integration branch). Scope: internal/zeroruntime + internal/providers only — fully backward-compatible/additive (the rest of the tree builds unchanged against it). - SSE idle-timeout watchdog (reader goroutine + idle timer + ctx-cancel) across OpenAI/Anthropic/Gemini via providerio.ScanSSEDataWithContext, so a stalled-but-open upstream no longer blocks the agent forever. - Streamed tool-call assembly hardened: nameless/dropped tool calls reported (StreamEventToolCallDropped), start-order preserved, distinct empty-id calls no longer merge. - Terminal finish/stop reason surfaced (CollectedStream.FinishReason + Truncated()) so length/content-filter responses aren't mistaken for normal completions; OpenAI requests stream_options.include_usage (token usage no longer 0); OpenAI delegates error/redact to providerio (503/529 classified) and joins multi-line SSE data; max_completion_tokens wired. Build/vet/full-suite/-race green. * Address #105 review (CodeRabbit): ctx honored when idle watchdog off; cancel during retry backoff; test robustness providerio: ScanSSEDataWithContext now runs the goroutine+select loop even when idleTimeout<=0 (idle case via a nil channel), so ctx cancellation is honored with the watchdog disabled (was a blocker: it short-circuited to the non-ctx-aware ScanSSEData). openai: a ctx cancellation during 5xx retry backoff is reported as the cancellation instead of being misclassified as the upstream 5xx. Tests: finish-reason/tool-call tests now assert the done/start event is actually emitted (not vacuously pass); new providerio regression test asserts cancel is honored with idleTimeout=0. Build/vet/-race green. --------- Co-authored-by: KRATOS Co-authored-by: Claude Opus 4.8 (1M context) feat(specialist): add exec metadata flags (#104) * Add specialist exec metadata flags * Address exec metadata review feat(specialist): add profile management (#100) * Add specialist profile management * Address specialist profile review * Address specialist validation review Polish update target output (#99) feat(tui): polish command output (#98) * feat(tui): polish command output * fix(tui): address command output review feat(update): verify release metadata by target (#97) * Add update check target selection * Reject empty update target flag feat(update): expose release check target options (#96) * Expose update check target options * Reject non-positive update timeouts feat: add runtime permission decisions (#95) Add sandbox backend capability reporting (#94) feat(zerocommands): add typed sandbox policy, risk, violation, and decision snapshots (#92) * feat(zerocommands): add typed sandbox policy, risk, violation, and decision snapshots The merged permission and verification event contracts (#82) added typed snapshots for config, providers, models, and sessions, and PR #89 added the typed snapshot for the persistent sandbox grant store, but the live sandbox policy, the live risk classification, the live violation, and the live decision were still exposed as the raw backend structs. The TUI render path, the headless 'zero sandbox policy --json' and 'zero sandbox decide' commands, the audit log, and PR/CI automation all need typed snapshots so they do not hand-roll map[string]any payloads and so they do not have to re-implement the runtime default-mode translation themselves. This commit adds five typed snapshot constructors and a bundled plan snapshot to internal/zerocommands. SandboxPolicySnapshot - mode, network, enforceWorkspace, denyDestructiveShell, and allowPolicyOnlyRunner are copied verbatim from the underlying sandbox.Policy. - effectiveMode is the resolved policy mode. An empty Mode in the input falls back to ModeEnforce, which is the runtime default. Consumers can read EffectiveMode to render the 'what is actually in effect' line without re-implementing the default. - effectiveMode is JSON-omitted when empty so the snapshot shape stays stable for downstream consumers. SandboxRiskSnapshot - level, categories, reason. - categories is copied and sorted alphabetically so the JSON output is deterministic. - reason is trimmed. SandboxViolationSnapshot - code, toolName, action, path, reason, recoverable. - The pointer-returning helper returns nil for a nil input so callers can chain it through SandboxDecisionSnapshot without nil checks at the call site. SandboxBackendSnapshot - name, available, platform, fallback, commandWrapping, nativeIsolation, executable, message. - executable and message are trimmed. SandboxPlanSnapshot - bundles policy, backend, restrictions, and workspaceRoot into one payload so 'zero sandbox policy --json' can return one typed result describing the full sandbox posture. - each entry of restrictions is trimmed, whitespace-only entries are dropped, and the result is sorted alphabetically. - workspaceRoot is trimmed and JSON-omitted when empty. SandboxDecisionSnapshot - action, reason, risk, grantMatched, grant, violation. - The optional grant pointer is converted with SandboxGrantSnapshotFromGrant (from PR #89) so the JSON shape matches the persistent-grant snapshot exactly. - The optional violation pointer is converted with SandboxViolationSnapshotFromViolation so the snapshot always carries the same shape regardless of the decision outcome. - When grant or violation is nil, the field is JSON-omitted. Tests - TestSandboxPolicySnapshotFromPolicyFillsEffectiveMode: verifies EffectiveMode is enforced for an empty Mode, set to ModeDisabled for an explicit disabled mode, and preserved for an explicit enforced mode. - TestSandboxRiskSnapshotFromRiskSortsCategoriesAndTrimsReason: verifies alphabetical sort, trimmed reason, and that a nil Categories input produces a nil output (no spurious empty slice). - TestSandboxViolationSnapshotFromViolationHandlesNilAndTrims: verifies the nil-input contract and the trim behaviour for every string field. - TestSandboxBackendSnapshotFromBackendCopiesAllFields: verifies the boolean and string fields round-trip and the trim behaviour for the executable and message. - TestSandboxPlanSnapshotFromPlanSortsRestrictionsAndTrimsRoot: verifies alphabetical sort, per-entry trim, and workspaceRoot trim. - TestSandboxDecisionSnapshotFromDecisionAllowBranchHasNoViolation: verifies the allow path has no grant and no violation. - TestSandboxDecisionSnapshotFromDecisionPersistentDenyCarriesGrantAndViolation: verifies the deny path carries both the resolved grant and the violation, and that the grant snapshot matches the persistent-grant snapshot shape from PR #89. - TestSandboxDecisionSnapshotJSONShapeIsStable: verifies the stable keys and the nil-pointer omission. - TestSandboxPolicySnapshotJSONOmitsEmptyEffectiveMode: verifies the omitempty tag. Verification - go build ./... - go vet ./... - go test -count=1 -p 1 ./internal/zerocommands/... \ ./internal/sandbox/... ./internal/redaction/... DRI: Gnanam (runtime core owner per WORK_SPLIT_PRD.md §4). Closes the typed-snapshot gap for the live sandbox surface that the TUI render path, the 'zero sandbox' headless commands, the audit log, and PR/CI automation consume. * fix(npmwrapper): make copyWrapperFixture create package.json with type:module This ensures the isolated wrapper .js fixture is always treated as ESM (matching real package installs). Previously, without it, Node treated the .js as CJS on some platforms/runners (especially Windows), causing SyntaxError before the missing-native console.error path. This was causing TestNodeWrapperReportsMissingNativeBinary to get empty output + timeout on windows-latest smoke jobs (e.g. in PR 92). The test now reliably hits the error path and captures the expected message on all platforms. Also improves robustness of the npm wrapper smoke test added in #88. --------- Co-authored-by: Gnanam Co-authored-by: KRATOS feat(tui): surface sandbox permission events (#93) * feat(tui): surface sandbox permission events * fix(tui): stream permission rows live feat(zerocommands): add typed MCP, hooks, and plugins snapshots (#90) * feat(zerocommands): add typed MCP, hooks, and plugins snapshots The merged permission and verification event contracts added typed snapshots for config, providers, models, sessions, and the sandbox grant store, but the MCP server config, hooks config, and loaded plugin manifests still expose only the raw backend structs. The TUI render path, the headless 'zero mcp', 'zero hooks', and 'zero plugins' commands, and PR/CI automation all need typed snapshots so they do not hand-roll map[string]any payloads and do not accidentally leak the secret material that lives in MCP server Env and Headers. This commit adds three typed snapshot constructors plus a bundled backend lifecycle snapshot to internal/zerocommands. MCPServerSnapshot - name, type, identity, url, command are copied verbatim and trimmed. They are non-secret metadata the operator needs to see when triaging an MCP failure. - argCount, envKeyCount, headerCount replace the slices and maps with counts. Secret material in Env and Headers is never serialized. - toolCount, allowGranted, denyGranted are optional runtime counts that the snapshot can carry when the caller has a live tool registry and a live permission store. A nil counts struct leaves the snapshot at zero. HookSnapshot - id, name, event, matcher, command, args, enabled, source. - command and args are run through the standard redaction pipeline. Whitespace-only args are dropped so JSON output is tight. PluginSnapshot - id, name, version, description, enabled, source, root, pluginDir, manifestPath are copied verbatim and trimmed so the operator can see where the manifest came from. - toolCount, promptCount, skillCount, hookCount replace the full extension slices with counts so the headless JSON output stays small. BackendLifecycleSnapshot - bundles the three slices into a single payload so 'zero doctor' and 'zero config' can return one typed result that describes the full extensibility surface. - all three inner slices are always non-nil, so JSON output is always '[]' and never 'null'. Tests - TestMCPServerSnapshotFromServerStripsSecretsAndCountsMaps: verifies env and header values never appear in the JSON output, and that the count fields record the maps' sizes. - TestMCPServerSnapshotWithCountsMergesRuntimeCounts: verifies the optional counts bundle is merged and that a nil bundle leaves the snapshot at zero. - TestMCPServerSnapshotsSortsAndReturnsEmptySliceForEmptyInput: verifies alphabetical sort and that nil input returns a non-nil empty slice whose JSON encoding is '[]'. - TestHookSnapshotFromDefinitionRedactsCommandAndTrimsFields: verifies command and args are redacted, and the surrounding fields are trimmed. - TestHookSnapshotsSortsByIDAndEvent: verifies the deterministic (id, event) ordering. - TestHookSnapshotsWithSourceTagsEverySnapshot: verifies the helper that tags every snapshot with the same source string. - TestPluginSnapshotFromPluginCollapsesSlicesToCounts: verifies the path fields are preserved verbatim and the count fields record the slices' sizes. - TestPluginSnapshotsSortsByIDAndReturnsEmptySliceForEmptyInput: verifies id-based sort and empty-slice stability. - TestNewBackendLifecycleSnapshotBundlesAndDefaultsNilsToEmpty: verifies the bundled payload, that nil inputs produce empty slices in the JSON, and that a populated input round-trips. Verification - go build ./... - go vet ./... - go test -count=1 -p 1 ./internal/zerocommands/... \ ./internal/mcp/... ./internal/hooks/... \ ./internal/plugins/... ./internal/redaction/... DRI: Gnanam (runtime core owner per WORK_SPLIT_PRD.md §4). Closes the typed-snapshot gap for the three backend surfaces in §7.E that the TUI render path, the headless commands, and PR/CI automation consume. * fix(zerocommands): strip URL credentials and preserve redacted arg position Addresses the three actionable review items left on PR #90 by CodeRabbit. 1. MCPServerSnapshotFromServer now strips userinfo from the URL field so credentials embedded in an MCP server URL (https://user:token@host) never reach the headless JSON output. The new stripURLCredentialsFromURL helper uses net/url.Parse to remove the User field, then returns the sanitized URL. A URL that fails to parse is returned trimmed (not empty) so the operator still sees the configured endpoint when triaging a malformed MCP configuration. A blank input returns blank. 2. redactStringSlice now preserves position. Previously the helper appended to the output slice and skipped any element whose redacted value was empty, which meant a fully-redacted secret in the middle of a hook command line would shift every following argument by one position. The helper now allocates the output slice to the input length and assigns each element by index, so a fully-redacted element becomes an empty string in the output rather than disappearing. Whitespace-only inputs also become empty strings by index, which matches the previous drop behaviour but without shifting positions. A nil or empty input still returns nil so the JSON output omits the field entirely. 3. Test coverage is extended for both changes: - TestHookSnapshotFromDefinitionRedactsCommandAndTrimsFields now asserts len(snapshot.Args) == len(def.Args) so a future regression that drops or shifts redacted args is caught. - TestHookSnapshotFromDefinitionPreservesPositionForRedactedArgs verifies that a secret in the middle of an arg list ends up as an empty-string element at the same index, while the surrounding non-secret args round-trip verbatim. - TestMCPServerSnapshotStripsURLCredentials verifies that an https://admin:secret123@host URL becomes the credential-free form and that the username, password, and the '@' separator do not appear anywhere in the snapshot. - TestMCPServerSnapshotPreservesURLWithoutCredentials verifies that a URL without userinfo passes through unchanged. - TestMCPServerSnapshotKeepsUnparseableURLInsteadOfEmpty verifies the tolerance contract: a malformed URL is returned trimmed rather than empty, so the operator still sees the configured endpoint when triaging a broken configuration. Verification - go build ./... - go vet ./... - go test -count=1 -p 1 ./internal/zerocommands/... \ ./internal/mcp/... ./internal/hooks/... \ ./internal/plugins/... ./internal/redaction/... DRI: Gnanam. --------- Co-authored-by: Gnanam Remove Bun installer test dependency (#91) * Remove Bun installer test dependency * Harden installer smoke tests * Stabilize npm wrapper subprocess tests * Make npm wrapper fixture explicit ESM feat(zerocommands): add typed SandboxGrantSnapshot contract (#89) The merged permission and verification event contracts added typed snapshots for config, providers, models, and sessions, but the persistent sandbox grant store (internal/sandbox/grants.go) still exposes only sandbox.Grant directly. The TUI render path, the headless 'zero sandbox' command, and PR/CI automation all need a typed snapshot so they do not hand-roll map[string]any payloads and do not accidentally leak grant reasons that contain API keys or tokens. This commit adds SandboxGrantSnapshot alongside the existing ProviderSnapshot, ModelSnapshot, SessionSnapshot, and SessionTreeSnapshot in internal/zerocommands. The snapshot mirrors the format of the existing typed contracts: - ToolName, Decision, MaxAutonomy, ApprovedAt are copied verbatim from the underlying grant. They are non-secret metadata. - Reason is run through the standard redaction pipeline before it is copied, so any secret material the user typed when granting is masked before the snapshot leaves the runtime. - All string fields are trimmed. An empty or whitespace-only Reason becomes an empty Reason in the snapshot so JSON output omits the field. Two constructors are provided: - SandboxGrantSnapshotFromGrant(grant) for a single grant. - SandboxGrantSnapshots(grants) for a slice. Output is sorted alphabetically by ToolName so consumers (TUI, headless, JSON output) see a deterministic ordering. An empty input returns a non-nil empty slice so JSON output is always '[]' and never 'null'. A matched/unmatched helper is also added: - SandboxGrantMatchSnapshotFromLookup(toolName, lookup) pairs the typed snapshot with a Matched boolean. When the lookup did not match, the returned snapshot has Matched=false and a nil Grant pointer, so consumers can render the absence without inspecting an error or a typed zero value. Tests - TestSandboxGrantSnapshotFromGrantRedactsReasonAndTrimsFields: verifies the secret in the reason is masked and the surrounding fields are trimmed. - TestSandboxGrantSnapshotFromGrantEmptyReasonBecomesEmpty: verifies whitespace-only reasons normalize to the empty string. - TestSandboxGrantSnapshotsSortsByToolNameAndReturnsEmptySliceForEmptyInput: verifies alphabetical sort and that nil input returns a non-nil empty slice whose JSON encoding is '[]'. - TestSandboxGrantMatchSnapshotFromLookupMatchedAndUnmatched: verifies the matched and unmatched code paths. - TestSandboxGrantSnapshotJSONShapeIsStable: verifies every JSON key is present so downstream consumers can rely on the shape. Verification - go build ./... - go vet ./... - go test -count=1 -p 1 ./internal/zerocommands/... \ ./internal/sandbox/... ./internal/redaction/... DRI: Gnanam (runtime core owner per WORK_SPLIT_PRD.md §4). Connects: 'zero sandbox' command rendering, TUI grants panel, and PR/CI automation consumers to the persistent grant store without leaking grant reasons. Co-authored-by: Gnanam Replace npm wrapper with Node entrypoint (#88) * Replace npm wrapper with Node entrypoint * Fix npm wrapper test portability feat(tui): premium splash + working-view redesign (#83) * feat(tui): premium splash + working-view redesign Centralized truecolor theme and a visual-first session UI. Splash: - Two-tone ANSI Shadow ZERO wordmark (bright blocks / dim shadow) - Replace Enter/Tab/Ctrl+C keycaps with a permission-mode status line - Rounded boxes for header and prompt Working view (3 zones): - Header bar: cwd, git branch, provider/model, run state - Role-gutter rows (you / zero), tool rows with ok/err status and arg hints - Colorized diff cards for patch/edit output - Pinned status line: mode (left) + model, tokens, cost (right) Internals: - Centralized palette in theme.go (role/status/diff/mode styles) - Enrich transcriptRow with tool/status/detail for structured rendering - gitBranch resolves .git/HEAD including worktrees * fix: stabilize tui startup rendering * fix: preserve tui tool row metadata * test: document tui event rendering contract Add runtime permission and verification event contracts (#82) * feat: add runtime permission event contract * feat: stream permission events from exec * feat: add runtime command snapshots * feat: add session rewind compaction plans * feat: add verification event contracts * fix: harden runtime contract review gaps * test: assert approved exec stderr contract * fix: address CodeRabbit CHANGES_REQUESTED items for #82 - Make OnSandboxDecision callback async + panic-safe (non-blocking, recovered) in tools/registry.go per CR. - Add explicit regression test for ConfigSnapshot when ResolveRuntimeMetadata errors (no secret leak in Message/BaseURL). - (Many prior items like shapedPayloadPreview for permission/tool events, dual-target conflict test, stderr empty on approved path, Kind/Framework preservation, redactCommand empty slice, etc. were already landed on this branch.) All relevant tests now pass. * fix(agent): make sandbox decision available synchronously on Result for permission events The recent change to invoke OnSandboxDecision asynchronously (to satisfy non-blocking requirement) broke the agent's immediate capture of the decision for building PermissionEvent after registry.RunWithOptions. - Add SandboxDecision *sandbox.Decision to tools.Result (not serialized). - Populate it on all sandbox-involved return paths in registry.RunWithOptions (sync, before/while firing async callback for observers). - In agent executeToolCall, use result.SandboxDecision for buildPermissionEvent (reliable ordering) instead of side-effect capture via the callback. - The async callback is still supported for any external OnSandboxDecision consumers. This fixes the two failing agent tests that were causing the cross-platform 'Smoke' CI jobs to fail on this PR: - TestRunEmitsPermissionEventForPersistentSandboxGrant - TestRunAppliesSandboxEvenInUnsafeMode All go tests now pass. --------- Co-authored-by: KRATOS Move performance benchmark to Go (#87) * Move performance benchmark to Go * Address perf benchmark review feedback * Fix perf benchmark pipe drain order Move build smoke tooling to Go (#86) Move release packaging to Go (#85) * Move release packaging to Go * Handle release archive close errors * Guard release output cleanup paths Verify update release assets (#84) Report sandbox platform capabilities (#81) docs: update work split ownership plan (#80) feat: harden sandbox command execution (#79) * feat: add sandbox command runner * feat: route shell tools through sandbox engine * test: cover sandboxed shell execution Remove legacy TypeScript runtime (#78) * chore: remove legacy TypeScript runtime * fix: align perf benchmark semantics * fix: clarify harness rss benchmark feat: add M6 sandbox backend (#77) * feat: add sandbox policy backend * feat: enforce sandbox decisions in tool runtime * feat: add sandbox policy and grants CLI * fix: honor sandbox grants in registry feat: add M5 self verification backend (#76) * feat: add self verification backend * feat: wire verify attempts to self verification Generate PR automation summary in Go (#75) feat: add M5 test runner backend (#74) * feat: add test runner backend * feat: surface test summaries in verify * fix: address test runner review feedback feat: add M5 git workflow backend (#72) * feat: add Zero git change backend * feat: add self verification summaries * feat: expose git workflow CLI * fix: address M5 review feedback * fix: clean up zerogit review nits feat: make Go runtime the app path (#71) * feat: make go runtime the app path * fix: harden update semver parsing feat: add Go worktree and verification backend (#70) * feat: add Zero worktree isolation backend * feat: add Zero verification runner backend * feat: wire worktree and verification commands * fix: address worktree verification review feedback * test: tighten worktree base dir assertion * fix: redact workflow CLI output paths * fix: keep workflow diagnostics useful while redacted feat: persist TUI sessions (#69) * feat: persist TUI sessions * fix: address TUI session review fix: address session lineage review feedback feat: add Go session lineage backend fix: preserve MCP response close errors fix: guard concurrent MCP client close fix: gracefully close stdio MCP clients test: address MCP transport review feedback feat: add Go MCP network transports fix: narrow TUI usage fallback feat: add TUI session controls feat: expose MCP server CLI feat: add MCP server protocol test: assert MCP env replacement precisely test: clarify MCP overlay replacement coverage fix: address MCP review feedback [codex] expose MCP tools in Go CLI [codex] add Go MCP stdio tool bridge [codex] add Go MCP config resolution feat: add Go command center (#63) * feat: add Go command center * fix: address command center review * fix: redact command center base urls * test: cover command center redaction streams [codex] add Go extension backend commands (#61) * feat(go): add plugin manifest backend * feat(go): add extension policy stores * feat(go): expose extension backend commands * fix(go): reject rooted plugin paths cross-platform * fix(go): address extension backend review * fix(go): harden extension backend persistence * fix(go): preserve extension usage and hook enabled semantics * fix(go): use process-released MCP permission locks * fix(plugins): close symlink missing-leaf escapes * fix(plugins): fail closed on manifest realpath errors * fix(tests): keep plugin realpath regression portable * fix: enable CodeRabbit verdict workflow --------- Co-authored-by: KRATOS feat: add Go headless protocol sessions (#60) * feat: add Go headless protocol sessions * test: harden stream-json tool list filtering * fix: resolve stream-json review followups * chore: enable coderabbit review verdicts Enable request_changes_workflow in CodeRabbit config (#62) [codex] add Go observability backend commands (#59) * feat(go): add observability backend primitives * feat(go): add session search and doctor backends * feat(go): expose observability cli commands * fix(go): address observability review feedback --------- Co-authored-by: KRATOS [codex] add Go provider runtime adapters (#58) * feat(go): accept anthropic and google provider config * feat(go): add provider stream io helpers * feat(go): add anthropic streaming provider * feat(go): add gemini streaming provider * feat(go): wire provider factory to registry models * fix(go): address provider runtime review feedback feat: advance Go-first TUI and npm wrapper (#57) * feat: advance Go-first TUI and npm wrapper * fix: address wrapper and footer review feedback * feat: deepen Go TUI model and exec controls * fix: address coderabbit exec and model list feedback Add Go model registry catalog and cost helpers Summary: - Add Go model catalog metadata and registry filters for provider, capability, aliases, and reasoning effort. - Add tier-aware cost estimation and USD formatting helpers with normalized usage handling. - Cover model validation, catalog filtering, defensive cloning, pricing tiers, and Windows command-timeout CI behavior. Validation: - go test ./internal/config -run TestLoadProviderCommandTimeout -count=3 -v - go test ./... - bun run typecheck - bun test ./tests --timeout 15000 - bun run build - bun run smoke:build - bun run build:go - bun run smoke:go - ./zero --help - git diff --check feat: wire Go CLI to real provider flow (#54) * feat: wire go cli to real provider flow * fix: address exec review feedback * test: harden exec provider assertions * chore: prevent coderabbit blocking reviews Build release binary with Go [codex] add Go runtime contract foundations (#55) * feat: add Go runtime usage contracts * feat: add Go model registry contracts * feat: add Go config contract gaps * fix: address Go contract review gaps * fix(go): validate model entries in registry fix: address coderabbit review feedback feat: add go provider config and tui foundations feat: unify go runtime provider contracts fix: reject empty exec prompts feat: add go agent runtime Harden Go stream collection contract Add Go runtime provider contracts Add Go M0 bash tool (#49) * feat: add go bash tool * fix: avoid quoted helper path on windows * fix: make bash cwd test portable Add Go M0 write tools (#48) * feat: add go write tools * fix: address write tool review feedback * fix: recheck write target resolver [codex] add Go read-only core tools (#47) * feat: add go read-only core tools * fix: harden go read-only tools Add Go M0 validation scripts (#46) * Add Go M0 validation scripts * Validate Go smoke package version * Align README with Go migration [codex] add Go module entrypoint (#45) * feat: add go module entrypoint * fix: address go entrypoint review Update PRD for Go-native Zero direction (#44) * docs: update zero prd for go migration * docs: clarify go migration validation criteria * docs: reset m0 plan for go migration feat: add Zero hook backend (#43) * feat: add Zero hook backend * fix: harden Zero hook review paths feat: add local plugin manifest loader (#42) * feat: add local plugin manifest loader * test: harden plugin manifest loading Polish TUI theme experience (#38) * feat: polish TUI theme experience * Restore initial TUI polish layout * Restore startup ASCII logo * Fix TUI theme review blockers * Revert "Restore startup ASCII logo" This reverts commit ffc9b6b4b3b0aa5a54abc8814d0560c3cbb8e25e. * Restore saved TUI polish details * Fix startup logo redraw * Align startup rendering with saved TUI * fix: align tui startup logo * Fix TUI input style polish * Fix TUI review interactions * Fix startup logo visibility * Validate OpenGateway model input --------- Co-authored-by: Vasanthdev2004 feat: add MCP permission grants (#40) Configure CodeRabbit review decisions (#41) * ci: tune coderabbit review workflow * ci: anchor coderabbit artifact filters ci: configure coderabbit auto reviews (#39) feat: add Zero MCP client backend (#37) * feat: add Zero MCP client backend * fix: make MCP tool names collision-safe feat: add session search indexing (#36) Add a persistent per-session search index that stores redacted event text and rebuilds when session metadata changes. Route zero search through the index and add --reindex for explicit cache repair. Cover index persistence, stale rebuilds, forced reindexing, and CLI output. feat: add config validation diagnostics (#35) * feat: add config validation diagnostics Report invalid config layers with source paths and field paths while keeping loaded layers non-blocking. Wire config inspection and CLI JSON output to emit structured validation details without import-time config warnings. Add loader, inspection, and CLI coverage for invalid file and env diagnostics. * test: make config CLI home fixtures portable Set USERPROFILE alongside HOME so Windows smoke resolves the same temporary config path during zero config CLI tests. Add automated PR review workflow (#34) * ci: add automated pr review * ci: keep pr review posting trusted docs: comprehensive project README (#33) * docs: write a comprehensive project README Replace the stub README with a full overview: highlights, quick start, provider/model config, TUI + headless usage, command reference, tool table, architecture, project layout, dev/release, and docs index. * docs: align TUI shortcuts with implementation * docs: fix README to match implemented TUI interactions Address review: the TUI input handler only implements Enter (send), Tab (accept slash suggestion), arrow/PgUp-PgDn/Home-End scrolling when empty, and Ctrl+C. Remove advertised @ file refs, ! shell, shift+tab cycling, and Esc interrupt (those are UI affordances not yet wired). Also correct 'collapsible tool rows' to 'compact tool-call rows' to match the current renderer. feat: add Zero exec resume and fork sessions (#32) * feat: add Zero exec session resume fork backend * feat: wire Zero exec resume fork flags Add runtime command flow foundation (#30) * Add TUI tool approval flow * Remove startup shortcut hints * Add runtime command flow foundation * fix: serialize prompt-gated tool approvals feat: add Zero stream-json protocol (#31) * feat: add Zero stream-json protocol schema * feat: wire stream-json exec mode * fix: read piped stream-json stdin fix: lazy load tui for cli commands fix: restore startup shortcut hints feat(tui): animate live dot and running tool rows Add a pulsing LiveDot in the footer (heartbeat while the agent works, steady otherwise) and an animated spinner on tool rows while they're running (resolves to a static dot on completion). Each is an isolated leaf component so only it repaints per tick. Thinking spinner already existed. feat(tui): redesign in-session working view Header now shows the active file + git branch/ahead-behind; footer shows model · tokens · cost · ctx% · live. Activity rows are mockup-style (dot + tool + summary: Read N lines / Grep "q" N matches / Edit +A -B), and edits render an inline DiffCard (red/green). Usage/git are derived/estimated for now; live token usage + structured diffs are follow-ups. feat(tui): show slash-command menu on splash, drop keycap hints Render CommandSuggestions on the startup splash (TuiShell only showed it in the in-session view, so typing / on the splash showed nothing). Remove the Enter/Tab/Ctrl+C keycap line; the mode line + command menu cover the affordances. feat(tui): add Claude-Code-style mode status line to splash New ModeStatus shows the active mode (build/plan/auto-accept/bypass) with a chevron indicator, a shift+tab cycle affordance, and a /-command hint; color-coded per mode. Presentational for now — shell wires the actual cycling later. feat(tui): simplify shortcut hints to a clean single line Drop the boxed keycaps (heavy/noisy); render keys in cyan with dim labels and a faint middot separator. Wraps on narrow terminals. feat(tui): switch splash wordmark to figlet Rebel with two-tone cyan Use the Rebel 3D figlet font for ZERO; render solid front faces in bright cyan and the shaded drop-shadow dim, so the 3D depth reads correctly. Generated from figlet, padded to equal width for clean centering. feat(tui): use figlet Electronic wordmark for ZERO splash logo Outlined glyphs with a dotted fill — closer to the splash mockup than the solid block. Generated from figlet (not hand-drawn); lines padded to equal width so the block stays centered. feat(tui): rebuild startup splash into reusable components Replace the inlined StartupScreen with composable Header, ZeroLogo, CommandChips, PromptBox and ShortcutHints under src/tui/startup, plus a self-contained theme. Keeps the same props so TuiShell is unchanged; typecheck clean, shell render test passing. feat: add premium startup splash fix: align tui with compact agent stream fix: redesign tui as agent console fix: strengthen tui shell visual hierarchy feat: redesign tui shell feat: add Zero config inspection (#29) feat: add Zero doctor backend (#28) feat: add Zero session search (#27) * feat: add Zero session search backend * feat: expose Zero session search CLI * fix: redact Zero search output feat: add Zero secret redaction (#26) feat: add M2 release checksum verification (#25) * Add M2 release checksum verification * Cover release checksum edge cases feat: add tui model selector (#22) feat: add Zero usage tracking backend (#23) * feat: add Zero usage tracking backend Adds normalized token usage records, per-model cost summaries, and compact usage formatting backed by the existing model registry pricing. * feat: expose agent usage events Adds an onUsage callback to runAgent and covers provider usage forwarding with an agent-loop regression test. feat: add M2 performance benchmark harness (#20) * Add M2 performance benchmark harness * Polish performance benchmark CLI feat: add Zero session event store (#21) * feat: add Zero session event store * test: cover Zero session search limits * test: make session root assertions portable feat: add Zero Gemini provider (#18) * feat: add Zero Gemini stream provider * feat: wire Zero Gemini runtime * fix: merge Gemini user turns after tool results feat: add M2 install scripts (#19) * Add M2 install scripts * Harden install archive extraction * Fix install script runtime test platform feat: add m1 headless exec surface (#17) feat: add M2 update check (#16) * Add M2 update check command * Harden update check version and timeout feat: add Zero Anthropic provider (#15) * feat: add Zero Anthropic stream provider * feat: wire Zero Anthropic runtime * fix: address Anthropic provider review feedback Add M1 release packaging workflow (#14) feat: add Zero provider runtime resolver (#12) Resolve provider runtime inputs through the Zero model registry, enforce dispatch-safe OpenAI-compatible gateway URLs, normalize official OpenAI base URLs, and keep provider source metadata in the config boundary. Unify config typing on the loader schema, make provider-command parsing bounded and validated, improve headless adapter errors, and add regression coverage for requested-change cases. [codex] p0 foundation tools (#11) * p0 foundation tools * fix p0 foundation review blockers * fix review feedback on foundation tools * fix: hide prompt-gated tools from agent loop docs: add final Zero PRD and work split (#13) Add M0 CI scripts baseline (#10) * Add M0 CI scripts baseline * Address M0 CI review feedback feat: add Zero model registry (#9) * feat: add Zero model registry Add a Zero-branded model registry with provider metadata, aliases, capabilities, context limits, pricing source metadata, and cost helpers. Cover registry lookup, filters, reasoning effort metadata, deprecated-model handling, tiered pricing, and cost formatting with Bun tests. * fix: address model registry review feedback Remove speculative model entries and unstable latest-style aliases from the Zero model registry. Use verified public model metadata, default to gpt-4.1, document stable alias semantics, and tighten cached-token cost handling. Expand tests for deprecated filtering, alias stability, provider mismatch errors, direct model cost inputs, unsupported cache pricing, and invalid cost formatting. --------- Co-authored-by: KRATOS M0: foundation — ToolBase, layered config, write_file, line ranges, edit uniqueness (#6) * M0: foundation — ToolBase, layered config, write_file, line ranges, edit uniqueness Adds the core architecture for the M0 milestone (Weeks 1-2 of the PRD): - src/tools/base.ts: ToolBase abstract class with Zod-validated parameters, run() that converts invalid args / thrown errors into friendly strings, and built-in JSON Schema generation for providers. - src/tools/types.ts: introduce a generic Tool interface alongside the duck-typed contract the registry already accepts. - src/providers/base.ts: re-export the Provider interface and add seedMessages() + collectStream() helpers used by tests and (later) any non-streaming consumer. - src/config/loader.ts: layered config (defaults -> user config -> project config -> env vars -> CLI overrides), Zod-validated, with a layer-source report for /config-style introspection. - src/tools/read_file.ts: line-numbered output plus optional start_line / end_line / max_lines range slicing. - src/tools/write_file.ts: create-new-file with overwrite safety, auto-creates parent directories. - src/tools/edit_file.ts: exact-string replace with a uniqueness check (old_string must match exactly one place unless replace_all is true) to prevent accidental global edits. - src/tools/{read,edit}-file.ts: kept as re-exports of the new snake_case files so existing imports and the registry wiring continue to work. Tests: - 27 new tests across tests/tool-base.test.ts, tests/config-loader.test.ts, tests/provider-base.test.ts, and the expanded tests/file-tools.test.ts. - Full suite: 47 pass, 0 fail. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * fix: resolve strict TypeScript errors for go-live - src/agent/loop.ts: guard firstTool against undefined - src/config/loader.ts: make providers field optional in schema - src/tui/AddProvider.tsx: remove invalid marginTop prop from Text - src/tui/App.tsx: guard parts[0] against undefined - src/tui/MessageRenderer.tsx: use non-null assertion for matches[i] inside length check - src/tui/ProviderPicker.tsx: guard selected against undefined - src/tui/highlighter.ts: shiki v3 dropped codeToAnsi, fall back to plain text - tests/tool-base.test.ts: explicit Promise return type for FailingTool.execute Validation: tsc clean, 47/47 tests pass, build succeeds. * fix: address review feedback for M0 go-live - src/tools/write_file.ts (P1): use stat() for existence check so zero-byte existing files are not silently overwritten; previous guard used existing.length > 0 which treated empty files as new. - src/tools/types.ts + src/agent/loop.ts (P2): add optional Tool.run path; the agent loop now prefers tool.run() when present so the schema validation and thrown-error handling from ToolBase.run() are honored for ToolBase subclasses. Plain object-literal tools fall back to execute() unchanged. - tests/file-tools.test.ts: add regression tests for the empty-file overwrite guard. Validation: tsc clean, 49/49 tests pass (was 47, +2 regression tests). P2 finding about the layered config loader not being wired into runtime is acknowledged as out of scope for this PR; that work is tracked in a follow-up issue. --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> fix plan mode and add test (#2) * fix plan mode and add test * Address review: wire plan mode, harden test script, restore friendly errors - Wire /plan into agent behavior: add PLAN_MODE_SYSTEM_PROMPT and a planMode option on runAgent; App passes isPlanMode through so the agent actually plans without editing instead of only changing UI color. - Harden test script to "bun test ./tests --timeout 15000" for deterministic discovery and a bounded runtime so it always exits. - Restore friendly provider-setup/auth/network error mapping via toFriendlyError(); debug mode still records the full error object. - Add tests/agent-loop.test.ts covering prompt selection and the tool-call -> result -> final-answer loop with a mock provider. --------- Co-authored-by: Kevin Codex Co-authored-by: Claude Opus 4.8 (1M context) Add contributing guidelines (#1) initial commit --- internal/tui/file_view.go | 5 + internal/tui/files_git_sweep.go | 217 +++++++++++++++++++++++++++ internal/tui/files_git_sweep_test.go | 201 +++++++++++++++++++++++++ internal/tui/files_panel.go | 9 ++ internal/tui/model.go | 31 +++- 5 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 internal/tui/files_git_sweep.go create mode 100644 internal/tui/files_git_sweep_test.go diff --git a/internal/tui/file_view.go b/internal/tui/file_view.go index 667692ad0..194554e3b 100644 --- a/internal/tui/file_view.go +++ b/internal/tui/file_view.go @@ -50,6 +50,11 @@ func (m model) openFileView(path string) model { m.fileView.active = true m.fileView.path = path m.fileView.mode = fileViewDiff + // A file only the git sweep knows about (bash/subagent mutation) has no edit + // cards to stack — open straight on the full file instead of a placeholder. + if len(m.fileViewResultRows()) == 0 { + m.fileView.mode = fileViewFull + } m.chatScrollOffset = 0 m = m.clearHover() // bodyY numbering differs between the file body and the chat return m diff --git a/internal/tui/files_git_sweep.go b/internal/tui/files_git_sweep.go new file mode 100644 index 000000000..14385a477 --- /dev/null +++ b/internal/tui/files_git_sweep.go @@ -0,0 +1,217 @@ +// files_git_sweep.go fills the FILES sidebar's blind spot: mutations that +// bypass the file tools entirely — bash/exec_command scaffolding (npm create, +// go generate, heredoc writes) and subagents editing the shared workspace. None +// of those produce a changedFiles-carrying tool result, so the transcript-derived +// roster (files_panel.go) never sees them. The sweep asks git instead: a +// baseline `git status --porcelain` snapshot is taken at startup (Init), and a +// re-run after each command tool result / turn end reports any NEWLY dirty +// paths, which merge into the roster with a diffstat from `git diff --numstat`. +// Pre-existing dirty state stays in the baseline and never shows; a non-git +// workspace fails the first command and the sweep silently stays off. +package tui + +import ( + "context" + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" +) + +// gitSweepTimeout bounds each background git invocation so a hung index lock +// can never stall message delivery (the command runs off the update goroutine). +const gitSweepTimeout = 3 * time.Second + +// gitSweepFile is one workspace file git reports as dirty: workspace-relative +// path (git's own output convention, matching changedFiles), whether it is new +// (untracked/added), and the tracked diffstat when known (0/0 for untracked). +type gitSweepFile struct { + path string + created bool + adds int + dels int +} + +// gitSweepMsg carries one sweep's result. baseline marks the startup snapshot +// (recorded as "pre-existing, never show") vs a live re-check (merged into the +// roster). ok is false when git failed (not a repo, no git binary) — the +// handler then marks the sweep unavailable rather than retrying every turn. +type gitSweepMsg struct { + baseline bool + ok bool + files []gitSweepFile +} + +// gitSweepCmd runs the status+numstat pair off the update goroutine. cwd is the +// workspace root the TUI resolves paths against. +func gitSweepCmd(parent context.Context, cwd string, baseline bool) tea.Cmd { + return func() tea.Msg { + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, gitSweepTimeout) + defer cancel() + // --untracked-files=all enumerates files inside new directories; plain + // porcelain collapses them to "dir/", which is useless as a FILES row. + status, err := defaultPRCommandRunner(ctx, cwd, "git", "status", "--porcelain", "--untracked-files=all") + if err != nil { + return gitSweepMsg{baseline: baseline, ok: false} + } + files := parseGitPorcelain(status) + // Diffstat for tracked modifications; untracked files have no diff to + // stat. Best-effort: a failure (e.g. unborn HEAD in a fresh repo) keeps + // the file list with zero counts rather than dropping the sweep. + if numstat, err := defaultPRCommandRunner(ctx, cwd, "git", "diff", "HEAD", "--numstat"); err == nil { + stats := parseGitNumstat(numstat) + for i := range files { + if counts, ok := stats[files[i].path]; ok { + files[i].adds, files[i].dels = counts[0], counts[1] + } + } + } + return gitSweepMsg{baseline: baseline, ok: true, files: files} + } +} + +// parseGitPorcelain parses `git status --porcelain` v1 output into sweep files. +// Format: two status columns, a space, then the path ("XY path"); renames show +// "old -> new" (keep the new side); untracked entries are "?? path". +func parseGitPorcelain(out string) []gitSweepFile { + var files []gitSweepFile + for _, line := range strings.Split(out, "\n") { + if len(line) < 4 { + continue + } + code, path := line[:2], strings.TrimSpace(line[3:]) + if to, _, found := cutRename(path); found { + path = to + } + path = unquoteGitPath(path) + if path == "" { + continue + } + files = append(files, gitSweepFile{ + path: path, + created: code == "??" || strings.Contains(code, "A"), + }) + } + return files +} + +// cutRename splits a porcelain rename value "old -> new", returning the new +// path. found is false for ordinary (non-rename) paths. +func cutRename(path string) (string, string, bool) { + if idx := strings.Index(path, " -> "); idx >= 0 { + return path[idx+4:], path[:idx], true + } + return "", "", false +} + +// unquoteGitPath strips the quotes git wraps around paths with special +// characters ("web/my file.js"). Escapes inside are left as-is — such a path +// still identifies the file well enough for a sidebar row. +func unquoteGitPath(path string) string { + if len(path) >= 2 && strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) { + return path[1 : len(path)-1] + } + return path +} + +// parseGitNumstat parses `git diff --numstat` output ("added\tdeleted\tpath") +// into path → [added, deleted]. Binary files report "-" and are skipped. +func parseGitNumstat(out string) map[string][2]int { + stats := map[string][2]int{} + for _, line := range strings.Split(out, "\n") { + parts := strings.SplitN(line, "\t", 3) + if len(parts) != 3 { + continue + } + adds, errA := strconv.Atoi(parts[0]) + dels, errD := strconv.Atoi(parts[1]) + if errA != nil || errD != nil { + continue + } + path := parts[2] + if to, _, found := cutRename(path); found { + path = to + } + stats[unquoteGitPath(path)] = [2]int{adds, dels} + } + return stats +} + +// handleGitSweepMsg folds a sweep result into the model: the baseline snapshot +// records what was already dirty before this TUI session (those paths never +// show), a live sweep upserts newly dirty paths into gitTouched in first-seen +// order. A failed sweep marks git unavailable so no further sweeps are issued. +func (m model) handleGitSweepMsg(msg gitSweepMsg) model { + m.gitSweepInFlight = false + if !msg.ok { + m.gitSweepUnavailable = true + if m.gitFileBaseline == nil { + m.gitFileBaseline = map[string]bool{} + } + return m + } + if msg.baseline { + baseline := make(map[string]bool, len(msg.files)) + for _, f := range msg.files { + baseline[f.path] = true + } + m.gitFileBaseline = baseline + return m + } + for _, f := range msg.files { + if m.gitFileBaseline[f.path] { + continue + } + found := false + for i := range m.gitTouched { + if m.gitTouched[i].path == f.path { + m.gitTouched[i] = f + found = true + break + } + } + if !found { + // Copy-on-append: model copies share the backing array, so an in-place + // append from two update branches could alias. Rebuilding is cheap at + // sidebar scale. + m.gitTouched = append(append([]gitSweepFile(nil), m.gitTouched...), f) + } + } + return m +} + +// maybeGitSweep issues a live sweep when one is useful and none is running: +// the baseline exists (Init's snapshot answered), git works here, and the +// workspace is known. Returns the (possibly nil) command to batch. +func (m model) maybeGitSweep() (model, tea.Cmd) { + if m.gitSweepInFlight || m.gitSweepUnavailable || m.gitFileBaseline == nil || strings.TrimSpace(m.cwd) == "" { + return m, nil + } + m.gitSweepInFlight = true + return m, gitSweepCmd(m.ctx, m.cwd, false) +} + +// gitTouchedFiles adapts the sweep results to the roster's touchedFile shape, +// for merging under the transcript-derived entries (files_panel.go). No +// transcript row backs them (lastRowIndex -1), so selecting one skips the +// scroll/tint and the drill-in opens on the full file. +func (m model) gitTouchedFiles() []touchedFile { + if len(m.gitTouched) == 0 { + return nil + } + files := make([]touchedFile, 0, len(m.gitTouched)) + for _, f := range m.gitTouched { + files = append(files, touchedFile{ + path: f.path, + created: f.created, + adds: f.adds, + dels: f.dels, + lastRowIndex: -1, + }) + } + return files +} diff --git a/internal/tui/files_git_sweep_test.go b/internal/tui/files_git_sweep_test.go new file mode 100644 index 000000000..68c80d16f --- /dev/null +++ b/internal/tui/files_git_sweep_test.go @@ -0,0 +1,201 @@ +package tui + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestParseGitPorcelain(t *testing.T) { + out := " M web/app.js\n?? web/new.css\nA docs/added.md\nR old.go -> pkg/new.go\n?? \"web/my file.js\"\n\n" + files := parseGitPorcelain(out) + if len(files) != 5 { + t.Fatalf("expected 5 entries, got %d: %+v", len(files), files) + } + byPath := map[string]gitSweepFile{} + for _, f := range files { + byPath[f.path] = f + } + if f := byPath["web/app.js"]; f.created { + t.Error("modified file must not read as created") + } + if f := byPath["web/new.css"]; !f.created { + t.Error("untracked file should read as created") + } + if f := byPath["docs/added.md"]; !f.created { + t.Error("index-added file should read as created") + } + if _, ok := byPath["pkg/new.go"]; !ok { + t.Errorf("rename should keep the new path: %+v", files) + } + if _, ok := byPath["web/my file.js"]; !ok { + t.Errorf("quoted path should unquote: %+v", files) + } +} + +func TestParseGitNumstat(t *testing.T) { + stats := parseGitNumstat("12\t3\tweb/app.js\n-\t-\tassets/logo.png\n4\t0\told.go => pkg/new.go\n1\t1\tR\told.go -> pkg/new.go\n") + if got := stats["web/app.js"]; got != [2]int{12, 3} { + t.Errorf("web/app.js = %v, want [12 3]", got) + } + if _, ok := stats["assets/logo.png"]; ok { + t.Error("binary '-' entries must be skipped") + } +} + +// TestGitSweepMergeAndBaseline: the baseline snapshot hides pre-existing dirty +// paths; live sweeps upsert only new ones; a failed sweep latches unavailable. +func TestGitSweepMergeAndBaseline(t *testing.T) { + m := model{} + m = m.handleGitSweepMsg(gitSweepMsg{baseline: true, ok: true, files: []gitSweepFile{{path: "dirty-before.go"}}}) + if !m.gitFileBaseline["dirty-before.go"] { + t.Fatal("baseline should record pre-existing dirty paths") + } + + m = m.handleGitSweepMsg(gitSweepMsg{ok: true, files: []gitSweepFile{ + {path: "dirty-before.go", adds: 9}, + {path: "kanban/board.tsx", created: true, adds: 120}, + }}) + if len(m.gitTouched) != 1 || m.gitTouched[0].path != "kanban/board.tsx" { + t.Fatalf("only newly dirty paths should merge: %+v", m.gitTouched) + } + + // Re-sweep updates stats in place, no duplicate. + m = m.handleGitSweepMsg(gitSweepMsg{ok: true, files: []gitSweepFile{{path: "kanban/board.tsx", created: true, adds: 150, dels: 2}}}) + if len(m.gitTouched) != 1 || m.gitTouched[0].adds != 150 { + t.Fatalf("re-sweep should upsert stats: %+v", m.gitTouched) + } + + failed := model{} + failed = failed.handleGitSweepMsg(gitSweepMsg{baseline: true, ok: false}) + if !failed.gitSweepUnavailable { + t.Fatal("a failed sweep should latch unavailable") + } + if _, cmd := failed.maybeGitSweep(); cmd != nil { + t.Fatal("no further sweeps once unavailable") + } +} + +// TestMaybeGitSweepGating: no sweep before the baseline answers, none while one +// is in flight, and the in-flight flag sets when one is issued. +func TestMaybeGitSweepGating(t *testing.T) { + m := model{cwd: "/tmp"} + if _, cmd := m.maybeGitSweep(); cmd != nil { + t.Fatal("no baseline yet: sweep must not run") + } + m.gitFileBaseline = map[string]bool{} + next, cmd := m.maybeGitSweep() + if cmd == nil || !next.gitSweepInFlight { + t.Fatal("with a baseline, a sweep should be issued and marked in flight") + } + if _, again := next.maybeGitSweep(); again != nil { + t.Fatal("single-flight: no second sweep while one runs") + } +} + +// TestTouchedFilesMergesGitSweep: git-discovered files append below the +// transcript-derived entries, deduped by path (a tool-result entry wins). +func TestTouchedFilesMergesGitSweep(t *testing.T) { + m := filesPanelTestModel() + m.gitTouched = []gitSweepFile{ + {path: "kanban/board.tsx", created: true, adds: 120}, + {path: "web/app.js", adds: 999}, // duplicate of a transcript entry + } + files := m.touchedFiles() + var kanban *touchedFile + appCount := 0 + for i := range files { + if files[i].path == "kanban/board.tsx" { + kanban = &files[i] + } + if files[i].path == "web/app.js" { + appCount++ + if files[i].adds == 999 { + t.Error("transcript-derived entry should win over the git duplicate") + } + } + } + if kanban == nil || !kanban.created || kanban.lastRowIndex != -1 { + t.Fatalf("git-only file should merge with created badge and no row anchor: %+v", files) + } + if appCount != 1 { + t.Fatalf("web/app.js should appear exactly once, got %d", appCount) + } +} + +// TestOpenFileViewGitOnlyFallsBackToFull: a file with no edit cards (git sweep +// discovery) opens straight on the full-file view instead of an empty diff. +func TestOpenFileViewGitOnlyFallsBackToFull(t *testing.T) { + m := filesPanelTestModel() + m.gitTouched = []gitSweepFile{{path: "kanban/board.tsx", created: true}} + m = m.openFileView("kanban/board.tsx") + if m.fileView.mode != fileViewFull { + t.Fatal("git-only file should open in full mode") + } + m = m.exitFileView() + m = m.openFileView("web/app.js") + if m.fileView.mode != fileViewDiff { + t.Fatal("a file with edit cards still opens in diff mode") + } +} + +// TestGitSweepCmdAgainstRealRepo: end-to-end against a real git repo — the +// baseline sees pre-existing dirt, a post-mutation sweep reports the new file +// as created and the modified file with its numstat. +func TestGitSweepCmdAgainstRealRepo(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + dir := t.TempDir() + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + write := func(name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + run("init", "-q") + write("tracked.txt", "one\ntwo\n") + run("add", ".") + run("commit", "-q", "-m", "seed") + write("pre-existing.txt", "dirt\n") // dirty BEFORE the TUI "opens" + + baseline := gitSweepCmd(nil, dir, true)().(gitSweepMsg) + if !baseline.ok || len(baseline.files) != 1 || baseline.files[0].path != "pre-existing.txt" { + t.Fatalf("baseline should see only the pre-existing dirt: %+v", baseline) + } + + // The "agent" now scaffolds via the shell. + write("scaffolded.txt", "hello\n") + write("tracked.txt", "one\ntwo\nthree\nfour\n") + + sweep := gitSweepCmd(nil, dir, false)().(gitSweepMsg) + if !sweep.ok { + t.Fatal("sweep failed") + } + byPath := map[string]gitSweepFile{} + for _, f := range sweep.files { + byPath[f.path] = f + } + if f, ok := byPath["scaffolded.txt"]; !ok || !f.created { + t.Fatalf("scaffolded file should report created: %+v", sweep.files) + } + if f := byPath["tracked.txt"]; f.adds != 2 || f.dels != 0 { + t.Fatalf("tracked.txt numstat = +%d −%d, want +2 −0", f.adds, f.dels) + } + + // Not-a-repo → ok=false (the sweep latches off). + if msg := gitSweepCmd(nil, t.TempDir(), false)().(gitSweepMsg); msg.ok { + t.Fatal("a non-repo should report ok=false") + } +} diff --git a/internal/tui/files_panel.go b/internal/tui/files_panel.go index f162b531a..f25a1b05c 100644 --- a/internal/tui/files_panel.go +++ b/internal/tui/files_panel.go @@ -76,6 +76,15 @@ func (m model) touchedFiles() []touchedFile { for left, right := 0, len(files)-1; left < right; left, right = left+1, right-1 { files[left], files[right] = files[right], files[left] } + // Merge the git sweep's discoveries (bash/subagent mutations that carry no + // changedFiles — files_git_sweep.go) below the transcript-derived entries, + // skipping paths a tool result already reported. + for _, f := range m.gitTouchedFiles() { + if _, seen := index[f.path]; seen { + continue + } + files = append(files, f) + } return files } diff --git a/internal/tui/model.go b/internal/tui/model.go index 8e6ef5a96..315c2e748 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -217,6 +217,14 @@ type model struct { // active the chat column's body shows the file's diff/content instead of the // transcript, mirroring the subchat drill-in. fileView fileViewState + // Git-sweep state (files_git_sweep.go): the startup snapshot of already-dirty + // paths (nil until Init's sweep answers), the newly dirty files discovered by + // live sweeps (bash/subagent mutations that carry no changedFiles), the + // single-flight guard, and the "not a git repo / no git" latch. + gitFileBaseline map[string]bool + gitTouched []gitSweepFile + gitSweepInFlight bool + gitSweepUnavailable bool // swarmDoneAt records when each swarm member was first seen finished (done/ // failed) in a swarm_status report, so the sidebar can linger it briefly with a // fading ✓ before dropping it (a smooth exit, not an abrupt pop). Stamped in the @@ -790,6 +798,12 @@ func composerBlinkCmd() tea.Cmd { func (m model) Init() tea.Cmd { cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd()} + // Baseline git snapshot for the FILES sidebar sweep: whatever is already + // dirty when the TUI opens is pre-existing state, not this session's work + // (files_git_sweep.go). Async; a non-git workspace just disables the sweep. + if strings.TrimSpace(m.cwd) != "" { + cmds = append(cmds, gitSweepCmd(m.ctx, m.cwd, true)) + } // In auto mode, ask the terminal for its background color; the reply arrives // as tea.BackgroundColorMsg and selects light vs dark (see updateModel). if m.themeMode == themeAuto { @@ -1811,8 +1825,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { } m, recapCmd = m.maybeRecapTurn(msg.runID, finalAnswer) } + // End-of-turn git sweep: catch file mutations the tool stream couldn't + // report (bash scaffolding, subagent edits) so the FILES sidebar is + // complete once the turn settles. + var sweepCmd tea.Cmd + m, sweepCmd = m.maybeGitSweep() next, queuedCmd := m.launchQueuedMessageIfReady() - return next, tea.Batch(titleCmd, recapCmd, queuedCmd) + return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd) case sessionTitleGeneratedMsg: return m.handleSessionTitleGenerated(msg) case recapGeneratedMsg: @@ -1963,6 +1982,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.transcript = collapseRepeatedStatusCard(m.transcript, msg.row) m.transcript = appendTranscriptRow(m.transcript, msg.row) m = m.captureStepWork(msg.row) + // A finished command tool may have mutated files git can see but no + // changedFiles reports (npm create, heredoc writes, subagent edits) — + // re-sweep so the FILES sidebar picks them up mid-turn. + if msg.row.kind == rowToolResult && isPlanCommandTool(msg.row.tool) { + var sweep tea.Cmd + m, sweep = m.maybeGitSweep() + return m, sweep + } return m, nil case swarmSessionsMsg: // Merge completed swarm members' session ids so their AGENTS sidebar rows @@ -1993,6 +2020,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { case prStateMsg: m.prState = msg.state return m, nil + case gitSweepMsg: + return m.handleGitSweepMsg(msg), nil case prWatcherStartedMsg: if msg.stop == nil { return m, nil