Commit Graph
101 Commits
Author SHA1 Message Date
Vasanth T 378d538e24 perf(agent): execution profiles with one-shot posture escalation (PR10b+PR10c) (#740)
* perf(agent): execution profiles, --exec-profile, bench passthrough, TUI /profile (PR10b+10c)

* fix(execprofile): harden profiles against review findings

- fast escalates on critical-risk mutations, not high: the sandbox marks
  every shell command high before command analysis, so a high trigger
  spent the one-shot on the first go test of any task
- escalation can now restore the provider-default effort a profile
  displaced (PostureEscalation.RestoreDefaultEffort; a plain empty
  target means leave untouched)
- TUI /turns while a profile is active pins the budget: disarms the
  escalation turn target (mirroring exec's explicit --max-turns rule)
  and profile reverts use touched bits instead of value equality, so
  explicit choices survive even when they coincide with profile values
- spec-draft runs get the same escalation policy as the main run
  instead of the displaced budget with no safety net
- bench validates and canonicalizes --exec-profile before spawning:
  an unknown name previously produced an exit-0 report full of
  millisecond usage-error walls
- turn summary prints exec-profile alongside mode; zero exec rejects
  an inline-empty --exec-profile=; balanced trace label documented and
  pinned as deliberate attribution

* fix(execprofile): explicit effort choices disarm the escalation effort restore

An explicit effort set AFTER profile selection could be cleared by a
mid-run escalation (the effort analog of the /turns pin, introduced by
RestoreDefaultEffort): TUI /effort and Ctrl+T now disarm the restore on
a policy clone, and the spec-draft path only arms it when no explicit
--spec-reasoning-effort governs the draft.

* fix(execprofile): address review findings on the profile surfaces

- --use-spec projects away a profile's self-correct knob instead of
  silently arming an option the draft runner ignores (the explicit
  --self-correct --use-spec combination is already a parse-time error)
- RunTurnBench canonicalizes and validates ExecProfile at the library
  boundary, so a direct caller's casing/whitespace cannot fork report
  labels and an unknown name fails before anything runs
- reverting a profile whose displaced budget was zero clears
  ZERO_MAX_TURNS (SetMaxTurnsEnv ignores zero, so sub-agents would have
  kept the removed profile's budget)
- the TUI effort-disarm test fails instead of skipping when the catalog
  stops filling low effort

* fix(tui): reconcile an active profile's effort fill on model switch

The fill is per-model, not a session preference: fast selected on a
model without low fills nothing, and switching to a model that supports
it should behave like selecting the profile there; the reverse switch
must not keep sending a level the destination does not support. Both
switch paths (the /model command and the cross-provider picker) now
re-derive the fill, leaving explicitly touched efforts alone and
keeping the escalation's effort restore in step. Also: the revert-env
test asserts key removal via LookupEnv, and applyProfileTurnBudget's
pinned-budget parameter is named and documented as covering mode
presets, not just the flag.

* fix(tui): void profile effort bookkeeping when the model-switch drop erases it

The /model path's pre-existing unsupported-effort drop ran before the
profile reconciliation and left contradictory state behind: a dropped
touched override kept blocking the profile from governing, and a
dropped profile fill left a stale applied value with the effort restore
still armed. The drop itself is load-bearing (the TUI run path forwards
the effort to the provider unfiltered), so instead of preserving a
known-unsupported level, the voided choice now resets the profile's
effort bookkeeping and the reconcile re-derives from scratch. The whole
sequence lives in one ring-parameterized helper driven by the switch
target's authoritative ring, unit-tested against synthetic rings for
both the void-then-refill arc and the supported-override survival.

* fix(tui): only drop an explicit effort when the target ring is authoritative

A nil ring meant both a catalog model with no effort controls and a
live-discovered custom model the catalog cannot vouch for. The drop now
requires an authoritative ring (a resolved catalog entry, whose ring may
legitimately be empty); an unknown ring preserves an explicit preference,
matching the cross-provider picker's behavior, while the profile's own
fill still applies only where support is known.
2026-07-19 00:53:17 +05:30
Vasanth T af875df587 perf(agent): posture-escalation signals and controller (PR10a) (#736)
* trace(perf): add posture escalation counter

* feat(agent): typed posture-escalation policy and tool-result risk field

* feat(agent): posture controller wiring and executed-risk stamping

* test(agent): cover posture controller, escalation act path, and risk stamping

* test(agent): pin zero risk on not-executed results

* fix(agent): wire uncertain-path escalation and validate risk thresholds
2026-07-18 22:32:02 +05:30
Vasanth T 30e2c3f7ff perf(providers): provider capabilities and default turn-session adapter (PR7) (#720)
* perf(providers): add provider capabilities and default turn-session adapter (PR7)

* fix(providers): project effective reasoning efforts into capabilities

* feat(agent): carry optimized turn sessions across mid-run model switches

* test(agent): cover swap-time session open failure
2026-07-18 09:33:07 +05:30
Anandan 18cce358a2 Add deterministic completion policy (#719)
Extract completion decisions from the agent loop into a feature-gated typed policy with complete, incomplete, and uncertain outcomes. Preserve bounded plan-stall nudges and allow at most one semantic acceptance check for self-correcting runs.

Tested: make build

Tested: make test

Tested: go fmt ./...

Tested: go vet ./...

Tested: make lint

Tested: govulncheck ./...

Note: repository-wide pinned golangci-lint reports 36 pre-existing unrelated findings; internal/agent/... reports 0 issues.
2026-07-18 00:29:58 +05:30
KRATOSandKRATOS e5670c427f perf(output): add token-aware semantic output budgeting (PR11) (#717)
* perf(output): add token-aware budget contract

* perf(output): add semantic retention policies

* perf(output): budget results after redaction

* perf(output): propagate tool truncation metadata

* perf(output): trace semantic budget decisions

* perf(output): classify core tool output

* docs(output): describe semantic output budgeting

* test(output): make spill fallback portable on windows

* fix(output): address semantic budgeting review findings

* test(output): make hook budgeting tests portable

* fix(output): retain oversized single-line file content

* fix(output): apply semantic budgets to captured output

* fix(output): preserve hook budgets and search paths

* fix(output): preserve alias and truncation metadata

* test(trace): validate output budget metadata

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-18 00:02:43 +05:30
KRATOSandKRATOS 31d45d5f14 perf(agent): concurrent read-only tool batches via capability gate (#715)
Upgrade the parallel tool planner from SideEffectRead probes to the PR5
CapabilitiesOf contract: EffectReadOnly + ThreadSafe + auto-allowed, with
resource-key conflict boundaries so same-path reads stay sequential.

Mark audited pure reads ThreadSafe (read_file, read_minified_file,
list_directory, glob, grep, skill, web_fetch). Glob/grep use scopedScan
directory keys (nil for workspace-wide "." so scans do not false-conflict).

Tests cover the capability gate, key conflicts, extendParallelRun windows,
and catalog ThreadSafe audit for concurrent-safe reads.

Co-authored-by: KRATOS <[email protected]>
2026-07-17 17:52:11 +05:30
PierrunoYTandClaude Sonnet 5 824ecdbcf9 fix(hooks): run sessionEnd hooks after Esc/Ctrl+C interrupts (#606)
* fix(hooks): run sessionEnd hooks after Esc/Ctrl+C interrupts

On interrupt, Run returns with ctx already canceled. The deferred
dispatchSessionEnd call passed that same ctx into Hooks.Dispatch, whose
context.WithTimeout derives an already-canceled child context, so the
hook process never actually launches (recorded as timed-out/error in
the audit log). Since most sessions end via interrupt, sessionEnd
hooks were effectively unreliable.

Detach the sessionEnd dispatch from the run context's cancellation
with context.WithoutCancel so the dispatcher's own timeout still
applies without being short-circuited by the run's cancellation.

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

* test(agent): fall back to GOROOT for hook regression

* test(agent): justify GOROOT fallback

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-17 13:03:14 +08:00
Anandanandanandh8x 1c5c6e78a8 feat(perf): emit prompt-prefix hash fingerprint per turn (#704)
Compute a seven-field SHA-256 fingerprint of the cacheable prompt
prefix (base instructions, confirmation policy, project context, skills,
tools, tool schemas, complete) and emit one prefix_hash event per turn
through the trace recorder. The complete hash is the SHA-256 of the
canonical join of the other six, so any sub-component drift is
observable both individually and in aggregate.

The decomposition exists so a downstream observer can detect which
sub-component of the prefix broke cache stability across turns: a
stable complete_prefix hash means the cacheable prefix is byte-
identical, and the sub-hash that changed names the culprit.

No behavior change for untraced runs. For OpenAI, the existing
prompt_cache_key (session ID) is unchanged; the fingerprint is purely
additive observability. The Anthropic cache_control breakpoint work
already in main is not touched.

Co-authored-by: anandh8x <[email protected]>
2026-07-17 12:47:53 +08:00
Vasanth T abe24bcb10 Add per-turn tracing and a turn-benchmark harness (Phase 0 baseline) (#700)
* Add per-turn tracing and a turn-benchmark harness (Phase 0 baseline)

Introduce an opt-in `internal/trace` package that attributes a run's wall
time to named spans — prompt build, provider connect/queue, generation,
tool queue/execution, permission wait, verification, compaction,
persistence — plus counters for model requests, tool calls, retries,
reconnects, compactions, completion nudges, acceptance checks, model
switches, and input/cached/output tokens. The recorder is nil-safe (a nil
*Recorder is a no-op), so every stamp site can call it unguarded; an
untraced run is byte-identical to today.

The recorder threads into agent.Run via a new `Options.Trace` field and a
single `trace.WithContext` assignment, so the shared providerio seam
(connect around client.Do, queue around OAuth resolve, first token at the
first non-keepalive SSE payload) reaches it through `trace.FromContext`
without any provider-interface change. All instrumentation is opt-in: when
`Options.Trace` is nil the hot path is untouched.

Headless `zero exec` gains `--trace <path>` (and `ZERO_TRACE=<path>`),
writing an agenteval-contract-compatible NDJSON trace (`-` for stderr).
The trace is pure observation; enabling it does not change behavior.

A new per-turn benchmark harness extends `internal/perfbench` (reusing the
existing TaskSet/NumericStats/subprocess runner shapes): `RunTurnBench`
aggregates per-span median/P95 across iterations, ranks the top three
controllable latency sources by share of attributed time, rolls up per
task class, and totals tokens/counts. A checked-in 42-task manifest across
seven classes (nav, edit, fix, refactor, longproc, longctx, parallel) and
local offline fixtures back it, and `make baseline` runs it against the
built `zero`, writing `internal/perfbench/reports/baseline.json`. The
report is machine-specific and regenerated, not hand-edited.

The manifest is JSON rather than YAML to avoid adding a dependency;
per-task verification targets the specific test (`go test -run <name>`)
so fix tasks are independent. Each task is a fresh `zero exec` process, so
iterations are cold-start samples; a warm in-process path is left for
follow-up.

Verified: `go build ./...`, `go vet ./...`, `gofmt -l` clean on touched
files; `go test -race` green for internal/trace, internal/agent,
internal/perfbench, and cmd/zero-perf-bench.

* test(trace): make TestAttributionRatio clock-independent

The test recorded two synthetic 10ms spans with no real delay and asserted
the attribution ratio stayed <= 1.0. On a precise clock (Linux/macOS CI) the
wall is microseconds while attributed is 20ms, so attributed/wall blows past
1.0 and the test failed — even though the ratio legitimately exceeds 1.0
when spans overlap (parallel tool execution). On Windows the coarse timer
masked it, so the failure only surfaced in CI.

Assert the contract instead: attributed is the deterministic sum of span
durations, and the ratio equals attributed/wall (0 when wall is zero). Add a
zero-wall case for the divide-by-zero guard.

* Address review feedback on the per-turn tracer + benchmark harness

Benchmark runner (internal/perfbench):
- A nonzero agent exit code no longer counts as Passed. The runner set
  VerifyErr but then fell through to outcome.Passed = true, so a crashed
  run could be recorded as a pass. Return before the verification block
  when VerifyErr is already set.
- A task now passes only when every iteration passes. The previous
  "any iteration passes" rule let a flaky fail hide behind one good run.
- Each invocation copies its workspace fixture to a fresh temp dir and
  runs the agent + verifier there, so mutating tasks (edit/fix/refactor)
  can't dirty the checked-in fixtures or bleed into the next iteration.
- Trace load failures (missing/malformed trace file) are surfaced as
  result warnings via a new TraceIssue field instead of being swallowed.

Tracer (internal/trace):
- Finish freezes the recorder: Counter, addSpan, and the first-event
  stamps are dropped after Finish returns, so a snapshot captures exactly
  one turn and late stamps can't mutate it.
- WriteText propagates the first write error instead of silently
  swallowing sink failures.

Provider seam (internal/providers/providerio):
- FirstTokenAt is stamped only after a payload is accepted as real model
  output, not before handle() decides to drop it as an error payload.

Agent loop (internal/agent):
- The compaction counter increments only when the history actually
  shrinks, in both the proactive and reactive paths — paid no-ops no
  longer inflate the compaction total.
- Document on Options.Trace that a fresh Recorder is required per Run.

CLI (internal/cli/exec):
- Finish the trace at the agent.Run boundary and defer only the
  serialization, so the snapshot covers exactly the turn.
- Reject --trace / ZERO_TRACE for spec-draft runs with a clear error
  instead of silently accepting and writing nothing.

Manifest + fixtures:
- Strengthen the edit verifiers: rename and value-bump tasks now assert
  the old symbol is gone, and the getter task greps the method signature.
- Add the missing testdata/edit fixture (10 edit tasks referenced a
  directory that was never committed, so the whole edit class errored
  out at run time).
- The manifest test now stats each fixture so a missing dir fails at
  load time instead of at every run.
- longproc benchmark keeps Process live across the loop; README code
  fence gets a language tag and documents the per-invocation fixture copy.
- Add tests for the tracing wrapper (nil vs wired recorder, OnUsage
  forwarding, token aggregation) and the frozen-recorder contract.

* Address review feedback: exclusive-time attribution + wired spans + fix-suite compile

Rewrite the trace attribution model so concurrent/nested spans no longer
double-count. Spans record wall intervals; the recorder derives a parent for
each by interval containment and computes exclusive time (duration minus the
union of its children). AttributedDuration sums only top-level spans; Coverage
is the union of intervals over wall, capped at 1, the honest ">=95%" metric.
Top-latency ranking uses exclusive time so shares sum to ~1 instead of being
inflated by overlapping provider_connect/generation and nested
permission_wait/tool_execution.

Wire the spans that were named but never stamped: verification around the
self-correct verify pass, and StampFirstVisibleEvent on the first forwarded
text event. Drop the tool_queue/process_wait/persistence constants that had no
stamps, and rename the misnamed near-noise prompt_build span to tool_partition.
Trim RequiredEventKeys to events a healthy short run actually emits; move
conditional phases (tools, permission, compaction, verification) to
OptionalEventKeys so a short trace does not false-fail.

Make ReadNDJSON fail loudly on a corrupt file: require a type:trace header,
error on span/counter lines that appear before it, and error when a header
yields no spans or counters.

Fix the blocking fix-suite compile error: Label's %d format verb on a string
tripped go test's vet before any fix task could verify. Make it a runtime
wrong-prefix bug ("admin-" vs "user-") that still type-checks, so each fix
task is independent again.

Manifest: correct refactor-04's stats.go to main.go, reword fix-07 to the
wrong-prefix bug, add a latency-only description (read-only tasks carry no
verification oracle by design), and frame the report README on exclusive time
and coverage.

Tests updated for the occurrence/exclusive/coverage model; trace, perfbench,
and agent green under -race.

* Fix exclusive-time round-trip, identical-interval nesting, and empty-trace parse

Three correctness bugs in the attribution model found by an adversarial review
of the prior commit, all undermining the no-double-count guarantee:

1. exclusive_ms:0 was overwritten with the inclusive Duration on re-parse. A
   parent whose children tile its interval legitimately has exclusive time 0;
   WriteNDJSON emits exclusive_ms:0, but ReadNDJSON's "<= 0 means missing"
   fallback turned that back into the full duration, so every re-parsed trace
   re-introduced the double-counting the model exists to prevent (the benchmark
   harness, which ranks re-parsed traces, inherits it). Gate the fallback on
   key ABSENCE, so a written 0 is preserved.

2. deriveNesting formed a 2-cycle for two spans with identical [start, end]
   intervals: the symmetric containment check made each parent the other,
   dropping both from top-level and zeroing AttributedDuration. Break the tie
   deterministically — strict containment is an unambiguous parent; for
   identical intervals, only the lower-indexed span may parent the other, so
   one stays top-level.

3. ReadNDJSON returned a valid empty TurnTrace for empty/blank-only input, so a
   run that crashed before emitting a trace (or where --trace was not honored)
   masqueraded as a clean zero-attribution sample. Empty input now errors, so
   the harness records a TraceIssue.

Also: decode counters via json.Number so int64 values above 2^53 round-trip
exactly instead of losing precision through float64; and drop the harness's
own "exclusive <= 0 -> Duration" ranking fallback, which was the same
double-count on the re-parsed path. Added regression tests covering the
zero-exclusive round-trip, the identical-interval nesting, empty-input
rejection, and counter precision.

* perf(turn-bench): split pass/fail into oracle tiers so the pass rate can't be misread as correctness

The turn benchmark counted any exit-0 task as passed, including the 24
read-only tasks (nav/longproc/longctx/parallel) that carry no verification
oracle — so the JSON tasksPassed / "N/M passed" headline could read as a
correctness verdict it never was. Split pass/fail into three oracle tiers:

- correctness (edit grep + fix go test): the only pass rate that can move
  with model quality (tasksVerified / tasksPassed / correctnessPassRate)
- build-only (refactor go build): a non-positive oracle, reported as
  buildPassRate and excluded from correctnessPassRate; the manifest declares
  buildOnlyClasses: ["refactor"]
- latency-only (no oracle): counted in latencyOnlyTasks, never in any pass
  rate

NewTurnExecRunner no longer sets Passed=true for tasks with no
verificationCommand, and the summary headline names each tier explicitly so
an exit-0 read-only run cannot inflate a correctness number. Bumps
TurnSchemaVersion to 2. Class breakdown and tier contract documented in
internal/perfbench/MANIFEST.md and reports/README.md.

Also install the trace first-token/visible-event forwarding handlers in
agent.Run whenever options.Trace is set, not only when a UI OnText /
OnReasoning callback is set, so a headless traced run (zero exec --trace)
still captures FirstTokenAt. forwardedVisibleText stays tied to the user
callback so stall-retry semantics are unchanged. Adds a regression
assertion to the existing traced-run test.

* Address CodeRabbit review on 2bfb3406: omitempty + crash visibility

Three findings from CodeRabbit's review of the 3-tier pass-rate fix:

- BuildPassRate dropped its omitempty so an explicit 0.0 (all refactor
  tasks fail to compile) is no longer indistinguishable from 'no build
  tasks ran' in the JSON. Matches CorrectnessPassRate's always-serialize
  behavior, which is the whole point of the tier split: the report
  cannot be misread.
- A crashed sample (outcome.Err) now emits a Warning mirroring the
  TraceIssue path, so a run that died every iteration can't look like a
  normal benchmark with fewer measurements instead of a failure.
- cmd.Run()'s returned error is preserved on the no-run_end path: when
  stderr is empty and Run failed (binary missing, exec permission
  denied), the actual OS error is reported instead of collapsing to the
  generic 'missing terminal run_end event'.

go build/vet/gofmt clean; go test -race green for perfbench/agent/trace.

* Address CodeRabbit re-review: anti-averaging README + fixture-isolation test

Re-review at d0615249 asked for the pass-rate contract to be loud to
downstream consumers and for the fixture-isolation property to be asserted,
not just read from code.

- reports/README.md gains a prominent 'do not average the tier pass rates /
  do not report a single pass rate' warning. The schema offers no headline
  pass rate on purpose; a consumer must name the tier it is reporting. This
  is the durability fix for the misread the tier split exists to prevent.
- turn_bench_test.go gains TestCopyFixtureIsolatesSourceFromMutation: copies
  a fixture, mutates the copy, and asserts the source is byte-identical
  (and that a file added to the copy never appears in the source). Proves the
  'fixtures stay clean across runs' property without needing the binary.

Items the re-review also raised that were already satisfied: the
oracle-presence-over-declared-list precedence is documented on the
BuildOnlyClasses field (taskbench.go), and the PR body already says
'48-task manifest'. The Share->exclusiveShare rename is tracked in #701.

go build/vet/gofmt clean; go test -race green for perfbench.
2026-07-16 21:58:20 +08:00
euxaristia 0184581ec2 fix(agent,tui): resolve git branch detection when starting Zero in subdirectories (#613)
Currently, both gitBranchForPrompt (agent) and gitBranch (tui) check for a .git entry directly inside the current working directory. When starting Zero in nested folders/subdirectories of a repository, the check fails and the branch segment is omitted.

Export findProjectGitRoot as FindProjectGitRoot in agent package, and use it in both functions to resolve the correct repository root before checking HEAD and resolving the branch name. Also resolve relative worktree gitdirs relative to the repository root.
2026-07-10 10:05:36 +08:00
euxaristia b6117af86d fix(exec): stop false INCOMPLETE downgrades on conversational final messages (#608)
* fix(agent): scope self-reported incompletion to sentences about the current objective

The detector matched inability stems anywhere in the final message, so a
conversational recap of a past exchange ("You asked if I could work
autonomously ... so I couldn't actually do it at the time") downgraded a
completed run to INCOMPLETE. Match per sentence instead, drop quoted spans,
and skip sentences that retell a past exchange. The real transcript is now a
regression test, and genuine admissions still fire, both present tense and
past tense about the current task.

* feat(exec): add --no-completion-gate for conversational callers

A chat frontend driving zero exec has an operator present, so a final
message that hands the turn back ("the sandbox blocks network egress,
approve it and I'll continue") is a complete conversational answer, not an
unfinished task. The headless completion gate still stamped such runs
INCOMPLETE (exit 4) on the honest inability phrasing, and no phrase
heuristic can fix that case because the admission is true. The new flag
maps to RequireCompletionSignal=false so conversational callers can opt
out. Default behavior is unchanged.

* fix(exec): address review findings on quote stripping and --use-spec combination

stripQuoted now removes only balanced quoted spans: an opening delimiter
that never closes is restored verbatim, so a stray quote cannot swallow
the rest of the message and hide a genuine admission from the detector.
Regression tests cover dangling straight, curly, and backtick delimiters.

--no-completion-gate combined with --use-spec is now rejected at parse
time, mirroring the --self-correct check: the spec-draft path never
consults the completion gate, so the flag would be silently ignored.
2026-07-10 08:59:33 +08:00
euxaristiaandVasanthdev2004 f10ed0c893 fix(tools): Windows cmd.exe quoting guidance and clipboard escaping fix (#468)
* merge: rebase onto main, combine Windows shell guidance with #476's MSYS text

#476 merged into main and rewrote the same "Shell syntax: Windows cmd.exe..."
guidance string in system_prompt.go and shellGuidanceForGOOS in
shell_runtime.go that this branch also edits (this PR's fix for the broken
single-quoted quoting examples). Combined both into one coherent string in
each spot: the double-quote/single-quote metachar rule with correct
examples, plus #476's MSYS coreutils warning and require_escalated
mention. Merged the corresponding test assertions in loop_test.go the
same way.

internal/imageinput/clipboard.go, internal/tools/bash_tool_test.go, and
internal/tools/exec_command_test.go applied cleanly with no overlap.

* test(tools): add host shell syntax description test for exec_command

* chore: retrigger CI after infra flake

* style(tools): gofmt exec_command_test.go

---------

Co-authored-by: Vasanthdev2004 <[email protected]>
2026-07-09 09:23:40 +08:00
PierrunoYT 6ec77d62f5 Dispatch session lifecycle hooks (#581) 2026-07-08 09:10:51 +08:00
Pengkuh Dwi Septiandi 3f0503bc23 fix(agent): keep tools exposed for max-turn finalization (#533)
* fix(agent): keep tools exposed for max-turn finalization

* chore(agent): rename max-turn tool definitions parameter
2026-07-06 12:07:07 +08:00
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