@phnx-labs/agents-cli 1.20.34 → 1.20.35

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.
Files changed (121) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -1
  3. package/dist/commands/computer-actions.d.ts +15 -0
  4. package/dist/commands/computer-actions.js +120 -22
  5. package/dist/commands/cost.js +17 -12
  6. package/dist/commands/doctor.d.ts +1 -0
  7. package/dist/commands/doctor.js +59 -14
  8. package/dist/commands/events.d.ts +16 -0
  9. package/dist/commands/events.js +173 -0
  10. package/dist/commands/exec.js +6 -1
  11. package/dist/commands/hosts.js +28 -24
  12. package/dist/commands/inspect.d.ts +2 -0
  13. package/dist/commands/inspect.js +54 -7
  14. package/dist/commands/logs.d.ts +17 -0
  15. package/dist/commands/logs.js +139 -0
  16. package/dist/commands/models.d.ts +3 -0
  17. package/dist/commands/models.js +27 -8
  18. package/dist/commands/permissions.js +9 -2
  19. package/dist/commands/repo.d.ts +34 -0
  20. package/dist/commands/repo.js +243 -65
  21. package/dist/commands/resource-view.d.ts +20 -0
  22. package/dist/commands/resource-view.js +90 -28
  23. package/dist/commands/secrets.js +40 -16
  24. package/dist/commands/sessions-resume.d.ts +2 -0
  25. package/dist/commands/sessions-resume.js +209 -0
  26. package/dist/commands/sessions-tail.d.ts +10 -0
  27. package/dist/commands/sessions-tail.js +11 -0
  28. package/dist/commands/sessions.d.ts +73 -1
  29. package/dist/commands/sessions.js +273 -53
  30. package/dist/commands/view.d.ts +3 -0
  31. package/dist/commands/view.js +19 -8
  32. package/dist/commands/wallet.d.ts +6 -0
  33. package/dist/commands/wallet.js +22 -5
  34. package/dist/index.js +58 -31
  35. package/dist/lib/agents.js +30 -9
  36. package/dist/lib/browser/cdp.js +5 -1
  37. package/dist/lib/browser/drivers/ssh.d.ts +8 -0
  38. package/dist/lib/browser/drivers/ssh.js +76 -16
  39. package/dist/lib/browser/ipc.d.ts +3 -0
  40. package/dist/lib/browser/ipc.js +13 -9
  41. package/dist/lib/browser/service.js +71 -15
  42. package/dist/lib/daemon.d.ts +35 -0
  43. package/dist/lib/daemon.js +33 -5
  44. package/dist/lib/devices/connect.d.ts +3 -2
  45. package/dist/lib/devices/connect.js +5 -3
  46. package/dist/lib/events.d.ts +9 -2
  47. package/dist/lib/events.js +70 -11
  48. package/dist/lib/exec.js +22 -0
  49. package/dist/lib/hooks.js +8 -2
  50. package/dist/lib/hosts/dispatch.js +6 -7
  51. package/dist/lib/hosts/logs.d.ts +16 -0
  52. package/dist/lib/hosts/logs.js +45 -0
  53. package/dist/lib/hosts/progress.d.ts +66 -0
  54. package/dist/lib/hosts/progress.js +125 -17
  55. package/dist/lib/hosts/ready.d.ts +23 -2
  56. package/dist/lib/hosts/ready.js +35 -13
  57. package/dist/lib/hosts/reconcile.d.ts +53 -0
  58. package/dist/lib/hosts/reconcile.js +81 -0
  59. package/dist/lib/hosts/tasks.d.ts +8 -0
  60. package/dist/lib/hosts/tasks.js +14 -0
  61. package/dist/lib/permissions.d.ts +4 -0
  62. package/dist/lib/permissions.js +35 -0
  63. package/dist/lib/picker.d.ts +24 -0
  64. package/dist/lib/picker.js +124 -0
  65. package/dist/lib/plugin-marketplace.d.ts +30 -0
  66. package/dist/lib/plugin-marketplace.js +215 -2
  67. package/dist/lib/plugins.d.ts +5 -0
  68. package/dist/lib/plugins.js +45 -4
  69. package/dist/lib/session/active.d.ts +7 -0
  70. package/dist/lib/session/active.js +11 -3
  71. package/dist/lib/session/discover.js +94 -0
  72. package/dist/lib/session/parse.d.ts +6 -0
  73. package/dist/lib/session/parse.js +283 -2
  74. package/dist/lib/session/pid-registry.d.ts +21 -0
  75. package/dist/lib/session/pid-registry.js +80 -0
  76. package/dist/lib/session/remote-active.d.ts +26 -0
  77. package/dist/lib/session/remote-active.js +141 -0
  78. package/dist/lib/session/remote.js +2 -6
  79. package/dist/lib/session/render.js +1 -1
  80. package/dist/lib/session/sync/config.d.ts +8 -0
  81. package/dist/lib/session/sync/config.js +11 -2
  82. package/dist/lib/session/types.d.ts +1 -1
  83. package/dist/lib/session/types.js +1 -1
  84. package/dist/lib/shims.d.ts +11 -3
  85. package/dist/lib/shims.js +54 -30
  86. package/dist/lib/ssh-exec.d.ts +26 -3
  87. package/dist/lib/ssh-exec.js +45 -3
  88. package/dist/lib/ssh-tunnel.d.ts +24 -5
  89. package/dist/lib/ssh-tunnel.js +60 -62
  90. package/dist/lib/staleness/writers/hooks.js +1 -1
  91. package/dist/lib/startup/command-registry.d.ts +2 -0
  92. package/dist/lib/startup/command-registry.js +4 -0
  93. package/dist/lib/teams/registry.js +25 -9
  94. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  95. package/dist/lib/terminal/backends/ghostty.js +69 -0
  96. package/dist/lib/terminal/backends/index.d.ts +18 -0
  97. package/dist/lib/terminal/backends/index.js +29 -0
  98. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  99. package/dist/lib/terminal/backends/iterm.js +62 -0
  100. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  101. package/dist/lib/terminal/backends/tmux.js +23 -0
  102. package/dist/lib/terminal/engine.d.ts +39 -0
  103. package/dist/lib/terminal/engine.js +54 -0
  104. package/dist/lib/terminal/index.d.ts +14 -0
  105. package/dist/lib/terminal/index.js +5 -0
  106. package/dist/lib/terminal/policy.d.ts +11 -0
  107. package/dist/lib/terminal/policy.js +11 -0
  108. package/dist/lib/terminal/quote.d.ts +11 -0
  109. package/dist/lib/terminal/quote.js +13 -0
  110. package/dist/lib/terminal/shell.d.ts +6 -0
  111. package/dist/lib/terminal/shell.js +23 -0
  112. package/dist/lib/terminal/transport.d.ts +15 -0
  113. package/dist/lib/terminal/transport.js +40 -0
  114. package/dist/lib/terminal/types.d.ts +59 -0
  115. package/dist/lib/terminal/types.js +13 -0
  116. package/dist/lib/types.d.ts +7 -0
  117. package/dist/lib/versions.d.ts +7 -3
  118. package/dist/lib/versions.js +10 -5
  119. package/dist/lib/whats-new.d.ts +9 -0
  120. package/dist/lib/whats-new.js +35 -0
  121. package/package.json +1 -1
