@phnx-labs/agents-cli 1.22.66 → 1.22.69

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 (161) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +21 -8
  3. package/dist/bootstrap.js +6 -3
  4. package/dist/commands/browser.js +46 -21
  5. package/dist/commands/daemon-test-harness.d.ts +1 -1
  6. package/dist/commands/daemon-test-harness.js +2 -2
  7. package/dist/commands/daemon.js +21 -22
  8. package/dist/commands/exec.js +50 -0
  9. package/dist/commands/feed.js +20 -7
  10. package/dist/commands/monitors.js +5 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/repo.js +57 -19
  14. package/dist/commands/resume.d.ts +16 -0
  15. package/dist/commands/resume.js +41 -8
  16. package/dist/commands/routines.js +42 -21
  17. package/dist/commands/send.js +29 -2
  18. package/dist/commands/sessions-inject.d.ts +58 -0
  19. package/dist/commands/sessions-inject.js +143 -7
  20. package/dist/commands/sessions-optimize.js +1 -1
  21. package/dist/commands/sessions-picker.js +1 -0
  22. package/dist/commands/sessions.js +4 -11
  23. package/dist/commands/share.d.ts +5 -3
  24. package/dist/commands/share.js +73 -20
  25. package/dist/commands/ssh.js +205 -2
  26. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  27. package/dist/lib/accounting/account-pool-collect.js +6 -4
  28. package/dist/lib/accounting/usage-ingest.js +4 -2
  29. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  30. package/dist/lib/accounting/usage-sync.js +66 -210
  31. package/dist/lib/accounting/usage.d.ts +18 -5
  32. package/dist/lib/accounting/usage.js +165 -20
  33. package/dist/lib/auth-health.d.ts +8 -0
  34. package/dist/lib/auth-health.js +4 -4
  35. package/dist/lib/boot-profile.d.ts +14 -0
  36. package/dist/lib/boot-profile.js +66 -0
  37. package/dist/lib/browser/caller-identity.d.ts +12 -0
  38. package/dist/lib/browser/caller-identity.js +19 -0
  39. package/dist/lib/browser/ipc.d.ts +37 -32
  40. package/dist/lib/browser/ipc.js +146 -94
  41. package/dist/lib/browser/task-index.d.ts +10 -2
  42. package/dist/lib/browser/task-index.js +22 -3
  43. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  44. package/dist/lib/channels/providers/desktop.js +5 -4
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  47. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  48. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  49. package/dist/lib/daemon/auth-sync-service.js +17 -6
  50. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  51. package/dist/lib/daemon/catchup-service.js +51 -0
  52. package/dist/lib/daemon/daemon.d.ts +12 -22
  53. package/dist/lib/daemon/daemon.js +463 -176
  54. package/dist/lib/daemon/runner.js +2 -0
  55. package/dist/lib/daemon/service.d.ts +22 -4
  56. package/dist/lib/daemon/service.js +2 -2
  57. package/dist/lib/daemon/supervisor.d.ts +55 -15
  58. package/dist/lib/daemon/supervisor.js +119 -29
  59. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  60. package/dist/lib/daemon/usage-sync-service.js +22 -18
  61. package/dist/lib/daemon-health.js +36 -31
  62. package/dist/lib/daemon-services.d.ts +1 -1
  63. package/dist/lib/daemon-services.js +12 -2
  64. package/dist/lib/daemon-ticks.d.ts +9 -6
  65. package/dist/lib/daemon-ticks.js +14 -8
  66. package/dist/lib/devices/health.d.ts +38 -2
  67. package/dist/lib/devices/health.js +43 -5
  68. package/dist/lib/devices/registry.js +2 -0
  69. package/dist/lib/devices/worker-pick.d.ts +1 -1
  70. package/dist/lib/devices/worker-pick.js +4 -1
  71. package/dist/lib/exec.js +15 -0
  72. package/dist/lib/feed/watch.d.ts +3 -0
  73. package/dist/lib/feed/watch.js +13 -3
  74. package/dist/lib/feed-broadcast.d.ts +64 -5
  75. package/dist/lib/feed-broadcast.js +124 -22
  76. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  77. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  78. package/dist/lib/fleet-shared-state.d.ts +38 -0
  79. package/dist/lib/fleet-shared-state.js +105 -0
  80. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  81. package/dist/lib/hosts/remote-cmd.js +12 -3
  82. package/dist/lib/lock-compromise.d.ts +8 -0
  83. package/dist/lib/lock-compromise.js +12 -0
  84. package/dist/lib/monitors/engine.d.ts +2 -1
  85. package/dist/lib/monitors/engine.js +27 -2
  86. package/dist/lib/monitors/sources/command.js +13 -3
  87. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  88. package/dist/lib/monitors/sources/failure.js +52 -0
  89. package/dist/lib/monitors/sources/types.d.ts +9 -0
  90. package/dist/lib/owner-message.d.ts +12 -0
  91. package/dist/lib/owner-message.js +44 -0
  92. package/dist/lib/refresh-coordinator.js +2 -0
  93. package/dist/lib/run-trace-sync.d.ts +28 -0
  94. package/dist/lib/run-trace-sync.js +99 -0
  95. package/dist/lib/secrets/filestore.d.ts +4 -0
  96. package/dist/lib/secrets/filestore.js +164 -3
  97. package/dist/lib/secrets/push.d.ts +10 -0
  98. package/dist/lib/secrets/push.js +86 -7
  99. package/dist/lib/secrets/remote.d.ts +18 -6
  100. package/dist/lib/secrets/remote.js +29 -4
  101. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  102. package/dist/lib/secrets/reserved-sync.js +119 -101
  103. package/dist/lib/session/active.d.ts +13 -1
  104. package/dist/lib/session/active.js +5 -0
  105. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  106. package/dist/lib/session/actor-sidecar.js +2 -0
  107. package/dist/lib/session/db.d.ts +16 -2
  108. package/dist/lib/session/db.js +73 -22
  109. package/dist/lib/session/discover.d.ts +12 -3
  110. package/dist/lib/session/discover.js +187 -26
  111. package/dist/lib/session/linear.d.ts +13 -0
  112. package/dist/lib/session/linear.js +44 -0
  113. package/dist/lib/session/live-metadata.js +1 -0
  114. package/dist/lib/session/parse.js +2 -3
  115. package/dist/lib/session/prompt.d.ts +17 -0
  116. package/dist/lib/session/prompt.js +35 -0
  117. package/dist/lib/session/recovery.d.ts +43 -6
  118. package/dist/lib/session/recovery.js +80 -10
  119. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  120. package/dist/lib/session/remote/remote-list.js +29 -4
  121. package/dist/lib/session/remote/watch.d.ts +25 -2
  122. package/dist/lib/session/remote/watch.js +188 -11
  123. package/dist/lib/session/session-cache.d.ts +2 -1
  124. package/dist/lib/session/session-cache.js +1 -0
  125. package/dist/lib/session/state.js +11 -13
  126. package/dist/lib/session/types.d.ts +2 -0
  127. package/dist/lib/share/backend.d.ts +2 -2
  128. package/dist/lib/share/backend.js +20 -9
  129. package/dist/lib/share/delete.d.ts +5 -1
  130. package/dist/lib/share/delete.js +7 -2
  131. package/dist/lib/share/http-error.d.ts +52 -0
  132. package/dist/lib/share/http-error.js +65 -0
  133. package/dist/lib/share/publish.d.ts +67 -11
  134. package/dist/lib/share/publish.js +98 -16
  135. package/dist/lib/share/worker-template.js +105 -9
  136. package/dist/lib/smart-launch.js +27 -4
  137. package/dist/lib/ssh-exec.d.ts +2 -0
  138. package/dist/lib/ssh-exec.js +20 -4
  139. package/dist/lib/storage/index.d.ts +14 -0
  140. package/dist/lib/storage/index.js +14 -0
  141. package/dist/lib/storage/selection.d.ts +48 -0
  142. package/dist/lib/storage/selection.js +39 -0
  143. package/dist/lib/storage/visibility.d.ts +82 -0
  144. package/dist/lib/storage/visibility.js +99 -0
  145. package/dist/lib/teams/agents.js +3 -1
  146. package/dist/lib/teams/placement-probe.js +1 -0
  147. package/dist/lib/teams/registry.js +2 -0
  148. package/dist/lib/teams/scheduler.d.ts +8 -1
  149. package/dist/lib/teams/scheduler.js +4 -1
  150. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  151. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  152. package/dist/lib/traces/backend.js +13 -2
  153. package/dist/lib/traces/sync.d.ts +7 -0
  154. package/dist/lib/traces/sync.js +9 -0
  155. package/dist/lib/usage-refresh.d.ts +8 -2
  156. package/dist/lib/usage-refresh.js +3 -3
  157. package/dist/lib/worktree/held.d.ts +166 -0
  158. package/dist/lib/worktree/held.js +368 -0
  159. package/package.json +2 -2
  160. package/dist/lib/account-state-service.d.ts +0 -21
  161. package/dist/lib/account-state-service.js +0 -60
