sidekick-shared 0.27.0 → 0.27.1

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 CHANGED
@@ -5,6 +5,22 @@ All notable changes to sidekick-shared will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.27.1] - 2026-09-22
9
+
10
+ ### Fixed
11
+
12
+ - `collectPromptHistory()` returns a prompt again when its first answering call is written after the prompt was collected. Previously the cursor moved past the prompt and a resumed scan could never attach the answer's `model` / `usage`. Now, while a file's last emitted prompt is not final, the cursor keeps `pending: { ordinal, offset, emitted, state? }`: the byte offset of the prompt's line, a 16-hex digest of the model, usage, and status last returned, and (Codex) the parse state from just before that line. The next call replays from that offset, so the prompt's `provider`, `sessionId`, `ordinal`, `timestamp`, and `text` come from the log again and are never synthesized or renumbered, and returns it only if its metadata digest changed. No prompt or assistant text is stored in the cursor. A replay that would exceed `maxFileBytes` is dropped in favour of the committed offset, and that prompt is not revisited.
13
+ - First-answer attribution: Claude split records of the first call replace usage rather than adding it, a later assistant message id, a `tool_result`, or a non-null `stop_reason` closes the first call, later tool-loop calls never replace it, and `<synthetic>` records (API errors, interrupts) are ignored instead of contributing zero usage. Codex closes on the first `token_count` with `last_token_usage`; a `token_count` without usage (rate-limit only) does not. Missing usage stays `undefined`.
14
+ - Documentation: cursor keys are path-free, but values are not (Codex `state.cwd` / `gitBranch`), so the cursor is sensitive, local-only state; a file over `maxFileBytes` is skipped and calling again with the same bounds skips it again.
15
+
16
+ ### Added
17
+
18
+ - `PromptHistoryEntry.metadataStatus`: `pending` (no answering call yet), `provisional` (Claude call seen with `stop_reason: null`), or `final` (complete, or closed without an answer). A prompt closed by the next prompt without an answer is returned once more as `final`. New exported types `PromptHistoryMetadataStatus` and `PromptHistoryPendingPrompt`.
19
+
20
+ ### Changed
21
+
22
+ - `PromptHistoryCursor.version` is now `2` and sessions may carry `pending`. Version 1 cursors are accepted as input and upgraded; prompts they already returned without metadata cannot be recovered. Unknown versions are ignored (full rescan, same identities).
23
+
8
24
  ## [0.27.0] - 2026-09-22
9
25
 
10
26
  ### Added
package/README.md CHANGED
@@ -9,6 +9,7 @@ Types, parsers, providers, readers, formatters, aggregation, search, reporting,
9
9
 
10
10
  ## Recent additions
11
11
 
12
+ - **0.27.1: late prompt metadata** — `collectPromptHistory()` returns a prompt again, with the same provider, sessionId, ordinal, timestamp, and text, when its first answering call is written after an earlier collection, and entries gain `metadataStatus` (`pending` / `provisional` / `final`). The cursor becomes `version: 2`, keeps a replay offset and metadata digest for the one open prompt per file (no prompt text), and still accepts 0.27.0 cursors.
12
13
  - **0.27.0: human prompt history** — `collectPromptHistory()` (Node-only) returns the prompts a person typed in Claude Code and Codex sessions under a set of workspace roots (plus git worktree siblings), with fail-closed cwd scoping, untruncated text, stable per-session ordinals, the answering model and usage, a JSON-serializable resume cursor, and bounds that stop cleanly and report themselves in `boundsHit`. `isHumanPrompt()` / `humanPromptText()` expose the same classification for any canonical transcript message, and transcript provenance now keeps Claude Code's `isCompactSummary`, `originKind`, and `promptSource`. Claude Opus 5.5 gets its own pricing and context entries.
13
14
  - **0.26.6: verified account switching** — `syncLiveAccountState()` reconciles the live Claude Code and Codex logins with saved profiles (registers unknown logins under their email, folds rotated tokens, merges duplicate Codex profiles, re-points the active pointer) and runs at startup, on account-change events, and as phase one of every switch; `getAccountHealth()` / `listAccountsWithHealth()` report secret-free `fresh | expiring | expired | unknown | missing` health; `switchAccount[Async]()` returns a `SwitchAccountResult` with verification, `runningConsumers`, `needsLogin`, and an `undoToken` for `undoLastSwitch()`; `detectRunningAccountConsumers()`, `getAccountLaunchEnv()`, `refreshInactiveAccounts()`, and `getCodexCredentialStoreMode()` are new. Claude logins run `claude auth login`, the Keychain suffix follows Claude Code 2.1.x, and the unused shell-hook helpers were removed.
