@proagentstore/cli 0.4.54 → 0.4.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Every outcome `/browser/complete` can be called with, and what it means locally.
3
+ *
4
+ * Only `cancelled` changed in #636. The rest are the behaviour that was already there, written
5
+ * down: `blocked` stays its own status because the agent stopped and needs the USER (email
6
+ * verification, something it cannot answer truthfully) — needs-attention, not a failure, and
7
+ * the console shows it as an active ticket rather than burying it.
8
+ *
9
+ * `captcha`/`stuck`/`needs_input` reach here only through the workflow's handoff TIMEOUT,
10
+ * which calls this endpoint with `failed` and a "… not resolved in time" detail — so their
11
+ * rows are a backstop for a direct call. They are `failed` because `TaskStatus` has no
12
+ * `escalated`, which is what the cloud's run record calls them; the honest local equivalent
13
+ * would be `needs_human`, but a task in that state is not finished, and every call to this
14
+ * method IS the finish. Left as-is deliberately rather than changed under an unrelated fix.
15
+ */
16
+ export const APPLY_OUTCOME_DISPOSITION = {
17
+ submitted: { status: "completed", event: "task.completed", error: false },
18
+ ready: { status: "completed", event: "task.completed", error: false },
19
+ expired: { status: "completed", event: "task.completed", error: false },
20
+ blocked: { status: "blocked", event: "task.failed", error: true },
21
+ captcha: { status: "failed", event: "task.failed", error: true },
22
+ stuck: { status: "failed", event: "task.failed", error: true },
23
+ needs_input: { status: "failed", event: "task.failed", error: true },
24
+ failed: { status: "failed", event: "task.failed", error: true },
25
+ max_steps: { status: "failed", event: "task.failed", error: true },
26
+ cancelled: { status: "cancelled", event: "task.cancelled", error: false },
27
+ };
28
+ /**
29
+ * The disposition for an outcome off the wire.
30
+ *
31
+ * `outcome` is a string from an HTTP body: a newer cloud can send one this bundled runner has
32
+ * never heard of, since the runner ships inside a CLI the user upgrades on their own schedule.
33
+ * An unknown outcome falls back to `failed` — the same `?? "failed"` the cloud's
34
+ * `browserRunStopReason` uses — because "something ended and I cannot say it went well" is the
35
+ * only honest reading of a word this build does not know.
36
+ */
37
+ export function dispositionForOutcome(outcome) {
38
+ return APPLY_OUTCOME_DISPOSITION[outcome] ?? APPLY_OUTCOME_DISPOSITION.failed;
39
+ }
40
+ /**
41
+ * Write a finished run's outcome onto the task, and name the event to append.
42
+ *
43
+ * Pure apart from the mutation of the task it is handed, so the whole disposition — status,
44
+ * error-or-not, which event — is decided in one tested place instead of inside a method that
45
+ * also detaches CDP sessions and closes pages.
46
+ */
47
+ export function settleTaskOutcome(task, outcome, detail) {
48
+ const disposition = dispositionForOutcome(outcome);
49
+ task.status = disposition.status;
50
+ task.output = { outcome, detail };
51
+ if (disposition.error)
52
+ task.error = detail || outcome;
53
+ task.updatedAt = new Date().toISOString();
54
+ task.completedAt = task.updatedAt;
55
+ return disposition;
56
+ }
57
+ /**
58
+ * The statuses a task cannot be moved out of — what `cancelTask` refuses to overwrite.
59
+ *
60
+ * `blocked` is deliberately NOT here: a blocked task is waiting on the owner, and Stop is the
61
+ * owner answering. `cancelled` IS here, and was the member the old `completed || failed` pair
62
+ * missed (#636) — a second Stop rewrote the timestamps and appended a duplicate
63
+ * `task.cancelled`. `completed` and `failed` stay untouchable in the other direction: a run
64
+ * that genuinely finished must not be relabelled as cancelled, because it was not.
65
+ */
66
+ export const TERMINAL_TASK_STATUSES = ["completed", "failed", "cancelled"];
67
+ /** Has this task already finished, whichever way it finished? */
68
+ export function isTerminalTaskStatus(status) {
69
+ return TERMINAL_TASK_STATUSES.includes(status);
70
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * What environment the Engine's process actually gets.
3
+ *
4
+ * Extracted from `headless.ts` when #679 added the second decision made at this seam. There are
5
+ * now two, they are asked at the same three call sites (both spawns and `authResolved`), and
6
+ * `headless.ts` sat one line under its size pin — so the concern gets a file rather than the pin
7
+ * getting a raise. `engine-auth.ts` READS this env; this builds it.
8
+ *
9
+ * 1. {@link mergeEnv} — the platform's overlay over the machine's, where empty means REMOVE.
10
+ * 2. {@link engineSpawnEnv} — that, plus the `gh` guard ahead of the real binary on `PATH`.
11
+ */
12
+ import { ghGuardEnv } from "./gh-guard.js";
13
+ /**
14
+ * Merge the platform's resolved engine env over the machine's, where an EMPTY value means
15
+ * REMOVE rather than "set to empty".
16
+ *
17
+ * Needed because the machine env is inherited wholesale: a developer with ANTHROPIC_API_KEY in
18
+ * their shell handed it to every engine, and Claude Code prefers an API key over the
19
+ * subscription token — so choosing "subscription" injected CLAUDE_CODE_OAUTH_TOKEN and then
20
+ * silently lost, billing per token anyway. Without a way to express removal the setting could
21
+ * not mean what it said.
22
+ */
23
+ export function mergeEnv(base, overlay) {
24
+ const out = { ...base };
25
+ for (const [k, v] of Object.entries(overlay ?? {})) {
26
+ if (v === "")
27
+ delete out[k];
28
+ else
29
+ out[k] = v;
30
+ }
31
+ return out;
32
+ }
33
+ /**
34
+ * The env a turn is spawned with: the merged env, with the `gh` write guard on `PATH` when the
35
+ * platform named a scope for this session (#679).
36
+ *
37
+ * ONE expression, called everywhere the question is asked — including `authResolved`, whose whole
38
+ * contract is that it reports the env the next turn is spawned with rather than the configured
39
+ * intent. Two expressions here is how that guarantee quietly stops being true.
40
+ *
41
+ * `ghGuardEnv` returns its input untouched whenever the guard cannot be installed, so this is safe
42
+ * in front of every spawn: the failure mode is the machine's own `gh`, never a missing one.
43
+ */
44
+ export function engineSpawnEnv(overlay, ghScope, ghGuardRoot) {
45
+ return ghGuardEnv(mergeEnv(process.env, overlay), ghScope, ghGuardRoot);
46
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Containment for the Engine's `gh` writes (#679, absorbing #676).
3
+ *
4
+ * ── The defect
5
+ *
6
+ * `headless.ts` spawns the Engine with `env: mergeEnv(process.env, this.config.env)` — twice, at
7
+ * the persistent stream-json spawn and again in `runOneShot`. The Engine therefore inherits the
8
+ * machine's ENTIRE environment, and with it whatever `git` and `gh` credentials that machine
9
+ * holds. `coding_diagnostics` has been saying so out loud since #676:
10
+ *
11
+ * "A write naming a repository outside this list halts the run and is recorded. The first such
12
+ * write still LANDS — the engine uses this machine's own git and gh credentials, which are
13
+ * not scoped."
14
+ *
15
+ * Detection after the fact is not containment. This is the containment half.
16
+ *
17
+ * ── What this is, and what it deliberately is NOT
18
+ *
19
+ * It is an **allowlist gate on `gh`**, installed as a shim earlier on the Engine's `PATH` than the
20
+ * real binary. A `gh` invocation whose verb is in the closed write vocabulary AND which names a
21
+ * repository outside this session's registered scope is refused before it runs, with the refused
22
+ * repository in stderr. Everything else — every read, every write to the session's own repo — is
23
+ * `exec`'d straight through to the real `gh`, on the machine's own credentials, unchanged.
24
+ *
25
+ * It is **not a credential broker**, and the sketch's "route a repo-scoped installation token for
26
+ * the allowed case" half is deliberately rejected:
27
+ *
28
+ * - The token that would be routed is a GitHub-App installation token minted in the cloud
29
+ * (`workers/api/src/lib/git-credentials.ts`) and delivered once at session start, for the
30
+ * clone. It lives about an hour; a coding session does not. Swapping the machine's working
31
+ * login for it would turn a containment feature into an outage partway through long runs.
32
+ * - It would BREAK the one property this ticket says must survive: a cross-repo `gh pr view
33
+ * --repo <another org>/<repo>` is a different installation, so an installation token cannot
34
+ * read it. The machine's own login can, and does today.
35
+ * - It buys nothing. The harm is the wrong-repo write LANDING, and refusing to exec stops that
36
+ * strictly earlier than handing over a credential that would have failed.
37
+ *
38
+ * It is also **not a change to the machine's git configuration.** No `insteadOf`, no
39
+ * `credential.helper`, no `GIT_SSH_COMMAND`. The owner drives his own work through a custom
40
+ * `github-personal` SSH host alias, and forcing https to gate an agent would take over
41
+ * configuration he uses personally.
42
+ *
43
+ * ── What remains open, and is stated rather than hidden
44
+ *
45
+ * 1. **`git push` is not gated at all.** Remotes here are SSH (`git@github-personal:…`), and
46
+ * `repo.ts` says why in code: *"Only https carries a credential: git ignores userinfo on an
47
+ * ssh URL"*. There is no token to withhold, so no credential decision can reach it.
48
+ * 2. **A `PATH` shim is bypassable.** `/opt/homebrew/bin/gh` skips it entirely, and the Engine
49
+ * has been observed re-exporting its own environment (`export GH_CONFIG_DIR=…`). This raises
50
+ * the cost of a wrong-repo write; it does not make one impossible.
51
+ * 3. **`gh api graphql` is not classified.** A mutation could travel in the query text, and
52
+ * deciding that would mean pattern-matching free prose — the judgement this codebase refuses
53
+ * to make in a gate, because a false positive that halts a run costs more than the finding.
54
+ * Explicit `gh api --method POST|PATCH|PUT|DELETE` with a `/repos/{owner}/{repo}` path IS
55
+ * classified.
56
+ * 4. **A write that names no repository is not gated.** `gh` then resolves the target from the
57
+ * working directory's remote, which the shim cannot see without running git itself. In a
58
+ * session that is the workdir the platform registered — but the Engine has a shell, so `cd`
59
+ * into another checkout on this machine reaches that repository instead. Refusing every
60
+ * unqualified write was rejected: it is the ordinary shape of `gh pr create` inside the repo
61
+ * the session owns, so it would break the common case to narrow an uncommon one.
62
+ *
63
+ * All three are reported through {@link ghGuardReport} → `coding_diagnostics`, which is where the
64
+ * `enforcement: "acts-observed-halt"` value they replace used to be. A gate that overstates itself
65
+ * is worse than no gate.
66
+ */
67
+ import { createHash } from "node:crypto";
68
+ import { accessSync, constants, existsSync, mkdirSync, writeFileSync } from "node:fs";
69
+ import { homedir } from "node:os";
70
+ import { join } from "node:path";
71
+ /**
72
+ * The closed WRITE vocabulary — `<command> <subcommand>` pairs that change something on GitHub.
73
+ *
74
+ * A denylist rather than an allowlist, which inverts `repo-write.ts`'s shape, and the inversion is
75
+ * the point rather than an oversight: `gh`'s read surface is enormous and open-ended (`gh api`,
76
+ * `gh run`, `gh release download`, `gh search`, third-party extensions), so an allowlist of reads
77
+ * would refuse working commands nobody enumerated and the Engine would be broken in ways that look
78
+ * like the platform, not like a policy. What has to be complete here is the list of verbs that
79
+ * WRITE, and that list is small, stable and reviewable. Adding to it is a code review.
80
+ *
81
+ * `gh gist` is absent on purpose: a gist belongs to no repository, so there is nothing to scope it
82
+ * against and refusing it would be theatre.
83
+ */
84
+ export const GH_WRITE_VERBS = [
85
+ "pr create", "pr merge", "pr close", "pr reopen", "pr edit", "pr comment", "pr review", "pr ready", "pr lock", "pr unlock",
86
+ "issue create", "issue close", "issue reopen", "issue edit", "issue comment", "issue delete", "issue lock", "issue unlock", "issue pin", "issue unpin", "issue transfer",
87
+ "release create", "release delete", "release edit", "release upload",
88
+ "repo create", "repo delete", "repo edit", "repo rename", "repo archive", "repo unarchive", "repo sync", "repo fork", "repo deploy-key",
89
+ "workflow enable", "workflow disable", "workflow run",
90
+ "run cancel", "run rerun", "run delete",
91
+ "secret set", "secret delete",
92
+ "variable set", "variable delete",
93
+ "label create", "label delete", "label edit", "label clone",
94
+ "cache delete",
95
+ "ruleset check",
96
+ ];
97
+ /** The exit status the shim refuses with — distinct from `gh`'s own 1, so a refusal is telling. */
98
+ export const GH_REFUSED_EXIT = 3;
99
+ /** The marker a refusal always carries, so the reason is greppable in a pane full of tool output. */
100
+ export const GH_REFUSED_MARKER = "pags: refused";
101
+ /** Where the generated shims live. Under the runner's existing config root, not a new location. */
102
+ export function ghGuardRoot() {
103
+ return join(homedir(), ".config", "proagentstore", "gh-guard");
104
+ }
105
+ /** `owner/repo`, lower-cased, with any `host/` prefix or `.git` suffix removed. */
106
+ export function normalizeRepo(raw) {
107
+ const parts = raw
108
+ .trim()
109
+ .replace(/^https?:\/\//i, "")
110
+ .replace(/\.git$/i, "")
111
+ .split("/")
112
+ .filter(Boolean);
113
+ return parts.slice(-2).join("/").toLowerCase();
114
+ }
115
+ /**
116
+ * The `gh` the shim delegates to: the first executable named `gh` on `path`, skipping anything
117
+ * under `guardRoot` — otherwise a shim installed on a previous spawn could be resolved as the
118
+ * "real" one and the second generation would exec the first, forever.
119
+ */
120
+ export function findRealGh(path, guardRoot) {
121
+ for (const dir of (path ?? "").split(":")) {
122
+ if (!dir || (guardRoot && dir.startsWith(guardRoot)))
123
+ continue;
124
+ const candidate = join(dir, "gh");
125
+ try {
126
+ accessSync(candidate, constants.X_OK);
127
+ return candidate;
128
+ }
129
+ catch {
130
+ /* not here, or not executable — keep looking */
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+ /**
136
+ * The shim itself, as text.
137
+ *
138
+ * POSIX `sh` and not a Node script, because this runs on every `gh` the Engine invokes and it must
139
+ * not depend on resolving a module out of a bundle whose layout differs between the monorepo
140
+ * (`src/*.ts` under tsx) and the published CLI (`dist/browser-runner/*.js`). The logic is small
141
+ * enough to be read in one screen, and its tests execute THIS text rather than a TypeScript twin
142
+ * of it — so what is verified is the artifact that ships.
143
+ *
144
+ * Every unclassified path ends in `exec`, so a bug here degrades to today's behaviour rather than
145
+ * to a broken `gh`.
146
+ */
147
+ export function renderGhShim(opts) {
148
+ const scope = opts.scope.map(normalizeRepo).filter(Boolean).join(" ");
149
+ // `case` patterns, one per write verb. Generated from the constant so the shim and the
150
+ // reviewable list can never disagree.
151
+ const verbs = GH_WRITE_VERBS.map((v) => ` ${sq(v)}) write=1 ;;`).join("\n");
152
+ return `#!/bin/sh
153
+ # ProAgentStore gh guard (#679) — GENERATED. Source of truth:
154
+ # packages/browser-runner/src/coding/gh-guard.ts. Editing this file widens what the agent may
155
+ # write to; that is a bypass, not a configuration.
156
+ REAL=${sq(opts.realGh)}
157
+ SCOPE=${sq(scope)}
158
+
159
+ cmd=""; sub=""; repo=""; method=""; want=""
160
+ for a in "$@"; do
161
+ if [ "$want" = "repo" ]; then repo="$a"; want=""; continue; fi
162
+ if [ "$want" = "method" ]; then method="$a"; want=""; continue; fi
163
+ case "$a" in
164
+ --repo=*) repo=\${a#--repo=} ;;
165
+ --method=*) method=\${a#--method=} ;;
166
+ -R|--repo) want="repo" ;;
167
+ -X|--method) want="method" ;;
168
+ -*) : ;;
169
+ *) if [ -z "$cmd" ]; then cmd="$a"; elif [ -z "$sub" ]; then sub="$a"; fi ;;
170
+ esac
171
+ done
172
+
173
+ write=0
174
+ case "$cmd $sub" in
175
+ ${verbs}
176
+ esac
177
+ # \`gh api\` is the generic escape hatch: only an explicitly mutating method counts, and the target
178
+ # comes out of the /repos/{owner}/{repo} path. \`gh api graphql\` is NOT classified — see the module.
179
+ if [ "$cmd" = "api" ]; then
180
+ m=$(printf '%s' "$method" | tr '[:lower:]' '[:upper:]')
181
+ case "$m" in POST|PATCH|PUT|DELETE) write=1 ;; esac
182
+ if [ -z "$repo" ]; then
183
+ repo=$(printf '%s' "$sub" | sed -n 's|^/\\{0,1\\}repos/\\([^/]*\\)/\\([^/]*\\).*|\\1/\\2|p')
184
+ fi
185
+ fi
186
+
187
+ if [ "$write" = "1" ] && [ -n "$repo" ]; then
188
+ want_repo=$(printf '%s' "$repo" | sed 's|^https\\{0,1\\}://||; s|\\.git$||' | awk -F/ '{ if (NF>1) print $(NF-1)"/"$NF; else print $0 }' | tr '[:upper:]' '[:lower:]')
189
+ allowed=0
190
+ for r in $SCOPE; do
191
+ if [ "$r" = "$want_repo" ]; then allowed=1; fi
192
+ done
193
+ if [ "$allowed" = "0" ]; then
194
+ echo "${GH_REFUSED_MARKER}: \\\`gh $cmd $sub\\\` writes to $want_repo, which this agent session is not registered for (allowed: $SCOPE)." >&2
195
+ echo "Ask the owner to register that repository with the agent, or do the write in a session that owns it. Reads are not affected." >&2
196
+ exit ${GH_REFUSED_EXIT}
197
+ fi
198
+ fi
199
+
200
+ exec "$REAL" "$@"
201
+ `;
202
+ }
203
+ /** Single-quote for `sh`: the only character that needs care inside `'…'` is `'` itself. */
204
+ function sq(value) {
205
+ return `'${value.replace(/'/g, `'\\''`)}'`;
206
+ }
207
+ /** The gaps, in one place, so the runner and `coding_diagnostics` cannot drift apart. */
208
+ export const GH_GUARD_GAPS = [
209
+ "`git push` is not gated — this machine's remotes are SSH, so there is no credential to withhold.",
210
+ "A PATH shim is bypassable: invoking `/opt/homebrew/bin/gh` by absolute path, or re-exporting PATH, skips it.",
211
+ "`gh api graphql` is not classified — a mutation can travel inside the query text.",
212
+ "A write with no `--repo` is not gated: gh resolves the target from the working directory, so a `cd` into another checkout on this machine reaches it.",
213
+ ];
214
+ /** Report for a session with no guard installed. */
215
+ export function ghGuardReport(scope, reason) {
216
+ return { installed: !reason, scope, ...(reason ? { reason } : {}), gaps: [...GH_GUARD_GAPS] };
217
+ }
218
+ /** Installed shims, keyed by (scope, real gh) — writing the same file per turn would be silly. */
219
+ const installed = new Map();
220
+ /**
221
+ * Install (idempotently) a shim for `scope` and return its directory, or `null` with the reason.
222
+ *
223
+ * Keyed by a hash of what the shim CONTAINS, so a scope change lands in a different directory and
224
+ * a stale shim can never be handed a scope it was not generated for.
225
+ */
226
+ export function installGhGuard(scope, env, root = ghGuardRoot()) {
227
+ const repos = scope.map(normalizeRepo).filter(Boolean);
228
+ // No scope is not "allow nothing" — it is "the platform did not say", and a guard that refused
229
+ // every write on that basis would break every session an older cloud starts.
230
+ if (repos.length === 0)
231
+ return { reason: "no-scope" };
232
+ const realGh = findRealGh(env.PATH, root);
233
+ if (!realGh)
234
+ return { reason: "gh-not-found" };
235
+ const script = renderGhShim({ realGh, scope: repos });
236
+ const key = createHash("sha256").update(`${root}${script}`).digest("hex").slice(0, 16);
237
+ const cached = installed.get(key);
238
+ if (cached)
239
+ return { dir: cached };
240
+ const dir = join(root, key);
241
+ try {
242
+ mkdirSync(dir, { recursive: true });
243
+ const shim = join(dir, "gh");
244
+ if (!existsSync(shim))
245
+ writeFileSync(shim, script, { mode: 0o755 });
246
+ installed.set(key, dir);
247
+ return { dir };
248
+ }
249
+ catch {
250
+ // Fail OPEN and say so. A runner that cannot write to its own config dir must still be able
251
+ // to run the Engine; silently producing a `gh` that does not exist would be far worse.
252
+ return { reason: "install-failed" };
253
+ }
254
+ }
255
+ /**
256
+ * The Engine's spawn env, with the guard ahead of the real `gh` on `PATH`.
257
+ *
258
+ * Returns `env` untouched whenever the guard could not be installed, which is what makes this safe
259
+ * to put in front of every spawn: the failure mode is today's behaviour, never a broken `gh`.
260
+ */
261
+ export function ghGuardEnv(env, scope, root) {
262
+ const out = installGhGuard(scope ?? [], env, root);
263
+ if (!("dir" in out))
264
+ return env;
265
+ return { ...env, PATH: `${out.dir}:${env.PATH ?? ""}` };
266
+ }
267
+ /** What to report for a session started with `scope`, derived from the same call the spawn made. */
268
+ export function ghGuardStatus(scope, env, root) {
269
+ const repos = (scope ?? []).map(normalizeRepo).filter(Boolean);
270
+ const out = installGhGuard(scope ?? [], env, root);
271
+ return "dir" in out ? ghGuardReport(repos) : ghGuardReport(repos, out.reason);
272
+ }
@@ -2,26 +2,10 @@ import { spawn } from "node:child_process";
2
2
  import { classifyCommand, commandFromToolInput, fillTargetFromResult, toolCallOk, toolResultMark } from "./engine-acts.js";
3
3
  import { parseEngineUsage } from "./engine-usage.js";
4
4
  import { turnReportFromExit, turnReportFromResult } from "./engine-turn.js";
5
- /**
6
- * Merge the platform's resolved engine env over the machine's, where an EMPTY value means
7
- * REMOVE rather than "set to empty".
8
- *
9
- * Needed because the machine env is inherited wholesale: a developer with ANTHROPIC_API_KEY in
10
- * their shell handed it to every engine, and Claude Code prefers an API key over the
11
- * subscription token — so choosing "subscription" injected CLAUDE_CODE_OAUTH_TOKEN and then
12
- * silently lost, billing per token anyway. Without a way to express removal the setting could
13
- * not mean what it said.
14
- */
15
- export function mergeEnv(base, overlay) {
16
- const out = { ...base };
17
- for (const [k, v] of Object.entries(overlay ?? {})) {
18
- if (v === "")
19
- delete out[k];
20
- else
21
- out[k] = v;
22
- }
23
- return out;
24
- }
5
+ import { renderToolResult, shortInput, stripAnsi } from "./transcript-lines.js";
6
+ import { authoredTurn, authorTag } from "./turn-author.js";
7
+ import { engineSpawnEnv, mergeEnv } from "./engine-env.js";
8
+ import { ghGuardStatus } from "./gh-guard.js";
25
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
10
  import { dirname, join } from "node:path";
27
11
  import { handlerFor } from "./handlers.js";
@@ -115,6 +99,15 @@ export class HeadlessSession {
115
99
  * damages the record exactly as much as missing a real one.
116
100
  */
117
101
  awaitingResult = new Map();
102
+ /**
103
+ * `tool_use_id` → tool name, for the ONE decision the result event cannot make on its own: how
104
+ * much of it reaches the pane (#700, `toolResultBudget` in `transcript-lines.ts`).
105
+ *
106
+ * Separate from {@link awaitingResult}, which holds only the small minority of calls that
107
+ * classify as consequential acts. Entries are deleted when their result arrives and the whole
108
+ * map is cleared at the turn boundary, where a call that never got one is known never to.
109
+ */
110
+ toolNames = new Map();
118
111
  /** Turn counter — only used to build a fallback id when the CLI's event has no `uuid`. */
119
112
  usageSeq = 0;
120
113
  /**
@@ -163,7 +156,15 @@ export class HeadlessSession {
163
156
  * Presence only: no key or token value leaves this class.
164
157
  */
165
158
  get authResolved() {
166
- return resolveEngineAuth(this.config.clientType, mergeEnv(process.env, this.config.env));
159
+ return resolveEngineAuth(this.config.clientType, this.spawnEnv);
160
+ }
161
+ /** The env every turn is spawned with — one expression, three call sites. See engine-env.ts. */
162
+ get spawnEnv() {
163
+ return engineSpawnEnv(this.config.env, this.config.ghScope, this.config.ghGuardRoot);
164
+ }
165
+ /** What the `gh` guard actually did on this machine (#679) — reported, never assumed. */
166
+ get ghGuard() {
167
+ return ghGuardStatus(this.config.ghScope, mergeEnv(process.env, this.config.env), this.config.ghGuardRoot);
167
168
  }
168
169
  /**
169
170
  * Did this engine launch with a conversation to continue (#408)?
@@ -334,7 +335,7 @@ export class HeadlessSession {
334
335
  const args = this.mode === "stream-json" ? buildClaudeArgs(this.cmdArgs, this.claudeSessionId) : [...this.cmdArgs];
335
336
  const proc = spawn(this.cmdBin, args, {
336
337
  cwd: this.config.workDir,
337
- env: mergeEnv(process.env, this.config.env),
338
+ env: this.spawnEnv,
338
339
  stdio: ["pipe", "pipe", "pipe"],
339
340
  });
340
341
  this.proc = proc;
@@ -376,11 +377,13 @@ export class HeadlessSession {
376
377
  }
377
378
  });
378
379
  }
379
- /** Send a user turn to the agent (it acts on it). */
380
- input(text) {
380
+ /** Send a user turn. `author` names who wrote it, because `role` cannot — see turn-author.ts (#505). */
381
+ input(text, opts = {}) {
382
+ // The Engine reads the preamble, the pane the short marker; the instruction stays evidence.
383
+ const sent = authoredTurn(text, opts.author);
381
384
  if (!this.alive)
382
385
  this.start();
383
- this.push(`\n❯ [${stamp()}] ${text}`); // ❯ — your turn, timestamped
386
+ this.push(`\n❯ [${stamp()}] ${authorTag(opts.author)}${text}`); // ❯ — your turn, timestamped
384
387
  this.run = "thinking";
385
388
  const now = Date.now();
386
389
  this.lastOutputAt = now;
@@ -388,13 +391,15 @@ export class HeadlessSession {
388
391
  this.sawOutputSinceInput = false; // arm the persistent-raw idle heuristic for THIS turn
389
392
  try {
390
393
  if (this.mode === "stream-json") {
391
- const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
394
+ // `role` stays "user" — the only role this protocol accepts, which is why the
395
+ // disambiguation rides in the text instead (#505).
396
+ const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: sent }] } });
392
397
  this.proc?.stdin?.write(`${msg}\n`);
393
398
  }
394
399
  else {
395
400
  // Raw CLI: spawn THIS turn. See `oneShot` — there is no persistent process to
396
401
  // write to, because a non-interactive binary would never have read it.
397
- this.runOneShot(text);
402
+ this.runOneShot(sent);
398
403
  }
399
404
  }
400
405
  catch {
@@ -422,7 +427,7 @@ export class HeadlessSession {
422
427
  this.turnLastLine = "";
423
428
  const proc = spawn(this.cmdBin, [...this.cmdArgs, text], {
424
429
  cwd: this.config.workDir,
425
- env: mergeEnv(process.env, this.config.env),
430
+ env: this.spawnEnv,
426
431
  stdio: ["ignore", "pipe", "pipe"],
427
432
  });
428
433
  // A turn already running is aborted before its replacement starts: `input()` accepts a turn
@@ -597,7 +602,13 @@ export class HeadlessSession {
597
602
  this.push(`[${stamp()}] ${block.text.trim()}`); // timestamped agent reply
598
603
  }
599
604
  else if (block.type === "tool_use") {
600
- this.push(`⚙ ${String(block.name ?? "tool")} ${shortInput(block.input)}`); // ⚙
605
+ const name = String(block.name ?? "tool");
606
+ this.push(`⚙ ${name} ${shortInput(block.input)}`); // ⚙
607
+ // The result arrives in a LATER event carrying only `tool_use_id`, and how much
608
+ // of it reaches the pane depends on which tool it was (#700) — so the name is
609
+ // remembered here and read back in `settleAct`'s sibling branch below.
610
+ if (typeof block.id === "string" && block.id)
611
+ this.toolNames.set(block.id, name);
601
612
  this.noteAct(block);
602
613
  }
603
614
  }
@@ -605,7 +616,12 @@ export class HeadlessSession {
605
616
  case "user": // tool results come back as a synthetic user message
606
617
  for (const block of ev.message?.content ?? []) {
607
618
  if (block.type === "tool_result") {
608
- this.push(` ↳${toolResultMark(block)} ${toolResult(block.content)}`); // ↳✓ / ↳✗ (#597)
619
+ const id = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
620
+ // `""` when the call was not seen (a pane that began mid-turn, a runner restart):
621
+ // an unknown tool takes the conservative budget rather than the generous one.
622
+ const tool = this.toolNames.get(id) ?? "";
623
+ this.toolNames.delete(id);
624
+ this.push(renderToolResult(toolResultMark(block), block.content, tool)); // ↳✓ / ↳✗ (#597)
609
625
  this.settleAct(block);
610
626
  }
611
627
  }
@@ -633,13 +649,16 @@ export class HeadlessSession {
633
649
  // never saw whether it worked" is a materially different claim from silence, and
634
650
  // silence is what a supervisor would read as "it did nothing".
635
651
  this.flushAwaitingActs();
652
+ this.toolNames.clear(); // no further result is coming for anything still named here
636
653
  this.run = "idle"; // the turn is OVER — a fact, not a guess
637
654
  break;
638
655
  }
639
656
  default:
640
657
  break;
641
658
  }
642
- // Keep the in-memory transcript bounded.
659
+ // Keep the in-memory transcript bounded. Counts ENTRIES, and an entry may now be a
660
+ // multi-line block (a result, or a long assistant reply) rather than one line — the
661
+ // character bound that matters is `MAX_PANE` in runtime.ts, applied on the way out.
643
662
  if (this.transcript.length > 4000)
644
663
  this.transcript = this.transcript.slice(-3000);
645
664
  }
@@ -665,8 +684,8 @@ export class HeadlessSession {
665
684
  *
666
685
  * The result carries more than the outcome: `gh pr create --fill` states its PR number nowhere
667
686
  * but its own stdout, so an unnumbered `pr.open`/`pr.merge` takes it from here (#417). It is read
668
- * from the RAW `block.content`, never from `toolResult()`'s display line — that truncates to 240
669
- * characters for the transcript and would cut the URL off a verbose result.
687
+ * from the RAW `block.content`, never from `renderToolResult()`'s display lines — those are cut to
688
+ * the pane's budget (`transcript-lines.ts`) and would drop the URL off a verbose result.
670
689
  *
671
690
  * This path (and `noteAct`) is reachable ONLY from the structured stream-json handling above
672
691
  * (`assistant` → `tool_use`, `user` → `tool_result`). A Codex/Grok session is a raw spawn with no
@@ -820,36 +839,6 @@ export function buildClaudeArgs(userArgs, resumeId) {
820
839
  args.push("--resume", resumeId);
821
840
  return args;
822
841
  }
823
- /** Strip ANSI/VT escape sequences so a raw CLI's coloured output reads as plain text. */
824
- function stripAnsi(s) {
825
- // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escape/control codes from terminal output.
826
- return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b[()][AB0-2]/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
827
- }
828
- /** Compact a tool_use input object to a single readable line. */
829
- function shortInput(input) {
830
- if (input == null)
831
- return "";
832
- try {
833
- const s = typeof input === "string" ? input : JSON.stringify(input);
834
- return s.length > 160 ? `${s.slice(0, 160)}…` : s;
835
- }
836
- catch {
837
- return "";
838
- }
839
- }
840
- /** Render a tool_result's content (string, or array of {type:text,text}) to text. */
841
- function toolResult(content) {
842
- let text = "";
843
- if (typeof content === "string")
844
- text = content;
845
- else if (Array.isArray(content)) {
846
- text = content
847
- .map((b) => (b && typeof b === "object" && "text" in b ? String(b.text) : ""))
848
- .join(" ");
849
- }
850
- text = text.replace(/\s+/g, " ").trim();
851
- return text.length > 240 ? `${text.slice(0, 240)}…` : text;
852
- }
853
842
  function loadFile(path) {
854
843
  if (!path || !existsSync(path))
855
844
  return {};
@@ -5,6 +5,7 @@ import { defaultStatePath, HeadlessSession } from "./headless.js";
5
5
  import { InspectError, readGitRemoteOrigin, readRepoFile, repoSearch, repoTree, runRepoGit } from "./inspect.js";
6
6
  import { switchRepoBranch } from "./repo-write.js";
7
7
  import { checkWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
8
+ import { asTurnAuthor } from "./turn-author.js";
8
9
  /** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
9
10
  const MAX_PANE = 64 * 1024;
10
11
  export class CodingRuntime {
@@ -116,6 +117,7 @@ export class CodingRuntime {
116
117
  env: input.env,
117
118
  statePath: defaultStatePath(this.reposBaseDir),
118
119
  resumeFrom: input.resumeFrom,
120
+ ghScope: input.ghScope,
119
121
  bin: input.bin,
120
122
  });
121
123
  this.sessions.set(input.sessionId, session);
@@ -163,7 +165,9 @@ export class CodingRuntime {
163
165
  const session = this.require(sessionId);
164
166
  switch (action.kind) {
165
167
  case "message":
166
- session.input(action.text);
168
+ // Narrowed rather than trusted: this runner is a published package any caller can
169
+ // POST to, and an unrecognised author must read as "unstated", not become a label.
170
+ session.input(action.text, { author: asTurnAuthor(action.author) });
167
171
  break;
168
172
  case "keys": {
169
173
  // A snapshot is no longer the whole answer (#448). `key()` records the attempt and
@@ -243,6 +247,7 @@ export class CodingRuntime {
243
247
  takeover: this.takeovers.has(sessionId),
244
248
  authResolved: s.authResolved,
245
249
  engineRuntime: s.engineRuntime,
250
+ ghGuard: s.ghGuard,
246
251
  }));
247
252
  }
248
253
  // ── Human takeover (the "stuck" handoff) ────────────────────────────────
@@ -0,0 +1,223 @@
1
+ /**
2
+ * How an engine event becomes a line in the pane — the runner's half of #700.
3
+ *
4
+ * ── The defect this file exists to remove
5
+ *
6
+ * `headless.ts` rendered every `tool_result` through a four-line helper that did two things before
7
+ * the pane existed: it collapsed `\s+` to a single space, and it cut the text to 240 characters.
8
+ * Both losses were total and neither was disclosed beyond a trailing ellipsis.
9
+ *
10
+ * The consequence is not "results are short". It is that **the shape of a result no longer depends
11
+ * on the command that produced it.** `cat`, `sed -n '1,50p'`, `cat -n | head -60` and "print every
12
+ * character" all arrive at the same size, on one line, with the indentation gone. A Pilot that
13
+ * cannot see a file therefore has no move: every rephrasing it can reach is the same request with
14
+ * the same bound. One live run spent twelve of its sixteen decisions searching that empty space and
15
+ * concluded — reasonably, on the evidence in front of it — that "the CLI is summarizing file
16
+ * contents". It was not; the evidence had been destroyed upstream.
17
+ *
18
+ * Measured across 20 sessions of one instance: **150 of 251 stored tool-result lines (59.8%) hit
19
+ * the 240-character cap** — 102 `Bash`, 43 `Read` — with a maximum line length of 246. The cap was
20
+ * the common case, and it fired hardest on `Read`, the tool whose entire job is returning file
21
+ * contents.
22
+ *
23
+ * ── What replaces it, and what it costs
24
+ *
25
+ * Three changes, and the reason each is separate:
26
+ *
27
+ * 1. **Line structure survives.** Newlines are kept and continuation lines carry
28
+ * {@link RESULT_CONT_PREFIX}. This is the half that matters most and it costs almost nothing:
29
+ * `cat -n` output is unreadable as one space-joined line at ANY length, so the collapse made
30
+ * the cap worse than its size alone implies.
31
+ * 2. **The cap is per tool** ({@link toolResultBudget}), not one number. A `Read` result is the
32
+ * case that needs room; an `Edit`'s "has been updated successfully" is a status string that was
33
+ * never near 240 anyway. Raising one number for everything would buy file text by spending the
34
+ * pane on results that carry none.
35
+ * 3. **A cut states its own size** (`…[cut: 1,500 of 18,432 chars]`), so the reader learns there
36
+ * is more AND how much — which is what makes "ask for a slice that fits" a strategy rather than
37
+ * a guess. Same disclosure discipline as `repo_read_file`'s window header and `repo_git`'s
38
+ * TRUNCATED note on the cloud side.
39
+ *
40
+ * **The cost is real and it is the pane.** A `terminal` row stores the last 8,000 characters
41
+ * (`TERMINAL_SNAPSHOT_CHARS`) and one Pilot decision reads the last 6,000 (`PILOT_PANE_CHARS`), so
42
+ * every character bought for file text is bought by evicting history — including the `⚙`/`↳`
43
+ * framing `engine-tool-calls.ts` parses back out and the engine-error lines #580 exists to keep
44
+ * visible. {@link RESULT_CAP_CONTENT_CHARS} is therefore derived from that window rather than
45
+ * chosen from it — see {@link RESULT_CAP_CONTENT_CHARS} and {@link RESULT_RENDERED_MAX}, which
46
+ * state the content budget and what it actually costs the pane once framing is paid for.
47
+ *
48
+ * ── Old runners, and why nothing gates on a version
49
+ *
50
+ * A machine that has not upgraded keeps the 240-char single-line behaviour, so every cloud reader
51
+ * must go on tolerating short, structureless results — `engine-tool-calls.ts` already models that
52
+ * (`outputCut`) and continues to. Continuation lines are read by their PREFIX, not by a version
53
+ * probe: a pane is judged by what it contains, the way the `↳✓` outcome marker is.
54
+ */
55
+ /**
56
+ * The prefix every line of a result after the first carries.
57
+ *
58
+ * It exists for two reasons and the second is the load-bearing one:
59
+ *
60
+ * - the pane stays readable as a transcript rather than as an unindented dump, and
61
+ * - a result can no longer FORGE the framing. `parseEngineToolCalls` decides what a line is from
62
+ * its first characters, so a file containing a line that begins `⚙ ` or `↳` would, once
63
+ * newlines survived, parse as a tool call the engine never made. Every continuation line begins
64
+ * with this prefix instead, so a result's own text can never start a line that the parser reads
65
+ * as anything else.
66
+ *
67
+ * `│` (U+2502), not `|`: a markdown table in an engine's reply uses the ASCII pipe, and the point
68
+ * of the character is that transcript prose does not use it.
69
+ */
70
+ export const RESULT_CONT_PREFIX = " │ ";
71
+ /**
72
+ * How much of a CONTENT tool's result — its own characters, framing excluded — reaches the pane.
73
+ *
74
+ * Derived, not picked: the Pilot reads the last 6,000 characters of the pane
75
+ * (`PILOT_PANE_CHARS`, `workers/api/src/lib/coding-repetition.ts`), and no single result may take
76
+ * more than a QUARTER of that in content, which is ~35 lines of ordinary source — what a bounded
77
+ * slice request actually asks for. What that costs the pane in full is {@link RESULT_RENDERED_MAX},
78
+ * and it is the larger number, because framing is not free.
79
+ *
80
+ * The two constants are separate declarations in separate packages for the reason `RESULT_ARROW` is
81
+ * (a Worker must not import the runner's Node package); the derivation is stated here so that
82
+ * moving one without the other is at least a visible mistake.
83
+ *
84
+ * `used` and `total` in the cut notice are BOTH content characters, deliberately: the number is
85
+ * only actionable if the model can compare it against the file it is asking for.
86
+ */
87
+ export const RESULT_CAP_CONTENT_CHARS = 1500;
88
+ /**
89
+ * A ceiling on LINES as well as characters, because the prefix is charged per line.
90
+ *
91
+ * A result of 1,500 one-character lines would pay {@link RESULT_CONT_PREFIX} 1,500 times — 6,000
92
+ * characters of framing for 1,500 of content, i.e. the whole of the Pilot's window spent on
93
+ * indentation. 60 lines bounds that overhead at 240 characters.
94
+ */
95
+ export const RESULT_CAP_CONTENT_LINES = 60;
96
+ /** Every other tool keeps the historical 240 characters — see {@link CONTENT_TOOLS}. */
97
+ export const RESULT_CAP_DEFAULT_CHARS = 240;
98
+ /** …and at most six lines of it, so a status string cannot spend six prefixes to say nothing. */
99
+ export const RESULT_CAP_DEFAULT_LINES = 6;
100
+ /** The longest cut notice the renderer can emit — `…[cut: 999,999,999 of 999,999,999 chars]`. */
101
+ const CUT_NOTICE_MAX = 45;
102
+ /**
103
+ * What the WHOLE result block can cost the pane, framing and disclosure included.
104
+ *
105
+ * Stated because the content budget alone is not the honest figure and pretending otherwise is how
106
+ * a cap gets quietly exceeded: the arrow prefix, one `RESULT_CONT_PREFIX` and one newline per
107
+ * continuation line, and the cut notice are all pane characters too. At the current numbers that is
108
+ * ~1,840 — under a THIRD of the Pilot's 6,000-character window, so three consecutive content
109
+ * results still coexist with the narrative and the `⚙`/`↳` framing rather than evicting them.
110
+ *
111
+ * If someone widens {@link RESULT_CAP_CONTENT_CHARS}, this is the number that says what it costs.
112
+ */
113
+ export const RESULT_RENDERED_MAX = " ↳✓ ".length + RESULT_CAP_CONTENT_CHARS + (RESULT_CAP_CONTENT_LINES - 1) * (RESULT_CONT_PREFIX.length + 1) + CUT_NOTICE_MAX;
114
+ /**
115
+ * The tools whose RESULT is the answer, rather than a receipt for an action.
116
+ *
117
+ * `Read`/`Bash` are the two the measurement names (43 and 102 of the 150 truncated lines).
118
+ * `Grep`/`Glob`/`BashOutput`/`NotebookRead` are here because their results are line-structured
119
+ * listings — the same content, arriving through a different tool name — and a Pilot that asked for
120
+ * a grep instead of a cat should not be punished for it.
121
+ *
122
+ * `Edit`, `Write`, `MultiEdit` and `TodoWrite` are deliberately absent: their results are status
123
+ * strings ("has been updated successfully…", 176-214 characters in the sampled sessions) that the
124
+ * old cap barely touched, so widening them would spend the pane and buy nothing.
125
+ */
126
+ export const CONTENT_TOOLS = new Set(["Read", "Bash", "BashOutput", "Grep", "Glob", "NotebookRead"]);
127
+ /** The budget for one tool's result. An unknown or unnamed tool gets the conservative one. */
128
+ export function toolResultBudget(tool) {
129
+ return CONTENT_TOOLS.has(tool)
130
+ ? { chars: RESULT_CAP_CONTENT_CHARS, lines: RESULT_CAP_CONTENT_LINES }
131
+ : { chars: RESULT_CAP_DEFAULT_CHARS, lines: RESULT_CAP_DEFAULT_LINES };
132
+ }
133
+ /** Strip ANSI/VT escape sequences so a raw CLI's coloured output reads as plain text. */
134
+ export function stripAnsi(s) {
135
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escape/control codes from terminal output.
136
+ return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b[()][AB0-2]/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
137
+ }
138
+ /** Compact a tool_use input object to a single readable line. */
139
+ export function shortInput(input) {
140
+ if (input == null)
141
+ return "";
142
+ try {
143
+ const s = typeof input === "string" ? input : JSON.stringify(input);
144
+ return s.length > 160 ? `${s.slice(0, 160)}…` : s;
145
+ }
146
+ catch {
147
+ return "";
148
+ }
149
+ }
150
+ /** Render a tool_result's content (string, or array of `{type:text,text}`) to raw text. */
151
+ export function toolResultText(content) {
152
+ if (typeof content === "string")
153
+ return content;
154
+ if (!Array.isArray(content))
155
+ return "";
156
+ // Joined with a NEWLINE, not a space: separate content blocks are separate pieces of output,
157
+ // and the whole point of this module is that the difference is preserved.
158
+ return content
159
+ .map((b) => (b && typeof b === "object" && "text" in b ? String(b.text) : ""))
160
+ .filter((t) => t !== "")
161
+ .join("\n");
162
+ }
163
+ /**
164
+ * Normalise a result's text to display lines: no CR, no escape codes, no trailing whitespace, and
165
+ * no runs of blank lines. Every one of those is pane budget spent on nothing.
166
+ */
167
+ function tidyLines(text) {
168
+ const out = [];
169
+ for (const raw of stripAnsi(text.replace(/\r\n?/g, "\n")).split("\n")) {
170
+ const line = raw.trimEnd();
171
+ // Drop a leading blank, and collapse a run of blanks to one.
172
+ if (!line && (out.length === 0 || out[out.length - 1] === ""))
173
+ continue;
174
+ out.push(line);
175
+ }
176
+ while (out.length && out[out.length - 1] === "")
177
+ out.pop();
178
+ return out;
179
+ }
180
+ /**
181
+ * The full transcript entry for one `tool_result` — the arrow line plus any continuation lines.
182
+ *
183
+ * The head is cut rather than the tail, unchanged from the old helper: a file, a listing and a
184
+ * grep all answer from their beginning, and a test run's tail is recoverable from the engine's own
185
+ * reply text, which is never truncated. Changing that is a separate decision and is not made here.
186
+ */
187
+ export function renderToolResult(mark, content, tool) {
188
+ const lines = tidyLines(toolResultText(content));
189
+ const budget = toolResultBudget(tool);
190
+ const total = lines.join("\n").length;
191
+ const kept = [];
192
+ let used = 0;
193
+ let cut = false;
194
+ for (const line of lines) {
195
+ if (kept.length >= budget.lines) {
196
+ cut = true;
197
+ break;
198
+ }
199
+ const room = budget.chars - used;
200
+ if (room <= 0) {
201
+ cut = true;
202
+ break;
203
+ }
204
+ if (line.length > room) {
205
+ kept.push(line.slice(0, room));
206
+ used += room;
207
+ cut = true;
208
+ break;
209
+ }
210
+ kept.push(line);
211
+ used += line.length;
212
+ }
213
+ if (cut && kept.length) {
214
+ // The figures, not just the ellipsis: "there was more" tells the reader to give up, "1,500
215
+ // of 18,432" tells it how narrow a slice would arrive whole. The ellipsis stays because it
216
+ // is what every runner ever written has used to mark a cut, and readers key on it.
217
+ kept[kept.length - 1] = `${kept[kept.length - 1]}…[cut: ${used.toLocaleString("en-US")} of ${total.toLocaleString("en-US")} chars]`;
218
+ }
219
+ const head = ` ↳${mark} ${kept[0] ?? ""}`.trimEnd();
220
+ if (kept.length < 2)
221
+ return head;
222
+ return [head, ...kept.slice(1).map((l) => `${RESULT_CONT_PREFIX}${l}`)].join("\n");
223
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Who wrote the turn the Engine is about to receive (#505, criterion 3).
3
+ *
4
+ * ── The defect
5
+ *
6
+ * `HeadlessSession.input` writes every turn to Claude Code as
7
+ * `{ type: "user", message: { role: "user", … } }`. That is the only shape the stream-json input
8
+ * protocol accepts — the role vocabulary is `user`/`assistant`, there is no third value to move
9
+ * to, and an Engine that stops receiving instructions is far worse than the defect — so the
10
+ * framing cannot be fixed by changing the role. The consequence is that the Engine calls whoever
11
+ * is driving it "the user", and **whoever is driving it is usually the Pilot, not a person.**
12
+ *
13
+ * On 2026-08-11 that produced a completion message telling the owner he had been warned and had
14
+ * "explicitly chosen" to bump the wrong `pubspec.yaml`. `instance_messages` shows he sent nothing
15
+ * in that window (22:31:20 and 22:47:54 bracket it); the Engine's objection was correct, and
16
+ * overriding it broke the deploy. Nothing had lied: the Engine said "the user", the Pilot read
17
+ * that back, and "the user" is the only word the protocol gave it.
18
+ *
19
+ * ── What this module does, and what it deliberately does not
20
+ *
21
+ * It **names the author** on the two surfaces the naming collision travels through:
22
+ *
23
+ * - {@link authoredTurn} — a one-line preamble the Engine reads BEFORE the instruction, so the
24
+ * Engine's own prose ("you asked me to…") is at least anchored to something that says what
25
+ * "you" is. The instruction follows **verbatim**, after a blank line. #505's standing promise
26
+ * is *annotate, never rewrite*: the instruction is the evidence, and truncating or rephrasing
27
+ * it would destroy the only record of what was actually sent.
28
+ * - {@link authorTag} — a two-word marker on the transcript line, which is what the owner reads
29
+ * in the Terminal view and what the Pilot re-reads through `/coding/capture`. It is short
30
+ * because that pane is a fixed character budget the Pilot pays for on every decision; a
31
+ * 150-character preamble repeated on every turn would evict real output from the window the
32
+ * Pilot can see. The pane has never been a wire log — it already renders `❯ [12:03:04] …`,
33
+ * which is not sent either — so a compact marker there is consistent with what that line is.
34
+ *
35
+ * ── Why only the Pilot is named
36
+ *
37
+ * The vocabulary is **closed at one member on purpose**, the way `repo-write.ts` closes its verb
38
+ * list. `POST /coding/act` is a shared door: the console's manual `/message` route, MCP's
39
+ * `coding_session_message`, the Overseer's delegation and the agent's own `drive_claude` tool all
40
+ * arrive through it, and none of them declares an author. An absent author therefore means
41
+ * "nobody said", which is exactly what it renders as — nothing. Adding a `"human"` member would
42
+ * turn silence into a claim the runner cannot support: it could only ever be set by the callers
43
+ * that already bothered to be explicit, so an unlabelled human turn and an unlabelled machine turn
44
+ * would still be indistinguishable while the labelling implied otherwise.
45
+ *
46
+ * A runner older than this change ignores the field entirely and behaves exactly as before; a
47
+ * cloud older than this change sends no author and the bytes are byte-identical to today. Both
48
+ * directions degrade to the status quo rather than to a wrong label.
49
+ */
50
+ /** The authors this runner will accept over the wire. Anything else is treated as unstated. */
51
+ const AUTHORS = ["pilot"];
52
+ /**
53
+ * Narrow an untrusted wire value to a {@link TurnAuthor}.
54
+ *
55
+ * The runner is a published npm package that any caller can POST to, so the field arrives as
56
+ * `unknown` and an unrecognised value must read as "unstated" rather than becoming a label.
57
+ */
58
+ export function asTurnAuthor(value) {
59
+ return typeof value === "string" && AUTHORS.includes(value) ? value : undefined;
60
+ }
61
+ /**
62
+ * The preamble the Engine reads. Kept to one sentence pair: it is prepended to EVERY Pilot turn,
63
+ * so its length is a per-turn token cost on every engine, on every run.
64
+ *
65
+ * It states only what the runner actually knows — the caller declared this turn's author — and
66
+ * makes no claim about whether a human is watching, because the console does surface the Pilot's
67
+ * step lines in the owner's chat and "no person has seen this" would be false there.
68
+ */
69
+ const PILOT_PREAMBLE = '[pags] This turn was written by the Pilot, an automated orchestrator, not typed by a person. "The user" in this conversation means the Pilot.';
70
+ /**
71
+ * The instruction as the Engine receives it: the author's preamble, a blank line, then the
72
+ * caller's text **unchanged**.
73
+ *
74
+ * Returns the input untouched when the author is unstated, so the only turns whose bytes change
75
+ * are the ones the platform can actually name.
76
+ */
77
+ export function authoredTurn(text, author) {
78
+ return author === "pilot" ? `${PILOT_PREAMBLE}\n\n${text}` : text;
79
+ }
80
+ /**
81
+ * The transcript marker, already spaced for concatenation (`""` when the author is unstated), so
82
+ * a reader of the pane can tell a Pilot turn from a turn a person typed.
83
+ */
84
+ export function authorTag(author) {
85
+ return author === "pilot" ? "(pilot) " : "";
86
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * What a handoff-status poll can answer WITHOUT looking at the live page (#641).
3
+ *
4
+ * A handoff is the runner asking a human for something the brain cannot do: solve a
5
+ * challenge, perform one widget step it failed at, or supply a value it must not invent.
6
+ * The remote Workflow polls `browserHandoffStatus` until it is told the human acted, and
7
+ * `solved: true` is a licence to resume the application — so the only safe answers are the
8
+ * ones the runner can actually stand behind.
9
+ *
10
+ * ── The rule
11
+ *
12
+ * A handoff whose PAGE IS GONE is LOST, not resolved. It is the same category as a handoff
13
+ * whose SESSION is gone (the runner restarted, the takeover expired), which the caller
14
+ * already answers `solved: false` under a comment stating the rule outright: *a status poll
15
+ * must never relaunch the browser or claim a lost handoff is done.* Three lines below that
16
+ * comment sat `if (page.isClosed()) return { solved: true }`, ahead of all three per-reason
17
+ * branches, so it overrode every one of them:
18
+ *
19
+ * - `challenge` — the brain resumed with the CAPTCHA unsolved, and the claim was acted
20
+ * on rather than re-checked: the workflow records `solvedChallengeUrl`, and the apply
21
+ * loop then suppresses captcha re-detection on that page for the rest of the round. A
22
+ * false "solved" muted the detector that would have caught it.
23
+ * - `stuck` — resumed without the human doing the single step the handoff exists for;
24
+ * `humanDone` was never consulted.
25
+ * - `needs_input` — resumed with `value: undefined`, so the workflow skipped the save and
26
+ * the brain re-asked on the next round for a value it had been given.
27
+ *
28
+ * There is nothing to resume ONTO in any of those cases: the page the human was working in
29
+ * no longer exists, and the brain's next action would land on a freshly created blank tab.
30
+ * Staying unsolved lets the wait time out into "not resolved in time", which the cloud maps
31
+ * to `escalated` → the board's "Needs you" column — visible, and retryable by the owner.
32
+ *
33
+ * ── The one exception, and why it is not one
34
+ *
35
+ * `needs_input` is answered BEFORE page liveness is considered, because its answer never
36
+ * involved the page: the value arrives out of band through `browserSubmitInput`, which
37
+ * writes it onto the session. A value the owner typed is a real answer whether or not the
38
+ * tab survived, and discarding it would re-ask for something already in hand.
39
+ */
40
+ /**
41
+ * The answer to a handoff-status poll, or `null` when only the live DOM can give one — a
42
+ * `challenge` on a page that is still open, whose token clears in the page itself.
43
+ */
44
+ export function resolveHandoffStatus(facts) {
45
+ // Out of band, and therefore unaffected by a page that has gone.
46
+ if (facts.reason === "needs_input")
47
+ return { solved: !!facts.inputValue, challenge: null, value: facts.inputValue };
48
+ // Lost, not done. See the note above — this line is the fix for #641.
49
+ if (facts.pageClosed)
50
+ return { solved: false, challenge: null };
51
+ // A stuck handoff resumes only when the human explicitly clicks Resume — there's
52
+ // nothing to auto-detect.
53
+ if (facts.reason === "stuck")
54
+ return { solved: !!facts.humanDone, challenge: null };
55
+ return null;
56
+ }
@@ -5,9 +5,11 @@ import { basename, dirname, join, resolve } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { captureScreenshotDataUrl, challengeSolved, detectHumanChallenge } from "./challenge.js";
7
7
  import { resolveRealChromeProfileDir, seedProfileCopy } from "./browser-profile.js";
8
+ import { isTerminalTaskStatus, settleTaskOutcome } from "./apply-outcome.js";
8
9
  import { McpRuntime } from "./mcp-runtime.js";
9
10
  import { commitLabelRe, ELEMENT_PROBE_FN, FOCUS_PROBE_FN, refuseClick, refuseKey } from "./commit-guard.js";
10
11
  import { HumanHandoffError, RunnerInputError } from "./errors.js";
12
+ import { resolveHandoffStatus } from "./handoff-status.js";
11
13
  import { RunnerStore } from "./store.js";
12
14
  import { CodingRuntime } from "./coding/runtime.js";
13
15
  import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
@@ -170,7 +172,11 @@ export class LocalRunner {
170
172
  }
171
173
  cancelTask(id) {
172
174
  const task = this.requireTask(id);
173
- if (task.status === "completed" || task.status === "failed")
175
+ // Already finished, whichever way — the old `completed || failed` pair missed `cancelled`, so
176
+ // a second Stop rewrote the timestamps and appended a duplicate event (#636). A run that
177
+ // genuinely completed or failed is NOT relabelled: it ended before the Stop arrived. Off THIS
178
+ // return the cloud mirrored `task.cancelled` carrying `{status:"failed"}` — fixed in the cloud.
179
+ if (isTerminalTaskStatus(task.status))
174
180
  return task;
175
181
  task.status = "cancelled";
176
182
  task.updatedAt = new Date().toISOString();
@@ -1144,15 +1150,12 @@ export class LocalRunner {
1144
1150
  if (!session)
1145
1151
  return { solved: false, challenge: null };
1146
1152
  const page = session.page;
1147
- if (page.isClosed())
1148
- return { solved: true, challenge: null };
1149
- // A needs_input handoff resumes once the user supplies the value.
1150
- if (session.reason === "needs_input")
1151
- return { solved: !!session.inputValue, challenge: null, value: session.inputValue };
1152
- // A stuck handoff resumes only when the human explicitly clicks Resume —
1153
- // there's nothing to auto-detect.
1154
- if (session.reason === "stuck")
1155
- return { solved: !!session.humanDone, challenge: null };
1153
+ // Every answer that needs no live DOM — including a GONE page, which used to report
1154
+ // solved:true and resume past an unsolved challenge (#641) — comes from
1155
+ // handoff-status.ts; null means only an open challenge's own DOM can answer.
1156
+ const offline = resolveHandoffStatus({ reason: session.reason, pageClosed: page.isClosed(), inputValue: session.inputValue, humanDone: session.humanDone });
1157
+ if (offline)
1158
+ return offline;
1156
1159
  // A challenge resumes when the token/widget clears OR the human clicks Done —
1157
1160
  // custom captchas (e.g. PageUp's "not a robot") have no detectable token, so
1158
1161
  // the human's explicit Done is the authority; never strand them.
@@ -1197,23 +1200,17 @@ export class LocalRunner {
1197
1200
  }
1198
1201
  return { ok: true };
1199
1202
  }
1200
- /** Finalize an agent-driven application (submitted/expired → completed; else failed). */
1203
+ /** Finalize an agent-driven application — the outcome→status table lives in apply-outcome.ts. */
1201
1204
  async browserComplete(taskId, outcome, detail) {
1202
1205
  await this.endTakeover(taskId).catch(() => undefined);
1203
1206
  const task = this.store.getTask(taskId);
1204
1207
  if (task) {
1205
- const success = outcome === "submitted" || outcome === "ready" || outcome === "expired";
1206
- // 'blocked' = the agent stopped and needs the USER (email verification, can't
1207
- // proceed truthfully) — it's needs-attention, not a failure, so keep it a
1208
- // distinct status (the console shows it as an active ticket, not buried).
1209
- task.status = outcome === "blocked" ? "blocked" : success ? "completed" : "failed";
1210
- task.output = { outcome, detail };
1211
- if (!success)
1212
- task.error = detail || outcome;
1213
- task.updatedAt = new Date().toISOString();
1214
- task.completedAt = task.updatedAt;
1208
+ // A TOTAL table, not a chain of string comparisons: `cancelled` matched none of
1209
+ // them and fell through to `failed`, so a run the owner stopped was filed as an
1210
+ // error with a Retry button on it (#636). See apply-outcome.ts.
1211
+ const { event } = settleTaskOutcome(task, outcome, detail);
1215
1212
  this.store.putTask(task);
1216
- this.addTaskEvent(task, success ? "task.completed" : "task.failed", detail || `Application ${outcome}`, { outcome, detail });
1213
+ this.addTaskEvent(task, event, detail || `Application ${outcome}`, { outcome, detail });
1217
1214
  }
1218
1215
  await this.closeStalePages().catch(() => undefined);
1219
1216
  return { ok: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.54",
3
+ "version": "0.4.56",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",