@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
@@ -22,13 +22,14 @@ import { extractBackgroundShells, extractSkills, extractSlashCommands, harnessTr
22
22
  import { resolveResource } from '../resources.js';
23
23
  import { discoverPlugins } from '../plugins/plugins.js';
24
24
  import { machineId } from '../machine-id.js';
25
- import { firstUserMessageFromEvents } from './prompt.js';
25
+ import { firstUserMessageFromEvents, lastUserMessageFromEvents } from './prompt.js';
26
+ import { emptyTimelineState, TIMELINE_EXTRACTOR_VERSION } from './timeline.js';
26
27
  const SESSIONS_DIR = getSessionsDir();
27
28
  const DB_PATH = getSessionsDbPath();
28
29
  /** Current schema version; bumped when migrations are added. Exported so tests
29
30
  * assert against the constant instead of hardcoding a number that every bump
30
31
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
31
- export const SCHEMA_VERSION = 47;
32
+ export const SCHEMA_VERSION = 48;
32
33
  /**
33
34
  * Bump to force the content extractor (assistant-answer text, alongside the
34
35
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -103,6 +104,7 @@ CREATE TABLE IF NOT EXISTS sessions (
103
104
  git_branch TEXT,
104
105
  topic TEXT,
105
106
  first_user_message TEXT,
107
+ last_user_message TEXT,
106
108
  label TEXT,
107
109
  message_count INTEGER,
108
110
  token_count INTEGER,
@@ -420,6 +422,27 @@ CREATE TABLE IF NOT EXISTS session_summaries (
420
422
  summary_json TEXT NOT NULL
421
423
  );
422
424
 
425
+ -- Daemon-computed narration-anchored timeline (PHNX-3939). Same lazy,
426
+ -- stamp-validated cache shape as session_summaries, with one addition: the fold
427
+ -- is INCREMENTAL, so the row carries both the bounded projection the display
428
+ -- path reads and the resume state the pass folds the next appended bytes onto.
429
+ --
430
+ -- Two columns rather than one on purpose. projection_json is small and bounded
431
+ -- (8 steps + counters + the tidied request + 8 file rows) and is what a session
432
+ -- row merges on the read path; state_json holds the whole TimelineState (byte
433
+ -- offset, open step, pending call ids, per-path file ledger) and is read ONLY by
434
+ -- the daemon pass. Folding one blob would make every row merge parse the resume
435
+ -- state it has no use for.
436
+ CREATE TABLE IF NOT EXISTS session_timelines (
437
+ session_id TEXT PRIMARY KEY,
438
+ file_mtime_ms INTEGER,
439
+ file_size INTEGER,
440
+ extractor_version INTEGER NOT NULL,
441
+ computed_at INTEGER NOT NULL,
442
+ projection_json TEXT NOT NULL,
443
+ state_json TEXT NOT NULL
444
+ );
445
+
423
446
  -- Durable metadata for one browser task (RUSH-2549). The browser daemon's
424
447
  -- tasks.json is LIVE state: saveTaskState writes the in-memory task map, so
425
448
  -- stopping a task drops its entry and a daemon restart empties the file. That is
@@ -1286,6 +1309,25 @@ function migrateSchema(db, fromVersion) {
1286
1309
  // The idx_sessions_mirror_synced index is created unconditionally after this
1287
1310
  // block (fresh DBs skip migrations), alongside idx_sessions_last_activity.
1288
1311
  }
1312
+ if (fromVersion < 48) {
1313
+ // v47 -> v48: retain the LATEST genuine user turn beside the first
1314
+ // (PHNX-3939). For a /continue, a redirect, or an interrupted-and-restated
1315
+ // session the operative request is the last thing the user said, and the row
1316
+ // title / sidebar Request card show that.
1317
+ //
1318
+ // Deliberately WITHOUT a CONTENT_INDEX_VERSION bump, unlike the v45
1319
+ // first_user_message column. That bump forces a re-parse of every indexed
1320
+ // transcript on the next scan; measured on this fleet it pinned the daemon
1321
+ // long enough to blow the session-index and session-state tick deadlines and
1322
+ // park both services. It also buys nothing here: the value can only CHANGE
1323
+ // when the transcript changes, so the ordinary incremental scan fills it in
1324
+ // for exactly the sessions where it matters, and the sidebar's request comes
1325
+ // from the daemon-folded `session_timelines` projection rather than this
1326
+ // column. An old row simply keeps NULL until its next write.
1327
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
1328
+ if (!cols.has('last_user_message'))
1329
+ db.exec(`ALTER TABLE sessions ADD COLUMN last_user_message TEXT`);
1330
+ }
1289
1331
  if (fromVersion < 47) {
1290
1332
  // v46 -> v47: carry the actor's Phoenix id (PHNX-3798). Additive, write-once
1291
1333
  // launch metadata joined from the actor sidecar — never transcript-derived,
@@ -1842,7 +1884,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1842
1884
  INSERT INTO sessions (
1843
1885
  id, short_id, agent, harness, origin, routine_name, routine_run_id,
1844
1886
  version, account, account_key, account_org, mode, timestamp, last_activity,
1845
- project, cwd, git_branch, topic, first_user_message, label, message_count, token_count,
1887
+ project, cwd, git_branch, topic, first_user_message, last_user_message, label, message_count, token_count,
1846
1888
  output_tokens, input_tokens, cache_read_tokens, cache_write_tokens,
1847
1889
  cost_usd, cost_usd_nocache, duration_ms, model, tool_call_count,
1848
1890
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
@@ -1853,7 +1895,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1853
1895
  ) VALUES (
1854
1896
  @id, @short_id, @agent, @harness, @origin, @routine_name, @routine_run_id,
1855
1897
  @version, @account, @account_key, @account_org, @mode, @timestamp, @last_activity,
1856
- @project, @cwd, @git_branch, @topic, @first_user_message, @label, @message_count, @token_count,
1898
+ @project, @cwd, @git_branch, @topic, @first_user_message, @last_user_message, @label, @message_count, @token_count,
1857
1899
  @output_tokens, @input_tokens, @cache_read_tokens, @cache_write_tokens,
1858
1900
  @cost_usd, @cost_usd_nocache, @duration_ms, @model, @tool_call_count,
1859
1901
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
@@ -1891,6 +1933,10 @@ const upsertSessionStmt = (db) => db.prepare(`
1891
1933
  git_branch = excluded.git_branch,
1892
1934
  topic = excluded.topic,
1893
1935
  first_user_message = COALESCE(excluded.first_user_message, sessions.first_user_message),
1936
+ -- Unlike the first turn, the LATEST one moves: a new turn must replace the
1937
+ -- stored value, so this is excluded-wins with a COALESCE only to keep a
1938
+ -- known value when a rescan cannot re-derive one.
1939
+ last_user_message = COALESCE(excluded.last_user_message, sessions.last_user_message),
1894
1940
  -- Never let an empty/placeholder incoming label clobber a good stored one.
1895
1941
  -- A real incoming label (non-empty after trim) still wins; a blank one keeps
1896
1942
  -- the label seeded by --name (seedLabelsFromNames) or refined by an agent
@@ -2110,6 +2156,7 @@ function enrichMetaFromEvents(meta, events) {
2110
2156
  return {
2111
2157
  ...meta,
2112
2158
  firstUserMessage: meta.firstUserMessage ?? firstUserMessageFromEvents(events),
2159
+ lastUserMessage: meta.lastUserMessage ?? lastUserMessageFromEvents(events),
2113
2160
  todos: extractTodoProgressFromEvents(events),
2114
2161
  recentDirectoriesTouched: extractRecentDirectoriesTouched(events, meta.cwd),
2115
2162
  ...fanOutCounts(events, meta.agent),
@@ -2201,6 +2248,7 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
2201
2248
  git_branch: meta.gitBranch ?? null,
2202
2249
  topic: meta.topic ?? null,
2203
2250
  first_user_message: meta.firstUserMessage ?? null,
2251
+ last_user_message: meta.lastUserMessage ?? null,
2204
2252
  label: meta.label ?? null,
2205
2253
  message_count: meta.messageCount ?? null,
2206
2254
  token_count: meta.tokenCount ?? null,
@@ -2441,6 +2489,7 @@ export function upsertSessionsBatch(entries) {
2441
2489
  git_branch: meta.gitBranch ?? null,
2442
2490
  topic: meta.topic ?? null,
2443
2491
  first_user_message: meta.firstUserMessage ?? null,
2492
+ last_user_message: meta.lastUserMessage ?? null,
2444
2493
  label: meta.label ?? null,
2445
2494
  message_count: meta.messageCount ?? null,
2446
2495
  token_count: meta.tokenCount ?? null,
@@ -2690,6 +2739,7 @@ function rowToMeta(row) {
2690
2739
  mode: isSessionRunMode(row.mode) ? row.mode : undefined,
2691
2740
  topic: row.topic ?? undefined,
2692
2741
  firstUserMessage: row.first_user_message ?? undefined,
2742
+ lastUserMessage: row.last_user_message ?? undefined,
2693
2743
  label: row.label ?? undefined,
2694
2744
  isTeamOrigin: row.is_team_origin === 1,
2695
2745
  prUrl: row.pr_url ?? undefined,
@@ -3417,6 +3467,68 @@ export function writeSessionSummary(entry) {
3417
3467
  summary_json = excluded.summary_json
3418
3468
  `).run(entry.id, entry.fileMtimeMs, entry.fileSize, SESSION_SUMMARY_EXTRACTOR_VERSION, Date.now(), JSON.stringify(entry.summary));
3419
3469
  }
3470
+ function parseTimelineProjection(json) {
3471
+ try {
3472
+ return JSON.parse(json);
3473
+ }
3474
+ catch {
3475
+ return undefined;
3476
+ }
3477
+ }
3478
+ /**
3479
+ * Read the projection AND the resume state for one session, whatever the
3480
+ * transcript stamp — the daemon pass's read, which needs the state to continue
3481
+ * from and re-stats the file itself.
3482
+ */
3483
+ export function readSessionTimelineEntry(id) {
3484
+ const row = getDB().prepare(`
3485
+ SELECT projection_json AS projectionJson, state_json AS stateJson, computed_at AS computedAt
3486
+ FROM session_timelines
3487
+ WHERE session_id = ? AND extractor_version = ?
3488
+ `).get(id, TIMELINE_EXTRACTOR_VERSION);
3489
+ if (!row)
3490
+ return undefined;
3491
+ const projection = parseTimelineProjection(row.projectionJson);
3492
+ if (!projection)
3493
+ return undefined;
3494
+ try {
3495
+ return { ...projection, state: JSON.parse(row.stateJson), computedAt: row.computedAt };
3496
+ }
3497
+ catch {
3498
+ return undefined;
3499
+ }
3500
+ }
3501
+ /**
3502
+ * Read only the bounded projection for one session — the display merge, on the
3503
+ * read path. Deliberately does NOT parse the resume state: a live row needs the
3504
+ * 8 steps and the request, never the fold's bookkeeping.
3505
+ */
3506
+ export function readSessionTimelineAny(id) {
3507
+ const row = getDB().prepare(`
3508
+ SELECT projection_json AS projectionJson
3509
+ FROM session_timelines
3510
+ WHERE session_id = ? AND extractor_version = ?
3511
+ `).get(id, TIMELINE_EXTRACTOR_VERSION);
3512
+ if (!row)
3513
+ return undefined;
3514
+ return parseTimelineProjection(row.projectionJson);
3515
+ }
3516
+ /** Persist a folded timeline against the exact transcript bytes it was folded to. */
3517
+ export function writeSessionTimeline(entry) {
3518
+ const { state, ...projection } = entry.timeline;
3519
+ getDB().prepare(`
3520
+ INSERT INTO session_timelines
3521
+ (session_id, file_mtime_ms, file_size, extractor_version, computed_at, projection_json, state_json)
3522
+ VALUES (?, ?, ?, ?, ?, ?, ?)
3523
+ ON CONFLICT(session_id) DO UPDATE SET
3524
+ file_mtime_ms = excluded.file_mtime_ms,
3525
+ file_size = excluded.file_size,
3526
+ extractor_version = excluded.extractor_version,
3527
+ computed_at = excluded.computed_at,
3528
+ projection_json = excluded.projection_json,
3529
+ state_json = excluded.state_json
3530
+ `).run(entry.id, entry.fileMtimeMs, entry.fileSize, TIMELINE_EXTRACTOR_VERSION, entry.computedAtMs ?? Date.now(), JSON.stringify(projection), JSON.stringify(state));
3531
+ }
3420
3532
  /**
3421
3533
  * The most-recently-active sessions whose transcript is genuinely local to this
3422
3534
  * box — a real `file_path`, not a peer mirror — for publishing to the fleet
@@ -3444,6 +3556,7 @@ export function queryLocalOriginSessionsForMirror(self, limit) {
3444
3556
  // Ride the daemon-computed summary alongside the digest so a peer renders it
3445
3557
  // without a transcript (PHNX-3939); only a summarized session carries one.
3446
3558
  summary: readSessionSummaryAny(r.id) ?? null,
3559
+ timeline: readSessionTimelineAny(r.id) ?? null,
3447
3560
  }));
3448
3561
  }
3449
3562
  /**
@@ -3525,6 +3638,18 @@ export function upsertMirrorSession(row, source, syncedAt) {
3525
3638
  if (row.summary) {
3526
3639
  writeSessionSummary({ id: row.id, fileMtimeMs: null, fileSize: null, summary: row.summary });
3527
3640
  }
3641
+ // Same for the peer's folded timeline (PHNX-3939): the projection is what a row
3642
+ // renders, and the resume state is deliberately EMPTY — this box never folds a
3643
+ // peer's transcript (it does not have one), so storing a foreign byte offset
3644
+ // would be a resume point into a file that is not here.
3645
+ if (row.timeline) {
3646
+ writeSessionTimeline({
3647
+ id: row.id,
3648
+ fileMtimeMs: null,
3649
+ fileSize: null,
3650
+ timeline: { ...row.timeline, state: emptyTimelineState() },
3651
+ });
3652
+ }
3528
3653
  return true;
3529
3654
  }
3530
3655
  /**
@@ -3542,12 +3667,14 @@ export function pruneMirrorSessions(cutoffMs) {
3542
3667
  const delText = db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
3543
3668
  const delPreview = db.prepare(`DELETE FROM session_preview_cache WHERE session_id = ?`);
3544
3669
  const delSummary = db.prepare(`DELETE FROM session_summaries WHERE session_id = ?`);
3670
+ const delTimeline = db.prepare(`DELETE FROM session_timelines WHERE session_id = ?`);
3545
3671
  const txn = db.transaction(() => {
3546
3672
  for (const { id } of stale) {
3547
3673
  delRow.run(id);
3548
3674
  delText.run(id);
3549
3675
  delPreview.run(id);
3550
3676
  delSummary.run(id);
3677
+ delTimeline.run(id);
3551
3678
  }
3552
3679
  });
3553
3680
  txn();
@@ -26,6 +26,11 @@ export declare const SESSION_MIRROR_MAX_ROWS = 200;
26
26
  export declare const SESSION_MIRROR_SNIPPET_MAX = 280;
27
27
  /** Mirror rows older than this since their last sync are pruned (staleness/size ceiling). */
28
28
  export declare const SESSION_MIRROR_MAX_AGE_MS: number;
29
+ /** Caps for the timeline block on a published mirror row. */
30
+ export declare const SESSION_MIRROR_MAX_STEPS = 8;
31
+ export declare const SESSION_MIRROR_STEP_TEXT_MAX = 160;
32
+ export declare const SESSION_MIRROR_REQUEST_MAX = 2000;
33
+ export declare const SESSION_MIRROR_MAX_FILES = 8;
29
34
  export interface PublishSessionMirrorOptions {
30
35
  userAgentsDir?: string;
31
36
  device?: string;
@@ -30,6 +30,43 @@ export const SESSION_MIRROR_MAX_ROWS = 200;
30
30
  export const SESSION_MIRROR_SNIPPET_MAX = 280;
31
31
  /** Mirror rows older than this since their last sync are pruned (staleness/size ceiling). */
32
32
  export const SESSION_MIRROR_MAX_AGE_MS = 14 * 24 * 60 * 60_000;
33
+ /** Caps for the timeline block on a published mirror row. */
34
+ export const SESSION_MIRROR_MAX_STEPS = 8;
35
+ export const SESSION_MIRROR_STEP_TEXT_MAX = 160;
36
+ export const SESSION_MIRROR_REQUEST_MAX = 2_000;
37
+ export const SESSION_MIRROR_MAX_FILES = 8;
38
+ function cap(value, max) {
39
+ return value.length > max ? value.slice(0, max) : value;
40
+ }
41
+ /** Bound the tidied request: prose capped, chips capped, never re-derived. */
42
+ function boundedRequest(request) {
43
+ return {
44
+ ...request,
45
+ text: cap(request.text, SESSION_MIRROR_REQUEST_MAX),
46
+ headline: cap(request.headline, SESSION_MIRROR_STEP_TEXT_MAX),
47
+ ...(request.command ? { command: cap(request.command, 120) } : {}),
48
+ attachments: request.attachments.slice(0, SESSION_MIRROR_MAX_FILES)
49
+ .map((a) => ({ kind: a.kind, name: cap(a.name, 120) })),
50
+ };
51
+ }
52
+ /** Bound the step list. Counters are kept exact — only text is truncated. */
53
+ function boundedTimeline(timeline) {
54
+ const steps = timeline.steps.slice(-SESSION_MIRROR_MAX_STEPS).map((step) => ({
55
+ ...step,
56
+ text: cap(step.text, SESSION_MIRROR_STEP_TEXT_MAX),
57
+ ...(step.now ? { now: cap(step.now, SESSION_MIRROR_STEP_TEXT_MAX) } : {}),
58
+ ...(step.marks ? { marks: step.marks.slice(0, 4).map((mark) => cap(mark, 40)) } : {}),
59
+ }));
60
+ return { ...timeline, steps, ...(timeline.reason ? { reason: cap(timeline.reason, 200) } : {}) };
61
+ }
62
+ /** Bound the file rows; `total` still reports the real count. */
63
+ function boundedFiles(files) {
64
+ return {
65
+ ...files,
66
+ changes: files.changes.slice(0, SESSION_MIRROR_MAX_FILES)
67
+ .map((change) => ({ ...change, path: cap(change.path, 400) })),
68
+ };
69
+ }
33
70
  function snippet(value, max = SESSION_MIRROR_SNIPPET_MAX) {
34
71
  if (!value)
35
72
  return undefined;
@@ -83,6 +120,12 @@ export async function publishSessionMirrorToSharedStore(options = {}) {
83
120
  }
84
121
  : {}),
85
122
  ...(s.summary?.summaryState ? { summaryState: s.summary.summaryState } : {}),
123
+ // Same bound-on-publish discipline as the summary above: the fleet state file
124
+ // is git-synced, so the request text, the step list, and the file rows are
125
+ // capped here rather than shipped raw (PHNX-3939).
126
+ ...(s.timeline?.request ? { request: boundedRequest(s.timeline.request) } : {}),
127
+ ...(s.timeline?.timeline ? { timeline: boundedTimeline(s.timeline.timeline) } : {}),
128
+ ...(s.timeline?.files ? { files: boundedFiles(s.timeline.files) } : {}),
86
129
  capturedAt,
87
130
  }));
