@phnx-labs/agents-cli 1.22.25 → 1.22.26
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 +183 -0
- package/README.md +17 -2
- package/dist/bin/agents +0 -0
- package/dist/browser.js +14 -4
- package/dist/commands/apply.js +52 -8
- package/dist/commands/browser.js +35 -0
- package/dist/commands/doctor.js +8 -0
- package/dist/commands/insights.d.ts +25 -19
- package/dist/commands/insights.js +107 -33
- package/dist/commands/reconnect.d.ts +46 -0
- package/dist/commands/reconnect.js +109 -0
- package/dist/commands/routines.js +2 -2
- package/dist/commands/secrets.d.ts +2 -8
- package/dist/commands/secrets.js +29 -105
- package/dist/commands/sessions.js +4 -0
- package/dist/commands/setup-secrets.d.ts +1 -0
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/setup.d.ts +26 -3
- package/dist/commands/setup.js +105 -46
- package/dist/commands/teams.d.ts +6 -0
- package/dist/commands/teams.js +43 -0
- package/dist/commands/trends.d.ts +8 -0
- package/dist/commands/trends.js +10 -156
- package/dist/index.js +1 -1
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +29 -2
- package/dist/lib/analytics/dashboard.d.ts +10 -6
- package/dist/lib/analytics/dashboard.js +6 -4
- package/dist/lib/analytics/mix-commands.d.ts +53 -0
- package/dist/lib/analytics/mix-commands.js +229 -0
- package/dist/lib/analytics/recipes.d.ts +19 -14
- package/dist/lib/analytics/recipes.js +4 -2
- package/dist/lib/browser/ipc.d.ts +26 -0
- package/dist/lib/browser/ipc.js +139 -24
- package/dist/lib/browser/profiles.d.ts +11 -0
- package/dist/lib/browser/profiles.js +1 -1
- package/dist/lib/browser/stream.d.ts +14 -0
- package/dist/lib/browser/stream.js +71 -0
- package/dist/lib/channels/owner-sink.d.ts +27 -0
- package/dist/lib/channels/owner-sink.js +93 -0
- package/dist/lib/devices/doctor-findings.d.ts +7 -1
- package/dist/lib/devices/doctor-findings.js +33 -1
- package/dist/lib/fleet/apply.d.ts +59 -3
- package/dist/lib/fleet/apply.js +183 -6
- package/dist/lib/fleet/types.d.ts +21 -2
- package/dist/lib/hooks/cache.js +15 -0
- package/dist/lib/hosts/passthrough.d.ts +23 -0
- package/dist/lib/hosts/passthrough.js +45 -0
- package/dist/lib/hosts/ready.d.ts +2 -0
- package/dist/lib/hosts/ready.js +10 -1
- package/dist/lib/hosts/reconnect.d.ts +14 -12
- package/dist/lib/hosts/reconnect.js +41 -40
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/routines.js +14 -2
- package/dist/lib/runner.d.ts +0 -3
- package/dist/lib/runner.js +1 -14
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/push.d.ts +94 -0
- package/dist/lib/secrets/push.js +145 -0
- package/dist/lib/secrets/reaper.d.ts +15 -1
- package/dist/lib/secrets/reaper.js +30 -3
- package/dist/lib/session/db.d.ts +21 -3
- package/dist/lib/session/db.js +221 -13
- package/dist/lib/session/discover.d.ts +1 -0
- package/dist/lib/session/discover.js +115 -19
- package/dist/lib/session/insights.d.ts +18 -0
- package/dist/lib/session/insights.js +143 -1
- package/dist/lib/session/tool-index.js +133 -22
- package/dist/lib/session/tool-store.d.ts +26 -2
- package/dist/lib/session/tool-store.js +36 -17
- package/dist/lib/ssh-exec.js +8 -2
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/teams/agents.d.ts +13 -0
- package/dist/lib/teams/agents.js +75 -7
- package/dist/lib/teams/placement-probe.d.ts +21 -0
- package/dist/lib/teams/placement-probe.js +135 -0
- package/dist/lib/teams/scheduler.d.ts +74 -1
- package/dist/lib/teams/scheduler.js +187 -10
- package/package.json +1 -1
package/dist/lib/hooks/cache.js
CHANGED
|
@@ -505,6 +505,21 @@ if [ "$CACHE_STATUS" = miss ]; then
|
|
|
505
505
|
if [ "$_in_backoff" -eq 0 ]; then
|
|
506
506
|
# Fix 1: lockdir — only one background refresh runs at a time.
|
|
507
507
|
LOCK_DIR="$CACHE_FILE.bg.lck"
|
|
508
|
+
# Fix 4 (RUSH-2259): reclaim a stale lock. The lock is released by the bg
|
|
509
|
+
# subshell's EXIT trap, but a hard kill (SIGKILL, OOM, reboot) skips the
|
|
510
|
+
# trap and orphans the dir — after which every future mkdir fails and bg
|
|
511
|
+
# refresh stops FOREVER while stale cache is served indefinitely. The dir
|
|
512
|
+
# mtime is fixed at mkdir time (the refresh writes elsewhere), so its age
|
|
513
|
+
# is the time the lock has been held; treat a lock older than LOCK_TTL_SEC
|
|
514
|
+
# as abandoned and remove it before locking. LOCK_TTL_SEC is set well above
|
|
515
|
+
# any real hook runtime so a live refresh is never reclaimed out from under.
|
|
516
|
+
LOCK_TTL_SEC=300
|
|
517
|
+
if [ -d "$LOCK_DIR" ]; then
|
|
518
|
+
_lock_mtime=$("$PY" -c 'import os,sys; print(int(os.path.getmtime(sys.argv[1])))' "$LOCK_DIR" 2>/dev/null || echo 0)
|
|
519
|
+
_lock_now_s=$(date +%s)
|
|
520
|
+
_lock_age=$((_lock_now_s - \${_lock_mtime:-0}))
|
|
521
|
+
[ "$_lock_age" -ge "$LOCK_TTL_SEC" ] && rm -rf "$LOCK_DIR" 2>/dev/null
|
|
522
|
+
fi
|
|
508
523
|
if mkdir "$LOCK_DIR" 2>/dev/null; then
|
|
509
524
|
tmp="$CACHE_FILE.new.$$"
|
|
510
525
|
# Fix 3: background subshell captures and logs its own real exit code.
|
|
@@ -61,6 +61,29 @@ export declare function runFleetPassthrough(command: string, allArgs: string[],
|
|
|
61
61
|
* @param allArgs `process.argv.slice(2)` — the command name followed by its args.
|
|
62
62
|
*/
|
|
63
63
|
export declare function maybeRunOnHost(command: string, allArgs: string[], opts?: FleetPassthroughOptions): Promise<boolean>;
|
|
64
|
+
/**
|
|
65
|
+
* `--host` passthrough for a **standalone binary** whose command name is fixed by
|
|
66
|
+
* the binary itself (the `browser`/`computer` bins, `dist/browser.js` etc.) rather
|
|
67
|
+
* than being the first argv token.
|
|
68
|
+
*
|
|
69
|
+
* `agents browser … --host <box>` routes through {@link maybeRunOnHost} in
|
|
70
|
+
* index.ts, but the standalone `browser` binary never enters index.ts — so without
|
|
71
|
+
* this it dropped `--host` entirely (commander errored with `unknown option
|
|
72
|
+
* '--host'`). This wires the identical routing: it synthesizes the implicit command
|
|
73
|
+
* token (`browser`) at the front of the argv so the remote invocation builds as
|
|
74
|
+
* `agents browser …`, delegates to {@link maybeRunOnHost}, and returns `true` when
|
|
75
|
+
* it dispatched the run to a remote (the local program must then NOT parse).
|
|
76
|
+
*
|
|
77
|
+
* On a local / self-host fall-through it rewrites `process.argv` to the ORIGINAL
|
|
78
|
+
* args minus the routing flags — never the synthetic command token — so the
|
|
79
|
+
* standalone commander program parses cleanly and never sees an unknown `--host`.
|
|
80
|
+
* When no routing flag is present it leaves `process.argv` untouched (the common
|
|
81
|
+
* case), so an unrelated flag like `--no-tty` is never stripped from a purely local
|
|
82
|
+
* run.
|
|
83
|
+
*
|
|
84
|
+
* @param command the fixed command name the binary stands for (`'browser'`).
|
|
85
|
+
*/
|
|
86
|
+
export declare function maybeRunStandaloneOnHost(command: string, opts?: FleetPassthroughOptions): Promise<boolean>;
|
|
64
87
|
/**
|
|
65
88
|
* Run `agents <forwardedArgs>` on `host` over SSH, streaming its output, and
|
|
66
89
|
* return the exit code. The single place the SSH hop is built, so every remote
|
|
@@ -551,6 +551,51 @@ export async function maybeRunOnHost(command, allArgs, opts) {
|
|
|
551
551
|
});
|
|
552
552
|
return true;
|
|
553
553
|
}
|
|
554
|
+
/**
|
|
555
|
+
* `--host` passthrough for a **standalone binary** whose command name is fixed by
|
|
556
|
+
* the binary itself (the `browser`/`computer` bins, `dist/browser.js` etc.) rather
|
|
557
|
+
* than being the first argv token.
|
|
558
|
+
*
|
|
559
|
+
* `agents browser … --host <box>` routes through {@link maybeRunOnHost} in
|
|
560
|
+
* index.ts, but the standalone `browser` binary never enters index.ts — so without
|
|
561
|
+
* this it dropped `--host` entirely (commander errored with `unknown option
|
|
562
|
+
* '--host'`). This wires the identical routing: it synthesizes the implicit command
|
|
563
|
+
* token (`browser`) at the front of the argv so the remote invocation builds as
|
|
564
|
+
* `agents browser …`, delegates to {@link maybeRunOnHost}, and returns `true` when
|
|
565
|
+
* it dispatched the run to a remote (the local program must then NOT parse).
|
|
566
|
+
*
|
|
567
|
+
* On a local / self-host fall-through it rewrites `process.argv` to the ORIGINAL
|
|
568
|
+
* args minus the routing flags — never the synthetic command token — so the
|
|
569
|
+
* standalone commander program parses cleanly and never sees an unknown `--host`.
|
|
570
|
+
* When no routing flag is present it leaves `process.argv` untouched (the common
|
|
571
|
+
* case), so an unrelated flag like `--no-tty` is never stripped from a purely local
|
|
572
|
+
* run.
|
|
573
|
+
*
|
|
574
|
+
* @param command the fixed command name the binary stands for (`'browser'`).
|
|
575
|
+
*/
|
|
576
|
+
export async function maybeRunStandaloneOnHost(command, opts) {
|
|
577
|
+
const rawArgs = process.argv.slice(2);
|
|
578
|
+
const hasRoutingFlag = flagValue(rawArgs, 'host', 'H') !== undefined ||
|
|
579
|
+
flagValue(rawArgs, 'device') !== undefined ||
|
|
580
|
+
flagValue(rawArgs, 'hosts') !== undefined ||
|
|
581
|
+
flagValue(rawArgs, 'devices') !== undefined;
|
|
582
|
+
// No routing flag → nothing to route or strip. Leave argv alone so a purely
|
|
583
|
+
// local run keeps every flag it passed.
|
|
584
|
+
if (!hasRoutingFlag)
|
|
585
|
+
return false;
|
|
586
|
+
// Keep --help/--version local (docs must work without a reachable host), mirroring
|
|
587
|
+
// index.ts's `helpOrVersionRequested` guard, but still strip the routing flags
|
|
588
|
+
// below so commander doesn't choke on them.
|
|
589
|
+
const helpOrVersion = rawArgs.some((a) => a === '--help' || a === '-h' || a === '--version' || a === '-V');
|
|
590
|
+
if (!helpOrVersion && (await maybeRunOnHost(command, [command, ...rawArgs], opts))) {
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
// Local / self-host fall-through (maybeRunOnHost may have rewritten process.argv
|
|
594
|
+
// with the synthetic command token). Rebuild argv from the original args minus
|
|
595
|
+
// the routing flags so the standalone program parses cleanly.
|
|
596
|
+
process.argv = [process.argv[0], process.argv[1], ...stripRoutingFlags(rawArgs, STRIP_SPECS)];
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
554
599
|
/**
|
|
555
600
|
* Run `agents <forwardedArgs>` on `host` over SSH, streaming its output, and
|
|
556
601
|
* return the exit code. The single place the SSH hop is built, so every remote
|
|
@@ -42,6 +42,8 @@ export interface ReadyProbe {
|
|
|
42
42
|
version: string | null;
|
|
43
43
|
/** Raw `agents view`/`list` output, for installed-agent checks. */
|
|
44
44
|
view: string;
|
|
45
|
+
/** True when the ssh probe timed out before the sentinel arrived. */
|
|
46
|
+
timedOut?: boolean;
|
|
45
47
|
}
|
|
46
48
|
/**
|
|
47
49
|
* Answer every readiness question in ONE ssh round-trip: reachable? (the login
|
package/dist/lib/hosts/ready.js
CHANGED
|
@@ -121,7 +121,11 @@ export function buildReadyProbeCommand(os) {
|
|
|
121
121
|
return `bash -lc ${shellQuote(script)}`;
|
|
122
122
|
}
|
|
123
123
|
export function readyProbe(target, os) {
|
|
124
|
-
|
|
124
|
+
// Disable multiplexing: a stale control socket can hang the local ssh client
|
|
125
|
+
// until the timeout fires, just like sshExecAsync does for the same reason.
|
|
126
|
+
const r = sshExec(target, buildReadyProbeCommand(os), { timeoutMs: 20000, multiplex: false });
|
|
127
|
+
if (r.timedOut)
|
|
128
|
+
return { reachable: false, version: null, view: '', timedOut: true };
|
|
125
129
|
return parseReadyProbe(r.stdout);
|
|
126
130
|
}
|
|
127
131
|
/** Pure parser for `readyProbe` output (unit-tested without ssh). */
|
|
@@ -146,6 +150,11 @@ export function viewHasAgent(view, agent) {
|
|
|
146
150
|
export function ensureHostReady(host, opts) {
|
|
147
151
|
const target = sshTargetFor(host);
|
|
148
152
|
const probe = readyProbe(target, host.os ?? resolveRemoteOsSync(host.name));
|
|
153
|
+
if (probe.timedOut) {
|
|
154
|
+
throw new Error(`Host "${host.name}" (${target}) did not respond in time — the SSH probe timed out after 20 seconds. ` +
|
|
155
|
+
`The host may be slow to start a login shell (nvm/sdkman init, cold node startup). ` +
|
|
156
|
+
`Retry, or run \`agents ssh ${host.name} agents view\` to confirm manually.`);
|
|
157
|
+
}
|
|
149
158
|
if (!probe.reachable) {
|
|
150
159
|
throw new Error(`Host "${host.name}" (${target}) is not reachable over SSH. Check it's online and key auth works.`);
|
|
151
160
|
}
|
|
@@ -48,7 +48,8 @@ export declare function backoffMs(attempt: number): number;
|
|
|
48
48
|
export declare function reconnectStep(state: ReconnectState, outcome: ReconnectOutcome): ReconnectDecision;
|
|
49
49
|
/** Human-readable notice shown before each reconnect wait. "13 seconds", not "12.8s". */
|
|
50
50
|
export declare function reconnectNotice(sessionId: string, host: string, attempt: number, waitMs: number): string;
|
|
51
|
-
/** Notice shown once the retry budget is spent.
|
|
51
|
+
/** Notice shown once the retry budget is spent. Hands back the one verb that
|
|
52
|
+
* re-enters the terminal — attach the live pane if it survived, else resume. */
|
|
52
53
|
export declare function exhaustedNotice(sessionId: string, host: string): string;
|
|
53
54
|
/** Notice shown when a reattach stops on a remapped remote-side exit
|
|
54
55
|
* ({@link REMOTE_EXIT_255_REMAPPED} — a would-be-255 the remote command decided
|
|
@@ -75,13 +76,14 @@ export declare function remoteExitNotice(sessionId: string, host: string): strin
|
|
|
75
76
|
*/
|
|
76
77
|
export declare function wrapRemoteExitCode(cmd: string): string;
|
|
77
78
|
/**
|
|
78
|
-
* The remote command a reattach runs — the peer's own
|
|
79
|
-
* (`agents sessions focus <id> --local
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
79
|
+
* The remote command a reattach runs — the peer's own recovery verb
|
|
80
|
+
* (`agents sessions focus <id> --local`), wrapped by {@link wrapRemoteExitCode}
|
|
81
|
+
* so a stray remote-origin 255 (from this command, whatever produces it — see the
|
|
82
|
+
* file header) can never masquerade as a network drop. No `--attach-only`: focus
|
|
83
|
+
* joins the live pane when it survived, else RESUMES the session in place, so a
|
|
84
|
+
* reattach landing after the pane died recovers the agent instead of dead-ending
|
|
85
|
+
* (RUSH-2085). Split out from {@link reattachRemoteSession} so it is unit-tested
|
|
86
|
+
* without SSH — mirrors `remoteAgentsJsonCommand` in lib/remote-agents-json.ts.
|
|
85
87
|
*/
|
|
86
88
|
export declare function reattachRemoteCommand(sessionId: string): string;
|
|
87
89
|
/**
|
|
@@ -89,10 +91,10 @@ export declare function reattachRemoteCommand(sessionId: string): string;
|
|
|
89
91
|
* `agents sessions focus`. A fast, un-multiplexed preflight probe (`ssh … true`)
|
|
90
92
|
* first establishes whether the host is actually reachable this attempt — that
|
|
91
93
|
* `connected` bit, not the call duration, is what the retry policy keys on. Only on
|
|
92
|
-
* a reachable host do we run the interactive attach (which carries no
|
|
93
|
-
* the agent already runs on the peer — so it rides the normal
|
|
94
|
-
* the ssh exit code (255 = dropped again / unreachable; 0 =
|
|
95
|
-
* session ended) plus whether this attempt connected.
|
|
94
|
+
* a reachable host do we run the interactive attach-or-resume (which carries no
|
|
95
|
+
* credentials — the agent already runs on the peer — so it rides the normal
|
|
96
|
+
* transport). Returns the ssh exit code (255 = dropped again / unreachable; 0 =
|
|
97
|
+
* clean detach; other = session ended) plus whether this attempt connected.
|
|
96
98
|
*/
|
|
97
99
|
export declare function reattachRemoteSession(host: Host, sessionId: string): ReconnectOutcome;
|
|
98
100
|
export interface ReconnectLoopOpts {
|
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
* SSH automatically, with bounded backoff, until the user detaches cleanly (the
|
|
12
12
|
* remote returns 0) or the agent exits (the tmux session is gone; a non-255 code).
|
|
13
13
|
*
|
|
14
|
-
* The re-attach reuses the peer's OWN
|
|
15
|
-
* --local
|
|
16
|
-
*
|
|
14
|
+
* The re-attach reuses the peer's OWN recovery verb — `agents sessions focus <id>
|
|
15
|
+
* --local` — which JOINS the live local tmux pane there (a second client, no fork)
|
|
16
|
+
* when it still exists, and RESUMES the session in place when the pane is already
|
|
17
|
+
* gone. Dropping `--attach-only` is deliberate: a reattach that lands after the
|
|
18
|
+
* remote pane died must not dead-end at a bare shell (the RUSH-2085 bug), it must
|
|
19
|
+
* fall through to resume so the user is put back into the agent. There is one
|
|
20
|
+
* re-attach implementation (the peer's focus) to keep in sync.
|
|
17
21
|
*
|
|
18
22
|
* **Why the budget refills on `connected`, not on call duration.** ssh returns 255
|
|
19
23
|
* for BOTH "couldn't connect at all" and "connected, then the link dropped." A
|
|
@@ -31,29 +35,24 @@
|
|
|
31
35
|
*
|
|
32
36
|
* **255 from the REMOTE side should never be trusted as "the link dropped."**
|
|
33
37
|
* `reattachRemoteSession`'s `connected` flag is set as soon as the fast preflight
|
|
34
|
-
* probe succeeds — it says nothing about whether the interactive attach
|
|
35
|
-
* follows actually
|
|
36
|
-
* sessions focus <id> --local
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
38
|
+
* probe succeeds — it says nothing about whether the interactive attach/resume
|
|
39
|
+
* that follows actually put the user back into the agent. If the REMOTE command
|
|
40
|
+
* (`agents sessions focus <id> --local`) itself ever happened to exit 255 for a
|
|
41
|
+
* reason that has nothing to do with the ssh transport, `sshStream` would return
|
|
42
|
+
* that same 255, `reconnectStep` couldn't tell it apart from a genuine drop, and
|
|
43
|
+
* `connected: true` would refill the retry budget forever — "attempt 1/N" printed
|
|
44
|
+
* on every single cycle, the terminal filling with aborted-TTY escape-code
|
|
45
|
+
* garbage, `MAX_ATTEMPTS` never actually bounding anything.
|
|
42
46
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* or not — indistinguishable from a real drop. {@link wrapRemoteExitCode} wraps
|
|
53
|
-
* the entire remote command so that whatever exit code it decides on, a 255 is
|
|
54
|
-
* remapped to {@link REMOTE_EXIT_255_REMAPPED} before `sshStream` ever sees it,
|
|
55
|
-
* regardless of which internal branch produced it and regardless of the peer's
|
|
56
|
-
* `agents` version (the remap happens in the shell wrapper THIS process sends).
|
|
47
|
+
* The resume fall-through only widens that surface — the peer's focus now runs a
|
|
48
|
+
* full recovery path (`resumeSessionInPlace` / `runOnPeer`) on a dead pane, any
|
|
49
|
+
* step of which could in principle exit 255 for its own reasons. So the channel-
|
|
50
|
+
* level defense is what matters, not an audit of which branch can fire:
|
|
51
|
+
* {@link wrapRemoteExitCode} wraps the entire remote command so that whatever exit
|
|
52
|
+
* code it decides on, a 255 is remapped to {@link REMOTE_EXIT_255_REMAPPED} before
|
|
53
|
+
* `sshStream` ever sees it, regardless of which internal branch produced it and
|
|
54
|
+
* regardless of the peer's `agents` version (the remap happens in the shell
|
|
55
|
+
* wrapper THIS process sends).
|
|
57
56
|
*
|
|
58
57
|
* This does NOT close every way `reconnectStep` can loop on a real transport
|
|
59
58
|
* 255: a genuinely recurring LOCAL ssh failure (a fast-flapping link, an
|
|
@@ -111,9 +110,10 @@ export function reconnectNotice(sessionId, host, attempt, waitMs) {
|
|
|
111
110
|
const when = secs <= 1 ? 'now' : `in ${secs} seconds`;
|
|
112
111
|
return `\nConnection to ${host} dropped — the agent is still running there. Reconnecting to ${sessionId.slice(0, 8)} ${when} (attempt ${attempt}/${MAX_ATTEMPTS})…\n`;
|
|
113
112
|
}
|
|
114
|
-
/** Notice shown once the retry budget is spent.
|
|
113
|
+
/** Notice shown once the retry budget is spent. Hands back the one verb that
|
|
114
|
+
* re-enters the terminal — attach the live pane if it survived, else resume. */
|
|
115
115
|
export function exhaustedNotice(sessionId, host) {
|
|
116
|
-
return `\nCouldn't reconnect to ${host} after ${MAX_ATTEMPTS} attempts. The agent may still be running —
|
|
116
|
+
return `\nCouldn't reconnect to ${host} after ${MAX_ATTEMPTS} attempts. The agent may still be running — get back in when the network is back:\n agents reconnect ${sessionId.slice(0, 8)}\n`;
|
|
117
117
|
}
|
|
118
118
|
/** Notice shown when a reattach stops on a remapped remote-side exit
|
|
119
119
|
* ({@link REMOTE_EXIT_255_REMAPPED} — a would-be-255 the remote command decided
|
|
@@ -121,7 +121,7 @@ export function exhaustedNotice(sessionId, host) {
|
|
|
121
121
|
* {@link wrapRemoteExitCode}). Distinct from {@link exhaustedNotice}, which is
|
|
122
122
|
* only for a genuinely spent retry budget. */
|
|
123
123
|
export function remoteExitNotice(sessionId, host) {
|
|
124
|
-
return `\nReattach to ${sessionId.slice(0, 8)} on ${host} ended (not a network drop) — check whether it's still live:\n agents
|
|
124
|
+
return `\nReattach to ${sessionId.slice(0, 8)} on ${host} ended (not a network drop) — get back in, or check whether it's still live:\n agents reconnect ${sessionId.slice(0, 8)}\n`;
|
|
125
125
|
}
|
|
126
126
|
/**
|
|
127
127
|
* Wrap `cmd` in `bash -lc` (the login-shell pattern `buildRemoteAgentsInvocation`
|
|
@@ -145,16 +145,17 @@ export function wrapRemoteExitCode(cmd) {
|
|
|
145
145
|
return `bash -lc ${shellQuote(guarded)}`;
|
|
146
146
|
}
|
|
147
147
|
/**
|
|
148
|
-
* The remote command a reattach runs — the peer's own
|
|
149
|
-
* (`agents sessions focus <id> --local
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
148
|
+
* The remote command a reattach runs — the peer's own recovery verb
|
|
149
|
+
* (`agents sessions focus <id> --local`), wrapped by {@link wrapRemoteExitCode}
|
|
150
|
+
* so a stray remote-origin 255 (from this command, whatever produces it — see the
|
|
151
|
+
* file header) can never masquerade as a network drop. No `--attach-only`: focus
|
|
152
|
+
* joins the live pane when it survived, else RESUMES the session in place, so a
|
|
153
|
+
* reattach landing after the pane died recovers the agent instead of dead-ending
|
|
154
|
+
* (RUSH-2085). Split out from {@link reattachRemoteSession} so it is unit-tested
|
|
155
|
+
* without SSH — mirrors `remoteAgentsJsonCommand` in lib/remote-agents-json.ts.
|
|
155
156
|
*/
|
|
156
157
|
export function reattachRemoteCommand(sessionId) {
|
|
157
|
-
const inner = ['agents', 'sessions', 'focus', sessionId, '--local'
|
|
158
|
+
const inner = ['agents', 'sessions', 'focus', sessionId, '--local']
|
|
158
159
|
.map(shellQuote)
|
|
159
160
|
.join(' ');
|
|
160
161
|
return wrapRemoteExitCode(inner);
|
|
@@ -164,10 +165,10 @@ export function reattachRemoteCommand(sessionId) {
|
|
|
164
165
|
* `agents sessions focus`. A fast, un-multiplexed preflight probe (`ssh … true`)
|
|
165
166
|
* first establishes whether the host is actually reachable this attempt — that
|
|
166
167
|
* `connected` bit, not the call duration, is what the retry policy keys on. Only on
|
|
167
|
-
* a reachable host do we run the interactive attach (which carries no
|
|
168
|
-
* the agent already runs on the peer — so it rides the normal
|
|
169
|
-
* the ssh exit code (255 = dropped again / unreachable; 0 =
|
|
170
|
-
* session ended) plus whether this attempt connected.
|
|
168
|
+
* a reachable host do we run the interactive attach-or-resume (which carries no
|
|
169
|
+
* credentials — the agent already runs on the peer — so it rides the normal
|
|
170
|
+
* transport). Returns the ssh exit code (255 = dropped again / unreachable; 0 =
|
|
171
|
+
* clean detach; other = session ended) plus whether this attempt connected.
|
|
171
172
|
*/
|
|
172
173
|
export function reattachRemoteSession(host, sessionId) {
|
|
173
174
|
const target = sshTargetFor(host);
|
|
Binary file
|
|
Binary file
|
package/dist/lib/routines.js
CHANGED
|
@@ -14,7 +14,7 @@ import { getRoutinesDir, getSystemRoutinesDir, getRunsDir, ensureAgentsDir, getP
|
|
|
14
14
|
import { safeJoin, isSafeSegmentName } from './paths.js';
|
|
15
15
|
import { isSafeProjectName } from './projects.js';
|
|
16
16
|
import { atomicWriteFileSync } from './fs-atomic.js';
|
|
17
|
-
import { ALL_AGENT_IDS } from './agents.js';
|
|
17
|
+
import { ALL_AGENT_IDS, ROUTINE_AGENT_IDS } from './agents.js';
|
|
18
18
|
import { machineId, normalizeHost } from './machine-id.js';
|
|
19
19
|
import { resolveActor } from './actor.js';
|
|
20
20
|
import { percentile } from './percentile.js';
|
|
@@ -600,6 +600,7 @@ export function validateJob(config) {
|
|
|
600
600
|
const hasAgent = Boolean(config.agent && typeof config.agent === 'string');
|
|
601
601
|
const hasWorkflow = Boolean(config.workflow && typeof config.workflow === 'string');
|
|
602
602
|
const hasCommand = Boolean(config.command && typeof config.command === 'string');
|
|
603
|
+
const strategy = resolveHostStrategy(config);
|
|
603
604
|
const set = [hasAgent, hasWorkflow, hasCommand].filter(Boolean).length;
|
|
604
605
|
if (set === 0) {
|
|
605
606
|
errors.push('exactly one of agent, workflow, or command is required');
|
|
@@ -613,6 +614,18 @@ export function validateJob(config) {
|
|
|
613
614
|
if (hasAgent && config.agent && !ALL_AGENT_IDS.includes(config.agent)) {
|
|
614
615
|
errors.push(`agent must be one of: ${ALL_AGENT_IDS.join(', ')}`);
|
|
615
616
|
}
|
|
617
|
+
else if (hasAgent && config.agent && strategy === 'local' &&
|
|
618
|
+
!ROUTINE_AGENT_IDS.includes(config.agent)) {
|
|
619
|
+
// The local daemon only knows how to build a command for the agents in
|
|
620
|
+
// ROUTINE_AGENT_IDS (runner.ts's AGENT_COMMANDS table) — anything else is a
|
|
621
|
+
// real, installable agent (it passed the ALL_AGENT_IDS check above) but one
|
|
622
|
+
// the daemon can't fire itself, so reject it now instead of accepting the
|
|
623
|
+
// routine and failing at fire time (runner.ts buildJobCommand: "Unsupported
|
|
624
|
+
// agent for daemon jobs"). host/fleet/cloud placement dispatches through
|
|
625
|
+
// `agents run`/a cloud provider instead of this table, so they're exempt.
|
|
626
|
+
errors.push(`agent '${config.agent}' is not supported by the local routine daemon; use one of: ` +
|
|
627
|
+
`${ROUTINE_AGENT_IDS.join(', ')} (or set hostStrategy: host/fleet/cloud to run it elsewhere)`);
|
|
628
|
+
}
|
|
616
629
|
if (hasWorkflow && config.workflow) {
|
|
617
630
|
if (!/^[a-z0-9][a-z0-9_-]*$/.test(config.workflow)) {
|
|
618
631
|
errors.push('workflow must be a lowercase alphanumeric name (hyphens and underscores allowed, e.g. autodev)');
|
|
@@ -662,7 +675,6 @@ export function validateJob(config) {
|
|
|
662
675
|
errors.push(`hostStrategy must be one of: ${HOST_STRATEGIES.join(', ')}`);
|
|
663
676
|
}
|
|
664
677
|
}
|
|
665
|
-
const strategy = resolveHostStrategy(config);
|
|
666
678
|
if (config.host !== undefined) {
|
|
667
679
|
if (typeof config.host !== 'string' || config.host.trim() === '') {
|
|
668
680
|
errors.push('host must be a non-empty machine name (a registered host, device, capability tag, or user@host)');
|
package/dist/lib/runner.d.ts
CHANGED
|
@@ -26,9 +26,6 @@ export interface RunResult {
|
|
|
26
26
|
export declare class RoutineAlreadyRunningError extends Error {
|
|
27
27
|
constructor(jobName: string, runId: string);
|
|
28
28
|
}
|
|
29
|
-
/** Agents the daemon can actually run, derived from the command table above
|
|
30
|
-
* so the `--agent` help and any validation can never drift from it. */
|
|
31
|
-
export declare const ROUTINE_AGENT_IDS: readonly string[];
|
|
32
29
|
/** Stable working directory for routine children, independent of the daemon's launch cwd. */
|
|
33
30
|
export declare function routineSpawnCwd(config: Pick<JobConfig, 'repo'>, configuredRoot?: string | undefined): string;
|
|
34
31
|
/** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
|
package/dist/lib/runner.js
CHANGED
|
@@ -36,7 +36,7 @@ import { resolveClaudeSetupToken } from './claude-account-token.js';
|
|
|
36
36
|
import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, formatNoHealthyAccountError, } from './rotate.js';
|
|
37
37
|
import { readAuthHealth, isDeadVerdict } from './auth-health.js';
|
|
38
38
|
import { machineId } from './machine-id.js';
|
|
39
|
-
import { isSelfUpdatingAgent } from './agents.js';
|
|
39
|
+
import { isSelfUpdatingAgent, ROUTINE_AGENT_COMMANDS as AGENT_COMMANDS } from './agents.js';
|
|
40
40
|
import { expandLocalHome, getProjectRoot } from './project-root.js';
|
|
41
41
|
export class RoutineAlreadyRunningError extends Error {
|
|
42
42
|
constructor(jobName, runId) {
|
|
@@ -98,19 +98,6 @@ function terminateRoutineTree(pid) {
|
|
|
98
98
|
}
|
|
99
99
|
catch { /* already exited */ }
|
|
100
100
|
}
|
|
101
|
-
/** CLI command templates per agent, with {prompt} as a placeholder. */
|
|
102
|
-
const AGENT_COMMANDS = {
|
|
103
|
-
claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
|
|
104
|
-
codex: ['codex', 'exec', '{prompt}', '--json'],
|
|
105
|
-
gemini: ['gemini', '{prompt}', '--output-format', 'stream-json'],
|
|
106
|
-
cursor: ['cursor-agent', '-p', '{prompt}', '--output-format', 'stream-json'],
|
|
107
|
-
kimi: ['kimi', '--prompt', '{prompt}', '--output-format', 'stream-json'],
|
|
108
|
-
droid: ['droid', 'exec', '{prompt}', '-o', 'stream-json'],
|
|
109
|
-
muse: ['muse', 'exec', '{prompt}', '--json'],
|
|
110
|
-
};
|
|
111
|
-
/** Agents the daemon can actually run, derived from the command table above
|
|
112
|
-
* so the `--agent` help and any validation can never drift from it. */
|
|
113
|
-
export const ROUTINE_AGENT_IDS = Object.freeze(Object.keys(AGENT_COMMANDS));
|
|
114
101
|
/**
|
|
115
102
|
* Where each agent's transcript files live under an overlay HOME, mirroring
|
|
116
103
|
* `SESSION_ROOT_SPECS` (session/discover.ts) — the CLI's own source of truth
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize a resolved env map to `.env` lines that round-trip losslessly through
|
|
3
|
+
* `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
|
|
4
|
+
* quote pair and takes the inner bytes verbatim (no unescaping), so any single-line
|
|
5
|
+
* value survives unchanged with no escaping. Newlines would break its line-based
|
|
6
|
+
* parse, so multi-line values are rejected rather than silently corrupted.
|
|
7
|
+
*/
|
|
8
|
+
export declare function bundleEnvToDotenv(env: Record<string, string>): string;
|
|
9
|
+
/** Where the bundle should live ON THE REMOTE. */
|
|
10
|
+
export type RemoteBackend = 'keychain' | 'file';
|
|
11
|
+
/** A bundle read once, ready to push to any number of hosts. */
|
|
12
|
+
export interface ResolvedBundleForPush {
|
|
13
|
+
/** key -> value. Never logged; only its KEY NAMES are ever surfaced. */
|
|
14
|
+
env: Record<string, string>;
|
|
15
|
+
/** The same values as a dotenv blob, shipped over ssh stdin (never argv). */
|
|
16
|
+
dotenv: string;
|
|
17
|
+
keyCount: number;
|
|
18
|
+
}
|
|
19
|
+
export interface PushBundleOptions {
|
|
20
|
+
remoteBackend: RemoteBackend;
|
|
21
|
+
/** Overwrite a key that already exists on the remote. */
|
|
22
|
+
force?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Forwarded to the remote as the FIRST stdin line for the FILE backend only,
|
|
25
|
+
* and only when non-empty.
|
|
26
|
+
*
|
|
27
|
+
* Empty is the DEFAULT and the good path: the remote's file store then
|
|
28
|
+
* auto-provisions its own machine-local key (0600, `~/.agents/.secrets-key/`)
|
|
29
|
+
* and reads headlessly. Setting this keys the remote bundle under a shared
|
|
30
|
+
* off-disk secret instead — an opt-in, never a requirement. Requiring one is
|
|
31
|
+
* what pushed operators toward exporting the master key fleet-wide (RUSH-1968).
|
|
32
|
+
*/
|
|
33
|
+
passphrase?: string;
|
|
34
|
+
/** Label for the audit trail — `export --host` vs `fleet apply`. */
|
|
35
|
+
operation: string;
|
|
36
|
+
}
|
|
37
|
+
export interface PushBundleResult {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
host: string;
|
|
40
|
+
bundle: string;
|
|
41
|
+
keyCount: number;
|
|
42
|
+
/** One line for the caller to render. Never contains a secret value. */
|
|
43
|
+
message: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Read and resolve a bundle once, for pushing to one or more hosts.
|
|
47
|
+
*
|
|
48
|
+
* `agentOnly` + `keyMode: 'storage'` match what `export --host` has always
|
|
49
|
+
* passed: storage-shaped values, and the headless guard that fails fast rather
|
|
50
|
+
* than popping Touch ID inside an automated run.
|
|
51
|
+
*/
|
|
52
|
+
export declare function resolveBundleForPush(bundle: string, caller: string): ResolvedBundleForPush;
|
|
53
|
+
/**
|
|
54
|
+
* WHICH transport a (backend, target-OS) pair selects, and the exact bytes it
|
|
55
|
+
* will send. One of four outcomes, and picking the wrong one is silent: a
|
|
56
|
+
* Windows target handed a POSIX `bash -lc` produces garbage, and a Windows
|
|
57
|
+
* target handed `--from -` hangs forever on a stdin the `agents.ps1` shim never
|
|
58
|
+
* forwards. Neither shows up as a failed ssh, so the selection is the thing
|
|
59
|
+
* worth pinning.
|
|
60
|
+
*
|
|
61
|
+
* Separated from execution so it is decidable without a network: the branch is
|
|
62
|
+
* chosen from the device registry alone, and `pushResolvedBundleToHost` below
|
|
63
|
+
* does nothing but run what this returns.
|
|
64
|
+
*/
|
|
65
|
+
export type PushTransport =
|
|
66
|
+
/** No supported command exists for this pair — fail loud, never a wrong path. */
|
|
67
|
+
{
|
|
68
|
+
kind: 'refuse';
|
|
69
|
+
message: string;
|
|
70
|
+
}
|
|
71
|
+
/** A command sent over the raw ssh engine, with the .env on stdin. */
|
|
72
|
+
| {
|
|
73
|
+
kind: 'ssh';
|
|
74
|
+
remoteCmd: string;
|
|
75
|
+
input: string;
|
|
76
|
+
}
|
|
77
|
+
/** The OS-aware `agents secrets` wrapper, the READ inverse's own path. */
|
|
78
|
+
| {
|
|
79
|
+
kind: 'remote-secrets';
|
|
80
|
+
args: string[];
|
|
81
|
+
input: string;
|
|
82
|
+
};
|
|
83
|
+
/** Choose the transport for one push. Pure: registry read in, plan out. */
|
|
84
|
+
export declare function planPushTransport(resolved: ResolvedBundleForPush, bundle: string, host: string, opts: PushBundleOptions): PushTransport;
|
|
85
|
+
/**
|
|
86
|
+
* Push an already-resolved bundle to ONE host.
|
|
87
|
+
*
|
|
88
|
+
* Drives the remote's own `agents secrets import --from -`, so the values land
|
|
89
|
+
* in the remote's chosen backend and the .env is read off ssh stdin rather than
|
|
90
|
+
* parsed by a remote shell. `import` auto-creates the bundle.
|
|
91
|
+
*/
|
|
92
|
+
export declare function pushResolvedBundleToHost(resolved: ResolvedBundleForPush, bundle: string, host: string, opts: PushBundleOptions): PushBundleResult;
|
|
93
|
+
/** Resolve and push in one call — for a single host. */
|
|
94
|
+
export declare function pushBundleToHost(bundle: string, host: string, opts: PushBundleOptions): PushBundleResult;
|