@phnx-labs/agents-cli 1.20.64 → 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 (155) hide show
  1. package/CHANGELOG.md +39 -3
  2. package/README.md +37 -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 +94 -49
  10. package/dist/commands/feed.js +25 -11
  11. package/dist/commands/hosts.js +44 -6
  12. package/dist/commands/mcp.js +55 -5
  13. package/dist/commands/monitors.d.ts +12 -0
  14. package/dist/commands/monitors.js +740 -0
  15. package/dist/commands/output.js +2 -2
  16. package/dist/commands/routines.js +23 -2
  17. package/dist/commands/secrets.d.ts +16 -0
  18. package/dist/commands/secrets.js +215 -64
  19. package/dist/commands/serve.js +31 -0
  20. package/dist/commands/sessions-export.js +8 -3
  21. package/dist/commands/sessions.d.ts +16 -0
  22. package/dist/commands/sessions.js +36 -6
  23. package/dist/commands/ssh.js +45 -2
  24. package/dist/commands/versions.js +7 -3
  25. package/dist/commands/view.d.ts +26 -0
  26. package/dist/commands/view.js +32 -9
  27. package/dist/commands/webhook.js +10 -2
  28. package/dist/index.js +34 -14
  29. package/dist/lib/agents.d.ts +18 -0
  30. package/dist/lib/agents.js +53 -3
  31. package/dist/lib/auto-dispatch-provider.js +7 -2
  32. package/dist/lib/auto-dispatch.d.ts +3 -0
  33. package/dist/lib/auto-dispatch.js +3 -0
  34. package/dist/lib/browser/chrome.js +2 -2
  35. package/dist/lib/cloud/antigravity.js +2 -2
  36. package/dist/lib/cloud/host.d.ts +59 -0
  37. package/dist/lib/cloud/host.js +224 -0
  38. package/dist/lib/cloud/registry.js +4 -0
  39. package/dist/lib/cloud/types.d.ts +6 -4
  40. package/dist/lib/computer-rpc.js +3 -1
  41. package/dist/lib/crabbox/cli.js +5 -1
  42. package/dist/lib/crabbox/runtimes.js +11 -2
  43. package/dist/lib/daemon.d.ts +20 -4
  44. package/dist/lib/daemon.js +62 -19
  45. package/dist/lib/devices/fleet.d.ts +3 -2
  46. package/dist/lib/devices/fleet.js +9 -0
  47. package/dist/lib/devices/registry.d.ts +15 -0
  48. package/dist/lib/devices/registry.js +9 -0
  49. package/dist/lib/exec.d.ts +19 -2
  50. package/dist/lib/exec.js +41 -13
  51. package/dist/lib/fleet/apply.d.ts +63 -0
  52. package/dist/lib/fleet/apply.js +214 -0
  53. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  54. package/dist/lib/fleet/auth-sync.js +142 -0
  55. package/dist/lib/fleet/manifest.d.ts +29 -0
  56. package/dist/lib/fleet/manifest.js +127 -0
  57. package/dist/lib/fleet/types.d.ts +129 -0
  58. package/dist/lib/fleet/types.js +13 -0
  59. package/dist/lib/git.d.ts +27 -0
  60. package/dist/lib/git.js +34 -2
  61. package/dist/lib/hosts/dispatch.d.ts +29 -8
  62. package/dist/lib/hosts/dispatch.js +46 -18
  63. package/dist/lib/hosts/passthrough.js +2 -0
  64. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  65. package/dist/lib/hosts/providers/devices.js +98 -0
  66. package/dist/lib/hosts/registry.d.ts +10 -16
  67. package/dist/lib/hosts/registry.js +17 -50
  68. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  69. package/dist/lib/hosts/remote-cmd.js +71 -0
  70. package/dist/lib/hosts/run-target.d.ts +84 -0
  71. package/dist/lib/hosts/run-target.js +99 -0
  72. package/dist/lib/hosts/types.d.ts +23 -5
  73. package/dist/lib/hosts/types.js +22 -4
  74. package/dist/lib/linear-autoclose.d.ts +30 -0
  75. package/dist/lib/linear-autoclose.js +22 -0
  76. package/dist/lib/mcp.d.ts +27 -1
  77. package/dist/lib/mcp.js +126 -12
  78. package/dist/lib/monitors/config.d.ts +161 -0
  79. package/dist/lib/monitors/config.js +372 -0
  80. package/dist/lib/monitors/dispatch.d.ts +28 -0
  81. package/dist/lib/monitors/dispatch.js +91 -0
  82. package/dist/lib/monitors/engine.d.ts +61 -0
  83. package/dist/lib/monitors/engine.js +201 -0
  84. package/dist/lib/monitors/sources/command.d.ts +11 -0
  85. package/dist/lib/monitors/sources/command.js +31 -0
  86. package/dist/lib/monitors/sources/device.d.ts +13 -0
  87. package/dist/lib/monitors/sources/device.js +35 -0
  88. package/dist/lib/monitors/sources/file.d.ts +14 -0
  89. package/dist/lib/monitors/sources/file.js +57 -0
  90. package/dist/lib/monitors/sources/http.d.ts +10 -0
  91. package/dist/lib/monitors/sources/http.js +34 -0
  92. package/dist/lib/monitors/sources/index.d.ts +14 -0
  93. package/dist/lib/monitors/sources/index.js +31 -0
  94. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  95. package/dist/lib/monitors/sources/poll.js +9 -0
  96. package/dist/lib/monitors/sources/types.d.ts +18 -0
  97. package/dist/lib/monitors/sources/types.js +9 -0
  98. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  99. package/dist/lib/monitors/sources/webhook.js +47 -0
  100. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  101. package/dist/lib/monitors/sources/ws.js +45 -0
  102. package/dist/lib/monitors/state.d.ts +69 -0
  103. package/dist/lib/monitors/state.js +144 -0
  104. package/dist/lib/platform/exec.d.ts +16 -0
  105. package/dist/lib/platform/exec.js +17 -0
  106. package/dist/lib/plugins.js +101 -2
  107. package/dist/lib/redact.d.ts +14 -1
  108. package/dist/lib/redact.js +47 -1
  109. package/dist/lib/remote-agents-json.js +7 -1
  110. package/dist/lib/rotate.d.ts +6 -3
  111. package/dist/lib/rotate.js +0 -1
  112. package/dist/lib/routines.d.ts +16 -0
  113. package/dist/lib/routines.js +19 -0
  114. package/dist/lib/runner.d.ts +1 -0
  115. package/dist/lib/runner.js +102 -9
  116. package/dist/lib/secrets/agent.d.ts +48 -10
  117. package/dist/lib/secrets/agent.js +123 -15
  118. package/dist/lib/secrets/bundles.d.ts +26 -0
  119. package/dist/lib/secrets/bundles.js +59 -8
  120. package/dist/lib/secrets/mcp.js +4 -2
  121. package/dist/lib/secrets/remote.d.ts +17 -0
  122. package/dist/lib/secrets/remote.js +40 -0
  123. package/dist/lib/self-update.d.ts +20 -0
  124. package/dist/lib/self-update.js +54 -1
  125. package/dist/lib/serve/control.d.ts +95 -0
  126. package/dist/lib/serve/control.js +260 -0
  127. package/dist/lib/serve/server.d.ts +35 -1
  128. package/dist/lib/serve/server.js +106 -76
  129. package/dist/lib/serve/stream.d.ts +43 -0
  130. package/dist/lib/serve/stream.js +116 -0
  131. package/dist/lib/serve/token.d.ts +35 -0
  132. package/dist/lib/serve/token.js +85 -0
  133. package/dist/lib/session/bundle.d.ts +14 -0
  134. package/dist/lib/session/bundle.js +12 -1
  135. package/dist/lib/session/remote-list.js +5 -1
  136. package/dist/lib/session/state.d.ts +7 -25
  137. package/dist/lib/session/state.js +16 -6
  138. package/dist/lib/session/sync/config.js +8 -2
  139. package/dist/lib/session/types.d.ts +30 -0
  140. package/dist/lib/ssh-tunnel.d.ts +19 -1
  141. package/dist/lib/ssh-tunnel.js +86 -7
  142. package/dist/lib/startup/command-registry.d.ts +2 -0
  143. package/dist/lib/startup/command-registry.js +4 -0
  144. package/dist/lib/state.d.ts +5 -0
  145. package/dist/lib/state.js +12 -0
  146. package/dist/lib/tmux/session.d.ts +7 -0
  147. package/dist/lib/tmux/session.js +3 -1
  148. package/dist/lib/triggers/webhook.d.ts +18 -0
  149. package/dist/lib/triggers/webhook.js +105 -0
  150. package/dist/lib/types.d.ts +26 -1
  151. package/dist/lib/usage.js +7 -5
  152. package/dist/lib/versions.js +14 -11
  153. package/dist/lib/workflows.d.ts +20 -0
  154. package/dist/lib/workflows.js +24 -0
  155. package/package.json +2 -1