88
131
  try {
@@ -133,8 +176,131 @@ function toUpsert(raw) {
133
176
  ticketId: cap(r.ticketId, 64),
134
177
  prUrl: cap(r.prUrl, 512),
135
178
  summary: toMirrorSummary(r),
179
+ timeline: toMirrorTimeline(r),
136
180
  };
137
181
  }
182
+ /**
183
+ * Validate an untrusted peer's published request/timeline/files into the bounded
184
+ * projection the local cache stores (PHNX-3939). Bounds mirror the publish side,
185
+ * so a hostile or oversized peer cannot bloat this box's cache. Only a row that
186
+ * carries a well-formed timeline is kept; a malformed one is dropped whole
187
+ * rather than partially trusted.
188
+ */
189
+ function toMirrorTimeline(r) {
190
+ const timeline = toMirrorTimelineBlock(r.timeline);
191
+ if (!timeline)
192
+ return undefined;
193
+ return {
194
+ timeline,
195
+ ...(toMirrorRequest(r.request) ? { request: toMirrorRequest(r.request) } : {}),
196
+ ...(toMirrorFiles(r.files) ? { files: toMirrorFiles(r.files) } : {}),
197
+ };
198
+ }
199
+ function isRecord(v) {
200
+ return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
201
+ }
202
+ function count(v) {
203
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;
204
+ }
205
+ /** The verb keys a peer's `mix` may carry — anything else is dropped, not trusted. */
206
+ const MIRROR_VERB_CLASSES = [
207
+ 'read', 'edit', 'run', 'git', 'test', 'browser', 'agent', 'other',
208
+ ];
209
+ /** A peer's per-verb breakdown, whitelisted by key and clamped by value. */
210
+ function toMirrorMix(raw) {
211
+ if (!isRecord(raw))
212
+ return undefined;
213
+ const mix = {};
214
+ for (const verb of MIRROR_VERB_CLASSES) {
215
+ const n = count(raw[verb]);
216
+ if (n > 0)
217
+ mix[verb] = n;
218
+ }
219
+ return Object.keys(mix).length ? mix : undefined;
220
+ }
221
+ function toMirrorTimelineBlock(raw) {
222
+ if (!isRecord(raw))
223
+ return undefined;
224
+ const state = raw.state;
225
+ if (state !== 'ready' && state !== 'partial' && state !== 'unavailable')
226
+ return undefined;
227
+ const steps = (Array.isArray(raw.steps) ? raw.steps : [])
228
+ .filter(isRecord)
229
+ .filter((step) => isString(step.text) && isString(step.at))
230
+ .slice(0, SESSION_MIRROR_MAX_STEPS)
231
+ .map((step) => ({
232
+ text: String(step.text).slice(0, SESSION_MIRROR_STEP_TEXT_MAX),
233
+ at: String(step.at).slice(0, 40),
234
+ ...(isString(step.endedAt) ? { endedAt: step.endedAt.slice(0, 40) } : {}),
235
+ source: (step.source === 'thinking' || step.source === 'derived' || step.source === 'user'
236
+ ? step.source : 'narration'),
237
+ tools: count(step.tools),
238
+ failed: count(step.failed),
239
+ blocked: count(step.blocked),
240
+ ...(isString(step.now) ? { now: step.now.slice(0, SESSION_MIRROR_STEP_TEXT_MAX) } : {}),
241
+ // `mix` and `live` are what the sidebar renders a step WITH ("6 run ·
242
+ // 2 blocked", the amber now-line). Dropping them here made a remote row
243
+ // disagree with the local one about what a live step even is, while `now`
244
+ // survived — so they ride through, whitelisted like every other field.
245
+ ...(step.live === true ? { live: true } : {}),
246
+ ...(() => {
247
+ const mix = toMirrorMix(step.mix);
248
+ return mix ? { mix } : {};
249
+ })(),
250
+ ...(Array.isArray(step.marks)
251
+ ? { marks: step.marks.filter(isString).slice(0, 4).map((mark) => mark.slice(0, 40)) }
252
+ : {}),
253
+ }));
254
+ const earlier = isRecord(raw.earlier) ? raw.earlier : {};
255
+ return {
256
+ steps,
257
+ earlier: { steps: count(earlier.steps), tools: count(earlier.tools), failed: count(earlier.failed) },
258
+ tools: count(raw.tools),
259
+ failed: count(raw.failed),
260
+ blocked: count(raw.blocked),
261
+ spanMs: count(raw.spanMs),
262
+ state,
263
+ ...(isString(raw.reason) ? { reason: raw.reason.slice(0, 200) } : {}),
264
+ };
265
+ }
266
+ function toMirrorRequest(raw) {
267
+ if (!isRecord(raw) || !isString(raw.headline))
268
+ return undefined;
269
+ const kind = raw.kind;
270
+ return {
271
+ text: isString(raw.text) ? raw.text.slice(0, SESSION_MIRROR_REQUEST_MAX) : '',
272
+ headline: raw.headline.slice(0, SESSION_MIRROR_STEP_TEXT_MAX),
273
+ kind: kind === 'image' || kind === 'command' || kind === 'skill' ? kind : 'text',
274
+ ...(isString(raw.command) ? { command: raw.command.slice(0, 120) } : {}),
275
+ attachments: (Array.isArray(raw.attachments) ? raw.attachments : [])
276
+ .filter(isRecord)
277
+ .filter((a) => isString(a.name))
278
+ .slice(0, SESSION_MIRROR_MAX_FILES)
279
+ .map((a) => ({
280
+ kind: a.kind === 'image' || a.kind === 'dir' ? a.kind : 'file',
281
+ name: String(a.name).slice(0, 120),
282
+ })),
283
+ pastedLines: count(raw.pastedLines),
284
+ ...(typeof raw.turns === 'number' ? { turns: count(raw.turns) } : {}),
285
+ };
286
+ }
287
+ function toMirrorFiles(raw) {
288
+ if (!isRecord(raw))
289
+ return undefined;
290
+ const changes = (Array.isArray(raw.changes) ? raw.changes : [])
291
+ .filter(isRecord)
292
+ .filter((change) => isString(change.path))
293
+ .slice(0, SESSION_MIRROR_MAX_FILES)
294
+ .map((change) => ({
295
+ path: String(change.path).slice(0, 400),
296
+ op: (change.op === 'created' || change.op === 'deleted' ? change.op : 'modified'),
297
+ edits: count(change.edits),
298
+ at: isString(change.at) ? change.at.slice(0, 40) : '',
299
+ }));
300
+ if (!changes.length)
301
+ return undefined;
302
+ return { changes, total: count(raw.total) || changes.length, source: raw.source === 'harness' ? 'harness' : 'tools' };
303
+ }
138
304
  /** One published summary state, validated back into the union or undefined. */
