@manyoars/oars 0.4.0 → 0.5.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.
Files changed (185) hide show
  1. package/README.md +82 -32
  2. package/dist/bin.js +3 -1
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +206 -20
  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/issuer.d.ts +1 -1
  15. package/dist/issuer.js +2 -2
  16. package/dist/main.js +5 -0
  17. package/dist/node-floor.d.ts +10 -0
  18. package/dist/node-floor.js +23 -0
  19. package/dist/oars-hook.d.ts +2 -0
  20. package/dist/oars-hook.js +7 -0
  21. package/dist/sessions/allow.d.ts +1 -0
  22. package/dist/sessions/allow.js +1 -1
  23. package/dist/sessions/call-shape.d.ts +111 -0
  24. package/dist/sessions/call-shape.js +464 -0
  25. package/dist/sessions/candidates.d.ts +65 -0
  26. package/dist/sessions/candidates.js +236 -0
  27. package/dist/sessions/card-command.d.ts +24 -0
  28. package/dist/sessions/card-command.js +71 -0
  29. package/dist/sessions/card-font.d.ts +6 -0
  30. package/dist/sessions/card-font.js +36 -0
  31. package/dist/sessions/card-line.d.ts +30 -0
  32. package/dist/sessions/card-line.js +105 -0
  33. package/dist/sessions/card-render.d.ts +15 -0
  34. package/dist/sessions/card-render.js +146 -0
  35. package/dist/sessions/card.d.ts +59 -0
  36. package/dist/sessions/card.js +107 -0
  37. package/dist/sessions/cell-increments.d.ts +14 -3
  38. package/dist/sessions/cell-increments.js +79 -12
  39. package/dist/sessions/cells.d.ts +42 -4
  40. package/dist/sessions/cells.js +72 -18
  41. package/dist/sessions/codex-hook-bin.d.ts +1 -0
  42. package/dist/sessions/codex-hook-bin.js +24 -0
  43. package/dist/sessions/codex-hook-install.d.ts +21 -0
  44. package/dist/sessions/codex-hook-install.js +47 -0
  45. package/dist/sessions/codex-hook-view.d.ts +20 -0
  46. package/dist/sessions/codex-hook-view.js +20 -0
  47. package/dist/sessions/codex-hook.d.ts +77 -0
  48. package/dist/sessions/codex-hook.js +255 -0
  49. package/dist/sessions/codex.d.ts +9 -0
  50. package/dist/sessions/codex.js +104 -18
  51. package/dist/sessions/command.d.ts +17 -2
  52. package/dist/sessions/command.js +145 -33
  53. package/dist/sessions/copilot.d.ts +3 -0
  54. package/dist/sessions/copilot.js +12 -4
  55. package/dist/sessions/derive.js +2 -1
  56. package/dist/sessions/empty-state.d.ts +17 -0
  57. package/dist/sessions/empty-state.js +38 -0
  58. package/dist/sessions/exclusion.js +48 -3
  59. package/dist/sessions/extract.d.ts +16 -3
  60. package/dist/sessions/extract.js +281 -24
  61. package/dist/sessions/gemini.d.ts +2 -0
  62. package/dist/sessions/gemini.js +29 -2
  63. package/dist/sessions/harness-files.d.ts +27 -0
  64. package/dist/sessions/harness-files.js +109 -0
  65. package/dist/sessions/head-only.d.ts +7 -0
  66. package/dist/sessions/head-only.js +21 -0
  67. package/dist/sessions/hook-calls.js +4 -3
  68. package/dist/sessions/hooks.d.ts +1 -1
  69. package/dist/sessions/hooks.js +6 -2
  70. package/dist/sessions/identity.d.ts +6 -0
  71. package/dist/sessions/injected.d.ts +13 -0
  72. package/dist/sessions/injected.js +212 -0
  73. package/dist/sessions/install.d.ts +31 -20
  74. package/dist/sessions/install.js +80 -82
  75. package/dist/sessions/load-status.d.ts +41 -0
  76. package/dist/sessions/load-status.js +156 -0
  77. package/dist/sessions/machine-child-deps.d.ts +5 -0
  78. package/dist/sessions/machine-child-deps.js +83 -0
  79. package/dist/sessions/machine-child.d.ts +43 -0
  80. package/dist/sessions/machine-child.js +120 -0
  81. package/dist/sessions/machine-credential.d.ts +10 -0
  82. package/dist/sessions/machine-credential.js +20 -0
  83. package/dist/sessions/machine-enroll.d.ts +47 -0
  84. package/dist/sessions/machine-enroll.js +230 -0
  85. package/dist/sessions/machine-hold.d.ts +49 -0
  86. package/dist/sessions/machine-hold.js +88 -0
  87. package/dist/sessions/machine-ids.d.ts +6 -0
  88. package/dist/sessions/machine-ids.js +9 -0
  89. package/dist/sessions/machine-notice.d.ts +21 -0
  90. package/dist/sessions/machine-notice.js +89 -0
  91. package/dist/sessions/machine-pull.d.ts +40 -0
  92. package/dist/sessions/machine-pull.js +220 -0
  93. package/dist/sessions/machine-repo.d.ts +7 -0
  94. package/dist/sessions/machine-repo.js +30 -0
  95. package/dist/sessions/machine-scan-cache.d.ts +12 -0
  96. package/dist/sessions/machine-scan-cache.js +47 -0
  97. package/dist/sessions/machine-send.d.ts +12 -0
  98. package/dist/sessions/machine-send.js +156 -0
  99. package/dist/sessions/machine-stop.d.ts +22 -0
  100. package/dist/sessions/machine-stop.js +53 -0
  101. package/dist/sessions/machine.d.ts +41 -0
  102. package/dist/sessions/machine.js +89 -0
  103. package/dist/sessions/opencode.d.ts +2 -0
  104. package/dist/sessions/opencode.js +6 -0
  105. package/dist/sessions/otel.d.ts +21 -0
  106. package/dist/sessions/otel.js +109 -38
  107. package/dist/sessions/owner-only.d.ts +7 -0
  108. package/dist/sessions/owner-only.js +34 -0
  109. package/dist/sessions/pi.d.ts +13 -1
  110. package/dist/sessions/pi.js +27 -13
  111. package/dist/sessions/pool-books.d.ts +1 -1
  112. package/dist/sessions/pool-books.js +1 -1
  113. package/dist/sessions/pool-choices.d.ts +4 -2
  114. package/dist/sessions/pool-choices.js +4 -2
  115. package/dist/sessions/pool-command.d.ts +46 -6
  116. package/dist/sessions/pool-command.js +577 -133
  117. package/dist/sessions/pool-delta.d.ts +7 -0
  118. package/dist/sessions/pool-delta.js +28 -5
  119. package/dist/sessions/pool-due.d.ts +43 -0
  120. package/dist/sessions/pool-due.js +113 -0
  121. package/dist/sessions/pool-nightly.d.ts +9 -1
  122. package/dist/sessions/pool-nightly.js +65 -21
  123. package/dist/sessions/pool-outbox.d.ts +51 -5
  124. package/dist/sessions/pool-outbox.js +217 -24
  125. package/dist/sessions/pool-print.d.ts +1 -1
  126. package/dist/sessions/pool-raw.js +4 -3
  127. package/dist/sessions/pool-replica.d.ts +4 -4
  128. package/dist/sessions/pool-replica.js +26 -7
  129. package/dist/sessions/pool-send-lock.js +1 -1
  130. package/dist/sessions/pool-sent.d.ts +2 -0
  131. package/dist/sessions/pool-sent.js +21 -4
  132. package/dist/sessions/pool-wire.d.ts +172 -12
  133. package/dist/sessions/pool-wire.js +284 -30
  134. package/dist/sessions/pool.js +4 -2
  135. package/dist/sessions/public-vocabulary.d.ts +5 -0
  136. package/dist/sessions/public-vocabulary.js +16 -0
  137. package/dist/sessions/read-call-shape.js +12 -6
  138. package/dist/sessions/read-fit.d.ts +5 -0
  139. package/dist/sessions/read-fit.js +23 -2
  140. package/dist/sessions/read.d.ts +1 -1
  141. package/dist/sessions/recall-consequence.d.ts +2 -2
  142. package/dist/sessions/recall-consequence.js +14 -9
  143. package/dist/sessions/recall-object.d.ts +9 -2
  144. package/dist/sessions/receipt.d.ts +8 -0
  145. package/dist/sessions/receipt.js +78 -0
  146. package/dist/sessions/recovery-calls.d.ts +3 -2
  147. package/dist/sessions/recovery-calls.js +9 -3
  148. package/dist/sessions/redact.d.ts +2 -2
  149. package/dist/sessions/redact.js +116 -14
  150. package/dist/sessions/reversion.d.ts +8 -0
  151. package/dist/sessions/reversion.js +114 -0
  152. package/dist/sessions/schema.d.ts +78 -4
  153. package/dist/sessions/schema.js +31 -1
  154. package/dist/sessions/session-shape.d.ts +2 -1
  155. package/dist/sessions/session-shape.js +15 -0
  156. package/dist/sessions/sources.d.ts +9 -0
  157. package/dist/sessions/sources.js +16 -5
  158. package/dist/sessions/steps.js +3 -2
  159. package/dist/sessions/store.d.ts +41 -2
  160. package/dist/sessions/store.js +68 -16
  161. package/dist/sessions/try-demo.d.ts +8 -0
  162. package/dist/sessions/try-demo.js +90 -0
  163. package/dist/sessions/try.d.ts +13 -0
  164. package/dist/sessions/try.js +50 -0
  165. package/dist/sessions/vscode-copilot.d.ts +4 -0
  166. package/dist/sessions/vscode-copilot.js +25 -18
  167. package/dist/telemetry/command.js +12 -6
  168. package/dist/telemetry/consent.d.ts +9 -3
  169. package/dist/telemetry/consent.js +25 -9
  170. package/dist/telemetry/fields.d.ts +11 -0
  171. package/dist/telemetry/fields.js +56 -0
  172. package/dist/telemetry/measures.d.ts +13 -0
  173. package/dist/telemetry/measures.js +30 -0
  174. package/dist/telemetry/observe.d.ts +15 -2
  175. package/dist/telemetry/observe.js +34 -7
  176. package/dist/telemetry/region.d.ts +6 -0
  177. package/dist/telemetry/region.js +28 -0
  178. package/dist/telemetry/report.js +1 -1
  179. package/dist/telemetry/schema.d.ts +3 -0
  180. package/dist/telemetry/schema.js +11 -1
  181. package/dist/telemetry/send.d.ts +5 -2
  182. package/dist/telemetry/send.js +15 -7
  183. package/dist/telemetry/words.d.ts +6 -0
  184. package/dist/telemetry/words.js +6 -0
  185. package/package.json +23 -4