@@ -71,6 +71,7 @@ interface ClaudeSessionScan {
71
71
  version?: string;
72
72
  model?: string;
73
73
  topic?: string;
74
+ firstUserMessage?: string;
74
75
  /** Harness-owned session name (`/rename` or Claude's generated `ai-title`). */
75
76
  label?: string;
76
77
  messageCount: number;
@@ -135,6 +136,7 @@ interface CodexSessionScan {
135
136
  version?: string;
136
137
  model?: string;
137
138
  topic?: string;
139
+ firstUserMessage?: string;
138
140
  messageCount: number;
139
141
  tokenCount?: number;
140
142
  /** Real generated (output) tokens, excluding cache-read/-write context. */
@@ -418,6 +420,7 @@ interface ClaudeParseState {
418
420
  version?: string;
419
421
  model?: string;
420
422
  topic?: string;
423
+ firstUserMessage?: string;
421
424
  customTitle?: string;
422
425
  aiTitle?: string;
423
426
  entrypoint?: string;
@@ -492,7 +495,7 @@ export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessi
492
495
  * exact even when the recent window is smaller than the true count.
493
496
  */
494
497
  export interface ClaudeParserState {
495
- v: 5;
498
+ v: 6;
496
499
  /**
497
500
  * Fan-out tallies carried across a RESUMED parse (RUSH-3091/3095). They must
498
501
  * live in the durable blob: the resumable parser reads only new bytes, so
@@ -512,6 +515,7 @@ export interface ClaudeParserState {
512
515
  entrypoint?: string;
513
516
  firstTsMs?: number;
514
517
  topic?: string;
518
+ firstUserMessage?: string;
515
519
  customTitle?: string;
516
520
  aiTitle?: string;
517
521
  plan?: string;
@@ -629,6 +633,7 @@ interface CodexParseState {
629
633
  version?: string;
630
634
  model?: string;
631
635
  topic?: string;
636
+ firstUserMessage?: string;
632
637
  messageCount: number;
633
638
  tokenCount?: number;
634
639
  lastTotalTokenUsage?: any;
@@ -667,7 +672,7 @@ export declare function initCodexParseState(): CodexParseState;
667
672
  * finalize is identical after a resume.
668
673
  */
669
674
  export interface CodexParserState {
670
- v: 3;
675
+ v: 4;
671
676
  offset: number;
672
677
  jsonlDroppingOversizedLine?: boolean;
673
678
  sessionId?: string;
@@ -677,6 +682,7 @@ export interface CodexParserState {
677
682
  version?: string;
678
683
  model?: string;
679
684
  topic?: string;
685
+ firstUserMessage?: string;
680
686
  messageCount: number;
681
687
  tokenCount?: number;
682
688
  lastTotalTokenUsage?: any;
@@ -743,6 +749,8 @@ export declare function __codexScanBranchCountsForTest(): {
743
749
  };
744
750
  /** Test seam: reset the Codex parse-branch counters to observe a scan from a clean slate. */
745
751
  export declare function __resetCodexScanBranchCountsForTest(): void;
752
+ /** Extract the version number from a managed versions/<agent>/<version>/... path under either repo. */
753
+ export declare function extractVersionFromManagedPath(agent: SessionAgentId, sourcePath?: string): string | undefined;
746
754
  /** Parse one Cursor transcript and enrich it with the matching chat meta.json. */
747
755
  export declare function readCursorMeta(filePath: string, currentVersion?: string): {
748
756
  meta: SessionMeta;
@@ -765,11 +773,12 @@ export declare function readKimiMeta(filePath: string, priorRow?: {
765
773
  * from `offset` + adding the appended tail's deltas equals a full parse.
766
774
  */
767
775
  export interface KimiParserState {
768
- v: 1;
776
+ v: 2;
769
777
  offset: number;
770
778
  messageCount: number;
771
779
  tokenCount: number;
772
780
  outputTokens: number;
781
+ firstUserMessage?: string;
773
782
  }
774
783
  /**
775
784
  * Incrementally parse Kimi's wire.jsonl for message-count and token counters,
@@ -26,7 +26,7 @@ import { getConfigSymlinkVersion } from '../installations/shims.js';
26
26
  import { SESSION_AGENTS } from './types.js';
27
27
  import { deriveShortId } from './short-id.js';
28
28
  import { buildClaudeAccountIndex, resolveClaudeAccount } from './claude-accounts.js';
29
- import { extractSessionTopic, extractSlashCommandName, extractSlashCommandFromToolInput, cleanGeneratedSessionLabel } from './prompt.js';
29
+ import { cleanFirstUserMessage, extractSessionTopic, extractSlashCommandName, extractSlashCommandFromToolInput, cleanGeneratedSessionLabel } from './prompt.js';
30
30
  import { isBackgroundShellStart, isSkillInvocation, extractSkills, extractSlashCommands, isSubAgentTool } from './highlights.js';
31
31
  import { parseAntigravity, parseCursor, splitSessionFilePath } from './parse.js';
32
32
  import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
@@ -1200,6 +1200,7 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, label) {
1200
1200
  accountKey: acct.key,
1201
1201
  accountOrg: acct.orgName ?? undefined,
1202
1202
  topic: scan.topic,
1203
+ firstUserMessage: scan.firstUserMessage,
1203
1204
  // The live sessions metadata file contains only user `/rename` values;
1204
1205
  // prefer it when present, then fall back to the title parsed from JSONL.
1205
1206
  label: label || scan.label,
@@ -1256,6 +1257,7 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, label) {
1256
1257
  costUsdNoCache: scan.costUsdNoCache,
1257
1258
  durationMs: scan.durationMs,
1258
1259
  topic: scan.topic,
1260
+ firstUserMessage: scan.firstUserMessage,
1259
1261
  isTeamOrigin,
1260
1262
  prUrl: scan.prUrl,
1261
1263
  prNumber: scan.prNumber,
@@ -1565,6 +1567,7 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion, sc
1565
1567
  version: resolveSessionVersion('codex', filePath, scan.version, currentVersion),
1566
1568
  model: scan.model,
1567
1569
  topic: scan.topic,
1570
+ firstUserMessage: scan.firstUserMessage,
1568
1571
  messageCount: scan.messageCount,
1569
1572
  tokenCount: scan.tokenCount,
1570
1573
  outputTokens: scan.outputTokens,
@@ -1804,6 +1807,7 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
1804
1807
  version: resolveSessionVersion('gemini', filePath, embeddedVersion, currentVersion),
1805
1808
  model: sessionModel,
1806
1809
  topic,
1810
+ firstUserMessage: userTexts.map(cleanFirstUserMessage).find((text) => !!text),
1807
1811
  messageCount,
1808
1812
  tokenCount: sawTokenCount ? tokenCount : undefined,
1809
1813
  outputTokens: sawTokenCount ? outputTokens : undefined,
@@ -2046,7 +2050,13 @@ async function scanOpenCodeIncremental(onProgress) {
2046
2050
  fileSize: stat.size,
2047
2051
  };
2048
2052
  const prev = getScanStampByPath(OPENCODE_DB);
2049
- if (prev && prev.fileMtimeMs === currentScan.fileMtimeMs && prev.fileSize === currentScan.fileSize) {
2053
+ const extractorUpgrade = prev != null && prev.extractorVersion !== CONTENT_INDEX_VERSION;
2054
+ // OpenCode has a shared database rather than one transcript file per session,
2055
+ // so this container-level gate must honor extractor-version invalidation too.
2056
+ if (prev &&
2057
+ prev.fileMtimeMs === currentScan.fileMtimeMs &&
2058
+ prev.fileSize === currentScan.fileSize &&
2059
+ prev.extractorVersion === CONTENT_INDEX_VERSION) {
2050
2060
  return;
2051
2061
  }
2052
2062
  const currentVersion = await getCurrentAgentVersion('opencode');
@@ -2123,7 +2133,7 @@ async function scanOpenCodeIncremental(onProgress) {
2123
2133
  ) stats ON stats.session_id = s.id
2124
2134
  WHERE s.parent_id IS NULL
2125
2135
  ORDER BY time_created DESC
2126
- LIMIT 1000;
2136
+ ${extractorUpgrade ? '' : 'LIMIT 1000'};
2127
2137
  `.replace(/\n/g, ' ');
2128
2138
  const account = getOpenCodeAccount();
2129
2139
  const rows = db.prepare(query).all();
@@ -2154,11 +2164,31 @@ async function scanOpenCodeIncremental(onProgress) {
2154
2164
  const prevStamp = priorStamps.get(c.filePath);
2155
2165
  return !prevStamp
2156
2166
  || prevStamp.fileMtimeMs !== c.scan.fileMtimeMs
2157
- || prevStamp.fileSize !== c.scan.fileSize;
2167
+ || prevStamp.fileSize !== c.scan.fileSize
2168
+ || prevStamp.extractorVersion !== CONTENT_INDEX_VERSION;
2158
2169
  });
2170
+ // Role lives on the message row and the genuine user text lives on its
2171
+ // ordered text parts. Poisoned JSON is ignored per row, never per database.
2172
+ const firstUserStmt = db.prepare(`
2173
+ SELECT json_extract(p.data, '$.text') AS text
2174
+ FROM part p
2175
+ WHERE p.message_id = (
2176
+ SELECT m.id FROM message m
2177
+ WHERE m.session_id = ?
2178
+ AND json_valid(m.data) AND json_extract(m.data, '$.role') = 'user'
2179
+ ORDER BY m.time_created ASC
2180
+ LIMIT 1
2181
+ )
2182
+ AND json_valid(p.data) AND json_extract(p.data, '$.type') = 'text'
2183
+ ORDER BY p.time_created ASC
2184
+ `);
2159
2185
  const entries = [];
2160
2186
  for (const { row, id, filePath, scan } of changed) {
2161
2187
  const title = typeof row.title === 'string' ? row.title : '';
2188
+ const firstUserMessage = firstUserStmt.all(id)
2189
+ .map((part) => (typeof part.text === 'string' ? part.text : ''))
2190
+ .join('')
2191
+ .trim() || undefined;
2162
2192
  const directory = typeof row.directory === 'string' ? row.directory : '';
2163
2193
  const version = typeof row.version === 'string' ? row.version : '';
2164
2194
  const timeCreated = asInt(row.time_created);
@@ -2212,6 +2242,7 @@ async function scanOpenCodeIncremental(onProgress) {
2212
2242
  version: resolveSessionVersion('opencode', OPENCODE_DB, version || undefined, currentVersion),
2213
2243
  account,
2214
2244
  topic,
2245
+ firstUserMessage,
2215
2246
  model,
2216
2247
  costUsd,
2217
2248
  durationMs,
@@ -2420,6 +2451,7 @@ async function readRushMeta(filePath, sessionId) {
2420
2451
  project: scan.agentId,
2421
2452
  filePath,
2422
2453
  topic: scan.topic,
2454
+ firstUserMessage: scan.firstUserMessage,
2423
2455
  messageCount: scan.messageCount,
2424
2456
  };
2425
2457
  return { meta, content: scan.contentText || '', assistantContent: scan.assistantText || '' };
@@ -2430,6 +2462,7 @@ async function scanRushSession(filePath) {
2430
2462
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
2431
2463
  let timestamp;
2432
2464
  let topic;
2465
+ let firstUserMessage;
2433
2466
  let agentId;
2434
2467
  let messageCount = 0;
2435
2468
  const userTexts = [];
@@ -2467,6 +2500,8 @@ async function scanRushSession(filePath) {
2467
2500
  messageCount++;
2468
2501
  if (parsed.role === 'user') {
2469
2502
  userTexts.push(cleaned);
2503
+ if (!firstUserMessage)
2504
+ firstUserMessage = cleanFirstUserMessage(cleaned);
2470
2505
  if (!topic)
2471
2506
  topic = extractSessionTopic(cleaned);
2472
2507
  }
@@ -2482,6 +2517,7 @@ async function scanRushSession(filePath) {
2482
2517
  return {
2483
2518
  timestamp,
2484
2519
  topic,
2520
+ firstUserMessage,
2485
2521
  agentId,
2486
2522
  messageCount,
2487
2523
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
@@ -2559,6 +2595,7 @@ function readHermesMeta(filePath) {
2559
2595
  const userTexts = [];
2560
2596
  const assistantTexts = [];
2561
2597
  let topic;
2598
+ let firstUserMessage;
2562
2599
  let messageCount = 0;
2563
2600
  for (const msg of messages) {
2564
2601
  const text = extractHermesMessageText(msg?.content);
@@ -2567,6 +2604,8 @@ function readHermesMeta(filePath) {
2567
2604
  messageCount++;
2568
2605
  if (msg?.role === 'user') {
2569
2606
  userTexts.push(text);
2607
+ if (!firstUserMessage)
2608
+ firstUserMessage = cleanFirstUserMessage(text);
2570
2609
  if (!topic)
2571
2610
  topic = extractSessionTopic(text);
2572
2611
  }
@@ -2593,6 +2632,7 @@ function readHermesMeta(filePath) {
2593
2632
  version: model,
2594
2633
  model,
2595
2634
  topic,
2635
+ firstUserMessage,
2596
2636
  messageCount: messageCount || (typeof session.message_count === 'number' ? session.message_count : undefined),
2597
2637
  };
2598
2638
  return { meta, content: userTexts.join('\n'), assistantContent: assistantTexts.join('\n') };
@@ -2695,6 +2735,7 @@ function readMuseMeta(filePath) {
2695
2735
  const userTexts = [];
2696
2736
  const assistantTexts = [];
2697
2737
  let topic;
2738
+ let firstUserMessage;
2698
2739
  let messageCount = 0;
2699
2740
  let model;
2700
2741
  let project;
@@ -2731,6 +2772,8 @@ function readMuseMeta(filePath) {
2731
2772
  if (cmd?.kind === 'turn_submit' && typeof cmd.prompt === 'string' && cmd.prompt.trim()) {
2732
2773
  messageCount++;
2733
2774
  userTexts.push(cmd.prompt.trim());
2775
+ if (!firstUserMessage)
2776
+ firstUserMessage = cleanFirstUserMessage(cmd.prompt.trim());
2734
2777
  if (!topic)
2735
2778
  topic = extractSessionTopic(cmd.prompt.trim());
2736
2779
  }
@@ -2762,6 +2805,7 @@ function readMuseMeta(filePath) {
2762
2805
  filePath,
2763
2806
  model,
2764
2807
  topic,
2808
+ firstUserMessage,
2765
2809
  messageCount: messageCount || undefined,
2766
2810
  };
2767
2811
  return { meta, content: userTexts.join('\n'), assistantContent: assistantTexts.join('\n') };
@@ -2865,6 +2909,7 @@ async function readDroidMeta(filePath, currentVersion) {
2865
2909
  version: resolveSessionVersion('droid', filePath, undefined, currentVersion),
2866
2910
  model,
2867
2911
  topic: scan.topic,
2912
+ firstUserMessage: scan.firstUserMessage,
2868
2913
  messageCount: scan.messageCount,
2869
2914
  tokenCount,
2870
2915
  outputTokens: settings.usage?.outputTokens,
@@ -2913,6 +2958,7 @@ async function scanDroidSession(filePath) {
2913
2958
  let title;
2914
2959
  let sessionTitle;
2915
2960
  let firstUserTopic;
2961
+ let firstUserMessage;
2916
2962
  let model;
2917
2963
  let messageCount = 0;
2918
2964
  let firstTsMs;
@@ -2965,6 +3011,8 @@ async function scanDroidSession(filePath) {
2965
3011
  messageCount++;
2966
3012
  if (msg.role === 'user') {
2967
3013
  userTexts.push(text);
3014
+ if (!firstUserMessage)
3015
+ firstUserMessage = cleanFirstUserMessage(text);
2968
3016
  if (!firstUserTopic)
2969
3017
  firstUserTopic = extractSessionTopic(text);
2970
3018
  }
@@ -2987,6 +3035,7 @@ async function scanDroidSession(filePath) {
2987
3035
  // Prefer Droid's auto-title, then the raw first-prompt title, then the
2988
3036
  // derived first-user-message topic.
2989
3037
  topic: sessionTitle || title || firstUserTopic,
3038
+ firstUserMessage,
2990
3039
  model,
2991
3040
  messageCount,
2992
3041
  durationMs,
@@ -3020,6 +3069,7 @@ export function initClaudeParseState() {
3020
3069
  version: undefined,
3021
3070
  model: undefined,
3022
3071
  topic: undefined,
3072
+ firstUserMessage: undefined,
3023
3073
  customTitle: undefined,
3024
3074
  aiTitle: undefined,
3025
3075
  entrypoint: undefined,
@@ -3202,6 +3252,8 @@ export function applyClaudeLine(state, parsed) {
3202
3252
  if (parsed.type === 'user') {
3203
3253
  const text = extractClaudeUserText(parsed);
3204
3254
  if (text) {
3255
+ if (!state.firstUserMessage)
3256
+ state.firstUserMessage = cleanFirstUserMessage(text);
3205
3257
  state.messageCount++;
3206
3258
  state.userTexts.push(text);
3207
3259
  if (!state.topic)
@@ -3297,6 +3349,7 @@ export function finalizeClaudeScan(state) {
3297
3349
  version: state.version,
3298
3350
  model: state.model,
3299
3351
  topic: state.topic,
3352
+ firstUserMessage: state.firstUserMessage,
3300
3353
  label,
3301
3354
  entrypoint: state.entrypoint,
3302
3355
  messageCount: state.messageCount,
@@ -3366,7 +3419,7 @@ export function serializeClaudeParserState(state, offset, jsonlDroppingOversized
3366
3419
  : allIds;
3367
3420
  const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
3368
3421
  return {
3369
- v: 5,
3422
+ v: 6,
3370
3423
  subAgents: state.subAgents,
3371
3424
  backgroundShells: state.backgroundShells,
3372
3425
  offset,
@@ -3379,6 +3432,7 @@ export function serializeClaudeParserState(state, offset, jsonlDroppingOversized
3379
3432
  entrypoint: state.entrypoint,
3380
3433
  firstTsMs: state.firstTsMs,
3381
3434
  topic: state.topic,
3435
+ firstUserMessage: state.firstUserMessage,
3382
3436
  customTitle: state.customTitle,
3383
3437
  aiTitle: state.aiTitle,
3384
3438
  plan: state.plan,
@@ -3449,6 +3503,7 @@ export function hydrateClaudeParseState(prior) {
3449
3503
  version: prior.version,
3450
3504
  model: prior.model,
3451
3505
  topic: prior.topic,
3506
+ firstUserMessage: prior.firstUserMessage,
3452
3507
  customTitle: prior.customTitle,
3453
3508
  aiTitle: prior.aiTitle,
3454
3509
  entrypoint: prior.entrypoint,
@@ -3613,7 +3668,7 @@ function parsePriorClaudeState(row) {
3613
3668
  return null;
3614
3669
  try {
3615
3670
  const parsed = JSON.parse(row.parserState);
3616
- if (parsed?.v !== 5 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
3671
+ if (parsed?.v !== 6 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
3617
3672
  return null;
3618
3673
  return parsed;
3619
3674
  }
@@ -3644,6 +3699,7 @@ export function initCodexParseState() {
3644
3699
  version: undefined,
3645
3700
  model: undefined,
3646
3701
  topic: undefined,
3702
+ firstUserMessage: undefined,
3647
3703
  messageCount: 0,
3648
3704
  tokenCount: undefined,
3649
3705
  lastTotalTokenUsage: undefined,
@@ -3748,7 +3804,8 @@ function applyCodexLine(state, parsed) {
3748
3804
  return;
3749
3805
  }
3750
3806
  if (parsed.type === 'response_item' && parsed.payload?.type === 'message') {
3751
- const role = parsed.payload.role === 'user' || parsed.payload.role === 'developer'
3807
+ const payloadRole = parsed.payload.role;
3808
+ const role = payloadRole === 'user' || payloadRole === 'developer'
3752
3809
  ? 'user'
3753
3810
  : 'assistant';
3754
3811
  const text = extractCodexMessageText(parsed.payload.content, role);
@@ -3756,9 +3813,17 @@ function applyCodexLine(state, parsed) {
3756
3813
  return;
3757
3814
  state.messageCount++;
3758
3815
  if (role === 'user') {
3759
- state.userTexts.push(text);
3816
+ const genuine = cleanFirstUserMessage(text);
3817
+ if (!genuine)
3818
+ return;
3819
+ state.userTexts.push(genuine);
3820
+ // Codex records developer instructions as response_item messages too.
3821
+ // They remain searchable for compatibility, but only an actual user role
3822
+ // may become the session's first request.
3823
+ if (payloadRole === 'user' && !state.firstUserMessage)
3824
+ state.firstUserMessage = genuine;
3760
3825
  if (!state.topic)
3761
- state.topic = extractSessionTopic(text);
3826
+ state.topic = extractSessionTopic(genuine);
3762
3827
  }
3763
3828
  else {
3764
3829
  state.assistantTexts.push(text);
@@ -3827,6 +3892,7 @@ function finalizeCodexScan(state) {
3827
3892
  version: state.version,
3828
3893
  model: state.model,
3829
3894
  topic: state.topic,
3895
+ firstUserMessage: state.firstUserMessage,
3830
3896
  messageCount: state.messageCount,
3831
3897
  tokenCount: state.tokenCount,
3832
3898
  outputTokens,
@@ -3882,7 +3948,7 @@ async function scanCodexSession(filePath) {
3882
3948
  export function serializeCodexParserState(state, offset, jsonlDroppingOversizedLine = false) {
3883
3949
  const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
3884
3950
  return {
3885
- v: 3,
3951
+ v: 4,
3886
3952
  offset,
3887
3953
  jsonlDroppingOversizedLine: jsonlDroppingOversizedLine || undefined,
3888
3954
  sessionId: state.sessionId,
@@ -3892,6 +3958,7 @@ export function serializeCodexParserState(state, offset, jsonlDroppingOversizedL
3892
3958
  version: state.version,
3893
3959
  model: state.model,
3894
3960
  topic: state.topic,
3961
+ firstUserMessage: state.firstUserMessage,
3895
3962
  messageCount: state.messageCount,
3896
3963
  tokenCount: state.tokenCount,
3897
3964
  lastTotalTokenUsage: state.lastTotalTokenUsage,
@@ -3934,6 +4001,7 @@ function hydrateCodexParseState(prior) {
3934
4001
  version: prior.version,
3935
4002
  model: prior.model,
3936
4003
  topic: prior.topic,
4004
+ firstUserMessage: prior.firstUserMessage,
3937
4005
  messageCount: prior.messageCount,
3938
4006
  tokenCount: prior.tokenCount,
3939
4007
  lastTotalTokenUsage: prior.lastTotalTokenUsage,
@@ -4073,7 +4141,7 @@ function parsePriorCodexState(row) {
4073
4141
  return null;
4074
4142
  try {
4075
4143
  const parsed = JSON.parse(row.parserState);
4076
- if (parsed?.v !== 3 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
4144
+ if (parsed?.v !== 4 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
4077
4145
  return null;
4078
4146
  return parsed;
4079
4147
  }
@@ -4208,7 +4276,7 @@ function getClaudeUsageTotal(usage) {
4208
4276
  usage.cache_read_input_tokens,
4209
4277
  ]);
4210
4278
  }
4211
- /** Extract text from Codex message content blocks, filtering out system instructions for user messages. */
4279
+ /** Extract text from Codex message content blocks; the caller classifies user scaffolding. */
4212
4280
  function extractCodexMessageText(contentBlocks, role) {
4213
4281
  if (!Array.isArray(contentBlocks))
4214
4282
  return undefined;
@@ -4217,14 +4285,7 @@ function extractCodexMessageText(contentBlocks, role) {
4217
4285
  : contentBlocks.filter((block) => block.type === 'output_text');
4218
4286
  const text = matches
4219
4287
  .map((block) => String(block.text || '').trim())
4220
- .find((value) => {
4221
- if (!value)
4222
- return false;
4223
- if (role === 'user' && (value.length >= 2000 || value.includes('<permissions instructions>') || value.startsWith('# AGENTS.md instructions'))) {
4224
- return false;
4225
- }
4226
- return true;
4227
- });
4288
+ .find((value) => !!value);
4228
4289
  return text || undefined;
4229
4290
  }
4230
4291
  /** Trim and normalize a version string, returning undefined for empty values. */
@@ -4233,11 +4294,18 @@ function normalizeVersion(version) {
4233
4294
  return trimmed ? trimmed : undefined;
4234
4295
  }
4235
4296
  /** Extract the version number from a managed versions/<agent>/<version>/... path under either repo. */
4236
- function extractVersionFromManagedPath(agent, sourcePath) {
4297
+ export function extractVersionFromManagedPath(agent, sourcePath) {
4237
4298
  if (!sourcePath)
4238
4299
  return undefined;
4239
4300
  const candidates = [sourcePath, safeRealpathSync(sourcePath) || ''];
4240
4301
  const markers = [`/.agents/versions/${agent}/`, `/.agents-system/versions/${agent}/`];
4302
+ // Codex is relocated by CODEX_HOME to `~/.agents/.codex-homes/<version>/`
4303
+ // (shims.ts `codexHomeShimBash`), NOT the `versions/<agent>/<version>/home/…`
4304
+ // layout every other isolated agent uses — so its rollout transcripts live
4305
+ // under `.codex-homes/<version>/sessions/…` and the markers above never match,
4306
+ // leaving `version` NULL and native resume degraded to `/continue` (PHNX-3626).
4307
+ if (agent === 'codex')
4308
+ markers.push('/.agents/.codex-homes/');
4241
4309
  for (const candidate of candidates) {
4242
4310
  if (!candidate)
4243
4311
  continue;
@@ -4585,16 +4653,30 @@ export function readKimiMeta(filePath, priorRow) {
4585
4653
  project,
4586
4654
  filePath,
4587
4655
  topic,
4656
+ firstUserMessage: newState.firstUserMessage,
4588
4657
  messageCount,
4589
4658
  tokenCount: tokenCount > 0 ? tokenCount : undefined,
4590
4659
  outputTokens: outputTokens > 0 ? outputTokens : undefined,
4591
4660
  };
4592
4661
  return { meta, content: lastPrompt || '', parserState: JSON.stringify(newState) };
4593
4662
  }
4663
+ function kimiWireUserText(event) {
4664
+ if (event?.type !== 'context.append_message' || event?.message?.role !== 'user')
4665
+ return undefined;
4666
+ const content = event.message.content;
4667
+ const raw = typeof content === 'string'
4668
+ ? content
4669
+ : Array.isArray(content)
4670
+ ? content.map((part) => typeof part === 'string' ? part : typeof part?.text === 'string' ? part.text : '').join('\n')
4671
+ : '';
4672
+ return cleanFirstUserMessage(raw);
4673
+ }
4594
4674
  /** Fold one parsed Kimi wire event into the additive counters, in place. */
4595
4675
  function applyKimiWireEvent(acc, event) {
4596
4676
  if (event.type === 'context.append_message') {
4597
4677
  acc.messageCount++;
4678
+ if (!acc.firstUserMessage)
4679
+ acc.firstUserMessage = kimiWireUserText(event);
4598
4680
  }
4599
4681
  else if (event.type === 'usage.record' && event.usage) {
4600
4682
  // Kimi usage structure: inputOther + output + inputCacheRead + inputCacheCreation
@@ -4624,7 +4706,7 @@ export function parseKimiWireMetricsIncremental(sessionDir, prior) {
4624
4706
  if (!stat) {
4625
4707
  // No wire.jsonl (yet): zero counters, offset 0 so a later append is a clean
4626
4708
  // full parse.
4627
- return { messageCount: 0, tokenCount: 0, outputTokens: 0, newState: { v: 1, offset: 0, messageCount: 0, tokenCount: 0, outputTokens: 0 } };
4709
+ return { messageCount: 0, tokenCount: 0, outputTokens: 0, newState: { v: 2, offset: 0, messageCount: 0, tokenCount: 0, outputTokens: 0 } };
4628
4710
  }
4629
4711
  // INCREMENTAL only when a usable prior exists AND the file grew past its
4630
4712
  // offset; otherwise FULL from byte 0 with fresh counters (cold start OR the
@@ -4643,8 +4725,8 @@ export function parseKimiWireMetricsIncremental(sessionDir, prior) {
4643
4725
  const canIncrement = prior !== null && stat.size > prior.offset;
4644
4726
  const fromOffset = canIncrement ? prior.offset : 0;
4645
4727
  const acc = canIncrement
4646
- ? { messageCount: prior.messageCount, tokenCount: prior.tokenCount, outputTokens: prior.outputTokens }
4647
- : { messageCount: 0, tokenCount: 0, outputTokens: 0 };
4728
+ ? { messageCount: prior.messageCount, tokenCount: prior.tokenCount, outputTokens: prior.outputTokens, firstUserMessage: prior.firstUserMessage }
4729
+ : { messageCount: 0, tokenCount: 0, outputTokens: 0, firstUserMessage: undefined };
4648
4730
  let consumedBytes = 0;
4649
4731
  let fd;
4650
4732
  try {
@@ -4699,7 +4781,7 @@ export function parseKimiWireMetricsIncremental(sessionDir, prior) {
4699
4781
  messageCount: acc.messageCount,
4700
4782
  tokenCount: acc.tokenCount,
4701
4783
  outputTokens: acc.outputTokens,
4702
- newState: { v: 1, offset: fromOffset + consumedBytes, messageCount: acc.messageCount, tokenCount: acc.tokenCount, outputTokens: acc.outputTokens },
4784
+ newState: { v: 2, offset: fromOffset + consumedBytes, messageCount: acc.messageCount, tokenCount: acc.tokenCount, outputTokens: acc.outputTokens, firstUserMessage: acc.firstUserMessage },
4703
4785
  };
4704
4786
  }
4705
4787
  /**
@@ -4713,7 +4795,7 @@ function parsePriorKimiState(row) {
4713
4795
  return null;
4714
4796
  try {
4715
4797
  const parsed = JSON.parse(row.parserState);
4716
- if (parsed?.v !== 1 || typeof parsed.offset !== 'number')
4798
+ if (parsed?.v !== 2 || typeof parsed.offset !== 'number')
4717
4799
  return null;
4718
4800
  return parsed;
4719
4801
  }
@@ -4790,6 +4872,84 @@ async function scanGrokIncremental(onProgress) {
4790
4872
  upsertSessionsBatch(scanEntries);
4791
4873
  recordScans(touched);
4792
4874
  }
4875
+ /**
4876
+ * Bounded read of a Grok `chat_history.jsonl` for the genuine full first user
4877
+ * turn (PHNX-3621 leftover from #3359). Grok's cheap scan reads only
4878
+ * `summary.json` (a LARGE-transcript harness whose full message log is
4879
+ * deliberately not parsed on the hot tick), but the first turn is at the START
4880
+ * of the log, so a bounded prefix read recovers it without paying the full-file
4881
+ * cost. Production logs lead with a `type:system` record, then a huge
4882
+ * `<user_info>` user dump (no `synthetic_reason`), then a `synthetic_reason`
4883
+ * reminder, then the originating turn at `prompt_index: 0` wrapped in
4884
+ * `<user_query>`. Skip that scaffolding and prefer `prompt_index` when present.
4885
+ * Returns undefined when no genuine user turn appears within the budget.
4886
+ */
4887
+ function readGrokFirstUserMessage(sessionDir, maxBytes = 262_144) {
4888
+ const historyPath = path.join(sessionDir, 'chat_history.jsonl');
4889
+ let fd;
4890
+ try {
4891
+ const stat = safeStatSync(historyPath);
4892
+ if (!stat)
4893
+ return undefined;
4894
+ const bytesToRead = Math.min(maxBytes, stat.size);
4895
+ if (bytesToRead <= 0)
4896
+ return undefined;
4897
+ const buf = Buffer.allocUnsafe(bytesToRead);
4898
+ fd = fs.openSync(historyPath, 'r');
4899
+ let read = 0;
4900
+ while (read < bytesToRead) {
4901
+ const n = fs.readSync(fd, buf, read, bytesToRead - read, read);
4902
+ if (n <= 0)
4903
+ break;
4904
+ read += n;
4905
+ }
4906
+ const chunk = buf.subarray(0, read).toString('utf-8');
4907
+ // Drop a trailing partial line so we never parse half a JSON record.
4908
+ const lastNl = chunk.lastIndexOf('\n');
4909
+ const complete = lastNl === -1 ? chunk : chunk.slice(0, lastNl);
4910
+ let firstGenuine;
4911
+ for (const line of complete.split('\n')) {
4912
+ if (!line.trim())
4913
+ continue;
4914
+ let msg;
4915
+ try {
4916
+ msg = JSON.parse(line);
4917
+ }
4918
+ catch {
4919
+ continue;
4920
+ }
4921
+ if (msg?.type !== 'user')
4922
+ continue;
4923
+ if (msg.synthetic_reason)
4924
+ continue;
4925
+ const raw = msg.content;
4926
+ const text = typeof raw === 'string'
4927
+ ? raw
4928
+ : Array.isArray(raw)
4929
+ ? raw.map((p) => (typeof p?.text === 'string' ? p.text : '')).join('')
4930
+ : '';
4931
+ const genuine = cleanFirstUserMessage(text);
4932
+ if (!genuine)
4933
+ continue;
4934
+ if (msg.prompt_index != null)
4935
+ return genuine;
4936
+ if (!firstGenuine)
4937
+ firstGenuine = genuine;
4938
+ }
4939
+ return firstGenuine;
4940
+ }
4941
+ catch {
4942
+ return undefined;
4943
+ }
4944
+ finally {
4945
+ if (fd !== undefined) {
4946
+ try {
4947
+ fs.closeSync(fd);
4948
+ }
4949
+ catch { /* already closed / gone */ }
4950
+ }
4951
+ }
4952
+ }
4793
4953
  /** Parse a single Grok session summary.json into session metadata. */
4794
4954
  export function readGrokMeta(filePath, currentVersion) {
4795
4955
  let summary;
@@ -4842,6 +5002,7 @@ export function readGrokMeta(filePath, currentVersion) {
4842
5002
  filePath,
4843
5003
  version: resolveSessionVersion('grok', filePath, embeddedVersion, currentVersion),
4844
5004
  topic,
5005
+ firstUserMessage: readGrokFirstUserMessage(sessionDir),
4845
5006
  messageCount,
4846
5007
  };
4847
5008
  return { meta, content: topic || '' };
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Team prefixes that match the key shape but are not trackers — unit strings and
3
+ * common acronyms. Canonical here (the Linear module) so both the URL linkifier
4
+ * and the session-transcript ticket detector (`detectTicket` in `state.ts`) agree
5
+ * on what is a real key rather than each keeping its own copy.
6
+ */
7
+ export declare const LINEAR_KEY_DENYLIST: ReadonlySet<string>;
8
+ /**
9
+ * Every distinct real Linear key mentioned in free text, in first-seen order.
10
+ * Honours {@link LINEAR_KEY_DENYLIST} so `UTF-8` / `X86-64` never count. Used to
11
+ * linkify ticket ids an owner ping only names in prose (no `session.ticketId`).
12
+ */
13
+ export declare function linearIssueKeys(text: string | undefined): string[];
1
14
  /** The configured Linear workspace slug, or undefined when unknown. Cached per process. */
2
15
  export declare function linearWorkspace(): string | undefined;
3
16
  /** Canonical Linear issue URL for a tracker key, or undefined if unresolvable. */