@@ -5,45 +5,153 @@
5
5
  * a sibling `.exit` file. We poll `tail -c +<offset>` (durable, offset-tracked —
6
6
  * a dropped connection resumes from the saved offset) and finish when `.exit`
7
7
  * appears. Rich transcript-parser rendering is a fast-follow.
8
+ *
9
+ * Efficiency: each cycle is a SINGLE ssh round-trip that returns the new log
10
+ * bytes, a per-task sentinel, then the exit-file contents — half the process +
11
+ * handshake cost of the old tail-then-cat pair. It rides the default control
12
+ * socket (multiplex on) that the launch opened, and eases the poll interval off
13
+ * toward `maxPollMs` while the job is idle, so a quiet long-running follow no
14
+ * longer spawns thousands of ssh processes per hour on the laptop.
8
15
  */
9
16
  import * as fs from 'fs';
10
- import { sshExec } from '../ssh-exec.js';
17
+ import { sshExec, sshExecRaw } from '../ssh-exec.js';
11
18
  import { localLogPath } from './tasks.js';
12
19
  function sleep(ms) {
13
20
  return new Promise((resolve) => setTimeout(resolve, ms));
14
21
  }
22
+ /**
23
+ * Build the per-task sentinel that separates the log tail from the exit-file
24
+ * contents in one combined fetch. The task id (8 hex chars) makes collision with
25
+ * the agent's own output effectively impossible; callers still split on the LAST
26
+ * occurrence so a token echoed into the log can never be mistaken for the real
27
+ * trailing marker.
28
+ */
29
+ export function exitMarker(taskId) {
30
+ return `\n@@AGENTS_HOST_EXIT_${taskId}@@\n`;
31
+ }
32
+ /**
33
+ * Split a combined fetch (`<log bytes><marker><exit>`) back into its parts at the
34
+ * BYTE level. Splits on the LAST marker occurrence, so even if the agent's own
35
+ * output happened to echo the token, the real trailing sentinel still wins.
36
+ * Returns null when the marker is absent (a transient fetch miss — the remote
37
+ * shell never ran our printf), telling the caller to retry without advancing.
38
+ *
39
+ * Byte-level (not string) is load-bearing: `logChunk.length` is the EXACT number
40
+ * of log bytes consumed on the wire, which the follow loop adds to its offset. A
41
+ * string split would first UTF-8-decode, turning any multibyte char split at the
42
+ * `tail -c` boundary into a U+FFFD whose re-encoded length ≠ the wire bytes,
43
+ * drifting the offset (see followHostTask). `consumed` is returned explicitly for
44
+ * clarity; it always equals `logChunk.length`.
45
+ */
46
+ export function splitProgressBytes(buf, taskId) {
47
+ const marker = Buffer.from(exitMarker(taskId), 'utf8'); // ASCII-only, unambiguous
48
+ const idx = buf.lastIndexOf(marker);
49
+ if (idx === -1)
50
+ return null;
51
+ return {
52
+ logChunk: buf.subarray(0, idx),
53
+ exit: buf.subarray(idx + marker.length),
54
+ consumed: idx,
55
+ };
56
+ }
57
+ /**
58
+ * One round-trip: new log bytes since `offset`, the sentinel, then the exit
59
+ * file. Returns null on a transient fetch miss (ssh error / marker absent) so
60
+ * the caller simply retries next cycle without advancing the offset.
61
+ *
62
+ * `remoteLog`/`remoteExit` are $HOME-prefixed paths with safe (hex) basenames —
63
+ * intentionally unquoted so the remote shell expands $HOME.
64
+ */
65
+ export function fetchProgress(target, opts) {
66
+ // Derive the printf format from the SAME exitMarker the parser splits on, so
67
+ // the emitted sentinel and the one we look for can never desync. The marker's
68
+ // only escape-sensitive bytes are its newlines (→ `\n`); it carries no `%`,
69
+ // single-quote, or other printf/shell-special chars (task id is hex).
70
+ const printfArg = exitMarker(opts.taskId).replace(/\n/g, '\\n');
71
+ const remote = `tail -c +${opts.offset + 1} ${opts.remoteLog} 2>/dev/null; ` +
72
+ `printf '${printfArg}'; ` +
73
+ `cat ${opts.remoteExit} 2>/dev/null`;
74
+ // Raw bytes (no UTF-8 decode): the log tail must be counted and re-emitted
75
+ // byte-for-byte so a multibyte char split at the `tail -c` boundary neither
76
+ // drifts the offset nor renders as U+FFFD. The exit code is pure ASCII → safe
77
+ // to decode to a string for the caller's `.trim()`.
78
+ const res = sshExecRaw(target, remote, { timeoutMs: 20000 });
79
+ const parts = splitProgressBytes(res.stdout, opts.taskId);
80
+ if (!parts)
81
+ return null;
82
+ return { logChunk: parts.logChunk, exit: parts.exit.toString('utf8') };
83
+ }
84
+ /**
85
+ * File identity (`dev:ino`) of a path on the remote host, or null if it can't be
86
+ * stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
87
+ */
88
+ export function readRemoteFileId(target, remotePath) {
89
+ const res = sshExec(target, `stat -c '%d:%i' ${remotePath} 2>/dev/null || stat -f '%d:%i' ${remotePath} 2>/dev/null`, { timeoutMs: 8000 });
90
+ const id = res.stdout.trim();
91
+ return id || null;
92
+ }
93
+ /**
94
+ * True when the local mirror file IS the very file we're tailing — the
95
+ * localhost-as-host case, where `remoteLog` ($HOME-expanded) and `localLogPath`
96
+ * resolve to the same inode. Appending our read bytes back into it would feed the
97
+ * tail and multiply the log, so the caller must skip the mirror write.
98
+ */
99
+ export function mirrorAliasesSource(localId, remoteId) {
100
+ return localId !== null && remoteId !== null && localId === remoteId;
101
+ }
15
102
  /** Tail the remote log to stdout until the run finishes; return its exit code. */
