@manyoars/oars 0.4.1 → 0.5.2

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 (188) hide show
  1. package/README.md +79 -29
  2. package/dist/bin.js +3 -1
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +207 -21
  5. package/dist/club.d.ts +19 -0
  6. package/dist/club.js +132 -0
  7. package/dist/gate/hook-fill-main.js +1 -1
  8. package/dist/gate/hook-fill.d.ts +1 -1
  9. package/dist/gate/hook-fill.js +1 -1
  10. package/dist/gate/hook-main.js +7 -5
  11. package/dist/gate/hook.d.ts +12 -1
  12. package/dist/gate/hook.js +38 -7
  13. package/dist/hook-bin.js +23 -4
  14. package/dist/main.js +5 -0
  15. package/dist/node-floor.d.ts +10 -0
  16. package/dist/node-floor.js +23 -0
  17. package/dist/oars-hook.d.ts +2 -0
  18. package/dist/oars-hook.js +7 -0
  19. package/dist/sessions/allow.d.ts +1 -0
  20. package/dist/sessions/allow.js +1 -1
  21. package/dist/sessions/call-shape.d.ts +111 -0
  22. package/dist/sessions/call-shape.js +464 -0
  23. package/dist/sessions/candidates.d.ts +65 -0
  24. package/dist/sessions/candidates.js +236 -0
  25. package/dist/sessions/card-command.d.ts +24 -0
  26. package/dist/sessions/card-command.js +71 -0
  27. package/dist/sessions/card-font.d.ts +6 -0
  28. package/dist/sessions/card-font.js +36 -0
  29. package/dist/sessions/card-line.d.ts +30 -0
  30. package/dist/sessions/card-line.js +105 -0
  31. package/dist/sessions/card-render.d.ts +15 -0
  32. package/dist/sessions/card-render.js +146 -0
  33. package/dist/sessions/card.d.ts +59 -0
  34. package/dist/sessions/card.js +107 -0
  35. package/dist/sessions/cell-increments.d.ts +14 -3
  36. package/dist/sessions/cell-increments.js +79 -12
  37. package/dist/sessions/cells.d.ts +42 -4
  38. package/dist/sessions/cells.js +72 -18
  39. package/dist/sessions/codex-hook-bin.d.ts +1 -0
  40. package/dist/sessions/codex-hook-bin.js +24 -0
  41. package/dist/sessions/codex-hook-install.d.ts +21 -0
  42. package/dist/sessions/codex-hook-install.js +47 -0
  43. package/dist/sessions/codex-hook-view.d.ts +20 -0
  44. package/dist/sessions/codex-hook-view.js +20 -0
  45. package/dist/sessions/codex-hook.d.ts +77 -0
  46. package/dist/sessions/codex-hook.js +255 -0
  47. package/dist/sessions/codex.d.ts +9 -0
  48. package/dist/sessions/codex.js +104 -18
  49. package/dist/sessions/command.d.ts +17 -2
  50. package/dist/sessions/command.js +145 -33
  51. package/dist/sessions/copilot.d.ts +3 -0
  52. package/dist/sessions/copilot.js +12 -4
  53. package/dist/sessions/derive.js +2 -1
  54. package/dist/sessions/empty-state.d.ts +17 -0
  55. package/dist/sessions/empty-state.js +38 -0
  56. package/dist/sessions/exclusion.js +48 -3
  57. package/dist/sessions/extract.d.ts +16 -3
  58. package/dist/sessions/extract.js +281 -24
  59. package/dist/sessions/gemini.d.ts +2 -0
  60. package/dist/sessions/gemini.js +29 -2
  61. package/dist/sessions/harness-files.d.ts +27 -0
  62. package/dist/sessions/harness-files.js +109 -0
  63. package/dist/sessions/head-only.d.ts +7 -0
  64. package/dist/sessions/head-only.js +21 -0
  65. package/dist/sessions/hook-calls.js +4 -3
  66. package/dist/sessions/hooks.d.ts +1 -1
  67. package/dist/sessions/hooks.js +6 -2
  68. package/dist/sessions/identity.d.ts +6 -0
  69. package/dist/sessions/injected.d.ts +13 -0
  70. package/dist/sessions/injected.js +260 -0
  71. package/dist/sessions/install.d.ts +31 -20
  72. package/dist/sessions/install.js +80 -82
  73. package/dist/sessions/load-status.d.ts +41 -0
  74. package/dist/sessions/load-status.js +156 -0
  75. package/dist/sessions/machine-child-deps.d.ts +5 -0
  76. package/dist/sessions/machine-child-deps.js +83 -0
  77. package/dist/sessions/machine-child.d.ts +43 -0
  78. package/dist/sessions/machine-child.js +120 -0
  79. package/dist/sessions/machine-credential.d.ts +10 -0
  80. package/dist/sessions/machine-credential.js +20 -0
  81. package/dist/sessions/machine-enroll.d.ts +47 -0
  82. package/dist/sessions/machine-enroll.js +230 -0
  83. package/dist/sessions/machine-hold.d.ts +49 -0
  84. package/dist/sessions/machine-hold.js +88 -0
  85. package/dist/sessions/machine-ids.d.ts +6 -0
  86. package/dist/sessions/machine-ids.js +9 -0
  87. package/dist/sessions/machine-notice.d.ts +21 -0
  88. package/dist/sessions/machine-notice.js +89 -0
  89. package/dist/sessions/machine-pull.d.ts +40 -0
  90. package/dist/sessions/machine-pull.js +220 -0
  91. package/dist/sessions/machine-repo.d.ts +7 -0
  92. package/dist/sessions/machine-repo.js +30 -0
  93. package/dist/sessions/machine-scan-cache.d.ts +12 -0
  94. package/dist/sessions/machine-scan-cache.js +47 -0
  95. package/dist/sessions/machine-send.d.ts +12 -0
  96. package/dist/sessions/machine-send.js +156 -0
  97. package/dist/sessions/machine-stop.d.ts +22 -0
  98. package/dist/sessions/machine-stop.js +53 -0
  99. package/dist/sessions/machine.d.ts +41 -0
  100. package/dist/sessions/machine.js +89 -0
  101. package/dist/sessions/opencode.d.ts +2 -0
  102. package/dist/sessions/opencode.js +6 -0
  103. package/dist/sessions/otel.d.ts +21 -0
  104. package/dist/sessions/otel.js +109 -38
  105. package/dist/sessions/owner-only.d.ts +7 -0
  106. package/dist/sessions/owner-only.js +34 -0
  107. package/dist/sessions/pi.d.ts +13 -1
  108. package/dist/sessions/pi.js +27 -13
  109. package/dist/sessions/pool-books.d.ts +1 -1
  110. package/dist/sessions/pool-books.js +1 -1
  111. package/dist/sessions/pool-choices.d.ts +4 -2
  112. package/dist/sessions/pool-choices.js +4 -2
  113. package/dist/sessions/pool-command.d.ts +46 -6
  114. package/dist/sessions/pool-command.js +613 -136
  115. package/dist/sessions/pool-delta.d.ts +7 -0
  116. package/dist/sessions/pool-delta.js +28 -5
  117. package/dist/sessions/pool-due.d.ts +43 -0
  118. package/dist/sessions/pool-due.js +113 -0
  119. package/dist/sessions/pool-nightly.d.ts +9 -1
  120. package/dist/sessions/pool-nightly.js +65 -21
  121. package/dist/sessions/pool-outbox.d.ts +51 -5
  122. package/dist/sessions/pool-outbox.js +217 -24
  123. package/dist/sessions/pool-print.d.ts +1 -1
  124. package/dist/sessions/pool-raw.js +4 -3
  125. package/dist/sessions/pool-replica.d.ts +4 -4
  126. package/dist/sessions/pool-replica.js +26 -7
  127. package/dist/sessions/pool-send-lock.js +1 -1
  128. package/dist/sessions/pool-sent.d.ts +2 -0
  129. package/dist/sessions/pool-sent.js +21 -4
  130. package/dist/sessions/pool-wire.d.ts +176 -15
  131. package/dist/sessions/pool-wire.js +297 -33
  132. package/dist/sessions/pool.js +4 -2
  133. package/dist/sessions/public-vocabulary.d.ts +5 -0
  134. package/dist/sessions/public-vocabulary.js +16 -0
  135. package/dist/sessions/read-call-shape.js +12 -6
  136. package/dist/sessions/read-fit.d.ts +5 -0
  137. package/dist/sessions/read-fit.js +23 -2
  138. package/dist/sessions/read-interaction.js +1 -1
  139. package/dist/sessions/read-lead-lag.js +1 -1
  140. package/dist/sessions/read-readable.js +1 -1
  141. package/dist/sessions/read.d.ts +1 -1
  142. package/dist/sessions/recall-consequence.d.ts +2 -2
  143. package/dist/sessions/recall-consequence.js +14 -9
  144. package/dist/sessions/recall-object.d.ts +9 -2
  145. package/dist/sessions/receipt.d.ts +8 -0
  146. package/dist/sessions/receipt.js +78 -0
  147. package/dist/sessions/recovery-calls.d.ts +3 -2
  148. package/dist/sessions/recovery-calls.js +9 -3
  149. package/dist/sessions/redact.d.ts +11 -2
  150. package/dist/sessions/redact.js +201 -23
  151. package/dist/sessions/repo-root.d.ts +7 -0
  152. package/dist/sessions/repo-root.js +17 -13
  153. package/dist/sessions/reversion.d.ts +8 -0
  154. package/dist/sessions/reversion.js +114 -0
  155. package/dist/sessions/schema.d.ts +78 -4
  156. package/dist/sessions/schema.js +31 -1
  157. package/dist/sessions/session-shape.d.ts +2 -1
  158. package/dist/sessions/session-shape.js +15 -0
  159. package/dist/sessions/sources.d.ts +9 -0
  160. package/dist/sessions/sources.js +16 -5
  161. package/dist/sessions/steps.js +3 -2
  162. package/dist/sessions/store.d.ts +41 -2
  163. package/dist/sessions/store.js +68 -16
  164. package/dist/sessions/try-demo.d.ts +8 -0
  165. package/dist/sessions/try-demo.js +90 -0
  166. package/dist/sessions/try.d.ts +13 -0
  167. package/dist/sessions/try.js +50 -0
  168. package/dist/sessions/vscode-copilot.d.ts +4 -0
  169. package/dist/sessions/vscode-copilot.js +25 -18
  170. package/dist/telemetry/command.js +12 -6
  171. package/dist/telemetry/consent.d.ts +9 -3
  172. package/dist/telemetry/consent.js +25 -9
  173. package/dist/telemetry/fields.d.ts +11 -0
  174. package/dist/telemetry/fields.js +56 -0
  175. package/dist/telemetry/measures.d.ts +13 -0
  176. package/dist/telemetry/measures.js +30 -0
  177. package/dist/telemetry/observe.d.ts +15 -2
  178. package/dist/telemetry/observe.js +34 -7
  179. package/dist/telemetry/region.d.ts +6 -0
  180. package/dist/telemetry/region.js +28 -0
  181. package/dist/telemetry/report.js +1 -1
  182. package/dist/telemetry/schema.d.ts +3 -0
  183. package/dist/telemetry/schema.js +11 -1
  184. package/dist/telemetry/send.d.ts +5 -2
  185. package/dist/telemetry/send.js +15 -7
  186. package/dist/telemetry/words.d.ts +6 -0
  187. package/dist/telemetry/words.js +6 -0
  188. package/package.json +24 -5
