@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.
- package/CHANGELOG.md +22 -0
- package/README.md +5 -1
- package/dist/commands/computer-actions.d.ts +15 -0
- package/dist/commands/computer-actions.js +120 -22
- package/dist/commands/cost.js +17 -12
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +59 -14
- package/dist/commands/events.d.ts +16 -0
- package/dist/commands/events.js +173 -0
- package/dist/commands/exec.js +6 -1
- package/dist/commands/hosts.js +28 -24
- package/dist/commands/inspect.d.ts +2 -0
- package/dist/commands/inspect.js +54 -7
- package/dist/commands/logs.d.ts +17 -0
- package/dist/commands/logs.js +139 -0
- package/dist/commands/models.d.ts +3 -0
- package/dist/commands/models.js +27 -8
- package/dist/commands/permissions.js +9 -2
- package/dist/commands/repo.d.ts +34 -0
- package/dist/commands/repo.js +243 -65
- package/dist/commands/resource-view.d.ts +20 -0
- package/dist/commands/resource-view.js +90 -28
- package/dist/commands/secrets.js +40 -16
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +209 -0
- package/dist/commands/sessions-tail.d.ts +10 -0
- package/dist/commands/sessions-tail.js +11 -0
- package/dist/commands/sessions.d.ts +73 -1
- package/dist/commands/sessions.js +273 -53
- package/dist/commands/view.d.ts +3 -0
- package/dist/commands/view.js +19 -8
- package/dist/commands/wallet.d.ts +6 -0
- package/dist/commands/wallet.js +22 -5
- package/dist/index.js +58 -31
- package/dist/lib/agents.js +30 -9
- package/dist/lib/browser/cdp.js +5 -1
- package/dist/lib/browser/drivers/ssh.d.ts +8 -0
- package/dist/lib/browser/drivers/ssh.js +76 -16
- package/dist/lib/browser/ipc.d.ts +3 -0
- package/dist/lib/browser/ipc.js +13 -9
- package/dist/lib/browser/service.js +71 -15
- package/dist/lib/daemon.d.ts +35 -0
- package/dist/lib/daemon.js +33 -5
- package/dist/lib/devices/connect.d.ts +3 -2
- package/dist/lib/devices/connect.js +5 -3
- package/dist/lib/events.d.ts +9 -2
- package/dist/lib/events.js +70 -11
- package/dist/lib/exec.js +22 -0
- package/dist/lib/hooks.js +8 -2
- package/dist/lib/hosts/dispatch.js +6 -7
- package/dist/lib/hosts/logs.d.ts +16 -0
- package/dist/lib/hosts/logs.js +45 -0
- package/dist/lib/hosts/progress.d.ts +66 -0
- package/dist/lib/hosts/progress.js +125 -17
- package/dist/lib/hosts/ready.d.ts +23 -2
- package/dist/lib/hosts/ready.js +35 -13
- package/dist/lib/hosts/reconcile.d.ts +53 -0
- package/dist/lib/hosts/reconcile.js +81 -0
- package/dist/lib/hosts/tasks.d.ts +8 -0
- package/dist/lib/hosts/tasks.js +14 -0
- package/dist/lib/permissions.d.ts +4 -0
- package/dist/lib/permissions.js +35 -0
- package/dist/lib/picker.d.ts +24 -0
- package/dist/lib/picker.js +124 -0
- package/dist/lib/plugin-marketplace.d.ts +30 -0
- package/dist/lib/plugin-marketplace.js +215 -2
- package/dist/lib/plugins.d.ts +5 -0
- package/dist/lib/plugins.js +45 -4
- package/dist/lib/session/active.d.ts +7 -0
- package/dist/lib/session/active.js +11 -3
- package/dist/lib/session/discover.js +94 -0
- package/dist/lib/session/parse.d.ts +6 -0
- package/dist/lib/session/parse.js +283 -2
- package/dist/lib/session/pid-registry.d.ts +21 -0
- package/dist/lib/session/pid-registry.js +80 -0
- package/dist/lib/session/remote-active.d.ts +26 -0
- package/dist/lib/session/remote-active.js +141 -0
- package/dist/lib/session/remote.js +2 -6
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +11 -2
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/shims.d.ts +11 -3
- package/dist/lib/shims.js +54 -30
- package/dist/lib/ssh-exec.d.ts +26 -3
- package/dist/lib/ssh-exec.js +45 -3
- package/dist/lib/ssh-tunnel.d.ts +24 -5
- package/dist/lib/ssh-tunnel.js +60 -62
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/teams/registry.js +25 -9
- package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
- package/dist/lib/terminal/backends/ghostty.js +69 -0
- package/dist/lib/terminal/backends/index.d.ts +18 -0
- package/dist/lib/terminal/backends/index.js +29 -0
- package/dist/lib/terminal/backends/iterm.d.ts +6 -0
- package/dist/lib/terminal/backends/iterm.js +62 -0
- package/dist/lib/terminal/backends/tmux.d.ts +14 -0
- package/dist/lib/terminal/backends/tmux.js +23 -0
- package/dist/lib/terminal/engine.d.ts +39 -0
- package/dist/lib/terminal/engine.js +54 -0
- package/dist/lib/terminal/index.d.ts +14 -0
- package/dist/lib/terminal/index.js +5 -0
- package/dist/lib/terminal/policy.d.ts +11 -0
- package/dist/lib/terminal/policy.js +11 -0
- package/dist/lib/terminal/quote.d.ts +11 -0
- package/dist/lib/terminal/quote.js +13 -0
- package/dist/lib/terminal/shell.d.ts +6 -0
- package/dist/lib/terminal/shell.js +23 -0
- package/dist/lib/terminal/transport.d.ts +15 -0
- package/dist/lib/terminal/transport.js +40 -0
- package/dist/lib/terminal/types.d.ts +59 -0
- package/dist/lib/terminal/types.js +13 -0
- package/dist/lib/types.d.ts +7 -0
- package/dist/lib/versions.d.ts +7 -3
- package/dist/lib/versions.js +10 -5
- package/dist/lib/whats-new.d.ts +9 -0
- package/dist/lib/whats-new.js +35 -0
- 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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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,
|
|
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
|
-
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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[];
|
package/dist/lib/hosts/ready.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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[];
|
package/dist/lib/hosts/tasks.js
CHANGED
|
@@ -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;
|
package/dist/lib/permissions.js
CHANGED
|
@@ -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();
|
package/dist/lib/picker.d.ts
CHANGED
|
@@ -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>;
|