@phnx-labs/agents-cli 1.22.26 → 1.22.27

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 (131) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/README.md +18 -1
  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 +17 -16
  8. package/dist/commands/insights.js +39 -5
  9. package/dist/commands/output.js +100 -25
  10. package/dist/commands/resume.d.ts +11 -0
  11. package/dist/commands/resume.js +51 -0
  12. package/dist/commands/sessions-picker.js +12 -7
  13. package/dist/commands/sessions.d.ts +47 -1
  14. package/dist/commands/sessions.js +345 -37
  15. package/dist/commands/ssh.js +184 -11
  16. package/dist/commands/teams.js +3 -3
  17. package/dist/index.js +16 -20
  18. package/dist/lib/auth-health.d.ts +47 -3
  19. package/dist/lib/auth-health.js +74 -15
  20. package/dist/lib/bench/index.d.ts +4 -0
  21. package/dist/lib/bench/index.js +4 -0
  22. package/dist/lib/bench/runner.d.ts +16 -0
  23. package/dist/lib/bench/runner.js +111 -0
  24. package/dist/lib/bench/schema.d.ts +5 -0
  25. package/dist/lib/bench/schema.js +91 -0
  26. package/dist/lib/bench/storage.d.ts +5 -0
  27. package/dist/lib/bench/storage.js +32 -0
  28. package/dist/lib/bench/types.d.ts +40 -0
  29. package/dist/lib/bench/types.js +1 -0
  30. package/dist/lib/claude-account-token.d.ts +2 -0
  31. package/dist/lib/claude-account-token.js +27 -1
  32. package/dist/lib/crabbox/cli.d.ts +2 -0
  33. package/dist/lib/crabbox/cli.js +2 -0
  34. package/dist/lib/crabbox/lease.js +7 -1
  35. package/dist/lib/daemon.js +20 -0
  36. package/dist/lib/devices/connect.d.ts +2 -0
  37. package/dist/lib/devices/connect.js +7 -0
  38. package/dist/lib/devices/doctor-findings.d.ts +4 -1
  39. package/dist/lib/devices/doctor-findings.js +15 -1
  40. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  41. package/dist/lib/devices/harness-inventory.js +0 -0
  42. package/dist/lib/devices/registry.d.ts +2 -0
  43. package/dist/lib/devices/ssh-config.js +3 -0
  44. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  45. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  46. package/dist/lib/exec.bench.d.ts +1 -0
  47. package/dist/lib/exec.bench.js +186 -0
  48. package/dist/lib/exec.js +18 -8
  49. package/dist/lib/fleet/apply.js +9 -7
  50. package/dist/lib/fleet/remote-login.d.ts +4 -3
  51. package/dist/lib/fleet/remote-login.js +11 -9
  52. package/dist/lib/gemini-settings.d.ts +0 -1
  53. package/dist/lib/gemini-settings.js +12 -7
  54. package/dist/lib/hooks.d.ts +8 -5
  55. package/dist/lib/hooks.js +10 -6
  56. package/dist/lib/hosts/dispatch.js +7 -3
  57. package/dist/lib/hosts/passthrough.d.ts +22 -0
  58. package/dist/lib/hosts/passthrough.js +14 -8
  59. package/dist/lib/hosts/progress.d.ts +2 -1
  60. package/dist/lib/hosts/progress.js +4 -3
  61. package/dist/lib/hosts/providers/devices.js +1 -0
  62. package/dist/lib/hosts/ready.d.ts +4 -2
  63. package/dist/lib/hosts/ready.js +24 -8
  64. package/dist/lib/hosts/reconcile.d.ts +1 -1
  65. package/dist/lib/hosts/reconcile.js +16 -7
  66. package/dist/lib/hosts/reconnect.d.ts +45 -12
  67. package/dist/lib/hosts/reconnect.js +90 -34
  68. package/dist/lib/hosts/registry.d.ts +2 -2
  69. package/dist/lib/hosts/registry.js +3 -5
  70. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  71. package/dist/lib/hosts/remote-cmd.js +29 -0
  72. package/dist/lib/hosts/tasks.d.ts +2 -0
  73. package/dist/lib/hosts/types.d.ts +1 -0
  74. package/dist/lib/hosts/types.js +3 -0
  75. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  76. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  77. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  78. package/dist/lib/menubar/install-menubar.js +20 -9
  79. package/dist/lib/models.d.ts +30 -1
  80. package/dist/lib/models.js +42 -144
  81. package/dist/lib/pricing/cost.d.ts +9 -0
  82. package/dist/lib/pricing/cost.js +24 -0
  83. package/dist/lib/pricing/index.d.ts +1 -1
  84. package/dist/lib/pricing/index.js +1 -1
  85. package/dist/lib/redact.js +8 -3
  86. package/dist/lib/remote-agents-json.d.ts +32 -0
  87. package/dist/lib/remote-agents-json.js +47 -16
  88. package/dist/lib/resource-profiles.js +1 -2
  89. package/dist/lib/routine-notify-owner.d.ts +102 -0
  90. package/dist/lib/routine-notify-owner.js +232 -0
  91. package/dist/lib/routines.d.ts +10 -0
  92. package/dist/lib/runner.d.ts +9 -1
  93. package/dist/lib/runner.js +182 -17
  94. package/dist/lib/sandbox.d.ts +0 -2
  95. package/dist/lib/sandbox.js +2 -19
  96. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  97. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  98. package/dist/lib/secrets/agent.js +3 -2
  99. package/dist/lib/secrets/lease.d.ts +25 -0
  100. package/dist/lib/secrets/lease.js +44 -0
  101. package/dist/lib/self-update.d.ts +20 -5
  102. package/dist/lib/self-update.js +93 -16
  103. package/dist/lib/session/db.d.ts +19 -2
  104. package/dist/lib/session/db.js +54 -5
  105. package/dist/lib/session/discover.d.ts +21 -1
  106. package/dist/lib/session/discover.js +124 -32
  107. package/dist/lib/session/insights.d.ts +19 -0
  108. package/dist/lib/session/insights.js +78 -10
  109. package/dist/lib/session/recovery.d.ts +17 -1
  110. package/dist/lib/session/recovery.js +111 -4
  111. package/dist/lib/session/remote-list.d.ts +10 -9
  112. package/dist/lib/session/remote-list.js +25 -23
  113. package/dist/lib/session/resume-owner.d.ts +55 -0
  114. package/dist/lib/session/resume-owner.js +69 -0
  115. package/dist/lib/session/team-filter.d.ts +65 -0
  116. package/dist/lib/session/team-filter.js +98 -3
  117. package/dist/lib/session/types.d.ts +33 -0
  118. package/dist/lib/smart-launch.d.ts +31 -5
  119. package/dist/lib/smart-launch.js +43 -7
  120. package/dist/lib/ssh-exec.d.ts +27 -0
  121. package/dist/lib/ssh-exec.js +34 -1
  122. package/dist/lib/ssh-tunnel.d.ts +3 -2
  123. package/dist/lib/ssh-tunnel.js +25 -16
  124. package/dist/lib/startup/command-registry.d.ts +1 -0
  125. package/dist/lib/startup/command-registry.js +2 -0
  126. package/dist/lib/teams/placement-probe.d.ts +1 -1
  127. package/dist/lib/teams/placement-probe.js +24 -24
  128. package/dist/lib/tmux/session.d.ts +8 -0
  129. package/dist/lib/tmux/session.js +22 -0
  130. package/dist/lib/types.d.ts +2 -0
  131. package/package.json +1 -1
