@phnx-labs/agents-cli 1.22.26 → 1.22.28

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 (183) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/README.md +80 -27
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/bench.d.ts +2 -0
  5. package/dist/commands/bench.js +101 -0
  6. package/dist/commands/doctor.js +24 -10
  7. package/dist/commands/exec.js +71 -26
  8. package/dist/commands/hosts.js +4 -3
  9. package/dist/commands/insights.js +39 -5
  10. package/dist/commands/output.js +100 -25
  11. package/dist/commands/perf.d.ts +10 -0
  12. package/dist/commands/perf.js +14 -6
  13. package/dist/commands/projects.js +24 -10
  14. package/dist/commands/resume.d.ts +11 -0
  15. package/dist/commands/resume.js +51 -0
  16. package/dist/commands/routines.js +31 -4
  17. package/dist/commands/run-account-picker.d.ts +37 -0
  18. package/dist/commands/run-account-picker.js +101 -17
  19. package/dist/commands/secrets.js +86 -2
  20. package/dist/commands/sessions-picker.js +12 -7
  21. package/dist/commands/sessions.d.ts +73 -7
  22. package/dist/commands/sessions.js +391 -49
  23. package/dist/commands/ssh.js +184 -11
  24. package/dist/commands/teams.js +22 -11
  25. package/dist/index.js +16 -20
  26. package/dist/lib/activity.js +45 -99
  27. package/dist/lib/auth-health.d.ts +47 -3
  28. package/dist/lib/auth-health.js +74 -15
  29. package/dist/lib/bench/index.d.ts +4 -0
  30. package/dist/lib/bench/index.js +4 -0
  31. package/dist/lib/bench/runner.d.ts +16 -0
  32. package/dist/lib/bench/runner.js +111 -0
  33. package/dist/lib/bench/schema.d.ts +5 -0
  34. package/dist/lib/bench/schema.js +91 -0
  35. package/dist/lib/bench/storage.d.ts +5 -0
  36. package/dist/lib/bench/storage.js +32 -0
  37. package/dist/lib/bench/types.d.ts +40 -0
  38. package/dist/lib/bench/types.js +1 -0
  39. package/dist/lib/claude-account-token.d.ts +2 -0
  40. package/dist/lib/claude-account-token.js +56 -3
  41. package/dist/lib/cloud/host.js +1 -0
  42. package/dist/lib/crabbox/cli.d.ts +2 -0
  43. package/dist/lib/crabbox/cli.js +2 -0
  44. package/dist/lib/crabbox/lease.js +7 -1
  45. package/dist/lib/daemon.js +20 -0
  46. package/dist/lib/devices/connect.d.ts +2 -0
  47. package/dist/lib/devices/connect.js +7 -0
  48. package/dist/lib/devices/doctor-findings.d.ts +4 -1
  49. package/dist/lib/devices/doctor-findings.js +15 -1
  50. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  51. package/dist/lib/devices/harness-inventory.js +0 -0
  52. package/dist/lib/devices/registry.d.ts +2 -0
  53. package/dist/lib/devices/resolve-target.d.ts +1 -0
  54. package/dist/lib/devices/resolve-target.js +9 -2
  55. package/dist/lib/devices/ssh-config.js +3 -0
  56. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  57. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  58. package/dist/lib/events.d.ts +1 -1
  59. package/dist/lib/events.js +2 -2
  60. package/dist/lib/exec.js +18 -8
  61. package/dist/lib/fleet/apply.js +9 -7
  62. package/dist/lib/fleet/remote-login.d.ts +4 -3
  63. package/dist/lib/fleet/remote-login.js +11 -9
  64. package/dist/lib/gemini-settings.d.ts +0 -1
  65. package/dist/lib/gemini-settings.js +12 -7
  66. package/dist/lib/git.d.ts +9 -3
  67. package/dist/lib/git.js +39 -22
  68. package/dist/lib/hooks/profile.d.ts +12 -1
  69. package/dist/lib/hooks/profile.js +6 -1
  70. package/dist/lib/hooks.d.ts +8 -5
  71. package/dist/lib/hooks.js +14 -7
  72. package/dist/lib/hosts/dispatch.d.ts +10 -0
  73. package/dist/lib/hosts/dispatch.js +85 -25
  74. package/dist/lib/hosts/logs.js +11 -1
  75. package/dist/lib/hosts/passthrough.d.ts +27 -0
  76. package/dist/lib/hosts/passthrough.js +19 -10
  77. package/dist/lib/hosts/progress.d.ts +12 -1
  78. package/dist/lib/hosts/progress.js +41 -8
  79. package/dist/lib/hosts/providers/devices.js +1 -0
  80. package/dist/lib/hosts/ready.d.ts +45 -4
  81. package/dist/lib/hosts/ready.js +124 -20
  82. package/dist/lib/hosts/reconcile.d.ts +2 -1
  83. package/dist/lib/hosts/reconcile.js +24 -7
  84. package/dist/lib/hosts/reconnect.d.ts +45 -12
  85. package/dist/lib/hosts/reconnect.js +94 -36
  86. package/dist/lib/hosts/registry.d.ts +2 -2
  87. package/dist/lib/hosts/registry.js +4 -5
  88. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  89. package/dist/lib/hosts/remote-cmd.js +29 -0
  90. package/dist/lib/hosts/tasks.d.ts +3 -0
  91. package/dist/lib/hosts/types.d.ts +1 -0
  92. package/dist/lib/hosts/types.js +3 -0
  93. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  94. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  95. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  96. package/dist/lib/menubar/install-menubar.js +20 -9
  97. package/dist/lib/menubar/snapshot.js +15 -2
  98. package/dist/lib/models.d.ts +42 -6
  99. package/dist/lib/models.js +55 -153
  100. package/dist/lib/perf/db.js +16 -3
  101. package/dist/lib/perf/types.d.ts +12 -1
  102. package/dist/lib/pricing/cost.d.ts +9 -0
  103. package/dist/lib/pricing/cost.js +24 -0
  104. package/dist/lib/pricing/index.d.ts +1 -1
  105. package/dist/lib/pricing/index.js +1 -1
  106. package/dist/lib/project-probe.d.ts +22 -3
  107. package/dist/lib/project-probe.js +105 -17
  108. package/dist/lib/project-status.d.ts +9 -0
  109. package/dist/lib/project-status.js +15 -0
  110. package/dist/lib/redact.js +8 -3
  111. package/dist/lib/refresh.js +22 -11
  112. package/dist/lib/remote-agents-json.d.ts +32 -0
  113. package/dist/lib/remote-agents-json.js +47 -16
  114. package/dist/lib/resource-profiles.js +1 -2
  115. package/dist/lib/rotate.d.ts +22 -0
  116. package/dist/lib/rotate.js +26 -0
  117. package/dist/lib/routine-notify-owner.d.ts +102 -0
  118. package/dist/lib/routine-notify-owner.js +232 -0
  119. package/dist/lib/routines.d.ts +10 -0
  120. package/dist/lib/runner.d.ts +9 -1
  121. package/dist/lib/runner.js +182 -17
  122. package/dist/lib/sandbox.d.ts +0 -2
  123. package/dist/lib/sandbox.js +2 -19
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  126. package/dist/lib/secrets/agent.d.ts +19 -3
  127. package/dist/lib/secrets/agent.js +61 -22
  128. package/dist/lib/secrets/audit.d.ts +1 -1
  129. package/dist/lib/secrets/audit.js +2 -0
  130. package/dist/lib/secrets/bundles.d.ts +3 -3
  131. package/dist/lib/secrets/bundles.js +15 -5
  132. package/dist/lib/secrets/filestore.d.ts +2 -0
  133. package/dist/lib/secrets/filestore.js +4 -0
  134. package/dist/lib/secrets/lease.d.ts +25 -0
  135. package/dist/lib/secrets/lease.js +44 -0
  136. package/dist/lib/secrets/session-store.d.ts +7 -0
  137. package/dist/lib/secrets/session-store.js +21 -0
  138. package/dist/lib/self-update.d.ts +20 -5
  139. package/dist/lib/self-update.js +93 -16
  140. package/dist/lib/session/active.d.ts +49 -0
  141. package/dist/lib/session/active.js +175 -11
  142. package/dist/lib/session/bash-command.d.ts +16 -0
  143. package/dist/lib/session/bash-command.js +65 -0
  144. package/dist/lib/session/db.d.ts +23 -2
  145. package/dist/lib/session/db.js +86 -20
  146. package/dist/lib/session/discover.d.ts +21 -1
  147. package/dist/lib/session/discover.js +124 -32
  148. package/dist/lib/session/insights.d.ts +19 -0
  149. package/dist/lib/session/insights.js +78 -10
  150. package/dist/lib/session/recovery.d.ts +17 -1
  151. package/dist/lib/session/recovery.js +111 -4
  152. package/dist/lib/session/remote-bundle.js +1 -1
  153. package/dist/lib/session/remote-list.d.ts +10 -9
  154. package/dist/lib/session/remote-list.js +25 -23
  155. package/dist/lib/session/resume-owner.d.ts +55 -0
  156. package/dist/lib/session/resume-owner.js +69 -0
  157. package/dist/lib/session/team-filter.d.ts +65 -0
  158. package/dist/lib/session/team-filter.js +98 -3
  159. package/dist/lib/session/types.d.ts +33 -0
  160. package/dist/lib/smart-launch.d.ts +31 -5
  161. package/dist/lib/smart-launch.js +43 -7
  162. package/dist/lib/ssh-exec.d.ts +27 -0
  163. package/dist/lib/ssh-exec.js +34 -1
  164. package/dist/lib/ssh-tunnel.d.ts +3 -2
  165. package/dist/lib/ssh-tunnel.js +25 -16
  166. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  167. package/dist/lib/staleness/detectors/skills.js +17 -2
  168. package/dist/lib/staleness/index.d.ts +10 -1
  169. package/dist/lib/staleness/index.js +28 -3
  170. package/dist/lib/startup/command-registry.d.ts +1 -0
  171. package/dist/lib/startup/command-registry.js +2 -0
  172. package/dist/lib/teams/agents.d.ts +1 -0
  173. package/dist/lib/teams/agents.js +38 -11
  174. package/dist/lib/teams/placement-probe.d.ts +1 -1
  175. package/dist/lib/teams/placement-probe.js +24 -24
  176. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  177. package/dist/lib/teams/remoteWorktree.js +29 -27
  178. package/dist/lib/tmux/session.d.ts +8 -0
  179. package/dist/lib/tmux/session.js +22 -0
  180. package/dist/lib/types.d.ts +2 -0
  181. package/dist/lib/versions.d.ts +1 -0
  182. package/dist/lib/versions.js +40 -22
  183. package/package.json +3 -1
