@phnx-labs/agents-cli 1.20.63 → 1.20.65

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 (197) hide show
  1. package/CHANGELOG.md +56 -3
  2. package/README.md +46 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.d.ts +12 -0
  5. package/dist/commands/apply.js +274 -0
  6. package/dist/commands/browser.js +2 -2
  7. package/dist/commands/cloud.js +32 -2
  8. package/dist/commands/doctor.js +4 -1
  9. package/dist/commands/exec.js +149 -77
  10. package/dist/commands/feed.d.ts +4 -0
  11. package/dist/commands/feed.js +52 -19
  12. package/dist/commands/hosts.js +44 -6
  13. package/dist/commands/lease.d.ts +23 -0
  14. package/dist/commands/lease.js +201 -0
  15. package/dist/commands/mailboxes.d.ts +20 -0
  16. package/dist/commands/mailboxes.js +390 -0
  17. package/dist/commands/mcp.js +55 -5
  18. package/dist/commands/monitors.d.ts +12 -0
  19. package/dist/commands/monitors.js +740 -0
  20. package/dist/commands/output.js +2 -2
  21. package/dist/commands/routines.js +43 -16
  22. package/dist/commands/secrets.d.ts +16 -0
  23. package/dist/commands/secrets.js +215 -64
  24. package/dist/commands/serve.js +31 -0
  25. package/dist/commands/sessions-export.d.ts +2 -0
  26. package/dist/commands/sessions-export.js +284 -0
  27. package/dist/commands/sessions-import.d.ts +2 -0
  28. package/dist/commands/sessions-import.js +230 -0
  29. package/dist/commands/sessions.d.ts +16 -0
  30. package/dist/commands/sessions.js +40 -6
  31. package/dist/commands/ssh.js +143 -5
  32. package/dist/commands/usage.d.ts +2 -0
  33. package/dist/commands/usage.js +7 -2
  34. package/dist/commands/versions.js +7 -3
  35. package/dist/commands/view.d.ts +27 -1
  36. package/dist/commands/view.js +32 -9
  37. package/dist/commands/webhook.js +10 -2
  38. package/dist/index.js +35 -14
  39. package/dist/lib/agents.d.ts +36 -0
  40. package/dist/lib/agents.js +80 -20
  41. package/dist/lib/auto-dispatch-provider.js +7 -2
  42. package/dist/lib/auto-dispatch.d.ts +3 -0
  43. package/dist/lib/auto-dispatch.js +3 -0
  44. package/dist/lib/browser/chrome.js +2 -2
  45. package/dist/lib/browser/drivers/ssh.js +19 -2
  46. package/dist/lib/cloud/antigravity.js +2 -2
  47. package/dist/lib/cloud/host.d.ts +59 -0
  48. package/dist/lib/cloud/host.js +224 -0
  49. package/dist/lib/cloud/registry.js +4 -0
  50. package/dist/lib/cloud/types.d.ts +6 -4
  51. package/dist/lib/comms-render.d.ts +37 -0
  52. package/dist/lib/comms-render.js +89 -0
  53. package/dist/lib/computer-rpc.js +3 -1
  54. package/dist/lib/crabbox/cli.d.ts +72 -0
  55. package/dist/lib/crabbox/cli.js +162 -9
  56. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  57. package/dist/lib/crabbox/runtimes.js +35 -2
  58. package/dist/lib/daemon.d.ts +20 -4
  59. package/dist/lib/daemon.js +68 -20
  60. package/dist/lib/devices/fleet.d.ts +3 -2
  61. package/dist/lib/devices/fleet.js +9 -0
  62. package/dist/lib/devices/health.d.ts +77 -0
  63. package/dist/lib/devices/health.js +186 -0
  64. package/dist/lib/devices/registry.d.ts +15 -0
  65. package/dist/lib/devices/registry.js +9 -0
  66. package/dist/lib/exec.d.ts +19 -2
  67. package/dist/lib/exec.js +41 -13
  68. package/dist/lib/fleet/apply.d.ts +63 -0
  69. package/dist/lib/fleet/apply.js +214 -0
  70. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  71. package/dist/lib/fleet/auth-sync.js +142 -0
  72. package/dist/lib/fleet/manifest.d.ts +29 -0
  73. package/dist/lib/fleet/manifest.js +127 -0
  74. package/dist/lib/fleet/types.d.ts +129 -0
  75. package/dist/lib/fleet/types.js +13 -0
  76. package/dist/lib/git.d.ts +27 -0
  77. package/dist/lib/git.js +34 -2
  78. package/dist/lib/hosts/dispatch.d.ts +29 -8
  79. package/dist/lib/hosts/dispatch.js +46 -18
  80. package/dist/lib/hosts/passthrough.js +2 -0
  81. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  82. package/dist/lib/hosts/providers/devices.js +98 -0
  83. package/dist/lib/hosts/registry.d.ts +10 -16
  84. package/dist/lib/hosts/registry.js +17 -50
  85. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  86. package/dist/lib/hosts/remote-cmd.js +71 -0
  87. package/dist/lib/hosts/run-target.d.ts +84 -0
  88. package/dist/lib/hosts/run-target.js +99 -0
  89. package/dist/lib/hosts/types.d.ts +23 -5
  90. package/dist/lib/hosts/types.js +22 -4
  91. package/dist/lib/linear-autoclose.d.ts +30 -0
  92. package/dist/lib/linear-autoclose.js +22 -0
  93. package/dist/lib/mailbox.d.ts +39 -0
  94. package/dist/lib/mailbox.js +112 -0
  95. package/dist/lib/mcp.d.ts +27 -1
  96. package/dist/lib/mcp.js +126 -12
  97. package/dist/lib/monitors/config.d.ts +161 -0
  98. package/dist/lib/monitors/config.js +372 -0
  99. package/dist/lib/monitors/dispatch.d.ts +28 -0
  100. package/dist/lib/monitors/dispatch.js +91 -0
  101. package/dist/lib/monitors/engine.d.ts +61 -0
  102. package/dist/lib/monitors/engine.js +201 -0
  103. package/dist/lib/monitors/sources/command.d.ts +11 -0
  104. package/dist/lib/monitors/sources/command.js +31 -0
  105. package/dist/lib/monitors/sources/device.d.ts +13 -0
  106. package/dist/lib/monitors/sources/device.js +35 -0
  107. package/dist/lib/monitors/sources/file.d.ts +14 -0
  108. package/dist/lib/monitors/sources/file.js +57 -0
  109. package/dist/lib/monitors/sources/http.d.ts +10 -0
  110. package/dist/lib/monitors/sources/http.js +34 -0
  111. package/dist/lib/monitors/sources/index.d.ts +14 -0
  112. package/dist/lib/monitors/sources/index.js +31 -0
  113. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  114. package/dist/lib/monitors/sources/poll.js +9 -0
  115. package/dist/lib/monitors/sources/types.d.ts +18 -0
  116. package/dist/lib/monitors/sources/types.js +9 -0
  117. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  118. package/dist/lib/monitors/sources/webhook.js +47 -0
  119. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  120. package/dist/lib/monitors/sources/ws.js +45 -0
  121. package/dist/lib/monitors/state.d.ts +69 -0
  122. package/dist/lib/monitors/state.js +144 -0
  123. package/dist/lib/paths.d.ts +13 -0
  124. package/dist/lib/paths.js +26 -4
  125. package/dist/lib/platform/exec.d.ts +16 -0
  126. package/dist/lib/platform/exec.js +17 -0
  127. package/dist/lib/plugins.js +101 -2
  128. package/dist/lib/redact.d.ts +14 -1
  129. package/dist/lib/redact.js +47 -1
  130. package/dist/lib/remote-agents-json.js +7 -1
  131. package/dist/lib/rotate.d.ts +6 -3
  132. package/dist/lib/rotate.js +0 -1
  133. package/dist/lib/routines.d.ts +37 -2
  134. package/dist/lib/routines.js +54 -12
  135. package/dist/lib/runner.d.ts +1 -0
  136. package/dist/lib/runner.js +348 -13
  137. package/dist/lib/sandbox.d.ts +9 -1
  138. package/dist/lib/sandbox.js +11 -2
  139. package/dist/lib/secrets/agent.d.ts +48 -10
  140. package/dist/lib/secrets/agent.js +123 -15
  141. package/dist/lib/secrets/bundles.d.ts +26 -0
  142. package/dist/lib/secrets/bundles.js +59 -8
  143. package/dist/lib/secrets/mcp.js +4 -2
  144. package/dist/lib/secrets/remote.d.ts +17 -0
  145. package/dist/lib/secrets/remote.js +40 -0
  146. package/dist/lib/self-update.d.ts +20 -0
  147. package/dist/lib/self-update.js +54 -1
  148. package/dist/lib/serve/control.d.ts +95 -0
  149. package/dist/lib/serve/control.js +260 -0
  150. package/dist/lib/serve/server.d.ts +35 -1
  151. package/dist/lib/serve/server.js +106 -76
  152. package/dist/lib/serve/stream.d.ts +43 -0
  153. package/dist/lib/serve/stream.js +116 -0
  154. package/dist/lib/serve/token.d.ts +35 -0
  155. package/dist/lib/serve/token.js +85 -0
  156. package/dist/lib/session/bundle.d.ts +164 -0
  157. package/dist/lib/session/bundle.js +200 -0
  158. package/dist/lib/session/remote-bundle.d.ts +12 -0
  159. package/dist/lib/session/remote-bundle.js +61 -0
  160. package/dist/lib/session/remote-list.js +5 -1
  161. package/dist/lib/session/state.d.ts +7 -25
  162. package/dist/lib/session/state.js +16 -6
  163. package/dist/lib/session/sync/agents.d.ts +54 -6
  164. package/dist/lib/session/sync/agents.js +0 -0
  165. package/dist/lib/session/sync/config.js +8 -2
  166. package/dist/lib/session/sync/manifest.d.ts +14 -3
  167. package/dist/lib/session/sync/manifest.js +4 -0
  168. package/dist/lib/session/sync/sync.d.ts +23 -2
  169. package/dist/lib/session/sync/sync.js +177 -74
  170. package/dist/lib/session/types.d.ts +30 -0
  171. package/dist/lib/ssh-tunnel.d.ts +19 -1
  172. package/dist/lib/ssh-tunnel.js +99 -8
  173. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  174. package/dist/lib/staleness/detectors/subagents.js +5 -192
  175. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  176. package/dist/lib/staleness/writers/subagents.js +11 -102
  177. package/dist/lib/startup/command-registry.d.ts +4 -0
  178. package/dist/lib/startup/command-registry.js +9 -0
  179. package/dist/lib/state.d.ts +5 -0
  180. package/dist/lib/state.js +12 -0
  181. package/dist/lib/subagents-registry.d.ts +85 -0
  182. package/dist/lib/subagents-registry.js +393 -0
  183. package/dist/lib/subagents.d.ts +8 -8
  184. package/dist/lib/subagents.js +32 -663
  185. package/dist/lib/sync-umbrella.d.ts +1 -0
  186. package/dist/lib/sync-umbrella.js +14 -3
  187. package/dist/lib/tmux/session.d.ts +7 -0
  188. package/dist/lib/tmux/session.js +3 -1
  189. package/dist/lib/triggers/webhook.d.ts +18 -0
  190. package/dist/lib/triggers/webhook.js +105 -0
  191. package/dist/lib/types.d.ts +35 -1
  192. package/dist/lib/usage.d.ts +42 -3
  193. package/dist/lib/usage.js +163 -21
  194. package/dist/lib/versions.js +14 -11
  195. package/dist/lib/workflows.d.ts +20 -0
  196. package/dist/lib/workflows.js +24 -0
  197. package/package.json +2 -1
