@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +17 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/browser.js +35 -0
  7. package/dist/commands/doctor.js +8 -0
  8. package/dist/commands/insights.d.ts +25 -19
  9. package/dist/commands/insights.js +107 -33
  10. package/dist/commands/reconnect.d.ts +46 -0
  11. package/dist/commands/reconnect.js +109 -0
  12. package/dist/commands/routines.js +2 -2
  13. package/dist/commands/secrets.d.ts +2 -8
  14. package/dist/commands/secrets.js +29 -105
  15. package/dist/commands/sessions.js +4 -0
  16. package/dist/commands/setup-secrets.d.ts +1 -0
  17. package/dist/commands/setup-secrets.js +1 -1
  18. package/dist/commands/setup.d.ts +26 -3
  19. package/dist/commands/setup.js +105 -46
  20. package/dist/commands/teams.d.ts +6 -0
  21. package/dist/commands/teams.js +43 -0
  22. package/dist/commands/trends.d.ts +8 -0
  23. package/dist/commands/trends.js +10 -156
  24. package/dist/index.js +1 -1
  25. package/dist/lib/agents.d.ts +11 -0
  26. package/dist/lib/agents.js +29 -2
  27. package/dist/lib/analytics/dashboard.d.ts +10 -6
  28. package/dist/lib/analytics/dashboard.js +6 -4
  29. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  30. package/dist/lib/analytics/mix-commands.js +229 -0
  31. package/dist/lib/analytics/recipes.d.ts +19 -14
  32. package/dist/lib/analytics/recipes.js +4 -2
  33. package/dist/lib/browser/ipc.d.ts +26 -0
  34. package/dist/lib/browser/ipc.js +139 -24
  35. package/dist/lib/browser/profiles.d.ts +11 -0
  36. package/dist/lib/browser/profiles.js +1 -1
  37. package/dist/lib/browser/stream.d.ts +14 -0
  38. package/dist/lib/browser/stream.js +71 -0
  39. package/dist/lib/channels/owner-sink.d.ts +27 -0
  40. package/dist/lib/channels/owner-sink.js +93 -0
  41. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  42. package/dist/lib/devices/doctor-findings.js +33 -1
  43. package/dist/lib/fleet/apply.d.ts +59 -3
  44. package/dist/lib/fleet/apply.js +183 -6
  45. package/dist/lib/fleet/types.d.ts +21 -2
  46. package/dist/lib/hooks/cache.js +15 -0
  47. package/dist/lib/hosts/passthrough.d.ts +23 -0
  48. package/dist/lib/hosts/passthrough.js +45 -0
  49. package/dist/lib/hosts/ready.d.ts +2 -0
  50. package/dist/lib/hosts/ready.js +10 -1
  51. package/dist/lib/hosts/reconnect.d.ts +14 -12
  52. package/dist/lib/hosts/reconnect.js +41 -40
  53. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  54. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  55. package/dist/lib/routines.js +14 -2
  56. package/dist/lib/runner.d.ts +0 -3
  57. package/dist/lib/runner.js +1 -14
  58. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  59. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  60. package/dist/lib/secrets/push.d.ts +94 -0
  61. package/dist/lib/secrets/push.js +145 -0
  62. package/dist/lib/secrets/reaper.d.ts +15 -1
  63. package/dist/lib/secrets/reaper.js +30 -3
  64. package/dist/lib/session/db.d.ts +21 -3
  65. package/dist/lib/session/db.js +221 -13
  66. package/dist/lib/session/discover.d.ts +1 -0
  67. package/dist/lib/session/discover.js +115 -19
  68. package/dist/lib/session/insights.d.ts +18 -0
  69. package/dist/lib/session/insights.js +143 -1
  70. package/dist/lib/session/tool-index.js +133 -22
  71. package/dist/lib/session/tool-store.d.ts +26 -2
  72. package/dist/lib/session/tool-store.js +36 -17
  73. package/dist/lib/ssh-exec.js +8 -2
  74. package/dist/lib/startup/command-registry.d.ts +1 -0
  75. package/dist/lib/startup/command-registry.js +4 -0
  76. package/dist/lib/teams/agents.d.ts +13 -0
  77. package/dist/lib/teams/agents.js +75 -7
  78. package/dist/lib/teams/placement-probe.d.ts +21 -0
  79. package/dist/lib/teams/placement-probe.js +135 -0
  80. package/dist/lib/teams/scheduler.d.ts +74 -1
  81. package/dist/lib/teams/scheduler.js +187 -10
  82. package/package.json +1 -1
@@ -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
@@ -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
- const r = sshExec(target, buildReadyProbeCommand(os), { timeoutMs: 20000 });
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 reconnect verb
79
- * (`agents sessions focus <id> --local --attach-only`), wrapped by
80
- * {@link wrapRemoteExitCode} so a stray remote-origin 255 (from this command,
81
- * whatever produces it — see the file header) can never masquerade as a
82
- * network drop. Split out from {@link reattachRemoteSession} so it is
83
- * unit-tested without SSH mirrors `remoteAgentsJsonCommand` in
84
- * lib/remote-agents-json.ts.
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 credentials —
93
- * the agent already runs on the peer — so it rides the normal transport). Returns
94
- * the ssh exit code (255 = dropped again / unreachable; 0 = clean detach; other =
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 reconnect verb — `agents sessions focus <id>
15
- * --local --attach-only` joins the live local tmux pane there (no fork, no resumed
16
- * copy) so there is no second re-attach implementation to keep in sync.
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 that
35
- * follows actually reattached a live pane. If the REMOTE command (`agents
36
- * sessions focus <id> --local --attach-only`) itself ever happened to exit 255
37
- * for a reason that has nothing to do with the ssh transport, `sshStream` would
38
- * return that same 255, `reconnectStep` couldn't tell it apart from a genuine
39
- * drop, and `connected: true` would refill the retry budget forever — "attempt
40
- * 1/N" printed on every single cycle, the terminal filling with aborted-TTY
41
- * escape-code garbage, `MAX_ATTEMPTS` never actually bounding anything.
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
- * Two candidate producers of that scenario were investigated
44
- * `refuseFallback`'s login-shell fallback (commands/go.ts) and `jumpTo`'s
45
- * nested remote-tmux hop and both turned out to be UNREACHABLE through this
46
- * exact remote command: under `--local`, `gatherLiveTargets` never sets a
47
- * foreign `.machine` (go.ts:61), so `remote` is always `undefined` in both,
48
- * and neither branch can fire (the same reasoning that made an earlier,
49
- * narrower fix here dead code see git history). So this fix does not close
50
- * a confirmed incident cause; what it closes is the underlying channel-level
51
- * flaw that would make *any* future remote-side 255 producer — reachable today
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 — reattach when the network is back:\n agents sessions focus ${sessionId.slice(0, 8)}\n`;
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 sessions ${sessionId.slice(0, 8)}\n`;
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 reconnect verb
149
- * (`agents sessions focus <id> --local --attach-only`), wrapped by
150
- * {@link wrapRemoteExitCode} so a stray remote-origin 255 (from this command,
151
- * whatever produces it — see the file header) can never masquerade as a
152
- * network drop. Split out from {@link reattachRemoteSession} so it is
153
- * unit-tested without SSH mirrors `remoteAgentsJsonCommand` in
154
- * lib/remote-agents-json.ts.
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', '--attach-only']
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 credentials —
168
- * the agent already runs on the peer — so it rides the normal transport). Returns
169
- * the ssh exit code (255 = dropped again / unreachable; 0 = clean detach; other =
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);
@@ -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)');
@@ -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. */
@@ -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
@@ -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;