@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.
- package/CHANGELOG.md +47 -0
- package/dist/commands/accounts.js +34 -6
- package/dist/commands/sessions-trace.d.ts +11 -0
- package/dist/commands/sessions-trace.js +71 -0
- package/dist/commands/view.js +3 -6
- package/dist/lib/account-registry.d.ts +40 -3
- package/dist/lib/account-registry.js +89 -18
- package/dist/lib/accounts/connect.d.ts +6 -0
- package/dist/lib/accounts/connect.js +70 -0
- package/dist/lib/claude-statusline.d.ts +13 -5
- package/dist/lib/claude-statusline.js +19 -6
- package/dist/lib/daemon-ticks.js +23 -2
- package/dist/lib/devices/fleet-inventory.js +3 -4
- package/dist/lib/devices/harness-inventory.js +4 -5
- package/dist/lib/feed/activity-stream.d.ts +60 -0
- package/dist/lib/feed/activity-stream.js +271 -0
- package/dist/lib/feed/activity.d.ts +7 -0
- package/dist/lib/feed/activity.js +103 -13
- package/dist/lib/feed/watch.d.ts +9 -3
- package/dist/lib/feed/watch.js +72 -36
- package/dist/lib/fleet-shared-state.d.ts +6 -0
- package/dist/lib/session/active.d.ts +38 -4
- package/dist/lib/session/active.js +36 -4
- package/dist/lib/session/bash-command.js +10 -0
- package/dist/lib/session/db.d.ts +47 -2
- package/dist/lib/session/db.js +131 -4
- package/dist/lib/session/mirror.d.ts +5 -0
- package/dist/lib/session/mirror.js +166 -0
- package/dist/lib/session/parse.d.ts +49 -0
- package/dist/lib/session/parse.js +324 -30
- package/dist/lib/session/prompt.d.ts +93 -2
- package/dist/lib/session/prompt.js +266 -15
- package/dist/lib/session/remote/peer-stream.d.ts +47 -0
- package/dist/lib/session/remote/peer-stream.js +142 -0
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +41 -42
- package/dist/lib/session/session-cache.d.ts +9 -0
- package/dist/lib/session/session-cache.js +40 -2
- package/dist/lib/session/timeline-pass.d.ts +129 -0
- package/dist/lib/session/timeline-pass.js +323 -0
- package/dist/lib/session/timeline.d.ts +182 -0
- package/dist/lib/session/timeline.js +636 -0
- package/dist/lib/session/types.d.ts +155 -1
- package/dist/lib/summarizer/pass.d.ts +2 -0
- package/dist/lib/summarizer/pass.js +14 -1
- package/dist/lib/summarizer/summarize.d.ts +7 -0
- package/dist/lib/summarizer/summarize.js +3 -0
- 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
|
-
*
|
|
1172
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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;
|