@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
@@ -0,0 +1,636 @@
1
+ /**
2
+ * The narration-anchored session timeline (PHNX-3939) — a pure, resumable fold.
3
+ *
4
+ * A transcript is a flat event stream: the agent says a line, runs six tools,
5
+ * says the next line. A person reading a sidebar wants the LINES, with the tool
6
+ * calls collapsed underneath them. That is the whole model here: one
7
+ * {@link SessionStep} per narration beat, the calls until the next beat folded
8
+ * into counts, failures and milestones.
9
+ *
10
+ * Two properties this module is built around:
11
+ *
12
+ * - **Deterministic, no model.** The headline is the harness's OWN narration
13
+ * (Claude assistant text, Codex `AgentMessage` commentary, Gemini thought
14
+ * subject) and the per-tool label is the harness's own (`description`,
15
+ * `state.title`, the parsed command). Nothing is invented; the optional
16
+ * summarizer polishes ON TOP and is never required.
17
+ * - **Resumable.** {@link foldTimeline} takes the state from the previous fold
18
+ * and the events that arrived since, so the daemon reads only the bytes a
19
+ * transcript grew by. Folding the appended tail onto the prior state is
20
+ * identical to folding the whole file from zero — every piece of mutable
21
+ * state lives in the serializable {@link TimelineState}, nothing in a
22
+ * closure.
23
+ *
24
+ * No I/O: the pass (`timeline-pass.ts`) reads bytes and calls this. Same
25
+ * discipline as `digest.ts` and `trajectory.ts`.
26
+ */
27
+ import { knownSecretValuesFromEnv, redactSecrets, sanitizeForTerminal } from '../redact.js';
28
+ import { classifyBashCommand, detectBashMilestone } from './bash-command.js';
29
+ import { firstSentence, tidyRequest } from './prompt.js';
30
+ /**
31
+ * Bumped when the fold's OUTPUT changes, so a cached state computed by an older
32
+ * CLI is recomputed rather than rendered. Same role as
33
+ * `SESSION_SUMMARY_EXTRACTOR_VERSION`.
34
+ */
35
+ export const TIMELINE_EXTRACTOR_VERSION = 1;
36
+ /** Steps carried on the row in full; everything older folds into a counter. */
37
+ export const TIMELINE_KEEP_STEPS = 8;
38
+ /** File rows carried on the row in full; `total` still reports the real count. */
39
+ export const TIMELINE_KEEP_FILES = 8;
40
+ /**
41
+ * Steps kept in the persisted resume state. A long session narrates for hours,
42
+ * and the state is re-read every tick, so the fold rolls everything past this
43
+ * into {@link TimelineState.dropped} — exact totals, bounded bytes.
44
+ */
45
+ export const TIMELINE_STATE_MAX_STEPS = 200;
46
+ /** Paths kept in the persisted resume state; older ones roll into `filesDropped`. */
47
+ export const TIMELINE_STATE_MAX_FILES = 200;
48
+ /** A narration beat that said nothing and ran nothing merges into the next one within this window. */
49
+ const NARRATION_MERGE_MS = 2_000;
50
+ /** A thinking block shorter than this is a fragment, not a beat worth showing. */
51
+ const MIN_THINKING_CHARS = 20;
52
+ /** An open step this busy is a wall of calls: let a thinking block break it up. */
53
+ const THINKING_BREAK_TOOLS = 8;
54
+ /** Repeated identical user steps inside this window are one turn, not two. */
55
+ const USER_STEP_DEDUPE_MS = 10_000;
56
+ /**
57
+ * Roll the oldest steps and file rows out of the state so a session that runs
58
+ * for hours does not grow its cached state without bound. Their tallies survive
59
+ * in {@link TimelineState.dropped} / {@link TimelineState.filesDropped}, which
60
+ * the projection adds back — so this changes the state's SIZE, never its
61
+ * reported numbers.
62
+ */
63
+ export function compactTimelineState(state, maxSteps = TIMELINE_STATE_MAX_STEPS, maxFiles = TIMELINE_STATE_MAX_FILES) {
64
+ if (state.steps.length > maxSteps) {
65
+ const cut = state.steps.length - maxSteps;
66
+ const removed = state.steps.slice(0, cut);
67
+ const dropped = state.dropped ?? { steps: 0, tools: 0, failed: 0, blocked: 0 };
68
+ for (const step of removed) {
69
+ dropped.steps++;
70
+ dropped.tools += step.tools;
71
+ dropped.failed += step.failed;
72
+ dropped.blocked += step.blocked;
73
+ }
74
+ state.dropped = dropped;
75
+ state.steps = state.steps.slice(cut);
76
+ state.open = state.open >= 0 ? state.open - cut : -1;
77
+ if (state.open < 0)
78
+ state.open = -1;
79
+ // Pending call ids point at step INDICES, so they shift with the window; a
80
+ // call whose step is gone can no longer be resolved and is dropped rather
81
+ // than left to land on the wrong step.
82
+ const pending = {};
83
+ for (const [callId, index] of Object.entries(state.pending)) {
84
+ const shifted = index - cut;
85
+ if (shifted >= 0)
86
+ pending[callId] = shifted;
87
+ }
88
+ state.pending = pending;
89
+ }
90
+ const paths = Object.keys(state.files);
91
+ if (paths.length > maxFiles) {
92
+ const ordered = paths.sort((a, b) => Date.parse(state.files[b].at) - Date.parse(state.files[a].at));
93
+ for (const stale of ordered.slice(maxFiles))
94
+ delete state.files[stale];
95
+ state.filesDropped = (state.filesDropped ?? 0) + (paths.length - maxFiles);
96
+ }
97
+ return state;
98
+ }
99
+ /** A fresh state for a session nothing has been folded for yet. */
100
+ export function emptyTimelineState() {
101
+ return {
102
+ version: TIMELINE_EXTRACTOR_VERSION,
103
+ offset: 0,
104
+ steps: [],
105
+ open: -1,
106
+ pending: {},
107
+ firstMs: null,
108
+ lastMs: null,
109
+ turns: 0,
110
+ files: {},
111
+ };
112
+ }
113
+ /** Tool names whose verb class is fixed by the name across every harness. */
114
+ const TOOL_VERB_CLASSES = {
115
+ read: 'read', Read: 'read', ReadFile: 'read', ReadMediaFile: 'read', read_file: 'read',
116
+ Glob: 'read', glob: 'read', Grep: 'read', grep: 'read', LS: 'read', list: 'read',
117
+ list_dir: 'read', ListDir: 'read', list_files: 'read', search: 'read', NotebookRead: 'read',
118
+ view_image: 'read',
119
+ Edit: 'edit', edit: 'edit', edit_file: 'edit', Write: 'edit', write: 'edit',
120
+ write_file: 'edit', WriteFile: 'edit', StrReplaceFile: 'edit', str_replace: 'edit',
121
+ MultiEdit: 'edit', NotebookEdit: 'edit', apply_patch: 'edit', patch: 'edit',
122
+ Agent: 'agent', Task: 'agent', task: 'agent', AgentSwarm: 'agent',
123
+ WebFetch: 'browser', webfetch: 'browser', FetchURL: 'browser',
124
+ WebSearch: 'browser', websearch: 'browser', SearchWeb: 'browser', web_search: 'browser',
125
+ };
126
+ /** `detectBashMilestone` event names → the short mark a step renders. */
127
+ const MILESTONE_MARKS = {
128
+ 'commit.created': 'commit',
129
+ pushed: 'pushed',
130
+ 'worktree.created': 'worktree created',
131
+ 'worktree.removed': 'worktree removed',
132
+ 'pr.opened': 'PR opened',
133
+ 'pr.merged': 'PR merged',
134
+ 'team.spawned': 'team spawned',
135
+ 'artifact.rendered': 'artifact rendered',
136
+ 'video.rendered': 'video rendered',
137
+ 'video.converted': 'video converted',
138
+ 'image.upscaled': 'image upscaled',
139
+ 'metadata.edited': 'metadata edited',
140
+ };
141
+ /** How each verb class reads in a derived step's headline. */
142
+ const VERB_PHRASES = {
143
+ read: (n) => `read ${n} file${n === 1 ? '' : 's'}`,
144
+ edit: (n) => `edited ${n} file${n === 1 ? '' : 's'}`,
145
+ run: (n) => `ran ${n} command${n === 1 ? '' : 's'}`,
146
+ git: (n) => `ran ${n} git command${n === 1 ? '' : 's'}`,
147
+ test: (n) => `ran ${n} test command${n === 1 ? '' : 's'}`,
148
+ browser: (n) => `made ${n} web call${n === 1 ? '' : 's'}`,
149
+ agent: (n) => `spawned ${n} agent${n === 1 ? '' : 's'}`,
150
+ other: (n) => `made ${n} call${n === 1 ? '' : 's'}`,
151
+ };
152
+ /** A shell redirection that WRITES a file (`> out`, `| tee f`), not `2>&1` / `>/dev/null`. */
153
+ const WRITE_REDIRECT_RE = /(?:^|[^\d&>])>\s*[^&\s]|\btee\b|\bsed\s+-i\b/;
154
+ /** Commands whose exit 1 means "no match", not "the work failed". */
155
+ const BENIGN_EXIT1_RE = /^\s*(rg|grep|diff|test|\[)\b/;
156
+ /**
157
+ * Bucket one Bash command into a verb class, reusing the repo's single command
158
+ * classifier (`classifyBashCommand`) rather than re-deriving a second taxonomy.
159
+ * Its `category` answers most of it; the three things its taxonomy deliberately
160
+ * does not carry — a write redirection, `agents` subcommands, and whether a
161
+ * build tool is running tests — are added on top.
162
+ *
163
+ * Multi-segment commands take the most consequential class present, so
164
+ * `cd x && bun test` is a test, not a `cd`.
165
+ */
166
+ export function classifyCommandVerb(command) {
167
+ const text = (command ?? '').trim();
168
+ if (!text)
169
+ return 'other';
170
+ const segments = text.split(/\s*(?:&&|\|\||;|\|)\s*/).filter((s) => s.trim());
171
+ const classes = segments.map((segment) => classifySegmentVerb(segment));
172
+ for (const priority of ['test', 'browser', 'agent', 'git', 'edit']) {
173
+ if (classes.includes(priority))
174
+ return priority;
175
+ }
176
+ if (classes.length > 0 && classes.every((verb) => verb === 'read'))
177
+ return 'read';
178
+ return 'run';
179
+ }
180
+ function classifySegmentVerb(segment) {
181
+ const info = classifyBashCommand(segment);
182
+ const lower = segment.toLowerCase();
183
+ if (info.tool === 'agents') {
184
+ if (info.subcommand === 'browser' || info.subcommand === 'computer')
185
+ return 'browser';
186
+ if (info.subcommand === 'run' || info.subcommand === 'teams')
187
+ return 'agent';
188
+ }
189
+ switch (info.category) {
190
+ case 'vcs':
191
+ return 'git';
192
+ case 'build-test':
193
+ return /\b(test|vitest|jest|mocha|pytest)\b/.test(lower) ? 'test' : 'run';
194
+ case 'search':
195
+ case 'probe':
196
+ case 'wait':
197
+ return WRITE_REDIRECT_RE.test(segment) ? 'edit' : 'read';
198
+ case 'shell':
199
+ // `sed -n`/`echo`/`awk` READ; `rm`/`mv`/`cp`/`mkdir`/`tee`/`sed -i` WRITE.
200
+ if (/^(rm|mv|cp|mkdir|rmdir|touch|ln|tee)\b/.test(lower))
201
+ return 'edit';
202
+ return WRITE_REDIRECT_RE.test(segment) ? 'edit' : 'read';
203
+ default:
204
+ return WRITE_REDIRECT_RE.test(segment) ? 'edit' : 'run';
205
+ }
206
+ }
207
+ /** The verb class for one tool call: the harness's own if it classified it, else derived. */
208
+ export function verbClassForEvent(event) {
209
+ if (event.verbClass)
210
+ return event.verbClass;
211
+ const byName = event.tool ? TOOL_VERB_CLASSES[event.tool] : undefined;
212
+ if (byName)
213
+ return byName;
214
+ const command = event.command ?? (typeof event.args?.command === 'string' ? event.args.command : undefined);
215
+ if (command)
216
+ return classifyCommandVerb(command);
217
+ return 'other';
218
+ }
219
+ /** The label a now-line shows: the harness's own, else a short tool + target. */
220
+ function labelForEvent(event, verb) {
221
+ if (event.label)
222
+ return truncateLabel(event.label);
223
+ const command = event.command ?? (typeof event.args?.command === 'string' ? event.args.command : undefined);
224
+ if (command)
225
+ return truncateLabel(command.replace(/\s+/g, ' ').trim());
226
+ const target = event.path
227
+ ?? event.args?.file_path ?? event.args?.filePath ?? event.args?.path
228
+ ?? event.args?.pattern ?? event.args?.description ?? event.args?.url ?? event.args?.query ?? '';
229
+ const shown = verb === 'read' || verb === 'edit'
230
+ ? String(target).split(/[\\/]/).pop() ?? ''
231
+ : String(target);
232
+ return truncateLabel(shown ? `${event.tool}: ${shown}` : (event.tool ?? 'tool'));
233
+ }
234
+ function truncateLabel(text, max = 90) {
235
+ return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
236
+ }
237
+ function epochMs(timestamp) {
238
+ const parsed = timestamp ? Date.parse(timestamp) : Number.NaN;
239
+ return Number.isFinite(parsed) ? parsed : null;
240
+ }
241
+ /**
242
+ * Fold `events` onto `prior` (or a fresh state) and return the new state.
243
+ *
244
+ * Pure: no clock, no filesystem, no database. The rules, in the order they are
245
+ * applied per event, are the plan's fold table — narration opens, thinking opens
246
+ * only when there is nothing open or the open step is already a wall of calls,
247
+ * tools attach, a genuine user turn is its own step, results resolve
248
+ * failed-vs-blocked, and a harness file ledger both marks the step and feeds the
249
+ * files projection.
250
+ */
251
+ export function foldTimeline(events, prior, opts = {}) {
252
+ const state = prior && prior.version === TIMELINE_EXTRACTOR_VERSION
253
+ ? prior
254
+ : emptyTimelineState();
255
+ const cap = opts.maxEvents ?? Number.POSITIVE_INFINITY;
256
+ let seen = 0;
257
+ for (const event of events) {
258
+ if (seen >= cap) {
259
+ state.truncated = true;
260
+ break;
261
+ }
262
+ seen++;
263
+ const at = epochMs(event.timestamp);
264
+ noteTime(state, at);
265
+ switch (event.type) {
266
+ case 'message': {
267
+ if (event.role === 'assistant') {
268
+ const text = (event.content ?? '').trim();
269
+ if (text)
270
+ openStep(state, text, event.timestamp, 'narration');
271
+ break;
272
+ }
273
+ if (event.role !== 'user')
274
+ break;
275
+ const raw = (event.content ?? '').trim();
276
+ if (!raw)
277
+ break;
278
+ if (raw.startsWith('[Request interrupted')) {
279
+ userStep(state, 'Request interrupted by user', event.timestamp);
280
+ break;
281
+ }
282
+ const request = tidyRequest(raw, { attachments: opts.attachments });
283
+ if (!request)
284
+ break;
285
+ state.turns++;
286
+ const withTurns = { ...request, turns: state.turns };
287
+ state.request = withTurns;
288
+ if (!state.firstRequest)
289
+ state.firstRequest = withTurns;
290
+ userStep(state, request.headline, event.timestamp);
291
+ break;
292
+ }
293
+ case 'interrupt': {
294
+ userStep(state, 'Request interrupted by user', event.timestamp);
295
+ break;
296
+ }
297
+ case 'thinking': {
298
+ const text = (event.content ?? '').trim();
299
+ if (text.length < MIN_THINKING_CHARS)
300
+ break;
301
+ const open = openStepOf(state);
302
+ if (!open || open.tools >= THINKING_BREAK_TOOLS)
303
+ openStep(state, text, event.timestamp, 'thinking');
304
+ break;
305
+ }
306
+ case 'tool_use': {
307
+ attachTool(state, event);
308
+ break;
309
+ }
310
+ case 'tool_result':
311
+ case 'error': {
312
+ resolveTool(state, event);
313
+ break;
314
+ }
315
+ case 'file_change': {
316
+ applyFileChanges(state, event);
317
+ break;
318
+ }
319
+ case 'hook': {
320
+ if (event.hookName === 'ContextCompaction')
321
+ addMark(state, 'context compacted');
322
+ break;
323
+ }
324
+ default:
325
+ break;
326
+ }
327
+ }
328
+ if (opts.offset !== undefined)
329
+ state.offset = opts.offset;
330
+ return state;
331
+ }
332
+ function noteTime(state, at) {
333
+ if (at === null)
334
+ return;
335
+ state.firstMs = state.firstMs === null ? at : Math.min(state.firstMs, at);
336
+ state.lastMs = state.lastMs === null ? at : Math.max(state.lastMs, at);
337
+ }
338
+ function openStepOf(state) {
339
+ return state.open >= 0 ? state.steps[state.open] : undefined;
340
+ }
341
+ function openStep(state, text, timestamp, source) {
342
+ const open = openStepOf(state);
343
+ // Narration split across two blocks in the same breath is ONE beat: the first
344
+ // said nothing and ran nothing, so it would render as a dangling headline.
345
+ if (open && open.tools === 0 && source === 'narration' && open.source === 'narration'
346
+ && withinMs(open.at, timestamp, NARRATION_MERGE_MS)) {
347
+ return;
348
+ }
349
+ state.steps.push({
350
+ text: firstSentence(text),
351
+ at: timestamp,
352
+ endedAt: timestamp,
353
+ source,
354
+ tools: 0,
355
+ failed: 0,
356
+ blocked: 0,
357
+ mix: {},
358
+ marks: [],
359
+ });
360
+ state.open = state.steps.length - 1;
361
+ }
362
+ function withinMs(a, b, window) {
363
+ const first = epochMs(a);
364
+ const second = epochMs(b);
365
+ if (first === null || second === null)
366
+ return false;
367
+ return Math.abs(second - first) < window;
368
+ }
369
+ /** A user turn is its own step and closes whatever was open — the agent's turn is over. */
370
+ function userStep(state, text, timestamp) {
371
+ const headline = firstSentence(text);
372
+ const last = state.steps[state.steps.length - 1];
373
+ // Harnesses re-emit the same turn under two record shapes (a message and a
374
+ // command wrapper); one turn, one step.
375
+ if (last && last.source === 'user' && last.text === headline && withinMs(last.at, timestamp, USER_STEP_DEDUPE_MS)) {
376
+ state.open = -1;
377
+ return;
378
+ }
379
+ state.steps.push({
380
+ text: headline,
381
+ at: timestamp,
382
+ endedAt: timestamp,
383
+ source: 'user',
384
+ tools: 0,
385
+ failed: 0,
386
+ blocked: 0,
387
+ });
388
+ state.open = -1;
389
+ }
390
+ function attachTool(state, event) {
391
+ let step = openStepOf(state);
392
+ if (!step) {
393
+ // Tools with nothing said: a real step, marked `derived`, headlined from the
394
+ // mix once the fold closes (a harness that narrates nothing — Antigravity —
395
+ // renders entirely as these).
396
+ state.steps.push({
397
+ text: '',
398
+ at: event.timestamp,
399
+ endedAt: event.timestamp,
400
+ source: 'derived',
401
+ tools: 0,
402
+ failed: 0,
403
+ blocked: 0,
404
+ mix: {},
405
+ marks: [],
406
+ });
407
+ state.open = state.steps.length - 1;
408
+ step = state.steps[state.open];
409
+ }
410
+ const verb = verbClassForEvent(event);
411
+ step.tools++;
412
+ step.mix = step.mix ?? {};
413
+ step.mix[verb] = (step.mix[verb] ?? 0) + 1;
414
+ step.endedAt = event.timestamp;
415
+ step.now = labelForEvent(event, verb);
416
+ const command = event.command ?? (typeof event.args?.command === 'string' ? event.args.command : undefined);
417
+ if (command) {
418
+ const milestone = detectBashMilestone(command);
419
+ const mark = milestone ? MILESTONE_MARKS[milestone.event] : undefined;
420
+ if (mark)
421
+ addMarkTo(step, mark);
422
+ }
423
+ recordToolFile(state, event, verb);
424
+ if (event.callId)
425
+ state.pending[event.callId] = state.open;
426
+ }
427
+ /** Resolve one call's outcome onto the step that owns it (results can arrive late). */
428
+ function resolveTool(state, event) {
429
+ const index = event.callId !== undefined ? state.pending[event.callId] : undefined;
430
+ const step = index !== undefined ? state.steps[index] : openStepOf(state);
431
+ if (event.callId !== undefined)
432
+ delete state.pending[event.callId];
433
+ if (!step)
434
+ return;
435
+ if (event.blocked) {
436
+ step.blocked++;
437
+ return;
438
+ }
439
+ const failedOutcome = event.type === 'error' || event.outcome === 'error' || event.success === false;
440
+ if (!failedOutcome)
441
+ return;
442
+ // A search that matched nothing exits 1. It ran, it did its job, and counting
443
+ // it as a failure made every ordinary session look broken.
444
+ const command = event.command ?? (typeof event.args?.command === 'string' ? event.args.command : undefined);
445
+ if (event.exitCode === 1 && command && BENIGN_EXIT1_RE.test(command))
446
+ return;
447
+ step.failed++;
448
+ }
449
+ function addMark(state, mark) {
450
+ const step = openStepOf(state);
451
+ if (step)
452
+ addMarkTo(step, mark);
453
+ }
454
+ function addMarkTo(step, mark) {
455
+ step.marks = step.marks ?? [];
456
+ if (!step.marks.includes(mark))
457
+ step.marks.push(mark);
458
+ }
459
+ /** The harness's own file ledger: exact paths, and (for Codex) exact operations. */
460
+ function applyFileChanges(state, event) {
461
+ const changes = event.changes ?? [];
462
+ if (!changes.length)
463
+ return;
464
+ for (const change of changes) {
465
+ const prior = state.files[change.path];
466
+ state.files[change.path] = {
467
+ // A `created` claim, once made, survives a later `modified` on the same
468
+ // path: the session did create that file.
469
+ op: prior?.op === 'created' && change.op === 'modified' ? 'created' : change.op,
470
+ edits: (prior?.edits ?? 0) + 1,
471
+ at: event.timestamp,
472
+ harness: true,
473
+ };
474
+ }
475
+ const step = openStepOf(state);
476
+ if (step) {
477
+ addMarkTo(step, `${changes.length} file${changes.length === 1 ? '' : 's'} changed`);
478
+ step.endedAt = event.timestamp;
479
+ }
480
+ }
481
+ /** Fall back to the tool arguments for a harness that keeps no file ledger. */
482
+ function recordToolFile(state, event, verb) {
483
+ if (verb !== 'edit')
484
+ return;
485
+ const target = event.path ?? event.args?.file_path ?? event.args?.filePath ?? event.args?.path;
486
+ if (typeof target !== 'string' || !target.startsWith('/'))
487
+ return;
488
+ const prior = state.files[target];
489
+ // A create-style tool on a path nothing has touched yet CREATED it; anything
490
+ // after that is a modification of what the session already made.
491
+ const creates = event.tool === 'Write' || event.tool === 'write' || event.tool === 'write_file' || event.tool === 'WriteFile';
492
+ state.files[target] = {
493
+ op: prior?.op ?? (creates ? 'created' : 'modified'),
494
+ edits: (prior?.edits ?? 0) + 1,
495
+ at: event.timestamp,
496
+ harness: prior?.harness ?? false,
497
+ };
498
+ }
499
+ /** Give every `derived` step a headline from what it actually did. */
500
+ function derivedHeadline(step) {
501
+ const parts = Object.entries(step.mix ?? {})
502
+ .filter(([, count]) => (count ?? 0) > 0)
503
+ .sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))
504
+ .map(([verb, count]) => VERB_PHRASES[verb](count ?? 0));
505
+ if (!parts.length)
506
+ return 'Tool calls';
507
+ const joined = parts.join(', ');
508
+ return joined.charAt(0).toUpperCase() + joined.slice(1);
509
+ }
510
+ /**
511
+ * Project the folded state onto the bounded shape that rides a session row.
512
+ *
513
+ * `keep` newest steps travel in full; everything older collapses into
514
+ * `earlier`. The newest step is marked `live` while the session is working —
515
+ * that is what the sidebar renders with the amber now-line, using the `now`
516
+ * label the harness wrote for the call that is running.
517
+ *
518
+ * **This is the single place raw transcript text leaves the fold**, so it is
519
+ * where scrubbing happens — every consumer (the session row, the git-tracked
520
+ * fleet mirror `daemon-state.json`, and `sessions trace --steps`) is covered at
521
+ * the source rather than each remembering to do it. Two passes, and they are
522
+ * not the same thing:
523
+ *
524
+ * - **Terminal escapes are ALWAYS stripped.** A transcript is untrusted input
525
+ * and these labels are printed to a terminal; `sanitizeEvents` never runs on
526
+ * the fold's parse path (only `tail.ts` / `stream-render.ts` call it), so
527
+ * this is the only place that scrub happens for a timeline.
528
+ * - **Secrets are redacted unless the caller opts out.** `redact` defaults to
529
+ * ON; only the local-only `--no-redact` path passes `redact: false`, and it
530
+ * buys exactly that — not raw control characters.
531
+ */
532
+ export function projectTimeline(state, activity, keep = TIMELINE_KEEP_STEPS, options = {}) {
533
+ const redact = options.redact !== false;
534
+ // Resolved once per projection, not once per step: the env scan is the same
535
+ // for every step in the fold.
536
+ const knownSecrets = redact ? (options.knownSecrets ?? knownSecretValuesFromEnv()) : undefined;
537
+ const scrub = (text) => sanitizeForTerminal(redact ? redactSecrets(text, knownSecrets) : text);
538
+ // Copy each step, fill a derived headline, and drop the empty bookkeeping the
539
+ // fold carries (`mix: {}`, `marks: []`) so the row payload stays small and a
540
+ // consumer never has to distinguish "empty" from "absent".
541
+ const steps = state.steps.map((step) => {
542
+ const projected = {
543
+ ...step,
544
+ text: scrub(step.source === 'derived' && !step.text ? derivedHeadline(step) : step.text),
545
+ };
546
+ if (projected.now)
547
+ projected.now = scrub(projected.now);
548
+ if (projected.marks?.length)
549
+ projected.marks = projected.marks.map(scrub);
550
+ if (!projected.mix || Object.keys(projected.mix).length === 0)
551
+ delete projected.mix;
552
+ if (!projected.marks || projected.marks.length === 0)
553
+ delete projected.marks;
554
+ return projected;
555
+ });
556
+ const dropped = state.dropped ?? { steps: 0, tools: 0, failed: 0, blocked: 0 };
557
+ const totals = steps.reduce((acc, step) => ({
558
+ tools: acc.tools + step.tools,
559
+ failed: acc.failed + step.failed,
560
+ blocked: acc.blocked + step.blocked,
561
+ }), { tools: dropped.tools, failed: dropped.failed, blocked: dropped.blocked });
562
+ const cut = Math.max(0, steps.length - keep);
563
+ const older = steps.slice(0, cut);
564
+ const tail = steps.slice(cut);
565
+ const newest = tail.length ? tail[tail.length - 1] : undefined;
566
+ if (newest && activity === 'working')
567
+ newest.live = true;
568
+ // `now` is the label of the call RUNNING RIGHT NOW, so it is meaningful only
569
+ // on the live step. `attachTool` sets it on every step as it folds; a finished
570
+ // step keeping it made the sidebar render a now-line on completed work and
571
+ // cost ~700 bytes a row.
572
+ for (const step of tail) {
573
+ if (!step.live || !step.now)
574
+ delete step.now;
575
+ }
576
+ // A fold that produced no step has nothing to say, and `ready` with an empty
577
+ // list would read as "the session did nothing". Say so instead — the contract
578
+ // is "never an empty step list presented as nothing happened".
579
+ if (!tail.length) {
580
+ return {
581
+ ...unavailableTimeline('no narration folded yet'),
582
+ spanMs: state.firstMs !== null && state.lastMs !== null ? Math.max(0, state.lastMs - state.firstMs) : 0,
583
+ ...(state.truncated ? { state: 'partial', reason: 'transcript larger than one pass could fold' } : {}),
584
+ };
585
+ }
586
+ return {
587
+ steps: tail,
588
+ earlier: {
589
+ steps: older.length + dropped.steps,
590
+ tools: older.reduce((n, step) => n + step.tools, dropped.tools),
591
+ failed: older.reduce((n, step) => n + step.failed, dropped.failed),
592
+ },
593
+ tools: totals.tools,
594
+ failed: totals.failed,
595
+ blocked: totals.blocked,
596
+ spanMs: state.firstMs !== null && state.lastMs !== null ? Math.max(0, state.lastMs - state.firstMs) : 0,
597
+ state: state.truncated ? 'partial' : 'ready',
598
+ ...(state.truncated ? { reason: 'transcript larger than one pass could fold' } : {}),
599
+ };
600
+ }
601
+ /** The honest empty timeline for a harness that writes no parseable transcript. */
602
+ export function unavailableTimeline(reason) {
603
+ return {
604
+ steps: [],
605
+ earlier: { steps: 0, tools: 0, failed: 0 },
606
+ tools: 0,
607
+ failed: 0,
608
+ blocked: 0,
609
+ spanMs: 0,
610
+ state: 'unavailable',
611
+ reason,
612
+ };
613
+ }
614
+ /**
615
+ * Project the files the session changed. Bounded to `keep` rows, newest first,
616
+ * with `total` reporting the real count.
617
+ *
618
+ * `source` reports where the OPERATIONS came from: `harness` when at least one
619
+ * entry was written by the harness's own ledger (Codex `FileChange` carries
620
+ * add/update/delete per path, OpenCode `patch` and Claude `file-history-delta`
621
+ * carry the path set), `tools` when everything was derived from Edit/Write
622
+ * arguments because the harness records no ledger at all.
623
+ */
624
+ export function projectSessionFiles(state, keep = TIMELINE_KEEP_FILES) {
625
+ const entries = Object.entries(state.files);
626
+ if (!entries.length)
627
+ return undefined;
628
+ const changes = entries
629
+ .map(([filePath, entry]) => ({ path: filePath, op: entry.op, edits: entry.edits, at: entry.at }))
630
+ .sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
631
+ return {
632
+ changes: changes.slice(0, keep),
633
+ total: changes.length + (state.filesDropped ?? 0),
634
+ source: entries.some(([, entry]) => entry.harness) ? 'harness' : 'tools',
635
+ };
636
+ }