@phnx-labs/agents-cli 1.22.83 → 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 (41) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/commands/accounts.js +1 -1
  3. package/dist/commands/sessions-trace.d.ts +11 -0
  4. package/dist/commands/sessions-trace.js +71 -0
  5. package/dist/lib/accounts/connect.d.ts +6 -0
  6. package/dist/lib/accounts/connect.js +70 -0
  7. package/dist/lib/daemon-ticks.js +23 -2
  8. package/dist/lib/feed/activity-stream.d.ts +60 -0
  9. package/dist/lib/feed/activity-stream.js +271 -0
  10. package/dist/lib/feed/activity.d.ts +7 -0
  11. package/dist/lib/feed/activity.js +10 -4
  12. package/dist/lib/feed/watch.d.ts +9 -3
  13. package/dist/lib/feed/watch.js +72 -36
  14. package/dist/lib/fleet-shared-state.d.ts +6 -0
  15. package/dist/lib/session/active.d.ts +38 -4
  16. package/dist/lib/session/active.js +36 -4
  17. package/dist/lib/session/bash-command.js +10 -0
  18. package/dist/lib/session/db.d.ts +47 -2
  19. package/dist/lib/session/db.js +131 -4
  20. package/dist/lib/session/mirror.d.ts +5 -0
  21. package/dist/lib/session/mirror.js +166 -0
  22. package/dist/lib/session/parse.d.ts +49 -0
  23. package/dist/lib/session/parse.js +324 -30
  24. package/dist/lib/session/prompt.d.ts +93 -2
  25. package/dist/lib/session/prompt.js +266 -15
  26. package/dist/lib/session/remote/peer-stream.d.ts +47 -0
  27. package/dist/lib/session/remote/peer-stream.js +142 -0
  28. package/dist/lib/session/remote/watch.d.ts +1 -1
  29. package/dist/lib/session/remote/watch.js +41 -42
  30. package/dist/lib/session/session-cache.d.ts +9 -0
  31. package/dist/lib/session/session-cache.js +40 -2
  32. package/dist/lib/session/timeline-pass.d.ts +129 -0
  33. package/dist/lib/session/timeline-pass.js +323 -0
  34. package/dist/lib/session/timeline.d.ts +182 -0
  35. package/dist/lib/session/timeline.js +636 -0
  36. package/dist/lib/session/types.d.ts +155 -1
  37. package/dist/lib/summarizer/pass.d.ts +2 -0
  38. package/dist/lib/summarizer/pass.js +14 -1
  39. package/dist/lib/summarizer/summarize.d.ts +7 -0
  40. package/dist/lib/summarizer/summarize.js +3 -0
  41. package/package.json +1 -1
@@ -27,8 +27,8 @@ import * as fs from 'fs';
27
27
  import * as path from 'path';
28
28
  import { createMemoryCache } from '../memory-cache.js';
29
29
  import { getCacheDir } from '../state.js';
30
- import { backfillActiveRowsFromIndex, sessionProcessIsLocal } from './active.js';
31
- import { readSessionSummaryAny } from './db.js';
30
+ import { backfillActiveRowsFromIndex, foldRecap, sessionProcessIsLocal } from './active.js';
31
+ import { readSessionSummaryAny, readSessionTimelineAny } from './db.js';
32
32
  import { isSummarizerReady } from '../summarizer/config.js';
33
33
  /** Snapshot file under `getCacheDir()` (regenerable, gitignored). */
34
34
  const SNAPSHOT_FILE = '.active-sessions.json';
@@ -468,10 +468,48 @@ export function resolveStreamSummaryState(current, nowMs = Date.now()) {
468
468
  return current;
469
469
  return isSummarizerReady(nowMs) ? 'pending' : 'skipped';
470
470
  }