@@ -14,6 +14,7 @@ import { getDaemonDir as getDaemonDirRoot } from './state.js';
14
14
  import { isAlive, killTree, backgroundSpawnOptions } from './platform/index.js';
15
15
  import { listJobs as listAllJobs } from './routines.js';
16
16
  import { JobScheduler } from './scheduler.js';
17
+ import { MonitorEngine } from './monitors/engine.js';
17
18
  import { executeJobDetached, monitorRunningJobs } from './runner.js';
18
19
  import { detectOverdueJobs, notifyOverdue } from './overdue.js';
19
20
  import { BrowserService } from './browser/service.js';
@@ -293,6 +294,21 @@ export async function runDaemon() {
293
294
  process.exit(0);
294
295
  }
295
296
  log('INFO', `Daemon started (PID: ${process.pid})`);
297
+ // RUSH-1759: the launchd plist / systemd unit no longer bake the Claude OAuth
298
+ // token onto disk. Obtain it here from the secure `claude` secrets bundle and
299
+ // inject into this process's env so every routine run this daemon spawns still
300
+ // receives it (via the sandbox allowlist), without the token ever being
301
+ // persisted in the service manifest. A read from a file-backed store (Linux)
302
+ // needs no prompt; on macOS it resolves broker-only from an unlocked
303
+ // secrets-agent and is otherwise absent (leaving the daemon on its existing
304
+ // interactive OAuth session), matching the detached-start path. Never blocks.
305
+ if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) {
306
+ const oauthToken = readDaemonClaudeOAuthToken();
307
+ if (oauthToken) {
308
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
309
+ log('INFO', 'Loaded Claude OAuth token from secrets bundle for routine runs');
310
+ }
311
+ }
296
312
  // Reap any stray duplicate daemon of this install that slipped past the start
