@proagentstore/cli 0.4.61 → 0.4.63

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 @@
1
+ #!/usr/bin/env node
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ BOOTSTRAP_ENV,
4
+ NO_SELF_UPDATE_ENV,
5
+ cachedPayloads,
6
+ installPayload,
7
+ latestPublishedVersion,
8
+ newestPayload,
9
+ olderThan,
10
+ payloadAt
11
+ } from "./chunk-JOWYSZOL.js";
12
+
13
+ // src/bootstrap.ts
14
+ import { existsSync } from "fs";
15
+ import { dirname, join } from "path";
16
+ import { fileURLToPath } from "url";
17
+
18
+ // src/bootstrap/stub.ts
19
+ import { pathToFileURL } from "url";
20
+ function checksForUpdate(args) {
21
+ return args.find((a) => !a.startsWith("-")) === "up";
22
+ }
23
+ async function runStub(deps) {
24
+ const log = deps.log ?? ((line) => process.stderr.write(`${line}
25
+ `));
26
+ const local = () => newestPayload([...deps.bundled ? [deps.bundled] : [], ...(deps.cached ?? cachedPayloads)()]);
27
+ let chosen = local();
28
+ if (checksForUpdate(deps.args) && !deps.fromSource && process.env[NO_SELF_UPDATE_ENV] !== "1") {
29
+ const latest = await (deps.latest ?? (() => latestPublishedVersion(15e3)))();
30
+ if (latest && (!chosen || olderThan(chosen.version, latest))) {
31
+ log(`pags: updating ${chosen?.version ?? "(none)"} \u2192 ${latest}\u2026`);
32
+ try {
33
+ chosen = await (deps.install ?? ((v) => installPayload(v)))(latest);
34
+ log(`pags: now on ${chosen.version}`);
35
+ } catch (e) {
36
+ log(`pags: could not fetch ${latest} (${e instanceof Error ? e.message : String(e)}) \u2014 starting ${chosen?.version ?? "nothing"} instead`);
37
+ }
38
+ }
39
+ }
40
+ if (!chosen) throw new Error("pags: no CLI to run \u2014 reinstall with `npm i -g @proagentstore/cli`");
41
+ process.env[BOOTSTRAP_ENV] = deps.self;
42
+ await (deps.load ?? ((entry) => import(pathToFileURL(entry).href)))(chosen.entry);
43
+ return chosen;
44
+ }
45
+
46
+ // src/bootstrap.ts
47
+ var self = fileURLToPath(import.meta.url);
48
+ var packageDir = join(dirname(self), "..");
49
+ try {
50
+ await runStub({
51
+ bundled: payloadAt(packageDir),
52
+ args: process.argv.slice(2),
53
+ self,
54
+ fromSource: existsSync(join(packageDir, "src", "bootstrap.ts"))
55
+ });
56
+ } catch (e) {
57
+ process.stderr.write(`${e instanceof Error ? e.message : String(e)}
58
+ `);
59
+ process.exit(1);
60
+ }
@@ -1,3 +1,4 @@
1
+ import { stripAnsi } from "./transcript-lines.js";
1
2
  export function engineInvocationModeFromAdapter(mode) {
2
3
  return mode === "stream-json" ? "structured" : "raw";
3
4
  }
@@ -5,7 +6,7 @@ export function structuredCapableEngine(clientType) {
5
6
  return clientType === "claude" || clientType === "codex";
6
7
  }
7
8
  export function engineInvocationWarning(clientType, mode) {
8
- if (mode !== "raw" || clientType !== "claude")
9
+ if (mode !== "raw" || !structuredCapableEngine(clientType))
9
10
  return null;
10
11
  return `running raw — structured not available on this machine's ${clientType} CLI`;
11
12
  }
@@ -113,6 +114,31 @@ function buildCodexExecArgs(userArgs, turnText) {
113
114
  args.push(turnText);
114
115
  return args;
115
116
  }