@@ -135,6 +135,21 @@ export function liveDeadSplit(byStatus) {
135
135
  deadByStatus.sort((a, b) => b.n - a.n || a.status.localeCompare(b.status));
136
136
  return { live, dead, deadByStatus };
137
137
  }
138
+ /**
139
+ * The `dead` row body: `41 crashed` when every dead session shares one status,
140
+ * else `12 finished or lost (8 crashed, 4 closed)`. The generic "finished or
141
+ * lost" only earns its keep when the statuses actually differ — with a single
142
+ * status it just hides which one behind a parenthetical that repeats the count.
143
+ * Pure — chalk styling only; the caller adds the `dead` label. Assumes
144
+ * `split.dead > 0` (the caller gates on it).
145
+ */
146
+ export function formatDeadSummary(split) {
147
+ if (split.deadByStatus.length === 1) {
148
+ return chalk.yellow(`${split.dead} ${split.deadByStatus[0].status}`);
149
+ }
150
+ const detail = split.deadByStatus.map((d) => `${d.n} ${d.status}`).join(', ');
151
+ return `${chalk.yellow(`${split.dead} finished or lost`)} ${chalk.dim(`(${detail})`)}`;
152
+ }
138
153
  /**
139
154
  * Display order for the members line: the states a human scans for first
140
155
  * (running, then idle, then need-input, then queued), everything else after,
@@ -13,9 +13,14 @@ const SECRET_PATTERNS = [
13
13
  [/\bghp_[A-Za-z0-9]{36}\b/g, '[REDACTED_GITHUB_TOKEN]'],
14
14
  [/\bgh[osru]_[A-Za-z0-9]{36}\b/g, '[REDACTED_GITHUB_TOKEN]'],
15
15
  [/\bgithub_pat_[A-Za-z0-9_]{22,}\b/g, '[REDACTED_GITHUB_TOKEN]'],
16
- // Anthropic keys (sk-ant-api03-…) before the generic sk- rule so the marker
17
- // is specific; the generic rule would otherwise swallow it first.
18
- [/\bsk-ant-api03-[A-Za-z0-9_-]{20,}\b/g, '[REDACTED_ANTHROPIC_KEY]'],
16
+ // Anthropic credentials, before the generic sk- rule so the marker is specific
17
+ // (the generic rule would otherwise swallow them first). Covers every kind of
18
+ // sk-ant token by matching the kind segment generically: API keys
19
+ // (sk-ant-api03-…) AND the long-lived OAuth setup-tokens (sk-ant-oat01-…) that
20
+ // `claude setup-token` mints. The generic sk- rule below CANNOT match an oat01
21
+ // token — the hyphen after `ant` breaks its [A-Za-z0-9]{20,} run — so without
22
+ // this an OAuth setup-token leaks verbatim into any log/export (#1767).
23
+ [/\bsk-ant-[a-z0-9]{3,8}-[A-Za-z0-9_-]{8,}\b/g, '[REDACTED_ANTHROPIC_KEY]'],
19
24
  // Stripe live secret / restricted keys.
20
25
  [/\b[rs]k_live_[A-Za-z0-9]{20,}\b/g, '[REDACTED_STRIPE_KEY]'],
21
26
  [/\bsk-[A-Za-z0-9]{20,}\b/g, '[REDACTED_API_KEY]'],
@@ -135,15 +135,24 @@ export async function refresh(options = {}) {
135
135
  const versionsToSync = skipPrompts
136
136
  ? listInstalledVersions(agentId)
137
137
  : [defaultVer];
138
- const actuallySynced = getActuallySyncedResources(agentId, defaultVer);
139
- const newResources = getNewResources(available, actuallySynced, getProjectOnlyResources());
140
- const hasAnySynced = actuallySynced.commands.length > 0 ||
141
- actuallySynced.skills.length > 0 ||
142
- actuallySynced.hooks.length > 0 ||
143
- actuallySynced.memory.length > 0 ||
144
- actuallySynced.mcp.length > 0 ||
145
- actuallySynced.permissions.length > 0 ||
146
- actuallySynced.plugins.length > 0;
138
+ // Interactive-only: getActuallySyncedResources walks every skill tree with
139
+ // content compares (~1s/agent on a full install). The unattended path
140
+ // (`skipPrompts` / `agents sync --yes`) never reads these — it always
141
+ // force-full-syncs so skip the scan entirely (RUSH-2320 #1).
142
+ let actuallySynced;
143
+ let newResources;
144
+ let hasAnySynced = false;
145
+ if (!skipPrompts) {
146
+ actuallySynced = getActuallySyncedResources(agentId, defaultVer);
147
+ newResources = getNewResources(available, actuallySynced, getProjectOnlyResources());
148
+ hasAnySynced = actuallySynced.commands.length > 0 ||
149
+ actuallySynced.skills.length > 0 ||
150
+ actuallySynced.hooks.length > 0 ||
151
+ actuallySynced.memory.length > 0 ||
152
+ actuallySynced.mcp.length > 0 ||
153
+ actuallySynced.permissions.length > 0 ||
154
+ actuallySynced.plugins.length > 0;
155
+ }
147
156
  try {
148
157
  let selection;
149
158
  let forceFullSync = false;
@@ -156,7 +165,7 @@ export async function refresh(options = {}) {
156
165
  if (userSelection)
157
166
  selection = userSelection;
158
167
  }
159
- else if (hasNewResources(newResources, agentId, defaultVer)) {
168
+ else if (newResources && hasNewResources(newResources, agentId, defaultVer)) {
160
169
  log(chalk.cyan(`\n${agentLabel(agentId)}@${defaultVer}:`));
161
170
  const userSelection = await promptNewResourceSelection(agentId, newResources, defaultVer);
162
171
  if (userSelection)
@@ -168,7 +177,9 @@ export async function refresh(options = {}) {
168
177
  if (forceFullSync || (selection && Object.keys(selection).length > 0)) {
169
178
  const kinds = new Set();
170
179
  for (const ver of versionsToSync) {
171
- const syncResult = syncResourcesToVersion(agentId, ver, selection, forceFullSync ? { force: true } : undefined);
180
+ // Pass the already-built `available` so each version does not re-scan
181
+ // resource trees (RUSH-2320 #5).
182
+ const syncResult = syncResourcesToVersion(agentId, ver, selection, { available, ...(forceFullSync ? { force: true } : {}) });
172
183
  if (syncResult.commands)
173
184
  kinds.add('commands');
174
185
  if (syncResult.skills)
@@ -34,6 +34,7 @@ export interface RemoteAgentsJsonOptions<T> {
34
34
  export type SshCaptureFn = (target: string, remoteCmd: string, opts: {
35
35
  timeoutMs: number;
36
36
  signal?: AbortSignal;
37
+ extraSshArgs?: string[];
37
38
  }) => Promise<{
38
39
  code: number | null;
39
40
  stdout: string;
@@ -62,5 +63,36 @@ export declare function parseRemoteAgentsJsonPayload<T>(stdout: string, machine:
62
63
  };
63
64
  /** Build the command one peer runs, with a guard that prevents recursive fan-out. */