@@ -112,51 +112,8 @@ export function locateModelSource(agent, version) {
112
112
  }
113
113
  return null;
114
114
  }
115
- if (agent === 'gemini') {
116
- // <=0.41 shipped a clean ES module at @google/gemini-cli-core/dist/src/config/models.js.
117
- // 0.42+ inlines the same constants into a minified chunk under @google/gemini-cli/bundle/.
118
- const modelsJs = path.join(versionDir, 'node_modules', '@google', 'gemini-cli-core', 'dist', 'src', 'config', 'models.js');
119
- if (fs.existsSync(modelsJs))
120
- return { path: modelsJs, kind: 'js' };
121
- const bundleDir = path.join(versionDir, 'node_modules', '@google', 'gemini-cli', 'bundle');
122
- if (fs.existsSync(bundleDir)) {
123
- try {
124
- const entries = fs.readdirSync(bundleDir);
125
- // The model constants live in a single chunk; pick the first .js file
126
- // whose contents contain VALID_GEMINI_MODELS. Skip subdirectories.
127
- for (const name of entries) {
128
- if (!name.endsWith('.js'))
129
- continue;
130
- const full = path.join(bundleDir, name);
131
- let stat;
132
- try {
133
- stat = fs.statSync(full);
134
- }
135
- catch {
136
- continue;
137
- }
138
- if (!stat.isFile())
139
- continue;
140
- // Most chunks just re-export the constants; only the defining chunk
141
- // actually has `var VALID_GEMINI_MODELS = ... new Set(`. Match that
142
- // shape so we pick the chunk that the extractor can parse.
143
- let body;
144
- try {
145
- body = fs.readFileSync(full, 'utf-8');
146
- }
147
- catch {
148
- continue;
149
- }
150
- if (/var\s+VALID_GEMINI_MODELS\s*=[^\n]*new\s+Set/.test(body) &&
151
- /var\s+DEFAULT_GEMINI_MODEL\s*=/.test(body)) {
152
- return { path: full, kind: 'js' };
153
- }
154
- }
155
- }
156
- catch { /* unreadable bundle dir */ }
157
- }
158
- return null;
159
- }
115
+ // gemini is hard-deprecated (no launch path left to validate a model
116
+ // against), so its bundle is deliberately not parsed for a catalog here.
160
117
  if (agent === 'opencode') {
161
118
  // The `opencode` shim under node_modules/.bin dispatches to a platform-
162
119
  // specific native binary. We don't parse the 100MB binary; we let the CLI
@@ -345,6 +302,45 @@ export function dropBareLegacyIds(ids) {
345
302
  return !(bareMajor && ids.some((o) => o !== id && o.startsWith(`${id}-`)));
346
303
  });
