@phnx-labs/agents-cli 1.22.69 → 1.22.71

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 (130) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +34 -9
  3. package/dist/bootstrap.js +4 -4
  4. package/dist/commands/accounts.js +23 -7
  5. package/dist/commands/exec.js +2 -2
  6. package/dist/commands/import.js +2 -2
  7. package/dist/commands/models.js +2 -2
  8. package/dist/commands/permissions.js +2 -2
  9. package/dist/commands/repo.js +2 -2
  10. package/dist/commands/rules.js +1 -1
  11. package/dist/commands/send.js +9 -3
  12. package/dist/commands/sessions-export.d.ts +5 -1
  13. package/dist/commands/sessions-export.js +100 -24
  14. package/dist/commands/sessions-import.d.ts +2 -1
  15. package/dist/commands/sessions-import.js +85 -21
  16. package/dist/commands/traces.js +1 -1
  17. package/dist/lib/account-capabilities.js +0 -2
  18. package/dist/lib/account-registry.d.ts +3 -3
  19. package/dist/lib/account-registry.js +25 -7
  20. package/dist/lib/accounting/usage-sync.d.ts +1 -1
  21. package/dist/lib/accounting/usage-sync.js +3 -3
  22. package/dist/lib/acp/client.d.ts +1 -1
  23. package/dist/lib/acp/client.js +12 -1
  24. package/dist/lib/acp/harnesses.js +1 -1
  25. package/dist/lib/add-dir.js +0 -2
  26. package/dist/lib/agent-cli-commands.js +0 -2
  27. package/dist/lib/agent-spec/agents.d.ts +1 -1
  28. package/dist/lib/agent-spec/agents.js +2 -83
  29. package/dist/lib/browser/ipc.d.ts +34 -0
  30. package/dist/lib/browser/ipc.js +149 -20
  31. package/dist/lib/browser/remote-control.d.ts +6 -3
  32. package/dist/lib/browser/remote-control.js +6 -3
  33. package/dist/lib/browser/service.d.ts +4 -1
  34. package/dist/lib/browser/service.js +7 -1
  35. package/dist/lib/browser/types.d.ts +3 -1
  36. package/dist/lib/channels/registry.d.ts +6 -0
  37. package/dist/lib/channels/send.d.ts +11 -2
  38. package/dist/lib/channels/send.js +11 -2
  39. package/dist/lib/cloud/rush.d.ts +10 -2
  40. package/dist/lib/cloud/rush.js +15 -9
  41. package/dist/lib/daemon/auth-sync-service.js +1 -1
  42. package/dist/lib/daemon/browser-task-reap-service.js +1 -1
  43. package/dist/lib/daemon/daemon.js +41 -11
  44. package/dist/lib/daemon/heartbeat-service.js +3 -3
  45. package/dist/lib/daemon/keychain-reap-service.js +1 -1
  46. package/dist/lib/daemon/runner.d.ts +18 -1
  47. package/dist/lib/daemon/runner.js +237 -80
  48. package/dist/lib/daemon/self-heal-service.js +13 -3
  49. package/dist/lib/daemon/self-update-service.d.ts +174 -0
  50. package/dist/lib/daemon/self-update-service.js +353 -0
  51. package/dist/lib/daemon/state-dir-check-service.js +3 -3
  52. package/dist/lib/daemon/usage-sync-service.js +1 -1
  53. package/dist/lib/daemon/watchdog-service.js +4 -4
  54. package/dist/lib/daemon-services.d.ts +1 -1
  55. package/dist/lib/daemon-services.js +5 -0
  56. package/dist/lib/device-config.d.ts +12 -1
  57. package/dist/lib/device-config.js +63 -13
  58. package/dist/lib/exec-bounded.d.ts +52 -0
  59. package/dist/lib/exec-bounded.js +113 -0
  60. package/dist/lib/exec.d.ts +2 -2
  61. package/dist/lib/exec.js +3 -34
  62. package/dist/lib/feed/events.d.ts +22 -14
  63. package/dist/lib/feed/events.js +84 -44
  64. package/dist/lib/feed-broadcast.d.ts +12 -29
  65. package/dist/lib/feed-broadcast.js +28 -27
  66. package/dist/lib/fleet-shared-state.d.ts +12 -5
  67. package/dist/lib/fleet-shared-state.js +50 -20
  68. package/dist/lib/fs-atomic.d.ts +11 -0
  69. package/dist/lib/fs-atomic.js +60 -0
  70. package/dist/lib/hooks/install.js +0 -87
  71. package/dist/lib/hosts/reconcile.d.ts +11 -4
  72. package/dist/lib/hosts/reconcile.js +31 -5
  73. package/dist/lib/installations/strategies.js +1 -1
  74. package/dist/lib/mcp-registry.js +0 -13
  75. package/dist/lib/mcp.js +2 -2
  76. package/dist/lib/model-tiers.js +1 -1
  77. package/dist/lib/models.js +0 -63
  78. package/dist/lib/notify.d.ts +11 -0
  79. package/dist/lib/notify.js +17 -4
  80. package/dist/lib/owner-message.d.ts +46 -3
  81. package/dist/lib/owner-message.js +26 -6
  82. package/dist/lib/permissions-registry.d.ts +0 -2
  83. package/dist/lib/permissions-registry.js +3 -50
  84. package/dist/lib/permissions.d.ts +3 -17
  85. package/dist/lib/permissions.js +4 -73
  86. package/dist/lib/project-resources.d.ts +12 -0
  87. package/dist/lib/project-resources.js +129 -0
  88. package/dist/lib/routine-process-cleanup.d.ts +2 -2
  89. package/dist/lib/routine-process-cleanup.js +45 -34
  90. package/dist/lib/rush-session.d.ts +19 -0
  91. package/dist/lib/rush-session.js +24 -0
  92. package/dist/lib/secrets/drivers/rush.js +2 -1
  93. package/dist/lib/secrets/reaper.d.ts +2 -2
  94. package/dist/lib/secrets/reaper.js +13 -10
  95. package/dist/lib/secrets/reserved-sync.d.ts +1 -1
  96. package/dist/lib/secrets/reserved-sync.js +4 -4
  97. package/dist/lib/self-update.d.ts +21 -8
  98. package/dist/lib/self-update.js +54 -31
  99. package/dist/lib/session/cloud.js +2 -1
  100. package/dist/lib/session/sync/backend.d.ts +61 -0
  101. package/dist/lib/session/sync/backend.js +89 -0
  102. package/dist/lib/session/sync/managed-config.d.ts +29 -0
  103. package/dist/lib/session/sync/managed-config.js +23 -0
  104. package/dist/lib/session/sync/managed-key.d.ts +45 -0
  105. package/dist/lib/session/sync/managed-key.js +128 -0
  106. package/dist/lib/session/sync/net-client.d.ts +65 -0
  107. package/dist/lib/session/sync/net-client.js +117 -0
  108. package/dist/lib/session/sync/provision.d.ts +19 -0
  109. package/dist/lib/session/sync/provision.js +38 -0
  110. package/dist/lib/session/sync/r2.d.ts +5 -2
  111. package/dist/lib/session/sync/r2.js +5 -2
  112. package/dist/lib/session/sync/worker-template.d.ts +6 -0
  113. package/dist/lib/session/sync/worker-template.js +847 -0
  114. package/dist/lib/sink-format.d.ts +34 -0
  115. package/dist/lib/sink-format.js +17 -0
  116. package/dist/lib/staleness/detectors/permissions.js +0 -20
  117. package/dist/lib/staleness/writers/commands.js +1 -1
  118. package/dist/lib/staleness/writers/hooks.js +2 -2
  119. package/dist/lib/subagents-registry.js +2 -12
  120. package/dist/lib/subagents.d.ts +0 -10
  121. package/dist/lib/subagents.js +0 -37
  122. package/dist/lib/tmux/orphan-reap.js +6 -4
  123. package/dist/lib/tmux/session.js +4 -1
  124. package/dist/lib/traces/classify.d.ts +8 -1
  125. package/dist/lib/traces/insights.d.ts +13 -1
  126. package/dist/lib/traces/insights.js +78 -3
  127. package/dist/lib/traces/sync.js +8 -3
  128. package/dist/lib/traces/worker-template.js +9 -5
  129. package/dist/lib/types.d.ts +1 -1
  130. package/package.json +1 -1
