@manyoars/oars 0.3.0 → 0.4.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 (161) hide show
  1. package/README.md +44 -29
  2. package/dist/cli.d.ts +4 -1
  3. package/dist/cli.js +102 -32
  4. package/dist/commands/login.d.ts +1 -1
  5. package/dist/commands/login.js +1 -1
  6. package/dist/gate/hook-main.js +13 -1
  7. package/dist/gate/hook-process.d.ts +11 -1
  8. package/dist/gate/hook-process.js +50 -2
  9. package/dist/gate/hook-record.d.ts +3 -0
  10. package/dist/gate/hook-record.js +8 -0
  11. package/dist/gate/hook.d.ts +2 -0
  12. package/dist/gate/hook.js +3 -0
  13. package/dist/gate/replay.js +2 -5
  14. package/dist/gate/report.js +1 -1
  15. package/dist/gate/transcripts.js +1 -1
  16. package/dist/hook-bin.js +27 -2
  17. package/dist/issuer.d.ts +7 -9
  18. package/dist/issuer.js +10 -32
  19. package/dist/main.js +3 -0
  20. package/dist/sessions/actions.js +78 -0
  21. package/dist/sessions/allow.d.ts +10 -1
  22. package/dist/sessions/allow.js +29 -9
  23. package/dist/sessions/baseline.d.ts +1 -1
  24. package/dist/sessions/baseline.js +6 -4
  25. package/dist/sessions/call-shape.d.ts +19 -0
  26. package/dist/sessions/call-shape.js +85 -0
  27. package/dist/sessions/cell-increments.d.ts +5 -1
  28. package/dist/sessions/cell-increments.js +84 -50
  29. package/dist/sessions/cells.d.ts +33 -3
  30. package/dist/sessions/cells.js +89 -9
  31. package/dist/sessions/codex.js +93 -27
  32. package/dist/sessions/command.d.ts +11 -1
  33. package/dist/sessions/command.js +64 -18
  34. package/dist/sessions/coverage.d.ts +19 -0
  35. package/dist/sessions/coverage.js +56 -0
  36. package/dist/sessions/cursor.js +2 -2
  37. package/dist/sessions/derive.js +72 -6
  38. package/dist/sessions/exclusion.d.ts +6 -0
  39. package/dist/sessions/exclusion.js +8 -0
  40. package/dist/sessions/extract.d.ts +4 -2
  41. package/dist/sessions/extract.js +58 -26
  42. package/dist/sessions/hook-calls.js +1 -1
  43. package/dist/sessions/hook-index.d.ts +14 -1
  44. package/dist/sessions/hook-index.js +10 -3
  45. package/dist/sessions/hooks.d.ts +5 -1
  46. package/dist/sessions/hooks.js +133 -22
  47. package/dist/sessions/identity.d.ts +8 -3
  48. package/dist/sessions/identity.js +45 -15
  49. package/dist/sessions/install.d.ts +18 -2
  50. package/dist/sessions/install.js +53 -8
  51. package/dist/sessions/mcp.d.ts +3 -2
  52. package/dist/sessions/mcp.js +8 -4
  53. package/dist/sessions/motif.d.ts +2 -1
  54. package/dist/sessions/motif.js +2 -2
  55. package/dist/sessions/opencode.d.ts +33 -0
  56. package/dist/sessions/opencode.js +319 -0
  57. package/dist/sessions/otel.d.ts +60 -0
  58. package/dist/sessions/otel.js +494 -0
  59. package/dist/sessions/package-books.d.ts +48 -0
  60. package/dist/sessions/package-books.js +120 -0
  61. package/dist/sessions/package-resolve.d.ts +2 -0
  62. package/dist/sessions/package-resolve.js +3 -1
  63. package/dist/sessions/pi.d.ts +36 -0
  64. package/dist/sessions/pi.js +353 -0
  65. package/dist/sessions/pool-books.d.ts +21 -0
  66. package/dist/sessions/pool-books.js +75 -0
  67. package/dist/sessions/pool-choices.d.ts +5 -0
  68. package/dist/sessions/pool-choices.js +11 -0
  69. package/dist/sessions/pool-command.d.ts +76 -5
  70. package/dist/sessions/pool-command.js +263 -136
  71. package/dist/sessions/pool-nightly-deps.d.ts +3 -0
  72. package/dist/sessions/pool-nightly-deps.js +11 -0
  73. package/dist/sessions/pool-nightly.d.ts +10 -0
  74. package/dist/sessions/pool-nightly.js +85 -0
  75. package/dist/sessions/pool-outbox.d.ts +15 -2
  76. package/dist/sessions/pool-outbox.js +79 -26
  77. package/dist/sessions/pool-print.d.ts +1 -1
  78. package/dist/sessions/pool-raw.d.ts +27 -0
  79. package/dist/sessions/pool-raw.js +68 -0
  80. package/dist/sessions/pool-replica.d.ts +35 -0
  81. package/dist/sessions/pool-replica.js +91 -0
  82. package/dist/sessions/pool-send-lock.d.ts +20 -0
  83. package/dist/sessions/pool-send-lock.js +87 -0
  84. package/dist/sessions/pool-sent.d.ts +4 -0
  85. package/dist/sessions/pool-sent.js +26 -0
  86. package/dist/sessions/pool-totals.d.ts +10 -0
  87. package/dist/sessions/pool-totals.js +21 -0
  88. package/dist/sessions/pool-wire.d.ts +240 -30
  89. package/dist/sessions/pool-wire.js +695 -215
  90. package/dist/sessions/public-vocabulary.d.ts +2 -3
  91. package/dist/sessions/public-vocabulary.js +2 -9
  92. package/dist/sessions/query.d.ts +16 -4
  93. package/dist/sessions/query.js +32 -10
  94. package/dist/sessions/read-cost.js +14 -3
  95. package/dist/sessions/read-recovery-edge.d.ts +2 -0
  96. package/dist/sessions/read-recovery-edge.js +26 -0
  97. package/dist/sessions/read.d.ts +8 -1
  98. package/dist/sessions/read.js +6 -3
  99. package/dist/sessions/recall-bundle.d.ts +12 -0
  100. package/dist/sessions/recall-bundle.js +33 -0
  101. package/dist/sessions/recall-frames.d.ts +18 -0
  102. package/dist/sessions/recall-frames.js +44 -0
  103. package/dist/sessions/recall-next.d.ts +145 -0
  104. package/dist/sessions/recall-next.js +241 -0
  105. package/dist/sessions/recall-object.d.ts +2 -0
  106. package/dist/sessions/recall-object.js +1 -0
  107. package/dist/sessions/recall-push.d.ts +2 -2
  108. package/dist/sessions/recall-push.js +3 -51
  109. package/dist/sessions/recall-reads.d.ts +7 -0
  110. package/dist/sessions/recall-reads.js +7 -0
  111. package/dist/sessions/recovery-calls.d.ts +21 -0
  112. package/dist/sessions/recovery-calls.js +25 -0
  113. package/dist/sessions/redact.d.ts +11 -0
  114. package/dist/sessions/redact.js +59 -0
  115. package/dist/sessions/schema.d.ts +59 -9
  116. package/dist/sessions/schema.js +22 -3
  117. package/dist/sessions/session-shape.d.ts +6 -3
  118. package/dist/sessions/session-shape.js +19 -6
  119. package/dist/sessions/sources.js +75 -1
  120. package/dist/sessions/store.d.ts +13 -1
  121. package/dist/sessions/store.js +36 -16
  122. package/dist/sessions/survival.d.ts +2 -0
  123. package/dist/sessions/survival.js +2 -2
  124. package/dist/sessions/transcript-tail.d.ts +14 -0
  125. package/dist/sessions/transcript-tail.js +64 -0
  126. package/dist/sessions/vscode-copilot.js +24 -7
  127. package/dist/telemetry/command.d.ts +8 -0
  128. package/dist/telemetry/command.js +85 -0
  129. package/dist/telemetry/consent.d.ts +17 -0
  130. package/dist/telemetry/consent.js +63 -0
  131. package/dist/telemetry/crash.d.ts +8 -0
  132. package/dist/telemetry/crash.js +52 -0
  133. package/dist/telemetry/emit.d.ts +31 -0
  134. package/dist/telemetry/emit.js +42 -0
  135. package/dist/telemetry/fields.d.ts +16 -0
  136. package/dist/telemetry/fields.js +26 -0
  137. package/dist/telemetry/health.d.ts +21 -0
  138. package/dist/telemetry/health.js +108 -0
  139. package/dist/telemetry/hook-emit.d.ts +4 -0
  140. package/dist/telemetry/hook-emit.js +18 -0
  141. package/dist/telemetry/measures.d.ts +32 -0
  142. package/dist/telemetry/measures.js +128 -0
  143. package/dist/telemetry/observe.d.ts +13 -0
  144. package/dist/telemetry/observe.js +51 -0
  145. package/dist/telemetry/read-events.d.ts +9 -0
  146. package/dist/telemetry/read-events.js +17 -0
  147. package/dist/telemetry/record.d.ts +17 -0
  148. package/dist/telemetry/record.js +26 -0
  149. package/dist/telemetry/report.d.ts +77 -0
  150. package/dist/telemetry/report.js +188 -0
  151. package/dist/telemetry/schema.d.ts +40 -0
  152. package/dist/telemetry/schema.js +187 -0
  153. package/dist/telemetry/send-main.d.ts +1 -0
  154. package/dist/telemetry/send-main.js +11 -0
  155. package/dist/telemetry/send.d.ts +36 -0
  156. package/dist/telemetry/send.js +172 -0
  157. package/dist/telemetry/spool.d.ts +17 -0
  158. package/dist/telemetry/spool.js +70 -0
  159. package/dist/telemetry/wire.d.ts +25 -0
  160. package/dist/telemetry/wire.js +76 -0
  161. package/package.json +2 -2
