@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
@@ -43,7 +43,7 @@ export interface ModelCatalog {
43
43
  aliases: Record<string, string>;
44
44
  }
45
45
  /** How the model catalog source was obtained. */
46
- export type ModelSourceKind = 'bundle' | 'binary' | 'js' | 'cli';
46
+ export type ModelSourceKind = 'bundle' | 'binary' | 'cli';
47
47
  /** Describes the location and extraction strategy for a model catalog source. */
48
48
  export interface ModelSource {
49
49
  path: string;
@@ -69,13 +69,49 @@ export declare function locateModelSource(agent: AgentId, version: string): Mode
69
69
  * - constants: {OPUS_ID:"...",OPUS_NAME:"...",SONNET_ID:"...",...}
70
70
  */
71
71
  /**
72
- * Drop a bare `claude-<family>-<major>` (e.g. `claude-opus-4`) when a more specific
73
- * sibling (`claude-opus-4-8`) is present. The bare form is only ever an internal
74
- * `.includes("claude-opus-4")` prefix-check string in the binary, not a submittable
75
- * id (issue #1892); a bare id with no sibling (e.g. `claude-sonnet-5`) is a real
76
- * current model and is kept.
72
+ * Drop a catalog id that is a proper dash-boundary prefix of a more-specific
73
+ * sibling also present in the list (e.g. `claude-opus-4` when `claude-opus-4-8`
74
+ * is present, or `claude-opus-4-1` when `claude-opus-4-1-20250805` is present).
75
+ *
76
+ * Two sources produce these prefix forms in the native binary:
77
+ * - standalone `.includes("claude-opus-4")` prefix-check strings (#1892)
78
+ * - per-cloud metadata field values such as `foundry:"claude-opus-4-1"` next
79
+ * to a real firstParty id `claude-opus-4-1-20250805` (#2233)
80
+ *
81
+ * A genuine bare current id with no longer sibling (e.g. `claude-sonnet-5`) is
82
+ * kept. The dash boundary (`startsWith(id + '-')`) avoids collapsing
83
+ * `claude-opus-4-1` into `claude-opus-4-10`.
77
84
  */
78
85
  export declare function dropBareLegacyIds(ids: string[]): string[];
86
+ /**
87
+ * Scan raw binary/bundle text for canonical Claude model ids, then drop bare
88
+ * legacy / cloud-metadata prefixes (#1892, #2233). Two independent guards keep
89
+ * non-model strings out of the catalog:
90
+ *
91
+ * - **Word-boundary anchors on the id regex.** The id must not be glued to a
92
+ * surrounding identifier character, and must not be the truncated prefix of a
93
+ * longer *version* token. `(?<![A-Za-z0-9_])` rejects a glued prefix;
94
+ * `(?![A-Za-z0-9])` rejects a glued alnum suffix; `(?!\.\d)` rejects a
95
+ * dotted-version continuation — so the bare-major prefix of the binary's own
96
+ * "Typo in model ID" troubleshooting string `claude-sonnet-4.6` is not
97
+ * scraped as `claude-sonnet-4`, while a real id followed by an unrelated `.`
98
+ * suffix (`claude-fable-5.md`) still matches. Dash-separated segments only:
99
+ * the dotted form never appears in a genuine id.
100
+ *
101
+ * The id body is captured inside a lookahead (`(?=(...))\1`) so the greedy
102
+ * `-\d+` run matches **atomically**: without it, a suffix-glued token like
103
+ * `claude-opus-4-1x` would fail the trailing anchor on the full match, then
104
+ * backtrack a segment and re-emit the bare `claude-opus-4` — the exact 404-able
105
+ * id this scan exists to suppress (two packed strings can end up glued with no
106
+ * separator in the extracted binary text). The atomic match fails outright
107
+ * instead of degrading to the bare form.
108
+ * - **`dropBareLegacyIds`.** A fully delimited string the anchors cannot tell
109
+ * apart from a real id — whether a bare major (`.includes("claude-opus-4")`)
110
+ * or a bare-minor per-cloud field value (`foundry:"claude-opus-4-1"`) — is
111
+ * dropped when a more-specific sibling is also present; a genuinely bare
112
+ * current id with no sibling (`claude-sonnet-5`) is kept.
113
+ */
114
+ export declare function scanClaudeCatalogIds(text: string): string[];
79
115
  /**
80
116
  * Parse `grok models` stdout into a catalog. Exported for unit tests.
81
117
  *
@@ -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
@@ -333,17 +290,60 @@ function extractStrings(filePath, minLen = 6) {
333
290
  * - constants: {OPUS_ID:"...",OPUS_NAME:"...",SONNET_ID:"...",...}
334
291
  */
335
292
  /**
336
- * Drop a bare `claude-<family>-<major>` (e.g. `claude-opus-4`) when a more specific
337
- * sibling (`claude-opus-4-8`) is present. The bare form is only ever an internal
338
- * `.includes("claude-opus-4")` prefix-check string in the binary, not a submittable
339
- * id (issue #1892); a bare id with no sibling (e.g. `claude-sonnet-5`) is a real
340
- * current model and is kept.
293
+ * Drop a catalog id that is a proper dash-boundary prefix of a more-specific
294
+ * sibling also present in the list (e.g. `claude-opus-4` when `claude-opus-4-8`
295
+ * is present, or `claude-opus-4-1` when `claude-opus-4-1-20250805` is present).
296
+ *
297
+ * Two sources produce these prefix forms in the native binary:
298
+ * - standalone `.includes("claude-opus-4")` prefix-check strings (#1892)
299
+ * - per-cloud metadata field values such as `foundry:"claude-opus-4-1"` next
300
+ * to a real firstParty id `claude-opus-4-1-20250805` (#2233)
301
+ *
302
+ * A genuine bare current id with no longer sibling (e.g. `claude-sonnet-5`) is
303
+ * kept. The dash boundary (`startsWith(id + '-')`) avoids collapsing
304
+ * `claude-opus-4-1` into `claude-opus-4-10`.
341
305
  */
342
306
  export function dropBareLegacyIds(ids) {
343
- return ids.filter((id) => {
344
- const bareMajor = /^claude-[a-z]+-\d+$/.test(id);
345
- return !(bareMajor && ids.some((o) => o !== id && o.startsWith(`${id}-`)));
346
- });
307
+ return ids.filter((id) => !ids.some((other) => other !== id && other.startsWith(`${id}-`)));
308
+ }
309
+ /**
310
+ * Scan raw binary/bundle text for canonical Claude model ids, then drop bare
311
+ * legacy / cloud-metadata prefixes (#1892, #2233). Two independent guards keep
312
+ * non-model strings out of the catalog:
313
+ *
314
+ * - **Word-boundary anchors on the id regex.** The id must not be glued to a
315
+ * surrounding identifier character, and must not be the truncated prefix of a
316
+ * longer *version* token. `(?<![A-Za-z0-9_])` rejects a glued prefix;
317
+ * `(?![A-Za-z0-9])` rejects a glued alnum suffix; `(?!\.\d)` rejects a
318
+ * dotted-version continuation — so the bare-major prefix of the binary's own
319
+ * "Typo in model ID" troubleshooting string `claude-sonnet-4.6` is not
320
+ * scraped as `claude-sonnet-4`, while a real id followed by an unrelated `.`
321
+ * suffix (`claude-fable-5.md`) still matches. Dash-separated segments only:
322
+ * the dotted form never appears in a genuine id.
323
+ *
324
+ * The id body is captured inside a lookahead (`(?=(...))\1`) so the greedy
325
+ * `-\d+` run matches **atomically**: without it, a suffix-glued token like
326
+ * `claude-opus-4-1x` would fail the trailing anchor on the full match, then
327
+ * backtrack a segment and re-emit the bare `claude-opus-4` — the exact 404-able
328
+ * id this scan exists to suppress (two packed strings can end up glued with no
329
+ * separator in the extracted binary text). The atomic match fails outright
330
+ * instead of degrading to the bare form.
331
+ * - **`dropBareLegacyIds`.** A fully delimited string the anchors cannot tell
332
+ * apart from a real id — whether a bare major (`.includes("claude-opus-4")`)
333
+ * or a bare-minor per-cloud field value (`foundry:"claude-opus-4-1"`) — is
334
+ * dropped when a more-specific sibling is also present; a genuinely bare
335
+ * current id with no sibling (`claude-sonnet-5`) is kept.
336
+ */
337
+ export function scanClaudeCatalogIds(text) {
338
+ const idRe = /(?<![A-Za-z0-9_])(?=(claude-(?:opus|sonnet|haiku|fable|mythos)-\d+(?:-\d+)*(?:-(?:fast|v\d+))?))\1(?![A-Za-z0-9])(?!\.\d)/g;
339
+ const scanned = new Set();
340
+ let sm;
341
+ while ((sm = idRe.exec(text)) !== null) {
342
+ scanned.add(sm[0]);
343
+ if (sm.index === idRe.lastIndex)
344
+ idRe.lastIndex++;
345
+ }
346
+ return dropBareLegacyIds([...scanned]);
347
347
  }
348
348
  function extractClaudeCatalog(text) {
349
349
  const aliases = {};
@@ -406,16 +406,7 @@ function extractClaudeCatalog(text) {
406
406
  // catalog while a newer one still gets a real catalog (incl. fable/mythos and
407
407
  // the opus-5/sonnet-5 line) rather than an empty or single-model one.
408
408
  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]);
409
+ const filtered = scanClaudeCatalogIds(text);
419
410
  if (filtered.length >= 2)
420
411
  models = build(filtered);
421
412
  }