117
+ /**
118
+ * The only supported runner-owned Codex continuity form (#848).
119
+ *
120
+ * `exec resume` takes the thread id between the subcommand and the prompt, so it cannot use the
121
+ * normal preset-prefix-plus-final-prompt contract. Its write flag is deliberately not inherited
122
+ * from a fresh `exec`: resume accepts the bypass flag but does not accept `--sandbox <mode>`.
123
+ * This remains an opaque, machine-local optimisation until #693's platform timeline owns the
124
+ * conversation; it must never select a conversation with `--last`.
125
+ */
126
+ export function buildCodexResumeArgs(userArgs, threadId, turnText) {
127
+ const extras = [];
128
+ for (let i = 1; i < userArgs.length; i++) {
129
+ const arg = userArgs[i];
130
+ if (arg === "--json" || arg === "--dangerously-bypass-approvals-and-sandbox")
131
+ continue;
132
+ if (arg === "--sandbox") {
133
+ i++;
134
+ continue;
135
+ }
136
+ if (arg.startsWith("--sandbox="))
137
+ continue;
138
+ extras.push(arg);
139
+ }
140
+ return ["exec", "resume", threadId, "--json", "--dangerously-bypass-approvals-and-sandbox", ...extras, turnText];
141
+ }
116
142
  function parseCodexLine(line) {
117
143
  let ev;
118
144
  try {
@@ -163,12 +189,24 @@ function parseCodexLine(line) {
163
189
  return [{ kind: "tool_use", block, id, name: "Bash", input: { command } }];
164
190
  return [];
165
191
  }
192
+ /**
193
+ * An older Codex CLI exits before doing work when it does not know `--json`. Do not treat every
194
+ * plain line as a downgrade signal: current Codex can interleave malformed/tool stderr with valid
195
+ * JSONL, and retrying after that could run a real instruction twice.
196
+ */
197
+ function codexRejectsJson(line) {
198
+ const plain = stripAnsi(line).toLowerCase();
199
+ if (!plain.includes("--json"))
200
+ return false;
201
+ return /(?:unexpected argument|unknown (?:argument|option)|unrecognized (?:argument|option)|invalid option)/.test(plain);
202
+ }
166
203
  export const codexEngineAdapter = {
167
204
  mode: "stream-json",
168
205
  persistent: false,
169
206
  buildLaunchArgs: (userArgs) => [...userArgs],
170
207
  buildTurnArgs: buildCodexExecArgs,
171
208
  parseLine: parseCodexLine,
209
+ rejectsStructuredOutput: codexRejectsJson,
172
210
  };
173
211
  export const genericRawEngineAdapter = {
174
212
  mode: "raw",
@@ -0,0 +1,50 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ /** Codex CLI emitted UUID-shaped `thread_id`s in the #730 proof; reject anything unsafe to argv. */
4
+ export function isCodexThreadId(value) {
5
+ return typeof value === "string" && /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(value);
6
+ }
7
+ function stateKey(id, engine) {
8
+ return engine === "codex" ? `codex:${id}` : id;
9
+ }
10
+ function loadFile(path) {
11
+ if (!path || !existsSync(path))
12
+ return {};
13
+ try {
14
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
15
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
16
+ return {};
17
+ return Object.fromEntries(Object.entries(parsed).flatMap(([key, value]) => (typeof value === "string" && value.trim() ? [[key, value]] : [])));
18
+ }
19
+ catch {
20
+ return {};
21
+ }
22
+ }
23
+ export function readState(path, id, engine) {
24
+ return loadFile(path)[stateKey(id, engine)] ?? null;
25
+ }
26
+ export function readCodexState(path, id) {
27
+ const threadId = readState(path, id, "codex");
28
+ return isCodexThreadId(threadId) ? threadId : null;
29
+ }
30
+ export function writeState(path, id, sessionId, engine) {
31
+ if (!path)
32
+ return;
33
+ try {
34
+ const data = loadFile(path);
35
+ const key = stateKey(id, engine);
36
+ if (sessionId)
37
+ data[key] = sessionId;
38
+ else
39
+ delete data[key];
40
+ mkdirSync(dirname(path), { recursive: true });
41
+ writeFileSync(path, JSON.stringify(data));
42
+ }
43
+ catch {
44
+ /* best-effort persistence */
45
+ }
46
+ }
47
+ /** Default location for the resume-id store, under the repos base dir. */
48
+ export function defaultStatePath(reposBaseDir) {
49
+ return join(reposBaseDir, "headless-sessions.json");
50
+ }
@@ -6,12 +6,12 @@ import { renderToolResult, shortInput, stripAnsi } from "./transcript-lines.js";
6
6
  import { authoredTurn, authorTag } from "./turn-author.js";
7
7
  import { engineSpawnEnv, mergeEnv } from "./engine-env.js";
8
8
  import { ghGuardStatus } from "./gh-guard.js";
9
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
- import { dirname, join } from "node:path";
11
9
  import { handlerFor } from "./handlers.js";
12
10
  import { resolveEngineAuth } from "./engine-auth.js";
13
- import { engineAdapterFor, engineInvocationModeFromAdapter, engineInvocationWarning } from "./engine-adapter.js";
11
+ import { buildCodexResumeArgs, engineAdapterFor, engineInvocationModeFromAdapter, engineInvocationWarning, genericRawEngineAdapter } from "./engine-adapter.js";
12
+ import { isCodexThreadId, readCodexState, readState, writeState } from "./headless-state.js";
14
13
  export { buildClaudeArgs } from "./engine-adapter.js";
14
+ export { defaultStatePath } from "./headless-state.js";
15
15
  /**
16
16
  * How many un-drained usage records a session holds (#267).
17
17
  *
@@ -62,6 +62,8 @@ export class HeadlessSession {
62
62
  run = "idle";
63
63
  /** Claude Code's own session id (from the init event) — used to --resume. */
64
64
  claudeSessionId = null;
65
+ /** Codex's thread id from `thread.started` — used only by the explicit-ID resume path (#848). */
66
+ codexThreadId = null;
65
67
  /**
66
68
  * The cloud's context brief, until the first turn spends it (ADR 0005, #693). Null once
67
69
  * delivered, and null from the start when the engine resumed its own conversation.
@@ -96,6 +98,12 @@ export class HeadlessSession {
96
98
  * carry a previous turn's line.
97
99
  */
98
100
  turnLastLine = "";
101
+ /** True once this session has retried an old Codex CLI without its unsupported `--json` flag. */
102
+ fellBackToRaw = false;
103
+ /** Structured events observed in the current one-shot process. */
104
+ sawStructuredEvent = false;
105
+ /** A Codex CLI explicitly rejected the `--json` flag in the current one-shot process. */
106
+ structuredOutputRejected = false;
99
107
  /** Measured engine spend not yet handed to the cloud (#267). Drained by {@link takeUsage}. */
100
108
  pendingUsage = [];
101
109
  /**
@@ -196,11 +204,13 @@ export class HeadlessSession {
196
204
  * clean, so a cloud that announced "resumed where we left off" on its own intent would be
197
205
  * telling most of the fleet's users the opposite of what happened.
198
206
  *
199
- * False for a raw (non-Claude) engine under every circumstance — `--resume` is a Claude Code
200
- * flag and {@link buildClaudeArgs} is only reached in stream-json mode.
207
+ * False for raw engines. Claude has a persistent protocol-level resume; supported structured
208
+ * `codex exec` uses the separately proven explicit-ID one-shot path (#848), only as the
209
+ * machine-local stopgap before #693's platform-owned timeline becomes authoritative.
201
210
  */
202
211
  get resumedConversation() {
203
- return this.config.clientType === "claude" && this.mode === "stream-json" && this.claudeSessionId !== null;
212
+ return ((this.config.clientType === "claude" && this.mode === "stream-json" && this.claudeSessionId !== null) ||
213
+ (this.config.clientType === "codex" && this.mode === "stream-json" && this.codexThreadId !== null));
204
214
  }
205
215
  /**
206
216
  * Did this engine come up cold AND with a brief to lead its first turn (ADR 0005, #693)?
@@ -216,11 +226,6 @@ export class HeadlessSession {
216
226
  constructor(config) {
217
227
  this.config = config;
218
228
  this.engineLabel = `${config.clientType}:${config.id}`;
219
- // Our own key first, the cloud's nominated predecessor second. See `resumeFrom`.
220
- this.claudeSessionId =
221
- config.clientType === "claude"
222
- ? readState(config.statePath, config.id) ?? (config.resumeFrom ? readState(config.statePath, config.resumeFrom) : null)
223
- : null;
224
229
  const { bin, args } = parseCommand(config.command);
225
230
  // When no explicit command is configured, fall back to THIS engine's default
226
231
  // command (codex/gemini/grok/…) — not a hard-coded "claude", which would drive a
@@ -233,6 +238,16 @@ export class HeadlessSession {
233
238
  this.cmdArgs = bin ? args : fallback.args;
234
239
  this.adapter = engineAdapterFor(config.clientType, this.cmdArgs);
235
240
  this.mode = this.adapter.mode;
241
+ // Our own key first, the cloud's nominated predecessor second. See `resumeFrom`. Codex uses
242
+ // a distinct key namespace: a vendor thread id is not interchangeable with Claude's id.
243
+ this.claudeSessionId =
244
+ config.clientType === "claude"
245
+ ? readState(config.statePath, config.id, "claude") ?? (config.resumeFrom ? readState(config.statePath, config.resumeFrom, "claude") : null)
246
+ : null;
247
+ this.codexThreadId =
248
+ config.clientType === "codex" && this.mode === "stream-json"
249
+ ? readCodexState(config.statePath, config.id) ?? (config.resumeFrom ? readCodexState(config.statePath, config.resumeFrom) : null)
250
+ : null;
236
251
  // AFTER both lines above, because `resumedConversation` reads them: the brief is the fallback,
237
252
  // so an engine that found its own conversation drops it unread rather than being handed a
238
253
  // summary of the conversation it is already in.
@@ -409,7 +424,7 @@ export class HeadlessSession {
409
424
  // start is a clean session rather than looping on a dead id.
410
425
  if (code && code !== 0 && this.claudeSessionId) {
411
426
  this.claudeSessionId = null;
412
- writeState(this.config.statePath, this.config.id, null);
427
+ writeState(this.config.statePath, this.config.id, null, "claude");
413
428
  }
414
429
  });
415
430
  }
@@ -473,7 +488,13 @@ export class HeadlessSession {
473
488
  // Arm the per-turn line capture BEFORE the spawn, so a report can only ever carry a line
474
489
  // this turn produced (#545).
475
490
  this.turnLastLine = "";
476
- const proc = spawn(this.cmdBin, this.adapter.buildTurnArgs(this.cmdArgs, text), {
491
+ this.sawStructuredEvent = false;
492
+ this.structuredOutputRejected = false;
493
+ const resumedCodexThreadId = this.codexResumeThreadId;
494
+ const turnArgs = resumedCodexThreadId
495
+ ? buildCodexResumeArgs(this.cmdArgs, resumedCodexThreadId, text)
496
+ : this.adapter.buildTurnArgs(this.cmdArgs, text);
497
+ const proc = spawn(this.cmdBin, turnArgs, {
477
498
  cwd: this.config.workDir,
478
499
  env: this.spawnEnv,
479
500
  stdio: ["ignore", "pipe", "pipe"],
@@ -541,6 +562,25 @@ export class HeadlessSession {
541
562
  // kill-tmux.
542
563
  if (this.proc !== proc)
543
564
  return;
565
+ // A runner cannot know the Codex version before spawning it. An old binary reliably
566
+ // rejects the flag before it executes the prompt, so retrying only this precise failure
567
+ // is safe. Do NOT downgrade merely because a line failed JSON parsing: current Codex can
568
+ // interleave tool/MCP stderr with valid JSONL, and a second run could repeat real work.
569
+ if (!this.fellBackToRaw && code && !this.sawStructuredEvent && this.structuredOutputRejected) {
570
+ this.fellBackToRaw = true;
571
+ this.adapter = genericRawEngineAdapter;
572
+ this.mode = this.adapter.mode;
573
+ this.proc = null;
574
+ this.push(`[${this.config.clientType} CLI rejected --json; retrying this turn with raw output]`);
575
+ this.runOneShot(text);
576
+ return;
577
+ }
578
+ // A rejected/expired explicit ID is not retried as the same turn: the CLI may have started
579
+ // work before failing. Drop only that known-bad local key so the NEXT turn is safely fresh.
580
+ if (code && resumedCodexThreadId && this.codexThreadId === resumedCodexThreadId) {
581
+ this.codexThreadId = null;
582
+ writeState(this.config.statePath, this.config.id, null, "codex");
583
+ }
544
584
  // THE EXIT CODE STOPS BEING ONLY PROSE HERE (#545). Recorded after the staleness guard
545
585
  // on purpose: a turn aborted by its successor (see the kill above) must not overwrite
546
586
  // the report of the turn that replaced it — the loser's outcome is about a turn nobody
@@ -599,8 +639,12 @@ export class HeadlessSession {
599
639
  this.buf = this.buf.slice(nl + 1);
600
640
  if (!line)
601
641
  continue;
602
- if (this.mode === "stream-json")
603
- this.handle(line);
642
+ if (this.mode === "stream-json") {
643
+ if (this.handle(line))
644
+ this.sawStructuredEvent = true;
645
+ else if (this.adapter.rejectsStructuredOutput?.(line))
646
+ this.structuredOutputRejected = true;
647
+ }
604
648
  else
605
649
  this.pushRaw(line); // raw engine — the line IS the terminal output
606
650
  }
@@ -617,6 +661,10 @@ export class HeadlessSession {
617
661
  this.buf = "";
618
662
  }
619
663
  }
664
+ /** A stored id is used only for the supported structured `codex exec` shape. */
665
+ get codexResumeThreadId() {
666
+ return this.config.clientType === "codex" && this.mode === "stream-json" && isCodexThreadId(this.codexThreadId) ? this.codexThreadId : null;
667
+ }
620
668
  /** Raw-engine stdout: strip ANSI control codes and append to the transcript. */
621
669
  pushRaw(line) {
622
670
  const clean = stripAnsi(line);
@@ -630,12 +678,17 @@ export class HeadlessSession {
630
678
  this.transcript = this.transcript.slice(-3000);
631
679
  }
632
680
  handle(line) {
633
- for (const ev of this.adapter.parseLine(line)) {
681
+ const events = this.adapter.parseLine(line);
682
+ for (const ev of events) {
634
683
  switch (ev.kind) {
635
684
  case "session":
636
685
  if (this.config.clientType === "claude") {
637
686
  this.claudeSessionId = ev.sessionId;
638
- writeState(this.config.statePath, this.config.id, ev.sessionId);
687
+ writeState(this.config.statePath, this.config.id, ev.sessionId, "claude");
688
+ }
689
+ else if (this.config.clientType === "codex" && isCodexThreadId(ev.sessionId)) {
690
+ this.codexThreadId = ev.sessionId;
691
+ writeState(this.config.statePath, this.config.id, ev.sessionId, "codex");
639
692
  }
640
693
  break;
641
694
  case "assistant_text":
@@ -695,6 +748,7 @@ export class HeadlessSession {
695
748
  // character bound that matters is `MAX_PANE` in runtime.ts, applied on the way out.
696
749
  if (this.transcript.length > 4000)
697
750
  this.transcript = this.transcript.slice(-3000);
751
+ return events.length > 0;
698
752
  }
699
753
  push(line) {
700
754
  this.transcript.push(line);
@@ -842,36 +896,3 @@ export function parseCommand(command) {
842
896
  }
843
897
  return { bin: tokens[0] ?? "", args: tokens.slice(1) };
844
898
  }
845
- function loadFile(path) {
846
- if (!path || !existsSync(path))
847
- return {};
848
- try {
849
- return JSON.parse(readFileSync(path, "utf8"));
850
- }
851
- catch {
852
- return {};
853
- }
854
- }
855
- function readState(path, id) {
856
- return loadFile(path)[id] ?? null;
857
- }
858
- function writeState(path, id, claudeSessionId) {
859
- if (!path)
860
- return;
861
- try {
862
- const data = loadFile(path);
863
- if (claudeSessionId)
864
- data[id] = claudeSessionId;
865
- else
866
- delete data[id];
867
- mkdirSync(dirname(path), { recursive: true });
868
- writeFileSync(path, JSON.stringify(data));
869
- }
870
- catch {
871
- /* best-effort persistence */
872
- }
873
- }
874
- /** Default location for the resume-id store, under the repos base dir. */
875
- export function defaultStatePath(reposBaseDir) {
876
- return join(reposBaseDir, "headless-sessions.json");
877
- }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Cold-start clones as BACKGROUND jobs, over https or SSH (#858).
3
+ *
4
+ * #857's clone was one synchronous `git clone` inside a relay command: a repository that took longer
5
+ * than the relay's two-minute ceiling failed the call while git kept going, and `execFileSync` held the
6
+ * runner's event loop — its relay socket included — for as long as git ran. Here the clone is a job:
7
+ * started, answered at once, and run with an async `git` so the runner keeps serving everything else.
8
+ * The cloud reads the job back until it is `done` or `failed`; one job per folder, so asking again
9
+ * joins the clone in flight instead of starting a second one.
10
+ *
11
+ * Which URL: the machine's own credentials decide. https first (its credential helper — what
12
+ * `gh auth login` configures). If that is refused and the machine holds an SSH identity for github.com,
13
+ * `git@github.com:<owner>/<repo>.git` next — a machine that reaches GitHub only through a key used to
14
+ * fail a private repository with "could not read Username". `protocol` pins one or the other.
15
+ *
16
+ * The folder guards are {@link ensureRepo}'s owner-folder rules: an absent or empty folder is cloned
17
+ * into, a folder with anything in it never is, an empty folder inside another checkout is refused.
18
+ */
19
+ import { execFile } from "node:child_process";
20
+ import { rmSync } from "node:fs";
21
+ import { promisify } from "node:util";
22
+ import { checkWorkdir, probeGitSshIdentity } from "./repo.js";
23
+ const run = promisify(execFile);
24
+ export const cloneUrlFor = (slug, via) => (via === "ssh" ? `git@github.com:${slug}.git` : `https://github.com/${slug}.git`);
25
+ /**
26
+ * Run a job to its end. Never rejects: every outcome is written onto `job`.
27
+ *
28
+ * `auto` tries https, then SSH only when https was refused AND the machine has an SSH identity — the
29
+ * probe runs only then, because it is a network round trip a clone that already worked does not need.
30
+ */
31
+ export async function runCloneJob(job, protocol, deps) {
32
+ const order = protocol === "ssh" ? ["ssh"] : protocol === "https" ? ["https"] : ["https", "ssh"];
33
+ for (const via of order) {
34
+ if (via === "ssh" && protocol === "auto") {
35
+ const identity = deps.sshIdentity();
36
+ if (!identity) {
37
+ job.attempts.push("ssh: not tried — this machine has no SSH key that github.com accepts");
38
+ break;
39
+ }
40
+ }
41
+ try {
42
+ await deps.clone(job.path, cloneUrlFor(job.slug, via));
43
+ return Object.assign(job, { state: "done", via, finishedAt: deps.now() });
44
+ }
45
+ catch (e) {
46
+ job.attempts.push(`${via}: ${e instanceof Error ? e.message : String(e)}`);
47
+ }
48
+ }
49
+ return Object.assign(job, {
50
+ state: "failed",
51
+ finishedAt: deps.now(),
52
+ error: `${job.attempts.join(" | ")} — the machine clones with its OWN git credentials: sign in over https there (\`gh auth login\`), or add an SSH key that github.com accepts.`,
53
+ });
54
+ }
55
+ /**
56
+ * Clone `url` into the owner's folder `dir` — async, never prompting, with the owner-folder guards.
57
+ * Rejects with git's reason (scrubbed of nothing: these URLs carry no token).
58
+ */
59
+ export async function cloneIntoOwnFolder(dir, url) {
60
+ const at = checkWorkdir(dir);
61
+ if (at.exists && at.isDirectory && at.entryCount > 0)
62
+ throw new Error(`"${dir}" is not empty — never cloned into`);
63
+ if (at.exists && !at.isDirectory)
64
+ throw new Error(`"${dir}" is a file, not a folder`);
65
+ if (at.exists && at.insideWorkTree)
66
+ throw new Error(`"${dir}" is an empty folder inside another git checkout — not cloning a second repository into it`);
67
+ if (at.exists)
68
+ rmSync(dir, { recursive: true, force: true });
69
+ try {
70
+ await run("git", ["clone", url, dir], {
71
+ timeout: 60 * 60_000,
72
+ // No prompt, ever: a refusal must fail with git's reason, not wait on input nobody will give.
73
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new" },
74
+ });
75
+ }
76
+ catch (e) {
77
+ const stderr = String(e.stderr ?? "").trim() || (e instanceof Error ? e.message : "git clone failed");
78
+ throw new Error(stderr.slice(0, 300));
79
+ }
80
+ }
81
+ /** The production deps: real git, the real SSH probe. */
82
+ export const liveCloneDeps = {
83
+ clone: cloneIntoOwnFolder,
84
+ sshIdentity: () => probeGitSshIdentity("github.com").identity,
85
+ now: () => Date.now(),
86
+ };
87
+ /**
88
+ * The runner's clone jobs, one per folder. `start` joins a clone in flight rather than starting a
89
+ * second; a finished or failed job is replaced by a new start, which is how a failed clone is retried.
90
+ */
91
+ export class CloneJobs {
92
+ deps;
93
+ jobs = new Map();
94
+ constructor(deps = liveCloneDeps) {
95
+ this.deps = deps;
96
+ }
97
+ start(path, slug, protocol = "auto") {
98
+ const current = this.jobs.get(path);
99
+ if (current?.state === "cloning")
100
+ return current;
101
+ const job = { path, slug, state: "cloning", attempts: [], startedAt: this.deps.now() };
102
+ this.jobs.set(path, job);
103
+ void runCloneJob(job, protocol, this.deps);
104
+ return job;
105
+ }
106
+ status(path) {
107
+ return this.jobs.get(path) ?? { path, state: "none" };
108
+ }
109
+ }
@@ -115,6 +115,23 @@ export function probeGitSshIdentity(host) {
115
115
  const isDeployKey = identity === null ? null : identity.includes("/");
116
116
  return { checked: true, host, identity, isDeployKey, raw };
117
117
  }
118
+ /**
119
+ * Clone `cloneUrl` into the owner's folder `dir` — only when there is nothing there yet (#857).
120
+ *
121
+ * The cold-start step `coding_repo_add … clone:true` asks for. It is {@link ensureRepo}'s owner-folder
122
+ * clone and inherits every one of its guards: an existing checkout is left untouched, a folder with
123
+ * anything in it is never cloned into, an empty folder inside another checkout is refused, and the
124
+ * clone is a full one whose `origin` is the plain URL. Authentication is the MACHINE's own — its git
125
+ * credential helper for https — and a repository it cannot read fails with git's reason.
126
+ *
127
+ * `cloned` says whether this call cloned; false means the folder was already there and was not touched.
128
+ */
129
+ export function cloneIntoWorkdir(dir, cloneUrl) {
130
+ const before = checkWorkdir(dir);
131
+ const empty = !before.exists || (before.isDirectory && before.entryCount === 0);
132
+ ensureRepo(dir, { cloneUrl, ownFolder: true });
133
+ return { cloned: empty, path: dir };
134
+ }
118
135
  /**
119
136
  * Ensure a repo is present at `dir`, cloning it from `cloneUrl` if not. Idempotent
120
137
  * — an existing checkout is left alone (no clobber). For private repos the cloud
@@ -126,6 +143,14 @@ export function probeGitSshIdentity(host) {
126
143
  * `x-access-token` — the value this function used to hardcode — so an older cloud that
127
144
  * sends only `token` behaves exactly as before.
128
145
  *
146
+ * `ownFolder` marks a path the OWNER configured (a local checkout) rather than a managed dir
147
+ * (#828). Such a path is cloned into only when there is nothing there yet — absent, or an empty
148
+ * folder that is not inside another checkout — which is the normal state of an unpinned
149
+ * instance landing on a machine that has never cloned this repo. A folder with anything in it
150
+ * is run in exactly as it is, the behaviour a local path always had. The clone is a full one
151
+ * (the owner's own checkout must be able to rebase and read history), and its `origin` is left
152
+ * without the short-lived token, which would otherwise break every later push once it expired.
153
+ *
129
154
  * Returns the absolute working directory. Throws on clone failure so the caller
130
155
  * can surface it (a session can't start without its repo).
131
156
  */
@@ -147,15 +172,36 @@ export function ensureRepo(dir, opts = {}) {
147
172
  if (existsSync(dir)) {
148
173
  const entries = readdirSync(dir);
149
174
  if (entries.length > 0) {
175
+ // The owner's own folder (a monorepo subfolder, a plain project) — run in it untouched.
176
+ if (opts.ownFolder)
177
+ return dir;
150
178
  throw new Error(`Refusing to clone into non-empty directory "${dir}" (no .git found) — move it aside or point at an empty path.`);
151
179
  }
180
+ if (opts.ownFolder && checkWorkdir(dir).insideWorkTree) {
181
+ throw new Error(`"${dir}" is an empty folder inside another git checkout — not cloning a second repository into it. Point this repo at the checkout itself, or remove the folder.`);
182
+ }
152
183
  rmSync(dir, { recursive: true, force: true });
153
184
  }
154
185
  const url = authenticatedCloneUrl(opts.cloneUrl, opts.token, opts.tokenUsername);
155
- const args = ["clone", "--depth", "1"];
186
+ const args = ["clone"];
187
+ if (!opts.ownFolder)
188
+ args.push("--depth", "1");
156
189
  if (opts.branch)
157
190
  args.push("--branch", opts.branch);
158
191
  args.push(url, dir);
159
- execFileSync("git", args, { stdio: "pipe", timeout: 180_000 });
192
+ try {
193
+ // No prompt, ever (#857): a clone the machine's credentials cannot authorise must FAIL, with git's
194
+ // own reason, rather than wait on a username prompt no one will ever see until the timeout.
195
+ execFileSync("git", args, { stdio: "pipe", timeout: 180_000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
196
+ if (opts.ownFolder && url !== opts.cloneUrl)
197
+ execFileSync("git", ["remote", "set-url", "origin", opts.cloneUrl], { cwd: dir, stdio: "pipe" });
198
+ }
199
+ catch (e) {
200
+ // `e.message` carries the whole command line, token included — the reason git's own
201
+ // stderr is used instead, and the token scrubbed from that too in case git echoed the URL.
202
+ const stderr = String(e.stderr ?? "").trim() || "git clone failed";
203
+ const why = opts.token ? stderr.split(opts.token).join("***") : stderr;
204
+ throw new Error(`Could not clone ${opts.cloneUrl} into "${dir}": ${why.slice(0, 400)}`);
205
+ }
160
206
  return dir;
161
207
  }
@@ -4,13 +4,16 @@ import { RunnerInputError } from "../errors.js";
4
4
  import { defaultStatePath, HeadlessSession } from "./headless.js";
5
5
  import { InspectError, readGitRemoteOrigin, readRepoFile, repoSearch, repoSync, repoTree, runRepoGit } from "./inspect.js";
6
6
  import { fastForwardRepo, switchRepoBranch } from "./repo-write.js";
7
- import { checkWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
7
+ import { checkWorkdir, cloneIntoWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
8
+ import { CloneJobs } from "./repo-clone-job.js";
8
9
  import { asTurnAuthor } from "./turn-author.js";
9
10
  /** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
10
11
  const MAX_PANE = 64 * 1024;
11
12
  export class CodingRuntime {
12
13
  reposBaseDir;
13
14
  sessions = new Map();
15
+ /** Background cold-start clones, one per folder (#858). */
16
+ cloneJobs = new CloneJobs();
14
17
  /**
15
18
  * Active human handoffs keyed by session id. `resolved` flips when the human
16
19
  * finishes (console "Resume" / submits a value); the brain workflow polls
@@ -111,6 +114,28 @@ export class CodingRuntime {
111
114
  checkRepo(input) {
112
115
  return checkWorkdir(this.resolveWorkDir(input));
113
116
  }
117
+ /**
118
+ * Start — or join — a background clone of GitHub `slug` into an absent or empty owner folder (#858).
119
+ * Answers at once with the job; `cloneStatus` reads it back. See `repo-clone-job.ts`.
120
+ */
121
+ startClone(input) {
122
+ if (!input.workDir || !input.slug || !/^[\w.-]+\/[\w.-]+$/.test(input.slug))
123
+ throw new InspectError("workDir and an owner/repo slug are required");
124
+ const protocol = input.protocol === "https" || input.protocol === "ssh" ? input.protocol : "auto";
125
+ return this.cloneJobs.start(this.resolveWorkDir({ workDir: input.workDir }), input.slug, protocol);
126
+ }
127
+ /** The background clone for this folder, or `state: "none"` (#858). */
128
+ cloneStatus(input) {
129
+ if (!input.workDir)
130
+ throw new InspectError("workDir is required");
131
+ return this.cloneJobs.status(this.resolveWorkDir({ workDir: input.workDir }));
132
+ }
133
+ /** Clone a repository into an absent or empty owner folder (#857) — see `cloneIntoWorkdir`. */
134
+ cloneRepo(input) {
135
+ if (!input.workDir || !input.cloneUrl)
136
+ throw new InspectError("workDir and cloneUrl are required");
137
+ return cloneIntoWorkdir(this.resolveWorkDir({ workDir: input.workDir }), input.cloneUrl);
138
+ }
114
139
  static taskTypes() {
115
140
  return ["coding.session"];
116
141
  }
@@ -121,10 +146,11 @@ export class CodingRuntime {
121
146
  // Resolve the working dir and ensure the repo is present (clone on first
122
147
  // start). A user-supplied local path may use ~ — expand it; otherwise
123
148
  // clone into a managed dir. Without this the CLI would launch nowhere.
149
+ // A local path is cloned into only when absent or empty (#828).
124
150
  const workDir = input.workDir
125
151
  ? resolve(input.workDir.replace(/^~(?=$|\/)/, homedir()))
126
152
  : join(this.reposBaseDir, sanitizeSessionName(input.repoId));
127
- ensureRepo(workDir, { cloneUrl: input.cloneUrl, branch: input.branch, token: input.token, tokenUsername: input.tokenUsername });
153
+ ensureRepo(workDir, { cloneUrl: input.cloneUrl ?? input.emptyCheckoutCloneUrl, branch: input.branch, token: input.token, tokenUsername: input.tokenUsername, ownFolder: Boolean(input.workDir) });
128
154
  session = new HeadlessSession({
129
155
  id: input.sessionId,
130
156
  workDir,
@@ -254,6 +280,8 @@ export class CodingRuntime {
254
280
  sessionId,
255
281
  alive: s.alive,
256
282
  engineLabel: s.engineLabel,
283
+ // Whether an engine is mid-turn — what `runner_update` waits on before restarting (#859).
284
+ runState: s.runState(),
257
285
  }));
258
286
  }
259
287
  /** Rich diagnostics for every tracked session — the console's transparency view. */
@@ -94,7 +94,7 @@ export class LocalRunner {
94
94
  runtimePlane: "pags",
95
95
  runnerRole: "tool-executor",
96
96
  capabilities: [...CAPABILITIES, ...CodingRuntime.capabilities()],
97
- taskTypes: ["echo", "browser.open", "job.apply_agent", ...CodingRuntime.taskTypes()],
97
+ taskTypes: ["echo", "browser.open", "job.apply_agent", "site_builder_runtime", ...CodingRuntime.taskTypes()],
98
98
  approvalRequiredFor: [...APPROVAL_REQUIRED_TASKS],
99
99
  };
100
100
  }
@@ -187,6 +187,39 @@ export class LocalRunner {
187
187
  void this.endTakeover(id).catch(() => undefined);
188
188
  return task;
189
189
  }
190
+ /**
191
+ * Receive a bounded FWS capture manifest from the PAGS broker. The pixels remain behind the
192
+ * job-scoped signed URLs; keeping the manifest on the durable local task lets Claude/Codex
193
+ * inspect both layouts after a relay reconnect without ever receiving FWS OAuth material.
194
+ */
195
+ appendCaptureArtifacts(id, artifacts) {
196
+ const task = this.requireTask(id);
197
+ if (task.type !== "site_builder_runtime")
198
+ throw new RunnerInputError("Capture artifacts are only valid for Website Builder tasks");
199
+ if (!Array.isArray(artifacts) || artifacts.length < 1 || artifacts.length > 2)
200
+ throw new RunnerInputError("Expected one or two capture artifacts");
201
+ const valid = artifacts.map((item) => {
202
+ if (!item || typeof item !== "object" || Array.isArray(item))
203
+ throw new RunnerInputError("Invalid capture artifact");
204
+ const a = item;
205
+ if (typeof a.id !== "string" || !/^[a-f0-9]{64}$/.test(a.id) || (a.device !== "desktop" && a.device !== "mobile") ||
206
+ typeof a.contentType !== "string" || typeof a.bytes !== "number" || !Number.isInteger(a.bytes) || a.bytes <= 0 || a.bytes > 4 * 1024 * 1024 ||
207
+ typeof a.url !== "string" || a.url.length > 4_000 || !a.url.startsWith("https://api.proagentstore.online/")) {
208
+ throw new RunnerInputError("Invalid capture artifact");
209
+ }
210
+ return a;
211
+ });
212
+ const prior = Array.isArray(task.input.captureArtifacts) ? task.input.captureArtifacts.filter((item) => !!item && typeof item === "object" && !Array.isArray(item)) : [];
213
+ const merged = new Map();
214
+ for (const artifact of [...prior, ...valid])
215
+ if (typeof artifact.id === "string")
216
+ merged.set(artifact.id, artifact);
217
+ task.input = { ...task.input, captureArtifacts: [...merged.values()].slice(-8) };
218
+ task.updatedAt = new Date().toISOString();
219
+ this.store.putTask(task);
220
+ this.addTaskEvent(task, "site_builder.capture_received", `FWS ${valid.map((artifact) => artifact.device).join(" + ")} capture received`, { artifacts: valid.map(({ id, device, contentType, bytes }) => ({ id, device, contentType, bytes })) });
221
+ return task;
222
+ }
190
223
  /**
191
224
  * Tear everything down, and never let one failure strand the rest (#274).
192
225
  *
@@ -82,6 +82,11 @@ async function route(runner, req, res) {
82
82
  if (req.method === "POST" && cancelMatch) {
83
83
  return json(res, 200, runner.cancelTask(cancelMatch[1]));
84
84
  }
85
+ const artifactMatch = path.match(/^\/tasks\/([^/]+)\/artifacts$/);
86
+ if (req.method === "POST" && artifactMatch) {
87
+ const body = await readJson(req);
88
+ return json(res, 200, runner.appendCaptureArtifacts(artifactMatch[1], body.captureArtifacts ?? []));
89
+ }
85
90
  if (req.method === "GET" && path === "/events") {
86
91
  const limit = clampLimit(url.searchParams.get("limit"), 100, 500);
87
92
  return json(res, 200, { events: runner.store.listEvents(limit) });
@@ -378,6 +383,28 @@ async function route(runner, req, res) {
378
383
  return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
379
384
  }
380
385
  }
386
+ // Background cold-start clones (#858): start (or join) one, and read it back. An older runner 404s
387
+ // both, and the cloud falls back to the synchronous `/coding/clone` below.
388
+ if (req.method === "POST" && (path === "/coding/clone-start" || path === "/coding/clone-status")) {
389
+ const b = await readJson(req);
390
+ try {
391
+ return json(res, 200, path === "/coding/clone-start" ? runner.coding.startClone(b) : runner.coding.cloneStatus(b));
392
+ }
393
+ catch (e) {
394
+ return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
395
+ }
396
+ }
397
+ // Clone into an absent or empty owner folder (#857) — the cold-start half of `coding_repo_add`.
398
+ // An older runner 404s this, and the cloud says the CLI must be updated rather than guessing.
399
+ if (req.method === "POST" && path === "/coding/clone") {
400
+ const b = await readJson(req);
401
+ try {
402
+ return json(res, 200, runner.coding.cloneRepo(b));
403
+ }
404
+ catch (e) {
405
+ return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
406
+ }
407
+ }
381
408
  if (req.method === "POST" && path === "/coding/tree") {
382
409
  const b = await readJson(req);
383
410
  try {
@@ -17,4 +17,6 @@ export const WORKFLOW_DRIVEN_TASKS = new Set([
17
17
  "job.apply_agent",
18
18
  "browser.task",
19
19
  "browser.handoff",
20
+ // #841: local Claude/Codex authors an FWS draft; PAGS retains the audit and deployment gate.
21
+ "site_builder_runtime",
20
22
  ]);
@@ -0,0 +1,97 @@
1
+ // src/bootstrap/payload.ts
2
+ import { execFile } from "child_process";
3
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync } from "fs";
4
+ import { homedir } from "os";
5
+ import { join } from "path";
6
+ import { promisify } from "util";
7
+ var run = promisify(execFile);
8
+ var CLI_PACKAGE = "@proagentstore/cli";
9
+ var BOOTSTRAP_ENV = "PAGS_BOOTSTRAP";
10
+ var NO_SELF_UPDATE_ENV = "PAGS_NO_SELF_UPDATE";
11
+ var KEEP = 2;
12
+ function payloadRoot() {
13
+ return process.env.PAGS_CLI_CACHE || join(homedir(), ".config", "proagentstore", "cli");
14
+ }
15
+ function olderThan(a, b) {
16
+ const parse = (v) => /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim())?.slice(1).map(Number);
17
+ const x = parse(a);
18
+ const y = parse(b);
19
+ if (!x || !y) return false;
20
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i];
21
+ return false;
22
+ }
23
+ function payloadAt(dir) {
24
+ try {
25
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
26
+ if (pkg.name !== CLI_PACKAGE || !pkg.version) return null;
27
+ const entry = join(dir, pkg.pagsPayload || "dist/index.js");
28
+ return existsSync(entry) ? { version: pkg.version, entry } : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+ var installedAt = (prefix) => join(prefix, "node_modules", ...CLI_PACKAGE.split("/"));
34
+ var packageDir = (root, version) => installedAt(join(root, version));
35
+ function cachedPayloads(root = payloadRoot()) {
36
+ let names;
37
+ try {
38
+ names = readdirSync(root);
39
+ } catch {
40
+ return [];
41
+ }
42
+ return names.flatMap((v) => v.startsWith(".") ? [] : payloadAt(packageDir(root, v)) ?? []);
43
+ }
44
+ function newestPayload(payloads) {
45
+ return payloads.reduce((best, p) => !best || olderThan(best.version, p.version) ? p : best, null);
46
+ }
47
+ async function latestPublishedVersion(timeoutMs = 3e4) {
48
+ try {
49
+ const { stdout } = await run("npm", ["view", CLI_PACKAGE, "version"], { timeout: timeoutMs });
50
+ return stdout.trim() || null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+ async function npmInstallInto(dir, version) {
56
+ try {
57
+ await run("npm", ["install", "--prefix", dir, "--omit=dev", "--no-audit", "--no-fund", "--no-save", `${CLI_PACKAGE}@${version}`], { timeout: 5 * 6e4 });
58
+ } catch (e) {
59
+ const stderr = String(e.stderr ?? "").trim();
60
+ throw new Error((stderr || (e instanceof Error ? e.message : String(e))).slice(-400));
61
+ }
62
+ }
63
+ async function installPayload(version, root = payloadRoot(), install = npmInstallInto) {
64
+ const done = payloadAt(packageDir(root, version));
65
+ if (done) return done;
66
+ mkdirSync(root, { recursive: true });
67
+ const scratch = mkdtempSync(join(root, `.install-${version}-`));
68
+ try {
69
+ await install(scratch, version);
70
+ if (payloadAt(installedAt(scratch))?.version !== version) throw new Error(`npm installed no ${CLI_PACKAGE}@${version}`);
71
+ try {
72
+ renameSync(scratch, join(root, version));
73
+ } catch (e) {
74
+ if (!payloadAt(packageDir(root, version))) throw e;
75
+ }
76
+ } finally {
77
+ rmSync(scratch, { recursive: true, force: true });
78
+ }
79
+ const payload = payloadAt(packageDir(root, version));
80
+ if (!payload) throw new Error(`${CLI_PACKAGE}@${version} is not usable after install`);
81
+ for (const old of cachedPayloads(root).sort((a, b) => olderThan(a.version, b.version) ? 1 : -1).slice(KEEP)) {
82
+ rmSync(join(root, old.version), { recursive: true, force: true });
83
+ }
84
+ return payload;
85
+ }
86
+
87
+ export {
88
+ CLI_PACKAGE,
89
+ BOOTSTRAP_ENV,
90
+ NO_SELF_UPDATE_ENV,
91
+ olderThan,
92
+ payloadAt,
93
+ cachedPayloads,
94
+ newestPayload,
95
+ latestPublishedVersion,
96
+ installPayload
97
+ };
package/dist/index.js CHANGED
@@ -1,4 +1,11 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ BOOTSTRAP_ENV,
4
+ CLI_PACKAGE,
5
+ installPayload,
6
+ latestPublishedVersion,
7
+ olderThan
8
+ } from "./chunk-JOWYSZOL.js";
2
9
 
3
10
  // src/index.ts
4
11
  import { createRequire as createRequire3 } from "module";
@@ -1140,6 +1147,9 @@ function findWorkspaceRoot() {
1140
1147
  }
1141
1148
  return process.cwd();
1142
1149
  }
1150
+ function runsFromSource() {
1151
+ return existsSync6(resolve4(findWorkspaceRoot(), "packages", "browser-runner", "src", "index.ts"));
1152
+ }
1143
1153
  function bundledRunnerPath() {
1144
1154
  return fileURLToPath2(new URL("./browser-runner/index.js", import.meta.url));
1145
1155
  }
@@ -1194,6 +1204,40 @@ async function waitForLocalRunner(opts, timeoutMs = 15e3) {
1194
1204
  // src/commands/runner/relay.ts
1195
1205
  import { hostname as hostname3 } from "os";
1196
1206
 
1207
+ // src/commands/runner/self-update.ts
1208
+ import { execFile } from "child_process";
1209
+ import { promisify } from "util";
1210
+ var run = promisify(execFile);
1211
+ var RUNNER_UPDATE_PATH = "/pags/runner/update";
1212
+ var RUNNER_RESTART_EXIT_CODE = 75;
1213
+ var SUPERVISED_ENV = "PAGS_UP_SUPERVISED";
1214
+ function planRunnerUpdate(f) {
1215
+ if (f.fromSource) return { action: "refused", current: f.current, reason: "This runner runs from a source checkout \u2014 update it with `git pull` there, not npm." };
1216
+ if (!f.latest) return { action: "refused", current: f.current, reason: `npm could not be asked for the latest ${CLI_PACKAGE} from this machine.` };
1217
+ if (!olderThan(f.current, f.latest)) return { action: "up-to-date", current: f.current };
1218
+ if (!f.supervised) {
1219
+ return {
1220
+ action: "refused",
1221
+ current: f.current,
1222
+ reason: `This runner was not started by a \`pags up\` that can restart it (\`pags runner connect\` directly, or a \`pags up\` older than the respawn). Update once at the machine: \`npm i -g ${CLI_PACKAGE}\` and restart \`pags up\` \u2014 later updates can then be done remotely.`
1223
+ };
1224
+ }
1225
+ if (f.busy.length > 0) return { action: "wait", current: f.current, latest: f.latest, waitingFor: f.busy };
1226
+ return { action: "update", current: f.current, latest: f.latest };
1227
+ }
1228
+ async function installVersion(version2) {
1229
+ if (process.env[BOOTSTRAP_ENV]) {
1230
+ await installPayload(version2);
1231
+ return;
1232
+ }
1233
+ try {
1234
+ await run("npm", ["i", "-g", `${CLI_PACKAGE}@${version2}`], { timeout: 5 * 6e4 });
1235
+ } catch (e) {
1236
+ const stderr = String(e.stderr ?? "").trim();
1237
+ throw new Error((stderr || (e instanceof Error ? e.message : String(e))).slice(-400));
1238
+ }
1239
+ }
1240
+
1197
1241
  // src/commands/runner/membership.ts
1198
1242
  function isEligible(inst, thisNode, alsoKnownAs = []) {
1199
1243
  if (inst.status !== "active") return false;
@@ -1236,6 +1280,20 @@ function instanceLabel(inst) {
1236
1280
  const short = `${inst.id.slice(0, 8)}\u2026`;
1237
1281
  return inst.name ? `${inst.name} (${short})` : short;
1238
1282
  }
1283
+ function reattachPlan(request, state) {
1284
+ const target = typeof request?.attach === "string" && request.attach ? request.attach : null;
1285
+ const force = target !== null && request?.force === true;
1286
+ if (!state.watching && !(target && state.scope.includes(target))) {
1287
+ return {
1288
+ target,
1289
+ unblock: false,
1290
+ detach: false,
1291
+ force: false,
1292
+ refuse: "This machine's `pags up` was started with --instance, so it serves only that agent. Restart it without --instance to let it take repinned agents."
1293
+ };
1294
+ }
1295
+ return { target, unblock: target !== null && state.blocked, detach: target !== null && state.held, force };
1296
+ }
1239
1297
 
1240
1298
  // src/commands/runner/status-line.ts
1241
1299
  var STATUS_PREFIX = "PAGS-STATUS";
@@ -1268,6 +1326,8 @@ function parseStatusLine(line) {
1268
1326
  }
1269
1327
 
1270
1328
  // src/commands/runner/relay.ts
1329
+ var MEMBERSHIP_SYNC_PATH = "/pags/membership/sync";
1330
+ var CLI_CONTROL_PATHS = /* @__PURE__ */ new Set([MEMBERSHIP_SYNC_PATH, RUNNER_UPDATE_PATH]);
1271
1331
  async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force = false, watchInstances = false) {
1272
1332
  const apiBase = pagsApiBase(opts.apiBase).replace(/^http/, "ws");
1273
1333
  const pagsToken = clean(opts.pagsToken) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
@@ -1303,11 +1363,86 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1303
1363
  };
1304
1364
  const attached = /* @__PURE__ */ new Map();
1305
1365
  const blocked = /* @__PURE__ */ new Set();
1366
+ let syncing = Promise.resolve();
1367
+ const forceNext = /* @__PURE__ */ new Set();
1306
1368
  const reportRegistration = () => {
1307
1369
  const { agents, state } = registrationStatus(attached.keys(), registered);
1308
1370
  writeLine(formatStatusLine({ registration: state, agents, reason: state === "ok" ? void 0 : lastRegisterError }));
1309
1371
  };
1310
1372
  for (const id of instanceIds) await registerRuntime(id);
1373
+ const updateFacts = async () => {
1374
+ const sessions = await requestRunner("GET", "/coding/sessions", {
1375
+ url: localUrl,
1376
+ token: runnerToken,
1377
+ instanceId: instanceIds[0]
1378
+ }).catch(() => ({ sessions: [] }));
1379
+ return {
1380
+ current: CLI_VERSION,
1381
+ latest: await latestPublishedVersion(),
1382
+ fromSource: runsFromSource(),
1383
+ supervised: process.env[SUPERVISED_ENV] === "1",
1384
+ busy: (sessions.sessions ?? []).filter((s) => s.alive && s.runState && s.runState !== "idle").map((s) => s.sessionId)
1385
+ };
1386
+ };
1387
+ const installAndRestart = async (plan) => {
1388
+ writeLine(`Updating ${plan.current} \u2192 ${plan.latest} (runner_update)\u2026`);
1389
+ await installVersion(plan.latest);
1390
+ writeLine(`Installed ${plan.latest} \u2014 restarting; every agent re-attaches on the way back up.`);
1391
+ setTimeout(() => {
1392
+ for (const id of [...attached.keys()]) detach(id);
1393
+ process.exit(RUNNER_RESTART_EXIT_CODE);
1394
+ }, 500).unref();
1395
+ };
1396
+ let updateWaiting = false;
1397
+ const updateWhenIdle = () => {
1398
+ if (updateWaiting) return;
1399
+ updateWaiting = true;
1400
+ const until = Date.now() + 60 * 6e4;
1401
+ const tick = () => setTimeout(async () => {
1402
+ const plan = planRunnerUpdate(await updateFacts());
1403
+ if (plan.action === "update") {
1404
+ await installAndRestart(plan).catch((e) => writeError(`runner_update: install failed \u2014 ${e instanceof Error ? e.message : String(e)}`));
1405
+ updateWaiting = false;
1406
+ } else if (plan.action === "wait" && Date.now() < until) tick();
1407
+ else updateWaiting = false;
1408
+ }, 15e3).unref();
1409
+ tick();
1410
+ };
1411
+ const answerUpdate = async (body) => {
1412
+ const dryRun = body?.dryRun === true;
1413
+ const plan = planRunnerUpdate(await updateFacts());
1414
+ if (dryRun || plan.action === "up-to-date" || plan.action === "refused") return { status: 200, result: { ...plan, dryRun } };
1415
+ if (plan.action === "wait") {
1416
+ updateWhenIdle();
1417
+ return { status: 200, result: { ...plan, detail: "Restarts itself as soon as these engines finish their turns \u2014 no run is cut off." } };
1418
+ }
1419
+ try {
1420
+ await installAndRestart(plan);
1421
+ } catch (e) {
1422
+ return { status: 500, result: { error: `npm could not install ${plan.latest}: ${e instanceof Error ? e.message : String(e)}` } };
1423
+ }
1424
+ return { status: 200, result: { action: "restarting", current: plan.current, latest: plan.latest } };
1425
+ };
1426
+ const answerControl = async (path, body) => {
1427
+ if (path === RUNNER_UPDATE_PATH) return answerUpdate(body);
1428
+ if (path !== MEMBERSHIP_SYNC_PATH) return { status: 404, result: { error: `Unknown runner control ${path}` } };
1429
+ const request = body;
1430
+ const named = typeof request?.attach === "string" ? request.attach : "";
1431
+ const plan = reattachPlan(request, { held: attached.has(named), blocked: blocked.has(named), watching: watchInstances, scope: instanceIds });
1432
+ if (plan.refuse) return { status: 409, result: { error: plan.refuse } };
1433
+ if (plan.target) {
1434
+ if (plan.unblock) blocked.delete(plan.target);
1435
+ if (plan.detach) detach(plan.target);
1436
+ if (plan.force) forceNext.add(plan.target);
1437
+ }
1438
+ if (watchInstances) await syncMembership();
1439
+ else if (plan.target) {
1440
+ await registerRuntime(plan.target);
1441
+ attach(plan.target);
1442
+ }
1443
+ if (plan.target && !attached.has(plan.target)) forceNext.delete(plan.target);
1444
+ return { status: 200, result: { attached: [...attached.keys()], ...plan.target ? { target: plan.target, holding: attached.has(plan.target) } : {} } };
1445
+ };
1311
1446
  const attach = (id, label = `${id.slice(0, 8)}\u2026`) => {
1312
1447
  if (attached.has(id)) return;
1313
1448
  const mintToken = () => requestPags("POST", `/v1/relay/${apiPathSegment(id)}/token`, { ...opts, pagsToken }, {}).then((r) => r.token);
@@ -1319,7 +1454,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1319
1454
  mintToken,
1320
1455
  localUrl,
1321
1456
  runnerToken,
1322
- force,
1457
+ // `pags up --force` for the whole process, or for this one agent at the cloud's request (#856).
1458
+ force || forceNext.delete(id),
1323
1459
  (conflicted) => {
1324
1460
  blocked.add(conflicted);
1325
1461
  attached.delete(conflicted);
@@ -1336,7 +1472,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1336
1472
  if (!shouldRegisterOnOpen(reconnect, registered.has(openedId))) return;
1337
1473
  await registerRuntime(openedId, reconnect ? false : force);
1338
1474
  reportRegistration();
1339
- }
1475
+ },
1476
+ answerControl
1340
1477
  )
1341
1478
  );
1342
1479
  if (label) writeLine(`Attached agent: ${label}`);
@@ -1396,36 +1533,42 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1396
1533
  writeLine(`Relay conflict cleared: ${id.slice(0, 8)}\u2026 \u2014 the other runner is gone; reattaching.`);
1397
1534
  }
1398
1535
  }
1536
+ function syncMembership() {
1537
+ syncing = syncing.catch(() => void 0).then(async () => {
1538
+ await clearFinishedConflicts();
1539
+ const res = await requestPags(
1540
+ "GET",
1541
+ "/v1/instances/my/instances",
1542
+ { ...opts, pagsToken }
1543
+ );
1544
+ const { attach: toAttach, detach: toDetach } = diffMembership(
1545
+ attached.keys(),
1546
+ res.instances ?? [],
1547
+ runnerNode,
1548
+ blocked,
1549
+ // The names this machine has also worn. Without them a pin made under a
1550
+ // previous hostname reads as "pinned to another machine", and this poll
1551
+ // detaches the agent twenty seconds after startup attached it (#379).
1552
+ machine.names
1553
+ );
1554
+ for (const inst of toAttach) {
1555
+ await registerRuntime(inst.id);
1556
+ attach(inst.id, instanceLabel(inst));
1557
+ }
1558
+ for (const id of toDetach) detach(id);
1559
+ const pending = pendingRegistrations(attached.keys(), registered);
1560
+ if (pending.length) {
1561
+ for (const id of pending) await registerRuntime(id);
1562
+ reportRegistration();
1563
+ }
1564
+ });
1565
+ return syncing;
1566
+ }
1399
1567
  function startDiscovery() {
1400
1568
  const tick = () => {
1401
1569
  const timer = setTimeout(async () => {
1402
1570
  try {
1403
- await clearFinishedConflicts();
1404
- const res = await requestPags(
1405
- "GET",
1406
- "/v1/instances/my/instances",
1407
- { ...opts, pagsToken }
1408
- );
1409
- const { attach: toAttach, detach: toDetach } = diffMembership(
1410
- attached.keys(),
1411
- res.instances ?? [],
1412
- runnerNode,
1413
- blocked,
1414
- // The names this machine has also worn. Without them a pin made under a
1415
- // previous hostname reads as "pinned to another machine", and this poll
1416
- // detaches the agent twenty seconds after startup attached it (#379).
1417
- machine.names
1418
- );
1419
- for (const inst of toAttach) {
1420
- await registerRuntime(inst.id);
1421
- attach(inst.id, instanceLabel(inst));
1422
- }
1423
- for (const id of toDetach) detach(id);
1424
- const pending = pendingRegistrations(attached.keys(), registered);
1425
- if (pending.length) {
1426
- for (const id of pending) await registerRuntime(id);
1427
- reportRegistration();
1428
- }
1571
+ await syncMembership();
1429
1572
  } catch {
1430
1573
  }
1431
1574
  tick();
@@ -1435,7 +1578,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1435
1578
  tick();
1436
1579
  }
1437
1580
  }
1438
- function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen) {
1581
+ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl) {
1439
1582
  let backoffMs = 1e3;
1440
1583
  let reconnecting = false;
1441
1584
  let closed = false;
@@ -1489,6 +1632,14 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
1489
1632
  return;
1490
1633
  }
1491
1634
  if (!cmd.id || !cmd.path) return;
1635
+ if (onControl && CLI_CONTROL_PATHS.has(cmd.path)) {
1636
+ const reply = await onControl(cmd.path, cmd.body).catch((err) => ({ status: 500, result: { error: err instanceof Error ? err.message : String(err) } }));
1637
+ try {
1638
+ ws.send(JSON.stringify({ id: cmd.id, ...reply }));
1639
+ } catch {
1640
+ }
1641
+ return;
1642
+ }
1492
1643
  const method = (cmd.method || "POST").toUpperCase();
1493
1644
  const hasBody = method !== "GET" && method !== "HEAD" && cmd.body !== void 0;
1494
1645
  try {
@@ -1594,6 +1745,7 @@ function createRunnerCommand() {
1594
1745
  shuttingDown = true;
1595
1746
  if (!runner.killed) runner.kill("SIGTERM");
1596
1747
  };
1748
+ process.once("exit", shutdown);
1597
1749
  process.once("SIGINT", () => {
1598
1750
  shutdown();
1599
1751
  process.exit(0);
@@ -1952,10 +2104,11 @@ var upCommand = new Command8("up").description("Start the browser runner for all
1952
2104
  if (opts.headless) args.push("--headless");
1953
2105
  if (opts.force) args.push("--force");
1954
2106
  if (!opts.instance) args.push("--watch-instances");
1955
- const child = spawn4(process.execPath, args, {
2107
+ const spawnChild = () => spawn4(process.execPath, args, {
1956
2108
  stdio: ["ignore", "pipe", "pipe"],
1957
- env: { ...process.env, PAGS_TOKEN: session.token }
2109
+ env: { ...process.env, PAGS_TOKEN: session.token, [SUPERVISED_ENV]: "1" }
1958
2110
  });
2111
+ let child = spawnChild();
1959
2112
  const logs = [];
1960
2113
  const handleOutput = (data2) => {
1961
2114
  const text = data2.toString("utf-8");
@@ -2013,10 +2166,20 @@ var upCommand = new Command8("up").description("Start the browser runner for all
2013
2166
  }
2014
2167
  }
2015
2168
  };
2016
- child.stdout?.on("data", handleOutput);
2017
- child.stderr?.on("data", handleOutput);
2018
2169
  let childDead = false;
2019
- child.on("exit", (code) => {
2170
+ const wire = () => {
2171
+ child.stdout?.on("data", handleOutput);
2172
+ child.stderr?.on("data", handleOutput);
2173
+ child.on("exit", onChildExit);
2174
+ };
2175
+ function onChildExit(code) {
2176
+ if (code === RUNNER_RESTART_EXIT_CODE) {
2177
+ state.lastEvent = "Runner updated remotely \u2014 restarting on the new version";
2178
+ printStatus(state);
2179
+ child = spawnChild();
2180
+ wire();
2181
+ return;
2182
+ }
2020
2183
  childDead = true;
2021
2184
  if (code && code !== 0) {
2022
2185
  state.runner = "error";
@@ -2025,7 +2188,8 @@ var upCommand = new Command8("up").description("Start the browser runner for all
2025
2188
  if (recent.length) state.lastEvent += ": " + recent[recent.length - 1].slice(0, 60);
2026
2189
  printStatus(state);
2027
2190
  }
2028
- });
2191
+ }
2192
+ wire();
2029
2193
  const shutdown = () => {
2030
2194
  child.kill();
2031
2195
  clearScreen();
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.61",
3
+ "version": "0.4.63",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
- "pags": "dist/index.js"
8
+ "pags": "dist/bootstrap.js"
9
9
  },
10
+ "pagsPayload": "dist/index.js",
10
11
  "homepage": "https://github.com/ProAgentStore/platform#readme",
11
12
  "repository": {
12
13
  "type": "git",
@@ -25,7 +26,7 @@
25
26
  "access": "public"
26
27
  },
27
28
  "scripts": {
28
- "build": "pnpm --filter @proagentstore/browser-runner build && tsup src/index.ts --format esm --dts && node scripts/copy-browser-runner.mjs",
29
+ "build": "pnpm --filter @proagentstore/browser-runner build && tsup src/index.ts src/bootstrap.ts --format esm --dts && node scripts/copy-browser-runner.mjs",
29
30
  "dev": "tsx src/index.ts",
30
31
  "typecheck": "tsc --noEmit"
31
32
  },