@@ -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
  /**
@@ -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
package/dist/lib/exec.js CHANGED
@@ -147,12 +147,13 @@ export function resolveInteractive(options) {
147
147
  * @param piped true when the parent's stdout is NOT a TTY (output piped)
148
148
  * @param capsActive true when a budget watcher is attached (caps configured)
149
149
  */
150
- export function shouldTapStdout(interactive, piped, capsActive) {
150
+ export function shouldTapStdout(interactive, piped, capsActive, captureTail = false) {
151
151
  if (interactive)
152
152
  return false;
153
153
  // Always pipe when the caller pipes us downstream (preserve composability),
154
- // OR when caps are active so the watcher can read the stream at a TTY.
155
- return piped || capsActive;
154
+ // when caps are active so the watcher can read the stream at a TTY, or when
155
+ // a fallback chain needs a stdout tail for rate-limit detection.
156
+ return piped || capsActive || captureTail;
156
157
  }
157
158
  /** Pattern for valid environment variable names (C identifier rules). */
158
159
  const EXEC_ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -866,11 +867,17 @@ export function shouldWrapInTmux(ctx) {
866
867
  * `#{pane_pid}`, clean signal delivery on detach/kill).
867
868
  * Keys are filtered to valid identifiers so exported shell functions
868
869
  * (`BASH_FUNC_*%%`) can't make `env` choke.
870
+ *
871
+ * `redactEnvValues` replaces every value with a `<redacted>` marker while keeping
872
+ * the KEY names. The env map here carries resolved secrets bundles (options.env),
873
+ * so the real string would embed secret VALUES — which get persisted verbatim
874
+ * into SessionMeta.cmd on disk (tmux/session.ts). The launched command uses the
875
+ * real values; the stored/informational copy uses the redacted form (RUSH-1758).
869
876
  */
870
- export function buildTmuxAgentCommand(executable, args, env) {
877
+ export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
871
878
  const envPrefix = Object.entries(env)
872
879
  .filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
873
- .map(([k, v]) => `${k}=${shellQuote(String(v))}`)
880
+ .map(([k, v]) => `${k}=${opts.redactEnvValues ? '<redacted>' : shellQuote(String(v))}`)
874
881
  .join(' ');
875
882
  const agentCmd = [executable, ...args].map(shellQuote).join(' ');
876
883
  return `exec env ${envPrefix} ${agentCmd}`;
@@ -917,11 +924,16 @@ async function runInTmux(options, executable, args) {
917
924
  const cwd = options.cwd || process.cwd();
918
925
  const idSeed = (options.sessionId ?? randomUUID()).slice(0, 8);
919
926
  const name = slugifyName(`ag-${options.agent}-${idSeed}`);
920
- const cmd = buildTmuxAgentCommand(executable, args, buildExecEnv(options));
927
+ const execEnv = buildExecEnv(options);
928
+ // Launch with the real env (secret VALUES materialized into the pane); persist
929
+ // only a value-redacted copy in SessionMeta.cmd so resolved secrets never hit
930
+ // disk via the informational cmd field (RUSH-1758).
931
+ const cmd = buildTmuxAgentCommand(executable, args, execEnv);
932
+ const metaCmd = buildTmuxAgentCommand(executable, args, execEnv, { redactEnvValues: true });
921
933
  const labels = { agent: options.agent };
922
934
  if (options.sessionId)
923
935
  labels.sessionId = options.sessionId;
924
- const meta = await createSession({ name, cmd, cwd, socket, source: 'cli', labels });
936
+ const meta = await createSession({ name, cmd, metaCmd, cwd, socket, source: 'cli', labels });
925
937
  const pane = meta.pane;
926
938
  if (pane) {
927
939
  // When the AGENT pane dies, detach the client (don't kill) so the session
@@ -990,7 +1002,7 @@ async function runInTmux(options, executable, args) {
990
1002
  await surfacePaneFailure(before.status, `${options.agent} exited before it could start`);
991
1003
  }
992
1004
  await killSession(name, socket).catch(() => { });
993
- return { exitCode: before.status ?? 0, stderr: '' };
1005
+ return { exitCode: before.status ?? 0, stderr: '', stdout: '' };
994
1006
  }
995
1007
  await attachTmux({ socket, args: ['attach-session', '-t', name] });
996
1008
  const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
@@ -1003,10 +1015,10 @@ async function runInTmux(options, executable, args) {
1003
1015
  await surfacePaneFailure(after.status, `${options.agent} exited`);
1004
1016
  }
1005
1017
  await killSession(name, socket).catch(() => { });
1006
- return { exitCode: after.status ?? 0, stderr: '' };
1018
+ return { exitCode: after.status ?? 0, stderr: '', stdout: '' };
1007
1019
  }
1008
1020
  // Pane still alive → the user detached; keep the session for `agents focus`.
1009
- return { exitCode: 0, stderr: '' };
1021
+ return { exitCode: 0, stderr: '', stdout: '' };
1010
1022
  }
1011
1023
  /**
1012
1024
  * Spawn an agent process and return its exit code plus a tee'd copy of stderr.
@@ -1092,7 +1104,7 @@ async function spawnAgent(options) {
1092
1104
  // PIPE (and later tee) stdout whenever the live budget watcher must read it
1093
1105
  // — for ALL non-interactive runs when caps are active, regardless of TTY.
1094
1106
  // See shouldTapStdout() for the rationale (FIX 3, issue #346).
1095
- const tapStdout = shouldTapStdout(interactive, piped, watcherState !== null);
1107
+ const tapStdout = shouldTapStdout(interactive, piped, watcherState !== null, options.captureStdoutTail);
1096
1108
  const stdio = interactive
1097
1109
  ? ['inherit', 'inherit', 'inherit']
1098
1110
  : ['inherit', tapStdout ? 'pipe' : 'inherit', 'pipe'];
@@ -1127,10 +1139,17 @@ async function spawnAgent(options) {
1127
1139
  timer.mark('startup');
1128
1140
  let budgetKilled = false;
1129
1141
  let budgetKillTimer;
1142
+ let stdoutTail = '';
1143
+ const STDOUT_TAIL_CAP = 16 * 1024;
1130
1144
  if (!interactive && tapStdout && child.stdout) {
1131
1145
  // TEE the child's stdout back to the parent's so the user still sees
1132
1146
  // output (mirrors stdio:'inherit') while we tap the same stream for usage.
1133
1147
  child.stdout.pipe(process.stdout);
1148
+ // Keep a rolling TAIL (billing refusals arrive at the very end of a run)
1149
+ // for the fallback chain's rate-limit scan.
1150
+ child.stdout.on('data', (chunk) => {
1151
+ stdoutTail = (stdoutTail + chunk.toString('utf-8')).slice(-STDOUT_TAIL_CAP);
1152
+ });
1134
1153
  // Tap the same stream for budget usage events without consuming the pipe
1135
1154
  // (a 'data' listener and .pipe() both receive every chunk). Kill on breach.
1136
1155
  if (watcherState) {
@@ -1200,7 +1219,7 @@ async function spawnAgent(options) {
1200
1219
  // teams/cloud can tell a budget termination apart from a normal failure.
1201
1220
  const exitCode = budgetKilled ? BUDGET_KILL_EXIT_CODE : (code ?? 0);
1202
1221
  timer.end({ exitCode, status: budgetKilled ? 'budget_killed' : code === 0 ? 'success' : 'failed' });
1203
- resolve({ exitCode, stderr: stderrBuffer });
1222
+ resolve({ exitCode, stderr: stderrBuffer, stdout: stdoutTail });
1204
1223
  });
1205
1224
  });
1206
1225
  }
@@ -1282,6 +1301,12 @@ export const RATE_LIMIT_PATTERNS = [
1282
1301
  /too many requests/i,
1283
1302
  /api[\s_-]?overloaded/i,
1284
1303
  /\boverloaded\b/i,
1304
+ // Claude billing refusals — "You've hit your org's monthly spend limit" and
1305
+ // "You're out of usage credits". Both end the run with exit 1 and are exactly
1306
+ // the condition a fallback chain exists to recover from. Printed to STDOUT,
1307
+ // hence the stdout tail in SpawnResult.
1308
+ /spend[\s-]?limit/i,
1309
+ /out of (?:usage )?credits/i,
1285
1310
  ];
1286
1311
  /** Return true if the text contains any known rate-limit or overload indicator. */
1287
1312
  export function detectRateLimit(text) {
@@ -1370,6 +1395,9 @@ export async function runWithFallback(options) {
1370
1395
  prompt,
1371
1396
  env: envOverride ? { ...(options.env ?? {}), ...envOverride } : options.env,
1372
1397
  sessionId: pinnedSessionId ?? (i === 0 ? options.sessionId : undefined),
1398
+ // Claude prints billing refusals (spend limit / out of credits) to
1399
+ // stdout; tail it so the cascade check below can see them.
1400
+ captureStdoutTail: true,
1373
1401
  };
1374
1402
  const label = version ? `${agent}@${version}` : agent;
1375
1403
  const modelSwapNote = sameHostRetry && envOverride
@@ -1397,7 +1425,7 @@ export async function runWithFallback(options) {
1397
1425
  const isLast = i === chain.length - 1;
1398
1426
  if (isLast)
1399
1427
  return result.exitCode;
1400
- if (!detectRateLimit(result.stderr)) {
1428
+ if (!detectRateLimit(result.stderr) && !detectRateLimit(result.stdout)) {
1401
1429
  return result.exitCode;
1402
1430
  }
1403
1431
  const next = chain[i + 1];
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Reconcile engine for `agents apply`. The diff (`diffFleet`) is pure and unit-
3
+ * tested; execution (`probeDevice`, `reconcileDevice`, `runFleetApply`) drives
4
+ * the real fleet over SSH and is verified end-to-end against live devices.
5
+ *
6
+ * Flow per device: probe → install/upgrade agents-cli → add missing agents →
7
+ * sync config → propagate login. Every step reuses an existing primitive
8
+ * (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
9
+ */
10
+ import type { DeviceProfile } from '../devices/registry.js';
11
+ import type { DeviceDesired, DeviceProbe, DeviceDiff, FleetAction, FleetPlan, AuthFilePayload } from './types.js';
12
+ /** Strip a version suffix from an agent spec: `claude@latest` -> `claude`. */
13
+ export declare function agentIdOf(spec: string): string;
14
+ /** Source-side auth availability, computed once from `snapshotAuth`. */
15
+ export interface SourceAuth {
16
+ /** Agent ids the source has a readable, propagatable credential file for. */
17
+ available: Set<string>;
18
+ /** Agent ids whose source auth is device-bound (macOS keychain). */
19
+ bound: Set<string>;
20
+ /** The captured file payloads, keyed by agent. */
21
+ filesByAgent: Map<string, AuthFilePayload[]>;
22
+ }
23
+ /**
24
+ * Can we propagate `agent`'s login to a target on `targetPlatform`? False when
25
+ * the agent has no portable file, the source can't provide it (bound / not
26
+ * signed in), or the target consumes credentials from its own keychain.
27
+ */
28
+ export declare function canPushLogin(agent: string, targetPlatform: string | undefined, src: SourceAuth): boolean;
29
+ export interface DiffContext {
30
+ /** agents-cli version the source is on — the fleet target version. */
31
+ targetCliVersion: string;
32
+ sourceAuth: SourceAuth;
33
+ }
34
+ /** Pure: desired vs probed -> per-device diff + flat action list. */
35
+ export declare function diffFleet(desired: DeviceDesired[], probes: Map<string, DeviceProbe>, ctx: DiffContext): FleetPlan;
36
+ /** Probe one device: reachability + agents-cli version + installed agent ids. */
37
+ export declare function probeDevice(device: DeviceProfile): DeviceProbe;
38
+ export interface ApplyStep {
39
+ kind: FleetAction['kind'];
40
+ ok: boolean;
41
+ detail: string;
42
+ }
43
+ export interface DeviceApplyResult {
44
+ device: string;
45
+ ok: boolean;
46
+ steps: ApplyStep[];
47
+ note?: string;
48
+ }
49
+ export interface ExecContext {
50
+ targetCliVersion: string;
51
+ source: string;
52
+ sourceAuth: SourceAuth;
53
+ /** Set for a dry run — probe + plan only, execute nothing. */
54
+ dryRun?: boolean;
55
+ }
56
+ /** Execute one device's planned actions in order. Real SSH — no mocks. */
57
+ export declare function reconcileDevice(row: DeviceDiff, device: DeviceProfile, ctx: ExecContext): DeviceApplyResult;
58
+ /** Run a pool of async tasks with a concurrency cap, preserving input order. */
59
+ export declare function pool<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
60
+ /** Reconcile every device row in parallel (capped). */
61
+ export declare function runFleetApply(rows: DeviceDiff[], nameToProfile: Map<string, DeviceProfile>, ctx: ExecContext, concurrency?: number): Promise<DeviceApplyResult[]>;
62
+ /** Default home for source snapshots (overridable in tests). */
63
+ export declare function sourceHome(): string;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Reconcile engine for `agents apply`. The diff (`diffFleet`) is pure and unit-
3
+ * tested; execution (`probeDevice`, `reconcileDevice`, `runFleetApply`) drives
4
+ * the real fleet over SSH and is verified end-to-end against live devices.
5
+ *
6
+ * Flow per device: probe → install/upgrade agents-cli → add missing agents →
7
+ * sync config → propagate login. Every step reuses an existing primitive
8
+ * (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
9
+ */
10
+ import * as os from 'os';
11
+ import { sshTargetFor } from '../devices/connect.js';
12
+ import { readyProbe, bootstrapAgentsCli } from '../hosts/ready.js';
13
+ import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
14
+ import { sshExec } from '../ssh-exec.js';
15
+ import { isPropagatableAgent, KEYCHAIN_BOUND_ON_MAC, buildAuthBundle, } from './auth-sync.js';
16
+ /** Strip a version suffix from an agent spec: `claude@latest` -> `claude`. */
17
+ export function agentIdOf(spec) {
18
+ return spec.split('@')[0].trim();
19
+ }
20
+ /**
21
+ * Can we propagate `agent`'s login to a target on `targetPlatform`? False when
22
+ * the agent has no portable file, the source can't provide it (bound / not
23
+ * signed in), or the target consumes credentials from its own keychain.
24
+ */
25
+ export function canPushLogin(agent, targetPlatform, src) {
26
+ if (!isPropagatableAgent(agent))
27
+ return false;
28
+ if (src.bound.has(agent))
29
+ return false;
30
+ if (targetPlatform === 'macos' && KEYCHAIN_BOUND_ON_MAC.has(agent))
31
+ return false;
32
+ return src.available.has(agent);
33
+ }
34
+ /** Pure: desired vs probed -> per-device diff + flat action list. */
35
+ export function diffFleet(desired, probes, ctx) {
36
+ const devices = [];
37
+ const actions = [];
38
+ for (const d of desired) {
39
+ const probe = probes.get(d.device) ?? {
40
+ device: d.device,
41
+ reachable: false,
42
+ installedAgents: [],
43
+ note: 'not probed',
44
+ };
45
+ const rowActions = [];
46
+ const loginBlocked = [];
47
+ if (probe.reachable) {
48
+ // agents-cli presence.
49
+ if (!probe.cliVersion) {
50
+ rowActions.push({ device: d.device, kind: 'install-cli', detail: `install agents-cli ${ctx.targetCliVersion}` });
51
+ }
52
+ else if (probe.cliVersion !== ctx.targetCliVersion) {
53
+ rowActions.push({ device: d.device, kind: 'upgrade-cli', detail: `agents-cli ${probe.cliVersion} -> ${ctx.targetCliVersion}` });
54
+ }
55
+ // agents.
56
+ for (const spec of d.agents) {
57
+ const id = agentIdOf(spec);
58
+ if (!probe.installedAgents.includes(id)) {
59
+ rowActions.push({ device: d.device, kind: 'add-agent', agent: id, detail: `install ${spec}` });
60
+ }
61
+ }
62
+ // config.
63
+ if (d.sync.length > 0) {
64
+ rowActions.push({ device: d.device, kind: 'sync-config', detail: `sync config (${d.sync.join(', ')})` });
65
+ }
66
+ // login.
67
+ if (d.login === 'sync') {
68
+ for (const spec of d.agents) {
69
+ const id = agentIdOf(spec);
70
+ if (canPushLogin(id, probe.platform, ctx.sourceAuth)) {
71
+ rowActions.push({ device: d.device, kind: 'push-login', agent: id, detail: `propagate ${id} login` });
72
+ }
73
+ else if (isPropagatableAgent(id) &&
74
+ (ctx.sourceAuth.bound.has(id) || (probe.platform === 'macos' && KEYCHAIN_BOUND_ON_MAC.has(id)))) {
75
+ // A login-propagation candidate we still can't push: the source token
76
+ // is keychain-bound (unextractable), or the macOS target consumes its
77
+ // own keychain. Surface those as manual — don't fake. Agents that were
78
+ // never propagation candidates (no portable file), or a source that
79
+ // simply isn't signed in, are silently skipped the same on every OS.
80
+ loginBlocked.push(id);
81
+ rowActions.push({ device: d.device, kind: 'needs-login', agent: id, detail: `${id} needs a manual login` });
82
+ }
83
+ }
84
+ }
85
+ }
86
+ devices.push({ device: d.device, desired: d, probe, actions: rowActions, loginBlocked });
87
+ actions.push(...rowActions);
88
+ }
89
+ return { devices, actions };
90
+ }
91
+ // ---- execution (real SSH; verified end-to-end, not unit-mocked) ----
92
+ function osHint(platform) {
93
+ return platform === 'windows' ? 'windows' : undefined;
94
+ }
95
+ /** POSIX login shells often miss the shims dir; inject it (mirrors doctor). */
96
+ function remoteEnv(platform) {
97
+ return platform === 'windows' ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
98
+ }
99
+ /** Probe one device: reachability + agents-cli version + installed agent ids. */
100
+ export function probeDevice(device) {
101
+ let target;
102
+ try {
103
+ target = sshTargetFor(device);
104
+ }
105
+ catch (e) {
106
+ return { device: device.name, reachable: false, platform: device.platform, installedAgents: [], note: e.message };
107
+ }
108
+ const hint = osHint(device.platform);
109
+ const ready = readyProbe(target, hint);
110
+ if (!ready.reachable) {
111
+ return { device: device.name, reachable: false, platform: device.platform, installedAgents: [], note: 'unreachable' };
112
+ }
113
+ let installed = [];
114
+ const remoteCmd = buildRemoteAgentsInvocation(['teams', 'doctor', '--json'], undefined, hint, remoteEnv(device.platform));
115
+ const res = sshExec(target, remoteCmd, { timeoutMs: 30000, multiplex: true });
116
+ if (res.code === 0) {
117
+ try {
118
+ const map = JSON.parse(res.stdout);
119
+ installed = Object.entries(map).filter(([, e]) => e?.installed).map(([k]) => k);
120
+ }
121
+ catch {
122
+ /* agents-cli present but doctor output unparsable — treat as no agents */
123
+ }
124
+ }
125
+ return {
126
+ device: device.name,
127
+ reachable: true,
128
+ platform: device.platform,
129
+ cliVersion: ready.version ?? undefined,
130
+ installedAgents: installed,
131
+ };
132
+ }
133
+ /** Execute one device's planned actions in order. Real SSH — no mocks. */
134
+ export function reconcileDevice(row, device, ctx) {
135
+ if (!row.probe.reachable) {
136
+ return { device: row.device, ok: false, steps: [], note: row.probe.note ?? 'unreachable' };
137
+ }
138
+ const steps = [];
139
+ let target;
140
+ try {
141
+ target = sshTargetFor(device);
142
+ }
143
+ catch (e) {
144
+ return { device: row.device, ok: false, steps: [], note: e.message };
145
+ }
146
+ const hint = osHint(device.platform);
147
+ const env = remoteEnv(device.platform);
148
+ let ok = true;
149
+ const sshAgents = (args, input) => sshExec(target, buildRemoteAgentsInvocation(args, undefined, hint, env), { timeoutMs: 300000, multiplex: true, input });
150
+ // 1. agents-cli install/upgrade.
151
+ const cliAction = row.actions.find((a) => a.kind === 'install-cli' || a.kind === 'upgrade-cli');
152
+ if (cliAction) {
153
+ const r = bootstrapAgentsCli(target, ctx.targetCliVersion, hint);
154
+ steps.push({ kind: cliAction.kind, ok: r.ok, detail: cliAction.detail });
155
+ ok = ok && r.ok;
156
+ }
157
+ // 2. agents.
158
+ for (const a of row.actions.filter((x) => x.kind === 'add-agent')) {
159
+ const spec = a.detail.replace(/^install\s+/, '');
160
+ const r = sshAgents(['add', spec, '--yes']);
161
+ steps.push({ kind: 'add-agent', ok: r.code === 0, detail: a.detail });
162
+ ok = ok && r.code === 0;
163
+ }
164
+ // 3. config sync.
165
+ if (row.actions.some((a) => a.kind === 'sync-config')) {
166
+ const r = sshAgents(['sync']);
167
+ steps.push({ kind: 'sync-config', ok: r.code === 0, detail: `sync config` });
168
+ ok = ok && r.code === 0;
169
+ }
170
+ // 4. login propagation — one bundle for all pushable agents on this device.
171
+ const pushAgents = [...new Set(row.actions.filter((a) => a.kind === 'push-login').map((a) => a.agent))];
172
+ if (pushAgents.length > 0) {
173
+ const files = [];
174
+ for (const agent of pushAgents)
175
+ files.push(...(ctx.sourceAuth.filesByAgent.get(agent) ?? []));
176
+ const bundle = buildAuthBundle(ctx.source, files);
177
+ const r = sshAgents(['apply', '--recv-auth'], JSON.stringify(bundle));
178
+ steps.push({ kind: 'push-login', ok: r.code === 0, detail: `propagate login: ${pushAgents.join(', ')}` });
179
+ ok = ok && r.code === 0;
180
+ }
181
+ // Surface blocked logins as (non-fatal) informational steps.
182
+ for (const blocked of row.loginBlocked) {
183
+ steps.push({ kind: 'needs-login', ok: false, detail: `${blocked} needs a manual login (\`agents ssh ${row.device} -- ${blocked}\`)` });
184
+ }
185
+ return { device: row.device, ok, steps };
186
+ }
187
+ /** Run a pool of async tasks with a concurrency cap, preserving input order. */
188
+ export async function pool(items, limit, fn) {
189
+ const results = new Array(items.length);
190
+ let next = 0;
191
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
192
+ for (;;) {
193
+ const i = next++;
194
+ if (i >= items.length)
195
+ return;
196
+ results[i] = await fn(items[i], i);
197
+ }
198
+ });
199
+ await Promise.all(workers);
200
+ return results;
201
+ }
202
+ /** Reconcile every device row in parallel (capped). */
203
+ export async function runFleetApply(rows, nameToProfile, ctx, concurrency = 6) {
204
+ return pool(rows, concurrency, async (row) => {
205
+ const profile = nameToProfile.get(row.device);
206
+ if (!profile)
207
+ return { device: row.device, ok: false, steps: [], note: 'no device profile' };
208
+ return reconcileDevice(row, profile, ctx);
209
+ });
210
+ }
211
+ /** Default home for source snapshots (overridable in tests). */
212
+ export function sourceHome() {
213
+ return os.homedir();
214
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Login/token propagation — the "one login instead of ~48 OAuth flows" core of
3
+ * `agents apply`.
4
+ *
5
+ * A source machine that is already signed in to a set of harnesses has portable
6
+ * credential files on disk (verified per-agent locations below). `snapshotAuth`
7
+ * captures those; `materializeAuth` writes them into the corresponding paths on
8
+ * a target device. Transport is the caller's job — `apply` streams the bundle
9
+ * over the existing (encrypted, authenticated) SSH channel via `sshExec`'s
10
+ * stdin `input`, so no app-layer crypto is layered on top of SSH here.
11
+ *
12
+ * Honest boundary: on macOS, claude and antigravity keep their tokens in the
13
+ * login keychain, ACL-bound to the harness process — unreadable by us. Those are
14
+ * classified `bound` and surfaced for a one-time manual login, never faked.
15
+ */
16
+ import type { AuthFilePayload, AuthBundle, AuthSnapshotResult } from './types.js';
17
+ /** A portable credential file location, relative to $HOME. */
18
+ interface AuthFileSpec {
19
+ rel: string;
20
+ mode: number;
21
+ }
22
+ /**
23
+ * Verified portable auth-file locations per agent (home-relative). Sourced from
24
+ * live inspection of a Linux fleet box + the agent registry. Agents absent here
25
+ * have no portable credential file we can propagate.
26
+ */
27
+ export declare const FLEET_AUTH_FILES: Record<string, AuthFileSpec[]>;
28
+ /** Agents whose macOS credentials live in the ACL-bound login keychain. */
29
+ export declare const KEYCHAIN_BOUND_ON_MAC: ReadonlySet<string>;
30
+ /** Which agents `apply` can propagate auth for at all. */
31
+ export declare function isPropagatableAgent(agent: string): boolean;
32
+ export interface SnapshotOptions {
33
+ /** Home directory to read credential files from. */
34
+ home: string;
35
+ /** Platform of the source machine (`process.platform`). */
36
+ platform: NodeJS.Platform;
37
+ }
38
+ /**
39
+ * Capture portable credential files for the given agents from a source home.
40
+ * Returns the readable file payloads plus the list of agents whose auth is
41
+ * device-bound (macOS keychain) and therefore cannot be captured. Agents that
42
+ * are simply not signed in (no file on disk) are silently omitted — nothing to
43
+ * propagate, not an error.
44
+ */
45
+ export declare function snapshotAuth(agents: string[], opts: SnapshotOptions): AuthSnapshotResult;
46
+ /** Assemble the plaintext bundle shipped to a target. */
47
+ export declare function buildAuthBundle(source: string, files: AuthFilePayload[]): AuthBundle;
48
+ export interface MaterializeOptions {
49
+ /** Home directory to write credential files into. */
50
+ home: string;
51
+ }
52
+ export interface MaterializeResult {
53
+ /** Agent ids that received at least one file. */
54
+ written: string[];
55
+ /** `rel: reason` for any file that failed. */
56
+ errors: string[];
57
+ }
58
+ /**
59
+ * Write a captured auth bundle into a target home. Parent dirs are created; the
60
+ * captured POSIX mode is restored (0600 for credentials). Writing through an
61
+ * existing symlink lands in the agent's active version home, which is exactly
62
+ * what per-version credential carry-forward expects.
63
+ */
64
+ export declare function materializeAuth(bundle: AuthBundle, opts: MaterializeOptions): MaterializeResult;
65
+ /** Parse + validate a bundle received on stdin (the remote `--_recv-auth` path). */
66
+ export declare function parseAuthBundle(raw: string): AuthBundle;
67
+ export {};