139
305
  function toSummaryState(v) {
140
306
  return v === 'pending' || v === 'ready' || v === 'skipped' ? v : undefined;
@@ -33,6 +33,14 @@ export interface ParseSessionOptions {
33
33
  maxToolOutputChars?: number;
34
34
  /** Opt-in because interrupts are not user messages and would change the published event stream. */
35
35
  includeInterrupts?: boolean;
36
+ /**
37
+ * Opt-in `file_change` events from the harness's own file ledger (Claude
38
+ * `file-history-delta`). Opt-in for the same reason as
39
+ * {@link includeInterrupts}: they are not tool calls, and emitting them by
40
+ * default would change every published event stream (message counts, digests,
41
+ * trajectories) for a signal only the timeline fold consumes.
42
+ */
43
+ includeFileHistory?: boolean;
36
44
  }
37
45
  export declare function parseSession(filePath: string, agent?: SessionAgentId, opts?: ParseSessionOptions): SessionEvent[];
38
46
  /** Infer the agent type from a session file path using known directory conventions. */
@@ -75,6 +83,28 @@ export declare function applyPatchTargets(input: string): Array<{
75
83
  op: 'Add' | 'Update' | 'Delete';
76
84
  }>;
77
85
  export declare function applyPatchTargetPaths(input: string): string[];
86
+ /**
87
+ * Codex writes its turn TWICE in one rollout: the raw `response_item` records
88
+ * the model exchanged, and a parallel `event_msg` / `item_completed` stream of
89
+ * typed, already-classified items. The two overlap — on a 6,244-line rollout on
90
+ * zion (2026-09-06) the same turn appears as 648 `custom_tool_call` +
91
+ * 60 `function_call` records AND as 1,038 `CommandExecution` items — so the two
92
+ * MUST NOT be merged into one event list: every consumer that counts tool calls
93
+ * (digest, trajectory, insights, the tool index) would double-count.
94
+ *
95
+ * {@link parseCodexContent} therefore keeps reading `response_item` and this is
96
+ * a SEPARATE reader over the item stream, for consumers that want the harness's
97
+ * own classification instead of re-deriving it: the command as Codex parsed it
98
+ * (`parsed_cmd.type`), its `exit_code`/`status`, the per-path `FileChange`
99
+ * ledger, `AgentMessage.phase` (`commentary` is the narration between calls),
100
+ * web searches, sub-agent activity and compactions. The timeline fold is the
101
+ * consumer; `parseSession` is untouched.
102
+ *
103
+ * Version-tolerant by construction: an item type this does not know folds to a
104
+ * generic `other` tool_use rather than throwing, so a Codex release that adds an
105
+ * item type degrades to a counted step instead of an empty timeline.
106
+ */
107
+ export declare function parseCodexItemsContent(content: string): SessionEvent[];
78
108
  /**
79
109
  * Parse Codex JSONL *content* (already read into a string) into normalized
80
110
  * events. Split from `parseCodex` so the tail reader can parse just the last
@@ -142,6 +172,25 @@ export declare function sessionFilePathContainer(filePath: string): string;
142
172
  * timestamps aren't stored, so every event carries the session's `created_at`
143
173
  * (from summary.json), falling back to the transcript's mtime.
144
174
  */
175
+ /**
176
+ * Counters Grok writes beside its transcript in `signals.json` — the only place
177
+ * a Grok session records milestones and failures, since `chat_history.jsonl`
178
+ * carries no timestamps and no exit codes. Read by the timeline fold to mark a
179
+ * session that committed / opened / merged a PR and to report a tool-failure
180
+ * count the event stream cannot supply.
181
+ */
182
+ export interface GrokSessionSignals {
183
+ gitCommitCount: number;
184
+ prCreatedCount: number;
185
+ prMergedCount: number;
186
+ toolFailureCount: number;
187
+ }
188
+ /**
189
+ * Read `signals.json` next to a Grok transcript. Returns `undefined` when the
190
+ * file is absent or unreadable — a Grok session simply has no signals then, and
191
+ * the fold reports what it does have rather than inventing zeros.
192
+ */
193
+ export declare function readGrokSignals(filePath: string): GrokSessionSignals | undefined;
145
194
  export declare function parseGrok(filePath: string): SessionEvent[];
146
195
  /**
147
196
  * The transcript query {@link parseOpenCode} runs, exported so a test can assert