14
15
  - **0.26.5: event-scoped collection** — `ObservedSessionCollector.subscribe()` reconciles each watch event with one `statWatchedSessionFile()` stat and runs a full `discover()` only for unknown paths, catch-up polls, and the initial pass, gated by `minReconcileGapMs`; `discover({ limit, since })` / `collect({ limit, since })` bound a pass, `maxCacheEntries` / `maxCacheBytes` bound the parse cache, `DirectoryListingCache` makes a full pass one stat per unchanged directory, and the info-severity `provider-discovery-completed` diagnostic reports what each pass cost.
@@ -294,7 +295,7 @@ const humanPrompts = transcript.messages
294
295
  .map((message) => humanPromptText(message, 'claude-code')); // `/review foo` for slash commands
295
296
  ```
296
297
 
297
- A Claude Code message is a human prompt when it has the user role, an entrypoint in `HUMAN_CLAUDE_ENTRYPOINTS` (`cli`, `claude-desktop`, `sdk-cli` — Agent SDK programs report `sdk-ts` / `sdk-py` and are excluded), is not meta, sidechain, or a compaction summary, has no non-human `origin.kind` (task notifications, coordinator messages, auto-continuations) or `promptSource: 'system'`, and is not tool-result-only, local-command output or caveat, a system reminder, or an interrupt marker. A Codex message is one when it has the user role and some text is left after injected `AGENTS.md`, `<environment_context>`, `<user_instructions>`, turn-aborted, skill, subagent-notification, and internal-context blocks and `<image>` wrappers are dropped. Codex session source is a session-level rule: `collectPromptHistory()` reads only sessions whose `session_meta.source` is in `HUMAN_CODEX_SOURCES` (`cli`, `vscode`, `exec`) and skips subagent and forked sessions.
298
+ A Claude Code message is a human prompt when it has the user role, an entrypoint in `HUMAN_CLAUDE_ENTRYPOINTS` (`cli`, `claude-desktop`, `sdk-cli` — Agent SDK programs report `sdk-ts` / `sdk-py` and are excluded), is not meta, sidechain, or a compaction summary, has no non-human `origin.kind` (task notifications, coordinator messages, auto-continuations) or `promptSource: 'system'`, and is not tool-result-only, local-command output or caveat, bash-mode input or output, a hook or task notification, a system reminder, a continued-session summary, or an interrupt marker. A Codex message is one when it has the user role and some text is left after injected `AGENTS.md`, `<environment_context>`, `<user_instructions>`, permissions and collaboration-mode, recommended-plugin, user-shell-command, turn-aborted, skill, subagent-notification, and internal-context blocks and `<image>` wrappers are dropped. Codex session source is a session-level rule: `collectPromptHistory()` reads only sessions whose `session_meta.source` is in `HUMAN_CODEX_SOURCES` (`cli`, `vscode`, `exec`) and skips subagent and forked sessions.
298
299
 
299
300
  ### Collect human prompt history for workspace roots
300
301
 
@@ -317,17 +318,19 @@ const result = await collectPromptHistory({
317
318
  });
318
319
 
319
320
  for (const entry of result.entries) {
320
- // { provider, sessionId, ordinal, timestamp, text, cwd, gitBranch?, model?, usage? }
321
+ // { provider, sessionId, ordinal, timestamp, text, cwd, gitBranch?, model?, usage?, metadataStatus }
322
+ // Upsert by (provider, sessionId, ordinal): a prompt comes back when its answer metadata changes.
321
323
  }
322
- saveCursor(result.cursor); // unchanged files are skipped next time; appended files resume
324
+ saveCursor(result.cursor); // sensitive local state; unchanged files are skipped next time
323
325
  if (result.boundsHit.length > 0) console.warn('partial pass', result.boundsHit, result.stats);
324
326
  ```
325
327
 
