Agent Control Plane for one Claude Code session: task scope, evidence, hand-over. A control and a display, not an isolation boundary.

Tested with Claude Code 2.1.288 (headless) and 2.1.289 (interactive, after the host auto-updated) (claude plugin validate ., claude plugin test .: 203 tests); 0.2.x re-verified live on 2.1.289 (§ Verified live, 0.2.x). The mods API is marked changeable between releases; the type declarations the host writes into .claude-plugin/types/ are the authority for the installed version. Run claude plugin validate . after every Claude Code upgrade.
Design: docs/features/agent-control-plane-mod/ in this repository (requirements, feasibility study, tech spec, request tickets). This directory is not part of the sd0x-dev-flow plugin: installing the plugin does not install the mod. To install it, run /agentctl-setup (it checks the host, installs agentctl@sd0xdev-marketplace after you approve, and builds your first task line; /agentctl-setup --task and the workflow skills draft a proposal for you to accept instead), or load it for one session with claude --plugin-dir mods/agentctl.
Type what you are doing — you do not need any other command:
/agentctl 測試一下這個新功能
bin/propose.mjs). If something is unclear it asks you instead of inventing permissions./agentctl accept <digest> in the box: Tab, then Enter. Accepting binds the scope; it starts no work./agentctl shows where things stand and what to do next — run the checks, then /agentctl handoff — and /agentctl help lists the rest.A tester who had not read the design walked steps 1–4 and the hand-over unaided (§ Uncoached run).
Sending the request is an ordinary Claude turn; the mod itself calls no model. Replies use Traditional Chinese when your goal is written in Chinese, English otherwise — a simple heuristic, not locale detection.
| Function | How | |
|---|---|---|
| Task scope | Claude drafts a proposal (/agentctl-setup --task, or /feature-dev, /bug-fix, /refactor when the mod is installed); the mod previews it and /agentctl accept typed at your own prompt binds exactly what was previewed — see § Proposals. /agentctl task set <json> typed at your prompt also binds a task to the worktree. Tool output, files and messages cannot change it — only a command you type. The binding is per worktree, so your own task set / task clear in another session on the same worktree does replace or clear it. Task records and hand-overs are kept per worktree, so the same task id in two worktrees names two separate tasks. A store error is reported as such, and the previous task keeps applying; a binding whose record is missing refuses everything but reads in the worktree | |
| Refusal | A deny-list. Recognized direct production writes and remote git writes are refused with or without a task; while a task is bound, its own forbid entries and edits outside its roots are refused too. Edit roots bind the edit tools (Write, Edit, NotebookEdit); a file written through a shell redirect is a Bash command the mod cannot classify, so it goes to the host — the refusal tells Claude not to reach the same result another way, but roots are not a sandbox. Everything the mod cannot classify — scripts, compound shell, unknown tools — goes to Claude Code's own permission prompt or auto mode, recorded as delegated. A downstream deny is never weakened and an allow is never created. Best-effort: the mod reads each tool call, never a script's contents or the commands it starts, so a push inside a script is the host's and that workflow's to authorize | |
| Evidence | A run counts when it matches a declared check executor — its words first, so added arguments still count and may check less than declared, while a pipe, redirect or wrapper (`npm test \ | grep pass) does not; the accept reply and /agentctl name each check as a line that matches it, or — when the formatter cannot quote a word in one piece — as its JSON words, which do not match if copied as is (a correctly quoted spelling of the same words does). A matching call is bracketed by Git-derived tree readings (executors are matched first, so a declared git status` check is a check); a later edit shows the result stale within 30 s; a background check stays "completion unobserved" until a terminal result is seen. Evidence belongs to the task and policy version it was observed under, and a reading with partial coverage is never listed as verified |
| Panel | The band above the prompt is compact: task, runtime and its duration, what needs you, the gate reading with its age (and stale past 30 s), the last hand-over time. /agentctl is a compact status reply — the next step, task, what needs you, the gate and evidence; /agentctl status --details adds storage health, runtime, context and the 5 h window. The gate, context and window lines name their source and age, or read missing / unavailable, and each evidence line its outcome, freshness, coverage and age | |
| Hand-over | /agentctl handoff — eight answers from records, no model, no network, no process; logged in full on reopen (transcript only); bare /agentctl only points at it, /agentctl last prints it | |
| Stop | /agentctl stop — saves the hand-over, requests cancellation of the current turn, lists tracked operations; never "all stopped" |
Claude writes ~/.claude/agentctl/proposals/<URI-encoded worktree>.json (outside the worktree) through the mod's bundled bin/propose.mjs (the /agentctl <goal> path) or sd0x-dev-flow's agentctl-setup.js propose (setup and workflow skills); both apply the mod's own validation first. At session start and at the end of each main turn the mod reads it once, validates it again, and keeps the effective object: the worktree comes from the session, a proposal naming another worktree, drafted against a task that is no longer bound, or overlapping a built-in class is refused (the reason is logged). The preview lists every permission being accepted with a SHA-256 digest (24 hex) in the transcript and the band. Each entry is shown up to 160 characters; a longer one ends in …, and the digest — and what accept binds — still covers all of it, so discard a draft whose entries you cannot see whole.
| Command | Who | Does |
|---|---|---|
/agentctl proposal | anyone | Prints the waiting preview |
/agentctl accept [digest prefix] | your prompt only | Binds the retained object — never the file re-read — if the bound task has not changed since the preview |
/agentctl discard | anyone | Drops the waiting proposal; the bound task stays |
Changing the file later produces a new preview and digest; an older digest no longer matches. Concurrent sessions on one worktree share one binding and one store, which is not atomic across sessions: accept in the session that showed the preview.
{
"goal": "investigate the quiz service timeout",
"allow": ["edit"],
"editRoots": ["src", "tests"],
"forbid": ["terraform apply"],
"executors": [{ "argv": ["npm", "test"], "check": true }],
"needsUser": [["npm", "publish"]],
"tools": ["mcp__docs__search"],
"acceptance": ["unit tests pass on the final tree"]
}
Built-in forbidden classes apply with or without a task, on top of a task's own: production writes (kubectl apply|delete|patch|rollout|scale|edit|replace, helm install|upgrade|uninstall|rollback, gcloud … deploy|delete|update) and remote git writes (git push, gh pr merge). Secrets never belong in a task: a credential-like value in a policy field is refused.
needsUser asks only where a person was seen answering: the interactive terminal under the default (manual), acceptEdits and auto permission modes (lib/verdict.js). The mode is read from the classic hook inputs at each prompt and tool end; until one has been seen, and on every other surface or mode — -p, dontAsk, plan, bypassPermissions, Desktop, VS Code, mobile — a needsUser call is refused. A mode switched in the middle of a turn is seen at the next tool end.
permissions.deny, for example: { "permissions": { "deny": ["Bash(kubectl delete:*)", "Bash(kubectl rollout:*)", "Bash(helm upgrade:*)"] } }
Those rules match command text, not programs (git -C . push is a different text), which is why both layers exist. Bash(git push:*) is left out on purpose: sd0x-dev-flow's /push-ci runs it.
tool.check; this mod discloses that in /agentctl policy.npm test) run with their effects unclassified.cd mods/agentctl # from the repository root
claude plugin validate . # hooks and $ calls: no $.model.*, $.http.*, $.tool.register, $.prompt.submit (fill/suggest/read allowed)
claude plugin test . # every *.test.ts under tests/
The mod is versioned on its own and released under agentctl-v<version> tags (.github/workflows/release-agentctl.yml), separate from sd0x-dev-flow's releases. Claude Code caches an installed plugin by version, so a change to anything but this README or tests/ needs a version bump: raise "version" in .claude-plugin/plugin.json, then run node .github/scripts/agentctl-version.js update from the repository root (or /bump-version agentctl). CI's version lock fails until both are done, and CI also runs claude plugin validate and claude plugin test here.
claude -p --plugin-dir on 2.1.288, interactive tmux on 2.1.289)These rows record version 0.1.0. Since 0.2.0 an unclassified call is delegated to the host instead of refused, and the proposal commands exist; the 0.2.x rows follow the table.
| Check | Result |
|---|---|
Load and /agentctl under -p | Loads for the session only; replies in ~3.7 s with no model turn |
task set from a -p prompt | Refused: a -p prompt is not stamped composer. Headless runs cannot set a task; set it from an interactive prompt |
| Forbidden and unclassified Bash calls | Refused before running, rule named (task-forbid: …, unclassified: python3 has no adapter) |
An allowed read (git status) | Went to the host's own permission path and ran |
A check exiting non-zero (sh check.sh → exit 1) | Recorded as error: a non-zero Bash exit reaches the mod as isError |
| Evidence bracket | Before/after readings recorded, coverage git-hybrid |
| Two concurrent sessions | Both session records written; the task record kept |
| Leftover processes after exit | None |
| Tree reading cost | ~90 ms with 500 changed/untracked files; ~75 ms on a clean 974-file repository |
| 5 h window after a model turn (V7) | 1% (resets …) read from the host, with its source and age |
| Background check (V5, background half) | Closed from the host's task notification (<task-id>, <status>) as error — no polling needed. TaskGet is the todo-list tool; GetTask reads background tasks |
| Subagent tool calls (V4) | A subagent's danger-cmd was refused by the same rule |
ask under -p (V3, -p only) | With a probe copy answering ask, neither tested headless configuration (with and without an allow rule for Bash) ran the command on 2.1.288 — the probe's ask was not auto-approved |
/agentctl stop under -p stream input (V6) | The input was processed after the turn ended, so immediate does not apply there; the reply said no running turn was observed — never "stopped" |
| MCP tool calls (V4) | A probe MCP tool was refused as unclassified until the task named it, then ran |
| Interactive terminal in tmux (V10) | The band renders CJK goal text correctly at 80 and 50 columns, wrapping to two and three lines |
/agentctl stop during a running foreground tool, interactive (V6) | Ran at once (immediate); the turn was cancelled; the host moved the running command to the background, where it kept running — the reply listed it as running and never said "stopped"; when it finished, the host's notification closed its evidence |
ask on the interactive terminal in manual mode (V3) | With a probe copy answering ask, the host showed its own approval dialog with the mod's reason (Yes / No, no "don't ask again"); declining interrupted the call; re-run with the shipped code (manual: dialog; dontAsk: refused by the mod), and with a probe copy under acceptEdits and auto (dialog) and dontAsk (refused). The strings the host sends were read from a plain classic UserPromptSubmit hook (2.1.289): default, acceptEdits, auto, plan and dontAsk each arrive verbatim; on a model without auto mode (Haiku) --permission-mode auto falls back to default |
| Interactive start (found live) | session.start and the first band render arrive together; two concurrent context builds left the band on an orphaned context (gates missing). Fixed: one shared build |
| Install, disable, uninstall (Signal 12) | Installed from a local marketplace, ran, disabled (/agentctl gone), uninstalled: no mod process left, the permissions settings byte-identical. The host itself left an empty extraKnownMarketplaces key, a plugin cache directory and an empty installed_plugins.json; the mod leaves only its own store file. All were restored and verified byte-identical afterwards |
Removal: --plugin-dir installs nothing and changes no setting. The mod's own data stays in ~/.claude/plugins/store/agentctl_*.json (sessions, tasks, checkpoints), and drafted proposals in ~/.claude/agentctl/proposals/; delete them to remove it.
| Check | Result |
|---|---|
Direct git push with no task bound (-p) | Refused remote-git-write before running; the remote stayed empty |
Unclassified python3 -c … and a script that pushes (/bin/bash -p push.sh, -p) | Both passed to the host and ran; the script's push reached git (rejected by the scratch remote itself, not by the mod) — the disclosed script blindness |
Proposal written by agentctl-setup.js propose, interactive start | The preview was logged at session start with the helper's digest, and the band named the waiting proposal |
/agentctl accept <digest> typed at the prompt | Bound the previewed scope as a new task; without a base the helper printed no digest, the mod filled the bound task in and its own digest was accepted |
| A declared check, an unclassified call, an edit outside the roots, a direct push (bound task) | Check recorded as current evidence; python3 delegated and listed as such by /agentctl events; the edit refused edit outside the allowed roots; the push refused remote-git-write |
| An untracked file added from outside | The check read stale (tree changed since) within 32 s |
/agentctl handoff, reopen, /agentctl, /agentctl last | Hand-over states when the tree was read and lists the stale check under Not verified; on reopen the transcript shows it, bare /agentctl prints one pointer line, /agentctl last the whole of it |
| Found live and fixed in 0.2.1 | An accepted proposal file was read again by the next session as a stale draft (now remembered per worktree); the built-in refusal said "the task's scope", and Claude then proposed widening the task (now: "a built-in class no task can lift"); transcript lines read agentctl: agentctl: |
Cost of the two -p probes | 2–3 turns each, ≈ $0.06 each on Haiku; the mod's own replies, previews and band call no model |
| Independent adversarial test (Codex, 2026-10-05), fixed in 0.2.2 | An absolute or upper-case program path (/usr/bin/git push, GIT push) slipped past the built-in classes; an edit root such as ../other authorized writes outside the worktree; the proposal cap counted characters, not UTF-8 bytes; with no task bound, delegated calls were not recorded in /agentctl events. Digest agreement, prototype keys, other-worktree refusal, evidence partitioning and partial coverage held |
Signal 13, walked on 2.1.289 (steps 1–3) and again from the start on 2.1.292:
| Step | Result |
|---|---|
/agentctl 幫 …/README 加一行測試說明 | The request filled the empty box in Traditional Chinese with the helper path from $.plugin.root; nothing was sent or bound |
| Enter | Claude ran the helper's --help, read the project, found the file missing and asked instead of inventing scope; after the answer it wrote the proposal through the helper |
| Preview | Shown at the turn's end with its digest and "binds the scope only; starts no work"; the band named the waiting proposal |
| Tab + Enter | Accepted from the suggestion; the reply named the next step |
| "start the work" | The edit inside the accepted root went to the host's own permission prompt; the declared check was recorded as current evidence |
/agentctl, handoff, reopen, last | Status led with the next step; the hand-over listed the check as verified with its reading time; reopening pointed at it, last printed it |
| A second goal with the task bound | The helper delivered under the bound task; the preview said "Replaces: task …"; Tab + Enter replaced it |
Found and fixed during the walk: on 2.1.292 the host's own next-prompt guess ("確認") took the box, so Tab + Enter would have sent that word to Claude — the mod now replaces the host's guess with the accept line while a proposal waits, and offers it again at each turn's end; the accept reply's last line stayed English in a Chinese session; with a task bound, the fill reply said "nothing is bound". Hesitations recorded: Claude once tried to read the mod's own files outside the worktree (declined at the host prompt); the copy language resets with each new session.
The tester was given only /agentctl <what you are doing> and read neither docs/ nor this mod's code. Describe → Enter → preview (≈ 72 s, README-only scope, three checks) → Tab + Enter → "start the work" → /agentctl → /agentctl handoff completed in six steps with no outside help. Fixed in 0.3.1 from what it found:
| Found | 0.3.1 |
|---|---|
| The hand-over read "Edits observed: 0" beside a changed README (Claude had edited through Bash) | The line counts edit-tool calls and says Bash changes are in the tree line |
Claude ran the tests piped through grep; the hand-over called the check stale while Claude called it passing | The accept reply names the checks to run verbatim; /agentctl asks for them again by name |
With the work done, /agentctl still said "ask Claude to start the work" | The next step follows the declared checks: work and run them, re-run what is not current, or hand over |
| A Write outside the roots was refused, then the same file was written with a Bash heredoc | The refusal tells Claude not to reach the result another way; this section and § What it does state that roots bind the edit tools only |
| Claude asked a question and wrote a proposal in the same turn | The drafting request says to wait for the answer before writing |
Left as they are: the run took minutes because the clone's own CLAUDE.md requires two review rounds for a doc change, not because of the mod.
V3 on Desktop, VS Code and mobile, and under plan and bypassPermissions (accepting the bypass-mode warning is the operator's own decision, so it was not exercised): needsUser stays refused there. The band was checked in tmux, not over SSH.
hooks/register.js 739 lines1// agentctl — Agent Control Plane for one Claude Code session.
2// A control and a display, not an isolation boundary: production read-only belongs to credentials
3// and the target's own permissions, and named dangerous invocations to the user's permission rules.
4// This module wires hooks to the pure modules in ../lib; every decision lives there.
5
6import { MAX_HASHED_PATHS, TIMEOUT_MS, commands as FP, digest, fold, parseStatus } from '../lib/fingerprint.js'
7import { handoff } from '../lib/handoff.js'
8import { decideNotices, parseTaskNotification } from '../lib/notices.js'
9import { HARD_FORBIDDEN, classify, taskRecord, validateTask } from '../lib/policy.js'
10import { acceptedRecord, digestMatches, previewLines, proposalDigest, proposalPath, readProposal } from '../lib/proposal.js'
11import { checkArgv, copy, draftingRequest, helpText, languageOf, nextForTask, parseCommand, suggestionWhileWaiting } from '../lib/firstrun.js'
12import { initialState, reduce } from '../lib/reducer.js'
13import { CAPS, sanitize } from '../lib/sanitize.js'
14import { applyRetention, createWriter, keys, latestCheckpoint, taskScope } from '../lib/store.js'
15import { combine, surfaceVerified } from '../lib/verdict.js'
16import { bandText, checkKey, checkProgress, contextReading, eventsText, gateReading, policyText, statusText, usageReading } from '../lib/view.js'
17
18const DECISIONS_CAP = 50
19const BUILT_IN_RULES = new Set(HARD_FORBIDDEN.map((h) => h.rule))
20// Evidence freshness must show a later edit within 30 s (requirements Signal 3).
21const TICK_MS = 30 * 1000
22const GATE_STALE_MS = 30 * 1000
23
24// Per-session runtime context, rebuilt from the store after a hot reload. Built once per session
25// through one shared promise: session.start and the first ui.render arrive concurrently in an
26// interactive terminal, and two separate builds left the band reading a context nobody updated
27// (found live on 2.1.289). A hot reload drops the timers and in-memory readings without a new
28// session.start, so the readings and the tick start with the build.
29let ctx = null
30let building = null
31
32function context($) {
33 if (ctx) return Promise.resolve(ctx)
34 if (!building) building = buildContext($).finally(() => { building = null })
35 return building
36}
37
38async function buildContext($) {
39 const sessionId = await $.session.id()
40 const cwd = await $.session.cwd()
41 const writer = createWriter(storeOf($))
42 const saved = await $.store.get(keys.session(sessionId))
43 const now = await $.clock.now()
44 const c = {
45 sessionId,
46 cwd,
47 writer,
48 surface: saved?.surface ?? null,
49 interactive: saved?.interactive ?? false,
50 // The permission mode as of the last classic hook input that carried it (each prompt and each
51 // tool end); unknown until then, and unknown never enables needs-user.
52 permissionMode: null,
53 startedAt: saved?.startedAt ?? now,
54 state: saved?.state ?? initialState(now),
55 decisions: saved?.decisions ?? [],
56 evidence: saved?.evidence ?? {},
57 notices: saved?.notices ?? {},
58 gate: null,
59 usage: null,
60 usageAt: 0,
61 fingerprint: null,
62 checkpoint: null,
63 // The validated proposal waiting for `/agentctl accept`, frozen until accepted or discarded, and
64 // the digest of the file text it came from (an unchanged file is not read twice).
65 pending: saved?.pending ?? null,
66 // The copy language for this session: set by the person's own goal text (§ 3.7, a heuristic).
67 lang: saved?.lang ?? 'en',
68 proposalSeen: saved?.proposalSeen ?? null,
69 }
70 await readGate($, c)
71 await readUsage($, c)
72 $.clock.every(TICK_MS, async () => {
73 // A tick that outlives its session (a /clear started another) does nothing.
74 if (ctx !== c) return
75 const t = await boundTask($, c)
76 await refreshTree($, c)
77 await readGate($, c)
78 await observe($, c, { type: 'tick', at: await $.clock.now() }, t?.id)
79 })
80 ctx = c
81 return c
82}
83
84function storeOf($) {
85 return { get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v), delete: (k) => $.store.delete(k), keys: () => $.store.keys() }
86}
87
88export function worktreeKey(cwd) {
89 return encodeURIComponent(String(cwd ?? ''))
90}
91
92const scopeOf = (c, taskId) => taskScope(worktreeKey(c.cwd), taskId)
93
94async function boundTask($, c) {
95 const taskId = await $.store.get(keys.binding(worktreeKey(c.cwd)))
96 if (!taskId) return null
97 const t = await $.store.get(keys.task(scopeOf(c, taskId)))
98 if (t) return t
99 // A record written before task records were namespaced: used only when it names this worktree.
100 const legacy = await $.store.get(keys.task(taskId))
101 if (legacy && legacy.worktree === c.cwd) return { ...legacy, legacyRecord: true }
102 // A binding whose record is gone is not "no task": refuse everything but reads in this worktree.
103 return { id: String(taskId), goal: '', worktree: c.cwd, allow: [], editRoots: [], forbid: [], executors: [], needsUser: [], tools: [], acceptance: [], recordMissing: true }
104}
105
106function persist(c, taskId) {
107 c.writer.set(keys.session(c.sessionId), {
108 taskId: taskId ? scopeOf(c, taskId) : null,
109 surface: c.surface,
110 interactive: c.interactive,
111 startedAt: c.startedAt,
112 state: c.state,
113 decisions: c.decisions,
114 evidence: c.evidence,
115 notices: c.notices,
116 pending: c.pending,
117 proposalSeen: c.proposalSeen,
118 lang: c.lang,
119 health: c.writer.health.value,
120 })
121}
122
123function recordDecision(c, entry) {
124 c.decisions = [...c.decisions, entry].slice(-DECISIONS_CAP)
125}
126
127function callOf(e) {
128 // tool.call carries the tool's own fields beside `tool`; tool.check carries them under `input`.
129 if (e && e.input && typeof e.input === 'object') return { tool: e.tool, ...e.input }
130 return e
131}
132
133function outcomeOf(r) {
134 if (!r) return 'unknown'
135 if (r.deny) return 'refused'
136 if (r.isError) return 'error'
137 if (r.result && typeof r.result === 'object' && r.result.backgroundTaskId) return 'backgrounded'
138 return 'ok'
139}
140
141// ── Readings ────────────────────────────────────────────────────────────────────────────────────
142async function runGit($, cwd, argv, stdin) {
143 try {
144 return await $.process.run(argv, { cwd, timeoutMs: TIMEOUT_MS, ...(stdin === undefined ? {} : { stdin }) })
145 } catch (err) {
146 return { error: sanitize(err && err.message ? err.message : err, 120) }
147 }
148}
149
150async function readFingerprint($, cwd) {
151 const results = {}
152 results.head = await runGit($, cwd, FP.head)
153 results.index = await runGit($, cwd, FP.index)
154 results.flags = await runGit($, cwd, FP.flags)
155 results.status = await runGit($, cwd, FP.status)
156 const changed = results.status && !results.status.error && results.status.exitCode === 0 ? parseStatus(results.status.stdout).changed : []
157 if (changed.length) results.hash = await runGit($, cwd, FP.hash, changed.slice(0, MAX_HASHED_PATHS).join('\n') + '\n')
158 // Nothing answered: there is no reading at all, so the evidence is unavailable, not partial.
159 if (Object.values(results).every((r) => r && r.error)) return null
160 // When the reading was taken: every view of it says how old it is.
161 return { ...fold(results, changed), at: await $.clock.now() }
162}
163
164// The current tree, re-read only while there is evidence whose freshness depends on it.
165async function refreshTree($, c) {
166 if (Object.keys(c.evidence).length === 0) return
167 c.fingerprint = await readFingerprint($, c.cwd)
168 $.ui.invalidate('ui.render')
169}
170
171// A reading that fails is an `unavailable` field, never a failed hook.
172async function readGate($, c) {
173 const at = await $.clock.now()
174 let script = null
175 try {
176 if (await $.fs.exists('.claude/scripts/review-state.js')) script = '.claude/scripts/review-state.js'
177 else if (await $.fs.exists('scripts/review-state.js')) script = 'scripts/review-state.js'
178 } catch (err) {
179 c.gate = { failed: sanitize('cannot look for review-state.js: ' + (err && err.message ? err.message : err), 80), at }
180 return
181 }
182 if (!script) { c.gate = { failed: 'no review-state.js here', at }; return }
183 try {
184 // `check` only — this mod never writes a verdict (FR-19).
185 const r = await $.process.run(['node', script, 'check', '--format=json'], { cwd: c.cwd, timeoutMs: TIMEOUT_MS })
186 c.gate = { ...gateReading(r, at), staleAfterMs: GATE_STALE_MS }
187 } catch (err) {
188 c.gate = { failed: sanitize('check failed: ' + (err && err.message ? err.message : err), 80), at }
189 }
190}
191
192async function readUsage($, c) {
193 try {
194 c.usage = await $.session.usage()
195 c.usageAt = await $.clock.now()
196 } catch {
197 c.usage = null
198 }
199}
200
201async function model($, c) {
202 const task = await boundTask($, c)
203 const now = await $.clock.now()
204 return {
205 now,
206 task,
207 state: c.state,
208 health: c.writer.health.value,
209 gate: c.gate,
210 context: c.usage ? contextReading(c.usage, c.usageAt) : null,
211 fiveHour: c.usage ? usageReading(c.usage, 'five_hour', c.usageAt) : null,
212 // Evidence belongs to the task (and policy version) it was observed under; another task's checks
213 // are never shown as this one's.
214 evidence: evidenceFor(c.evidence, task),
215 fingerprint: c.fingerprint,
216 pending: c.pending,
217 decisions: c.decisions,
218 sessionId: c.sessionId,
219 checkpoint: c.checkpoint,
220 }
221}
222
223// Partial if either reading is partial: a value equality says nothing about what was not read.
224function worstCoverage(...readings) {
225 const cs = readings.filter(Boolean).map((r) => r.coverage)
226 return cs.includes('partial') ? 'partial' : cs[0]
227}
228
229function evidenceFor(evidence, task) {
230 const out = {}
231 for (const [k, ev] of Object.entries(evidence ?? {})) {
232 if ((ev.taskId ?? null) === (task?.id ?? null) && (ev.policyVersion ?? null) === (task?.policyVersion ?? null)) out[k] = ev
233 }
234 return out
235}
236
237async function observe($, c, observation, taskId) {
238 c.state = reduce(c.state, observation)
239 const now = await $.clock.now()
240 const { toSend, sent } = decideNotices(c.state.interventions, c.notices, now)
241 c.notices = sent
242 for (const n of toSend) $.ui.toast(`agentctl: ${n.reason.replace('-', ' ')} — /agentctl for details`)
243 persist(c, taskId)
244 $.ui.invalidate('ui.render')
245}
246
247// The hand-over starts no process, calls no model and no network (NFR-5): it uses the last tree
248// reading taken by a check, which the hand-over labels with its own HEAD and coverage.
249// `saved` is the store's own answer: the write is awaited, so a stop never cancels before the
250// checkpoint exists and never claims a write that failed.
251async function saveHandoff($, c) {
252 const m = await model($, c)
253 const text = handoff(m)
254 if (!m.task) return { text, saved: false, reason: 'no-task' }
255 const ok = await c.writer.set(keys.checkpoint(scopeOf(c, m.task.id), c.sessionId), { savedAt: m.now, taskId: m.task.id, markdown: text })
256 return { text, saved: ok, reason: ok ? null : 'store-error' }
257}
258
259async function closeBackground($, c, id, status, isError) {
260 const task = await boundTask($, c)
261 await observe($, c, { type: 'background.terminal', id, status, at: await $.clock.now() }, task?.id)
262 for (const ev of Object.values(c.evidence)) {
263 if (ev.backgroundId && ev.backgroundId === id && ev.outcome === 'backgrounded') {
264 ev.outcome = status === 'completed' ? (isError ? 'error' : 'ok') : status === 'failed' ? 'error' : 'cancelled'
265 ev.after = await readFingerprint($, c.cwd)
266 ev.coverage = worstCoverage(ev.before, ev.after)
267 ev.note = 'after-reading taken when the terminal result was observed, not at completion'
268 }
269 }
270 persist(c, task?.id)
271}
272
273// ── Commands ────────────────────────────────────────────────────────────────────────────────────
274async function taskCommand($, c, e, rest) {
275 const [sub, ...more] = rest
276 if (!sub || sub === 'show') {
277 const t = await boundTask($, c)
278 return t ? policyText(t) : 'No task bound to this worktree. Set one with /agentctl task set <json>.'
279 }
280 if (sub !== 'set' && sub !== 'clear') return 'Usage: /agentctl task show | set <json> | clear'
281 // Scope changes come only from the person at the prompt (FR-25).
282 if (e.origin?.kind !== 'composer') return 'agentctl refused: the task can be set or cleared only from your own prompt.'
283 const now = await $.clock.now()
284 if (sub === 'clear') {
285 // Report what persisted (INV-006): a failed delete leaves the old policy in force.
286 if (!(await c.writer.delete(keys.binding(worktreeKey(c.cwd))))) {
287 return 'agentctl: the task could not be cleared (store error); the previous task still applies.'
288 }
289 return 'Task cleared for this worktree; only the built-in classes are refused now.'
290 }
291 let input
292 try { input = JSON.parse(more.join(' ')) } catch { return 'agentctl: the task must be JSON.' }
293 if (!input || typeof input !== 'object' || Array.isArray(input)) return 'agentctl: the task must be a JSON object.'
294 // The worktree is this session's; a task naming another one is refused, never re-pointed.
295 if (input.worktree !== undefined && input.worktree !== c.cwd) return 'agentctl: task rejected —\n- the task names another worktree'
296 const raw = { ...input, worktree: c.cwd }
297 const v = validateTask(raw)
298 if (!v.ok) return `agentctl: task rejected —\n- ${v.errors.map((x) => sanitize(x, 200)).join('\n- ')}`
299 // The policy version only rises: re-setting an id that already has a record continues from it, so
300 // evidence and proposals tied to the earlier policy never read as this one's.
301 const id = raw.id ?? `T${now}`
302 const bound = await boundTask($, c)
303 const prior = bound && bound.id === id && !bound.recordMissing ? bound : await $.store.get(keys.task(scopeOf(c, id)))
304 // Only allowlisted fields are stored; descriptive text is redacted (NFR-9).
305 return bindTask($, c, taskRecord({ ...raw, policyVersion: prior ? (prior.policyVersion ?? 1) : 0 }, { id, now, cwd: c.cwd }))
306}
307
308// The record first, the binding only once the record is saved. Re-setting the bound id is complete
309// once its record is saved — that record IS the policy in force — so no binding write can fail after it.
310async function bindTask($, c, task) {
311 const bindingKey = keys.binding(worktreeKey(c.cwd))
312 const current = await $.store.get(bindingKey)
313 if (!(await c.writer.set(keys.task(scopeOf(c, task.id)), task))) {
314 return 'agentctl: the task could not be saved (store error); the previous task, if any, still applies.'
315 }
316 if (current !== task.id && !(await c.writer.set(bindingKey, task.id))) {
317 return 'agentctl: the task was saved but could not be bound (store error); the previous task, if any, still applies.'
318 }
319 persist(c, task.id)
320 $.ui.invalidate('ui.render')
321 return `Task ${task.id} bound to this worktree.\n${policyText(task)}`
322}
323
324// ── Proposals ───────────────────────────────────────────────────────────────────────────────────
325// Read at session start and at each main turn's end. A failure to read is no proposal, never an
326// error: the file is optional and the mod works without it.
327async function checkProposal($, c) {
328 let text
329 try {
330 const home = await $.env.get('HOME')
331 if (!home) return
332 const path = proposalPath(home, worktreeKey(c.cwd))
333 if (!(await $.fs.exists(path))) return
334 text = await $.fs.read(path)
335 } catch {
336 return
337 }
338 if (typeof text !== 'string') return
339 const seen = digest(text)
340 if (seen === c.proposalSeen) return
341 c.proposalSeen = seen
342 // Found live: the file stays after accept, and a new session read it again as a stale draft.
343 const handled = await $.store.get(keys.proposal(worktreeKey(c.cwd)))
344 if (handled && handled.text === seen) return
345 const bound = await boundTask($, c)
346 // A malformed submission is a rejection, never a failed hook.
347 let r
348 try { r = readProposal(text, { cwd: c.cwd, boundId: bound?.id ?? null }) } catch { r = { ok: false, errors: ['the proposal could not be validated'] } }
349 if (!r.ok) {
350 // No `agentctl:` prefix: the host already names the mod on every transcript line (found live).
351 $.ui.log(`proposal not shown — ${r.errors.join('; ')}`)
352 persist(c, bound?.id)
353 return
354 }
355 c.pending = { effective: r.effective, digest: await proposalDigest(r.effective), baseRev: revisionOf(bound), textDigest: seen, at: await $.clock.now() }
356 for (const line of preview(c)) $.ui.log(line)
357 persist(c, bound?.id)
358 $.ui.invalidate('ui.render')
359 await suggestAccept($, c)
360}
361
362// The preview always ends with a copyable accept line in the session's language.
363function preview(c) {
364 const lines = previewLines(c.pending)
365 lines[lines.length - 1] = ` ${copy(c.lang).acceptLine(c.pending.digest.slice(0, 8))}`
366 return lines
367}
368
369// Offer the accept line as the box's dim suggestion (Tab to take). It shows only when the box is
370// empty and no turn runs; the copyable line in the preview is what always works.
371async function suggestAccept($, c) {
372 if (!c.pending) return
373 try { await $.prompt.suggest({ text: `/agentctl accept ${c.pending.digest.slice(0, 8)}` }) } catch { /* optional */ }
374}
375
376// `/agentctl <what you are doing>`: a request for Claude to draft the scope, put in the person's own
377// prompt box when it is empty (FR-19 allows fill, never submit). Nothing is sent or bound here.
378async function goalCommand($, c, e, goal) {
379 // Only the person's own goal fills their box or sets the session's language; any other origin
380 // gets the same request to copy and changes nothing (§ 3.7 item 2).
381 const composer = e.origin?.kind === 'composer'
382 const lang = composer ? languageOf(goal) : c.lang
383 if (composer) c.lang = lang
384 const t = copy(lang)
385 const bound = await boundTask($, c)
386 const request = draftingRequest({ goal, worktree: c.cwd, base: bound && !bound.recordMissing ? bound.id : null, root: $.plugin.root, lang })
387 let filled = false
388 let mixed = false
389 if (composer) {
390 try {
391 const box = await $.prompt.read()
392 // `append`, never the default `replace`: the read and the fill are two steps, and anything the
393 // person types between them must survive (found in review). Read back to tell them apart.
394 if (box && box.text === '' && (await $.prompt.fill({ text: request, mode: 'append' }))?.isFilled === true) {
395 const after = await $.prompt.read()
396 filled = true
397 mixed = Boolean(after && after.text !== request)
398 }
399 } catch { filled = false }
400 }
401 // A bound task keeps applying until a new scope is accepted; say so instead of "nothing is bound" (found live).
402 const keeps = Boolean(bound && !bound.recordMissing)
403 if (filled && mixed) return t.mixed(keeps)
404 return filled ? t.filled(keeps) : `${t.copy(keeps)}\n\n${request}`
405}
406
407// The bound task's exact revision: a same-id `task set` after the preview makes the proposal stale.
408function revisionOf(task) {
409 return task ? `${task.id}@${task.policyVersion ?? 1}@${task.confirmedAt ?? 0}` : null
410}
411
412// The waiting proposal is done with: forget it here and remember its file text for later sessions.
413async function settleProposal($, c, p) {
414 c.pending = null
415 if (p?.textDigest) await c.writer.set(keys.proposal(worktreeKey(c.cwd)), { text: p.textDigest, at: await $.clock.now() })
416}
417
418async function acceptCommand($, c, e, given) {
419 // Scope comes only from the person at the prompt (FR-25).
420 if (e.origin?.kind !== 'composer') return 'agentctl refused: a proposal can be accepted only from your own prompt.'
421 const p = c.pending
422 if (!p) return c.lang === 'zh' ? '目前沒有等待中的草稿。要起草:/agentctl <你要做的事>' : 'No proposal is waiting. To draft one: /agentctl <what you are doing>'
423 if (!digestMatches(given, p.digest)) return `agentctl refused: ${sanitize(given, 30)} does not match the waiting proposal ${p.digest.slice(0, 8)}. Check /agentctl proposal.`
424 const bound = await boundTask($, c)
425 if ((bound?.id ?? null) !== p.effective.base || revisionOf(bound) !== p.baseRev) {
426 await settleProposal($, c, p)
427 persist(c, bound?.id)
428 return 'agentctl refused: the bound task changed since this proposal was drafted (stale); it was discarded. Ask for a new draft.'
429 }
430 const now = await $.clock.now()
431 const record = acceptedRecord(p.effective, { now, baseTask: bound })
432 const text = await bindTask($, c, record)
433 if (!text.startsWith('Task ')) return text
434 await settleProposal($, c, p)
435 persist(c, `T${now}`)
436 // Say what accepting did and did not do; the full policy is one command away.
437 // Claude reads this reply: name the checks it must run as written to leave evidence (found in an
438 // uncoached run, where `npm test | grep` passed but never counted). Never shortened: a cut command
439 // would not match its check.
440 const checks = checkArgv(record)
441 return [copy(c.lang).accepted, `Task ${record.id}: ${sanitize(record.goal, 120)}`,
442 ...(checks.length ? [copy(c.lang).runChecks(checks.map((a) => sanitize(a, Infinity)))] : []), copy(c.lang).wholeScope].join('\n')
443}
444
445async function stopCommand($, c) {
446 const h = await saveHandoff($, c)
447 const turnId = c.state.runtime.turnId
448 // Say what happened: a checkpoint exists only when a task is bound.
449 const lines = [h.saved ? 'Hand-over saved before stopping.'
450 : h.reason === 'no-task' ? 'No task bound: the hand-over was not saved (see /agentctl handoff).'
451 : 'The hand-over could not be saved (store error); /agentctl handoff still prints it.']
452 if (!turnId) lines.push('No running turn was observed; nothing was cancelled.')
453 else {
454 try {
455 await $.turn.abort({ turnId })
456 lines.push(`Cancellation requested for turn ${turnId}; it reads as ended only once its end is observed.`)
457 } catch (err) {
458 lines.push(`Cancellation request failed; turn outcome unconfirmed (${sanitize(err && err.message ? err.message : err, 120)}).`)
459 }
460 }
461 const open = Object.entries(c.state.ops).filter(([, o]) => o.endedAt === undefined && o.outcome !== 'refused')
462 lines.push(open.length ? 'Tracked operations, as last observed:' : 'No tracked operation was running.')
463 for (const [id, o] of open) lines.push(`- ${sanitize(o.requested, 120)} (${o.tool}) — ${o.outcome}, ${id}`)
464 lines.push('Untracked processes cannot be confirmed; this is never "all stopped".')
465 return lines.join('\n')
466}
467
468export function register(on) {
469 on('session.start', async ($, e, next) => {
470 // A new session (start, /clear, resume) gets a fresh context under its own id; the readings and
471 // the tick restart with it.
472 // Reuse a context already built (or being built, e.g. by the first band render) for this same
473 // session; replace it only when the session changed (/clear, resume) — never discard state that
474 // concurrent hooks are writing into.
475 const sid = await $.session.id()
476 if (building) await building.catch(() => {})
477 if (ctx && ctx.sessionId !== sid) ctx = null
478 const c = await context($)
479 c.surface = e.surface ?? null
480 c.interactive = Boolean(e.isInteractive)
481 const task = await boundTask($, c)
482 persist(c, task?.id)
483 // Retention after this session's own record is written, so it counts among the newest.
484 await c.writer.idle()
485 await applyRetention(storeOf($), c.writer, await $.clock.now())
486 // Resume: show the last hand-over first; nothing is replayed, and the mod holds no approvals.
487 // A checkpoint written before records were namespaced belongs to the legacy record it was saved
488 // for, so it is read only when that verified legacy record is the task — never for a new scoped
489 // task that merely reuses the id.
490 if (task) c.checkpoint = await latestCheckpoint(storeOf($), scopeOf(c, task.id))
491 ?? (task.legacyRecord ? await latestCheckpoint(storeOf($), task.id) : null)
492 if (c.checkpoint) {
493 // Shown before any work: a transcript notice (not sent to the model; `-p` receives it as
494 // ui_log) and the band. Nothing is replayed, and the mod holds no approvals to carry over.
495 $.ui.log(`last hand-over for task ${task.id}, saved ${new Date(c.checkpoint.savedAt).toISOString()} — /agentctl last shows it again`)
496 // The whole checkpoint (already bounded by the hand-over cap), never a silent preview.
497 for (const line of String(c.checkpoint.markdown).split('\n')) $.ui.log(line)
498 }
499 await checkProposal($, c)
500 // `immediate`: /agentctl stop must run while the turn it cancels is still in flight.
501 await $.command.register({ name: 'agentctl', description: 'Agent Control Plane — describe the work and Claude drafts a scope you accept', argumentHint: '<what you are doing> | accept <digest> | help', immediate: true })
502 $.ui.invalidate('ui.render')
503 return next(e)
504 })
505
506 // Only the main loop's turns move the held turn: subagent turns carry an agentId (host types,
507 // turn.complete) and their completion must not clear the main turn a stop would cancel.
508 on('turn.start', async ($, e, next) => {
509 if (e.agentId !== undefined) return next(e)
510 const c = await context($)
511 await observe($, c, { type: 'turn.start', turnId: e.turnId, at: await $.clock.now() }, (await boundTask($, c))?.id)
512 return next(e)
513 })
514
515 on('turn.complete', async ($, e, next) => {
516 const c = await context($)
517 if (e.agentId !== undefined || (c.state.runtime.turnId && e.turnId !== c.state.runtime.turnId)) return next(e)
518 await observe($, c, { type: 'turn.complete', turnId: e.turnId, at: await $.clock.now() }, (await boundTask($, c))?.id)
519 await readGate($, c)
520 await refreshTree($, c)
521 const had = c.pending?.digest
522 await checkProposal($, c)
523 // A proposal still waiting from an earlier turn is offered again at each turn's end (found
524 // live: after an unrelated reply the box was empty, and the accept line was only in scrollback).
525 if (c.pending && c.pending.digest === had) await suggestAccept($, c)
526 return next(e)
527 })
528
529 on('session.measure', async ($, e, next) => {
530 const c = await context($)
531 await readUsage($, c)
532 const w = (c.usage?.rateLimits ?? []).find((r) => r.kind === 'five_hour')
533 if (w) await observe($, c, { type: 'rate-limit', kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt, at: c.usageAt }, (await boundTask($, c))?.id)
534 return next(e)
535 })
536
537 // The permission mode rides on classic hook inputs; read it at every prompt and tool end.
538 on('classic.UserPromptSubmit', async ($, e, next) => {
539 const c = await context($)
540 c.permissionMode = e.permission_mode ?? null
541 return next(e)
542 })
543
544 on('classic.PostToolUse', async ($, e, next) => {
545 const c = await context($)
546 if (e.permission_mode !== undefined) c.permissionMode = e.permission_mode
547 return next(e)
548 })
549
550 on('classic.PermissionRequest', async ($, e, next) => {
551 const c = await context($)
552 if (e.permission_mode !== undefined) c.permissionMode = e.permission_mode
553 await observe($, c, { type: 'permission.request', tool: e.tool_name, at: await $.clock.now() }, (await boundTask($, c))?.id)
554 return next(e)
555 })
556
557 on('classic.Stop', async ($, e, next) => {
558 const c = await context($)
559 const ids = (e.background_tasks ?? []).map((b) => b.id)
560 await observe($, c, { type: 'background.inflight', ids, at: await $.clock.now() }, (await boundTask($, c))?.id)
561 return next(e)
562 })
563
564 // Policy: classification completes before delegation; a refusal never reaches core.
565 on('tool.call', async ($, e, next) => {
566 const c = await context($)
567 const task = await boundTask($, c)
568 const v = classify(task, callOf(e))
569 if (v.outcome === 'deny') {
570 const at = await $.clock.now()
571 const requested = sanitize(e.command ?? e.file_path ?? e.tool, CAPS.requested)
572 const rule = sanitize(v.rule, CAPS.reason)
573 recordDecision(c, { at, tool: e.tool, requested, outcome: v.outcome, rule })
574 await observe($, c, { type: 'refused', id: e.tool_use_id ?? `refused-${at}`, tool: e.tool, requested, rule, at }, task?.id)
575 // A built-in class is not the task's to lift; saying "the task's scope" sent Claude off to
576 // propose widening a task that cannot widen it (found live).
577 const builtIn = BUILT_IN_RULES.has(v.rule)
578 return { deny: builtIn
579 ? `agentctl refused (${rule}): a built-in class no task can lift. Run it yourself, or through your project's own push or deploy workflow.`
580 : `agentctl refused (${rule}). Do not reach the same result another way (a shell redirect, a script); if it is needed, ask the person to accept a wider scope. The task's scope is shown by /agentctl policy.` }
581 }
582 // Delegated: the host's permission flow (or auto mode) decides; the mod only records that it did
583 // not classify the call, so the events list can show it.
584 if (v.delegated) {
585 recordDecision(c, { at: await $.clock.now(), tool: e.tool, requested: sanitize(e.command ?? e.file_path ?? e.tool, CAPS.requested), outcome: 'delegated', rule: sanitize(v.rule, CAPS.reason) })
586 }
587 // Every allowed call is observed; Bash is observed (with its evidence) by the hook beneath.
588 if (e.tool === 'Bash') return next(e)
589 const id = e.tool_use_id ?? `${e.tool}-${await $.clock.now()}`
590 const requested = sanitize(e.file_path ?? e.notebook_path ?? e.tool, CAPS.requested)
591 await observe($, c, { type: 'tool.start', id, tool: e.tool, requested, kind: ['Write', 'Edit', 'NotebookEdit', 'MultiEdit'].includes(e.tool) ? 'edit' : undefined, at: await $.clock.now() }, task?.id)
592 const r = await next(e)
593 const endAt = await $.clock.now()
594 await observe($, c, { type: 'tool.end', id, outcome: outcomeOf(r), at: endAt }, task?.id)
595 await observe($, c, { type: 'permission.settled', at: endAt }, task?.id)
596 return r
597 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'agentctl refused: the policy check failed before the call ran' }))
598
599 // Evidence, beneath the policy hook: brackets Bash calls; its failures are evidence failures,
600 // never refusals (feasibility § 6).
601 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
602 const c = await context($)
603 const task = await boundTask($, c)
604 const v = classify(task, callOf(e))
605 const id = e.tool_use_id ?? `bash-${await $.clock.now()}`
606 const requested = sanitize(e.command, CAPS.requested)
607 const isCheck = Boolean(v.executor && v.executor.check)
608 const startAt = await $.clock.now()
609 let before = null
610 if (isCheck) before = await readFingerprint($, c.cwd)
611 await observe($, c, { type: 'tool.start', id, tool: 'Bash', requested, kind: isCheck ? 'check' : undefined, at: startAt }, task?.id)
612 const r = await next(e)
613 const outcome = outcomeOf(r)
614 const endAt = await $.clock.now()
615 const backgroundId = outcome === 'backgrounded' ? r.result.backgroundTaskId : undefined
616 await observe($, c, { type: 'tool.end', id, outcome, backgroundId, at: endAt }, task?.id)
617 await observe($, c, { type: 'permission.settled', at: endAt }, task?.id)
618 if (isCheck) {
619 const after = outcome === 'backgrounded' ? null : await readFingerprint($, c.cwd)
620 c.fingerprint = after ?? c.fingerprint
621 // An opaque key: executor arguments never become a stored identifier in plaintext.
622 const key = checkKey(v.executor.argv)
623 c.evidence = { ...c.evidence, [key]: { checkKey: key, taskId: task?.id ?? null, policyVersion: task?.policyVersion ?? null, requested, outcome, before, after, coverage: worstCoverage(before, after), at: endAt, backgroundId } }
624 persist(c, task?.id)
625 }
626 return r
627 }).catch(($, e, next) => next(e))
628
629 // A background job's notification is the host's own terminal report: observe it, pass it on.
630 on('prompt.submit', async ($, e, next) => {
631 if (e.origin?.kind === 'task-notification') {
632 const n = parseTaskNotification(e.text)
633 if (n) await closeBackground($, await context($), n.id, n.status, undefined)
634 }
635 return next(e)
636 })
637
638 // A background job closes only on an observed terminal result.
639 on('tool.call', { tool: 'GetTask' }, async ($, e, next) => {
640 const r = await next(e)
641 const c = await context($)
642 const st = r && r.result && typeof r.result === 'object' ? r.result.status : undefined
643 if (st && st !== 'working') await closeBackground($, c, r.result.taskId ?? e.taskId, st, r.result.result?.isError)
644 return r
645 }).catch(($, e, next) => next(e))
646
647 // Verdict: never weaken a downstream deny, never create an allow.
648 on('tool.check', async ($, e, next) => {
649 const c = await context($)
650 const task = await boundTask($, c)
651 const v = classify(task, callOf(e))
652 // Rule text can quote the command; it is sanitized before it becomes a reason anyone reads.
653 const safe = { ...v, rule: sanitize(v.rule, CAPS.reason) }
654 if (v.outcome === 'deny') return combine(safe, null, false)
655 const down = await next(e)
656 const out = combine(safe, down, surfaceVerified(c))
657 if (v.outcome === 'needs-user') {
658 const at = await $.clock.now()
659 recordDecision(c, { at, tool: e.tool, requested: sanitize(e.input?.command ?? e.tool, CAPS.requested), outcome: out.decision === 'ask' ? 'asked' : 'needs-user-refused', rule: safe.rule })
660 persist(c, task?.id)
661 }
662 return out
663 }).catch(() => ({ decision: 'deny', reason: 'agentctl refused: the verdict check failed' }))
664
665 // Text replies for every surface, answered without a model call.
666 on('command.run', { command: 'agentctl' }, async ($, e) => {
667 const c = await context($)
668 // Replies carry no `agentctl:` prefix of their own: the host already adds one (found in a
669 // first-run test, where every reply read "agentctl: agentctl:").
670 const reply = (text) => ({ text: String(text).replace(/^agentctl: /gm, '').replace(/^agentctl refused: /gm, 'Refused: ') })
671 const t = copy(c.lang)
672 const p = parseCommand(e.args)
673 if (p.kind === 'goal') return reply(await goalCommand($, c, e, p.text))
674 if (p.kind === 'typo') return reply(t.typo(p.verb))
675 if (p.kind === 'extra') return reply(t.extra(p.verb))
676 const [sub, ...rest] = p.kind === 'status' ? ['status'] : [p.verb, ...p.rest]
677 if (sub === 'status') {
678 await refreshTree($, c)
679 const m = await model($, c)
680 const bound = m.task && !m.task.recordMissing
681 const next = c.pending ? t.next.proposal(c.pending.digest.slice(0, 8)) : bound ? nextForTask(t, checkProgress(m.task, m.evidence, m.fingerprint)) : t.next.none
682 const extra = c.checkpoint ? [`Last hand-over saved ${new Date(c.checkpoint.savedAt).toISOString()} — /agentctl last`] : []
683 return reply([next, statusText(m, { details: rest[0] === '--details' }), ...extra].join('\n'))
684 }
685 if (sub === 'help') return reply(helpText(c.lang, rest[0] === 'advanced'))
686 if (sub === 'last') {
687 if (c.checkpoint) return reply(`Last hand-over (${new Date(c.checkpoint.savedAt).toISOString()}):\n${c.checkpoint.markdown}`)
688 return reply((await boundTask($, c)) ? t.noHandover : t.noTaskLast)
689 }
690 if (sub === 'proposal') {
691 if (!c.pending) return reply(c.lang === 'zh' ? '目前沒有等待中的草稿。要起草:/agentctl <你要做的事>' : 'No proposal is waiting. To draft one: /agentctl <what you are doing>')
692 await suggestAccept($, c)
693 return reply(preview(c).join('\n'))
694 }
695 if (sub === 'accept') return reply(await acceptCommand($, c, e, rest[0]))
696 if (sub === 'discard') {
697 const had = Boolean(c.pending)
698 await settleProposal($, c, c.pending)
699 persist(c, (await boundTask($, c))?.id)
700 $.ui.invalidate('ui.render')
701 return reply(had ? 'Proposal discarded; the bound task is unchanged.' : 'No proposal was waiting.')
702 }
703 if (sub === 'task') {
704 // A sentence where a subcommand belongs is a goal, not a usage error (found in a first-run test).
705 const [verb, ...more] = rest
706 if (verb && !['show', 'set', 'clear'].includes(verb)) return reply(await goalCommand($, c, e, rest.join(' ')))
707 if (verb === 'set' && more.length && !more.join(' ').trimStart().startsWith('{')) return reply(await goalCommand($, c, e, more.join(' ')))
708 // `show` and `clear` take nothing: a stray word is refused before any state is read or changed.
709 if ((verb === 'show' || verb === 'clear') && more.length) return reply(t.extra(`task ${verb}`))
710 return reply(await taskCommand($, c, e, rest))
711 }
712 if (sub === 'policy') return reply(policyText(await boundTask($, c)))
713 if (sub === 'events') {
714 if (rest.length && !/^\d+$/.test(rest[0])) return reply(t.extra('events'))
715 return reply(eventsText(c.decisions, Math.min(50, Math.max(1, Number(rest[0]) || 10)), await $.clock.now()))
716 }
717 if (sub === 'handoff') return reply((await saveHandoff($, c)).text)
718 if (sub === 'stop') return reply(await stopCommand($, c))
719 return reply(helpText(c.lang, false))
720 })
721
722 // While a proposal waits, the box's dim suggestion is its accept line, not the host's own guess
723 // at the next prompt (found live). The person still takes it with Tab and sends it with Enter.
724 on('prompt.suggest', async ($, e, next) => {
725 const c = await context($)
726 const text = suggestionWhileWaiting(e.origin?.kind, c.pending?.digest)
727 return next(text ? { ...e, text } : e)
728 }).catch(($, e, next) => next(e))
729
730 // The band above the prompt. Other mods' band output is kept beside ours.
731 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
732 const { Box, Text } = $.ui.resolve(e)
733 const theirs = await next(e)
734 const c = await context($)
735 const ours = Text({ dimColor: true, children: [bandText(await model($, c))] })
736 return theirs ? Box({ flexDirection: 'column', children: [theirs, ours] }) : ours
737 })
738}
739lib/fingerprint.js 116 lines1// Git-derived hybrid fingerprint (tech spec § 3.4 Evidence, feasibility § 6). Pure: it builds the
2// argv lists the hooks module runs through `$.process.run`, and folds their output.
3//
4// What it covers: HEAD, the index entries, Git's own list of changed and untracked paths, and the raw
5// (`--no-filters`) bytes of those changed and untracked paths. Clean tracked paths rely on Git's
6// cleanliness check and on its normalization; that is stated in `coverage`, never hidden.
7
8export const TIMEOUT_MS = 5000
9export const MAX_HASHED_PATHS = 500
10
11const GIT = ['git', '--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false']
12
13export const commands = {
14 head: [...GIT, 'rev-parse', '--verify', '-q', 'HEAD'],
15 index: [...GIT, 'ls-files', '-s', '-z'],
16 flags: [...GIT, 'ls-files', '-v', '-z'],
17 status: [...GIT, 'status', '--porcelain=v2', '-z', '--untracked-files=all', '--ignore-submodules=none'],
18 hash: [...GIT, 'hash-object', '--no-filters', '--stdin-paths'],
19}
20
21// FNV-1a over UTF-16 code units, 2×32 bits with different offsets. Equality only — a tamper-proof
22// digest is not the claim (requirements § 2 Non-Goals: not a security boundary).
23export function digest(text) {
24 let a = 0x811c9dc5
25 let b = 0x01000193 ^ 0x5bd1e995
26 for (let i = 0; i < text.length; i++) {
27 const c = text.charCodeAt(i)
28 a = Math.imul(a ^ c, 0x01000193) >>> 0
29 b = Math.imul(b ^ c ^ (i & 0xff), 0x5bd1e995) >>> 0
30 }
31 return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0')
32}
33
34// porcelain=v2 -z: records are NUL-terminated; a rename/copy record ('2 …') is followed by one more
35// NUL-terminated field, its original path.
36export function parseStatus(out) {
37 const recs = String(out ?? '').split('\0')
38 const changed = []
39 const reasons = new Set()
40 for (let i = 0; i < recs.length; i++) {
41 const r = recs[i]
42 if (!r) continue
43 const kind = r[0]
44 if (kind === '#') continue
45 if (kind === '?') { changed.push(r.slice(2)); continue }
46 if (kind === '!') continue
47 if (kind === 'u') { reasons.add('unmerged conflict'); changed.push(r.split(' ').slice(10).join(' ')); continue }
48 if (kind === '1' || kind === '2') {
49 const f = r.split(' ')
50 if (f[2] && f[2][0] === 'S') reasons.add('submodule')
51 const path = f.slice(kind === '1' ? 8 : 9).join(' ')
52 changed.push(path)
53 if (kind === '2') i++ // skip the original path field
54 continue
55 }
56 reasons.add('unparsed status record')
57 }
58 return { changed, reasons: [...reasons] }
59}
60
61// `ls-files -v`: a lowercase tag means assume-unchanged, `S` means skip-worktree. Either lets a
62// content change hide from status, so the reading is partial.
63export function parseFlags(out) {
64 const reasons = new Set()
65 for (const r of String(out ?? '').split('\0')) {
66 if (!r) continue
67 const tag = r[0]
68 if (tag === 'S') reasons.add('skip-worktree entries')
69 else if (tag >= 'a' && tag <= 'z') reasons.add('assume-unchanged entries')
70 }
71 return [...reasons]
72}
73
74// results: { head, index, flags, status, hash } — each { exitCode, stdout } or { error }.
75// changed: the paths hashed, in order (from parseStatus).
76export function fold(results, changed = []) {
77 const reasons = []
78 const part = (name) => {
79 const r = results[name]
80 if (!r) { reasons.push(`${name}: not read`); return '' }
81 if (r.error) { reasons.push(`${name}: ${r.error}`); return '' }
82 if (name !== 'head' && r.exitCode !== 0) { reasons.push(`${name}: exit ${r.exitCode}`); return '' }
83 return r.stdout ?? ''
84 }
85 // `rev-parse --verify -q HEAD` exits 1 with no output on an unborn branch — the one failure that is
86 // a reading. A missing read, a thrown read or any other exit is a failed observation, never "unborn".
87 const h = results.head
88 let head
89 if (!h) { head = 'unknown'; reasons.push('head: not read') }
90 else if (h.error) { head = 'unknown'; reasons.push(`head: ${h.error}`) }
91 else if (h.exitCode === 0) head = String(h.stdout ?? '').trim()
92 else if (h.exitCode === 1 && !String(h.stdout ?? '').trim() && !String(h.stderr ?? '').trim()) head = 'unborn'
93 else { head = 'unknown'; reasons.push(`head: exit ${h.exitCode}`) }
94 const index = part('index')
95 const statusOut = part('status')
96 const flagsOut = part('flags')
97 const hashOut = changed.length ? part('hash') : ''
98 const status = parseStatus(statusOut)
99 reasons.push(...status.reasons, ...parseFlags(flagsOut))
100 if (changed.length > MAX_HASHED_PATHS) reasons.push(`more than ${MAX_HASHED_PATHS} changed paths`)
101 if (changed.length && hashOut.trim().split('\n').filter(Boolean).length !== Math.min(changed.length, MAX_HASHED_PATHS)) {
102 reasons.push('hash-object did not answer for every path')
103 }
104 const value = digest([head, index, statusOut, hashOut].join('\u0001'))
105 return {
106 value,
107 head,
108 changedCount: changed.length,
109 coverage: reasons.length ? 'partial' : 'git-hybrid',
110 reasons: [...new Set(reasons)],
111 }
112}
113
114export const COVERAGE_NOTE =
115 'HEAD, index entries, Git-reported changes, raw bytes of changed and untracked files; clean files rely on Git\'s cleanliness check; ignored files, submodule contents and the environment are not covered'
116lib/handoff.js 90 lines1// Model-free hand-over (requirements FR-15, UC-6; tech spec § 3.4). Pure: built only from records.
2// It answers the eight questions and keeps three sources apart: what the user confirmed (the task),
3// what tools observed (operations, evidence), and what Claude only claimed (never stored as a fact,
4// so the hand-over says plainly that nothing claimed is shown as verified).
5
6import { CAPS, sanitize } from './sanitize.js'
7import { HARD_FORBIDDEN } from './policy.js'
8import { evidenceCurrent } from './view.js'
9
10const s = (x, n = 200) => sanitize(x, n)
11
12// Each section is { heading, items, fixed }: headings and `fixed` lines always appear; `items` fill the
13// remaining budget in order and the rest is counted, so the size cap never drops a heading or the
14// limits disclosure (INV-005) the way truncating the finished text would.
15function assemble(head, sections, cap) {
16 const fixedText = [...head, ...sections.flatMap((x) => ['', x.heading, ...x.fixed])].join('\n')
17 const omitLine = (n) => `- … ${n} more omitted (hand-over size cap)`
18 let budget = cap - fixedText.length - sections.length * omitLine(99999).length
19 const out = [...head]
20 for (const x of sections) {
21 out.push('', x.heading)
22 let shown = 0
23 for (const it of x.items) {
24 if (it.length + 1 > budget) break
25 out.push(it); budget -= it.length + 1; shown++
26 }
27 if (shown < x.items.length) out.push(omitLine(x.items.length - shown))
28 out.push(...x.fixed)
29 }
30 return out.join('\n')
31}
32
33export function handoff({ task, state, evidence, fingerprint, decisions, now, sessionId }) {
34 // When the last tree reading was taken: the hand-over starts no process, so it never re-reads.
35 const seenAt = typeof fingerprint?.at === 'number' ? ` as of ${new Date(fingerprint.at).toISOString()}` : ''
36 const head = [
37 `# Hand-over — ${task ? s(task.goal, 120) : 'no task bound'}`,
38 '',
39 `Generated from structured records at ${new Date(now).toISOString()} (session ${s(sessionId, 60)}); no model was called.`,
40 ]
41 const sec = []
42 sec.push({ heading: '## 1. Goal', items: [], fixed: [task ? s(task.goal, 500) : 'No task was declared for this session.'] })
43 const scope = []
44 if (task) {
45 scope.push(`- Worktree: ${s(task.worktree)}`)
46 scope.push(`- Edits: ${(task.allow ?? []).includes('edit') ? `allowed in ${(task.editRoots ?? ['.']).map((r) => s(r)).join(', ')}` : 'not allowed'}`)
47 scope.push(`- Authorized executors: ${(task.executors ?? []).map((e) => s(e.argv.join(' '))).join('; ') || 'none'}`)
48 scope.push(`- Forbidden: ${(task.forbid ?? []).map((f) => s(Array.isArray(f) ? f.join(' ') : f)).join('; ') || 'none'}, plus ${[...new Set(HARD_FORBIDDEN.map((h) => h.rule))].join(', ')}`)
49 if (task.acceptance?.length) scope.push(`- Acceptance conditions: ${task.acceptance.map((a) => s(a)).join('; ')}`)
50 }
51 sec.push({ heading: '## 2. Allowed and forbidden scope', items: scope, fixed: task ? [] : ['- No scope was declared.'] })
52 const ops = Object.entries(state.ops ?? {})
53 const edits = ops.filter(([, o]) => ['Write', 'Edit', 'NotebookEdit', 'MultiEdit'].includes(o.tool) && o.outcome === 'ok')
54 sec.push({ heading: '## 3. What changed', items: [], fixed: [
55 fingerprint ? `- Tree: HEAD ${s(fingerprint.head, 60)}, ${fingerprint.changedCount ?? '?'} changed or untracked path(s) (${fingerprint.coverage})${seenAt ? `, read${seenAt}` : ''}` : '- Tree: not read for this hand-over',
56 // Only the edit tools are counted here; a file changed through Bash shows in the tree line above
57 // (found in an uncoached run: "Edits observed: 0" beside a changed README read as "nothing changed").
58 `- Edit-tool calls this session: ${edits.length} (changes made through Bash are not counted here; the tree line is the record)`,
59 ] })
60 const ev = Object.values(evidence ?? {})
61 const matches = (e) => fingerprint && e.after && e.before && e.before.value === e.after.value && e.after.value === fingerprint.value && e.outcome === 'ok'
62 // Partial coverage is never "verified": what a reading did not cover may have changed — the
63 // before, the after and the last reading must each be complete. The same rule `/agentctl` uses.
64 const current = (e) => evidenceCurrent(e, fingerprint)
65 const verified = ev.filter(current).map((e) => `- ${s(e.requested)} — no error reported, matches the last tree reading${seenAt} (${e.coverage})`)
66 sec.push({ heading: '## 4. Verified (execution evidence on the last tree reading)', items: verified, fixed: verified.length ? [] : ['- Nothing.'] })
67 const notVerified = ev.filter((e) => !current(e)).map((e) => {
68 const why = e.outcome === 'backgrounded' ? 'started, completion unobserved' : e.outcome !== 'ok' ? `outcome ${e.outcome}` : !e.after || !e.before ? 'evidence unavailable' : e.before.value !== e.after.value ? 'tree changed during the run' : matches(e) ? 'partial coverage — part of the tree was not read' : 'stale — the tree changed since'
69 return `- ${s(e.requested)} — ${why}`
70 })
71 sec.push({ heading: '## 5. Not verified', items: notVerified, fixed: ['- Anything Claude stated as done, passing or fixed without an evidence record above is only its own judgement.'] })
72 const open = ops.filter(([, o]) => o.endedAt === undefined && o.outcome !== 'refused')
73 const bg = Object.entries(state.background ?? {}).filter(([, b]) => b.status === 'started' || b.status === 'in-flight')
74 const running = [
75 ...open.map(([id, o]) => `- ${s(o.requested)} (${o.tool}, ${o.outcome}) — ${id}`),
76 ...bg.map(([id, b]) => `- background ${s(id, 60)} — ${b.status}${b.asOf ? ` as of ${new Date(b.asOf).toISOString()}` : ''}`),
77 ]
78 sec.push({ heading: '## 6. Still running or unknown', items: running, fixed: running.length ? [] : ['- Nothing observed as still running. Untracked processes cannot be confirmed either way.'] })
79 const iv = Object.keys(state.interventions ?? {})
80 sec.push({ heading: '## 7. Next step', items: [], fixed: [iv.length ? `- Resolve: ${iv.map((x) => s(x, 60)).join(', ')}` : '- None recorded. Decide from sections 4–6.'] })
81 const refused = (decisions ?? []).filter((d) => d.outcome === 'deny' || d.outcome === 'unknown' || d.outcome === 'needs-user-refused')
82 sec.push({ heading: '## 8. Still not allowed', items: [], fixed: [
83 ...(refused.length ? refused.slice(-10).map((d) => `- ${s(d.requested)} — ${s(d.rule)}`) : ['- No call was refused this session; the forbidden scope in section 2 still applies.']),
84 '- Earlier approvals do not carry over: on resume, every call is classified again and the host asks again.',
85 // INV-005: what this mod cannot refuse is disclosed here as in /agentctl policy, never silent.
86 '- Limits: if the host skips this mod\'s hook (worker crash, mod disabled) nothing here refused; another mod can change a verdict at tool.check. Hard rules belong in permissions.deny.',
87 ] })
88 return assemble(head, sec, CAPS.handoff)
89}
90lib/notices.js 40 lines1// Local notices (requirements FR-24; tech spec § 3.4 Notices). Pure: given the current interventions
2// and what was already notified, decide what to say now. One notice per blocking reason; repeated
3// only when the reason cleared and came back, or its severity rose; never twice within the cool-down.
4
5export const COOLDOWN_MS = 10 * 60 * 1000
6const RANK = { info: 0, warn: 1, critical: 2 }
7
8// sent: { [reason]: { at, severity, active } }
9export function decideNotices(interventions, sent, now, cooldownMs = COOLDOWN_MS) {
10 const next = {}
11 const toSend = []
12 for (const [reason, prev] of Object.entries(sent ?? {})) next[reason] = { ...prev, active: false }
13 for (const [reason, iv] of Object.entries(interventions ?? {})) {
14 const prev = sent?.[reason]
15 const rose = prev && RANK[iv.severity] > RANK[prev.severity]
16 const returned = prev && prev.active === false
17 const fresh = !prev
18 const cooled = !prev || now - prev.at >= cooldownMs
19 if ((fresh || ((returned || rose) && cooled))) {
20 toSend.push({ reason, severity: iv.severity })
21 next[reason] = { at: now, severity: iv.severity, active: true }
22 } else {
23 next[reason] = { ...prev, active: true, severity: prev.severity }
24 }
25 }
26 return { toSend, sent: next }
27}
28
29// A background task's notification arrives as a prompt whose origin is `task-notification`; its text
30// carries `<task-id>` and `<status>` elements (found live on 2.1.288). Anything else, or a status that
31// is not terminal, is not a completion.
32export function parseTaskNotification(text) {
33 const s = String(text ?? '')
34 const id = /<task-id>([^<]{1,200})<\/task-id>/.exec(s)?.[1]?.trim()
35 const status = /<status>([^<]{1,40})<\/status>/.exec(s)?.[1]?.trim()
36 if (!id || !status) return null
37 if (!['completed', 'failed', 'killed', 'cancelled'].includes(status)) return null
38 return { id, status }
39}
40lib/policy.js 309 lines1// Task-scoped classifier (requirements FR-7, FR-8, FR-25; tech spec § 3.4). Pure: no `$`, no I/O.
2// Outcomes: pass (to the host's own permission path), deny (by rule), needs-user. A deny-list
3// (2026-10-04, user decision, intent INV-004): what the mod recognizes as forbidden is refused; what
4// it cannot classify is delegated to the host — its permission prompt or auto mode — with
5// `delegated: true`. Order matters and deny wins: forbidden classes are matched first.
6// Best-effort by construction: the classifier reads the tool input, never a script's contents or a
7// command's descendants, so a push or production write inside a script is not seen.
8
9import { checkAdapter } from './adapters.js'
10import { sanitize as redactLike } from './sanitize.js'
11
12// Built-in hard classes, always forbidden whatever the task says. Each is a word sequence matched as
13// an in-order subsequence of the command's non-option words, so flags and option values placed
14// between them (`kubectl --context prod rollout restart`, `git -C . push`) do not hide the match.
15// Matching more than the class intends refuses more, which is the safe direction.
16export const HARD_FORBIDDEN = [
17 { rule: 'production-write', words: ['kubectl', 'apply'] },
18 { rule: 'production-write', words: ['kubectl', 'delete'] },
19 { rule: 'production-write', words: ['kubectl', 'patch'] },
20 { rule: 'production-write', words: ['kubectl', 'rollout'] },
21 { rule: 'production-write', words: ['kubectl', 'scale'] },
22 { rule: 'production-write', words: ['kubectl', 'edit'] },
23 { rule: 'production-write', words: ['kubectl', 'replace'] },
24 { rule: 'production-write', words: ['helm', 'install'] },
25 { rule: 'production-write', words: ['helm', 'upgrade'] },
26 { rule: 'production-write', words: ['helm', 'uninstall'] },
27 { rule: 'production-write', words: ['helm', 'rollback'] },
28 { rule: 'production-write', words: ['gcloud', 'deploy'] },
29 { rule: 'production-write', words: ['gcloud', 'delete'] },
30 { rule: 'production-write', words: ['gcloud', 'update'] },
31 { rule: 'remote-git-write', words: ['git', 'push'] },
32 { rule: 'remote-git-write', words: ['gh', 'pr', 'merge'] },
33]
34
35const EDIT_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'MultiEdit'])
36const NO_TASK = Object.freeze({ forbid: [], executors: [], needsUser: [] })
37// Host-internal tools that change nothing outside the session: asking the user, the todo list,
38// reading a background task's status. Refusing them would only break supervision itself.
39// ToolSearch only loads tool schemas; every tool it loads is still classified when it is called.
40export const INTERNAL_TOOLS = new Set(['AskUserQuestion', 'TodoWrite', 'GetTask', 'ToolSearch'])
41
42// ── Tokenizer ───────────────────────────────────────────────────────────────────────────────────
43// Splits a command into pipeline segments of argv words. Anything beyond plain words, single and
44// double quotes and `|` between segments makes the command unclassifiable: substitution, expansion
45// inside double quotes, redirection, `;`, `&&`, `||`, `&`, newlines, backslash escapes, globs that
46// a shell would expand are all refused rather than interpreted.
47export function tokenize(command) {
48 const s = String(command ?? '')
49 const segments = []
50 let argv = []
51 let word = null
52 let i = 0
53 const push = () => { if (word !== null) { argv.push(word); word = null } }
54 while (i < s.length) {
55 const c = s[i]
56 if (c === ' ' || c === '\t') { push(); i++; continue }
57 if (c === '|') {
58 if (s[i + 1] === '|') return { ok: false, why: 'operator ||' }
59 push()
60 if (argv.length === 0) return { ok: false, why: 'empty pipeline segment' }
61 segments.push(argv); argv = []; i++; continue
62 }
63 if (c === "'") {
64 const end = s.indexOf("'", i + 1)
65 if (end < 0) return { ok: false, why: 'unbalanced quote' }
66 word = (word ?? '') + s.slice(i + 1, end); i = end + 1; continue
67 }
68 if (c === '"') {
69 const end = s.indexOf('"', i + 1)
70 if (end < 0) return { ok: false, why: 'unbalanced quote' }
71 const inner = s.slice(i + 1, end)
72 if (/[$`\\!]/.test(inner)) return { ok: false, why: 'expansion inside double quotes' }
73 word = (word ?? '') + inner; i = end + 1; continue
74 }
75 // `~` expands only at the start of a word (`~/x`), so `HEAD~1` is a plain word.
76 if (c === '~' && word === null) return { ok: false, why: 'shell syntax "~"' }
77 if (/[;&<>()`$\\\n\r{}*?[\]!#]/.test(c)) return { ok: false, why: `shell syntax ${JSON.stringify(c)}` }
78 word = (word ?? '') + c; i++
79 }
80 push()
81 if (argv.length === 0) return segments.length ? { ok: false, why: 'empty pipeline segment' } : { ok: false, why: 'empty command' }
82 segments.push(argv)
83 return { ok: true, segments }
84}
85
86// The inverse of `tokenize` for one segment: a command line that reads back as exactly this argv, so a
87// check named in a reply can be copied and still match its executor (found in review: joining with
88// spaces split `tests/a b.test.js` in two). A word plain where it can be, else single- or
89// double-quoted; null when no quoting this tokenizer reads can carry the word.
90export function renderArgv(argv) {
91 const out = []
92 for (const w of argv ?? []) {
93 const x = String(w)
94 if (x && /^[^\s'"|;&<>()`$\\{}*?[\]!#]+$/.test(x) && !x.startsWith('~')) out.push(x)
95 else if (!x.includes("'")) out.push(`'${x}'`)
96 else if (!/[$`\\!"]/.test(x)) out.push(`"${x}"`)
97 else return null
98 }
99 return out.join(' ')
100}
101
102// Leading NAME=value words are inline environment assignments, separated from the argv.
103export function splitEnv(argv) {
104 const env = {}
105 let i = 0
106 while (i < argv.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[i])) {
107 const eq = argv[i].indexOf('=')
108 env[argv[i].slice(0, eq)] = argv[i].slice(eq + 1)
109 i++
110 }
111 return { env, argv: argv.slice(i) }
112}
113
114function words(argv) {
115 return argv.filter((a) => !a.startsWith('-'))
116}
117
118const fold = (x) => String(x).toLowerCase()
119const program = (x) => { const f = fold(x); return f.includes('/') ? f.slice(f.lastIndexOf('/') + 1) : f }
120
121function matchesClass(needle, hay) {
122 if (needle.length === 0) return true
123 let j = 0
124 for (const x of hay) {
125 const hit = j === 0 ? program(x) === program(needle[0]) : fold(x) === fold(needle[j])
126 if (hit && ++j === needle.length) return true
127 }
128 return false
129}
130
131function toWords(cls) {
132 return Array.isArray(cls) ? cls : String(cls).trim().split(/\s+/).filter(Boolean)
133}
134
135export function forbiddenClasses(task) {
136 const custom = (task?.forbid ?? []).map((c) => ({ rule: `task-forbid: ${toWords(c).join(' ')}`, words: toWords(c) }))
137 return [...HARD_FORBIDDEN, ...custom]
138}
139
140function matchForbidden(argvFull, classes) {
141 // Match on every word, including those after an env prefix or a wrapper (`sudo`, `env`), so a
142 // wrapper never hides a forbidden class. Case-folded (a case-insensitive filesystem runs `GIT`),
143 // and a path whose last segment names a class's program is that program (`/usr/bin/git`, found
144 // by an adversarial test): only the program word is reduced, so `git add src/push` stays a path.
145 // Per class, not by rewriting the command: only the class's program word is compared by program
146 // name (so `/usr/bin/git commit` and `git commit` meet, whichever is written where); every later
147 // class word is compared with the command's own words, case-folded, so an argument path such as
148 // `/tmp/git` is never reduced (both found in review).
149 const w = words(argvFull)
150 for (const c of classes) if (matchesClass(c.words, w)) return c
151 return null
152}
153
154function startsWith(argv, prefix) {
155 if (!Array.isArray(prefix) || prefix.length === 0 || prefix.length > argv.length) return false
156 return prefix.every((p, k) => argv[k] === p)
157}
158
159// ── Paths ───────────────────────────────────────────────────────────────────────────────────────
160export function normalizePath(p, base) {
161 const raw = String(p ?? '')
162 if (!raw) return null
163 const abs = raw.startsWith('/') ? raw : `${base ?? ''}/${raw}`
164 const out = []
165 for (const seg of abs.split('/')) {
166 if (seg === '' || seg === '.') continue
167 if (seg === '..') { if (out.length === 0) return null; out.pop(); continue }
168 out.push(seg)
169 }
170 return '/' + out.join('/')
171}
172
173function inside(path, root) {
174 return path === root || path.startsWith(root + '/')
175}
176
177// ── Classification ──────────────────────────────────────────────────────────────────────────────
178// call: { tool, command?, file_path?, notebook_path? } task: the bound task, or null.
179const delegated = (rule) => ({ outcome: 'pass', rule: `delegated to the host: ${rule}`, delegated: true })
180
181export function classify(task, call) {
182 const tool = call?.tool
183 // No task bound: Bash goes through the same classifier with an empty scope, so the built-in
184 // classes refuse, observational adapters pass, and everything else is marked delegated — and is
185 // therefore recorded, task or not (found by an adversarial test). Other tools are not judged.
186 if (!task) {
187 if (tool !== 'Bash') return { outcome: 'pass', rule: 'no task bound' }
188 return classifyBash(NO_TASK, call.command)
189 }
190 // A binding whose record is missing: only the Read tool inside the worktree, whose path is checked,
191 // passes. Bash is refused outright — an observational command's paths are not verified here.
192 if (task.recordMissing) {
193 return tool === 'Read' ? classifyTool(task, call) : { outcome: 'deny', rule: 'task record missing: only reads inside the worktree' }
194 }
195 if (tool === 'Bash') return classifyBash(task, call.command)
196 return classifyTool(task, call)
197}
198
199function classifyBash(task, command) {
200 const t = tokenize(command)
201 if (!t.ok) return delegated(`unclassifiable: ${t.why}`)
202 const classes = forbiddenClasses(task)
203 for (const seg of t.segments) {
204 const hit = matchForbidden(seg, classes)
205 if (hit) return { outcome: 'deny', rule: hit.rule }
206 }
207 if (t.segments.length === 1) {
208 const { env, argv } = splitEnv(t.segments[0])
209 if (Object.keys(env).length === 0) {
210 for (const n of task.needsUser ?? []) if (startsWith(argv, n)) return { outcome: 'needs-user', rule: `needs-user: ${n.join(' ')}` }
211 }
212 }
213 // Executors before adapters: a declared check that is also observational (`git status`) must still
214 // reach the evidence bracket.
215 if (t.segments.length === 1) {
216 const { env, argv } = splitEnv(t.segments[0])
217 if (Object.keys(env).length === 0) {
218 for (const e of task.executors ?? []) if (startsWith(argv, e.argv)) return { outcome: 'pass', rule: `authorized executor: ${e.argv.join(' ')}`, executor: e }
219 }
220 }
221 let allAdapters = true
222 for (const seg of t.segments) {
223 const { env, argv } = splitEnv(seg)
224 if (!checkAdapter(argv, env).ok) { allAdapters = false; break }
225 }
226 if (allAdapters) return { outcome: 'pass', rule: 'observational adapter' }
227 const first = splitEnv(t.segments[0]).argv
228 const why = checkAdapter(first, splitEnv(t.segments[0]).env).why
229 return delegated(`unclassified: ${why}`)
230}
231
232function classifyTool(task, call) {
233 const tool = call?.tool
234 const worktree = normalizePath(task.worktree, '/')
235 if (tool === 'Read') {
236 const p = normalizePath(call.file_path, worktree)
237 return p && worktree && inside(p, worktree) ? { outcome: 'pass', rule: 'read inside the worktree' } : { outcome: 'deny', rule: 'read outside the worktree' }
238 }
239 if (EDIT_TOOLS.has(tool)) {
240 if (!(task.allow ?? []).includes('edit')) return { outcome: 'deny', rule: 'edits not allowed by this task' }
241 const p = normalizePath(call.file_path ?? call.notebook_path, worktree)
242 // A root outside the worktree grants nothing, even on a record stored before validation refused it.
243 const roots = (task.editRoots ?? ['.']).map((r) => normalizePath(r, worktree)).filter((r) => r && worktree && inside(r, worktree))
244 return p && roots.some((r) => inside(p, r)) ? { outcome: 'pass', rule: 'edit inside an allowed root' } : { outcome: 'deny', rule: 'edit outside the allowed roots' }
245 }
246 if (INTERNAL_TOOLS.has(tool)) return { outcome: 'pass', rule: `host-internal tool: ${tool}` }
247 if ((task.tools ?? []).includes(tool)) return { outcome: 'pass', rule: `tool named by the task: ${tool}` }
248 return delegated(`tool not classified: ${tool}`)
249}
250
251// ── Task validation (at `/agentctl task set`) ──────────────────────────────────────────────────────
252export function validateTask(task) {
253 const errors = []
254 if (!task || typeof task !== 'object') return { ok: false, errors: ['task must be an object'] }
255 // A task id becomes a store key and is shown everywhere: a bounded identifier, never free text.
256 if (task.id !== undefined && (typeof task.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(task.id) || redactLike(task.id) !== task.id)) {
257 errors.push('id must be 1-64 letters, digits, dot, underscore or hyphen, and carry no credential')
258 }
259 if (!task.goal || typeof task.goal !== 'string') errors.push('goal is required')
260 if (!task.worktree || typeof task.worktree !== 'string' || !task.worktree.startsWith('/')) errors.push('worktree must be an absolute path')
261 for (const k of ['executors', 'needsUser', 'forbid', 'allow', 'editRoots', 'tools']) {
262 if (task[k] !== undefined && !Array.isArray(task[k])) errors.push(`${k} must be an array`)
263 }
264 if (errors.length) return { ok: false, errors }
265 for (const e of task.executors ?? []) if (!e || typeof e !== 'object' || !Array.isArray(e.argv) || e.argv.length === 0 || !e.argv.every((x) => typeof x === 'string')) errors.push('each executor needs a non-empty argv array')
266 for (const n of task.needsUser ?? []) if (!Array.isArray(n) || n.length === 0) errors.push('each needsUser entry must be a non-empty argv array')
267 // A credential in a policy value would be stored and shown verbatim; such a task is refused.
268 const secretLike = (argv) => argv.join(' ') !== redactLike(argv.join(' '))
269 for (const e of task.executors ?? []) if (Array.isArray(e?.argv) && secretLike(e.argv)) errors.push('an executor carries a credential-like value; keep secrets out of the task')
270 for (const n of task.needsUser ?? []) if (Array.isArray(n) && secretLike(n)) errors.push('a needsUser entry carries a credential-like value; keep secrets out of the task')
271 for (const f of task.forbid ?? []) if (secretLike(toWords(f))) errors.push('a forbid entry carries a credential-like value')
272 for (const k of ['allow', 'editRoots', 'tools']) for (const x of task[k] ?? []) if (secretLike([String(x)])) errors.push(`a ${k} entry carries a credential-like value`)
273 // Edit roots stay inside the worktree: `../other` or an absolute path elsewhere would let the task
274 // authorize writes the worktree does not own (found by an adversarial test).
275 const wt = typeof task.worktree === 'string' ? normalizePath(task.worktree, '/') : null
276 for (const r of task.editRoots ?? []) {
277 const n = wt ? normalizePath(String(r), wt) : null
278 if (!n || !inside(n, wt)) errors.push(`edit root ${String(r).slice(0, 80)} is outside the worktree`)
279 }
280 if (typeof task.worktree === 'string' && secretLike([task.worktree])) errors.push('the worktree carries a credential-like value')
281 const classes = forbiddenClasses(task)
282 const overlap = (argv, label) => { const hit = matchForbidden(argv, classes); if (hit) errors.push(`${label} ${argv.join(' ')} overlaps forbidden class (${hit.rule})`) }
283 for (const e of task.executors ?? []) if (Array.isArray(e?.argv)) overlap(e.argv, 'executor')
284 for (const n of task.needsUser ?? []) if (Array.isArray(n)) overlap(n, 'needsUser')
285 return { ok: errors.length === 0, errors }
286}
287
288// The record that is stored for a task: an allowlist of fields. Descriptive text (goal, acceptance)
289// is redacted; policy values must be stored exactly as they are matched, so a credential-like policy
290// value is refused by validateTask instead of being silently altered (NFR-9, FR-25).
291export function taskRecord(input, { id, now, cwd }) {
292 const strArr = (xs) => (Array.isArray(xs) ? xs.map((x) => String(x)) : undefined)
293 return {
294 id: String(input.id ?? id),
295 goal: redactLike(input.goal ?? '', 500),
296 worktree: String(input.worktree ?? cwd),
297 allow: strArr(input.allow) ?? [],
298 editRoots: strArr(input.editRoots) ?? ['.'],
299 forbid: Array.isArray(input.forbid) ? input.forbid.map((f) => (Array.isArray(f) ? f.map(String) : String(f))) : [],
300 executors: Array.isArray(input.executors) ? input.executors.map((e) => ({ argv: strArr(e?.argv) ?? [], check: e?.check === true })) : [],
301 needsUser: Array.isArray(input.needsUser) ? input.needsUser.map((n) => strArr(n) ?? []) : [],
302 tools: strArr(input.tools) ?? [],
303 acceptance: (strArr(input.acceptance) ?? []).map((a) => redactLike(a, 300)),
304 policyVersion: (Number(input.policyVersion) || 0) + 1,
305 confirmedAt: now,
306 result: 'in-progress',
307 }
308}
309lib/proposal.js 84 lines1// Task proposals (2026-10-04 brainstorm equilibrium): Claude drafts a scope into a file outside the
2// worktree; the mod reads it once, validates it and keeps the effective object; only the person at
3// the prompt binds it, with `/agentctl accept`. The file is an untrusted submission channel — what is
4// accepted is the retained object the preview showed, never the file re-read at accept time.
5// Pure apart from `crypto.subtle`: no `$`, no I/O.
6
7import { taskRecord, validateTask } from './policy.js'
8import { sanitize } from './sanitize.js'
9
10export const MAX_PROPOSAL_BYTES = 16 * 1024
11export const DIGEST_HEX = 24
12
13// The proposal file for a worktree, under the user's home and never inside the worktree (a file there
14// would change the tree fingerprint it is meant to be judged by).
15export function proposalPath(home, worktreeKey) {
16 return `${String(home).replace(/\/+$/, '')}/.claude/agentctl/proposals/${worktreeKey}.json`
17}
18
19// Parse and validate a proposal against this session's worktree and the task bound right now.
20// `base` in the file is the task id Claude saw bound (null for none); a mismatch is stale. Omitted, it
21// is the task bound when the mod reads the file — the preview names it, and accept still refuses if
22// the binding changed after the preview.
23export function readProposal(text, { cwd, boundId }) {
24 if (typeof text !== 'string' || text.length === 0) return { ok: false, errors: ['the proposal file is empty'] }
25 // Bytes, not UTF-16 code units: a non-ASCII draft is up to three times longer on disk.
26 if (new TextEncoder().encode(text).length > MAX_PROPOSAL_BYTES) return { ok: false, errors: [`the proposal is over ${MAX_PROPOSAL_BYTES} bytes`] }
27 let input
28 try { input = JSON.parse(text) } catch { return { ok: false, errors: ['the proposal is not JSON'] } }
29 if (!input || typeof input !== 'object' || Array.isArray(input)) return { ok: false, errors: ['the proposal must be a JSON object'] }
30 // The worktree comes from the session; a proposal naming another one is refused, never re-pointed.
31 if (input.worktree !== undefined && input.worktree !== cwd) return { ok: false, errors: ['the proposal names another worktree'] }
32 const base = input.base === undefined ? (boundId ?? null) : input.base
33 if (base !== null && typeof base !== 'string') return { ok: false, errors: ['base must be the bound task id or null'] }
34 if (base !== (boundId ?? null)) return { ok: false, stale: true, errors: [`stale: drafted against ${base ? `task ${sanitize(base, 64)}` : 'no task'}, but ${boundId ? `task ${sanitize(boundId, 64)}` : 'no task'} is bound now`] }
35 const { base: _b, id: _i, ...draft } = input
36 const v = validateTask({ ...draft, worktree: cwd })
37 if (!v.ok) return { ok: false, errors: v.errors.map((x) => sanitize(x, 200)) }
38 // The effective object: exactly the fields a bound task is matched by, normalized as stored.
39 const r = taskRecord({ ...draft, worktree: cwd }, { id: 'pending', now: 0, cwd })
40 const effective = {
41 goal: r.goal, worktree: r.worktree, allow: r.allow, editRoots: r.editRoots, forbid: r.forbid,
42 executors: r.executors, needsUser: r.needsUser, tools: r.tools, acceptance: r.acceptance, base,
43 }
44 return { ok: true, effective }
45}
46
47// SHA-256 over the canonical effective object (fixed key order by construction), first 24 hex.
48export async function proposalDigest(effective) {
49 const bytes = new TextEncoder().encode(JSON.stringify(effective))
50 const buf = await crypto.subtle.digest('SHA-256', bytes)
51 return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, DIGEST_HEX)
52}
53
54// A digest given at accept must be a prefix of the pending one, at least 8 hex long.
55export function digestMatches(given, pending) {
56 if (given === undefined || given === '') return true
57 return /^[0-9a-f]{8,24}$/.test(given) && pending.startsWith(given)
58}
59
60// The task record bound at accept: the retained effective object, a fresh id, the policy version
61// following the task it replaces.
62export function acceptedRecord(effective, { now, baseTask }) {
63 const { base: _b, ...fields } = effective
64 return taskRecord({ ...fields, policyVersion: baseTask ? (baseTask.policyVersion ?? 1) : 0 }, { id: `T${now}`, now, cwd: effective.worktree })
65}
66
67// Every permission being accepted is listed; nothing is silently truncated beyond the per-entry cap.
68export function previewLines(pending) {
69 const e = pending.effective
70 const one = (x) => (Array.isArray(x) ? x.join(' ') : Array.isArray(x?.argv) ? x.argv.join(' ') + (x.check ? ' (check)' : '') : String(x))
71 const fmt = (xs) => (xs?.length ? xs.map((x) => sanitize(one(x), 160)).join('; ') : 'none')
72 return [
73 `Proposed task ${pending.digest} — ${sanitize(e.goal, 120)}`,
74 ` Replaces: ${e.base ? `task ${sanitize(e.base, 64)}` : 'nothing (no task bound)'}`,
75 ` Edits: ${e.allow.includes('edit') ? `allowed in ${fmt(e.editRoots)}` : 'not allowed'}`,
76 ` Checks and executors: ${fmt(e.executors)}`,
77 ` Needs a person: ${fmt(e.needsUser)}`,
78 ` Other tools: ${fmt(e.tools)}`,
79 ` Forbidden (task): ${fmt(e.forbid)} · plus the built-in production-write and remote-git-write classes`,
80 ...(e.acceptance.length ? [` Acceptance: ${e.acceptance.map((a) => sanitize(a, 160)).join('; ')}`] : []),
81 ` Accept this scope: /agentctl accept ${pending.digest.slice(0, 8)} — it binds the scope only; it does not start any work · or /agentctl discard`,
82 ]
83}
84lib/firstrun.js 209 lines1// First run (tech spec § 3.7, 2026-10-07): parsing `/agentctl …`, the drafting request a goal
2// prepares, and the copy a first-time user reads. Pure: no `$`, no I/O.
3//
4// Found by a user's first run of 0.2.2: every path assumed the vocabulary, so a sentence after
5// `/agentctl` got the usage grammar. A goal now prepares a request for Claude in the person's own
6// prompt box; nothing here sends or binds anything.
7
8import { renderArgv } from './policy.js'
9
10// Verbs and how many words may follow each. `task` and `events` check their own arguments.
11export const VERBS = {
12 status: { max: 1, args: ['--details'] },
13 help: { max: 1, args: ['advanced'] },
14 proposal: { max: 0 },
15 accept: { max: 1 },
16 discard: { max: 0 },
17 task: { max: Infinity },
18 policy: { max: 0 },
19 events: { max: 1 },
20 handoff: { max: 0 },
21 last: { max: 0 },
22 stop: { max: 0 },
23}
24
25function distance(a, b) {
26 const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)])
27 for (let j = 1; j <= b.length; j++) d[0][j] = j
28 for (let i = 1; i <= a.length; i++) {
29 for (let j = 1; j <= b.length; j++) {
30 d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
31 }
32 }
33 return d[a.length][b.length]
34}
35
36// What the line after `/agentctl` is: a verb (with its words), a near-miss of a verb, or a goal.
37// Only a line that looks like command syntax — one ASCII word, optionally a digest or a number —
38// can be a near-miss, so "test the login flow" stays a goal while "accepet ab12cd34" is corrected.
39export function parseCommand(raw) {
40 const text = String(raw ?? '').trim()
41 if (!text) return { kind: 'status', words: [] }
42 const words = text.split(/\s+/)
43 const head = words[0].toLowerCase()
44 if (Object.hasOwn(VERBS, head)) {
45 const rest = words.slice(1)
46 const v = VERBS[head]
47 if (rest.length > v.max) return { kind: 'extra', verb: head, rest }
48 if (v.args && rest.length && !v.args.includes(rest[0])) return { kind: 'extra', verb: head, rest }
49 return { kind: 'verb', verb: head, rest }
50 }
51 if (/^[a-z]{3,}(\s+[0-9a-z]{1,24})?$/i.test(text)) {
52 let best = null
53 for (const v of Object.keys(VERBS)) {
54 const d = distance(head, v)
55 if (d <= 2 && (!best || d < best.d)) best = { v, d }
56 }
57 if (best) return { kind: 'typo', verb: best.v, words }
58 }
59 return { kind: 'goal', text }
60}
61
62// A limited English / Traditional Chinese copy heuristic over the goal text — not locale detection.
63export function languageOf(goal) {
64 return /\p{Script=Han}/u.test(String(goal ?? '')) ? 'zh' : 'en'
65}
66
67// A path for a POSIX shell, single-quoted: the only character needing care inside is `'` itself.
68export function shellQuote(s) {
69 return `'${String(s).replace(/'/g, `'\\''`)}'`
70}
71
72// The helper lives in the mod; `$.plugin.root` is the plugin's directory, but tolerate a root that
73// points at `.claude-plugin` itself.
74export function helperPath(root) {
75 const r = String(root ?? '').replace(/\/+$/, '')
76 return `${r.endsWith('/.claude-plugin') ? r.slice(0, -'/.claude-plugin'.length) : r}/bin/propose.mjs`
77}
78
79// The request the person sends to Claude. The goal is quoted verbatim and never truncated; the rest
80// is fixed text kept short, since it becomes one ordinary model turn.
81export function draftingRequest({ goal, worktree, base, root, lang }) {
82 const helper = `node ${shellQuote(helperPath(root))} --worktree ${shellQuote(worktree)} --stdin`
83 const baseText = base ? `"${base}"` : 'null'
84 if (lang === 'zh') {
85 return [
86 `請為這件事起草 agentctl 任務範圍:「${goal}」`,
87 `先跑 ${helper.replace(' --stdin', ' --help')} 看欄位,讀專案後提出最窄範圍(編輯路徑、檢查指令、驗收)。有不清楚的地方就問我並等我回答——回答前不要寫 proposal,也別自己加權限。`,
88 `用 ${helper} 寫入(base 為 ${baseText};heredoc 分隔字加引號且不在 JSON 內),只寫 proposal,然後等我確認。`,
89 ].join('\n')
90 }
91 return [
92 `Draft an agentctl task scope for: "${goal}"`,
93 `First run ${helper.replace(' --stdin', ' --help')} for the fields. Read the project and propose the narrowest scope (edit paths, check commands, acceptance). If something is unclear, ask me and wait for my answer — write no proposal before it, and never invent permissions.`,
94 `Write it with ${helper} (base ${baseText}; a quoted heredoc delimiter that does not occur in the JSON). Write the proposal only, then wait for me to accept.`,
95 ].join('\n')
96}
97
98const COPY = {
99 en: {
100 filled: (bound) => `A request for Claude to draft the scope is in your prompt box — press Enter to send it. ${bound ? 'The current task stays in force until you accept the new scope.' : 'Nothing is bound yet.'}`,
101 mixed: (bound) => `The request was added after text you typed meanwhile; both are in your prompt box. Edit it before you press Enter. ${bound ? 'The current task stays in force until you accept the new scope.' : 'Nothing is bound yet.'}`,
102 copy: (bound) => `Send this to Claude to have it draft the scope (${bound ? 'the current task stays in force until you accept the new one' : 'nothing is bound yet'}):`,
103 typo: (v) => `Did you mean "/agentctl ${v}"? To start a task, describe it: /agentctl <what you are doing>`,
104 extra: (v) => `"/agentctl ${v}" takes no such arguments. ${v === 'proposal' ? 'It shows a waiting draft; to make one, describe the work: /agentctl <what you are doing>' : 'See /agentctl help.'}`,
105 acceptLine: (d) => `Accept this scope: /agentctl accept ${d} — it binds the scope only; it does not start any work.`,
106 accepted: 'Scope bound. Next: ask Claude to start the work.',
107 runChecks: (checks) => `To leave evidence, run each check as written: ${checks.join(' · ')} — a pipe, redirect or wrapper keeps a run from counting, and added arguments may check less than declared.`,
108 wholeScope: '/agentctl policy shows the whole scope.',
109 noTaskLast: 'No task is bound, so there is no hand-over yet. Start one: /agentctl <what you are doing>',
110 noHandover: 'This task has no saved hand-over yet: /agentctl handoff saves one.',
111 next: {
112 none: 'Next: describe the work — /agentctl <what you are doing>. Built-in refusals (direct git push, production writes) apply even now.',
113 proposal: (d) => `Next: review /agentctl proposal, then /agentctl accept ${d}`,
114 task: 'Next: ask Claude to work; /agentctl handoff saves where things stand.',
115 taskThenChecks: (checks) => `Next: ask Claude to work, then to run the checks exactly as written: ${checks.join(' · ')}. Once they are current: /agentctl handoff`,
116 rerun: (checks) => `Next: not current on this tree — ask Claude to run exactly as written: ${checks.join(' · ')}. Then: /agentctl handoff`,
117 checksCurrent: 'Next: every declared check is current on this tree — /agentctl handoff saves the hand-over.',
118 },
119 },
120 zh: {
121 filled: (bound) => `已把請 Claude 起草範圍的請求放進輸入框——按 Enter 送出。${bound ? '接受新範圍之前,目前的任務照舊生效。' : '目前尚未綁定任何範圍。'}`,
122 mixed: (bound) => `請求接在你剛才打的字後面,兩者都在輸入框裡;送出前請先整理。${bound ? '接受新範圍之前,目前的任務照舊生效。' : '目前尚未綁定任何範圍。'}`,
123 copy: (bound) => `把這段送給 Claude 讓它起草範圍(${bound ? '接受新範圍之前,目前的任務照舊生效' : '目前尚未綁定任何範圍'}):`,
124 typo: (v) => `你是要用「/agentctl ${v}」嗎?要開始任務,請描述它:/agentctl <你要做的事>`,
125 extra: (v) => `「/agentctl ${v}」不接受這些參數。${v === 'proposal' ? '它只顯示等待中的草稿;要起草,請描述工作:/agentctl <你要做的事>' : '見 /agentctl help。'}`,
126 acceptLine: (d) => `確認此範圍:/agentctl accept ${d} ——只會綁定範圍,不會開始任何工作。`,
127 accepted: '範圍已綁定。下一步:請 Claude 開始這項工作。',
128 runChecks: (checks) => `要留下證據,請照原樣執行每條檢查:${checks.join(' · ')} ——加 pipe、redirect 或外層包裝就不算;多加參數可能只檢查到一部分。`,
129 wholeScope: '完整範圍:/agentctl policy',
130 noTaskLast: '目前沒有綁定任務,所以還沒有交接紀錄。開始一個:/agentctl <你要做的事>',
131 noHandover: '這個任務還沒有存過交接:/agentctl handoff 會存一份。',
132 next: {
133 none: '下一步:描述工作——/agentctl <你要做的事>。內建規則(直接 git push、production 寫入)現在就會擋。',
134 proposal: (d) => `下一步:看 /agentctl proposal,再 /agentctl accept ${d}`,
135 task: '下一步:請 Claude 開始工作;/agentctl handoff 會存下目前進度。',
136 taskThenChecks: (checks) => `下一步:請 Claude 開始工作,完成後逐字執行檢查:${checks.join(' · ')}。檢查都是最新的之後:/agentctl handoff`,
137 rerun: (checks) => `下一步:這些檢查在目前的程式碼上不是最新的——請 Claude 逐字執行:${checks.join(' · ')}。之後:/agentctl handoff`,
138 checksCurrent: '下一步:宣告的檢查在目前的程式碼上都是最新的——/agentctl handoff 會存下交接。',
139 },
140 },
141}
142
143export function copy(lang) {
144 return COPY[lang === 'zh' ? 'zh' : 'en']
145}
146
147// A check as a line Claude can run as written: quoted so it reads back as the declared argv, never
148// shortened. An argv no quoting can carry is shown as its JSON array instead, which says to run it as
149// those words.
150export function checkLine(argv) {
151 return renderArgv(argv) ?? `${JSON.stringify(argv)} (as these words)`
152}
153
154// The task's declared checks, as the command lines Claude must run as written.
155export function checkArgv(task) {
156 return (task?.executors ?? []).filter((e) => e.check).map((e) => checkLine(e.argv))
157}
158
159// The next step for a bound task, from where its declared checks stand (`checkProgress` in view.js).
160// Found in an uncoached run: with the work done, "ask Claude to start" was still the next step.
161export function nextForTask(t, progress) {
162 if (!progress.declared) return t.next.task
163 if (!progress.pending.length) return t.next.checksCurrent
164 return progress.ran ? t.next.rerun(progress.pending) : t.next.taskThenChecks(progress.pending)
165}
166
167export function helpText(lang, advanced) {
168 if (lang === 'zh') {
169 return advanced ? [
170 '進階:',
171 ' /agentctl task show | set <json> | clear — 直接檢視或設定任務',
172 ' /agentctl policy — 目前範圍與限制',
173 ' /agentctl events [n] — 最近的拒絕與交給 host 的呼叫',
174 ' /agentctl handoff · last — 存下交接 · 顯示上次的交接',
175 ' /agentctl stop — 取消目前回合並存下交接',
176 ' /agentctl status --details · discard — 完整狀態 · 丟棄草稿',
177 ].join('\n') : [
178 'agentctl 讓 Claude 在你確認的範圍內工作,並記錄檢查證據。',
179 ' /agentctl <你要做的事> — Claude 起草範圍(不會綁定)',
180 ' /agentctl accept <digest> — 你確認範圍(不會開始工作)',
181 ' /agentctl — 目前狀態與下一步',
182 '更多:/agentctl help advanced',
183 ].join('\n')
184 }
185 return advanced ? [
186 'Advanced:',
187 ' /agentctl task show | set <json> | clear — view or set the task directly',
188 ' /agentctl policy — the scope in force and its limits',
189 ' /agentctl events [n] — recent refusals and calls left to the host',
190 ' /agentctl handoff · last — save a hand-over · show the last one',
191 ' /agentctl stop — cancel the turn and save a hand-over',
192 ' /agentctl status --details · discard — full status · drop a draft',
193 ].join('\n') : [
194 'agentctl keeps Claude inside a scope you confirm and records check evidence.',
195 ' /agentctl <what you are doing> — Claude drafts a scope (binds nothing)',
196 ' /agentctl accept <digest> — you confirm it (starts no work)',
197 ' /agentctl — where things stand and what to do next',
198 'More: /agentctl help advanced',
199 ].join('\n')
200}
201
202// What the box should suggest while a proposal waits (found live on 2.1.292: the host's own guess —
203// "確認" — took the box, so Tab + Enter sent that word to Claude instead of accepting). Only the
204// host's own guess is replaced, and only while a proposal is waiting; a plugin's suggestion, ours
205// included, passes unchanged.
206export function suggestionWhileWaiting(originKind, pendingDigest) {
207 return originKind === 'suggestion' && pendingDigest ? `/agentctl accept ${String(pendingDigest).slice(0, 8)}` : null
208}
209lib/reducer.js 131 lines1// Pure state reducer (requirements FR-4, tech spec § 3.4 State). Four separate dimensions, never one
2// enum, because several facts hold at once: a permission request beside a background job, a tool
3// running while a rate limit is near. Every fact carries the time it was observed.
4
5export const IDLE_STALL_MS = 10 * 60 * 1000
6const OPS_CAP = 200
7
8export function initialState(at = 0) {
9 return {
10 phase: { value: 'unknown', since: at },
11 runtime: { value: 'idle', since: at, turnId: null, tool: null },
12 interventions: {},
13 result: { value: 'in-progress', since: at },
14 ops: {},
15 background: {},
16 lastEventAt: at,
17 }
18}
19
20const PHASE_BY_KIND = { edit: 'editing', check: 'testing', read: 'reviewing', plan: 'planning' }
21
22function setIntervention(s, reason, at, detail, severity = 'warn') {
23 const prev = s.interventions[reason]
24 const rank = { info: 0, warn: 1, critical: 2 }
25 const next = prev
26 ? { ...prev, detail, severity: rank[severity] > rank[prev.severity] ? severity : prev.severity }
27 : { since: at, detail, severity }
28 return { ...s, interventions: { ...s.interventions, [reason]: next } }
29}
30
31function clearIntervention(s, reason) {
32 if (!s.interventions[reason]) return s
33 const { [reason]: _gone, ...rest } = s.interventions
34 return { ...s, interventions: rest }
35}
36
37function capOps(ops) {
38 const ids = Object.keys(ops)
39 if (ids.length <= OPS_CAP) return ops
40 // Drop the oldest resolved operations first; unresolved ones are kept as long as possible.
41 const resolved = ids.filter((id) => ops[id].endedAt !== undefined).sort((a, b) => ops[a].startedAt - ops[b].startedAt)
42 const unresolved = ids.filter((id) => ops[id].endedAt === undefined).sort((a, b) => ops[a].startedAt - ops[b].startedAt)
43 const order = [...resolved, ...unresolved]
44 const drop = new Set(order.slice(0, ids.length - OPS_CAP))
45 return Object.fromEntries(ids.filter((id) => !drop.has(id)).map((id) => [id, ops[id]]))
46}
47
48function anyToolRunning(s) {
49 return Object.values(s.ops).some((o) => o.endedAt === undefined && o.outcome !== 'refused')
50}
51
52export function reduce(state, o) {
53 // A tick is the clock, not activity: it must not move the last-activity time it measures against.
54 let s = o.type === 'tick' ? { ...state } : { ...state, lastEventAt: Math.max(state.lastEventAt, o.at ?? state.lastEventAt) }
55 switch (o.type) {
56 case 'turn.start':
57 s = { ...s, runtime: { value: 'model-active', since: o.at, turnId: o.turnId, tool: null } }
58 // A refusal is attention for the turn it happened in; the next turn means it was seen. Its
59 // record stays in the operations and in /agentctl events.
60 return clearIntervention(clearIntervention(clearIntervention(s, 'input'), 'suspected-stall'), 'policy-denied')
61 case 'turn.complete':
62 s = { ...s, runtime: { value: 'idle', since: o.at, turnId: null, tool: null } }
63 return o.awaitsInput ? setIntervention(s, 'input', o.at, o.detail ?? 'turn ended with a question', 'info') : s
64 case 'tool.start': {
65 const op = { requested: o.requested, tool: o.tool, startedAt: o.at, outcome: 'running' }
66 s = { ...s, ops: capOps({ ...s.ops, [o.id]: op }), runtime: { ...s.runtime, value: 'tool-running', since: o.at, tool: o.tool } }
67 if (o.kind && PHASE_BY_KIND[o.kind] && s.phase.value !== PHASE_BY_KIND[o.kind]) s = { ...s, phase: { value: PHASE_BY_KIND[o.kind], since: o.at } }
68 return clearIntervention(s, 'suspected-stall')
69 }
70 case 'tool.end': {
71 const prev = s.ops[o.id] ?? { requested: o.requested ?? '', tool: o.tool, startedAt: o.at }
72 const op = { ...prev, endedAt: o.outcome === 'backgrounded' ? undefined : o.at, outcome: o.outcome }
73 if (o.backgroundId) op.backgroundId = o.backgroundId
74 s = { ...s, ops: { ...s.ops, [o.id]: op } }
75 if (o.backgroundId) s = { ...s, background: { ...s.background, [o.backgroundId]: { status: 'started', since: o.at, opId: o.id } } }
76 if (!anyToolRunning(s) && s.runtime.value === 'tool-running') s = { ...s, runtime: { ...s.runtime, value: s.runtime.turnId ? 'model-active' : 'idle', since: o.at, tool: null } }
77 return s
78 }
79 case 'refused': {
80 const prev = s.ops[o.id] ?? { requested: o.requested ?? '', tool: o.tool, startedAt: o.at }
81 s = { ...s, ops: capOps({ ...s.ops, [o.id]: { ...prev, endedAt: o.at, outcome: 'refused', rule: o.rule } }) }
82 return setIntervention(s, 'policy-denied', o.at, `${o.rule}`, 'warn')
83 }
84 case 'permission.request':
85 return setIntervention(s, 'permission', o.at, o.tool, 'warn')
86 case 'permission.settled':
87 return clearIntervention(s, 'permission')
88 case 'background.inflight': {
89 // A Stop list is in-flight work only: it updates "in flight as of T", it never closes a record.
90 const bg = { ...s.background }
91 for (const id of o.ids) bg[id] = { ...(bg[id] ?? { since: o.at }), status: bg[id]?.status === 'started' || !bg[id] ? 'in-flight' : bg[id].status, asOf: o.at }
92 return { ...s, background: bg }
93 }
94 case 'background.terminal': {
95 const rec = s.background[o.id] ?? { since: o.at }
96 const bg = { ...s.background, [o.id]: { ...rec, status: o.status, endedAt: o.at } }
97 s = { ...s, background: bg }
98 // Close the operation that started this job with the observed terminal outcome.
99 if (rec.opId && s.ops[rec.opId]) {
100 const outcome = { completed: 'ok', failed: 'error', cancelled: 'cancelled', killed: 'cancelled' }[o.status] ?? o.status
101 s = { ...s, ops: { ...s.ops, [rec.opId]: { ...s.ops[rec.opId], endedAt: o.at, outcome } } }
102 }
103 if (!anyToolRunning(s) && s.runtime.value === 'tool-running') s = { ...s, runtime: { ...s.runtime, value: s.runtime.turnId ? 'model-active' : 'idle', since: o.at, tool: null } }
104 return s
105 }
106 case 'rate-limit':
107 return o.percentUsed >= 100
108 ? setIntervention(s, 'rate-limit', o.at, { kind: o.kind, resetsAt: o.resetsAt ?? null }, 'critical')
109 : clearIntervention(s, 'rate-limit')
110 case 'result':
111 return { ...s, result: { value: o.value, since: o.at } }
112 case 'tick':
113 return evaluateStall(s, o.at, o.idleMs ?? IDLE_STALL_MS)
114 case 'session.end':
115 return { ...s, runtime: { value: 'ended', since: o.at, turnId: null, tool: null } }
116 default:
117 return s
118 }
119}
120
121// Suspected stall needs all three: idle past the threshold, no tool running, nothing in flight in
122// the background. It is always unconfirmed — silence alone is never "stuck".
123export function evaluateStall(s, now, idleMs = IDLE_STALL_MS) {
124 const idle = now - s.lastEventAt
125 const bgInFlight = Object.values(s.background).some((b) => b.status === 'started' || b.status === 'in-flight')
126 if (idle >= idleMs && !anyToolRunning(s) && !bgInFlight && s.runtime.value !== 'ended') {
127 return setIntervention(s, 'suspected-stall', now, { idleMs: idle, basis: 'no turn or tool event', confirmed: false }, 'info')
128 }
129 return clearIntervention(s, 'suspected-stall')
130}
131lib/sanitize.js 51 lines1// Redaction applied before every store write and every reply (requirements NFR-9, tech spec § 3.4).
2// Data minimization comes first: callers persist only allowlisted fields; this module removes what
3// can still hide inside them — a token in an argument, a credential in a URL, a secret assignment.
4
5export const CAPS = { requested: 500, reason: 300, handoff: 16 * 1024 }
6
7const SECRET_KEY = /(token|secret|pass(word|wd)?|api[_-]?key|key|auth|cookie|credential|session)/i
8
9function redactUrl(url) {
10 return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)([^/?#]*@)?([^?#]*)(\?[^#]*)?(#.*)?$/i, (m, scheme, userinfo, rest, query, frag) =>
11 `${scheme}${userinfo ? '<redacted>@' : ''}${rest}${query ? '?<redacted>' : ''}${frag ? '#<redacted>' : ''}`)
12}
13
14export function sanitize(text, cap = CAPS.requested) {
15 if (text === undefined || text === null) return ''
16 let s = redact(String(text), 0)
17 if (s.length > cap) s = s.slice(0, Math.max(0, cap - 1)) + '…'
18 return s
19}
20
21// Quoted segments are redacted from the inside first (`sh -c "tool --token x"`), so a later pattern
22// that consumes a whole quoted value can never carry a secret past the redaction.
23function redact(input, depth) {
24 let s = input
25 if (depth < 3) {
26 s = s.replace(/"([^"]*)"|'([^']*)'/g, (m, dq, sq) => (dq !== undefined ? `"${redact(dq, depth + 1)}"` : `'${redact(sq, depth + 1)}'`))
27 }
28 // URLs: userinfo, query and fragment can each carry a credential.
29 s = s.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s'"`<>]+/gi, (u) => redactUrl(u))
30 // scp-like git remotes with a user part: token@host:path
31 s = s.replace(/(^|[\s'"=])([^\s@'"/:]+)@([a-z0-9.-]+):(?!\/\/)/gi, (m, pre, user, host) => `${pre}<redacted>@${host}:`)
32 // Authorization headers and bearer tokens.
33 s = s.replace(/\b(authorization\s*:\s*)(bearer|basic|token)?\s*[^\s'"]+/gi, (m, h, kind) => `${h}${kind ? kind + ' ' : ''}<redacted>`)
34 s = s.replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer <redacted>')
35 // KEY=value assignments whose key names a secret (env prefixes, inline exports, config flags).
36 s = s.replace(/\b([A-Za-z_][A-Za-z0-9_.-]*)=("[^"]*"|'[^']*'|[^\s'"]+)/g, (m, k) => (SECRET_KEY.test(k) ? `${k}=<redacted>` : m))
37 // Flags whose name says secret, in both `--flag=value` and `--flag value` forms. The same predicate
38 // as assignments, so `--key`, `--private-key`, `--api-key`, `--token`, `--password` all match.
39 s = s.replace(/(^|\s)(--?[A-Za-z][A-Za-z0-9-]*)(=|\s+)("[^"]*"|'[^']*'|[^\s'"-][^\s'"]*)/g,
40 (m, pre, flag, sep, val) => (SECRET_KEY.test(flag.replace(/^-+/, '')) ? `${pre}${flag}${sep}<redacted>` : m))
41 return s
42}
43
44// A path outside the worktree is shown relative to the home directory, never in full.
45export function displayPath(path, { worktree, home } = {}) {
46 const p = String(path ?? '')
47 if (worktree && (p === worktree || p.startsWith(worktree + '/'))) return p.slice(worktree.length + 1) || '.'
48 if (home && (p === home || p.startsWith(home + '/'))) return '~' + p.slice(home.length)
49 return p
50}
51lib/store.js 95 lines1// Store access (tech spec § 3.2). `$.store` is one JSON file shared by every session on the machine
2// and get-then-set is not atomic, so: every key a hook writes carries the session id, and this
3// session's writes go through one promise chain. `task/*` and `binding/*` are written only by a
4// composer command.
5
6export const RETENTION = { maxAgeMs: 7 * 24 * 60 * 60 * 1000, perTask: 5 }
7
8// Task ids are chosen at the prompt and are not unique across worktrees, so every task-keyed record
9// is namespaced by the worktree that set it: two worktrees using one id write different keys.
10// `worktreeKey` is URI-encoded and a task id matches [A-Za-z0-9._-], so neither carries `:` or `/`.
11export function taskScope(worktreeKey, taskId) {
12 return `${worktreeKey}:${taskId}`
13}
14
15export const keys = {
16 task: (taskId) => `task/${taskId}`,
17 binding: (worktreeKey) => `binding/${worktreeKey}`,
18 session: (sessionId) => `session/${sessionId}`,
19 checkpoint: (taskId, sessionId) => `checkpoint/${taskId}/${sessionId}`,
20 // The proposal file text last accepted or discarded in a worktree, as a digest: a later session
21 // does not offer the same file again.
22 proposal: (worktreeKey) => `proposal/${worktreeKey}`,
23}
24
25// One serialized writer per session. A failed write never throws into the hook that asked for it;
26// it is reported through `health`, which the band and `/agentctl` show.
27export function createWriter(store) {
28 let chain = Promise.resolve()
29 const health = { value: 'ok', lastError: null }
30 function enqueue(op) {
31 const run = chain.then(op).then(
32 () => { if (health.value === 'store-error') health.value = 'ok' ; return true },
33 (err) => { health.value = 'store-error'; health.lastError = String(err && err.message ? err.message : err).slice(0, 200); return false },
34 )
35 chain = run
36 return run
37 }
38 return {
39 health,
40 set: (key, value) => enqueue(() => store.set(key, value)),
41 delete: (key) => enqueue(() => store.delete(key)),
42 idle: () => chain,
43 }
44}
45
46// Retention at session start: session records older than maxAge, and all but the newest `perTask`
47// sessions and checkpoints of each task, are deleted. Pure planning, so it is testable.
48export function planRetention(entries, now, { maxAgeMs, perTask } = RETENTION) {
49 const del = new Set()
50 const sessions = entries.filter((e) => e.key.startsWith('session/'))
51 const checkpoints = entries.filter((e) => e.key.startsWith('checkpoint/'))
52 for (const e of sessions) if (now - (e.value?.startedAt ?? 0) > maxAgeMs) del.add(e.key)
53 const byTask = (list, at) => {
54 // A Map keyed by the task id itself: sessions without a task share the `null` group, which no
55 // valid task id can collide with (found live: grouping them by their own id left them uncapped).
56 // A checkpoint's task is the second segment of its key.
57 const groups = new Map()
58 for (const e of list) {
59 const t = e.key.startsWith('checkpoint/') ? e.key.split('/')[1] : (e.value?.taskId ?? null)
60 if (!groups.has(t)) groups.set(t, [])
61 groups.get(t).push(e)
62 }
63 for (const g of groups.values()) {
64 g.sort((a, b) => at(b) - at(a))
65 for (const e of g.slice(perTask)) del.add(e.key)
66 }
67 }
68 byTask(sessions.filter((e) => !del.has(e.key)), (e) => e.value?.startedAt ?? 0)
69 byTask(checkpoints, (e) => e.value?.savedAt ?? 0)
70 return [...del]
71}
72
73export async function applyRetention(store, writer, now) {
74 const all = await store.keys()
75 const entries = []
76 for (const key of all) {
77 if (key.startsWith('session/') || key.startsWith('checkpoint/')) entries.push({ key, value: await store.get(key) })
78 }
79 const doomed = planRetention(entries, now)
80 for (const key of doomed) writer.delete(key)
81 await writer.idle()
82 return doomed
83}
84
85// The newest checkpoint among a task's sessions — what resume shows.
86export async function latestCheckpoint(store, taskId) {
87 let best = null
88 for (const key of await store.keys()) {
89 if (!key.startsWith(`checkpoint/${taskId}/`)) continue
90 const v = await store.get(key)
91 if (v && (!best || (v.savedAt ?? 0) > (best.savedAt ?? 0))) best = v
92 }
93 return best
94}
95lib/verdict.js 30 lines1// The tool.check verdict (tech spec § 3.4, feasibility § 6): never weaken a deny, never create an
2// allow. `allow` leaves this function only as an unchanged downstream `allow` on a pass-through call.
3
4// Where an `ask` is known to reach a person, from recorded dialogs only (T8, 2026-10-04, 2.1.289):
5// the interactive terminal under the default (manual), acceptEdits and auto permission modes showed
6// the host's own Yes/No dialog. dontAsk refused the ask; bypassPermissions, plan, `-p`, Desktop,
7// VS Code and mobile are unverified. Anything not listed — including an unknown mode — is refused.
8export const VERIFIED_ASK_SURFACES = ['terminal']
9export const VERIFIED_ASK_MODES = ['default', 'acceptEdits', 'auto']
10
11export function surfaceVerified(session) {
12 return Boolean(session?.interactive)
13 && VERIFIED_ASK_SURFACES.includes(session?.surface)
14 && VERIFIED_ASK_MODES.includes(session?.permissionMode)
15}
16
17// outcome: the classifier's; downstream: { decision, reason?, rule? } from next(e).
18export function combine(outcome, downstream, verified) {
19 const down = downstream && typeof downstream.decision === 'string' ? downstream : { decision: 'deny', reason: 'agentctl: no downstream verdict' }
20 if (outcome.outcome === 'deny' || outcome.outcome === 'unknown') return { decision: 'deny', reason: `agentctl refused: ${outcome.rule}` }
21 if (down.decision === 'deny') return down
22 if (outcome.outcome === 'pass') return down
23 if (outcome.outcome === 'needs-user') {
24 return verified
25 ? { decision: 'ask', reason: `agentctl: ${outcome.rule} — needs your approval` }
26 : { decision: 'deny', reason: `agentctl refused: ${outcome.rule} needs a person, and this surface has no verified approval dialog` }
27 }
28 return { decision: 'deny', reason: 'agentctl refused: unrecognised classification' }
29}
30lib/view.js 177 lines1// What the band, `/agentctl` and `/agentctl policy|events` say (requirements FR-2, FR-3, FR-17,
2// FR-23; tech spec § 3.3). Pure: every input arrives as data, every line names its source and age,
3// and a field whose source did not answer says so instead of showing a value.
4
5import { checkLine } from './firstrun.js'
6import { digest } from './fingerprint.js'
7import { sanitize } from './sanitize.js'
8
9// The opaque key evidence for a declared check is stored under: arguments never become a stored
10// identifier in plaintext.
11export function checkKey(argv) {
12 return 'check-' + digest(argv.join('\u0000'))
13}
14
15// Evidence counts as current only when the check reported no error, the tree did not change during
16// the run, the run's reading matches the last one, and no reading was partial.
17export function evidenceCurrent(ev, fingerprint) {
18 if (!fingerprint || !ev || ev.outcome !== 'ok' || !ev.before || !ev.after) return false
19 if (ev.before.value !== ev.after.value || ev.after.value !== fingerprint.value) return false
20 return ![ev.coverage, ev.before.coverage, ev.after.coverage, fingerprint.coverage].includes('partial')
21}
22
23// Where the task's declared checks stand on the current tree: how many there are, how many have run
24// at all, and which are not current (never run, failed, or stale).
25export function checkProgress(task, evidence, fingerprint) {
26 const rows = (task?.executors ?? []).filter((e) => e.check).map((e) => {
27 const ev = evidence?.[checkKey(e.argv)]
28 // Never shortened: the line is one Claude runs, and a cut command would not match the check.
29 return { argv: sanitize(checkLine(e.argv), Infinity), ran: Boolean(ev), current: evidenceCurrent(ev, fingerprint) }
30 })
31 return { declared: rows.length, ran: rows.filter((r) => r.ran).length, pending: rows.filter((r) => !r.current).map((r) => r.argv) }
32}
33
34// The four states a field can be in (FR-3).
35export function field(label, reading, now) {
36 if (!reading) return `${label}: missing`
37 if (reading.failed) return `${label}: unavailable (${reading.failed})`
38 const age = Math.max(0, Math.round((now - reading.at) / 1000))
39 const stale = reading.staleAfterMs !== undefined && now - reading.at > reading.staleAfterMs
40 return `${label}: ${reading.value} — ${reading.source}, ${age}s ago${stale ? ' (stale)' : ''}`
41}
42
43// review-state.js check --format=json → a gate reading. A gate pass is a gate pass, never a test pass.
44export function gateReading(run, at) {
45 if (typeof run === 'string') return { failed: run, at }
46 if (!run || run.exitCode !== 0) return { failed: `check exited ${run ? run.exitCode : 'without a result'}`, at }
47 let s
48 try { s = JSON.parse(run.stdout) } catch { return { failed: 'not JSON', at } }
49 const word = (p) => (!p ? '?' : p.passed ? '✅' : p.verdict === 'fail' && p.digest_match ? '⛔' : p.noted && !p.digest_match ? '⏳ stale' : p.owed ? '⏳ owed' : '·')
50 return { value: `code ${word(s.code_review)} · doc ${word(s.doc_review)} · precommit ${word(s.precommit)}`, source: 'review-state.js check', at }
51}
52
53// Usage windows are read by kind; a missing window is missing, never 0%. One account's windows are
54// shown for this session only and never summed with other sessions.
55export function usageReading(usage, kind, at) {
56 if (!usage) return null
57 const w = (usage.rateLimits ?? []).find((r) => r.kind === kind)
58 if (!w) return null
59 return { value: `${Math.round(w.percentUsed)}%${w.resetsAt ? ` (resets ${w.resetsAt})` : ''}`, source: `session usage (${w.source ?? 'host'})`, at }
60}
61
62export function contextReading(usage, at) {
63 const p = usage?.context?.percent
64 return typeof p === 'number' ? { value: `${Math.round(p)}%`, source: 'session usage', at } : null
65}
66
67function evidenceLines(evidence, currentFingerprint, now) {
68 const out = []
69 for (const ev of Object.values(evidence ?? {})) {
70 const fresh = currentFingerprint && ev.after && ev.after.value === currentFingerprint.value && ev.before && ev.before.value === ev.after.value
71 const state = ev.outcome === 'backgrounded' ? 'started, completion unobserved'
72 : ev.outcome === 'refused' ? 'refused'
73 : !ev.after || !ev.before ? 'evidence unavailable'
74 : ev.before.value !== ev.after.value ? 'tree changed during the run — unbound'
75 : fresh && [ev.coverage, ev.before.coverage, ev.after.coverage, currentFingerprint.coverage].includes('partial') ? 'matches, but a reading was partial'
76 : fresh ? 'current'
77 : 'stale (tree changed since)'
78 const age = Math.max(0, Math.round((now - ev.at) / 1000))
79 out.push(` ${sanitize(ev.requested, 120)}: ${ev.outcome === 'ok' ? 'no error reported' : ev.outcome} · ${state} · ${ev.coverage ?? 'coverage unknown'} · ${age}s ago`)
80 }
81 return out
82}
83
84const REASON_LABEL = {
85 input: 'waiting for your input',
86 permission: 'waiting for a permission decision',
87 'policy-denied': 'refused by task policy',
88 'rate-limit': 'rate limit reached',
89 'suspected-stall': 'no activity (unconfirmed stall)',
90}
91
92function interventionLines(state, now) {
93 return Object.entries(state.interventions ?? {}).map(([reason, v]) => {
94 const age = Math.max(0, Math.round((now - v.since) / 1000))
95 const detail = typeof v.detail === 'string' ? sanitize(v.detail, 120) : v.detail?.resetsAt ? `resets ${v.detail.resetsAt}` : ''
96 return ` ${REASON_LABEL[reason] ?? reason}${detail ? ` — ${detail}` : ''} · since ${age}s ago`
97 })
98}
99
100// Compact by default — it reaches Claude — and the diagnostic lines on `/agentctl status --details`.
101// Labels say what they measure (found in a first-run test: "Observation: ok" read as "work passed").
102export function statusText(m, { details = false } = {}) {
103 const { now, task, state } = m
104 const lines = []
105 lines.push(task ? `Task: ${sanitize(task.goal, 120)} (${task.id})` : 'Task: none bound')
106 const iv = interventionLines(state, now)
107 lines.push(iv.length ? 'Needs attention:' : 'Needs attention: nothing observed')
108 lines.push(...iv)
109 lines.push(field('Review gates', m.gate, now))
110 const ev = evidenceLines(m.evidence, m.fingerprint, now)
111 lines.push(ev.length ? 'Execution evidence (not a review verdict):' : 'Execution evidence: no declared check has run')
112 lines.push(...ev)
113 const bg = Object.entries(state.background ?? {}).filter(([, b]) => b.status === 'started' || b.status === 'in-flight')
114 if (bg.length) lines.push(`Background in flight: ${bg.length} (${bg.map(([id]) => id).join(', ')})`)
115 if (!details) {
116 lines.push('More: /agentctl status --details · /agentctl help')
117 return lines.join('\n')
118 }
119 lines.push(`State storage: ${m.health === 'ok' ? 'healthy' : m.health}`)
120 lines.push(`Runtime: ${state.runtime.value}${state.runtime.tool ? ` (${state.runtime.tool})` : ''} since ${Math.max(0, Math.round((now - state.runtime.since) / 1000))}s ago · phase ${state.phase.value} · result ${state.result.value}`)
121 // A usage reading arrives with Claude's first reply in the session; before that it is absent.
122 const usage = (label, r) => (r ? field(label, r, now) : `${label}: missing (read after Claude's first reply)`)
123 lines.push(usage('Context', m.context))
124 lines.push(usage('5h window', m.fiveHour))
125 return lines.join('\n')
126}
127
128export function bandText(m) {
129 const { task, state, now } = m
130 const iv = Object.keys(state.interventions ?? {})
131 const attention = iv.length ? `⚠ ${iv.map((r) => REASON_LABEL[r] ?? r).join(', ')}` : 'no attention needed'
132 const head = task ? `agentctl · ${sanitize(task.goal, 40)}` : 'agentctl · no task'
133 const rt = `${state.runtime.value}${state.runtime.tool ? `:${state.runtime.tool}` : ''} ${Math.max(0, Math.round((now - state.runtime.since) / 1000))}s`
134 let gate = 'gates missing'
135 if (m.gate?.failed) gate = 'gates unavailable'
136 else if (m.gate) {
137 const age = Math.max(0, Math.round((now - m.gate.at) / 1000))
138 const stale = m.gate.staleAfterMs !== undefined && now - m.gate.at > m.gate.staleAfterMs
139 gate = `${m.gate.value} (${age}s${stale ? ', stale' : ''})`
140 }
141 const resume = m.checkpoint ? ` · last hand-over ${new Date(m.checkpoint.savedAt).toISOString()}` : ''
142 const proposal = m.pending ? ` · proposal ${m.pending.digest.slice(0, 8)} waiting — /agentctl accept` : ''
143 return `${head} · ${rt} · ${attention} · ${gate}${proposal}${resume}${m.health === 'ok' ? '' : ` · ${m.health}`}`
144}
145
146export const LIMITS = [
147 'This mod is a control and a display, not an isolation boundary; production read-only is enforced by credentials and the target\'s own permissions.',
148 'If the host skips this mod\'s hook (worker crash, mod disabled) nothing here refuses; keep named dangerous commands in your permissions.deny rules.',
149 'Native permission rules match command text, not programs; another mod can change a verdict at tool.check.',
150 'Authorized executors run with their effects unclassified.',
151]
152
153export function policyText(task) {
154 if (!task) return ['No task bound: the built-in production-write and remote-git-write classes are still refused; everything else goes to the host\'s own permission flow.', ...LIMITS].join('\n')
155 const one = (x) => (Array.isArray(x) ? x.join(' ') : Array.isArray(x?.argv) ? x.argv.join(' ') + (x.check ? ' (check)' : '') : String(x))
156 const fmt = (xs) => (xs?.length ? xs.map((x) => sanitize(one(x), 200)).join('; ') : 'none')
157 return [
158 `Task ${task.id} · policy version ${task.policyVersion ?? 1}`,
159 ...(task.recordMissing ? ['The bound task record is missing: everything but reads inside the worktree is refused. Set the task again.'] : []),
160 `Worktree: ${sanitize(task.worktree, 200)}`,
161 `Edits: ${(task.allow ?? []).includes('edit') ? `allowed in ${fmt(task.editRoots ?? ['.'])}` : 'not allowed'}`,
162 `Forbidden (task): ${fmt(task.forbid)} · plus the built-in production-write and remote-git-write classes`,
163 `Authorized executors: ${fmt(task.executors)}`,
164 `Needs a person: ${fmt(task.needsUser)}`,
165 `Other tools allowed: ${fmt(task.tools)}`,
166 'Everything else goes to the host\'s own permission flow (or auto mode): the mod does not classify it.',
167 'Best-effort: the mod reads each tool call, never a script\'s contents or the commands it starts.',
168 ...LIMITS,
169 ].join('\n')
170}
171
172export function eventsText(decisions, n, now) {
173 const list = (decisions ?? []).slice(-n)
174 if (!list.length) return 'No decisions recorded in this session.'
175 return list.map((d) => `${Math.max(0, Math.round((now - d.at) / 1000))}s ago · ${d.outcome} · ${d.tool} · ${sanitize(d.requested, 160)} · ${sanitize(d.rule, 160)}`).join('\n')
176}
177