471
+ /**
472
+ * Merge the daemon-folded timeline (PHNX-3939) onto a live row from the
473
+ * transcript-keyed `session_timelines` cache. Sibling of
474
+ * {@link mergeSessionSummary} and the same contract: one indexed by-id read of
475
+ * the BOUNDED projection (never the fold's resume state), never a parse, never
476
+ * a model call. `runTimelinePass` in the daemon tick is the only producer.
477
+ * Best-effort — a DB error leaves the row untouched.
478
+ */
479
+ export function mergeSessionTimeline(s) {
480
+ if (!s.sessionId)
481
+ return s;
482
+ try {
483
+ const stored = readSessionTimelineAny(s.sessionId);
484
+ if (!stored)
485
+ return s;
486
+ if (s.timeline === undefined)
487
+ s.timeline = stored.timeline;
488
+ if (s.files === undefined && stored.files !== undefined)
489
+ s.files = stored.files;
490
+ if (stored.request) {
491
+ // `request` is the one field where the FOLD wins over what the gather
492
+ // already put there. `foldRecap` tidies whatever turn the INDEX had for the
493
+ // row, which lags a live session by up to a scan; the daemon fold read the
494
+ // transcript itself this tick and counted the turns. Re-deriving the recap
495
+ // afterwards keeps the row's title and `userPromptClean` agreeing with the
496
+ // request they are supposed to describe.
497
+ const changed = s.request?.headline !== stored.request.headline || s.request?.turns !== stored.request.turns;
498
+ s.request = stored.request;
499
+ if (changed)
500
+ foldRecap([s]);
501
+ }
502
+ }
503
+ catch {
504
+ // best-effort: never let the timeline merge break the gather path.
505
+ }
506
+ return s;
507
+ }
471
508
  export function applyImmutableMemo(s) {
472
509
  if (!s.sessionId)
473
510
  return s;
474
511
  mergeSessionSummary(s);
512
+ mergeSessionTimeline(s);
475
513
  const mtime = transcriptMtimeMs(s);
476
514
  if (mtime === null)
477
515
  return s;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The daemon's incremental timeline pass (PHNX-3939).
3
+ *
4
+ * Runs inside the existing `SessionStateService` tick, which is already
5
+ * reader-gated — no watcher, no work — and already visits every live session.
6
+ * This adds the one thing the tick did not do: fold each live transcript's
7
+ * NEW BYTES into its cached {@link TimelineState} and store the bounded
8
+ * projection a session row merges.
9
+ *
10
+ * Three properties keep it off the request path and off the CPU:
11
+ * - **Reader-gated and budgeted.** Nothing runs unless a `sessions watch`
12
+ * consumer is attached, and at most `budget` sessions are folded per tick.
13
+ * - **Appended bytes only.** For the resumable harnesses (Claude and Codex,
14
+ * line-delimited JSON) a fold reads `size - offset` bytes, not the file.
15
+ * The same resume rule the tool index already uses (`tool-index.ts`).
16
+ * - **Complete records only.** Only newline-terminated lines are folded and
17
+ * the offset advances past those alone, so the 973,963-byte record in one
18
+ * live transcript on this fleet cannot be folded half-written; the next tick
19
+ * picks it up whole.
20
+ */
21
+ import type { ActiveSession } from './active.js';
22
+ import { type SessionTimelineCacheRow, type SessionTimelineEntry } from './db.js';
23
+ import type { SessionAgentId, SessionEvent } from './types.js';
24
+ /** Sessions folded per tick. The tick's own deadline is 30 s; this stays well inside it. */
25
+ export declare const TIMELINE_PASS_MAX_PER_TICK = 8;
26
+ /** Bytes one session may consume in one tick. A bigger backlog catches up over ticks. */
27
+ export declare const TIMELINE_PASS_MAX_BYTES_PER_SESSION: number;
28
+ /**
29
+ * Bytes the whole pass may consume in one tick, across every session.
30
+ *
31
+ * The per-session cap alone is not a bound on the TICK: on a cold cache eight
32
+ * live sessions each fold from offset 0, and eight multi-megabyte transcripts
33
+ * parsed back to back is what blew the 30 s `session-state` deadline and parked
34
+ * the service on this fleet (observed 2026-09-06). A tick budget makes a cold
35
+ * start catch up over a few ticks instead of stalling one.
36
+ */
37
+ export declare const TIMELINE_PASS_MAX_BYTES_PER_TICK: number;
38
+ /**
39
+ * Ceiling on a whole-file re-parse for a harness with no resumable reader.
40
+ * Beyond it the timeline is reported `partial` rather than wedging the tick —
41
+ * the same honesty rule the tool index applies at its own in-memory limit.
42
+ */
43
+ export declare const TIMELINE_PASS_MAX_WHOLE_FILE_BYTES: number;
44
+ /** Events folded from one non-resumable whole-file parse before reporting `partial`. */
45
+ export declare const TIMELINE_PASS_MAX_EVENTS = 20000;
46
+ /**
47
+ * How long a non-resumable harness's timeline may go stale before the pass
48
+ * re-parses its transcript whole.
49
+ *
50
+ * Kimi and Grok expose no byte offset to resume from, so their only option is a
51
+ * full parse — and a full parse of a large, actively-appended transcript wedges
52
+ * the Node event loop for seconds (the PHNX-3411 incident, which is why the tool
53
+ * index deferred those two harnesses to their own budgeted pass). Re-parsing
54
+ * every 15 s would reproduce it. One re-parse per minute keeps such a session's
55
+ * timeline current enough for a card that moves about once a minute, at a
56
+ * quarter of the cost.
57
+ */
58
+ export declare const TIMELINE_PASS_NON_RESUMABLE_MIN_INTERVAL_MS = 60000;
59
+ export interface TimelinePassResult {
60
+ /** Sessions whose timeline was folded and written this tick. */
61
+ computed: number;
62
+ /** Sessions whose cached timeline already matched the transcript bytes. */
63
+ reused: number;
64
+ /** Sessions skipped: unreadable transcript, no id/file, or no parseable transcript. */
65
+ skipped: number;
66
+ }
67
+ export interface TimelinePassOptions {
68
+ /** Live rows to fold. Defaults to the local active-sessions cache. */
69
+ sessions?: ActiveSession[];
70
+ budget?: number;
71
+ /** Bytes the whole pass may read this tick, across every session. */
72
+ maxBytes?: number;
73
+ /** Set false only in tests: the pass is reader-gated exactly like the warm tick. */
74
+ requireReader?: boolean;
75
+ nowMs?: number;
76
+ signal?: AbortSignal;
77
+ statFile?: (path: string) => {
78
+ mtimeMs: number;
79
+ size: number;
80
+ };
81
+ }
82
+ /**
83
+ * Every event of one transcript, read the way the timeline fold needs them.
84
+ *
85
+ * The single source of truth for "which reader does this harness's timeline
86
+ * use", shared by the daemon pass's non-resumable branch and
87
+ * `agents sessions trace --steps` — so the CLI door and the cached row can never
88
+ * fold two different event streams for the same session.
89
+ */
90
+ export declare function parseTimelineEvents(filePath: string, agent: SessionAgentId): SessionEvent[];
91
+ /** One session's fold: the entry to cache, and what it actually cost to produce. */
92
+ export interface SessionTimelineFold {
93
+ entry: SessionTimelineEntry;
94
+ /**
95
+ * Bytes this fold READ off disk — what the tick's byte budget is debited by.
96
+ *
97
+ * Not the transcript's growth delta: a non-resumable harness re-parses the
98
+ * WHOLE file every time, so eight 3 MiB sessions grown by 10 KB each read
99
+ * 24 MiB, not 80 KB. Charging the delta is what let a tick overrun its budget
100
+ * by an order of magnitude.
101
+ */
102
+ bytesRead: number;
103
+ }
104
+ /**
105
+ * Fold one session's new transcript bytes and return the entry to cache.
106
+ * Exported for tests: this is where the resume decision and the whole-file
107
+ * fallback live, and both are worth pinning without a daemon.
108
+ *
109
+ * `byteBudget` is the TICK's remaining allowance. Each branch derives its own
110
+ * ceiling from it — the resumable chunk read is additionally capped per session,
111
+ * while a whole-file re-parse is gated on a budget that can actually reach a
112
+ * real transcript. A session that genuinely does not fit gets a `partial` row
113
+ * stating why, never silence.
114
+ */
115
+ export declare function foldSessionTimeline(session: ActiveSession, filePath: string, fileSize: number, prior: SessionTimelineCacheRow | undefined, nowMs?: number, byteBudget?: number): SessionTimelineFold | undefined;
116
+ /**
117
+ * Fold the timelines of the live local sessions, bounded, once per tick.
118
+ * Returns what it did so the daemon tick can log it like every other pass.
119
+ *
120
+ * Async only because resolving the session list needs the session-cache module,
121
+ * which is loaded lazily to keep this off the CLI's startup graph. When the
122
+ * caller already HAS the rows (the daemon tick does — it just gathered them),
123
+ * {@link runTimelinePassSync} is the same work with no dynamic import.
124
+ */
125
+ export declare function runTimelinePass(opts?: TimelinePassOptions): Promise<TimelinePassResult>;
126
+ /** {@link runTimelinePass} over an explicit row list. Pure of the session cache. */
127
+ export declare function runTimelinePassSync(opts: TimelinePassOptions & {
128
+ sessions: ActiveSession[];
129
+ }): TimelinePassResult;
@@ -0,0 +1,323 @@
1
+ /**
2
+ * The daemon's incremental timeline pass (PHNX-3939).
3
+ *
4
+ * Runs inside the existing `SessionStateService` tick, which is already
5
+ * reader-gated — no watcher, no work — and already visits every live session.
6
+ * This adds the one thing the tick did not do: fold each live transcript's
7
+ * NEW BYTES into its cached {@link TimelineState} and store the bounded
8
+ * projection a session row merges.
9
+ *
10
+ * Three properties keep it off the request path and off the CPU:
11
+ * - **Reader-gated and budgeted.** Nothing runs unless a `sessions watch`
12
+ * consumer is attached, and at most `budget` sessions are folded per tick.
13
+ * - **Appended bytes only.** For the resumable harnesses (Claude and Codex,
14
+ * line-delimited JSON) a fold reads `size - offset` bytes, not the file.
15
+ * The same resume rule the tool index already uses (`tool-index.ts`).
16
+ * - **Complete records only.** Only newline-terminated lines are folded and
17
+ * the offset advances past those alone, so the 973,963-byte record in one
18
+ * live transcript on this fleet cannot be folded half-written; the next tick
19
+ * picks it up whole.
20
+ */
21
+ import * as fs from 'fs';
22
+ import { readSessionTimelineEntry, writeSessionTimeline } from './db.js';
23
+ import { parseClaudeContent, parseCodexItemsContent, parseSession } from './parse.js';
24
+ import { toolEvidenceSourcePath } from './tool-store.js';
25
+ import { compactTimelineState, emptyTimelineState, foldTimeline, projectSessionFiles, projectTimeline, unavailableTimeline, TIMELINE_EXTRACTOR_VERSION, } from './timeline.js';
26
+ /** Sessions folded per tick. The tick's own deadline is 30 s; this stays well inside it. */
27
+ export const TIMELINE_PASS_MAX_PER_TICK = 8;
28
+ /** Bytes one session may consume in one tick. A bigger backlog catches up over ticks. */
29
+ export const TIMELINE_PASS_MAX_BYTES_PER_SESSION = 4 * 1024 * 1024;
30
+ /**
31
+ * Bytes the whole pass may consume in one tick, across every session.
32
+ *
33
+ * The per-session cap alone is not a bound on the TICK: on a cold cache eight
34
+ * live sessions each fold from offset 0, and eight multi-megabyte transcripts
35
+ * parsed back to back is what blew the 30 s `session-state` deadline and parked
36
+ * the service on this fleet (observed 2026-09-06). A tick budget makes a cold
37
+ * start catch up over a few ticks instead of stalling one.
38
+ */
39
+ export const TIMELINE_PASS_MAX_BYTES_PER_TICK = 8 * 1024 * 1024;
40
+ /**
41
+ * Ceiling on a whole-file re-parse for a harness with no resumable reader.
42
+ * Beyond it the timeline is reported `partial` rather than wedging the tick —
43
+ * the same honesty rule the tool index applies at its own in-memory limit.
44
+ */
45
+ export const TIMELINE_PASS_MAX_WHOLE_FILE_BYTES = 16 * 1024 * 1024;
46
+ /** Events folded from one non-resumable whole-file parse before reporting `partial`. */
47
+ export const TIMELINE_PASS_MAX_EVENTS = 20_000;
48
+ /**
49
+ * How long a non-resumable harness's timeline may go stale before the pass
50
+ * re-parses its transcript whole.
51
+ *
52
+ * Kimi and Grok expose no byte offset to resume from, so their only option is a
53
+ * full parse — and a full parse of a large, actively-appended transcript wedges
54
+ * the Node event loop for seconds (the PHNX-3411 incident, which is why the tool
55
+ * index deferred those two harnesses to their own budgeted pass). Re-parsing
56
+ * every 15 s would reproduce it. One re-parse per minute keeps such a session's
57
+ * timeline current enough for a card that moves about once a minute, at a
58
+ * quarter of the cost.
59
+ */
60
+ export const TIMELINE_PASS_NON_RESUMABLE_MIN_INTERVAL_MS = 60_000;
61
+ /**
62
+ * Harnesses whose transcript is append-only line-delimited JSON that the fold
63
+ * can resume from a byte offset. Everything else is re-parsed whole (bounded)
64
+ * because its parser exposes no offset — the same split as
65
+ * `isResumableToolSource` in `tool-index.ts`.
66
+ */
67
+ function isResumableTimelineSource(agent) {
68
+ return agent === 'claude' || agent === 'codex';
69
+ }
70
+ /** Harnesses that write no parseable transcript at all — stated, never faked as empty. */
71
+ const NO_TRANSCRIPT_REASON = {
72
+ openclaw: 'OpenClaw writes no parseable transcript, so there are no steps to fold',
73
+ };
74
+ /** Read `[start, end)` of a file, keeping only complete newline-terminated lines. */
75
+ function readCompleteLines(filePath, start, end, maxBytes) {
76
+ const stop = Math.min(end, start + maxBytes);
77
+ if (stop <= start)
78
+ return { text: '', offset: start };
79
+ const fd = fs.openSync(filePath, 'r');
80
+ try {
81
+ const buffer = Buffer.alloc(stop - start);
82
+ const read = fs.readSync(fd, buffer, 0, buffer.length, start);
83
+ const lastNewline = buffer.lastIndexOf(0x0a, read - 1);
84
+ // Nothing complete in this window: leave the offset where it was so the
85
+ // record is folded whole once its closing newline lands.
86
+ if (lastNewline < 0)
87
+ return { text: '', offset: start };
88
+ return {
89
+ text: buffer.toString('utf8', 0, lastNewline + 1),
90
+ offset: start + lastNewline + 1,
91
+ };
92
+ }
93
+ finally {
94
+ fs.closeSync(fd);
95
+ }
96
+ }
97
+ /**
98
+ * Normalize one transcript chunk into the events the fold consumes.
99
+ *
100
+ * Interrupts are user steps in the timeline and the harness file ledger is the
101
+ * Files card, so both are opted in — the published stream every OTHER consumer
102
+ * reads stays unchanged. Codex reads its typed `item_completed` stream, NOT the
103
+ * `response_item` records `parseCodexContent` reads: the two describe the same
104
+ * turn, so folding both would double-count every call (see
105
+ * `parseCodexItemsContent`).
106
+ */
107
+ function eventsForChunk(agent, text) {
108
+ if (agent === 'claude') {
109
+ return parseClaudeContent(text, { includeInterrupts: true, includeFileHistory: true });
110
+ }
111
+ return parseCodexItemsContent(text);
112
+ }
113
+ /**
114
+ * Every event of one transcript, read the way the timeline fold needs them.
115
+ *
116
+ * The single source of truth for "which reader does this harness's timeline
117
+ * use", shared by the daemon pass's non-resumable branch and
118
+ * `agents sessions trace --steps` — so the CLI door and the cached row can never
119
+ * fold two different event streams for the same session.
120
+ */
121
+ export function parseTimelineEvents(filePath, agent) {
122
+ if (isResumableTimelineSource(agent)) {
123
+ return eventsForChunk(agent, fs.readFileSync(filePath, 'utf8'));
124
+ }
125
+ return parseSession(filePath, agent, { includeInterrupts: true, includeFileHistory: true });
126
+ }
127
+ /** MiB, for the human-readable reasons a row carries. */
128
+ function mib(bytes) {
129
+ return Math.round((bytes / (1024 * 1024)) * 10) / 10;
130
+ }
131
+ /**
132
+ * Fold one session's new transcript bytes and return the entry to cache.
133
+ * Exported for tests: this is where the resume decision and the whole-file
134
+ * fallback live, and both are worth pinning without a daemon.
135
+ *
136
+ * `byteBudget` is the TICK's remaining allowance. Each branch derives its own
137
+ * ceiling from it — the resumable chunk read is additionally capped per session,
138
+ * while a whole-file re-parse is gated on a budget that can actually reach a
139
+ * real transcript. A session that genuinely does not fit gets a `partial` row
140
+ * stating why, never silence.
141
+ */
142
+ export function foldSessionTimeline(session, filePath, fileSize, prior, nowMs = Date.now(), byteBudget = TIMELINE_PASS_MAX_BYTES_PER_TICK) {
143
+ const agent = (session.kind ?? 'claude');
144
+ // A settled state stamped at the file's current size, so an unavailable or
145
+ // over-limit session is recognized as up to date next tick instead of being
146
+ // re-decided every 15 s.
147
+ const settledAt = () => ({ ...emptyTimelineState(), offset: fileSize });
148
+ const unavailable = NO_TRANSCRIPT_REASON[agent];
149
+ if (unavailable) {
150
+ return { entry: { timeline: unavailableTimeline(unavailable), state: settledAt() }, bytesRead: 0 };
151
+ }
152
+ let state;
153
+ let events;
154
+ let offset;
155
+ let bytesRead;
156
+ if (isResumableTimelineSource(agent)) {
157
+ const resumable = prior?.state
158
+ && prior.state.version === TIMELINE_EXTRACTOR_VERSION
159
+ && prior.state.offset <= fileSize;
160
+ state = resumable ? prior.state : emptyTimelineState();
161
+ const start = resumable ? state.offset : 0;
162
+ const maxBytes = Math.min(byteBudget, TIMELINE_PASS_MAX_BYTES_PER_SESSION);
163
+ const chunk = readCompleteLines(filePath, start, fileSize, maxBytes);
164
+ // Nothing COMPLETE in the window this tick could read — a record still being
165
+ // written, or a byte budget too small to reach the next newline. Leave the
166
+ // cached row and its offset alone; writing here would stamp an empty fold
167
+ // over a real one and move the resume point past bytes never folded.
168
+ if (!chunk.text && chunk.offset === start)
169
+ return undefined;
170
+ events = eventsForChunk(agent, chunk.text);
171
+ offset = chunk.offset;
172
+ bytesRead = chunk.offset - start;
173
+ }
174
+ else {
175
+ // No resumable reader for this harness: re-parse whole, bounded, from a
176
+ // FRESH state — a partial re-fold onto a prior state would double-count.
177
+ // Rate-limited so an actively-appended large transcript is not re-parsed on
178
+ // every 15 s tick (see TIMELINE_PASS_NON_RESUMABLE_MIN_INTERVAL_MS).
179
+ if (prior && nowMs - prior.computedAt < TIMELINE_PASS_NON_RESUMABLE_MIN_INTERVAL_MS)
180
+ return undefined;
181
+ if (fileSize > TIMELINE_PASS_MAX_WHOLE_FILE_BYTES) {
182
+ return {
183
+ entry: {
184
+ timeline: unavailableTimeline(`transcript is larger than the ${Math.round(TIMELINE_PASS_MAX_WHOLE_FILE_BYTES / (1024 * 1024))} MiB whole-file fold limit for ${agent}`),
185
+ state: settledAt(),
186
+ },
187
+ bytesRead: 0,
188
+ };
189
+ }
190
+ // Eligibility is the smaller of what the tick has left and what a whole-file
191
+ // fold is allowed to cost — NOT the per-session allowance, which is capped
192
+ // at 4 MiB and so could never reach a transcript in (4 MiB, 16 MiB] however
193
+ // idle the tick was. That gap left every such session on kimi/grok/gemini/
194
+ // cursor/opencode/droid/warp/forge/copilot/goose/antigravity with no row at
195
+ // all, silently counted `reused`.
196
+ const wholeFileAllowance = Math.min(byteBudget, TIMELINE_PASS_MAX_WHOLE_FILE_BYTES);
197
+ if (fileSize > wholeFileAllowance) {
198
+ // It does not fit THIS tick. Say so on the row rather than write nothing:
199
+ // the state is left at offset 0, so a tick with more budget upgrades it to
200
+ // a real fold instead of the session staying invisible forever.
201
+ return {
202
+ entry: {
203
+ timeline: {
204
+ ...unavailableTimeline(`a ${mib(fileSize)} MiB ${agent} transcript needs a whole-file fold, and this tick had ${mib(wholeFileAllowance)} MiB of budget left`),
205
+ state: 'partial',
206
+ },
207
+ state: emptyTimelineState(),
208
+ },
209
+ bytesRead: 0,
210
+ };
211
+ }
212
+ state = emptyTimelineState();
213
+ events = parseTimelineEvents(filePath, agent);
214
+ offset = fileSize;
215
+ // The whole file was read, not the delta — charge the tick for all of it.
216
+ bytesRead = fileSize;
217
+ }
218
+ const folded = compactTimelineState(foldTimeline(events, state, {
219
+ attachments: session.attachments,
220
+ maxEvents: isResumableTimelineSource(agent) ? undefined : TIMELINE_PASS_MAX_EVENTS,
221
+ offset,
222
+ }));
223
+ const files = projectSessionFiles(folded);
224
+ return {
225
+ entry: {
226
+ timeline: projectTimeline(folded, session.activity),
227
+ ...(folded.request ? { request: folded.request } : {}),
228
+ ...(files ? { files } : {}),
229
+ state: folded,
230
+ },
231
+ bytesRead,
232
+ };
233
+ }
234
+ /**
235
+ * Fold the timelines of the live local sessions, bounded, once per tick.
236
+ * Returns what it did so the daemon tick can log it like every other pass.
237
+ *
238
+ * Async only because resolving the session list needs the session-cache module,
239
+ * which is loaded lazily to keep this off the CLI's startup graph. When the
240
+ * caller already HAS the rows (the daemon tick does — it just gathered them),
241
+ * {@link runTimelinePassSync} is the same work with no dynamic import.
242
+ */
243
+ export async function runTimelinePass(opts = {}) {
244
+ const { readActiveSessionsCache, isActiveSessionsJournalReaderRecent } = await import('./session-cache.js');
245
+ const now = opts.nowMs ?? Date.now();
246
+ let sessions = opts.sessions;
247
+ if (!sessions) {
248
+ if (opts.requireReader !== false && !isActiveSessionsJournalReaderRecent(now)) {
249
+ return { computed: 0, reused: 0, skipped: 0 };
250
+ }
251
+ sessions = readActiveSessionsCache('local')?.sessions ?? [];
252
+ }
253
+ return runTimelinePassSync({ ...opts, sessions });
254
+ }
255
+ /** {@link runTimelinePass} over an explicit row list. Pure of the session cache. */
256
+ export function runTimelinePassSync(opts) {
257
+ const result = { computed: 0, reused: 0, skipped: 0 };
258
+ const sessions = opts.sessions;
259
+ const now = opts.nowMs ?? Date.now();
260
+ const stat = opts.statFile ?? ((p) => {
261
+ const s = fs.statSync(p);
262
+ return { mtimeMs: s.mtimeMs, size: s.size };
263
+ });
264
+ let budget = opts.budget ?? TIMELINE_PASS_MAX_PER_TICK;
265
+ let byteBudget = opts.maxBytes ?? TIMELINE_PASS_MAX_BYTES_PER_TICK;
266
+ for (const session of sessions) {
267
+ if (opts.signal?.aborted)
268
+ break;
269
+ if (budget <= 0 || byteBudget <= 0)
270
+ break;
271
+ const id = session.sessionId;
272
+ const file = session.sessionFile;
273
+ if (!id || !file)
274
+ continue;
275
+ // Stamp on the file the PARSER reads, not the row's `sessionFile`: Kimi's row
276
+ // points at `state.json` while its content is in a sibling `wire.jsonl`, and
277
+ // Grok's at `summary.json` beside `chat_history.jsonl`. Stamping the wrong
278
+ // file makes an actively-growing session look unchanged forever. Same helper
279
+ // the tool index uses, so the two agree on what a session's bytes are.
280
+ const source = toolEvidenceSourcePath(file, (session.kind ?? 'claude'));
281
+ let stamp;
282
+ try {
283
+ const st = stat(source);
284
+ stamp = { fileMtimeMs: Math.round(st.mtimeMs), fileSize: st.size };
285
+ }
286
+ catch {
287
+ result.skipped++;
288
+ continue; // transcript unreadable this tick — try again next time
289
+ }
290
+ const prior = readSessionTimelineEntry(id);
291
+ // Cached against these exact bytes: nothing was appended, nothing to fold.
292
+ if (prior && prior.state.offset === stamp.fileSize) {
293
+ result.reused++;
294
+ continue;
295
+ }
296
+ budget--;
297
+ let fold;
298
+ try {
299
+ fold = foldSessionTimeline(session, file, stamp.fileSize, prior, now, byteBudget);
300
+ }
301
+ catch (err) {
302
+ // A novel record shape, or a transcript rotated between the stat and the
303
+ // read. That is a real failure, not "nothing new" — say so and count it a
304
+ // skip, or it retries forever behind a healthy-looking log line.
305
+ console.log(`timeline pass: fold failed for ${id} (${session.kind ?? 'claude'}): ${err.message}`);
306
+ result.skipped++;
307
+ continue;
308
+ }
309
+ if (!fold) {
310
+ // Nothing complete to fold yet, or a non-resumable harness inside its
311
+ // re-parse interval — the cached row stays current, so this is not a skip.
312
+ result.reused++;
313
+ continue;
314
+ }
315
+ // Debited by the bytes actually read, so the tick's bound is real: a
316
+ // whole-file re-parse charges the whole file, and a session that read
317
+ // nothing (over budget, unavailable harness) charges nothing.
318
+ byteBudget -= fold.bytesRead;
319
+ writeSessionTimeline({ id, ...stamp, timeline: fold.entry, computedAtMs: now });
320
+ result.computed++;
321
+ }
322
+ return result;
323
+ }