@@ -460,85 +451,6 @@ function extractCodexCatalog(text) {
460
451
  }
461
452
  return { models, aliases: {} };
462
453
  }
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
454
  /**
543
455
  * Extract OpenCode's catalog by invoking `opencode models --verbose`. The
544
456
  * output is a sequence of `<provider>/<id>\n{json}` blocks -- we parse every
@@ -1042,16 +954,6 @@ export function getModelCatalog(agent, version) {
1042
954
  : agent === 'codex' ? extractCodexCatalog(text)
1043
955
  : { models: [], aliases: {} });
1044
956
  }
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
957
  else if (src.kind === 'cli') {
1056
958
  if (agent === 'opencode')
1057
959
  ({ models, aliases } = extractOpenCodeCatalog(src.path));
@@ -244,7 +244,7 @@ export function aggregateSamples(opts = {}) {
244
244
  const key = `${r.kind}\0${r.label}`;
245
245
  let b = map.get(key);
246
246
  if (!b) {
247
- b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0, timeouts: 0 };
247
+ b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0, blocks: 0, timeouts: 0 };
248
248
  map.set(key, b);
249
249
  }
250
250
  b.durations.push(Number(r.duration_ms));
@@ -254,10 +254,19 @@ export function aggregateSamples(opts = {}) {
254
254
  b.stale++;
255
255
  else if (r.cache === 'miss' || r.cache === 'none')
256
256
  b.misses++;
257
+ // Exit classes (Claude/Codex PreToolUse convention):
258
+ // 0 → allowed
259
+ // 2 → intentional deny/block (not a crash)
260
+ // 1 / other nonzero → real error
261
+ // Timeouts are recorded via status, not exit_code, so they don't double-count.
257
262
  if (r.status === 'timeout')
258
263
  b.timeouts++;
259
- else if (typeof r.exit_code === 'number' && r.exit_code !== 0)
260
- b.errors++;
264
+ else if (typeof r.exit_code === 'number') {
265
+ if (r.exit_code === 2)
266
+ b.blocks++;
267
+ else if (r.exit_code !== 0)
268
+ b.errors++;
269
+ }
261
270
  }
262
271
  const out = [];
263
272
  for (const b of map.values()) {
@@ -286,6 +295,10 @@ export function aggregateSamples(opts = {}) {
286
295
  row.errorCount = b.errors;
287
296
  row.errorRate = Math.round((b.errors / n) * 1000) / 1000;
288
297
  }
298
+ if (b.blocks > 0) {
299
+ row.blockCount = b.blocks;
300
+ row.blockRate = Math.round((b.blocks / n) * 1000) / 1000;
301
+ }
289
302
  if (b.timeouts > 0)
290
303
  row.timeoutRate = Math.round((b.timeouts / n) * 1000) / 1000;
291
304
  if (opts.project)
@@ -35,8 +35,19 @@ export interface PerfAggregateRow {
35
35
  cacheStalePct?: number;
36
36
  cacheMissPct?: number;
37
37
  errorCount?: number;
38
- /** Fraction (0-1) of samples with a nonzero exit code. */
38
+ /**
39
+ * Fraction (0-1) of samples with a real crash exit (exit 1 / other nonzero
40
+ * except the intentional PreToolUse deny code 2). Exit 2 is blockRate.
41
+ */
39
42
  errorRate?: number;
