@phnx-labs/agents-cli 1.22.82 → 1.22.84

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 (48) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/commands/accounts.js +34 -6
  3. package/dist/commands/sessions-trace.d.ts +11 -0
  4. package/dist/commands/sessions-trace.js +71 -0
  5. package/dist/commands/view.js +3 -6
  6. package/dist/lib/account-registry.d.ts +40 -3
  7. package/dist/lib/account-registry.js +89 -18
  8. package/dist/lib/accounts/connect.d.ts +6 -0
  9. package/dist/lib/accounts/connect.js +70 -0
  10. package/dist/lib/claude-statusline.d.ts +13 -5
  11. package/dist/lib/claude-statusline.js +19 -6
  12. package/dist/lib/daemon-ticks.js +23 -2
  13. package/dist/lib/devices/fleet-inventory.js +3 -4
  14. package/dist/lib/devices/harness-inventory.js +4 -5
  15. package/dist/lib/feed/activity-stream.d.ts +60 -0
  16. package/dist/lib/feed/activity-stream.js +271 -0
  17. package/dist/lib/feed/activity.d.ts +7 -0
  18. package/dist/lib/feed/activity.js +103 -13
  19. package/dist/lib/feed/watch.d.ts +9 -3
  20. package/dist/lib/feed/watch.js +72 -36
  21. package/dist/lib/fleet-shared-state.d.ts +6 -0
  22. package/dist/lib/session/active.d.ts +38 -4
  23. package/dist/lib/session/active.js +36 -4
  24. package/dist/lib/session/bash-command.js +10 -0
  25. package/dist/lib/session/db.d.ts +47 -2
  26. package/dist/lib/session/db.js +131 -4
  27. package/dist/lib/session/mirror.d.ts +5 -0
  28. package/dist/lib/session/mirror.js +166 -0
  29. package/dist/lib/session/parse.d.ts +49 -0
  30. package/dist/lib/session/parse.js +324 -30
  31. package/dist/lib/session/prompt.d.ts +93 -2
  32. package/dist/lib/session/prompt.js +266 -15
  33. package/dist/lib/session/remote/peer-stream.d.ts +47 -0
  34. package/dist/lib/session/remote/peer-stream.js +142 -0
  35. package/dist/lib/session/remote/watch.d.ts +1 -1
  36. package/dist/lib/session/remote/watch.js +41 -42
  37. package/dist/lib/session/session-cache.d.ts +9 -0
  38. package/dist/lib/session/session-cache.js +40 -2
  39. package/dist/lib/session/timeline-pass.d.ts +129 -0
  40. package/dist/lib/session/timeline-pass.js +323 -0
  41. package/dist/lib/session/timeline.d.ts +182 -0
  42. package/dist/lib/session/timeline.js +636 -0
  43. package/dist/lib/session/types.d.ts +155 -1
  44. package/dist/lib/summarizer/pass.d.ts +2 -0
  45. package/dist/lib/summarizer/pass.js +14 -1
  46. package/dist/lib/summarizer/summarize.d.ts +7 -0
  47. package/dist/lib/summarizer/summarize.js +3 -0
  48. package/package.json +1 -1
@@ -67,9 +67,29 @@ function sanitizeEvent(e) {
67
67
  e.hookName = sanitizeForTerminal(e.hookName);
68
68
  if (e.hookEvent)
69
69
  e.hookEvent = sanitizeForTerminal(e.hookEvent);
70
+ // The harness's own per-call label and turn phase are free text from the same
71
+ // untrusted transcript, and the timeline ships them straight to a terminal.
72
+ if (e.label)
73
+ e.label = sanitizeForTerminal(e.label);
74
+ if (e.phase)
75
+ e.phase = sanitizeForTerminal(e.phase);
70
76
  if (e.args)
71
77
  e.args = sanitizeArgsDeep(e.args);
72
78
  }
