Commit Graph
87 Commits
Author SHA1 Message Date
euxaristia 8c6c49ad52 chore: fix golangci-lint findings (dead code, ineffassign, staticcheck) (#527)
- Remove unused safeRequestedPrefix, safeShellCommandTokens
  (internal/agent/command_prefix.go), and truncateHeadTail
  (internal/tools/bash.go): each was a thin wrapper with no remaining
  callers.
- Drop an ineffectual assignment in stringListArgWebSearch: every
  return path after it already sets the named return explicitly.
- Simplify a negated-and condition in
  partition_cache_stable_test.go via De Morgan's law.
- Use strings.Contains instead of strings.Index(...) < 0 in
  edit_replacers.go.
- Fix error string style (ST1005): lowercase "unsafe redirect URL"
  and drop the trailing period from webFetchPublicOnlyHint.

Skipped the errcheck findings on this pass (unchecked defer
Close()/Write() errors): those are stylistic, not correctness bugs,
and this repo's CI only runs go vet, not golangci-lint.
2026-07-06 08:09:32 +08:00
euxaristiaandClaude Fable 5 81aad58d97 fix(tools): Block MSYS coreutils under Windows sandbox (#476)
* fix(tools): block MSYS coreutils under Windows sandbox

Git for Windows usr\bin coreutils (head, grep, cat, ...) are MSYS PE binaries that fail with ACCESS_DENIED and cygheap/signal-pipe errors when Zero runs them in the write-restricted Windows sandbox. Detect and block these commands before execution, surface MSYS runtime failures from stderr, steer agents toward native Zero tools or require_escalated, and exclude MSYS-prone names from known-safe command tails.

Refs #458

* fix(tools): anchor the last MSYS failure heuristic to MSYS markers

CodeRabbit review feedback: the win32-error-5 plus terminating check
had no MSYS-specific anchor, so unrelated access-denied failures could
be mislabeled as windows_msys_sandbox. Require an MSYS marker like the
neighboring checks do.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(tools): let escalation bypass the MSYS guard and make detection quote-aware

Addresses jatmn's review on PR #476:
- bash and exec_command called detectShellCommandIssue before resolving
  sandbox_permissions into a command engine, so a require_escalated retry of
  a blocked command was still hard-blocked by the same preflight check.
  Resolve the command engine first and only bypass the windows_msys_sandbox
  issue kind when that resolves to true unsandboxed execution; other issue
  kinds (real cmd.exe syntax problems) still block regardless of escalation.
- Unify all MSYS-prone command name checks (MsysProneCommandName, the
  preflight scan, and transitively command_prefix.go's known-safe guard)
  behind one windowsMsysProneNames set. This fixes expr (in the name set but
  missing from the old regex alternations, so never proactively flagged) and
  ls (hit the older windows_shell_syntax branch first, so it got different
  metadata than every other MSYS-prone name).
- Make the preflight scan quote-aware: split the command into cmd.exe-
  operator-separated segments respecting double-quote grouping, and only
  check the first word of each segment instead of scanning raw text anywhere.
  Fixes false positives on quoted text like commit messages or PR comment
  bodies that merely mention a coreutil name.

* test(tools): decouple MSYS-guard bypass tests from real command output

TestBashToolRequireEscalatedMsysGuard and
TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval asserted
shell_issue == "" to prove the require_escalated bypass worked. Once
bypassed, "cat somefile.txt" actually runs unsandboxed, and its real,
PATH-dependent output could trip the unrelated post-execution
detectShellOutputIssue heuristic, failing the assertion for reasons
unrelated to the preflight guard under test.

Assert on the exit_code "-1" sentinel (set only by shellIssueBlockResult)
instead, which directly reflects whether the preflight guard blocked the
command.

* fix(tools): anchor MSYS path detection to command position, fix prefix approval gap

Address jatmn's latest review on PR #476:

- shell_runtime.go: the explicit MSYS binary-path check (usr\bin\,
  mingw64\bin\, msys-2.0.dll, cygwin1.dll) ran against the raw command
  string, so a quoted mention (e.g. a commit message describing a
  usr\bin\head.exe failure) was still hard-blocked. It now runs against
  the first word of each operator-separated segment, same as the
  coreutil-name check. windowsCommandSegments also stopped treating ;
  as a command separator (cmd.exe does not split on ;, unlike bash) and
  now respects cmd.exe's ^ escape character, so escaped operators like
  echo ^| head are no longer misread as a real head invocation.

- command_prefix.go: proposedCommandPrefix could offer to approve a
  single segment (e.g. "ps aux" in "ps aux | head -5") without checking
  that every other segment was independently safe. Since approving a
  prefix escalates the whole command to bypass the sandbox, an
  MSYS-prone or otherwise unsafe segment could ride along uncovered.
  It now only proposes a segment as the prefix when every other
  segment in the command is known-safe on its own.

Added regression tests for quoted MSYS path mentions, caret-escaped
operators, semicolon text, and the prefix-approval gap.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(tools): treat redirection as a command boundary, stop using command text as output evidence

Addresses jatmn's latest review on PR #476:
- firstCommandWord only split on whitespace, so cmd.exe redirection
  attached directly to a command name (head>out.txt, cat<in.txt,
  grep>matches.txt) was read as one word and never matched against the
  MSYS-prone name set. It now also stops at < and >.
- detectShellOutputIssue scanned command+output together, so a command
  whose own argument text merely quoted an MSYS failure string (e.g. a
  gh pr comment --body describing the bug) could be mislabeled after
  running even though the real stdout/stderr was unrelated. Removed the
  command parameter entirely from detectShellOutputIssue and both call
  sites (bash.go, exec_command.go), so the command line can never be
  used as post-execution evidence again.

* fix(tools): remove unused truncateHeadTail wrapper

CodeRabbit nitpick: truncateHeadTailWithTotal is called directly
everywhere, so this wrapper is dead code that trips golangci-lint's
unused check and fails CI.

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-06 08:03:32 +08:00
Kevin Codex 95ccd5bc32 perf: universal tool-output ceiling with spill + async post-edit diagnostics (#518)
* feat(tools): universal token-based output ceiling with spill-to-disk

Cap every tool result at the registry boundary (16k estimated tokens,
ZERO_TOOL_OUTPUT_CEILING_TOKENS-tunable, 0 disables) unless the tool
manages its own deliberate budget. Head+tail truncation, full output
spilled to the existing redacted spill dir and re-readable via
grep/read_file, so nothing is lost — only deferred.

This closes the three unbounded paths a single call could flood the
context window through (and then re-bill every following turn):
- web_fetch: up to 256KiB default / 2MiB max of non-HTML body
- skill: full SKILL.md returned uncapped
- MCP-served tools: no budget at all

Self-budgeting tools opt out via an unexported marker interface (an MCP
server can never exempt itself): bash, exec_command (model-raisable),
read_file, read_minified_file, grep, glob, list_directory. Browser
snapshots deliberately stay under the net.

bash itself is tightened to match the unified token model: emit budget
96KiB -> 32KiB per stream (~24k -> ~8k tokens worst case per stream),
while capture retention stays at 96KiB head+tail so the new spill file
covers 6x more of the output than the transcript shows.

* perf(agent): collect post-edit LSP diagnostics off the tool-call path

Inline post-edit diagnostics blocked every edit_file/write_file result on
the language server: a >=300ms quiet-period debounce that reset on each
publish, seconds on the first edit per language, capped at 10s — the
largest recurring latency tax in an edit-heavy session.

Diagnostics are now collected in the background: a file is enqueued the
moment a mutating tool finishes, a single worker checks it while the rest
of the batch executes, and the loop drains completed results just before
building the NEXT provider request, appending errors as a user nudge.
The model always issues another request after an edit turn (to read its
tool results), so it still sees introduced errors at the same decision
point — no tool call ever stalls on the language server.

A file re-edited before its check runs is checked once in its final
state (the checker re-reads the file); re-edited after, it is re-queued
and the newer result wins. A drain that finds the worker still busy
gives up after 3s and delivers on a later turn — results stay accurate
because any further edit re-enqueues the file.

The tools-side inline mechanism (tools.RunOptions.Diagnostics) is kept
for direct API callers; the loop just no longer wires it.

* fix: address review — max-turns diagnostics drain, capture-gap marker, test hardening

- Drain background diagnostics before the max-turns final-answer request
  so an error introduced by the LAST turn's edit is reported in the
  summary instead of silently dropped (+ regression test).
- spillBashStreams marks the head/tail junction with a capture-gap
  marker when boundedBuffer dropped the middle of a stream, so a spilled
  log never reads as contiguous when it is not; junction position is
  asserted in the test.
- Tests override TMP/TEMP alongside TMPDIR (os.TempDir reads TMP/TEMP on
  Windows) via a shared setTestTempDir helper.
- TestBudgetBashCaptureReportsTrueTotal now asserts spill_path meta and
  spill file content; exemption-list test gains read_minified_file.

* fix(test): use a real workspace root in async diagnostics tests

filepath.IsAbs("/ws") is false on Windows (no drive letter), so the
collector test's workspace-absolute assertion failed on the windows
smoke job. Root the fake workspace at t.TempDir() — absolute on every
platform — and assert against it.

* fix: pattern-scrub spill files and gate finalization on pending diagnostics

Review follow-ups (Vasanthdev2004):

- Spill files now pass the pattern-based secret scanner (AWS keys,
  GitHub/Slack/OpenAI tokens, PEM blocks, JWTs) in addition to the
  configured-key scrub. A bash spill runs before formatBashOutput's
  scan, so without this a spilled file held in cleartext exactly the
  credentials the transcript redacts. Applied centrally in
  spillTruncatedOutput, covering bash, exec_command, and ceiling spills.

- Finalization diagnostics gate: a no-tool-call final answer now drains
  pending post-edit diagnostics with the full inline-era budget (10s,
  vs the 3s per-turn wait) before the run is accepted; introduced
  errors append the nudge and give the model one more turn instead of
  being silently dropped when no later turn exists. The max-turns
  summary path uses the same finalization budget. Free for runs that
  never edited — an idle collector returns immediately.
2026-07-05 23:50:31 +08:00
KRATOS 284a6c86cb Skills: deterministic invocation — /<skill-name>, searchable /skills picker, discovery-budget fix (#520)
* feat(skills): make skill invocation deterministic and discoverable

Skills were model-pulled only: the model had to notice a skill in a
640-byte system-prompt list (~6 skills visible; the rest collapsed into
'…and N more') and call the skill tool on its own. Users could not run a
skill at all — typing /<skill-name> reported 'unknown command'. The two
halves of the fix:

User invocation
- /<skill-name> [args] now runs an installed skill directly: resolved
  after builtins and user commands, the skill body is inlined as the
  prompt with the typed args appended, and submitted through the same
  launch path user commands use.
- /skills lists installed skills with their slash form, live from disk.
  Labels are honest: a skill shadowed by a builtin, alias, user command,
  or a case-colliding sibling is listed bare with a '(via skill tool)'
  note instead of advertising a dead /name.
- The slash palette suggests skills (labeled '(skill)') read through the
  same loader dispatch uses, so mid-session installs appear and shadowed
  or case-colliding names are never advertised.
- Skill and user-command invocations now apply the plain-prompt run-state
  guards: mid-run submissions queue the EXPANDED prompt (the queue flush
  submits literal text), compaction-in-flight warns, exiting drops.
- Skills resolve through a lazy plugin-aware loader (default dir merged
  with plugin skill roots — the same set the skill tool and the system
  prompt see), TTL-cached so palette keystrokes don't re-read SKILL.md
  bodies.

Model triggering
- Raise the skills-list budget 640 -> 4096 bytes: the list is the model's
  only discovery surface, and the old cap made skills past ~6 invisible —
  invocation reliability collapsed exactly for users invested in skills.
- Raise description truncation 100 -> 200 runes: the description carries
  the trigger conditions ('use when…'); cutting them is what stopped the
  model from firing the skill.
- Strengthen the instruction line: scan the list before acting, call the
  matching skill FIRST, don't substitute your own approach.

* feat(tui): /skills opens a searchable skill picker

Replace the static /skills card with the same picker UX /model uses:
type-to-filter (ranked search), arrow-key selection, Enter runs the
chosen skill immediately. Selection is by the skill's exact name, so
skills the slash path cannot reach — names shadowed by a builtin or
user command, or names that don't fit a slash token — are still
runnable from the picker, where the choice is unambiguous. The text
card remains only as the no-skills install hint, and the picker path
applies the same run-state guards as slash invocation.

* feat(tui): ask-first guard for bare skill invocations

A skill invoked with no request submits only its body — instructions
with no target ("review the PR" — which PR?) — and the model improvises
a target instead of asking, which is especially wrong in a fresh
session. Bare invocations (slash and picker) now append a conditional
note: if the instructions need a target or details not already clear
from the conversation, ask first; self-contained skills still just run.
Invocations that carry a request are unchanged.

* fix(tui): review follow-ups — restore typed invocation on compaction guard, cap merged suggestions

- The compaction-in-flight guard for skill/user-command invocations fired
  after the dispatch path had already cleared the composer, so the typed
  "/name args" was lost and had to be retyped; thread the raw invocation
  through and restore it into the composer (the plain-prompt guard keeps
  the composer intact by firing before the clear). The picker path has no
  typed form and restores nothing.
- Cap the merged autocomplete list at the shared bound: each source
  self-caps, but three sources could stack to ~3x the palette limit.
- Tighten the shadowed-skill palette test to assert on the advertised
  Name rather than the fallback Desc wording.

* chore: ignore zero-skills local build artifact

A locally built test binary under this name was accidentally committed
(and has been scrubbed from the branch history); ignore the name so it
cannot recur.
2026-07-05 21:14:53 +05:30
Kevin Codex 87e7e69afd feat(openai): forward prompt_cache_key for server-side prefix cache routing (#515)
* feat(openai): forward prompt_cache_key for server-side prefix cache routing

Send the session ID as OpenAI's prompt_cache_key on every completion
request so consecutive turns route to a backend replica that already
holds the conversation prefix in its prompt cache, instead of re-billing
the full ~11k-token system+tools prefix each turn on OpenAI-compatible
endpoints (which get no cache_control breakpoints, unlike Anthropic).

Keyless requests serialize byte-identically to before, and
ZERO_DISABLE_PROMPT_CACHE_KEY=1 suppresses the field for strict
endpoints that reject unknown parameters. The Codex (Responses API)
flavor is deliberately untouched: the ChatGPT backend is a separate,
stricter surface.

* fix(openai): parse prompt-cache-key kill switch as a boolean flag

ZERO_DISABLE_PROMPT_CACHE_KEY=0 or =false previously disabled forwarding
because any non-empty value tripped the check. Parse it the same way
ZERO_FORMAT_ON_WRITE parses boolean flags, so explicitly-falsy values
are a no-op and only truthy values flip the toggle.
2026-07-05 21:41:03 +08:00
Vasanth T f4c998ac30 feat(agent): curb over-engineering the solution in the editing discipline (#517)
The editing discipline already caps the diff size and scope, but a small diff can
still over-build the solution — speculative abstraction, unrequested
configurability, handling for cases that cannot occur. Add a guardrail: solve the
problem as posed, nothing the user did not ask for; if a 200-line solution could
be 50, rewrite it.

Validated with a before/after eval on five over-engineering-prone tasks: 3/5
solutions came out simpler with the guardrail, none more complex, none broke
correctness (avg 9 vs 11 non-blank lines); the baseline gold-plated on two of them
(an unrequested cache-introspection attribute and header-merge config). No effect
on trivial tasks, where the baseline is already minimal.

Closes #516
2026-07-05 21:38:42 +08:00
euxaristiaandClaude Sonnet 5 7b10aab74b feat(agent): inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt (#475)
* Add-user-ZERO-guidelines-to-system-prompt

* fix(agent): resolve user ZERO.md under config.UserConfigDir()/zero

Review feedback: every other user-level config (config.json, commands,
specialists) lives under config.UserConfigDir()/zero, so the per-user
guidelines file now resolves there instead of ~/.zero. The basename match
is case-insensitive to mirror the project guideline loader.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(agent): extract shared guideline truncation and lookup helpers

CodeRabbit review feedback: the rune-safe truncation block and the
case-insensitive basename scan were duplicated across the project and
user guideline loaders. Extract truncateGuidelineContent and
findCaseInsensitiveFile and use them in both paths.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(agent): keep truncated guideline content within the byte limit

CodeRabbit review feedback: truncateGuidelineContent appended the
truncation marker after slicing to the limit, so the result could
exceed the requested budget by the marker length. Reserve space for
the marker before choosing the cut point.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(agent): fix small-limit truncation, precedence, and test isolation for user guidelines

Address remaining review feedback on PR #475:

- truncateGuidelineContent still exceeded the limit when limit was
  smaller than the truncation marker itself. Fall back to a hard,
  rune-safe cut with no marker in that case so the result never
  exceeds the caller's budget.
- Reorder buildSystemPrompt so user guidelines are injected before
  workspace/project guidelines, and add an explicit precedence note
  in the user guidelines section text, so a repo's AGENTS.md/ZERO.md
  is the later, more specific instruction and wins on conflict.
- Add a TestMain in the agent package that stubs userConfigDirForPrompt
  to an empty temp directory by default, so buildSystemPrompt(Options{})
  calls in unrelated tests don't pick up a contributor's real
  ~/.config/zero/ZERO.md.
- Document the per-user ZERO.md location and its precedence relative
  to project guidelines in AGENTS.md.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-05 18:53:17 +08:00
Kevin Codex 3c81fea228 feat: agent quality, caching, retry, and tooling upgrades (#506)
* feat: agent quality, caching, retry, and tooling upgrades

Ten self-contained improvements to the core agent engine and tools:

- edit_file: fuzzy fallback matching (7-strategy cascade with uniqueness
  protection and a disproportion guard) when old_string does not match
  byte-for-byte; exact match remains the fast path
- edit_file/write_file: inline error-severity LSP diagnostics in tool
  output so the model sees a break it introduced in the same turn
- lsp: expand server registry from 4 to 28 languages; doctor grades only
  tier-1 servers and lists the long tail informationally
- agent: consecutive read-only tool calls execute concurrently (bounded
  at 8), consumed in original order, never spanning a mutating call
- anthropic: cache_control breakpoints on the last two conversation
  messages so the transcript prefix stays a prompt-cache hit
- providerio: retry schedule raised to 6 attempts with exponential
  2s-30s backoff when the server sends no Retry-After
- tools: truncated exec/bash output spills to a redacted temp file
  referenced in the truncation notice; bash output (previously
  unbounded) now shares exec_command's token budget
- web_fetch: dependency-free HTML-to-markdown conversion (format:
  auto|raw|markdown) and larger limits (256KiB default, 2MiB max, 30s)
- modelregistry: live models.dev overlay refreshes context limits and
  base pricing from a TTL-cached fetch; opt-in at the CLI entrypoint
- tools: opt-in format-on-write (ZERO_FORMAT_ON_WRITE=1) runs standard
  formatters across 27 extensions, ordered before the file-tracker
  re-baseline so conflict detection is preserved

* fix: address PR review feedback on fuzzy edits, diagnostics, spill

- edit_file: adapt the replacement to the fuzzy-resolved span — when the
  span is a uniformly deeper-indented copy of old_string (line-trimmed /
  indentation-flexible matches), the same indentation delta is applied to
  new_string, and a span ending in a bare CR keeps its CR so CRLF files
  never gain a mixed line ending; block-anchor matches with drifted
  interiors are deliberately left untouched
- diagnostics: render workspace-relative paths in inline post-edit
  diagnostics (base name outside the workspace) so absolute paths never
  leak the local username or directory layout into the model prompt or
  session transcript
- spill: harden the spill directory for shared temp dirs — per-uid name,
  reject symlinked or non-directory paths (MkdirAll follows symlinks),
  and on Unix require current-user ownership
- bash: the branch's output cap and truncation-flag threading are
  superseded by main's capture-time bounding (boundedBuffer +
  budgetBashCapture), which already sets Result.Truncated; this rebase
  adopts that design and keeps spill-to-disk on exec_command, where the
  full output is still buffered and a spill is genuinely complete

* fix: address review round 2 — permission-event race, fuzzy ambiguity, spill reachability

- agent: serialize OnPermission behind the same mutex as
  OnPermissionRequest in the parallel read batch. buildPermissionEvent
  fires for batched reads with a non-trivial sandbox decision (e.g. a
  grant-matched extra root), and front-end handlers append to shared
  session-recording state without their own locking — two batched reads
  emitting concurrently was a data race the pre-batch serial loop never
  allowed.
- edit_file: reject two or more DISTINCT candidate spans from one fuzzy
  strategy as ambiguous instead of silently editing the first. Duplicate
  blocks at different indentation each resolve to a unique literal, so
  the previous occurrence-uniqueness check passed and wrote the wrong
  span. A single candidate occurring at multiple positions still
  cascades to stricter strategies as before.
- tools: make spill files actually reachable by the recovery path the
  truncation notice advertises — resolveScopedReadPath now recognizes
  files inside the per-uid spill dir (containment verified after symlink
  resolution, so a planted link cannot smuggle an out-of-scope file
  through the gate); read_file/grep can follow the notice instead of
  re-running the command.

* fix: surface competing block-anchor spans; make write-barrier test observe cross-probe order

- edit_file: blockAnchorReplacer now returns every span clearing the
  similarity threshold (best first) instead of silently picking the
  single most-similar block. Hiding competitors from the cascade's
  distinct-candidate check could edit the wrong block when two blocks
  share anchors and both interiors are plausible; one clear winner still
  resolves, two plausible blocks now report ambiguity (covered by a
  two-block test that asserts the file is untouched).
- agent tests: the parallel write-barrier test now records both probes
  into one mutex-guarded shared log and asserts the cross-probe ordering
  it always claimed to (write starts after the first read batch ends;
  second read batch starts after the write ends). The previous version
  only compared indices within the read probe's own log, which a
  regression letting the write overlap a batch could still pass; the
  stray no-op mutex assignment is gone.
2026-07-05 17:25:26 +08:00
Ashwinhegde19andAshwin f6c060631e fix(openai): handle Ollama reasoning stream deltas (#486)
* fix(openai): handle reasoning alias in stream deltas

* test(cli): assert reasoning event order

---------

Co-authored-by: Ashwin <[email protected]>
2026-07-05 17:05:54 +08:00
Anandan afb5010a46 polish v2: smoothness & delight (#484)
* feat(tui): actionable hints on provider/model errors

Classify a failed turn's provider error into auth / rate-limit /
connectivity / model-not-found / context-overflow and render a one-line
next step below the red error row, instead of dumping an identical raw
blob for every failure mode.

The classifier lives in a new internal/errhint package so both the TUI
error row and the CLI exec path can share it (TUIHint references slash
commands, CLIHint references zero subcommands). Classification is a
conservative string heuristic keyed off providerio.ClassifiedError's
prefixes plus lower-level DNS/TLS/timeout/context-length signatures,
since the numeric HTTP status is gone by the time the error reaches a UI
surface.

* feat(tools): budget bash output with head+tail truncation

bash was the one tool that returned command output with no byte cap —
'cat large.log' or a verbose test run could dump megabytes into context
and force compaction, while every other read/search tool already applies
a budget.

Cap stdout and stderr at 96KB each, keeping the head and tail of an
oversized stream (build/test failures usually land at the tail) and
dropping the middle behind a marker that points at redirect-to-file +
read_file. Record raw_bytes/emitted_bytes/estimated_tokens/truncated in
Meta like the other tools. Sandbox-denial and shell-issue detection still
run on the full raw output before budgeting.

* fix(gemini): retry 401 with refreshed OAuth token

The Gemini provider used providerio.SendWithRetry (429/503/529 only),
so an expired OAuth token surfaced as a terminal 'API key not valid'
auth error while OpenAI and Anthropic silently recovered via
SendWithAuthRetry's force-refresh-and-replay on 401.

Plumb OAuthResolver through gemini.Options/Provider and the factory
(exactly as the other two providers already do) and switch the stream
send to SendWithAuthRetry. With a nil resolver (API-key users) behavior
is unchanged.

* fix(errhint): gate classification on provider-origin marker

Address CodeRabbit review on #1: agentResponseMsg.err can also carry
local failures (a tool's 'permission denied', a 'file does not exist',
a config error), and broad substrings would have attached a bogus
/provider or /model hint to those.

Classify now returns Unknown unless the message carries a provider marker
the provider layer always attaches (auth error: / rate limit error: /
provider error: / provider request error: / provider stream error:).
Local errors never draw a provider hint; provider errors sub-classify as
before. Added local-failure test cases proving they stay Unknown.

* feat(cli): actionable hint on text-mode exec provider errors

zero exec printed provider failures as a bare '[zero] <raw error>' with
no next step. Append a one-line hint (reusing the shared errhint
classifier) for recognized provider failures — 'run `zero auth`',
'run `zero doctor`', etc. The provider-origin gate means non-provider
codes (sandbox_error, mcp_error) and JSON/stream-json output are
untouched.

* fix(tools): set Result.Truncated for budgeted bash output

Address CodeRabbit review on #3: budgetBashOutput recorded truncation in
meta["truncated"] but never set the Result.Truncated struct field, so
consumers reading the struct (like glob/web_fetch/read_minified do) would
miss truncated bash results. Return the bool from budgetBashOutput and
set Result.Truncated on every return path.

* perf(tui): reuse one LSP manager across prompts

runAgentWithOptions built a fresh lsp.NewManager per run and shut it down
when the run returned, so gopls (and every other language server)
cold-started on the first edit of every turn — 200ms-2s of latency the
manager's own design (long-lived, reused servers) exists to avoid.

Build one manager per session in newModel (cheap: servers start lazily on
first Check) and reuse it across runs; the SelfCorrector still gets a
fresh checker wrapping the shared manager each run. Torn down with a
short deadline in quit(). Runs fall back to a per-run manager when the
session manager is absent (cwd unknown, or a directly-built test model).

* perf(tui): accumulate streamingText as []byte (O(1) append)

m.streamingText += msg.delta was O(len) per delta -> O(n²) across a long
generation (a 10k-token code gen allocates hundreds of MB of intermediate
strings and the UI gets progressively laggier). Accumulate into a []byte
with append (O(1) amortized) and read via streamingTextString().

A []byte rather than strings.Builder because the TUI model is copied by
value on every Update, which would trip strings.Builder's copy check;
[]byte is nil-safe and copies cleanly like the other slice fields.

* perf(tui): coalesce streamed text deltas to one frame

Every OnText delta was its own tea.Msg, so a fast provider (100+ tok/s)
drove 100+ full Update->View cycles per second, each re-parsing the
growing markdown — visible stutter over SSH and wasted CPU.

Batch agentTextMsg deltas at the runtime sink over a ~16ms frame and
forward them as a single message, decoupling render rate from token rate.
Any non-text message flushes pending text first so ordering with
tool-call / reasoning / row messages is preserved; the final
agentResponseMsg (a tea.Cmd return, not a sink message) is safe because
the model already drops deltas for an inactive runID.

* fix(agent): raise tool-failure stop threshold 4->6

guardrails.go halted a run after 4 consecutive same-error tool failures.
A corrective hint fires at 2, and a model iterating on a genuinely tricky
edit can legitimately fail a couple more times after the hint while
converging — stopping at 4 cut those runs short. Raise to 6; the streak
still resets the instant the tool succeeds or hits a different error, so
only true same-error loops are affected.

* fix(agent): more reconnect retries with jitter; narrow to transport errors

#19: bump maxStreamReconnects 2->4 and add up-to-50% jitter on the
exponential backoff (capped at 8s), so a multi-second network blip is
ridden out instead of killing the run on a 2s hiccup, and concurrent runs
(swarms, cron fleets) don't reconnect in lockstep.

#34: drop the '502'/'503' substring matches from shouldReconnect. 503
already exhausted providerio.SendWithRetry (retrying here is a redundant
double-retry) and 502 is non-idempotent by providerio's rule (the POST
may have been processed). Only genuine transport failures — where no
response was received — reconnect now.

* fix(agent): reduce stall retries 2->1 to bound stuck-session time

A no-output stream stall is detected only after the full stream idle
timeout (~5min) elapses, and each retry can idle again — so 2 retries
left an interactive session frozen for ~15min. Drop to 1 retry: keeps the
common single-hiccup recovery while bounding the worst case to ~2x the
idle timeout.

* fix(tui): serialize coalescer forwarding to preserve message order

Address CodeRabbit review on #7: flush() and the run-switch path drained
the text buffer under c.mu but called forward() after releasing it, so a
timer-fired text flush could race a concurrent non-text send() and land
after the tool-call/reasoning message it should precede.

Hold c.mu across drain AND forward (drainAndForwardLocked), so whoever
holds the lock delivers atomically and the other caller blocks until it
is done — text can never overtake a following non-text message. Added a
concurrency stress test (timer racing an inline send) that passes under
-race.

* feat(tui): accept /compact now

Bare /compact already triggers compaction, but /compact now — what users
reach for when the context gauge climbs — hit the usage-error branch.
Accept the 'now' keyword and advertise it in the command usage.

* feat(tui): add /retry, /edit, /copy, /export commands

/retry resends the last prompt; /edit recalls it into the composer to
tweak and resend — both read a new lastPrompt field captured verbatim
(pre-expansion) in launchPrompt. /copy puts the last answer on the
clipboard via the existing copy machinery; /export writes a plain
role-prefixed transcript to a file (timestamped default, or a given
path). Real gaps for SSH users with no mouse select.

* feat(tools): separator-insensitive tool_search matching

tool_search ranked deferred tools by exact substring, so a model that
typed 'webfetch' (dropping the underscore) matched nothing and looped.
Add a separator-squashing fallback ('web_fetch' -> 'webfetch') scored
below an exact substring match, so precise queries still rank first.

* feat(tools): signal ask_user dismissal distinctly from a blank field

Empty answers all rendered as '(no answer provided)', so the model
couldn't tell a wholesale dismissal (user closed the prompt without
answering anything) from one field left blank amid real answers — and
might invent a default. FormatAskUserAnswers now flags a full dismissal
up front as a skip and marks individual empties '(left blank)' vs
'(skipped)'.

* feat(tui): fuzzy ranking in model/session pickers

The pickers filtered by flat strings.Contains with no ranking, so
finding 'sonnet 4.5' among 50+ models meant an exact prefix or scrolling.
Rank matches (exact < prefix < contains < subsequence, reusing
fuzzySubsequenceGap) so the closest match lands on top, and 'snt45' now
matches 'Sonnet 4.5'. Groups stay contiguous — ordered by their best
match, never split — so the grouped model picker still renders one header
per provider. Covers both /model and /resume (shared commandPicker).

* perf(agent): cache per-tool schema render across turns

partitionTools re-ran the recursive schema->map conversion
(schemaToRuntimeMap) for every tool on every turn — redundant, since a
tool's advertised name/description/schema is stable for the run. Add a
per-run definition cache keyed by tool name (nil-safe; the plain
partitionTools entrypoint and tests pass nil for fresh renders).

The partitioning itself (visibility, deferral, ordering) still recomputes
every turn — it must, because a tool's deferred state can flip mid-run
(swarm tools un-defer once a swarm is active). Only the expensive schema
render is memoized. tool_search is excluded by its callers (dynamic
description) so it never poisons the cache.

* perf(agent): calibrate compaction estimate against real prompt tokens

The byte/4 heuristic (ApproxTextTokens) over-counts code-heavy content
~15-20%, so with triggerRatio=0.7 compaction fired at ~60% of true
capacity — premature summarizer calls that degrade quality. Each turn now
folds (rawEstimate, provider InputTokens) into an EMA calibration ratio,
clamped per-sample to a sane band; maybeCompact and the reactive path
scale their estimates by it. Turn 1 uses the raw estimate (no data yet);
later turns compact near real capacity.

* style: gofmt the new agent test files

The CI gofmt check flagged the #22/#25 test files (struct-field and
switch alignment). No behavior change.

* feat(agent): turn-based plan staleness reminder

The stale-plan nudge only fired after 10 tool calls since the last
update_plan, so a plan that drifts stale across many low-tool-call turns
(one call per turn) took many turns to catch — the 'plan set turn 1 stays
pending forever' case. Add a turn-count complement: after
stalePlanTurnThreshold (8) turns without an update AND with items still
pending, fire the same one-shot reminder. Gated on pending items so a
fully-completed plan is never nagged; the existing tool-call trigger is
unchanged. (The roadmap's fuzzy 'tool calls unrelated to plan items'
relevance match is deliberately omitted — too false-positive-prone.)

* feat(agent): preserve recent-edits summary across compaction

Compaction preserved the plan, loaded skills, tool schemas, and project
instructions, but not file edits — so after the editing turns were elided
the model knew it had changed a file only vaguely, and would re-read to
rediscover its own footprint. Extract write_file/edit_file targets from
the elided middle with a one-line note from each tool result, merge them
across repeated compactions (newer note per path wins), and carry them in
the preserved-state JSON (capped at 20 paths / 160 bytes per note).

* feat(tui): workspace-scoped /resume + fewer event reads

/resume listed sessions from every project globally, and read the full
event file for each of them to test for resumable content. Filter the
picker (and /resume latest) by the current workspace Cwd, checked BEFORE
the per-session read so a large global history no longer pays N full file
reads to build one workspace's list; also skip zero-event sessions via
metadata without any read. Sessions with no recorded Cwd (older runs) and
an unknown current workspace stay visible so nothing is hidden that can't
be confidently placed. Explicit /resume <id> still resolves any session.

* feat(tui): warn on model switch that drops staged images

Staged images were silently dropped at submit when the active model had
no vision support — the user only learned after sending. Chips already
render above the composer (renderAttachmentChips); add an immediate amber
warning line to the /model switch status (both same-provider and
cross-provider paths) when images are staged and the newly selected model
can't accept them, so the drop is surfaced at switch time.

* feat(tui): inline recovery affordance on setup errors

A first-run setup error rendered as a bare red 'error: <text>' with no
pointer to the fix. Append a faint, stage-tailored recovery line (edit
the endpoint/name/key above then Enter, or pick another model) that names
only keys the stage's footer confirms, so the guidance is always accurate.
Empty for stages whose footer already makes recovery obvious.

* fix: address CodeRabbit review (5 findings)

- loop.go: recompute the compaction calibration estimate at calibration
  time, not request-build time, so a reactive-compaction reissue that
  shrinks the request feeds the EMA the sample that actually completed.
- errhint: match bare HTTP status codes (401/403/429/529) only at digit
  boundaries, so an incidental number ('4290ms', 'id 14015') isn't
  mis-bucketed as auth/rate-limit.
- bash: drop the stray unmatched ']' in the output-truncation marker.
- tui /retry: apply the same exiting/compactInFlight guards commandPrompt
  has, so a retry can't race compactResultMsg's rewrite of session state.
- tui /export: write the transcript 0o600 (it may contain echoed
  secrets), not world/group-readable 0o644.

* test(tui): skip export file-mode assertion on Windows

os.WriteFile ignores Unix permission bits on Windows and Stat reports
0666, so the 0o600 assertion failed there. The production 0o600 is
honored on Linux/macOS (where it matters); guard the check with
runtime.GOOS so Windows smoke passes.

* fix(tui): align /resume latest filters + case-insensitive workspace match on Windows

Address CodeRabbit review on #21:
- latestResumableInWorkspace now applies the SAME filters as the picker
  (skip zero-event metadata and empty/failed runs), so /resume latest
  can't land on a session the picker intentionally hides.
- sessionMatchesWorkspace compares paths case-insensitively on Windows
  (its filesystem is), so the same workspace spelled with different casing
  no longer hides resumable sessions; other platforms stay case-sensitive.

* fix(tui): /retry resends the last prompt's image/PDF attachments

/retry relaunched only lastPrompt's text, but launchPrompt clears the
pending image/document queues once a turn is sent. A vision- or PDF-backed
prompt that failed would silently retry as text-only and answer a different
task. Snapshot the consumed attachments at launch and re-stage them on
/retry so launchPrompt rebuilds an identical request (document preamble,
images, and the submit-time vision re-check). startNewSession drops the
snapshot so a post-/new /retry cannot leak the previous session's
attachments. Also correct the OAuthResolver factory comment, which omitted
the Gemini provider it is now passed to.

* fix: address jatmn review (reconnect 5xx, /edit attachments, edit-cap, coalescer test)

- reconnect: exclude HTTP 5xx (500/502/503/504) before the transport
  substring match so "504 Gateway Timeout" no longer slips through on the
  generic "timeout" needle. A gateway timeout is non-idempotent — the
  completion POST may already have reached the model — so replaying the
  connect risked duplicate billable work. Reuses a digit-boundary status
  matcher exported from errhint (HasStatusCode). Test asserts 504/500 stay
  non-reconnectable.

- tui /edit: re-stage the remembered image/PDF snapshot alongside the
  recalled prompt text, mirroring the /retry fix — editing a vision- or
  document-backed prompt no longer silently resends a text-only version.

- compaction recent-edits: order edits by LAST edit, not first, and move a
  re-touched path to the newest position on merge, so the maxRecentEdits
  tail cap keeps the file the model most recently edited instead of dropping
  it. Adds mergeRecentEdits (edit-specific) and a capped-list regression.

- coalescer test: inject the frame timer via afterFunc so
  TestCoalescerBatchesDeltas proves buffering deterministically instead of
  racing the real 16ms wall-clock timer.

* fix(tools): bound bash output capture in memory, not just model-visible text

Previously stdout/stderr streamed into unbounded bytes.Buffers and were only
truncated to the 96 KiB budget AFTER the command finished, so a runaway command
(cat huge.log, yes) could grow Zero's memory until it stalled or OOMed before
budgetBashOutput ever ran. Replace the capture with boundedBuffer, an io.Writer
that keeps only the head+tail each stream will surface and discards the middle as
it arrives, while counting the true total for the truncation marker. Memory is now
bounded to ~head+2×tail per stream regardless of output size.

truncateHeadTail is refactored onto a total-aware core so the string path (and its
tests) are unchanged, and budgetBashCapture reports the true raw_bytes even though
only a bounded slice was held. Adds boundedBuffer/capture unit tests and an
end-to-end test that streams ~5× the budget and asserts bounded emission with an
accurate raw_bytes.
2026-07-05 13:15:14 +05:30
Anandan f401c66e81 polish: cut per-turn overhead, stabilize cache, add /new (#478)
* polish: cut per-turn overhead, stabilize cache, add /new

Context economy
- Compaction: preserveLast 8->6, triggerRatio 0.8->0.7 (both copies in sync).
- System prompt: kill the "ELABORATE summary" directive; scale summary to
  the work. Comment discipline moved into the core prompt for all model
  families (was Anthropic-only); Minimax/DeepSeek/Qwen/etc. now get it.
- Token-budget regression test ratchets the fixed per-turn overhead (base
  prompt + eager tool schemas) using the runtime's own estimator, so silent
  prompt/schema growth fails CI.

Response quality
- defaultResponseStyle balanced->concise. Reversible via /style.

Tool discovery
- <available_skills> block in the system prompt lists installed skills
  (name + one-line description) so the model loads the right one on the
  first call instead of guessing and reading the failure. Budget-capped at
  640 bytes with an "...and N more" fallback; renders nothing when no
  skills are installed (byte-identical to prior prompt).

Session lifecycle
- /new starts a fresh session in place; previous session stays on disk and
  is named in the confirmation for /resume. Guards against mid-run reset.
- /clear now notes that context persists and points to /new.

Performance & caching
- partitionTools no longer alpha-sorts the whole tools array every turn,
  which inserted a loaded deferred tool mid-array and shifted the cached
  prefix on every load. It now builds append-only regions
  [eager sorted] + [tool_search] + [loaded deferred sorted] so a
  mid-session load grows the tail instead of busting the eager prefix.
  Regression test asserts the eager block precedes any loaded tool.
- /context now shows cache hit rate (flags low rate over >5 turns as a
  possible unstable prefix) and rolling average turn latency with
  time-to-first-token. Both reset on /new.

Measured baselines (auto-mode, no workspace): base prompt 3162 tokens,
eager tool schemas 2433 across 12 tools. Budget ceilings set ~10% above.

Deliberately deferred (need a decision): inverted safety-policy rewrite
(sandbox doesn't catch ~half the B-tier rules), core-tool/web_fetch
deferral (activation-semantics blocker), aggressive prompt slim.

* fix(tui): clear staged attachments + queued message on /new

CodeRabbit: startNewSession reset conversation/usage/latency state but not
the staged input (pendingImages, pendingImageLabels, pendingDocuments,
queuedMessage), which is only consumed at prompt-submit. Without clearing it,
the fresh session's first prompt silently inherited the previous session's
images/documents/queued text. Test now stages all four and asserts they clear.
2026-07-04 20:56:52 +08:00
euxaristiaandClaude Fable 5 e4f760ee8b fix(agent): reject a malformed additional_permissions payload before prompting (#453)
* fix(agent): reject a malformed additional_permissions payload before prompting

A shell tool call that sets sandbox_permissions: with_additional_permissions
but omits (or malforms) the additional_permissions payload was accepted into
the normal permission-prompt flow. buildPermissionEvent silently swallowed
the validation error while rendering the prompt's scope text, so the user
saw an ordinary-looking request, approved it, and only then hit a hard
denial from the same validation running again at grant time (loop.go's
grantInlineAdditionalPermissions), with the confusing message "failed to
grant session additional permissions: missing additional_permissions".

A malformed payload can never succeed regardless of the user's decision, so
run the same check (inlineAdditionalPermissionsProfile, already the single
source of truth for this shape) up front, before any prompt, and return a
plain tool error the driving model can see and retry from. This also covers
the mismatched case (additional_permissions present without the flag).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(agent): make the additional_permissions rejection message actionable

The rejection added in the previous commit correctly stops a malformed
sandbox_permissions: with_additional_permissions call before any prompt,
but its message ("missing additional_permissions; provide at least one of
network or file_system") gave a model nothing concrete to correct itself
with. Observed in practice: a weaker tool-calling model (xai/grok-build-0.1)
kept re-attaching sandbox_permissions out of habit from an earlier, correct
gh/git call onto later commands that didn't need it (network was already
open via a session-scoped grant), hit this same rejection 4 times in a row
with no change in its retry, and gave up.

Both messages (missing payload, and empty-but-present payload) now include
a concrete, schema-accurate example - {"network": {"enabled": true}} or
{"file_system": {"write": ["/path"]}} - and the missing-payload message
adds the other half of the fix: if the command doesn't actually need
elevated permissions, omit sandbox_permissions entirely and retry.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-04 08:51:14 +08:00
Leonardo Faoro 4266baf222 fix(docs): rename AGENTS.MD > AGENTS.md (#438)
* fix(docs): rename AGENTS.MD > AGENTS.md

* fix(docs): fix references to AGENTS.MD > AGENTS.md
2026-07-03 19:40:50 +08:00
KRATOS 952788f72d fix(tui): resolve every permission request so the agent can't deadlock (#397)
A goroutine dump of a run "stuck for 33 minutes" showed the agent parked
forever in executeToolCall -> requestPermission, blocked on the TUI's
decision channel. Root cause: a shell command whose sandbox decision was
Allow but which requested additional sandbox permissions
(sandbox_permissions: with_additional_permissions) made shouldRequestPermission
block on OnPermissionRequest, yet buildPermissionEvent stamped the event
Action=allow. The TUI's permissionRequestMsg handler only rendered/resolved
Action=prompt requests and silently dropped everything else — so the decide
callback was never called, the agent waited on a channel nothing would signal
(no ctx cancellation on the active run), and the UI showed "Working" with no
prompt and zero network activity. Intermittent and model-dependent: gpt-5.5's
folder-creation command set the additional-permissions flag, glm's did not,
which is exactly why one hung and the other ran clean.

Two-part fix:

- agent (buildPermissionEvent): an additional-permissions request is an
  ELEVATION the user must consent to, even over a sandbox Allow, so mark it
  Action=prompt (matching shouldRequestPermission's own condition). It now
  surfaces as a real prompt instead of a dropped auto-allow.

- tui (permissionRequestMsg handler): enforce the contract that a blocked
  agent ALWAYS gets a decision. A non-prompt action resolves immediately and
  FAILS CLOSED (deny; cancel stays cancel) so the UI can never silently grant
  an unsurfaced permission; a superseded-run (runID mismatch) resolves as
  cancel so its parked goroutine unblocks without depending on ctx-cancel
  ordering. Matches the ACP handler's always-resolve, fail-closed contract.

Red-green: reverting the tui branch reproduces the deadlock (decide never
called); reverting the agent override reproduces Action=allow. End-to-end
against a mock backend emitting the exact triggering tool call, the real TUI
now renders the permission prompt and the run completes on approval, where
before it parked forever.
2026-07-03 06:02:57 +08:00
Gautam ManchandaniandGautam Manchandani 960db9660e fix(tools): require permission before web_search requests (#382)
* fix(tools): gate web_search behind network permission

* chore(tools): clarify web_search permission reason

---------

Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-02 22:14:58 +08:00
KRATOS 02f0c0929b fix: cut the content-stall dead-wait and auto-retry a mid-tool-call stall (#362)
* fix: cut the content-stall dead-wait and auto-retry a mid-tool-call stall

Root-caused (via a verified multi-agent audit) the chatgpt/gpt-5.5 "stuck"
reports: the ChatGPT Codex backend pauses real generation mid-stream on
large single tool-call outputs (a ~1000-line write_file) while continuing
SSE keep-alive heartbeats. That is an upstream limitation, not a Zero bug
or a connection-pooling issue (the darwin DisableKeepAlives fix is applied
and confirmed not the cause). Zero already detected it, but two UX gaps
made it feel like a hang:

1. Faster abort. The content-stall watchdog fired at idle*2 = 10 minutes.
   A heartbeat-pause stall on gpt-5.x rarely recovers, so 10 minutes of a
   frozen token counter was a terrible wait for a doomed turn. Replaced the
   StreamContentStallFactor=2 const with ContentStallTimeout(idle)=idle*1.2
   (6 minutes at the default). It still only ever fires on a TRUE zero-output
   freeze — any real data line resets it — so healthy slow-reasoning runs are
   unaffected. Scales with ZERO_STREAM_IDLE_TIMEOUT; returns 0 (disabled)
   when idle is disabled.

2. Auto-retry the actual stall. The existing stall-retry only fired when
   NOTHING had been forwarded — so the common case (model streamed transient
   reasoning + began the big write_file, then froze mid-arguments) was NOT
   retried, because the incomplete tool call is collected and transient
   output was forwarded. Relaxed the gate to retry when no visible PROSE was
   shown (forwardedVisibleText, marked only by OnText) and no final text was
   collected — dropping the len(ToolCalls)==0 exclusion, since an incomplete
   tool call from a timed-out stream is never executed (the turn returns on
   the error before dispatch) and never appended to messages, so the retry
   re-sends clean context with no duplication. A visible
   "[no output — model stalled; retrying N/max]" notice fires per attempt.
   Forwarded prose still blocks the retry (it would duplicate answer text).

Verified: full -race suite green; a 4-dimension root-cause audit and a
3-dimension adversarial review of this diff both passed with no confirmed
defects. New tests: TestRunRetriesStallAfterIncompleteToolCall (the exact
gpt-5.x case), TestRunRetriesStallAfterOnlyReasoning; preserved
TestRunDoesNotRetryStallAfterPartialOutput (prose still blocks retry).

* fix(providerio): make ContentStallTimeout overflow-safe

Address PR #362 review: idleTimeout*6/5 could overflow int64 for a
pathologically large ZERO_STREAM_IDLE_TIMEOUT and wrap to a NEGATIVE
duration, which would arm the content-stall timer to fire immediately and
abort every stream. Compute 1.2× as idle + idle/5 with a clamp to the max
duration on overflow (which just means "effectively no content watchdog",
the sane result for an absurd idle timeout). The normal 1.2× values are
unchanged (5m->6m, 30s->36s, 100ms->120ms).

New test: TestContentStallTimeout covers the scaled values, the
zero/disabled case, and the MaxInt64 overflow guard.
2026-07-02 00:14:51 +05:30
KRATOS 7fb0092de7 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).
2026-07-01 09:15:08 +05:30
KRATOS fb3277f46e 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.
2026-06-27 17:41:48 +05:30
KRATOS 1d1fa7c514 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".
2026-06-27 14:12:41 +05:30
Anandan b8b0bb7f65 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
2026-06-27 13:02:09 +05:30
KRATOS f8ac5eceb0 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.
2026-06-27 12:31:41 +05:30
Anandan d29ab98a79 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 ./...
2026-06-26 16:21:06 +05:30
Anandan 80ab238361 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 ./...
2026-06-26 13:20:07 +05:30
Anandan f1502f0bfe 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
2026-06-26 08:22:56 +05:30
KRATOS 0e7318ff72 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.
2026-06-25 20:23:29 +05:30
Vasanth T ca41ac2b6c 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.
2026-06-25 19:32:41 +05:30
Vasanth T 46c44a8753 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
2026-06-25 11:36:40 +05:30
Anandan 644f61fa58 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
2026-06-25 09:47:55 +05:30
Anandan 6be50cd3a7 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
2026-06-23 17:44:46 +05:30
KRATOS 63c0e278ff 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.
2026-06-23 10:51:47 +05:30
KRATOSandgnanam1990 f7ee189ba5 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 <[email protected]>
2026-06-21 00:44:23 +05:30
Vasanth T b2bb6c4b41 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/<name>.md` (project, shared with the
team) or `<userConfigDir>/zero/commands/<name>.md` (personal); typing
`/<name> args` expands its body template and submits it as a normal prompt.
Optional YAML frontmatter (`description:`, `model:`, `agent:`) feeds help text
and routing. This turns Zero's model-pulled skills into user-invokable,
repo-checked-in team workflows (`/release v1.2`, `/fix-flaky <test>`).

New internal/usercommands package: Load() scans the project + user dirs
(project shadows user on name collision, mirroring specialist scope
precedence), validates names to a safe slash-command shape, and parses
frontmatter with a self-contained parser. Expand() substitutes $ARGUMENTS,
$1..$9, and $$ (literal $); a placeholder-free template gets the raw args
appended so a trivial command still receives input.

Wiring: model loads commands at construction; the commandUnknown dispatch
checks the user set before reporting "unknown"; autocomplete lists user
commands alongside builtins. Builtins win on collision (listed first, shadow at
dispatch). Tests cover load/precedence/name-validation, the placeholder
substitution table, and the TUI dispatch + autocomplete + still-unknown paths.

* feat(init): guided /init that generates AGENTS.md seeded with repo facts

`/init` (TUI) and `zero init` (headless) investigate the repo and write a
concise AGENTS.md so future runs start with project context. Zero already has
the AGENTS.md loader and the repoinfo scanner, but the file only existed if a
user hand-authored it — this activates the machinery in one command.

New internal/agentinit.BuildPrompt seeds the bootstrap prompt with
repoinfo.Collect() output (primary language, build/test tools, CI, workspace
layout, git) formatted as a fact block, then instructs the agent to investigate
the gaps and write a tight AGENTS.md (build/test commands, conventions, things
to avoid). Collection failure is non-fatal — the agent investigates from
scratch. This is one better than a pure-prompt /init: the agent starts from
known facts instead of re-deriving them.

TUI: /init command + handleInitCommand reuse launchPrompt (standard run, tools,
AGENTS.md write path). Headless: `zero init` builds the prompt and forwards it
through the existing exec machinery, so all exec flags (--model, -w, --add-dir)
work unchanged. Tests cover FormatFacts (rendered/omitted fields), BuildPrompt,
and the TUI dispatch.

* feat(tools): lsp_navigate — semantic code navigation via the language server

A read-only, model-callable tool exposing LSP navigation: jump-to-definition,
find-all-references, find-implementations, and workspace_symbol search. The
agent can answer "where is X defined / who calls X / what implements this
interface" precisely, where grep collides on common names or misses indirect
references — cutting wasted read/grep turns and wrong-edit risk on large repos.

Zero already runs language servers for diagnostics, so this adds the
request/response navigation methods on top: Manager.Navigate syncs the file
(didOpen) then issues textDocument/definition|references|implementation or
workspace/symbol via the existing client.Call, decoding all three result shapes
(Location, []Location, []LocationLink). 1-based file:line:col in and out
(converting LSP's 0-based positions); the tool has its own lazily-started
manager (servers start on first use, reused across calls).

Opportunistic: a file type with no installed server, or a server lacking the
capability, degrades to a clear "unavailable — fall back to grep" message, not
an error. Registered in CoreReadOnlyToolsScoped. Tests cover the response-shape
decoding (incl. LocationLink + null), symbol decoding/kinds, unknown-op and
unsupported-extension degradation, and the tool's arg validation + read-only
safety.

* feat(agent): prune stale tool output before paid summarization

Long, dump-heavy sessions accumulate large read_file/grep/glob/bash tool
results the model has already acted on; their bodies are dead weight the loop
otherwise keeps paying an LLM summarizer to compress. Add a zero-cost prune
stage inside maybeCompact, before the summarizer: when over threshold, replace
the bodies of OLD, large tool results with a compact placeholder, recompute
size, and only fall through to the paid Compact() if still over.

pruneStaleToolOutput walks newest-first, protects the last preserveLast
messages plus a trailing 40k-token window of tool output (most likely still in
use), and prunes only tool results older than that and larger than 200 tokens —
gated so the whole pass is a no-op unless reclaimable output exceeds 20k tokens.
It keeps the tool message and its ToolCallID (provider replay stays valid; the
model knows what was there and can re-run the tool), never touches non-tool
messages, never re-prunes a placeholder (idempotent), and copies-on-write so it
never mutates the caller's slice. Recent turns are preserved verbatim — no
paraphrase loss, no token or latency cost.

Tests cover the reclaim gate, recent-window protection, idempotence,
non-tool-message safety, ToolCallID/role preservation, and no-input-mutation.

* feat(agent): reconnect on a transient model disconnect instead of ending the run

A long autonomous task (a big refactor, a swarm member, a headless/cron run)
should survive a single transient upstream hiccup rather than dying and
re-burning every token on a restart. When the initial StreamCompletion connect
fails with a disconnect-shaped error, streamWithReconnect re-issues the SAME
request with exponential backoff up to 2 times.

It retries only the connect — before any content is forwarded — so no
already-streamed OnText is ever duplicated. A context-limit error (the
compactor's job), an image rejection, a non-disconnect error, or a cancelled
context is returned immediately without retry, so the reconnect never fights
the existing handlers. Disconnect classification matches EOF / connection
reset|refused|closed / broken pipe / timeout / 502 / 503 / server-closed.
Reconnect attempts surface through OnReasoning ("connection lost —
reconnecting N/max"), a non-content channel that never corrupts the answer
text; silent when there's no reasoning sink.

Tests: recover-from-transient, give-up-after-max, no-retry-on-context-limit,
stop-on-context-cancel, the disconnect-vs-not classifier, backoff growth, and
the notice routing.

* fix(specialist): add lsp_navigate to knownToolNames

The new lsp_navigate tool was added to the core read-only registry but not to
specialist.knownToolNames, which mirrors the core tool set — so
TestKnownToolNamesMatchCoreRegistry failed (the list drifted from the registry).
Add it to the read-only group to match.

* fix(tools): confine lsp_navigate to the workspace (close arbitrary-file-read)

Review caught that lsp_navigate dropped the PathScope every sibling read-only
tool (read_file/glob/grep) carries, and its readFile joined or used the
model-supplied path verbatim — so `path:"/etc/passwd"` (absolute) or
`path:"../../etc/..."` (escaping) read arbitrary files off disk AND opened them
into the language-server subprocess, reachable via indirect prompt injection.

Add NewScopedLSPNavigateTool(workspaceRoot, scope) (NewLSPNavigateTool now
delegates with nil scope), resolve the path through resolveScopedReadPath BEFORE
reading or building the LSP request — the same confinement the neighbouring
tools enforce — and hand the LSP manager the resolved absolute path so it never
opens an out-of-workspace file. Errors echo only the relative path, never an
absolute one. Registered via the scoped constructor in CoreReadOnlyToolsScoped.

Regression test asserts `/etc/passwd` and `../../` are rejected for both the
position and workspace_symbol ops; verified by mutation (bypassing the scope
resolution makes the test fail).
2026-06-20 23:12:15 +05:30
Anandan f5038151c8 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
2026-06-20 20:30:39 +05:30
Vasanth TandClaude Opus 4.8 c572791edf 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).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* test(tools): de-flake TestWriteStdinInterruptTerminatesSession

The test had two Windows-CI flakes that reddened unrelated PRs:

1. It relied on write_stdin's 1000ms yield window being long enough for the
   async SIGKILL + reap to land before asserting the session was no longer
   running. On a loaded runner the reap exceeded that window, so write_stdin
   reported "still running" and the assertion failed. Fix: after terminating,
   wait on session.done deterministically (30s safety timeout that fails loudly
   if the kill genuinely hangs); the common case returns the instant the
   process exits.

2. The SIGKILL'd child had the test's t.TempDir() as its cwd, and Windows may
   not release the directory handle the instant the process is reaped — so
   t.TempDir()'s immediate RemoveAll on cleanup failed with "being used by
   another process". Fix: resilientTempDir retries RemoveAll for up to 5s
   (best-effort; a leaked temp dir never fails the test).

Verified 20x locally; full internal/tools suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-20 19:40:32 +05:30
Anandan cf54e2cb0e 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
2026-06-20 14:20:32 +05:30
KRATOS d3e7b89eb0 TUI: live code-streaming preview, theme system, plan/Task transcript dedup (#262)
* tui: live code-streaming preview, theme system, plan/Task dedup

- Live "writing <file>" preview: a long write_file/edit_file/apply_patch
  now streams the code as the model generates it (path + line count + a
  color-coded tail) instead of a frozen spinner. Tool-call argument deltas
  are forwarded zeroruntime -> agent -> TUI (OnToolCallStart/Delta).
- Theme system: auto dark/light detection via a terminal-background probe,
  a /theme [auto|dark|light] command, a refreshed palette, and streaming
  fade auto-disabled on low-color / SSH / tmux terminals.
- Plan/Task transcript dedup: update_plan collapses to a one-line summary
  (the live plan panel owns the detail), and Task delegations render only
  as the specialist card, not also as raw tool-call/result rows -- in both
  the live and resume paths.

Robustness for the streaming preview: tolerate whitespace in tool-call
JSON, extract edit_file's new_string, clear the preview on tool finalize /
cancel / run-end (no stale or duplicate previews; a stale run can't wipe
the active one), color new content green (never red for a leading '-'),
and truncate by display width.

* address M10: keep failed Task delegations on resume

The unconditional Task tool-row skip on resume also hid a Task that failed
before a specialist started (there is no card to replace it). A pre-pass now
collects the tool-call ids that actually started a specialist; the Task
tool-call/result rows are skipped only for those, so a failed delegation and
its error stay visible on resume.

* streaming preview: decode tool-call args incrementally (O(n) not O(n^2))

The live "writing" preview re-scanned the whole accumulated args buffer on
every delta and grew it via string concat, so a large write_file/edit was
quadratic and janked exactly during the long write it targets (M14/L6/L7).

Replace the per-render decode with a streamingDecoder that consumes each
fragment once: it buffers the head until the content value begins (extracting
the path), then decodes the content's JSON-string escapes as they arrive,
keeping only a bounded tail of lines plus a running line count. The view now
reads that pre-computed state in O(tail) per render. Handles whitespace,
escapes split across fragments, \uXXXX, and the closing quote; unit-tested
including byte-by-byte vs whole-buffer equivalence.
2026-06-20 10:52:01 +05:30
KRATOS 1c30f4c117 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.
2026-06-19 21:11:34 +05:30
Vasanth T b237e9849a 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
2026-06-19 20:35:08 +05:30
Anandan ac6addaa83 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
2026-06-19 19:50:12 +05:30
Anandan 673fce11bd 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
2026-06-18 21:56:24 +05:30
Anandan f00e83a5ed 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 ./...
2026-06-18 21:00:13 +05:30
Anandan 2cf6fd2334 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 ./...
2026-06-18 20:00:35 +05:30
KRATOS 995b9086c7 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 "@<specialist> <task>" in the TUI composer delegates
  to that specialist (transcript keeps the verbatim mention); the @ palette
  suggests specialists on the first token, file references mid-message.
- swarm.maxTeamSize config caps concurrent members per team (0 => default 8;
  negative rejected), honored in user and project config.
- Proactive spawning: read-only specialists (explorer/code-review) auto-
  approve via a new args-aware tool permission (tools.ArgsPermissioner), so
  the agent delegates exploration off a normal prompt without an approval
  prompt; write-capable specialists, resume, and unknown names still prompt.
  The exec gate registers specialist tools at medium/high autonomy so a
  non-trivial headless run can auto-delegate read-only too.

* fix(specialist): fail-safe child autonomy when PermissionMode is unset

specialistAutonomy mapped an empty PermissionMode to "--auto high" (unsafe),
silently re-introducing the privilege escalation the rest of this PR fixes: an
orchestrator that forgets to wire PermissionMode would get an unsafe child. The
Task tool and swarm both now propagate a resolved mode, so "" only occurs for a
caller that omitted it -- default that case to "low".

Only an explicit unsafe parent now yields --auto high; everything else (incl.
unset/unknown) is fail-safe low. Updates the stale "back-compat" comments and the
tests that documented the old behavior.

Addresses the non-blocking follow-up from PR review.
2026-06-18 20:00:12 +05:30
KRATOS b903825139 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.
2026-06-18 19:41:36 +05:30
Anandan eb608741b0 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
2026-06-18 16:53:20 +05:30
Anandan 2d62387e08 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
2026-06-18 14:54:00 +05:30
Anandan a1b9bbb07c 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 ./...
2026-06-18 13:45:49 +05:30
Anandan 4cb1f10952 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
2026-06-18 12:03:10 +05:30
Vasanth T 7d66942564 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.
2026-06-18 10:41:36 +05:30
KRATOS 6df5c2b4cb 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 <id> and
  /resume latest still resolve directly.
- Resume now loads the rehydrated (compaction-aware) event view instead of the
  raw log, so a resumed session honors a prior /compact — matching the CLI's
  `zero exec --resume` and the in-TUI /compact reload. Falls back to raw on a
  rehydrate error.
- The resume summary reports the model/provider the continuation will actually
  use (the active ones), noting the session's recorded model/provider when it
  differs, instead of implying a switch that doesn't happen.
- Drop the dead, self-contradictory resumeText(arg) branch (it was unreachable
  and its hint was circular); resumeText is now the no-session/error fallback.

Tests: session picker opens with title+id rows and hydrates on selection;
resume honors prior compaction (rehydrated, not raw events).

* tui: lead /resume picker rows with a precise timestamp

Same-titled sessions (e.g. the same first prompt run several times) looked like
duplicates because the row showed only the identical title plus a long id that
truncated the age away. Lead each row's label with a precise local timestamp
(HH:MM:SS today, "Jan _2 15:04" earlier this year, else the date) so distinct
sessions are obviously distinct; the full id stays as the right-aligned meta and
is what selection resolves. sessionWhen parses the RFC3339 UpdatedAt.

* tui: hide empty/no-output sessions from the /resume picker

Most of a heavily-retried prompt's sessions are empty failed runs — just the
prompt plus the no-output guardrail stop ("Agent stopped … with no output …"),
with no assistant text and no tool calls. They have nothing to resume and flood
the picker with identical-looking rows. Skip any session with no resumable
content (no tool call/result and no real non-user message) from the picker; they
stay on disk and are never deleted. Adds agent.IsNoProgressStop to recognize the
stop message from a recorded event, and a test that the picker hides an empty
run while keeping a real one.

* feat(tui): model-generated session titles (auto + /retitle backfill)

Sessions were titled from their first user message, so prompts that
started the same way (or empty/failed runs) looked identical in the
/resume picker. Generate a concise, specific title with the active
provider instead.

- sessions.Store.UpdateTitle(id, title): rewrites only Title under the
  per-session lock, re-reading the latest metadata first; leaves
  UpdatedAt untouched (a retitle is not activity and must not reorder
  the resumable list), rejects a blank title, and no-ops an unchanged
  one.
- Title generator: a bounded digest of the conversation (user/assistant
  text + tool names, per-message and total caps, skipping the no-output
  guardrail stop) is sent as a one-shot completion; the response is
  cleaned (first content line, quotes/markup/"Title:" label stripped,
  word- and rune-capped) before storing.
- Auto-title going forward: after a successful turn, a session still
  carrying its default first-message title gets a title in the
  background, at most once per session.
- /retitle: backfills existing resumable sessions that still have a
  first-message title, one at a time, skipping empty/failed runs and
  already-named sessions, with kickoff and completion status lines.

Generation runs off the Update goroutine; failures are non-fatal (the
first-message title simply stays). Adds store + TUI unit tests
(UpdateTitle semantics, digest/cleaning, auto-title one-shot, backfill
candidate selection). gofmt/vet/build(host+linux+windows)/test -race/
staticcheck all green.

* address review: tighten no-progress detection, fix fallback errors + nil guard

Three review findings on the /resume work:

- IsNoProgressStop matched any content CONTAINING the no-output marker
  substring (strings.Contains), so a legitimate assistant/tool message that
  quoted the marker could be misread as a failed empty run — wrongly hiding a
  real session from the /resume picker and skipping its title generation. Now
  it requires the full answer structure (prefix + marker + suffix); a quote no
  longer matches.
- resumeEvents returned the earlier rehydration error when the raw ReadEvents
  fallback itself failed, masking the actual fallback failure. Returns rawErr.
- resumeText dereferenced m.sessionStore unconditionally; a nil store (fallback/
  test paths) would panic. Now renders a safe "store unavailable" message.
  (newSessionPicker already guarded nil.)

Tests: IsNoProgressStop accepts the real answer and rejects a quoting message /
bare marker / prefix- or suffix-only; resumeText with a nil store returns the
safe message instead of panicking. Full gate green.

* address review (Vasanth): structural no-progress check, retryable auto-title, nil-store guard, deep resume assertion

- guardrails: IsNoProgressStop now matches the exact structure noOutputStopAnswer
  emits — prefix + "<int> turns " + marker + " " + suffix — instead of
  prefix && contains(marker) && suffix. A genuine message that merely quotes the
  marker amid other prose is no longer misclassified as a failed empty run (which
  would wrongly hide a real session from /resume and skip its title generation).
  Adds the hostile-input regression cases (quoted marker, text between
  marker/suffix, non-integer turn count).
- session_title: release the optimistic titledSessions gate when a generation
  FAILS (provider error, empty title, store write error) so a later turn or
  /retitle can retry; success keeps the one-shot gate. Kept the optimistic mark
  (rather than mark-on-success) to preserve the no-double-fire guarantee while a
  generation is in flight.
- session_title: guard maybeAutoTitleActiveSession against a nil sessionStore
  (the title cmd calls store.UpdateTitle) — mirrors the resume nil-store guards.
- model_test: assert resumed sessionEvents with reflect.DeepEqual against the
  rehydrated context, not just slice length, so a reordered/substituted-but-
  same-length regression is caught.
2026-06-17 12:41:20 +05:30