@proagentstore/cli 0.4.55 → 0.4.57

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,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
+ }
@@ -3,26 +3,9 @@ import { classifyCommand, commandFromToolInput, fillTargetFromResult, toolCallOk
3
3
  import { parseEngineUsage } from "./engine-usage.js";
4
4
  import { turnReportFromExit, turnReportFromResult } from "./engine-turn.js";
5
5
  import { renderToolResult, shortInput, stripAnsi } from "./transcript-lines.js";
6
- /**
7
- * Merge the platform's resolved engine env over the machine's, where an EMPTY value means
8
- * REMOVE rather than "set to empty".
9
- *
10
- * Needed because the machine env is inherited wholesale: a developer with ANTHROPIC_API_KEY in
11
- * their shell handed it to every engine, and Claude Code prefers an API key over the
12
- * subscription token — so choosing "subscription" injected CLAUDE_CODE_OAUTH_TOKEN and then
13
- * silently lost, billing per token anyway. Without a way to express removal the setting could
14
- * not mean what it said.
15
- */
16
- export function mergeEnv(base, overlay) {
17
- const out = { ...base };
18
- for (const [k, v] of Object.entries(overlay ?? {})) {
19
- if (v === "")
20
- delete out[k];
21
- else
22
- out[k] = v;
23
- }
24
- return out;
25
- }
6
+ import { authoredTurn, authorTag } from "./turn-author.js";
7
+ import { engineSpawnEnv, mergeEnv } from "./engine-env.js";
8
+ import { ghGuardStatus } from "./gh-guard.js";
26
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
27
10
  import { dirname, join } from "node:path";
28
11
  import { handlerFor } from "./handlers.js";
@@ -77,6 +60,16 @@ export class HeadlessSession {
77
60
  run = "idle";
78
61
  /** Claude Code's own session id (from the init event) — used to --resume. */
79
62
  claudeSessionId = null;
63
+ /**
64
+ * The cloud's context brief, until the first turn spends it (ADR 0005, #693). Null once
65
+ * delivered, and null from the start when the engine resumed its own conversation.
66
+ *
67
+ * Consumed ONCE, by construction rather than by convention: it is cleared in the same expression
68
+ * that reads it. A brief re-sent on every turn would be the "unbounded brief… a token bill that
69
+ * grows every turn" the ADR names as the cost of owning the conversation, and it would also start
70
+ * contradicting the engine's own memory of the turns since.
71
+ */
72
+ pendingSeed = null;
80
73
  /** "stream-json" for Claude (structured) · "raw" for any other CLI (stdout capture). */
81
74
  mode;
82
75
  cmdBin;
@@ -173,7 +166,15 @@ export class HeadlessSession {
173
166
  * Presence only: no key or token value leaves this class.
174
167
  */
175
168
  get authResolved() {
176
- return resolveEngineAuth(this.config.clientType, mergeEnv(process.env, this.config.env));
169
+ return resolveEngineAuth(this.config.clientType, this.spawnEnv);
170
+ }
171
+ /** The env every turn is spawned with — one expression, three call sites. See engine-env.ts. */
172
+ get spawnEnv() {
173
+ return engineSpawnEnv(this.config.env, this.config.ghScope, this.config.ghGuardRoot);
174
+ }
175
+ /** What the `gh` guard actually did on this machine (#679) — reported, never assumed. */
176
+ get ghGuard() {
177
+ return ghGuardStatus(this.config.ghScope, mergeEnv(process.env, this.config.env), this.config.ghGuardRoot);
177
178
  }
178
179
  /**
179
180
  * Did this engine launch with a conversation to continue (#408)?
@@ -190,6 +191,17 @@ export class HeadlessSession {
190
191
  get resumedConversation() {
191
192
  return this.mode === "stream-json" && this.claudeSessionId !== null;
192
193
  }
194
+ /**
195
+ * Did this engine come up cold AND with a brief to lead its first turn (ADR 0005, #693)?
196
+ *
197
+ * Read by `/coding/start` so the sentence the user is shown is one this side CONFIRMED. It is
198
+ * armed-not-delivered: the brief goes out with the first `input()`, which may never arrive if
199
+ * the session is closed first. That is why the cloud's wording is "it was given a brief" — true
200
+ * the moment the engine holds one — and never "it has read your history".
201
+ */
202
+ get seededConversation() {
203
+ return this.pendingSeed !== null;
204
+ }
193
205
  constructor(config) {
194
206
  this.config = config;
195
207
  this.engineLabel = `${config.clientType}:${config.id}`;
@@ -197,6 +209,10 @@ export class HeadlessSession {
197
209
  this.claudeSessionId = readState(config.statePath, config.id) ?? (config.resumeFrom ? readState(config.statePath, config.resumeFrom) : null);
198
210
  // Claude is the structured engine; everything else is a raw CLI.
199
211
  this.mode = config.clientType === "claude" ? "stream-json" : "raw";
212
+ // AFTER both lines above, because `resumedConversation` reads them: the brief is the fallback,
213
+ // so an engine that found its own conversation drops it unread rather than being handed a
214
+ // summary of the conversation it is already in.
215
+ this.pendingSeed = this.resumedConversation ? null : config.seed?.trim() || null;
200
216
  const { bin, args } = parseCommand(config.command);
201
217
  // When no explicit command is configured, fall back to THIS engine's default
202
218
  // command (codex/gemini/grok/…) — not a hard-coded "claude", which would drive a
@@ -344,7 +360,7 @@ export class HeadlessSession {
344
360
  const args = this.mode === "stream-json" ? buildClaudeArgs(this.cmdArgs, this.claudeSessionId) : [...this.cmdArgs];
345
361
  const proc = spawn(this.cmdBin, args, {
346
362
  cwd: this.config.workDir,
347
- env: mergeEnv(process.env, this.config.env),
363
+ env: this.spawnEnv,
348
364
  stdio: ["pipe", "pipe", "pipe"],
349
365
  });
350
366
  this.proc = proc;
@@ -386,25 +402,41 @@ export class HeadlessSession {
386
402
  }
387
403
  });
388
404
  }
389
- /** Send a user turn to the agent (it acts on it). */
390
- input(text) {
405
+ /** Send a user turn. `author` names who wrote it, because `role` cannot — see turn-author.ts (#505). */
406
+ input(text, opts = {}) {
407
+ // The Engine reads the preamble, the pane the short marker; the instruction stays evidence.
408
+ const sent = authoredTurn(text, opts.author);
409
+ // The brief leads the turn and is spent in the reading (#693). It goes to the ENGINE only:
410
+ // the pane gets the one-line marker below, because the transcript is what the owner and the
411
+ // Pilot re-read through `/coding/capture` and twelve thousand characters of reconstructed
412
+ // history would evict the live output they are looking at — the same budget argument
413
+ // `turn-author.ts` makes for its two-word marker.
414
+ const seed = this.pendingSeed;
415
+ this.pendingSeed = null;
391
416
  if (!this.alive)
392
417
  this.start();
393
- this.push(`\n❯ [${stamp()}] ${text}`); // ❯ — your turn, timestamped
418
+ if (seed)
419
+ this.push("[pags] a context brief from ProAgentStore's record was delivered with this turn — a reconstruction, not the previous conversation");
420
+ this.push(`\n❯ [${stamp()}] ${authorTag(opts.author)}${text}`); // ❯ — your turn, timestamped
394
421
  this.run = "thinking";
395
422
  const now = Date.now();
396
423
  this.lastOutputAt = now;
397
424
  this.turnStartedAt = now;
398
425
  this.sawOutputSinceInput = false; // arm the persistent-raw idle heuristic for THIS turn
399
426
  try {
427
+ // Brief first, instruction second, and never the other way round: it is background for
428
+ // the request, and an engine that reads the request last acts on the request.
429
+ const withSeed = seed ? `${seed}\n\n${sent}` : sent;
400
430
  if (this.mode === "stream-json") {
401
- const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
431
+ // `role` stays "user" — the only role this protocol accepts, which is why the
432
+ // disambiguation rides in the text instead (#505).
433
+ const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: withSeed }] } });
402
434
  this.proc?.stdin?.write(`${msg}\n`);
403
435
  }
404
436
  else {
405
437
  // Raw CLI: spawn THIS turn. See `oneShot` — there is no persistent process to
406
438
  // write to, because a non-interactive binary would never have read it.
407
- this.runOneShot(text);
439
+ this.runOneShot(withSeed);
408
440
  }
409
441
  }
