@manyoars/oars 0.4.1 → 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 (183) 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 +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/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 +212 -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 +577 -133
  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 +172 -12
  131. package/dist/sessions/pool-wire.js +284 -30
  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.d.ts +1 -1
  139. package/dist/sessions/recall-consequence.d.ts +2 -2
  140. package/dist/sessions/recall-consequence.js +14 -9
  141. package/dist/sessions/recall-object.d.ts +9 -2
  142. package/dist/sessions/receipt.d.ts +8 -0
  143. package/dist/sessions/receipt.js +78 -0
  144. package/dist/sessions/recovery-calls.d.ts +3 -2
  145. package/dist/sessions/recovery-calls.js +9 -3
  146. package/dist/sessions/redact.d.ts +2 -2
  147. package/dist/sessions/redact.js +116 -14
  148. package/dist/sessions/reversion.d.ts +8 -0
  149. package/dist/sessions/reversion.js +114 -0
  150. package/dist/sessions/schema.d.ts +78 -4
  151. package/dist/sessions/schema.js +31 -1
  152. package/dist/sessions/session-shape.d.ts +2 -1
  153. package/dist/sessions/session-shape.js +15 -0
  154. package/dist/sessions/sources.d.ts +9 -0
  155. package/dist/sessions/sources.js +16 -5
  156. package/dist/sessions/steps.js +3 -2
  157. package/dist/sessions/store.d.ts +41 -2
  158. package/dist/sessions/store.js +68 -16
  159. package/dist/sessions/try-demo.d.ts +8 -0
  160. package/dist/sessions/try-demo.js +90 -0
  161. package/dist/sessions/try.d.ts +13 -0
  162. package/dist/sessions/try.js +50 -0
  163. package/dist/sessions/vscode-copilot.d.ts +4 -0
  164. package/dist/sessions/vscode-copilot.js +25 -18
  165. package/dist/telemetry/command.js +12 -6
  166. package/dist/telemetry/consent.d.ts +9 -3
  167. package/dist/telemetry/consent.js +25 -9
  168. package/dist/telemetry/fields.d.ts +11 -0
  169. package/dist/telemetry/fields.js +56 -0
  170. package/dist/telemetry/measures.d.ts +13 -0
  171. package/dist/telemetry/measures.js +30 -0
  172. package/dist/telemetry/observe.d.ts +15 -2
  173. package/dist/telemetry/observe.js +34 -7
  174. package/dist/telemetry/region.d.ts +6 -0
  175. package/dist/telemetry/region.js +28 -0
  176. package/dist/telemetry/report.js +1 -1
  177. package/dist/telemetry/schema.d.ts +3 -0
  178. package/dist/telemetry/schema.js +11 -1
  179. package/dist/telemetry/send.d.ts +5 -2
  180. package/dist/telemetry/send.js +15 -7
  181. package/dist/telemetry/words.d.ts +6 -0
  182. package/dist/telemetry/words.js +6 -0
  183. package/package.json +23 -4
@@ -1,8 +1,10 @@
1
1
  // The outbox (`oars pool deposit` and `oars pool outbox`): every batch that may leave this machine is first written here, as canonical JSON lines, and `deposit` stops. Nothing in this file
2
2
  // looks inside a batch except to name its fields, at every depth, for `--explain`; what a batch holds is the projection's business.
3
3
  import { createHash } from 'node:crypto';