64
65
  export declare function remoteAgentsJsonCommand(args: string[], noFanoutEnv: string, os?: string): string;
66
+ /**
67
+ * The subset of a spawned `ssh` child this capture loop drives. Structural so a
68
+ * unit test can feed synthetic `data`/`close` events through a fake without a
69
+ * live SSH connection.
70
+ */
71
+ export interface CapturableChild {
72
+ stdout: {
73
+ on(event: 'data', listener: (chunk: Buffer) => void): unknown;
74
+ } | null;
75
+ on(event: 'error' | 'close', listener: (arg?: number | null) => void): unknown;
76
+ kill(signal?: NodeJS.Signals): unknown;
77
+ }
78
+ /**
79
+ * Stream one child's stdout into memory under a hard per-peer byte ceiling.
80
+ *
81
+ * A cross-machine fan-out awaits every peer's capture under one `Promise.all`,
82
+ * so an unbounded buffer means a single runaway peer (a corrupt or
83
+ * pathologically large payload) exhausts the caller's heap and takes the whole
84
+ * sweep down — RUSH-2065. Once the accumulated bytes would exceed
85
+ * {@link REMOTE_STDOUT_MAX_BYTES}, the child is SIGKILLed and the capture settles
86
+ * as `code: null` (unreachable) rather than trust a truncated body. A definitive
87
+ * early-exit `signal` SIGTERMs the child the same way, and the timer is the
88
+ * per-peer deadline.
89
+ */
90
+ export declare function captureBoundedStdout(child: CapturableChild, { timeoutMs, signal }: {
91
+ timeoutMs: number;
92
+ signal?: AbortSignal;
93
+ }): Promise<{
94
+ code: number | null;
95
+ stdout: string;
96
+ }>;
65
97
  /** Query explicit hosts, or every registered online peer when hosts is omitted. */