43
+ /**
44
+ * Count of intentional deny/block exits (Claude/Codex PreToolUse exit 2).
45
+ * Not an error — deny-by-design guards (ask-user-question-guard, git-guard,
46
+ * plan-html-reminder) exit 2 when they block.
47
+ */
48
+ blockCount?: number;
49
+ /** Fraction (0-1) of samples with exit code 2 (intentional deny/block). */
50
+ blockRate?: number;
40
51
  /** Fraction (0-1) of samples with status:'timeout'. */
41
52
  timeoutRate?: number;
42
53
  /** Project key (see project-key.ts) the row is scoped to — set only when
@@ -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';
@@ -73,6 +73,16 @@ export declare function formatWorkspaceLine(s: RepoWorkspaceStatus): string;
73
73
  * `fleet` row label.
74
74
  */
75
75
  export declare function formatFleetWorkspaces(statuses: HostWorkspaceStatus[]): string[];
76
+ /**
77
+ * One-line fleet health summary — `6/13 clean · 4 behind · 4 dirty · 1 missing`.
78
+ * Sits ABOVE the per-host {@link formatFleetWorkspaces} table so the card is
79
+ * scannable without reading every host cell; the table keeps the per-host branch
80
+ * and drift detail. Zero buckets are omitted. `behind` colours red when any host
81
+ * is ≥10 behind (matching the footer's critical threshold), else yellow; a host
82
+ * that is both behind and dirty counts in both. Each host×path row is one unit,
83
+ * the same unit the table renders. Pure — chalk styling only.
84
+ */
85
+ export declare function formatFleetSummary(statuses: HostWorkspaceStatus[]): string;
76
86
  /** Severity for a workspace/repo warning on the project card. */