326
- - **Scope fails closed.** A prompt is returned only when its recorded working directory (the Claude line's `cwd`; the Codex `session_meta` / `turn_context` cwd) realpath-resolves to a root or somewhere inside one. Missing or unresolvable cwds are excluded and counted in `stats.promptsOutOfScope`; Codex scope never comes from the rollout's location. `cwd` is the only path in the output, and cursor keys hold no paths.
328
+ - **Scope fails closed.** A prompt is returned only when its recorded working directory (the Claude line's `cwd`; the Codex `session_meta` / `turn_context` cwd) realpath-resolves to a root or somewhere inside one. Missing or unresolvable cwds are excluded and counted in `stats.promptsOutOfScope`; Codex scope never comes from the rollout's location. `cwd` is the only path in entries. Cursor keys hold no paths, but cursor values can (Codex resume state keeps the session's cwd and branch), so store the cursor as sensitive, local-only state.
327
329
  - **Parse-only.** No redaction and no network I/O; text is untruncated.
328
330
  - **Ordinals are stable.** Every human prompt in a file consumes one ordinal, even when it is out of scope, older than `since`, or lacks a timestamp (such lines are skipped and counted, never given a synthesized time). Entries are ordered by provider, session, then ordinal.
329
- - **Model and usage** come from the first model call that answered the prompt (Claude: the first assistant message; Codex: the first `token_count` with the model from `turn_context`). `inputTokens` is uncached input. The last prompt of a still-running session can be returned before its answer exists and is not revisited.
330
- - **Bounds never throw.** Hitting `maxSessions`, `maxTotalBytes`, the deadline, or an abort stops the pass; a file over `maxFileBytes` is skipped and counted in `stats.filesOverSizeLimit`. An interrupted file contributes no entries, and the cursor covers only fully processed files, so the next call picks up where this one stopped.
331
+ - **Model and usage** come from the first model call that answered the prompt (Claude: the first non-synthetic assistant message, with split records of that call replacing rather than adding usage; Codex: the first `token_count` with the model from `turn_context`). Later tool-loop calls never replace it. `inputTokens` is uncached input, and missing usage stays `undefined`, never zero.
332
+ - **Late answers enrich the same prompt.** A prompt can be collected before its answer is written. `metadataStatus` is `pending` (no answering call yet), `provisional` (Claude call still being written, `stop_reason: null`), or `final`. While a file's last prompt is not final, the cursor keeps the byte offset of its line and a digest of what was returned (never prompt or answer text); the next call replays from that line and returns the prompt again, with the same provider, sessionId, ordinal, timestamp, and text, only when its model, usage, or status changed. A prompt closed by the next prompt without an answer comes back once as `final` with no usage. Consumers should upsert by identity. Cursors from 0.27.0 (`version: 1`) are accepted but cannot recover prompts they already returned without metadata.
333
+ - **Bounds never throw.** Hitting `maxSessions`, `maxTotalBytes`, the deadline, or an abort stops the pass. An interrupted file contributes no entries, and the cursor covers only fully processed files, so the next call picks up where this one stopped. A file over `maxFileBytes` is different: it is skipped and counted in `stats.filesOverSizeLimit`, and calling again with the same bounds skips it again, so raise the bound to make progress on it. When only the replay of a pending prompt would exceed `maxFileBytes`, the replay is dropped and the file continues from its committed offset; that prompt is not revisited.
331
334
 
332
335
  For an already-open incremental Codex rollout, keep one `CodexRolloutParser` per stream and pass each parsed `SessionEvent[]` batch to `projectSessionTranscript()` (or append the events to an existing projection). This preserves parser state for paired tool calls, model context, and cumulative token deltas; consumers should not convert raw `response_item` rows themselves.
333
336
 
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@ export { projectSessionTranscript } from './transcript';
11
11
  export type { TranscriptFidelity, TranscriptSourceProvenance, CanonicalTranscriptBlock, CanonicalTranscriptMessage, CanonicalToolCall, CanonicalTranscriptUsageTotals, CanonicalSessionTranscript, ProjectSessionTranscriptOptions, } from './transcript';
12
12
  export { listRecentSessions, readSessionTranscript } from './sessionTranscripts';
13
13
  export { collectPromptHistory } from './promptHistory';
14
- export type { CollectPromptHistoryOptions, PromptHistoryBound, PromptHistoryBounds, PromptHistoryCursor, PromptHistoryCursorSession, PromptHistoryEntry, PromptHistoryProvider, PromptHistoryResult, PromptHistoryResumeState, PromptHistoryStats, PromptHistoryUsage, } from './promptHistory';
14
+ export type { CollectPromptHistoryOptions, PromptHistoryBound, PromptHistoryBounds, PromptHistoryCursor, PromptHistoryCursorSession, PromptHistoryEntry, PromptHistoryMetadataStatus, PromptHistoryPendingPrompt, PromptHistoryProvider, PromptHistoryResult, PromptHistoryResumeState, PromptHistoryStats, PromptHistoryUsage, } from './promptHistory';
15
15
  export { HUMAN_CLAUDE_ENTRYPOINTS, HUMAN_CODEX_SOURCES, humanPromptText, isHumanPrompt, } from './humanPrompt';
16
16
  export type { HumanPromptProvider } from './humanPrompt';
17
17
  export { computeSessionFileStats, firstUserPrompt, providerContextSizeFn, readSessionFileStats, } from './sessionStats';
package/dist/node.d.ts CHANGED
@@ -11,7 +11,7 @@ export { loadObservedContextWindows, recordObservedContextWindow, getObservedCon
11
11
  export type { ObservedContextWindowOptions, ObservedContextWindowStore, } from './observedContextWindows';
12
12
  export { listRecentSessions, readSessionTranscript } from './sessionTranscripts';
13
13
  export { collectPromptHistory } from './promptHistory';
14
- export type { CollectPromptHistoryOptions, PromptHistoryBound, PromptHistoryBounds, PromptHistoryCursor, PromptHistoryCursorSession, PromptHistoryEntry, PromptHistoryProvider, PromptHistoryResult, PromptHistoryResumeState, PromptHistoryStats, PromptHistoryUsage, } from './promptHistory';
14
+ export type { CollectPromptHistoryOptions, PromptHistoryBound, PromptHistoryBounds, PromptHistoryCursor, PromptHistoryCursorSession, PromptHistoryEntry, PromptHistoryMetadataStatus, PromptHistoryPendingPrompt, PromptHistoryProvider, PromptHistoryResult, PromptHistoryResumeState, PromptHistoryStats, PromptHistoryUsage, } from './promptHistory';
15
15
  export { HUMAN_CLAUDE_ENTRYPOINTS, HUMAN_CODEX_SOURCES, humanPromptText, isHumanPrompt, } from './humanPrompt';
16
16
  export type { HumanPromptProvider } from './humanPrompt';
17
17
  export { computeSessionFileStats, firstUserPrompt, providerContextSizeFn, readSessionFileStats, } from './sessionStats';
@@ -7,6 +7,11 @@
7
7
  * for Codex, its session/turn context) realpath-resolves to an allowed root
8
8
  * or somewhere inside one. Every bound stops cleanly and is reported in
9
9
  * `boundsHit`; nothing is silently truncated and no bound throws.
10
+ *
11
+ * A prompt can be written before its answer. The cursor then keeps the byte
12
+ * offset of that prompt's line (not its text), the next call replays from it,
13
+ * and the prompt is returned again under the same identity once its first
14
+ * answering call brings model or usage (`metadataStatus` says how settled).
10
15
  */
11
16
  import { type HumanPromptProvider } from './humanPrompt';
12
17
  export type PromptHistoryProvider = HumanPromptProvider;
@@ -14,7 +19,10 @@ export type PromptHistoryBound = 'maxSessions' | 'maxFileBytes' | 'maxTotalBytes
14
19
  export interface PromptHistoryBounds {
15
20
  /** Session files read in one call (unchanged files do not count). Default 1000. */
16
21
  maxSessions?: number;
17
- /** Bytes a single file may need read in this pass. Default 16 MiB. */
22
+ /**
23
+ * Bytes a single file may need read in this pass. Default 16 MiB. A file over
24
+ * it is skipped, and calling again with the same bounds skips it again.
25
+ */
18
26
  maxFileBytes?: number;
19
27
  /** Bytes read across all files in this call. Default 256 MiB. */
20
28
  maxTotalBytes?: number;
@@ -31,6 +39,19 @@ export interface PromptHistoryResumeState {
31
39
  /** `session_meta` has been seen. */
32
40
  meta?: boolean;
33
41
  }
42
+ /**
43
+ * The last emitted prompt of a file whose answering call is not final yet.
44
+ * Holds a replay position and a metadata digest, never prompt or answer text.
45
+ */
46
+ export interface PromptHistoryPendingPrompt {
47
+ ordinal: number;
48
+ /** Byte offset where the prompt's log line starts; the next pass replays from here. */
49
+ offset: number;
50
+ /** Digest of the model, usage, and status last returned for this prompt. */
51
+ emitted: string;
52
+ /** Codex parse state as it was just before the prompt line. */
53
+ state?: PromptHistoryResumeState;
54
+ }
34
55
  export interface PromptHistoryCursorSession {
35
56
  provider: PromptHistoryProvider;
36
57
  sessionId: string;
@@ -41,12 +62,26 @@ export interface PromptHistoryCursorSession {
41
62
  /** Byte offset just past the last complete line processed. */
42
63
  offset: number;
43
64
  state?: PromptHistoryResumeState;
65
+ pending?: PromptHistoryPendingPrompt;
44
66
  }
45
- /** JSON-serializable resume point. Keys are opaque; they never contain paths. */
67
+ /**
68
+ * JSON-serializable resume point. Keys are opaque and never contain paths, but
69
+ * values can (Codex `state.cwd`), so treat the cursor as sensitive local state.
70
+ * Version 1 cursors from 0.27.0 are accepted as input; output is always version 2.
71
+ */
46
72
  export interface PromptHistoryCursor {
47
- version: 1;
73
+ version: 2;
48
74
  sessions: Record<string, PromptHistoryCursorSession>;
49
75
  }
76
+ /**
77
+ * How settled an entry's `model` / `usage` are:
78
+ * - `pending`: no answering call seen yet.
79
+ * - `provisional`: the first answering call is still being written (Claude
80
+ * `stop_reason: null`); values may change.
81
+ * - `final`: the first answering call is complete, or the prompt was closed
82
+ * without one. Missing usage is then unknown, not zero.
83
+ */
84
+ export type PromptHistoryMetadataStatus = 'pending' | 'provisional' | 'final';
50
85
  export interface PromptHistoryUsage {
51
86
  /** Uncached input tokens. */
52
87
  inputTokens?: number;
@@ -70,6 +105,12 @@ export interface PromptHistoryEntry {
70
105
  model?: string;
71
106
  /** Usage of that first answering model call, when known. */
72
107
  usage?: PromptHistoryUsage;
108
+ /**
109
+ * Whether `model` / `usage` can still change. A prompt that is not `final`
110
+ * is returned again, with the same provider, sessionId, ordinal, timestamp,
111
+ * and text, by a later call once its metadata or status changes.
112
+ */
113
+ metadataStatus: PromptHistoryMetadataStatus;
73
114
  }
74
115
  export interface PromptHistoryStats {
75
116
  sessionsScanned: number;
@@ -8,6 +8,11 @@
8
8
  * for Codex, its session/turn context) realpath-resolves to an allowed root
9
9
  * or somewhere inside one. Every bound stops cleanly and is reported in
10
10
  * `boundsHit`; nothing is silently truncated and no bound throws.
11
+ *
12
+ * A prompt can be written before its answer. The cursor then keeps the byte
13
+ * offset of that prompt's line (not its text), the next call replays from it,
14
+ * and the prompt is returned again under the same identity once its first
15
+ * answering call brings model or usage (`metadataStatus` says how settled).
11
16
  */
12
17
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
18
  if (k2 === undefined) k2 = k;
@@ -73,8 +78,8 @@ async function collectPromptHistory(options) {
73
78
  promptsMissingTimestamp: 0,
74
79
  promptsOutOfScope: 0,
75
80
  };
76
- const previous = options.cursor?.version === 1 ? options.cursor.sessions : {};
77
- const cursor = { version: 1, sessions: { ...previous } };
81
+ const previous = acceptedCursorSessions(options.cursor);
82
+ const cursor = { version: 2, sessions: { ...previous } };
78
83
  const entries = [];
79
84
  const shouldStop = () => {
80
85
  if (options.signal?.aborted)
@@ -118,8 +123,9 @@ async function collectPromptHistory(options) {
118
123
  }
119
124
  // A file that shrank was rewritten; its old offsets and ordinals are meaningless.
120
125
  const resume = prior && prior.offset <= stat.size ? prior : undefined;
121
- const offset = resume?.offset ?? 0;
122
- const bytesToRead = stat.size - offset;
126
+ // Replay from the open prompt's line so late answer metadata can reach it.
127
+ let replay = validPending(resume);
128
+ let offset = replay?.offset ?? resume?.offset ?? 0;
123
129
  if (resume?.state?.excluded) {
124
130
  cursor.sessions[candidate.key] = {
125
131
  ...resume,
@@ -130,6 +136,12 @@ async function collectPromptHistory(options) {
130
136
  stats.sessionsNotInteractive++;
131
137
  continue;
132
138
  }
139
+ if (replay && resume && stat.size - offset > bounds.maxFileBytes) {
140
+ // Too far to replay: stop waiting on that prompt rather than stall the file.
141
+ replay = undefined;
142
+ offset = resume.offset;
143
+ }
144
+ const bytesToRead = stat.size - offset;
133
145
  if (bytesToRead > bounds.maxFileBytes) {
134
146
  stats.filesOverSizeLimit++;
135
147
  boundsHit.add('maxFileBytes');
@@ -152,7 +164,7 @@ async function collectPromptHistory(options) {
152
164
  }
153
165
  totalBytes += buffer.length;
154
166
  stats.sessionsScanned++;
155
- const outcome = await scanBuffer(candidate, buffer, offset, resume, context);
167
+ const outcome = await scanBuffer(candidate, buffer, offset, { resume, replay }, context);
156
168
  if (outcome.stoppedBy) {
157
169
  // Partially processed file: drop its entries and leave its cursor untouched.
158
170
  boundsHit.add(outcome.stoppedBy);
@@ -162,7 +174,13 @@ async function collectPromptHistory(options) {
162
174
  stats.sessionsNotInteractive++;
163
175
  else if (outcome.inScope === 0 && outcome.outOfScope > 0)
164
176
  stats.sessionsOutOfScope++;
165
- entries.push(...outcome.entries);
177
+ for (const entry of outcome.entries) {
178
+ // A replayed prompt is returned again only when its metadata changed.
179
+ if (replay && entry.ordinal === replay.ordinal && metadataDigest(entry) === replay.emitted) {
180
+ continue;
181
+ }
182
+ entries.push(entry);
183
+ }
166
184
  cursor.sessions[candidate.key] = {
167
185
  provider: candidate.provider,
168
186
  sessionId: candidate.sessionId,
@@ -171,6 +189,7 @@ async function collectPromptHistory(options) {
171
189
  lastOrdinal: outcome.lastOrdinal,
172
190
  offset: outcome.offset,
173
191
  ...(outcome.state ? { state: outcome.state } : {}),
192
+ ...(outcome.pending ? { pending: outcome.pending } : {}),
174
193
  };
175
194
  await yieldToEventLoop();
176
195
  }
@@ -187,6 +206,41 @@ function definedBounds(bounds) {
187
206
  }
188
207
  return result;
189
208
  }
209
+ function acceptedCursorSessions(cursor) {
210
+ if (!cursor || (cursor.version !== 1 && cursor.version !== 2))
211
+ return {};
212
+ const sessions = cursor.sessions;
213
+ return typeof sessions === 'object' && sessions !== null
214
+ ? sessions
215
+ : {};
216
+ }
217
+ function validPending(resume) {
218
+ const pending = resume?.pending;
219
+ if (!resume || !pending)
220
+ return undefined;
221
+ const valid = Number.isInteger(pending.ordinal) &&
222
+ pending.ordinal === resume.lastOrdinal &&
223
+ Number.isInteger(pending.offset) &&
224
+ pending.offset >= 0 &&
225
+ pending.offset <= resume.offset &&
226
+ typeof pending.emitted === 'string';
227
+ return valid ? pending : undefined;
228
+ }
229
+ /** Digest of what a consumer would store for an entry besides its identity and text. */
230
+ function metadataDigest(entry) {
231
+ const usage = entry.usage
232
+ ? [
233
+ entry.usage.inputTokens ?? null,
234
+ entry.usage.outputTokens ?? null,
235
+ entry.usage.cacheReadTokens ?? null,
236
+ entry.usage.cacheWriteTokens ?? null,
237
+ ]
238
+ : null;
239
+ return (0, crypto_1.createHash)('sha256')
240
+ .update(JSON.stringify([entry.model ?? null, usage, entry.metadataStatus]))
241
+ .digest('hex')
242
+ .slice(0, 16);
243
+ }
190
244
  function compareEntries(left, right) {
191
245
  return (compareStrings(left.provider, right.provider) ||
192
246
  compareStrings(left.sessionId, right.sessionId) ||
@@ -311,21 +365,24 @@ async function readRange(filePath, offset, length) {
311
365
  function yieldToEventLoop() {
312
366
  return new Promise((resolve) => setImmediate(resolve));
313
367
  }
314
- async function scanBuffer(candidate, buffer, startOffset, resume, context) {
368
+ async function scanBuffer(candidate, buffer, startOffset, start, context) {
315
369
  const lastNewline = buffer.lastIndexOf(0x0a);
316
- const complete = lastNewline >= 0 ? buffer.subarray(0, lastNewline + 1) : Buffer.alloc(0);
317
- const lines = complete.toString('utf8').split('\n');
370
+ const completeLength = lastNewline + 1;
318
371
  const scanner = candidate.provider === 'claude-code'
319
- ? new ClaudeLineScanner(candidate, resume, context)
320
- : new CodexLineScanner(candidate, resume, context);
321
- for (let index = 0; index < lines.length; index++) {
372
+ ? new ClaudeLineScanner(candidate, start, context)
373
+ : new CodexLineScanner(candidate, start, context);
374
+ let lineStart = 0;
375
+ for (let index = 0; lineStart < completeLength; index++) {
322
376
  if (index > 0 && index % CHECK_EVERY_LINES === 0) {
323
377
  await yieldToEventLoop();
324
378
  const stop = context.shouldStop();
325
379
  if (stop)
326
380
  return { ...scanner.outcome(startOffset), stoppedBy: stop };
327
381
  }
328
- const line = lines[index];
382
+ const lineEnd = buffer.indexOf(0x0a, lineStart);
383
+ const line = buffer.toString('utf8', lineStart, lineEnd);
384
+ const lineOffset = startOffset + lineStart;
385
+ lineStart = lineEnd + 1;
329
386
  if (!line.trim())
330
387
  continue;
331
388
  let record;
@@ -337,17 +394,18 @@ async function scanBuffer(candidate, buffer, startOffset, resume, context) {
337
394
  }
338
395
  if (typeof record !== 'object' || record === null)
339
396
  continue;
340
- if (scanner.accept(record) === 'excluded') {
397
+ if (scanner.accept(record, lineOffset) === 'excluded') {
341
398
  // Non-interactive Codex session: nothing in it will ever count.
342
399
  return {
343
400
  ...scanner.outcome(startOffset + buffer.length),
344
401
  entries: [],
402
+ pending: undefined,
345
403
  notInteractive: true,
346
404
  stoppedBy: null,
347
405
  };
348
406
  }
349
407
  }
350
- return { ...scanner.outcome(startOffset + complete.length), stoppedBy: null };
408
+ return { ...scanner.outcome(startOffset + completeLength), stoppedBy: null };
351
409
  }
352
410
  /** Shared per-file prompt bookkeeping for both providers. */
353
411
  class LineScanner {
@@ -357,31 +415,49 @@ class LineScanner {
357
415
  nextOrdinal;
358
416
  inScope = 0;
359
417
  outOfScope = 0;
360
- pending = null;
361
- constructor(candidate, resume, context) {
418
+ /** Latest emitted prompt still collecting answer metadata. */
419
+ open = null;
420
+ constructor(candidate, start, context) {
362
421
  this.candidate = candidate;
363
422
  this.context = context;
364
- this.nextOrdinal = (resume?.lastOrdinal ?? -1) + 1;
423
+ this.nextOrdinal = start.replay ? start.replay.ordinal : (start.resume?.lastOrdinal ?? -1) + 1;
365
424
  }
366
425
  state() {
367
426
  return undefined;
368
427
  }
369
428
  outcome(offset) {
370
429
  const state = this.state();
430
+ const open = this.open;
371
431
  return {
372
432
  entries: this.entries,
373
433
  lastOrdinal: this.nextOrdinal - 1,
374
434
  offset,
375
435
  ...(state ? { state } : {}),
436
+ ...(open
437
+ ? {
438
+ pending: {
439
+ ordinal: open.entry.ordinal,
440
+ offset: open.lineOffset,
441
+ emitted: metadataDigest(open.entry),
442
+ ...(open.stateBefore ? { state: open.stateBefore } : {}),
443
+ },
444
+ }
445
+ : {}),
376
446
  inScope: this.inScope,
377
447
  outOfScope: this.outOfScope,
378
448
  notInteractive: false,
379
449
  };
380
450
  }
451
+ /** The open prompt's answer is complete, or will never arrive. */
452
+ closeOpen() {
453
+ if (this.open)
454
+ this.open.entry.metadataStatus = 'final';
455
+ this.open = null;
456
+ }
381
457
  /** Assign an ordinal to a human prompt and emit it when in scope. */
382
- recordPrompt(text, timestamp, cwd, gitBranch) {
458
+ recordPrompt(text, timestamp, cwd, gitBranch, lineOffset, stateBefore) {
383
459
  const ordinal = this.nextOrdinal++;
384
- this.pending = null;
460
+ this.closeOpen();
385
461
  if (typeof timestamp !== 'string' || Number.isNaN(Date.parse(timestamp))) {
386
462
  this.context.stats.promptsMissingTimestamp++;
387
463
  return;
@@ -403,9 +479,10 @@ class LineScanner {
403
479
  text,
404
480
  cwd: scoped,
405
481
  ...(gitBranch ? { gitBranch } : {}),
482
+ metadataStatus: 'pending',
406
483
  };
407
484
  this.entries.push(entry);
408
- this.pending = entry;
485
+ this.open = { entry, lineOffset, ...(stateBefore ? { stateBefore } : {}) };
409
486
  }
410
487
  }
411
488
  function transcriptMessage(candidate, record, role, content, extra, eventIndex) {
@@ -446,14 +523,15 @@ function compactUsage(usage) {
446
523
  class ClaudeLineScanner extends LineScanner {
447
524
  lineIndex = 0;
448
525
  answerId = null;
449
- accept(record) {
526
+ accept(record, lineOffset) {
450
527
  const index = this.lineIndex++;
451
528
  const message = record.message;
452
529
  if (typeof message !== 'object' || message === null)
453
530
  return 'ok';
454
531
  if (record.type === 'user') {
455
532
  const origin = record.origin;
456
- const canonical = transcriptMessage(this.candidate, record, typeof message.role === 'string' ? message.role : 'user', claudeBlocks(message.content), {
533
+ const blocks = claudeBlocks(message.content);
534
+ const canonical = transcriptMessage(this.candidate, record, typeof message.role === 'string' ? message.role : 'user', blocks, {
457
535
  entrypoint: stringField(record.entrypoint),
458
536
  isMeta: record.isMeta === true ? true : undefined,
459
537
  isSidechain: record.isSidechain === true ? true : undefined,
@@ -462,35 +540,54 @@ class ClaudeLineScanner extends LineScanner {
462
540
  promptSource: stringField(record.promptSource),
463
541
  }, index);
464
542
  const text = (0, humanPrompt_1.humanPromptText)(canonical, 'claude-code');
465
- if (text === null)
543
+ if (text === null) {
544
+ // A tool result means the first answering call already finished.
545
+ if (this.answerId !== null &&
546
+ record.isSidechain !== true &&
547
+ blocks.some((block) => block.type === 'tool_result')) {
548
+ this.closeOpen();
549
+ }
466
550
  return 'ok';
551
+ }
467
552
  this.answerId = null;
468
- this.recordPrompt(text, record.timestamp, record.cwd, stringField(record.gitBranch));
553
+ this.recordPrompt(text, record.timestamp, record.cwd, stringField(record.gitBranch), lineOffset);
469
554
  return 'ok';
470
555
  }
471
- if (record.type === 'assistant' && this.pending && record.isSidechain !== true) {
472
- const id = stringField(message.id) ?? `line:${index}`;
473
- if (this.answerId === null)
474
- this.answerId = id;
475
- if (id !== this.answerId) {
476
- this.pending = null;
477
- return 'ok';
478
- }
479
- const model = stringField(message.model);
480
- if (model && model !== '<synthetic>')
481
- this.pending.model = model;
482
- const usage = message.usage;
483
- if (usage && typeof usage === 'object') {
484
- const mapped = compactUsage({
485
- inputTokens: tokenCount(usage.input_tokens),
486
- outputTokens: tokenCount(usage.output_tokens),
487
- cacheReadTokens: tokenCount(usage.cache_read_input_tokens),
488
- cacheWriteTokens: tokenCount(usage.cache_creation_input_tokens),
489
- });
490
- if (mapped)
491
- this.pending.usage = mapped;
492
- }
556
+ const open = this.open;
557
+ if (record.type !== 'assistant' || !open || record.isSidechain === true)
558
+ return 'ok';
559
+ // Synthetic records (API errors, interrupts) are not model calls.
560
+ if (message.model === '<synthetic>')
561
+ return 'ok';
562
+ const id = stringField(message.id) ?? `line:${index}`;
563
+ if (this.answerId === null)
564
+ this.answerId = id;
565
+ if (id !== this.answerId) {
566
+ // A later tool-loop call: the first answering call is over and keeps its attribution.
567
+ this.closeOpen();
568
+ return 'ok';
569
+ }
570
+ const entry = open.entry;
571
+ const model = stringField(message.model);
572
+ if (model)
573
+ entry.model = model;
574
+ const usage = message.usage;
575
+ if (usage && typeof usage === 'object') {
576
+ // Split records of one call repeat its usage; the latest replaces, never adds.
577
+ const mapped = compactUsage({
578
+ inputTokens: tokenCount(usage.input_tokens),
579
+ outputTokens: tokenCount(usage.output_tokens),
580
+ cacheReadTokens: tokenCount(usage.cache_read_input_tokens),
581
+ cacheWriteTokens: tokenCount(usage.cache_creation_input_tokens),
582
+ });
583
+ if (mapped)
584
+ entry.usage = mapped;
493
585
  }
586
+ // Claude Code writes the call's final stop_reason and usage on every split record.
587
+ if (stringField(message.stop_reason))
588
+ this.closeOpen();
589
+ else
590
+ entry.metadataStatus = 'provisional';
494
591
  return 'ok';
495
592
  }
496
593
  }
@@ -522,14 +619,16 @@ function claudeBlocks(content) {
522
619
  class CodexLineScanner extends LineScanner {
523
620
  lineIndex = 0;
524
621
  resumeState;
525
- constructor(candidate, resume, context) {
526
- super(candidate, resume, context);
527
- this.resumeState = { ...(resume?.state ?? {}) };
622
+ constructor(candidate, start, context) {
623
+ super(candidate, start, context);
624
+ // A replay restarts at the open prompt's line, so it needs the state from just before it.
625
+ const initial = start.replay ? start.replay.state : start.resume?.state;
626
+ this.resumeState = { ...(initial ?? {}) };
528
627
  }
529
628
  state() {
530
629
  return { ...this.resumeState };
531
630
  }
532
- accept(record) {
631
+ accept(record, lineOffset) {
533
632
  const index = this.lineIndex++;
534
633
  const payload = record.payload;
535
634
  if (typeof payload !== 'object' || payload === null)
@@ -569,10 +668,14 @@ class CodexLineScanner extends LineScanner {
569
668
  const text = (0, humanPrompt_1.humanPromptText)(canonical, 'codex');
570
669
  if (text === null)
571
670
  return 'ok';
572
- this.recordPrompt(text, record.timestamp, state.cwd, state.gitBranch);
671
+ this.recordPrompt(text, record.timestamp, state.cwd, state.gitBranch, lineOffset, {
672
+ ...state,
673
+ });
573
674
  return 'ok';
574
675
  }
575
- if (record.type === 'event_msg' && payload.type === 'token_count' && this.pending) {
676
+ const open = this.open;
677
+ if (record.type === 'event_msg' && payload.type === 'token_count' && open) {
678
+ // The first token_count after the prompt is the first answering call, complete.
576
679
  const info = payload.info;
577
680
  const last = info && typeof info === 'object' ? info.last_token_usage : undefined;
578
681
  if (!last || typeof last !== 'object')
@@ -586,10 +689,10 @@ class CodexLineScanner extends LineScanner {
586
689
  cacheWriteTokens: tokenCount(last.cache_write_input_tokens),
587
690
  });
588
691
  if (mapped)
589
- this.pending.usage = mapped;
692
+ open.entry.usage = mapped;
590
693
  if (state.model)
591
- this.pending.model = state.model;
592
- this.pending = null;
694
+ open.entry.model = state.model;
695
+ this.closeOpen();
593
696
  }
594
697
  return 'ok';
595
698
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidekick-shared",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "Shared data access layer for Sidekick — readers, types, providers, credentials, quota",
5
5
  "keywords": [
6
6
  "claude-code",