66
98
  export declare function gatherRemoteAgentsJson<T>(options: RemoteAgentsJsonOptions<T>, deps?: GatherRemoteAgentsJsonDeps): Promise<RemoteAgentsJsonResult<T>>;
@@ -9,18 +9,20 @@
9
9
  import { spawn } from 'child_process';
10
10
  import { setMaxListeners } from 'node:events';
11
11
  import chalk from 'chalk';
12
- import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from './ssh-exec.js';
13
- import { sshTargetFor } from './devices/connect.js';
12
+ import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote, REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator, } from './ssh-exec.js';
13
+ import { deviceIdentityArgs, sshTargetFor } from './devices/connect.js';
14
14
  import { resolveExplicitTargets } from './devices/resolve-target.js';
15
15
  import { loadDevices, isControlDevice, isDialableDevice } from './devices/registry.js';
16
- import { remoteShellFor, buildWindowsAgentsCommand } from './hosts/remote-cmd.js';
16
+ import { remoteShellFor, buildWindowsAgentsCommand, stripClixml } from './hosts/remote-cmd.js';
17
17
  import { machineId, normalizeHost } from './machine-id.js';
18
18
  const REMOTE_TIMEOUT_MS = 12_000;
19
19
  export function normalizeRemoteAgentsJsonParse(parsed) {
20
20
  return Array.isArray(parsed) ? { items: parsed, valid: true } : parsed;
21
21
  }