77
87
  export type WorkspaceWarningSeverity = 'critical' | 'continue';
78
88
  export interface WorkspaceWarning {
@@ -81,13 +91,22 @@ export interface WorkspaceWarning {
81
91
  remediation?: string;
82
92
  }
83
93
  /**
84
- * Turn probed workspace rows into card-footer warnings.
94
+ * Turn probed workspace rows into card-footer warnings, GROUPED by root cause so
95
+ * a fleet where eight hosts drift is a few lines, not sixteen (each with its own
96
+ * repeated remediation).
85
97
  *
86
98
  * - missing / unreadable git → critical (agents there cannot share a tree)
87
- * - behind upstream → critical when ≥10 commits, continue otherwise
99
+ * - behind upstream → critical when ANY host is ≥10 commits behind, else continue
88
100
  * - dirty tree → continue (local work is fine; just note it)
89
101
  * - ahead-only is not a warning (that is progress waiting to push)
90
102
  *
91
- * Pure. Caller decides whether the rows came from `--fleet` or a local probe.
103
+ * Within one probed path, all behind hosts collapse to one warning listing each
104
+ * host with its count (`4 hosts behind origin/main — mac-mini ↓172, …`) plus one
105
+ * shared remediation; a lone host keeps its full sentence. Missing and dirty
106
+ * collapse the same way. `error` stays per-host (each message is distinct).
107
+ * Grouping is per path so two different repos never merge into one count. Unlike
108
+ * doctor's `emitGroup`, the list names EVERY host, not the first two — each
109
+ * host's drift count differs and is individually actionable. Pure — chalk only.
110
+ * Caller decides whether the rows came from `--fleet` or a local probe.
92
111
  */
93
112
  export declare function workspaceWarnings(statuses: HostWorkspaceStatus[]): WorkspaceWarning[];
@@ -159,46 +159,134 @@ export function formatFleetWorkspaces(statuses) {
159
159
  });
160
160
  }