@@ -0,0 +1,11 @@
1
+ export const nightlyPoolDeps = (env, sessions, version, now) => ({
2
+ env,
3
+ io: { stdout: () => undefined, stderr: () => undefined },
4
+ store: { read: () => Promise.resolve(undefined), write: () => Promise.resolve(), delete: () => Promise.resolve() },
5
+ version,
6
+ sleep: () => Promise.resolve(),
7
+ random: () => 0,
8
+ now,
9
+ openBrowser: () => Promise.resolve(),
10
+ sessions,
11
+ });
@@ -0,0 +1,10 @@
1
+ import type { RunDeps } from '../cli.js';
2
+ import { type Sender } from './pool-command.js';
3
+ export type NightlyOutcome = 'no-server' | 'not-signed-in' | 'not-accepted' | 'failed' | 'sent';
4
+ export interface NightlyPool {
5
+ outcome: NightlyOutcome;
6
+ /** Batches and raw copies this night sent. */
7
+ batches: number;
8
+ raw: number;
9
+ }
10
+ export declare const nightlyPoolSend: (deps: RunDeps, via?: Sender) => Promise<NightlyPool>;
@@ -0,0 +1,85 @@
1
+ // The nightly send (t884 Item 4). After the person has accepted the pool's covenant, the nightly task builds the outbox and sends what is new: skeleton batches, and, only after a yes to the raw prompt, the locally redacted raw copies.
2
+ // Acceptance is the consent: this module never accepts a covenant, and with an unusable server URL, no sign-in, or no acceptance it makes one request at most (the covenant read) and sends nothing. `oars pool outbox` still prints everything that is queued.
3
+ // It never throws, and what it records is counts and an outcome word, never a path, a host, or a byte of a transcript.
4
+ import { writeFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { ask, DEPOSIT, isObject, networkSender, poolDeposit, poolServer, postBatch, postRaw, runtimeOf, signedIn, textIn, vocabularyExpired } from './pool-command.js';
7
+ import { heldBatches, lockedBatch, outboxFolder } from './pool-outbox.js';
8
+ import { heldRaw } from './pool-raw.js';
9
+ import { withSendLock } from './pool-send-lock.js';
10
+ import { markSent, sentHashes } from './pool-sent.js';
11
+ import { readState } from './store.js';
12
+ const record = async (storeDir, result, at) => {
13
+ // A note that cannot be written changes nothing about the night.
14
+ await writeFile(join(storeDir, 'pool-nightly.json'), `${JSON.stringify({ at: new Date(at).toISOString(), ...result })}\n`, 'utf8').catch(() => undefined);
15
+ };
16
+ /** Whether the pool says this account has accepted the covenant now in force. Anything else, a closed pool and a refusal included, is no. */
17
+ const accepted = async (deps, server, token, via) => {
18
+ const reply = await ask(deps, DEPOSIT, server, '/v1/covenant', { token }, via);
19
+ return reply.status === 200 && isObject(reply.body) && textIn(reply.body, 'version') !== undefined && Reflect.get(reply.body, 'accepted') === true;
20
+ };
21
+ const night = async (deps, net, tally) => {
22
+ const none = (outcome) => ({ outcome, batches: 0, raw: 0 });
23
+ let server;
24
+ try {
25
+ server = poolServer(deps.env, DEPOSIT);
26
+ }
27
+ catch {
28
+ return none('no-server');
29
+ }
30
+ const { storeDir } = runtimeOf(deps);
31
+ let token;
32
+ try {
33
+ ({ token } = await signedIn(deps, storeDir, server, DEPOSIT));
34
+ }
35
+ catch {
36
+ return none('not-signed-in');
37
+ }
38
+ if (vocabularyExpired(deps) !== undefined)
39
+ return none('failed');
40
+ if (!(await accepted(deps, server, token, net)))
41
+ return none('not-accepted');
42
+ const outbox = outboxFolder(storeDir, server);
43
+ tally.outcome = 'sent';
44
+ // One sender at a time per outbox, the lock `deposit --send` takes: a second run waits, then finds `sent.log` full and sends nothing twice.
45
+ await withSendLock(outbox, async () => {
46
+ // The outbox is built only for an account that has accepted, then sent: the same gates `oars pool deposit` runs, so nothing queues that a person could not print.
47
+ await poolDeposit(deps, { server });
48
+ const done = await sentHashes(outbox);
49
+ for (const batch of (await heldBatches(outbox)).batches) {
50
+ if (done.has(batch.hash))
51
+ continue;
52
+ // Acceptance is the consent, so nothing was printed first; the file is still read and hashed again just before the request.
53
+ const locked = await lockedBatch(outbox, batch.hash, { requireShown: false });
54
+ if (!locked.ok)
55
+ continue;
56
+ await postBatch(deps, { server, token, bytes: locked.bytes, hash: batch.hash, via: net });
57
+ await markSent(outbox, batch.hash, deps.now());
58
+ tally.batches += 1;
59
+ }
60
+ // Raw goes only with its own yes, read again here so an answer changed since the build wins.
61
+ if ((await readState(storeDir)).pool.rawConsent === 'granted') {
62
+ for (const copy of (await heldRaw(outbox)).copies) {
63
+ if (done.has(copy.hash) || copy.bytes === 0)
64
+ continue;
65
+ await postRaw(deps, { server, token, hash: copy.hash, body: copy.body, bytes: copy.bytes, via: net });
66
+ await markSent(outbox, copy.hash, deps.now());
67
+ tally.raw += 1;
68
+ }
69
+ }
70
+ });
71
+ return tally;
72
+ };
73
+ export const nightlyPoolSend = async (deps, via) => {
74
+ // The tally is filled as sends land, so a night that fails halfway still records what it did send.
75
+ const tally = { outcome: 'failed', batches: 0, raw: 0 };
76
+ let result;
77
+ try {
78
+ result = await night(deps, via ?? networkSender(deps, DEPOSIT), tally);
79
+ }
80
+ catch {
81
+ result = { ...tally, outcome: 'failed' };
82
+ }
83
+ await record(runtimeOf(deps).storeDir, result, deps.now());
84
+ return result;
85
+ };
@@ -1,5 +1,11 @@
1
1
  /** The batch as one canonical JSON line: sorted keys, no optional whitespace, UTF-8 when written, and a newline at the end. These are the bytes that are hashed, printed, and sent. */
2
2
  export declare const canonicalBytes: (batch: unknown) => string;
3
+ /** The most bytes one request of a deposit carries. The pool refuses a request past 1 MiB, so a batch past this goes in parts, each of them under that cap. */
4
+ export declare const PART_BYTES: number;
5
+ /**
6
+ * The batch cut into parts of at most `limit` UTF-8 bytes, never inside a character, so the parts join back to exactly the bytes that were hashed. A batch within the limit is one part.
7
+ */
8
+ export declare const partsOf: (bytes: string, limit?: number) => string[];
3
9
  export declare const batchHash: (bytes: string) => string;
4
10
  /**
5
11
  * `oars pool outbox --explain`: one row for each field name a batch holds, at any depth, saying what admits its values. A field with no row fails the command, so a field added to the batch adds its row here.
@@ -21,6 +27,7 @@ export declare const outboxFolder: (storeDir: string, server: string) => string;
21
27
  export declare const writeBatch: (outbox: string, key: string, batch: unknown, stamp?: string) => Promise<{
22
28
  hash: string;
23
29
  folder: string;
30
+ bytes: number;
24
31
  }>;
25
32
  export declare const isMissing: (error: unknown) => boolean;
26
33
  /** Removes every batch under this stamp whose session is not in `kept`, and returns how many went. A session that fails a gate, leaves the allowlist, or is gone from the store never enters the outbox, so an earlier pass's batch of it cannot stay. */
@@ -44,8 +51,14 @@ export declare const heldBatches: (outbox: string, stamp?: string) => Promise<{
44
51
  /** Records that this hash was printed, one line for each print: the lock below sends only a hash that appears here. */
45
52
  export declare const markShown: (outbox: string, hash: string, at: number) => Promise<void>;
46
53
  export type LockRefusal = 'batch_unknown' | 'batch_not_shown' | 'batch_changed';
47
- /** The lock on `deposit --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. */
48
- export declare const lockedBatch: (outbox: string, hash: string, stamp?: string) => Promise<{
54
+ /**
55
+ * 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`,
56
+ * because covenant acceptance is its consent and it prints nothing; the other two checks still hold.
57
+ */
58
+ export declare const lockedBatch: (outbox: string, hash: string, options?: {
59
+ stamp?: string;
60
+ requireShown?: boolean;
61
+ }) => Promise<{
49
62
  ok: true;
50
63
  file: string;
51
64
  bytes: string;
@@ -24,6 +24,28 @@ const canonical = (value) => {
24
24
  };
25
25
  /** The batch as one canonical JSON line: sorted keys, no optional whitespace, UTF-8 when written, and a newline at the end. These are the bytes that are hashed, printed, and sent. */
26
26
  export const canonicalBytes = (batch) => `${canonical(batch)}\n`;
27
+ /** The most bytes one request of a deposit carries. The pool refuses a request past 1 MiB, so a batch past this goes in parts, each of them under that cap. */
28
+ export const PART_BYTES = 512 * 1024;
29
+ /**
30
+ * The batch cut into parts of at most `limit` UTF-8 bytes, never inside a character, so the parts join back to exactly the bytes that were hashed. A batch within the limit is one part.
31
+ */
32
+ export const partsOf = (bytes, limit = PART_BYTES) => {
33
+ const parts = [];
34
+ let part = '';
35
+ let size = 0;
36
+ for (const character of bytes) {
37
+ const width = Buffer.byteLength(character, 'utf8');
38
+ if (size + width > limit) {
39
+ parts.push(part);
40
+ part = '';
41
+ size = 0;
42
+ }
43
+ part += character;
44
+ size += width;
45
+ }
46
+ parts.push(part);
47
+ return parts;
48
+ };
27
49
  export const batchHash = (bytes) => createHash('sha256').update(Buffer.from(bytes, 'utf8')).digest('hex');
28
50
  /**
29
51
  * `oars pool outbox --explain`: one row for each field name a batch holds, at any depth, saying what admits its values. A field with no row fails the command, so a field added to the batch adds its row here.
@@ -32,8 +54,10 @@ export const batchHash = (bytes) => createHash('sha256').update(Buffer.from(byte
32
54
  export const FIELD_ROWS = {
33
55
  action: 'The canonical action of a step, one of a closed list this client holds; never the harness tool text.',
34
56
  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.',
35
- cacheRead: 'A bucket of the tokens read from the cache in a prompt, a number.',
36
- cacheWrite: 'A bucket of the tokens written to the cache in a prompt, a number.',
57
+ 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
+ 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.',
59
+ cacheRead: 'A bucket of the tokens read from the cache in a prompt, a number, or null when the source never recorded it.',
60
+ cacheWrite: 'A bucket of the tokens written to the cache in a prompt, a number, or null when the source never recorded it.',
37
61
  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.',
38
62
  cls: 'The class of a prompt, one of a closed list of eight, computed from the kinds of tools it used; never its words.',
39
63
  day: 'The UTC calendar day of the session as YYYY-MM-DD, or null; never a time of day.',
@@ -41,19 +65,30 @@ export const FIELD_ROWS = {
41
65
  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.',
42
66
  durationMs: 'The time of one tool call in milliseconds, exact, a number or null.',
43
67
  effort: 'The effort level the model ran at, only when it is a public one; else the literal placeholder.',
68
+ 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
+ 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.',
44
70
  errors: 'A bucket of how many tool calls failed in a prompt, a number.',
45
71
  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.',
46
72
  field: 'What a delta is about, one of plugin, package, permissionMode, and effort: the field alone, never its value.',
47
- flags: 'The flags of a shell command, each a public flag or a short flag; never an argument.',
73
+ 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
+ fieldsVersion: 'The version of the field list this client builds a batch from: a constant of this client, never a value from a session.',
48
75
  format: 'The skeleton format number: a constant of this client, never a value from a session.',
49
- frame: 'Where a session ran: the harness and its version, the model, the effort, the day, the platform, the shell, and the stack, every value checked against the closed vocabulary.',
76
+ filled: 'Whether the harness recorded a setup or a tool funnel for the session, true or false; false means the lists beside it are empty because nothing was recorded, not because no tool was in reach.',
77
+ frame: 'Where a session ran: the harness and its version, the model, the effort, the day, the platform, the shell, and the stack, each checked against its closed list, and the reach of the session (see `reach`). On a step it is a number, the position of the frame its call ran in, in the transcript list of frames.',
78
+ 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
+ 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.',
50
80
  frameworks: 'The frameworks of the project, each from a closed public list; never a path or a name of yours.',
51
81
  harness: 'Which harness wrote the session, one of a closed list.',
52
- harnessVersion: 'The harness version, only when it is a public release; else the literal placeholder.',
53
- host: 'The host of a web fetch or search, only when it is on the public list; else private, or null when the step names no host.',
82
+ 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
+ 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.',
84
+ hour: 'The UTC hour of day, 0 to 23, beside the day; never a minute, a second, or an offset.',
54
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.",
55
- identity: 'What a step called, in closed vocabulary: a shell program with its subcommand and flags, an MCP server and tool, a skill, or a web host; null for an action with none.',
56
- input: 'A bucket of the input tokens in a prompt, a number.',
86
+ 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
+ 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.',
89
+ 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
+ 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
+ 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.',
57
92
  language: 'A programming language of the project, from a closed public list, or other for any language off the list, beside its share bucket.',
58
93
  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.',
59
94
  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.',
@@ -61,36 +96,51 @@ export const FIELD_ROWS = {
61
96
  leaves: 'How many lines of a transcript its root covers, a count.',
62
97
  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.',
63
98
  metric: 'What a delta measures, calls per session or thinking tokens, one of a closed list the field fixes.',
64
- model: 'The model name, only when it is a public one; else the literal placeholder.',
99
+ 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.',
65
100
  ms: 'A bucket of the milliseconds a step took (a lower bound), a number or null; never the exact time.',
66
- name: 'The name of a package a shell command pulled or a delta removed, only when it is on the public list; else private. A plugin in a delta is always private, and null when a delta names its field alone.',
67
- native: "The name the harness gave the tool, only when it is in this client's action table; else private.",
68
- output: 'A bucket of the output tokens in a prompt, a number.',
69
- parent: 'The position of the main-run call that spawned this step, a number, or null (also null for a subagent a subagent spawned).',
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.',
102
+ 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.',
103
+ 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.',
104
+ output: 'A bucket of the output tokens in a prompt, a number, or null when the source never recorded it.',
105
+ parent: 'The position of the call that spawned this step, in the tools of the transcript that spawnedIn names, a number, or null.',
70
106
  platform: 'The operating system family, one of a closed list.',
71
- program: 'The program a shell command ran, when it is on the public list; else the word private.',
107
+ 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.',
72
108
  prompt: 'Which prompt of the batch a tool call follows, a position in this batch, or null.',
73
109
  prompts: 'The prompts of a transcript: each a class, bucketed counts and tokens, and a frame; never their words.',
74
110
  pulls: 'The packages a shell command pulled from a registry: the registry, the public package name, and its version.',
111
+ 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.',
75
112
  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.',
76
- remainingTokens: 'A bucket of the tokens the run spent after a step (a lower bound), a number: the bucket of the exact sum, with the subagent runs a main-run step spawned later in the session.',
77
- server: 'The MCP server a call went to, only when it is on the public list; else private.',
113
+ 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.',
114
+ server: 'The MCP server a call went to: 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.',
78
115
  shell: 'The shell a command ran in, one of a closed list, or null.',
79
116
  side: 'Whether a tool call ran as a side call, true or false.',
117
+ spawnedIn: 'The position, among the transcripts of the batch, of the transcript that spawned this one, a number; null for the main run and for a subagent whose spawner the batch does not hold.',
80
118
  size: 'A size class of the repository, one of a closed list, or null; never a count of files.',
81
- skill: 'The skill a step ran, only when it is on the public list; else private.',
119
+ mcp: 'The MCP servers in reach of a session (offered, loaded, or called), each 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, sorted; never a tool, an argument, or a path.',
120
+ orgSize: 'How many people the operator\'s organization has, one of a closed list of bands or unknown; set by the operator on this machine, never looked up.',
121
+ plugins: 'The plugins in reach of a session, each the word private, since no plugin list is pinned; their number, never their names.',
122
+ reach: 'The MCP servers, skills, and plugins in reach of a session, and whether the harness recorded them; every name is 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 is always private).',
123
+ repoKey: "A hash of the repository salted with this store's salt: one store's sessions in one repository share it; one account on two machines gets two keys until the account salt lands. 16 hex characters; never a path, a remote, or a name.",
124
+ repoVisibility: 'Whether the repository is public or private, or unknown; set by the operator on this machine, never looked up.',
125
+ skill: 'The skill a step ran (a skill-run name capture keeps as it is after redaction), or the skill whose run the step\'s call sat in, 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. Absent on a step outside a skill\'s run.',
126
+ skills: 'The skills in reach of a session (called in it), each 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, sorted.',
82
127
  stack: 'The languages, frameworks, and size class of the project.',
83
128
  status: 'Whether a tool call or a step succeeded: ok, error, or none.',
84
129
  steps: 'The steps of a tool call, each an action in closed vocabulary with its bucketed cost and spawner.',
85
- sub: 'The subcommand of a shell program, only when it is on the public list for that program; else null.',
86
- thinking: 'A bucket of the thinking tokens in a prompt, a number.',
130
+ team: 'Whether the repository\'s local git log shows one commit author or several, or unknown; a count taken on this machine and dropped, never an author.',
131
+ sub: 'The subcommand of a shell program, or null: 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.',
132
+ thinking: 'A bucket of the thinking tokens in a prompt, a number, or null when the source never recorded it.',
133
+ target: 'The position of the step an edge names, a number counted over the steps of one transcript in call order.',
134
+ 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.',
87
135
  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.',
88
136
  tokens: 'A bucket of tokens (a lower bound of the count), a number or null; a step holds one, never the exact count.',
89
- tool: 'The tool of an MCP call, only when it is on the public list for that server; else the literal placeholder.',
137
+ 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.',
90
138
  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.',
91
- transcripts: 'The main transcript, then each subagent: its frame, prompts, and tool calls, every value checked against the closed vocabulary before the batch is built.',
139
+ 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.',
92
140
  usage: 'The tokens of a prompt as buckets (lower bounds): input, cache write, cache read, output, and thinking.',
141
+ 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).',
93
142
  version: 'The version of a pulled package, only when it is a registry version; else the literal placeholder or null.',
143
+ vocabVersion: 'The version of the closed vocabulary this client built the batch under: a constant of this client, never a value from a session.',
94
144
  wallSec: 'A bucket of the seconds a prompt took, or null.',
95
145
  };
96
146
  /**
@@ -116,7 +166,7 @@ export const writeBatch = async (outbox, key, batch, stamp = currentStamp()) =>
116
166
  await rm(join(folder, name), { force: true });
117
167
  }
118
168
  await writeFile(join(folder, `${key}.${hash}.jsonl`), bytes, 'utf8');
119
- return { hash, folder };
169
+ return { hash, folder, bytes: Buffer.byteLength(bytes) };
120
170
  };
121
171
  export const isMissing = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
122
172
  /** A path that is missing, or has a file where a folder should be, holds nothing. */
@@ -195,13 +245,16 @@ const wasShown = async (outbox, hash) => {
195
245
  throw error;
196
246
  }
197
247
  };
198
- /** The lock on `deposit --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. */
199
- export const lockedBatch = async (outbox, hash, stamp = currentStamp()) => {
200
- const folder = join(outbox, stamp);
248
+ /**
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`,
250
+ * because covenant acceptance is its consent and it prints nothing; the other two checks still hold.
251
+ */
252
+ export const lockedBatch = async (outbox, hash, options = {}) => {
253
+ const folder = join(outbox, options.stamp ?? currentStamp());
201
254
  const name = (await namesIn(folder)).find((entry) => BATCH_FILE.exec(entry)?.[2] === hash);
202
255
  if (name === undefined)
203
256
  return { ok: false, code: 'batch_unknown' };
204
- if (!(await wasShown(outbox, hash)))
257
+ if (options.requireShown !== false && !(await wasShown(outbox, hash)))
205
258
  return { ok: false, code: 'batch_not_shown' };
206
259
  const file = join(folder, name);
207
260
  const bytes = await unchangedBytes(file, hash);
@@ -1,6 +1,6 @@
1
1
  /** One request as it will leave this machine. */
2
2
  export interface Outgoing {
3
- method: 'GET' | 'POST';
3
+ method: 'GET' | 'POST' | 'PUT';
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]>;
@@ -0,0 +1,27 @@
1
+ /** Parts of a raw copy, each at most this many bytes: under the pool's 1 MB per request. */
2
+ export declare const RAW_PART_BYTES: number;
3
+ /** The folder of the raw copies this client builds now; one built under another redaction or capture version is never sent. */
4
+ export declare const rawStamp: () => string;
5
+ export declare const rawHash: (bytes: string) => string;
6
+ /** Writes one redacted transcript for a session, replacing an earlier copy of the same place. */
7
+ export declare const writeRaw: (outbox: string, key: string, place: number, bytes: string) => Promise<{
8
+ hash: string;
9
+ bytes: number;
10
+ }>;
11
+ export interface HeldRaw {
12
+ key: string;
13
+ place: number;
14
+ hash: string;
15
+ file: string;
16
+ body: string;
17
+ bytes: number;
18
+ }
19
+ /** 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. */
20
+ export declare const heldRaw: (outbox: string) => Promise<{
21
+ copies: HeldRaw[];
22
+ edited: number;
23
+ }>;
24
+ /** Removes every raw copy whose session key is not in `kept` (all of them when none is), and returns how many went. A folder switched off, a consent withdrawn, or a session no longer passing the gates leaves nothing queued. */
25
+ export declare const removeRawExcept: (outbox: string, kept: ReadonlySet<string>, only?: string) => Promise<number>;
26
+ /** The parts a raw copy goes in, in order. */
27
+ export declare const rawParts: (body: string) => string[];
@@ -0,0 +1,68 @@
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
+ // 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
+ import { createHash } from 'node:crypto';
4
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { partsOf } from './pool-outbox.js';
7
+ import { REDACTION_VERSION } from './redact.js';
8
+ import { CAPTURE_VERSION } from './schema.js';
9
+ const RAW_FILE = /^([0-9a-f]{16})\.(\d+)\.([0-9a-f]{64})\.redacted$/;
10
+ /** Parts of a raw copy, each at most this many bytes: under the pool's 1 MB per request. */
11
+ export const RAW_PART_BYTES = 512 * 1024;
12
+ /** The folder of the raw copies this client builds now; one built under another redaction or capture version is never sent. */
13
+ export const rawStamp = () => `raw-r${REDACTION_VERSION}-c${CAPTURE_VERSION}`;
14
+ export const rawHash = (bytes) => createHash('sha256').update(Buffer.from(bytes, 'utf8')).digest('hex');
15
+ const namesIn = async (folder) => {
16
+ try {
17
+ return (await readdir(folder)).sort();
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ };
23
+ /** Writes one redacted transcript for a session, replacing an earlier copy of the same place. */
24
+ export const writeRaw = async (outbox, key, place, bytes) => {
25
+ const folder = join(outbox, rawStamp());
26
+ const hash = rawHash(bytes);
27
+ await mkdir(folder, { recursive: true });
28
+ for (const name of await namesIn(folder)) {
29
+ const found = RAW_FILE.exec(name);
30
+ if (found?.[1] === key && Number(found[2]) === place && found[3] !== hash)
31
+ await rm(join(folder, name), { force: true });
32
+ }
33
+ await writeFile(join(folder, `${key}.${String(place)}.${hash}.redacted`), bytes, 'utf8');
34
+ return { hash, bytes: Buffer.byteLength(bytes) };
35
+ };
36
+ /** 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. */
37
+ export const heldRaw = async (outbox) => {
38
+ const folder = join(outbox, rawStamp());
39
+ const copies = [];
40
+ let edited = 0;
41
+ for (const name of await namesIn(folder)) {
42
+ const found = RAW_FILE.exec(name);
43
+ if (found === null)
44
+ continue;
45
+ const file = join(folder, name);
46
+ const body = await readFile(file, 'utf8');
47
+ if (rawHash(body) === found[3])
48
+ copies.push({ key: found[1] ?? '', place: Number(found[2]), hash: found[3], file, body, bytes: Buffer.byteLength(body) });
49
+ else
50
+ edited += 1;
51
+ }
52
+ return { copies, edited };
53
+ };
54
+ /** Removes every raw copy whose session key is not in `kept` (all of them when none is), and returns how many went. A folder switched off, a consent withdrawn, or a session no longer passing the gates leaves nothing queued. */
55
+ export const removeRawExcept = async (outbox, kept, only) => {
56
+ const folder = join(outbox, rawStamp());
57
+ let removed = 0;
58
+ for (const name of await namesIn(folder)) {
59
+ const found = RAW_FILE.exec(name);
60
+ if (found !== null && !kept.has(found[1] ?? '') && (only === undefined || found[1] === only)) {
61
+ await rm(join(folder, name), { force: true });
62
+ removed += 1;
63
+ }
64
+ }
65
+ return removed;
66
+ };
67
+ /** The parts a raw copy goes in, in order. */
68
+ export const rawParts = (body) => partsOf(body, RAW_PART_BYTES);
@@ -0,0 +1,35 @@
1
+ import { type Bundle, type PinnedKey } from './pool-wire.js';
2
+ import type { Harness } from './schema.js';
3
+ export declare const textIn: (value: object, key: string) => string | undefined;
4
+ export declare const numberIn: (value: object, key: string) => number | undefined;
5
+ export declare const isObject: (value: unknown) => value is object;
6
+ /** One server's sign-in, as `login` saved it. */
7
+ export interface SignIn {
8
+ server: string;
9
+ account: string;
10
+ token: string;
11
+ expiresAt: number;
12
+ /** Pinned at the first login to this server; every bundle is checked against it. */
13
+ signingKey: PinnedKey;
14
+ }
15
+ export declare const signInOf: (value: unknown) => SignIn | undefined;
16
+ export declare const signInsFile: (storeDir: string) => string;
17
+ /** 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. */
18
+ export declare const loadSignIns: (storeDir: string, step: string) => Promise<SignIn[]>;
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
+ export declare const replicaFile: (storeDir: string, server: string, harness: Harness, harnessVersion: string) => string;
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<{
23
+ etag: string | undefined;
24
+ bundle: Bundle;
25
+ } | undefined>;
26
+ /** A session's harness and the version it runs, as a hook input, a transcript, or the store states them. */
27
+ export interface Setup {
28
+ harness: Harness;
29
+ harnessVersion: string;
30
+ }
31
+ /**
32
+ * The kept bundle for a session's own harness and version, from the first signed-in server that kept one which still verifies; undefined otherwise. It never throws: a damaged
33
+ * sign-in file, a missing or corrupt replica, or one signed for another harness all mean the caller answers from the local store alone, and a hook must not fail on any of them.
34
+ */
35
+ export declare const sessionBundle: (storeDir: string, setup: Setup | undefined) => Promise<Bundle | undefined>;
@@ -0,0 +1,91 @@
1
+ // The kept pool bundle: where `oars pool fetch` keeps one harness version's signed bundle, the sign-ins whose pinned keys verify it, and the one reader every caller uses (t926).
2
+ // `poolFetch` reads it for its ETag, and recall, the MCP tool, and the hooks read it for the session's own harness and version. A reader for recall is fail-open: no bundle,
3
+ // one for another harness, or one that does not verify means local reads only, and nothing is printed.
4
+ import { readFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { CliError, ExitCode } from '../errors.js';
7
+ import { isMissing } from './pool-outbox.js';
8
+ import { isBundleVersion, verifyBundle } from './pool-wire.js';
9
+ import { contentHash } from './read.js';
10
+ export const textIn = (value, key) => {
11
+ const found = Reflect.get(value, key);
12
+ return typeof found === 'string' && found !== '' ? found : undefined;
13
+ };
14
+ export const numberIn = (value, key) => {
15
+ const found = Reflect.get(value, key);
16
+ return typeof found === 'number' && Number.isFinite(found) ? found : undefined;
17
+ };
18
+ export const isObject = (value) => typeof value === 'object' && value !== null;
19
+ const keyOf = (value) => {
20
+ if (!isObject(value))
21
+ return undefined;
22
+ const [kty, crv, x] = [textIn(value, 'kty'), textIn(value, 'crv'), textIn(value, 'x')];
23
+ return kty === 'OKP' && crv === 'Ed25519' && x !== undefined ? { kty, crv, x } : undefined;
24
+ };
25
+ export const signInOf = (value) => {
26
+ if (!isObject(value))
27
+ return undefined;
28
+ 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 };
31
+ };
32
+ export const signInsFile = (storeDir) => join(storeDir, 'pool-accounts.json');
33
+ /** 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. */
34
+ export const loadSignIns = async (storeDir, step) => {
35
+ const file = signInsFile(storeDir);
36
+ let text;
37
+ try {
38
+ text = await readFile(file, 'utf8');
39
+ }
40
+ catch (error) {
41
+ if (isMissing(error))
42
+ return [];
43
+ throw error;
44
+ }
45
+ const damaged = new CliError(`${file} cannot be read. Remove it, then run oars pool login again.`, ExitCode.INVALID, step);
46
+ let parsed;
47
+ try {
48
+ parsed = JSON.parse(text);
49
+ }
50
+ catch {
51
+ throw damaged;
52
+ }
53
+ if (!Array.isArray(parsed))
54
+ throw damaged;
55
+ const entries = parsed;
56
+ return entries.map((entry) => {
57
+ const signIn = signInOf(entry);
58
+ if (signIn === undefined)
59
+ throw damaged;
60
+ return signIn;
61
+ });
62
+ };
63
+ /** One folder per harness under the server's, so a fetch for one harness never overwrites another's bundle of the same version. */
64
+ export const replicaFile = (storeDir, server, harness, harnessVersion) => join(storeDir, 'pool-replica', contentHash(server), harness, `${encodeURIComponent(harnessVersion)}.json`);
65
+ /** 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) => {
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(await readFile(file, 'utf8'));
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ const verified = await verifyBundle(parsed, key, harness, harnessVersion);
75
+ return verified.ok ? { etag: isObject(parsed) ? textIn(parsed, 'etag') : undefined, bundle: verified.bundle } : undefined;
76
+ };
77
+ /**
78
+ * The kept bundle for a session's own harness and version, from the first signed-in server that kept one which still verifies; undefined otherwise. It never throws: a damaged
79
+ * sign-in file, a missing or corrupt replica, or one signed for another harness all mean the caller answers from the local store alone, and a hook must not fail on any of them.
80
+ */
81
+ export const sessionBundle = async (storeDir, setup) => {
82
+ if (setup === undefined || !isBundleVersion(setup.harnessVersion))
83
+ return undefined;
84
+ 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);
87
+ if (kept !== undefined)
88
+ return kept.bundle;
89
+ }
90
+ return undefined;
91
+ };
@@ -0,0 +1,20 @@
1
+ import { type FileHandle } from 'node:fs/promises';
2
+ export interface LockTimes {
3
+ /** A lock file untouched for longer than this was left by a run that died; its holder touches it every quarter of this. */
4
+ staleMs: number;
5
+ /** How long a run waits for the lock before it gives up. */
6
+ waitMs: number;
7
+ pollMs: number;
8
+ }
9
+ /** What a test replaces: `stat` to read a lock as stale at the moment a waiter looks, `open` to make the create fail the way Windows does, `platform` to pick the platform's rules. */
10
+ export interface LockFs {
11
+ stat: (path: string) => Promise<{
12
+ mtimeMs: number;
13
+ }>;
14
+ open?: (path: string, flags: 'wx') => Promise<FileHandle>;
15
+ platform?: NodeJS.Platform;
16
+ }
17
+ export declare class SendLockBusy extends Error {
18
+ constructor();
19
+ }
20
+ export declare const withSendLock: <T>(outbox: string, run: () => Promise<T>, times?: Partial<LockTimes>, fs?: LockFs) => Promise<T>;