410
442
  catch {
@@ -432,7 +464,7 @@ export class HeadlessSession {
432
464
  this.turnLastLine = "";
433
465
  const proc = spawn(this.cmdBin, [...this.cmdArgs, text], {
434
466
  cwd: this.config.workDir,
435
- env: mergeEnv(process.env, this.config.env),
467
+ env: this.spawnEnv,
436
468
  stdio: ["ignore", "pipe", "pipe"],
437
469
  });
438
470
  // A turn already running is aborted before its replacement starts: `input()` accepts a turn
@@ -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,8 @@ export class CodingRuntime {
116
117
  env: input.env,
117
118
  statePath: defaultStatePath(this.reposBaseDir),
118
119
  resumeFrom: input.resumeFrom,
120
+ seed: input.seed,
121
+ ghScope: input.ghScope,
119
122
  bin: input.bin,
120
123
  });
121
124
  this.sessions.set(input.sessionId, session);
@@ -124,8 +127,12 @@ export class CodingRuntime {
124
127
  // clears its own key on that exit. Reporting after would say "started clean" about a launch
125
128
  // that did carry a conversation, and the transcript (which shows the crash) would disagree.
126
129
  const resumed = session.resumedConversation;
130
+ // Read alongside `resumed`, and BEFORE `start()` for the same reason: a bad `--resume` that
131
+ // kills the process on spawn clears the engine's key, and a seed answer read afterwards would
132
+ // describe a different launch from the one the caller asked about.
133
+ const seeded = session.seededConversation;
127
134
  session.start();
128
- return { ...this.snapshot(input.sessionId), resumed };
135
+ return { ...this.snapshot(input.sessionId), resumed, seeded };
129
136
  }
130
137
  /**
131
138
  * The pane the brain reasons over + the inferred run state.
@@ -163,7 +170,9 @@ export class CodingRuntime {
163
170
  const session = this.require(sessionId);
164
171
  switch (action.kind) {
165
172
  case "message":
166
- session.input(action.text);
173
+ // Narrowed rather than trusted: this runner is a published package any caller can
174
+ // POST to, and an unrecognised author must read as "unstated", not become a label.
175
+ session.input(action.text, { author: asTurnAuthor(action.author) });
167
176
  break;
168
177
  case "keys": {
169
178
  // A snapshot is no longer the whole answer (#448). `key()` records the attempt and
@@ -243,6 +252,7 @@ export class CodingRuntime {
243
252
  takeover: this.takeovers.has(sessionId),
244
253
  authResolved: s.authResolved,
245
254
  engineRuntime: s.engineRuntime,
255
+ ghGuard: s.ghGuard,
246
256
  }));
247
257
  }
248
258
  // ── Human takeover (the "stuck" handoff) ────────────────────────────────
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.55",
3
+ "version": "0.4.57",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",