@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.
- package/dist/bootstrap.d.ts +1 -0
- package/dist/bootstrap.js +60 -0
- package/dist/browser-runner/coding/engine-adapter.js +39 -1
- package/dist/browser-runner/coding/headless-state.js +50 -0
- package/dist/browser-runner/coding/headless.js +71 -50
- package/dist/browser-runner/coding/repo-clone-job.js +109 -0
- package/dist/browser-runner/coding/repo.js +48 -2
- package/dist/browser-runner/coding/runtime.js +30 -2
- package/dist/browser-runner/runner.js +34 -1
- package/dist/browser-runner/server.js +27 -0
- package/dist/browser-runner/task-types.js +2 -0
- package/dist/chunk-JOWYSZOL.js +97 -0
- package/dist/index.js +199 -35
- package/package.json +4 -3
|
@@ -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
|
|
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
|
|
200
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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 {
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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/
|
|
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
|
},
|