@phnx-labs/agents-cli 1.22.53 → 1.22.55

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 (166) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +59 -9
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +219 -173
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +10 -8
  16. package/dist/commands/focus.d.ts +1 -0
  17. package/dist/commands/focus.js +2 -2
  18. package/dist/commands/go.d.ts +5 -4
  19. package/dist/commands/go.js +7 -7
  20. package/dist/commands/insights.js +9 -0
  21. package/dist/commands/monitors.js +85 -30
  22. package/dist/commands/output.js +8 -2
  23. package/dist/commands/repo.js +18 -0
  24. package/dist/commands/routines.js +31 -2
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions.d.ts +20 -12
  27. package/dist/commands/sessions.js +64 -20
  28. package/dist/commands/setup-accounts.d.ts +8 -0
  29. package/dist/commands/setup-accounts.js +47 -0
  30. package/dist/commands/setup.d.ts +1 -1
  31. package/dist/commands/setup.js +11 -2
  32. package/dist/commands/share.d.ts +79 -3
  33. package/dist/commands/share.js +347 -18
  34. package/dist/commands/ssh.d.ts +7 -0
  35. package/dist/commands/ssh.js +18 -2
  36. package/dist/commands/status.js +14 -0
  37. package/dist/commands/view.d.ts +11 -1
  38. package/dist/commands/view.js +35 -7
  39. package/dist/lib/account-registry.js +15 -3
  40. package/dist/lib/accounting/rotate.d.ts +20 -6
  41. package/dist/lib/accounting/rotate.js +38 -7
  42. package/dist/lib/accounting/usage.d.ts +68 -1
  43. package/dist/lib/accounting/usage.js +116 -10
  44. package/dist/lib/agent-spec/agents.d.ts +5 -2
  45. package/dist/lib/agent-spec/agents.js +25 -7
  46. package/dist/lib/analytics/mix-commands.js +12 -6
  47. package/dist/lib/auth-mint.d.ts +150 -0
  48. package/dist/lib/auth-mint.js +434 -0
  49. package/dist/lib/browser/cdp.d.ts +1 -1
  50. package/dist/lib/browser/cdp.js +1 -1
  51. package/dist/lib/browser/ffmpeg.d.ts +12 -0
  52. package/dist/lib/browser/ffmpeg.js +184 -0
  53. package/dist/lib/browser/remote-control.d.ts +9 -7
  54. package/dist/lib/browser/remote-control.js +9 -7
  55. package/dist/lib/browser/service.js +119 -25
  56. package/dist/lib/claude-account-token.d.ts +10 -0
  57. package/dist/lib/claude-account-token.js +14 -4
  58. package/dist/lib/config-drift.d.ts +37 -0
  59. package/dist/lib/config-drift.js +72 -0
  60. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  61. package/dist/lib/daemon/auth-sync-service.js +34 -0
  62. package/dist/lib/daemon/browser-task-reap-service.d.ts +14 -0
  63. package/dist/lib/daemon/browser-task-reap-service.js +26 -0
  64. package/dist/lib/daemon/daemon.js +87 -176
  65. package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
  66. package/dist/lib/daemon/heartbeat-service.js +26 -0
  67. package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
  68. package/dist/lib/daemon/monitor-engine-service.js +15 -7
  69. package/dist/lib/daemon/runner.d.ts +15 -0
  70. package/dist/lib/daemon/runner.js +23 -0
  71. package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
  72. package/dist/lib/daemon/secrets-broker-service.js +17 -32
  73. package/dist/lib/daemon/service.d.ts +2 -2
  74. package/dist/lib/daemon/service.js +1 -1
  75. package/dist/lib/daemon/session-state-service.d.ts +21 -0
  76. package/dist/lib/daemon/session-state-service.js +34 -0
  77. package/dist/lib/daemon/supervisor.d.ts +17 -7
  78. package/dist/lib/daemon/supervisor.js +87 -14
  79. package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
  80. package/dist/lib/daemon/tmux-reap-service.js +28 -0
  81. package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
  82. package/dist/lib/daemon/webhook-receiver-service.js +17 -0
  83. package/dist/lib/daemon-services.d.ts +1 -1
  84. package/dist/lib/daemon-services.js +25 -0
  85. package/dist/lib/device-config.d.ts +3 -3
  86. package/dist/lib/device-config.js +5 -5
  87. package/dist/lib/devices/connect.d.ts +26 -0
  88. package/dist/lib/devices/connect.js +48 -1
  89. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  90. package/dist/lib/devices/doctor-findings.js +19 -1
  91. package/dist/lib/devices/harness-inventory.js +5 -2
  92. package/dist/lib/exec.d.ts +28 -0
  93. package/dist/lib/exec.js +73 -7
  94. package/dist/lib/feed/feed.d.ts +1 -1
  95. package/dist/lib/feed/feed.js +23 -1
  96. package/dist/lib/feed-broadcast.js +1 -1
  97. package/dist/lib/fleet/apply.d.ts +11 -0
  98. package/dist/lib/fleet/apply.js +23 -3
  99. package/dist/lib/fleet/auth-sync.js +5 -3
  100. package/dist/lib/help.d.ts +9 -0
  101. package/dist/lib/help.js +29 -1
  102. package/dist/lib/hosts/passthrough.d.ts +1 -10
  103. package/dist/lib/hosts/passthrough.js +1 -13
  104. package/dist/lib/installations/versions.js +9 -1
  105. package/dist/lib/linux-userns.d.ts +58 -0
  106. package/dist/lib/linux-userns.js +116 -0
  107. package/dist/lib/memory.d.ts +26 -0
  108. package/dist/lib/memory.js +80 -1
  109. package/dist/lib/monitors/config.d.ts +11 -0
  110. package/dist/lib/monitors/config.js +8 -0
  111. package/dist/lib/monitors/engine.d.ts +5 -1
  112. package/dist/lib/monitors/engine.js +13 -4
  113. package/dist/lib/monitors/state.d.ts +37 -1
  114. package/dist/lib/monitors/state.js +79 -4
  115. package/dist/lib/permissions-registry.d.ts +2 -0
  116. package/dist/lib/permissions-registry.js +116 -14
  117. package/dist/lib/permissions.d.ts +5 -3
  118. package/dist/lib/permissions.js +25 -27
  119. package/dist/lib/profiles.d.ts +8 -7
  120. package/dist/lib/profiles.js +12 -0
  121. package/dist/lib/project-key.d.ts +9 -0
  122. package/dist/lib/project-key.js +11 -0
  123. package/dist/lib/scheduling/routines.d.ts +47 -0
  124. package/dist/lib/scheduling/routines.js +70 -1
  125. package/dist/lib/secrets/bundles.d.ts +35 -0
  126. package/dist/lib/secrets/bundles.js +78 -1
  127. package/dist/lib/secrets/push.d.ts +3 -8
  128. package/dist/lib/secrets/push.js +18 -14
  129. package/dist/lib/secrets/remote.d.ts +9 -18
  130. package/dist/lib/secrets/remote.js +11 -26
  131. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  132. package/dist/lib/secrets/reserved-sync.js +129 -0
  133. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  134. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  135. package/dist/lib/self-heal/registry.js +4 -0
  136. package/dist/lib/self-heal/types.d.ts +1 -1
  137. package/dist/lib/session/active.js +1 -4
  138. package/dist/lib/session/db.d.ts +41 -5
  139. package/dist/lib/session/db.js +132 -30
  140. package/dist/lib/session/discover.d.ts +32 -4
  141. package/dist/lib/session/discover.js +119 -25
  142. package/dist/lib/session/insights.d.ts +14 -0
  143. package/dist/lib/session/insights.js +25 -2
  144. package/dist/lib/session/linear.js +1 -1
  145. package/dist/lib/session/shell-programs.d.ts +17 -0
  146. package/dist/lib/session/shell-programs.js +21 -0
  147. package/dist/lib/session/state.js +2 -1
  148. package/dist/lib/session/stream-render.js +2 -1
  149. package/dist/lib/session/tool-calls.js +2 -5
  150. package/dist/lib/session/trajectory-html.js +2 -1
  151. package/dist/lib/session/trajectory.js +3 -12
  152. package/dist/lib/session/types.d.ts +8 -0
  153. package/dist/lib/share/capture.js +11 -2
  154. package/dist/lib/share/publish.d.ts +56 -5
  155. package/dist/lib/share/publish.js +126 -18
  156. package/dist/lib/share/worker-template.d.ts +3 -12
  157. package/dist/lib/share/worker-template.js +860 -59
  158. package/dist/lib/startup/root-command.js +2 -1
  159. package/dist/lib/state.d.ts +16 -0
  160. package/dist/lib/state.js +178 -46
  161. package/dist/lib/sync-status.d.ts +4 -0
  162. package/dist/lib/sync-status.js +3 -0
  163. package/dist/lib/traces/classify.js +24 -19
  164. package/dist/lib/usage-refresh.js +2 -1
  165. package/dist/lib/view-types.d.ts +7 -0
  166. package/package.json +9 -2