161
161
  /**
162
- * Turn probed workspace rows into card-footer warnings.
162
+ * One-line fleet health summary `6/13 clean · 4 behind · 4 dirty · 1 missing`.
163
+ * Sits ABOVE the per-host {@link formatFleetWorkspaces} table so the card is
164
+ * scannable without reading every host cell; the table keeps the per-host branch
165
+ * and drift detail. Zero buckets are omitted. `behind` colours red when any host
166
+ * is ≥10 behind (matching the footer's critical threshold), else yellow; a host
167
+ * that is both behind and dirty counts in both. Each host×path row is one unit,
168
+ * the same unit the table renders. Pure — chalk styling only.
169
+ */
170
+ export function formatFleetSummary(statuses) {
171
+ const total = statuses.length;
172
+ let clean = 0;
173
+ let behind = 0;
174
+ let dirty = 0;
175
+ let missing = 0;
176
+ let hardBehind = false;
177
+ for (const s of statuses) {
178
+ if (!s.present) {
179
+ missing++;
180
+ continue;
181
+ }
182
+ if (s.error)
183
+ continue; // unreadable — neither clean nor a drift bucket (surfaced in the footer)
184
+ const isBehind = s.behind !== undefined && s.behind > 0;
185
+ const isDirty = s.dirty !== undefined && s.dirty > 0;
186
+ if (isBehind) {
187
+ behind++;
188
+ if (s.behind >= 10)
189
+ hardBehind = true;
190
+ }
191
+ if (isDirty)
192
+ dirty++;
193
+ if (!isBehind && !isDirty)
194
+ clean++;
195
+ }
196
+ const parts = [chalk.green(`${clean}/${total} clean`)];
197
+ if (behind)
198
+ parts.push((hardBehind ? chalk.red : chalk.yellow)(`${behind} behind`));
199
+ if (dirty)
200
+ parts.push(chalk.yellow(`${dirty} dirty`));
201
+ if (missing)
202
+ parts.push(chalk.red(`${missing} missing`));
203
+ return parts.join(chalk.dim(' · '));
204
+ }
205
+ /**
206
+ * Turn probed workspace rows into card-footer warnings, GROUPED by root cause so
207
+ * a fleet where eight hosts drift is a few lines, not sixteen (each with its own
208
+ * repeated remediation).
163
209
  *
164
210
  * - missing / unreadable git → critical (agents there cannot share a tree)
165
- * - behind upstream → critical when ≥10 commits, continue otherwise
211
+ * - behind upstream → critical when ANY host is ≥10 commits behind, else continue
166
212
  * - dirty tree → continue (local work is fine; just note it)
167
213
  * - ahead-only is not a warning (that is progress waiting to push)
168
214
  *
169
- * Pure. Caller decides whether the rows came from `--fleet` or a local probe.
215
+ * Within one probed path, all behind hosts collapse to one warning listing each
216
+ * host with its count (`4 hosts behind origin/main — mac-mini ↓172, …`) plus one
217
+ * shared remediation; a lone host keeps its full sentence. Missing and dirty
218
+ * collapse the same way. `error` stays per-host (each message is distinct).
219
+ * Grouping is per path so two different repos never merge into one count. Unlike
220
+ * doctor's `emitGroup`, the list names EVERY host, not the first two — each
221
+ * host's drift count differs and is individually actionable. Pure — chalk only.
222
+ * Caller decides whether the rows came from `--fleet` or a local probe.
170
223
  */