@@ -0,0 +1,34 @@
1
+ /**
2
+ * How loudly a destination can render a link — the one decision that shapes the
3
+ * shared owner/broadcast `{message}` per channel (PHNX-3698).
4
+ *
5
+ * - `mrkdwn` — Slack, which renders `<url|label>` as blue tappable text. The
6
+ * session crumb and every ticket key the prose NAMES become inline labeled
7
+ * links, so nothing rides a trailing naked-URL line.
8
+ * - `plain` — iMessage, the owner-scoped rush message, a spawned `command:`
9
+ * sink, desktop banners: none can render a labeled link and a dumped naked
10
+ * URL reads as noise, so the message stays the human sentence with no URLs.
11
+ *
12
+ * The default is `plain`; only Slack opts into `mrkdwn`.
13
+ *
14
+ * This lives in its own tiny module because both the feed broadcast fan-out
15
+ * (`feed-broadcast.ts`) and the owner delivery fan-out (`notify.ts`) key on it,
16
+ * and those two modules already import each other — a shared symbol here keeps
17
+ * the format decision a single source of truth without a circular import.
18
+ */
19
+ export type SinkMessageFormat = 'plain' | 'mrkdwn';
20
+ /**
21
+ * Only Slack renders `<url|label>`, so it is the one format that gets labeled
22
+ * links. iMessage / owner-scoped rush / command / desktop sinks stay `plain`
23
+ * (they can't turn `claude/6fc1db18` blue, and dumping the raw URL is worse than
24
+ * leaving the crumb unlinked — PHNX-3698).
25
+ *
26
+ * The argument is the **resolved provider name**, not a sink's declared channel:
27
+ * an operator can point an arbitrary channel name at the Slack provider through
28
+ * `notify.transports` (e.g. `eng-alerts -> slack`), and delivery keys off that
29
+ * resolved provider (`lookupTransport`), so the format decision must too —
30
+ * otherwise an aliased Slack sink would compose plain while delivering to Slack,
31
+ * or a name remapped AWAY from Slack would emit `<url|label>` markup a non-Slack
32
+ * transport shows literally.
33
+ */
34
+ export declare function sinkMessageFormat(provider: string | undefined): SinkMessageFormat;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Only Slack renders `<url|label>`, so it is the one format that gets labeled
3
+ * links. iMessage / owner-scoped rush / command / desktop sinks stay `plain`
4
+ * (they can't turn `claude/6fc1db18` blue, and dumping the raw URL is worse than
5
+ * leaving the crumb unlinked — PHNX-3698).
6
+ *
7
+ * The argument is the **resolved provider name**, not a sink's declared channel:
8
+ * an operator can point an arbitrary channel name at the Slack provider through
9
+ * `notify.transports` (e.g. `eng-alerts -> slack`), and delivery keys off that
10
+ * resolved provider (`lookupTransport`), so the format decision must too —
11
+ * otherwise an aliased Slack sink would compose plain while delivering to Slack,
12
+ * or a name remapped AWAY from Slack would emit `<url|label>` markup a non-Slack
13
+ * transport shows literally.
14
+ */
15
+ export function sinkMessageFormat(provider) {
16
+ return provider?.trim().toLowerCase() === 'slack' ? 'mrkdwn' : 'plain';
17
+ }
@@ -202,25 +202,6 @@ function buildDroidDetector() {
202
202
  },