@@ -34,7 +34,7 @@ import { costOfUsage, costOfUsageNoCache } from '../pricing/index.js';
34
34
  import { machineForSessionFile } from './origin-machine.js';
35
35
  export { machineForSessionFile } from './origin-machine.js';
36
36
  import { mapBounded } from '../concurrency.js';
37
- import { getDB, getScanStampByPath, getScanStampsForPaths, getParserStatesForPaths, getDirLedgerForPaths, recordDirScans, recordScans, syncLabels, seedLabelsFromNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, scanInProgressByLivePid, cacheLinearProject, } from './db.js';
37
+ import { getDB, getScanStampByPath, getScanStampsForPaths, getParserStatesForPaths, getDirLedgerForPaths, recordDirScans, recordScans, syncLabels, seedLabelsFromNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, getSessionById, tryClaimScan, releaseScan, scanInProgressByLivePid, cacheLinearProject, CONTENT_INDEX_VERSION, } from './db.js';
38
38
  import { buildRunNameMap } from './run-names.js';
39
39
  import { resolveLinearApiKey } from '../linear-cache.js';
40
40
  import { ToolCallCollector, collectClaudeToolCalls, collectCodexToolCalls, } from './tool-calls.js';
@@ -628,8 +628,15 @@ export function resolveSessionById(sessions, idQuery) {
628
628
  // Content-index search (FTS5-backed)
629
629
  // ---------------------------------------------------------------------------
630
630
  /**
631
- * Run an FTS5 search over the DB and intersect with the given session list,
632
- * preserving the existing SessionMeta[] contract so sessions.ts is unchanged.
631
+ * Run an FTS5 search over the DB and union hits with the given session list.
632
+ *
633
+ * The listing pool is a minority of the index — cwd-scoped, default-capped at
634
+ * 50, and skipping whole classes of indexed transcript — so intersecting FTS
635
+ * hits with it dropped grep-visible sessions that the index already found
636
+ * (PHNX-2767: `agents sessions "tmux pane"` returned 0 while the project
637
+ * transcripts matched). Hits already in the pool keep the caller's SessionMeta;
638
+ * hits the pool missed are hydrated from the index so a content query returns
639
+ * the transcript FTS matched.
633
640
  */
634
641
  export function searchContentIndex(sessions, query) {
635
642
  if (!query.trim())
@@ -640,13 +647,14 @@ export function searchContentIndex(sessions, query) {
640
647
  const byId = new Map(sessions.map(s => [s.id, s]));
641
648
  const result = new Map();
642
649
  for (const hit of hits) {
643
- const session = byId.get(hit.sessionId);
650
+ const session = byId.get(hit.sessionId) ?? getSessionById(hit.sessionId);
644
651
  if (!session)
645
652
  continue;
646
653
  result.set(hit.sessionId, {
647
654
  ...session,
648
655
  _matchedTerms: hit.matchedTerms,
649
656
  _bm25Score: hit.score,
657
+ snippet: hit.snippet,
650
658
  });
651
659
  }
652
660
  return result;
@@ -678,6 +686,13 @@ export function filterChangedFiles(filePaths) {
678
686
  * stat. Same debounce and change-detection as filterChangedFiles; the raw
679
687
  * mtime is floored here so warm files match the ledger exactly as the stat path
680
688
  * does (Math.floor(stat.mtimeMs)).
689
+ *
690
+ * A file is also treated as "changed" — independent of (mtime, size) — when
691
+ * its ledger row's `extractor_version` is behind {@link CONTENT_INDEX_VERSION}.
692
+ * This is the lever that backfills a content-extractor improvement (e.g.
693
+ * indexing assistant answers, not just user prompts) into every already-scanned
694
+ * session on its next pass, without a `DELETE FROM scan_ledger` that would also
695
+ * discard the Claude/Codex resumable parser_state.
681
696
  */
682
697
  export function filterChangedEntries(entries) {
683
698
  const ledger = getScanStampsForPaths(entries.map(e => e.filePath));
@@ -689,10 +704,11 @@ export function filterChangedEntries(entries) {
689
704
  fileSize: entry.fileSize,
690
705
  };
691
706
  const prev = ledger.get(entry.filePath);
692
- if (prev && prev.fileMtimeMs === scan.fileMtimeMs && prev.fileSize === scan.fileSize) {
707
+ const contentIndexStale = prev?.extractorVersion !== CONTENT_INDEX_VERSION;
708
+ if (prev && prev.fileMtimeMs === scan.fileMtimeMs && prev.fileSize === scan.fileSize && !contentIndexStale) {
693
709
  continue;
694
710
  }
695
- if (prev && shouldDeferRecentAppend(prev, scan, now)) {
711
+ if (prev && !contentIndexStale && shouldDeferRecentAppend(prev, scan, now)) {
696
712
  continue;
697
713
  }
698
714
  out.push({ filePath: entry.filePath, scan });
@@ -1000,6 +1016,7 @@ async function scanRoutineArchivesIncremental(agent, onProgress) {
1000
1016
  entries.push({
1001
1017
  meta: result.meta,
1002
1018
  content: result.content,
1019
+ assistantContent: result.assistantContent,
1003
1020
  scan,
1004
1021
  events: result.events,
1005
1022
  toolCalls: result.toolCalls,
@@ -1152,6 +1169,7 @@ async function scanClaudeIncremental(onProgress) {
1152
1169
  entries.push({
1153
1170
  meta: result.meta,
1154
1171
  content: result.content,
1172
+ assistantContent: result.assistantContent,
1155
1173
  scan,
1156
1174
  parserState: result.parserState,
1157
1175
  contentText: result.contentText,
@@ -1185,7 +1203,12 @@ async function scanClaudeIncremental(onProgress) {
1185
1203
  * next scan.
1186
1204
  */
1187
1205
  async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, label) {
1188
- const prior = parsePriorClaudeState(priorRow);
1206
+ // A prior continuation extracted at an older CONTENT_INDEX_VERSION is missing
1207
+ // whatever the current extractor adds (e.g. assistant-answer text) — resuming
1208
+ // from it would fold only the NEWLY appended lines into that gap, never
1209
+ // backfilling the file's earlier assistant text. Treat it as absent so the
1210
+ // file gets one full from-offset-0 reparse instead.
1211
+ const prior = priorRow?.extractorVersion === CONTENT_INDEX_VERSION ? parsePriorClaudeState(priorRow) : null;
1189
1212
  const { scan, newState, toolCalls, mode } = await scanClaudeSessionResumable(filePath, prior, scanStamp.fileMtimeMs, scanStamp.fileSize, priorRow?.fileMtimeMs);
1190
1213
  if (mode === 'incremental')
1191
1214
  claudeIncrementalScanCount++;
@@ -1290,6 +1313,7 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, label) {
1290
1313
  return {
1291
1314
  meta,
1292
1315
  content: scan.contentText || '',
1316
+ assistantContent: scan.assistantText || '',
1293
1317
  // Persist the continuation so the next scan of this file can resume from the
1294
1318
  // offset instead of a full reparse. content_text is the same accumulated user
1295
1319
  // doc, cached so the resume can hydrate userTexts without re-reading the file.
@@ -1431,6 +1455,7 @@ async function scanCodexIncremental(onProgress) {
1431
1455
  entries.push({
1432
1456
  meta: result.meta,
1433
1457
  content: result.content,
1458
+ assistantContent: result.assistantContent,
1434
1459
  scan,
1435
1460
  parserState: result.parserState,
1436
1461
  contentText: result.contentText,
@@ -1541,7 +1566,10 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion, sc
1541
1566
  let toolCalls;
1542
1567
  let toolIndexMode = 'replace';
1543
1568
  if (scanStamp) {
1544
- const prior = parsePriorCodexState(priorRow);
1569
+ // See readClaudeMeta: a prior continuation from an older content extractor
1570
+ // is missing what the current extractor adds, so it must not be resumed
1571
+ // from — treat it as absent and force one full reparse.
1572
+ const prior = priorRow?.extractorVersion === CONTENT_INDEX_VERSION ? parsePriorCodexState(priorRow) : null;
1545
1573
  const result = await scanCodexSessionResumable(filePath, prior, scanStamp.fileMtimeMs, scanStamp.fileSize, priorRow?.fileMtimeMs);
1546
1574
  if (result.mode === 'incremental')
1547
1575
  codexIncrementalScanCount++;
@@ -1597,6 +1625,7 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion, sc
1597
1625
  return {
1598
1626
  meta,
1599
1627
  content: scan.contentText || '',
1628
+ assistantContent: scan.assistantText || '',
1600
1629
  // Persist the continuation so the next scan of this rollout resumes from the
1601
1630
  // offset instead of a full reparse; content_text caches the accumulated user
1602
1631
  // doc for the resume's hydrate. Absent when no stamp was supplied.
@@ -1675,7 +1704,7 @@ async function scanGeminiIncremental(onProgress) {
1675
1704
  const result = readGeminiMeta(filePath, hashDir, projectMap, currentVersion);
1676
1705
  if (result && !seen.has(result.meta.id)) {
1677
1706
  seen.add(result.meta.id);
1678
- entries.push({ meta: result.meta, content: result.content, scan });
1707
+ entries.push({ meta: result.meta, content: result.content, assistantContent: result.assistantContent, scan });
1679
1708
  }
1680
1709
  else {
1681
1710
  // Gemini file without a sessionId — record scan so we don't re-parse it next run.
@@ -1729,6 +1758,7 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
1729
1758
  let firstTsMs;
1730
1759
  let lastTsMs;
1731
1760
  const userTexts = [];
1761
+ const assistantTexts = [];
1732
1762
  for (const message of messages) {
1733
1763
  if (message.type === 'user') {
1734
1764
  const text = extractGeminiMessageText(message.content);
@@ -1740,8 +1770,10 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
1740
1770
  }
1741
1771
  }
1742
1772
  else if (message.type === 'gemini') {
1743
- if (extractGeminiMessageText(message.content)) {
1773
+ const text = extractGeminiMessageText(message.content);
1774
+ if (text) {
1744
1775
  messageCount++;
1776
+ assistantTexts.push(text);
1745
1777
  }
1746
1778
  }
1747
1779
  // Duration: messages carry a `timestamp` on most Gemini CLI versions.
@@ -1819,7 +1851,7 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
1819
1851
  costUsdNoCache: sawCost ? costUsdNoCache : undefined,
1820
1852
  durationMs,
1821
1853
  };
1822
- return { meta, content: userTexts.join('\n') };
1854
+ return { meta, content: userTexts.join('\n'), assistantContent: assistantTexts.join('\n') };
1823
1855
  }
1824
1856
  /** Build a hash-to-project mapping from Gemini's projects.json and history directories. */
1825
1857
  function buildGeminiProjectMap() {
@@ -2396,7 +2428,7 @@ async function scanRushIncremental(onProgress) {
2396
2428
  const sessionId = path.basename(path.dirname(filePath));
2397
2429
  const result = await readRushMeta(filePath, sessionId);
2398
2430
  if (result) {
2399
- entries.push({ meta: result.meta, content: result.content, scan });
2431
+ entries.push({ meta: result.meta, content: result.content, assistantContent: result.assistantContent, scan });
2400
2432
  }
2401
2433
  else {
2402
2434
  touched.push({ filePath, scan });
@@ -2428,7 +2460,7 @@ async function readRushMeta(filePath, sessionId) {
2428
2460
  topic: scan.topic,
2429
2461
  messageCount: scan.messageCount,
2430
2462
  };
2431
- return { meta, content: scan.contentText || '' };
2463
+ return { meta, content: scan.contentText || '', assistantContent: scan.assistantText || '' };
2432
2464
  }
2433
2465
  /** Stream a Rush messages.jsonl file and extract scan-level metadata. */
2434
2466
  async function scanRushSession(filePath) {
@@ -2439,6 +2471,7 @@ async function scanRushSession(filePath) {
2439
2471
  let agentId;
2440
2472
  let messageCount = 0;
2441
2473
  const userTexts = [];
2474
+ const assistantTexts = [];
2442
2475
  try {
2443
2476
  for await (const line of rl) {
2444
2477
  if (!line.trim())
@@ -2475,6 +2508,9 @@ async function scanRushSession(filePath) {
2475
2508
  if (!topic)
2476
2509
  topic = extractSessionTopic(cleaned);
2477
2510
  }
2511
+ else if (parsed.role === 'assistant') {
2512
+ assistantTexts.push(cleaned);
2513
+ }
2478
2514
  }
2479
2515
  }
2480
2516
  finally {
@@ -2487,6 +2523,7 @@ async function scanRushSession(filePath) {
2487
2523
  agentId,
2488
2524
  messageCount,
2489
2525
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
2526
+ assistantText: assistantTexts.length > 0 ? assistantTexts.join('\n') : undefined,
2490
2527
  };
2491
2528
  }
2492
2529
  // ---------------------------------------------------------------------------
@@ -2529,7 +2566,7 @@ async function scanHermesIncremental(onProgress) {
2529
2566
  const result = readHermesMeta(filePath);
2530
2567
  if (result && !seen.has(result.meta.id)) {
2531
2568
  seen.add(result.meta.id);
2532
- scanEntries.push({ meta: result.meta, content: result.content, scan });
2569
+ scanEntries.push({ meta: result.meta, content: result.content, assistantContent: result.assistantContent, scan });
2533
2570
  }
2534
2571
  else {
2535
2572
  touched.push({ filePath, scan });
@@ -2558,6 +2595,7 @@ function readHermesMeta(filePath) {
2558
2595
  return null;
2559
2596
  const messages = Array.isArray(session.messages) ? session.messages : [];
2560
2597
  const userTexts = [];
2598
+ const assistantTexts = [];
2561
2599
  let topic;
2562
2600
  let messageCount = 0;
2563
2601
  for (const msg of messages) {
@@ -2570,6 +2608,9 @@ function readHermesMeta(filePath) {
2570
2608
  if (!topic)
2571
2609
  topic = extractSessionTopic(text);
2572
2610
  }
2611
+ else if (msg?.role === 'assistant') {
2612
+ assistantTexts.push(text);
2613
+ }
2573
2614
  }
2574
2615
  const stat = safeStatSync(filePath);
2575
2616
  const timestamp = typeof session.last_updated === 'string'
@@ -2592,7 +2633,7 @@ function readHermesMeta(filePath) {
2592
2633
  topic,
2593
2634
  messageCount: messageCount || (typeof session.message_count === 'number' ? session.message_count : undefined),
2594
2635
  };
2595
- return { meta, content: userTexts.join('\n') };
2636
+ return { meta, content: userTexts.join('\n'), assistantContent: assistantTexts.join('\n') };
2596
2637
  }
2597
2638
  /**
2598
2639
  * Muse Code stores one session per directory under
@@ -2660,7 +2701,7 @@ function scanMuseIncremental(onProgress) {
2660
2701
  const result = readMuseMeta(filePath);
2661
2702
  if (result && !seen.has(result.meta.id)) {
2662
2703
  seen.add(result.meta.id);
2663
- scanEntries.push({ meta: result.meta, content: result.content, scan });
2704
+ scanEntries.push({ meta: result.meta, content: result.content, assistantContent: result.assistantContent, scan });
2664
2705
  }
2665
2706
  else {
2666
2707
  touched.push({ filePath, scan });
@@ -2690,6 +2731,7 @@ function readMuseMeta(filePath) {
2690
2731
  return null;
2691
2732
  }
2692
2733
  const userTexts = [];
2734
+ const assistantTexts = [];
2693
2735
  let topic;
2694
2736
  let messageCount = 0;
2695
2737
  let model;
@@ -2734,6 +2776,9 @@ function readMuseMeta(filePath) {
2734
2776
  const event = payload?.event ?? payload;
2735
2777
  if (event?.kind === 'assistant_message_committed' && typeof event.text === 'string') {
2736
2778
  messageCount++;
2779
+ const text = event.text.trim();
2780
+ if (text)
2781
+ assistantTexts.push(text);
2737
2782
  }
2738
2783
  if (event?.kind === 'model_request_configured' && typeof event.model === 'string') {
2739
2784
  model = event.model;
@@ -2757,7 +2802,7 @@ function readMuseMeta(filePath) {
2757
2802
  topic,
2758
2803
  messageCount: messageCount || undefined,
2759
2804
  };
2760
- return { meta, content: userTexts.join('\n') };
2805
+ return { meta, content: userTexts.join('\n'), assistantContent: assistantTexts.join('\n') };
2761
2806
  }
2762
2807
  /** Extract plain text from a Hermes message content field (string or list of parts). */
2763
2808
  function extractHermesMessageText(content) {
@@ -2805,7 +2850,7 @@ async function scanDroidIncremental(onProgress) {
2805
2850
  const result = await readDroidMeta(filePath, currentVersion);
2806
2851
  if (result && !seen.has(result.meta.id)) {
2807
2852
  seen.add(result.meta.id);
2808
- entries.push({ meta: result.meta, content: result.content, scan });
2853
+ entries.push({ meta: result.meta, content: result.content, assistantContent: result.assistantContent, scan });
2809
2854
  }
2810
2855
  else {
2811
2856
  touched.push({ filePath, scan });
@@ -2868,7 +2913,7 @@ async function readDroidMeta(filePath, currentVersion) {
2868
2913
  costUsdNoCache: costUsd > 0 ? costUsdNoCache : undefined,
2869
2914
  durationMs: scan.durationMs,
2870
2915
  };
2871
- return { meta, content: scan.contentText || '' };
2916
+ return { meta, content: scan.contentText || '', assistantContent: scan.assistantText || '' };
2872
2917
  }
2873
2918
  /** Read model + token usage from a Droid `<uuid>.settings.json` sidecar. */
2874
2919
  function readDroidSettings(settingsPath) {
@@ -2911,6 +2956,7 @@ async function scanDroidSession(filePath) {
2911
2956
  let firstTsMs;
2912
2957
  let lastTsMs;
2913
2958
  const userTexts = [];
2959
+ const assistantTexts = [];
2914
2960
  try {
2915
2961
  for await (const line of rl) {
2916
2962
  if (!line.trim())
@@ -2960,6 +3006,9 @@ async function scanDroidSession(filePath) {
2960
3006
  if (!firstUserTopic)
2961
3007
  firstUserTopic = extractSessionTopic(text);
2962
3008
  }
3009
+ else if (msg.role === 'assistant') {
3010
+ assistantTexts.push(text);
3011
+ }
2963
3012
  }
2964
3013
  }
2965
3014
  finally {
@@ -2981,6 +3030,7 @@ async function scanDroidSession(filePath) {
2981
3030
  durationMs,
2982
3031
  lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
2983
3032
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
3033
+ assistantText: assistantTexts.length > 0 ? assistantTexts.join('\n') : undefined,
2984
3034
  };
2985
3035
  }
2986
3036
  /** Extract plain text from a Droid message content field (Anthropic-shaped blocks). */
@@ -3026,6 +3076,7 @@ export function initClaudeParseState() {
3026
3076
  lastTsMs: undefined,
3027
3077
  seenAssistantIds: new Set(),
3028
3078
  userTexts: [],
3079
+ assistantTexts: [],
3029
3080
  sawPrCreate: false,
3030
3081
  prUrl: undefined,
3031
3082
  prNumber: undefined,
@@ -3217,6 +3268,9 @@ export function applyClaudeLine(state, parsed) {
3217
3268
  return;
3218
3269
  state.seenAssistantIds.add(logicalId);
3219
3270
  state.messageCount++;
3271
+ const assistantText = extractClaudeAssistantText(parsed);
3272
+ if (assistantText)
3273
+ state.assistantTexts.push(assistantText);
3220
3274
  const usageObj = parsed.message?.usage || parsed.usage;
3221
3275
  const usage = getClaudeUsageTotal(usageObj);
3222
3276
  if (usage !== null) {
@@ -3295,6 +3349,7 @@ export function finalizeClaudeScan(state) {
3295
3349
  durationMs,
3296
3350
  lastActivity: state.lastTsMs !== undefined ? new Date(state.lastTsMs).toISOString() : undefined,
3297
3351
  contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
3352
+ assistantText: state.assistantTexts.length > 0 ? state.assistantTexts.join('\n') : undefined,
3298
3353
  prUrl: state.prUrl,
3299
3354
  prNumber: state.prNumber,
3300
3355
  worktreeSlug: worktree?.slug,
@@ -3349,7 +3404,7 @@ export function serializeClaudeParserState(state, offset, jsonlDroppingOversized
3349
3404
  : allIds;
3350
3405
  const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
3351
3406
  return {
3352
- v: 4,
3407
+ v: 5,
3353
3408
  subAgents: state.subAgents,
3354
3409
  backgroundShells: state.backgroundShells,
3355
3410
  offset,
@@ -3391,6 +3446,7 @@ export function serializeClaudeParserState(state, offset, jsonlDroppingOversized
3391
3446
  // not be persisted.
3392
3447
  ticketId: ticket?.id,
3393
3448
  contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
3449
+ assistantContentText: state.assistantTexts.length > 0 ? state.assistantTexts.join('\n') : undefined,
3394
3450
  checklistEvents: state.checklistEvents,
3395
3451
  recentDirectoriesTouched: state.recentDirectoriesTouched,
3396
3452
  toolCalls: state.toolCollector.snapshot(),
@@ -3449,6 +3505,9 @@ export function hydrateClaudeParseState(prior) {
3449
3505
  lastTsMs: prior.lastTsMs,
3450
3506
  seenAssistantIds: seen,
3451
3507
  userTexts: prior.contentText !== undefined && prior.contentText.length > 0 ? [prior.contentText] : [],
3508
+ assistantTexts: prior.assistantContentText !== undefined && prior.assistantContentText.length > 0
3509
+ ? [prior.assistantContentText]
3510
+ : [],
3452
3511
  sawPrCreate: prior.sawPrCreate,
3453
3512
  prUrl: prior.prUrl,
3454
3513
  prNumber: prior.prNumber,
@@ -3592,7 +3651,7 @@ function parsePriorClaudeState(row) {
3592
3651
  return null;
3593
3652
  try {
3594
3653
  const parsed = JSON.parse(row.parserState);
3595
- if (parsed?.v !== 4 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
3654
+ if (parsed?.v !== 5 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
3596
3655
  return null;
3597
3656
  return parsed;
3598
3657
  }
@@ -3629,6 +3688,7 @@ export function initCodexParseState() {
3629
3688
  firstTsMs: undefined,
3630
3689
  lastTsMs: undefined,
3631
3690
  userTexts: [],
3691
+ assistantTexts: [],
3632
3692
  sawPrCreate: false,
3633
3693
  prUrl: undefined,
3634
3694
  prNumber: undefined,
@@ -3738,6 +3798,9 @@ function applyCodexLine(state, parsed) {
3738
3798
  if (!state.topic)
3739
3799
  state.topic = extractSessionTopic(text);
3740
3800
  }
3801
+ else {
3802
+ state.assistantTexts.push(text);
3803
+ }
3741
3804
  return;
3742
3805
  }
3743
3806
  if (parsed.type === 'event_msg' && parsed.payload?.type === 'token_count') {
@@ -3813,6 +3876,7 @@ function finalizeCodexScan(state) {
3813
3876
  durationMs,
3814
3877
  lastActivity: state.lastTsMs !== undefined ? new Date(state.lastTsMs).toISOString() : undefined,
3815
3878
  contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
3879
+ assistantText: state.assistantTexts.length > 0 ? state.assistantTexts.join('\n') : undefined,
3816
3880
  prUrl: state.prUrl,
3817
3881
  prNumber: state.prNumber,
3818
3882
  worktreeSlug: worktree?.slug,
@@ -3856,7 +3920,7 @@ async function scanCodexSession(filePath) {
3856
3920
  export function serializeCodexParserState(state, offset, jsonlDroppingOversizedLine = false) {
3857
3921
  const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
3858
3922
  return {
3859
- v: 2,
3923
+ v: 3,
3860
3924
  offset,
3861
3925
  jsonlDroppingOversizedLine: jsonlDroppingOversizedLine || undefined,
3862
3926
  sessionId: state.sessionId,
@@ -3883,6 +3947,7 @@ export function serializeCodexParserState(state, offset, jsonlDroppingOversizedL
3883
3947
  // be persisted.
3884
3948
  ticketId: ticket?.id,
3885
3949
  contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
3950
+ assistantContentText: state.assistantTexts.length > 0 ? state.assistantTexts.join('\n') : undefined,
3886
3951
  checklistEvents: state.checklistEvents,
3887
3952
  recentDirectoriesTouched: state.recentDirectoriesTouched,
3888
3953
  toolCalls: state.toolCollector.snapshot(),
@@ -3913,6 +3978,9 @@ function hydrateCodexParseState(prior) {
3913
3978
  firstTsMs: prior.firstTsMs,
3914
3979
  lastTsMs: prior.lastTsMs,
3915
3980
  userTexts: prior.contentText !== undefined && prior.contentText.length > 0 ? [prior.contentText] : [],
3981
+ assistantTexts: prior.assistantContentText !== undefined && prior.assistantContentText.length > 0
3982
+ ? [prior.assistantContentText]
3983
+ : [],
3916
3984
  sawPrCreate: prior.sawPrCreate,
3917
3985
  prUrl: prior.prUrl,
3918
3986
  prNumber: prior.prNumber,
@@ -4043,7 +4111,7 @@ function parsePriorCodexState(row) {
4043
4111
  return null;
4044
4112
  try {
4045
4113
  const parsed = JSON.parse(row.parserState);
4046
- if (parsed?.v !== 2 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
4114
+ if (parsed?.v !== 3 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
4047
4115
  return null;
4048
4116
  return parsed;
4049
4117
  }
@@ -4144,6 +4212,29 @@ function extractClaudeUserText(parsed) {
4144
4212
  function isLocalCommandMessage(text) {
4145
4213
  return /<local-command-caveat>|<bash-(input|stdout|stderr)>/i.test(text);
4146
4214
  }
4215
+ /**
4216
+ * Extract the assistant's own answer text from a Claude JSONL assistant event —
4217
+ * the reply text blocks, skipping tool_use/tool_result blocks and an
4218
+ * interrupted turn, mirroring {@link extractClaudeUserText}'s filters. Unlike
4219
+ * the user side (which keeps only the first text block), a single assistant
4220
+ * turn can legitimately carry multiple text blocks around tool calls, so every
4221
+ * one is joined.
4222
+ */
4223
+ function extractClaudeAssistantText(parsed) {
4224
+ const content = parsed.message?.content;
4225
+ if (typeof content === 'string') {
4226
+ const text = content.trim();
4227
+ return text && !text.startsWith('[Request interrupted') ? text : undefined;
4228
+ }
4229
+ if (!Array.isArray(content))
4230
+ return undefined;
4231
+ const text = content
4232
+ .filter((block) => block.type === 'text')
4233
+ .map((block) => String(block.text || '').trim())
4234
+ .filter((value) => value && !value.startsWith('[Request interrupted'))
4235
+ .join('\n');
4236
+ return text || undefined;
4237
+ }
4147
4238
  /** Sum all token usage fields from a Claude assistant message's usage object. */
4148
4239
  function getClaudeUsageTotal(usage) {
4149
4240
  if (!usage || typeof usage !== 'object')
@@ -4308,7 +4399,7 @@ async function scanCursorIncremental(onProgress) {
4308
4399
  const result = readCursorMeta(filePath, currentVersion);
4309
4400
  if (result && !seen.has(result.meta.id)) {
4310
4401
  seen.add(result.meta.id);
4311
- entries.push({ meta: result.meta, content: result.content, scan, events: result.events });
4402
+ entries.push({ meta: result.meta, content: result.content, assistantContent: result.assistantContent, scan, events: result.events });
4312
4403
  }
4313
4404
  else {
4314
4405
  touched.push({ filePath, scan });
@@ -4385,6 +4476,9 @@ export function readCursorMeta(filePath, currentVersion) {
4385
4476
  const userTexts = events
4386
4477
  .filter((event) => event.type === 'message' && event.role === 'user' && event.content)
4387
4478
  .map((event) => event.content);
4479
+ const assistantTexts = events
4480
+ .filter((event) => event.type === 'message' && event.role === 'assistant' && event.content)
4481
+ .map((event) => event.content);
4388
4482
  const firstUserText = userTexts[0];
4389
4483
  const title = typeof chatMeta?.title === 'string'
4390
4484
  ? cleanGeneratedSessionLabel(chatMeta.title)
@@ -4404,7 +4498,7 @@ export function readCursorMeta(filePath, currentVersion) {
4404
4498
  messageCount: events.filter((event) => event.type === 'message').length,
4405
4499
  todos: extractTodoProgressFromEvents(events),
4406
4500
  };
4407
- return { meta, content: userTexts.join('\n'), events };
4501
+ return { meta, content: userTexts.join('\n'), assistantContent: assistantTexts.join('\n'), events };
4408
4502
  }
4409
4503
  // ---------------------------------------------------------------------------
4410
4504
  // Kimi
@@ -85,6 +85,20 @@ export interface InsightFacets {
85
85
  assistantTurns: number;
86
86
  toolCount: number;
87
87
  errorCount: number;
88
+ /**
89
+ * Shell invocations bucketed by the actual executable + first subcommand token, not
90
+ * lumped under the harness tool name. `bucketKey` collapses each command to
91
+ * `git commit`, `gh pr`, `agents ssh`, `find`, `ssh→git pull`, … so the tool mix says
92
+ * which binary ran, not just that `Bash` did. Cross-harness: keyed on `e.command`,
93
+ * which the codex parser sets for `exec_command` too, so it is not Claude-`Bash`-only.
94
+ */
95
+ bashCommands: Record<string, number>;
96
+ /**
97
+ * Same bucketing for the shell command that was running when a tool call failed —
98
+ * turns one `failed tool loop: Bash` line into per-binary failure counts
99
+ * (`git reconcile`, `find`, `gh`) so the friction is attributable to a command.
100
+ */
101
+ bashCommandFailures: Record<string, number>;
88
102
  /** Deterministic evidence buckets used by the actions-forward report. */
89
103
  frictionSignals: Record<string, number>;
90
104
  correctionSignals: Record<string, number>;
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { computeSummaryStats, shortenModel } from './render.js';
24
24
  import { classifyFileChanges, EDIT_TOOLS, WRITE_TOOLS } from './digest.js';
25
+ import { bucketKey } from './bash-command.js';
25
26
  /** File extension → language label. Mirrors the set `/insights` attributes by. */
26
27
  const LANGUAGE_BY_EXT = {
27
28
  '.ts': 'TypeScript', '.tsx': 'TypeScript', '.mts': 'TypeScript', '.cts': 'TypeScript',
@@ -82,6 +83,7 @@ function emptyFacets() {
82
83
  shellCommandsSeen: 0,
83
84
  messageHours: new Array(24).fill(0), userTurns: 0, assistantTurns: 0,
84
85
  toolCount: 0, errorCount: 0,
86
+ bashCommands: {}, bashCommandFailures: {},
85
87
  frictionSignals: {}, correctionSignals: {}, automationSignals: {},
86
88
  };
87
89
  }
@@ -172,6 +174,9 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
172
174
  /** Shortened model id of the last assistant activity (for stall attribution). */
173
175
  let lastAssistantModel = null;
174
176
  let lastFailedTool = null;
177
+ // Most recent shell command seen per tool, so an `error` event (which carries the
178
+ // failing tool name but not its command) can attribute the failure to a binary.
179
+ const lastCommandByTool = {};
175
180
  for (const e of events) {
176
181
  const ts = new Date(e.timestamp).getTime();
177
182
  const hasTs = !Number.isNaN(ts);
@@ -191,16 +196,27 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
191
196
  lastAssistantModel = m;
192
197
  }
193
198
  break;
194
- case 'error':
199
+ case 'error': {
195
200
  bump(f.errorCategories, categorizeError(e.content ?? e.output ?? ''));
196
201
  if (e.tool && e.tool === lastFailedTool)
197
202
  bump(f.frictionSignals, `failed tool loop: ${e.tool}`);
203
+ // Attribute the failure to the binary of that tool's most recent command,
204
+ // so `Bash` failures split into `git reconcile`/`find`/`gh` rather than one lump.
205
+ const failedCmd = e.tool ? lastCommandByTool[e.tool] : undefined;
206
+ if (failedCmd)
207
+ bump(f.bashCommandFailures, bucketKey(failedCmd));
198
208
  lastFailedTool = e.tool ?? null;
199
209
  classifyFriction(e.content ?? e.output ?? '', f.frictionSignals);
200
210
  break;
211
+ }
201
212
  case 'tool_result':
202
- if (e.success !== false && e.outcome !== 'error')
213
+ if (e.success !== false && e.outcome !== 'error') {
203
214
  lastFailedTool = null;
215
+ // Drop the succeeded tool's remembered command so a later failing call that
216
+ // carries no command of its own is not misattributed to this stale binary.
217
+ if (e.tool)
218
+ delete lastCommandByTool[e.tool];
219
+ }
204
220
  break;
205
221
  case 'message':
206
222
  if (e.role === 'assistant') {
@@ -277,6 +293,9 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
277
293
  f.gitCommits += countGitOp(e.command, 'commit');
278
294
  f.gitPushes += countGitOp(e.command, 'push');
279
295
  classifyAutomation(e.command, f.automationSignals);
296
+ bump(f.bashCommands, bucketKey(e.command));
297
+ if (toolName)
298
+ lastCommandByTool[toolName] = e.command;
280
299
  }
281
300
  break;
282
301
  }
@@ -430,6 +449,10 @@ export function mergeFacets(into, add) {
430
449
  bump(into.slashCommands, k, v);
431
450
  for (const [k, v] of Object.entries(add.errorCategories))
432
451
  bump(into.errorCategories, k, v);
452
+ for (const [k, v] of Object.entries(add.bashCommands ?? {}))
453
+ bump(into.bashCommands, k, v);
454
+ for (const [k, v] of Object.entries(add.bashCommandFailures ?? {}))
455
+ bump(into.bashCommandFailures, k, v);
433
456
  for (const [k, v] of Object.entries(add.frictionSignals ?? {}))
434
457
  bump(into.frictionSignals, k, v);
435
458
  for (const [k, v] of Object.entries(add.correctionSignals ?? {}))
@@ -9,7 +9,7 @@
9
9
  * 2. `workspaceUrlKey` in the linear-cli config (`~/.linear-cli/config.json`).
10
10
  *
11
11
  * When neither is set the ticket stays plain text — it's still shown, just not
12
- * linked. This keeps the OSS CLI free of any Rush/workspace-specific string.
12
+ * linked. This keeps the published CLI free of any Rush/workspace-specific string.
13
13
  */
14
14
  import fs from 'node:fs';
15
15
  import os from 'node:os';
@@ -9,6 +9,23 @@ export interface ShellProgramOccurrence {
9
9
  program: string;
10
10
  role: ShellProgramRole;
11
11
  }
12
+ /**
13
+ * Harness tool NAMES that run a shell command — Claude's `Bash`, Codex's
14
+ * `exec_command`/`exec` (`exec` is newer gpt-5.6-sol), Droid's `Execute`, plus the
15
+ * generic `shell`/`run_shell_command`/`run_command`/`execute`. This is the single
16
+ * source of truth for "does this tool row carry a shell command?", consumed by the
17
+ * trajectory model (program resolution + command-arg extraction), the directory-touched
18
+ * scan in `state.ts`, the tool-call indexer, and stream rendering — replacing five
19
+ * hand-synced copies that had already drifted apart.
20
+ *
21
+ * Distinct from {@link SHELL_WRAPPERS}, which is interpreter binaries (`bash`, `sh`…)
22
+ * found INSIDE a command string, not the harness's tool name. Match through
23
+ * {@link isShellExecTool} so casing is normalized in one place — harnesses disagree on
24
+ * case (`Bash` vs `bash`, `Execute` vs `exec_command`).
25
+ */
26
+ export declare const SHELL_EXEC_TOOLS: Set<string>;
27
+ /** Case-insensitive membership test for {@link SHELL_EXEC_TOOLS}. */
28
+ export declare function isShellExecTool(tool: string | undefined): boolean;
12
29
  /** Return a word only when Bash expansion cannot change its value. */
13
30
  export declare function staticShellWord(word: Word | undefined): string | undefined;
14
31
  /** Parse Bash without executing it and return every statically identifiable program. */
@@ -1,5 +1,26 @@
1
1
  import path from 'path';
2
2
  import { parse, } from 'unbash';
3
+ /**
4
+ * Harness tool NAMES that run a shell command — Claude's `Bash`, Codex's
5
+ * `exec_command`/`exec` (`exec` is newer gpt-5.6-sol), Droid's `Execute`, plus the
6
+ * generic `shell`/`run_shell_command`/`run_command`/`execute`. This is the single
7
+ * source of truth for "does this tool row carry a shell command?", consumed by the
8
+ * trajectory model (program resolution + command-arg extraction), the directory-touched
9
+ * scan in `state.ts`, the tool-call indexer, and stream rendering — replacing five
10
+ * hand-synced copies that had already drifted apart.
11
+ *
12
+ * Distinct from {@link SHELL_WRAPPERS}, which is interpreter binaries (`bash`, `sh`…)
13
+ * found INSIDE a command string, not the harness's tool name. Match through
14
+ * {@link isShellExecTool} so casing is normalized in one place — harnesses disagree on
15
+ * case (`Bash` vs `bash`, `Execute` vs `exec_command`).
16
+ */
17
+ export const SHELL_EXEC_TOOLS = new Set([
18
+ 'bash', 'exec', 'execute', 'exec_command', 'run_command', 'run_shell_command', 'shell',
19
+ ]);
20
+ /** Case-insensitive membership test for {@link SHELL_EXEC_TOOLS}. */
21
+ export function isShellExecTool(tool) {
22
+ return tool !== undefined && SHELL_EXEC_TOOLS.has(tool.toLowerCase());
23
+ }
3
24
  const SHELL_WRAPPERS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh']);
4
25
  const PROGRAM_WRAPPERS = new Set(['command', 'builtin', 'env', 'nohup', 'sudo']);
5
26
  const MAX_NESTED_DEPTH = 3;