@phnx-labs/agents-cli 1.22.65 → 1.22.68

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 (112) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +7 -2
  3. package/dist/bootstrap.js +3 -3
  4. package/dist/commands/apply.js +9 -2
  5. package/dist/commands/browser.js +46 -21
  6. package/dist/commands/daemon-test-harness.d.ts +1 -1
  7. package/dist/commands/daemon-test-harness.js +2 -2
  8. package/dist/commands/daemon.js +21 -22
  9. package/dist/commands/exec.js +40 -0
  10. package/dist/commands/monitors.js +2 -2
  11. package/dist/commands/repo.js +57 -19
  12. package/dist/commands/resume.d.ts +16 -0
  13. package/dist/commands/resume.js +41 -8
  14. package/dist/commands/routines.js +42 -21
  15. package/dist/commands/sessions-optimize.js +1 -1
  16. package/dist/commands/sessions.js +4 -11
  17. package/dist/commands/share.d.ts +5 -3
  18. package/dist/commands/share.js +33 -9
  19. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  20. package/dist/lib/accounting/account-pool-collect.js +6 -4
  21. package/dist/lib/accounting/usage-ingest.js +4 -2
  22. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  23. package/dist/lib/accounting/usage-sync.js +66 -210
  24. package/dist/lib/accounting/usage.d.ts +11 -3
  25. package/dist/lib/accounting/usage.js +23 -10
  26. package/dist/lib/auth-health.d.ts +8 -0
  27. package/dist/lib/auth-health.js +4 -4
  28. package/dist/lib/browser/caller-identity.d.ts +12 -0
  29. package/dist/lib/browser/caller-identity.js +19 -0
  30. package/dist/lib/browser/ipc.d.ts +37 -32
  31. package/dist/lib/browser/ipc.js +146 -94
  32. package/dist/lib/browser/task-index.d.ts +10 -2
  33. package/dist/lib/browser/task-index.js +22 -3
  34. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  35. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  36. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  37. package/dist/lib/daemon/auth-sync-service.js +17 -6
  38. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  39. package/dist/lib/daemon/catchup-service.js +51 -0
  40. package/dist/lib/daemon/daemon.d.ts +12 -22
  41. package/dist/lib/daemon/daemon.js +463 -176
  42. package/dist/lib/daemon/runner.js +2 -0
  43. package/dist/lib/daemon/service.d.ts +22 -4
  44. package/dist/lib/daemon/service.js +2 -2
  45. package/dist/lib/daemon/supervisor.d.ts +55 -15
  46. package/dist/lib/daemon/supervisor.js +119 -29
  47. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  48. package/dist/lib/daemon/usage-sync-service.js +22 -18
  49. package/dist/lib/daemon-health.js +36 -31
  50. package/dist/lib/daemon-services.d.ts +1 -1
  51. package/dist/lib/daemon-services.js +12 -2
  52. package/dist/lib/daemon-ticks.d.ts +9 -6
  53. package/dist/lib/daemon-ticks.js +14 -8
  54. package/dist/lib/devices/known-hosts.d.ts +15 -0
  55. package/dist/lib/devices/known-hosts.js +18 -0
  56. package/dist/lib/devices/registry.js +2 -0
  57. package/dist/lib/exec.js +11 -0
  58. package/dist/lib/feed/watch.d.ts +3 -0
  59. package/dist/lib/feed/watch.js +13 -3
  60. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  61. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  62. package/dist/lib/fleet-shared-state.d.ts +38 -0
  63. package/dist/lib/fleet-shared-state.js +105 -0
  64. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  65. package/dist/lib/hosts/remote-cmd.js +12 -3
  66. package/dist/lib/lock-compromise.d.ts +8 -0
  67. package/dist/lib/lock-compromise.js +12 -0
  68. package/dist/lib/monitors/engine.d.ts +2 -1
  69. package/dist/lib/monitors/engine.js +9 -2
  70. package/dist/lib/refresh-coordinator.js +2 -0
  71. package/dist/lib/run-trace-sync.d.ts +13 -0
  72. package/dist/lib/run-trace-sync.js +77 -0
  73. package/dist/lib/secrets/push.d.ts +10 -0
  74. package/dist/lib/secrets/push.js +86 -7
  75. package/dist/lib/secrets/remote.d.ts +18 -6
  76. package/dist/lib/secrets/remote.js +29 -4
  77. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  78. package/dist/lib/secrets/reserved-sync.js +120 -102
  79. package/dist/lib/session/active.d.ts +3 -1
  80. package/dist/lib/session/active.js +2 -0
  81. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  82. package/dist/lib/session/actor-sidecar.js +2 -0
  83. package/dist/lib/session/db.d.ts +5 -2
  84. package/dist/lib/session/db.js +54 -22
  85. package/dist/lib/session/discover.d.ts +12 -3
  86. package/dist/lib/session/discover.js +106 -25
  87. package/dist/lib/session/prompt.d.ts +11 -0
  88. package/dist/lib/session/prompt.js +25 -0
  89. package/dist/lib/session/recovery.d.ts +30 -2
  90. package/dist/lib/session/recovery.js +56 -4
  91. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  92. package/dist/lib/session/remote/remote-list.js +29 -4
  93. package/dist/lib/session/remote/watch.d.ts +25 -2
  94. package/dist/lib/session/remote/watch.js +185 -11
  95. package/dist/lib/session/session-cache.d.ts +2 -1
  96. package/dist/lib/session/session-cache.js +1 -0
  97. package/dist/lib/session/types.d.ts +2 -0
  98. package/dist/lib/share/publish.d.ts +55 -9
  99. package/dist/lib/share/publish.js +87 -9
  100. package/dist/lib/share/worker-template.js +100 -8
  101. package/dist/lib/ssh-exec.d.ts +2 -0
  102. package/dist/lib/ssh-exec.js +20 -4
  103. package/dist/lib/teams/registry.js +2 -0
  104. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  105. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  106. package/dist/lib/traces/sync.d.ts +7 -0
  107. package/dist/lib/traces/sync.js +9 -0
  108. package/dist/lib/usage-refresh.d.ts +8 -2
  109. package/dist/lib/usage-refresh.js +3 -3
  110. package/package.json +1 -1
  111. package/dist/lib/account-state-service.d.ts +0 -21
  112. package/dist/lib/account-state-service.js +0 -60