22
22
  export function parseRemoteAgentsJsonPayload(stdout, machine, parse) {
23
- const parsed = normalizeRemoteAgentsJsonParse(parse(stdout, machine));
23
+ // A Windows peer can prefix the JSON with a PowerShell CLIXML banner; strip it
24
+ // before handing stdout to the command-specific parser (RUSH-2286).
25
+ const parsed = normalizeRemoteAgentsJsonParse(parse(stripClixml(stdout), machine));
24
26
  return parsed.valid
25
27
  ? { items: parsed.items, parseFailed: false }
26
28
  : { items: [], parseFailed: true };
@@ -33,16 +35,22 @@ export function remoteAgentsJsonCommand(args, noFanoutEnv, os) {
33
35
  const inner = `${noFanoutEnv}=1 agents ${args.map(shellQuote).join(' ')}`;
34
36
  return `bash -lc ${shellQuote(inner)}`;
35
37
  }
36
- const sshCapture = (target, remoteCmd, { timeoutMs, signal }) => {
37
- assertValidSshTarget(target);
38
+ /**
39
+ * Stream one child's stdout into memory under a hard per-peer byte ceiling.
40
+ *
41
+ * A cross-machine fan-out awaits every peer's capture under one `Promise.all`,
42
+ * so an unbounded buffer means a single runaway peer (a corrupt or
43
+ * pathologically large payload) exhausts the caller's heap and takes the whole
44
+ * sweep down — RUSH-2065. Once the accumulated bytes would exceed
45
+ * {@link REMOTE_STDOUT_MAX_BYTES}, the child is SIGKILLed and the capture settles
46
+ * as `code: null` (unreachable) rather than trust a truncated body. A definitive
47
+ * early-exit `signal` SIGTERMs the child the same way, and the timer is the
48
+ * per-peer deadline.
49
+ */
50
+ export function captureBoundedStdout(child, { timeoutMs, signal }) {
38
51
  return new Promise((resolve) => {
39
- if (signal?.aborted) {
40
- resolve({ code: null, stdout: '' });
41
- return;
42
- }
43
- const args = [...SSH_OPTS, ...controlOpts(), target, remoteCmd];
44
- const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'ignore'] });
45
- let stdout = '';
52
+ const decoded = new RemoteUtf8Accumulator();
53
+ let stdoutBytes = 0;
46
54
  let settled = false;
47
55
  const done = (code) => {
48
56
  if (settled)
@@ -50,7 +58,10 @@ const sshCapture = (target, remoteCmd, { timeoutMs, signal }) => {
50
58
  settled = true;
51
59
  clearTimeout(timer);
52
60
  signal?.removeEventListener('abort', onAbort);
53
- resolve({ code, stdout });
61
+ // A killed/aborted capture never cleanly ends its decoder; return what
62
+ // decoded so far (the caller treats a non-zero code as unreachable and
63
+ // ignores stdout anyway).
64
+ resolve({ code, stdout: code === null ? decoded.current() : decoded.end() });
54
65
  };
55
66
  const timer = setTimeout(() => {
56
67
  child.kill('SIGKILL');
@@ -60,10 +71,28 @@ const sshCapture = (target, remoteCmd, { timeoutMs, signal }) => {
60
71
  // unreachable peer is not left running to its full timeout.
61
72
  const onAbort = () => { child.kill('SIGTERM'); done(null); };
62
73
  signal?.addEventListener('abort', onAbort, { once: true });
63
- child.stdout.on('data', (data) => { stdout += data.toString(); });
74
+ child.stdout?.on('data', (data) => {
75
+ // Over the ceiling, SIGKILL the child and treat it as unreachable rather
76
+ // than trust a partial payload — the RUSH-2065 OOM guard.
77
+ if (stdoutBytes + data.byteLength > REMOTE_STDOUT_MAX_BYTES) {
78
+ child.kill('SIGKILL');
79
+ done(null);
80
+ return;
81
+ }
82
+ stdoutBytes += data.byteLength;
83
+ decoded.write(data);
84
+ });
64
85
  child.on('error', () => done(null));
65
- child.on('close', (code) => done(code));
86
+ child.on('close', (code) => done(code ?? null));
66
87
  });
88
+ }
89
+ const sshCapture = (target, remoteCmd, { timeoutMs, signal, extraSshArgs }) => {
90
+ assertValidSshTarget(target);
91
+ if (signal?.aborted)
92
+ return Promise.resolve({ code: null, stdout: '' });
93
+ const args = [...SSH_OPTS, ...controlOpts(), ...(extraSshArgs ?? []), target, remoteCmd];
94
+ const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'ignore'] });
95
+ return captureBoundedStdout(child, { timeoutMs, signal });
67
96
  };