@@ -30,6 +30,36 @@
30
30
  // reads, not moved ones. A Copilot CLI session with rotated `events.N.jsonl` archives is now read as one session, so its `prompts`, `tools`, `lines`, and skeleton move; one
31
31
  // with no archives is unchanged. EXTRACTOR stays 11. A version-16 store's next deposit captures every transcript again from the harness or `raw/`.
32
32
  //
33
+ // Version 32 (CAPTURE 32) is the machine repoKey salt: `repoKey` is derived from the machine's own salt, so every stored repoKey moves and an older store's next deposit captures every transcript again.
34
+ //
35
+ // Version 35 (CAPTURE 35) adds two optional fields to a tool call: `digests` (an Edit, MultiEdit, or Write call's old and new text, each as a digest) and `restores` (the files a `git restore` or `git checkout --` names, hashed as `target` is), and a
36
+ // `git restore` path also gives its file and folder names to `names`. It changes no value captured before; an older store's next deposit captures every transcript again, and a call of an older record has none.
37
+ //
38
+ // Version 33 (CAPTURE 33) adds the optional `names` to a tool call (`argNamesOf`, `call-shape.ts`): the name tokens its values hold, each with its kind, never a value. It changes no value captured before; an older store's next deposit captures every transcript again,
39
+ // and a call of an older record has none.
40
+ //
41
+ // Version 34 (CAPTURE 34) adds the optional `templates` to a shell call (`templatesOf`, `call-shape.ts`): one template per command, its program, subcommand, and flags with every literal argument as a typed slot, never an argument; and the optional `diffShape` to an
42
+ // edit (`diffShapeOf`): the shapes of the change from a closed list, never its text. It changes no value captured
43
+ // before; an older store's next deposit captures every transcript again, and a call of an older record has none.
44
+ //
45
+ // Version 31 (CAPTURE 31) writes a Codex `token_count` event's `input` and `output` only when the event reported them, as the cache categories already were, so a partial event leaves the other
46
+ // unobserved and not a measured zero (`usageObserved`). It changes a stored value of a Codex session; an older store's next deposit captures every transcript again.
47
+ //
48
+ // Version 30 (CAPTURE 30) adds the optional `local`, `dotClaude`, and `rules` counts to `loadedInstructions`: how many `CLAUDE.local.md`, `.claude/CLAUDE.md`, and `.claude/rules/` files the transcript shows
49
+ // loaded from the working folder. It changes no other value; an older store's next deposit captures every transcript again, and a record from before then reads those three as unknown.
50
+ //
51
+ // Version 29 (CAPTURE 29) adds the optional `loadedInstructions` to the session record: counts of the instruction files the transcript shows loaded, by level, never a path. It changes no value captured
52
+ // before; an older store's next deposit captures every transcript again, and a session recorded before then has none until it is.
53
+ //
54
+ // Version 28 (CAPTURE 28) adds the optional `messageUsage` to the session record: one entry per distinct model message (a Claude Code `message.id`, a Codex response), holding that message's token counts,
55
+ // the ids of the tool calls it made, and, for Codex, the plan percent used and window length when the event held a rate-limit block (`MessageUsage`). Numbers and ids only; the counts are never split across the
56
+ // message's calls. For a Codex event that did not report a cache category, that category is no longer observed, so `usageObserved` omits it and the skeleton and pool wire send null where they sent a zero bucket; no other value changes and EXTRACTOR stays as it is. A version-27 store's next deposit captures every transcript again, and a session recorded before then has no `messageUsage`
57
+ // until it is: nothing reads the field back into the older record. A file that reports no usage gets none either way.
58
+ //
59
+ // Version 27 (CAPTURE 27) adds three optional fields to a tool call (`call-shape.ts`), each computed from the input at capture and stored as closed-list members, never a value: `argFeatures` (per key, the value's size and,
60
+ // for a path, its depth, extension class, and folder kind, for a pattern what it uses), `argValues` (a boolean, or a string its tool's closed list holds), and `changeKind` (what changed from the previous call of the
61
+ // same tool in the prompt). It changes no value captured before, so EXTRACTOR stays as it is; a version-26 store's next deposit captures every transcript again, and a call of an older record has none of them.
62
+ //
33
63
  // Version 24 (CAPTURE 24, t903) changes three stored values and no field name: a step identity's skill-run name, host, and pull's package name are kept as they are after the operator's redaction (`redactDomains`, the secret and email patterns, the name shape, and the 128-character cap still apply; the operator's exclusion terms are applied at the deposit's gate, not at capture), where before each was `<literal>` unless a public list held it. A store opened at version 23 or older keeps its `<literal>` values until its next deposit captures every transcript again from the harness or `raw/`; the tool record's `steps` moves from class P to class A because those names can be private.
