@proagentstore/cli 0.4.28 → 0.4.30

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.
@@ -454,21 +454,28 @@ export function parseCommand(command) {
454
454
  i++;
455
455
  if (i >= src.length)
456
456
  break;
457
- const start = i;
457
+ const tokenStart = i;
458
458
  let out = "";
459
- let quote = null;
460
459
  while (i < src.length) {
461
460
  const ch = src[i];
462
- if (quote) {
463
- if (ch === quote)
464
- quote = null;
465
- else
466
- out += ch;
467
- i++;
468
- continue;
469
- }
470
- if (ch === '"' || ch === "'") {
471
- quote = ch;
461
+ // A DOUBLE quote always opens a span — that is what `--flag "two words"` means and
462
+ // what anyone typing this expects.
463
+ //
464
+ // A SINGLE quote opens one only at a token boundary (token start, or right after `=`),
465
+ // because in ordinary English it is an apostrophe. This field is a preset text box, not
466
+ // a shell: a real shell would pair the two apostrophes in `don't guess and don't stop`
467
+ // and hand the engine `dont guess and dont` plus a stray `stop`, which is exactly the
468
+ // mangling seen here. `--agent='my agent'` and `'my agent'` still work.
469
+ const opens = ch === '"' || (ch === "'" && (i === tokenStart || src[i - 1] === "="));
470
+ if (opens) {
471
+ const close = src.indexOf(ch, i + 1);
472
+ if (close !== -1) {
473
+ out += src.slice(i + 1, close);
474
+ i = close + 1;
475
+ continue;
476
+ }
477
+ // Unterminated — the character is literal, not the start of a span.
478
+ out += ch;
472
479
  i++;
473
480
  continue;
474
481
  }
@@ -477,21 +484,7 @@ export function parseCommand(command) {
477
484
  out += ch;
478
485
  i++;
479
486
  }
480
- if (quote) {
481
- // UNTERMINATED quote → it was a literal character, not a quote. `don't` is ordinary
482
- // English in a user-edited preset; treating the apostrophe as an opening quote made
483
- // `--append-system-prompt don't guess` reach the engine as three broken arguments.
484
- i = start;
485
- let raw = "";
486
- while (i < src.length && !isSpace(src[i])) {
487
- raw += src[i];
488
- i++;
489
- }
490
- tokens.push(raw);
491
- }
492
- else {
493
- tokens.push(out);
494
- }
487
+ tokens.push(out);
495
488
  }
496
489
  return { bin: tokens[0] ?? "", args: tokens.slice(1) };
497
490
  }
@@ -0,0 +1,256 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { resolve } from "node:path";
4
+ import { capturePane, createSession, killSession, listSessionsDetailed, runCommand as tmuxRunCommand, sendKey as tmuxSendKey, sendText as tmuxSendText, sessionExists } from "./tmux.js";
5
+ function shellPath() {
6
+ return process.env.SHELL || "/bin/zsh";
7
+ }
8
+ function expandWorkDir(workDir) {
9
+ return resolve(String(workDir || "~").replace(/^~(?=$|\/)/, homedir()));
10
+ }
11
+ export function splitTerminalTarget(raw, fallback) {
12
+ const value = String(raw || "").trim();
13
+ const m = value.match(/^(tmux|kitty|iterm2):(.+)$/i);
14
+ if (m)
15
+ return { backend: m[1].toLowerCase(), id: m[2].trim() };
16
+ if (!fallback)
17
+ throw new Error("A target must include a backend prefix like `tmux:main`, or pass `backend` separately.");
18
+ return { backend: fallback, id: value };
19
+ }
20
+ export function listTerminalTargets(backend = "all") {
21
+ const out = [];
22
+ if (backend === "all" || backend === "tmux") {
23
+ for (const s of listSessionsDetailed()) {
24
+ out.push({
25
+ backend: "tmux",
26
+ id: s.name,
27
+ name: s.name,
28
+ attached: s.attached,
29
+ activeCommand: s.activeCommand,
30
+ activeWindow: s.activeWindow,
31
+ created: s.created,
32
+ });
33
+ }
34
+ }
35
+ if (backend === "all" || backend === "kitty")
36
+ out.push(...listKittyTargets());
37
+ if (backend === "all" || backend === "iterm2")
38
+ out.push(...listItermTargets());
39
+ return out;
40
+ }
41
+ export function captureTerminalTarget(target, opts = {}) {
42
+ const t = splitTerminalTarget(target, opts.backend);
43
+ switch (t.backend) {
44
+ case "tmux":
45
+ if (!sessionExists(t.id))
46
+ throw new Error(`No tmux session "${t.id}".`);
47
+ return capturePane(t.id, Math.min(Math.max(Number(opts.lines) || 200, 1), 2000));
48
+ case "kitty":
49
+ return kittyExec(["@", "get-text", "--match", `id:${t.id}`, "--extent", "all"], 5000);
50
+ case "iterm2":
51
+ return itermSessionScript(t.id, "return contents of theSession");
52
+ }
53
+ }
54
+ export function runTerminalCommand(target, command, backend) {
55
+ const t = splitTerminalTarget(target, backend);
56
+ if (!command.trim())
57
+ throw new Error("A `command` is required.");
58
+ switch (t.backend) {
59
+ case "tmux":
60
+ if (!sessionExists(t.id))
61
+ throw new Error(`No tmux session "${t.id}".`);
62
+ tmuxRunCommand(t.id, command);
63
+ return capturePane(t.id, 200);
64
+ case "kitty":
65
+ kittyExec(["@", "send-text", "--match", `id:${t.id}`, command]);
66
+ kittyExec(["@", "send-key", "--match", `id:${t.id}`, "enter"]);
67
+ return captureTerminalTarget(t.id, { backend: "kitty" });
68
+ case "iterm2":
69
+ itermSessionScript(t.id, `tell theSession to write text ${appleString(command)}`);
70
+ return captureTerminalTarget(t.id, { backend: "iterm2" });
71
+ }
72
+ }
73
+ export function sendTerminalKeys(target, opts) {
74
+ const t = splitTerminalTarget(target, opts.backend);
75
+ if (opts.text == null && (!opts.keys || opts.keys.length === 0))
76
+ throw new Error("Provide `text` and/or `keys` to send.");
77
+ switch (t.backend) {
78
+ case "tmux":
79
+ if (!sessionExists(t.id))
80
+ throw new Error(`No tmux session "${t.id}".`);
81
+ if (opts.text != null)
82
+ tmuxSendText(t.id, opts.text);
83
+ for (const key of opts.keys ?? [])
84
+ tmuxSendKey(t.id, key);
85
+ return capturePane(t.id, 200);
86
+ case "kitty":
87
+ if (opts.text != null)
88
+ kittyExec(["@", "send-text", "--match", `id:${t.id}`, opts.text]);
89
+ for (const key of opts.keys ?? [])
90
+ kittyExec(["@", "send-key", "--match", `id:${t.id}`, kittyKeyName(key)]);
91
+ return captureTerminalTarget(t.id, { backend: "kitty" });
92
+ case "iterm2":
93
+ if ((opts.keys ?? []).length > 0)
94
+ throw new Error("iTerm2 generic key events are not supported yet; send text or run a command.");
95
+ itermSessionScript(t.id, `tell theSession to write text ${appleString(opts.text ?? "")}`);
96
+ return captureTerminalTarget(t.id, { backend: "iterm2" });
97
+ }
98
+ }
99
+ export function createTerminalTarget(opts) {
100
+ const workDir = expandWorkDir(opts.workDir);
101
+ switch (opts.backend) {
102
+ case "tmux": {
103
+ const name = String(opts.name || "").trim();
104
+ if (!name)
105
+ throw new Error("A `name` is required for tmux.");
106
+ if (sessionExists(name))
107
+ return { backend: "tmux", id: name, name, existed: true, workDir };
108
+ createSession(name, workDir, opts.command);
109
+ return { backend: "tmux", id: name, name, workDir };
110
+ }
111
+ case "kitty": {
112
+ const args = ["@", "launch", "--type", "os-window", "--cwd", workDir];
113
+ if (opts.command)
114
+ args.push(shellPath(), "-lc", opts.command);
115
+ const id = kittyExec(args).trim();
116
+ return { backend: "kitty", id, name: opts.name || id, workDir };
117
+ }
118
+ case "iterm2": {
119
+ const script = [
120
+ 'tell application "iTerm2"',
121
+ "create window with default profile",
122
+ "set theSession to current session of current window",
123
+ `tell theSession to write text ${appleString(`cd ${shellQuote(workDir)}${opts.command ? ` && ${opts.command}` : ""}`)}`,
124
+ "return ((index of current window) as text) & \":1:1\"",
125
+ "end tell",
126
+ ].join("\n");
127
+ const id = osascript(script).trim() || "1:1:1";
128
+ return { backend: "iterm2", id, name: opts.name || id, workDir };
129
+ }
130
+ }
131
+ }
132
+ export function killTerminalTarget(target, backend) {
133
+ const t = splitTerminalTarget(target, backend);
134
+ switch (t.backend) {
135
+ case "tmux":
136
+ return killSession(t.id);
137
+ case "kitty":
138
+ kittyExec(["@", "close-window", "--match", `id:${t.id}`]);
139
+ return true;
140
+ case "iterm2":
141
+ itermCloseTarget(t.id);
142
+ return true;
143
+ }
144
+ }
145
+ function listKittyTargets() {
146
+ try {
147
+ const raw = kittyExec(["@", "ls"], 5000);
148
+ const parsed = JSON.parse(raw);
149
+ if (!Array.isArray(parsed))
150
+ return [];
151
+ const out = [];
152
+ for (const osWindow of parsed) {
153
+ const tabs = Array.isArray(osWindow.tabs) ? osWindow.tabs : [];
154
+ for (const tab of tabs) {
155
+ const windows = Array.isArray(tab.windows) ? tab.windows : [];
156
+ for (const win of windows) {
157
+ const row = win;
158
+ const id = String(row.id ?? "").trim();
159
+ if (!id)
160
+ continue;
161
+ const title = String(row.title ?? row.user_vars ?? id);
162
+ out.push({ backend: "kitty", id, name: title || id, activeWindow: String(tab.title ?? "") });
163
+ }
164
+ }
165
+ }
166
+ return out;
167
+ }
168
+ catch {
169
+ return [];
170
+ }
171
+ }
172
+ function listItermTargets() {
173
+ try {
174
+ const script = [
175
+ 'set out to ""',
176
+ 'tell application "iTerm2"',
177
+ "repeat with wi from 1 to count of windows",
178
+ "repeat with ti from 1 to count of tabs of window wi",
179
+ "repeat with si from 1 to count of sessions of tab ti of window wi",
180
+ "set sid to (wi as text) & \":\" & (ti as text) & \":\" & (si as text)",
181
+ "set sname to name of session si of tab ti of window wi",
182
+ "set out to out & sid & (ASCII character 9) & sname & linefeed",
183
+ "end repeat",
184
+ "end repeat",
185
+ "end repeat",
186
+ "end tell",
187
+ "return out",
188
+ ].join("\n");
189
+ return osascript(script)
190
+ .split("\n")
191
+ .map((line) => line.trim())
192
+ .filter(Boolean)
193
+ .flatMap((line) => {
194
+ const [id, name] = line.split("\t");
195
+ return id ? [{ backend: "iterm2", id, name: name || id }] : [];
196
+ });
197
+ }
198
+ catch {
199
+ return [];
200
+ }
201
+ }
202
+ function kittyExec(args, timeoutMs = 5000) {
203
+ try {
204
+ return execFileSync("kitty", args, { encoding: "utf8", timeout: timeoutMs, stdio: "pipe" });
205
+ }
206
+ catch (e) {
207
+ throw new Error(`kitty remote control is unavailable. Start kitty with remote control enabled (for example, allow_remote_control yes). ${e instanceof Error ? e.message : String(e)}`);
208
+ }
209
+ }
210
+ function osascript(script) {
211
+ try {
212
+ return execFileSync("osascript", ["-e", script], { encoding: "utf8", timeout: 5000, stdio: "pipe" });
213
+ }
214
+ catch (e) {
215
+ throw new Error(`iTerm2 automation is unavailable. Open iTerm2 and grant macOS Automation/Accessibility permission if prompted. ${e instanceof Error ? e.message : String(e)}`);
216
+ }
217
+ }
218
+ function itermSessionScript(target, command) {
219
+ const [w, t, s] = target.split(":").map((v) => Math.max(1, Number.parseInt(v, 10) || 1));
220
+ const script = [
221
+ 'tell application "iTerm2"',
222
+ `set theSession to session ${s} of tab ${t} of window ${w}`,
223
+ command,
224
+ "end tell",
225
+ ].join("\n");
226
+ return osascript(script);
227
+ }
228
+ function itermCloseTarget(target) {
229
+ const [w, t] = target.split(":").map((v) => Math.max(1, Number.parseInt(v, 10) || 1));
230
+ const script = [
231
+ 'tell application "iTerm2"',
232
+ `close tab ${t} of window ${w}`,
233
+ "end tell",
234
+ ].join("\n");
235
+ return osascript(script);
236
+ }
237
+ function appleString(s) {
238
+ return JSON.stringify(s);
239
+ }
240
+ function shellQuote(s) {
241
+ return `'${s.replace(/'/g, "'\\''")}'`;
242
+ }
243
+ function kittyKeyName(key) {
244
+ const k = key.trim();
245
+ if (/^enter$/i.test(k))
246
+ return "enter";
247
+ if (/^escape$/i.test(k))
248
+ return "escape";
249
+ if (/^tab$/i.test(k))
250
+ return "tab";
251
+ if (/^backspace$/i.test(k))
252
+ return "backspace";
253
+ if (/^c-/i.test(k))
254
+ return `ctrl+${k.slice(2).toLowerCase()}`;
255
+ return k.toLowerCase();
256
+ }
@@ -9,6 +9,7 @@ import { McpRuntime } from "./mcp-runtime.js";
9
9
  import { HumanHandoffError, RunnerInputError } from "./errors.js";
10
10
  import { RunnerStore } from "./store.js";
11
11
  import { CodingRuntime } from "./coding/runtime.js";
12
+ import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
12
13
  /** True for a plain object. */
13
14
  function isRecord(value) {
14
15
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -39,7 +40,6 @@ const APPROVAL_REQUIRED_TASKS = new Set(["browser.open"]);
39
40
  * workflow then polled a dead session for 15 minutes before failing a run it had already
40
41
  * completed.
41
42
  */
42
- const WORKFLOW_DRIVEN_TASKS = new Set(["job.apply_agent", "browser.task"]);
43
43
  const require = createRequire(import.meta.url);
44
44
  export class LocalRunner {
45
45
  config;
@@ -357,6 +357,60 @@ async function route(runner, req, res) {
357
357
  createSession(session, workDir, b.command ? String(b.command) : undefined);
358
358
  return json(res, 200, { session, created: true, workDir });
359
359
  }
360
+ // ── generic terminal connector ──────────────────────────────────────────
361
+ // One local-terminal vocabulary over backend-specific adapters. tmux is fully
362
+ // controllable; kitty needs remote control enabled; iTerm2 needs macOS Automation access.
363
+ if ((req.method === "GET" || req.method === "POST") && path === "/terminal/list") {
364
+ const { listTerminalTargets } = await import("./coding/terminal.js");
365
+ const b = req.method === "POST" ? await readJson(req) : { backend: "all" };
366
+ const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : "all";
367
+ return json(res, 200, { targets: listTerminalTargets(backend) });
368
+ }
369
+ if (req.method === "POST" && path === "/terminal/capture") {
370
+ const { captureTerminalTarget } = await import("./coding/terminal.js");
371
+ const b = await readJson(req);
372
+ const target = String(b.target || "").trim();
373
+ if (!target)
374
+ return json(res, 400, { error: "A `target` is required." });
375
+ const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
376
+ return json(res, 200, { target, pane: captureTerminalTarget(target, { backend, lines: b.lines }) });
377
+ }
378
+ if (req.method === "POST" && path === "/terminal/run") {
379
+ const { runTerminalCommand } = await import("./coding/terminal.js");
380
+ const b = await readJson(req);
381
+ const target = String(b.target || "").trim();
382
+ const command = String(b.command ?? "");
383
+ if (!target)
384
+ return json(res, 400, { error: "A `target` is required." });
385
+ if (!command.trim())
386
+ return json(res, 400, { error: "A `command` is required." });
387
+ const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
388
+ return json(res, 200, { target, command, pane: runTerminalCommand(target, command, backend) });
389
+ }
390
+ if (req.method === "POST" && path === "/terminal/send") {
391
+ const { sendTerminalKeys } = await import("./coding/terminal.js");
392
+ const b = await readJson(req);
393
+ const target = String(b.target || "").trim();
394
+ if (!target)
395
+ return json(res, 400, { error: "A `target` is required." });
396
+ const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
397
+ return json(res, 200, { target, pane: sendTerminalKeys(target, { backend, text: b.text == null ? undefined : String(b.text), keys: b.keys ?? [] }) });
398
+ }
399
+ if (req.method === "POST" && path === "/terminal/session") {
400
+ const { createTerminalTarget, killTerminalTarget } = await import("./coding/terminal.js");
401
+ const b = await readJson(req);
402
+ const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
403
+ if (b.action === "kill") {
404
+ const target = String(b.target || "").trim();
405
+ if (!target)
406
+ return json(res, 400, { error: "A `target` is required." });
407
+ return json(res, 200, { target, killed: killTerminalTarget(target, backend) });
408
+ }
409
+ if (!backend)
410
+ return json(res, 400, { error: "`backend` must be tmux, kitty, or iterm2." });
411
+ const target = createTerminalTarget({ backend, name: b.name, workDir: b.workDir, command: b.command });
412
+ return json(res, 200, { target });
413
+ }
360
414
  return json(res, 404, { error: "Not found" });
361
415
  }
362
416
  function authorize(req, config) {
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
+ import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
3
4
  function emptyStore() {
4
5
  return {
5
6
  sessions: [],
@@ -50,7 +51,7 @@ export class RunnerStore {
50
51
  // to "failed", which re-mirrors to the board, resurrects the Retry button, and
51
52
  // slips past the API single-flight guard → a second concurrent apply on the one
52
53
  // browser page. Mirrors the API carve-out in expireOrphanedRuntimeTasks.
53
- if (task.type === "job.apply_agent")
54
+ if (WORKFLOW_DRIVEN_TASKS.has(task.type))
54
55
  continue;
55
56
  if (task.status === "needs_human" || task.status === "running") {
56
57
  task.status = "failed";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Task types steered by a remote durable Workflow rather than executed by the runner.
3
+ *
4
+ * ONE list, in its own module because THREE places need it and they drifted: task creation had
5
+ * `job.apply_agent || browser.task`, `resumeTakeover` had only `job.apply_agent` (so resolving a
6
+ * stuck browse handoff destroyed the session), and `RunnerStore.expireInFlightTasks` still
7
+ * hardcodes its own copy — which meant restarting `pags up` failed a live browse run locally while
8
+ * the cloud side preserved it, leaving the two views of one durable run disagreeing.
9
+ *
10
+ * `browser.handoff` is the synthetic task `browserHandoff` creates for a caller that has none (the
11
+ * engine sign-in relay mints its own id for a coding session with no runner task). It belongs here
12
+ * for the same reason: the console's Resume button is agent-agnostic, so without it pressing
13
+ * Resume would complete and END the takeover mid-sign-in — reintroducing the exact bug the browse
14
+ * case was fixed for.
15
+ */
16
+ export const WORKFLOW_DRIVEN_TASKS = new Set([
17
+ "job.apply_agent",
18
+ "browser.task",
19
+ "browser.handoff",
20
+ ]);
package/dist/index.js CHANGED
@@ -1,10 +1,4 @@
1
1
  #!/usr/bin/env node
2
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
- }) : x)(function(x) {
5
- if (typeof require !== "undefined") return require.apply(this, arguments);
6
- throw Error('Dynamic require of "' + x + '" is not supported');
7
- });
8
2
 
9
3
  // src/index.ts
10
4
  import { createRequire as createRequire3 } from "module";
@@ -371,7 +365,7 @@ jobs:
371
365
  });
372
366
 
373
367
  // src/commands/login.ts
374
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
368
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
375
369
  import { createServer } from "http";
376
370
  import { homedir } from "os";
377
371
  import { join as join3 } from "path";
@@ -472,14 +466,16 @@ var loginCommand = new Command3("login").description("Sign in with Google or Git
472
466
  }
473
467
  });