203
203
  };
204
204
  }
205
- function buildKiroDetector() {
206
- return {
207
- kind: 'permissions',
208
- agent: 'kiro',
209
- list({ versionHome }) {
210
- const permissionsPath = path.join(versionHome, '.kiro', 'settings', 'permissions.yaml');
211
- if (!fs.existsSync(permissionsPath))
212
- return [];
213
- try {
214
- const config = yaml.parse(fs.readFileSync(permissionsPath, 'utf-8'));
215
- if (config && Array.isArray(config.rules) && config.rules.length > 0) {
216
- return discoverPermissionGroups().map(g => g.name);
217
- }
218
- }
219
- catch { /* parse fail */ }
220
- return [];
221
- },
222
- };
223
- }
224
205
  function buildOpenClawDetector() {
225
206
  return {
226
207
  kind: 'permissions',
@@ -296,7 +277,6 @@ const handlers = {
296
277
  kimi: buildKimiDetector,
297
278
  cursor: buildCursorDetector,
298
279
  droid: buildDroidDetector,
299
- kiro: buildKiroDetector,
300
280
  openclaw: buildOpenClawDetector,
301
281
  copilot: buildCopilotDetector,
302
282
  hermes: buildHermesDetector,
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * - native command file — `{agentDir}/<commandsSubdir>/<name>.md` (or .toml
15
15
  * when the agent's format is toml). Standard path for Claude, Codex
16
- * < 0.117.0, Cursor, OpenCode, Copilot, Amp, Kiro, Roo, Antigravity.
16
+ * < 0.117.0, Cursor, OpenCode, Copilot, Amp, Roo, Antigravity.
17
17
  *
18
18
  * Source resolution is `resolveCommandSource` (user → system → extras —
19
19
  * project layer intentionally excluded).
@@ -90,9 +90,9 @@ function buildHooksWriter(agent) {
90
90
  // so subrule-bundled guards (absolute paths outside the central hooks/
91
91
  // copy set) get registered into ~/.grok/hooks/hooks.json via
92
92
  // registerHooksForGrok — file copy alone only sees top-level available.hooks
93
- // names (RUSH-1353). Copilot/Kiro/Goose load managed *.json under their
93
+ // names (RUSH-1353). Copilot/Goose load managed *.json under their
94
94
  // hooks dirs the same way.
95
- if (agent === 'claude' || agent === 'codex' || agent === 'antigravity' || agent === 'kimi' || agent === 'droid' || agent === 'copilot' || agent === 'kiro' || agent === 'goose' || agent === 'cursor' || agent === 'grok' || agent === 'hermes' || agent === 'muse') {
95
+ if (agent === 'claude' || agent === 'codex' || agent === 'antigravity' || agent === 'kimi' || agent === 'droid' || agent === 'copilot' || agent === 'goose' || agent === 'cursor' || agent === 'grok' || agent === 'hermes' || agent === 'muse') {
96
96
  registerHooksToSettings(agent, versionHome);
97
97
  }
98
98
  return { synced, paths };
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * - `flatFile` one `<name><ext>` file, body from a `transform` fn.
17
17
  * (claude, grok, pi, droid, codex, opencode, copilot,
18
- * cursor, kiro, goose, kimi)
18
+ * cursor, goose, kimi)
19
19
  * - `dirFile` a `<name>/` directory holding one generated `<file>`.
20
20
  * (antigravity: `<name>/agent.md`)
21
21
  * - `dirCopy` copy the whole source directory to `<name>/`, applying
@@ -35,7 +35,7 @@ import * as TOML from 'smol-toml';
35
35
  import * as yaml from 'yaml';
36
36
  import { safeJoin } from './paths.js';
37
37
  import { filesContentMatch, normalizeResourceContent } from './resource-content-diff.js';
38
- import { parseSubagentFrontmatter, transformSubagentForClaude, transformSubagentForCodex, transformSubagentForCopilot, transformSubagentForCursor, transformSubagentForDroid, transformSubagentForGoose, transformSubagentForKiro, transformSubagentForOpenCode, transformSubagentForAntigravity, } from './subagents.js';
38
+ import { parseSubagentFrontmatter, transformSubagentForClaude, transformSubagentForCodex, transformSubagentForCopilot, transformSubagentForCursor, transformSubagentForDroid, transformSubagentForGoose, transformSubagentForOpenCode, transformSubagentForAntigravity, } from './subagents.js';
39
39
  /** Read a file's UTF-8 content, or null when it is missing/unreadable. */
40
40
  function readFileSafe(filePath) {
41
41
  try {
@@ -54,7 +54,6 @@ function metaFrontmatterSkip(filePath) {
54
54
  function metaFrontmatterFallback(filePath, name) {
55
55
  return parseSubagentFrontmatter(filePath) ?? { name, description: '' };
56
56
  }
57
- /** Kiro custom-agent JSON: read name/description/model; skip on parse error. */
58
57
  function metaJson(filePath, name) {
59
58
  try {
60
59
  const cfg = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
@@ -283,9 +282,6 @@ export const SUBAGENT_TARGETS = {
283
282
  // Tier 1 -- flat markdown, Claude-compatible flatten.
284
283
  claude: flatFile({ subdir: ['.claude', 'agents'], ext: '.md', transform: transformSubagentForClaude }),
285
284
  grok: flatFile({ subdir: ['.grok', 'agents'], ext: '.md', transform: transformSubagentForClaude }),
286
- // Oh My Pi discovers Claude-shaped agent markdown from ~/.omp/agent/agents/
287
- // (`omp agents unpack` writes there); frontmatter name/description + body.
288
- pi: flatFile({ subdir: ['.omp', 'agent', 'agents'], ext: '.md', transform: transformSubagentForClaude, readMeta: metaFrontmatterFallback }),
289
285
  droid: flatFile({ subdir: ['.factory', 'droids'], ext: '.md', transform: transformSubagentForDroid }),
290
286
  // Bespoke frontmatter/format, still one flat file.
291
287
  codex: flatFile({
@@ -312,12 +308,6 @@ export const SUBAGENT_TARGETS = {
312
308
  transform: transformSubagentForCursor,
313
309
  readMeta: metaFrontmatterFallback,
314
310
  }),
315
- kiro: flatFile({
316
- subdir: ['.kiro', 'agents'],
317
- ext: '.json',
318
- transform: transformSubagentForKiro,
319
- readMeta: metaJson,
320
- }),
321
311
  goose: flatFile({
322
312
  subdir: ['.config', 'goose', 'agents'],
323
313
  ext: '.yaml',
@@ -106,16 +106,6 @@ export declare function transformSubagentForOpenCode(subagentDir: string): strin
106
106
  * https://developers.openai.com/codex/subagents (custom agents section)
107
107
  */
108
108
  export declare function transformSubagentForCodex(subagentDir: string): string;
109
- /**
110
- * Transform a subagent into a Kiro CLI custom-agent JSON file.
111
- *
112
- * Kiro custom agents live in `~/.kiro/agents/<name>.json` (or `.kiro/agents/`
113
- * workspace-local) and declare name, description, prompt, tools, and optional
114
- * model. We flatten the AGENT.md frontmatter + body plus any sibling .md files
115
- * as sections into a single `prompt`, and expose the standard built-in tool
116
- * set so the subagent can actually run.
117
- */
118
- export declare function transformSubagentForKiro(subagentDir: string): string;
119
109
  /**
120
110
  * Transform a subagent into a Goose recipe YAML file.
121
111
  *
@@ -403,43 +403,6 @@ function flattenSubagentInstructions(subagentDir) {
403
403
  }
404
404
  return instructions;
405
405
  }
406
- /**
407
- * Transform a subagent into a Kiro CLI custom-agent JSON file.
408
- *
409
- * Kiro custom agents live in `~/.kiro/agents/<name>.json` (or `.kiro/agents/`
410
- * workspace-local) and declare name, description, prompt, tools, and optional
411
- * model. We flatten the AGENT.md frontmatter + body plus any sibling .md files
412
- * as sections into a single `prompt`, and expose the standard built-in tool
413
- * set so the subagent can actually run.
414
- */
415
- export function transformSubagentForKiro(subagentDir) {
416
- const agentMd = path.join(subagentDir, 'AGENT.md');
417
- const frontmatter = parseSubagentFrontmatter(agentMd);
418
- const body = getSubagentBody(agentMd);
419
- if (!frontmatter) {
420
- throw new Error(`Invalid AGENT.md in ${subagentDir}`);
421
- }
422
- const files = fs.readdirSync(subagentDir)
423
- .filter(f => f.endsWith('.md') && f !== 'AGENT.md')
424
- .sort();
425
- let prompt = body;
426
- for (const file of files) {
427
- const content = fs.readFileSync(path.join(subagentDir, file), 'utf-8').trim();
428
- const sectionName = file.replace('.md', '');
429
- const title = sectionName.charAt(0).toUpperCase() + sectionName.slice(1).toLowerCase();
430
- prompt += `\n\n## ${title}\n\n${content}`;
431
- }
432
- const config = {
433
- name: frontmatter.name,
434
- description: frontmatter.description,
435
- prompt,
436
- tools: ['read', 'write', 'shell', 'web_search', 'web_fetch'],
437
- };
438
- if (frontmatter.model) {
439
- config.model = frontmatter.model;
440
- }
441
- return JSON.stringify(config, null, 2);
442
- }
443
406
  /**
444
407
  * Transform a subagent into a Goose recipe YAML file.
445
408
  *
@@ -99,7 +99,7 @@
99
99
  * helper); it is not a parsing bug {@link parseTmuxSessionMarker} can fix.
100
100
  */
101
101
  import { execFile } from 'child_process';
102
- import * as fs from 'fs';
102
+ import * as fsp from 'fs/promises';
103
103
  import { promisify } from 'util';
104
104
  const execFileAsync = promisify(execFile);
105
105
  /** Ceiling on the `ps` snapshot so a wedged `ps` can never stall the daemon tick. */
@@ -407,7 +407,9 @@ export async function readAgentProcesses(opts = {}) {
407
407
  if (process.platform === 'win32')
408
408
  return [];
409
409
  const scope = opts.pids && opts.pids.length > 0 ? ['-p', opts.pids.join(',')] : ['-A'];
410
- const hasProc = fs.existsSync('/proc/self/environ');
410
+ // Async /proc reads — this runs on the daemon's tmux-reap tick, so a sync scan
411
+ // of the whole process table's environ files would freeze the loop (PHNX-3695).
412
+ const hasProc = await fsp.access('/proc/self/environ').then(() => true, () => false);
411
413
  const args = hasProc ? [...scope, '-o', 'pid=,ppid=,args='] : [...scope, '-E', '-o', 'pid=,ppid=,args='];
412
414
  let stdout;
413
415
  try {
@@ -426,7 +428,7 @@ export async function readAgentProcesses(opts = {}) {
426
428
  for (const row of rows) {
427
429
  let blob;
428
430
  try {
429
- blob = fs.readFileSync(`/proc/${row.pid}/environ`, 'utf8');
431
+ blob = await fsp.readFile(`/proc/${row.pid}/environ`, 'utf8');
430
432
  }
431
433
  catch {
432
434
  continue; // exited between ps and the read, or not ours to inspect
@@ -450,7 +452,7 @@ export async function readPaneOwners(socket) {
450
452
  // No socket file at all means no server was ever started here — for the
451
453
  // shared agents socket this is reliable (tmux unlinks its own socket on
452
454
  // exit), so this is a confident, reliable EMPTY read, not an unknown one.
453
- if (!fs.existsSync(socket))
455
+ if (!(await fsp.access(socket).then(() => true, () => false)))
454
456
  return { ok: true, owners: new Map(), panePids: new Set() };
455
457
  const { runTmux } = await import('./binary.js');
456
458
  try {
@@ -10,6 +10,7 @@
10
10
  * `tmux list-sessions` and prunes stale entries on the fly.
11
11
  */
12
12
  import * as fs from 'fs';
13
+ import * as fsp from 'fs/promises';
13
14
  import * as os from 'os';
14
15
  import * as path from 'path';
15
16
  import { runTmux, TmuxCommandError } from './binary.js';
@@ -412,7 +413,9 @@ export async function reapDeadTmuxPanes(socket, opts = {}) {
412
413
  result.warnings = orphans.warnings;
413
414
  result.processes = opts.dryRun ? orphans.candidates.length : orphans.killed;
414
415
  result.processDetails = orphans.details;
415
- if (!fs.existsSync(sock))
416
+ // Async existence check — this runs on the daemon's tmux-reap tick, so a sync
417
+ // `existsSync` would block the shared event loop (PHNX-3695).
418
+ if (!(await fsp.access(sock).then(() => true, () => false)))
416
419
  return result;
417
420
  const res = await runTmux({
418
421
  socket: sock,
@@ -1,5 +1,12 @@
1
1
  export type TraceTopicGroup = 'code' | 'research' | 'review' | 'content' | 'ops';
2
- export type TraceFailureCause = 'real' | 'guard' | 'hook';
2
+ /**
3
+ * `real`/`guard`/`hook` are the cause buckets of a FAILED tool call (`classifyCause`).
4
+ * `behavioral` is different in kind: a silent failure with no error code at all — the
5
+ * agent went idle after its last event and a human had to nudge it. It is derived from
6
+ * per-session friction facets (`computeBehavioralPatterns` in `insights.ts`), never from
7
+ * `classifyCause`, so a failed-tool-call classifier never returns it.
8
+ */
9
+ export type TraceFailureCause = 'real' | 'guard' | 'hook' | 'behavioral';
3
10
  /** Per-bucket aggregate stats for one day, stored in the rolling bucketHistory. */
4
11
  export interface BucketStats {
5
12
  key: string;
@@ -29,6 +29,7 @@ import { type TraceFailureCause } from './classify.js';
29
29
  import type { FailurePhenotype } from './phenotype.js';
30
30
  import { type LatencyInsight } from './segments.js';
31
31
  import { type SyncRow, type ToolCallRow, type TracesIndexShard } from './sync.js';
32
+ import type { InsightFacets } from '../session/insights.js';
32
33
  export interface FailureSignature {
33
34
  tool: string;
34
35
  cause: TraceFailureCause;
@@ -97,4 +98,15 @@ export declare function normalizeErrorKey(desc: string, raw: string | null): str
97
98
  * estimate, not ground truth; it is not inflated by folding in ordinary
98
99
  * processing time between unrelated calls.
99
100
  */
100
- export declare function computeInsights(rows: readonly SyncRow[], calls: readonly ToolCallRow[], prevShard?: TracesIndexShard | null, phenotypes?: ReadonlyMap<string, FailurePhenotype | null>): ComputedInsights;
101
+ export declare function computeInsights(rows: readonly SyncRow[], calls: readonly ToolCallRow[], prevShard?: TracesIndexShard | null, phenotypes?: ReadonlyMap<string, FailurePhenotype | null>, behavioralPatterns?: readonly FailurePattern[]): ComputedInsights;
102
+ /**
103
+ * Promote the per-session silent-stall friction signals — already computed by
104
+ * `computeInsightFacets` in `session/insights.ts` and keyed `silent stall: <bucket>`
105
+ * — into cross-session behavioral `FailurePattern`s. These are silent failures
106
+ * with NO error code: the agent went idle after its last event and a human had to
107
+ * nudge it. They never surface through `computeInsights` (which only clusters
108
+ * `outcome === 'error'` tool calls) and were previously only a `needsAttention`
109
+ * friction counter, never a ranked issue. Cause is `behavioral`; the signature
110
+ * `tool` is the synthetic `silent-stall` and `key` is the duration bucket.
111
+ */
112
+ export declare function computeBehavioralPatterns(facetsBySession: ReadonlyMap<string, Pick<InsightFacets, 'frictionSignals'>>, prevShard?: TracesIndexShard | null): FailurePattern[];
@@ -132,7 +132,7 @@ function labelFor(tool, cause, key) {
132
132
  * estimate, not ground truth; it is not inflated by folding in ordinary
133
133
  * processing time between unrelated calls.
134
134
  */
135
- export function computeInsights(rows, calls, prevShard, phenotypes) {
135
+ export function computeInsights(rows, calls, prevShard, phenotypes, behavioralPatterns = []) {
136
136
  const bySession = new Map();
137
137
  for (const call of calls) {
138
138
  const list = bySession.get(call.session_id);
@@ -226,13 +226,88 @@ export function computeInsights(rows, calls, prevShard, phenotypes) {
226
226
  drift,
227
227
  };
228
228
  });
229
- const wastedMsTotal = allPatterns.reduce((sum, p) => sum + p.wastedMs, 0);
230
- const failurePatterns = [...allPatterns]
229
+ // Behavioral patterns (silent stalls — no failed tool call, so absent from the
230
+ // error-anchored clustering above) join the SAME ranking and total, so the top-K
231
+ // is by impact across both kinds and `wastedMsTotal` counts the idle time too.
232
+ const combined = [...allPatterns, ...behavioralPatterns];
233
+ const wastedMsTotal = combined.reduce((sum, p) => sum + p.wastedMs, 0);
234
+ const failurePatterns = [...combined]
231
235
  .sort((a, b) => b.wastedMs - a.wastedMs || b.occurrences - a.occurrences || a.id.localeCompare(b.id))
232
236
  .slice(0, TOP_K_PATTERNS);
233
237
  const latency = computeLatency(firstToolSegments(rows, bySession));
234
238
  return { failurePatterns, wastedMsTotal, latency };
235
239
  }
240
+ // ---------------------------------------------------------------------------
241
+ // Behavioral patterns — silent failures with no error code
242
+ // ---------------------------------------------------------------------------
243
+ /**
244
+ * Estimated agent-owned idle ms per silent-stall bucket. A bucket's midpoint,
245
+ * capped at MAX_GAP_ATTRIBUTION_MS exactly like the tool-error gap attribution
246
+ * above so one long overnight stall can't book hours of "waste" — the same
247
+ * honesty bound the error path uses. The open-ended buckets are the cap.
248
+ */
249
+ const SILENT_STALL_WASTED_MS = {
250
+ '5-15m': 10 * 60_000,
251
+ '15-60m': MAX_GAP_ATTRIBUTION_MS,
252
+ '1h+': MAX_GAP_ATTRIBUTION_MS,
253
+ };
254
+ /**
255
+ * Promote the per-session silent-stall friction signals — already computed by
256
+ * `computeInsightFacets` in `session/insights.ts` and keyed `silent stall: <bucket>`
257
+ * — into cross-session behavioral `FailurePattern`s. These are silent failures
258
+ * with NO error code: the agent went idle after its last event and a human had to
259
+ * nudge it. They never surface through `computeInsights` (which only clusters
260
+ * `outcome === 'error'` tool calls) and were previously only a `needsAttention`
261
+ * friction counter, never a ranked issue. Cause is `behavioral`; the signature
262
+ * `tool` is the synthetic `silent-stall` and `key` is the duration bucket.
263
+ */
264
+ export function computeBehavioralPatterns(facetsBySession, prevShard) {
265
+ const groups = new Map();
266
+ for (const [sessionId, facets] of facetsBySession) {
267
+ for (const [signal, count] of Object.entries(facets.frictionSignals ?? {})) {
268
+ if (count <= 0)
269
+ continue;
270
+ const match = /^silent stall: (.+)$/.exec(signal);
271
+ if (!match)
272
+ continue;
273
+ const bucket = match[1];
274
+ let group = groups.get(bucket);
275
+ if (!group) {
276
+ group = { bucket, sessions: new Set(), occurrences: 0, wastedMs: 0, examples: [] };
277
+ groups.set(bucket, group);
278
+ }
279
+ group.occurrences += count;
280
+ group.sessions.add(sessionId);
281
+ group.wastedMs += (SILENT_STALL_WASTED_MS[bucket] ?? MAX_GAP_ATTRIBUTION_MS) * count;
282
+ if (group.examples.length < MAX_EXAMPLE_SESSIONS && !group.examples.includes(sessionId)) {
283
+ group.examples.push(sessionId);
284
+ }
285
+ }
286
+ }
287
+ const prevById = new Map((prevShard?.failurePatterns ?? []).map((p) => [p.id, p]));
288
+ return [...groups.values()].map((group) => {
289
+ const id = hashSignature('silent-stall', 'behavioral', group.bucket, null);
290
+ const prev = prevById.get(id);
291
+ const drift = !prev
292
+ ? 'up'
293
+ : group.occurrences > prev.occurrences
294
+ ? 'up'
295
+ : group.occurrences < prev.occurrences
296
+ ? 'down'
297
+ : 'flat';
298
+ return {
299
+ id,
300
+ label: `Agent silent stall (${group.bucket}) — idle until nudged`,
301
+ signature: { tool: 'silent-stall', cause: 'behavioral', key: group.bucket },
302
+ phenotype: null,
303
+ sessions: group.sessions.size,
304
+ occurrences: group.occurrences,
305
+ wastedMs: group.wastedMs,
306
+ exampleSessionIds: group.examples,
307
+ drift,
308
+ };
309
+ });
310
+ }
236
311
  /** Synthesize one-step SegmentSessions carrying only the time-to-first-tool offset, for computeLatency() reuse. */
237
312
  function firstToolSegments(rows, bySession) {
238
313
  return rows.flatMap((row) => {
@@ -30,7 +30,7 @@ import { knownSecretValuesFromEnv, redactSecrets } from '../redact.js';
30
30
  import { getRuntimeStateDir } from '../state.js';
31
31
  import { resolveTracesBackend } from './backend.js';
32
32
  import { classifyCause, classifyTopic, computeDriftSignal, } from './classify.js';
33
- import { computeInsights } from './insights.js';
33
+ import { computeBehavioralPatterns, computeInsights } from './insights.js';
34
34
  import { classifyPhenotype, recoveredAfterErrors } from './phenotype.js';
35
35
  import { buildSessionDetailV2 } from './schema2-build.js';
36
36
  /**
@@ -529,7 +529,9 @@ export function buildIndexShard(rows, device, owner, prevShard) {
529
529
  topicCounts.set(topic.key, bucket);
530
530
  }
531
531
  const failedCalls = agentCalls.filter((call) => call.outcome === 'error');
532
- const byCause = { real: 0, guard: 0, hook: 0 };
532
+ // `behavioral` is not a failed-tool-call cause (classifyCause never returns it),
533
+ // so it stays 0 in this tool-error split; it surfaces as its own failurePatterns.
534
+ const byCause = { real: 0, guard: 0, hook: 0, behavioral: 0 };
533
535
  const failureCounts = new Map();
534
536
  for (const call of failedCalls) {
535
537
  const cause = classifyCause(call);
@@ -585,7 +587,10 @@ export function buildIndexShard(rows, device, owner, prevShard) {
585
587
  const prevHistory = prevShard?.bucketHistory ?? [];
586
588
  const bucketHistory = [...prevHistory, todayStats].slice(-14);
587
589
  const driftSignals = computeDriftSignal(prevHistory, todayStats);
588
- const patternInsights = computeInsights(agentRows, agentCalls, prevShard, phenotypes);
590
+ // Silent-stall friction (per-session, no failed tool call) becomes cross-session
591
+ // behavioral FailurePatterns, ranked into the same top-K by wasted idle time.
592
+ const behavioralPatterns = computeBehavioralPatterns(insights, prevShard);
593
+ const patternInsights = computeInsights(agentRows, agentCalls, prevShard, phenotypes, behavioralPatterns);
589
594
  // Per-session roster (PHNX-3483): one flat scalar row per agent session, the raw
590
595
  // material the Rush console filters and re-aggregates client-side. `durationMs`
591
596
  // reuses `sessionActiveMs` (the value behind `stats.medianMs`; 0 for a null-duration
@@ -170,7 +170,11 @@ function mergeIndexShards(shards, owner) {
170
170
  topicCounts.set(t.key, cur);
171
171
  }
172
172
  const byToolError = new Map();
173
- let real = 0, guard = 0, hook = 0;
173
+ // Accumulate byCause over WHATEVER cause keys each shard carries (real / guard /
174
+ // hook / behavioral / any future TraceFailureCause) rather than a hand-enumerated
175
+ // set — a hardcoded {real,guard,hook} silently dropped a new member from the /all
176
+ // view and made the cause sum NaN (RUSH-2988).
177
+ const byCause = {};
174
178
  for (const s of sorted) {
175
179
  const f = s.failures || {};
176
180
  for (const e of f.byToolError || []) {
@@ -179,9 +183,9 @@ function mergeIndexShards(shards, owner) {
179
183
  cur.count += e.count || 0;
180
184
  byToolError.set(key, cur);
181
185
  }
182
- real += (f.byCause && f.byCause.real) || 0;
183
- guard += (f.byCause && f.byCause.guard) || 0;
184
- hook += (f.byCause && f.byCause.hook) || 0;
186
+ for (const [cause, n] of Object.entries((f.byCause) || {})) {
187
+ byCause[cause] = (byCause[cause] || 0) + (n || 0);
188
+ }
185
189
  }
186
190
  // Fold failure patterns by their stable id, the same way topics/byToolError fold
187
191
  // by key above — a signature that fired on two devices (a rate limit on the laptop
@@ -220,7 +224,7 @@ function mergeIndexShards(shards, owner) {
220
224
  topics: Array.from(topicCounts.values()).sort((a, b) => b.count - a.count),
221
225
  failures: {
222
226
  byToolError: Array.from(byToolError.values()).sort((a, b) => b.count - a.count).slice(0, 50),
223
- byCause: { real, guard, hook },
227
+ byCause,
224
228
  },
225
229
  failurePatterns: Array.from(patternById.values())
226
230
  .sort((a, b) => (b.wastedMs || 0) - (a.wastedMs || 0)).slice(0, 25),
@@ -8,7 +8,7 @@
8
8
  import type { CloudProviderId } from './cloud/types.js';
9
9
  import type { FeedBroadcastConfig } from './feed-broadcast.js';
10
10
  /** Unique identifier for a current or legacy AI coding agent. */
11
- export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'copilot' | 'amp' | 'kiro' | 'goose' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes' | 'pi' | 'muse' | 'warp';
11
+ export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'copilot' | 'amp' | 'goose' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes' | 'muse' | 'warp';
12
12
  /** How `agents run <agent>` chooses an installed version when none is pinned. */
13
13
  export type RunStrategy = 'pinned' | 'available' | 'balanced';
14
14
  export type RunEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.69",
3
+ "version": "1.22.71",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",