34
64
  //
35
65
  // Version 23 (CAPTURE 23) keeps a `git checkout` step's target class, `target` `path` or `word` (never the target's text), so the `revert` rule can tell `git checkout -- src/a.ts` and `git checkout --ours src/a.ts` (a
@@ -88,7 +118,7 @@
88
118
  // harness versions, and tool names (an MCP name's part after its prefix is redacted). `redact` masks secrets by pattern and entropy, emails, and the
89
119
  // organization domains the operator names. The skeleton holds hashes only.
90
120
  /** Stamped `cv` on every captured record. Bump it when the captured field list changes or a stored value of an existing captured record or skeleton changes: a bump re-reads every transcript. */
91
- export const CAPTURE_VERSION = '25';
121
+ export const CAPTURE_VERSION = '35';
92
122
  /** Capture versions the reader derives from. A version-3 record is upgraded on read; a newer one is read for the fields this code knows. */
93
123
  export const OLDEST_READABLE_CAPTURE = 3;
94
124
  /** Stamped `v` on every derived record. A bump re-derives from the log; no transcript is read. */
@@ -1,4 +1,4 @@
1
- import type { Harness } from './schema.js';
1
+ import type { Harness, LoadedInstructions } from './schema.js';
2
2
  type Obj = {
3
3
  [key: string]: unknown;
4
4
  };
@@ -52,6 +52,7 @@ export interface SessionTrust {
52
52
  unattendedRun?: number[];
53
53
  }
54
54
  export interface SessionShape {
55
+ loadedInstructions?: LoadedInstructions;
55
56
  setup?: SessionSetup;
56
57
  funnel?: SessionFunnel;
57
58
  trust?: SessionTrust;
@@ -5,6 +5,7 @@
5
5
  //
6
6
  // One collector per transcript, fed line by line by `captureSession`. It reads the Claude Code line shapes (a census of 2.1.312 to 2.1.318) and
7
7
  // one neutral line an adapter may write for a harness whose records are not Claude-shaped (`oars-tools`).
8
+ import { loadedInstructionsOf } from './load-status.js';
8
9
  const isObj = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
9
10
  const str = (value) => (typeof value === 'string' ? value : undefined);
10
11
  const names = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item !== '') : []);
@@ -90,6 +91,8 @@ export const createShape = (harness, stored, options) => {
90
91
  const stopCommands = new Set();
91
92
  let stopSeen = false;
92
93
  const memory = new Map();
94
+ let instructionFiles;
95
+ let cwd;
93
96
  const plugins = new Set();
94
97
  /** Every name offered or removed, in order: the last event on a name decides which side it ends on. */
95
98
  const standing = new Map();
@@ -201,6 +204,15 @@ export const createShape = (harness, stored, options) => {
201
204
  stopSeen = true;
202
205
  }
203
206
  }