79
+ /**
80
+ * Did this Claude tool_result record fail, or was it never allowed to run?
81
+ *
82
+ * Two distinct signals, both on the record ENCLOSING the `tool_result` block:
83
+ * `toolDenialKind` when a permission rule or hook refused the call, and
84
+ * `toolUseResult.interrupted` when the operator cut it short with Ctrl-C.
85
+ * Neither is a failure of the work, so both land on `blocked`, which is what
86
+ * separates "this agent is failing" from "this agent is being stopped".
87
+ */
88
+ function isBlockedToolResult(raw) {
89
+ if (typeof raw?.toolDenialKind === 'string' && raw.toolDenialKind)
90
+ return true;
91
+ return raw?.toolUseResult?.interrupted === true;
92
+ }
73
93
  function firstString(...values) {
74
94
  for (const value of values) {
75
95
  if (typeof value === 'string' && value.trim())
@@ -324,6 +344,22 @@ export function parseClaudeContent(content, opts = {}) {
324
344
  }
325
345
  const type = raw.type;
326
346
  const timestamp = raw.timestamp || new Date().toISOString();
347
+ // Claude's own file ledger: one record per file it backed up before changing
348
+ // it. `trackingPath` is the exact path; the record carries no add/update/
349
+ // delete verb, so the OPERATION still comes from the tool that touched it —
350
+ // this supplies the authoritative path set and touch time, nothing more.
351
+ if (type === 'file-history-delta' && opts.includeFileHistory) {
352
+ const trackingPath = typeof raw.trackingPath === 'string' ? raw.trackingPath : '';
353
+ if (trackingPath) {
354
+ events.push({
355
+ type: 'file_change',
356
+ agent: 'claude',
357
+ timestamp: typeof raw.timestamp === 'string' ? raw.timestamp : timestamp,
358
+ changes: [{ path: trackingPath, op: 'modified' }],
359
+ });
360
+ }
361
+ continue;
362
+ }
327
363
  if (type === 'assistant') {
328
364
  const contentBlocks = raw.message?.content || [];
329
365
  for (const block of contentBlocks) {
@@ -369,6 +405,13 @@ export function parseClaudeContent(content, opts = {}) {
369
405
  path: toolInput.file_path || undefined,
370
406
  command: toolName === 'Bash' ? toolInput.command : undefined,
371
407
  };
408
+ // Claude writes a human `description` on every Bash call (and on Agent
409
+ // spawns) — the one-line label the model authored for exactly this
410
+ // purpose. `summarizeToolUse` renders the command instead, so the label
411
+ // was reaching no consumer; the timeline's now-line is written from it.
412
+ if (typeof toolInput.description === 'string' && toolInput.description.trim()) {
413
+ event.label = toolInput.description.trim();
414
+ }
372
415
  if (isLocal)
373
416
  event._local = true;
374
417
  // SlashCommand: the MODEL invoking a slash command programmatically
@@ -484,6 +527,12 @@ export function parseClaudeContent(content, opts = {}) {
484
527
  statusCode: structured.statusCode,
485
528
  errorCode: structured.errorCode,
486
529
  content: output || 'Tool execution failed',
530
+ // A permission rule or a hook denied the call, or the operator cut
531
+ // it short with Ctrl-C: it never completed, so it is not a failure
532
+ // of the work. Claude stamps the denial kind on the enclosing
533
+ // record (`toolDenialKind: 'permission-rule'`) and the interrupt
534
+ // on its result (`toolUseResult.interrupted: true`).
535
+ ...(isBlockedToolResult(raw) ? { blocked: true } : {}),
487
536
  });
488
537
  }
489
538
  else {
@@ -589,6 +638,203 @@ function codexExecCommand(input) {
589
638
  const commands = commandsFromCodexExec(input);
590
639
  return commands.length > 0 ? commands.join('\n') : undefined;
591
640
  }
641
+ /**
642
+ * Codex writes its turn TWICE in one rollout: the raw `response_item` records
643
+ * the model exchanged, and a parallel `event_msg` / `item_completed` stream of
644
+ * typed, already-classified items. The two overlap — on a 6,244-line rollout on
645
+ * zion (2026-09-06) the same turn appears as 648 `custom_tool_call` +
646
+ * 60 `function_call` records AND as 1,038 `CommandExecution` items — so the two
647
+ * MUST NOT be merged into one event list: every consumer that counts tool calls
648
+ * (digest, trajectory, insights, the tool index) would double-count.
649
+ *
650
+ * {@link parseCodexContent} therefore keeps reading `response_item` and this is
651
+ * a SEPARATE reader over the item stream, for consumers that want the harness's
652
+ * own classification instead of re-deriving it: the command as Codex parsed it
653
+ * (`parsed_cmd.type`), its `exit_code`/`status`, the per-path `FileChange`
654
+ * ledger, `AgentMessage.phase` (`commentary` is the narration between calls),
655
+ * web searches, sub-agent activity and compactions. The timeline fold is the
656
+ * consumer; `parseSession` is untouched.
657
+ *
658
+ * Version-tolerant by construction: an item type this does not know folds to a
659
+ * generic `other` tool_use rather than throwing, so a Codex release that adds an
660
+ * item type degrades to a counted step instead of an empty timeline.
661
+ */
662
+ export function parseCodexItemsContent(content) {
663
+ const events = [];
664
+ for (const line of content.split('\n')) {
665
+ if (!line.trim())
666
+ continue;
667
+ let raw;
668
+ try {
669
+ raw = JSON.parse(line);
670
+ }
671
+ catch {
672
+ continue;
673
+ }
674
+ if (raw?.type !== 'event_msg')
675
+ continue;
676
+ const payload = raw.payload || {};
677
+ const timestamp = raw.timestamp || new Date().toISOString();
678
+ if (payload.type === 'turn_aborted') {
679
+ events.push({ type: 'interrupt', agent: 'codex', timestamp, content: 'Turn interrupted by user' });
680
+ continue;
681
+ }
682
+ if (payload.type !== 'item_completed')
683
+ continue;
684
+ const item = payload.item || {};
685
+ const id = typeof item.id === 'string' ? item.id : undefined;
686
+ switch (item.type) {
687
+ case 'AgentMessage': {
688
+ const text = codexItemText(item.content);
689
+ if (!text)
690
+ break;
691
+ events.push({
692
+ type: 'message', agent: 'codex', timestamp, role: 'assistant', content: text,
693
+ ...(typeof item.phase === 'string' ? { phase: item.phase } : {}),
694
+ });
695
+ break;
696
+ }
697
+ case 'UserMessage': {
698
+ const text = codexItemText(item.content);
699
+ if (text)
700
+ events.push({ type: 'message', agent: 'codex', timestamp, role: 'user', content: text });
701
+ break;
702
+ }
703
+ case 'CommandExecution': {
704
+ const command = codexItemCommand(item.command);
705
+ const parsed = Array.isArray(item.parsed_cmd) ? item.parsed_cmd[0] : undefined;
706
+ const parsedType = parsed && typeof parsed === 'object' ? parsed.type : undefined;
707
+ const verbClass = CODEX_PARSED_CMD_VERBS[String(parsedType)];
708
+ events.push({
709
+ type: 'tool_use', agent: 'codex', timestamp, tool: 'Bash', callId: id,
710
+ args: { command }, command,
711
+ label: truncate(command.replace(/\n/g, ' ').trim(), 120),
712
+ ...(verbClass ? { verbClass } : {}),
713
+ });
714
+ const exitCode = typeof item.exit_code === 'number' ? item.exit_code : undefined;
715
+ const failedStatus = item.status === 'failed' || item.status === 'error';
716
+ // A search that matched nothing exits 1 and is not a failure — the same
717
+ // rule the fold applies to Claude, kept here so the two agree.
718
+ const benign = exitCode === 1 && CODEX_BENIGN_EXIT1_RE.test(command);
719
+ if (failedStatus || (exitCode !== undefined && exitCode !== 0 && !benign)) {
720
+ events.push({
721
+ type: 'error', agent: 'codex', timestamp, tool: 'Bash', callId: id,
722
+ outcome: 'error', ...(exitCode !== undefined ? { exitCode } : {}),
723
+ content: 'Command failed',
724
+ });
725
+ }
726
+ else if (exitCode !== undefined || item.status) {
727
+ events.push({
728
+ type: 'tool_result', agent: 'codex', timestamp, tool: 'Bash', callId: id,
729
+ success: true, outcome: 'ok', ...(exitCode !== undefined ? { exitCode } : {}),
730
+ });
731
+ }
732
+ break;
733
+ }
734
+ case 'FileChange': {
735
+ const changes = codexFileChanges(item.changes);
736
+ if (!changes.length)
737
+ break;
738
+ events.push({ type: 'file_change', agent: 'codex', timestamp, callId: id, changes });
739
+ break;
740
+ }
741
+ case 'Extension': {
742
+ const query = typeof item.query === 'string' ? item.query : '';
743
+ events.push({
744
+ type: 'tool_use', agent: 'codex', timestamp, tool: 'WebSearch', callId: id,
745
+ args: { query }, verbClass: 'browser',
746
+ label: `web search: ${truncate(query, 60)}`,
747
+ });
748
+ break;
749
+ }
750
+ case 'SubAgentActivity': {
751
+ // `started` opens a sub-agent; the matching `completed` would count the
752
+ // same spawn twice, so only the opening edge is a call.
753
+ if (item.kind !== 'started')
754
+ break;
755
+ events.push({
756
+ type: 'tool_use', agent: 'codex', timestamp, tool: 'Agent', callId: id,
757
+ args: { path: item.agent_path }, verbClass: 'agent',
758
+ label: `subagent ${String(item.agent_path ?? '').replace(/^\//, '') || 'started'}`,
759
+ });
760
+ break;
761
+ }
762
+ case 'McpToolCall': {
763
+ const label = `${item.server ?? ''}.${item.tool ?? ''}`.replace(/^\.|\.$/g, '');
764
+ events.push({
765
+ type: 'tool_use', agent: 'codex', timestamp, tool: 'mcp', callId: id,
766
+ args: {}, verbClass: 'other', label: label || 'mcp call',
767
+ });
768
+ break;
769
+ }
770
+ case 'ImageView': {
771
+ events.push({
772
+ type: 'tool_use', agent: 'codex', timestamp, tool: 'Read', callId: id,
773
+ args: { path: item.path }, verbClass: 'read', label: 'viewed image',
774
+ });
775
+ break;
776
+ }
777
+ case 'ContextCompaction': {
778
+ events.push({ type: 'hook', agent: 'codex', timestamp, hookName: 'ContextCompaction', content: 'context compacted' });
779
+ break;
780
+ }
781
+ case 'Reasoning':
782
+ // `summary_text` is empty on every rollout measured (13,461 items, 0 with
783
+ // text — only `encrypted_content`), so there is nothing to read. Skipped
784
+ // deliberately rather than emitted as an empty thinking event.
785
+ break;
786
+ default: {
787
+ // Unknown item type from a newer Codex: counted, never dropped, never thrown.
788
+ if (!item.type)
789
+ break;
790
+ events.push({
791
+ type: 'tool_use', agent: 'codex', timestamp, tool: String(item.type), callId: id,
792
+ args: {}, verbClass: 'other', label: String(item.type),
793
+ });
794
+ break;
795
+ }
796
+ }
797
+ }
798
+ return events;
799
+ }
800
+ /** Codex `parsed_cmd[0].type` → the timeline's verb class. Anything else is derived from the command. */
801
+ const CODEX_PARSED_CMD_VERBS = {
802
+ read: 'read',
803
+ list_files: 'read',
804
+ search: 'read',
805
+ };
806
+ /** Commands whose exit 1 means "no match", not "failed". */
807
+ const CODEX_BENIGN_EXIT1_RE = /^\s*(rg|grep|diff|test|\[)\b/;
808
+ /** Text of a Codex item's `content` array (`{ type, text }` blocks). */
809
+ function codexItemText(content) {
810
+ if (!Array.isArray(content))
811
+ return '';
812
+ return content
813
+ .map((part) => (part && typeof part.text === 'string' ? part.text : ''))
814
+ .join(' ')
815
+ .trim();
816
+ }
817
+ /** `["/bin/zsh", "-lc", "git pull"]` → `git pull`; a plain string passes through. */
818
+ function codexItemCommand(command) {
819
+ if (typeof command === 'string')
820
+ return command;
821
+ if (!Array.isArray(command))
822
+ return '';
823
+ const shellWrapped = command.length >= 3 && (command[1] === '-lc' || command[1] === '-c');
824
+ return (shellWrapped ? command.slice(2) : command).map(String).join(' ');
825
+ }
826
+ /** Codex `FileChange.changes`: `{ "<path>": { type: "add" | "update" | "delete" } }`. */
827
+ function codexFileChanges(changes) {
828
+ if (!changes || typeof changes !== 'object' || Array.isArray(changes))
829
+ return [];
830
+ const ops = {
831
+ add: 'created', update: 'modified', delete: 'deleted',
832
+ };
833
+ return Object.entries(changes).map(([filePath, change]) => ({
834
+ path: filePath,
835
+ op: ops[String(change?.type)] ?? 'modified',
836
+ }));
837
+ }
592
838
  /**
593
839
  * Parse Codex JSONL *content* (already read into a string) into normalized
594
840
  * events. Split from `parseCodex` so the tail reader can parse just the last
@@ -1164,35 +1410,32 @@ export function splitSessionFilePath(filePath) {
1164
1410
  export function sessionFilePathContainer(filePath) {
1165
1411
  return splitSessionFilePath(filePath).container;
1166
1412
  }
1167
- // ---------------------------------------------------------------------------
1168
- // OpenCode parser
1169
- // ---------------------------------------------------------------------------
1170
1413
  /**
1171
- * Parse an OpenCode session from its SQLite database.
1172
- * filePath format: "/path/to/opencode.db#session_id"
1173
- *
1174
- * Data model: session -> message -> part
1175
- * Messages have role (user/assistant) and metadata.
1176
- * Parts contain the actual content: text, tool, reasoning, patch, step-start/finish.
1177
- */
1178
- /**
1179
- * Parse a Grok (xAI CLI) session into normalized events.
1180
- *
1181
- * A Grok session dir holds several files; the conversation transcript is
1182
- * `chat_history.jsonl`, one JSON object per line with a `type`:
1183
- * - `system` — the system prompt (skipped; not conversational)
1184
- * - `user` — `content` is an array of `{ type: 'text', text }` blocks
1185
- * - `assistant` — `content` is a string, plus a `tool_calls[]` array of
1186
- * `{ id, name, arguments }` (arguments is a JSON string)
1187
- * - `reasoning` — chain-of-thought; text (when present) lives in `summary`
1188
- * - `tool_result` — `{ tool_call_id, content }`, correlated back to the call
1189
- *
1190
- * The scanner records `summary.json` as the session's filePath (see
1191
- * `readGrokMeta` in discover.ts), so resolve `chat_history.jsonl` from the same
1192
- * dir; also accept being handed the transcript file directly. Per-line
1193
- * timestamps aren't stored, so every event carries the session's `created_at`
1194
- * (from summary.json), falling back to the transcript's mtime.
1414
+ * Read `signals.json` next to a Grok transcript. Returns `undefined` when the
1415
+ * file is absent or unreadable — a Grok session simply has no signals then, and
1416
+ * the fold reports what it does have rather than inventing zeros.
1195
1417
  */
1418
+ export function readGrokSignals(filePath) {
1419
+ const signalsPath = filePath.endsWith('signals.json')
1420
+ ? filePath
1421
+ : path.join(path.dirname(filePath), 'signals.json');
1422
+ let raw;
1423
+ try {
1424
+ raw = JSON.parse(fs.readFileSync(signalsPath, 'utf-8'));
1425
+ }
1426
+ catch {
1427
+ return undefined;
1428
+ }
1429
+ if (!raw || typeof raw !== 'object')
1430
+ return undefined;
1431
+ const count = (value) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0);
1432
+ return {
1433
+ gitCommitCount: count(raw.gitCommitCount),
1434
+ prCreatedCount: count(raw.prCreatedCount),
1435
+ prMergedCount: count(raw.prMergedCount),
1436
+ toolFailureCount: count(raw.toolFailureCount),
1437
+ };
1438
+ }
1196
1439
  export function parseGrok(filePath) {
1197
1440
  const sessionDir = path.dirname(filePath);
1198
1441
  const historyPath = filePath.endsWith('chat_history.jsonl')
@@ -1372,6 +1615,12 @@ export const OPENCODE_TRANSCRIPT_QUERY = `
1372
1615
  'callID', json_extract(p.data, '$.callID'),
1373
1616
  'state', json_object(
1374
1617
  'status', json_extract(p.data, '$.state.status'),
1618
+ /* state.title is the human label OpenCode writes per call ("Read
1619
+ src/index.ts") — kept so the timeline now-line reads as the harness
1620
+ wrote it (PHNX-3939). A BLOCK comment, never a line comment: this
1621
+ template is flattened to one line below, so a line comment would
1622
+ swallow the rest of the query. */
1623
+ 'title', json_extract(p.data, '$.state.title'),
1375
1624
  'input', CASE
1376
1625
  WHEN LENGTH(CAST(COALESCE(json_extract(p.data, '$.state.input'), '') AS BLOB)) > ${OPENCODE_INPUT_MAX_BYTES}
1377
1626
  THEN json_object(
@@ -1497,6 +1746,13 @@ export function parseOpenCode(filePath) {
1497
1746
  const input = state.input || {};
1498
1747
  const output = state.output || '';
1499
1748
  const callId = typeof partData.callID === 'string' ? partData.callID : undefined;
1749
+ // OpenCode writes both a per-call `input.description` and a rendered
1750
+ // `state.title`; either is the label a person would read.
1751
+ const label = typeof input.description === 'string' && input.description.trim()
1752
+ ? input.description.trim()
1753
+ : typeof state.title === 'string' && state.title.trim()
1754
+ ? state.title.trim()
1755
+ : undefined;
1500
1756
  events.push({
1501
1757
  type: 'tool_use',
1502
1758
  agent: 'opencode',
@@ -1506,6 +1762,7 @@ export function parseOpenCode(filePath) {
1506
1762
  args: input,
1507
1763
  command: toolName === 'shell' ? input.command : undefined,
1508
1764
  path: input.filePath || input.path || undefined,
1765
+ ...(label ? { label } : {}),
1509
1766
  });
1510
1767
  if (state.status === 'completed' || state.status === 'error') {
1511
1768
  const outputStr = typeof output === 'string' ? output : JSON.stringify(output);
@@ -1521,7 +1778,28 @@ export function parseOpenCode(filePath) {
1521
1778
  }
1522
1779
  break;
1523
1780
  }
1524
- // Skip step-start, step-finish, patch, file — not needed for transcript/trace
1781
+ case 'patch': {
1782
+ // OpenCode's own edit ledger for one step: the exact paths it wrote.
1783
+ // The part records no add/update/delete verb, so the operation still
1784
+ // comes from the tool that produced it (see foldSessionFiles).
1785
+ const files = Array.isArray(partData.files) ? partData.files : [];
1786
+ const changes = files
1787
+ .map((file) => (typeof file === 'string' ? file : file?.path))
1788
+ .filter((filePath) => typeof filePath === 'string' && filePath.length > 0)
1789
+ .map((filePath) => ({ path: filePath, op: 'modified' }));
1790
+ if (changes.length) {
1791
+ events.push({ type: 'file_change', agent: 'opencode', timestamp, changes });
1792
+ }
1793
+ break;
1794
+ }
1795
+ case 'compaction': {
1796
+ events.push({
1797
+ type: 'hook', agent: 'opencode', timestamp,
1798
+ hookName: 'ContextCompaction', content: 'context compacted',
1799
+ });
1800
+ break;
1801
+ }
1802
+ // Skip step-start, step-finish, file — not needed for transcript/trace
1525
1803
  }
1526
1804
  }
1527
1805
  }
@@ -1919,6 +2197,13 @@ export function parseKimi(filePath) {
1919
2197
  if (callId) {
1920
2198
  toolCallMap.set(callId, toolName);
1921
2199
  }
2200
+ // Kimi writes a per-call `description` on the wire event (and sometimes
2201
+ // inside the args) — the same human label Claude puts on a Bash call.
2202
+ const kimiLabel = typeof event.description === 'string' && event.description.trim()
2203
+ ? event.description.trim()
2204
+ : typeof args.description === 'string' && args.description.trim()
2205
+ ? args.description.trim()
2206
+ : undefined;
1922
2207
  events.push({
1923
2208
  type: 'tool_use',
1924
2209
  agent: 'kimi',
@@ -1928,6 +2213,7 @@ export function parseKimi(filePath) {
1928
2213
  args,
1929
2214
  path: args.path || args.file_path || undefined,
1930
2215
  command: toolName === 'Bash' ? args.command : undefined,
2216
+ ...(kimiLabel ? { label: kimiLabel } : {}),
1931
2217
  });
1932
2218
  }
1933
2219
  else if (eventType === 'tool.result') {
@@ -2062,7 +2348,7 @@ function parseAnthropicMessageJsonl(filePath, agent) {
2062
2348
  const toolInput = block.input || {};
2063
2349
  if (block.id)
2064
2350
  toolUseMap.set(block.id, { tool: toolName, args: toolInput });
2065
- events.push({
2351
+ const event = {
2066
2352
  type: 'tool_use',
2067
2353
  agent,
2068
2354
  timestamp,
@@ -2071,7 +2357,15 @@ function parseAnthropicMessageJsonl(filePath, agent) {
2071
2357
  args: toolInput,
2072
2358
  path: toolInput.file_path || toolInput.path || undefined,
2073
2359
  command: (toolName === 'Bash' || toolName === 'Execute' || toolName === 'Shell') ? toolInput.command : undefined,
2074
- });
2360
+ };
2361
+ // Both harnesses on this parser write the same per-call human label
2362
+ // under different keys — Cursor `description`, Droid `summary` — so the
2363
+ // timeline's now-line reads "List files" rather than `ls -la`, exactly
2364
+ // as it does for the Claude arm above.
2365
+ const label = firstString(toolInput.description, toolInput.summary);
2366
+ if (label)
2367
+ event.label = label;
2368
+ events.push(event);
2075
2369
  }
2076
2370
  else if (block.type === 'tool_result') {
2077
2371
  const toolId = block.tool_use_id;
@@ -5,7 +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
+ import type { SessionAttachment, SessionEvent } from './types.js';
9
9
  /**
10
10
  * True when a `role=user` message is harness-injected scaffolding, not a genuine
11
11
  * user turn. Text-based so it is cross-harness by construction (the markers are
@@ -27,6 +27,15 @@ export declare function unwrapUserQuery(text: string): string;
27
27
  * so the stored turn is the originating request, not the harness tags.
28
28
  */
29
29
  export declare function cleanFirstUserMessage(raw: string | undefined): string | undefined;
30
+ /**
31
+ * LATEST genuine user message from an already-normalized event stream.
32
+ *
33
+ * The operative request of a `/continue`d, redirected, or interrupted-and-
34
+ * restated session is the last thing the user said, not the first (PHNX-3939).
35
+ * Same rejection rules as {@link firstUserMessageFromEvents} — walking from the
36
+ * end so a long transcript stops at the first genuine turn it meets.
37
+ */
38
+ export declare function lastUserMessageFromEvents(events: SessionEvent[]): string | undefined;
30
39
  /** First genuine user message from an already-normalized event stream. */
31
40
  export declare function firstUserMessageFromEvents(events: SessionEvent[]): string | undefined;
32
41
  /**
@@ -101,5 +110,87 @@ export declare function classifyUserPrompt(raw: string | undefined, opts?: {
101
110
  * here — the caller keeps it verbatim.
102
111
  */
103
112
  export declare function cleanGeneratedSessionLabel(title: string | undefined): string | undefined;
104
- /** Extract a one-line topic from a raw user message, or undefined if the message is pure noise. */
113
+ /**
114
+ * Extract a one-line topic from a raw user message, or undefined if the message
115
+ * is pure noise.
116
+ *
117
+ * Rejection is {@link isSyntheticUserMessage} — the SAME list the turn cleaner
118
+ * uses. This function used to consult a shorter, looser copy of it
119
+ * (permissions / collaboration / AGENTS.md / local-command-caveat /
120
+ * `<command-*>` only), which is how a `/model` echo (`<local-command-stdout>`)
121
+ * or a skill body ("Base directory for this skill:") became a session's topic —
122
+ * and then its displayed title — while `cleanFirstUserMessage` rejected the same
123
+ * text (PHNX-3939). Whatever is not a genuine user turn is not a topic either.
124
+ *
125
+ * The check runs twice on purpose: once on the raw message, and again after
126
+ * {@link cleanSessionPrompt} strips wrapper tags, since stripping can expose a
127
+ * marker (a skill body) that the tags were hiding.
128
+ */
105
129
  export declare function extractSessionTopic(raw: string): string | undefined;
130
+ /**
131
+ * One classified piece of a request that is not prose: an image or file the user
132
+ * dropped in, or an `@path` directory reference. `name` is the basename (or the
133
+ * `@ref` verbatim) — the sidebar renders these as chips beside the request text.
134
+ */
135
+ export interface RequestAttachmentRef {
136
+ kind: 'image' | 'file' | 'dir';
137
+ name: string;
138
+ }
139
+ /**
140
+ * A user request split into its parts, never rewritten (PHNX-3939).
141
+ *
142
+ * The rule this type exists to enforce: the request card shows what the agent
143
+ * was actually told. Prose is joined and whitespace-collapsed, never
144
+ * paraphrased or summarized — a model rewrite is what the optional summarizer's
145
+ * `goal` is for, and it is labeled as such. Everything that is NOT the user's
146
+ * sentence (screenshot paths, `host:/path` clip references, `@dir` mentions,
147
+ * pasted terminal echo) is pulled out into `attachments` / `pastedLines` so the
148
+ * sentence survives intact instead of being buried.
149
+ */
150
+ export interface TidyRequest {
151
+ /** The user's prose, joined and whitespace-collapsed. Empty for an image-only turn. */
152
+ text: string;
153
+ /** First sentence of {@link text}, capped — the one line a row shows. */
154
+ headline: string;
155
+ kind: UserPromptKind;
156
+ /** The invocation this turn came in as, e.g. `/continue 8231082e`. */
157
+ command?: string;
158
+ attachments: RequestAttachmentRef[];
159
+ /** Lines classified as pasted terminal echo rather than prose. */
160
+ pastedLines: number;
161
+ }
162
+ /**
163
+ * First sentence of a block of text, flattened and capped.
164
+ *
165
+ * Leading markdown headings are skipped so the headline is the ask, not the
166
+ * section label. A block that then opens with markdown structure (a table row, a
167
+ * bullet, a numbered item) is reduced to its first LINE — a sentence-terminator
168
+ * scan across a table would otherwise splice two cells together. Markdown
169
+ * emphasis characters are dropped so a headline never renders as raw syntax. A
170
+ * "sentence" shorter than 12 characters is not one (an abbreviation, a version
171
+ * number), so the whole flattened text is kept instead.
172
+ */
173
+ export declare function firstSentence(text: string, cap?: number): string;
174
+ /**
175
+ * Split one raw user turn into prose, attachments and pasted terminal echo
176
+ * WITHOUT rewriting a word of it (PHNX-3939). Returns `undefined` when the turn
177
+ * is harness-injected scaffolding or carries nothing to show.
178
+ *
179
+ * Order matters and mirrors how a turn is actually assembled:
180
+ * 1. a `<user_query>` wrapper is unwrapped (Grok/Cursor);
181
+ * 2. a `/name <id>` or `<command-name>` invocation becomes `command`, and any
182
+ * prose inside `<command-args>` becomes the request itself — this is what
183
+ * makes a `/plan -- Wait hold on…` turn read as the user's sentence rather
184
+ * than as a bare command name;
185
+ * 3. whole-message scaffolding is rejected;
186
+ * 4. a multi-line paste that opens with a command and asks nothing is pasted
187
+ * output, not a request;
188
+ * 5. every remaining segment (split on ` -- ` and per line) is classified as
189
+ * an attachment, terminal echo, or prose.
190
+ *
191
+ * `opts.attachments` are the row's own recorded attachments, folded in so an
192
+ * image the harness captured but the text never named still shows as a chip.
193
+ */
194
+ export declare function tidyRequest(raw: string | undefined, opts?: {
195
+ attachments?: SessionAttachment[];
196
+ }, depth?: number): TidyRequest | undefined;