@proagentstore/cli 0.4.29 → 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.
@@ -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
+ }
@@ -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) {
package/dist/index.js CHANGED
@@ -794,14 +794,41 @@ async function waitForLocalRunner(opts, timeoutMs = 15e3) {
794
794
 
795
795
  // src/commands/runner/relay.ts
796
796
  import { hostname as hostname2 } from "os";
797
- 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) {
798
825
  const apiBase = pagsApiBase(opts.apiBase).replace(/^http/, "ws");
799
826
  const pagsToken = clean(opts.pagsToken) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
800
827
  if (!pagsToken) throw new Error("PAGS token required for WebSocket relay");
801
828
  const runnerNode = hostname2();
802
829
  const capabilities = await requestRunner("GET", "/capabilities", { url: localUrl, token: runnerToken, instanceId: instanceIds[0] });
803
830
  const caps = Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [];
804
- for (const id of instanceIds) {
831
+ const registerRuntime = async (id) => {
805
832
  try {
806
833
  await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime`, opts, {
807
834
  endpointUrl: localUrl,
@@ -816,11 +843,30 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
816
843
  const msg = e instanceof Error ? e.message : String(e);
817
844
  writeError(`register ${id.slice(0, 8)}\u2026 failed: ${msg}`);
818
845
  }
819
- }
820
- 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;
821
852
  const mintToken = () => requestPags("POST", `/v1/relay/${apiPathSegment(id)}/token`, { ...opts, pagsToken }, {}).then((r) => r.token);
822
- openRelaySocket(id, apiBase, mintToken, localUrl, runnerToken, force);
823
- }
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, "");
824
870
  writeLine("Runtime registered with PAGS \u2713");
825
871
  writeLine("");
826
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");
@@ -830,7 +876,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
830
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");
831
877
  const heartbeat = () => {
832
878
  const timer = setTimeout(async () => {
833
- for (const id of instanceIds) {
879
+ for (const id of [...attached.keys()]) {
834
880
  await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, { runnerNode }).catch(() => void 0);
835
881
  }
836
882
  heartbeat();
@@ -838,11 +884,44 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
838
884
  timer.unref();
839
885
  };
840
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
+ }
841
916
  }
842
- function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false) {
917
+ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict) {
843
918
  let backoffMs = 1e3;
844
919
  let reconnecting = false;
920
+ let closed = false;
921
+ let socket = null;
922
+ let retryTimer = null;
845
923
  const connect = async () => {
924
+ if (closed) return;
846
925
  let relayToken;
847
926
  try {
848
927
  relayToken = await mintToken();
@@ -854,7 +933,7 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
854
933
  }
855
934
  const hint = /401|token|sign/i.test(msg) ? " (run `pags login`)" : "";
856
935
  writeLine(`Relay token mint failed: ${instanceId.slice(0, 8)}\u2026${hint} \u2014 retrying in ${Math.round(backoffMs / 1e3)}s`);
857
- setTimeout(() => {
936
+ retryTimer = setTimeout(() => {
858
937
  connect();
859
938
  }, backoffMs);
860
939
  backoffMs = Math.min(backoffMs * 2, 3e4);
@@ -864,6 +943,7 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
864
943
  if (force) params.set("force", "1");
865
944
  const url = `${wsBase}/v1/relay/${encodeURIComponent(instanceId)}/connect?${params.toString()}`;
866
945
  const ws = new WebSocket(url);
946
+ socket = ws;
867
947
  ws.onopen = () => {
868
948
  backoffMs = 1e3;
869
949
  writeLine(`Relay connected: ${instanceId.slice(0, 8)}\u2026`);
@@ -915,13 +995,19 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
915
995
  }
916
996
  };
917
997
  ws.onclose = (ev) => {
918
- 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
+ }
919
1005
  reconnecting = true;
920
1006
  const said = (ev.reason || "").trim();
921
1007
  const hint = ev.code === 4401 ? " \u2014 run `pags login`, then `pags up`" : ev.code === 4409 ? " \u2014 run `pags up --force` to take over" : "";
922
1008
  const reason = said ? ` (${said}${hint})` : ev.code === 1008 ? " (token expired \u2014 run `pags login` then `pags up`)" : "";
923
1009
  writeLine(`Relay disconnected: ${instanceId.slice(0, 8)}\u2026${reason} \u2014 reconnecting in ${Math.round(backoffMs / 1e3)}s`);
924
- setTimeout(() => {
1010
+ retryTimer = setTimeout(() => {
925
1011
  reconnecting = false;
926
1012
  connect();
927
1013
  }, backoffMs);
@@ -931,6 +1017,20 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
931
1017
  };
932
1018
  };
933
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
+ };
934
1034
  }
935
1035
 
936
1036
  // src/commands/runner/command.ts
@@ -944,7 +1044,7 @@ function createRunnerCommand() {
944
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) => {
945
1045
  await startRunnerForeground(opts);
946
1046
  });
947
- 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) => {
948
1048
  const runnerToken = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID()}`;
949
1049
  const host = clean(opts.host) || "127.0.0.1";
950
1050
  const port = clean(opts.port) || String(await findFreePort2(49171));
@@ -980,7 +1080,7 @@ function createRunnerCommand() {
980
1080
  try {
981
1081
  await waitForLocalRunner({ url: localUrl, token: runnerToken, instanceId: primary });
982
1082
  writeLine(`Local browser runtime healthy at ${localUrl}`);
983
- await connectViaRelay(instanceIds, localUrl, runnerToken, opts, Boolean(opts.force));
1083
+ await connectViaRelay(instanceIds, localUrl, runnerToken, opts, Boolean(opts.force), Boolean(opts.watchInstances));
984
1084
  await new Promise((resolvePromise) => {
985
1085
  runner.on("exit", () => resolvePromise());
986
1086
  });
@@ -1289,6 +1389,7 @@ var upCommand = new Command7("up").description("Start the browser runner for all
1289
1389
  const args = [cliPath, "runner", "connect", ...instances.map((i) => i.id)];
1290
1390
  if (opts.headless) args.push("--headless");
1291
1391
  if (opts.force) args.push("--force");
1392
+ if (!opts.instance) args.push("--watch-instances");
1292
1393
  const child = spawn4(process.execPath, args, {
1293
1394
  stdio: ["ignore", "pipe", "pipe"],
1294
1395
  env: { ...process.env, PAGS_TOKEN: session.token }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.29",
3
+ "version": "0.4.30",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",