297
313
  // lock or was orphaned by a hard-crash — before it can double-fire jobs.
298
314
  try {
@@ -329,7 +345,12 @@ export async function runDaemon() {
329
345
  log('WARN', `Secrets broker host skipped: ${err.message}`);
330
346
  }
331
347
  const scheduler = new JobScheduler(async (config) => {
332
- log('INFO', `Triggering job '${config.name}' (agent: ${config.agent})`);
348
+ const jobLabel = config.command
349
+ ? 'command'
350
+ : config.workflow
351
+ ? `workflow: ${config.workflow}`
352
+ : `agent: ${config.agent}`;
353
+ log('INFO', `Triggering job '${config.name}' (${jobLabel})`);
333
354
  try {
334
355
  const meta = await executeJobDetached(config);
335
356
  log('INFO', `Job '${config.name}' spawned (run: ${meta.runId}, PID: ${meta.pid})`);
@@ -344,6 +365,16 @@ export async function runDaemon() {
344
365
  for (const job of scheduled) {
345
366
  log('INFO', ` ${job.name} -> next: ${job.nextRun?.toISOString() || 'unknown'}`);
346
367
  }
368
+ // Monitor engine: event-triggered watchers, beside the cron scheduler. Same
369
+ // daemon, same dispatch seam — a monitor is a routine whose trigger is a
370
+ // watched source instead of a clock. Reloads on SIGHUP alongside the scheduler.
371
+ const monitorEngine = new MonitorEngine((level, message) => log(level, message));
372
+ try {
373
+ monitorEngine.start();
374
+ }
375
+ catch (err) {
376
+ log('ERROR', `Monitor engine failed to start: ${err.message}`);
377
+ }
347
378
  // Backlog detection: any enabled recurring job whose most-recent expected
348
379
  // fire is older than its most-recent recorded run is overdue. Happens when
349
380
  // the laptop was off or the daemon crashed through a scheduled fire.
@@ -598,6 +629,12 @@ export async function runDaemon() {
598
629
  scheduler.reloadAll();
599
630
  const reloaded = scheduler.listScheduled();
600
631
  log('INFO', `Reloaded ${reloaded.length} jobs`);
632
+ try {
633
+ monitorEngine.reload();
634
+ }
635
+ catch (err) {
636
+ log('ERROR', `Monitor engine reload failed: ${err.message}`);
637
+ }
601
638
  // Drop the memoized R2 config so rotated/added sync credentials are re-read
602
639
  // on the next cycle instead of waiting for a restart.
603
640
  void import('./session/sync/config.js').then(m => m.clearR2ConfigCache());
@@ -605,6 +642,7 @@ export async function runDaemon() {
605
642
  const handleShutdown = async () => {
606
643
  log('INFO', 'Daemon shutting down');
607
644
  scheduler.stopAll();
645
+ monitorEngine.stop();
608
646
  await browserIPC.stop();
609
647
  clearInterval(monitorInterval);
610
648
  clearInterval(syncInterval);
@@ -675,15 +713,18 @@ export function writeOwnerOnlyServiceManifest(filePath, content) {
675
713
  fs.rmSync(filePath, { force: true });
676
714
  fs.writeFileSync(filePath, content, { encoding: 'utf-8', mode: 0o600 });
677
715
  }
678
- /** Generate a macOS launchd plist for auto-starting the daemon. */
679
- export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken(), agentsBin = getAgentsBinPath()) {
716
+ /**
717
+ * Generate a macOS launchd plist for auto-starting the daemon.
718
+ *
719
+ * The plist never embeds the Claude OAuth token (RUSH-1759): a persisted service
720
+ * manifest is a plaintext credential on disk even at 0600. The daemon instead
721
+ * obtains the token at startup from the `claude` secrets bundle
722
+ * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the
723
+ * Keychain-backed secure store and never touches the unit file.
724
+ */
725
+ export function generateLaunchdPlist(agentsBin = getAgentsBinPath()) {
680
726
  const launch = getDaemonLaunch(agentsBin);
681
727
  const logPath = getLogPath();
682
- const oauthEntry = oauthToken
683
- ? `
684
- <key>${DAEMON_OAUTH_KEY}</key>
685
- <string>${xmlEscape(oauthToken)}</string>`
686
- : '';
687
728
  return `<?xml version="1.0" encoding="UTF-8"?>
688
729
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
689
730
  <plist version="1.0">
@@ -705,7 +746,7 @@ ${[launch.command, ...launch.args].map((arg) => ` <string>${xmlEscape(arg)}</
705
746
  <key>EnvironmentVariables</key>
706
747
  <dict>
707
748
  <key>PATH</key>
708
- <string>${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin</string>${oauthEntry}
749
+ <string>${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin</string>
709
750
  </dict>
710
751
  </dict>
711
752
  </plist>`;
@@ -714,13 +755,18 @@ ${[launch.command, ...launch.args].map((arg) => ` <string>${xmlEscape(arg)}</
714
755
  function systemdExecArg(value) {
715
756
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
716
757
  }
717
- /** Generate a Linux systemd user unit for auto-starting the daemon. */
718
- export function generateSystemdUnit(oauthToken = readDaemonClaudeOAuthToken(), agentsBin = getAgentsBinPath()) {
758
+ /**
759
+ * Generate a Linux systemd user unit for auto-starting the daemon.
760
+ *
761
+ * The unit never embeds the Claude OAuth token (RUSH-1759): a persisted service
762
+ * manifest is a plaintext credential on disk even at 0600. The daemon instead
763
+ * obtains the token at startup from the `claude` secrets bundle
764
+ * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the secure
765
+ * store and never touches the unit file.
766
+ */
767
+ export function generateSystemdUnit(agentsBin = getAgentsBinPath()) {
719
768
  const launch = getDaemonLaunch(agentsBin);
720
769
  const execStart = [launch.command, ...launch.args].map(systemdExecArg).join(' ');
721
- const oauthLine = oauthToken
722
- ? `\nEnvironment=${DAEMON_OAUTH_KEY}=${oauthToken}`
723
- : '';
724
770
  return `[Unit]
725
771
  Description=Agents Daemon - Scheduled Job Runner
726
772
  After=network.target
@@ -730,7 +776,7 @@ Type=simple
730
776
  ExecStart=${execStart}
731
777
  Restart=always
732
778
  RestartSec=10
733
- Environment=PATH=${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin${oauthLine}
779
+ Environment=PATH=${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin
734
780
 
735
781
  [Install]
736
782
  WantedBy=default.target`;
@@ -847,9 +893,10 @@ function startDaemonLocked(agentsBin) {
847
893
  if (!fs.existsSync(plistDir)) {
848
894
  fs.mkdirSync(plistDir, { recursive: true });
849
895
  }
850
- // The plist may embed a long-lived OAuth token in EnvironmentVariables;
851
- // create owner-only atomically (no world-readable window before chmod).
852
- writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist(undefined, agentsBin));
896
+ // The plist carries no credential (RUSH-1759 the daemon reads the OAuth
897
+ // token itself at startup); still create owner-only atomically to match the
898
+ // detached path and keep the log/PATH surface owner-private.
899
+ writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist(agentsBin));
853
900
  try {
854
901
  execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
855
902
  }
@@ -876,8 +923,9 @@ function startDaemonLocked(agentsBin) {
876
923
  if (!fs.existsSync(unitDir)) {
877
924
  fs.mkdirSync(unitDir, { recursive: true });
878
925
  }
879
- // May embed a long-lived OAuth token in an Environment= line; owner-only.
880
- writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit(undefined, agentsBin));
926
+ // Carries no credential (RUSH-1759 the daemon reads the OAuth token
927
+ // itself at startup); owner-only to keep the PATH/log surface private.
928
+ writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit(agentsBin));
881
929
  execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
882
930
  execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
883
931
  execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
@@ -7,8 +7,8 @@
7
7
  * throws (misconfigured auth, etc.) become `failed` rows — they never abort
8
8
  * the remaining devices.
9
9
  */
10
- import type { DeviceProfile, DeviceRegistry } from './registry.js';
11
- export type FleetSkipReason = 'offline' | 'no-address';
10
+ import { type DeviceProfile, type DeviceRegistry } from './registry.js';
11
+ export type FleetSkipReason = 'offline' | 'no-address' | 'control';
12
12
  /** npm dist-tags / semver pins only — rejects shell metacharacters. */
13
13
  export declare const FLEET_VERSION_RE: RegExp;
14
14
  export interface FleetTarget {
@@ -27,6 +27,7 @@ export interface FleetRunResult {
27
27
  /**
28
28
  * Classify each registered device for a fleet operation.
29
29
  *
30
+ * - Control-only device (a cockpit, e.g. a paired iPhone) → skip `control`
30
31
  * - Tailscale-offline → skip `offline`
31
32
  * - No address → skip `no-address`
32
33
  * - Everything else is a target (including this machine, reached over ssh when
@@ -8,12 +8,14 @@
8
8
  * the remaining devices.
9
9
  */
10
10
  import { spawnSync } from 'child_process';
11
+ import { isControlDevice } from './registry.js';
11
12
  import { buildSshInvocation, sshTargetFor, writeAskpassShim } from './connect.js';
12
13
  /** npm dist-tags / semver pins only — rejects shell metacharacters. */
13
14
  export const FLEET_VERSION_RE = /^[A-Za-z0-9._-]+$/;
14
15
  /**
15
16
  * Classify each registered device for a fleet operation.
16
17
  *
18
+ * - Control-only device (a cockpit, e.g. a paired iPhone) → skip `control`
17
19
  * - Tailscale-offline → skip `offline`
18
20
  * - No address → skip `no-address`
19
21
  * - Everything else is a target (including this machine, reached over ssh when
@@ -23,6 +25,11 @@ export function planFleetTargets(reg) {
23
25
  const names = Object.keys(reg).sort();
24
26
  return names.map((name) => {
25
27
  const device = reg[name];
28
+ // A control device drives the fleet but never runs agents — never a target
29
+ // for update/run/stats, whatever its platform reads as.
30
+ if (isControlDevice(device)) {
31
+ return { device, skip: 'control' };
32
+ }
26
33
  if (device.tailscale && !device.tailscale.online) {
27
34
  return { device, skip: 'offline' };
28
35
  }
@@ -42,6 +49,8 @@ export function skipLabel(reason) {
42
49
  return 'offline';
43
50
  case 'no-address':
44
51
  return 'no address';
52
+ case 'control':
53
+ return 'control device';
45
54
  }
46
55
  }
47
56
  /**
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Device resource probing for `agents devices list`.
3
+ *
4
+ * One SSH round-trip per device gathers load average, memory pressure, and core
5
+ * count (mac + linux), parsed into a {@link DeviceStats}. Probes run in parallel
6
+ * with a bounded timeout so the list stays responsive — a slow or hung box
7
+ * degrades to "no stats" instead of blocking the whole table.
8
+ *
9
+ * The parsers are pure and unit-tested (health.test.ts). They mirror the ones in
10
+ * the Factory extension (apps/factory/src/core/deviceHealth.ts) — kept as a
11
+ * separate copy on purpose: the CLI does not import across packages.
12
+ */
13
+ import type { DeviceProfile } from './registry.js';
14
+ /** Default per-device probe budget. Short enough that the list never hangs on a
15
+ * wedged box, long enough for a cold relayed SSH handshake. */
16
+ export declare const PROBE_TIMEOUT_MS = 2500;
17
+ export interface DeviceStats {
18
+ host: string;
19
+ reachable: boolean;
20
+ loadAvg1?: number;
21
+ ncpu?: number;
22
+ /** loadAvg1 / ncpu * 100 — load normalized to core count (the "has room" number). */
23
+ loadPercent?: number;
24
+ memPercent?: number;
25
+ memTotalBytes?: number;
26
+ memFreeBytes?: number;
27
+ fetchedAt: number;
28
+ }
29
+ /** Compact human byte size: 512M, 64G, 1.5T (binary units, ≤1 decimal). */
30
+ export declare function fmtBytes(bytes: number | undefined): string;
31
+ export declare function parseUptime(out: string): {
32
+ loadAvg1?: number;
33
+ };
34
+ interface MemStats {
35
+ memPercent?: number;
36
+ memTotalBytes?: number;
37
+ memFreeBytes?: number;
38
+ }
39
+ export declare function parseVmStat(out: string): MemStats;
40
+ export declare function parseLinuxMemInfo(out: string): MemStats;
41
+ export declare function parseNcpu(out: string): {
42
+ ncpu?: number;
43
+ };
44
+ /** Assemble a DeviceStats from the three snippet sections. */
45
+ export declare function parseProbeOutput(host: string, stdout: string, fetchedAt: number): DeviceStats;
46
+ export interface FleetCapacity {
47
+ reachable: number;
48
+ cores: number;
49
+ memTotalBytes: number;
50
+ memFreeBytes: number;
51
+ }
52
+ /** Sum cores and memory across reachable devices for the summary footer. */
53
+ export declare function fleetCapacity(statsList: Iterable<DeviceStats>): FleetCapacity;
54
+ /** Headroom bucket from the worst of normalized-load and memory. */
55
+ export type Headroom = 'idle' | 'light' | 'busy' | 'loaded' | 'unknown';
56
+ export declare function headroom(stats: DeviceStats | undefined): Headroom;
57
+ /** Probe one device over the same ssh path as `agents ssh <name>`. Never throws;
58
+ * an unreachable/slow/misconfigured device resolves to `reachable: false`. */
59
+ export declare function probeDeviceStats(device: DeviceProfile, opts?: {
60
+ timeoutMs?: number;
61
+ now?: number;
62
+ }): Promise<DeviceStats>;
63
+ /** Probe the local machine directly (no ssh round-trip) — used for the "this
64
+ * machine" row so it always shows real numbers even if it isn't ssh-reachable
65
+ * from itself. */
66
+ export declare function probeLocalStats(host: string, opts?: {
67
+ timeoutMs?: number;
68
+ now?: number;
69
+ }): Promise<DeviceStats>;
70
+ /** Probe many devices concurrently; returns a name→stats map. Bounded by the
71
+ * per-probe timeout, so total wall time ≈ the slowest single probe. The device
72
+ * named `selfName` is probed locally instead of over ssh. */
73
+ export declare function probeFleetStats(devices: DeviceProfile[], opts?: {
74
+ timeoutMs?: number;
75
+ selfName?: string;
76
+ }): Promise<Map<string, DeviceStats>>;
77
+ export {};
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Device resource probing for `agents devices list`.
3
+ *
4
+ * One SSH round-trip per device gathers load average, memory pressure, and core
5
+ * count (mac + linux), parsed into a {@link DeviceStats}. Probes run in parallel
6
+ * with a bounded timeout so the list stays responsive — a slow or hung box
7
+ * degrades to "no stats" instead of blocking the whole table.
8
+ *
9
+ * The parsers are pure and unit-tested (health.test.ts). They mirror the ones in
10
+ * the Factory extension (apps/factory/src/core/deviceHealth.ts) — kept as a
11
+ * separate copy on purpose: the CLI does not import across packages.
12
+ */
13
+ import { execFile } from 'child_process';
14
+ import { buildSshInvocation, writeAskpassShim } from './connect.js';
15
+ /** Default per-device probe budget. Short enough that the list never hangs on a
16
+ * wedged box, long enough for a cold relayed SSH handshake. */
17
+ export const PROBE_TIMEOUT_MS = 2_500;
18
+ const SEP = '---AGSTAT---';
19
+ /** One-shot remote snapshot: load, then memory (mac vm_stat else linux
20
+ * meminfo), then core count (linux nproc else mac hw.ncpu). */
21
+ const PROBE_SNIPPET = `uptime; echo ${SEP}; (vm_stat 2>/dev/null || cat /proc/meminfo 2>/dev/null); echo ${SEP}; (nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null)`;
22
+ /** Compact human byte size: 512M, 64G, 1.5T (binary units, ≤1 decimal). */
23
+ export function fmtBytes(bytes) {
24
+ if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0)
25
+ return '—';
26
+ const units = ['B', 'K', 'M', 'G', 'T', 'P'];
27
+ let v = bytes;
28
+ let i = 0;
29
+ while (v >= 1024 && i < units.length - 1) {
30
+ v /= 1024;
31
+ i++;
32
+ }
33
+ const s = v >= 100 || i <= 1 ? Math.round(v).toString() : v.toFixed(1).replace(/\.0$/, '');
34
+ return `${s}${units[i]}`;
35
+ }
36
+ export function parseUptime(out) {
37
+ const m = out.match(/load average[s]?:\s*([0-9]+[.,][0-9]+|[0-9]+)/i);
38
+ if (!m)
39
+ return {};
40
+ const v = parseFloat(m[1].replace(',', '.'));
41
+ if (!Number.isFinite(v))
42
+ return {};
43
+ return { loadAvg1: v };
44
+ }
45
+ export function parseVmStat(out) {
46
+ const pageSize = parseInt(out.match(/page size of\s+([0-9]+)\s+bytes/)?.[1] ?? '4096', 10);
47
+ const active = out.match(/Pages active:\s+([0-9]+)/);
48
+ const wired = out.match(/Pages wired down:\s+([0-9]+)/);
49
+ const compressed = out.match(/Pages occupied by compressor:\s+([0-9]+)/);
50
+ const free = out.match(/Pages free:\s+([0-9]+)/);
51
+ if (!active || !wired || !compressed || !free)
52
+ return {};
53
+ // macOS reclaims inactive + speculative pages on demand, so they count as
54
+ // available — folding them into "free" (as Activity Monitor / vm_pressure do)
55
+ // rather than "used" keeps the headroom bucket honest on a Mac.
56
+ const inactive = out.match(/Pages inactive:\s+([0-9]+)/);
57
+ const speculative = out.match(/Pages speculative:\s+([0-9]+)/);
58
+ const usedPages = parseInt(active[1], 10) + parseInt(wired[1], 10) + parseInt(compressed[1], 10);
59
+ const freePages = parseInt(free[1], 10) +
60
+ parseInt(inactive?.[1] ?? '0', 10) +
61
+ parseInt(speculative?.[1] ?? '0', 10);
62
+ const totalPages = usedPages + freePages;
63
+ if (totalPages <= 0)
64
+ return {};
65
+ return {
66
+ memPercent: (usedPages / totalPages) * 100,
67
+ memTotalBytes: totalPages * pageSize,
68
+ memFreeBytes: freePages * pageSize,
69
+ };
70
+ }
71
+ export function parseLinuxMemInfo(out) {
72
+ const total = out.match(/^MemTotal:\s+([0-9]+)/im);
73
+ const available = out.match(/^MemAvailable:\s+([0-9]+)/im);
74
+ if (!total || !available)
75
+ return {};
76
+ const tKb = parseInt(total[1], 10);
77
+ const aKb = parseInt(available[1], 10);
78
+ if (tKb <= 0)
79
+ return {};
80
+ return {
81
+ memPercent: Math.max(0, Math.min(100, ((tKb - aKb) / tKb) * 100)),
82
+ memTotalBytes: tKb * 1024,
83
+ memFreeBytes: aKb * 1024,
84
+ };
85
+ }
86
+ export function parseNcpu(out) {
87
+ const n = parseInt(out.trim().split(/\s+/)[0] ?? '', 10);
88
+ return Number.isFinite(n) && n > 0 ? { ncpu: n } : {};
89
+ }
90
+ /** Assemble a DeviceStats from the three snippet sections. */
91
+ export function parseProbeOutput(host, stdout, fetchedAt) {
92
+ const [uptimePart = '', memPart = '', ncpuPart = ''] = stdout.split(SEP);
93
+ const { loadAvg1 } = parseUptime(uptimePart);
94
+ const mem = memPart.includes('MemTotal') ? parseLinuxMemInfo(memPart) : parseVmStat(memPart);
95
+ const { ncpu } = parseNcpu(ncpuPart);
96
+ const loadPercent = loadAvg1 !== undefined && ncpu ? (loadAvg1 / ncpu) * 100 : undefined;
97
+ return {
98
+ host,
99
+ reachable: true,
100
+ loadAvg1,
101
+ ncpu,
102
+ loadPercent,
103
+ memPercent: mem.memPercent,
104
+ memTotalBytes: mem.memTotalBytes,
105
+ memFreeBytes: mem.memFreeBytes,
106
+ fetchedAt,
107
+ };
108
+ }
109
+ /** Sum cores and memory across reachable devices for the summary footer. */
110
+ export function fleetCapacity(statsList) {
111
+ const cap = { reachable: 0, cores: 0, memTotalBytes: 0, memFreeBytes: 0 };
112
+ for (const s of statsList) {
113
+ if (!s.reachable)
114
+ continue;
115
+ cap.reachable++;
116
+ cap.cores += s.ncpu ?? 0;
117
+ cap.memTotalBytes += s.memTotalBytes ?? 0;
118
+ cap.memFreeBytes += s.memFreeBytes ?? 0;
119
+ }
120
+ return cap;
121
+ }
122
+ export function headroom(stats) {
123
+ if (!stats || !stats.reachable)
124
+ return 'unknown';
125
+ const signals = [stats.loadPercent, stats.memPercent].filter((v) => typeof v === 'number');
126
+ if (signals.length === 0)
127
+ return 'unknown';
128
+ const worst = Math.max(...signals);
129
+ if (worst < 15)
130
+ return 'idle';
131
+ if (worst < 40)
132
+ return 'light';
133
+ if (worst < 75)
134
+ return 'busy';
135
+ return 'loaded';
136
+ }
137
+ /** Probe one device over the same ssh path as `agents ssh <name>`. Never throws;
138
+ * an unreachable/slow/misconfigured device resolves to `reachable: false`. */
139
+ export function probeDeviceStats(device, opts = {}) {
140
+ const host = device.name;
141
+ const fetchedAt = opts.now ?? Date.now();
142
+ let args;
143
+ let env;
144
+ try {
145
+ const shim = writeAskpassShim();
146
+ // buildSshInvocation joins the cmd with spaces and hands the string to the
147
+ // remote login shell, which evaluates the snippet's `;`/`||` directly — no
148
+ // `sh -c` wrapper needed (and a wrapper would only re-quote the first token).
149
+ ({ args, env } = buildSshInvocation(device, [PROBE_SNIPPET], shim));
150
+ }
151
+ catch {
152
+ return Promise.resolve({ host, reachable: false, fetchedAt });
153
+ }
154
+ return new Promise((resolve) => {
155
+ execFile('ssh', args, { encoding: 'utf-8', env: { ...process.env, ...env }, timeout: opts.timeoutMs ?? PROBE_TIMEOUT_MS }, (err, stdout) => {
156
+ if (err || !stdout)
157
+ return resolve({ host, reachable: false, fetchedAt });
158
+ resolve(parseProbeOutput(host, stdout, fetchedAt));
159
+ });
160
+ });
161
+ }
162
+ /** Probe the local machine directly (no ssh round-trip) — used for the "this
163
+ * machine" row so it always shows real numbers even if it isn't ssh-reachable
164
+ * from itself. */
165
+ export function probeLocalStats(host, opts = {}) {
166
+ const fetchedAt = opts.now ?? Date.now();
167
+ return new Promise((resolve) => {
168
+ execFile('sh', ['-c', PROBE_SNIPPET], { encoding: 'utf-8', timeout: opts.timeoutMs ?? PROBE_TIMEOUT_MS }, (err, stdout) => {
169
+ if (err || !stdout)
170
+ return resolve({ host, reachable: false, fetchedAt });
171
+ resolve(parseProbeOutput(host, stdout, fetchedAt));
172
+ });
173
+ });
174
+ }
175
+ /** Probe many devices concurrently; returns a name→stats map. Bounded by the
176
+ * per-probe timeout, so total wall time ≈ the slowest single probe. The device
177
+ * named `selfName` is probed locally instead of over ssh. */
178
+ export async function probeFleetStats(devices, opts = {}) {
179
+ const entries = await Promise.all(devices.map(async (d) => {
180
+ const stats = d.name === opts.selfName
181
+ ? await probeLocalStats(d.name, opts)
182
+ : await probeDeviceStats(d, opts);
183
+ return [d.name, stats];
184
+ }));
185
+ return new Map(entries);
186
+ }
@@ -1,5 +1,13 @@
1
1
  /** Operating-system family of a device, used to pick the remote shell. */
2
2
  export type DevicePlatform = 'windows' | 'linux' | 'macos' | 'unknown';
3
+ /**
4
+ * What a device is *for*. A `worker` (the default) can run agents — it's dialed
5
+ * over SSH for sessions and eligible for run/team placement. A `control` device
6
+ * is a cockpit that drives the fleet but never executes agents itself (an
7
+ * iPhone/iPad running the companion app): it appears in the fleet but is never
8
+ * dialed for SSH nor scheduled work. Absent `role` means `worker`.
9
+ */
10
+ export type DeviceRole = 'worker' | 'control';
3
11
  /** Remote shell dialect derived from the platform. */
4
12
  export type DeviceShell = 'powershell' | 'posix';
5
13
  /** How `agents ssh` authenticates to a device. Both are first-class, fully
@@ -41,9 +49,15 @@ export interface DeviceProfile {
41
49
  address: DeviceAddress;
42
50
  auth: DeviceAuth;
43
51
  tailscale?: DeviceTailscale;
52
+ /** What the device is for. Absent means `worker` (see {@link DeviceRole}). */
53
+ role?: DeviceRole;
44
54
  createdAt: string;
45
55
  updatedAt: string;
46
56
  }
57
+ /** A device's effective role, defaulting to `worker` when unset. */
58
+ export declare function deviceRole(d: DeviceProfile): DeviceRole;
59
+ /** True for a control-only device (a cockpit) that must never be dialed/scheduled. */
60
+ export declare function isControlDevice(d: DeviceProfile): boolean;
47
61
  /** Map of device name to profile. */
48
62
  export type DeviceRegistry = Record<string, DeviceProfile>;
49
63
  /** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
@@ -74,6 +88,7 @@ export interface DeviceInput {
74
88
  address?: DeviceAddress;
75
89
  auth?: DeviceAuth;
76
90
  tailscale?: DeviceTailscale;
91
+ role?: DeviceRole;
77
92
  }
78
93
  /**
79
94
  * Create the device if absent, otherwise merge the supplied fields into the
@@ -19,6 +19,14 @@ import * as path from 'path';
19
19
  import { randomBytes } from 'crypto';
20
20
  import lockfile from 'proper-lockfile';
21
21
  import { getDevicesRegistryPath, getDevicesIgnoredPath } from '../state.js';
22
+ /** A device's effective role, defaulting to `worker` when unset. */
23
+ export function deviceRole(d) {
24
+ return d.role ?? 'worker';
25
+ }
26
+ /** True for a control-only device (a cockpit) that must never be dialed/scheduled. */
27
+ export function isControlDevice(d) {
28
+ return deviceRole(d) === 'control';
29
+ }
22
30
  function registryPath() {
23
31
  return getDevicesRegistryPath();
24
32
  }
@@ -170,6 +178,7 @@ export async function upsertDevice(name, input) {
170
178
  address: input.address ?? prev?.address ?? { via: 'manual' },
171
179
  auth: input.auth ?? prev?.auth ?? { method: 'key' },
172
180
  tailscale: input.tailscale ?? prev?.tailscale,
181
+ role: input.role ?? prev?.role,
173
182
  createdAt: prev?.createdAt ?? now,
174
183
  updatedAt: now,
175
184
  };
@@ -120,6 +120,15 @@ export interface ExecOptions {
120
120
  mcpConfigPath?: string;
121
121
  /** Raw args captured after `--` on the command line, forwarded verbatim to the underlying agent CLI. */
122
122
  passthroughArgs?: string[];
123
+ /**
124
+ * Tee-and-tail the child's stdout even when no budget cap is active, so the
125
+ * caller can scan it for rate/usage-limit messages. Claude prints billing
126
+ * refusals ("monthly spend limit", "out of usage credits") to STDOUT, not
127
+ * stderr — a fallback chain that only inspects stderr never cascades on
128
+ * them. Set by runWithFallback for every chain entry; harmless elsewhere
129
+ * (output is mirrored to the parent's stdout exactly like stdio:'inherit').
130
+ */
131
+ captureStdoutTail?: boolean;
123
132
  /**
124
133
  * Escape hatch for the interactive tmux spawn-wrap (see shouldWrapInTmux):
125
134
  * when true, spawn the agent directly instead of inside a shared-socket tmux
@@ -150,7 +159,7 @@ export declare function resolveInteractive(options: Pick<ExecOptions, 'interacti
150
159
  * @param piped true when the parent's stdout is NOT a TTY (output piped)
151
160
  * @param capsActive true when a budget watcher is attached (caps configured)
152
161
  */
153
- export declare function shouldTapStdout(interactive: boolean, piped: boolean, capsActive: boolean): boolean;
162
+ export declare function shouldTapStdout(interactive: boolean, piped: boolean, capsActive: boolean, captureTail?: boolean): boolean;
154
163
  /** Parse an array of KEY=VALUE strings into an env record. Returns undefined for empty input. */
155
164
  export declare function parseExecEnv(entries: string[]): Record<string, string> | undefined;
156
165
  /**
@@ -274,8 +283,16 @@ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
274
283
  * `#{pane_pid}`, clean signal delivery on detach/kill).
275
284
  * Keys are filtered to valid identifiers so exported shell functions
276
285
  * (`BASH_FUNC_*%%`) can't make `env` choke.
286
+ *
287
+ * `redactEnvValues` replaces every value with a `<redacted>` marker while keeping
288
+ * the KEY names. The env map here carries resolved secrets bundles (options.env),
289
+ * so the real string would embed secret VALUES — which get persisted verbatim
290
+ * into SessionMeta.cmd on disk (tmux/session.ts). The launched command uses the
291
+ * real values; the stored/informational copy uses the redacted form (RUSH-1758).
277
292
  */
278
- export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv): string;
293
+ export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv, opts?: {
294
+ redactEnvValues?: boolean;
295
+ }): string;
279
296
  /**
280
297
  * Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
281
298
  * (right-stripping each). Used by runInTmux to recap a fast-failed agent's