@@ -36,6 +36,8 @@ export interface CodexStore {
36
36
  sessions: CodexSession[];
37
37
  /** The session as transcript text for `captureSession`; throws when the file cannot be read. */
38
38
  transcript: (session: CodexSession) => Promise<string>;
39
+ /** True when the rollout holds only head records (`rolloutHeadOnly`); `sessions verify` counts such a file as empty, not unreadable. */
40
+ headOnly: (session: CodexSession) => Promise<boolean>;
39
41
  /** Files skipped because their id is not a safe file name (it names a skeleton), counted so the skip is never silent. */
40
42
  rejected: number;
41
43
  /** Files that could not be inspected, reported once each and never fatal; the reason names no path, so a log line built from it leaks none. */
@@ -46,6 +48,13 @@ export interface CodexStore {
46
48
  }
47
49
  /** The non-blank lines of a rollout that are not JSON (a torn write), kept so `captureSession` counts them and the deposit line reports them. */
48
50
  export declare const rolloutBadLines: (text: string) => string[];
51
+ export declare const rolloutHeadOnly: (text: string) => boolean;
52
+ /**
53
+ * The rollout with the history it inherited removed (the records below a subagent's `subagent_history_start_ordinal`, and a Copied fork's leading
54
+ * copy of its parent), by the same two rules `rolloutLines` applies, so the head-only test sees only what the session itself wrote. Lines that are not
55
+ * a JSON object with a payload stay, as a torn line must still count.
56
+ */
57
+ export declare const rolloutOwnText: (text: string, parentText?: string) => string;
49
58
  /** One rollout as the lines `captureSession` reads. */
50
59
  export declare const rolloutLines: (text: string, fallbackId: string, parentText?: string) => Obj[];
51
60
  /** Opens the Codex store under `root`; undefined when it holds no `sessions` folder, so a machine without Codex is skipped silently. */
@@ -23,7 +23,8 @@
23
23
  // That these records are persisted into the rollout is rollout/src/policy.rs:83, :91, and :137 at ac558e2.
24
24
  // event_msg user_message {message} a prompt (the `response_item` user messages also hold injected context, so they are not read)
25
25
  // event_msg agent_message {message} the assistant's text
26
- // event_msg token_count {info.last_token_usage} one model response's usage, closing the lines since the last one
26
+ // event_msg token_count {info.last_token_usage, rate_limits.primary/.secondary {used_percent, window_minutes}} one model response's usage, closing the lines since the last one, and the plan share used when the event holds it
27
+ // (read in preference to `token_usage_record`: it is the event already read for usage, it is the one that carries `rate_limits`, and it is on more responses)
27
28
  // response_item function_call / local_shell_call / custom_tool_call a tool call
28
29
  // response_item function_call_output {call_id, output} a shell call's outcome: the text `Exit code: N` / `Process exited with code N` then `Output:`
29
30
  // (the unified-exec emitter's own text, core/src/tools/events.rs:400, :416, :423; context.rs:556), the real exit code; `execution error: ...` (core/src/tools/events.rs:438) is a
@@ -53,6 +54,7 @@ import { basename, join } from 'node:path';
53
54
  import { createInterface } from 'node:readline';
54
55
  import { PassThrough, pipeline } from 'node:stream';
55
56
  import { createZstdDecompress, zstdDecompressSync } from 'node:zlib';
57
+ import { holdsOnlyRecords } from './head-only.js';
56
58
  import { INTERRUPT_MARKER, SHAPE_TOOLS_LINE } from './session-shape.js';
57
59
  /** The Codex home for an environment; a test passes its own so it never reads the real one. */
58
60
  export const codexRoot = (env, home = homedir()) => {
@@ -230,30 +232,68 @@ const outcomeOf = (raw) => {
230
232
  * an `Exit code -1` line would be the error line and the message lost. The message stands alone then, and a code is the line capture skips.
231
233
  */
232
234
  const failureOf = (exit, text) => exit === undefined || exit === 0 ? undefined : exit < 0 && text.trim() !== '' ? text : `Exit code ${String(exit)}\n${text}`;
235
+ /** One `rate_limits` window: the plan share used and the window's length. Absent when the event holds no number for it, never a zero. */
236
+ const windowOf = (raw) => {
237
+ if (!isObj(raw))
238
+ return undefined;
239
+ const usedPercent = num(raw.used_percent);
240
+ if (usedPercent === undefined)
241
+ return undefined;
242
+ const windowMinutes = num(raw.window_minutes);
243
+ return { usedPercent, ...(windowMinutes === undefined ? {} : { windowMinutes }) };
244
+ };
245
+ /** The event's `rate_limits.primary` and `.secondary`, or undefined when it holds neither (older rollouts and local models write none). */
246
+ const rateLimitsOf = (payload) => {
247
+ const limits = isObj(payload.rate_limits) ? payload.rate_limits : undefined;
248
+ const primary = windowOf(limits?.primary);
249
+ const secondary = windowOf(limits?.secondary);
250
+ return primary === undefined && secondary === undefined ? undefined : { ...(primary === undefined ? {} : { primary }), ...(secondary === undefined ? {} : { secondary }) };
251
+ };
233
252
  const usageOf = (info) => {
234
253
  const last = isObj(info) && isObj(info.last_token_usage) ? info.last_token_usage : undefined;
235
254
  if (last === undefined)
236
255
  return undefined;
237
- const input = num(last.input_tokens) ?? 0;
238
- const cached = Math.min(num(last.cached_input_tokens) ?? 0, input);
239
- const output = num(last.output_tokens) ?? 0;
256
+ const reportedInput = num(last.input_tokens);
257
+ const input = reportedInput ?? 0;
258
+ const reportedCached = num(last.cached_input_tokens);
259
+ const cached = Math.min(reportedCached ?? 0, input);
260
+ const reportedOutput = num(last.output_tokens);
261
+ const output = reportedOutput ?? 0;
240
262
  // A zero total means Codex recorded none, so no usage is written rather than a measured zero.
241
263
  if (input === 0 && output === 0)
242
264
  return undefined;
243
265
  const reasoning = num(last.reasoning_output_tokens);
244
- const written = num(last.cache_write_input_tokens) ?? 0;
266
+ const reportedWritten = num(last.cache_write_input_tokens);
267
+ const written = reportedWritten ?? 0;
245
268
  // Codex counts cached and written tokens both inside `input_tokens` (input 100 = cached 40 + write 60, codex-api/src/sse/responses.rs:889-911); capture's
246
269
  // `input` excludes cache reads and creation, as the Claude Code path's does, so each token is counted once.
247
270
  return {
248
- input_tokens: Math.max(input - cached - written, 0),
249
- cache_read_input_tokens: cached,
250
- cache_creation_input_tokens: written,
251
- output_tokens: output,
271
+ // A category the event left out stays out (a partial event, or an older rollout with no cache keys), so it is neither a zero entry nor an observed field.
272
+ ...(reportedInput === undefined ? {} : { input_tokens: Math.max(input - cached - written, 0) }),
273
+ ...(reportedCached === undefined ? {} : { cache_read_input_tokens: cached }),
274
+ ...(reportedWritten === undefined ? {} : { cache_creation_input_tokens: written }),
275
+ ...(reportedOutput === undefined ? {} : { output_tokens: output }),
252
276
  ...(reasoning === undefined ? {} : { output_tokens_details: { thinking_tokens: reasoning } }),
253
277
  };
254
278
  };
255
279
  /** The non-blank lines of a rollout that are not JSON (a torn write), kept so `captureSession` counts them and the deposit line reports them. */
256
280
  export const rolloutBadLines = (text) => text.split('\n').filter((raw) => raw.trim() !== '' && parse(raw) === undefined);
281
+ /** Rollout record types that carry no turn by design: the header and the per-turn context. A rollout of these alone is a session started and quit. */
282
+ const ROLLOUT_HEAD_KINDS = new Set(['session_meta', 'turn_context']);
283
+ /** Payload types the reader reads as carrying no turn: the usage event, an aborted turn (one the operator interrupted gives a prompt record, so it is no longer head-only), and the `response_item` messages of injected context (a prompt is the `event_msg` user_message). */
284
+ const ROLLOUT_HEAD_EVENTS = new Set(['token_count', 'turn_aborted']);
285
+ const ROLLOUT_HEAD_ITEMS = new Set(['message']);
286
+ /** An assistant `response_item` message is a turn, which the reader reads from `event_msg` only: a rollout whose turns moved to it is a moved form, not an empty one. */
287
+ const isAssistantItem = (record) => typeof record.payload === 'object' && record.payload !== null && 'role' in record.payload && record.payload.role === 'assistant';
288
+ export const rolloutHeadOnly = (text) => holdsOnlyRecords(text, (record) => {
289
+ if (ROLLOUT_HEAD_KINDS.has(record.type))
290
+ return true;
291
+ const payload = record.payload;
292
+ const kind = typeof payload === 'object' && payload !== null && 'type' in payload ? payload.type : undefined;
293
+ if (typeof kind !== 'string')
294
+ return false;
295
+ return (record.type === 'event_msg' && ROLLOUT_HEAD_EVENTS.has(kind)) || (record.type === 'response_item' && ROLLOUT_HEAD_ITEMS.has(kind) && !isAssistantItem(record));
296
+ });
257
297
  /**
258
298
  * The names in a list of tool declarations: each entry's `name` (a `DynamicToolSpec` function, dynamic_tools.rs; an `additional_tools` or
259
299
  * `tool_search_output` entry is an untyped value, models.rs:1076, :1257), and the names of a namespace's own `tools`; a namespace's own name is a grouping, not a tool. Nothing else is read: a
@@ -264,6 +304,49 @@ const toolNames = (list) => (Array.isArray(list) ? list : [])
264
304
  .filter((name) => name !== undefined && name !== '');
265
305
  /** The approval policy as the permission mode: a string, or the tag of an externally tagged object (`{granular: {...}}`, protocol.rs:1181), which `mode()` keeps if documented and stores as `other` if not. */
266
306
  const approvalMode = (policy) => (isObj(policy) ? Object.keys(policy)[0] : str(policy));
307
+ /** The parent's records (not its `session_meta`) as the keys a copied fork's leading records are matched against. */
308
+ const parentKeys = (parent) => parent
309
+ .split('\n')
310
+ .map((raw) => parse(raw))
311
+ .filter((parsed) => isObj(parsed) && isObj(parsed.payload) && parsed.type !== 'session_meta')
312
+ .map((parsed) => JSON.stringify([parsed.type, parsed.payload]));
313
+ /** True for a `session_meta` of a Copied fork whose parent text is known: the leading records equal to the parent's are its copied history, in either mode. */
314
+ const copiesParent = (payload, parentText) => parentText !== undefined && str(payload.forked_from_id) !== undefined && !isObj(payload.history_base);
315
+ /**
316
+ * The rollout with the history it inherited removed (the records below a subagent's `subagent_history_start_ordinal`, and a Copied fork's leading
317
+ * copy of its parent), by the same two rules `rolloutLines` applies, so the head-only test sees only what the session itself wrote. Lines that are not
318
+ * a JSON object with a payload stay, as a torn line must still count.
319
+ */
320
+ export const rolloutOwnText = (text, parentText) => {
321
+ let inherited;
322
+ let copied = null;
323
+ let seenMeta = false;
324
+ return text
325
+ .split('\n')
326
+ .filter((raw) => {
327
+ const entry = parse(raw);
328
+ if (!isObj(entry) || !isObj(entry.payload))
329
+ return true;
330
+ const ordinal = num(entry.ordinal);
331
+ if (inherited !== undefined && ordinal !== undefined && ordinal < inherited)
332
+ return false;
333
+ if (entry.type !== 'session_meta' && copied !== null) {
334
+ if (copied.keys[copied.at] === JSON.stringify([entry.type, entry.payload])) {
335
+ copied.at += 1;
336
+ return false;
337
+ }
338
+ copied = null;
339
+ }
340
+ if (entry.type === 'session_meta' && !seenMeta) {
341
+ seenMeta = true;
342
+ inherited = num(entry.payload.subagent_history_start_ordinal);
343
+ if (inherited === undefined && copiesParent(entry.payload, parentText))
344
+ copied = { keys: parentKeys(parentText), at: 0 };
345
+ }
346
+ return true;
347
+ })
348
+ .join('\n');
349
+ };
267
350
  /** One rollout as the lines `captureSession` reads. */
268
351
  export const rolloutLines = (text, fallbackId, parentText) => {
269
352
  const nonblank = text.split('\n').filter((raw) => raw.trim() !== '');
@@ -439,11 +522,6 @@ export const rolloutLines = (text, fallbackId, parentText) => {
439
522
  settleMcp(id, failed ? (text === '' ? 'dynamic tool call failed' : text) : undefined, when);
440
523
  }
441
524
  };
442
- const parentKeys = (parent) => parent
443
- .split('\n')
444
- .map((raw) => parse(raw))
445
- .filter((parsed) => isObj(parsed) && isObj(parsed.payload) && parsed.type !== 'session_meta')
446
- .map((parsed) => JSON.stringify([parsed.type, parsed.payload]));
447
525
  for (const raw of text.split('\n')) {
448
526
  const entry = parse(raw);
449
527
  if (!isObj(entry) || !isObj(entry.payload))
@@ -482,7 +560,7 @@ export const rolloutLines = (text, fallbackId, parentText) => {
482
560
  dynamicNames.add(name);
483
561
  inherited = num(payload.subagent_history_start_ordinal);
484
562
  paginated = payload.history_mode === 'paginated';
485
- if (inherited === undefined && parentText !== undefined && str(payload.forked_from_id) !== undefined && !isObj(payload.history_base))
563
+ if (inherited === undefined && copiesParent(payload, parentText))
486
564
  copied = { keys: parentKeys(parentText), at: 0 };
487
565
  }
488
566
  else if (type === 'turn_context') {
@@ -527,6 +605,10 @@ export const rolloutLines = (text, fallbackId, parentText) => {
527
605
  const first = open.message;
528
606
  if (isObj(first))
529
607
  first.usage = usage;
608
+ // The plan reading rides the response's first line beside its usage, so a repeated event adds neither.
609
+ const rateLimits = rateLimitsOf(payload);
610
+ if (rateLimits !== undefined)
611
+ open.rateLimits = rateLimits;
530
612
  lastTotal = total;
531
613
  response += 1;
532
614
  open = undefined;
@@ -727,6 +809,11 @@ export const openCodexStore = async (root) => {
727
809
  origin.mtimeMs = Math.max(origin.mtimeMs, own.mtimeMs);
728
810
  sessions.delete(id);
729
811
  }
812
+ // A Copied fork's history is found by matching it against the parent's lineage as of the fork's start; a parent outside the store (or unreadable) leaves it whole.
813
+ const parentTextOf = async (session) => {
814
+ const parent = session.forkedFrom === null ? undefined : sessions.get(session.forkedFrom);
815
+ return parent === undefined ? undefined : lineageText(parent, session.started).catch(() => undefined);
816
+ };
730
817
  const ordered = [...sessions.values()].sort((a, b) => a.mtimeMs - b.mtimeMs || a.id.localeCompare(b.id));
731
818
  return {
732
819
  sessions: ordered,
@@ -735,11 +822,10 @@ export const openCodexStore = async (root) => {
735
822
  transcript: async (session) => {
736
823
  // A rollout that vanished after it was listed (Codex compresses an old one) fails with its reason, which names no path.
737
824
  const text = await lineageText(session);
738
- // A Copied fork's history is found by matching it against the parent's lineage as of the fork's start; a parent outside the store (or unreadable) leaves it whole.
739
- const parent = session.forkedFrom === null ? undefined : sessions.get(session.forkedFrom);
740
- const parentText = parent === undefined ? undefined : await lineageText(parent, session.started).catch(() => undefined);
825
+ const parentText = await parentTextOf(session);
741
826
  const lines = [...rolloutLines(text, session.id, parentText).map((line) => JSON.stringify(line)), ...rolloutBadLines(text)];
742
827
  return lines.join('\n') + (lines.length === 0 ? '' : '\n');
743
828
  },
829
+ headOnly: async (session) => rolloutHeadOnly(rolloutOwnText(await lineageText(session), await parentTextOf(session))),
744
830
  };
745
831
  };
@@ -4,6 +4,7 @@ import type { Emit } from '../telemetry/emit.js';
4
4
  import { type PullInput } from './recall-reads.js';
5
5
  import { type ContextOptions } from './hook-index.js';
6
6
  import { type InstallRuntime, type Scope } from './install.js';
7
+ import { type HarnessFiles } from './harness-files.js';
7
8
  import { type SessionsRuntime, type SkippedScope } from './store.js';
8
9
  import { type StuckStep } from './steps.js';
9
10
  import { type SelfBaselineResult } from './baseline.js';
@@ -27,6 +28,11 @@ export declare const sessionsDeposit: (deps: SessionsDeps, options: {
27
28
  * this command answers an operator, so the `--error`, `--path`, and `--class` forms keep the signature text they have always printed; the MCP tool, which answers an agent, does not.
28
29
  */
29
30
  export declare const sessionsRecall: (deps: SessionsDeps, query: PullInput, sid?: string | null) => Promise<Outcome>;
31
+ /**
32
+ * `recall --error <text> --receipt`: the same pull, recorded as any recall is, printed as a paste-ready block for an issue. Only `--error` asks about one error;
33
+ * every other form refuses, because a receipt states one signature.
34
+ */
35
+ export declare const sessionsReceipt: (deps: SessionsDeps, query: PullInput, sid?: string | null) => Promise<Outcome>;
30
36
  /** `recall --counts`: push and pull, shown and held, as counts only. */
31
37
  export declare const sessionsRecallCounts: (deps: SessionsDeps, day: string | undefined) => Promise<Outcome>;
32
38
  /** `lineage`: a walk over files the derive already wrote. It computes nothing, so it logs no step. */
@@ -69,6 +75,8 @@ export interface VerifyReport {
69
75
  scope: SkippedScope;
70
76
  /** Every session's rows 1 to 10 with a status each (spec 5.4 item 5): `pass`, `fail`, or `not run` when the row had nothing to check. */
71
77
  sessionResults: SessionVerification[];
78
+ /** Per harness, the session files seen, how many yielded records, and how many were unreadable (`harness-files.ts` defines them). Read from the harness's own store on this machine; nothing is sent. */
79
+ harnessFiles: HarnessFiles;
72
80
  /** Layer 4 at T0, local (spec 2.4): each session's raw-file match, recomputed root, and count bindings, with the count of each row of the result table. */
73
81
  audit: {
74
82
  counts: T0Counts;
@@ -119,9 +127,16 @@ export interface HookTarget {
119
127
  }
120
128
  export declare const sessionsHookInstall: (deps: SessionsDeps, options: HookTarget & {
121
129
  keepRetention: boolean;
122
- schedule: "nightly" | "off" | undefined;
130
+ schedule: "nightly" | "off" | "on" | undefined;
131
+ harness?: "codex" | undefined;
132
+ machine?: boolean | undefined;
133
+ env?: readonly string[] | undefined;
134
+ termsFile?: string | undefined;
135
+ }) => Promise<Outcome>;
136
+ export declare const sessionsHookUninstall: (deps: SessionsDeps, options: HookTarget & {
137
+ harness?: "codex" | undefined;
123
138
  }) => Promise<Outcome>;
124
- export declare const sessionsHookUninstall: (deps: SessionsDeps, options: HookTarget) => Promise<Outcome>;
125
139
  export declare const sessionsHookStatus: (deps: SessionsDeps, options: {
126
140
  file: string | undefined;
141
+ harness?: "codex" | undefined;
127
142
  }) => Promise<Outcome>;
@@ -7,7 +7,7 @@ import { CliError, ExitCode } from '../errors.js';
7
7
  import { loadTerms } from './exclusion.js';
8
8
  import { EVALUATOR_MODEL, fillArms, sidecarSaver } from './gold-arms.js';
9
9
  import { armsPath, coverageLines, formatScore, isGoldDocument, parseGold, plural, renderGold, sampleGold, scoreGold } from './gold.js';
10
- import { makeRedactor } from './redact.js';
10
+ import { localNames, makeRedactor } from './redact.js';
11
11
  import { books, BOOKS_CAVEAT, formatBooks, formatMeasure, formatRecall, measure } from './query.js';
12
12
  import { formatLineage, loadLineage, walkLineage } from './lineage.js';
13
13
  import { recallFields } from '../telemetry/fields.js';
@@ -15,9 +15,14 @@ import { appendRecall, readRecalls } from './recall-object.js';
15
15
  import { deriveConsequences, formatRecallCounts, recallCounts } from './recall-consequence.js';
16
16
  import { pull, pullRefusal } from './recall-reads.js';
17
17
  import { MISS_TEXT } from './recall-render.js';
18
+ import { receiptOf, RECEIPT_MISS } from './receipt.js';
19
+ import { EMPTY_RECALL, emptyBooks, nothingFoundLines, savedListNote } from './empty-state.js';
18
20
  import { readHookCalls } from './hook-calls.js';
19
21
  import { buildIndex, contextBlock, inRepository, readIndex } from './hook-index.js';
20
- import { ensureSchedule, hookStatus, installHooks, recordSchedule, SCHEDULE_LEFT_OFF, scheduleOptedOut, scheduleStatus, setSchedule, settingsPath, uninstallHooks } from './install.js';
22
+ import { CODEX_TRUST_NOTICE, codexHookStatus, installCodexHook, uninstallCodexHook } from './codex-hook-install.js';
23
+ import { enrollmentOf } from './machine.js';
24
+ import { hookStatus, installHooks, installMachineHooks, readSettings, removeSchedule, SCHEDULE_REFUSED, scheduleStatus, settingsPath, uninstallHooks } from './install.js';
25
+ import { recordSchedule } from './pool-due.js';
21
26
  import { serve } from './mcp.js';
22
27
  import { refreshPool, servedPool } from './pool.js';
23
28
  import { recallPool } from './recall-bundle.js';
@@ -29,7 +34,8 @@ import { FAMILIES } from './read.js';
29
34
  import { formatNotReadable, notReadable } from './read-readable.js';
30
35
  import { formatReads, isFamily, parseSlice, selectReads } from './read-rows.js';
31
36
  import { CAPTURE_VERSION, OLDEST_READABLE_CAPTURE } from './schema.js';
32
- import { deposit, frozenSessions, loadStore, readCalls, readRedactDomains, readSaved, readSkeletons, skippedFolders, slugOf } from './store.js';
37
+ import { harnessFileCounts } from './harness-files.js';
38
+ import { deposit, frozenSessions, loadStore, paths, readCalls, readRedactDomains, readSaved, readSkeletons, skippedFolders, slugOf } from './store.js';
33
39
  import { formatStepCounters, inputKey, newContext, readSteps, stampKey, runDeposit, runStep, stepCounters, stuckSteps, tryLog } from './steps.js';
34
40
  import { formatSelfBaseline, selfBaseline as selfBaselineOf } from './baseline.js';
35
41
  import { staleBatchesAnywhere } from './pool-outbox.js';
@@ -70,6 +76,8 @@ export const sessionsDeposit = async (deps, options) => {
70
76
  const lines = [
71
77
  `Deposited ${String(result.sessions)} sessions and ${String(result.subagents)} subagent transcripts (${String(result.lines)} lines, ${String(result.badLines)} unparseable) as ${String(result.observations)} observations; ${String(result.unchanged)} files unchanged.`,
72
78
  `Read ${String(result.folders.length)} folders; skipped ${String(result.skippedFolders)} not on the allowlist.`,
79
+ // The list an earlier run saved decides what a plain deposit reads; the run says so instead of looking like "every source".
80
+ ...savedListNote(read, allow, paths(runtime.storeDir).state),
73
81
  ...Object.entries(result.sources).flatMap(([name, counts]) => [
74
82
  ...(counts.read === 0 ? [] : [`Read ${String(counts.read)} ${sourceLabel(name, table)} sessions.`]),
75
83
  ...counts.unreadable.map((reason) => `${sourceLabel(name, table)} store not read: ${reason}`),
@@ -79,6 +87,8 @@ export const sessionsDeposit = async (deps, options) => {
79
87
  survival === null
80
88
  ? 'Commit survival not read this run.'
81
89
  : `Commits older than ${String(SURVIVAL_DAYS)} days: ${String(survival.survived)} survived, ${String(survival.reverted)} reverted, ${String(survival.unreachable)} unreachable, ${String(survival.unknown)} unknown.`,
90
+ // Nothing parsed and nothing already deposited: say where it looked, so an empty result is not a silent one.
91
+ ...(result.sessions + result.subagents + result.unchanged === 0 ? nothingFoundLines(result, runtime, table, read) : []),
82
92
  ];
83
93
  return { document: { ok: true, ...result, survival }, text: lines.join('\n') };
84
94
  };
@@ -97,18 +107,32 @@ export const sessionsRecall = async (deps, query, sid = null) => {
97
107
  const { storeDir } = runtimeOf(deps);
98
108
  // The key is the query and `ev`, known before the work, so a recall that keeps failing is the same key each time.
99
109
  const key = stampKey(JSON.stringify(query));
100
- const result = await runStep(newContext(storeDir, deps.now, 'manual'), 'recall', async () => {
110
+ const { found: result, empty } = await runStep(newContext(storeDir, deps.now, 'manual'), 'recall', async () => {
101
111
  const store = await loadStore(storeDir);
102
112
  const found = pull(store, query, { sid, now: deps.now(), frame: null, redactDomains: await readRedactDomains(storeDir), current: await recallPool(storeDir, store, servedPool) });
103
- return { value: found, report: { in: { query: 1 }, out: { matches: found.legacy?.matches.length ?? found.reads.length }, key } };
113
+ return { value: { found, empty: store.sessions.length === 0 }, report: { in: { query: 1 }, out: { matches: found.legacy?.matches.length ?? found.reads.length }, key } };
104
114
  }, key);
105
115
  await tryLog(() => appendRecall(storeDir, result.recall));
106
116
  // Counts and closed words only: how it was asked, the families it served, how many reads, and its arm. Never the query or the answer.
107
117
  deps.telemetry?.('recall', recallFields(result.recall, result.reads.map((read) => read.family), result.legacy !== undefined));
118
+ // A miss over a store with no sessions is not "no match": the store has nothing to match, and the text names the command that fills it.
108
119
  if (result.legacy !== undefined)
109
- return { document: { ok: true, ...result.legacy, recall: result.recall }, text: formatRecall(result.legacy) };
120
+ return { document: { ok: true, ...result.legacy, recall: result.recall }, text: empty && result.legacy.matches.length === 0 ? EMPTY_RECALL : formatRecall(result.legacy) };
110
121
  // The document states the recall and not the raw reads: a read's tool, keys, and versions are store strings that can carry text, and `recall.evidence` already states them closed.
111
- return { document: { ok: true, recall: result.recall }, text: result.recall.answer.rendered === '' ? MISS_TEXT : result.recall.answer.rendered };
122
+ return { document: { ok: true, recall: result.recall }, text: result.recall.answer.rendered === '' ? (empty ? EMPTY_RECALL : MISS_TEXT) : result.recall.answer.rendered };
123
+ };
124
+ /**
125
+ * `recall --error <text> --receipt`: the same pull, recorded as any recall is, printed as a paste-ready block for an issue. Only `--error` asks about one error;
126
+ * every other form refuses, because a receipt states one signature.
127
+ */
128
+ export const sessionsReceipt = async (deps, query, sid = null) => {
129
+ if ('form' in query || query.error === undefined || query.path !== undefined || query.cls !== undefined || query.sinceDays !== undefined) {
130
+ throw new CliError('--receipt answers one error: pass --error "<text>" and no other form.', ExitCode.USAGE, 'usage');
131
+ }
132
+ await sessionsRecall(deps, query, sid);
133
+ const { storeDir } = runtimeOf(deps);
134
+ const block = receiptOf(await loadStore(storeDir), query, deps.now(), await readRedactDomains(storeDir), localNames());
135
+ return { document: { ok: true, receipt: block }, text: block ?? RECEIPT_MISS };
112
136
  };
113
137
  /** `recall --counts`: push and pull, shown and held, as counts only. */
114
138
  export const sessionsRecallCounts = async (deps, day) => {
@@ -149,12 +173,16 @@ export const sessionsContext = async (deps, options) => {
149
173
  const { storeDir } = runtimeOf(deps);
150
174
  // The manual recall verb (spec 4.1): its key is what it looks up, the repository's cwd and the harness, known before the work.
151
175
  const key = stampKey('context', options.cwd, options.harness ?? '');
152
- const block = await runStep(newContext(storeDir, deps.now, 'manual'), 'recall', async () => {
176
+ const step = await runStep(newContext(storeDir, deps.now, 'manual'), 'recall', async () => {
153
177
  const index = (await readIndex(storeDir)) ?? buildIndex(await loadStore(storeDir), await readRedactDomains(storeDir), new Date(deps.now()).toISOString());
154
178
  const found = contextBlock(index, options);
155
- return { value: found, report: { in: { query: 1 }, out: { matches: found === undefined ? 0 : 1 }, key } };
179
+ return { value: { found, noProjects: Object.keys(index.projects).length === 0 }, report: { in: { query: 1 }, out: { matches: found === undefined ? 0 : 1 }, key } };
156
180
  }, key);
157
- return { document: { ok: true, context: block ?? null }, text: block ?? 'Nothing recurs in this repository yet.' };
181
+ // Nothing to inject because the store holds no sessions at all is an empty store, not a quiet repository: say so and name the command that fills it.
182
+ // An index that names projects answers alone; the store is loaded only when the index is empty, to tell an empty store from an index with nothing to say.
183
+ const block = step.found;
184
+ const text = block ?? (step.noProjects && (await loadStore(storeDir)).sessions.length === 0 ? EMPTY_RECALL : 'Nothing recurs in this repository yet.');
185
+ return { document: { ok: true, context: block ?? null }, text };
158
186
  };
159
187
  export const sessionsMcp = async (deps) => {
160
188
  await serve(runtimeOf(deps), deps.now, deps.telemetry);
@@ -173,6 +201,11 @@ const skippedNotice = (scope) => {
173
201
  const transcripts = scope.transcripts === 0 ? 'no main transcript' : scope.transcripts === 1 ? '1 main transcript' : `${String(scope.transcripts)} main transcripts`;
174
202
  return [`${folders} ${transcripts}; ${one ? 'it is' : 'they are'} never read: not on the allowlist. Not captured: ${shown}${more}.`];
175
203
  };
204
+ /** The three counts per harness, as numbers only: no cause, no path, no verdict. */
205
+ const harnessFilesLines = (files, table) => {
206
+ const rows = Object.entries(files).map(([harness, counts]) => ` ${harness === 'claude-code' ? 'Claude Code' : sourceLabel(harness, table)}: seen ${String(counts.seen)}, yielded ${String(counts.yielded)}, unreadable ${String(counts.unreadable)}`);
207
+ return rows.length === 0 ? [] : ['Session files per harness:', ...rows];
208
+ };
176
209
  /** Scope is an extra, like the step log: a projects root this process cannot list reports an empty scope and fails neither `verify` nor `books`. */
177
210
  const scopeOf = (runtime) => skippedFolders(runtime).catch(() => ({ folders: 0, transcripts: 0, names: [] }));
178
211
  /** One line for a store a newer oars wrote into (spec state-on-open row for a newer `cv`: read known fields, warn). */
@@ -205,7 +238,9 @@ const packageBooksOutcome = async (deps, options) => {
205
238
  const [{ excluded }, store] = await Promise.all([scanStore(runtime, options.termsFile), loadStore(runtime.storeDir)]);
206
239
  const cutoff = options.sinceDays === undefined ? undefined : new Date(deps.now() - options.sinceDays * 86_400_000).toISOString();
207
240
  const result = packageBooks(store, new Set(excluded.map((entry) => entry.session)), vocabulary(), cutoff);
208
- return { document: { ok: true, by: 'package', ...result, newerCaptures: store.newerCaptures }, text: [formatPackageBooks(result), ...newerNotice(store)].join('\n') };
241
+ // The same empty-store text the other `books` forms print, so it names the command that fills the store.
242
+ const table = store.sessions.length === 0 && store.prompts.length === 0 ? emptyBooks('public package pulls') : formatPackageBooks(result);
243
+ return { document: { ok: true, by: 'package', ...result, newerCaptures: store.newerCaptures }, text: [table, ...newerNotice(store)].join('\n') };
209
244
  };
210
245
  export const sessionsBooks = async (deps, options) => {
211
246
  if (options.by === 'package') {
@@ -220,11 +255,13 @@ export const sessionsBooks = async (deps, options) => {
220
255
  const rows = books(store, options.by, deps.now(), options.sinceDays, await readSurvival(storeDir));
221
256
  // A mistyped root, or a --since window nothing falls in, over a populated store must not read as an empty store.
222
257
  const emptyWindow = rows.length === 0 && options.sinceDays !== undefined && store.prompts.length > 0;
223
- const table = store.prompts.length === 0 && options.repo !== undefined
224
- ? `No prompts for ${options.repo} in the store (${String(loaded.prompts.length)} prompts across all repositories).`
225
- : emptyWindow
226
- ? `No ${options.by === 'pull' ? 'pulls' : 'prompts'} in the --since window.`
227
- : formatBooks(rows, options.by);
258
+ const table = loaded.sessions.length === 0 && loaded.prompts.length === 0
259
+ ? emptyBooks(options.by === 'pull' ? 'pulls' : 'prompts')
260
+ : store.prompts.length === 0 && options.repo !== undefined
261
+ ? `No prompts for ${options.repo} in the store (${String(loaded.prompts.length)} prompts across all repositories).`
262
+ : emptyWindow
263
+ ? `No ${options.by === 'pull' ? 'pulls' : 'prompts'} in the --since window.`
264
+ : formatBooks(rows, options.by);
228
265
  const scope = await scopeOf(runtime);
229
266
  const bundle = await lastFetchedBundle(storeDir);
230
267
  return { document: { ok: true, by: options.by, rows, newerCaptures: store.newerCaptures, scope, pool: bundle?.books ?? null }, text: [table, ...skippedNotice(scope), ...newerNotice(store), '', ...booksLines(bundle), ...(rows.length === 0 ? [] : ['', BOOKS_CAVEAT])].join('\n') };
@@ -271,10 +308,12 @@ export const sessionsVerify = async (deps, options) => {
271
308
  // A verify that got here has succeeded, so a verify streak is the one this run ends and is not listed.
272
309
  const stuck = stuckSteps(await readSteps(storeDir).catch(() => [])).filter((entry) => entry.step !== 'verify');
273
310
  const scope = await scopeOf(runtime);
311
+ // Like the scope, an extra: a store that cannot be read is an unreadable count inside it, and a failure outside it leaves the section out.
312
+ const harnessFiles = await harnessFileCounts(runtime).catch(() => ({}));
274
313
  // A local count like the scope: an outbox folder that cannot be read is no reason for `verify` to fail.
275
314
  const staleBatches = await staleBatchesAnywhere(storeDir).catch(() => 0);
276
315
  const totals = await outboxTotals(storeDir).catch(() => ({ sentBatches: 0, sentRaw: 0, rawCopies: 0, rawMasked: 0 }));
277
- const report = { sessions: skeletons.length, unreadable, cvFrozen: frozen.size, scope, invariants: results.map((result) => ({ ...result, pass: result.violations === 0 })), stuck, sessionResults, audit, selfBaseline, staleBatches, ...totals };
316
+ const report = { sessions: skeletons.length, unreadable, cvFrozen: frozen.size, scope, harnessFiles, invariants: results.map((result) => ({ ...result, pass: result.violations === 0 })), stuck, sessionResults, audit, selfBaseline, staleBatches, ...totals };
278
317
  const failed = [...results.filter((result) => result.violations > 0).map((result) => result.name), ...(unreadable > 0 ? ['unreadable skeletons'] : []), ...merkleFailed, ...stuck.map((entry) => `stuck: ${entry.step}`)];
279
318
  const stuckLines = stuck.map((entry) => `FAIL ${entry.step} is stuck: key ${entry.key} failed ${String(entry.runs)} runs in a row: ${entry.error}`);
280
319
  const highWater = mine([...store.sessions, ...store.subagents]).map((session) => `${session.session}:${String(session.rev)}:${session.cv}`).sort();
@@ -283,7 +322,7 @@ export const sessionsVerify = async (deps, options) => {
283
322
  failed,
284
323
  report,
285
324
  document: { ok: true, ...report, newerCaptures: store.newerCaptures },
286
- text: [formatVerify(results, skeletons.length), formatAudit(audits), formatSelfBaseline(selfBaseline), ...(resultLines === '' ? [] : [resultLines]), ...(frozen.size > 0 ? [`${String(frozen.size)} cv-frozen sessions: behind the current capture version, but the transcript is gone from the harness and raw/ (or, for a Cursor session, the composer is gone from Cursor), so they cannot be captured again`] : []), ...skippedNotice(scope), ...(staleBatches > 0 ? [`${String(staleBatches)} outbox batches were built under an older vocabulary, field list, or skeleton format; they will never be sent, and oars pool deposit removes them`] : []), ...(totals.sentBatches + totals.sentRaw > 0 ? [`${String(totals.sentBatches)} outbox ${totals.sentBatches === 1 ? 'batch' : 'batches'} and ${String(totals.sentRaw)} raw ${totals.sentRaw === 1 ? 'copy were' : 'copies were'} already sent`] : []), ...(totals.rawCopies > 0 ? [`${String(totals.rawCopies)} queued raw ${totals.rawCopies === 1 ? 'copy holds' : 'copies hold'} ${String(totals.rawMasked)} placeholders (masked values, plus any a transcript already held as text)`] : []), ...newerNotice(store), ...(unreadable > 0 ? [`FAIL ${String(unreadable)} unreadable skeleton files (torn or corrupted); redeposit to rebuild them`] : []), ...stuckLines].join('\n'),
325
+ text: [formatVerify(results, skeletons.length), formatAudit(audits), formatSelfBaseline(selfBaseline), ...(resultLines === '' ? [] : [resultLines]), ...(frozen.size > 0 ? [`${String(frozen.size)} cv-frozen sessions: behind the current capture version, but the transcript is gone from the harness and raw/ (or, for a Cursor session, the composer is gone from Cursor), so they cannot be captured again`] : []), ...harnessFilesLines(harnessFiles, runtime.sources ?? SOURCES), ...skippedNotice(scope), ...(staleBatches > 0 ? [`${String(staleBatches)} outbox batches were built under an older vocabulary, field list, or skeleton format; they will never be sent, and oars pool deposit removes them`] : []), ...(totals.sentBatches + totals.sentRaw > 0 ? [`${String(totals.sentBatches)} outbox ${totals.sentBatches === 1 ? 'batch' : 'batches'} and ${String(totals.sentRaw)} raw ${totals.sentRaw === 1 ? 'copy were' : 'copies were'} already sent`] : []), ...(totals.rawCopies > 0 ? [`${String(totals.rawCopies)} queued raw ${totals.rawCopies === 1 ? 'copy holds' : 'copies hold'} ${String(totals.rawMasked)} placeholders (masked values, plus any a transcript already held as text)`] : []), ...newerNotice(store), ...(unreadable > 0 ? [`FAIL ${String(unreadable)} unreadable skeleton files (torn or corrupted); redeposit to rebuild them`] : []), ...stuckLines].join('\n'),
287
326
  };
288
327
  const cvBehind = results.find((result) => result.name === CAPTURE_INVARIANTS[0])?.violations ?? 0;
289
328
  // The key is the sessions checked, each at its rev and cv, and the code's ev (spec 4.2).
@@ -373,47 +412,120 @@ const installOf = (deps) => {
373
412
  throw new CliError('Hook install is not configured in this run.', ExitCode.UNEXPECTED, 'unexpected');
374
413
  return deps.install;
375
414
  };
415
+ const codexOf = (deps) => {
416
+ const codex = installOf(deps).codex;
417
+ if (codex === undefined)
418
+ throw new CliError('The Codex hook is not configured in this run.', ExitCode.UNEXPECTED, 'unexpected');
419
+ return codex;
420
+ };
421
+ /** `--harness codex` writes to the Codex home, so a settings-file flag beside it names a target that does not apply. */
422
+ const refuseSettingsFlags = (options) => {
423
+ if (options.scope !== undefined || options.file !== undefined)
424
+ throw new CliError('--harness codex writes hooks.json in the Codex home; --scope and --file apply to Claude Code settings only.', ExitCode.USAGE, 'usage');
425
+ };
376
426
  /** `--scope`, else `file` when `--file` names one, else the user settings. */
377
427
  const hookScope = (options) => options.scope ?? (options.file === undefined ? 'user' : 'file');
428
+ /** What `--schedule off` and `--schedule on` say: the recorded choice about the background sweep and send that the session-end hook and every command start. */
429
+ const SENDS_OFF = 'The background sweep and the pool send are off, as you chose with --schedule off (telemetry has its own switch: oars telemetry off). Turn them back on with: oars sessions hook install --schedule on';
430
+ const SENDS_ON = 'Background sweeps and sends are on: a due sweep and send starts from the session-end hook and from any oars command, at most about hourly.';
431
+ /** `--machine`: the Stop and SessionEnd entries for a cloud VM, a VPS, or a runner, and for a user-scope file the enrollment block too. Refused beside every flag it does not take, before anything is written. */
432
+ const installMachine = async (deps, options) => {
433
+ const refuse = (message) => new CliError(message, ExitCode.USAGE, 'usage');
434
+ if (options.harness !== undefined || options.schedule !== undefined)
435
+ throw refuse('--machine writes the Stop and SessionEnd entries of a machine; --harness and --schedule do not apply to it.');
436
+ if (options.keepRetention === true)
437
+ throw refuse('--keep-retention does not apply to --machine: a machine install leaves cleanupPeriodDays alone.');
438
+ const scope = hookScope(options);
439
+ if (scope === 'file')
440
+ throw refuse('--machine takes --scope project (the .claude/settings.json of a repository, committed) or --scope user (the ~/.claude/settings.json of a VPS or runner).');
441
+ if (options.file !== undefined)
442
+ throw refuse('--file does not apply to --machine: it writes the user or project settings file named by --scope.');
443
+ const runtime = installOf(deps);
444
+ const file = settingsPath(runtime, scope, undefined);
445
+ const lines = [];
446
+ let result;
447
+ if (scope === 'project') {
448
+ if ((options.env ?? []).length > 0 || options.termsFile !== undefined)
449
+ throw refuse('--env and --terms-file belong to --scope user: a project settings file is committed, and the enrollment block holds salts and terms.');
450
+ result = await installMachineHooks(file, undefined);
451
+ lines.push(`Installed ${result.installed.join(' and ')} machine hooks in ${file}. They run only where OARS_HOST_CLASS is set to a class other than local; set it, and the rest of the enrollment block, in the environment that runs the session.`);
452
+ }
453
+ else {
454
+ // A file that cannot be read is refused before the terms file is written.
455
+ await readSettings(file);
456
+ const enrollment = await enrollmentOf(options.env ?? [], options.termsFile, runtime.cwd);
457
+ result = await installMachineHooks(file, enrollment.env);
458
+ lines.push(`Installed ${result.installed.join(' and ')} machine hooks and the enrollment block in ${file}, which is now readable by its owner only.`);
459
+ if (enrollment.termsPath !== undefined)
460
+ lines.push(`The exclusion terms are in ${enrollment.termsPath} (owner-only), named by OARS_EXCLUSION_TERMS.`);
461
+ if (enrollment.env.OARS_EXCLUSION_TERMS === undefined && enrollment.env.OARS_EXCLUSION_TERMS_INLINE === undefined)
462
+ lines.push('No exclusion terms were given: with no term file every session is excluded (fail-closed), so nothing will be built.');
463
+ }
464
+ return { document: { ok: true, machine: true, install: result }, text: lines.join('\n') };
465
+ };
378
466
  export const sessionsHookInstall = async (deps, options) => {
467
+ if (options.machine === true)
468
+ return installMachine(deps, options);
469
+ if ((options.env ?? []).length > 0 || options.termsFile !== undefined)
470
+ throw new CliError('--env and --terms-file belong to --machine.', ExitCode.USAGE, 'usage');
471
+ if (options.harness === 'codex') {
472
+ refuseSettingsFlags(options);
473
+ if (options.schedule !== undefined)
474
+ throw new CliError('--schedule applies to the Claude Code hooks, not --harness codex.', ExitCode.USAGE, 'usage');
475
+ const result = await installCodexHook(codexOf(deps));
476
+ return { document: { ok: true, harness: 'codex', ...result, trust: 'asked by codex' }, text: `Installed ${result.installed.join(', ')} hooks in ${result.file}.\n${CODEX_TRUST_NOTICE}` };
477
+ }
379
478
  const runtime = installOf(deps);
479
+ // `--schedule nightly` asked for the task this version no longer registers: refused before anything is written.
480
+ if (options.schedule === 'nightly')
481
+ throw new CliError(SCHEDULE_REFUSED, ExitCode.USAGE, 'usage');
380
482
  const lines = [];
381
483
  const document = { ok: true };
382
- // `--schedule` alone touches no settings file: it names the task, not the hooks.
484
+ // `--schedule off` or `on` alone touches no settings file; it removes the old task and records the choice.
383
485
  if (options.schedule === undefined || options.scope !== undefined || options.file !== undefined) {
384
486
  const file = settingsPath(runtime, hookScope(options), options.file);
385
487
  const result = await installHooks(runtime, file, options.keepRetention);
386
488
  document.install = result;
387
489
  lines.push(`Installed ${result.installed.join(', ')} hooks in ${file}.`, result.retention);
388
490
  }
389
- // Without `--schedule`, Windows gets the nightly task too; `--schedule off` is the opt-out, and it sticks until `--schedule nightly`.
390
- if (options.schedule !== undefined || runtime.platform === 'win32') {
491
+ const removed = await removeSchedule(runtime);
492
+ if (removed !== undefined) {
493
+ document.schedule = removed;
494
+ lines.push(removed);
495
+ }
496
+ if (options.schedule !== undefined) {
391
497
  const storeDir = deps.sessions?.storeDir;
392
- const optedOut = options.schedule === undefined && storeDir !== undefined && (await scheduleOptedOut(storeDir));
393
- let line = SCHEDULE_LEFT_OFF;
394
- if (options.schedule !== undefined) {
395
- line = await setSchedule(runtime, options.schedule);
498
+ if (storeDir !== undefined)
396
499
  await recordSchedule(storeDir, options.schedule);
397
- }
398
- else if (!optedOut) {
399
- line = await ensureSchedule(runtime);
400
- }
401
- document.schedule = line;
402
- lines.push(line);
500
+ document.sends = options.schedule;
501
+ // An explicit `off` that found no task still says why nothing was removed, so the output is never empty.
502
+ if (options.schedule === 'off' && removed === undefined)
503
+ lines.push(`No scheduled task to remove${runtime.platform === 'win32' ? '' : ' (only Windows ever had one)'}.`);
504
+ lines.push(options.schedule === 'off' ? SENDS_OFF : SENDS_ON);
403
505
  }
404
506
  return { document, text: lines.join('\n') };
405
507
  };
406
508
  export const sessionsHookUninstall = async (deps, options) => {
509
+ if (options.harness === 'codex') {
510
+ refuseSettingsFlags(options);
511
+ const result = await uninstallCodexHook(codexOf(deps));
512
+ return { document: { ok: true, harness: 'codex', ...result }, text: result.removed.length === 0 ? `No Many Oars hooks in ${result.file}.` : `Removed ${result.removed.join(', ')} hooks from ${result.file}; other hooks are untouched.` };
513
+ }
407
514
  const runtime = installOf(deps);
408
515
  const file = settingsPath(runtime, hookScope(options), options.file);
409
516
  const result = await uninstallHooks(file);
517
+ const removed = await removeSchedule(runtime);
410
518
  const text = result.removed.length === 0 ? `No Many Oars hooks in ${file}.` : `Removed ${result.removed.join(', ')} hooks from ${file}; other settings and hooks are untouched (cleanupPeriodDays is left as it is).`;
411
- return { document: { ok: true, ...result }, text };
519
+ return { document: { ok: true, ...result, ...(removed === undefined ? {} : { schedule: removed }) }, text: removed === undefined ? text : `${text}\n${removed}` };
412
520
  };
413
521
  export const sessionsHookStatus = async (deps, options) => {
522
+ if (options.harness === 'codex') {
523
+ const result = await codexHookStatus(codexOf(deps));
524
+ return { document: { ok: true, harness: 'codex', ...result }, text: `codex: ${result.file}: ${result.installed.length === 0 ? 'no hooks' : result.installed.join(', ')}` };
525
+ }
414
526
  const runtime = installOf(deps);
415
527
  const files = await hookStatus(runtime, options.file);
416
528
  const schedule = await scheduleStatus(runtime);
417
529
  const lines = files.map((item) => `${item.scope}: ${item.file}: ${item.installed.length === 0 ? 'no hooks' : item.installed.join(', ')} (cleanupPeriodDays ${String(item.retentionDays)})`);
418
- return { document: { ok: true, files, schedule }, text: [...lines, schedule].join('\n') };
530
+ return { document: { ok: true, files, ...(schedule === undefined ? {} : { schedule }) }, text: (schedule === undefined ? lines : [...lines, schedule]).join('\n') };
419
531
  };
@@ -24,6 +24,8 @@ export interface CopilotStore {
24
24
  sessions: CopilotSession[];
25
25
  /** The session as transcript text for `captureSession`; throws when the file cannot be read or holds no parseable line. */
26
26
  transcript: (session: CopilotSession) => Promise<string>;
27
+ /** True when the log holds only head records (`eventHeadOnly`); `sessions verify` counts such a file as empty, not unreadable. */
28
+ headOnly: (session: CopilotSession) => Promise<boolean>;
27
29
  /** Folders skipped because their name is not a safe file name (it names a skeleton), counted so the skip is never silent. */
28
30
  rejected: number;
29
31
  /** Sessions that could not be inspected, reported once each and never fatal; the reason names no path. */
@@ -34,6 +36,7 @@ export interface CopilotStore {
34
36
  }
35
37
  /** The non-blank lines that are not JSON (a torn write), kept so `captureSession` counts them and the deposit line reports them. */
36
38
  export declare const eventBadLines: (text: string) => string[];
39
+ export declare const eventHeadOnly: (text: string) => boolean;
37
40
  /** One session as the lines `captureSession` reads. */
38
41
  export declare const eventLines: (text: string, fallbackId: string) => Obj[];
39
42
  export declare const openCopilotStore: (root: string) => Promise<CopilotStore | undefined>;