@@ -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,12 @@ 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
+ // OpenCode has a shared database rather than one transcript file per session,
2054
+ // so this container-level gate must honor extractor-version invalidation too.
2055
+ if (prev &&
2056
+ prev.fileMtimeMs === currentScan.fileMtimeMs &&
2057
+ prev.fileSize === currentScan.fileSize &&
2058
+ prev.extractorVersion === CONTENT_INDEX_VERSION) {
2050
2059
  return;
2051
2060
  }
2052
2061
  const currentVersion = await getCurrentAgentVersion('opencode');
@@ -2154,11 +2163,31 @@ async function scanOpenCodeIncremental(onProgress) {
2154
2163
  const prevStamp = priorStamps.get(c.filePath);
2155
2164
  return !prevStamp
2156
2165
  || prevStamp.fileMtimeMs !== c.scan.fileMtimeMs
2157
- || prevStamp.fileSize !== c.scan.fileSize;
2166
+ || prevStamp.fileSize !== c.scan.fileSize
2167
+ || prevStamp.extractorVersion !== CONTENT_INDEX_VERSION;
2158
2168
  });
2169
+ // Role lives on the message row and the genuine user text lives on its
2170
+ // ordered text parts. Poisoned JSON is ignored per row, never per database.
2171
+ const firstUserStmt = db.prepare(`
2172
+ SELECT json_extract(p.data, '$.text') AS text
2173
+ FROM part p
2174
+ WHERE p.message_id = (
2175
+ SELECT m.id FROM message m
2176
+ WHERE m.session_id = ?
2177
+ AND json_valid(m.data) AND json_extract(m.data, '$.role') = 'user'
2178
+ ORDER BY m.time_created ASC
2179
+ LIMIT 1
2180
+ )
2181
+ AND json_valid(p.data) AND json_extract(p.data, '$.type') = 'text'
2182
+ ORDER BY p.time_created ASC
2183
+ `);
2159
2184
  const entries = [];
