@phnx-labs/agents-cli 1.22.24 → 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 (143) hide show
  1. package/CHANGELOG.md +350 -1
  2. package/README.md +31 -6
  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/cloud.js +9 -5
  8. package/dist/commands/doctor.d.ts +24 -0
  9. package/dist/commands/doctor.js +108 -9
  10. package/dist/commands/exec.js +19 -16
  11. package/dist/commands/feed.d.ts +5 -0
  12. package/dist/commands/feed.js +21 -1
  13. package/dist/commands/focus.js +2 -2
  14. package/dist/commands/insights.d.ts +25 -19
  15. package/dist/commands/insights.js +107 -33
  16. package/dist/commands/menubar.js +8 -0
  17. package/dist/commands/reconnect.d.ts +46 -0
  18. package/dist/commands/reconnect.js +109 -0
  19. package/dist/commands/routines.d.ts +3 -0
  20. package/dist/commands/routines.js +72 -49
  21. package/dist/commands/run-cloud.js +1 -1
  22. package/dist/commands/secrets.d.ts +2 -8
  23. package/dist/commands/secrets.js +29 -105
  24. package/dist/commands/sessions-browser.d.ts +1 -1
  25. package/dist/commands/sessions-browser.js +27 -7
  26. package/dist/commands/sessions-resume.js +3 -2
  27. package/dist/commands/sessions.d.ts +13 -1
  28. package/dist/commands/sessions.js +28 -2
  29. package/dist/commands/setup-secrets.d.ts +1 -0
  30. package/dist/commands/setup-secrets.js +1 -1
  31. package/dist/commands/setup-watchdog.js +5 -10
  32. package/dist/commands/setup.d.ts +26 -3
  33. package/dist/commands/setup.js +106 -47
  34. package/dist/commands/teams.d.ts +6 -0
  35. package/dist/commands/teams.js +46 -2
  36. package/dist/commands/trends.d.ts +8 -0
  37. package/dist/commands/trends.js +10 -156
  38. package/dist/commands/watchdog.d.ts +3 -4
  39. package/dist/commands/watchdog.js +26 -66
  40. package/dist/index.js +37 -2
  41. package/dist/lib/agents.d.ts +11 -0
  42. package/dist/lib/agents.js +155 -23
  43. package/dist/lib/analytics/dashboard.d.ts +10 -6
  44. package/dist/lib/analytics/dashboard.js +6 -4
  45. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  46. package/dist/lib/analytics/mix-commands.js +229 -0
  47. package/dist/lib/analytics/recipes.d.ts +19 -14
  48. package/dist/lib/analytics/recipes.js +4 -2
  49. package/dist/lib/browser/ipc.d.ts +26 -0
  50. package/dist/lib/browser/ipc.js +139 -24
  51. package/dist/lib/browser/profiles.d.ts +11 -0
  52. package/dist/lib/browser/profiles.js +1 -1
  53. package/dist/lib/browser/stream.d.ts +14 -0
  54. package/dist/lib/browser/stream.js +71 -0
  55. package/dist/lib/channels/owner-sink.d.ts +27 -0
  56. package/dist/lib/channels/owner-sink.js +93 -0
  57. package/dist/lib/cloud/cursor.d.ts +79 -0
  58. package/dist/lib/cloud/cursor.js +228 -0
  59. package/dist/lib/cloud/registry.js +2 -0
  60. package/dist/lib/cloud/types.d.ts +7 -2
  61. package/dist/lib/cloud/types.js +14 -0
  62. package/dist/lib/crabbox/cli.d.ts +2 -2
  63. package/dist/lib/crabbox/config.d.ts +7 -8
  64. package/dist/lib/crabbox/config.js +14 -14
  65. package/dist/lib/crabbox/lease.d.ts +11 -4
  66. package/dist/lib/crabbox/lease.js +40 -8
  67. package/dist/lib/crabbox/setup-copy.d.ts +5 -0
  68. package/dist/lib/crabbox/setup-copy.js +17 -1
  69. package/dist/lib/daemon.js +27 -1
  70. package/dist/lib/device-config.js +7 -0
  71. package/dist/lib/devices/doctor-findings.d.ts +13 -1
  72. package/dist/lib/devices/doctor-findings.js +73 -2
  73. package/dist/lib/events.d.ts +9 -0
  74. package/dist/lib/events.js +58 -0
  75. package/dist/lib/exec.d.ts +3 -3
  76. package/dist/lib/exec.js +24 -10
  77. package/dist/lib/feed-outcome.d.ts +3 -0
  78. package/dist/lib/feed-outcome.js +18 -10
  79. package/dist/lib/feed.d.ts +4 -0
  80. package/dist/lib/fleet/apply.d.ts +59 -3
  81. package/dist/lib/fleet/apply.js +183 -6
  82. package/dist/lib/fleet/types.d.ts +21 -2
  83. package/dist/lib/hooks/cache.js +15 -0
  84. package/dist/lib/hosts/passthrough.d.ts +44 -0
  85. package/dist/lib/hosts/passthrough.js +84 -12
  86. package/dist/lib/hosts/ready.d.ts +2 -0
  87. package/dist/lib/hosts/ready.js +10 -1
  88. package/dist/lib/hosts/reconnect.d.ts +14 -12
  89. package/dist/lib/hosts/reconnect.js +41 -40
  90. package/dist/lib/mcp.js +5 -1
  91. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  92. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  93. package/dist/lib/menubar/snapshot.d.ts +15 -0
  94. package/dist/lib/menubar/snapshot.js +40 -0
  95. package/dist/lib/plugins.js +13 -1
  96. package/dist/lib/resources/mcp.js +3 -0
  97. package/dist/lib/routine-process-cleanup.d.ts +9 -0
  98. package/dist/lib/routine-process-cleanup.js +73 -0
  99. package/dist/lib/routines.js +14 -2
  100. package/dist/lib/runner.d.ts +0 -3
  101. package/dist/lib/runner.js +6 -19
  102. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  103. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  104. package/dist/lib/secrets/bundles.js +1 -20
  105. package/dist/lib/secrets/filestore.d.ts +2 -0
  106. package/dist/lib/secrets/filestore.js +13 -0
  107. package/dist/lib/secrets/push.d.ts +94 -0
  108. package/dist/lib/secrets/push.js +145 -0
  109. package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
  110. package/dist/lib/secrets/rc-hygiene.js +14 -1
  111. package/dist/lib/secrets/reaper.d.ts +15 -1
  112. package/dist/lib/secrets/reaper.js +30 -3
  113. package/dist/lib/session/active.d.ts +4 -0
  114. package/dist/lib/session/db.d.ts +21 -3
  115. package/dist/lib/session/db.js +229 -15
  116. package/dist/lib/session/discover.d.ts +1 -0
  117. package/dist/lib/session/discover.js +115 -19
  118. package/dist/lib/session/insights.d.ts +18 -0
  119. package/dist/lib/session/insights.js +143 -1
  120. package/dist/lib/session/remote-list.d.ts +2 -0
  121. package/dist/lib/session/remote-list.js +1 -0
  122. package/dist/lib/session/session-cache.d.ts +4 -4
  123. package/dist/lib/session/session-cache.js +4 -4
  124. package/dist/lib/session/tool-index.js +133 -22
  125. package/dist/lib/session/tool-store.d.ts +26 -2
  126. package/dist/lib/session/tool-store.js +36 -17
  127. package/dist/lib/shims.js +21 -1
  128. package/dist/lib/signin-badge.js +2 -0
  129. package/dist/lib/ssh-exec.js +8 -2
  130. package/dist/lib/startup/command-registry.d.ts +16 -0
  131. package/dist/lib/startup/command-registry.js +46 -0
  132. package/dist/lib/teams/agents.d.ts +13 -0
  133. package/dist/lib/teams/agents.js +76 -8
  134. package/dist/lib/teams/parsers.d.ts +1 -1
  135. package/dist/lib/teams/placement-probe.d.ts +21 -0
  136. package/dist/lib/teams/placement-probe.js +135 -0
  137. package/dist/lib/teams/scheduler.d.ts +74 -1
  138. package/dist/lib/teams/scheduler.js +187 -10
  139. package/dist/lib/types.d.ts +1 -1
  140. package/dist/lib/versions.js +16 -1
  141. package/dist/lib/watchdog/service.d.ts +17 -0
  142. package/dist/lib/watchdog/service.js +39 -0
  143. package/package.json +1 -1
