Compare commits

...
Author SHA1 Message Date
github-actions[bot] 82ecac5281 chore(main): release 0.2.0 (#370)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-06 11:51:03 +08:00
Vasanth T 387fe67ee7 feat(tui): /loop — repeat a prompt or command on an interval or self-paced (#502)
* feat(tui): /loop foundation — command parsing, loop state, safety bounds

First slice of the /loop feature (#501): the self-contained core with no TUI
wiring yet. parseLoopCommand handles the leading-interval, trailing-"every",
bare-self-paced, and list/stop forms; loopState models one session-scoped loop
with doom-loop + iteration-cap fields; interval/self-pace clamps and duration
formatting round it out, with unit tests. Because Zero drives loops off a Go
timer rather than a cron backend, arbitrary intervals (90s, 7m) are supported
with no whitelist limitation.

* feat(tui): wire /loop into the turn lifecycle — dispatch, idle tick, continuation

Second slice of #501. Adds the controller and wires it in:

- /loop command registered (commands.go) and dispatched to handleLoopCommand.
- Loop state on the model (loops, activeLoopID, loopSeq/loopTicking poll guards,
  loopNextWake/loopDone self-pace channel).
- An idle poll tick (loopTickMsg, ~1s) fires the earliest due loop only when the
  session is idle — mirrors the launchQueuedMessageIfReady guard so loops and a
  user's queued prompt never collide; a self-perpetuating tea.Tick that stops when
  no loops remain.
- The agentResponseMsg completion seam advances a loop iteration (schedule next
  wake for fixed, model delay for self-paced) with doom-loop, iteration-cap, and
  done stop conditions, restarting the ticker if other loops remain.
- /loop list|stop|stop all, footer summary + list rendering, and a guard that only
  a prompt or custom /command may loop (not a built-in).

Controller tests cover start/advance/self-paced-delay/done/doom/cap/stop/busy.

* feat(tui): /loop self-paced adaptive cadence + footer summary + /clear note

Third slice of #501.

- Self-paced loops now use an adaptive cadence (2m early -> 30m heartbeat as
  iterations accrue), with the loopNextWake channel kept as an override for a
  future explicit model signal.
- Persistent footer segment "↻ N loops · next 3:05pm" while loops are active.
- /clear notes that loops keep running (it wipes the transcript, not the session)
  so a loop never silently fires into a cleared screen.
- Tests for adaptive cadence + footer summary.

* test(tui): drive /loop end-to-end through Update (submit + stale tick)

* docs(readme): add /loop to the slash-command table

* fix(tui): stop looping on repeated failure + clear loop tag on cancel

Addresses CodeRabbit review on #502:
- advanceLoop now counts consecutive failed iterations (loopMaxFailures=3) and
  stops the loop, so a loop whose turns keep erroring no longer churns to the
  iteration cap (the doom guard only sees identical non-empty answers).
- cancelRun clears activeLoopID and re-arms the interrupted loop for its next
  cadence, so a cancelled iteration is not left running forever and the next
  unrelated turn is not misattributed as that loop's completion.

* fix(tui): finish /loop v1 — real self-paced completion, session scope, leave gate

Response to the review. The self-paced path was the real gap: it advertised
model-chosen completion but nothing ever wrote the done/next-wake fields, so it
was just adaptive polling dressed up. Replaced that with an actual control
channel — each self-paced iteration runs a short maintenance playbook and ends
its reply with a control line (LOOP: DONE / LOOP: CONTINUE [interval]) that
advanceLoop parses to stop the loop or pick the next wake. The two dead model
fields are gone; done and next-wake now come from the model's output.

Rest of the review:
- loops stop on a session switch (/new, /resume) instead of firing the previous
  conversation's prompt into the new one
- validateLoopTarget resolves custom /commands up front and rejects unknowns, so
  a mistyped `/loop 5m /nope` isn't scheduled and then re-sent as literal text
- a one-shot confirm gates /clear and /quit while loops are active
- age-based auto-expire (24h) next to the iteration cap
- the two CodeRabbit test nits (HasPrefix footer check, bare `/loop stop` on a
  single loop)

Hardening from a self-review pass over the above:
- normalize CRLF and allow a trailing note on the control line, so
  `LOOP: DONE — all clean` and \r\n replies still stop the loop
- disarm the leave-confirm before the queue/exit early returns, so an interposed
  prompt can't leave it falsely armed
- a loop no longer fires behind an open picker/modal; a run launched behind the
  /resume picker would otherwise complete into whatever session you switch to

* fix(tui): address /loop review round 2 — ticker re-arm, self-paced command, resume guard

- stopLoop re-arms the poll ticker after removing one loop, so stopping one of
  several no longer strands the rest with no tick scheduled (jatmn).
- a bare self-paced /command is rejected with a pointer to the interval form:
  self-paced drives a free-form goal via the playbook + LOOP: control line, which
  a custom command's expanded template never carries, so it would silently
  degrade to adaptive polling (jatmn).
- /resume only tears loops down on an actual session-id change; /resume latest or
  /resume <currentID> can resolve to the already-active session, whose loops are
  its own, not a "previous" session's (CodeRabbit).
- tests: skipped-behind-modal loop stays scheduled, footer carries the next-wake
  time, the confirmed /clear actually wipes the transcript, plus multi-loop stop
  keeping the ticker alive and self-paced-/command rejection.

* docs(readme): clarify /loop runs a prompt or custom command, not built-ins
2026-07-06 11:42:46 +08:00
Gautam ManchandaniandGautam Manchandani d185f3ef0d Fix action summary for structured output (#485)
* Fix action summary for structured output

* Address action summary review comments

* Keep action summary extraction best effort

---------

Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-06 08:52:43 +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
Gautam ManchandaniandGautam Manchandani 809489a6e3 Batch session event persistence (#526)
Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-06 08:08:58 +08:00
euxaristia 56147a708e ci: add golangci-lint as an advisory check (#528)
go vet, gofmt, govulncheck, and deadcode don't catch unused private
functions/assignments reachable within a package but never called, or
staticcheck-style correctness/readability issues. deadcode's whole-program
reachability analysis specifically missed several of these; golangci-lint's
truncateHeadTail nitpick on #476 assumed it would fail CI, but nothing in
this repo actually ran it.

Scoped to unused, ineffassign, and staticcheck rather than the full default
battery, and continue-on-error like deadcode, so this surfaces the existing
findings across the repo (being cleaned up incrementally per #527) without
blocking in-flight PRs on a big-bang adoption.
2026-07-06 08:07:29 +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
8f3b8f0d13 docs: document AGENTS.md/ZERO.md and plugin manifest format (#522)
* docs: document AGENTS.md/ZERO.md and plugin manifest format

Add an "Extending Zero" section to the README covering the
AGENTS.md/ZERO.md project-instructions lookup chain and the
plugin.json manifest fields, neither of which were previously
documented outside the source.

Closes #521

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

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* docs: fix plugin path scope and add per-user ZERO.md guidelines

- Correct project-scope plugin discovery to <cwd>/.zero/plugins, not
  the repo root.
- Clarify that user-scope plugin discovery always resolves under
  ~/.config regardless of OS, separate from config.UserConfigDir()
  (which is OS-aware and used for the per-user ZERO.md file).
- Document the per-user ZERO.md guidelines file added in #475.

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

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-05 22:51:20 +08:00
55 changed files with 4664 additions and 185 deletions

No files matched your search

+14 -3
View File
@@ -100,9 +100,10 @@ jobs:
go-version-file: go.mod
cache: true
# govulncheck and deadcode resolve a toolchain from their own modules, which
# can drop below the version go.mod requires and then fail to load our
# packages. Pin GOTOOLCHAIN to the go.mod toolchain so both run under it.
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
# their own modules, which can drop below the version go.mod requires and
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
# so all three run under it.
- name: Pin toolchain from go.mod
run: |
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
@@ -120,3 +121,13 @@ jobs:
- name: deadcode (advisory)
continue-on-error: true
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
# Advisory: catches what deadcode's whole-program reachability analysis
# doesn't, unused private functions/assignments reachable within a
# package but never actually called, plus staticcheck-style correctness
# and readability issues. Scoped to a few linters rather than the full
# default battery, and non-blocking, while the existing findings across
# the repo are cleaned up incrementally (see #527).
- name: golangci-lint (advisory)
continue-on-error: true
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
+1
View File
@@ -37,6 +37,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
/zero
/zero.exe
/zero-skills
/snake-game.html
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
+1 -1
View File
@@ -1 +1 @@
{".":"0.1.0"}
{".":"0.2.0"}
+65
View File
@@ -6,6 +6,71 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.2.0](https://github.com/Gitlawb/zero/compare/v0.1.0...v0.2.0) (2026-07-06)
### Features
* add --auto flag for LLM-generated commit messages ([#423](https://github.com/Gitlawb/zero/issues/423)) ([b0abde7](https://github.com/Gitlawb/zero/commit/b0abde7d0697e808480cd59d69a6f4d0c6320475))
* add zero changes push and pr subcommands, and extra repo-info metrics ([#391](https://github.com/Gitlawb/zero/issues/391)) ([2312abe](https://github.com/Gitlawb/zero/commit/2312abe5ddd95f4c6ef373cfb61cc03092f48cdd))
* agent quality, caching, retry, and tooling upgrades ([#506](https://github.com/Gitlawb/zero/issues/506)) ([3c81fea](https://github.com/Gitlawb/zero/commit/3c81fea22873ee3df7fc97b10cb4f77792706c4b))
* **agent:** curb over-engineering the solution in the editing discipline ([#517](https://github.com/Gitlawb/zero/issues/517)) ([f4c998a](https://github.com/Gitlawb/zero/commit/f4c998ac30c4f07ff313a2d706791e857293be49)), closes [#516](https://github.com/Gitlawb/zero/issues/516)
* **agent:** inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt ([#475](https://github.com/Gitlawb/zero/issues/475)) ([7b10aab](https://github.com/Gitlawb/zero/commit/7b10aab74bf14a01166b2cea22deab79bba9850b))
* **openai:** forward prompt_cache_key for server-side prefix cache routing ([#515](https://github.com/Gitlawb/zero/issues/515)) ([87e7e69](https://github.com/Gitlawb/zero/commit/87e7e69afd18b5539579856f3a61c6a95bc445ae))
* **providers:** add `zero providers models` to discover a provider's models ([#386](https://github.com/Gitlawb/zero/issues/386)) ([0bc8074](https://github.com/Gitlawb/zero/commit/0bc8074c97b0310e4a9d70c3f967003ee5e8a59f))
* **providers:** add KiloCode and OpenCode provider support ([#388](https://github.com/Gitlawb/zero/issues/388)) ([b1ccb6d](https://github.com/Gitlawb/zero/commit/b1ccb6d9c1875377f5e5ea81a1304edd1e41ab4f))
* **providers:** add Meituan LongCat catalog preset ([#424](https://github.com/Gitlawb/zero/issues/424)) ([b4275e3](https://github.com/Gitlawb/zero/commit/b4275e350472b2490212bf814709819d354c1216))
* **providers:** split minimax zai into intl cn ([#398](https://github.com/Gitlawb/zero/issues/398)) ([aaad4d2](https://github.com/Gitlawb/zero/commit/aaad4d271270f41af837b6f3b60ae80beba0c645))
* require manual approval before npm publish + drop release-as pin ([#369](https://github.com/Gitlawb/zero/issues/369)) ([bd89a1f](https://github.com/Gitlawb/zero/commit/bd89a1f451643c1b65ec803070abc7b116631ebe))
* **sandbox:** unelevated Windows fallback tier instead of prompts-only degrade ([#427](https://github.com/Gitlawb/zero/issues/427)) ([b9ddd6f](https://github.com/Gitlawb/zero/commit/b9ddd6f42138312a1fee8d8bb67c46c8eb1dea2f))
* support shift enter for composer newlines ([#462](https://github.com/Gitlawb/zero/issues/462)) ([daf65e0](https://github.com/Gitlawb/zero/commit/daf65e0af9a040314d4ab337b0ad59c55416b7bc))
* **tui:** /loop — repeat a prompt or command on an interval or self-paced ([#502](https://github.com/Gitlawb/zero/issues/502)) ([387fe67](https://github.com/Gitlawb/zero/commit/387fe67ee7cd81317c9c969f5906a4437080fea3))
* **tui:** add search/filter to provider picker in setup wizard ([#400](https://github.com/Gitlawb/zero/issues/400)) ([2fcea71](https://github.com/Gitlawb/zero/commit/2fcea71778d23e050c93409c471aef45b68c1621))
* **update:** add zero upgrade command to apply self-updates ([#461](https://github.com/Gitlawb/zero/issues/461)) ([5f36349](https://github.com/Gitlawb/zero/commit/5f36349c1884e81fa9bc66bb5fe813b627e897b7))
### Bug Fixes
* **action:** keep provider key scoped to zero step ([#448](https://github.com/Gitlawb/zero/issues/448)) ([407a927](https://github.com/Gitlawb/zero/commit/407a92739ff508cba32d2c12b3f36f0efcdd54c3))
* add android platform support for Termux npm install ([#455](https://github.com/Gitlawb/zero/issues/455)) ([9bd93c6](https://github.com/Gitlawb/zero/commit/9bd93c62f8d57fb74057284aa66a1b6e1429dcdd)), closes [#449](https://github.com/Gitlawb/zero/issues/449)
* **agent:** reject a malformed additional_permissions payload before prompting ([#453](https://github.com/Gitlawb/zero/issues/453)) ([e4f760e](https://github.com/Gitlawb/zero/commit/e4f760ee8bd57299cd2fcb37e8e23130037c2607))
* allow non-TLS connections to private-network provider endpoints ([#444](https://github.com/Gitlawb/zero/issues/444)) ([1d86384](https://github.com/Gitlawb/zero/commit/1d8638466ca31517eb9db2b9353d3dce1cbeeabc))
* **auth:** route zero auth login chatgpt to the dedicated ChatGPT flow ([#443](https://github.com/Gitlawb/zero/issues/443)) ([305a62c](https://github.com/Gitlawb/zero/commit/305a62c954ca6cec00bc58d5398f933415156aff))
* **config:** fall back to a usable saved provider instead of forcing full re-onboarding ([#410](https://github.com/Gitlawb/zero/issues/410)) ([c60ad87](https://github.com/Gitlawb/zero/commit/c60ad8729f79bb841114d352ee2d2fe29d5d0e41))
* **config:** let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible ([#497](https://github.com/Gitlawb/zero/issues/497)) ([30dd7c3](https://github.com/Gitlawb/zero/commit/30dd7c3112ad22d42fa12b5addd4e38f4beda42a)), closes [#479](https://github.com/Gitlawb/zero/issues/479)
* **config:** unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors ([#385](https://github.com/Gitlawb/zero/issues/385)) ([72eed06](https://github.com/Gitlawb/zero/commit/72eed06b4f94c43d75d31fe54a58d2f566de059e))
* **config:** use ~/.config on macOS and enter setup when no provider ([#371](https://github.com/Gitlawb/zero/issues/371)) ([#372](https://github.com/Gitlawb/zero/issues/372)) ([027a8f2](https://github.com/Gitlawb/zero/commit/027a8f2768b17b89f5c8270887f156e2ccda69ea))
* **docs:** rename AGENTS.MD &gt; AGENTS.md ([#438](https://github.com/Gitlawb/zero/issues/438)) ([4266baf](https://github.com/Gitlawb/zero/commit/4266baf222df583ed2078b776687f12d496475b5))
* **gemini:** strip unsupported JSON Schema fields from tool declarations ([#374](https://github.com/Gitlawb/zero/issues/374)) ([39e7100](https://github.com/Gitlawb/zero/commit/39e7100674150144a1152e3110c64c7cf0321d64)), closes [#373](https://github.com/Gitlawb/zero/issues/373)
* **install:** persist install dir to user PATH on Windows ([#407](https://github.com/Gitlawb/zero/issues/407)) ([bdb1b0e](https://github.com/Gitlawb/zero/commit/bdb1b0ecd15859b1712a6037d296dace7f9c3c3f))
* **mcp:** block cross-origin credential redirects ([#396](https://github.com/Gitlawb/zero/issues/396)) ([f915f70](https://github.com/Gitlawb/zero/commit/f915f70e5a3096e2419fa8d961a0f84a626fa4a9))
* **oauth:** treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile ([#445](https://github.com/Gitlawb/zero/issues/445)) ([d05e914](https://github.com/Gitlawb/zero/commit/d05e9148a7f79f67d1d3c31fca2775f21fbd331e))
* **openai:** handle Ollama reasoning stream deltas ([#486](https://github.com/Gitlawb/zero/issues/486)) ([f6c0606](https://github.com/Gitlawb/zero/commit/f6c060631e18e082dda24cc4dc0903c31c2120d6))
* preserve conversation context in exec prompts ([#460](https://github.com/Gitlawb/zero/issues/460)) ([949ee43](https://github.com/Gitlawb/zero/commit/949ee43f71e5cb7fab4695c5cb7b442fe4ecfbf7))
* **provider-wizard:** allow multiple custom OpenAI-compatible providers ([#403](https://github.com/Gitlawb/zero/issues/403)) ([3fbbd28](https://github.com/Gitlawb/zero/commit/3fbbd28e4c586822cc4312c86232d94befe56e87))
* **sandbox:** fix nested pipe creation under the Windows restricted token ([#456](https://github.com/Gitlawb/zero/issues/456)) ([563a6db](https://github.com/Gitlawb/zero/commit/563a6dbe91e65d5daeefd7626e8a77e30a6d8fb2))
* **sandbox:** gate /tmp test assertions on GOOS, not path existence ([#426](https://github.com/Gitlawb/zero/issues/426)) ([f653dca](https://github.com/Gitlawb/zero/commit/f653dcac363fb69ad7be5b35e6e0fa6d2bce476d))
* **sandbox:** self-heal a corrupt unelevated setup marker ([#437](https://github.com/Gitlawb/zero/issues/437)) ([8d0c5fe](https://github.com/Gitlawb/zero/commit/8d0c5feccb8bdbfb015df0508aa6e3bcbd1fd0e8))
* **specialist:** cap max specialist nesting depth ([#491](https://github.com/Gitlawb/zero/issues/491)) ([177442c](https://github.com/Gitlawb/zero/commit/177442cfe4015bd8df04cc9894f98b468ee796d4))
* Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs ([#509](https://github.com/Gitlawb/zero/issues/509)) ([0f69d99](https://github.com/Gitlawb/zero/commit/0f69d995e9b586b774f66c066b21abab5e03024a))
* **tools:** Block MSYS coreutils under Windows sandbox ([#476](https://github.com/Gitlawb/zero/issues/476)) ([81aad58](https://github.com/Gitlawb/zero/commit/81aad58d97839e51c068e2f08907618991fdc3fb))
* **tools:** CRLF line ending mismatch in edit_file tool on Windows ([#378](https://github.com/Gitlawb/zero/issues/378)) ([33dc7ae](https://github.com/Gitlawb/zero/commit/33dc7ae2cc82c5389675531e1416856dae7151ce))
* **tools:** fix cmd.exe /S/C corrupting commands with embedded quotes ([#465](https://github.com/Gitlawb/zero/issues/465)) ([190241b](https://github.com/Gitlawb/zero/commit/190241bd593f43211b766e0b13c8e89802d4bb37))
* **tools:** flag piped POSIX utilities before running on Windows ([#412](https://github.com/Gitlawb/zero/issues/412)) ([5658a36](https://github.com/Gitlawb/zero/commit/5658a366274fc59a9d5336b06a21019c9c25cbf1))
* **tools:** make grep and glob respect run cancellation ([#464](https://github.com/Gitlawb/zero/issues/464)) ([ba6c026](https://github.com/Gitlawb/zero/commit/ba6c0264697b7d7ed479f6e782fba9700a481e3d))
* **tools:** require permission before web_search requests ([#382](https://github.com/Gitlawb/zero/issues/382)) ([960db96](https://github.com/Gitlawb/zero/commit/960db9660e4e31dc588fe8f7d6f116ff5e225566))
* **tui:** compose help overlay through the viewport overlay pipeline ([#421](https://github.com/Gitlawb/zero/issues/421)) ([5b2b4de](https://github.com/Gitlawb/zero/commit/5b2b4dea1aaf9e0f68baa25e97e83296fb17b1a2))
* **tui:** keep the profile name on /model switch so the stored key resolves ([#441](https://github.com/Gitlawb/zero/issues/441)) ([9134148](https://github.com/Gitlawb/zero/commit/9134148f4df3e4e556fba6c2f8babfdf6fcfeee1)), closes [#440](https://github.com/Gitlawb/zero/issues/440)
* **tui:** resolve every permission request so the agent can't deadlock ([#397](https://github.com/Gitlawb/zero/issues/397)) ([952788f](https://github.com/Gitlawb/zero/commit/952788f72d32957659fe004521fcc8372b9ba9b4))
* **tui:** show an M suffix for million-scale token counts ([#457](https://github.com/Gitlawb/zero/issues/457)) ([0562e3b](https://github.com/Gitlawb/zero/commit/0562e3bef7df2328610a48a1e81632a8da4aec64))
* **tui:** title /model rows by model name, not the catalog description ([#395](https://github.com/Gitlawb/zero/issues/395)) ([cdf9d83](https://github.com/Gitlawb/zero/commit/cdf9d839ae57a729f292f36f7c5b0c67b41b288d))
### Performance Improvements
* cache TUI model registry ([#496](https://github.com/Gitlawb/zero/issues/496)) ([e7d88b4](https://github.com/Gitlawb/zero/commit/e7d88b4b518049733da25a8447c00144bd1da518))
* universal tool-output ceiling with spill + async post-edit diagnostics ([#518](https://github.com/Gitlawb/zero/issues/518)) ([95ccd5b](https://github.com/Gitlawb/zero/commit/95ccd5bc327f6fb464ff0239f7229de789f578dc))
## 0.1.0 (2026-07-02)
+34
View File
@@ -186,6 +186,7 @@ Common slash commands:
| `/spec`, `/plan` | draft and review a plan before building |
| `/image` | attach an image for vision-capable models |
| `/resume`, `/rewind` | continue or roll back local sessions |
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
| `/compact`, `/context` | manage context usage |
| `/permissions`, `/tools` | inspect available tools and policy |
| `/add-dir` | allow an extra write directory for this session |
@@ -283,6 +284,39 @@ zero cron scheduled agent jobs
zero update check for newer releases
```
## Extending Zero
### Project and personal instructions
Zero appends project-specific guidance to the system prompt from the first
`AGENTS.md`, `ZERO.md`, or `.zero/AGENTS.md` file found in each directory from
the git root down to your current working directory (checked in that order
per directory). Files are injected general-to-specific, capped at 8 KiB per
file and 32 KiB total.
A personal `ZERO.md` under `config.UserConfigDir()/zero/ZERO.md`
(`$XDG_CONFIG_HOME/zero/ZERO.md` or `~/.config/zero/ZERO.md` on Linux/macOS,
`%AppData%\Roaming\zero\ZERO.md` on Windows) applies across every workspace, ahead of any project guidelines.
### Plugins
Plugins are discovered from `~/.config/zero/plugins/<name>/plugin.json` (user
scope — `$XDG_CONFIG_HOME` or `~/.config` on every OS, independent of the
`config.UserConfigDir()` path used above) and `<cwd>/.zero/plugins/<name>/plugin.json`
(project scope — resolved from the current working directory, not the repo
root), and managed with `zero plugins`. A manifest can declare:
- `tools` — custom tools (`command`, `args`, `inputSchema`, and a
`permission` of `prompt` or `deny`; `allow` is honored only when manifest tool
auto-approval is enabled)
- `hooks` — commands run on `beforeTool`, `afterTool`, `sessionStart`, or
`sessionEnd`
- `prompts` and `skills` — additional prompt/skill files
MCP servers (`zero mcp`) and standalone markdown skills (`zero skills`) use
the same extension points and can also be wired up outside of a plugin
manifest.
## Appearance And Accessibility
| Control | Effect |
+4 -4
View File
@@ -180,6 +180,7 @@ runs:
ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }}
ZERO_INPUT_WORKTREE: ${{ inputs.worktree }}
ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }}
ACTION_PATH: ${{ github.action_path }}
RUNNER_TEMP: ${{ runner.temp }}
run: |
set -euo pipefail
@@ -247,10 +248,9 @@ runs:
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"
# Best-effort one-line summary: last non-empty line of the captured output.
summary="$(grep -v '^[[:space:]]*$' "${output_file}" | tail -n 1 || true)"
# Keep the summary single-line and bounded for the step output.
summary="$(printf '%s' "${summary}" | cut -c1-280)"
# Best-effort one-line summary. Structured output ends with lifecycle
# metadata, so parse final/error events instead of tailing stdout.
summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}" || true)"
{
echo "summary<<ZERO_EOF"
printf '%s\n' "${summary}"
+185
View File
@@ -0,0 +1,185 @@
package agent
import (
"context"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Post-edit LSP diagnostics used to run synchronously inside edit_file /
// write_file: every edit blocked its tool result on a fresh-publish wait with
// a quiet-period debounce (≥300ms floor that reset on each publish, 10s cap) —
// the largest recurring latency tax in an edit-heavy session. Collection is
// now asynchronous: a file is enqueued the moment a mutating tool finishes, a
// single worker checks it while the rest of the tool batch (and any
// self-correct verification) runs, and the loop drains completed results just
// before building the NEXT provider request, appending errors as a user-role
// nudge. The model always issues another request after an edit turn — it has
// to read its tool results — so it still sees introduced errors at the same
// decision point as before, without any tool call stalling 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); a file re-edited after its check completed
// is re-enqueued and the newer result wins. When the worker is still busy at
// drain time, the drain gives up after a short wait and the results are
// delivered on the following turn instead — they stay accurate because any
// further edit re-enqueues the file.
// asyncDiagnosticsDrainTimeout bounds how long a turn waits on the in-flight
// check before deferring delivery to the next turn. Most checks finish well
// under it (debounce floor + analysis); a cold language server on a large
// repo does not get to stall the loop the way the old inline 10s cap could.
// A var so tests can shorten the wait.
var asyncDiagnosticsDrainTimeout = 3 * time.Second
// asyncDiagnosticsFinalDrainTimeout is the wait used when the run is about to
// FINALIZE (natural completion or the max-turns summary): there is no later
// turn to defer to, so the gate waits out the old inline-era budget rather
// than silently dropping an error the last edit introduced. A var so tests
// can shorten the wait.
var asyncDiagnosticsFinalDrainTimeout = 10 * time.Second
// asyncDiagnosticsNudge prefixes the drained diagnostics blocks; phrased like
// the old inline block so the model reacts the same way.
const asyncDiagnosticsNudge = "Diagnostics after your recent edits (fix any errors you introduced):\n"
type asyncDiagnostics struct {
check func(context.Context, string) string
workspaceRoot string
mu sync.Mutex
queue []string
queued map[string]bool
results map[string]string
// working is non-nil while the worker goroutine runs and is closed when it
// exits, so drain can wait for quiescence without polling.
working chan struct{}
}
// newAsyncDiagnostics returns nil when check is nil, and every method on a nil
// receiver is a no-op — callers wire it unconditionally.
func newAsyncDiagnostics(check func(context.Context, string) string, workspaceRoot string) *asyncDiagnostics {
if check == nil {
return nil
}
return &asyncDiagnostics{
check: check,
workspaceRoot: workspaceRoot,
queued: map[string]bool{},
results: map[string]string{},
}
}
// enqueue schedules changed files for a background check. Paths are
// workspace-relative from Result.ChangedFiles (absolute when under a granted
// extra write root), mirroring SelfCorrector's resolution.
func (diagnostics *asyncDiagnostics) enqueue(ctx context.Context, files []string) {
if diagnostics == nil || len(files) == 0 {
return
}
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
for _, file := range files {
path := diagnostics.absPath(file)
if diagnostics.queued[path] {
continue
}
diagnostics.queued[path] = true
// A completed result for this file describes a pre-edit state now.
delete(diagnostics.results, path)
diagnostics.queue = append(diagnostics.queue, path)
}
if diagnostics.working == nil && len(diagnostics.queue) > 0 {
done := make(chan struct{})
diagnostics.working = done
go diagnostics.work(ctx, done)
}
}
func (diagnostics *asyncDiagnostics) work(ctx context.Context, done chan struct{}) {
defer close(done)
for {
diagnostics.mu.Lock()
if len(diagnostics.queue) == 0 || ctx.Err() != nil {
diagnostics.working = nil
diagnostics.mu.Unlock()
return
}
path := diagnostics.queue[0]
diagnostics.queue = diagnostics.queue[1:]
// Un-mark before checking so an edit that lands DURING the check
// re-enqueues the file and it is checked again in its final state.
delete(diagnostics.queued, path)
diagnostics.mu.Unlock()
block := diagnostics.check(ctx, path)
diagnostics.mu.Lock()
if block != "" {
diagnostics.results[path] = block
} else {
delete(diagnostics.results, path)
}
diagnostics.mu.Unlock()
}
}
// drain waits briefly for the worker to go quiet, then formats and consumes
// all completed error blocks as one nudge. Returns "" when there is nothing
// to report or the worker is still busy (results then surface next turn).
func (diagnostics *asyncDiagnostics) drain(ctx context.Context) string {
return diagnostics.drainWithin(ctx, asyncDiagnosticsDrainTimeout)
}
// drainFinal is drain with the finalization budget: called before the run's
// last model request, where an undelivered result would otherwise be lost.
func (diagnostics *asyncDiagnostics) drainFinal(ctx context.Context) string {
return diagnostics.drainWithin(ctx, asyncDiagnosticsFinalDrainTimeout)
}
func (diagnostics *asyncDiagnostics) drainWithin(ctx context.Context, timeout time.Duration) string {
if diagnostics == nil {
return ""
}
diagnostics.mu.Lock()
busy := diagnostics.working
diagnostics.mu.Unlock()
if busy != nil {
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-busy:
case <-timer.C:
return ""
case <-ctx.Done():
return ""
}
}
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
if len(diagnostics.results) == 0 {
return ""
}
paths := make([]string, 0, len(diagnostics.results))
for path := range diagnostics.results {
paths = append(paths, path)
}
sort.Strings(paths)
blocks := make([]string, 0, len(paths))
for _, path := range paths {
blocks = append(blocks, diagnostics.results[path])
}
diagnostics.results = map[string]string{}
return asyncDiagnosticsNudge + strings.Join(blocks, "\n")
}
func (diagnostics *asyncDiagnostics) absPath(path string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Join(diagnostics.workspaceRoot, path)
}
+338
View File
@@ -0,0 +1,338 @@
package agent
import (
"context"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
var diagnostics *asyncDiagnostics
if diagnostics != newAsyncDiagnostics(nil, "/ws") {
t.Fatal("nil check must yield a nil collector")
}
diagnostics.enqueue(context.Background(), []string{"a.go"})
if nudge := diagnostics.drain(context.Background()); nudge != "" {
t.Fatalf("nil collector drain = %q, want empty", nudge)
}
}
func TestAsyncDiagnosticsCollectsAndDrainsOnce(t *testing.T) {
// A real absolute root: a literal "/ws" is not filepath.IsAbs on Windows
// (no drive letter), which is exactly what the assertions below check.
root := t.TempDir()
var mu sync.Mutex
var checked []string
check := func(_ context.Context, absPath string) string {
mu.Lock()
checked = append(checked, absPath)
mu.Unlock()
if filepath.Base(absPath) == "clean.go" {
return ""
}
return "ERR " + filepath.Base(absPath)
}
diagnostics := newAsyncDiagnostics(check, root)
diagnostics.enqueue(context.Background(), []string{"broken.go", "clean.go"})
nudge := diagnostics.drain(context.Background())
if !strings.HasPrefix(nudge, asyncDiagnosticsNudge) {
t.Fatalf("nudge missing prefix: %q", nudge)
}
if !strings.Contains(nudge, "ERR broken.go") || strings.Contains(nudge, "clean.go") {
t.Fatalf("nudge = %q, want broken.go errors only", nudge)
}
mu.Lock()
for _, path := range checked {
if !filepath.IsAbs(path) || !strings.HasPrefix(path, root+string(filepath.Separator)) {
t.Fatalf("check received %q, want path under workspace root %q", path, root)
}
}
mu.Unlock()
if second := diagnostics.drain(context.Background()); second != "" {
t.Fatalf("second drain = %q, want empty (results are consumed)", second)
}
}
// A check still running at drain time defers delivery to the next drain
// instead of blocking the turn.
func TestAsyncDiagnosticsSlowCheckDefersToNextDrain(t *testing.T) {
previous := asyncDiagnosticsDrainTimeout
asyncDiagnosticsDrainTimeout = 20 * time.Millisecond
defer func() { asyncDiagnosticsDrainTimeout = previous }()
release := make(chan struct{})
check := func(_ context.Context, absPath string) string {
<-release
return "ERR " + filepath.Base(absPath)
}
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"slow.go"})
start := time.Now()
if nudge := diagnostics.drain(context.Background()); nudge != "" {
t.Fatalf("drain during in-flight check = %q, want empty", nudge)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("drain blocked %v, want bounded by the (shortened) timeout", elapsed)
}
close(release)
deadline := time.Now().Add(2 * time.Second)
for {
if nudge := diagnostics.drain(context.Background()); nudge != "" {
if !strings.Contains(nudge, "ERR slow.go") {
t.Fatalf("deferred nudge = %q", nudge)
}
return
}
if time.Now().After(deadline) {
t.Fatal("deferred diagnostics never delivered")
}
time.Sleep(5 * time.Millisecond)
}
}
// Re-enqueueing a file after its check completed replaces the stale result.
func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
var mu sync.Mutex
response := "ERR old"
check := func(context.Context, string) string {
mu.Lock()
defer mu.Unlock()
return response
}
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"a.go"})
waitForIdle(t, diagnostics)
mu.Lock()
response = "ERR new"
mu.Unlock()
diagnostics.enqueue(context.Background(), []string{"a.go"})
nudge := diagnostics.drain(context.Background())
if !strings.Contains(nudge, "ERR new") || strings.Contains(nudge, "ERR old") {
t.Fatalf("nudge = %q, want only the re-check result", nudge)
}
}
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
diagnostics.mu.Lock()
busy := diagnostics.working
diagnostics.mu.Unlock()
if busy == nil {
return
}
if time.Now().After(deadline) {
t.Fatal("worker never went idle")
}
time.Sleep(2 * time.Millisecond)
}
}
// changedFilesTool is a fake mutating tool reporting a changed file without
// touching the filesystem.
type changedFilesTool struct{}
func (changedFilesTool) Name() string { return "fake_edit" }
func (changedFilesTool) Description() string { return "test mutating tool" }
func (changedFilesTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (changedFilesTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "test"}
}
func (changedFilesTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "edited", ChangedFiles: []string{"main.go"}}
}
// End-to-end through Run: an edit turn does NOT carry diagnostics in the tool
// result (the old inline path), but the next request contains the background
// diagnostics nudge, before the model finalizes.
func TestRunDeliversAsyncDiagnosticsNudgeNextTurn(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
root := t.TempDir()
var checkedPath string
var mu sync.Mutex
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: root,
FileDiagnostics: func(_ context.Context, absPath string) string {
mu.Lock()
checkedPath = absPath
mu.Unlock()
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 provider turns, got %d", len(provider.requests))
}
// The tool result itself must NOT block on / embed diagnostics.
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleTool && strings.Contains(message.Content, "boom") {
t.Fatalf("diagnostics leaked into the tool result: %q", message.Content)
}
}
// The nudge must be present in the second request as a user message.
found := false
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
if !strings.Contains(message.Content, "boom") {
t.Fatalf("nudge missing diagnostics: %q", message.Content)
}
found = true
}
}
if !found {
t.Fatal("no async diagnostics nudge in the follow-up request")
}
mu.Lock()
defer mu.Unlock()
if want := filepath.Join(root, "main.go"); checkedPath != want {
t.Fatalf("checked path = %q, want %q", checkedPath, want)
}
}
// When the run hits the maxTurns ceiling right after an edit turn, the
// final-answer request must still carry the diagnostics nudge — otherwise an
// error introduced by the last edit would go unreported in the summary.
func TestRunDrainsAsyncDiagnosticsBeforeMaxTurnsFinalAnswer(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "summary"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: t.TempDir(),
MaxTurns: 1,
FileDiagnostics: func(context.Context, string) string {
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "summary" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected the max-turns final-answer request, got %d requests", len(provider.requests))
}
found := false
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
found = true
}
}
if !found {
t.Fatal("max-turns final-answer request missing the diagnostics nudge")
}
}
// When the model finalizes while a slow check is still in flight (the
// per-turn drain missed it), the finalization gate must wait it out and give
// the model one more turn with the nudge instead of dropping the errors.
func TestRunFinalizationGateDeliversLateDiagnostics(t *testing.T) {
previous := asyncDiagnosticsDrainTimeout
asyncDiagnosticsDrainTimeout = time.Millisecond
defer func() { asyncDiagnosticsDrainTimeout = previous }()
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done after fix"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: t.TempDir(),
FileDiagnostics: func(context.Context, string) string {
// Slower than the (shortened) per-turn drain, well under the
// finalization budget.
time.Sleep(100 * time.Millisecond)
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done after fix" {
t.Fatalf("final answer = %q, want the post-nudge answer", result.FinalAnswer)
}
if len(provider.requests) != 3 {
t.Fatalf("expected a third request carrying the late nudge, got %d requests", len(provider.requests))
}
found := false
for _, message := range provider.requests[2].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
if !strings.Contains(message.Content, "boom") {
t.Fatalf("nudge missing diagnostics: %q", message.Content)
}
found = true
}
}
if !found {
t.Fatal("finalization gate did not deliver the late diagnostics nudge")
}
}
+33 -16
View File
@@ -2,6 +2,7 @@ package agent
import (
"fmt"
"runtime"
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -27,10 +28,20 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
}
return nil
}
for _, tokens := range segments {
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
return append([]string(nil), tokens...)
// Only propose approving a prefix of one segment when every other segment
// in the command is independently known-safe. Once a prefix is approved,
// shellExecutionArgsForApproval escalates the whole command (every
// segment, not just the approved one) to bypass the sandbox, so offering a
// prefix that leaves an MSYS-prone (or otherwise unsafe) segment uncovered
// would let that segment run unsandboxed without ever being reviewed.
for index, tokens := range segments {
if knownSafeCommandSegment(tokens) {
continue
}
if !sandbox.ValidCommandPrefix(tokens) || !otherSegmentsKnownSafe(segments, index) {
return nil
}
return append([]string(nil), tokens...)
}
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
return nil
@@ -38,6 +49,20 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
return append([]string(nil), segments[0]...)
}
// otherSegmentsKnownSafe reports whether every segment other than the one at
// skip is known-safe on its own.
func otherSegmentsKnownSafe(segments [][]string, skip int) bool {
for index, tokens := range segments {
if index == skip {
continue
}
if !knownSafeCommandSegment(tokens) {
return false
}
}
return true
}
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
if !isShellCommandTool(toolName) || options.Sandbox == nil {
return sandbox.CommandPrefixGrant{}, false, false
@@ -168,10 +193,6 @@ func cleanPrefixRule(prefix []string) []string {
return cleaned
}
func safeRequestedPrefix(prefix []string, command []string) bool {
return safeRequestedPrefixForSegments(prefix, [][]string{command})
}
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
return false
@@ -196,14 +217,6 @@ func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
return matched
}
func safeShellCommandTokens(command string) ([]string, bool) {
segments, ok := safeShellCommandSegments(command)
if !ok || len(segments) != 1 {
return nil, false
}
return segments[0], true
}
func safeShellCommandSegments(command string) ([][]string, bool) {
command = strings.TrimSpace(command)
if command == "" {
@@ -275,7 +288,11 @@ func knownSafeCommandSegment(command []string) bool {
if len(command) == 0 {
return false
}
switch commandName(command[0]) {
name := commandName(command[0])
if runtime.GOOS == "windows" && tools.MsysProneCommandName(name) {
return false
}
switch name {
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
"true", "uname", "uniq", "wc", "which", "whoami":
+50 -1
View File
@@ -1,9 +1,11 @@
package agent
import (
"runtime"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
@@ -35,12 +37,37 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
if runtime.GOOS == "windows" {
// head is MSYS-prone on Windows (#458), so proposedCommandPrefix must
// not offer "ps aux" as a reusable prefix here: approving it would
// escalate the whole command, including the uncovered head segment,
// to bypass the sandbox unreviewed. See
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered for
// the platform-independent regression coverage of this behavior.
if got != nil {
t.Fatalf("expected no prefix on Windows because head is MSYS-prone, got %#v", got)
}
return
}
want := []string{"ps", "aux"}
if !equalStringSlices(got, want) {
t.Fatalf("prefix = %#v, want %#v", got, want)
}
}
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered guards
// against proposedCommandPrefix offering to approve one segment of a
// multi-segment command (e.g. "ps aux") while a different segment (e.g. "npm
// install") is not known-safe. shellExecutionArgsForApproval escalates the
// entire command once any prefix is approved, so an uncovered unsafe segment
// would bypass the sandbox unreviewed. Uses npm, which is never known-safe on
// any platform, so the assertion does not depend on runtime.GOOS.
func TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered(t *testing.T) {
if got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}); got != nil {
t.Fatalf("expected no prefix because npm segment is not known-safe, got %#v", got)
}
}
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "git status --short && git status --branch",
@@ -120,13 +147,35 @@ func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
// head is MSYS-prone on Windows (#458) and must not count as a known-safe tail.
command := "ps aux | head -5"
if runtime.GOOS == "windows" {
command = "ps aux | echo ok"
}
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, Options{Sandbox: engine})
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": command}, Options{Sandbox: engine})
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
}
}
func TestKnownSafeCommandSegmentRejectsMsysProneOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only known-safe MSYS guard")
}
for _, command := range [][]string{{"head", "-5"}, {"cat", "file.txt"}, {"grep", "pat"}} {
if knownSafeCommandSegment(command) {
t.Fatalf("expected %q to be unsafe on Windows, got known-safe", command)
}
}
if !knownSafeCommandSegment([]string{"echo", "ok"}) {
t.Fatal("expected echo to remain known-safe on Windows")
}
if !tools.MsysProneCommandName("head") {
t.Fatal("expected head to be MSYS-prone")
}
}
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
+54 -6
View File
@@ -137,6 +137,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
guards := newGuardState()
compactor := newCompactionState(options)
// Background post-edit diagnostics: files changed by mutating tools are
// checked off the tool-call critical path and any errors are appended as a
// nudge before the NEXT request (see async_diagnostics.go). nil when no
// FileDiagnostics callback is wired; every method no-ops on nil.
postEditDiagnostics := newAsyncDiagnostics(options.FileDiagnostics, options.Cwd)
// loaded tracks deferred-eligible tools the model has pulled via tool_search
// during THIS run. It is consulted by partitionTools each turn to expose a
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
@@ -159,6 +165,17 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
for turn := 0; turn < maxTurns; turn++ {
result.Turns = turn + 1
// Deliver background post-edit diagnostics from the previous turn's edits
// BEFORE compaction so the nudge is part of the request being budgeted.
// A brief wait at most (asyncDiagnosticsDrainTimeout); an unfinished check
// simply delivers on a later turn.
if nudge := postEditDiagnostics.drain(ctx); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
}
// Build the per-turn tool list first so proactive compaction can include
// the tool-definition tokens (they ride on every request) in its estimate.
// partitionTools depends only on registry/permissions/options/loaded, not on
@@ -474,6 +491,19 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
continue
}
}
// Finalization diagnostics gate: edits from this run may still have
// checks in flight — the per-turn drain waits only briefly and defers
// to "a later turn", but a final answer means there is no later turn.
// Wait out the full inline-era budget once; an introduced error gives
// the model one more turn to see (and fix) it instead of being lost.
// Free for runs that never edited: an idle collector returns "".
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
continue
}
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
@@ -590,11 +620,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
}
// Post-edit self-correction: collect the files this successful mutating
// tool changed; verification runs once over the union after the batch.
// A read-only tool (no ChangedFiles) never contributes.
if options.SelfCorrect != nil && toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
// Collect the files this successful mutating tool changed. Self-correct
// verification runs once over the union after the batch; background
// diagnostics start NOW so the language server analyzes while the rest
// of the batch executes. A read-only tool (no ChangedFiles) never
// contributes.
if toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
changedFilesThisBatch = append(changedFilesThisBatch, toolResult.ChangedFiles...)
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
}
}
@@ -677,6 +710,17 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// mid-run, not a natural completion (a finished run returns via the no-tool-call
// success path before this). Under the headless completion gate that is INCOMPLETE,
// not success, so a run that loops to the turn limit isn't reported as done.
//
// The final-answer call is one more model request, so diagnostics from the
// LAST turn's edits get a drain too — with the finalization budget, since
// there is no later turn to defer to — otherwise an error introduced by
// the final edit would go unreported in the summary.
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
}
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
result.FinalAnswer = answer
result.FinishReason = finishReason
@@ -1100,8 +1144,12 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// Per-session file version tracker so write_file/edit_file refuse to clobber
// a file that changed on disk outside Zero since it was last read.
FileTracker: options.FileTracker,
// Inline post-edit diagnostics for mutating tools (nil = disabled).
Diagnostics: options.FileDiagnostics,
// Post-edit diagnostics are NOT forwarded to the tools: they used to run
// synchronously inside edit_file/write_file and block every mutating tool
// call on the language server (≥300ms debounce floor, 10s cap). The loop
// now checks changed files in the background and nudges the model before
// its next request instead (see async_diagnostics.go). The tools' inline
// mechanism (tools.RunOptions.Diagnostics) stays for direct API callers.
// Forward the run's operator tool filters so a filter-aware tool
// (tool_search) never discloses or loads an operator-hidden deferred tool.
EnabledTools: options.EnabledTools,
+11 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
@@ -1945,6 +1946,11 @@ func TestRunCommandPrefixApprovalBypassesSandboxForMatchingShellCalls(t *testing
func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T) {
root := t.TempDir()
segmentedCommand := `ps aux | head -5`
if runtime.GOOS == "windows" {
// head is MSYS-prone on Windows (#458) and no longer counts as a known-safe tail.
segmentedCommand = `ps aux | echo ok`
}
retryTool := &sandboxDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
@@ -1958,7 +1964,7 @@ func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T)
},
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: fmt.Sprintf(`{"command":%q}`, segmentedCommand)},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
{Type: zeroruntime.StreamEventDone},
},
@@ -2907,8 +2913,10 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
t.Fatalf("expected operating system in environment block, got %q", prompt)
}
if runtime.GOOS == "windows" {
if !strings.Contains(prompt, "Windows cmd.exe syntax") || !strings.Contains(prompt, "cwd argument") {
t.Fatalf("expected Windows cmd.exe shell guidance in prompt, got %q", prompt)
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
}
}
} else if !strings.Contains(prompt, "/bin/sh syntax") {
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
@@ -34,7 +34,7 @@ func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
}
// The eager tools must precede the loaded deferred tool (appended, not inserted).
if !(readPos < alphaPos && searchPos < alphaPos) {
if readPos >= alphaPos || searchPos >= alphaPos {
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
}
// beta was never loaded — it stays hidden.
+30 -5
View File
@@ -7,14 +7,18 @@ import (
)
func TestSkillsContextCapsLongList(t *testing.T) {
skills := make([]SkillInfo, 0, 40)
for i := 0; i < 40; i++ {
// 60 skills with realistic trigger-rich descriptions (~180 chars each, under
// the 200-rune truncation) total well past the 4096-byte list budget, so the
// overflow summary must kick in while the block stays bounded.
longDesc := strings.Repeat("use when the request touches deployments, release notes, or verification; ", 2) + "number "
skills := make([]SkillInfo, 0, 60)
for i := 0; i < 60; i++ {
n := strconv.Itoa(i)
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: longDesc + n})
}
got := skillsContext(Options{Skills: skills})
if len(got) > 1200 {
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
if len(got) > skillsContextListBudget+1000 {
t.Fatalf("skills block should stay bounded near the %d-byte budget, got %d bytes:\n%s", skillsContextListBudget, len(got), got)
}
if !strings.Contains(got, "more (call skill") {
t.Fatalf("expected an overflow summary line, got:\n%s", got)
@@ -25,6 +29,27 @@ func TestSkillsContextCapsLongList(t *testing.T) {
}
}
// A realistic mid-size skill set (20 skills, trigger-rich descriptions) must be
// listed in FULL — no overflow summary. This pins the fix for the old 640-byte
// budget, under which skills past ~#6 were invisible to the model and therefore
// never triggered.
func TestSkillsContextListsRealisticSetInFull(t *testing.T) {
desc := "Use when the user asks about deployments, release notes, or pre-merge verification runs."
skills := make([]SkillInfo, 0, 20)
for i := 0; i < 20; i++ {
skills = append(skills, SkillInfo{Name: "skill-" + strconv.Itoa(i), Description: desc})
}
got := skillsContext(Options{Skills: skills})
if strings.Contains(got, "more (call skill") {
t.Fatalf("20 described skills must all be listed without overflow, got:\n%s", got)
}
for i := 0; i < 20; i++ {
if !strings.Contains(got, "- skill-"+strconv.Itoa(i)+":") {
t.Fatalf("skill-%d missing from the list:\n%s", i, got)
}
}
}
func TestSkillsContext(t *testing.T) {
if got := skillsContext(Options{}); got != "" {
t.Fatalf("no skills should yield an empty section, got %q", got)
+19 -8
View File
@@ -191,10 +191,19 @@ func specialistDelegationContext(options Options) string {
// when no skills are installed, so a skill-less run reproduces the previous prompt
// byte-for-byte.
// skillsContextListBudget bounds the bytes spent listing individual skills so a
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
// budget are summarized as a count rather than dropped — the model can still load
// any of them by name (an unknown name returns the full list from the skill tool).
const skillsContextListBudget = 640
// workspace with an extreme number of them can't bloat every turn's prompt.
// Skills past the budget are summarized as a count rather than dropped — the
// model can still load any of them by name (an unknown name returns the full
// list from the skill tool).
//
// The budget is deliberately generous — roughly 18 skills with maximally long
// (200-rune, ASCII) descriptions, or 40+ with typical terser ones; non-ASCII
// descriptions consume it faster since the budget counts bytes. This list is
// the model's ONLY discovery surface, so a skill squeezed out of it effectively
// never triggers. The previous 640-byte cap silently broke invocation for
// anyone with more than ~6 skills — the model cannot match a request against a
// skill it cannot see.
const skillsContextListBudget = 4096
func skillsContext(options Options) string {
if len(options.Skills) == 0 {
@@ -202,7 +211,7 @@ func skillsContext(options Options) string {
}
var b strings.Builder
b.WriteString("<available_skills>\n")
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. Before acting on a request, scan this list; when the request matches a skill's name or description, call skill with its exact name FIRST and follow the loaded guidance — do not guess names, do not skip a matching skill, and do not substitute your own approach for its instructions.\n")
listed, spent, omitted := 0, 0, 0
for _, info := range options.Skills {
name := strings.TrimSpace(info.Name)
@@ -232,9 +241,11 @@ func skillsContext(options Options) string {
}
// truncateForSkillLine keeps a skill's one-line description short so a single
// verbose description can't dominate the skills-list budget.
// verbose description can't dominate the skills-list budget. The cap is roomy on
// purpose: the description carries the skill's trigger conditions ("use when…"),
// and truncating those is what stops the model from ever invoking the skill.
func truncateForSkillLine(desc string) string {
const maxDescRunes = 100
const maxDescRunes = 200
runes := []rune(desc)
if len(runes) <= maxDescRunes {
return desc
@@ -275,7 +286,7 @@ func workspaceContext(cwd string) string {
b.WriteString("Working directory: " + cwd + "\n")
b.WriteString("Operating system: " + runtime.GOOS + "\n")
if runtime.GOOS == "windows" {
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories. Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required.\n")
} else {
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
}
+7 -5
View File
@@ -285,11 +285,13 @@ type Options struct {
// ceiling and the autonomy gate. nil disables it entirely (the loop is
// byte-identical to before). One instance per run — it holds attempt state.
SelfCorrect *SelfCorrector
// FileDiagnostics, when set, is passed to mutating tools so edit_file /
// write_file append error-severity language diagnostics for the file they
// just wrote directly to their tool output — the model sees an error it
// introduced in the same turn instead of a later verification pass. Build
// one with NewFileDiagnostics. nil disables inline diagnostics.
// FileDiagnostics, when set, checks files changed by mutating tools for
// error-severity language diagnostics IN THE BACKGROUND and appends any
// errors as a nudge before the model's next request — the model still sees
// an error it introduced at its next decision point, but no tool call ever
// blocks on the language server (the old inline path stalled every edit on
// a ≥300ms debounce, 10s cap). Build one with NewFileDiagnostics. nil
// disables post-edit diagnostics.
FileDiagnostics func(ctx context.Context, absPath string) string
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
+32
View File
@@ -12,6 +12,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/agent"
@@ -782,6 +783,14 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
Specialists: specialistRuntime.specialists,
Skills: pluginActivation.skillInfos(deps.skillsDir()),
},
// LoadSkills backs /skills and direct /<skill-name> invocation in the TUI.
// It resolves against the same merged set (default dir + plugin skill
// roots) as the skill tool and the system-prompt list, re-read per use so
// newly installed skills work without a restart.
LoadSkills: cachedSkillsLoader(func() []skills.Skill {
merged, _ := plugins.MergedSkillsLoaded(deps.skillsDir(), pluginActivation.skillRoots)
return merged
}),
PermissionMode: permissionMode,
Notify: resolved.Notify,
KeyBindings: resolved.KeyBindings,
@@ -1277,3 +1286,26 @@ Flags:
`)
return err
}
// cachedSkillsLoader memoizes a skills loader for a short interval. The TUI
// calls the loader from autocomplete on every "/x" keystroke; without a cache
// each keystroke would re-read every SKILL.md body. Two seconds is fresh enough
// that a newly installed skill still shows up "immediately" while typing.
func cachedSkillsLoader(load func() []skills.Skill) func() []skills.Skill {
var (
mu sync.Mutex
at time.Time
cached []skills.Skill
)
const ttl = 2 * time.Second
return func() []skills.Skill {
mu.Lock()
defer mu.Unlock()
if cached != nil && time.Since(at) < ttl {
return cached
}
cached = load()
at = time.Now()
return cached
}
}
+4 -3
View File
@@ -711,9 +711,10 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// sessions); it is a no-op when self-correct is off.
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func(context.Context, string) string, func()) {
// The manager is created regardless of --self-correct: it also backs the
// always-on inline post-edit diagnostics (agent.NewFileDiagnostics), and it
// stays lazy — no language server is spawned unless an edited file's
// language actually has one installed on PATH.
// always-on background post-edit diagnostics (agent.NewFileDiagnostics,
// collected off the tool-call path by the loop), and it stays lazy — no
// language server is spawned unless an edited file's language actually has
// one installed on PATH.
manager := lsp.NewManager(workspaceRoot)
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+233
View File
@@ -0,0 +1,233 @@
package sessions
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"sync"
"testing"
"time"
)
func TestStoreAppendEventsBatchesSequencesAndMetadata(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: sequenceClock([]time.Time{
time.Date(2026, 6, 4, 15, 0, 0, 0, time.UTC),
time.Date(2026, 6, 4, 15, 0, 1, 0, time.UTC),
time.Date(2026, 6, 4, 15, 0, 2, 0, time.UTC),
time.Date(2026, 6, 4, 15, 0, 3, 0, time.UTC),
})})
session, err := store.Create(CreateInput{SessionID: "batch"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
appended, err := store.AppendEvents(session.SessionID, []AppendEventInput{
{Type: EventMessage, Payload: map[string]any{"role": "user", "content": "one"}},
{Type: EventToolCall, Payload: map[string]any{"id": "call_1", "name": "read_file"}},
{Type: EventToolResult, Payload: map[string]any{"toolCallId": "call_1", "status": "ok"}},
})
if err != nil {
t.Fatalf("AppendEvents returned error: %v", err)
}
if len(appended) != 3 {
t.Fatalf("expected 3 appended events, got %#v", appended)
}
for index, event := range appended {
wantSequence := index + 1
if event.Sequence != wantSequence || event.ID != fmt.Sprintf("batch:%d", wantSequence) {
t.Fatalf("event identity mismatch at %d: %#v", index, event)
}
}
if appended[0].CreatedAt != "2026-06-04T15:00:01Z" || appended[2].CreatedAt != "2026-06-04T15:00:03Z" {
t.Fatalf("unexpected event timestamps: %#v", appended)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != 3 || loaded.LastEventType != EventToolResult || loaded.UpdatedAt != appended[2].CreatedAt {
t.Fatalf("metadata not updated from final batch event: %#v", loaded)
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if !reflect.DeepEqual(eventTypesForTest(events), []EventType{EventMessage, EventToolCall, EventToolResult}) {
t.Fatalf("unexpected event types: %#v", events)
}
}
func TestStoreAppendEventsEmptyBatchDoesNotRewriteMetadata(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:10:00Z")})
session, err := store.Create(CreateInput{SessionID: "empty_batch", Title: "unchanged"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
appended, err := store.AppendEvents(session.SessionID, nil)
if err != nil {
t.Fatalf("AppendEvents empty returned error: %v", err)
}
if len(appended) != 0 {
t.Fatalf("empty AppendEvents returned %#v", appended)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || !reflect.DeepEqual(*loaded, session) {
t.Fatalf("empty AppendEvents rewrote metadata: before=%#v after=%#v", session, loaded)
}
}
func TestStoreAppendEventsInvalidPayloadDoesNotPartiallyAppend(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:20:00Z")})
session, err := store.Create(CreateInput{SessionID: "invalid_batch"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
_, err = store.AppendEvents(session.SessionID, []AppendEventInput{
{Type: EventMessage, Payload: map[string]any{"content": "valid first"}},
{Type: EventMessage, Payload: json.RawMessage(`{"broken"`)},
})
if err == nil || !strings.Contains(err.Error(), "invalid raw JSON payload") {
t.Fatalf("expected invalid raw payload error, got %v", err)
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if len(events) != 0 {
t.Fatalf("invalid batch should not append partial events: %#v", events)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != 0 || loaded.LastEventType != "" {
t.Fatalf("invalid batch should not update metadata: %#v", loaded)
}
}
func TestStoreAppendEventsSequencesAfterMetadataLag(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:30:00Z")})
session, err := store.Create(CreateInput{SessionID: "lagging"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if _, err := store.AppendEvent(session.SessionID, AppendEventInput{Type: EventMessage, Payload: map[string]any{"content": "already durable"}}); err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
lagging, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
lagging.EventCount = 0
lagging.LastEventType = ""
if err := store.writeMetadata(*lagging); err != nil {
t.Fatalf("writeMetadata returned error: %v", err)
}
appended, err := store.AppendEvents(session.SessionID, []AppendEventInput{
{Type: EventToolCall, Payload: map[string]any{"id": "call"}},
{Type: EventToolResult, Payload: map[string]any{"toolCallId": "call"}},
})
if err != nil {
t.Fatalf("AppendEvents returned error: %v", err)
}
if len(appended) != 2 || appended[0].Sequence != 2 || appended[1].Sequence != 3 {
t.Fatalf("batch did not sequence after durable log tail: %#v", appended)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != 3 || loaded.LastEventType != EventToolResult {
t.Fatalf("metadata not repaired by batch append: %#v", loaded)
}
}
func TestStoreAppendEventsSerializesConcurrentBatches(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:40:00Z")})
session, err := store.Create(CreateInput{SessionID: "concurrent_batches"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
const writers = 8
const perBatch = 3
var wg sync.WaitGroup
errs := make(chan error, writers)
for writer := 0; writer < writers; writer++ {
wg.Add(1)
go func(writer int) {
defer wg.Done()
appended, err := store.AppendEvents(session.SessionID, []AppendEventInput{
{Type: EventMessage, Payload: map[string]int{"writer": writer, "index": 0}},
{Type: EventMessage, Payload: map[string]int{"writer": writer, "index": 1}},
{Type: EventMessage, Payload: map[string]int{"writer": writer, "index": 2}},
})
if err != nil {
errs <- err
return
}
if len(appended) != perBatch {
errs <- fmt.Errorf("writer %d appended %d events", writer, len(appended))
return
}
for index := 1; index < len(appended); index++ {
if appended[index].Sequence != appended[index-1].Sequence+1 {
errs <- fmt.Errorf("writer %d batch was interleaved: %#v", writer, appended)
return
}
}
errs <- nil
}(writer)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("AppendEvents returned error: %v", err)
}
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
total := writers * perBatch
if len(events) != total {
t.Fatalf("expected %d events, got %d", total, len(events))
}
seen := map[int]bool{}
for _, event := range events {
if seen[event.Sequence] {
t.Fatalf("duplicate sequence %d in %#v", event.Sequence, events)
}
seen[event.Sequence] = true
}
for sequence := 1; sequence <= total; sequence++ {
if !seen[sequence] {
t.Fatalf("missing sequence %d in %#v", sequence, events)
}
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != total || loaded.LastEventType != EventMessage {
t.Fatalf("metadata not updated after concurrent batches: %#v", loaded)
}
}
func eventTypesForTest(events []Event) []EventType {
types := make([]EventType, 0, len(events))
for _, event := range events {
types = append(types, event.Type)
}
return types
}
+99 -39
View File
@@ -153,6 +153,11 @@ type AppendEventInput struct {
Payload any
}
type preparedAppendEvent struct {
Type EventType
Payload json.RawMessage
}
type RecordSpecInput struct {
SpecID string
SpecFilePath string
@@ -426,7 +431,7 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
if err != nil {
return Metadata{}, err
}
copied := 0
copyInputs := []AppendEventInput{}
for _, event := range events {
// Do NOT copy usage accounting into the fork. It already counted against the
// parent, and a usage report that aggregates the parent and the fork would
@@ -435,11 +440,12 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
if event.Type == EventUsage {
continue
}
if _, err := store.AppendEvent(fork.SessionID, AppendEventInput{Type: event.Type, Payload: event.Payload}); err != nil {
return Metadata{}, err
}
copied++
copyInputs = append(copyInputs, AppendEventInput{Type: event.Type, Payload: event.Payload})
}
if _, err := store.AppendEvents(fork.SessionID, copyInputs); err != nil {
return Metadata{}, err
}
copied := len(copyInputs)
// Copy the parent's content-addressed checkpoint blobs into the fork so the
// copied EventSessionCheckpoint events resolve to real blobs and a rewind on
// the fork can restore file content (otherwise rewind reads missing blobs
@@ -513,19 +519,34 @@ func (store *Store) RecordSpec(sessionID string, input RecordSpecInput) (Metadat
}
func (store *Store) AppendEvent(sessionID string, input AppendEventInput) (Event, error) {
if !ValidSessionID(sessionID) {
return Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
if strings.TrimSpace(string(input.Type)) == "" {
return Event{}, fmt.Errorf("zero session event type is required")
}
unlock, err := store.lockSession(sessionID)
events, err := store.AppendEvents(sessionID, []AppendEventInput{input})
if err != nil {
return Event{}, err
}
return events[0], nil
}
// AppendEvents appends a batch of events atomically under one session lock and
// durability pass. The returned events have contiguous sequences in input order.
// An empty batch is a no-op and does not rewrite metadata.
func (store *Store) AppendEvents(sessionID string, inputs []AppendEventInput) ([]Event, error) {
if !ValidSessionID(sessionID) {
return nil, fmt.Errorf("invalid zero session id %q", sessionID)
}
prepared, err := prepareAppendEventInputs(inputs)
if err != nil {
return nil, err
}
if len(prepared) == 0 {
return []Event{}, nil
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return nil, err
}
defer unlock()
return store.appendEventLocked(sessionID, input)
return store.appendPreparedEventsLocked(sessionID, prepared)
}
// AppendEventUnlessExists appends input only when exists(currentEvents) is false,
@@ -572,14 +593,27 @@ func (store *Store) AppendEventUnlessExists(sessionID string, input AppendEventI
// the single lock they already hold, instead of re-locking (which would deadlock
// on the non-reentrant in-process mutex).
func (store *Store) appendEventLocked(sessionID string, input AppendEventInput) (Event, error) {
session, err := store.readMetadata(sessionID)
prepared, err := prepareAppendEventInputs([]AppendEventInput{input})
if err != nil {
return Event{}, err
}
payload, err := rawPayload(input.Payload)
events, err := store.appendPreparedEventsLocked(sessionID, prepared)
if err != nil {
return Event{}, err
}
return events[0], nil
}
// appendPreparedEventsLocked appends pre-validated events WITHOUT acquiring the
// session lock. The caller MUST already hold store.lockSession(sessionID).
func (store *Store) appendPreparedEventsLocked(sessionID string, inputs []preparedAppendEvent) ([]Event, error) {
if len(inputs) == 0 {
return []Event{}, nil
}
session, err := store.readMetadata(sessionID)
if err != nil {
return nil, err
}
sequence := session.EventCount + 1
// The events log is the source of truth for sequencing. metadata.EventCount is
// persisted separately (after the event line below), so a crash between the two
@@ -590,46 +624,54 @@ func (store *Store) appendEventLocked(sessionID string, input AppendEventInput)
if logSeq, err := store.lastEventSequence(sessionID); err == nil && logSeq >= sequence {
sequence = logSeq + 1
}
timestamp := store.timestamp()
event := Event{
ID: fmt.Sprintf("%s:%d", sessionID, sequence),
SessionID: sessionID,
Sequence: sequence,
Type: input.Type,
CreatedAt: timestamp,
Payload: payload,
}
data, err := json.Marshal(event)
if err != nil {
return Event{}, fmt.Errorf("encode zero session event: %w", err)
events := make([]Event, 0, len(inputs))
var batch bytes.Buffer
for index, input := range inputs {
timestamp := store.timestamp()
event := Event{
ID: fmt.Sprintf("%s:%d", sessionID, sequence+index),
SessionID: sessionID,
Sequence: sequence + index,
Type: input.Type,
CreatedAt: timestamp,
Payload: input.Payload,
}
data, err := json.Marshal(event)
if err != nil {
return nil, fmt.Errorf("encode zero session event: %w", err)
}
batch.Write(data)
batch.WriteByte('\n')
events = append(events, event)
}
file, err := os.OpenFile(store.eventsPath(sessionID), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
if err != nil {
return Event{}, fmt.Errorf("append zero session event: %w", err)
return nil, fmt.Errorf("append zero session event: %w", err)
}
if _, err := file.Write(append(data, '\n')); err != nil {
if _, err := file.Write(batch.Bytes()); err != nil {
_ = file.Close()
return Event{}, fmt.Errorf("append zero session event: %w", err)
return nil, fmt.Errorf("append zero session event: %w", err)
}
// fsync the event before reporting success: the derived metadata.json IS
// fsync the events before reporting success: the derived metadata.json IS
// fsync'd (writeMetadata), so without this a crash after the metadata flush
// but before the events.jsonl page reaches disk leaves EventCount ahead of the
// durable log — silently losing the just-appended event (incl. the checkpoint
// durable log — silently losing just-appended events (incl. checkpoints
// that /rewind targets). Make the log at least as durable as its metadata. (AUDIT-M12)
if err := file.Sync(); err != nil {
_ = file.Close()
return Event{}, fmt.Errorf("sync zero session event: %w", err)
return nil, fmt.Errorf("sync zero session event: %w", err)
}
if err := file.Close(); err != nil {
return Event{}, fmt.Errorf("close zero session event file: %w", err)
return nil, fmt.Errorf("close zero session event file: %w", err)
}
session.UpdatedAt = timestamp
session.EventCount = sequence
session.LastEventType = input.Type
last := events[len(events)-1]
session.UpdatedAt = last.CreatedAt
session.EventCount = last.Sequence
session.LastEventType = last.Type
if err := store.writeMetadata(session); err != nil {
return Event{}, err
return nil, err
}
return event, nil
return events, nil
}
// UpdateTitle replaces a session's Title and returns the updated metadata. It is
@@ -964,6 +1006,24 @@ func rawPayload(payload any) (json.RawMessage, error) {
return data, nil
}
func prepareAppendEventInputs(inputs []AppendEventInput) ([]preparedAppendEvent, error) {
prepared := make([]preparedAppendEvent, 0, len(inputs))
for _, input := range inputs {
if strings.TrimSpace(string(input.Type)) == "" {
return nil, fmt.Errorf("zero session event type is required")
}
payload, err := rawPayload(input.Payload)
if err != nil {
return nil, err
}
prepared = append(prepared, preparedAppendEvent{
Type: input.Type,
Payload: payload,
})
}
return prepared, nil
}
func envValue(env map[string]string, key string) string {
if env != nil {
return env[key]
+89 -22
View File
@@ -83,7 +83,12 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
if err != nil {
return errorResult("Error: Invalid arguments for bash: " + err.Error())
}
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil {
// Resolve the command engine before the MSYS preflight check so an
// approved require_escalated call (commandEngine == nil, truly
// unsandboxed) can actually bypass the MSYS guard instead of being
// hard-blocked by the same check it was meant to escalate past.
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
@@ -106,7 +111,6 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
"cwd": relativeCwd,
"timeout_ms": strconv.Itoa(timeoutMS),
}
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if commandEngine == nil && sandboxPermissions == SandboxPermissionsRequireEscalated {
meta["sandbox_permissions"] = string(SandboxPermissionsRequireEscalated)
}
@@ -126,8 +130,8 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
// can't grow Zero's memory before truncation: only the head+tail each stream
// will ever surface to the model are retained, the middle is discarded as it
// streams, and the true size is counted for the truncation marker.
stdout := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
stderr := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
stdout := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
stderr := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
command.Stdout = stdout
command.Stderr = stderr
@@ -167,7 +171,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
return Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
@@ -178,7 +182,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
if meta[SandboxLikelyDeniedMeta] == "true" {
return Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
@@ -198,6 +202,18 @@ func commandEngineForSandboxPermissions(engine *zeroSandbox.Engine, sandboxPermi
return engine
}
// msysGuardBypassed reports whether a windows_msys_sandbox preflight issue no
// longer applies because sandbox_permissions: require_escalated actually
// resolved to unsandboxed, host-level execution (commandEngine is nil only
// then, per commandEngineForSandboxPermissions). MSYS coreutils fail because
// of the sandbox's restricted token/handles, so running outside it removes
// the failure mode this guard exists for. Any other issue kind (e.g. a real
// cmd.exe syntax problem) still blocks regardless of escalation, since
// running outside the sandbox does not fix a syntax error.
func msysGuardBypassed(issue *shellIssue, commandEngine *zeroSandbox.Engine) bool {
return issue.Kind == windowsMsysSandboxKind && commandEngine == nil
}
// appendSandboxBlocks appends a <sandbox_blocks> block listing the denials
// the sandbox log monitor captured, so the model can see what was blocked. With no
// blocks the stderr is returned unchanged.
@@ -370,12 +386,21 @@ func formatBashOutput(stdout string, stderr string, exitCode int) string {
return strings.Join(parts, "\n")
}
// bashOutputBudgetBytes caps each of stdout/stderr shown to the model. bash is the
// one tool that can emit unbounded output (`cat large.log`, `find /`, verbose test
// runs); every other read/search tool already budgets its output. Head+tail
// truncation keeps both the start and the end of an oversized stream, since
// build/test failures usually surface at the tail.
const bashOutputBudgetBytes = 96 * 1024
// bashOutputBudgetBytes caps each of stdout/stderr shown to the model (32 KiB
// ≈ 8k tokens per stream). bash is the one tool that can emit unbounded output
// (`cat large.log`, `find /`, verbose test runs); a chatty build log at the old
// 96 KiB budget injected ~24k tokens per call and was then re-billed on every
// following turn. Head+tail truncation keeps both the start and the end of an
// oversized stream, since build/test failures usually surface at the tail, and
// the full captured text is spilled to disk so nothing is lost — the model
// greps/reads the spill instead of re-running the command.
const bashOutputBudgetBytes = 32 * 1024
// bashCaptureBudgetBytes bounds the head and tail retained IN MEMORY per
// stream during capture. Deliberately larger than the emit budget: the extra
// retained bytes never reach the transcript, but they make the spill file —
// the model's recovery path — cover 6× more of the output.
const bashCaptureBudgetBytes = 96 * 1024
// budgetBashOutput truncates stdout and stderr to bashOutputBudgetBytes each,
// keeping the head and tail of anything larger, and records raw/emitted byte
@@ -395,6 +420,19 @@ func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, me
outText, outRaw, outTrunc := truncateHeadTailWithTotal(out, outTotal, bashOutputBudgetBytes)
errText, errRaw, errTrunc := truncateHeadTailWithTotal(errStr, errTotal, bashOutputBudgetBytes)
truncated := outTrunc || errTrunc
if truncated {
if spillPath := spillBashStreams(out, outTotal, errStr, errTotal); spillPath != "" {
hint := "\n[zero] captured output saved to " + spillPath + " (grep or read_file it instead of re-running)"
if errTrunc {
errText += hint
} else {
outText += hint
}
if meta != nil {
meta["spill_path"] = spillPath
}
}
}
if meta != nil {
emitted := len(outText) + len(errText)
meta["raw_bytes"] = strconv.Itoa(outRaw + errRaw)
@@ -407,6 +445,43 @@ func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, me
return outText, errText, truncated
}
// spillBashStreams writes the retained (capture-bounded) stdout and stderr to
// the spill directory as one sectioned file and returns its path, or "" when
// spilling fails. The spill holds up to bashCaptureBudgetBytes of head and
// tail per stream — everything zero kept in memory, which is more than the
// emit budget shows the model but not necessarily the whole output. When the
// capture itself dropped the middle of a stream (total exceeds the retained
// bytes), a gap marker is inserted at the head/tail junction so the spilled
// log never reads as contiguous when it is not.
func spillBashStreams(stdout string, stdoutTotal int, stderr string, stderrTotal int) string {
stdout = sectionWithCaptureGap(stdout, stdoutTotal)
stderr = sectionWithCaptureGap(stderr, stderrTotal)
var combined strings.Builder
combined.Grow(len(stdout) + len(stderr) + 64)
combined.WriteString("### stdout\n")
combined.WriteString(stdout)
if !strings.HasSuffix(stdout, "\n") {
combined.WriteString("\n")
}
combined.WriteString("### stderr\n")
combined.WriteString(stderr)
return spillTruncatedOutput("bash", combined.String())
}
// sectionWithCaptureGap marks the point where boundedBuffer dropped the middle
// of a stream. When total exceeds the retained bytes, the retained text is the
// frozen head (bashCaptureBudgetBytes, always full once overflow happened)
// followed immediately by the rolling tail — the junction sits at the head cap,
// snapped back to a rune boundary.
func sectionWithCaptureGap(text string, total int) string {
if total <= len(text) || len(text) <= bashCaptureBudgetBytes {
return text
}
head := utf8Prefix(text, bashCaptureBudgetBytes)
marker := fmt.Sprintf("\n[zero] capture gap: %d bytes omitted from the middle of this stream\n", total-len(text))
return head + marker + text[len(head):]
}
// boundedBuffer is an io.Writer that retains at most headCap bytes from the start
// and tailCap bytes from the end of a stream while counting the total written, so
// a command emitting unbounded output (`cat huge.log`, `yes`) cannot grow Zero's
@@ -461,14 +536,6 @@ func (b *boundedBuffer) retained() string {
return string(b.head) + string(b.tail)
}
// truncateHeadTail keeps the first and last halves of value when it exceeds
// maxBytes, dropping the middle behind a marker. Returns the possibly-truncated
// text, the raw byte length, and whether truncation happened.
func truncateHeadTail(value string, maxBytes int) (string, int, bool) {
// value holds the whole stream, so its length is the true raw size.
return truncateHeadTailWithTotal(value, len(value), maxBytes)
}
// truncateHeadTailWithTotal head+tail-truncates value to maxBytes, using total —
// the full original byte count — for the "N bytes omitted" marker and the raw
// count. total may exceed len(value) when the middle was already discarded during
@@ -488,9 +555,9 @@ func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int,
return utf8Prefix(value, head) + marker + utf8Suffix(value, tail), total, true
}
func formatBashOutputWithShellHint(command string, stdout string, stderr string, exitCode int, meta map[string]string) string {
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
output := formatBashOutput(stdout, stderr, exitCode)
if issue := detectShellOutputIssue(command, stdout+"\n"+stderr, runtime.GOOS); issue != nil {
if issue := detectShellOutputIssue(stdout+"\n"+stderr, runtime.GOOS); issue != nil {
meta["shell_issue"] = issue.Kind
output = appendShellIssueHint(output, *issue)
}
+63 -7
View File
@@ -2,11 +2,23 @@ package tools
import (
"fmt"
"os"
"strconv"
"strings"
"testing"
)
// setTestTempDir points every platform's temp-dir lookup at a per-test
// directory so spill files never land in the real temp dir: os.TempDir reads
// TMPDIR on Unix but TMP/TEMP on Windows.
func setTestTempDir(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("TMPDIR", dir)
t.Setenv("TMP", dir)
t.Setenv("TEMP", dir)
}
// Small output passes through untouched and records raw==emitted, no truncated flag.
func TestBudgetBashOutputSmallPassesThrough(t *testing.T) {
meta := map[string]string{}
@@ -26,8 +38,10 @@ func TestBudgetBashOutputSmallPassesThrough(t *testing.T) {
}
// Oversized stdout is truncated head+tail: both the first and last lines survive,
// the middle is dropped behind a marker, and meta is flagged.
// the middle is dropped behind a marker, meta is flagged, and the captured text
// is spilled to a re-readable file.
func TestBudgetBashOutputTruncatesHeadAndTail(t *testing.T) {
setTestTempDir(t)
head := "FIRST_LINE_MARKER\n"
tail := "\nLAST_LINE_MARKER"
big := head + strings.Repeat("x", bashOutputBudgetBytes) + tail
@@ -47,9 +61,23 @@ func TestBudgetBashOutputTruncatesHeadAndTail(t *testing.T) {
if !strings.Contains(out, "output truncated") {
t.Fatalf("expected a truncation marker, got:\n%s", out[:200])
}
if len(out) > bashOutputBudgetBytes {
// The spill hint (path length varies) is the only allowed overage past the budget.
if len(out) > bashOutputBudgetBytes+512 {
t.Fatalf("emitted %d bytes exceeds budget %d", len(out), bashOutputBudgetBytes)
}
if !strings.Contains(out, "captured output saved to ") {
t.Fatalf("truncated bash output must carry a spill hint:\n%s", out[len(out)-300:])
}
if meta["spill_path"] == "" {
t.Fatal("spill path missing from meta")
}
content, err := os.ReadFile(meta["spill_path"])
if err != nil {
t.Fatalf("spill file unreadable: %v", err)
}
if !strings.Contains(string(content), "### stdout") || !strings.Contains(string(content), strings.Repeat("x", 1024)) {
t.Fatalf("spill must hold the sectioned captured streams (got %d bytes)", len(content))
}
if meta["truncated"] != "true" {
t.Fatalf("expected truncated=true, got %v", meta)
}
@@ -100,10 +128,12 @@ func TestBoundedBufferKeepsHeadAndTailBounded(t *testing.T) {
// budgetBashCapture reports the TRUE total (not the retained size) in the marker
// and raw_bytes, even though only a bounded head+tail was ever held in memory.
func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
// Retained head+tail as boundedBuffer would hand over; the real command produced
// far more than was kept.
retained := "HEAD_START" + strings.Repeat("y", bashOutputBudgetBytes) + "TAIL_END"
total := 10 * bashOutputBudgetBytes
setTestTempDir(t)
// Retained head+tail as boundedBuffer would hand over after overflow: the
// frozen head is full at bashCaptureBudgetBytes, then the rolling tail; the
// real command produced far more than was kept.
retained := "HEAD_START" + strings.Repeat("y", bashCaptureBudgetBytes) + "TAIL_END"
total := 10 * bashCaptureBudgetBytes
meta := map[string]string{}
out, _, truncated := budgetBashCapture(retained, total, "", 0, meta)
@@ -114,7 +144,8 @@ func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
if !strings.Contains(out, "HEAD_START") || !strings.Contains(out, "TAIL_END") {
t.Fatalf("head/tail lost after budgeting:\n%s", out[:min(120, len(out))])
}
if len(out) > bashOutputBudgetBytes {
// Spill hint is the only allowed overage past the budget.
if len(out) > bashOutputBudgetBytes+512 {
t.Fatalf("emitted %d bytes exceeds budget %d", len(out), bashOutputBudgetBytes)
}
if meta["raw_bytes"] != strconv.Itoa(total) {
@@ -124,4 +155,29 @@ func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
if !strings.Contains(out, strconv.Itoa(total-bashOutputBudgetBytes)) {
t.Fatalf("marker should cite the true omitted byte count:\n%s", out[:min(200, len(out))])
}
// The spill must be recorded, sectioned, and carry a capture-gap marker at
// the head/tail junction — the capture dropped the middle, so the spilled
// log must never read as contiguous.
if meta["spill_path"] == "" {
t.Fatal("spill path missing from meta")
}
content, err := os.ReadFile(meta["spill_path"])
if err != nil {
t.Fatalf("spill file unreadable: %v", err)
}
if !strings.Contains(string(content), "### stdout") {
t.Fatal("spill must be sectioned by stream")
}
gapMarker := fmt.Sprintf("capture gap: %d bytes omitted", total-len(retained))
gapIndex := strings.Index(string(content), gapMarker)
if gapIndex < 0 {
t.Fatalf("spill missing capture-gap marker %q", gapMarker)
}
// The junction sits right after the frozen head (plus the section header),
// not appended at the end of the stream.
headerOffset := len("### stdout\n")
if gapIndex < headerOffset+bashCaptureBudgetBytes-64 || gapIndex > headerOffset+bashCaptureBudgetBytes+64 {
t.Fatalf("capture-gap marker at offset %d, want ~%d (the head cap)", gapIndex, headerOffset+bashCaptureBudgetBytes)
}
}
+106 -3
View File
@@ -181,9 +181,13 @@ func TestDetectShellCommandIssueFlagsPipedPosixUtilities(t *testing.T) {
`cat file.txt | wc -l`,
`some_command | tail -n 20`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue == nil {
issue := detectShellCommandIssue(command, "windows")
if issue == nil {
t.Fatalf("expected POSIX utility pipeline to be flagged for %q", command)
}
if issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected windows_msys_sandbox for %q, got %q", command, issue.Kind)
}
}
}
@@ -195,7 +199,7 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
`echo header text`,
`tail-log.sh`,
`grep-cli --version`,
`sed.exe --help`,
`mytool.exe --help`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected unrelated command to pass for %q, got %#v", command, issue)
@@ -204,7 +208,7 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
}
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
issue := detectShellOutputIssue(`cd /d/tmp/zero-pr-158 && ls -la`, "The syntax of the command is incorrect.", "windows")
issue := detectShellOutputIssue("The syntax of the command is incorrect.", "windows")
if issue == nil {
t.Fatal("expected Windows syntax error to get shell guidance")
}
@@ -434,6 +438,105 @@ func TestRegistryRunsWithDegradedUnavailableNativeSandbox(t *testing.T) {
}
}
// TestBashToolRequireEscalatedMsysGuard covers the boundary of the fix for an
// escalation-vs-preflight ordering bug: the MSYS sandbox guard exists only
// because MSYS/Cygwin coreutils fail under the write-restricted sandbox, so
// once sandbox_permissions: require_escalated is actually approved (running
// the command unsandboxed), the guard must get out of the way, but an
// unrelated windows_shell_syntax issue (a real cmd.exe syntax problem, not a
// sandbox interaction) must still block regardless of escalation.
func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only MSYS sandbox guard")
}
root := t.TempDir()
newEngine := func() *sandbox.Engine {
return sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
})
}
registry := NewRegistry()
registry.Register(NewBashTool(root))
t.Run("default sandboxing still blocks an MSYS-prone command", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": "cat somefile.txt",
}, RunOptions{
PermissionGranted: true,
Sandbox: newEngine(),
PermissionMode: string(sandbox.PermissionModeAsk),
})
if result.Meta["shell_issue"] != "windows_msys_sandbox" {
t.Fatalf("expected windows_msys_sandbox block without escalation, got %#v", result)
}
})
t.Run("approved require_escalated bypasses the MSYS guard", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": "cat somefile.txt",
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
Sandbox: newEngine(),
PermissionMode: string(sandbox.PermissionModeAsk),
})
// Assert on the preflight block sentinel (exit_code "-1", set only by
// shellIssueBlockResult) rather than shell_issue: once the guard is
// bypassed, "cat somefile.txt" actually runs, and its real,
// PATH-dependent output could otherwise trip the unrelated
// post-execution detectShellOutputIssue heuristic and make this
// assertion flaky for reasons unrelated to the guard under test.
if result.Meta["exit_code"] == "-1" {
t.Fatalf("expected approved require_escalated to bypass the MSYS guard, got blocked: %#v", result)
}
})
t.Run("approved require_escalated still blocks an unrelated syntax issue", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": `cd /d/tmp/zero-pr-158 && dir`,
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
Sandbox: newEngine(),
PermissionMode: string(sandbox.PermissionModeAsk),
})
if result.Meta["shell_issue"] != "windows_shell_syntax" {
t.Fatalf("expected windows_shell_syntax block to still apply under require_escalated, got %#v", result)
}
})
}
// TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure guards the
// post-execution MSYS heuristic against treating the command line itself as
// evidence. The helper command below fails for a reason unrelated to MSYS
// (a plain non-zero exit), but its own argument text quotes an MSYS crash
// marker the way a PR-comment or commit-message argument might. Before the
// fix, detectShellOutputIssue scanned command+output together and would have
// misdiagnosed this as an MSYS sandbox failure even though the real
// stdout/stderr never mentions MSYS at all.
func TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only MSYS output guard")
}
root := t.TempDir()
// The helper only reads argv[2] ("fail"); this trailing quoted argument is
// otherwise unused by the helper but still part of the command line text.
command := helperCommand("fail") + ` "fatal error - CreateFileMapping S-1-5-21, Win32 error 5. Terminating. cygheap_user::init"`
result := NewBashTool(root).Run(context.Background(), map[string]any{
"command": command,
})
if result.Status != StatusError || result.Meta["exit_code"] != "7" {
t.Fatalf("expected the helper's real exit 7 failure, got %s: %#v", result.Status, result)
}
if result.Meta["shell_issue"] == "windows_msys_sandbox" {
t.Fatalf("expected the MSYS marker in the command's own argument text to be ignored, got %#v", result)
}
}
func TestBashToolRunsWithDegradedUnavailableNativeSandbox(t *testing.T) {
root := t.TempDir()
policy := sandbox.DefaultPolicy()
+1 -1
View File
@@ -58,7 +58,7 @@ func fuzzyEditMatch(content, find string, replaceAll bool) (string, error) {
continue
}
seen[search] = true
if strings.Index(content, search) < 0 {
if !strings.Contains(content, search) {
continue
}
candidates = append(candidates, search)
+12 -1
View File
@@ -517,7 +517,12 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil {
// Resolve the command engine before the MSYS preflight check so an
// approved require_escalated call (commandEngine == nil, truly
// unsandboxed) can actually bypass the MSYS guard instead of being
// hard-blocked by the same check it was meant to escalate past.
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
@@ -860,6 +865,12 @@ func execToolResult(input execToolResultInput) Result {
status = StatusError
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
if status == StatusError && input.exited && !input.interrupted {
if issue := detectShellOutputIssue(output, runtimeGOOS()); issue != nil {
meta["shell_issue"] = issue.Kind
body = appendShellIssueHint(body, *issue)
}
}
if input.outputBufferTruncated {
// Appended after truncateExecOutput's own head/tail slicing, not
// embedded in the text that goes through it — a marker inside that
+40
View File
@@ -141,6 +141,46 @@ func TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval(t *testin
}
}
// TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval mirrors
// TestBashToolRequireEscalatedMsysGuard for exec_command: the MSYS sandbox
// guard exists only because MSYS/Cygwin coreutils fail under the
// write-restricted sandbox, so once require_escalated is actually approved
// (unsandboxed execution), the guard must not block the same command it was
// meant to let escalate past.
func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only MSYS sandbox guard")
}
root := t.TempDir()
manager := newExecSessionManager()
registry := NewRegistry()
registry.Register(NewScopedExecCommandTool(root, nil, manager))
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
})
result := registry.RunWithOptions(context.Background(), ExecCommandToolName, map[string]any{
"cmd": "cat somefile.txt",
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
Sandbox: engine,
PermissionMode: string(sandbox.PermissionModeAsk),
})
// Assert on the preflight block sentinel (exit_code "-1", set only by
// shellIssueBlockResult) rather than shell_issue: once the guard is
// bypassed, "cat somefile.txt" actually runs, and its real,
// PATH-dependent output could otherwise trip the unrelated
// post-execution detectShellOutputIssue heuristic and make this
// assertion flaky for reasons unrelated to the guard under test.
if result.Meta["exit_code"] == "-1" {
t.Fatalf("expected approved require_escalated to bypass the MSYS guard, got blocked: %#v", result)
}
}
func TestExecCommandReturnsExitCodeWhenCommandCompletesDuringInitialYield(t *testing.T) {
root := t.TempDir()
manager := newExecSessionManager()
+81
View File
@@ -0,0 +1,81 @@
package tools
import (
"os"
"strconv"
"strings"
)
// Universal output ceiling. Every tool result leaving the registry boundary is
// capped at a single token-denominated budget unless the tool manages its own
// deliberate budget (selfBudgeting below). This is the safety net for tools
// with no cap of their own — web_fetch bodies, skill files, MCP server tools,
// browser snapshots, anything added later — so no single call can flood the
// context window with output that is then re-billed on every following turn.
// Truncation keeps head+tail and spills the full text to disk (re-readable via
// grep/read_file), so nothing is lost, only deferred.
// defaultOutputCeilingTokens is the per-call ceiling in estimated tokens
// (bytes/4). 16k tokens = 64 KiB — deliberately equal to the search-tool
// budget: generous enough for a large fetched document, small enough that one
// call cannot eat a third of a small context window.
const defaultOutputCeilingTokens = 16_000
// outputCeilingEnv overrides the ceiling (in tokens). Zero or negative
// disables the ceiling entirely; unset or unparsable keeps the default.
const outputCeilingEnv = "ZERO_TOOL_OUTPUT_CEILING_TOKENS"
// selfBudgeting marks a tool that enforces its own deliberate output budget —
// possibly model-raisable (exec_command) — which the registry ceiling must not
// second-guess. The method is unexported on purpose: only tools in this
// package can opt out, so an MCP-served tool can never exempt itself.
type selfBudgeting interface{ managesOutputBudget() }
// The exemption list, kept in one place. Each of these applies its own budget
// before returning: bash (bashOutputBudgetBytes per stream + spill),
// exec_command (model-raisable token budget + spill), read tools (128 KiB),
// search tools (64 KiB).
func (bashTool) managesOutputBudget() {}
func (execCommandTool) managesOutputBudget() {}
func (readFileTool) managesOutputBudget() {}
func (readMinifiedFileTool) managesOutputBudget() {}
func (grepTool) managesOutputBudget() {}
func (globTool) managesOutputBudget() {}
func (listDirectoryTool) managesOutputBudget() {}
func resolveOutputCeilingTokens() int {
raw := strings.TrimSpace(os.Getenv(outputCeilingEnv))
if raw == "" {
return defaultOutputCeilingTokens
}
parsed, err := strconv.Atoi(raw)
if err != nil {
return defaultOutputCeilingTokens
}
return parsed
}
// enforceOutputCeiling truncates an over-ceiling result to head+tail within
// the token budget, spilling the full (already redaction-scrubbed) output to
// disk with a recovery hint. Runs after scrubResultSecrets so the transcript
// and the spill file agree on what was hidden.
func enforceOutputCeiling(toolName string, result Result) Result {
ceiling := resolveOutputCeilingTokens()
if ceiling <= 0 {
return result
}
if len(result.Output) <= ceiling*4 {
return result
}
rawBytes := len(result.Output)
truncated, _ := truncateExecOutputSpill(result.Output, ceiling, toolName)
result.Output = truncated
result.Truncated = true
if result.Meta == nil {
result.Meta = map[string]string{}
}
result.Meta["raw_bytes"] = strconv.Itoa(rawBytes)
result.Meta["emitted_bytes"] = strconv.Itoa(len(result.Output))
result.Meta["estimated_tokens"] = strconv.Itoa(estimatedTokensFromBytes(len(result.Output)))
return result
}
+140
View File
@@ -0,0 +1,140 @@
package tools
import (
"context"
"os"
"strconv"
"strings"
"testing"
)
// ceilingFakeTool is a minimal tool with no output budget of its own — the
// stand-in for an MCP-served tool or any future builtin that forgets to cap
// its output.
type ceilingFakeTool struct {
baseTool
output string
}
func (tool ceilingFakeTool) Run(context.Context, map[string]any) Result {
return okResult(tool.output)
}
// ceilingExemptTool is the same fake but marked self-budgeting.
type ceilingExemptTool struct{ ceilingFakeTool }
func (ceilingExemptTool) managesOutputBudget() {}
func newCeilingFakeTool(name, output string) ceilingFakeTool {
return ceilingFakeTool{
baseTool: baseTool{name: name, safety: readOnlySafety("test tool")},
output: output,
}
}
// An unbudgeted tool's oversized output is capped at the universal ceiling,
// head and tail survive, and the full output is spilled to a re-readable file.
func TestOutputCeilingCapsUnbudgetedTool(t *testing.T) {
setTestTempDir(t)
big := "HEAD_MARK\n" + strings.Repeat("z", defaultOutputCeilingTokens*4*3) + "\nTAIL_MARK"
registry := NewRegistry()
registry.Register(newCeilingFakeTool("fake_big", big))
result := registry.Run(context.Background(), "fake_big", map[string]any{})
if !result.Truncated {
t.Fatal("over-ceiling output must report truncated=true")
}
maxBytes := defaultOutputCeilingTokens * 4
if len(result.Output) > maxBytes+512 {
t.Fatalf("emitted %d bytes, ceiling is %d", len(result.Output), maxBytes)
}
if !strings.Contains(result.Output, "HEAD_MARK") || !strings.Contains(result.Output, "TAIL_MARK") {
t.Fatal("head/tail lost at the ceiling")
}
if result.Meta["raw_bytes"] != strconv.Itoa(len(big)) {
t.Fatalf("raw_bytes = %s, want %d", result.Meta["raw_bytes"], len(big))
}
if !strings.Contains(result.Output, "full output saved to ") {
t.Fatalf("ceiling truncation must include a spill hint: %q", result.Output[:200])
}
start := strings.Index(result.Output, "full output saved to ") + len("full output saved to ")
end := strings.Index(result.Output[start:], " (grep")
content, err := os.ReadFile(result.Output[start : start+end])
if err != nil {
t.Fatalf("spill file unreadable: %v", err)
}
if string(content) != big {
t.Fatalf("spill must hold the full output: got %d bytes, want %d", len(content), len(big))
}
}
// A self-budgeting tool's output passes the boundary untouched even when it
// exceeds the universal ceiling — its own (possibly model-raised) budget wins.
func TestOutputCeilingSkipsSelfBudgetingTool(t *testing.T) {
big := strings.Repeat("z", defaultOutputCeilingTokens*4*2)
registry := NewRegistry()
registry.Register(ceilingExemptTool{newCeilingFakeTool("fake_exempt", big)})
result := registry.Run(context.Background(), "fake_exempt", map[string]any{})
if result.Truncated || result.Output != big {
t.Fatalf("self-budgeting tool must bypass the ceiling (truncated=%v, %d bytes)", result.Truncated, len(result.Output))
}
}
// Under-ceiling output is untouched and gains no budget metadata.
func TestOutputCeilingSmallOutputUntouched(t *testing.T) {
registry := NewRegistry()
registry.Register(newCeilingFakeTool("fake_small", "hello"))
result := registry.Run(context.Background(), "fake_small", map[string]any{})
if result.Truncated || result.Output != "hello" {
t.Fatalf("small output altered: %+v", result)
}
if _, ok := result.Meta["raw_bytes"]; ok {
t.Fatal("small output must not gain budget metadata")
}
}
// ZERO_TOOL_OUTPUT_CEILING_TOKENS tightens, loosens, or disables the ceiling.
func TestOutputCeilingEnvOverride(t *testing.T) {
setTestTempDir(t)
big := strings.Repeat("z", 100*1024)
t.Setenv(outputCeilingEnv, "1000")
registry := NewRegistry()
registry.Register(newCeilingFakeTool("fake_env", big))
result := registry.Run(context.Background(), "fake_env", map[string]any{})
if !result.Truncated || len(result.Output) > 1000*4+512 {
t.Fatalf("ceiling override ignored: truncated=%v, %d bytes", result.Truncated, len(result.Output))
}
t.Setenv(outputCeilingEnv, "0")
result = registry.Run(context.Background(), "fake_env", map[string]any{})
if result.Truncated || len(result.Output) != len(big) {
t.Fatalf("ceiling=0 must disable the net: truncated=%v, %d bytes", result.Truncated, len(result.Output))
}
}
// The exemption list matches the tools that really do budget themselves, and
// the unbudgeted ones (web_fetch, skill, browser) stay under the net.
func TestSelfBudgetingExemptionList(t *testing.T) {
dir := t.TempDir()
exempt := []Tool{
NewBashTool(dir),
NewExecCommandTool(dir, newExecSessionManager()),
NewReadFileTool(dir),
NewReadMinifiedFileTool(dir),
NewGrepTool(dir),
NewGlobTool(dir),
NewListDirectoryTool(dir),
}
for _, tool := range exempt {
if _, ok := tool.(selfBudgeting); !ok {
t.Errorf("%s must be exempt from the output ceiling", tool.Name())
}
}
if _, ok := NewWebFetchTool().(selfBudgeting); ok {
t.Error("web_fetch must NOT be exempt — the ceiling is its only budget")
}
}
+13 -2
View File
@@ -123,13 +123,24 @@ func (registry *Registry) Run(ctx context.Context, name string, args map[string]
func (registry *Registry) RunWithOptions(ctx context.Context, name string, args map[string]any, options RunOptions) (result Result) {
// Every return path passes through scrubResultSecrets exactly once, so denial,
// permission, and unknown-tool error messages (which can echo secret-bearing
// args/paths) are redacted at the boundary just like tool output.
defer func() { result = scrubResultSecrets(result) }()
// args/paths) are redacted at the boundary just like tool output. The output
// ceiling runs after the scrub so the transcript and the spill file agree on
// what was hidden.
ceilingExempt := false
defer func() {
result = scrubResultSecrets(result)
if !ceilingExempt {
result = enforceOutputCeiling(name, result)
}
}()
tool, ok := registry.Get(name)
if !ok {
return errorResult(`Error: Unknown tool "` + name + `".`)
}
if _, ok := tool.(selfBudgeting); ok {
ceilingExempt = true
}
if rejecter, ok := tool.(PrePermissionRejecter); ok {
if res, rejected := rejecter.RejectBeforePermission(args); rejected {
return res
+179 -21
View File
@@ -3,6 +3,7 @@ package tools
import (
"regexp"
"strings"
"unicode"
)
type shellRuntime struct {
@@ -17,14 +18,31 @@ type shellIssue struct {
Suggestion string
}
const windowsMsysSandboxKind = "windows_msys_sandbox"
const windowsMsysSandboxSuggestion = "MSYS/Cygwin coreutils from Git for Windows cannot run under Zero's write-restricted Windows sandbox. Prefer Zero native tools (grep, read_file with offset/limit, list_directory, glob), cmd.exe findstr/more, or PowerShell Select-Object -First/-Last. If host-level execution is truly required, rerun with sandbox_permissions: \"require_escalated\" and a narrow justification."
// windowsMsysProneNames is the single source of truth for POSIX coreutil names
// that commonly resolve to a Git-for-Windows MSYS/Cygwin binary rather than a
// cmd.exe-native command, and so fail under the write-restricted Windows
// sandbox (#458). Every Windows MSYS-detection path (the preflight command
// scan below, the exported MsysProneCommandName, and the known-safe-segment
// guard in internal/agent/command_prefix.go) derives from this one set, so
// they cannot drift out of sync with each other.
var windowsMsysProneNames = map[string]bool{
"cat": true, "cut": true, "expr": true, "grep": true, "head": true,
"id": true, "ls": true, "nl": true, "paste": true, "rev": true,
"seq": true, "stat": true, "tail": true, "tr": true, "uname": true,
"uniq": true, "wc": true, "which": true, "awk": true, "sed": true,
"xargs": true,
}
var (
windowsBashStyleCDPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)cd\s+/(?:[a-ce-z0-9_./~-]|d[a-z0-9_./~-])[a-z0-9_./~-]*`)
windowsLSCommandPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)ls\b(?:\s+|$)`)
// windowsPosixUtilityPattern catches the other common POSIX coreutils/grep
// family commands cmd.exe has no equivalent for (unlike `dir`/`findstr`,
// which sound similar enough that a model might reach for the POSIX name
// instead) — most often seen piped in, e.g. `git log ... | head`.
windowsPosixUtilityPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)(head|tail|grep|wc|awk|sed|cut|xargs|tr)(?:\s+|$)`)
// windowsMsysBinaryPathPattern catches explicit Git-for-Windows / MSYS usr\bin
// paths. These executables are valid Windows PE files but fail under the
// write-restricted sandbox with CreateFileMapping ACCESS_DENIED (#458).
windowsMsysBinaryPathPattern = regexp.MustCompile(`(?i)(?:\\usr\\bin\\|\\mingw64\\bin\\|msys-2\.0\.dll|cygwin1\.dll)`)
)
func detectShellRuntime(goos string) shellRuntime {
@@ -37,48 +55,162 @@ func detectShellRuntime(goos string) shellRuntime {
func shellGuidanceForGOOS(goos string) string {
runtime := detectShellRuntime(goos)
if goos == "windows" {
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories."
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories. MSYS/Cygwin coreutils on PATH (Git for Windows usr\\bin) are not sandbox-compatible; prefer native Zero file tools."
}
guidance := "Uses " + runtime.Syntax + " syntax."
if goos == "darwin" {
// `ps` is setuid root and cannot run under the macOS sandbox; `pgrep` needs a
// blocked system service. Point the model at the tools that DO work so it
// doesn't waste turns: lsof to find a process, kill to stop it.
guidance += " To find or stop a process, use `lsof -i :PORT` (or `lsof -nP -iTCP -sTCP:LISTEN`) for the PID then `kill <pid>`; `ps` and `pgrep` do not work under the sandbox."
}
return guidance
}
// MsysProneCommandName reports whether a bare command name commonly resolves to
// a Git-for-Windows MSYS binary that fails under the Windows restricted sandbox.
func MsysProneCommandName(name string) bool {
return windowsMsysProneNames[strings.ToLower(strings.TrimSpace(name))]
}
func windowsMsysSandboxIssue(message string) *shellIssue {
return &shellIssue{
Kind: windowsMsysSandboxKind,
Message: message,
Suggestion: windowsMsysSandboxSuggestion,
}
}
// windowsCommandSegments splits a command into cmd.exe-operator-separated
// segments (&, |, and their doubled forms &&/||), respecting double quotes
// (cmd.exe's grouping construct) and the caret (^) escape character, so an
// operator or command name mentioned inside a quoted argument (e.g. a commit
// message or PR comment body), or an operator escaped with ^ (cmd.exe prints
// `echo ^| head` as literal text instead of piping to head), is not mistaken
// for a real segment boundary or invocation. Unlike bash, cmd.exe does not
// treat ; as a statement separator, so it is left as ordinary argument text
// (e.g. `echo foo; head` is a single `echo` invocation with literal args).
func windowsCommandSegments(command string) []string {
var segments []string
var current strings.Builder
inQuotes := false
runes := []rune(command)
for i := 0; i < len(runes); i++ {
c := runes[i]
if !inQuotes && c == '^' && i+1 < len(runes) {
current.WriteRune(c)
i++
current.WriteRune(runes[i])
continue
}
if c == '"' {
inQuotes = !inQuotes
current.WriteRune(c)
continue
}
if !inQuotes && (c == '&' || c == '|') {
if seg := strings.TrimSpace(current.String()); seg != "" {
segments = append(segments, seg)
}
current.Reset()
continue
}
current.WriteRune(c)
}
if seg := strings.TrimSpace(current.String()); seg != "" {
segments = append(segments, seg)
}
return segments
}
// firstCommandWord returns the first token of a command segment. A leading
// double-quoted span counts as one token with its quotes stripped, since
// cmd.exe treats a quoted path as a single argument: the command invoked by
// `"C:\Program Files\Git\usr\bin\head.exe" file.txt` is the quoted path, not
// "C:\Program. For an unquoted command, the token ends at whitespace or a
// redirection operator (<, >): cmd.exe accepts redirection attached directly
// to the command name with no separating space (head>out.txt, cat<in.txt), so
// stopping only at whitespace would return "head>out.txt" as one word and
// miss the invoked command.
func firstCommandWord(segment string) string {
trimmed := strings.TrimSpace(segment)
if trimmed == "" {
return ""
}
if trimmed[0] == '"' {
if end := strings.IndexByte(trimmed[1:], '"'); end >= 0 {
return trimmed[1 : end+1]
}
return trimmed[1:]
}
if end := strings.IndexFunc(trimmed, isCommandWordBoundary); end >= 0 {
return trimmed[:end]
}
return trimmed
}
func isCommandWordBoundary(r rune) bool {
return unicode.IsSpace(r) || r == '<' || r == '>'
}
// msysProneCommandWord reports whether word (the first token of a command
// segment, as returned by firstCommandWord) names an MSYS-prone coreutil,
// bare or with a directory prefix and/or .exe suffix (head, head.exe,
// C:\...\usr\bin\head.exe, ...).
func msysProneCommandWord(word string) bool {
word = strings.Trim(word, `"`)
if i := strings.LastIndexAny(word, `\/`); i >= 0 {
word = word[i+1:]
}
word = strings.TrimSuffix(strings.ToLower(word), ".exe")
return MsysProneCommandName(word)
}
func detectShellCommandIssue(command string, goos string) *shellIssue {
if goos != "windows" {
return nil
}
trimmed := strings.TrimSpace(command)
if windowsBashStyleCDPattern.MatchString(trimmed) ||
windowsLSCommandPattern.MatchString(trimmed) {
if windowsBashStyleCDPattern.MatchString(trimmed) {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Command looks like POSIX/Bash syntax, but Zero runs bash tool commands through Windows cmd.exe on this host.",
Suggestion: "Use the cwd argument instead of cd, use Windows cmd.exe syntax, or use native tools such as list_directory, read_file, grep, and glob.",
}
}
if windowsPosixUtilityPattern.MatchString(trimmed) {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Command uses a POSIX utility (head/tail/grep/wc/awk/sed/cut/xargs/tr) that Windows cmd.exe does not have.",
Suggestion: "Use cmd.exe equivalents (e.g. `findstr` for grep, `more` to page output) or Zero's native tools (grep, read_file with offset/limit) instead of piping to a POSIX utility.",
// Check the first word of each operator-separated segment (not the raw
// text anywhere in the command) against the MSYS binary-path pattern and
// the single MSYS-prone name set, covering bare names (head), .exe names
// (head.exe), and directory-prefixed forms (C:\...\head.exe) uniformly.
// Being segment/word anchored rather than a whole-string regex or scan,
// neither check matches text that only appears inside a quoted argument
// (e.g. a commit message or PR comment body discussing head.exe).
for _, segment := range windowsCommandSegments(trimmed) {
word := firstCommandWord(segment)
if windowsMsysBinaryPathPattern.MatchString(word) {
return windowsMsysSandboxIssue("Command invokes an MSYS/Cygwin binary path that cannot run under Zero's Windows sandbox.")
}
if msysProneCommandWord(word) {
return windowsMsysSandboxIssue("Command uses a POSIX coreutil (head/tail/grep/cat/...) that commonly resolves to Git-for-Windows MSYS binaries incompatible with the Windows sandbox.")
}
}
return nil
}
func detectShellOutputIssue(command string, output string, goos string) *shellIssue {
// detectShellOutputIssue looks for MSYS runtime crash markers and cmd.exe
// syntax-error text in output only, never in the command that was run. The
// command line is attacker/user-controlled argument text (e.g. a `gh pr
// comment --body` quoting a sample failure), not something the shell
// produced, so treating it as evidence would reintroduce the same
// quoted-text false positives the preflight command-position check exists to
// avoid, just after execution instead of before it.
func detectShellOutputIssue(output string, goos string) *shellIssue {
if goos != "windows" {
return nil
}
haystack := strings.ToLower(command + "\n" + output)
if strings.Contains(haystack, "the syntax of the command is incorrect") ||
strings.Contains(haystack, "is not recognized as an internal or external command") {
lower := strings.ToLower(output)
if msysRuntimeFailedInOutput(lower) {
return windowsMsysSandboxIssue("An MSYS/Cygwin runtime failed under Zero's Windows sandbox (ACCESS_DENIED during MSYS startup).")
}
if strings.Contains(lower, "the syntax of the command is incorrect") ||
strings.Contains(lower, "is not recognized as an internal or external command") {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Windows cmd.exe rejected the command syntax.",
@@ -88,6 +220,32 @@ func detectShellOutputIssue(command string, output string, goos string) *shellIs
return nil
}
func msysRuntimeFailedInOutput(lower string) bool {
if strings.Contains(lower, "fatal error - createfilemapping") {
return true
}
if strings.Contains(lower, "couldn't create signal pipe") && strings.Contains(lower, "win32 error 5") {
return true
}
if strings.Contains(lower, "cygheap_user::init") && strings.Contains(lower, "fatal error") {
return true
}
if strings.Contains(lower, "usr\\bin\\") && strings.Contains(lower, "fatal error") {
return true
}
if !strings.Contains(lower, "win32 error 5") || !strings.Contains(lower, "terminating") {
return false
}
// Anchor the broad win32-error-5 fallback to an MSYS-specific marker so
// unrelated access-denied failures are not mislabeled as MSYS sandbox
// incompatibilities.
return strings.Contains(lower, `usr\bin\`) ||
strings.Contains(lower, "cygheap") ||
strings.Contains(lower, "msys-2.0.dll") ||
strings.Contains(lower, "cygwin1.dll") ||
strings.Contains(lower, "[main]")
}
func appendShellIssueHint(output string, issue shellIssue) string {
output = strings.TrimRight(output, "\r\n")
hint := "[zero] shell issue: " + issue.Message
+208
View File
@@ -0,0 +1,208 @@
package tools
import (
"strings"
"testing"
)
func TestDetectShellCommandIssueFlagsMsysBinaryPaths(t *testing.T) {
for _, command := range []string{
`for /F %i in ('whoami') do echo %i | "C:\Program Files\Git\usr\bin\head.exe" -1`,
`C:\Git\usr\bin\grep.exe pattern file.txt`,
} {
issue := detectShellCommandIssue(command, "windows")
if issue == nil {
t.Fatalf("expected MSYS path issue for %q", command)
}
if issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected windows_msys_sandbox kind, got %q", issue.Kind)
}
if !strings.Contains(issue.Suggestion, "require_escalated") {
t.Fatalf("expected escalation guidance, got %#v", issue)
}
}
}
func TestDetectShellCommandIssueFlagsStandaloneCat(t *testing.T) {
issue := detectShellCommandIssue(`cat README.md`, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS sandbox issue for cat, got %#v", issue)
}
}
func TestDetectShellOutputIssueFlagsMsysCreateFileMappingError(t *testing.T) {
output := `0 [main] head (3568) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - CreateFileMapping S-1-5-21-3149109338-1484423945-518236903-1001.1, Win32 error 5. Terminating.`
issue := detectShellOutputIssue(output, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS output issue, got %#v", issue)
}
if !strings.Contains(issue.Suggestion, "require_escalated") {
t.Fatalf("expected escalation guidance, got %#v", issue)
}
}
func TestDetectShellOutputIssueFlagsMsysSignalPipeError(t *testing.T) {
output := `0 [main] head (39684) cygheap_user::init: NtSetInformationToken (TokenDefaultDacl), 0xC0000022
648 [main] head (39684) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - couldn't create signal pipe, Win32 error 5`
issue := detectShellOutputIssue(output, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS output issue, got %#v", issue)
}
}
func TestDetectShellOutputIssueFlagsMsysTerminatingWithMsysMarker(t *testing.T) {
output := `1 [main] tail (4321) tail: *** MapViewOfFileEx failed, Win32 error 5. Terminating.`
issue := detectShellOutputIssue(output, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS output issue, got %#v", issue)
}
}
func TestDetectShellOutputIssueIgnoresNonMsysWin32Error5(t *testing.T) {
output := `myapp.exe: unable to open service handle, Win32 error 5 (access denied). Terminating worker.`
issue := detectShellOutputIssue(output, "windows")
if issue != nil {
t.Fatalf("expected no issue for non-MSYS access-denied output, got %#v", issue)
}
}
func TestShellIssueBlockResultMsysCommand(t *testing.T) {
result := shellIssueBlockResult(*detectShellCommandIssue(`cat README.md`, "windows"))
if result.Status != StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
for _, want := range []string{"[zero] shell issue:", "MSYS/Cygwin", "grep", "read_file", "require_escalated"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected %q in blocked output, got %q", want, result.Output)
}
}
if result.Meta["shell_issue"] != "windows_msys_sandbox" {
t.Fatalf("meta shell_issue = %q", result.Meta["shell_issue"])
}
}
func TestMsysProneCommandName(t *testing.T) {
if !MsysProneCommandName("HEAD") || MsysProneCommandName("echo") {
t.Fatalf("unexpected MsysProneCommandName results")
}
}
// TestDetectShellCommandIssueFlagsExprAndLsConsistently guards against the
// preflight regex list drifting from the canonical windowsMsysProneNames set
// (both listed expr and ls as MSYS-prone, but the old regex alternations
// omitted expr entirely and let ls hit the older windows_shell_syntax branch
// first, so it never got MSYS-kind guidance).
func TestDetectShellCommandIssueFlagsExprAndLsConsistently(t *testing.T) {
for _, command := range []string{
`expr 1 + 1`,
`expr.exe 1 + 1`,
`ls -la`,
`ls`,
} {
issue := detectShellCommandIssue(command, "windows")
if issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected windows_msys_sandbox for %q, got %#v", command, issue)
}
}
}
// TestDetectShellCommandIssueIgnoresQuotedMsysMentions guards against
// treating an MSYS-prone name that only appears inside a quoted argument
// (e.g. a commit message, a PR comment body, or a doc string discussing the
// command) as an actual invocation. The preflight check must anchor on the
// first word of each command segment, not scan the raw text anywhere.
func TestDetectShellCommandIssueIgnoresQuotedMsysMentions(t *testing.T) {
for _, command := range []string{
`git commit -m "fix head.exe crash"`,
`gh pr comment --body "grep.exe fails under MSYS"`,
`echo "log | head is broken on windows"`,
`git commit -m "note: | head does not work here"`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected quoted MSYS mention to pass for %q, got %#v", command, issue)
}
}
// A real invocation alongside quoted text must still be caught.
if issue := detectShellCommandIssue(`echo "not a real command" && head file.txt`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real head invocation to still be flagged, got %#v", issue)
}
}
// TestDetectShellCommandIssueIgnoresQuotedMsysPathMentions guards the
// explicit MSYS-binary-path check the same way as the coreutil-name check:
// a full usr\bin\ path that only appears inside a quoted argument (e.g. a
// commit message describing the failure) must not be treated as an
// invocation, since the path check is now anchored to the first word of each
// command segment rather than scanned across the raw command text.
func TestDetectShellCommandIssueIgnoresQuotedMsysPathMentions(t *testing.T) {
for _, command := range []string{
`git commit -m "C:\Program Files\Git\usr\bin\head.exe fails"`,
`gh pr comment --body "C:\Git\usr\bin\grep.exe is blocked"`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected quoted MSYS path mention to pass for %q, got %#v", command, issue)
}
}
// A real invocation by full path must still be caught.
if issue := detectShellCommandIssue(`C:\Git\usr\bin\grep.exe pattern file.txt`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real MSYS path invocation to still be flagged, got %#v", issue)
}
}
// TestDetectShellCommandIssueRespectsCaretEscapedOperators guards against
// misreading cmd.exe's ^ escape character: `echo ^| head` prints the pipe and
// "head" as literal text (the caret escapes the pipe so it never splits into
// a separate `head` invocation), and `echo foo; head` is a single `echo`
// command with literal arguments since cmd.exe (unlike bash) does not treat
// ; as a statement separator.
func TestDetectShellCommandIssueRespectsCaretEscapedOperators(t *testing.T) {
for _, command := range []string{
`echo ^| head`,
`echo ^& head`,
`echo foo; head`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected no issue for %q, got %#v", command, issue)
}
}
// An unescaped pipe must still split into a real head invocation.
if issue := detectShellCommandIssue(`echo foo | head`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real head invocation to still be flagged, got %#v", issue)
}
}
// TestDetectShellCommandIssueFlagsRedirectionAttachedToCommand guards against
// firstCommandWord treating cmd.exe redirection operators as part of the
// command name. cmd.exe accepts redirection with no separating space
// (head>out.txt, cat<in.txt), so splitting only on whitespace would return
// "head>out.txt" as one word and miss the invoked command entirely.
func TestDetectShellCommandIssueFlagsRedirectionAttachedToCommand(t *testing.T) {
for _, command := range []string{
`some-command | head>out.txt`,
`cat<in.txt`,
`grep>matches.txt pattern`,
} {
issue := detectShellCommandIssue(command, "windows")
if issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected windows_msys_sandbox for %q, got %#v", command, issue)
}
}
}
// TestDetectShellOutputIssueSignatureOmitsCommandText documents, at the type
// level, that detectShellOutputIssue can no longer take the command line as
// evidence: it only accepts the real output. Harmless output must not be
// flagged, and output that genuinely carries the MSYS failure markers must
// still be flagged.
func TestDetectShellOutputIssueSignatureOmitsCommandText(t *testing.T) {
if issue := detectShellOutputIssue("hello from bash", "windows"); issue != nil {
t.Fatalf("expected no issue for harmless output, got %#v", issue)
}
output := `0 [main] head (3568) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - CreateFileMapping ..., Win32 error 5. Terminating.`
if issue := detectShellOutputIssue(output, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real MSYS output to still be flagged, got %#v", issue)
}
}
+6 -2
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/secrets"
)
// Spill-to-disk for truncated tool output. When a command produces more than
@@ -90,8 +91,10 @@ func spillDir() (string, error) {
// spillTruncatedOutput writes the full pre-truncation output to the spill
// directory and returns the file path, or "" when spilling fails. Output is
// scrubbed with the same configured-key redaction the registry applies at the
// tool boundary, so a spilled file never holds a secret the transcript would
// have hidden.
// tool boundary PLUS the pattern-based secret scanner (AWS keys, tokens, PEM
// blocks, JWTs) that bash applies to its model-visible output — a spill runs
// before that formatter, so without the scan here a spilled file would hold
// pattern-matched credentials in cleartext that the transcript hides.
func spillTruncatedOutput(toolName, output string) string {
dir, err := spillDir()
if err != nil {
@@ -110,6 +113,7 @@ func spillTruncatedOutput(toolName, output string) string {
}
defer file.Close()
scrubbed := redaction.RedactString(output, redaction.Options{})
scrubbed, _ = secrets.Redact(scrubbed)
if _, err := file.WriteString(scrubbed); err != nil {
_ = os.Remove(file.Name())
return ""
+27
View File
@@ -63,6 +63,33 @@ func TestSweepSpillDirRemovesOnlyOldFiles(t *testing.T) {
}
}
// A spill happens BEFORE bash's model-facing formatter runs its pattern-based
// secret scan, so the spill itself must scrub pattern-matched credentials —
// otherwise the file would hold in cleartext exactly what the transcript hides.
func TestSpillTruncatedOutputScrubsPatternSecrets(t *testing.T) {
setTestTempDir(t)
githubToken := "ghp_" + strings.Repeat("a", 36)
body := "before\nAKIAIOSFODNN7EXAMPLE\n" + githubToken + "\nafter"
path := spillTruncatedOutput("bash", body)
if path == "" {
t.Fatal("spill must return a file path")
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(content), "AKIAIOSFODNN7EXAMPLE") {
t.Fatal("AWS access key reached the spill file in cleartext")
}
if strings.Contains(string(content), githubToken) {
t.Fatal("GitHub token reached the spill file in cleartext")
}
if !strings.Contains(string(content), "before") || !strings.Contains(string(content), "after") {
t.Fatalf("non-secret content must survive the scrub: %q", content)
}
}
func TestSpillTruncatedOutputWritesFile(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
path := spillTruncatedOutput("exec_command", "some output body")
+2 -2
View File
@@ -28,7 +28,7 @@ const (
webFetchTimeout = 30 * time.Second
webFetchRedirectLimit = 5
webFetchErrorBodyLimit = 4 * 1024
webFetchPublicOnlyHint = "web_fetch only supports public remote HTTP/HTTPS URLs. For localhost or private network URLs, use bash with curl so sandbox network permission can apply."
webFetchPublicOnlyHint = "web_fetch only supports public remote HTTP/HTTPS URLs. For localhost or private network URLs, use bash with curl so sandbox network permission can apply"
)
type webFetchTool struct {
@@ -348,7 +348,7 @@ func webFetchRedirectPolicy(previous func(*http.Request, []*http.Request) error,
return fmt.Errorf("too many redirects: maximum is %d", webFetchRedirectLimit)
}
if err := validateParsedWebFetchURL(request.Context(), request.URL, resolver); err != nil {
return fmt.Errorf("Unsafe redirect URL: %w", err)
return fmt.Errorf("unsafe redirect URL: %w", err)
}
if previous != nil {
return previous(request, via)
+1 -1
View File
@@ -405,7 +405,7 @@ func TestWebFetchToolRejectsUnsafeRedirects(t *testing.T) {
if result.Status != StatusError {
t.Fatalf("expected redirect safety error, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "Unsafe redirect URL") {
if !strings.Contains(result.Output, "unsafe redirect URL") {
t.Fatalf("expected unsafe redirect message, got %q", result.Output)
}
})
-1
View File
@@ -426,7 +426,6 @@ func stringListArgWebSearch(args map[string]any, key string) (domains []string,
if !ok || value == nil {
return nil, false, nil
}
provided = true
raw, ok := value.([]any)
if !ok {
if asString, ok2 := value.([]string); ok2 {
+48 -1
View File
@@ -188,7 +188,54 @@ func (m model) matchCommandSuggestions(token string) []commandSuggestion {
out := matchCommandSuggestionsWithFilter(token, func(command commandDefinition) bool {
return command.kind != commandSandboxSetup || m.sandboxSetupCommand != nil
})
return append(out, m.matchUserCommandSuggestions(token)...)
out = append(out, m.matchUserCommandSuggestions(token)...)
out = append(out, m.matchSkillSuggestions(token)...)
// Each source caps itself, but the merged list must honor the shared cap too
// (three sources could otherwise stack up to ~3x the palette bound).
if len(out) > maxCommandSuggestions {
out = out[:maxCommandSuggestions]
}
return out
}
// matchSkillSuggestions returns installed skills whose slash name has the typed
// prefix, so skills are discoverable and invocable from the palette like user
// commands. Precedence (builtin > user command > skill) is enforced here by
// skipping any skill whose name is claimed by a builtin (or alias) or a user
// command — unlike the builtin/user pair, a shadowed skill row would be dead at
// dispatch time, so it must not be advertised at all.
func (m model) matchSkillSuggestions(token string) []commandSuggestion {
prefix := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(token, "/")))
if prefix == "" || m.loadSkills == nil {
return nil
}
taken := m.takenSlashNames()
var out []commandSuggestion
// Read through the loader (not the startup snapshot in agentOptions) so the
// palette matches what dispatch will actually resolve — a skill installed or
// removed mid-session appears/disappears without a restart.
for _, skill := range m.installedSkills() {
name := skillSlashName(skill.Name)
if name == "" || taken[name] || !strings.HasPrefix(name, prefix) {
continue
}
// Two skills whose names collide after lowercasing (e.g. "Deploy" and
// "deploy") map to one slash name; only the first — the one dispatch will
// run — may be advertised, or the row's description and the executed
// instructions silently diverge.
taken[name] = true
desc := strings.TrimSpace(skill.Description)
if desc == "" {
desc = "Skill: /" + name
} else {
desc += " (skill)"
}
out = append(out, commandSuggestion{Name: "/" + name, Desc: desc})
if len(out) >= maxCommandSuggestions {
break
}
}
return out
}
// matchUserCommandSuggestions returns file-sourced /commands whose name has the
+16
View File
@@ -698,3 +698,19 @@ func (m model) debugText() string {
}},
})
}
// skillsText is the /skills fallback when NO skills are installed — an install
// hint. With skills present /skills opens the searchable skill picker instead
// (see newSkillPicker), matching how /model works.
func (m model) skillsText() string {
return renderCommandOutput(commandOutput{
Title: "Skills",
Status: commandStatusInfo,
Sections: []commandSection{{
Lines: []string{"No skills installed."},
}},
Hints: []string{
"install one: create <skills-dir>/<name>/SKILL.md (see `zero skills`)",
},
})
}
+16
View File
@@ -46,6 +46,8 @@ const (
commandCopy
commandExport
commandNew
commandSkills
commandLoop
commandUnknown
)
@@ -131,6 +133,13 @@ var commandDefinitions = []commandDefinition{
description: "List registered tools.",
kind: commandTools,
},
{
name: "/skills",
usage: "/skills",
group: commandGroupTools,
description: "List installed skills; run one directly with /<skill-name> [args].",
kind: commandSkills,
},
{
name: "/context",
usage: "/context",
@@ -289,6 +298,13 @@ var commandDefinitions = []commandDefinition{
description: "Write the conversation transcript to a file.",
kind: commandExport,
},
{
name: "/loop",
usage: "/loop [interval] <prompt|/command> | /loop list | /loop stop [id|all]",
group: commandGroupSession,
description: "Repeat a prompt or command on an interval (e.g. /loop 5m /babysit-prs), or self-paced when no interval is given.",
kind: commandLoop,
},
{
name: "/help",
usage: "/help",
+693
View File
@@ -0,0 +1,693 @@
package tui
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
tea "charm.land/bubbletea/v2"
)
// The /loop command repeats a prompt or slash command in one of two modes:
//
// - Fixed interval — `/loop 5m <prompt>` re-fires on a wall-clock cadence.
// - Self-paced — bare `/loop <prompt>` runs a maintenance playbook: the model
// does one increment of work each iteration and ends its reply with a control
// line (`LOOP: DONE` / `LOOP: CONTINUE [interval]`) that stops the loop or picks
// the next wake. With no control line it falls back to an adaptive cadence.
//
// Loops are foreground and session-scoped: a tick only fires while the session is
// idle between turns (it never interrupts a streaming turn), the loop lives with the
// session that created it, and switching sessions (/new, /resume) stops it. Safety
// rails cap iterations, age, consecutive failures, and no-progress repetition.
// Everything here is pure/loop-local; the turn-lifecycle wiring (ticker, launch,
// continuation) lives in model.go.
type loopMode int
const (
loopModeFixed loopMode = iota // re-fire every fixed interval
loopModeSelfPaced // model chooses the next wake each iteration
)
// Safety bounds. A loop is foreground + session-scoped, but these keep a runaway
// or forgotten loop from burning tokens forever.
const (
loopMinInterval = 30 * time.Second
loopMaxInterval = 24 * time.Hour
loopMaxIterations = 500 // hard ceiling; the loop auto-stops after this many
// loopMaxAge auto-expires a loop this long after it was created, so a forgotten
// loop stops on its own even if it never reaches the iteration cap.
loopMaxAge = 24 * time.Hour
loopSelfPaceMin = 1 * time.Minute
loopSelfPaceMax = 1 * time.Hour
loopSelfPaceDefault = 15 * time.Minute
// loopDoomThreshold ends a loop after this many consecutive identical final
// answers — a self-paced or interval loop that gets stuck repeating the same
// no-progress result should stop rather than churn.
loopDoomThreshold = 5
// loopMaxFailures ends a loop after this many consecutive failed iterations, so
// a loop whose iterations keep erroring stops instead of churning to the
// iteration cap (a failed turn has no final answer, so the doom guard can't see
// the lack of progress).
loopMaxFailures = 3
// loopPollInterval is how often the controller checks whether a loop is due.
// A loop only fires while idle, so this is a cheap between-turn poll.
loopPollInterval = 1 * time.Second
)
// loopState is one active loop owned by the current session.
type loopState struct {
id string
mode loopMode
prompt string // the prompt or "/command" re-run each iteration
interval time.Duration
iteration int
maxIter int // 0 => loopMaxIterations
createdAt time.Time
nextRunAt time.Time
// lastResult / repeatRun drive doom-loop detection: consecutive identical
// final answers past loopDoomThreshold end the loop.
lastResult string
repeatRun int
// failRun counts consecutive failed iterations; past loopMaxFailures the loop
// stops (a failed turn has no final answer for the doom guard to compare).
failRun int
paused bool
}
func (l *loopState) iterationCap() int {
if l.maxIter > 0 {
return l.maxIter
}
return loopMaxIterations
}
// due reports whether the loop should fire at now (active, not paused, past its
// next-run mark).
func (l *loopState) due(now time.Time) bool {
return !l.paused && !l.nextRunAt.IsZero() && !now.Before(l.nextRunAt)
}
// cadenceText renders the loop's cadence for status lines.
func (l *loopState) cadenceText() string {
if l.mode == loopModeSelfPaced {
return "self-paced"
}
return "every " + formatLoopDuration(l.interval)
}
type loopAction int
const (
loopActionUsage loopAction = iota // show usage / list when empty
loopActionStart // start a new loop
loopActionStop // stop one by id
loopActionStopAll // stop every loop
loopActionList // list active loops
)
// loopCommand is the parsed form of a `/loop ...` invocation (the text after the
// command name).
type loopCommand struct {
action loopAction
targetID string
mode loopMode
prompt string
interval time.Duration
// note carries a non-fatal advisory (e.g. an interval that was clamped) to
// surface back to the user.
note string
}
var loopIntervalRe = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
// loopControlRe matches a self-paced loop's control line at the start of a line,
// tolerant of a leading markdown bullet/quote marker and any trailing note after
// the keyword: "LOOP: DONE", "- loop: continue", "LOOP: CONTINUE 10m — still tidying".
// The leading ^ anchor keeps a mid-sentence mention ("I'll emit LOOP: DONE when…")
// from matching; parseLoopControl normalizes CRLF first so `$` can reach line end.
var loopControlRe = regexp.MustCompile(`(?im)^[^\S\r\n]*(?:[-*>•][^\S\r\n]*)?loop:[^\S\r\n]*(done|continue)\b(?:[^\S\r\n]+(\d+[smhd])\b)?.*$`)
// parseLoopCommand interprets the arguments to `/loop`. Forms:
//
// (empty) -> usage/list
// list | status -> list active loops
// stop | stop all | stop <id>
// <interval> <prompt> -> fixed-interval loop (e.g. "5m /babysit")
// <prompt> every <interval>
// <prompt> -> self-paced loop (no interval)
func parseLoopCommand(args string) loopCommand {
trimmed := strings.TrimSpace(args)
if trimmed == "" {
return loopCommand{action: loopActionUsage}
}
first := strings.ToLower(strings.Fields(trimmed)[0])
switch first {
case "list", "status", "ls":
return loopCommand{action: loopActionList}
case "stop", "cancel", "kill":
rest := strings.TrimSpace(trimmed[len(strings.Fields(trimmed)[0]):])
if rest == "" || strings.EqualFold(rest, "all") {
if strings.EqualFold(rest, "all") {
return loopCommand{action: loopActionStopAll}
}
// bare "stop" stops all when ambiguous; the caller may special-case a
// single active loop.
return loopCommand{action: loopActionStop, targetID: ""}
}
return loopCommand{action: loopActionStop, targetID: strings.TrimSpace(rest)}
}
// Leading interval token: "5m <prompt>".
if fields := strings.Fields(trimmed); len(fields) >= 2 {
if d, ok := parseLoopInterval(fields[0]); ok {
prompt := strings.TrimSpace(trimmed[len(fields[0]):])
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
cmd.interval, cmd.note = clampLoopInterval(d)
return cmd
}
}
// Trailing "every <interval>".
if prompt, d, ok := splitTrailingEvery(trimmed); ok {
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
cmd.interval, cmd.note = clampLoopInterval(d)
return cmd
}
// No interval anywhere -> self-paced.
return loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: trimmed}
}
// splitTrailingEvery matches "<prompt> every <interval>" (the interval must be a
// real duration token so "check every PR" is not mis-parsed as a cadence).
func splitTrailingEvery(input string) (string, time.Duration, bool) {
fields := strings.Fields(input)
if len(fields) < 3 {
return "", 0, false
}
last := fields[len(fields)-1]
prev := fields[len(fields)-2]
if !strings.EqualFold(prev, "every") {
return "", 0, false
}
d, ok := parseLoopInterval(last)
if !ok {
return "", 0, false
}
prompt := strings.TrimSpace(strings.Join(fields[:len(fields)-2], " "))
if prompt == "" {
return "", 0, false
}
return prompt, d, true
}
// parseLoopInterval parses a bare "<N><unit>" token (s/m/h/d) into a duration.
func parseLoopInterval(token string) (time.Duration, bool) {
m := loopIntervalRe.FindStringSubmatch(strings.ToLower(strings.TrimSpace(token)))
if m == nil {
return 0, false
}
n, err := strconv.Atoi(m[1])
if err != nil || n <= 0 {
return 0, false
}
unit := map[string]time.Duration{
"s": time.Second,
"m": time.Minute,
"h": time.Hour,
"d": 24 * time.Hour,
}[m[2]]
return time.Duration(n) * unit, true
}
// clampLoopInterval bounds an interval to [loopMinInterval, loopMaxInterval] and
// returns an advisory note when it was adjusted.
func clampLoopInterval(d time.Duration) (time.Duration, string) {
switch {
case d < loopMinInterval:
return loopMinInterval, fmt.Sprintf("interval raised to the %s minimum", formatLoopDuration(loopMinInterval))
case d > loopMaxInterval:
return loopMaxInterval, fmt.Sprintf("interval lowered to the %s maximum", formatLoopDuration(loopMaxInterval))
default:
return d, ""
}
}
// loopTickMsg is the between-turn poll that fires a due loop while the session is
// idle. seq invalidates a tick left over from before the loop set changed.
type loopTickMsg struct{ seq int }
// handleLoopCommand dispatches a parsed `/loop ...` invocation.
func (m model) handleLoopCommand(args string) (model, tea.Cmd) {
cmd := parseLoopCommand(args)
switch cmd.action {
case loopActionList:
return m.appendLoopSystem(m.loopListText()), nil
case loopActionUsage:
if len(m.loops) > 0 {
return m.appendLoopSystem(m.loopListText()), nil
}
return m.appendLoopSystem(loopUsageText()), nil
case loopActionStop:
return m.stopLoop(cmd.targetID)
case loopActionStopAll:
return m.stopAllLoops()
case loopActionStart:
return m.startLoop(cmd)
}
return m, nil
}
// startLoop registers a new loop and (re)starts the idle poll ticker. The first
// iteration fires on the next idle tick (nextRunAt = now).
func (m model) startLoop(cmd loopCommand) (model, tea.Cmd) {
if reason, ok := m.validateLoopTarget(cmd.prompt); !ok {
return m.appendLoopSystem(reason), nil
}
// A /command loop must be fixed-interval. Self-paced mode drives a free-form
// goal through the playbook + LOOP: control line (see firePrompt), which a
// custom command's expanded template never carries — so a bare self-paced
// /command would silently degrade to adaptive polling with no way to report
// done. Require an explicit interval for command loops instead.
if cmd.mode == loopModeSelfPaced && strings.HasPrefix(strings.TrimSpace(cmd.prompt), "/") {
return m.appendLoopSystem("A /command loop needs an interval — e.g. /loop 5m " + strings.Fields(cmd.prompt)[0] + ". Self-paced mode (no interval) is for a free-form goal."), nil
}
m.loopCounter++
id := "L" + strconv.Itoa(m.loopCounter)
loop := &loopState{
id: id,
mode: cmd.mode,
prompt: cmd.prompt,
interval: cmd.interval,
createdAt: m.now(),
nextRunAt: m.now(), // fire the first iteration on the next idle tick
}
m.loops = append(m.loops, loop)
note := ""
if cmd.note != "" {
note = " (" + cmd.note + ")"
}
m = m.appendLoopSystem(fmt.Sprintf(
"Loop %s started — %s%s. Runs while this session is idle; stop with /loop stop %s.",
id, loop.cadenceText(), note, id))
return m.ensureLoopTick()
}
// stopLoop stops one loop by id, or the single active loop when the id is omitted.
func (m model) stopLoop(id string) (model, tea.Cmd) {
if len(m.loops) == 0 {
return m.appendLoopSystem("No active loops."), nil
}
if id == "" {
if len(m.loops) == 1 {
return m.removeLoop(m.loops[0].id, "Loop "+m.loops[0].id+" stopped.").ensureLoopTick()
}
return m.appendLoopSystem("Multiple loops active — use /loop stop <id> or /loop stop all.\n" + m.loopListText()), nil
}
if m.findLoop(id) == nil {
return m.appendLoopSystem("No loop " + id + ". " + m.loopListText()), nil
}
// removeLoop stops the poll ticker; re-arm it so any OTHER loops that remain
// keep firing (ensureLoopTick no-ops when this was the last loop).
return m.removeLoop(id, "Loop "+id+" stopped.").ensureLoopTick()
}
func (m model) stopAllLoops() (model, tea.Cmd) {
if len(m.loops) == 0 {
return m.appendLoopSystem("No active loops."), nil
}
n := len(m.loops)
m.loops = nil
m.loopSeq++ // invalidate the pending poll tick
m.loopTicking = false
return m.appendLoopSystem(fmt.Sprintf("Stopped all %d loop(s).", n)), nil
}
// fireDueLoopIfIdle fires the earliest due loop when the session is idle. Called
// from the poll tick; a no-op while a turn, modal, or queued user message is
// pending (the loop simply waits for the next idle tick).
func (m model) fireDueLoopIfIdle() (model, tea.Cmd) {
if m.loopBusy() || len(m.loops) == 0 {
return m, nil
}
now := m.now()
var due *loopState
for _, l := range m.loops {
if l.due(now) && (due == nil || l.nextRunAt.Before(due.nextRunAt)) {
due = l
}
}
if due == nil {
return m, nil
}
m.activeLoopID = due.id
due.nextRunAt = time.Time{} // mark running
return m.fireLoopPrompt(due.firePrompt())
}
// firePrompt is the text actually sent for this iteration. A self-paced plain
// prompt is wrapped in the maintenance playbook so the model knows to do one
// increment of work and end with a LOOP: control line; a /command or a
// fixed-interval prompt is sent verbatim.
func (l *loopState) firePrompt() string {
if l.mode == loopModeSelfPaced && !strings.HasPrefix(strings.TrimSpace(l.prompt), "/") {
return loopSelfPacePlaybook(l)
}
return l.prompt
}
// fireLoopPrompt runs a loop's prompt as a turn — a custom /command if it resolves
// to one, otherwise a plain prompt.
func (m model) fireLoopPrompt(prompt string) (model, tea.Cmd) {
if strings.HasPrefix(strings.TrimSpace(prompt), "/") {
if next, teaCmd, ok := m.handleUserCommand(prompt); ok {
return next, teaCmd
}
}
return m.launchPrompt(prompt)
}
// advanceLoop updates a loop after one of its iterations completes: it folds the
// result into the failure/no-progress guards, honors a self-paced DONE/next-wake
// control line, enforces the iteration and age caps, then schedules the next wake
// (or stops the loop).
func (m model) advanceLoop(id, finalAnswer string, runErr error) model {
l := m.findLoop(id)
if l == nil {
return m // stopped mid-run
}
l.iteration++
if runErr != nil {
l.failRun++
} else {
l.failRun = 0
}
res := strings.TrimSpace(finalAnswer)
if res != "" && res == l.lastResult {
l.repeatRun++
} else {
l.repeatRun = 0
}
l.lastResult = res
// Only self-paced iterations run the playbook, so only they can carry a control
// line; a failed turn has no trustworthy final answer to read one from.
done, wake := false, time.Duration(0)
if l.mode == loopModeSelfPaced && runErr == nil {
done, wake = parseLoopControl(finalAnswer)
}
switch {
case done:
return m.removeLoop(id, fmt.Sprintf("Loop %s finished — the task reported done after %d iteration(s).", id, l.iteration))
case l.failRun >= loopMaxFailures:
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped after %d consecutive failed iterations.", id, l.failRun))
case l.iteration >= l.iterationCap():
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped at its %d-iteration cap.", id, l.iteration))
case !l.createdAt.IsZero() && m.now().Sub(l.createdAt) >= loopMaxAge:
return m.removeLoop(id, fmt.Sprintf("Loop %s expired after %s.", id, formatLoopDuration(loopMaxAge)))
case l.repeatRun >= loopDoomThreshold:
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped — %d identical results in a row (no progress).", id, l.repeatRun))
}
if l.mode == loopModeSelfPaced {
// A model-chosen next wake (from the control line) wins; otherwise fall back to
// an adaptive cadence that checks back often while work is fresh and settles to
// a slow heartbeat as iterations accrue.
delay := wake
if delay <= 0 {
delay = adaptiveSelfPaceDelay(l.iteration)
}
l.nextRunAt = m.now().Add(clampSelfPaceDelay(delay))
} else {
l.nextRunAt = m.now().Add(l.interval)
}
return m
}
// parseLoopControl reads a self-paced loop's control signal from the model's final
// answer. The last control line wins (the model may reference the protocol before
// emitting it). Returns done=true to stop the loop, or a next-wake hint (0 = none).
func parseLoopControl(answer string) (done bool, wake time.Duration) {
// Normalize CRLF/CR so the line-end anchor can reach `$` (Go's (?m)$ sits right
// before `\n`, and the horizontal-whitespace class can't consume a stray `\r`).
norm := strings.ReplaceAll(answer, "\r\n", "\n")
norm = strings.ReplaceAll(norm, "\r", "\n")
matches := loopControlRe.FindAllStringSubmatch(norm, -1)
if len(matches) == 0 {
return false, 0
}
last := matches[len(matches)-1]
if strings.EqualFold(last[1], "done") {
return true, 0
}
if last[2] != "" {
if d, ok := parseLoopInterval(last[2]); ok {
return false, d
}
}
return false, 0
}
// loopSelfPacePlaybook wraps a self-paced loop's goal in the maintenance-loop
// instructions the model follows each iteration: do one real increment of work,
// then emit a single control line so the harness knows whether to stop or when to
// wake next — the self-paced maintenance playbook the feature ships with.
func loopSelfPacePlaybook(l *loopState) string {
var b strings.Builder
b.WriteString("You are running in a self-paced maintenance loop (iteration ")
b.WriteString(strconv.Itoa(l.iteration + 1))
b.WriteString("). Do one concrete increment of work toward the goal below — actual work, not just a plan — then stop and report.\n\n")
b.WriteString("Goal: ")
b.WriteString(strings.TrimSpace(l.prompt))
b.WriteString("\n\nEnd your reply with a control line — on its own line, starting with \"LOOP:\":\n")
b.WriteString(" LOOP: DONE — the goal is fully met; the loop stops.\n")
b.WriteString(" LOOP: CONTINUE — more to do; the loop wakes again automatically.\n")
b.WriteString(" LOOP: CONTINUE 10m — more to do; wake after the delay you name (e.g. 5m, 1h).\n")
b.WriteString("A short note after the keyword is fine. Report DONE only when the goal is genuinely complete — the loop trusts that signal to stop.")
return b.String()
}
// adaptiveSelfPaceDelay picks a self-paced loop's next-wake by iteration: frequent
// early (work is actively progressing), slowing to a heartbeat as it matures.
func adaptiveSelfPaceDelay(iteration int) time.Duration {
switch {
case iteration < 2:
return 2 * time.Minute
case iteration < 5:
return 5 * time.Minute
case iteration < 10:
return 15 * time.Minute
default:
return 30 * time.Minute
}
}
func (m model) findLoop(id string) *loopState {
for _, l := range m.loops {
if l.id == id {
return l
}
}
return nil
}
func (m model) removeLoop(id, note string) model {
kept := make([]*loopState, 0, len(m.loops))
for _, l := range m.loops {
if l.id != id {
kept = append(kept, l)
}
}
m.loops = kept
m.loopSeq++ // invalidate the pending poll tick; a fresh one is scheduled if loops remain
m.loopTicking = false
if note != "" {
m = m.appendLoopSystem(note)
}
return m
}
// ensureLoopTick starts the idle poll ticker if it is not already running.
func (m model) ensureLoopTick() (model, tea.Cmd) {
if m.loopTicking || len(m.loops) == 0 {
return m, nil
}
m.loopTicking = true
return m, m.scheduleLoopTick()
}
func (m model) scheduleLoopTick() tea.Cmd {
seq := m.loopSeq
return tea.Tick(loopPollInterval, func(time.Time) tea.Msg {
return loopTickMsg{seq: seq}
})
}
// loopBusy reports whether the session is too busy for a loop to fire — a turn,
// queued user message, compaction, or ANY blocking modal (permission/askUser/spec
// review, provider or MCP wizard, MCP manager, or an open picker) is in flight.
// The modal check is load-bearing: a loop must not launch a run behind an open
// /resume picker, or that run would complete into whatever session the user then
// switches to. Mirrors launchQueuedMessageIfReady so loops and queued prompts never
// collide.
func (m model) loopBusy() bool {
return m.pending || m.exiting || m.compactInFlight || m.hasQueuedMessage() || !m.noBlockingModal()
}
func (m model) appendLoopSystem(text string) model {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m
}
// loopActive reports whether any loop is running (for the footer + leave warning).
func (m model) loopActive() bool { return len(m.loops) > 0 }
// loopFooterSummary renders the persistent "N loops · next 3:05pm" footer segment,
// or "" when no loops are active.
func (m model) loopFooterSummary() string {
if len(m.loops) == 0 {
return ""
}
var next time.Time
for _, l := range m.loops {
if l.nextRunAt.IsZero() || l.paused {
continue
}
if next.IsZero() || l.nextRunAt.Before(next) {
next = l.nextRunAt
}
}
label := fmt.Sprintf("%d loop", len(m.loops))
if len(m.loops) != 1 {
label += "s"
}
if next.IsZero() {
return label
}
return label + " · next " + next.Format("3:04pm")
}
func (m model) loopListText() string {
if len(m.loops) == 0 {
return "No active loops."
}
var b strings.Builder
b.WriteString("Active loops:\n")
now := m.now()
for _, l := range m.loops {
when := "running"
if !l.nextRunAt.IsZero() {
if d := l.nextRunAt.Sub(now); d > 0 {
when = "next in " + formatLoopDuration(d.Round(time.Second))
} else {
when = "due"
}
}
b.WriteString(fmt.Sprintf(" %s · %s · iter %d · %s · %s\n",
l.id, l.cadenceText(), l.iteration, when, truncateLoopPrompt(l.prompt)))
}
b.WriteString("Stop with /loop stop <id> or /loop stop all.")
return b.String()
}
func loopUsageText() string {
return "Repeat a prompt or command:\n" +
" /loop 5m /babysit-prs — run a command every 5 minutes\n" +
" /loop watch CI every 2m — trailing interval form\n" +
" /loop keep tidying docs — self-paced (the model picks its own cadence and stops when done)\n" +
" /loop list — show active loops\n" +
" /loop stop [id|all] — stop a loop\n" +
"Loops run while this session is idle and stop when you close it."
}
// validateLoopTarget rejects looping a built-in command or an unresolved custom
// command — only a prompt or an existing custom /command (.zero/commands/<name>.md)
// may loop. Resolving custom commands up front stops a typo'd `/loop 5m /nope` from
// being scheduled and then re-sent as literal text on every tick.
func (m model) validateLoopTarget(prompt string) (string, bool) {
p := strings.TrimSpace(prompt)
if p == "" {
return "Nothing to loop — give a prompt or a /command.", false
}
if strings.HasPrefix(p, "/") {
switch parseCommand(p).kind {
case commandPrompt:
// A bare "/" or similar that isn't a command — treat as a plain prompt.
case commandUnknown:
name, _ := splitUserCommand(p)
if _, ok := m.lookupUserCommand(name); !ok {
return "No command /" + name + " — a loop can only run a prompt or an existing custom /command.", false
}
default:
return "A loop can only run a prompt or a custom /command, not a built-in like " + strings.Fields(p)[0] + ".", false
}
}
return "", true
}
// clearLoopsForSessionSwitch drops every active loop and its ticker. Loops belong to
// the session that created them; carrying them across /new or /resume would fire the
// old session's prompt into an unrelated conversation. Returns the count cleared so
// the caller can note it. Pure state reset — no transcript writes.
func (m model) clearLoopsForSessionSwitch() (model, int) {
n := len(m.loops)
if n == 0 {
return m, 0
}
m.loops = nil
m.activeLoopID = ""
m.loopTicking = false
m.loopSeq++ // invalidate any pending poll tick
return m, n
}
func truncateLoopPrompt(prompt string) string {
const max = 48
p := strings.TrimSpace(strings.ReplaceAll(prompt, "\n", " "))
runes := []rune(p)
if len(runes) <= max {
return p
}
return strings.TrimSpace(string(runes[:max])) + "…"
}
// clampSelfPaceDelay bounds a model-chosen self-paced delay to the self-pace
// band, defaulting an unset/invalid value.
func clampSelfPaceDelay(d time.Duration) time.Duration {
switch {
case d <= 0:
return loopSelfPaceDefault
case d < loopSelfPaceMin:
return loopSelfPaceMin
case d > loopSelfPaceMax:
return loopSelfPaceMax
default:
return d
}
}
// formatLoopDuration renders a duration compactly (90s, 5m, 2h, 1d) for status
// text, preferring the largest whole unit.
func formatLoopDuration(d time.Duration) string {
switch {
case d%(24*time.Hour) == 0 && d >= 24*time.Hour:
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
case d%time.Hour == 0 && d >= time.Hour:
return strconv.Itoa(int(d/time.Hour)) + "h"
case d%time.Minute == 0 && d >= time.Minute:
return strconv.Itoa(int(d/time.Minute)) + "m"
default:
return strconv.Itoa(int(d/time.Second)) + "s"
}
}
+460
View File
@@ -0,0 +1,460 @@
package tui
import (
"context"
"errors"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/usercommands"
)
func loopTestModel(t *testing.T, now time.Time) model {
t.Helper()
m := newModel(context.Background(), Options{})
m.now = func() time.Time { return now }
return m
}
func startFixedLoop(m model, prompt string, interval time.Duration) model {
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt, interval: interval})
return m
}
func TestStartLoopRegistersAndSchedules(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "check the build", 5*time.Minute)
if len(m.loops) != 1 {
t.Fatalf("want 1 loop, got %d", len(m.loops))
}
l := m.loops[0]
if l.mode != loopModeFixed || l.interval != 5*time.Minute || l.prompt != "check the build" {
t.Fatalf("unexpected loop %+v", l)
}
// First iteration is due immediately (nextRunAt == now).
if !l.due(now) {
t.Errorf("first iteration should be due at creation")
}
if !m.loopTicking {
t.Errorf("starting a loop should start the poll ticker")
}
}
func TestAdvanceLoopSchedulesNextWake(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "poll", 5*time.Minute)
id := m.loops[0].id
m = m.advanceLoop(id, "did some work", nil)
l := m.loops[0]
if l.iteration != 1 {
t.Fatalf("iteration = %d, want 1", l.iteration)
}
if !l.nextRunAt.Equal(now.Add(5 * time.Minute)) {
t.Fatalf("nextRunAt = %v, want +5m", l.nextRunAt)
}
}
func TestSelfPacedUsesModelDelay(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "keep tidying"})
id := m.loops[0].id
// The model carries its chosen next wake in the reply's control line.
m = m.advanceLoop(id, "made progress\nLOOP: CONTINUE 10m", nil)
if len(m.loops) == 0 {
t.Fatal("a CONTINUE control line should keep the loop running")
}
if !m.loops[0].nextRunAt.Equal(now.Add(10 * time.Minute)) {
t.Fatalf("self-paced nextRunAt = %v, want +10m", m.loops[0].nextRunAt)
}
}
func TestLoopDoneStops(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "finish the task"})
id := m.loops[0].id
m = m.advanceLoop(id, "all done\nLOOP: DONE", nil)
if len(m.loops) != 0 {
t.Fatalf("loop should stop when the task reports done, %d remain", len(m.loops))
}
}
func TestFixedLoopIgnoresControlLine(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "watch CI", time.Minute)
id := m.loops[0].id
// A fixed-interval loop never runs the playbook, so a stray control line in the
// output is not a completion signal — it keeps its wall-clock cadence.
m = m.advanceLoop(id, "LOOP: DONE", nil)
if len(m.loops) != 1 {
t.Fatal("a fixed-interval loop should ignore a control line and keep running")
}
if !m.loops[0].nextRunAt.Equal(now.Add(time.Minute)) {
t.Fatalf("fixed nextRunAt = %v, want +1m", m.loops[0].nextRunAt)
}
}
func TestLoopExpiresByAge(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "forgotten", time.Minute)
id := m.loops[0].id
m.loops[0].createdAt = now.Add(-loopMaxAge - time.Hour) // created past the age cap
m = m.advanceLoop(id, "still going", nil)
if len(m.loops) != 0 {
t.Fatal("a loop past its age cap should auto-expire")
}
}
func TestLoopDoomGuardStops(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "spin", time.Minute)
id := m.loops[0].id
for i := 0; i < loopDoomThreshold+1 && len(m.loops) > 0; i++ {
m = m.advanceLoop(id, "identical answer", nil)
}
if len(m.loops) != 0 {
t.Fatalf("doom guard should stop a loop repeating identical results")
}
}
func TestLoopIterationCapStops(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "bounded", time.Minute)
m.loops[0].maxIter = 3
id := m.loops[0].id
for i := 0; i < 3 && len(m.loops) > 0; i++ {
m = m.advanceLoop(id, "answer "+string(rune('a'+i)), nil) // distinct answers so doom doesn't fire
}
if len(m.loops) != 0 {
t.Fatalf("loop should stop at its iteration cap")
}
}
func TestStopLoopByID(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "a", time.Minute)
m = startFixedLoop(m, "b", time.Minute)
id := m.loops[0].id
m, cmd := m.stopLoop(id)
if len(m.loops) != 1 || m.findLoop(id) != nil {
t.Fatalf("stopLoop(%s) should remove exactly that loop", id)
}
// Stopping one of several loops must keep the poll ticker alive for the rest:
// removeLoop kills the ticker, so stopLoop has to re-arm it (jatmn).
if cmd == nil || !m.loopTicking {
t.Fatal("stopping one of several loops must leave the remaining loop scheduled (ticker re-armed)")
}
// Bare stop with a single active loop stops that sole loop (parseLoopCommand
// leaves the target empty and stopLoop special-cases the one-loop case).
soleID := m.loops[0].id
m, _ = m.stopLoop("")
if len(m.loops) != 0 || m.findLoop(soleID) != nil {
t.Fatalf("bare stop should remove the sole active loop")
}
m = startFixedLoop(m, "c", time.Minute)
m = startFixedLoop(m, "d", time.Minute)
m, _ = m.stopAllLoops()
if len(m.loops) != 0 {
t.Fatalf("stopAllLoops should clear all loops")
}
}
func TestFireDueLoopSkipsWhenBusy(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", time.Minute)
m.pending = true // a turn is in flight
got, cmd := m.fireDueLoopIfIdle()
if got.activeLoopID != "" || cmd != nil {
t.Fatalf("a due loop must not fire while a turn is pending")
}
}
func TestFireDueLoopSkipsBehindModal(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "poll", time.Minute) // due immediately
m.picker = &commandPicker{} // e.g. an open /resume session picker
got, cmd := m.fireDueLoopIfIdle()
if got.activeLoopID != "" || cmd != nil {
t.Fatal("a due loop must not launch a run behind an open picker/modal — it would complete into whatever session the user switches to")
}
if got.loops[0].nextRunAt.IsZero() {
t.Fatal("a loop skipped because a modal is open must stay scheduled, not have its next run cleared")
}
}
func TestAdaptiveSelfPaceDelayWidensOverTime(t *testing.T) {
if adaptiveSelfPaceDelay(0) >= adaptiveSelfPaceDelay(5) {
t.Error("early iterations should check back sooner than later ones")
}
if adaptiveSelfPaceDelay(20) != 30*time.Minute {
t.Errorf("a mature loop should settle to a 30m heartbeat, got %v", adaptiveSelfPaceDelay(20))
}
}
func TestLoopFooterSummary(t *testing.T) {
now := time.Date(2026, 7, 5, 15, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
if m.loopFooterSummary() != "" {
t.Error("no loops -> empty footer summary")
}
m = startFixedLoop(m, "a", 5*time.Minute)
m.loops[0].nextRunAt = now.Add(5 * time.Minute)
got := m.loopFooterSummary()
wantWake := "next " + now.Add(5*time.Minute).Format("3:04pm")
if !strings.HasPrefix(got, "1 loop") || !strings.Contains(got, wantWake) {
t.Fatalf("footer summary = %q, want it to start with a loop count and contain %q", got, wantWake)
}
}
func TestLoopCommandThroughSubmit(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m.input.SetValue("/loop 5m tidy the imports")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if len(next.loops) != 1 {
t.Fatalf("submitting /loop should create a loop, got %d", len(next.loops))
}
if next.loops[0].mode != loopModeFixed || next.loops[0].interval != 5*time.Minute {
t.Fatalf("unexpected loop from submit: %+v", next.loops[0])
}
if !next.loopTicking {
t.Fatalf("submitting /loop should start the poll ticker")
}
// /loop stop all through the same path clears it.
next.input.SetValue("/loop stop all")
updated2, _ := next.Update(testKey(tea.KeyEnter))
final := updated2.(model)
if len(final.loops) != 0 {
t.Fatalf("/loop stop all should clear loops, got %d", len(final.loops))
}
}
func TestStaleLoopTickIgnored(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", time.Minute)
m.loopSeq = 7
// A tick from before the seq changed must be dropped and stop the ticker.
updated, cmd := m.Update(loopTickMsg{seq: 3})
next := updated.(model)
if next.loopTicking {
t.Error("a stale loop tick should stop the ticker")
}
if cmd != nil {
t.Error("a stale loop tick should not reschedule")
}
}
func TestLoopStopsAfterConsecutiveFailures(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "flaky", time.Minute)
id := m.loops[0].id
boom := errors.New("boom")
for i := 0; i < loopMaxFailures && len(m.loops) > 0; i++ {
m = m.advanceLoop(id, "", boom)
}
if len(m.loops) != 0 {
t.Fatalf("loop should stop after %d consecutive failed iterations", loopMaxFailures)
}
}
func TestLoopFailureCounterResetsOnSuccess(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "recovers", time.Minute)
id := m.loops[0].id
m = m.advanceLoop(id, "", errors.New("x"))
m = m.advanceLoop(id, "", errors.New("x"))
m = m.advanceLoop(id, "back to healthy", nil) // a success resets the streak
if len(m.loops) != 1 {
t.Fatalf("a recovered failure streak should not stop the loop")
}
if m.loops[0].failRun != 0 {
t.Fatalf("a successful iteration should reset failRun, got %d", m.loops[0].failRun)
}
}
func TestCancelRunClearsActiveLoopAndRearms(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", 5*time.Minute)
m.activeLoopID = m.loops[0].id
m.loops[0].nextRunAt = time.Time{} // as if the iteration is running
m.pending = true
m.activeRunID = 7
m.runCancel = func() {}
m.cancelRun()
if m.activeLoopID != "" {
t.Fatal("cancel should clear the active-loop tag so the next turn isn't misattributed")
}
if m.loops[0].nextRunAt.IsZero() {
t.Fatal("cancel should re-arm the interrupted loop, not leave it running forever")
}
if !m.loops[0].nextRunAt.Equal(now.Add(5 * time.Minute)) {
t.Fatalf("re-armed nextRunAt = %v, want +5m", m.loops[0].nextRunAt)
}
}
func TestValidateLoopTargetRejectsBuiltin(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
if _, ok := m.validateLoopTarget("/model list"); ok {
t.Error("looping a built-in command should be rejected")
}
if _, ok := m.validateLoopTarget("keep improving docs"); !ok {
t.Error("a plain prompt should be a valid loop target")
}
if _, ok := m.validateLoopTarget(""); ok {
t.Error("an empty target should be rejected")
}
// A custom command that does not exist is rejected up front, so it is never
// scheduled and then re-sent as literal "/…" text on every tick.
if _, ok := m.validateLoopTarget("/babysit-prs"); ok {
t.Error("an unresolved custom command should be rejected")
}
// A registered custom command is accepted.
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
if _, ok := m.validateLoopTarget("/babysit-prs"); !ok {
t.Error("an existing custom command should be a valid loop target")
}
}
func TestSelfPacedCommandLoopRejected(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
// A /command with no interval parses as self-paced, but self-paced mode drives
// a free-form goal via the playbook + LOOP: protocol, which a command's
// expanded template never carries — so a bare self-paced /command is rejected.
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "/babysit-prs"})
if len(m.loops) != 0 {
t.Fatal("a bare self-paced /command loop should be rejected (a command loop needs an interval)")
}
// The same command with an interval (fixed) is accepted.
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: "/babysit-prs", interval: 5 * time.Minute})
if len(m.loops) != 1 {
t.Fatal("a fixed-interval /command loop should be accepted")
}
}
func TestClearLoopsForSessionSwitch(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "a", time.Minute)
m = startFixedLoop(m, "b", time.Minute)
m.activeLoopID = m.loops[0].id
m, n := m.clearLoopsForSessionSwitch()
if n != 2 || len(m.loops) != 0 || m.activeLoopID != "" || m.loopTicking {
t.Fatalf("session switch should clear all loop state; got n=%d loops=%d active=%q ticking=%v",
n, len(m.loops), m.activeLoopID, m.loopTicking)
}
}
func TestStartNewSessionStopsLoops(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", time.Minute)
m = m.startNewSession()
if len(m.loops) != 0 || m.loopTicking {
t.Fatal("/new should stop loops tied to the previous session")
}
}
func TestLoopClearRequiresConfirm(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", time.Minute)
// First /clear with a loop active arms the confirm and does not clear yet.
m.input.SetValue("/clear")
u1, _ := m.Update(testKey(tea.KeyEnter))
n1 := u1.(model)
if n1.loopLeavePrompt != commandClear {
t.Fatal("first /clear with active loops should arm the confirm, not clear")
}
if len(n1.loops) != 1 {
t.Fatal("the loop should still be active after the warning")
}
// A different command disarms the confirm.
n1.input.SetValue("/loop list")
u2, _ := n1.Update(testKey(tea.KeyEnter))
n2 := u2.(model)
if n2.loopLeavePrompt != commandEmpty {
t.Fatal("an intervening command should disarm the leave confirm")
}
// Re-arm, then a second consecutive /clear confirms (disarms + clears).
n2.input.SetValue("/clear")
u3, _ := n2.Update(testKey(tea.KeyEnter))
n3 := u3.(model)
n3.input.SetValue("/clear")
u4, _ := n3.Update(testKey(tea.KeyEnter))
n4 := u4.(model)
if n4.loopLeavePrompt != commandEmpty {
t.Fatal("a second consecutive /clear should confirm and disarm")
}
if len(n4.loops) != 1 {
t.Fatal("/clear keeps loops running (it wipes the screen, not the session)")
}
// The confirmed clear must actually wipe the transcript, not silently no-op:
// only the clear path appends the "Transcript cleared" note, and the pre-clear
// loop-start row must be gone.
clearedNote, staleRow := false, false
for _, r := range n4.transcript {
if strings.Contains(r.text, "Transcript cleared") {
clearedNote = true
}
if strings.Contains(r.text, "Loop L1 started") {
staleRow = true
}
}
if !clearedNote || staleRow {
t.Fatalf("the second /clear must wipe the transcript (clearedNote=%v staleRow=%v)", clearedNote, staleRow)
}
}
func TestLeaveConfirmDisarmedByQueuedPrompt(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", time.Minute)
// Arm the /clear confirm.
m.input.SetValue("/clear")
u1, _ := m.Update(testKey(tea.KeyEnter))
n1 := u1.(model)
if n1.loopLeavePrompt != commandClear {
t.Fatal("first /clear should arm the confirm")
}
// A prompt submitted while a run streams is queued via an early return in
// handleSubmit; it must still disarm the confirm (the disarm runs before those
// returns). Otherwise a later /clear would be treated as the second press.
n1.pending = true
n1.input.SetValue("a follow-up question")
u2, _ := n1.Update(testKey(tea.KeyEnter))
n2 := u2.(model)
if n2.loopLeavePrompt != commandEmpty {
t.Fatal("a queued prompt should disarm the leave confirm; the early return must not skip the disarm")
}
}
+156
View File
@@ -0,0 +1,156 @@
package tui
import (
"strings"
"testing"
"time"
)
func TestParseLoopCommandModes(t *testing.T) {
cases := []struct {
in string
action loopAction
mode loopMode
prompt string
interval time.Duration
}{
{"", loopActionUsage, 0, "", 0},
{"list", loopActionList, 0, "", 0},
{"status", loopActionList, 0, "", 0},
{"stop all", loopActionStopAll, 0, "", 0},
{"stop ab12", loopActionStop, 0, "", 0},
{"5m /babysit-prs", loopActionStart, loopModeFixed, "/babysit-prs", 5 * time.Minute},
{"90s check the build", loopActionStart, loopModeFixed, "check the build", 90 * time.Second},
{"watch CI every 2m", loopActionStart, loopModeFixed, "watch CI", 2 * time.Minute},
{"keep improving the docs", loopActionStart, loopModeSelfPaced, "keep improving the docs", 0},
// "every PR" is not a cadence — the token after "every" isn't a duration.
{"review every PR", loopActionStart, loopModeSelfPaced, "review every PR", 0},
}
for _, c := range cases {
got := parseLoopCommand(c.in)
if got.action != c.action {
t.Errorf("%q action = %v, want %v", c.in, got.action, c.action)
}
if c.action == loopActionStart {
if got.mode != c.mode {
t.Errorf("%q mode = %v, want %v", c.in, got.mode, c.mode)
}
if got.prompt != c.prompt {
t.Errorf("%q prompt = %q, want %q", c.in, got.prompt, c.prompt)
}
if got.interval != c.interval {
t.Errorf("%q interval = %v, want %v", c.in, got.interval, c.interval)
}
}
}
}
func TestParseLoopCommandStopTarget(t *testing.T) {
if got := parseLoopCommand("stop"); got.action != loopActionStop || got.targetID != "" {
t.Fatalf("bare stop = %+v, want stop with empty target", got)
}
if got := parseLoopCommand("stop XyZ9"); got.targetID != "XyZ9" {
t.Fatalf("stop target = %q, want XyZ9", got.targetID)
}
}
func TestClampLoopInterval(t *testing.T) {
if d, note := clampLoopInterval(5 * time.Second); d != loopMinInterval || note == "" {
t.Fatalf("below-min = (%v,%q), want raised to min with note", d, note)
}
if d, note := clampLoopInterval(48 * time.Hour); d != loopMaxInterval || note == "" {
t.Fatalf("above-max = (%v,%q), want lowered to max with note", d, note)
}
if d, note := clampLoopInterval(5 * time.Minute); d != 5*time.Minute || note != "" {
t.Fatalf("in-range = (%v,%q), want unchanged, no note", d, note)
}
}
func TestClampSelfPaceDelay(t *testing.T) {
if got := clampSelfPaceDelay(0); got != loopSelfPaceDefault {
t.Errorf("zero delay = %v, want default %v", got, loopSelfPaceDefault)
}
if got := clampSelfPaceDelay(10 * time.Second); got != loopSelfPaceMin {
t.Errorf("below-min = %v, want %v", got, loopSelfPaceMin)
}
if got := clampSelfPaceDelay(3 * time.Hour); got != loopSelfPaceMax {
t.Errorf("above-max = %v, want %v", got, loopSelfPaceMax)
}
if got := clampSelfPaceDelay(10 * time.Minute); got != 10*time.Minute {
t.Errorf("in-band = %v, want unchanged", got)
}
}
func TestFormatLoopDuration(t *testing.T) {
cases := map[time.Duration]string{
30 * time.Second: "30s",
90 * time.Second: "90s",
5 * time.Minute: "5m",
2 * time.Hour: "2h",
24 * time.Hour: "1d",
}
for d, want := range cases {
if got := formatLoopDuration(d); got != want {
t.Errorf("formatLoopDuration(%v) = %q, want %q", d, got, want)
}
}
}
func TestParseLoopControl(t *testing.T) {
cases := []struct {
in string
done bool
wake time.Duration
}{
{"did the work\nLOOP: DONE", true, 0},
{"progress\nLOOP: CONTINUE", false, 0},
{"progress\nLOOP: CONTINUE 10m", false, 10 * time.Minute},
{"progress\n- loop: continue 1h", false, time.Hour}, // markdown bullet, lowercase
{"LOOP: CONTINUE 5m\n...more...\nLOOP: DONE", true, 0}, // last control line wins
{"no control line here", false, 0}, // absent -> adaptive
{"LOOP: DONE — all tests pass", true, 0}, // a trailing note is fine
{"progress\nLOOP: CONTINUE 5m — still tidying", false, 5 * time.Minute}, // note after the interval
{"work done.\r\nLOOP: DONE\r\n", true, 0}, // CRLF endings still parse
{"work\r\nLOOP: CONTINUE 10m\r\n", false, 10 * time.Minute}, // CRLF + interval
{"I will emit LOOP: DONE when finished", false, 0}, // mid-sentence mention doesn't match
{"LOOP: CONTINUE 99z", false, 0}, // bad unit -> continue, no wake hint
}
for _, c := range cases {
done, wake := parseLoopControl(c.in)
if done != c.done || wake != c.wake {
t.Errorf("parseLoopControl(%q) = (%v,%v), want (%v,%v)", c.in, done, wake, c.done, c.wake)
}
}
}
func TestFirePromptWrapsSelfPacedOnly(t *testing.T) {
self := &loopState{mode: loopModeSelfPaced, prompt: "tidy the docs"}
got := self.firePrompt()
if !strings.Contains(got, "LOOP: DONE") || !strings.Contains(got, "tidy the docs") {
t.Errorf("self-paced firePrompt should embed the playbook and goal, got %q", got)
}
fixed := &loopState{mode: loopModeFixed, prompt: "check the build"}
if fixed.firePrompt() != "check the build" {
t.Errorf("fixed-interval firePrompt should be verbatim, got %q", fixed.firePrompt())
}
// A self-paced /command is a real command, not a goal — send it verbatim, no playbook.
cmd := &loopState{mode: loopModeSelfPaced, prompt: "/babysit"}
if cmd.firePrompt() != "/babysit" {
t.Errorf("self-paced /command firePrompt should be verbatim, got %q", cmd.firePrompt())
}
}
func TestLoopStateDue(t *testing.T) {
base := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
l := &loopState{nextRunAt: base}
if l.due(base.Add(-time.Second)) {
t.Error("loop should not be due before nextRunAt")
}
if !l.due(base) {
t.Error("loop should be due at nextRunAt")
}
l.paused = true
if l.due(base.Add(time.Hour)) {
t.Error("paused loop should never be due")
}
}
+114 -8
View File
@@ -29,6 +29,7 @@ import (
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
@@ -62,6 +63,7 @@ type model struct {
ctx context.Context
cwd string
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
loadSkills func() []skills.Skill // lazy installed-skills loader for /skills + /<skill-name>
userConfigPath string
doctorUserConfigPath string
projectConfigPath string
@@ -188,10 +190,22 @@ type model struct {
// like a frozen terminal (for ANY provider, not just slow ones). Zero = idle.
turnStartedAt time.Time
queuedMessage string
exiting bool
runCancel context.CancelFunc
runID int
activeRunID int
// loops holds the session's active /loop definitions (see loop.go). activeLoopID
// tags the in-flight run when it is a loop iteration (empty = a user turn), so the
// completion seam knows whether to advance a loop. loopSeq invalidates a stale
// pending poll tick when loops are stopped; loopTicking guards against scheduling
// a second poll ticker. loopLeavePrompt arms a one-shot confirm before /clear or
// /quit while loops are active (see handleSubmit).
loops []*loopState
activeLoopID string
loopSeq int
loopCounter int
loopTicking bool
loopLeavePrompt commandKind
exiting bool
runCancel context.CancelFunc
runID int
activeRunID int
// flushRunIDs holds the ids of runs cancelled while still in flight, mapped
// to the session they were recording into AT CANCEL TIME. Each cancelled
// agent goroutine keeps running to completion and returns its accumulated
@@ -735,6 +749,7 @@ func newModel(ctx context.Context, options Options) model {
cwd: cwd,
swarmDoneAt: map[string]time.Time{},
userCommands: loadedUserCommands,
loadSkills: options.LoadSkills,
composerCursorVisible: true,
userConfigPath: options.UserConfigPath,
doctorUserConfigPath: doctorUserConfigPath,
@@ -1893,6 +1908,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.clearComposer()
m.clearSuggestions()
return m, nil
case loopTickMsg:
// Idle poll for due loops. A stale tick (loops changed) or an empty loop set
// ends the ticker; otherwise fire the earliest due loop if idle and reschedule.
if msg.seq != m.loopSeq || len(m.loops) == 0 {
m.loopTicking = false
return m, nil
}
var fireCmd tea.Cmd
m, fireCmd = m.fireDueLoopIfIdle()
return m, tea.Batch(fireCmd, m.scheduleLoopTick())
case agentResponseMsg:
if msg.runID != m.activeRunID {
// A run cancelled while in flight still finishes in its goroutine and
@@ -2062,8 +2087,25 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// complete once the turn settles.
var sweepCmd tea.Cmd
m, sweepCmd = m.maybeGitSweep()
// If this run was a loop iteration, advance that loop (schedule its next wake
// or stop it). Done before launchQueuedMessageIfReady so a user's queued prompt
// still wins the immediate re-launch; the loop fires on the next idle tick.
var loopTickCmd tea.Cmd
if loopID := m.activeLoopID; loopID != "" {
m.activeLoopID = ""
loopFinalAnswer := ""
for _, row := range msg.rows {
if row.kind == rowAssistant && row.final {
loopFinalAnswer = row.text
}
}
m = m.advanceLoop(loopID, loopFinalAnswer, msg.err)
// advanceLoop -> removeLoop may have stopped the ticker; restart it if
// other loops remain.
m, loopTickCmd = m.ensureLoopTick()
}
next, queuedCmd := m.launchQueuedMessageIfReady()
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd)
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd)
case sessionTitleGeneratedMsg:
return m.handleSessionTitleGenerated(msg)
case recapGeneratedMsg:
@@ -3730,6 +3772,11 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
if text != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
case pickerSkill:
// Fill the composer with "/name " so the user adds their request before
// submitting (a bare second Enter runs it without one); names the slash
// path cannot reach run immediately instead.
m, cmd = m.chooseSkillFromPicker(item)
case pickerTheme:
// The hovered palette is already live from the preview; handleThemeCommand
// records the choice (m.themeMode) and re-applies it, and reports the switch.
@@ -3781,6 +3828,13 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
return m, nil
}
command := parseCommand(input)
// A pending /clear or /quit leave-confirmation is armed for the immediately-next
// repeat of that same command only; any other submission — including one queued
// or deferred by the early returns below — disarms it. Runs before those returns
// so an interposed prompt can't be skipped over and leave the gate falsely armed.
if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt {
m.loopLeavePrompt = commandEmpty
}
// While exiting (Ctrl+C waiting on the cancelled run's checkpoint flush) a
// new run must not start: the deferred tea.Quit would abort it mid-flight
// and orphan its checkpoint blobs — the exact loss flushRunIDs prevents.
@@ -3814,6 +3868,14 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: helpText()})
return m, nil
case commandClear:
// A foreground loop keeps firing after /clear (it wipes the screen, not the
// session), so warn once before clearing the context it will run into.
if m.loopActive() && m.loopLeavePrompt != commandClear {
m.loopLeavePrompt = commandClear
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /clear keeps them firing behind a cleared screen. Run /clear again to confirm, or /loop stop all first.")
return m, nil
}
m.loopLeavePrompt = commandEmpty
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
// Clearing wipes the visible transcript only — the session's context is
// intact, so the next prompt still replays the full history. Say so, and
@@ -3822,6 +3884,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
// Scrollback above can't be un-printed; a faint divider marks where the
// cleared surface ended and the frontier restarts for the fresh transcript.
m.resetFlushFrontier("· cleared ·")
// /clear wipes the transcript but not the session, so loops keep running;
// say so rather than let them silently fire into a "cleared" screen.
if m.loopActive() {
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /loop stop all to end them.")
}
return m, nil
case commandNew:
// A fresh session mid-run would strand the in-flight turn's events; make the
@@ -3832,7 +3899,17 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
return m, nil
}
return m.startNewSession(), nil
case commandLoop:
return m.handleLoopCommand(command.text)
case commandExit:
// Closing the session stops its foreground loops mid-task; warn once so a
// token-spending loop isn't ended by reflex.
if m.loopActive() && m.loopLeavePrompt != commandExit {
m.loopLeavePrompt = commandExit
m = m.appendLoopSystem(m.loopFooterSummary() + " active — closing the session stops them. Run /exit again to confirm, or /loop stop all first.")
return m, nil
}
m.loopLeavePrompt = commandEmpty
// /exit gets the same protection as Ctrl+C: cancel any in-flight run and
// defer the quit until its checkpoint session events flush — quitting
// immediately would orphan the blobs and break /rewind.
@@ -3845,6 +3922,15 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
case commandTools:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.toolsText()})
return m, nil
case commandSkills:
// With skills installed, /skills opens a searchable picker (like /model);
// the text card remains only as the no-skills install hint.
if picker := m.newSkillPicker(); picker != nil {
m.picker = picker
return m, nil
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.skillsText()})
return m, nil
case commandMCP:
if strings.TrimSpace(command.text) == "" {
return m.openMCPManager(), nil
@@ -4040,6 +4126,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
if next, cmd, handled := m.handleUserCommand(command.text); handled {
return next, cmd
}
// Then an installed skill: "/<skill-name> [args]" runs the skill directly
// (deterministic invocation, vs waiting for the model to pull it in).
if next, cmd, handled := m.handleSkillCommand(command.text); handled {
return next, cmd
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "unknown command: " + command.text,
@@ -4311,6 +4402,21 @@ func (m *model) cancelRun() {
m.runCancel()
}
m.clearStreamingToolCall() // a cancelled file-write must not linger into the next run
// A cancelled loop iteration bypasses the agentResponseMsg completion seam (its
// late message is drained through flushRunIDs, not advanceLoop), so clear the
// loop tag here and re-arm the interrupted loop for its next cadence. Otherwise
// the loop is left "running" forever (nextRunAt stays zero) and the NEXT
// unrelated turn would be misattributed as this loop's completion.
if m.activeLoopID != "" {
if l := m.findLoop(m.activeLoopID); l != nil {
if l.mode == loopModeSelfPaced {
l.nextRunAt = m.now().Add(clampSelfPaceDelay(adaptiveSelfPaceDelay(l.iteration)))
} else {
l.nextRunAt = m.now().Add(l.interval)
}
}
m.activeLoopID = ""
}
// Remember the in-flight run — and the session it was recording into — so
// its final agentResponseMsg is still drained for session-event persistence
// after activeRunID is cleared. Otherwise the checkpoint blobs it captured
@@ -4446,9 +4552,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
IncludeLSP: true,
Autonomy: selfCorrectAutonomyForMode(options.PermissionMode),
})
// Inline post-edit diagnostics: edit_file/write_file append error
// diagnostics for the file they just wrote to their own output, so the
// model sees a break in the same turn. Shares the run's lazy manager.
// Background post-edit diagnostics: the loop checks files changed by
// edit_file/write_file off the tool-call path and nudges the model with
// any errors before its next request. Shares the run's lazy manager.
options.FileDiagnostics = agent.NewFileDiagnostics(lspManager, options.Cwd)
}
+8 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -48,7 +49,13 @@ type Options struct {
SessionCompactor SessionCompactor
PrService *PrService
AgentOptions agent.Options
AgentOptions agent.Options
// LoadSkills returns the installed skills (default skills dir merged with any
// plugin skill roots), bodies included, for /skills and direct /<skill-name>
// invocation. Called lazily per use so newly installed skills are picked up
// without a restart. Nil means the session has no skills wiring (skills stay
// model-pulled via the skill tool only).
LoadSkills func() []skills.Skill
PermissionMode agent.PermissionMode
ReasoningEffort modelregistry.ReasoningEffort
ResponseStyle string
+30
View File
@@ -24,6 +24,7 @@ const (
pickerEffort
pickerSession
pickerTheme
pickerSkill
)
// pickerItem is one selectable row: Label is shown, Value is passed to the
@@ -225,6 +226,35 @@ func (m model) newModelPicker() *commandPicker {
return &commandPicker{kind: pickerModel, title: "Choose a model", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
}
// newSkillPicker lists the installed skills for browse-and-run: type to filter,
// arrow keys to select, Enter runs the chosen skill immediately. Selection is by
// the skill's EXACT name (item.Value), so even skills the slash path cannot
// reach — names shadowed by a builtin or user command, or names that don't fit
// a slash token — are runnable here: picking from the Skills picker is
// unambiguous. Returns nil when no skills are installed (the caller falls back
// to the install-hint card).
func (m model) newSkillPicker() *commandPicker {
installed := m.installedSkills()
items := make([]pickerItem, 0, len(installed))
for _, skill := range installed {
name := strings.TrimSpace(skill.Name)
if name == "" {
continue
}
label := name
if slash := skillSlashName(name); slash != "" {
// Show the slash form where one exists — it doubles as documentation
// of the direct "/name [args]" invocation.
label = "/" + slash
}
items = append(items, pickerItem{Label: label, Value: name, Meta: strings.TrimSpace(skill.Description)})
}
if len(items) == 0 {
return nil
}
return &commandPicker{kind: pickerSkill, title: "pick a skill · ⏎ fills /name — add your request", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
}
// modelPickerProviders returns the providers to list in /model: all saved
// providers, falling back to the active profile when none were threaded in.
func (m model) modelPickerProviders() []config.ProviderProfile {
+39 -12
View File
@@ -99,6 +99,12 @@ func (m model) startNewSession() model {
// Scrollback above can't be un-printed; a faint divider marks the boundary and
// the flush frontier restarts for the fresh transcript (mirrors /clear, /resume).
m.resetFlushFrontier("· new session ·")
// Loops belong to the previous session; stop them so they don't fire the old
// conversation's prompt into the fresh one.
if updated, cleared := m.clearLoopsForSessionSwitch(); cleared > 0 {
m = updated
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", cleared)})
}
return m
}
@@ -123,13 +129,24 @@ func (m model) appendSessionEvent(eventType sessions.EventType, payload any) (mo
func (m model) appendSessionEvents(events []pendingSessionEvent) (model, []transcriptRow) {
rows := []transcriptRow{}
if m.activeSession.SessionID == "" || len(events) == 0 {
return m, rows
}
inputs := make([]sessions.AppendEventInput, 0, len(events))
for _, event := range events {
next, err := m.appendSessionEvent(event.Type, event.Payload)
if err != nil {
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
continue
}
m = next
inputs = append(inputs, sessions.AppendEventInput{Type: event.Type, Payload: event.Payload})
}
appended, err := m.sessionStore.AppendEvents(m.activeSession.SessionID, inputs)
if err != nil {
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
return m, rows
}
if len(appended) > 0 {
last := appended[len(appended)-1]
m.activeSession.UpdatedAt = last.CreatedAt
m.activeSession.EventCount = last.Sequence
m.activeSession.LastEventType = last.Type
m.sessionEvents = append(m.sessionEvents, appended...)
}
return m, rows
}
@@ -142,13 +159,12 @@ func (m model) appendSessionEventsTo(sessionID string, events []pendingSessionEv
if m.sessionStore == nil || sessionID == "" {
return rows
}
inputs := make([]sessions.AppendEventInput, 0, len(events))
for _, event := range events {
if _, err := m.sessionStore.AppendEvent(sessionID, sessions.AppendEventInput{
Type: event.Type,
Payload: event.Payload,
}); err != nil {
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
}
inputs = append(inputs, sessions.AppendEventInput{Type: event.Type, Payload: event.Payload})
}
if _, err := m.sessionStore.AppendEvents(sessionID, inputs); err != nil {
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
}
return rows
}
@@ -195,6 +211,10 @@ func (m model) handleResumeCommand(args string) (model, string) {
return m, "Sessions\nerror: " + err.Error()
}
// Capture the current session id before switching so loops are only torn down
// on a real change — `/resume latest` or `/resume <currentID>` can resolve to
// the already-active session, whose loops belong to it, not a "previous" one.
previousID := m.activeSession.SessionID
m.activeSession = *session
m.sessionEvents = append([]sessions.Event{}, events...)
if m.providerName == "" {
@@ -203,9 +223,16 @@ func (m model) handleResumeCommand(args string) (model, string) {
if m.modelName == "" {
m.modelName = session.ModelID
}
loopsCleared := 0
if session.SessionID != previousID {
m, loopsCleared = m.clearLoopsForSessionSwitch()
}
rows := initialTranscript()
rows = appendRow(rows, rowSystem, m.formatResumeSummary(*session, len(events)))
if loopsCleared > 0 {
rows = appendRow(rows, rowSystem, fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", loopsCleared))
}
rows = appendTranscriptRowsDedup(rows, transcriptRowsFromSessionEvents(events))
m.transcript = rows
// Every rehydrated row is settled by construction, so resetting the flush
+40
View File
@@ -108,6 +108,46 @@ func TestPromptSubmitPersistsTUISessionEvents(t *testing.T) {
}
}
func TestAppendSessionEventsBatchesAndUpdatesActiveSession(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "tui_batch", Title: "batch"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
m := model{
sessionStore: store,
activeSession: session,
}
next, rows := m.appendSessionEvents([]pendingSessionEvent{
{Type: sessions.EventMessage, Payload: map[string]any{"role": "assistant", "content": "done"}},
{Type: sessions.EventUsage, Payload: map[string]any{"totalTokens": 3}},
})
if len(rows) != 0 {
t.Fatalf("appendSessionEvents returned error rows: %#v", rows)
}
if next.activeSession.EventCount != 2 || next.activeSession.LastEventType != sessions.EventUsage {
t.Fatalf("active session metadata not updated from batch: %#v", next.activeSession)
}
if len(next.sessionEvents) != 2 || next.sessionEvents[0].Sequence != 1 || next.sessionEvents[1].Sequence != 2 {
t.Fatalf("in-memory session events not updated from batch: %#v", next.sessionEvents)
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if got := eventTypes(events); !equalEventTypes(got, []sessions.EventType{sessions.EventMessage, sessions.EventUsage}) {
t.Fatalf("unexpected persisted event types: %#v", got)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != next.activeSession.EventCount || loaded.LastEventType != next.activeSession.LastEventType {
t.Fatalf("store metadata diverged from active session: store=%#v active=%#v", loaded, next.activeSession)
}
}
func TestPromptWithoutProviderDoesNotCreateSession(t *testing.T) {
store := testSessionStore(t)
m := newModel(context.Background(), Options{SessionStore: store})
+205
View File
@@ -0,0 +1,205 @@
package tui
import (
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/skills"
)
// Skill slash invocation: "/<skill-name> [args]" runs an installed skill
// directly, mirroring user commands (.zero/commands). Skills were previously
// model-pulled only (the skill tool), which made invocation a matter of model
// discretion; typing the skill's name makes it deterministic. Precedence is
// builtin > user command > skill: parseCommand resolves builtins first, and the
// commandUnknown fallback tries user commands before skills.
// handleSkillCommand resolves a "/name args" that wasn't a builtin or a user
// command against the installed skills. On a match it launches a normal agent
// turn whose prompt inlines the skill body (its instructions) followed by the
// typed args, returning handled=true. handled=false means no skill matched, so
// the caller falls through to "unknown command".
func (m model) handleSkillCommand(raw string) (model, tea.Cmd, bool) {
name, args := splitUserCommand(raw)
if name == "" {
return m, nil, false
}
skill, ok := m.lookupSkillCommand(name)
if !ok {
return m, nil, false
}
body := strings.TrimSpace(skill.Content)
if body == "" {
// The name matched a real skill, so falling through to "unknown command"
// would mislead; surface the actual problem instead.
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "skill /" + name + " has an empty SKILL.md body (" + skill.Path + ")",
})
return m, nil, true
}
return m.launchOrDeferExpandedPrompt(raw, skillInvocationPrompt(body, args))
}
// bareSkillInvocationNote is appended when a skill is invoked with no request.
// The body alone is instructions with no target ("review the PR" — which PR?),
// and without this note the model improvises one instead of asking. The wording
// is conditional so self-contained skills (no target needed) still just run.
const bareSkillInvocationNote = "The user invoked this skill directly without providing a request. " +
"If these instructions need a target or details that are not already clear from the conversation " +
"(which pull request, file, branch, topic, …), ask for them first — do not guess or pick one yourself. " +
"If the instructions are self-contained, proceed."
// skillInvocationPrompt builds the agent prompt for a skill invocation: the
// skill body (its instructions), then either the user's request or — for a bare
// invocation — the ask-first note above. Mirrors usercommands.Expand's
// no-placeholder behavior for the args case.
func skillInvocationPrompt(body, args string) string {
if args != "" {
return body + "\n\n" + args
}
return body + "\n\n" + bareSkillInvocationNote
}
// launchOrDeferExpandedPrompt applies the same run-state guards the plain
// commandPrompt path has to an expanded (user command / skill) prompt: while
// exiting nothing may start, a prompt submitted mid-run is queued for the next
// turn, and compaction-in-flight warns instead of racing the compactor. The
// EXPANDED prompt is what gets queued — the queue flush path resubmits text as
// a literal prompt, so queuing the raw "/name args" would send it to the model
// as prose instead of re-dispatching it. raw is the typed invocation; on the
// compaction warning it is restored into the composer for an easy re-submit
// (the commandUnknown dispatch cleared the composer before we got here, unlike
// the plain-prompt path whose guard fires before the clear). Empty raw (the
// picker path has no typed form) restores nothing.
func (m model) launchOrDeferExpandedPrompt(raw, prompt string) (model, tea.Cmd, bool) {
if m.exiting {
return m, nil, true
}
if m.pending {
return m.queueMessage(prompt), nil, true
}
if m.compactInFlight {
if strings.TrimSpace(raw) != "" {
m.input.SetValue(raw)
m.input.CursorEnd()
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendSystem,
text: "Compact\nstatus: warning\nCompaction is running. Re-run the command when it finishes.",
})
return m, nil, true
}
next, teaCmd := m.launchPrompt(prompt)
return next, teaCmd, true
}
// takenSlashNames returns every slash token already claimed by a builtin
// command (or alias) or a user command — the names a typed "/x" would dispatch
// to before ever reaching a skill.
func (m model) takenSlashNames() map[string]bool {
taken := map[string]bool{}
for _, command := range commandDefinitions {
taken[strings.TrimPrefix(command.name, "/")] = true
for _, alias := range command.aliases {
taken[strings.TrimPrefix(alias, "/")] = true
}
}
for _, cmd := range m.userCommands {
taken[cmd.Name] = true
}
return taken
}
// chooseSkillFromPicker handles Enter on a skill-picker row. Most skills want a
// request ("review WHICH pr?"), so selection fills the composer with
// "/name " — cursor at the end — for the user to complete and submit (Enter
// again runs it bare). Skills whose names cannot be typed as a slash command
// (non-slash shapes, or names shadowed by a builtin or user command) fall back
// to running immediately: the picker is the only path that reaches them, and an
// unfillable composer would be a dead end.
func (m model) chooseSkillFromPicker(item pickerItem) (model, tea.Cmd) {
slash := skillSlashName(item.Value)
if slash == "" || m.takenSlashNames()[slash] {
return m.invokeSkillByName(item.Value)
}
m.input.SetValue("/" + slash + " ")
m.input.CursorEnd()
return m, nil
}
// invokeSkillByName runs the installed skill with the given EXACT raw name (the
// skill-picker path). The same run-state guards as slash invocation apply; the
// picker offers no args affordance, so the prompt is the skill body alone.
func (m model) invokeSkillByName(name string) (model, tea.Cmd) {
for _, skill := range m.installedSkills() {
if strings.TrimSpace(skill.Name) != name {
continue
}
body := strings.TrimSpace(skill.Content)
if body == "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "skill " + name + " has an empty SKILL.md body (" + skill.Path + ")",
})
return m, nil
}
next, teaCmd, _ := m.launchOrDeferExpandedPrompt("", skillInvocationPrompt(body, ""))
return next, teaCmd
}
// The picker row came from a slightly older load (TTL cache) and the skill
// has since been removed.
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "skill " + name + " is no longer installed",
})
return m, nil
}
// lookupSkillCommand returns the installed skill whose slash name matches the
// given (lowercased) name. Linear scan over a fresh load — skills are re-read
// per invocation so a skill installed mid-session is invocable without a
// restart, and the body is never held on the model.
func (m model) lookupSkillCommand(name string) (skills.Skill, bool) {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return skills.Skill{}, false
}
for _, skill := range m.installedSkills() {
if skillSlashName(skill.Name) == name {
return skill, true
}
}
return skills.Skill{}, false
}
// installedSkills returns the session's installed skills (default dir merged
// with plugin skill roots) via the injected loader, or nil when the session has
// no skills wiring (e.g. bare test models).
func (m model) installedSkills() []skills.Skill {
if m.loadSkills == nil {
return nil
}
return m.loadSkills()
}
// skillSlashName maps a skill's frontmatter name to its slash-command form:
// lowercased, and only if it fits the slash-token shape (letters, digits,
// dot/underscore/hyphen — a superset of user-command names, since skill names
// are free-form frontmatter). Returns "" for names that cannot be typed as a
// /command (e.g. containing spaces); those skills remain loadable by the model
// via the skill tool and are still listed by /skills.
func skillSlashName(name string) string {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return ""
}
for _, r := range name {
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.'
if !valid {
return ""
}
}
return name
}
+412
View File
@@ -0,0 +1,412 @@
package tui
import (
"context"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/skills"
)
// newSkillTestModel builds a model with an injected skills loader (and the
// matching palette metadata), no filesystem involved.
func newSkillTestModel(t *testing.T, installed ...skills.Skill) model {
t.Helper()
infos := make([]agent.SkillInfo, 0, len(installed))
for _, s := range installed {
infos = append(infos, agent.SkillInfo{Name: s.Name, Description: s.Description})
}
return newModel(context.Background(), Options{
Cwd: t.TempDir(),
LoadSkills: func() []skills.Skill { return installed },
AgentOptions: agent.Options{Skills: infos},
})
}
func submitInput(t *testing.T, m model, input string) model {
t.Helper()
m.input.SetValue(input)
updated, _ := m.Update(testKey(tea.KeyEnter))
return updated.(model)
}
func TestSkillCommandInvokesAndSubmits(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{
Name: "deploy-checks",
Description: "Run the pre-deploy verification suite.",
Content: "Run every verification step and summarize failures.",
Path: "/skills/deploy-checks/SKILL.md",
})
next := submitInput(t, m, "/deploy-checks ship v2 to production")
if transcriptContains(next.transcript, "unknown command") {
t.Fatalf("an installed skill must not be 'unknown', got %#v", next.transcript)
}
if !transcriptContains(next.transcript, "Run every verification step and summarize failures.") {
t.Fatalf("skill body should be inlined into the prompt, got %#v", next.transcript)
}
if !transcriptContains(next.transcript, "ship v2 to production") {
t.Fatalf("typed args should follow the skill body, got %#v", next.transcript)
}
}
func TestSkillCommandWithoutArgs(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff for correctness."})
next := submitInput(t, m, "/reviewer")
if !transcriptContains(next.transcript, "Review the diff for correctness.") {
t.Fatalf("bare skill invocation should submit the body, got %#v", next.transcript)
}
// A bare invocation carries the ask-first note: instructions with no target
// must make the model ask ("which PR?") instead of improvising one.
if !transcriptContains(next.transcript, "ask for them first") {
t.Fatalf("bare invocation should carry the clarify-first note, got %#v", next.transcript)
}
}
// An invocation WITH a request must not carry the ask-first note — the user
// already said what to apply the skill to.
func TestSkillCommandWithArgsHasNoClarifyNote(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff."})
next := submitInput(t, m, "/reviewer PR 484 reconnect changes")
if transcriptContains(next.transcript, "ask for them first") {
t.Fatalf("args invocation must not carry the clarify note, got %#v", next.transcript)
}
}
// A skill's frontmatter name is free-form; invocation matches its lowercased
// slash form.
func TestSkillCommandNameIsCaseInsensitive(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "Deploy-Checks", Content: "Check things."})
next := submitInput(t, m, "/deploy-checks")
if !transcriptContains(next.transcript, "Check things.") {
t.Fatalf("uppercase frontmatter name should be invocable lowercased, got %#v", next.transcript)
}
}
// Precedence: a user command (.zero/commands) shadows a same-named skill.
func TestUserCommandShadowsSkill(t *testing.T) {
root := t.TempDir()
writeUserCommand(t, root, "greet.md", "Say hello from the user command.")
m := newModel(context.Background(), Options{
Cwd: root,
LoadSkills: func() []skills.Skill { return []skills.Skill{{Name: "greet", Content: "Skill body must not run."}} },
})
next := submitInput(t, m, "/greet")
if !transcriptContains(next.transcript, "Say hello from the user command.") {
t.Fatalf("user command should win the name collision, got %#v", next.transcript)
}
if transcriptContains(next.transcript, "Skill body must not run.") {
t.Fatalf("shadowed skill must not run, got %#v", next.transcript)
}
}
// An empty skill body is a real, named match — surface the problem instead of
// falling through to a misleading "unknown command".
func TestSkillCommandEmptyBody(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "hollow", Path: "/skills/hollow/SKILL.md"})
next := submitInput(t, m, "/hollow")
if transcriptContains(next.transcript, "unknown command") {
t.Fatalf("an empty skill must not be 'unknown', got %#v", next.transcript)
}
if !transcriptContains(next.transcript, "empty SKILL.md body") {
t.Fatalf("empty-body error should be surfaced, got %#v", next.transcript)
}
}
func TestUnknownSlashStillUnknownWithSkillsInstalled(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review."})
next := submitInput(t, m, "/definitelynotaskill")
if !transcriptContains(next.transcript, "unknown command") {
t.Fatalf("non-matching name should still be unknown, got %#v", next.transcript)
}
}
func TestSkillSlashName(t *testing.T) {
cases := map[string]string{
"deploy-checks": "deploy-checks",
"Deploy-Checks": "deploy-checks",
" pdf_tools ": "pdf_tools",
"v2.migrate": "v2.migrate",
"has space": "",
"emoji✨": "",
"": "",
"slash/inside": "",
"tab\tseparated": "",
}
for in, want := range cases {
if got := skillSlashName(in); got != want {
t.Errorf("skillSlashName(%q) = %q, want %q", in, got, want)
}
}
}
func TestSkillAppearsInAutocomplete(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "deploy-checks", Description: "Run the pre-deploy suite.", Content: "x"})
got := m.matchCommandSuggestions("/dep")
found := false
for _, s := range got {
if s.Name == "/deploy-checks" {
found = true
if s.Desc != "Run the pre-deploy suite. (skill)" {
t.Fatalf("skill suggestion should be labeled, got %q", s.Desc)
}
}
}
if !found {
t.Fatalf("skill /deploy-checks should appear for '/dep', got %#v", got)
}
}
// A skill shadowed by a builtin (or its alias) or a user command would be dead
// at dispatch time, so the palette must not advertise it.
func TestShadowedSkillNotSuggested(t *testing.T) {
root := t.TempDir()
writeUserCommand(t, root, "deploy.md", "User deploy.")
installed := []skills.Skill{
{Name: "help", Content: "x"}, // builtin collision
{Name: "sessions", Content: "x"}, // builtin ALIAS collision (/resume alias)
{Name: "deploy", Content: "x"}, // user-command collision
}
infos := make([]agent.SkillInfo, 0, len(installed))
for _, s := range installed {
infos = append(infos, agent.SkillInfo{Name: s.Name})
}
m := newModel(context.Background(), Options{
Cwd: root,
LoadSkills: func() []skills.Skill { return installed },
AgentOptions: agent.Options{Skills: infos},
})
// Assert on the advertised NAME with a skill-marked Desc, not on the exact
// fallback Desc format — the invariant is that no skill row carries a
// builtin/user-command name, regardless of description wording.
for _, token := range []string{"/hel", "/ses", "/dep"} {
for _, s := range m.matchSkillSuggestions(token) {
if s.Name == "/help" || s.Name == "/sessions" || s.Name == "/deploy" {
t.Fatalf("shadowed skill advertised for %q: %#v", token, s)
}
}
}
}
// Invalid slash shapes (spaces etc.) and a bare "/" must not surface skills.
func TestSkillSuggestionEdgeCases(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "has space", Description: "not invocable", Content: "x"})
if got := m.matchSkillSuggestions("/has"); len(got) != 0 {
t.Fatalf("invalid slash-shape skill must not be suggested, got %#v", got)
}
if got := m.matchSkillSuggestions("/"); got != nil {
t.Fatalf("bare '/' should not list skills, got %#v", got)
}
}
func TestSkillsCommandResolves(t *testing.T) {
command, ok := resolveCommand("/skills")
if !ok || command.kind != commandSkills {
t.Fatalf("resolveCommand(/skills) = %#v, %v", command, ok)
}
}
// With skills installed, /skills opens a searchable picker (like /model).
func TestSkillsCommandOpensPicker(t *testing.T) {
m := newSkillTestModel(t,
skills.Skill{Name: "deploy-checks", Description: "Run the pre-deploy suite.", Content: "x"},
skills.Skill{Name: "has space", Description: "tool-only skill", Content: "x"},
)
next := submitInput(t, m, "/skills")
if next.picker == nil || next.picker.kind != pickerSkill {
t.Fatalf("/skills should open the skill picker, got %#v", next.picker)
}
labels := map[string]string{}
for _, item := range next.picker.items {
labels[item.Label] = item.Value
}
if labels["/deploy-checks"] != "deploy-checks" {
t.Fatalf("slash-invocable skill should show its slash form, got %#v", labels)
}
// A non-slash-shaped name is still selectable (bare label, exact-name value).
if labels["has space"] != "has space" {
t.Fatalf("non-slash skill should be listed bare but selectable, got %#v", labels)
}
}
// Enter on a picker row fills the composer with "/name " so the user can add
// their request (which PR? which file?) before submitting — it must NOT fire
// the skill with no request attached.
func TestSkillPickerEnterFillsComposer(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff for correctness."})
next := submitInput(t, m, "/skills")
if next.picker == nil {
t.Fatal("picker should be open")
}
updated, _ := next.Update(testKey(tea.KeyEnter))
after := updated.(model)
if after.picker != nil {
t.Fatal("picker should close on Enter")
}
if got := after.input.Value(); got != "/reviewer " {
t.Fatalf("composer should be filled with the invocation, got %q", got)
}
if transcriptContains(after.transcript, "Review the diff for correctness.") {
t.Fatalf("skill must not run before the user submits, got %#v", after.transcript)
}
// A second Enter submits the (bare) invocation and runs the skill.
final, _ := after.Update(testKey(tea.KeyEnter))
done := final.(model)
if !transcriptContains(done.transcript, "Review the diff for correctness.") {
t.Fatalf("submitting the filled composer should run the skill, got %#v", done.transcript)
}
}
// Picker selection is by exact name, so a skill shadowed by a builtin (dead on
// the slash path) is still runnable from the picker.
func TestSkillPickerRunsBuiltinShadowedSkill(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "help", Description: "shadowed", Content: "Shadowed skill body."})
next := submitInput(t, m, "/skills")
if next.picker == nil {
t.Fatal("picker should be open")
}
updated, _ := next.Update(testKey(tea.KeyEnter))
after := updated.(model)
if !transcriptContains(after.transcript, "Shadowed skill body.") {
t.Fatalf("picker must run the shadowed skill directly, got %#v", after.transcript)
}
}
func TestSkillsCommandEmptyState(t *testing.T) {
m := newModel(context.Background(), Options{Cwd: t.TempDir()})
next := submitInput(t, m, "/skills")
if !transcriptContains(next.transcript, "No skills installed.") {
t.Fatalf("empty state should say no skills, got %#v", next.transcript)
}
}
// Run-state guards: an expanded skill prompt must not race an active run — it
// queues (as the EXPANDED prompt, since the queue flush submits literal text).
func TestSkillInvocationQueuedWhileRunPending(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff."})
m.pending = true
next, _, handled := m.handleSkillCommand("/reviewer focus on tests")
if !handled {
t.Fatal("skill must be handled even while a run is pending")
}
if next.queuedMessage == "" || !strings.Contains(next.queuedMessage, "Review the diff.") {
t.Fatalf("expanded prompt should be queued, got %q", next.queuedMessage)
}
if !strings.Contains(next.queuedMessage, "focus on tests") {
t.Fatalf("queued prompt should carry the args, got %q", next.queuedMessage)
}
}
func TestSkillInvocationDroppedWhileExiting(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review."})
m.exiting = true
next, cmd, handled := m.handleSkillCommand("/reviewer")
if !handled || cmd != nil || next.queuedMessage != "" {
t.Fatalf("exiting must swallow the invocation: handled=%v cmd=%v queued=%q", handled, cmd, next.queuedMessage)
}
}
func TestSkillInvocationWarnsDuringCompaction(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review."})
m.compactInFlight = true
next, _, handled := m.handleSkillCommand("/reviewer")
if !handled {
t.Fatal("skill must be handled during compaction")
}
if !transcriptContains(next.transcript, "Compaction is running") {
t.Fatalf("compaction warning expected, got %#v", next.transcript)
}
// The typed invocation is restored for an easy re-submit (the dispatch path
// cleared the composer before the guard could fire).
if got := next.input.Value(); got != "/reviewer" {
t.Fatalf("typed invocation should be restored into the composer, got %q", got)
}
}
// The same guard now protects user commands (previously they could start a
// second concurrent run).
func TestUserCommandQueuedWhileRunPending(t *testing.T) {
root := t.TempDir()
writeUserCommand(t, root, "greet.md", "Say hello to $1.")
m := newModel(context.Background(), Options{Cwd: root})
m.pending = true
next, _, handled := m.handleUserCommand("/greet world")
if !handled {
t.Fatal("user command must be handled while pending")
}
if !strings.Contains(next.queuedMessage, "Say hello to world.") {
t.Fatalf("expanded user-command prompt should be queued, got %q", next.queuedMessage)
}
}
// Two skills whose names collide after lowercasing map to one slash name; only
// the one dispatch will run may be advertised.
func TestCaseCollidingSkillsAdvertisedOnce(t *testing.T) {
m := newSkillTestModel(t,
skills.Skill{Name: "Deploy", Description: "wins", Content: "a"},
skills.Skill{Name: "deploy", Description: "loses", Content: "b"},
)
rows := 0
for _, s := range m.matchSkillSuggestions("/dep") {
if s.Name == "/deploy" {
rows++
if !strings.Contains(s.Desc, "wins") {
t.Fatalf("advertised row must describe the skill dispatch runs, got %q", s.Desc)
}
}
}
if rows != 1 {
t.Fatalf("case-colliding skills must yield exactly one /deploy row, got %d", rows)
}
}
// The palette reads through the loader, so a skill installed mid-session (the
// loader's next result) appears without a restart.
func TestSkillPaletteReadsThroughLoader(t *testing.T) {
installed := []skills.Skill{}
m := newModel(context.Background(), Options{
Cwd: t.TempDir(),
LoadSkills: func() []skills.Skill { return installed },
})
if got := m.matchSkillSuggestions("/lat"); len(got) != 0 {
t.Fatalf("no skills installed yet, got %#v", got)
}
installed = append(installed, skills.Skill{Name: "late-arrival", Description: "installed mid-session", Content: "x"})
got := m.matchSkillSuggestions("/lat")
if len(got) != 1 || got[0].Name != "/late-arrival" {
t.Fatalf("mid-session skill should appear via the loader, got %#v", got)
}
}
+3 -2
View File
@@ -30,8 +30,9 @@ func (m model) handleUserCommand(raw string) (model, tea.Cmd, bool) {
if strings.TrimSpace(prompt) == "" {
return m, nil, false
}
next, teaCmd := m.launchPrompt(prompt)
return next, teaCmd, true
// Same run-state guards as a plain prompt: a user command invoked mid-run is
// queued (as its expanded prompt), not raced into a second concurrent turn.
return m.launchOrDeferExpandedPrompt(raw, prompt)
}
// lookupUserCommand returns the loaded user command with the given (lowercased)
+7
View File
@@ -218,6 +218,13 @@ func (m model) statusLine(width int) string {
} else if summary := m.backgroundTerminalSummary(); summary != "" {
left += separator + zeroTheme.muted.Render(summary)
}
// Active loops surface a persistent "↻ N loops · next 3:05pm" segment so a
// running loop is always visible (hidden during an exit/cancel confirm above).
if !m.exitConfirmActive && !m.cancelConfirmActive {
if loopSummary := m.loopFooterSummary(); loopSummary != "" {
left += separator + zeroTheme.accent.Render("↻ ") + zeroTheme.muted.Render(loopSummary)
}
}
rightGroups := []string{}
// Context-fill gauge: surface it down to the narrow tier (where it matters
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@gitlawb/zero",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gitlawb/zero",
"version": "0.1.0",
"version": "0.2.0",
"cpu": [
"x64",
"arm64"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@gitlawb/zero",
"version": "0.1.0",
"version": "0.2.0",
"description": "Zero — a fast, multi-provider terminal coding agent.",
"type": "module",
"module": "bin/zero.js",
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const SUMMARY_LIMIT = 280;
const STRUCTURED_FORMATS = new Set(['json', 'stream-json']);
function normalizeSummary(value) {
return String(value ?? '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, SUMMARY_LIMIT);
}
function truncateSummary(value) {
return String(value ?? '').slice(0, SUMMARY_LIMIT);
}
function lastNonEmptyLine(contents) {
let last = '';
for (const line of String(contents ?? '').split(/\r?\n/)) {
if (line.trim()) {
last = line;
}
}
return last;
}
function summarizeStructured(contents) {
let summary;
for (const line of String(contents ?? '').split(/\r?\n/)) {
if (!line.trim()) {
continue;
}
let event;
try {
event = JSON.parse(line);
} catch {
continue;
}
if (event?.type === 'final' && typeof event.text === 'string') {
summary = event.text;
} else if (event?.type === 'error' && typeof event.message === 'string') {
summary = event.message;
}
}
if (summary !== undefined) {
return normalizeSummary(summary);
}
return normalizeSummary(lastNonEmptyLine(contents));
}
export function summarizeOutput(format, contents) {
const normalizedFormat = String(format ?? 'text').toLowerCase();
if (!STRUCTURED_FORMATS.has(normalizedFormat)) {
return truncateSummary(lastNonEmptyLine(contents));
}
return summarizeStructured(contents);
}
function isMainModule() {
return (
process.argv[1] !== undefined &&
pathToFileURL(resolve(process.argv[1])).href === import.meta.url
);
}
if (isMainModule()) {
const [format, outputFile] = process.argv.slice(2);
let contents = '';
try {
contents = readFileSync(outputFile, 'utf8');
} catch {
contents = '';
}
process.stdout.write(`${summarizeOutput(format, contents)}\n`);
}
+148
View File
@@ -0,0 +1,148 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { summarizeOutput } from './action-summary.mjs';
const scriptPath = fileURLToPath(new URL('./action-summary.mjs', import.meta.url));
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
function jsonl(events) {
return events.map((event) => JSON.stringify(event)).join('\n');
}
test('stream-json final followed by run_end returns final text', () => {
const output = jsonl([
{ type: 'final', text: 'Done. Tests passed.' },
{ type: 'run_end', status: 'success', exitCode: 0 },
]);
assert.equal(summarizeOutput('stream-json', output), 'Done. Tests passed.');
});
test('stream-json error followed by run_end returns error message', () => {
const output = jsonl([
{ type: 'error', code: 'provider_error', message: 'provider request failed' },
{ type: 'run_end', status: 'error', exitCode: 3 },
]);
assert.equal(summarizeOutput('stream-json', output), 'provider request failed');
});
test('stream-json final then error returns error message', () => {
const output = jsonl([
{ type: 'final', text: 'partial answer' },
{ type: 'error', code: 'incomplete', message: 'run stopped with work unfinished' },
{ type: 'run_end', status: 'incomplete', exitCode: 4 },
]);
assert.equal(summarizeOutput('stream-json', output), 'run stopped with work unfinished');
});
test('json final followed by done returns final text', () => {
const output = jsonl([
{ type: 'final', text: 'Review complete.' },
{ type: 'done', exit_code: 0 },
]);
assert.equal(summarizeOutput('json', output), 'Review complete.');
});
test('json error followed by done returns error message', () => {
const output = jsonl([
{ type: 'error', code: 'exec_failed', message: 'command failed' },
{ type: 'done', exit_code: 1 },
]);
assert.equal(summarizeOutput('json', output), 'command failed');
});
test('json final then error returns error message', () => {
const output = jsonl([
{ type: 'final', text: 'partial answer' },
{ type: 'error', code: 'incomplete', message: 'run stopped with work unfinished' },
{ type: 'done', exit_code: 4 },
]);
assert.equal(summarizeOutput('json', output), 'run stopped with work unfinished');
});
test('text output returns last non-empty line', () => {
const output = '\nfirst line\n\n final streamed line \n';
assert.equal(summarizeOutput('text', output), ' final streamed line ');
});
test('unknown format falls back to text behavior', () => {
const output = 'alpha\n beta \n';
assert.equal(summarizeOutput('xml', output), ' beta ');
});
test('malformed structured output falls back safely', () => {
const output = '{not-json}\nraw fallback\n';
assert.equal(summarizeOutput('stream-json', output), 'raw fallback');
});
test('long multiline final becomes one-line 280-char summary', () => {
const text = `first line\n${'x'.repeat(400)}`;
const summary = summarizeOutput('stream-json', jsonl([{ type: 'final', text }]));
assert.equal(summary.length, 280);
assert.equal(summary.includes('\n'), false);
assert.equal(summary, `first line ${'x'.repeat(400)}`.slice(0, 280));
});
test('cli reads output file and prints summary', () => {
const dir = mkdtempSync(join(tmpdir(), 'zero-action-summary-'));
try {
const outputFile = join(dir, 'zero-output.jsonl');
writeFileSync(
outputFile,
jsonl([
{ type: 'final', text: 'CLI summary' },
{ type: 'run_end', status: 'success', exitCode: 0 },
]),
);
const result = spawnSync(process.execPath, [scriptPath, 'stream-json', outputFile], {
encoding: 'utf8',
});
assert.equal(result.status, 0);
assert.equal(result.stderr, '');
assert.equal(result.stdout, 'CLI summary\n');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('cli runs when invoked with relative script path', () => {
const dir = mkdtempSync(join(tmpdir(), 'zero-action-summary-'));
try {
const outputFile = join(dir, 'zero-output.jsonl');
writeFileSync(
outputFile,
jsonl([
{ type: 'final', text: 'relative CLI summary' },
{ type: 'run_end', status: 'success', exitCode: 0 },
]),
);
const result = spawnSync(process.execPath, ['scripts/action-summary.mjs', 'stream-json', outputFile], {
cwd: repoRoot,
encoding: 'utf8',
});
assert.equal(result.status, 0);
assert.equal(result.stderr, '');
assert.equal(result.stdout, 'relative CLI summary\n');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});