171
224
  export function workspaceWarnings(statuses) {
172
225
  const out = [];
173
- for (const s of [...statuses].sort((a, b) => a.host.localeCompare(b.host) || a.path.localeCompare(b.path))) {
174
- const where = s.host ? `${s.host}` : 'local';
175
- const pathBit = s.path ? ` (${s.path})` : '';
176
- if (!s.present) {
226
+ const where = (s) => (s.host ? s.host : 'local');
227
+ const paths = [...new Set(statuses.map((s) => s.path))].sort((a, b) => a.localeCompare(b));
228
+ for (const path of paths) {
229
+ const pathBit = path ? ` (${path})` : '';
230
+ const rows = statuses.filter((s) => s.path === path);
231
+ const present = rows.filter((s) => s.present && !s.error);
232
+ // Missing checkout — grouped critical, a lone host keeps its full sentence.
233
+ const missing = rows.filter((s) => !s.present).sort((a, b) => where(a).localeCompare(where(b)));
234
+ if (missing.length === 1) {
177
235
  out.push({
178
236
  severity: 'critical',
179
- text: `${where}: checkout missing${pathBit}`,
237
+ text: `${where(missing[0])}: checkout missing${pathBit}`,
180
238
  remediation: 'clone or sync the project root on that host before landing agents there',
181
239
  });
182
- continue;
183
240
  }
184
- if (s.error) {
241
+ else if (missing.length > 1) {
185
242
  out.push({
186
243
  severity: 'critical',
187
- text: `${where}: ${s.error}${pathBit}`,
244
+ text: `${missing.length} hosts missing checkout${pathBit}${missing.map(where).join(', ')}`,
245
+ remediation: 'clone or sync the project root on those hosts before landing agents there',
188
246
  });
189
- continue;
190
247
  }
191
- if (s.behind !== undefined && s.behind > 0) {
248
+ // Unreadable git one per host, since each error message is distinct.
249
+ for (const s of rows.filter((s) => s.present && s.error).sort((a, b) => where(a).localeCompare(where(b)))) {
250
+ out.push({ severity: 'critical', text: `${where(s)}: ${s.error}${pathBit}` });
251
+ }
252
+ // Behind upstream — grouped, worst count first, one shared remediation.
253
+ const behind = present
254
+ .filter((s) => s.behind !== undefined && s.behind > 0)
255
+ .sort((a, b) => (b.behind ?? 0) - (a.behind ?? 0) || where(a).localeCompare(where(b)));
256
+ if (behind.length === 1) {
257
+ const s = behind[0];
192
258
  out.push({
193
- severity: s.behind >= 10 ? 'critical' : 'continue',
194
- text: `${where} is ${s.behind} commit${s.behind === 1 ? '' : 's'} behind ${s.upstream ?? 'upstream'}${pathBit}`,
259
+ severity: (s.behind ?? 0) >= 10 ? 'critical' : 'continue',
260
+ text: `${where(s)} is ${s.behind} commit${s.behind === 1 ? '' : 's'} behind ${s.upstream ?? 'upstream'}${pathBit}`,
195
261
  remediation: 'pull (or rebase) before agents on this host open PRs against a stale base',
196
262
  });
197
263
  }
198
- if (s.dirty !== undefined && s.dirty > 0) {
264
+ else if (behind.length > 1) {
265
+ const upstreams = new Set(behind.map((s) => s.upstream ?? 'upstream'));
266
+ const upstream = upstreams.size === 1 ? [...upstreams][0] : 'upstream';
267
+ const list = behind.map((s) => `${where(s)} ↓${s.behind}`).join(', ');
268
+ out.push({
269
+ severity: behind.some((s) => (s.behind ?? 0) >= 10) ? 'critical' : 'continue',
270
+ text: `${behind.length} hosts behind ${upstream}${pathBit} — ${list}`,
271
+ remediation: 'pull (or rebase) before agents on these hosts open PRs against a stale base',
272
+ });
273
+ }
274
+ // Dirty tree — grouped, most changes first, no remediation (local work is fine).
275
+ const dirty = present
276
+ .filter((s) => s.dirty !== undefined && s.dirty > 0)
277
+ .sort((a, b) => (b.dirty ?? 0) - (a.dirty ?? 0) || where(a).localeCompare(where(b)));
278
+ if (dirty.length === 1) {
279
+ const s = dirty[0];
280
+ out.push({
281
+ severity: 'continue',
282
+ text: `${where(s)} has ${s.dirty} uncommitted change${s.dirty === 1 ? '' : 's'}${pathBit}`,
283
+ });
284
+ }
285
+ else if (dirty.length > 1) {
286
+ const list = dirty.map((s) => `${where(s)} ${s.dirty}`).join(', ');
199
287
  out.push({
200
288
  severity: 'continue',
201
- text: `${where} has ${s.dirty} uncommitted change${s.dirty === 1 ? '' : 's'}${pathBit}`,
289
+ text: `${dirty.length} hosts with uncommitted changes${pathBit} ${list}`,
202
290
  });
203
291
  }
204
292
  }
@@ -87,6 +87,15 @@ export interface LiveDeadSplit {
87
87
  * crashed sessions is itself a thing to go fix.
88
88
  */
89
89
  export declare function liveDeadSplit(byStatus: Partial<Record<ActiveStatus, number>>): LiveDeadSplit;
90
+ /**
91
+ * The `dead` row body: `41 crashed` when every dead session shares one status,
92
+ * else `12 finished or lost (8 crashed, 4 closed)`. The generic "finished or
93
+ * lost" only earns its keep when the statuses actually differ — with a single
94
+ * status it just hides which one behind a parenthetical that repeats the count.
95
+ * Pure — chalk styling only; the caller adds the `dead` label. Assumes
96
+ * `split.dead > 0` (the caller gates on it).
97
+ */
98
+ export declare function formatDeadSummary(split: LiveDeadSplit): string;
90
99
  /** Sort members for the card: running first, then idle, then the rest; agent name asc within a state. */
91
100
  export declare function sortProjectMembers(members: ProjectMember[]): ProjectMember[];
92
101
  /** Cap for the members line before it collapses to `+N more`. */