@@ -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);
package/dist/lib/mcp.js CHANGED
@@ -596,6 +596,7 @@ function writeMcpConfigSupportsAgent(agentId) {
596
596
  case 'hermes':
597
597
  case 'pi':
598
598
  case 'muse':
599
+ case 'warp':
599
600
  return true;
600
601
  default:
601
602
  return false;
@@ -621,7 +622,10 @@ export function writeMcpConfig(agentId, configPath, servers, mode = 'overwrite')
621
622
  // omp reads the same Claude `{ "mcpServers": {...} }` schema from .mcp.json
622
623
  // (stdio: command/args/env; http/sse: url/headers — transport inferred from
623
624
  // command/url presence).
624
- case 'pi': {
625
+ case 'pi':
626
+ // Oz reads the same Claude `{ "mcpServers": {...} }` schema from
627
+ // `.warp/.mcp.json` (stdio + http with headers).
628
+ case 'warp': {
625
629
  let config = {};
626
630
  if (fs.existsSync(configPath)) {
627
631
  try {
@@ -0,0 +1,15 @@
1
+ import type { WatchdogTickResult } from '../watchdog/runner.js';
2
+ export interface MenubarSnapshot {
3
+ version: 1;
4
+ capturedAt: string;
5
+ routines: Record<string, unknown>[];
6
+ recentSessions: Record<string, unknown>[];
7
+ activeSessions: Record<string, unknown>[];
8
+ watchdog: {
9
+ enabled: boolean;
10
+ lastTick: Pick<WatchdogTickResult, 'didNudge' | 'counts'> | null;
11
+ };
12
+ }
13
+ export declare function readLastWatchdogTick(stateDir?: string): WatchdogTickResult | null;
14
+ /** One-process read model for AGI Menu's repeating three-minute refresh. */
15
+ export declare function computeMenubarSnapshot(): Promise<MenubarSnapshot>;
@@ -0,0 +1,40 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { buildRoutineListJson } from '../../commands/routines.js';
4
+ import { backfillActiveRowsFromIndex, serializeActiveSessionsForJson, serializeSessionsJson } from '../../commands/sessions.js';
5
+ import { getConfigValue } from '../device-config.js';
6
+ import { querySessions } from '../session/db.js';
7
+ import { readActiveSessionsCache } from '../session/session-cache.js';
8
+ import { getRuntimeStateDir } from '../state.js';
9
+ export function readLastWatchdogTick(stateDir = path.join(getRuntimeStateDir(), 'watchdog')) {
10
+ try {
11
+ return JSON.parse(fs.readFileSync(path.join(stateDir, 'last-tick.json'), 'utf-8'));
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ /** One-process read model for AGI Menu's repeating three-minute refresh. */
18
+ export async function computeMenubarSnapshot() {
19
+ const [routines, recent] = await Promise.all([
20
+ Promise.resolve(buildRoutineListJson()),
21
+ Promise.resolve(querySessions({ limit: 40, skipExistenceCheck: true })),
22
+ ]);
23
+ const active = readActiveSessionsCache('local');
24
+ const activeSessions = active?.sessions ?? [];
25
+ backfillActiveRowsFromIndex(activeSessions);
26
+ return {
27
+ version: 1,
28
+ capturedAt: new Date().toISOString(),
29
+ routines,
30
+ recentSessions: JSON.parse(serializeSessionsJson(recent)),
31
+ activeSessions: serializeActiveSessionsForJson(activeSessions),
32
+ watchdog: {
33
+ enabled: getConfigValue('watchdog.enabled').value === true,
34
+ lastTick: (() => {
35
+ const tick = readLastWatchdogTick();
36
+ return tick ? { didNudge: tick.didNudge, counts: tick.counts } : null;
37
+ })(),
38
+ },
39
+ };
40
+ }
@@ -68,8 +68,20 @@ export function discoverPluginsInDir(pluginsDir, spec = { kind: 'user' }) {
68
68
  continue;
69
69
  const pluginRoot = path.join(pluginsDir, entry.name);
70
70
  const manifest = loadPluginManifest(pluginRoot);
71
- if (!manifest)
71
+ if (!manifest) {
72
+ // A directory that looks like a plugin root but has no valid manifest is
73
+ // silently invisible to every downstream command (list/info/sync, and the
74
+ // materialize-into-version-homes copy) with no other diagnostic anywhere
75
+ // in the chain — it can sit here indefinitely, on the correct git commit,
76
+ // and never surface as broken (RUSH-2270: the `work` plugin shipped
77
+ // without .claude-plugin/plugin.json and nothing noticed for a full merge
78
+ // cycle). Warn once here, at the one place that decides discoverability,
79
+ // so the gap is visible the moment `agents sync` runs.
80
+ const manifestPath = path.join(entry.name, PLUGIN_MANIFEST_DIR, PLUGIN_MANIFEST_FILE);
81
+ process.stderr.write(`agents-cli: '${entry.name}' in ${pluginsDir} has no valid ${manifestPath} ` +
82
+ `(missing, malformed JSON, or missing name/version) — skipped, not discovered as a plugin.\n`);
72
83
  continue;
84
+ }
73
85
  plugins.push(buildDiscoveredPlugin(pluginRoot, manifest, spec));
74
86
  }
75
87
  return plugins;
@@ -129,6 +129,9 @@ export function getMcpConfigPath(agent, versionHome) {
129
129
  return path.join(versionHome, '.omp', 'agent', '.mcp.json');
130
130
  case 'muse':
131
131
  return path.join(versionHome, '.config', 'muse', 'settings.json');
132
+ case 'warp':
133
+ // Oz reads user-scope MCP from ~/.warp/.mcp.json (Claude schema).
134
+ return path.join(versionHome, '.warp', '.mcp.json');
132
135
  default:
133
136
  return null;
134
137
  }
@@ -0,0 +1,9 @@
1
+ import type { RunMeta } from './routines.js';
2
+ export interface RoutineProcessCleanupOptions {
3
+ runsDir?: string;
4
+ alive?: (pid: number) => boolean;
5
+ owns?: (meta: RunMeta) => boolean;
6
+ terminate?: (pid: number) => void;
7
+ }
8
+ /** Reap process groups whose durable run record is already terminal. */
9
+ export declare function reapTerminalRoutineProcesses(opts?: RoutineProcessCleanupOptions): number[];
@@ -0,0 +1,73 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { execFileSync } from 'child_process';
4
+ import { isAlive, killTree } from './platform/index.js';
5
+ import { getRunsDir } from './state.js';
6
+ function processMatchesRun(meta) {
7
+ if (!meta.pid || !meta.spawnedAt)
8
+ return false;
9
+ try {
10
+ if (process.platform === 'win32') {
11
+ const startedAt = execFileSync('powershell.exe', [
12
+ '-NoProfile',
13
+ '-NonInteractive',
14
+ '-Command',
15
+ `(Get-Process -Id ${meta.pid} -ErrorAction Stop).StartTime.ToUniversalTime().ToString("o")`,
16
+ ], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim();
17
+ const processStart = Date.parse(startedAt);
18
+ return Number.isFinite(processStart) && Math.abs(processStart - meta.spawnedAt) < 30_000;
19
+ }
20
+ const elapsed = execFileSync('ps', ['-p', String(meta.pid), '-o', 'etime='], {
21
+ encoding: 'utf-8',
22
+ stdio: ['ignore', 'pipe', 'ignore'],
23
+ }).trim();
24
+ if (!elapsed)
25
+ return false;
26
+ const fields = elapsed.replace(/-/g, ':').split(':').reverse();
27
+ const seconds = Number(fields[0] ?? 0)
28
+ + Number(fields[1] ?? 0) * 60
29
+ + Number(fields[2] ?? 0) * 3600
30
+ + Number(fields[3] ?? 0) * 86400;
31
+ return Math.abs((Date.now() - seconds * 1000) - meta.spawnedAt) < 30_000;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ /** Reap process groups whose durable run record is already terminal. */
38
+ export function reapTerminalRoutineProcesses(opts = {}) {
39
+ const runsDir = opts.runsDir ?? getRunsDir();
40
+ const alive = opts.alive ?? isAlive;
41
+ const owns = opts.owns ?? processMatchesRun;
42
+ const terminate = opts.terminate ?? ((pid) => killTree(process.platform === 'win32' ? pid : -pid));
43
+ if (!fs.existsSync(runsDir))
44
+ return [];
45
+ const reaped = [];
46
+ for (const job of fs.readdirSync(runsDir, { withFileTypes: true })) {
47
+ if (!job.isDirectory())
48
+ continue;
49
+ const jobDir = path.join(runsDir, job.name);
50
+ for (const run of fs.readdirSync(jobDir, { withFileTypes: true })) {
51
+ if (!run.isDirectory())
52
+ continue;
53
+ try {
54
+ const meta = JSON.parse(fs.readFileSync(path.join(jobDir, run.name, 'meta.json'), 'utf-8'));
55
+ if (!['failed', 'timeout'].includes(meta.status) || !meta.pid || meta.hostTaskId || meta.cloudTaskId)
56
+ continue;
57
+ const completedAt = Date.parse(meta.completedAt ?? '');
58
+ if (!Number.isFinite(completedAt) || Date.now() - completedAt < 5_000)
59
+ continue;
60
+ if (!alive(meta.pid))
61
+ continue;
62
+ if (!owns(meta))
63
+ continue;
64
+ terminate(meta.pid);
65
+ reaped.push(meta.pid);
66
+ }
67
+ catch {
68
+ // Corrupt or concurrently-replaced records are left untouched.
69
+ }
70
+ }
71
+ }
72
+ return reaped;
73
+ }
@@ -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) {
@@ -52,7 +52,7 @@ function activeRoutineRun(config) {
52
52
  const runs = listRuns(config.name);
53
53
  for (let i = runs.length - 1; i >= 0; i--) {
54
54
  const run = runs[i];
55
- if (run.status !== 'running')
55
+ if (!['running', 'failed', 'timeout'].includes(run.status))
56
56
  continue;
57
57
  if (run.pid && isPidOurs(run.pid, run.spawnedAt))
58
58
  return run;
@@ -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
@@ -235,10 +222,10 @@ export function buildJobCommand(config, resolvedPrompt) {
235
222
  appendModelAndReasoning(cmd, config);
236
223
  }
237
224
  if (config.agent === 'cursor') {
238
- // cursor-agent supports --plan, but the capability registry has not been
239
- // upgraded yet. RUSH-2101 tracks adding that read-only mode after PR #1721.
240
- const cursorMode = resolveHeadlessMode('cursor', mode, false, `routine ${config.name}`);
241
- if (cursorMode === 'skip') {
225
+ if (mode === 'plan') {
226
+ cmd.push('--plan');
227
+ }
228
+ else if (mode === 'skip') {
242
229
  cmd.push('-f');
243
230
  }
244
231
  else {
@@ -55,19 +55,7 @@ const keychainStore = {
55
55
  const fileItemStore = {
56
56
  has: (item) => fileStore.has(item),
57
57
  get: (item) => fileStore.get(item),
58
- getBatch: (items) => {
59
- const out = new Map();
60
- for (const item of items) {
61
- try {
62
- out.set(item, fileStore.get(item));
63
- }
64
- catch {
65
- // Missing/undecryptable item — absent from the map, mirroring
66
- // getKeychainTokens (caller decides whether that's an error).
67
- }
68
- }
69
- return out;
70
- },
58
+ getBatch: (items) => fileStore.getBatch(items),
71
59
  set: (item, value) => fileStore.set(item, value),
72
60
  setBatch: (items) => {
73
61
  for (const [item, value] of items) {
@@ -1340,13 +1328,6 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1340
1328
  : store.getBatch([...new Set([metaItem, ...secretItems])]);
1341
1329
  const json = fetched.get(metaItem);
1342
1330
  if (json === undefined) {
1343
- // For a file-backed bundle the metadata item is on disk (that's how
1344
- // bundleBackend resolved to 'file'); a missing decrypt means the wrong
1345
- // passphrase, not a missing bundle. getBatch swallowed the decrypt error,
1346
- // so distinguish here rather than report a misleading "not found".
1347
- if (backend === 'file' && fileStore.has(metaItem)) {
1348
- throw new Error(`Bundle '${name}': failed to decrypt — wrong AGENTS_SECRETS_PASSPHRASE or tampered file store.`);
1349
- }
1350
1331
  if (vaultExists() && !getVaultSession().loggedIn) {
1351
1332
  throw new Error(`Synced secrets are locked. Run: agents login`);
1352
1333
  }
@@ -53,6 +53,7 @@ export declare function encryptForFallback(plaintext: string, passphrase: string
53
53
  export declare function decryptForFallback(enc: EncFile, passphrase: string): string;
54
54
  declare function fileHas(item: string): boolean;
55
55
  declare function fileGet(item: string): string;
56
+ declare function fileGetBatch(items: string[]): Map<string, string>;
56
57
  declare function fileSet(item: string, value: string): void;
57
58
  declare function fileDelete(item: string): boolean;
58
59
  declare function fileList(prefix: string): string[];
@@ -63,6 +64,7 @@ export declare function fileStoreHasItems(): boolean;
63
64
  export declare const fileStore: {
64
65
  has: typeof fileHas;
65
66
  get: typeof fileGet;
67
+ getBatch: typeof fileGetBatch;
66
68
  set: typeof fileSet;
67
69
  delete: typeof fileDelete;
68
70
  list: typeof fileList;
@@ -237,6 +237,18 @@ function fileGet(item) {
237
237
  throw new Error(`Failed to decrypt '${item}'. Wrong AGENTS_SECRETS_PASSPHRASE or tampered file.`);
238
238
  }
239
239
  }
240
+ function fileGetBatch(items) {
241
+ const out = new Map();
242
+ for (const item of items) {
243
+ // A missing file is an absent item. Any error reading an existing file,
244
+ // especially an AES-GCM authentication failure, is a broken store and must
245
+ // stop the caller rather than silently produce an incomplete environment.
246
+ if (!fileHas(item))
247
+ continue;
248
+ out.set(item, fileGet(item));
249
+ }
250
+ return out;
251
+ }
240
252
  function fileSet(item, value) {
241
253
  ensureFileDir();
242
254
  // Under the store lock: a write must not interleave with a rotation's swap.
@@ -283,6 +295,7 @@ export function fileStoreHasItems() {
283
295
  export const fileStore = {
284
296
  has: fileHas,
285
297
  get: fileGet,
298
+ getBatch: fileGetBatch,
286
299
  set: fileSet,
287
300
  delete: fileDelete,
288
301
  list: fileList,