@proagentstore/cli 0.4.60 → 0.4.62

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.
@@ -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
  ]);
package/dist/index.js CHANGED
@@ -1140,6 +1140,9 @@ function findWorkspaceRoot() {
1140
1140
  }
1141
1141
  return process.cwd();
1142
1142
  }
1143
+ function runsFromSource() {
1144
+ return existsSync6(resolve4(findWorkspaceRoot(), "packages", "browser-runner", "src", "index.ts"));
1145
+ }
1143
1146
  function bundledRunnerPath() {
1144
1147
  return fileURLToPath2(new URL("./browser-runner/index.js", import.meta.url));
1145
1148
  }
@@ -1194,6 +1197,53 @@ async function waitForLocalRunner(opts, timeoutMs = 15e3) {
1194
1197
  // src/commands/runner/relay.ts
1195
1198
  import { hostname as hostname3 } from "os";
1196
1199
 
1200
+ // src/commands/runner/self-update.ts
1201
+ import { execFile } from "child_process";
1202
+ import { promisify } from "util";
1203
+ var run = promisify(execFile);
1204
+ var RUNNER_UPDATE_PATH = "/pags/runner/update";
1205
+ var RUNNER_RESTART_EXIT_CODE = 75;
1206
+ var SUPERVISED_ENV = "PAGS_UP_SUPERVISED";
1207
+ var CLI_PACKAGE = "@proagentstore/cli";
1208
+ function olderThan(a, b) {
1209
+ const parse = (v) => /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim())?.slice(1).map(Number);
1210
+ const x = parse(a);
1211
+ const y = parse(b);
1212
+ if (!x || !y) return false;
1213
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i];
1214
+ return false;
1215
+ }
1216
+ function planRunnerUpdate(f) {
1217
+ 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." };
1218
+ if (!f.latest) return { action: "refused", current: f.current, reason: `npm could not be asked for the latest ${CLI_PACKAGE} from this machine.` };
1219
+ if (!olderThan(f.current, f.latest)) return { action: "up-to-date", current: f.current };
1220
+ if (!f.supervised) {
1221
+ return {
1222
+ action: "refused",
1223
+ current: f.current,
1224
+ 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.`
1225
+ };
1226
+ }
1227
+ if (f.busy.length > 0) return { action: "wait", current: f.current, latest: f.latest, waitingFor: f.busy };
1228
+ return { action: "update", current: f.current, latest: f.latest };
1229
+ }
1230
+ async function latestPublishedVersion() {
1231
+ try {
1232
+ const { stdout } = await run("npm", ["view", CLI_PACKAGE, "version"], { timeout: 3e4 });
1233
+ return stdout.trim() || null;
1234
+ } catch {
1235
+ return null;
1236
+ }
1237
+ }
1238
+ async function installVersion(version2) {
1239
+ try {
1240
+ await run("npm", ["i", "-g", `${CLI_PACKAGE}@${version2}`], { timeout: 5 * 6e4 });
1241
+ } catch (e) {
1242
+ const stderr = String(e.stderr ?? "").trim();
1243
+ throw new Error((stderr || (e instanceof Error ? e.message : String(e))).slice(-400));
1244
+ }
1245
+ }
1246
+
1197
1247
  // src/commands/runner/membership.ts
1198
1248
  function isEligible(inst, thisNode, alsoKnownAs = []) {
1199
1249
  if (inst.status !== "active") return false;
@@ -1220,10 +1270,36 @@ function shouldRegisterOnOpen(reconnect, alreadyRegistered) {
1220
1270
  function pendingRegistrations(attached, registered) {
1221
1271
  return [...attached].filter((id) => !registered.has(id));
1222
1272
  }
1273
+ function partitionByPin(instances, thisNode, alsoKnownAs = []) {
1274
+ const here = [];
1275
+ const elsewhere = [];
1276
+ for (const inst of instances) (isEligible(inst, thisNode, alsoKnownAs) ? here : elsewhere).push(inst);
1277
+ return { here, elsewhere };
1278
+ }
1279
+ function registrationStatus(attached, registered) {
1280
+ const ids = [...attached];
1281
+ const have = ids.filter((id) => registered.has(id)).length;
1282
+ const total = ids.length;
1283
+ return { agents: `${have}/${total}`, state: have === total ? "ok" : have === 0 ? "fail" : "partial" };
1284
+ }
1223
1285
  function instanceLabel(inst) {
1224
1286
  const short = `${inst.id.slice(0, 8)}\u2026`;
1225
1287
  return inst.name ? `${inst.name} (${short})` : short;
1226
1288
  }
1289
+ function reattachPlan(request, state) {
1290
+ const target = typeof request?.attach === "string" && request.attach ? request.attach : null;
1291
+ const force = target !== null && request?.force === true;
1292
+ if (!state.watching && !(target && state.scope.includes(target))) {
1293
+ return {
1294
+ target,
1295
+ unblock: false,
1296
+ detach: false,
1297
+ force: false,
1298
+ 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."
1299
+ };
1300
+ }
1301
+ return { target, unblock: target !== null && state.blocked, detach: target !== null && state.held, force };
1302
+ }
1227
1303
 
1228
1304
  // src/commands/runner/status-line.ts
1229
1305
  var STATUS_PREFIX = "PAGS-STATUS";
@@ -1256,6 +1332,8 @@ function parseStatusLine(line) {
1256
1332
  }
1257
1333
 
1258
1334
  // src/commands/runner/relay.ts
1335
+ var MEMBERSHIP_SYNC_PATH = "/pags/membership/sync";
1336
+ var CLI_CONTROL_PATHS = /* @__PURE__ */ new Set([MEMBERSHIP_SYNC_PATH, RUNNER_UPDATE_PATH]);
1259
1337
  async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force = false, watchInstances = false) {
1260
1338
  const apiBase = pagsApiBase(opts.apiBase).replace(/^http/, "ws");
1261
1339
  const pagsToken = clean(opts.pagsToken) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
@@ -1289,14 +1367,88 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1289
1367
  return false;
1290
1368
  }
1291
1369
  };
1370
+ const attached = /* @__PURE__ */ new Map();
1371
+ const blocked = /* @__PURE__ */ new Set();
1372
+ let syncing = Promise.resolve();
1373
+ const forceNext = /* @__PURE__ */ new Set();
1292
1374
  const reportRegistration = () => {
1293
- const agents = `${registered.size}/${instanceIds.length}`;
1294
- const state = registered.size === instanceIds.length ? "ok" : registered.size === 0 ? "fail" : "partial";
1375
+ const { agents, state } = registrationStatus(attached.keys(), registered);
1295
1376
  writeLine(formatStatusLine({ registration: state, agents, reason: state === "ok" ? void 0 : lastRegisterError }));
1296
1377
  };
1297
1378
  for (const id of instanceIds) await registerRuntime(id);
1298
- const attached = /* @__PURE__ */ new Map();
1299
- const blocked = /* @__PURE__ */ new Set();
1379
+ const updateFacts = async () => {
1380
+ const sessions = await requestRunner("GET", "/coding/sessions", {
1381
+ url: localUrl,
1382
+ token: runnerToken,
1383
+ instanceId: instanceIds[0]
1384
+ }).catch(() => ({ sessions: [] }));
1385
+ return {
1386
+ current: CLI_VERSION,
1387
+ latest: await latestPublishedVersion(),
1388
+ fromSource: runsFromSource(),
1389
+ supervised: process.env[SUPERVISED_ENV] === "1",
1390
+ busy: (sessions.sessions ?? []).filter((s) => s.alive && s.runState && s.runState !== "idle").map((s) => s.sessionId)
1391
+ };
1392
+ };
1393
+ const installAndRestart = async (plan) => {
1394
+ writeLine(`Updating ${plan.current} \u2192 ${plan.latest} (runner_update)\u2026`);
1395
+ await installVersion(plan.latest);
1396
+ writeLine(`Installed ${plan.latest} \u2014 restarting; every agent re-attaches on the way back up.`);
1397
+ setTimeout(() => {
1398
+ for (const id of [...attached.keys()]) detach(id);
1399
+ process.exit(RUNNER_RESTART_EXIT_CODE);
1400
+ }, 500).unref();
1401
+ };
1402
+ let updateWaiting = false;
1403
+ const updateWhenIdle = () => {
1404
+ if (updateWaiting) return;
1405
+ updateWaiting = true;
1406
+ const until = Date.now() + 60 * 6e4;
1407
+ const tick = () => setTimeout(async () => {
1408
+ const plan = planRunnerUpdate(await updateFacts());
1409
+ if (plan.action === "update") {
1410
+ await installAndRestart(plan).catch((e) => writeError(`runner_update: install failed \u2014 ${e instanceof Error ? e.message : String(e)}`));
1411
+ updateWaiting = false;
1412
+ } else if (plan.action === "wait" && Date.now() < until) tick();
1413
+ else updateWaiting = false;
1414
+ }, 15e3).unref();
1415
+ tick();
1416
+ };
1417
+ const answerUpdate = async (body) => {
1418
+ const dryRun = body?.dryRun === true;
1419
+ const plan = planRunnerUpdate(await updateFacts());
1420
+ if (dryRun || plan.action === "up-to-date" || plan.action === "refused") return { status: 200, result: { ...plan, dryRun } };
1421
+ if (plan.action === "wait") {
1422
+ updateWhenIdle();
1423
+ return { status: 200, result: { ...plan, detail: "Restarts itself as soon as these engines finish their turns \u2014 no run is cut off." } };
1424
+ }
1425
+ try {
1426
+ await installAndRestart(plan);
1427
+ } catch (e) {
1428
+ return { status: 500, result: { error: `npm could not install ${plan.latest}: ${e instanceof Error ? e.message : String(e)}` } };
1429
+ }
1430
+ return { status: 200, result: { action: "restarting", current: plan.current, latest: plan.latest } };
1431
+ };
1432
+ const answerControl = async (path, body) => {
1433
+ if (path === RUNNER_UPDATE_PATH) return answerUpdate(body);
1434
+ if (path !== MEMBERSHIP_SYNC_PATH) return { status: 404, result: { error: `Unknown runner control ${path}` } };
1435
+ const request = body;
1436
+ const named = typeof request?.attach === "string" ? request.attach : "";
1437
+ const plan = reattachPlan(request, { held: attached.has(named), blocked: blocked.has(named), watching: watchInstances, scope: instanceIds });
1438
+ if (plan.refuse) return { status: 409, result: { error: plan.refuse } };
1439
+ if (plan.target) {
1440
+ if (plan.unblock) blocked.delete(plan.target);
1441
+ if (plan.detach) detach(plan.target);
1442
+ if (plan.force) forceNext.add(plan.target);
1443
+ }
1444
+ if (watchInstances) await syncMembership();
1445
+ else if (plan.target) {
1446
+ await registerRuntime(plan.target);
1447
+ attach(plan.target);
1448
+ }
1449
+ if (plan.target && !attached.has(plan.target)) forceNext.delete(plan.target);
1450
+ return { status: 200, result: { attached: [...attached.keys()], ...plan.target ? { target: plan.target, holding: attached.has(plan.target) } : {} } };
1451
+ };
1300
1452
  const attach = (id, label = `${id.slice(0, 8)}\u2026`) => {
1301
1453
  if (attached.has(id)) return;
1302
1454
  const mintToken = () => requestPags("POST", `/v1/relay/${apiPathSegment(id)}/token`, { ...opts, pagsToken }, {}).then((r) => r.token);
@@ -1308,7 +1460,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1308
1460
  mintToken,
1309
1461
  localUrl,
1310
1462
  runnerToken,
1311
- force,
1463
+ // `pags up --force` for the whole process, or for this one agent at the cloud's request (#856).
1464
+ force || forceNext.delete(id),
1312
1465
  (conflicted) => {
1313
1466
  blocked.add(conflicted);
1314
1467
  attached.delete(conflicted);
@@ -1325,7 +1478,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1325
1478
  if (!shouldRegisterOnOpen(reconnect, registered.has(openedId))) return;
1326
1479
  await registerRuntime(openedId, reconnect ? false : force);
1327
1480
  reportRegistration();
1328
- }
1481
+ },
1482
+ answerControl
1329
1483
  )
1330
1484
  );
1331
1485
  if (label) writeLine(`Attached agent: ${label}`);
@@ -1339,7 +1493,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1339
1493
  };
1340
1494
  for (const id of instanceIds) attach(id, "");
1341
1495
  reportRegistration();
1342
- writeLine(registered.size === instanceIds.length ? `Runtime registered with PAGS \u2713 (${registered.size}/${instanceIds.length} agents)` : `Runtime registration incomplete: ${registered.size}/${instanceIds.length} agents \u2014 retried on each relay (re)connect${watchInstances ? " and every 20s while this runs" : ""}.`);
1496
+ const startup = registrationStatus(attached.keys(), registered);
1497
+ writeLine(startup.state === "ok" ? `Runtime registered with PAGS \u2713 (${startup.agents} agents)` : `Runtime registration incomplete: ${startup.agents} agents \u2014 retried on each relay (re)connect${watchInstances ? " and every 20s while this runs" : ""}.`);
1343
1498
  writeLine("");
1344
1499
  writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
1345
1500
  writeLine(` \u2705 CONNECTED \u2014 WebSocket relay \xB7 ${hostname3()}`);
@@ -1384,36 +1539,42 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1384
1539
  writeLine(`Relay conflict cleared: ${id.slice(0, 8)}\u2026 \u2014 the other runner is gone; reattaching.`);
1385
1540
  }
1386
1541
  }
1542
+ function syncMembership() {
1543
+ syncing = syncing.catch(() => void 0).then(async () => {
1544
+ await clearFinishedConflicts();
1545
+ const res = await requestPags(
1546
+ "GET",
1547
+ "/v1/instances/my/instances",
1548
+ { ...opts, pagsToken }
1549
+ );
1550
+ const { attach: toAttach, detach: toDetach } = diffMembership(
1551
+ attached.keys(),
1552
+ res.instances ?? [],
1553
+ runnerNode,
1554
+ blocked,
1555
+ // The names this machine has also worn. Without them a pin made under a
1556
+ // previous hostname reads as "pinned to another machine", and this poll
1557
+ // detaches the agent twenty seconds after startup attached it (#379).
1558
+ machine.names
1559
+ );
1560
+ for (const inst of toAttach) {
1561
+ await registerRuntime(inst.id);
1562
+ attach(inst.id, instanceLabel(inst));
1563
+ }
1564
+ for (const id of toDetach) detach(id);
1565
+ const pending = pendingRegistrations(attached.keys(), registered);
1566
+ if (pending.length) {
1567
+ for (const id of pending) await registerRuntime(id);
1568
+ reportRegistration();
1569
+ }
1570
+ });
1571
+ return syncing;
1572
+ }
1387
1573
  function startDiscovery() {
1388
1574
  const tick = () => {
1389
1575
  const timer = setTimeout(async () => {
1390
1576
  try {
1391
- await clearFinishedConflicts();
1392
- const res = await requestPags(
1393
- "GET",
1394
- "/v1/instances/my/instances",
1395
- { ...opts, pagsToken }
1396
- );
1397
- const { attach: toAttach, detach: toDetach } = diffMembership(
1398
- attached.keys(),
1399
- res.instances ?? [],
1400
- runnerNode,
1401
- blocked,
1402
- // The names this machine has also worn. Without them a pin made under a
1403
- // previous hostname reads as "pinned to another machine", and this poll
1404
- // detaches the agent twenty seconds after startup attached it (#379).
1405
- machine.names
1406
- );
1407
- for (const inst of toAttach) {
1408
- await registerRuntime(inst.id);
1409
- attach(inst.id, instanceLabel(inst));
1410
- }
1411
- for (const id of toDetach) detach(id);
1412
- const pending = pendingRegistrations(attached.keys(), registered);
1413
- if (pending.length) {
1414
- for (const id of pending) await registerRuntime(id);
1415
- reportRegistration();
1416
- }
1577
+ await syncMembership();
1417
1578
  } catch {
1418
1579
  }
1419
1580
  tick();
@@ -1423,7 +1584,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1423
1584
  tick();
1424
1585
  }
1425
1586
  }
1426
- function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen) {
1587
+ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl) {
1427
1588
  let backoffMs = 1e3;
1428
1589
  let reconnecting = false;
1429
1590
  let closed = false;
@@ -1477,6 +1638,14 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
1477
1638
  return;
1478
1639
  }
1479
1640
  if (!cmd.id || !cmd.path) return;
1641
+ if (onControl && CLI_CONTROL_PATHS.has(cmd.path)) {
1642
+ const reply = await onControl(cmd.path, cmd.body).catch((err) => ({ status: 500, result: { error: err instanceof Error ? err.message : String(err) } }));
1643
+ try {
1644
+ ws.send(JSON.stringify({ id: cmd.id, ...reply }));
1645
+ } catch {
1646
+ }
1647
+ return;
1648
+ }
1480
1649
  const method = (cmd.method || "POST").toUpperCase();
1481
1650
  const hasBody = method !== "GET" && method !== "HEAD" && cmd.body !== void 0;
1482
1651
  try {
@@ -1582,6 +1751,7 @@ function createRunnerCommand() {
1582
1751
  shuttingDown = true;
1583
1752
  if (!runner.killed) runner.kill("SIGTERM");
1584
1753
  };
1754
+ process.once("exit", shutdown);
1585
1755
  process.once("SIGINT", () => {
1586
1756
  shutdown();
1587
1757
  process.exit(0);
@@ -1717,6 +1887,7 @@ var runnerCommand = createRunnerCommand();
1717
1887
  // src/commands/up.ts
1718
1888
  import { createRequire as createRequire2 } from "module";
1719
1889
  import { Command as Command8 } from "commander";
1890
+ import { hostname as hostname5 } from "os";
1720
1891
 
1721
1892
  // src/tui.ts
1722
1893
  import chalk from "chalk";
@@ -1888,6 +2059,7 @@ var upCommand = new Command8("up").description("Start the browser runner for all
1888
2059
  const data = await res.json();
1889
2060
  const active = (data.instances || []).filter((i) => i.status === "active");
1890
2061
  let instances = active;
2062
+ let elsewhere = [];
1891
2063
  if (opts.instance) {
1892
2064
  instances = instances.filter((i) => i.id === opts.instance || i.slug === opts.instance);
1893
2065
  } else {
@@ -1904,16 +2076,31 @@ var upCommand = new Command8("up").description("Start the browser runner for all
1904
2076
  writeLine(" Subscribe to an agent at https://proagentstore.online");
1905
2077
  process.exit(1);
1906
2078
  }
1907
- state.instances = instances.map((i) => ({ id: i.id, name: i.name || i.slug || i.id.slice(0, 8) }));
1908
- printStep(`Found ${instances.length} instance${instances.length === 1 ? "" : "s"}`, "ok");
1909
- for (const inst of state.instances) {
1910
- writeLine(` ${inst.name} (${inst.id.slice(0, 8)}...)`);
1911
- }
1912
2079
  await maybeClaimMachineNames({
1913
2080
  token: session.token,
1914
2081
  apiBase: API_BASE3,
1915
2082
  headless: opts.headless
1916
2083
  }).catch(() => void 0);
2084
+ if (!opts.instance) {
2085
+ const split = partitionByPin(instances, hostname5(), loadMachineIdentity(hostname5()).names);
2086
+ instances = split.here;
2087
+ elsewhere = split.elsewhere;
2088
+ }
2089
+ if (instances.length === 0) {
2090
+ printStep(`All ${elsewhere.length} of your runtime agents are pinned to other machines`, "ok");
2091
+ for (const inst of elsewhere) writeLine(` ${inst.name || inst.slug || inst.id.slice(0, 8)} \u2192 ${inst.config?.runnerNode}`);
2092
+ writeLine(' They are served there. To run one from this machine, change its "Runs on" pin in the console, then `pags up` again.');
2093
+ process.exit(0);
2094
+ }
2095
+ state.instances = instances.map((i) => ({ id: i.id, name: i.name || i.slug || i.id.slice(0, 8) }));
2096
+ printStep(`Found ${instances.length} instance${instances.length === 1 ? "" : "s"}`, "ok");
2097
+ for (const inst of state.instances) {
2098
+ writeLine(` ${inst.name} (${inst.id.slice(0, 8)}...)`);
2099
+ }
2100
+ if (elsewhere.length) {
2101
+ printStep(`${elsewhere.length} more pinned to other machines \u2014 served there, not attached here`, "ok");
2102
+ for (const inst of elsewhere) writeLine(` ${inst.name || inst.slug || inst.id.slice(0, 8)} \u2192 ${inst.config?.runnerNode}`);
2103
+ }
1917
2104
  state.activeInstance = instances.length === 1 ? instances[0].name || instances[0].slug || instances[0].id.slice(0, 8) : `${instances.length} agents`;
1918
2105
  printStep(`Connecting ${state.activeInstance}\u2026`, "wait");
1919
2106
  await stopRunnerProcesses();
@@ -1923,10 +2110,11 @@ var upCommand = new Command8("up").description("Start the browser runner for all
1923
2110
  if (opts.headless) args.push("--headless");
1924
2111
  if (opts.force) args.push("--force");
1925
2112
  if (!opts.instance) args.push("--watch-instances");
1926
- const child = spawn4(process.execPath, args, {
2113
+ const spawnChild = () => spawn4(process.execPath, args, {
1927
2114
  stdio: ["ignore", "pipe", "pipe"],
1928
- env: { ...process.env, PAGS_TOKEN: session.token }
2115
+ env: { ...process.env, PAGS_TOKEN: session.token, [SUPERVISED_ENV]: "1" }
1929
2116
  });
2117
+ let child = spawnChild();
1930
2118
  const logs = [];
1931
2119
  const handleOutput = (data2) => {
1932
2120
  const text = data2.toString("utf-8");
@@ -1984,10 +2172,20 @@ var upCommand = new Command8("up").description("Start the browser runner for all
1984
2172
  }
1985
2173
  }
1986
2174
  };
1987
- child.stdout?.on("data", handleOutput);
1988
- child.stderr?.on("data", handleOutput);
1989
2175
  let childDead = false;
1990
- child.on("exit", (code) => {
2176
+ const wire = () => {
2177
+ child.stdout?.on("data", handleOutput);
2178
+ child.stderr?.on("data", handleOutput);
2179
+ child.on("exit", onChildExit);
2180
+ };
2181
+ function onChildExit(code) {
2182
+ if (code === RUNNER_RESTART_EXIT_CODE) {
2183
+ state.lastEvent = "Runner updated remotely \u2014 restarting on the new version";
2184
+ printStatus(state);
2185
+ child = spawnChild();
2186
+ wire();
2187
+ return;
2188
+ }
1991
2189
  childDead = true;
1992
2190
  if (code && code !== 0) {
1993
2191
  state.runner = "error";
@@ -1996,7 +2194,8 @@ var upCommand = new Command8("up").description("Start the browser runner for all
1996
2194
  if (recent.length) state.lastEvent += ": " + recent[recent.length - 1].slice(0, 60);
1997
2195
  printStatus(state);
1998
2196
  }
1999
- });
2197
+ }
2198
+ wire();
2000
2199
  const shutdown = () => {
2001
2200
  child.kill();
2002
2201
  clearScreen();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.60",
3
+ "version": "0.4.62",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",