474
468
  var logoutCommand = new Command3("logout").description("Sign out and remove stored session").action(() => {
469
+ if (!existsSync3(TOKEN_FILE)) {
470
+ writeLine("Already signed out.");
471
+ return;
472
+ }
475
473
  try {
476
- if (existsSync3(TOKEN_FILE)) {
477
- const { unlinkSync } = __require("fs");
478
- unlinkSync(TOKEN_FILE);
479
- }
474
+ unlinkSync(TOKEN_FILE);
480
475
  writeLine("Signed out.");
481
- } catch {
482
- writeLine("Already signed out.");
476
+ } catch (err) {
477
+ writeError(`Could not remove ${TOKEN_FILE}: ${err instanceof Error ? err.message : String(err)}`);
478
+ process.exit(1);
483
479
  }
484
480
  });
485
481
  var whoamiCommand = new Command3("whoami").description("Show current signed-in user").action(() => {
@@ -798,14 +794,41 @@ async function waitForLocalRunner(opts, timeoutMs = 15e3) {
798
794
 
799
795
  // src/commands/runner/relay.ts
800
796
  import { hostname as hostname2 } from "os";
801
- async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force = false) {
797
+
798
+ // src/commands/runner/membership.ts
799
+ function isEligible(inst, thisNode) {
800
+ if (inst.status !== "active") return false;
801
+ if (inst.capabilities?.runtime == null) return false;
802
+ const pin = inst.config?.runnerNode;
803
+ if (pin && pin !== thisNode) return false;
804
+ return true;
805
+ }
806
+ function diffMembership(attached, eligible, thisNode, blocked = /* @__PURE__ */ new Set()) {
807
+ const have = new Set(attached);
808
+ const want = eligible.filter((i) => isEligible(i, thisNode));
809
+ const wantIds = new Set(want.map((i) => i.id));
810
+ return {
811
+ attach: want.filter((i) => !have.has(i.id) && !blocked.has(i.id)),
812
+ // Detach what is no longer eligible — unsubscribed, deactivated, or re-pinned to another
813
+ // machine. Leaving the socket open would keep the agent looking connected here while the
814
+ // platform routes its work elsewhere.
815
+ detach: [...have].filter((id) => !wantIds.has(id))
816
+ };
817
+ }
818
+ function instanceLabel(inst) {
819
+ const short = `${inst.id.slice(0, 8)}\u2026`;
820
+ return inst.name ? `${inst.name} (${short})` : short;
821
+ }
822
+
823
+ // src/commands/runner/relay.ts
824
+ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force = false, watchInstances = false) {
802
825
  const apiBase = pagsApiBase(opts.apiBase).replace(/^http/, "ws");
803
826
  const pagsToken = clean(opts.pagsToken) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
804
827
  if (!pagsToken) throw new Error("PAGS token required for WebSocket relay");
805
828
  const runnerNode = hostname2();
806
829
  const capabilities = await requestRunner("GET", "/capabilities", { url: localUrl, token: runnerToken, instanceId: instanceIds[0] });
807
830
  const caps = Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [];
808
- for (const id of instanceIds) {
831
+ const registerRuntime = async (id) => {
809
832
  try {
810
833
  await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime`, opts, {
811
834
  endpointUrl: localUrl,
@@ -820,11 +843,30 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
820
843
  const msg = e instanceof Error ? e.message : String(e);
821
844
  writeError(`register ${id.slice(0, 8)}\u2026 failed: ${msg}`);
822
845
  }
823
- }
824
- for (const id of instanceIds) {
846
+ };
847
+ for (const id of instanceIds) await registerRuntime(id);
848
+ const attached = /* @__PURE__ */ new Map();
849
+ const blocked = /* @__PURE__ */ new Set();
850
+ const attach = (id, label = `${id.slice(0, 8)}\u2026`) => {
851
+ if (attached.has(id)) return;
825
852
  const mintToken = () => requestPags("POST", `/v1/relay/${apiPathSegment(id)}/token`, { ...opts, pagsToken }, {}).then((r) => r.token);
826
- openRelaySocket(id, apiBase, mintToken, localUrl, runnerToken, force);
827
- }
853
+ attached.set(
854
+ id,
855
+ openRelaySocket(id, apiBase, mintToken, localUrl, runnerToken, force, (conflicted) => {
856
+ blocked.add(conflicted);
857
+ attached.delete(conflicted);
858
+ })
859
+ );
860
+ if (label) writeLine(`Attached agent: ${label}`);
861
+ };
862
+ const detach = (id, label = `${id.slice(0, 8)}\u2026`) => {
863
+ const handle = attached.get(id);
864
+ if (!handle) return;
865
+ handle.close();
866
+ attached.delete(id);
867
+ writeLine(`Detached agent: ${label}`);
868
+ };
869
+ for (const id of instanceIds) attach(id, "");
828
870
  writeLine("Runtime registered with PAGS \u2713");
829
871
  writeLine("");
830
872
  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");
@@ -834,7 +876,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
834
876
  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");
835
877
  const heartbeat = () => {
836
878
  const timer = setTimeout(async () => {
837
- for (const id of instanceIds) {
879
+ for (const id of [...attached.keys()]) {
838
880
  await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, { runnerNode }).catch(() => void 0);
839
881
  }
840
882
  heartbeat();
@@ -842,11 +884,44 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
842
884
  timer.unref();
843
885
  };
844
886
  heartbeat();
887
+ if (watchInstances) startDiscovery();
888
+ function startDiscovery() {
889
+ const tick = () => {
890
+ const timer = setTimeout(async () => {
891
+ try {
892
+ const res = await requestPags(
893
+ "GET",
894
+ "/v1/instances/my/instances",
895
+ { ...opts, pagsToken }
896
+ );
897
+ const { attach: toAttach, detach: toDetach } = diffMembership(
898
+ attached.keys(),
899
+ res.instances ?? [],
900
+ runnerNode,
901
+ blocked
902
+ );
903
+ for (const inst of toAttach) {
904
+ await registerRuntime(inst.id);
905
+ attach(inst.id, instanceLabel(inst));
906
+ }
907
+ for (const id of toDetach) detach(id);
908
+ } catch {
909
+ }
910
+ tick();
911
+ }, 2e4);
912
+ timer.unref();
913
+ };
914
+ tick();
915
+ }
845
916
  }
846
- function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false) {
917
+ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict) {
847
918
  let backoffMs = 1e3;
848
919
  let reconnecting = false;
920
+ let closed = false;
921
+ let socket = null;
922
+ let retryTimer = null;
849
923
  const connect = async () => {
924
+ if (closed) return;
850
925
  let relayToken;
851
926
  try {
852
927
  relayToken = await mintToken();
@@ -858,7 +933,7 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
858
933
  }
859
934
  const hint = /401|token|sign/i.test(msg) ? " (run `pags login`)" : "";
860
935
  writeLine(`Relay token mint failed: ${instanceId.slice(0, 8)}\u2026${hint} \u2014 retrying in ${Math.round(backoffMs / 1e3)}s`);
861
- setTimeout(() => {
936
+ retryTimer = setTimeout(() => {
862
937
  connect();
863
938
  }, backoffMs);
864
939
  backoffMs = Math.min(backoffMs * 2, 3e4);
@@ -868,6 +943,7 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
868
943
  if (force) params.set("force", "1");
869
944
  const url = `${wsBase}/v1/relay/${encodeURIComponent(instanceId)}/connect?${params.toString()}`;
870
945
  const ws = new WebSocket(url);
946
+ socket = ws;
871
947
  ws.onopen = () => {
872
948
  backoffMs = 1e3;
873
949
  writeLine(`Relay connected: ${instanceId.slice(0, 8)}\u2026`);
@@ -919,11 +995,19 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
919
995
  }
920
996
  };
921
997
  ws.onclose = (ev) => {
922
- if (reconnecting) return;
998
+ if (closed || reconnecting) return;
999
+ if (ev.code === 4409 && !force) {
1000
+ writeLine(`Relay conflict: ${instanceId.slice(0, 8)}\u2026 is connected on another machine \u2014 run \`pags up --force\` here to take it over.`);
1001
+ closed = true;
1002
+ onConflict?.(instanceId);
1003
+ return;
1004
+ }
923
1005
  reconnecting = true;
924
- const reason = ev.code === 4401 || ev.code === 1008 ? " (token expired \u2014 run `pags login` then `pags up`)" : "";
1006
+ const said = (ev.reason || "").trim();
1007
+ const hint = ev.code === 4401 ? " \u2014 run `pags login`, then `pags up`" : ev.code === 4409 ? " \u2014 run `pags up --force` to take over" : "";
1008
+ const reason = said ? ` (${said}${hint})` : ev.code === 1008 ? " (token expired \u2014 run `pags login` then `pags up`)" : "";
925
1009
  writeLine(`Relay disconnected: ${instanceId.slice(0, 8)}\u2026${reason} \u2014 reconnecting in ${Math.round(backoffMs / 1e3)}s`);
926
- setTimeout(() => {
1010
+ retryTimer = setTimeout(() => {
927
1011
  reconnecting = false;
928
1012
  connect();
929
1013
  }, backoffMs);
@@ -933,6 +1017,20 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
933
1017
  };
934
1018
  };
935
1019
  connect();
1020
+ return {
1021
+ close() {
1022
+ closed = true;
1023
+ if (retryTimer) {
1024
+ clearTimeout(retryTimer);
1025
+ retryTimer = null;
1026
+ }
1027
+ try {
1028
+ socket?.close();
1029
+ } catch {
1030
+ }
1031
+ socket = null;
1032
+ }
1033
+ };
936
1034
  }
937
1035
 
938
1036
  // src/commands/runner/command.ts
@@ -943,10 +1041,10 @@ function createRunnerCommand() {
943
1041
  const command = new Command6("runner").description(
944
1042
  "Manage the local ProAgentStore browser runtime for ProAgentStore agents"
945
1043
  );
946
- command.command("start").description("Start the local ProAgentStore browser runtime in the foreground").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "49171").option("--data-dir <path>", "Runner data directory").option("--token <token>", "Require this bearer token").option("--instance-id <id>", "Bind runner requests to a PAGS instance id").option("--headless", "Run Playwright headless").action(async (opts) => {
1044
+ command.command("start").description("Start the local ProAgentStore browser runtime in the foreground").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind (default: first free port from 49171)").option("--data-dir <path>", "Runner data directory").option("--token <token>", "Require this bearer token").option("--instance-id <id>", "Bind runner requests to a PAGS instance id").option("--headless", "Run Playwright headless").action(async (opts) => {
947
1045
  await startRunnerForeground(opts);
948
1046
  });
949
- command.command("connect <instanceIds...>").description("Start ONE local runtime, connect via WebSocket relay, and register it for every given PAGS instance").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "49171").option("--data-dir <path>", "Runner data directory").option("--token <token>", "Runner bearer token. Defaults to PAGS_RUNNER_TOKEN or a generated token").option("--headless", "Run Playwright headless").option("--api-base <url>", "PAGS API base URL").option("--pags-token <token>", "PAGS session token. Defaults to PAGS_TOKEN").option("--runner-version <version>", "Runner version").option("--force", "Take over from another connected machine").action(async (instanceIds, opts) => {
1047
+ command.command("connect <instanceIds...>").description("Start ONE local runtime, connect via WebSocket relay, and register it for every given PAGS instance").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "49171").option("--data-dir <path>", "Runner data directory").option("--token <token>", "Runner bearer token. Defaults to PAGS_RUNNER_TOKEN or a generated token").option("--headless", "Run Playwright headless").option("--api-base <url>", "PAGS API base URL").option("--pags-token <token>", "PAGS session token. Defaults to PAGS_TOKEN").option("--runner-version <version>", "Runner version").option("--force", "Take over from another connected machine").option("--watch-instances", "Attach newly eligible agents while running, without a restart").action(async (instanceIds, opts) => {
950
1048
  const runnerToken = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID()}`;
951
1049
  const host = clean(opts.host) || "127.0.0.1";
952
1050
  const port = clean(opts.port) || String(await findFreePort2(49171));
@@ -982,7 +1080,7 @@ function createRunnerCommand() {
982
1080
  try {
983
1081
  await waitForLocalRunner({ url: localUrl, token: runnerToken, instanceId: primary });
984
1082
  writeLine(`Local browser runtime healthy at ${localUrl}`);
985
- await connectViaRelay(instanceIds, localUrl, runnerToken, opts, Boolean(opts.force));
1083
+ await connectViaRelay(instanceIds, localUrl, runnerToken, opts, Boolean(opts.force), Boolean(opts.watchInstances));
986
1084
  await new Promise((resolvePromise) => {
987
1085
  runner.on("exit", () => resolvePromise());
988
1086
  });
@@ -1184,7 +1282,7 @@ function printStep(label, status) {
1184
1282
  const icon = status === "ok" ? chalk.green("\u2713") : status === "fail" ? chalk.red("\u2717") : chalk.yellow("\u2026");
1185
1283
  console.log(pad + icon + " " + label);
1186
1284
  }
1187
- async function waitForKey(keys) {
1285
+ async function waitForKey(keys, onInterrupt) {
1188
1286
  return new Promise((resolve5) => {
1189
1287
  readline.emitKeypressEvents(process.stdin);
1190
1288
  if (process.stdin.isTTY) process.stdin.setRawMode(true);
@@ -1192,6 +1290,10 @@ async function waitForKey(keys) {
1192
1290
  const onKeypress = (str, key) => {
1193
1291
  if (key?.ctrl && key.name === "c") {
1194
1292
  cleanup();
1293
+ if (onInterrupt) {
1294
+ onInterrupt();
1295
+ return;
1296
+ }
1195
1297
  process.exit(0);
1196
1298
  }
1197
1299
  const val = (str || "").trim().toLowerCase();
@@ -1287,6 +1389,7 @@ var upCommand = new Command7("up").description("Start the browser runner for all
1287
1389
  const args = [cliPath, "runner", "connect", ...instances.map((i) => i.id)];
1288
1390
  if (opts.headless) args.push("--headless");
1289
1391
  if (opts.force) args.push("--force");
1392
+ if (!opts.instance) args.push("--watch-instances");
1290
1393
  const child = spawn4(process.execPath, args, {
1291
1394
  stdio: ["ignore", "pipe", "pipe"],
1292
1395
  env: { ...process.env, PAGS_TOKEN: session.token }
@@ -1366,7 +1469,7 @@ var upCommand = new Command7("up").description("Start the browser runner for all
1366
1469
  process.on("SIGINT", shutdown);
1367
1470
  process.on("SIGTERM", shutdown);
1368
1471
  while (true) {
1369
- const key = await waitForKey(["r", "l", "q"]);
1472
+ const key = await waitForKey(["r", "l", "q"], shutdown);
1370
1473
  if (key === "q") {
1371
1474
  shutdown();
1372
1475
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.28",
3
+ "version": "0.4.30",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",