207
+ else if (kind === 'instructions' && instructionFiles === undefined && Array.isArray(a.files)) {
208
+ // The first record only: a resumed session logs the set again, and the snapshot it joins with is that session's first.
209
+ instructionFiles = a.files.flatMap((file) => {
210
+ if (!isObj(file))
211
+ return [];
212
+ const path = str(file.path);
213
+ return path === undefined ? [] : [{ path, type: str(file.type) ?? '' }];
214
+ });
215
+ }
204
216
  else if (kind === 'nested_memory' && isObj(a.content) && typeof a.content.content === 'string') {
205
217
  // The same file comes again when the harness reloads it, so one path counts once.
206
218
  const key = str(a.path) ?? str(a.content.path) ?? `#${String(memory.size)}`;
@@ -219,6 +231,7 @@ export const createShape = (harness, stored, options) => {
219
231
  // A sidechain line is a subagent's own, not the main session's setup.
220
232
  if (at.side)
221
233
  return;
234
+ cwd ??= str(line.cwd);
222
235
  const type = str(line.type);
223
236
  if (type === 'permission-mode')
224
237
  mode(line.permissionMode);
@@ -322,6 +335,8 @@ export const createShape = (harness, stored, options) => {
322
335
  setup.instructionBytes = [...memory.values()].reduce((sum, bytes) => sum + bytes, 0);
323
336
  if (Object.keys(setup).length > 0)
324
337
  out.setup = setup;
338
+ if (instructionFiles !== undefined && cwd !== undefined)
339
+ out.loadedInstructions = loadedInstructionsOf(instructionFiles, cwd);
325
340
  const funnel = {};
326
341
  // Two names that redact to one form are one tool, as `tools` keys it.
327
342
  const sideOf = (which) => [...new Set([...standing].filter(([, side]) => side === which).map(([name]) => stored(name)))].sort();
@@ -11,13 +11,22 @@ export interface SourceSession {
11
11
  mtimeMs: number;
12
12
  /** Set when the session is a spawned agent's: it is deposited as a subagent of its parent, so session counts stay main sessions. */
13
13
  subagent?: SubagentOrigin;
14
+ /**
15
+ * Set when a later read folded this session into another (`<id>` of that one): the deposit records the supersede for a session it already holds, with no transcript,
16
+ * and records nothing for one it never held. `size` and `mtimeMs` are 0, a cursor no read session has.
17
+ */
18
+ supersededBy?: string;
14
19
  }
15
20
  export interface SourceStore {
16
21
  sessions: SourceSession[];
17
22
  /** The session as transcript text for `captureSession`; throws when it cannot be read. */
18
23
  transcript: (session: SourceSession) => string | Promise<string>;
24
+ /** True when the session's file holds only the harness's own head records and no turn, so `sessions verify` counts it as empty, not unreadable; absent when the format has no such form. */
25
+ headOnly?: (session: SourceSession) => boolean | Promise<boolean>;
19
26
  /** Sessions skipped because their id is not a safe file name, counted so the skip is never silent. */
20
27
  rejected: number;
28
+ /** Files the reader dropped before they were sessions that held records in a shape it does not know; only `sessions verify` reads it, as seen and unreadable. Absent when the format has no such drop. */
29
+ shapeMoved?: number;
21
30
  /** Items that could not be inspected, warned about once each and never fatal; `reason` names no path. */
22
31
  unreadable: readonly {
23
32
  file: string;
@@ -7,7 +7,7 @@ import { copilotRoot, openCopilotStore } from './copilot.js';
7
7
  import { cursorRoot, openCursorStore } from './cursor.js';
8
8
  import { grokRoot, openGrokStore } from './grok.js';
9
9
  import { geminiRoot, openGeminiStore } from './gemini.js';
10
- import { openPiStore, piRoot } from './pi.js';
10
+ import { openPiStore, piSessionsDir } from './pi.js';
11
11
  import { openOtelStore, otelRoot } from './otel.js';
12
12
  import { opencodeRoot, openOpencodeStore } from './opencode.js';
13
13
  import { openVscodeCopilotStore, vscodeAppData } from './vscode-copilot.js';
@@ -59,6 +59,7 @@ const codex = {
59
59
  ...(session.parent === null ? {} : { subagent: { parent: `codex-${session.parent}`, agentType: session.agentRole, spawnedBy: null } }),
60
60
  })),
61
61
  transcript: async (session) => await store.transcript(need(byId.get(session.id))),
62
+ headOnly: async (session) => await store.headOnly(need(byId.get(session.id))),
62
63
  rejected: store.rejected,
63
64
  unreadable: store.unreadable,
64
65
  where: (session) => byId.get(session.id)?.file ?? root,
@@ -80,6 +81,7 @@ const copilot = {
80
81
  return {
81
82
  sessions: store.sessions.map((session) => ({ id: session.id, folder: session.folder, size: session.size, mtimeMs: session.mtimeMs })),
82
83
  transcript: async (session) => await store.transcript(need(byId.get(session.id))),
84
+ headOnly: async (session) => await store.headOnly(need(byId.get(session.id))),
83
85
  rejected: store.rejected,
84
86
  unreadable: store.unreadable,
85
87
  where: (session) => byId.get(session.id)?.file ?? root,
@@ -108,6 +110,7 @@ const gemini = {
108
110
  })),
109
111
  transcript: async (session) => await store.transcript(need(byId.get(session.id))),
110
112
  rejected: store.rejected,
113
+ shapeMoved: store.shapeMoved,
111
114
  unreadable: store.unreadable,
112
115
  where: (session) => byId.get(session.id)?.file ?? root,
113
116
  close: () => undefined,
@@ -155,6 +158,7 @@ const opencode = {
155
158
  ...(session.parent === null ? {} : { subagent: { parent: `opencode-${session.parent}`, agentType: session.agent, spawnedBy: null } }),
156
159
  })),
157
160
  transcript: (session) => store.transcript(need(byId.get(session.id))),
161
+ headOnly: (session) => store.headOnly(need(byId.get(session.id))),
158
162
  rejected: store.rejected,
159
163
  unreadable: [],
160
164
  where: () => join(root, 'opencode.db'),
@@ -166,8 +170,8 @@ const pi = {
166
170
  name: 'pi',
167
171
  harness: 'pi',
168
172
  label: 'Pi',
169
- root: (_platform, env, home) => piRoot(env, home),
170
- storeFile: (root) => join(root, 'sessions'),
173
+ root: (_platform, env, home) => piSessionsDir(env, home),
174
+ storeFile: (root) => root,
171
175
  open: async (root) => {
172
176
  const store = await openPiStore(root);
173
177
  if (store === undefined)
@@ -176,6 +180,7 @@ const pi = {
176
180
  return {
177
181
  sessions: store.sessions.map((session) => ({ id: session.id, folder: session.folder, size: session.size, mtimeMs: session.mtimeMs })),
178
182
  transcript: async (session) => await store.transcript(need(byId.get(session.id))),
183
+ headOnly: async (session) => await store.headOnly(need(byId.get(session.id))),
179
184
  rejected: store.rejected,
180
185
  unreadable: store.unreadable,
181
186
  where: (session) => byId.get(session.id)?.file ?? root,
@@ -197,6 +202,7 @@ const vscodeCopilot = {
197
202
  return {
198
203
  sessions: store.sessions.map((session) => ({ id: session.id, folder: session.folder, size: session.size, mtimeMs: session.mtimeMs })),
199
204
  transcript: async (session) => await store.transcript(need(byId.get(session.id))),
205
+ headOnly: async (session) => await store.headOnly(need(byId.get(session.id))),
200
206
  rejected: store.rejected,
201
207
  unreadable: store.unreadable,
202
208
  where: (session) => byId.get(session.id)?.files[0] ?? root,
@@ -218,9 +224,14 @@ const otel = {
218
224
  return undefined;
219
225
  const byId = new Map(store.sessions.map((session) => [session.id, session]));
220
226
  return {
221
- sessions: store.sessions.map((session) => ({ id: session.id, folder: session.folder, size: session.size, mtimeMs: session.mtimeMs, ...(session.subagent === undefined ? {} : { subagent: session.subagent }) })),
222
- transcript: (session) => store.transcript(need(byId.get(session.id))),
227
+ sessions: [
228
+ ...store.sessions.map((session) => ({ id: session.id, folder: session.folder, size: session.size, mtimeMs: session.mtimeMs, ...(session.subagent === undefined ? {} : { subagent: session.subagent }) })),
229
+ ...store.superseded.map((entry) => ({ id: entry.id, folder: null, size: 0, mtimeMs: 0, supersededBy: entry.by })),
230
+ ],
231
+ transcript: (session) => (session.supersededBy === undefined ? store.transcript(need(byId.get(session.id))) : ''),
232
+ headOnly: (session) => session.supersededBy === undefined && store.headOnly(need(byId.get(session.id))),
223
233
  rejected: store.rejected,
234
+ shapeMoved: store.shapeMoved,
224
235
  unreadable: store.unreadable,
225
236
  where: (session) => byId.get(session.id)?.file ?? root,
226
237
  close: () => undefined,
@@ -1,16 +1,17 @@
1
1
  // The step log: `pipeline-runs.jsonl`, one row per run of a pipeline step, appended when the step ends (runtime-commons pipeline spec 3.1).
2
2
  // Local only: it sits in the store directory beside `hook-calls.jsonl`, and `measure` prints the spec 3.2 counters from it.
3
3
  import { randomUUID } from 'node:crypto';
4
- import { appendFile, mkdir, readFile } from 'node:fs/promises';
4
+ import { appendFile, readFile } from 'node:fs/promises';
5
5
  import { homedir } from 'node:os';
6
6
  import { performance } from 'node:perf_hooks';
7
7
  import { CliError, ExitCode } from '../errors.js';
8
+ import { mkdirOwnerOnly } from './owner-only.js';
8
9
  import { makeRedactor } from './redact.js';
9
10
  import { CAPTURE_VERSION, EXTRACTOR_VERSION, parseStepRow } from './schema.js';
10
11
  import { digest, newTrace, paths, readRedactDomains } from './store.js';
11
12
  export const newContext = (storeDir, now, trigger) => ({ storeDir, now, run: randomUUID(), trigger });
12
13
  export const appendStep = async (storeDir, row) => {
13
- await mkdir(storeDir, { recursive: true });
14
+ await mkdirOwnerOnly(storeDir);
14
15
  await appendFile(paths(storeDir).pipelineRuns, `${JSON.stringify(row)}\n`, { mode: 0o600 });
15
16
  };
16
17
  export const readSteps = async (storeDir) => {
@@ -3,6 +3,7 @@ import { type FileHandle } from 'node:fs/promises';
3
3
  import { CliError } from '../errors.js';
4
4
  import { slugOf } from './allow.js';
5
5
  import { type Source } from './sources.js';
6
+ import { type SubagentOrigin } from './extract.js';
6
7
  import { type RepoSize } from './public-vocabulary.js';
7
8
  import { type DerivedRecords, type ErrorObservation, type PromptObservation, type RecallCall, type SessionObservation, type SignatureRecord, type StepCounts, type Skeleton, type ToolObservation } from './schema.js';
8
9
  /** Where the store and the operator's Claude Code logs are; `bin.ts` fills it from the environment. */
@@ -21,12 +22,18 @@ export interface SessionsRuntime {
21
22
  sources?: readonly Source[];
22
23
  /** Where the account's salt comes from; absent means this machine's own (`localSalt`). The seam an account identity replaces (M4). */
23
24
  accountSalt?: (storeDir: string) => Promise<AccountSalt>;
25
+ /** The salt repository keys are hashed under; a machine takes it from its enrollment (`OARS_REPO_SALT`). Absent means the one derived from the account salt, as the trusted machine computes it. */
26
+ repoSalt?: string;
24
27
  /** The fresh single-turn reader behind `gold arms`; absent in a run that has none. */
25
28
  evaluator?: GoldEvaluator | undefined;
26
29
  stdin: NodeJS.ReadableStream;
27
30
  stdout: {
28
31
  write: (text: string) => unknown;
29
32
  };
33
+ /** Only its `isTTY` is read: the covenant screen is written to stderr, so a person is at a terminal only when stderr is one too. */
34
+ stderr?: {
35
+ isTTY?: boolean | undefined;
36
+ } | undefined;
30
37
  }
31
38
  interface FileState {
32
39
  size: number;
@@ -55,9 +62,15 @@ export interface State {
55
62
  /** What the person chose about sending to a pool: the raw-copy consent and the folders switched down. Never read by capture. */
56
63
  pool: PoolChoices;
57
64
  }
58
- /** The answer to the raw-copy prompt (`granted`, `declined`, or not asked yet) and the per-folder switches (an `--allow`-style entry mapped to what it may send). */
65
+ /**
66
+ * The answer to the raw-copy prompt (`granted`, `declined`, or not asked yet) and the per-folder switches (an `--allow`-style entry mapped to what it may send).
67
+ * A yes carries the covenant version in force when it was given, because the prompt names the sale and training use that covenant states. A yes with no version predates that wording and reads as not asked.
68
+ */
59
69
  export interface PoolChoices {
60
70
  rawConsent: 'granted' | 'declined' | null;
71
+ rawConsentVersion: string | null;
72
+ /** True from a typed no that the pool was not told, while this machine held a yes, until the pool hears the withdrawal or a new yes is typed: the pool may still hold the yes, so a rerun of the no must not read the local `declined` as nothing to tell. */
73
+ withdrawalPending: boolean;
61
74
  folders: {
62
75
  [entry: string]: FolderSend;
63
76
  };
@@ -70,6 +83,7 @@ export declare const paths: (storeDir: string) => {
70
83
  skeletons: string;
71
84
  calls: string;
72
85
  hookCalls: string;
86
+ hookRows: string;
73
87
  pipelineRuns: string;
74
88
  salt: string;
75
89
  raw: string;
@@ -142,6 +156,7 @@ export interface DepositResult {
142
156
  /** Allowed names that matched no project folder. */
143
157
  missing: string[];
144
158
  }
159
+ export declare const projectFolders: (projectsRoot: string) => Promise<string[]>;
145
160
  /** What a deposit never reads: project folders that hold transcripts but that no `--allow` entry names. */
146
161
  export interface SkippedScope {
147
162
  folders: number;
@@ -156,6 +171,18 @@ export interface SkippedScope {
156
171
  * only, never a transcript, so `verify` and `books` can say what the store does not hold.
157
172
  */
158
173
  export declare const skippedFolders: (runtime: SessionsRuntime) => Promise<SkippedScope>;
174
+ /** A transcript to deposit: a main session file, or a subagent's under `<session>/subagents/` (register A24). */
175
+ export interface Transcript {
176
+ file: string;
177
+ /** `<session>`, or `<parent>~agent-x` / `<parent>~wf_y~agent-x` for a subagent. */
178
+ session: string;
179
+ /** The path under the project folder, the key in `state.json`. */
180
+ relative: string;
181
+ subagent?: SubagentOrigin;
182
+ /** Read from the store's own archive because the harness no longer has the transcript; it is not archived again. */
183
+ fromRaw?: boolean;
184
+ }
185
+ export declare const mainTranscript: (folder: string, name: string) => Transcript;
159
186
  export declare const listDir: (dir: string) => Promise<import("fs").Dirent<string>[]>;
160
187
  interface SubagentFile {
161
188
  dir: string;
@@ -165,6 +192,7 @@ interface SubagentFile {
165
192
  name: string;
166
193
  }
167
194
  export declare const subagentFiles: (folder: string, sessions: readonly string[]) => Promise<SubagentFile[]>;
195
+ export declare const subagentTranscripts: (folder: string, sessions: readonly string[]) => Promise<Transcript[]>;
168
196
  /** The session folders under a project folder: each may hold subagent transcripts, even when its main transcript is gone. */
169
197
  export declare const sessionFolders: (folder: string) => Promise<string[]>;
170
198
  /** What a derive pass read and wrote, for the step log (spec 3.1 `in` and `out`, 4.2 `key`). */
@@ -234,6 +262,10 @@ export declare class StoreLockLostError extends CliError {
234
262
  */
235
263
  export declare const withLock: <T>(storeDir: string, work: (held: LockHold) => Promise<T>, options?: LockOptions) => Promise<T>;
236
264
  export declare const deposit: (runtime: SessionsRuntime, options: DepositOptions, trace?: DepositTrace) => Promise<DepositResult>;
265
+ /** The table in force: the runtime's own (a test's), else every source. */
266
+ export declare const sourcesOf: (runtime: SessionsRuntime) => readonly Source[];
267
+ /** The roots the runtime names, by source name; `cursorRoot` stays the alias of the Cursor entry. */
268
+ export declare const sourceRoots: (runtime: SessionsRuntime) => Map<string, string>;
237
269
  /**
238
270
  * The log's lines, a chunk at a time: the log can pass the longest string Node can hold (536,870,888 characters), so it is never one string.
239
271
  * It splits on the newline alone, not through `readline`, which also ends a line at U+2028 and U+2029: `JSON.stringify` leaves those raw inside a
@@ -250,6 +282,7 @@ export declare const frozenSessions: (runtime: SessionsRuntime, behind: readonly
250
282
  project: string;
251
283
  session: string;
252
284
  }[]) => Promise<Set<string>>;
285
+ export declare const HOOK_LOCK: LockOptions;
253
286
  export type SessionDeposit = 'deposited' | 'unchanged' | 'not-allowed' | 'missing' | 'deferred';
254
287
  /**
255
288
  * Deposits one transcript, as the SessionEnd hook does: only from a folder that earlier `--allow` entries name, and idempotent by size and
@@ -280,12 +313,18 @@ interface LogScan {
280
313
  newerCaptures: number;
281
314
  }
282
315
  export declare const scanLog: (file: string, held?: FileHandle) => Promise<LogScan>;
316
+ /** What a read keeps beyond the default. */
317
+ export type LoadOptions = {
318
+ /** Keep each session's `messageUsage` entries. Nothing in the store reads them yet, and on a large store they are a third more heap, so a read holds them only when asked. */
319
+ messageUsage?: boolean;
320
+ };
283
321
  /**
284
322
  * The store as a reader sees it: for each session only the highest `rev`. A version-3 or later session is derived here from its capture records
285
323
  * (EXTRACTOR_VERSION rules); a session whose last deposit was version 2 keeps the prompt and error lines that deposit wrote.
286
324
  * `afterFirstPass` runs between the two passes over the log: a seam for the test that compacts the log there.
325
+ * `options.messageUsage` keeps the per-message usage entries on each session record; without it they are dropped as the record is read.
287
326
  */
288
- export declare const loadStore: (storeDir: string, afterFirstPass?: () => Promise<void>) => Promise<Store>;
327
+ export declare const loadStore: (storeDir: string, afterFirstPass?: () => Promise<void>, options?: LoadOptions) => Promise<Store>;
289
328
  /**
290
329
  * Drops the superseded revs from the log, so a re-capture does not leave a full copy of every session behind. The caller holds the store lock,
291
330
  * the same one every append takes. Only a line a higher rev of its session replaces goes: a line this version cannot read stays, since a later
@@ -1,3 +1,4 @@
1
+ import { derivedSalt } from './machine-ids.js';
1
2
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
3
  import { createReadStream } from 'node:fs';
3
4
  import { performance } from 'node:perf_hooks';
@@ -15,6 +16,7 @@ import { callPositions, captureSession } from './extract.js';
15
16
  import { foldToolRows, withoutReplays } from './fold.js';
16
17
  import { asRepoSize } from './public-vocabulary.js';
17
18
  import { makeRepoIdentity, makeRepoRoot } from './repo-root.js';
19
+ import { mkdirOwnerOnly } from './owner-only.js';
18
20
  import { makeRedactor } from './redact.js';
19
21
  import { CAPTURE_VERSION, EXTRACTOR_VERSION, OLDEST_READABLE_CAPTURE, } from './schema.js';
20
22
  const isMissing = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
@@ -25,6 +27,7 @@ export const paths = (storeDir) => ({
25
27
  skeletons: join(storeDir, 'skeletons'),
26
28
  calls: join(storeDir, 'mcp-calls.jsonl'),
27
29
  hookCalls: join(storeDir, 'hook-calls.jsonl'),
30
+ hookRows: join(storeDir, 'hook-rows.jsonl'),
28
31
  pipelineRuns: join(storeDir, 'pipeline-runs.jsonl'),
29
32
  salt: join(storeDir, 'salt'),
30
33
  raw: join(storeDir, 'raw'),
@@ -80,14 +83,16 @@ export const readState = async (storeDir) => {
80
83
  for (const [entry, mode] of Object.entries(pool.folders))
81
84
  if (mode === 'skeleton' || mode === 'off')
82
85
  folders[entry] = mode;
83
- return { files, redactDomains: domains, allow, pool: { rawConsent: pool.rawConsent === 'granted' || pool.rawConsent === 'declined' ? pool.rawConsent : null, folders } };
86
+ const yesVersion = typeof pool.rawConsentVersion === 'string' && pool.rawConsentVersion !== '' ? pool.rawConsentVersion : null;
87
+ const granted = pool.rawConsent === 'granted' && yesVersion !== null;
88
+ return { files, redactDomains: domains, allow, pool: { rawConsent: granted ? 'granted' : pool.rawConsent === 'declined' ? 'declined' : null, rawConsentVersion: granted ? yesVersion : null, withdrawalPending: pool.withdrawalPending === true && !granted, folders } };
84
89
  }
85
90
  }
86
91
  catch (error) {
87
92
  if (!isMissing(error) && !(error instanceof SyntaxError))
88
93
  throw error;
89
94
  }
90
- return { files: {}, redactDomains: [], allow: [], pool: { rawConsent: null, folders: {} } };
95
+ return { files: {}, redactDomains: [], allow: [], pool: { rawConsent: null, rawConsentVersion: null, withdrawalPending: false, folders: {} } };
91
96
  };
92
97
  /** Changes the pool choices under the store lock, so a deposit running at the same time keeps them (it writes the state it read). */
93
98
  export const updatePoolChoices = (storeDir, change) => withLock(storeDir, async (held) => {
@@ -111,7 +116,7 @@ export const writeAtomic = async (file, text) => {
111
116
  await rename(temp, file);
112
117
  };
113
118
  export { slugOf };
114
- const projectFolders = async (projectsRoot) => {
119
+ export const projectFolders = async (projectsRoot) => {
115
120
  try {
116
121
  return (await readdir(projectsRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
117
122
  }
@@ -164,6 +169,7 @@ const contextFor = async (runtime, state, now, held) => {
164
169
  domains: [...state.redactDomains].sort(),
165
170
  redact: makeRedactor(state.redactDomains),
166
171
  salt,
172
+ repoSalt: runtime.repoSalt ?? derivedSalt(salt.salt, 'repoKey/1'),
167
173
  repoRoot: makeRepoRoot(),
168
174
  repoIdentity: makeRepoIdentity(),
169
175
  captureRuntime: captureRuntime(),
@@ -175,7 +181,7 @@ const contextFor = async (runtime, state, now, held) => {
175
181
  now,
176
182
  };
177
183
  };
178
- const mainTranscript = (folder, name) => ({ file: join(folder, name), session: name.slice(0, -'.jsonl'.length), relative: name });
184
+ export const mainTranscript = (folder, name) => ({ file: join(folder, name), session: name.slice(0, -'.jsonl'.length), relative: name });
179
185
  export const listDir = async (dir) => {
180
186
  try {
181
187
  return (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
@@ -221,7 +227,7 @@ export const subagentFiles = async (folder, sessions) => {
221
227
  return found;
222
228
  };
223
229
  // The subagent transcripts of the given sessions, each with its `.meta.json` read.
224
- const subagentTranscripts = async (folder, sessions) => {
230
+ export const subagentTranscripts = async (folder, sessions) => {
225
231
  const found = [];
226
232
  for (const { dir, path, parent, name } of await subagentFiles(folder, sessions)) {
227
233
  const agent = name.slice(0, -'.jsonl'.length);
@@ -340,6 +346,7 @@ const depositFile = async (context, project, transcript) => {
340
346
  bytes: info.size,
341
347
  redact: context.redact,
342
348
  salt: context.salt.salt,
349
+ repoSalt: context.repoSalt,
343
350
  sid: context.salt.sid,
344
351
  repoRoot: context.repoRoot,
345
352
  repoIdentity: context.repoIdentity,
@@ -439,6 +446,24 @@ const commitCapture = async (context, key, extract, cursor) => {
439
446
  await writeAtomic(join(files.skeletons, `${extract.session.session}.json`), JSON.stringify(extract.skeleton));
440
447
  context.state.files[key] = { ...cursor, v: CAPTURE_VERSION, s: context.salt.sid, d: context.domains, repoSize: extract.session.frame?.stack.size ?? null };
441
448
  };
449
+ /**
450
+ * Records that a session is superseded: its capture of no turns, marked with the session that holds them now, appended as the next rev (the log is never edited), and its
451
+ * skeleton removed, since a skeleton is derived and a reader of skeletons would count the session. The cursor records it, so a repeat deposit adds nothing. The nested agents of the
452
+ * session are read again, since their parent is now the merged session.
453
+ */
454
+ const commitSupersede = async (context, key, extract, by, cursor) => {
455
+ const files = paths(context.runtime.storeDir);
456
+ extract.session.supersededBy = by;
457
+ await requireHold(context.held);
458
+ await appendFile(files.observations, `${JSON.stringify(extract.session)}\n`, { mode: 0o600 });
459
+ await requireHold(context.held);
460
+ await rm(join(files.skeletons, `${extract.session.session}.json`), { force: true });
461
+ context.state.files[key] = { ...cursor, v: CAPTURE_VERSION, s: context.salt.sid, d: context.domains, repoSize: null };
462
+ // A nested agent's id is `<trace>-<span>`, so it stays when its parent moves to the merged session, and its spans may not change: stale its cursor so the next read names the new parent.
463
+ for (const [other, entry] of Object.entries(context.state.files))
464
+ if (other.startsWith(`${key}-`))
465
+ entry.v = '';
466
+ };
442
467
  /** Another deposit holds the store lock and the wait ran out: the one failure the Stop hook defers instead of counting as a failed run. */
443
468
  export class StoreLockedError extends CliError {
444
469
  constructor() {
@@ -496,13 +521,19 @@ const breakStale = async (lock, staleMs) => {
496
521
  await rm(guard, { force: true });
497
522
  }
498
523
  };
524
+ /**
525
+ * What a command a person typed waits for the store lock (`deposit --deny`, the pool choices). The background sweep starts after any command and its deposit
526
+ * holds the lock for the whole run, which a full re-capture stretches past 10 minutes, so a short wait made `--deny` fail right when the hold window
527
+ * wants it to work; two minutes is the wait the send lock gives. The hook path keeps `HOOK_LOCK`: a hook must not block a session.
528
+ */
529
+ const FOREGROUND_LOCK = { waitMs: 120_000, staleMs: 600_000 };
499
530
  /**
500
531
  * Runs `work` as the only writer of `state.json` and the observations file. The SessionEnd hooks of parallel sessions and the nightly
501
532
  * run each read, extract, and rewrite `state.json`; without this one's `files` and `allow` entries are lost to another's write.
502
533
  */
503
- export const withLock = async (storeDir, work, options = { waitMs: 10_000, staleMs: 600_000 }) => {
534
+ export const withLock = async (storeDir, work, options = FOREGROUND_LOCK) => {
504
535
  const lock = join(storeDir, 'store.lock');
505
- await mkdir(storeDir, { recursive: true });
536
+ await mkdirOwnerOnly(storeDir);
506
537
  const started = Date.now();
507
538
  // The token names this claim, so a holder whose lock was broken cannot delete the next holder's lock when it ends.
508
539
  const token = `${String(process.pid)} ${randomUUID()}`;
@@ -654,11 +685,11 @@ const depositLocked = async (runtime, options, holds, trace) => {
654
685
  return result;
655
686
  };
656
687
  /** The table in force: the runtime's own (a test's), else every source. */
657
- const sourcesOf = (runtime) => runtime.sources ?? SOURCES;
688
+ export const sourcesOf = (runtime) => runtime.sources ?? SOURCES;
658
689
  /** A source's name is the first `state.json` key segment, and no project folder uses it, so the Claude Code walk skips it. */
659
690
  const isSource = (table, name) => table.some((source) => source.name === name);
660
691
  /** The roots the runtime names, by source name; `cursorRoot` stays the alias of the Cursor entry. */
661
- const sourceRoots = (runtime) => new Map(Object.entries({ ...(runtime.cursorRoot === undefined ? {} : { cursor: runtime.cursorRoot }), ...runtime.sourceRoots }));
692
+ export const sourceRoots = (runtime) => new Map(Object.entries({ ...(runtime.cursorRoot === undefined ? {} : { cursor: runtime.cursorRoot }), ...runtime.sourceRoots }));
662
693
  /** Reports an unreadable source store or file, once per reason: the full path for the terminal, the base name for the hook-call record. */
663
694
  const noteReason = (counts, file, reason) => {
664
695
  const line = `${file}: ${reason}`;
@@ -696,16 +727,23 @@ const readSource = async (context, source, root, result, trace) => {
696
727
  for (const entry of store.unreadable)
697
728
  noteReason(counts, entry.file, entry.reason);
698
729
  try {
699
- for (const session of store.sessions) {
730
+ // The superseded sessions first: committing one stales the cursors of its nested agents, which are then read in this same deposit.
731
+ const ordered = [...store.sessions.filter((session) => session.supersededBy !== undefined), ...store.sessions.filter((session) => session.supersededBy === undefined)];
732
+ for (const session of ordered) {
700
733
  const key = `${source.name}/${session.id}`;
701
734
  const previous = context.state.files[key];
735
+ // A session this store never held has nothing to supersede.
736
+ if (session.supersededBy !== undefined && previous === undefined)
737
+ continue;
702
738
  if (previous !== undefined &&
703
739
  previous.size === session.size &&
704
740
  previous.mtimeMs === session.mtimeMs &&
705
741
  previous.v === CAPTURE_VERSION &&
706
742
  previous.s === context.salt.sid &&
707
743
  sameSet(previous.d, context.domains)) {
708
- result.unchanged += 1;
744
+ // A superseded session is recorded once and is no file of its own, so it is not one more file left unchanged.
745
+ if (session.supersededBy === undefined)
746
+ result.unchanged += 1;
709
747
  continue;
710
748
  }
711
749
  let text;
@@ -729,6 +767,7 @@ const readSource = async (context, source, root, result, trace) => {
729
767
  bytes: Buffer.byteLength(text),
730
768
  redact: context.redact,
731
769
  salt: context.salt.salt,
770
+ repoSalt: context.repoSalt,
732
771
  sid: context.salt.sid,
733
772
  repoRoot: context.repoRoot,
734
773
  repoIdentity: context.repoIdentity,
@@ -739,6 +778,10 @@ const readSource = async (context, source, root, result, trace) => {
739
778
  });
740
779
  // The input names the frame and the action table; the session record carries the harness for `harnessOf`, `books --by harness`, and `measure`.
741
780
  extract.session.harness = source.harness;
781
+ if (session.supersededBy !== undefined) {
782
+ await commitSupersede(context, key, extract, `${source.name}-${session.supersededBy}`, { size: session.size, mtimeMs: session.mtimeMs, rev });
783
+ continue;
784
+ }
742
785
  await commitCapture(context, key, extract, { size: session.size, mtimeMs: session.mtimeMs, rev });
743
786
  tally(trace, extract, session.subagent !== undefined);
744
787
  if (session.subagent === undefined) {
@@ -918,7 +961,7 @@ const rawRelatives = async (dir, path) => {
918
961
  }
919
962
  return found.sort();
920
963
  };
921
- const HOOK_LOCK = { waitMs: 3_000, staleMs: 600_000 };
964
+ export const HOOK_LOCK = { waitMs: 500, staleMs: 600_000 };
922
965
  /**
923
966
  * Deposits one transcript, as the SessionEnd hook does: only from a folder that earlier `--allow` entries name, and idempotent by size and
924
967
  * mtime. It does not rebuild `derived.json` or the hook index (that is a whole-store pass; the nightly run does it), so it stays fast.
@@ -927,7 +970,7 @@ const HOOK_LOCK = { waitMs: 3_000, staleMs: 600_000 };
927
970
  */
928
971
  export const depositSession = async (runtime, transcript, now, trace) => {
929
972
  try {
930
- // The hook path waits 3 s for the lock, well under the harness's 30 s ceiling on the hook (install.ts), so a SessionEnd behind the nightly deposit cannot hold session exit.
973
+ // The hook path waits under a second for the lock, well under the harness's 30 s ceiling on the hook (install.ts), so a SessionEnd behind the sender's sweep cannot hold session exit; `deferSession` covers a lock still held.
931
974
  return await withLock(runtime.storeDir, (held) => depositSessionLocked(runtime, transcript, now, held, trace), HOOK_LOCK);
932
975
  }
933
976
  catch (error) {
@@ -1091,12 +1134,20 @@ const internTool = (tool, intern) => ({
1091
1134
  target: tool.target === null ? null : intern(tool.target),
1092
1135
  ...(tool.segments === undefined ? {} : { segments: tool.segments.map((segment) => ({ ...segment, run: intern(segment.run) })) }),
1093
1136
  });
1137
+ const withoutMessageUsage = (session) => {
1138
+ if (session.messageUsage === undefined)
1139
+ return session;
1140
+ const rest = { ...session };
1141
+ Reflect.deleteProperty(rest, 'messageUsage');
1142
+ return rest;
1143
+ };
1094
1144
  /**
1095
1145
  * The store as a reader sees it: for each session only the highest `rev`. A version-3 or later session is derived here from its capture records
1096
1146
  * (EXTRACTOR_VERSION rules); a session whose last deposit was version 2 keeps the prompt and error lines that deposit wrote.
1097
1147
  * `afterFirstPass` runs between the two passes over the log: a seam for the test that compacts the log there.
1148
+ * `options.messageUsage` keeps the per-message usage entries on each session record; without it they are dropped as the record is read.
1098
1149
  */
1099
- export const loadStore = async (storeDir, afterFirstPass) => {
1150
+ export const loadStore = async (storeDir, afterFirstPass, options = {}) => {
1100
1151
  // Two passes, so a superseded record is never held: the first keeps one number per session, the second keeps only the records at that rev.
1101
1152
  // A re-capture leaves the old copies in the log until a compaction, and holding them as well doubled the heap of every read.
1102
1153
  // Both passes read one open file: a lock-free reader can meet a compaction between them, and a second open would see the compacted log, where
@@ -1119,7 +1170,7 @@ export const loadStore = async (storeDir, afterFirstPass) => {
1119
1170
  if (observation === undefined || observation.rev !== latest.get(observation.session))
1120
1171
  continue;
1121
1172
  if (observation.kind === 'session')
1122
- of(observation.session).session = observation;
1173
+ of(observation.session).session = options.messageUsage === true ? observation : withoutMessageUsage(observation);
1123
1174
  else if (observation.kind === 'user-prompt')
1124
1175
  of(observation.session).prompts.push(observation);
1125
1176
  else if (observation.kind === 'tool')
@@ -1138,7 +1189,8 @@ export const loadStore = async (storeDir, afterFirstPass) => {
1138
1189
  const redact = makeRedactor((await readState(storeDir)).redactDomains);
1139
1190
  const agents = [];
1140
1191
  for (const { session: written, prompts, tools: writtenTools, errorLines: writtenErrorLines } of captured.values()) {
1141
- if (written === undefined)
1192
+ // A superseded session holds no turns: the session that folded it in holds them.
1193
+ if (written === undefined || written.supersededBy !== undefined)
1142
1194
  continue;
1143
1195
  // One tool call is one record: a replay an older capture wrote beside the call is folded here.
1144
1196
  const folded = foldToolRows(writtenTools, writtenErrorLines);
@@ -0,0 +1,8 @@
1
+ /** The invented working directory the demo sessions claim; no such folder is read. Resolved, because a folder's name is its path's slug on this platform. */
2
+ export declare const DEMO_CWD: string;
3
+ /** The folder Claude Code would name for `DEMO_CWD` under `~/.claude/projects`. */
4
+ export declare const DEMO_FOLDER: string;
5
+ /** An error the three demo sessions all hit, as a stranger would paste it. */
6
+ export declare const DEMO_ERROR = "unexpected EOF while looking for matching quote";
7
+ /** Writes the demo sessions under `<projectsRoot>/<DEMO_FOLDER>/`, the layout Claude Code uses. */
8
+ export declare const writeDemoSessions: (projectsRoot: string) => Promise<void>;