347
304
  }
305
+ /**
306
+ * Scan raw binary/bundle text for canonical Claude model ids, then drop bare
307
+ * legacy prefixes (issue #1892). Two independent guards keep non-model strings
308
+ * out of the catalog:
309
+ *
310
+ * - **Word-boundary anchors on the id regex.** The id must not be glued to a
311
+ * surrounding identifier character, and must not be the truncated prefix of a
312
+ * longer *version* token. `(?<![A-Za-z0-9_])` rejects a glued prefix;
313
+ * `(?![A-Za-z0-9])` rejects a glued alnum suffix; `(?!\.\d)` rejects a
314
+ * dotted-version continuation — so the bare-major prefix of the binary's own
315
+ * "Typo in model ID" troubleshooting string `claude-sonnet-4.6` is not
316
+ * scraped as `claude-sonnet-4`, while a real id followed by an unrelated `.`
317
+ * suffix (`claude-fable-5.md`) still matches. Dash-separated segments only:
318
+ * the dotted form never appears in a genuine id.
319
+ *
320
+ * The id body is captured inside a lookahead (`(?=(...))\1`) so the greedy
321
+ * `-\d+` run matches **atomically**: without it, a suffix-glued token like
322
+ * `claude-opus-4-1x` would fail the trailing anchor on the full match, then
323
+ * backtrack a segment and re-emit the bare `claude-opus-4` — the exact 404-able
324
+ * id this scan exists to suppress (two packed strings can end up glued with no
325
+ * separator in the extracted binary text). The atomic match fails outright
326
+ * instead of degrading to the bare form.
327
+ * - **`dropBareLegacyIds`.** A standalone `.includes("claude-opus-4")` prefix
328
+ * check is a fully delimited string the anchors cannot tell apart from a real
329
+ * bare id, so a bare `claude-<family>-<major>` is dropped only when a
330
+ * more-specific sibling (`claude-opus-4-8`) is also present; a genuinely bare
331
+ * current id with no sibling (`claude-sonnet-5`) is kept.
332
+ */
333
+ export function scanClaudeCatalogIds(text) {
334
+ const idRe = /(?<![A-Za-z0-9_])(?=(claude-(?:opus|sonnet|haiku|fable|mythos)-\d+(?:-\d+)*(?:-(?:fast|v\d+))?))\1(?![A-Za-z0-9])(?!\.\d)/g;
335
+ const scanned = new Set();
336
+ let sm;
337
+ while ((sm = idRe.exec(text)) !== null) {
338
+ scanned.add(sm[0]);
339
+ if (sm.index === idRe.lastIndex)
340
+ idRe.lastIndex++;
341
+ }
342
+ return dropBareLegacyIds([...scanned]);
343
+ }
348
344
  function extractClaudeCatalog(text) {
349
345
  const aliases = {};
350
346
  const aliasMapMatch = text.match(/\{opus:"(claude-[^"]+)",sonnet:"(claude-[^"]+)",haiku:"(claude-[^"]+)"\}/);
@@ -406,16 +402,7 @@ function extractClaudeCatalog(text) {
406
402
  // catalog while a newer one still gets a real catalog (incl. fable/mythos and
407
403
  // the opus-5/sonnet-5 line) rather than an empty or single-model one.
408
404
  if (models.length < 2) {
409
- const scanned = new Set();
410
- // Dash-separated segments only. Real ids are `claude-sonnet-4-6`; the dotted
411
- // form `claude-sonnet-4.6` appears only inside the binary's own "Typo in
412
- // model ID" troubleshooting text, so a `.`-permitting pattern would scrape a
413
- // non-model string as if it were real.
414
- const idRe = /claude-(?:opus|sonnet|haiku|fable|mythos)-\d+(?:-\d+)*(?:-(?:fast|v\d+))?/g;
415
- let sm;
416
- while ((sm = idRe.exec(text)) !== null)
417
- scanned.add(sm[0]);
418
- const filtered = dropBareLegacyIds([...scanned]);
405
+ const filtered = scanClaudeCatalogIds(text);
419
406
  if (filtered.length >= 2)
420
407
  models = build(filtered);
421
408
  }
@@ -460,85 +447,6 @@ function extractCodexCatalog(text) {
460
447
  }
461
448
  return { models, aliases: {} };
462
449
  }
463
- /**
464
- * Extract Gemini's model catalog from `@google/gemini-cli-core/.../config/models.js`.
465
- *
466
- * The module exports a set of named constants (e.g. `DEFAULT_GEMINI_MODEL`,
467
- * `PREVIEW_GEMINI_FLASH_MODEL`) plus a `VALID_GEMINI_MODELS` Set and a handful
468
- * of `GEMINI_MODEL_ALIAS_*` strings. We parse it with regex (a JS-module import
469
- * would pollute the runtime and ES-module interop is awkward from a CJS build).
470
- */
471
- function extractGeminiCatalog(text) {
472
- // Old (<=0.41) layout used `export const FOO = 'bar';` in models.js.
473
- // New (0.42+) bundle inlines the same constants as `var FOO = "bar";`.
474
- const constRe = /(?:export\s+const|var)\s+([A-Z0-9_]+)\s*=\s*['"]([^'"]+)['"]/g;
475
- const constants = new Map();
476
- let m;
477
- while ((m = constRe.exec(text)) !== null) {
478
- constants.set(m[1], m[2]);
479
- }
480
- // The set of ids the CLI accepts as "valid model names". Names (not values)
481
- // are listed inside `new Set([...])`, so we expand them via the constants map.
482
- const validIds = new Set();
483
- const setBlock = text.match(/VALID_GEMINI_MODELS\s*=\s*(?:\/\*[^*]*\*\/\s*)?new\s+Set\(\[([\s\S]*?)\]\)/);
484
- if (setBlock) {
485
- const nameRe = /([A-Z0-9_]+)/g;
486
- let nm;
487
- while ((nm = nameRe.exec(setBlock[1])) !== null) {
488
- const id = constants.get(nm[1]);
489
- if (id)
490
- validIds.add(id);
491
- }
492
- }
493
- // Fall back to any gemini-shaped id we saw in the constants map -- useful
494
- // when the Set shape changes across gemini versions.
495
- if (validIds.size === 0) {
496
- for (const [name, value] of constants) {
497
- if (/^(DEFAULT_|PREVIEW_)/.test(name) && /^gemini-/.test(value)) {
498
- validIds.add(value);
499
- }
500
- }
501
- }
502
- // Aliases are exported as `GEMINI_MODEL_ALIAS_FLASH = 'flash'` etc. The alias
503
- // is the *value*; the target model is resolved at runtime by `resolveModel()`.
504
- // We replicate that logic here so callers get a concrete id per alias.
505
- const defaultId = constants.get('DEFAULT_GEMINI_MODEL');
506
- const previewPro = constants.get('PREVIEW_GEMINI_MODEL');
507
- const previewFlash = constants.get('PREVIEW_GEMINI_FLASH_MODEL');
508
- const flashLite = constants.get('DEFAULT_GEMINI_FLASH_LITE_MODEL');
509
- const aliases = {};
510
- if (previewPro) {
511
- aliases.auto = previewPro;
512
- aliases.pro = previewPro;
513
- }
514
- if (previewFlash)
515
- aliases.flash = previewFlash;
516
- if (flashLite)
517
- aliases['flash-lite'] = flashLite;
518
- const aliasReverse = {};
519
- for (const [alias, id] of Object.entries(aliases)) {
520
- (aliasReverse[id] ||= []).push(alias);
521
- }
522
- const defaults = new Set();
523
- if (defaultId)
524
- defaults.add(defaultId);
525
- if (previewPro)
526
- defaults.add(previewPro); // auto/pro alias resolves here
527
- const displayNameFor = (id) => {
528
- // Gemini has a `getDisplayString` for some aliases but the canonical id
529
- // is human-readable enough ("gemini-3-pro-preview") -- no separate map.
530
- return undefined;
531
- };
532
- const models = Array.from(validIds)
533
- .sort()
534
- .map((id) => ({
535
- id,
536
- displayName: displayNameFor(id),
537
- alias: aliasReverse[id]?.[0],
538
- isDefault: defaults.has(id),
539
- }));
540
- return { models, aliases };
541
- }
542
450
  /**
543
451
  * Extract OpenCode's catalog by invoking `opencode models --verbose`. The
544
452
  * output is a sequence of `<provider>/<id>\n{json}` blocks -- we parse every
@@ -1042,16 +950,6 @@ export function getModelCatalog(agent, version) {
1042
950
  : agent === 'codex' ? extractCodexCatalog(text)
1043
951
  : { models: [], aliases: {} });
1044
952
  }
1045
- else if (src.kind === 'js') {
1046
- try {
1047
- const text = fs.readFileSync(src.path, 'utf-8');
1048
- if (agent === 'gemini')
1049
- ({ models, aliases } = extractGeminiCatalog(text));
1050
- }
1051
- catch {
1052
- /* unreadable */
1053
- }
1054
- }
1055
953
  else if (src.kind === 'cli') {
1056
954
  if (agent === 'opencode')
1057
955
  ({ models, aliases } = extractOpenCodeCatalog(src.path));
@@ -14,6 +14,15 @@ export interface TokenUsage {
14
14
  * convention for models that don't publish a separate cache price).
15
15
  */
16
16
  export declare function costOfUsage(u: TokenUsage): number;
17
+ /**
18
+ * USD cost of one usage record priced as if caching were OFF: cache-read and
19
+ * cache-write tokens are billed at the model's full INPUT rate rather than their
20
+ * discounted cache rates. This is the "what would this have cost with no prompt
21
+ * caching?" scenario that `agents output --pricing no-cache` models. Output and
22
+ * uncached input are unchanged — only the cache tokens are repriced. Returns 0
23
+ * for a missing/unpriced model, exactly like {@link costOfUsage}.
24
+ */
25
+ export declare function costOfUsageNoCache(u: TokenUsage): number;
17
26
  /** Sum the USD cost of every usage record in a session. */
18
27
  export declare function costOfSession(usages: TokenUsage[]): number;
19
28
  /**
@@ -31,6 +31,30 @@ export function costOfUsage(u) {
31
31
  cacheRead * cacheReadRate +
32
32
  cacheWrite * cacheWriteRate);
33
33
  }
34
+ /**
35
+ * USD cost of one usage record priced as if caching were OFF: cache-read and
36
+ * cache-write tokens are billed at the model's full INPUT rate rather than their
37
+ * discounted cache rates. This is the "what would this have cost with no prompt
38
+ * caching?" scenario that `agents output --pricing no-cache` models. Output and
39
+ * uncached input are unchanged — only the cache tokens are repriced. Returns 0
40
+ * for a missing/unpriced model, exactly like {@link costOfUsage}.
41
+ */
42
+ export function costOfUsageNoCache(u) {
43
+ if (!u.model)
44
+ return 0;
45
+ const pricing = getModelPricing(u.model);
46
+ if (!pricing)
47
+ return 0;
48
+ const input = u.inputTokens ?? 0;
49
+ const output = u.outputTokens ?? 0;
50
+ const cacheRead = u.cacheReadTokens ?? 0;
51
+ const cacheWrite = u.cacheCreationTokens ?? 0;
52
+ // Cache tokens repriced at the input rate — no caching discount applied.
53
+ return (input * pricing.inputPerToken +
54
+ output * pricing.outputPerToken +
55
+ cacheRead * pricing.inputPerToken +
56
+ cacheWrite * pricing.inputPerToken);
57
+ }
34
58
  /** Sum the USD cost of every usage record in a session. */
35
59
  export function costOfSession(usages) {
36
60
  let total = 0;
@@ -5,4 +5,4 @@
5
5
  * enforcement) imports against — keep it stable.
6
6
  */
7
7
  export { type ModelPricing, PRICING_VERSION, getModelPricing, listPricedModels, } from './table.js';
8
- export { type TokenUsage, costOfUsage, costOfSession, formatUsd, estimateCost, actualCost, isModelPriced, } from './cost.js';
8
+ export { type TokenUsage, costOfUsage, costOfUsageNoCache, costOfSession, formatUsd, estimateCost, actualCost, isModelPriced, } from './cost.js';
@@ -5,4 +5,4 @@
5
5
  * enforcement) imports against — keep it stable.
6
6
  */
7
7
  export { PRICING_VERSION, getModelPricing, listPricedModels, } from './table.js';
8
- export { costOfUsage, costOfSession, formatUsd, estimateCost, actualCost, isModelPriced, } from './cost.js';
8
+ export { costOfUsage, costOfUsageNoCache, costOfSession, formatUsd, estimateCost, actualCost, isModelPriced, } from './cost.js';
@@ -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]'],
@@ -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) {
@@ -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>;