2160
2185
  for (const { row, id, filePath, scan } of changed) {
2161
2186
  const title = typeof row.title === 'string' ? row.title : '';
2187
+ const firstUserMessage = firstUserStmt.all(id)
2188
+ .map((part) => (typeof part.text === 'string' ? part.text : ''))
2189
+ .join('')
2190
+ .trim() || undefined;
2162
2191
  const directory = typeof row.directory === 'string' ? row.directory : '';
2163
2192
  const version = typeof row.version === 'string' ? row.version : '';
2164
2193
  const timeCreated = asInt(row.time_created);
@@ -2212,6 +2241,7 @@ async function scanOpenCodeIncremental(onProgress) {
2212
2241
  version: resolveSessionVersion('opencode', OPENCODE_DB, version || undefined, currentVersion),
2213
2242
  account,
2214
2243
  topic,
2244
+ firstUserMessage,
2215
2245
  model,
2216
2246
  costUsd,
2217
2247
  durationMs,
@@ -2420,6 +2450,7 @@ async function readRushMeta(filePath, sessionId) {
2420
2450
  project: scan.agentId,
2421
2451
  filePath,
2422
2452
  topic: scan.topic,
2453
+ firstUserMessage: scan.firstUserMessage,
2423
2454
  messageCount: scan.messageCount,
2424
2455
  };
2425
2456
  return { meta, content: scan.contentText || '', assistantContent: scan.assistantText || '' };
@@ -2430,6 +2461,7 @@ async function scanRushSession(filePath) {
2430
2461
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
2431
2462
  let timestamp;
2432
2463
  let topic;
2464
+ let firstUserMessage;
2433
2465
  let agentId;
2434
2466
  let messageCount = 0;
2435
2467
  const userTexts = [];
@@ -2467,6 +2499,8 @@ async function scanRushSession(filePath) {
2467
2499
  messageCount++;
2468
2500
  if (parsed.role === 'user') {
2469
2501
  userTexts.push(cleaned);
2502
+ if (!firstUserMessage)
2503
+ firstUserMessage = cleanFirstUserMessage(cleaned);
2470
2504
  if (!topic)
2471
2505
  topic = extractSessionTopic(cleaned);
2472
2506
  }
@@ -2482,6 +2516,7 @@ async function scanRushSession(filePath) {
2482
2516
  return {
2483
2517
  timestamp,
2484
2518
  topic,
2519
+ firstUserMessage,
2485
2520
  agentId,
2486
2521
  messageCount,
2487
2522
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
@@ -2559,6 +2594,7 @@ function readHermesMeta(filePath) {
2559
2594
  const userTexts = [];
2560
2595
  const assistantTexts = [];
2561
2596
  let topic;
2597
+ let firstUserMessage;
2562
2598
  let messageCount = 0;
2563
2599
  for (const msg of messages) {
2564
2600
  const text = extractHermesMessageText(msg?.content);
@@ -2567,6 +2603,8 @@ function readHermesMeta(filePath) {
2567
2603
  messageCount++;
2568
2604
  if (msg?.role === 'user') {
2569
2605
  userTexts.push(text);
2606
+ if (!firstUserMessage)
2607
+ firstUserMessage = cleanFirstUserMessage(text);
2570
2608
  if (!topic)
2571
2609
  topic = extractSessionTopic(text);
2572
2610
  }
@@ -2593,6 +2631,7 @@ function readHermesMeta(filePath) {
2593
2631
  version: model,
2594
2632
  model,
2595
2633
  topic,
2634
+ firstUserMessage,
2596
2635
  messageCount: messageCount || (typeof session.message_count === 'number' ? session.message_count : undefined),
2597
2636
  };
2598
2637
  return { meta, content: userTexts.join('\n'), assistantContent: assistantTexts.join('\n') };
@@ -2695,6 +2734,7 @@ function readMuseMeta(filePath) {
2695
2734
  const userTexts = [];
2696
2735
  const assistantTexts = [];
2697
2736
  let topic;
2737
+ let firstUserMessage;
2698
2738
  let messageCount = 0;
2699
2739
  let model;
2700
2740
  let project;
@@ -2731,6 +2771,8 @@ function readMuseMeta(filePath) {
2731
2771
  if (cmd?.kind === 'turn_submit' && typeof cmd.prompt === 'string' && cmd.prompt.trim()) {
2732
2772
  messageCount++;
2733
2773
  userTexts.push(cmd.prompt.trim());
2774
+ if (!firstUserMessage)
2775
+ firstUserMessage = cleanFirstUserMessage(cmd.prompt.trim());
2734
2776
  if (!topic)
2735
2777
  topic = extractSessionTopic(cmd.prompt.trim());
2736
2778
  }
@@ -2762,6 +2804,7 @@ function readMuseMeta(filePath) {
2762
2804
  filePath,
2763
2805
  model,
2764
2806
  topic,
2807
+ firstUserMessage,
2765
2808
  messageCount: messageCount || undefined,
2766
2809
  };
2767
2810
  return { meta, content: userTexts.join('\n'), assistantContent: assistantTexts.join('\n') };
@@ -2865,6 +2908,7 @@ async function readDroidMeta(filePath, currentVersion) {
2865
2908
  version: resolveSessionVersion('droid', filePath, undefined, currentVersion),
2866
2909
  model,
2867
2910
  topic: scan.topic,
2911
+ firstUserMessage: scan.firstUserMessage,
2868
2912
  messageCount: scan.messageCount,
2869
2913
  tokenCount,
2870
2914
  outputTokens: settings.usage?.outputTokens,
@@ -2913,6 +2957,7 @@ async function scanDroidSession(filePath) {
2913
2957
  let title;
2914
2958
  let sessionTitle;
2915
2959
  let firstUserTopic;
2960
+ let firstUserMessage;
2916
2961
  let model;
2917
2962
  let messageCount = 0;
2918
2963
  let firstTsMs;
@@ -2965,6 +3010,8 @@ async function scanDroidSession(filePath) {
2965
3010
  messageCount++;
2966
3011
  if (msg.role === 'user') {
2967
3012
  userTexts.push(text);
3013
+ if (!firstUserMessage)
3014
+ firstUserMessage = cleanFirstUserMessage(text);
2968
3015
  if (!firstUserTopic)
2969
3016
  firstUserTopic = extractSessionTopic(text);
2970
3017
  }
@@ -2987,6 +3034,7 @@ async function scanDroidSession(filePath) {
2987
3034
  // Prefer Droid's auto-title, then the raw first-prompt title, then the
2988
3035
  // derived first-user-message topic.
2989
3036
  topic: sessionTitle || title || firstUserTopic,
3037
+ firstUserMessage,
2990
3038
  model,
2991
3039
  messageCount,
2992
3040
  durationMs,
@@ -3020,6 +3068,7 @@ export function initClaudeParseState() {
3020
3068
  version: undefined,
3021
3069
  model: undefined,
3022
3070
  topic: undefined,
3071
+ firstUserMessage: undefined,
3023
3072
  customTitle: undefined,
3024
3073
  aiTitle: undefined,
3025
3074
  entrypoint: undefined,
@@ -3202,6 +3251,8 @@ export function applyClaudeLine(state, parsed) {
3202
3251
  if (parsed.type === 'user') {
3203
3252
  const text = extractClaudeUserText(parsed);
3204
3253
  if (text) {
3254
+ if (!state.firstUserMessage)
3255
+ state.firstUserMessage = cleanFirstUserMessage(text);
3205
3256
  state.messageCount++;
3206
3257
  state.userTexts.push(text);
3207
3258
  if (!state.topic)
@@ -3297,6 +3348,7 @@ export function finalizeClaudeScan(state) {
3297
3348
  version: state.version,
3298
3349
  model: state.model,
3299
3350
  topic: state.topic,
3351
+ firstUserMessage: state.firstUserMessage,
3300
3352
  label,
3301
3353
  entrypoint: state.entrypoint,
3302
3354
  messageCount: state.messageCount,
@@ -3366,7 +3418,7 @@ export function serializeClaudeParserState(state, offset, jsonlDroppingOversized
3366
3418
  : allIds;
3367
3419
  const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
3368
3420
  return {
3369
- v: 5,
3421
+ v: 6,
3370
3422
  subAgents: state.subAgents,
3371
3423
  backgroundShells: state.backgroundShells,
3372
3424
  offset,
@@ -3379,6 +3431,7 @@ export function serializeClaudeParserState(state, offset, jsonlDroppingOversized
3379
3431
  entrypoint: state.entrypoint,
3380
3432
  firstTsMs: state.firstTsMs,
3381
3433
  topic: state.topic,
3434
+ firstUserMessage: state.firstUserMessage,
3382
3435
  customTitle: state.customTitle,
3383
3436
  aiTitle: state.aiTitle,
3384
3437
  plan: state.plan,
@@ -3449,6 +3502,7 @@ export function hydrateClaudeParseState(prior) {
3449
3502
  version: prior.version,
3450
3503
  model: prior.model,
3451
3504
  topic: prior.topic,
3505
+ firstUserMessage: prior.firstUserMessage,
3452
3506
  customTitle: prior.customTitle,
3453
3507
  aiTitle: prior.aiTitle,
3454
3508
  entrypoint: prior.entrypoint,
@@ -3613,7 +3667,7 @@ function parsePriorClaudeState(row) {
3613
3667
  return null;
3614
3668
  try {
3615
3669
  const parsed = JSON.parse(row.parserState);
3616
- if (parsed?.v !== 5 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
3670
+ if (parsed?.v !== 6 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
3617
3671
  return null;
3618
3672
  return parsed;
3619
3673
  }
@@ -3644,6 +3698,7 @@ export function initCodexParseState() {
3644
3698
  version: undefined,
3645
3699
  model: undefined,
3646
3700
  topic: undefined,
3701
+ firstUserMessage: undefined,
3647
3702
  messageCount: 0,
3648
3703
  tokenCount: undefined,
3649
3704
  lastTotalTokenUsage: undefined,
@@ -3748,7 +3803,8 @@ function applyCodexLine(state, parsed) {
3748
3803
  return;
3749
3804
  }
3750
3805
  if (parsed.type === 'response_item' && parsed.payload?.type === 'message') {
3751
- const role = parsed.payload.role === 'user' || parsed.payload.role === 'developer'
3806
+ const payloadRole = parsed.payload.role;
3807
+ const role = payloadRole === 'user' || payloadRole === 'developer'
3752
3808
  ? 'user'
3753
3809
  : 'assistant';
3754
3810
  const text = extractCodexMessageText(parsed.payload.content, role);
@@ -3756,9 +3812,17 @@ function applyCodexLine(state, parsed) {
3756
3812
  return;
3757
3813
  state.messageCount++;
3758
3814
  if (role === 'user') {
3759
- state.userTexts.push(text);
3815
+ const genuine = cleanFirstUserMessage(text);
3816
+ if (!genuine)
3817
+ return;
3818
+ state.userTexts.push(genuine);
3819
+ // Codex records developer instructions as response_item messages too.
3820
+ // They remain searchable for compatibility, but only an actual user role
3821
+ // may become the session's first request.
3822
+ if (payloadRole === 'user' && !state.firstUserMessage)
3823
+ state.firstUserMessage = genuine;
3760
3824
  if (!state.topic)
3761
- state.topic = extractSessionTopic(text);
3825
+ state.topic = extractSessionTopic(genuine);
3762
3826
  }
3763
3827
  else {
3764
3828
  state.assistantTexts.push(text);
@@ -3827,6 +3891,7 @@ function finalizeCodexScan(state) {
3827
3891
  version: state.version,
3828
3892
  model: state.model,
3829
3893
  topic: state.topic,
3894
+ firstUserMessage: state.firstUserMessage,
3830
3895
  messageCount: state.messageCount,
3831
3896
  tokenCount: state.tokenCount,
3832
3897
  outputTokens,
@@ -3882,7 +3947,7 @@ async function scanCodexSession(filePath) {
3882
3947
  export function serializeCodexParserState(state, offset, jsonlDroppingOversizedLine = false) {
3883
3948
  const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
3884
3949
  return {
3885
- v: 3,
3950
+ v: 4,
3886
3951
  offset,
3887
3952
  jsonlDroppingOversizedLine: jsonlDroppingOversizedLine || undefined,
3888
3953
  sessionId: state.sessionId,
@@ -3892,6 +3957,7 @@ export function serializeCodexParserState(state, offset, jsonlDroppingOversizedL
3892
3957
  version: state.version,
3893
3958
  model: state.model,
3894
3959
  topic: state.topic,
3960
+ firstUserMessage: state.firstUserMessage,
3895
3961
  messageCount: state.messageCount,
3896
3962
  tokenCount: state.tokenCount,
3897
3963
  lastTotalTokenUsage: state.lastTotalTokenUsage,
@@ -3934,6 +4000,7 @@ function hydrateCodexParseState(prior) {
3934
4000
  version: prior.version,
3935
4001
  model: prior.model,
3936
4002
  topic: prior.topic,
4003
+ firstUserMessage: prior.firstUserMessage,
3937
4004
  messageCount: prior.messageCount,
3938
4005
  tokenCount: prior.tokenCount,
3939
4006
  lastTotalTokenUsage: prior.lastTotalTokenUsage,
@@ -4073,7 +4140,7 @@ function parsePriorCodexState(row) {
4073
4140
  return null;
4074
4141
  try {
4075
4142
  const parsed = JSON.parse(row.parserState);
4076
- if (parsed?.v !== 3 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
4143
+ if (parsed?.v !== 4 || typeof parsed.offset !== 'number' || parsed.toolCalls?.v !== 1)
4077
4144
  return null;
4078
4145
  return parsed;
4079
4146
  }
@@ -4208,7 +4275,7 @@ function getClaudeUsageTotal(usage) {
4208
4275
  usage.cache_read_input_tokens,
4209
4276
  ]);
4210
4277
  }
4211
- /** Extract text from Codex message content blocks, filtering out system instructions for user messages. */
4278
+ /** Extract text from Codex message content blocks; the caller classifies user scaffolding. */
4212
4279
  function extractCodexMessageText(contentBlocks, role) {
4213
4280
  if (!Array.isArray(contentBlocks))
4214
4281
  return undefined;
@@ -4217,14 +4284,7 @@ function extractCodexMessageText(contentBlocks, role) {
4217
4284
  : contentBlocks.filter((block) => block.type === 'output_text');
4218
4285
  const text = matches
4219
4286
  .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
- });
4287
+ .find((value) => !!value);
4228
4288
  return text || undefined;
4229
4289
  }
4230
4290
  /** Trim and normalize a version string, returning undefined for empty values. */
@@ -4233,11 +4293,18 @@ function normalizeVersion(version) {
4233
4293
  return trimmed ? trimmed : undefined;
4234
4294
  }
4235
4295
  /** Extract the version number from a managed versions/<agent>/<version>/... path under either repo. */
4236
- function extractVersionFromManagedPath(agent, sourcePath) {
4296
+ export function extractVersionFromManagedPath(agent, sourcePath) {
4237
4297
  if (!sourcePath)
4238
4298
  return undefined;
4239
4299
  const candidates = [sourcePath, safeRealpathSync(sourcePath) || ''];
4240
4300
  const markers = [`/.agents/versions/${agent}/`, `/.agents-system/versions/${agent}/`];
4301
+ // Codex is relocated by CODEX_HOME to `~/.agents/.codex-homes/<version>/`
4302
+ // (shims.ts `codexHomeShimBash`), NOT the `versions/<agent>/<version>/home/…`
4303
+ // layout every other isolated agent uses — so its rollout transcripts live
4304
+ // under `.codex-homes/<version>/sessions/…` and the markers above never match,
4305
+ // leaving `version` NULL and native resume degraded to `/continue` (PHNX-3626).
4306
+ if (agent === 'codex')
4307
+ markers.push('/.agents/.codex-homes/');
4241
4308
  for (const candidate of candidates) {
4242
4309
  if (!candidate)
4243
4310
  continue;
@@ -4585,16 +4652,30 @@ export function readKimiMeta(filePath, priorRow) {
4585
4652
  project,
4586
4653
  filePath,
4587
4654
  topic,
4655
+ firstUserMessage: newState.firstUserMessage,
4588
4656
  messageCount,
4589
4657
  tokenCount: tokenCount > 0 ? tokenCount : undefined,
4590
4658
  outputTokens: outputTokens > 0 ? outputTokens : undefined,
4591
4659
  };
4592
4660
  return { meta, content: lastPrompt || '', parserState: JSON.stringify(newState) };
4593
4661
  }
4662
+ function kimiWireUserText(event) {
4663
+ if (event?.type !== 'context.append_message' || event?.message?.role !== 'user')
4664
+ return undefined;
4665
+ const content = event.message.content;
4666
+ const raw = typeof content === 'string'
4667
+ ? content
4668
+ : Array.isArray(content)
4669
+ ? content.map((part) => typeof part === 'string' ? part : typeof part?.text === 'string' ? part.text : '').join('\n')
4670
+ : '';
4671
+ return cleanFirstUserMessage(raw);
4672
+ }
4594
4673
  /** Fold one parsed Kimi wire event into the additive counters, in place. */
4595
4674
  function applyKimiWireEvent(acc, event) {
4596
4675
  if (event.type === 'context.append_message') {
4597
4676
  acc.messageCount++;
4677
+ if (!acc.firstUserMessage)
4678
+ acc.firstUserMessage = kimiWireUserText(event);
4598
4679
  }
4599
4680
  else if (event.type === 'usage.record' && event.usage) {
4600
4681
  // Kimi usage structure: inputOther + output + inputCacheRead + inputCacheCreation
@@ -4624,7 +4705,7 @@ export function parseKimiWireMetricsIncremental(sessionDir, prior) {
4624
4705
  if (!stat) {
4625
4706
  // No wire.jsonl (yet): zero counters, offset 0 so a later append is a clean
4626
4707
  // full parse.
4627
- return { messageCount: 0, tokenCount: 0, outputTokens: 0, newState: { v: 1, offset: 0, messageCount: 0, tokenCount: 0, outputTokens: 0 } };
4708
+ return { messageCount: 0, tokenCount: 0, outputTokens: 0, newState: { v: 2, offset: 0, messageCount: 0, tokenCount: 0, outputTokens: 0 } };
4628
4709
  }
4629
4710
  // INCREMENTAL only when a usable prior exists AND the file grew past its
4630
4711
  // offset; otherwise FULL from byte 0 with fresh counters (cold start OR the
@@ -4643,8 +4724,8 @@ export function parseKimiWireMetricsIncremental(sessionDir, prior) {
4643
4724
  const canIncrement = prior !== null && stat.size > prior.offset;
4644
4725
  const fromOffset = canIncrement ? prior.offset : 0;
4645
4726
  const acc = canIncrement
4646
- ? { messageCount: prior.messageCount, tokenCount: prior.tokenCount, outputTokens: prior.outputTokens }
4647
- : { messageCount: 0, tokenCount: 0, outputTokens: 0 };
4727
+ ? { messageCount: prior.messageCount, tokenCount: prior.tokenCount, outputTokens: prior.outputTokens, firstUserMessage: prior.firstUserMessage }
4728
+ : { messageCount: 0, tokenCount: 0, outputTokens: 0, firstUserMessage: undefined };
4648
4729
  let consumedBytes = 0;
4649
4730
  let fd;
4650
4731
  try {
@@ -4699,7 +4780,7 @@ export function parseKimiWireMetricsIncremental(sessionDir, prior) {
4699
4780
  messageCount: acc.messageCount,
4700
4781
  tokenCount: acc.tokenCount,
4701
4782
  outputTokens: acc.outputTokens,
4702
- newState: { v: 1, offset: fromOffset + consumedBytes, messageCount: acc.messageCount, tokenCount: acc.tokenCount, outputTokens: acc.outputTokens },
4783
+ newState: { v: 2, offset: fromOffset + consumedBytes, messageCount: acc.messageCount, tokenCount: acc.tokenCount, outputTokens: acc.outputTokens, firstUserMessage: acc.firstUserMessage },
4703
4784
  };
4704
4785
  }
4705
4786
  /**
@@ -4713,7 +4794,7 @@ function parsePriorKimiState(row) {
4713
4794
  return null;
4714
4795
  try {
4715
4796
  const parsed = JSON.parse(row.parserState);
4716
- if (parsed?.v !== 1 || typeof parsed.offset !== 'number')
4797
+ if (parsed?.v !== 2 || typeof parsed.offset !== 'number')
4717
4798
  return null;
4718
4799
  return parsed;
4719
4800
  }
@@ -5,6 +5,7 @@
5
5
  * wrapper tags, team-spawn boilerplate) so that only the real user intent
6
6
  * remains. Used by the session picker to show a human-readable topic line.
7
7
  */
8
+ import type { SessionEvent } from './types.js';
8
9
  /**
9
10
  * True when a `role=user` message is harness-injected scaffolding, not a genuine
10
11
  * user turn. Text-based so it is cross-harness by construction (the markers are
@@ -12,6 +13,16 @@
12
13
  * own scaffolding at parse time simply never reach here.
13
14
  */
14
15
  export declare function isSyntheticUserMessage(raw: string | undefined): boolean;
16
+ /**
17
+ * Return one genuine user turn in full.
18
+ * Synthetic scaffolding is rejected as a whole so an injected Apps/AGENTS
19
+ * preamble can never become the session's displayed request. Unlike the topic
20
+ * cleaner, this intentionally preserves internal whitespace and every line of
21
+ * the user's actual request.
22
+ */
23
+ export declare function cleanFirstUserMessage(raw: string | undefined): string | undefined;
24
+ /** First genuine user message from an already-normalized event stream. */
25
+ export declare function firstUserMessageFromEvents(events: SessionEvent[]): string | undefined;
15
26
  /**
16
27
  * Extract the invoked slash-command name from a raw `role=user` message, or
17
28
  * undefined when the message carries no `<command-name>` wrapper. Used by
@@ -47,6 +47,7 @@ const SYNTHETIC_USER_MESSAGE_PATTERNS = [
47
47
  /^\s*<\/?persisted-output>/i,
48
48
  /^\s*<permissions instructions>/i,
49
49
  /^\s*<(?:apps|plugins|skills)_instructions>/i,
50
+ /^\s*<recommended_plugins>/i,
50
51
  /^\s*<(?:multi_agent_mode|environment_context)>/i,
51
52
  /^\s*## (?:In-flight in this repo|Host & Fleet)/i,
52
53
  /^\s*Your current session id is\b/i,
@@ -68,6 +69,30 @@ export function isSyntheticUserMessage(raw) {
68
69
  return false;
69
70
  return SYNTHETIC_USER_MESSAGE_PATTERNS.some(pattern => pattern.test(raw));
70
71
  }
72
+ /**
73
+ * Return one genuine user turn in full.
74
+ * Synthetic scaffolding is rejected as a whole so an injected Apps/AGENTS
75
+ * preamble can never become the session's displayed request. Unlike the topic
76
+ * cleaner, this intentionally preserves internal whitespace and every line of
77
+ * the user's actual request.
78
+ */
79
+ export function cleanFirstUserMessage(raw) {
80
+ if (!raw || isSyntheticUserMessage(raw))
81
+ return undefined;
82
+ const clean = raw.trim();
83
+ return clean || undefined;
84
+ }
85
+ /** First genuine user message from an already-normalized event stream. */
86
+ export function firstUserMessageFromEvents(events) {
87
+ for (const event of events) {
88
+ if (event.type !== 'message' || event.role !== 'user' || event._synthetic)
89
+ continue;
90
+ const first = cleanFirstUserMessage(event.content);
91
+ if (first)
92
+ return first;
93
+ }
94
+ return undefined;
95
+ }
71
96
  /**
72
97
  * The `<command-name>` wrapper Claude injects into a `role=user` message when
73
98
  * a typed slash command is invoked, e.g.:
@@ -1,11 +1,30 @@
1
1
  import { type RotateCandidate } from '../accounting/rotate.js';
2
2
  import type { AgentId } from '../types.js';
3
- import type { SessionMeta } from './types.js';
3
+ import type { SessionAgentId, SessionMeta } from './types.js';
4
+ /** One capability boundary for every surface that advertises faithful Resume. */
5
+ export declare function sessionAgentSupportsResume(agent: SessionAgentId): boolean;
6
+ /**
7
+ * The account a recovery should authenticate as. Present only when resume
8
+ * rotates AWAY from the session's original login (an account limit) to a
9
+ * healthy sibling of the SAME harness. A `providerAccount` is a durable
10
+ * setup-token / API-key account (RUSH-3182) injected via the `--account` spawn
11
+ * path (`resolveSpawnAccount` → `accountEnv`); it is the only kind that can
12
+ * authenticate a NATIVE resume in the origin version home, because a native
13
+ * login lives in its own isolated home and cannot be forwarded. Absent means
14
+ * "use the launched version home's own native login" — the healthy-origin happy
15
+ * path and every /continue fallback.
16
+ */
17
+ export interface RecoveryAccount {
18
+ providerAccount: string;
19
+ label: string;
20
+ email: string | null;
21
+ }
4
22
  export type SessionRecoveryTarget = {
5
23
  mode: 'native';
6
24
  agent: AgentId;
7
25
  version: string;
8
26
  cwd?: string;
27
+ account?: RecoveryAccount;
9
28
  reason: string;
10
29
  } | {
11
30
  mode: 'continue';
@@ -46,7 +65,16 @@ export declare function inspectNativeResumeSession(session: SessionMeta, version
46
65
  * retained version trash. No healthy same-harness account is a loud failure.
47
66
  */
48
67
  export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean, nativeInspection?: NativeResumeInspection): SessionRecoveryTarget;
49
- export declare function resolveSessionRecovery(session: SessionMeta): Promise<SessionRecoveryTarget>;
68
+ /**
69
+ * Resolve recovery for a durable session, reading the live account pool.
70
+ *
71
+ * Uses {@link collectRunCandidatesForRun} (native version-home logins PLUS
72
+ * durable provider accounts, RUSH-3182) rather than the native-only
73
+ * {@link collectRunCandidates}, so an origin-account limit can rotate to a
74
+ * healthy provider account and stay NATIVE (PHNX-3626). `collect` is injectable
75
+ * for tests and for callers that must stay native-only.
76
+ */
77
+ export declare function resolveSessionRecovery(session: SessionMeta, collect?: (agent: AgentId) => Promise<RotateCandidate[]>): Promise<SessionRecoveryTarget>;
50
78
  /** Stable self-command used by focus, resume, and attach. The owning device runs
51
79
  * the recovery resolver above; callers must not native-resume another version's
52
80
  * isolated home themselves. */
@@ -4,8 +4,14 @@ import { AGENTS, agentConfigDirName } from '../agents.js';
4
4
  import { isSelfHost } from '../devices/self-host.js';
5
5
  import { nativeResume } from '../exec.js';
6
6
  import { machineId, normalizeHost } from '../machine-id.js';
7
- import { collectRunCandidates, formatNoHealthyAccountError, pickBalancedCandidate, readinessFromCandidate, } from '../accounting/rotate.js';
7
+ import { formatNoHealthyAccountError, pickBalancedCandidate, readinessFromCandidate, } from '../accounting/rotate.js';
8
+ import { collectRunCandidatesForRun } from '../accounting/account-pool-collect.js';
8
9
  import { getVersionHomePath } from '../installations/store.js';
10
+ const RESUMABLE_SESSION_AGENTS = new Set(['claude', 'codex', 'muse', 'opencode']);
11
+ /** One capability boundary for every surface that advertises faithful Resume. */
12
+ export function sessionAgentSupportsResume(agent) {
13
+ return RESUMABLE_SESSION_AGENTS.has(agent);
14
+ }
9
15
  export class SessionRecoveryError extends Error {
10
16
  constructor(message) {
11
17
  super(message);
@@ -152,6 +158,39 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
152
158
  ? candidates.find((candidate) => candidate.version === session.version)
153
159
  : undefined;
154
160
  const sourceReady = source ? readinessFromCandidate(source).ready : false;
161
+ // Native-first with account rotation (PHNX-3626). When the origin login is
162
+ // usage/rate/session-LIMITED (not signed-out or revoked — those need a login,
163
+ // not a rotation, so they keep going to /continue per SES-39) but its version
164
+ // home is installed, native-resume-capable, and still owns the indexed
165
+ // transcript, keep resume NATIVE by rotating to a healthy INJECTABLE (provider)
166
+ // account in that SAME home — rather than dropping to /continue on a different
167
+ // version. Only a provider token/key qualifies: a native login lives in its own
168
+ // isolated home and cannot be forwarded, so it could never authenticate a
169
+ // resume that must read the origin home's transcript (see §11).
170
+ const originReadiness = source ? readinessFromCandidate(source) : null;
171
+ const originLimited = !!originReadiness && !originReadiness.ready
172
+ && (originReadiness.reason === 'rate_limited' || originReadiness.reason === 'out_of_credits');
173
+ if (originLimited && source && session.version && supportsNative(agent, session.version)) {
174
+ const inspection = nativeInspection
175
+ ?? inspectNativeResumeSession(session, getVersionHomePath(agent, session.version));
176
+ if (inspection.available) {
177
+ const rotated = pickBalancedCandidate(candidates.filter((c) => c.providerAccount && c.accountKey !== source.accountKey));
178
+ const providerAccount = rotated?.picked.providerAccount;
179
+ if (providerAccount) {
180
+ // `originLimited` guarantees the origin is unhealthy with a limit reason.
181
+ const why = originReadiness.ready ? 'limited' : originReadiness.reason;
182
+ const label = rotated.picked.accountLabel || providerAccount;
183
+ return {
184
+ mode: 'native',
185
+ agent,
186
+ version: session.version,
187
+ cwd: inspection.cwd,
188
+ account: { providerAccount, label, email: rotated.picked.email },
189
+ reason: `origin ${agent}@${session.version} account is ${why}; rotated to healthy ${label} and resuming natively in the same home`,
190
+ };
191
+ }
192
+ }
193
+ }
155
194
  // An exact healthy origin is deterministic: preserve its isolated home. If
156
195
  // native resume is unavailable for that harness, /continue still launches in
157
196
  // that same healthy home. Only an unusable/missing origin enters balanced
@@ -164,7 +203,11 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
164
203
  throw new SessionRecoveryError(`Cannot recover session ${session.shortId} on ${device}; origin ${agent}@${session.version ?? 'unknown'}. ${detail}`);
165
204
  }
166
205
  const version = selection.picked.version;
167
- if (session.version === version && supportsNative(agent, version)) {
206
+ // Native resume without an injected RecoveryAccount is valid only for the
207
+ // exact healthy origin login. A balanced same-version provider selected for
208
+ // a signed-out/revoked origin must stay on /continue; otherwise we would open
209
+ // the origin home with no usable credential and fail (or fork state).
210
+ if (sourceReady && session.version === version && supportsNative(agent, version)) {
168
211
  const inspection = nativeInspection
169
212
  ?? inspectNativeResumeSession(session, getVersionHomePath(agent, version));
170
213
  if (inspection.available) {
@@ -190,9 +233,18 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
190
233
  reason: `${sourceReason(session, candidates)}; continuing with healthy ${agent}@${version}`,
191
234
  };
192
235
  }
193
- export async function resolveSessionRecovery(session) {
236
+ /**
237
+ * Resolve recovery for a durable session, reading the live account pool.
238
+ *
239
+ * Uses {@link collectRunCandidatesForRun} (native version-home logins PLUS
240
+ * durable provider accounts, RUSH-3182) rather than the native-only
241
+ * {@link collectRunCandidates}, so an origin-account limit can rotate to a
242
+ * healthy provider account and stay NATIVE (PHNX-3626). `collect` is injectable
243
+ * for tests and for callers that must stay native-only.
244
+ */
245
+ export async function resolveSessionRecovery(session, collect = collectRunCandidatesForRun) {
194
246
  const agent = runnableSessionAgent(session);
195
- return resolveSessionRecoveryFromCandidates(session, await collectRunCandidates(agent));
247
+ return resolveSessionRecoveryFromCandidates(session, await collect(agent));
196
248
  }
197
249
  /** Stable self-command used by focus, resume, and attach. The owning device runs
198
250
  * the recovery resolver above; callers must not native-resume another version's