4
- import { appendFile, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
5
- import { join } from 'node:path';
4
+ import { readdir, readFile, rename, rm } from 'node:fs/promises';
5
+ import { basename, join } from 'node:path';
6
+ import { CliError, ExitCode } from '../errors.js';
7
+ import { appendOwnerOnly, mkdirOwnerOnly, writeOwnerOnly } from './owner-only.js';
6
8
  import { SKELETON_FORMAT, VOCAB_VERSION } from './pool-wire.js';
7
9
  import { contentHash } from './read.js';
8
10
  /** What `JSON.stringify` writes for a value, or undefined for one JSON cannot hold (the lib types call this a string always). */
@@ -56,19 +58,28 @@ export const FIELD_ROWS = {
56
58
  agentType: 'The type of the subagent whose run holds a step: a built-in type of the harness, else the word custom; null on a step of the main run.',
57
59
  allocated: 'Whether the step\'s token figure was divided among the calls of one model response (true) rather than measured for the call alone; absent when measured. A flag only, never a count.',
58
60
  argClass: 'The kinds of value a call\'s arguments held, a sorted set of a closed list: path, url, pattern, command, number, flag, text, and none; never a value.',
61
+ argFeatures: 'Each sent key\'s value features, sorted by key: the value\'s size bucket and, for a path, its depth, extension class, and folder kind, for a pattern what it uses; each from a closed list, never the value.',
62
+ argValues: 'Each sent key\'s closed value: a boolean as true or false, or a member of the tool\'s closed list for the key (an output mode, a built-in subagent type); a value off the list is not sent.',
63
+ arm: 'Whether a recommendation reached the asker or was withheld by the holdback, one of a closed list: shown or held. The local row\'s arm, never what was shown.',
59
64
  cacheRead: 'A bucket of the tokens read from the cache in a prompt, a number, or null when the source never recorded it.',
60
65
  cacheWrite: 'A bucket of the tokens written to the cache in a prompt, a number, or null when the source never recorded it.',
61
66
  change: 'How far a delta moved, a signed bucket of its metric (a lower bound) in a number from -7 to 7 for calls per session and from -11 to 11 for thinking tokens; never the exact count.',
67
+ changeKind: 'What changed from the previous call of the same tool in the same prompt, a sorted set from a closed list: same, key-added, key-removed, feature-changed, value-changed, target-changed; never a value.',
68
+ claimed: 'The host class and launcher a trusted machine states for a batch it admitted from a held branch, from its own enrollment record; absent on a batch built and sent from the machine itself. Two closed lists, never a name.',
62
69
  cls: 'The class of a prompt, one of a closed list of eight, computed from the kinds of tools it used; never its words.',
63
70
  day: 'The UTC calendar day of the session as YYYY-MM-DD, or null; never a time of day.',
64
71
  deltaKind: 'How a delta changed what the client ran: added, removed, or changed, one of a closed list the field allows; never a value.',
65
72
  deltas: 'The changes the client found between its own before and after windows, each sent once with the session that filled the after window: a kind, a field, a name, a registry, a metric, and a bucketed change; never a session id, a root, a date, or an exact value.',
73
+ digests: 'The digest of the old and new text of an Edit (one pair), a MultiEdit (one pair per edit, in order), or a Write (a new digest only): the first 32 hex digits of the SHA-256 of the exact text, so a line ending is a difference and the same text gives the same digest in any session. Never the text.',
74
+ dirKind: 'The kind of the folders a path argument names, one of a closed list: root, source, test, doc, generated, hidden, other; never a folder name.',
66
75
  durationMs: 'The time of one tool call in milliseconds, exact, a number or null.',
67
76
  effort: 'The effort level the model ran at, only when it is a public one; else the literal placeholder.',
77
+ enumValue: 'A closed value of one argument: true, false, or a member of its tool\'s closed list for the key.',
68
78
  errorCode: 'Why a tool call failed, one class of a closed list: an exit class (0, 1, 2, 126, 127, signal), an HTTP status class, a harness class (user-rejected, interrupted, permission-denied, input-validation, tool-use-error, timeout), or other; null on a call that did not fail or whose harness records no class. Never result text.',
69
79
  editFollowed: 'Whether one of the calls that followed a failed call wrote a file, true or false: what followed, never that the failure was fixed.',
70
80
  errors: 'A bucket of how many tool calls failed in a prompt, a number.',
71
81
  explore: 'A bucket of how many steps of a prompt only looked (read a file, searched files, searched the web, or fetched a page), a number or null; never the exact count.',
82
+ extension: 'The class of a path argument\'s extension, one of a closed list: source, doc, config, data, image, none, other; never the extension or the name.',
72
83
  field: 'What a delta is about, one of plugin, package, permissionMode, and effort: the field alone, never its value.',
73
84
  flags: 'The flags of a shell command, each a flag name, never its value: the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters. Capture still drops a subcommand or flag off the pinned vocabulary of its program, so a real session sends no off-list subcommand (null) and no off-list flag; a subcommand or flag the vocabulary lists goes as its name. A multi-letter single-dash flag no public list holds (`-race`) is dropped.',
74
85
  fieldsVersion: 'The version of the field list this client builds a batch from: a constant of this client, never a value from a session.',
@@ -78,14 +89,17 @@ export const FIELD_ROWS = {
78
89
  edges: 'The edges arriving at a step, each a kind and the position of the step it names, in the same transcript or, for a spawned edge, in the transcript that spawned this one; absent when none arrive.',
79
90
  frames: 'The distinct frames a transcript\'s steps ran in, each as a frame above with the reach of the session; a step names one by position.',
80
91
  frameworks: 'The frameworks of the project, each from a closed public list; never a path or a name of yours.',
92
+ from: 'The first call of a recommendation\'s window, a position in the main run\'s calls that are not sidechain, a number; with `to`, the calls a recommendation covers. No text and no time.',
81
93
  harness: 'Which harness wrote the session, one of a closed list.',
82
94
  harnessVersion: 'The harness version: the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters. A transcript or prompt frame carries the value capture closed it to (the public name, else `<literal>`), sent as it is; the version of a call, which capture keeps as its line names it, goes as the string after the gate.',
83
95
  host: 'The host of a web fetch or search, the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters; null when the step names no host. Capture keeps the host as the address names it, without credentials, path, or query. The pool replaces a host with a keyed hash on arrival and serves it only once enough accounts report it.',
96
+ hostClass: 'Where a session on a machine ran, one of a closed list: local, vps, cloud, ci, or hosted; stated by the trusted machine from its own enrollment record, never by the machine that built the batch.',
84
97
  hour: 'The UTC hour of day, 0 to 23, beside the day; never a minute, a second, or an offset.',
85
- id: "A hash the client computes per account from the account and the session, so a redeposit replaces and two accounts' sessions cannot be joined. Never the session id.",
98
+ id: "A hash the client computes per account from the account and the session (for a machine, from the account's session salt and the session's name), so a redeposit replaces and two accounts' sessions cannot be joined. Never the session id.",
86
99
  identity: 'What a step called: a shell program with its subcommand and flags, an MCP server and tool, a skill, or a web host, each a name after redaction or `private`; null for an action with none.',
87
100
  input: 'A bucket of the input tokens in a prompt, a number, or null when the source never recorded it.',
88
- kind: 'How an edge came about, one of a closed list: next, spawned, retried, or after-error.',
101
+ key: 'The key name an argument feature or closed value belongs to: one of the call\'s sent keys.',
102
+ kind: 'How an edge came about, one of a closed list: next, spawned, retried, or after-error; in a name token, what the name is, one of a closed list: file, dir, env, flag, or agent.',
89
103
  keys: 'The input key names of a call, sorted: a public tool\'s from its published list and any other tool\'s only when each passes the name gate; none for a tool that went as private. Names only, never a value.',
90
104
  keysDropped: 'A bucket of the key names of a call that were not sent, off a public tool\'s list or kept private by the gate, a number; never the names.',
91
105
  intervention: 'Whether a human stepped in after a prompt\'s work and how, one of a closed list: none, after-error (the prompt\'s last call failed), redo (the next prompt edited the same file), revert (the next prompt undid the edit), user-stop (the human rejected a call or interrupted the turn), or unknown; absent when the prompt predates the signal. An outcome row, not a grade; never the next prompt\'s words.',
@@ -93,21 +107,33 @@ export const FIELD_ROWS = {
93
107
  languageShare: 'How much of the files a session touched are in one language, one of four buckets: none, under 10 percent, 10 up to 50, or 50 and over; never a count of files.',
94
108
  languageShares: 'The share bucket of each language of the project whose share is known; a language with no share is in the languages list alone.',
95
109
  languages: 'The programming languages of the project, each from a closed public list.',
110
+ launcher: 'What started a session on a machine whose batch a trusted machine admitted, one of a closed list: interactive, orchestrated, scheduled, ci, hosted-run, or unknown; stated by the trusted machine from its own enrollment record, never by the machine that built the batch.',
96
111
  leaves: 'How many lines of a transcript its root covers, a count.',
97
112
  merkleRoot: 'The Merkle root of a transcript: a SHA-256 over the hashes of its lines, one root for each transcript, the main one first; never a line.',
98
113
  metric: 'What a delta measures, calls per session or thinking tokens, one of a closed list the field fixes.',
99
114
  model: 'The model name: the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters. A transcript or prompt frame carries the value capture closed it to (the public name, else `<literal>`), sent as it is; the model of a call, which capture keeps as its line names it, goes as the string after the gate.',
100
115
  ms: 'A bucket of the milliseconds a step took (a lower bound), a number or null; never the exact time.',
101
- name: 'The name of a package a shell command pulled or a delta removed, as capture kept it: the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters. A plugin in a delta is always private, and null when a delta names its field alone.',
116
+ name: 'The name of a package a shell command pulled or a delta removed, or a name token of a call (a file, folder, or variable name), as capture kept it: the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters (a name token that fails is dropped and counted, never sent as `private`). A plugin in a delta is always private, and null when a delta names its field alone.',
102
117
  native: 'The name the harness gave the tool: the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters.',
118
+ names: 'The name tokens of a call\'s values, sorted by kind and name: file names, the names of the folders of a path below the session root, environment variable names, flag values that are names, and a custom agent\'s name, each with its kind. A name is sent as written, so it can identify a project; one the redactor changes, an exclusion term matches, or that is not a name is dropped; never a value, a path, or a command.',
119
+ namesDropped: 'A bucket of the name tokens of a call that were not sent, because the redactor changes them, an exclusion term matches them, or they are not names, a number; never the names.',
120
+ new: 'The digest of the text an edit wrote, 32 hex digits, in a digest pair; never the text.',
121
+ templates: 'One template per shell command of a call, in the order the commands run: its program\'s base name in lower case, without a Windows launcher suffix, its subcommand where the program\'s own public list has one, and its flags that are on the public list or one letter, as written, and every other word as a slot (`<text>`, `<path>`, `<ident>`, `<num>`, `<url>`, `<var>`, `<flag>`), so `git commit -m <text>` and `pytest <path> -k <ident>`; a program given as a path is `<path>`, and the word after a credential, text, or one-letter flag (but `-k` on pytest) is `<text>`. A kept word is sent as written, so it can identify a project; a template with a word the redactor changes, an exclusion term matches, or that is not a name is dropped whole; never an argument, a path, or a value.',
122
+ diffShape: 'What an edit did, a sorted set from a closed list: import-added, import-removed, identifier-renamed, lines-added, lines-removed, lines-changed, whitespace-only, file-written. The shape of the change, read on the machine from the old and new text; never the text, a line, or a name.',
123
+ templatesDropped: 'A bucket of the templates of a call that were not sent, because a program, subcommand, or flag in them is changed by the redactor, matched by an exclusion term, or not a name, a number; never the templates.',
103
124
  next: 'The actions of up to the next 5 calls of the same prompt after a failed call, each one of the closed list of canonical actions: what followed, never a fix; no command, path, or text.',
125
+ old: 'The digest of the text an edit replaced, 32 hex digits, in a digest pair; absent for a Write, whose call does not hold the file\'s earlier text, and never the digest of an empty string.',
104
126
  output: 'A bucket of the output tokens in a prompt, a number, or null when the source never recorded it.',
105
127
  parent: 'The position of the call that spawned this step, in the tools of the transcript that spawnedIn names, a number, or null.',
128
+ pathDepth: 'How many folders a path argument sits below the repository or the working folder, a bucket: 0, 1, 2, 3-4, 5-8, over-8, or outside for an absolute path under neither; never the path.',
129
+ patternFeatures: 'What a pattern argument uses, a sorted set from a closed list: alternation, anchor, class, escape, group, literal, quantifier, recursive, wildcard; never the pattern.',
106
130
  platform: 'The operating system family, one of a closed list.',
131
+ poolVersion: 'The deposit time of the pool snapshot a recommendation answered from, as the local row holds it.',
107
132
  program: 'The program a shell command ran, its basename only: the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters.',
108
133
  prompt: 'Which prompt of the batch a tool call follows, a position in this batch, or null.',
109
134
  prompts: 'The prompts of a transcript: each a class, bucketed counts and tokens, and a frame; never their words.',
110
135
  pulls: 'The packages a shell command pulled from a registry: the registry, the public package name, and its version.',
136
+ recommendations: 'The recommendations the main session was shown or held back from, each an id, an arm, the pool version it answered from, and a window of calls; never the query, the text shown, or the evidence. Absent when the session had none, which the pool reads as unknown exposure, never as unexposed.',
111
137
  recoveryCalls: 'For a failed call, what followed it: the actions of up to its next 5 calls and whether one wrote a file. Absent on a call that did not fail; never a signature or text.',
112
138
  registry: 'Which registry a pull or a removed package came from, one of a closed list; null for a delta whose name is private or absent.',
113
139
  remainingTokens: 'A bucket of the tokens the run spent after a step (a lower bound), a number, or null when steps follow and none reported tokens: the bucket of the exact sum, with the subagent runs a main-run step spawned later in the session.',
@@ -133,12 +159,14 @@ export const FIELD_ROWS = {
133
159
  target: 'The position of the step an edge names, a number counted over the steps of one transcript in call order.',
134
160
  targetClass: 'The class of the path a call touched, one of a closed list: source, test, config, doc, lockfile, generated, data, none, other; never the path or its name.',
135
161
  timingPrint: 'A hash of the call durations of the main transcript in the order they ran, or null under 50 timed calls; the calls of a subagent are not in it.',
162
+ to: 'The end of a recommendation\'s window, one past its last call, a position in the main run\'s calls that are not sidechain, a number; with `from`, the calls a recommendation covers.',
136
163
  tokens: 'A bucket of tokens (a lower bound of the count), a number or null; a step holds one, never the exact count.',
137
164
  tool: 'The tool of an MCP call: the name as it is after redaction, beside a server that is not private and in a harness whose spelling delimits the server; else the literal placeholder.',
138
165
  tools: 'The tool calls of a transcript, each a status and duration and its steps, in the order they ran; never their targets or arguments.',
139
166
  transcripts: 'The main transcript, then each subagent: its frame, prompts, and tool calls, every value checked against its field\'s list or the name shape before the batch is built.',
140
167
  usage: 'The tokens of a prompt as buckets (lower bounds): input, cache write, cache read, output, and thinking.',
141
168
  usageResponses: 'A bucket of the responses of a prompt that reported usage, what a partial usage sum covers; left out only for a record captured before the coverage existed (capture version 20).',
169
+ valueSize: 'The size of an argument\'s value as written, a bucket of its length: 0, 1-8, 9-32, 33-128, 129-512, 513-2k, 2k-8k, over-8k; never the value.',
142
170
  version: 'The version of a pulled package, only when it is a registry version; else the literal placeholder or null.',
143
171
  vocabVersion: 'The version of the closed vocabulary this client built the batch under: a constant of this client, never a value from a session.',
144
172
  wallSec: 'A bucket of the seconds a prompt took, or null.',
@@ -159,13 +187,13 @@ export const writeBatch = async (outbox, key, batch, stamp = currentStamp()) =>
159
187
  const bytes = canonicalBytes(batch);
160
188
  const hash = batchHash(bytes);
161
189
  const folder = join(outbox, stamp);
162
- await mkdir(folder, { recursive: true });
190
+ await mkdirOwnerOnly(folder);
163
191
  for (const name of await readdir(folder)) {
164
192
  const found = BATCH_FILE.exec(name);
165
193
  if (found?.[1] === key && found[2] !== hash)
166
194
  await rm(join(folder, name), { force: true });
167
195
  }
168
- await writeFile(join(folder, `${key}.${hash}.jsonl`), bytes, 'utf8');
196
+ await writeOwnerOnly(join(folder, `${key}.${hash}.jsonl`), bytes);
169
197
  return { hash, folder, bytes: Buffer.byteLength(bytes) };
170
198
  };
171
199
  export const isMissing = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
@@ -186,9 +214,12 @@ const namesIn = async (folder) => {
186
214
  export const removeBatchesExcept = async (outbox, kept, stamp = currentStamp(), only) => {
187
215
  const folder = join(outbox, stamp);
188
216
  let removed = 0;
189
- for (const name of await namesIn(folder)) {
217
+ const names = await namesIn(folder);
218
+ // A reader is asked once the names are in hand: a key it reads then covers every batch listed above.
219
+ const keep = typeof kept === 'function' ? await kept() : kept;
220
+ for (const name of names) {
190
221
  const found = BATCH_FILE.exec(name);
191
- if (found !== null && !kept.has(found[1] ?? '') && (only === undefined || found[1] === only)) {
222
+ if (found !== null && !keep.has(found[1] ?? '') && (only === undefined || found[1] === only)) {
192
223
  await rm(join(folder, name), { force: true });
193
224
  removed += 1;
194
225
  }
@@ -210,9 +241,14 @@ export const unchangedBytes = async (file, hash) => {
210
241
  }
211
242
  return batchHash(bytes) === hash ? bytes : undefined;
212
243
  };
213
- /** The batches built under this client's vocabulary, field list, and skeleton format, and how many of their files no longer hash to their names. An edited file is not printed and cannot be sent: run `deposit` again to rebuild it. */
244
+ /**
245
+ * The batches filed under this stamp, and how many of their files no longer hash to their names. An edited file is not printed and cannot be sent: run `deposit` again to rebuild it. Under the current stamp that is every batch this client built;
246
+ * under any other stamp it is only a batch `pulled.json` lists there, since anything else in that folder is stale and is never printed or sent.
247
+ */
214
248
  export const heldBatches = async (outbox, stamp = currentStamp()) => {
215
249
  const folder = join(outbox, stamp);
250
+ const listed = await pulledAt(outbox, stamp);
251
+ const here = stamp === currentStamp();
216
252
  const batches = [];
217
253
  let edited = 0;
218
254
  for (const name of await namesIn(folder)) {
@@ -220,20 +256,72 @@ export const heldBatches = async (outbox, stamp = currentStamp()) => {
220
256
  if (found === null)
221
257
  continue;
222
258
  const [, key = '', hash = ''] = found;
259
+ if (!here && !listed.has(key))
260
+ continue;
223
261
  const file = join(folder, name);
224
262
  const bytes = await unchangedBytes(file, hash);
225
263
  if (bytes === undefined)
226
264
  edited += 1;
227
265
  else
228
- batches.push({ key, hash, file, bytes });
266
+ batches.push({ key, stamp, pulled: listed.has(key), hash, file, bytes });
229
267
  }
230
268
  return { batches, edited };
231
269
  };
270
+ /** The stamps a send reads: the current one, then every other stamp `pulled.json` lists. A listed name that is not a stamp folder is not a path this reads. */
271
+ export const sendStamps = async (outbox) => {
272
+ const others = new Set(Object.values(await readPulled(outbox, POOL_STEP)).filter((stamp) => stamp !== currentStamp() && STAMP_FOLDER.test(stamp)));
273
+ return [currentStamp(), ...[...others].sort()];
274
+ };
275
+ /** `heldBatches` over every stamp a send reads, the current one first. */
276
+ export const heldAcross = async (outbox) => {
277
+ const all = [];
278
+ let edited = 0;
279
+ for (const stamp of await sendStamps(outbox)) {
280
+ const held = await heldBatches(outbox, stamp);
281
+ all.push(...held.batches);
282
+ edited += held.edited;
283
+ }
284
+ return { batches: all, edited };
285
+ };
286
+ /** The held batch of one session under this stamp, read whole only when its file still hashes to its name; undefined when the session has none, or its file was edited. */
287
+ export const heldBatchOf = async (outbox, key, stamp = currentStamp()) => {
288
+ const folder = join(outbox, stamp);
289
+ for (const name of await namesIn(folder)) {
290
+ const found = BATCH_FILE.exec(name);
291
+ if (found?.[1] !== key)
292
+ continue;
293
+ const hash = found[2] ?? '';
294
+ const file = join(folder, name);
295
+ const bytes = await unchangedBytes(file, hash);
296
+ if (bytes !== undefined)
297
+ return { key, hash, file, bytes };
298
+ }
299
+ return undefined;
300
+ };
301
+ const machineKeysFile = (outbox) => join(outbox, 'machine-keys.log');
302
+ /** The batch keys a machine's Stop built, one per line. A person's `oars pool deposit` shares the outbox and cannot recompute them (the machine's name is in the key), so its sweep keeps these instead of treating them as batches of sessions the store lost. */
303
+ export const machineKeys = async (outbox) => {
304
+ try {
305
+ return new Set((await readFile(machineKeysFile(outbox), 'utf8')).split('\n').filter((line) => line !== ''));
306
+ }
307
+ catch (error) {
308
+ if (isAbsent(error))
309
+ return new Set();
310
+ throw error;
311
+ }
312
+ };
313
+ /** Notes that a machine built this key; a key already noted adds no line. */
314
+ export const noteMachineKey = async (outbox, key) => {
315
+ if ((await machineKeys(outbox)).has(key))
316
+ return;
317
+ await mkdirOwnerOnly(outbox);
318
+ await appendOwnerOnly(machineKeysFile(outbox), `${key}\n`);
319
+ };
232
320
  const shownFile = (outbox) => join(outbox, 'shown.log');
233
321
  /** Records that this hash was printed, one line for each print: the lock below sends only a hash that appears here. */
234
322
  export const markShown = async (outbox, hash, at) => {
235
- await mkdir(outbox, { recursive: true });
236
- await appendFile(shownFile(outbox), `${hash} ${new Date(at).toISOString()}\n`, 'utf8');
323
+ await mkdirOwnerOnly(outbox);
324
+ await appendOwnerOnly(shownFile(outbox), `${hash} ${new Date(at).toISOString()}\n`);
237
325
  };
238
326
  const wasShown = async (outbox, hash) => {
239
327
  try {
@@ -246,12 +334,18 @@ const wasShown = async (outbox, hash) => {
246
334
  }
247
335
  };
248
336
  /**
249
- * The lock on a send: a batch goes only if it is in the outbox, its hash was printed, and its file still hashes to that hash. The first check that fails names why. The nightly task sends with `requireShown: false`,
337
+ * The lock on a send: a batch goes only if it is in the outbox, its hash was printed, and its file still hashes to that hash. The first check that fails names why. The detached sender (hook-bin.js nightly) sends with `requireShown: false`,
250
338
  * because covenant acceptance is its consent and it prints nothing; the other two checks still hold.
251
339
  */
252
340
  export const lockedBatch = async (outbox, hash, options = {}) => {
253
- const folder = join(outbox, options.stamp ?? currentStamp());
254
- const name = (await namesIn(folder)).find((entry) => BATCH_FILE.exec(entry)?.[2] === hash);
341
+ const stamp = options.stamp ?? currentStamp();
342
+ const folder = join(outbox, stamp);
343
+ // Under any stamp but the current one, only a pulled batch is a batch: the rest of that folder is stale.
344
+ const listed = stamp === currentStamp() ? undefined : await pulledAt(outbox, stamp);
345
+ const name = (await namesIn(folder)).find((entry) => {
346
+ const found = BATCH_FILE.exec(entry);
347
+ return found?.[2] === hash && (listed === undefined || listed.has(found[1] ?? ''));
348
+ });
255
349
  if (name === undefined)
256
350
  return { ok: false, code: 'batch_unknown' };
257
351
  if (options.requireShown !== false && !(await wasShown(outbox, hash)))
@@ -260,6 +354,83 @@ export const lockedBatch = async (outbox, hash, options = {}) => {
260
354
  const bytes = await unchangedBytes(file, hash);
261
355
  return bytes === undefined ? { ok: false, code: 'batch_changed' } : { ok: true, file, bytes };
262
356
  };
357
+ const PULLED = 'pulled.json';
358
+ /** `pulled.json` as it stands; none when the file is missing. A file that is not a list of ids and stamps fails the command that reads it, so nothing is pruned or sent on a guess. */
359
+ export const readPulled = async (outbox, step) => {
360
+ let text;
361
+ try {
362
+ text = await readFile(join(outbox, PULLED), 'utf8');
363
+ }
364
+ catch (error) {
365
+ if (isMissing(error))
366
+ return {};
367
+ throw error;
368
+ }
369
+ let value;
370
+ try {
371
+ value = JSON.parse(text);
372
+ }
373
+ catch {
374
+ value = undefined;
375
+ }
376
+ if (typeof value !== 'object' || value === null || Array.isArray(value) || !Object.values(value).every((stamp) => typeof stamp === 'string')) {
377
+ throw new CliError(`${join(outbox, PULLED)} is not a list of ids and stamps. Move it aside and run the command again; nothing was changed.`, ExitCode.INVALID, step);
378
+ }
379
+ return Object.fromEntries(Object.entries(value).map(([id, stamp]) => [id, String(stamp)]));
380
+ };
381
+ /** Staged and renamed, so a crash leaves the whole old file or the whole new one. */
382
+ export const writePulled = async (outbox, pulled) => {
383
+ await mkdirOwnerOnly(outbox);
384
+ const staged = join(outbox, `${PULLED}.tmp`);
385
+ await writeOwnerOnly(staged, `${JSON.stringify(Object.fromEntries(Object.entries(pulled).sort(([a], [b]) => (a < b ? -1 : 1))), null, 2)}\n`);
386
+ await rename(staged, join(outbox, PULLED));
387
+ };
388
+ const POOL_STEP = 'pool-outbox';
389
+ /** Whether any string or key in `value` holds one of `terms`, compared lowercased the way the local gate compares a transcript. */
390
+ export const holdsTerm = (value, terms) => {
391
+ if (typeof value === 'string') {
392
+ const lower = value.toLowerCase();
393
+ return terms.some((term) => lower.includes(term));
394
+ }
395
+ if (Array.isArray(value))
396
+ return value.some((entry) => holdsTerm(entry, terms));
397
+ if (typeof value === 'object' && value !== null)
398
+ return Object.entries(value).some(([key, entry]) => holdsTerm(key, terms) || holdsTerm(entry, terms));
399
+ return false;
400
+ };
401
+ /**
402
+ * The exclusion rescan of a send pass: every pulled batch is read against the trusted machine's current term file, and one that now holds a term is removed, with its `pulled.json` entry, and its hash returned. A batch can wait as long as the covenant
403
+ * stays closed, so the terms it was admitted under may not be the terms now. With no terms (a missing or empty file) nothing can be checked: `open` is false, nothing is removed, and the caller sends no pulled batch.
404
+ */
405
+ export const rescanPulled = async (outbox, terms) => {
406
+ if (terms.length === 0)
407
+ return { open: false, removed: [] };
408
+ const pulled = await readPulled(outbox, POOL_STEP);
409
+ const removed = [];
410
+ for (const [id, stamp] of Object.entries(pulled)) {
411
+ const held = STAMP_FOLDER.test(stamp) ? await heldBatchOf(outbox, id, stamp) : undefined;
412
+ // The file is canonical JSON this client wrote, and its bytes just hashed to its name.
413
+ if (held === undefined || !holdsTerm(JSON.parse(held.bytes), terms))
414
+ continue;
415
+ await rm(held.file, { force: true });
416
+ Reflect.deleteProperty(pulled, id);
417
+ removed.push(held.hash);
418
+ }
419
+ if (removed.length > 0)
420
+ await writePulled(outbox, pulled);
421
+ return { open: true, removed };
422
+ };
423
+ /** The keys `pulled.json` lists at this stamp: the batches of that folder that no local build made and no stale pass may touch. */
424
+ export const pulledAt = async (outbox, stamp = currentStamp()) => new Set(Object.entries(await readPulled(outbox, POOL_STEP)).filter(([, at]) => at === stamp).map(([id]) => id));
425
+ /** `lockedBatch` over every stamp a send reads: the first stamp that holds the hash answers, whether it locks or refuses. */
426
+ export const lockedAcross = async (outbox, hash, options = {}) => {
427
+ for (const stamp of await sendStamps(outbox)) {
428
+ const locked = await lockedBatch(outbox, hash, { ...options, stamp });
429
+ if (locked.ok || locked.code !== 'batch_unknown')
430
+ return locked;
431
+ }
432
+ return { ok: false, code: 'batch_unknown' };
433
+ };
263
434
  /** Folders of an outbox other than the current stamp's: batches built under another vocabulary, field list, or skeleton format. */
264
435
  const staleFolders = async (outbox, stamp) => {
265
436
  const names = [];
@@ -269,13 +440,21 @@ const staleFolders = async (outbox, stamp) => {
269
440
  }
270
441
  return names;
271
442
  };
272
- const STAMP_FOLDER = /^v[^./]+-[0-9a-f]{8}$/;
273
- const batchesIn = async (folder) => (await namesIn(folder)).filter((name) => BATCH_FILE.test(name)).length;
274
- /** Batches in one server's outbox that were built under another vocabulary, field list, or skeleton format: never printed, never sent. */
443
+ /** The name of a stamp folder: a vocabulary version, then eight hex digits of the field list and format. No dot or slash, so a name that matches is one path segment. */
444
+ export const STAMP_FOLDER = /^v[^./]+-[0-9a-f]{8}$/;
445
+ /** The keys of the batch files in one folder. */
446
+ const keysIn = async (folder) => (await namesIn(folder)).flatMap((name) => BATCH_FILE.exec(name)?.[1] ?? []);
447
+ /** The batch files of one stale folder, and which of them a stale pass may touch: a key `pulled.json` lists at this folder's stamp is spared, by key and not by folder, so a local batch sharing the folder is still stale. */
448
+ const staleIn = async (folder, pulled) => {
449
+ const stamp = basename(folder);
450
+ return (await keysIn(folder)).filter((key) => pulled[key] !== stamp);
451
+ };
452
+ /** Batches in one server's outbox that were built under another vocabulary, field list, or skeleton format: never printed, never sent. A pulled batch is not one of them, at the stamp it is listed under. */
275
453
  export const staleBatches = async (outbox, stamp = currentStamp()) => {
454
+ const pulled = await readPulled(outbox, POOL_STEP);
276
455
  let count = 0;
277
456
  for (const folder of await staleFolders(outbox, stamp))
278
- count += await batchesIn(folder);
457
+ count += (await staleIn(folder, pulled)).length;
279
458
  return count;
280
459
  };
281
460
  /** Every server's stale batches, for `verify`. */
@@ -286,11 +465,25 @@ export const staleBatchesAnywhere = async (storeDir, stamp = currentStamp()) =>
286
465
  count += await staleBatches(join(root, name), stamp);
287
466
  return count;
288
467
  };
289
- /** Removes one server's stale batches, and returns how many there were. */
468
+ /** Removes one server's stale batches, and returns how many there were. A folder that holds a pulled batch loses only the files of the others; one that holds none goes whole. */
290
469
  export const discardStale = async (outbox, stamp = currentStamp()) => {
291
- const count = await staleBatches(outbox, stamp);
292
- for (const folder of await staleFolders(outbox, stamp))
293
- await rm(folder, { recursive: true, force: true });
470
+ const pulled = await readPulled(outbox, POOL_STEP);
471
+ let count = 0;
472
+ for (const folder of await staleFolders(outbox, stamp)) {
473
+ const listed = Object.values(pulled).includes(basename(folder));
474
+ if (!listed) {
475
+ count += (await keysIn(folder)).length;
476
+ await rm(folder, { recursive: true, force: true });
477
+ continue;
478
+ }
479
+ const stale = await staleIn(folder, pulled);
480
+ for (const name of await namesIn(folder)) {
481
+ const found = BATCH_FILE.exec(name);
482
+ if (found === null || stale.includes(found[1] ?? ''))
483
+ await rm(join(folder, name), { recursive: true, force: true });
484
+ }
485
+ count += stale.length;
486
+ }
294
487
  return count;
295
488
  };
296
489
  const collect = (value, into) => {
@@ -1,6 +1,6 @@
1
1
  /** One request as it will leave this machine. */
2
2
  export interface Outgoing {
3
- method: 'GET' | 'POST' | 'PUT';
3
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
4
4
  url: URL;
5
5
  /** Every header this code sets. The HTTP layer adds `host`, `connection`, and `content-length` itself, and `printed` lists those too. */
6
6
  headers: Array<[string, string]>;
@@ -1,8 +1,9 @@
1
1
  // The raw part of the outbox (t884): one redacted copy of each transcript of a session that passed the gates, in the same outbox as the batches and printed by the same command. A copy is named for its
2
2
  // session key, its place among the session's transcripts (0 is the main one), and the SHA-256 of its redacted bytes, which is the hash the pool's `/v1/raw/:sha256` routes take.
3
3
  import { createHash } from 'node:crypto';
4
- import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { readdir, readFile, rm } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
+ import { mkdirOwnerOnly, writeOwnerOnly } from './owner-only.js';
6
7
  import { partsOf } from './pool-outbox.js';
7
8
  import { REDACTION_VERSION } from './redact.js';
8
9
  import { CAPTURE_VERSION } from './schema.js';
@@ -24,13 +25,13 @@ const namesIn = async (folder) => {
24
25
  export const writeRaw = async (outbox, key, place, bytes) => {
25
26
  const folder = join(outbox, rawStamp());
26
27
  const hash = rawHash(bytes);
27
- await mkdir(folder, { recursive: true });
28
+ await mkdirOwnerOnly(folder);
28
29
  for (const name of await namesIn(folder)) {
29
30
  const found = RAW_FILE.exec(name);
30
31
  if (found?.[1] === key && Number(found[2]) === place && found[3] !== hash)
31
32
  await rm(join(folder, name), { force: true });
32
33
  }
33
- await writeFile(join(folder, `${key}.${String(place)}.${hash}.redacted`), bytes, 'utf8');
34
+ await writeOwnerOnly(join(folder, `${key}.${String(place)}.${hash}.redacted`), bytes);
34
35
  return { hash, bytes: Buffer.byteLength(bytes) };
35
36
  };
36
37
  /** The raw copies under the current stamp that still hash to their names; `edited` counts those that no longer do, which are neither printed nor sent. */
@@ -1,4 +1,4 @@
1
- import { type Bundle, type PinnedKey } from './pool-wire.js';
1
+ import { type Bundle, type PinnedKeys } from './pool-wire.js';
2
2
  import type { Harness } from './schema.js';
3
3
  export declare const textIn: (value: object, key: string) => string | undefined;
4
4
  export declare const numberIn: (value: object, key: string) => number | undefined;
@@ -9,8 +9,8 @@ export interface SignIn {
9
9
  account: string;
10
10
  token: string;
11
11
  expiresAt: number;
12
- /** Pinned at the first login to this server; every bundle is checked against it. */
13
- signingKey: PinnedKey;
12
+ /** The keys pinned for this server, by key id: each is trusted at the first login that names its id and never replaced, and every bundle is checked against the one its `kid` names. */
13
+ signingKeys: PinnedKeys;
14
14
  }
15
15
  export declare const signInOf: (value: unknown) => SignIn | undefined;
16
16
  export declare const signInsFile: (storeDir: string) => string;
@@ -19,7 +19,7 @@ export declare const loadSignIns: (storeDir: string, step: string) => Promise<Si
19
19
  /** One folder per harness under the server's, so a fetch for one harness never overwrites another's bundle of the same version. */
20
20
  export declare const replicaFile: (storeDir: string, server: string, harness: Harness, harnessVersion: string) => string;
21
21
  /** What an earlier fetch kept: the bundle, checked again against the pinned key, and the ETag the pool sent with it. Nothing readable and verified is nothing kept, and the next fetch is then an ordinary one. */
22
- export declare const keptReplica: (file: string, key: PinnedKey, harness: Harness, harnessVersion: string) => Promise<{
22
+ export declare const keptReplica: (file: string, keys: PinnedKeys, harness: Harness, harnessVersion: string) => Promise<{
23
23
  etag: string | undefined;
24
24
  bundle: Bundle;
25
25
  } | undefined>;
@@ -5,7 +5,7 @@ import { readFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { CliError, ExitCode } from '../errors.js';
7
7
  import { isMissing } from './pool-outbox.js';
8
- import { isBundleVersion, verifyBundle } from './pool-wire.js';
8
+ import { isBundleVersion, isKeyId, LEGACY_KEY_ID, verifyBundle } from './pool-wire.js';
9
9
  import { contentHash } from './read.js';
10
10
  export const textIn = (value, key) => {
11
11
  const found = Reflect.get(value, key);
@@ -22,12 +22,31 @@ const keyOf = (value) => {
22
22
  const [kty, crv, x] = [textIn(value, 'kty'), textIn(value, 'crv'), textIn(value, 'x')];
23
23
  return kty === 'OKP' && crv === 'Ed25519' && x !== undefined ? { kty, crv, x } : undefined;
24
24
  };
25
+ /** Keys as a sign-in file or the pool's login answer states them: a `signingKeys` object by key id, or the one `signingKey` the pool names with `kid` (a `kid` that is no key id, as the pool sends them, is refused; a file from before key ids has no `kid`, and pins under `LEGACY_KEY_ID`). */
26
+ const keysOf = (value) => {
27
+ const set = Reflect.get(value, 'signingKeys');
28
+ if (set === undefined) {
29
+ const single = keyOf(Reflect.get(value, 'signingKey'));
30
+ const kid = textIn(value, 'kid') ?? LEGACY_KEY_ID;
31
+ return single === undefined || !isKeyId(kid) ? undefined : { [kid]: single };
32
+ }
33
+ if (!isObject(set))
34
+ return undefined;
35
+ const keys = {};
36
+ for (const [kid, entry] of Object.entries(set)) {
37
+ const key = keyOf(entry);
38
+ if (key === undefined || !isKeyId(kid))
39
+ return undefined;
40
+ keys[kid] = key;
41
+ }
42
+ return Object.keys(keys).length === 0 ? undefined : keys;
43
+ };
25
44
  export const signInOf = (value) => {
26
45
  if (!isObject(value))
27
46
  return undefined;
28
47
  const [server, account, token, expiresAt] = [textIn(value, 'server'), textIn(value, 'account'), textIn(value, 'token'), numberIn(value, 'expiresAt')];
29
- const signingKey = keyOf(Reflect.get(value, 'signingKey'));
30
- return server === undefined || account === undefined || token === undefined || expiresAt === undefined || signingKey === undefined ? undefined : { server, account, token, expiresAt, signingKey };
48
+ const signingKeys = keysOf(value);
49
+ return server === undefined || account === undefined || token === undefined || expiresAt === undefined || signingKeys === undefined ? undefined : { server, account, token, expiresAt, signingKeys };
31
50
  };
32
51
  export const signInsFile = (storeDir) => join(storeDir, 'pool-accounts.json');
33
52
  /** Every saved sign-in. A file that cannot be read stops the command: treating it as empty would forget a pin and trust whatever key the server names next. */
@@ -63,7 +82,7 @@ export const loadSignIns = async (storeDir, step) => {
63
82
  /** One folder per harness under the server's, so a fetch for one harness never overwrites another's bundle of the same version. */
64
83
  export const replicaFile = (storeDir, server, harness, harnessVersion) => join(storeDir, 'pool-replica', contentHash(server), harness, `${encodeURIComponent(harnessVersion)}.json`);
65
84
  /** What an earlier fetch kept: the bundle, checked again against the pinned key, and the ETag the pool sent with it. Nothing readable and verified is nothing kept, and the next fetch is then an ordinary one. */
66
- export const keptReplica = async (file, key, harness, harnessVersion) => {
85
+ export const keptReplica = async (file, keys, harness, harnessVersion) => {
67
86
  let parsed;
68
87
  try {
69
88
  parsed = JSON.parse(await readFile(file, 'utf8'));
@@ -71,7 +90,7 @@ export const keptReplica = async (file, key, harness, harnessVersion) => {
71
90
  catch {
72
91
  return undefined;
73
92
  }
74
- const verified = await verifyBundle(parsed, key, harness, harnessVersion);
93
+ const verified = await verifyBundle(parsed, keys, harness, harnessVersion);
75
94
  return verified.ok ? { etag: isObject(parsed) ? textIn(parsed, 'etag') : undefined, bundle: verified.bundle } : undefined;
76
95
  };
77
96
  /**
@@ -82,8 +101,8 @@ export const sessionBundle = async (storeDir, setup) => {
82
101
  if (setup === undefined || !isBundleVersion(setup.harnessVersion))
83
102
  return undefined;
84
103
  const signIns = await loadSignIns(storeDir, 'recall').catch(() => []);
85
- for (const { server, signingKey } of signIns) {
86
- const kept = await keptReplica(replicaFile(storeDir, server, setup.harness, setup.harnessVersion), signingKey, setup.harness, setup.harnessVersion);
104
+ for (const { server, signingKeys } of signIns) {
105
+ const kept = await keptReplica(replicaFile(storeDir, server, setup.harness, setup.harnessVersion), signingKeys, setup.harness, setup.harnessVersion);
87
106
  if (kept !== undefined)
88
107
  return kept.bundle;
89
108
  }
@@ -1,4 +1,4 @@
1
- // One sender at a time per outbox (t884): the nightly task and `deposit --send` both take this lock, so a batch is never posted twice by two runs that overlap. The lock is a file created
1
+ // One sender at a time per outbox: the detached sender (hook-bin.js nightly) and `deposit --send` both take this lock, so a batch is never posted twice by two runs that overlap. The lock is a file created
2
2
  // with `wx` beside `sent.log`, so it holds across processes as well as inside one. A run that finds it held waits; a lock older than `staleMs` belongs to a run that died, and is taken over by one waiter only, under a guard file. The holder touches the file while it runs, so a long send (a first night of raw parts) is never mistaken for a dead run, and it removes the file only if the file still names its own run.
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { mkdir, open, readFile, rm, stat, utimes } from 'node:fs/promises';
@@ -2,3 +2,5 @@
2
2
  export declare const sentLog: (outbox: string) => Promise<Map<string, string>>;
3
3
  export declare const sentHashes: (outbox: string) => Promise<Set<string>>;
4
4
  export declare const markSent: (outbox: string, hash: string, at: number) => Promise<void>;
5
+ /** Drops every `pulled.json` entry whose batch hash is in `sent.log`, and returns how many went. The sent batch file stays until the next prune; `sent.log` is what stops a second post. */
6
+ export declare const retirePulled: (outbox: string, step: string) => Promise<number>;
@@ -1,7 +1,8 @@
1
1
  // What the nightly send has already sent, so a night after a failure sends only what was not taken: one line for each hash, in the outbox beside `shown.log`. A batch that is rebuilt hashes differently and so is sent again, which is what a deposit that changed should do.
2
- import { appendFile, mkdir, readFile } from 'node:fs/promises';
2
+ import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { isMissing } from './pool-outbox.js';
4
+ import { heldBatchOf, isMissing, readPulled, STAMP_FOLDER, writePulled } from './pool-outbox.js';
5
+ import { appendOwnerOnly, mkdirOwnerOnly } from './owner-only.js';
5
6
  const sentFile = (outbox) => join(outbox, 'sent.log');
6
7
  /** Each hash that was sent, with the time it was sent. A hash sent twice keeps its first time. */
7
8
  export const sentLog = async (outbox) => {
@@ -21,6 +22,22 @@ export const sentLog = async (outbox) => {
21
22
  };
22
23
  export const sentHashes = async (outbox) => new Set((await sentLog(outbox)).keys());
23
24
  export const markSent = async (outbox, hash, at) => {
24
- await mkdir(outbox, { recursive: true });
25
- await appendFile(sentFile(outbox), `${hash} ${new Date(at).toISOString()}\n`, 'utf8');
25
+ await mkdirOwnerOnly(outbox);
26
+ await appendOwnerOnly(sentFile(outbox), `${hash} ${new Date(at).toISOString()}\n`);
27
+ };
28
+ /** Drops every `pulled.json` entry whose batch hash is in `sent.log`, and returns how many went. The sent batch file stays until the next prune; `sent.log` is what stops a second post. */
29
+ export const retirePulled = async (outbox, step) => {
30
+ const pulled = await readPulled(outbox, step);
31
+ const sent = await sentHashes(outbox);
32
+ let retired = 0;
33
+ for (const [id, stamp] of Object.entries(pulled)) {
34
+ const held = STAMP_FOLDER.test(stamp) ? await heldBatchOf(outbox, id, stamp) : undefined;
35
+ if (held === undefined || !sent.has(held.hash))
36
+ continue;
37
+ Reflect.deleteProperty(pulled, id);
38
+ retired += 1;
39
+ }
40
+ if (retired > 0)
41
+ await writePulled(outbox, pulled);
42
+ return retired;
26
43
  };