@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.
- package/dist/browser-runner/apply-outcome.js +70 -0
- package/dist/browser-runner/coding/engine-env.js +46 -0
- package/dist/browser-runner/coding/gh-guard.js +272 -0
- package/dist/browser-runner/coding/headless.js +52 -63
- package/dist/browser-runner/coding/runtime.js +6 -1
- package/dist/browser-runner/coding/transcript-lines.js +223 -0
- package/dist/browser-runner/coding/turn-author.js +86 -0
- package/dist/browser-runner/handoff-status.js +56 -0
- package/dist/browser-runner/runner.js +19 -22
- package/package.json +1 -1
|
@@ -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
|
-
|
|
7
|
-
|
|
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,
|
|
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:
|
|
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
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
669
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1148
|
-
|
|
1149
|
-
//
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
|
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
|
-
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
|
|
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,
|
|
1213
|
+
this.addTaskEvent(task, event, detail || `Application ${outcome}`, { outcome, detail });
|
|
1217
1214
|
}
|
|
1218
1215
|
await this.closeStalePages().catch(() => undefined);
|
|
1219
1216
|
return { ok: true };
|