16
103
  export async function followHostTask(target, opts) {
17
- const pollMs = opts.pollMs ?? 1500;
104
+ const fastMs = opts.pollMs ?? 1500;
105
+ const maxMs = Math.max(opts.maxPollMs ?? fastMs * 4, 4000);
18
106
  const deadline = Date.now() + (opts.timeoutMs ?? 3600_000);
19
107
  const local = localLogPath(opts.taskId);
20
108
  let offset = 0;
21
- const drain = () => {
22
- // remoteLog is a $HOME-prefixed path with a safe (hex) basename intentionally
23
- // unquoted so the remote shell expands $HOME.
24
- const chunk = sshExec(target, `tail -c +${offset + 1} ${opts.remoteLog} 2>/dev/null`, { timeoutMs: 20000 });
25
- if (chunk.stdout) {
26
- if (opts.echo)
27
- process.stdout.write(chunk.stdout);
109
+ let waitMs = fastMs;
110
+ // localhost-as-host guard: when the local mirror and the remote log are the
111
+ // same physical file, appending our read bytes back would feed the tail and
112
+ // multiply the log (a plain `--host localhost` follow otherwise tripled it).
113
+ // Detect via file identity and echo-only in that case.
114
+ let mirror = true;
115
+ try {
116
+ const s = fs.statSync(local);
117
+ if (mirrorAliasesSource(`${s.dev}:${s.ino}`, readRemoteFileId(target, opts.remoteLog))) {
118
+ mirror = false;
119
+ }
120
+ }
121
+ catch { /* mirror absent or unstattable → distinct file, keep mirroring */ }
122
+ const flush = (logChunk) => {
123
+ if (logChunk.length === 0)
124
+ return false;
125
+ if (opts.echo)
126
+ process.stdout.write(logChunk);
127
+ if (mirror) {
28
128
  try {
29
- fs.appendFileSync(local, chunk.stdout);
129
+ fs.appendFileSync(local, logChunk);
30
130
  }
31
131
  catch { /* best-effort */ }
32
- offset += Buffer.byteLength(chunk.stdout, 'utf8');
33
132
  }
133
+ offset += logChunk.length; // exact wire bytes — no re-encode drift
134
+ return true;
34
135
  };
35
136
  for (;;) {
36
- drain();
37
- const exit = sshExec(target, `cat ${opts.remoteExit} 2>/dev/null`, { timeoutMs: 12000 });
38
- if (exit.code === 0 && exit.stdout.trim() !== '') {
39
- drain(); // final flush
40
- const code = parseInt(exit.stdout.trim(), 10);
137
+ const r = fetchProgress(target, { remoteLog: opts.remoteLog, remoteExit: opts.remoteExit, taskId: opts.taskId, offset });
138
+ const gotOutput = r ? flush(r.logChunk) : false;
139
+ if (r && r.exit.trim() !== '') {
140
+ // Finished — one final fetch catches bytes written between our tail and
141
+ // the exit file appearing.
142
+ const tail = fetchProgress(target, { remoteLog: opts.remoteLog, remoteExit: opts.remoteExit, taskId: opts.taskId, offset });
143
+ if (tail)
144
+ flush(tail.logChunk);
145
+ const code = parseInt(r.exit.trim(), 10);
41
146
  return Number.isFinite(code) ? code : 0;
42
147
  }
43
148
  if (Date.now() > deadline) {
44
149
  process.stderr.write('\n[hosts] follow timed out; the run continues on the host. Reattach with: agents hosts logs ' + opts.taskId + ' -f\n');
45
150
  return -1;
46
151
  }
47
- await sleep(pollMs);
152
+ // Fast while output flows; ease toward maxMs when idle so a quiet job isn't
153
+ // polled needlessly. New output snaps the cadence back to fast.
154
+ waitMs = gotOutput ? fastMs : Math.min(maxMs, Math.round(waitMs * 1.5));
155
+ await sleep(waitMs);
48
156
  }
49
157
  }
@@ -15,13 +15,32 @@ export declare function probeHost(target: string): {
15
15
  };
16
16
  /** Remote agents-cli version (login shell for PATH), or null if not installed. */
17
17
  export declare function remoteAgentsVersion(target: string): string | null;
18
- /** True if the named agent CLI is installed on the remote (best-effort). */
19
- export declare function remoteAgentInstalled(target: string, agent: string): boolean;
20
18
  /** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
21
19
  export declare function bootstrapAgentsCli(target: string, version: string | null): {
22
20
  ok: boolean;
23
21
  output: string;
24
22
  };
23
+ export interface ReadyProbe {
24
+ /** ssh connected and the remote login shell ran (our sentinel came back). */
25
+ reachable: boolean;
26
+ /** Remote agents-cli version (no leading `v`), or null if not installed. */
27
+ version: string | null;
28
+ /** Raw `agents view`/`list` output, for installed-agent checks. */
29
+ view: string;
30
+ }
31
+ /**
32
+ * Answer every readiness question in ONE ssh round-trip: reachable? (the login
33
+ * shell ran and echoed our sentinel), agents-cli version, and the installed-agent
34
+ * listing. This replaces three sequential probes (`true` + `agents --version` +
35
+ * `agents view`) — 3 handshakes collapse to 1. Reachability keys off the sentinel
36
+ * rather than the exit code, so a command that ran-but-failed is never mistaken
37
+ * for a dead connection (only ssh's own failure drops the sentinel).
38
+ */
39
+ export declare function readyProbe(target: string): ReadyProbe;
40
+ /** Pure parser for `readyProbe` output (unit-tested without ssh). */
41
+ export declare function parseReadyProbe(stdout: string): ReadyProbe;
42
+ /** True if `view` output lists the named agent (word-boundary, case-insensitive). */
43
+ export declare function viewHasAgent(view: string, agent: string): boolean;
25
44
  export interface EnsureReadyOptions {
26
45
  agent: string;
27
46
  /** Throw instead of warn when the agent isn't installed remotely. */
@@ -31,6 +50,8 @@ export interface EnsureReadyOptions {
31
50
  * Verify a host can run the agent: reachable + agents-cli present. Throws with an
32
51
  * actionable message otherwise. Agent-not-installed is a warning by default (the
33
52
  * remote `agents run` will surface it); pass requireAgent to make it fatal.
53
+ *
54
+ * One ssh round-trip (`readyProbe`) covers all three checks.
34
55
  */
35
56
  export declare function ensureHostReady(host: Host, opts: EnsureReadyOptions): {
36
57
  warnings: string[];
@@ -8,7 +8,7 @@
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { fileURLToPath } from 'url';
11
- import { sshExec, sshReachable, shellQuote } from '../ssh-exec.js';
11
+ import { sshExec, shellQuote } from '../ssh-exec.js';
12
12
  import { sshTargetFor } from './types.js';
13
13
  /** Resolve this CLI's own version by walking up to the nearest package.json. */
14
14
  export function localCliVersion() {
@@ -46,15 +46,6 @@ export function remoteAgentsVersion(target) {
46
46
  const v = r.stdout.trim();
47
47
  return v || null;
48
48
  }
49
- /** True if the named agent CLI is installed on the remote (best-effort). */
50
- export function remoteAgentInstalled(target, agent) {
51
- // `agents view` (formerly `agents list`) prints capitalized display names
52
- // (e.g. "Claude"), so match case-insensitively.
53
- const r = sshExec(target, `bash -lc "agents view 2>/dev/null || agents list 2>/dev/null"`, { timeoutMs: 20000 });
54
- if (r.code !== 0)
55
- return false;
56
- return new RegExp(`\\b${agent}\\b`, 'i').test(r.stdout);
57
- }
58
49
  /** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
59
50
  export function bootstrapAgentsCli(target, version) {
60
51
  const spec = version ? `@phnx-labs/agents-cli@${version}` : '@phnx-labs/agents-cli';
@@ -64,21 +55,52 @@ export function bootstrapAgentsCli(target, version) {
64
55
  const r = sshExec(target, `bash -lc ${shellQuote(script)}`, { timeoutMs: 300000 });
65
56
  return { ok: r.code === 0, output: (r.stdout + r.stderr).trim() };
66
57
  }
58
+ /** Sentinel splitting the version output from the agent listing in one probe. */
59
+ const READY_MARKER = '@@AGENTS_READY@@';
60
+ /**
61
+ * Answer every readiness question in ONE ssh round-trip: reachable? (the login
62
+ * shell ran and echoed our sentinel), agents-cli version, and the installed-agent
63
+ * listing. This replaces three sequential probes (`true` + `agents --version` +
64
+ * `agents view`) — 3 handshakes collapse to 1. Reachability keys off the sentinel
65
+ * rather than the exit code, so a command that ran-but-failed is never mistaken
66
+ * for a dead connection (only ssh's own failure drops the sentinel).
67
+ */
68
+ export function readyProbe(target) {
69
+ const script = `agents --version 2>/dev/null; printf '\\n${READY_MARKER}\\n'; ` +
70
+ `agents view 2>/dev/null || agents list 2>/dev/null`;
71
+ const r = sshExec(target, `bash -lc ${shellQuote(script)}`, { timeoutMs: 20000 });
72
+ return parseReadyProbe(r.stdout);
73
+ }
74
+ /** Pure parser for `readyProbe` output (unit-tested without ssh). */
75
+ export function parseReadyProbe(stdout) {
76
+ const idx = stdout.indexOf(READY_MARKER);
77
+ if (idx === -1)
78
+ return { reachable: false, version: null, view: '' };
79
+ const version = stdout.slice(0, idx).trim().replace(/^v/, '') || null;
80
+ return { reachable: true, version, view: stdout.slice(idx + READY_MARKER.length) };
81
+ }
82
+ /** True if `view` output lists the named agent (word-boundary, case-insensitive). */
83
+ export function viewHasAgent(view, agent) {
84
+ return new RegExp(`\\b${agent}\\b`, 'i').test(view);
85
+ }
67
86
  /**
68
87
  * Verify a host can run the agent: reachable + agents-cli present. Throws with an
69
88
  * actionable message otherwise. Agent-not-installed is a warning by default (the
70
89
  * remote `agents run` will surface it); pass requireAgent to make it fatal.
90
+ *
91
+ * One ssh round-trip (`readyProbe`) covers all three checks.
71
92
  */
72
93
  export function ensureHostReady(host, opts) {
73
94
  const target = sshTargetFor(host);
74
- if (!sshReachable(target)) {
95
+ const probe = readyProbe(target);
96
+ if (!probe.reachable) {
75
97
  throw new Error(`Host "${host.name}" (${target}) is not reachable over SSH. Check it's online and key auth works.`);
76
98
  }
77
- if (!remoteAgentsVersion(target)) {
99
+ if (!probe.version) {
78
100
  throw new Error(`agents-cli is not installed on "${host.name}". Enroll it first: agents hosts add ${host.name} (bootstraps agents-cli).`);
79
101
  }
80
102
  const warnings = [];
81
- if (!remoteAgentInstalled(target, opts.agent)) {
103
+ if (!viewHasAgent(probe.view, opts.agent)) {
82
104
  const msg = `Agent "${opts.agent}" may not be installed on "${host.name}" (remote \`agents add ${opts.agent}\` to install).`;
83
105
  if (opts.requireAgent)
84
106
  throw new Error(msg);
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Reconcile a local host-task record against the remote run's ground truth.
3
+ *
4
+ * A detached `--host` run outlives the local follower: it keeps running and
5
+ * writes its exit code to `<id>.exit` on the host even if the laptop sleeps or
6
+ * the SSH connection drops mid-follow. When that happens the local record is
7
+ * left at `status:'running'` forever, because the only path that finalizes it
8
+ * (dispatch's post-follow `updateTask`) never runs. This module re-reads the
9
+ * remote `.exit` on demand — from `agents hosts ps` / `logs` — and heals the
10
+ * record. We only ever CONFIRM completion; an unreachable host or an absent
11
+ * `.exit` leaves the record `running` (we never guess failure).
12
+ */
13
+ import { type SshExecResult } from '../ssh-exec.js';
14
+ import { type HostTask } from './tasks.js';
15
+ export type RemoteExitState = {
16
+ state: 'running';
17
+ } | {
18
+ state: 'done';
19
+ code: number;
20
+ } | {
21
+ state: 'unreachable';
22
+ };
23
+ /**
24
+ * Classify a `cat <remoteExit>` result into a remote run state. Pure: all the
25
+ * bug-prone branching (ssh-failure vs absent vs empty vs coded) lives here so it
26
+ * can be unit-tested without a live host. ssh's own connection failure surfaces
27
+ * as code 255, a spawn error/timeout as `code === null`; neither is the remote
28
+ * command's exit and both mean "unreachable". An empty read is "still running"
29
+ * (the `.exit` is written only after the run ends, so absent `cat` → exit 1 →
30
+ * empty stdout, and a truncate-then-write mid-race is a sub-ms empty window).
31
+ */
32
+ export declare function classifyExit(res: Pick<SshExecResult, 'code' | 'stdout' | 'timedOut'>): RemoteExitState;
33
+ /**
34
+ * Read a task's remote `.exit` over ssh and classify it. `remoteExit` is a
35
+ * $HOME-prefixed path with a safe (hex) basename — intentionally unquoted so the
36
+ * remote shell expands $HOME (same contract as progress.ts's fetch).
37
+ */
38
+ export declare function readRemoteExit(target: string, remoteExit: string, timeoutMs?: number): RemoteExitState;
39
+ /**
40
+ * Heal one record. Terminal records are immutable (and never re-probed); a
41
+ * `running` record is resolved to completed/failed only when the remote `.exit`
42
+ * holds a code. Returns the (possibly updated) task.
43
+ */
44
+ export declare function reconcileTask(task: HostTask): HostTask;
45
+ /**
46
+ * Heal a list of records for a listing (`agents hosts ps`). Only `running` tasks
47
+ * are probed; each host is reachability-checked ONCE (deduped by target) so a
48
+ * down host costs a single short timeout instead of one per task, and its tasks
49
+ * are left `running` rather than falsely failed. Sequential by design — with the
50
+ * shared ssh control socket the live-host reads are sub-100ms, and a parallel
51
+ * (async) ssh path is deliberately out of scope.
52
+ */
53
+ export declare function reconcileRunningTasks(tasks: HostTask[]): HostTask[];
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Reconcile a local host-task record against the remote run's ground truth.
3
+ *
4
+ * A detached `--host` run outlives the local follower: it keeps running and
5
+ * writes its exit code to `<id>.exit` on the host even if the laptop sleeps or
6
+ * the SSH connection drops mid-follow. When that happens the local record is
7
+ * left at `status:'running'` forever, because the only path that finalizes it
8
+ * (dispatch's post-follow `updateTask`) never runs. This module re-reads the
9
+ * remote `.exit` on demand — from `agents hosts ps` / `logs` — and heals the
10
+ * record. We only ever CONFIRM completion; an unreachable host or an absent
11
+ * `.exit` leaves the record `running` (we never guess failure).
12
+ */
13
+ import { sshExec, sshReachable } from '../ssh-exec.js';
14
+ import { updateTask, terminalPatch } from './tasks.js';
15
+ /**
16
+ * Classify a `cat <remoteExit>` result into a remote run state. Pure: all the
17
+ * bug-prone branching (ssh-failure vs absent vs empty vs coded) lives here so it
18
+ * can be unit-tested without a live host. ssh's own connection failure surfaces
19
+ * as code 255, a spawn error/timeout as `code === null`; neither is the remote
20
+ * command's exit and both mean "unreachable". An empty read is "still running"
21
+ * (the `.exit` is written only after the run ends, so absent `cat` → exit 1 →
22
+ * empty stdout, and a truncate-then-write mid-race is a sub-ms empty window).
23
+ */
24
+ export function classifyExit(res) {
25
+ if (res.timedOut || res.code === null || res.code === 255)
26
+ return { state: 'unreachable' };
27
+ const out = res.stdout.trim();
28
+ if (out === '')
29
+ return { state: 'running' };
30
+ const code = parseInt(out, 10);
31
+ return { state: 'done', code: Number.isFinite(code) ? code : 0 };
32
+ }
33
+ /**
34
+ * Read a task's remote `.exit` over ssh and classify it. `remoteExit` is a
35
+ * $HOME-prefixed path with a safe (hex) basename — intentionally unquoted so the
36
+ * remote shell expands $HOME (same contract as progress.ts's fetch).
37
+ */
38
+ export function readRemoteExit(target, remoteExit, timeoutMs = 6000) {
39
+ return classifyExit(sshExec(target, `cat ${remoteExit} 2>/dev/null`, { timeoutMs, multiplex: true }));
40
+ }
41
+ /**
42
+ * Heal one record. Terminal records are immutable (and never re-probed); a
43
+ * `running` record is resolved to completed/failed only when the remote `.exit`
44
+ * holds a code. Returns the (possibly updated) task.
45
+ */
46
+ export function reconcileTask(task) {
47
+ if (task.status !== 'running')
48
+ return task;
49
+ const st = readRemoteExit(task.target, task.remoteExit);
50
+ if (st.state !== 'done')
51
+ return task;
52
+ return updateTask(task.id, terminalPatch(st.code)) ?? task;
53
+ }
54
+ /**
55
+ * Heal a list of records for a listing (`agents hosts ps`). Only `running` tasks
56
+ * are probed; each host is reachability-checked ONCE (deduped by target) so a
57
+ * down host costs a single short timeout instead of one per task, and its tasks
58
+ * are left `running` rather than falsely failed. Sequential by design — with the
59
+ * shared ssh control socket the live-host reads are sub-100ms, and a parallel
60
+ * (async) ssh path is deliberately out of scope.
61
+ */
62
+ export function reconcileRunningTasks(tasks) {
63
+ const running = tasks.filter((t) => t.status === 'running');
64
+ if (running.length === 0)
65
+ return tasks;
66
+ const reachable = new Map();
67
+ const patched = new Map();
68
+ for (const t of running) {
69
+ if (!reachable.has(t.target))
70
+ reachable.set(t.target, sshReachable(t.target, 6000));
71
+ if (!reachable.get(t.target))
72
+ continue; // host down → leave running
73
+ const st = readRemoteExit(t.target, t.remoteExit);
74
+ if (st.state === 'done') {
75
+ const updated = updateTask(t.id, terminalPatch(st.code));
76
+ if (updated)
77
+ patched.set(t.id, updated);
78
+ }
79
+ }
80
+ return tasks.map((t) => patched.get(t.id) ?? t);
81
+ }
@@ -29,4 +29,12 @@ export declare function localLogPath(id: string): string;
29
29
  export declare function saveTask(task: HostTask): void;
30
30
  export declare function loadTask(id: string): HostTask | null;
31
31
  export declare function updateTask(id: string, patch: Partial<HostTask>): HostTask | null;
32
+ /**
33
+ * The record patch for a run that has finished with `code`. The single authority
34
+ * for the exit-code → status mapping, so the dispatch, reconcile, and log-follow
35
+ * paths can never disagree. A genuine remote exit code is never -1 (that sentinel
36
+ * means "follow window closed while the run continues"), so callers must resolve
37
+ * -1 as still-running and never pass it here.
38
+ */
39
+ export declare function terminalPatch(code: number): Partial<HostTask>;
32
40
  export declare function listTasks(): HostTask[];
@@ -40,6 +40,20 @@ export function updateTask(id, patch) {
40
40
  saveTask(next);
41
41
  return next;
42
42
  }
43
+ /**
44
+ * The record patch for a run that has finished with `code`. The single authority
45
+ * for the exit-code → status mapping, so the dispatch, reconcile, and log-follow
46
+ * paths can never disagree. A genuine remote exit code is never -1 (that sentinel
47
+ * means "follow window closed while the run continues"), so callers must resolve
48
+ * -1 as still-running and never pass it here.
49
+ */
50
+ export function terminalPatch(code) {
51
+ return {
52
+ status: code === 0 ? 'completed' : 'failed',
53
+ exitCode: code,
54
+ finishedAt: new Date().toISOString(),
55
+ };
56
+ }
43
57
  export function listTasks() {
44
58
  let files;
45
59
  try {
@@ -2,6 +2,10 @@ import type { AgentId, PermissionSet, InstalledPermission, ClaudePermissions, Op
2
2
  /** Filename used for Codex Starlark deny-rules generated from permission groups. */
3
3
  export declare const CODEX_RULES_FILENAME = "agents-deny.rules";
4
4
  export type ParsedRules = PermissionSet;
5
+ export declare const COMPUTER_PERMISSION_RULE_PREFIX = "Computer";
6
+ export declare const COMPUTER_APP_GATED_VERBS: readonly ["screenshot", "describe", "get-text", "launch", "raise", "click", "right-click", "type", "type-text", "key", "drag", "scroll", "ax-action", "focus", "wait"];
7
+ export declare const COMPUTER_INPUT_GATED_VERBS: readonly ["raise", "click", "right-click", "type", "type-text", "key", "drag", "scroll", "ax-action", "focus"];
8
+ export declare function formatComputerPermissionGrantHint(bundleId?: string): string;
5
9
  export declare function containsBroadGrants(rules: ParsedRules): {
6
10
  broad: string[];
7
11
  reason: string;
@@ -25,6 +25,41 @@ const HOME = os.homedir();
25
25
  // the writer in `applyPermissionsToVersion` handles the format dispatch.)
26
26
  /** Filename used for Codex Starlark deny-rules generated from permission groups. */
27
27
  export const CODEX_RULES_FILENAME = 'agents-deny.rules';
28
+ export const COMPUTER_PERMISSION_RULE_PREFIX = 'Computer';
29
+ export const COMPUTER_APP_GATED_VERBS = [
30
+ 'screenshot',
31
+ 'describe',
32
+ 'get-text',
33
+ 'launch',
34
+ 'raise',
35
+ 'click',
36
+ 'right-click',
37
+ 'type',
38
+ 'type-text',
39
+ 'key',
40
+ 'drag',
41
+ 'scroll',
42
+ 'ax-action',
43
+ 'focus',
44
+ 'wait',
45
+ ];
46
+ export const COMPUTER_INPUT_GATED_VERBS = [
47
+ 'raise',
48
+ 'click',
49
+ 'right-click',
50
+ 'type',
51
+ 'type-text',
52
+ 'key',
53
+ 'drag',
54
+ 'scroll',
55
+ 'ax-action',
56
+ 'focus',
57
+ ];
58
+ export function formatComputerPermissionGrantHint(bundleId) {
59
+ const target = bundleId && bundleId.length > 0 ? bundleId : '<bundle-id>';
60
+ return `add Computer(${target}) to a permissions group, then \`agents computer reload\`\n` +
61
+ `app-targeted computer verbs are gated by Computer(<bundle-id>): ${COMPUTER_APP_GATED_VERBS.join(', ')}`;
62
+ }
28
63
  export function containsBroadGrants(rules) {
29
64
  const broad = [];
30
65
  const reasons = new Set();
@@ -22,7 +22,31 @@ export interface PickerConfig<T> {
22
22
  export interface PickedItem<T> {
23
23
  item: T;
24
24
  }
25
+ /** Configuration for the multi-select picker prompt. */
26
+ export interface MultiPickerConfig<T> {
27
+ message: string;
28
+ items: T[];
29
+ filter: (query: string) => T[];
30
+ labelFor: (item: T, query: string) => string;
31
+ /** Stable identity for an item — drives the selected set. */
32
+ keyFor: (item: T) => string;
33
+ buildPreview?: (item: T) => string;
34
+ pageSize?: number;
35
+ initialSearch?: string;
36
+ emptyMessage?: string;
37
+ enterHint?: string;
38
+ }
25
39
  /** Clip a picker preview so the full prompt can fit in the terminal viewport. */
26
40
  export declare function limitPreviewHeight(preview: string, maxRows: number, width: number): string;
27
41
  /** Show an interactive fuzzy-filter picker and return the selected item, or null on cancel. */
28
42
  export declare function itemPicker<T>(config: PickerConfig<T>): Promise<PickedItem<T> | null>;
43
+ /**
44
+ * Multi-select variant of {@link itemPicker}. Same searchable, paginated list
45
+ * and preview pane, but `space` toggles a checkbox on the active row instead of
46
+ * the preview (preview moves to `tab`), and `enter` confirms every checked row.
47
+ *
48
+ * Returns the selected items (in the config's `items` order) or `null` on
49
+ * cancel. Pressing `enter` with nothing checked confirms just the highlighted
50
+ * row, so a quick single-pick still works.
51
+ */
52
+ export declare function multiItemPicker<T>(config: MultiPickerConfig<T>): Promise<T[] | null>;