68
97
  /** Query explicit hosts, or every registered online peer when hosts is omitted. */
69
98
  export async function gatherRemoteAgentsJson(options, deps = {}) {
@@ -102,6 +131,7 @@ export async function gatherRemoteAgentsJson(options, deps = {}) {
102
131
  machine: normalizeHost(device.name),
103
132
  name: device.name,
104
133
  os: device.platform,
134
+ extraSshArgs: deviceIdentityArgs(device),
105
135
  });
106
136
  }
107
137
  catch {
@@ -126,6 +156,7 @@ export async function gatherRemoteAgentsJson(options, deps = {}) {
126
156
  const result = await capture(target.target, command, {
127
157
  timeoutMs: options.timeoutMs ?? REMOTE_TIMEOUT_MS,
128
158
  signal: controller?.signal,
159
+ extraSshArgs: target.extraSshArgs,
129
160
  });
130
161
  // A peer we deliberately cancelled is neither a hit nor a failure — it never
131
162
  // got to answer, so it must not pollute skipped/parseFailed (which drive the
@@ -41,11 +41,10 @@ export function getActiveResourceProfileName() {
41
41
  return readMeta().profiles?.active ?? null;
42
42
  }
43
43
  export function getActiveResourceProfile() {
44
- const meta = readMeta();
45
44
  const name = getActiveResourceProfileName();
46
45
  if (!name)
47
46
  return null;
48
- const preset = meta.profiles?.presets?.[name];
47
+ const preset = readMeta().profiles?.presets?.[name];
49
48
  return preset ? { name, preset } : null;
50
49
  }
51
50
  export function upsertResourceProfilePreset(name, preset) {
@@ -145,6 +145,28 @@ export type AccountReadiness = {
145
145
  * reported while the account is actually serving requests.
146
146
  */
147
147
  export declare function readinessFromCandidate(candidate: RotateCandidate): AccountReadiness;
148
+ /**
149
+ * Whether a human sitting at a terminal can clear this exclusion by launching
150
+ * the agent and signing in. The two unhealthy classes are opposites, and the
151
+ * zero-healthy callers MUST NOT treat them alike:
152
+ *
153
+ * - `signed_out` / `revoked` — recoverable. There is no credential (or the
154
+ * server rejected it), and the harness's own TUI is the login surface, so
155
+ * launching it is exactly the fix. Refusing to launch strands the user with
156
+ * no way to authenticate through agents-cli at all (RUSH-2334).
157
+ * - `rate_limited` / `out_of_credits` — NOT recoverable. The account is signed
158
+ * in and throttled; launching it just hammers an exhausted account, which is
159
+ * precisely the loop RUSH-2132's fail-loud guard exists to stop. Only a
160
+ * window reset clears these.
161
+ */
162
+ export declare function isSignInRecoverable(readiness: AccountReadiness): boolean;
163
+ /**
164
+ * The subset of an `exhausted` set whose exclusion a sign-in would clear, so an
165
+ * interactive caller can offer the login instead of dead-ending. Empty means
166
+ * every account is throttled — nothing a human can fix right now, so the caller
167
+ * keeps failing loud.
168
+ */
169
+ export declare function signInRecoverableCandidates(candidates: RotateCandidate[]): RotateCandidate[];
148
170
  /**
149
171
  * Readiness for a specific installed (agent, version). Returns `{ ready: true }`
150
172
  * when the version isn't among the collected candidates — absence is the
@@ -169,6 +169,32 @@ export function readinessFromCandidate(candidate) {
169
169
  const reason = !snapRateLimited && candidate.usageStatus === 'out_of_credits' ? 'out_of_credits' : 'rate_limited';
170
170
  return { ready: false, reason, email: candidate.email };
171
171
  }
172
+ /**
173
+ * Whether a human sitting at a terminal can clear this exclusion by launching
174
+ * the agent and signing in. The two unhealthy classes are opposites, and the
175
+ * zero-healthy callers MUST NOT treat them alike:
176
+ *
177
+ * - `signed_out` / `revoked` — recoverable. There is no credential (or the
178
+ * server rejected it), and the harness's own TUI is the login surface, so
179
+ * launching it is exactly the fix. Refusing to launch strands the user with
180
+ * no way to authenticate through agents-cli at all (RUSH-2334).
181
+ * - `rate_limited` / `out_of_credits` — NOT recoverable. The account is signed
182
+ * in and throttled; launching it just hammers an exhausted account, which is
183
+ * precisely the loop RUSH-2132's fail-loud guard exists to stop. Only a
184
+ * window reset clears these.
185
+ */
186
+ export function isSignInRecoverable(readiness) {
187
+ return !readiness.ready && (readiness.reason === 'signed_out' || readiness.reason === 'revoked');
188
+ }
189
+ /**
190
+ * The subset of an `exhausted` set whose exclusion a sign-in would clear, so an
191
+ * interactive caller can offer the login instead of dead-ending. Empty means
192
+ * every account is throttled — nothing a human can fix right now, so the caller
193
+ * keeps failing loud.
194
+ */
195
+ export function signInRecoverableCandidates(candidates) {
196
+ return candidates.filter((c) => isSignInRecoverable(readinessFromCandidate(c)));
197
+ }
172
198
  /**
173
199
  * Readiness for a specific installed (agent, version). Returns `{ ready: true }`
174
200
  * when the version isn't among the collected candidates — absence is the
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Owner phone notification on routine FAILURE (RUSH-2288).
3
+ *
4
+ * The desktop lifecycle notifications (routine-notify.ts) never leave the
5
+ * machine, so a failed scheduled routine on a headless fleet box was invisible
6
+ * until someone happened to look. The per-routine prompt pattern (a prompt that
7
+ * ends by shelling `agents notify`) does not close the gap either: when the
8
+ * routine's OWN agent fails to spawn (`auth_failed`) that prompt never runs, so
9
+ * the one failure most worth surfacing is the one that goes unheard.
10
+ *
11
+ * So the DAEMON delivers on failure, from the SAME owner channel stack `agents
12
+ * notify` uses (humans.yaml owner channels, or the legacy `notify.owner`),
13
+ * calling the channel providers IN-PROCESS — no shelling out to `ssh mac-mini
14
+ * agents notify`. When the primary owner channel cannot deliver from this box it
15
+ * walks the remaining configured owner channels as fallbacks (OpenClaw, a second
16
+ * channel, …). Telegram is never used.
17
+ *
18
+ * Only failures notify. A green routine of any kind stays silent — this is an
19
+ * ADDITIONAL failures-only lane, and the desktop thresholds (which still ping a
20
+ * green agent/workflow finish on the local screen) are unchanged. Delivery is
21
+ * deduped per job+runId so a run is announced to the owner at most once.
22
+ *
23
+ * The message BUILDERS (`routineFinishOwnerText` / `routineStartFailedOwnerText`)
24
+ * are pure and unit-tested; `notifyOwnerRoutineFinish` /
25
+ * `notifyOwnerRoutineStartFailed` are the daemon glue that reads config and
26
+ * delivers.
27
+ */
28
+ import type { Meta } from './types.js';
29
+ import type { JobConfig, RunMeta } from './routines.js';
30
+ /**
31
+ * Owner-phone text for a routine FINISH, or null when the run did NOT fail. Only
32
+ * `failed` and `timeout` are failures worth an owner ping — a `completed` run
33
+ * (any kind) is silent, and `running`/`missed` never reach this path from the
34
+ * daemon's finish hook. The text is a short, phone-sized pointer: what failed,
35
+ * why, and on which box (so the owner knows where to look), well under the
36
+ * `user-message-guard` length ceiling.
37
+ */
38
+ export declare function routineFinishOwnerText(meta: Pick<RunMeta, 'jobName' | 'status' | 'exitCode' | 'errorMessage' | 'agent' | 'workflow' | 'command'>, host: string): string | null;
39
+ /**
40
+ * Owner-phone text for a routine that failed to even START (the daemon's
41
+ * pre-spawn catch — `executeJobDetached` threw before a child existed, e.g.
42
+ * `auth_failed`). Never null: a broken start is always a failure worth the ping,
43
+ * for every routine kind, and it is exactly the case the per-routine `agents
44
+ * notify` prompt can never cover because that prompt never ran.
45
+ */
46
+ export declare function routineStartFailedOwnerText(config: Pick<JobConfig, 'name' | 'agent' | 'workflow' | 'command'>, error: string, host: string): string;
47
+ /** One resolved owner destination to attempt, in plan order. */
48
+ export interface OwnerDest {
49
+ channel: string;
50
+ to: string;
51
+ }
52
+ /**
53
+ * Ordered owner delivery plan for a failure ping: the primary owner destination
54
+ * (the same one `agents notify` resolves) first, then every other configured
55
+ * owner channel as a fallback. Deduped by (channel, to). Telegram channels and
56
+ * intrusive channels (a voice call is too much for a routine failure) are
57
+ * excluded entirely — so an owner whose ONLY channel is Telegram gets an empty
58
+ * plan and no ping, which is the intended "silence beats Telegram" outcome.
59
+ *
60
+ * The same filters apply to the primary and the fallback list — a policy that
61
+ * points normal severity at a voice channel must not auto-call the owner on
62
+ * every routine failure.
63
+ */
64
+ export declare function ownerFailureDeliveryPlan(meta: Meta): OwnerDest[];
65
+ /** Outcome of one delivery attempt, for logging/telemetry. */
66
+ export interface OwnerDeliveryAttempt {
67
+ channel: string;
68
+ ok: boolean;
69
+ error?: string;
70
+ }
71
+ export interface OwnerDeliveryResult {
72
+ /** True once any channel in the plan accepted the message. */
73
+ delivered: boolean;
74
+ /** The channel that delivered, when `delivered`. */
75
+ channel?: string;
76
+ /** Every attempt made, in order — empty when no owner channel is configured. */
77
+ attempts: OwnerDeliveryAttempt[];
78
+ }
79
+ /**
80
+ * Deliver `text` to the owner over the failure plan, in-process, trying each
81
+ * channel until one accepts. Uses the non-dying `lookupTransport` (never
82
+ * `resolveTransport`, which `die()`s the process) so a daemon survives a bad
83
+ * channel name. Best-effort by contract: the daemon wraps this in try/catch, but
84
+ * it also never throws for a delivery failure — it returns the attempt log.
85
+ */
86
+ export declare function deliverOwnerFailure(text: string, meta: Meta): Promise<OwnerDeliveryResult>;
87
+ /** Test-only: reset the dedup set so cases don't leak state into each other. */
88
+ export declare function __resetOwnerFailureDedup(): void;
89
+ /**
90
+ * Daemon glue: on a routine FINISH, ping the owner IFF the run failed. Green
91
+ * runs return early (no ping). Deduped per job+runId — claimed for the
92
+ * in-flight window so two finish hooks don't double-text, released when no
93
+ * channel accepted so a later sweep can retry. Best-effort.
94
+ */
95
+ export declare function notifyOwnerRoutineFinish(meta: RunMeta): Promise<OwnerDeliveryResult>;
96
+ /**
97
+ * Daemon glue: on a pre-spawn failure (no run record, so no finish hook and no
98
+ * runId), ping the owner. Not deduped — a pre-spawn failure is one scheduled
99
+ * fire on the cron cadence (never a per-second storm), and each such fire is a
100
+ * distinct failure the owner should hear about. Best-effort.
101
+ */
102
+ export declare function notifyOwnerRoutineStartFailed(config: JobConfig, error: string): Promise<OwnerDeliveryResult>;