@manyoars/oars 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -207,8 +207,9 @@ Replay is local and read-only on your transcripts; it writes `crossings.jsonl` a
207
207
 
208
208
  ### pool
209
209
 
210
- `oars pool` uses the Many Oars pool, `https://pool.manyoars.com`, which is live (deposits there are closed until its covenant is published), or a pool server you name with `--server <url>` or `OARS_POOL_URL`. Every other command works without it.
210
+ `oars pool` uses the Many Oars pool, `https://pool.manyoars.com`, which is live and open for deposits, or a pool server you name with `--server <url>` or `OARS_POOL_URL`. Every other command works without it.
211
211
 
212
+ - `oars pool join --provider github|google --harness-version <version> [--harness <name>]` is the first way in: it signs you in, prepares your deposit, prints what would be sent with each field's rule, sends it, and fetches your first bundle, in one run. It is the five commands below in order, with the same prompts and the same checks, and one plain line says what each step is doing. If a step stops, it prints that step's own error and names the command to run again; running `oars pool join` again skips what already stands (a held sign-in, a prepared batch, a batch already sent). The commands below do the same step by step.
212
213
  - `oars pool login --provider github|google` asks the pool to start the provider's device flow, prints the user code and link, and polls the pool until you approve (a failed poll is retried with a longer wait until the code expires; the output names the exclusion-terms file when there is none, and the first `pool deposit` needs it); with `--verbose`, every request prints before it is sent. The pool holds the OAuth client and its secret, so the CLI needs no client ID or secret, and the provider's tokens never reach this machine.
213
214
  - `oars pool logout` revokes this machine's credential at the pool, then removes the local sign-in. If the pool cannot be reached it still removes the local sign-in and says the credential was not revoked there.
214
215
  - `oars pool deposit` writes a batch of session skeletons (a closed list of fields, each value redacted first; a host is hashed by the pool on arrival) to an outbox for each session that passes your folder allowlist and the exclusion check and whose last line is over 6 hours old, and stops: no session data is sent. `--send <hash>` sends one batch that `outbox` printed and that is unchanged on disk, once this account has accepted the pool covenant (a person types the phrase the covenant screen states, at a terminal; `--accept-covenant <version>` only names the version expected and accepts nothing; the screen then asks "Are you in the EU, the EEA, or the UK? (yes/no)" and sends the answer, and `--in-eu-eea-uk <yes|no>` answers it without asking; while the pool is closed to those regions a yes, or a request from them, is refused with `region_closed` and nothing is accepted); before that it reads the covenant, sends no batch, and exits 3 with `covenant_not_accepted`. A hash that was never shown, or a batch that changed, is refused before any request. The detached sender is the other sender: after the pool says your account accepted its covenant, it sends new batches, and raw copies only if you said yes to the raw prompt (`oars pool raw --consent yes`, answered by typing the phrase `I consent to sending raw copies` at a terminal; a yes also tells the pool, and `--consent no` tells it to stop accepting raw copies, whatever a later request states (a machine that held a yes and is signed out exits nonzero: the pool was not told), and the pool's nightly job then erases the raw copies it already holds, starting that night; a large account can take more than one night). With no sign-in, no acceptance, or an unreachable server it sends nothing.
@@ -217,6 +218,7 @@ Replay is local and read-only on your transcripts; it writes `crossings.jsonl` a
217
218
  - `oars pool export --out <folder> [--raw]` saves a copy of what the pool holds about your account (GDPR Articles 15(3) and 20), as JSON, one file a page, readable by you only. It covers your account row (with your raw consent record), when your sign-ins end (never the credential), your covenant acceptances, your machines (without their keys, with the day of each one's last deposit and its key days), your repository keys, your deposit hours, your deposits as they now read (with the machine, host class, and launcher each came from, and the host class and launcher the batch claimed), your raw ledger, your staged deposit parts, your erasure audit rows, and your telemetry days. `--raw` also saves each raw transcript you deposited that has not been erased, checked against its hash. It needs `oars pool login`, writes nothing to the pool, prints what it saved, and exits nonzero naming any page or object that failed; a rerun overwrites the same files. Totals the pool derives from many accounts are not yours to export and are not in it.
218
219
  - On an enrolled machine the hook child activates its key before its first deposit, rotates it where the disk is durable (`vps`, `ci`; the key lives in `<store>/machine-key`, owner-only), deposits under it, sends the batches its outbox held once the covenant is accepted, and tells you at the next Stop when the key expires within 14 days, has expired, or was revoked (`oars pool machines` lists it). A `cloud` machine's key is held by the network proxy and the child sends no credential of its own. Details: `docs/pool.md`, "Machine keys".
219
220
  - `oars pool outbox` prints each batch exactly as it would be sent, with its SHA-256, and `--explain` adds each field's rule.
221
+ - `oars pool remove <hash>` removes one batch from the outbox on this machine, with the queued raw copies of the same session, and says what it removed; nothing is sent and nothing at the pool changes. The session stays out of every later deposit, the background sender's included, until `oars pool remove <hash> --restore`, whichever account you sign in with. It refuses while a send holds the lock.
220
222
  - `oars pool fetch --harness-version <version>` fetches that version's signed bundle, verifies it against the key pinned at login, and keeps it locally. It prints the public and disclosure floors the bundle states (and any read family on a floor of its own) and holds no floor of its own: the pool sets them, so a change is a deploy and never a new release of oars.
221
223
 
222
224
  ### club
@@ -258,6 +260,7 @@ Exit codes:
258
260
  | 7 | rate limited |
259
261
  | 8 | the server could not be reached (the platform, the issuer, or a pool server) |
260
262
  | 10 | `pool fetch` hit the pool's weekly fetch cap; the bundle on disk is untouched, and the message says when to try again |
263
+ | 130 | `pool deposit --send` was ended with Ctrl-C (SIGINT); the batch stays queued |
261
264
 
262
265
  ## Source
263
266
 
package/dist/cli.d.ts CHANGED
@@ -32,6 +32,8 @@ export interface RunDeps {
32
32
  export interface Outcome {
33
33
  document: object;
34
34
  text: string;
35
+ /** `streamed`: the handler already wrote `text` as it went, so nothing is printed from it. `chunked`: `--json` falls back to writing the document one array element at a time when it is past V8's string length. */
36
+ output?: 'streamed' | 'chunked';
35
37
  }
36
38
  /** Runs one invocation (`argv` without the node and script entries) and returns the exit code. Telemetry records it afterward and cannot change it. */
37
39
  export declare const run: (argv: string[], deps: RunDeps) => Promise<ExitCode>;
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@ import { githubVisibility, poolEnroll, poolMachineRevoke, poolMachines } from '.
19
19
  import { runNetGit } from './sessions/machine-hold.js';
20
20
  import { holdPull } from './sessions/machine-pull.js';
21
21
  import { runGit } from './sessions/survival.js';
22
- import { poolCoverage, poolDeposit, poolExport, poolFetch, poolLogin, poolLogout, poolOutbox, poolProve, poolRaw } from './sessions/pool-command.js';
22
+ import { poolCoverage, poolDeposit, poolExport, poolFetch, poolJoin, poolLogin, poolLogout, poolOutbox, poolProve, poolRaw, poolRemove } from './sessions/pool-command.js';
23
23
  import { telemetryOff, telemetryOn, telemetryReport, telemetryShow, telemetryStatus } from './telemetry/command.js';
24
24
  import { cardLine } from './sessions/card-line.js';
25
25
  import { recordCrash } from './telemetry/crash.js';
@@ -52,7 +52,44 @@ const isoTime = (value) => {
52
52
  throw new InvalidArgumentError('it must be an ISO time such as 2026-11-01T16:00:00Z.');
53
53
  return new Date(parsed).toISOString();
54
54
  };
55
+ /** The document as one string, or `undefined` when it is past V8's string length (`RangeError: Invalid string length`). */
56
+ const stringified = (document) => {
57
+ try {
58
+ return JSON.stringify(document);
59
+ }
60
+ catch (error) {
61
+ if (error instanceof RangeError)
62
+ return undefined;
63
+ throw error;
64
+ }
65
+ };
66
+ /** `JSON.stringify(document)` plus a newline, in pieces: each top-level array goes out one element at a time, so no piece is longer than its largest element. */
67
+ const jsonPieces = function* (document) {
68
+ yield '{';
69
+ for (const [at, [key, value]] of Object.entries(document).entries()) {
70
+ const head = `${at === 0 ? '' : ','}${JSON.stringify(key)}:`;
71
+ if (!Array.isArray(value)) {
72
+ yield `${head}${JSON.stringify(value)}`;
73
+ continue;
74
+ }
75
+ yield `${head}[`;
76
+ for (const [index, item] of value.entries())
77
+ yield `${index === 0 ? '' : ','}${JSON.stringify(item)}`;
78
+ yield ']';
79
+ }
80
+ yield '}\n';
81
+ };
55
82
  const emit = (deps, json, outcome) => {
83
+ if (json && outcome.output === 'chunked') {
84
+ // One write when the document fits in a string, as every other command; past V8's string length, the pieces.
85
+ const whole = stringified(outcome.document);
86
+ for (const piece of whole === undefined ? jsonPieces(outcome.document) : [`${whole}
87
+ `])
88
+ deps.io.stdout(piece);
89
+ return;
90
+ }
91
+ if (!json && outcome.output === 'streamed')
92
+ return;
56
93
  deps.io.stdout(`${json ? JSON.stringify(outcome.document) : outcome.text}\n`);
57
94
  };
58
95
  /**
@@ -462,9 +499,24 @@ const buildProgram = (deps, observed) => {
462
499
  .action(async (_options, command) => {
463
500
  emit(deps, flag(command.optsWithGlobals(), 'json'), await gateReport(sessionDeps(deps)));
464
501
  });
465
- const pool = program.command('pool').description('sign in to a pool server, deposit session skeletons (a closed list of fields, each value redacted first) and, after a yes to the raw prompt, redacted raw copies, and fetch signed reads; local capture, recall, and reads need none of it. The covenant screen, where you type its phrase to accept, appears when you send a batch with oars pool deposit --send <hash> and have not accepted the covenant in force, not at sign-in');
502
+ const pool = program.command('pool').description('sign in to a pool server, deposit session skeletons (a closed list of fields, each value redacted first) and, after a yes to the raw prompt, redacted raw copies, and fetch signed reads; local capture, recall, and reads need none of it. The covenant screen, where you type its phrase to accept, appears when you send a batch with oars pool deposit --send <hash> or oars pool join and have not accepted the covenant in force, not at sign-in');
466
503
  const serverOption = '--server <url>';
467
504
  const serverHelp = 'the pool server, https or plain http to localhost (default: OARS_POOL_URL, else https://pool.manyoars.com)';
505
+ pool
506
+ .command('join')
507
+ .description('run login, deposit, outbox --explain, deposit --send, and fetch in one command. Run it again after a stop to continue from there')
508
+ .option('--provider <name>', 'github or google, for the sign-in step (not needed once signed in)')
509
+ .option('--verbose', 'print each sign-in request, headers and body, to stderr before it is sent (a credential shows as its size)')
510
+ .option('--accept-covenant <version>', 'name the covenant version you expect, as deposit --send takes it: on a terminal the screen is shown and you type its phrase yourself, and off a terminal this is refused')
511
+ .option('--in-eu-eea-uk <yes|no>', 'answer "Are you in the EU, the EEA, or the UK?" when you accept, as deposit --send takes it')
512
+ .option('--harness <name>', 'the harness whose bundle to fetch (default: the one your local store holds the most sessions of, claude-code when none leads)')
513
+ .requiredOption('--harness-version <version>', 'the public harness version whose bundle to fetch, as fetch takes it')
514
+ .option(serverOption, serverHelp)
515
+ .action(async (_options, command) => {
516
+ const options = command.optsWithGlobals();
517
+ const json = flag(options, 'json');
518
+ emit(deps, json, await poolJoin(deps, { server: text(options, 'server'), provider: text(options, 'provider'), verbose: flag(options, 'verbose'), acceptCovenant: text(options, 'acceptCovenant'), inEuEeaUk: text(options, 'inEuEeaUk'), harness: text(options, 'harness'), harnessVersion: text(options, 'harnessVersion') ?? '', json }));
519
+ });
468
520
  pool
469
521
  .command('logout')
470
522
  .description('revoke the credential held for this pool and remove it here; if the pool cannot be reached the local entry is still removed')
@@ -564,7 +616,17 @@ const buildProgram = (deps, observed) => {
564
616
  .option(serverOption, serverHelp)
565
617
  .action(async (_options, command) => {
566
618
  const options = command.optsWithGlobals();
567
- emit(deps, flag(options, 'json'), await poolOutbox(deps, { server: text(options, 'server'), explain: flag(options, 'explain') }));
619
+ emit(deps, flag(options, 'json'), await poolOutbox(deps, { server: text(options, 'server'), explain: flag(options, 'explain'), ...(flag(options, 'json') ? {} : { print: deps.io.stdout }) }));
620
+ });
621
+ pool
622
+ .command('remove')
623
+ .argument('<hash>', 'the SHA-256 of a batch, as oars pool outbox prints it')
624
+ .description('remove one batch and its queued raw copies from the outbox on this machine; nothing is sent, and nothing at the pool changes. Its session stays out of every later oars pool deposit, the background sender included, until --restore. It refuses while a send holds the outbox lock')
625
+ .option('--restore', 'take the session of an earlier removal off the list, so a later oars pool deposit builds it again (the hash is the one remove printed)')
626
+ .option(serverOption, serverHelp)
627
+ .action(async (hash, _options, command) => {
628
+ const options = command.optsWithGlobals();
629
+ emit(deps, flag(options, 'json'), await poolRemove(deps, hash, { server: text(options, 'server'), restore: flag(options, 'restore') }));
568
630
  });
569
631
  pool
570
632
  .command('prove')
package/dist/errors.d.ts CHANGED
@@ -12,6 +12,8 @@ export declare const ExitCode: {
12
12
  readonly UNREACHABLE: 8;
13
13
  /** `pool fetch` hit the pool's weekly cap: nothing is wrong, and the bundle on disk is untouched. */
14
14
  readonly CAPPED: 10;
15
+ /** A send the person ended with Ctrl-C (SIGINT): the shell convention, 128 plus the signal number. */
16
+ readonly INTERRUPTED: 130;
15
17
  };
16
18
  export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode];
17
19
  /** The exit code for a failed response. The problem's code (then its type) decides; the status covers what has none. Text is never read. */
package/dist/errors.js CHANGED
@@ -10,6 +10,8 @@ export const ExitCode = {
10
10
  UNREACHABLE: 8,
11
11
  /** `pool fetch` hit the pool's weekly cap: nothing is wrong, and the bundle on disk is untouched. */
12
12
  CAPPED: 10,
13
+ /** A send the person ended with Ctrl-C (SIGINT): the shell convention, 128 plus the signal number. */
14
+ INTERRUPTED: 130,
13
15
  };
14
16
  const codes = (exit, ...names) => names.map((name) => [name, exit]);
15
17
  // Problem codes (or types) from spec 1 §13, T87 and T92. EXPIRED_TOKEN and ACCESS_DENIED are the device flow's own, raised locally.
@@ -131,6 +131,15 @@ export declare const waitingLine: ({ holder, ageMs, waitMs }: Waiting) => string
131
131
  * retry is certain once the lock has gone untouched for staleMs.
132
132
  */
133
133
  export declare const busyLine: ({ holder, local, ageMs, untouchedMs, waitedMs, staleMs }: BusyDetail) => string;
134
+ /**
135
+ * `oars pool remove <hash>`: takes one batch out of this machine's outbox, the batch `oars pool outbox` printed under that hash. It takes the send lock without waiting, because a send in progress may be reading the very file, and refuses while another run holds it.
136
+ * It reads nothing from the pool and sends nothing, so it needs no sign-in, and it changes only files here. The session of a local batch goes on `removed.json`, which `poolDeposit` (and so the background sender) honors: nothing of it is built, queued, or sent until `--restore <hash>` takes it off. One a cloud session held is admitted again only by `oars pool hold pull`.
137
+ */
138
+ export declare const poolRemove: (deps: RunDeps, hash: string, options: {
139
+ server?: string | undefined;
140
+ restore?: boolean | undefined;
141
+ lock?: SendLock | undefined;
142
+ }) => Promise<Outcome>;
134
143
  /** The outbox key, and the skeleton `id`, of a machine's session: from the environment's session salt (per account, not per machine), else its own name standing in for the account, which a machine has none of. The holding route names its branch by the same key. */
135
144
  export declare const machineKeyOf: (machine: MachineConfig, session: string) => string;
136
145
  /**
@@ -149,10 +158,14 @@ export declare const poolDeposit: (deps: RunDeps, options: {
149
158
  covenant?: string | undefined;
150
159
  lock?: SendLock | undefined;
151
160
  }) => Promise<Outcome>;
152
- /** `oars pool outbox`: prints each batch as it would be sent (a batch past one request prints each part's request, in order), the request line and every header included, with the bearer credential as its size and nothing else masked, then its SHA-256, and records that it was shown. `--explain` adds each field's rule. */
161
+ /**
162
+ * `oars pool outbox`: prints each batch as it would be sent (a batch past one request prints each part's request, in order), the request line and every header included, with the bearer credential as its size and nothing else masked, then its SHA-256, and records that it was shown. `--explain` adds each field's rule.
163
+ * With `print` each batch is written as soon as it is built and marked shown right after, and the outcome carries no text: an outbox of thousands of sessions is far past V8's string length as one string. Without it (`--json`) the document carries each batch's request, and `--json` writes it element by element.
164
+ */
153
165
  export declare const poolOutbox: (deps: RunDeps, options: {
154
166
  server?: string | undefined;
155
167
  explain?: boolean | undefined;
168
+ print?: ((chunk: string) => void) | undefined;
156
169
  }) => Promise<Outcome>;
157
170
  /** `oars pool fetch`: one harness's signed bundle at one version, kept only once the key pinned at login has verified it. It keeps the signed form, so the replica can be checked again later, and the ETag, so the next fetch asks only whether it changed. The request prints before it is sent. */
158
171
  export declare const poolFetch: (deps: RunDeps, options: {
@@ -160,6 +173,21 @@ export declare const poolFetch: (deps: RunDeps, options: {
160
173
  harness?: string | undefined;
161
174
  harnessVersion: string;
162
175
  }) => Promise<Outcome>;
176
+ /**
177
+ * `oars pool join`: the five commands of a first deposit in one run, each by the handler the command itself uses: `login`, `deposit`, `outbox --explain`, `deposit --send <hash>` for each batch not yet sent, then `fetch`. It asks and checks
178
+ * nothing they do not, and a step that stops ends the run with that step's own error. Run again it skips what stands: a held sign-in, a prepared batch not yet sent, a batch in `sent.log`.
179
+ * Each step's text prints as it finishes, so the explanation is on screen before the send asks anything: on stdout, or with `json` on stderr, so stdout holds the one document.
180
+ */
181
+ export declare const poolJoin: (deps: RunDeps, options: {
182
+ server?: string | undefined;
183
+ provider?: string | undefined;
184
+ verbose?: boolean;
185
+ acceptCovenant?: string | undefined;
186
+ inEuEeaUk?: string | undefined;
187
+ harness?: string | undefined;
188
+ harnessVersion: string;
189
+ json?: boolean;
190
+ }) => Promise<Outcome>;
163
191
  /** The raw prompt, in plain words: shown by `oars pool raw` until it is answered, and what the detached sender relies on. Redaction is best effort and the text says so. */
164
192
  export declare const RAW_PROMPT: string;
165
193
  /** What a person types to say yes to the raw prompt: consent is a statement made at a terminal (GDPR Articles 7 and 9(2)(a)), not a flag a script can pass. */
@@ -11,7 +11,7 @@ import { makeCoverage, readCoverageSetting, writeCoverageSetting } from './cover
11
11
  import { checkSession, exclusionPaths, lastWritten, loadTerms, transcriptFilesOf } from './exclusion.js';
12
12
  import { localNames, makeRedactor, REDACTION_VERSION, redactTranscript } from './redact.js';
13
13
  import { recordSend, recordWire, newTally, tallyBatch } from '../telemetry/wire.js';
14
- import { discardStale, FIELD_ROWS, fieldsOf, heldAcross, lockedAcross, rescanPulled, machineKeys, markShown, noteMachineKey, outboxFolder, partsOf, pulledAt, removeBatchesExcept, removeBatchOf, staleBatches, unchangedBytes, writeBatch } from './pool-outbox.js';
14
+ import { discardStale, FIELD_ROWS, fieldsOf, heldAcross, lockedAcross, rescanPulled, machineKeys, markShown, noteMachineKey, outboxFolder, partsOf, pulledAt, readPulled, readRemoved, removeBatchesExcept, removeBatchOf, staleBatches, unchangedBytes, writeBatch, writePulled, writeRemoved } from './pool-outbox.js';
15
15
  import { deriveConsequences } from './recall-consequence.js';
16
16
  import { readRecalls } from './recall-object.js';
17
17
  import { HARNESSES, hasEnded, ORG_SIZES, REPO_VISIBILITIES, SESSION_IDLE_HOURS, isBundleVersion, isHarness, parseSkeleton, recommendationsOf, sessionsOf, skeletonOf, unpinnedKid, VOCAB_VERSION, verifyBundle } from './pool-wire.js';
@@ -37,6 +37,7 @@ const LOGIN = 'pool-login';
37
37
  const LOGOUT = 'pool-logout';
38
38
  export const DEPOSIT = 'pool-deposit';
39
39
  const OUTBOX = 'pool-outbox';
40
+ const REMOVE = 'pool-remove';
40
41
  const POOL_DEPOSITS = '/v1/deposits';
41
42
  const FETCH = 'pool-fetch';
42
43
  const EXPORT = 'pool-export';
@@ -74,9 +75,14 @@ export const runtimeOf = (deps) => {
74
75
  throw new CliError('The session store is not configured in this run.', ExitCode.UNEXPECTED, 'unexpected');
75
76
  return deps.sessions;
76
77
  };
77
- export const signedIn = async (deps, storeDir, server, step) => {
78
+ /** The sign-in held for this server that has not expired, or undefined. */
79
+ const heldSignIn = async (deps, storeDir, server, step) => {
78
80
  const found = (await loadSignIns(storeDir, step)).find((entry) => entry.server === server);
79
- if (found === undefined || found.expiresAt <= deps.now())
81
+ return found === undefined || found.expiresAt <= deps.now() ? undefined : found;
82
+ };
83
+ export const signedIn = async (deps, storeDir, server, step) => {
84
+ const found = await heldSignIn(deps, storeDir, server, step);
85
+ if (found === undefined)
80
86
  throw new CliError(`Not signed in to ${server}. Run oars pool login first.`, ExitCode.AUTH, step);
81
87
  return found;
82
88
  };
@@ -105,13 +111,14 @@ export const ask = (deps, step, server, path, init, via) => {
105
111
  /** The pool took the request and then said nothing for `Guard.stallMs`. */
106
112
  class PoolStalled extends Error {
107
113
  }
108
- /** A newcomer's words for the network failures a stalled or restarted pool produces; the raw reason follows the sentence in parentheses. */
114
+ /** A newcomer's words for the network failures a stalled or restarted pool produces; the raw reason follows the sentence in parentheses. The flag marks a failure that comes after the request began to go out, where the pool may hold the batch: a refused, unresolved, or connect-timed-out connection fails before any of it was sent, while ETIMEDOUT can also come from a retransmit timeout on an open socket after the body was written. */
109
115
  const PLAIN_REASONS = [
110
- [/ECONNRESET|socket hang up|UND_ERR_SOCKET/, 'the connection was dropped before the pool answered'],
111
- [/ECONNREFUSED/, 'nothing is listening there'],
112
- [/ENOTFOUND|EAI_AGAIN/, 'that name did not resolve'],
113
- [/ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT/, 'the connection timed out'],
114
- [/EPIPE/, 'the connection closed while the batch was going out'],
116
+ [/ECONNRESET|socket hang up|UND_ERR_SOCKET/, 'the connection was dropped before the pool answered', true],
117
+ [/ECONNREFUSED/, 'nothing is listening there', false],
118
+ [/ENOTFOUND|EAI_AGAIN/, 'that name did not resolve', false],
119
+ [/UND_ERR_CONNECT_TIMEOUT/, 'the connection timed out', false],
120
+ [/ETIMEDOUT/, 'the connection timed out', true],
121
+ [/EPIPE/, 'the connection closed while the batch was going out', true],
115
122
  ];
116
123
  const NOT_MARKED = 'Nothing was marked sent.';
117
124
  const TRY_AGAIN = 'Run the same command to try again.';
@@ -131,16 +138,18 @@ export const stoppedText = (received) => [
131
138
  'To keep a folder out, run oars pool raw --folder <folder> --mode off.',
132
139
  'To stop this machine sending, run oars pool logout.',
133
140
  ].join('\n');
141
+ /** The failure for a send the person or the launcher ended: Ctrl-C (SIGINT) exits 130, the shell convention, and any other way of stopping keeps the unexpected code. The text is the same for all of them. */
142
+ const stopped = (step, cause, received) => new CliError(stoppedText(received), cause.signal === 'SIGINT' ? ExitCode.INTERRUPTED : ExitCode.UNEXPECTED, step);
134
143
  /** Why a guarded request ended without an answer, in plain words: a stall, an interrupt, or a network failure whose raw reason is kept after the sentence. The exit code is the unreachable one, as before. */
135
144
  const guardedFailure = (step, cause, origin, guard, received) => {
136
145
  if (cause instanceof PoolStalled)
137
146
  return new CliError(`The pool at ${origin} did not answer within ${String(guard.stallMs / 1000)} seconds. ${received ? `${NOT_MARKED} ${MAY_HAVE_RECEIVED} ${TRY_AGAIN}` : RETRY}`, ExitCode.UNREACHABLE, step);
138
147
  if (cause instanceof SendInterrupted)
139
- return new CliError(stoppedText(received), ExitCode.UNEXPECTED, step);
148
+ return stopped(step, cause, received);
140
149
  const unreachable = unreachableError(step, cause, origin);
141
150
  const raw = unreachable.message.slice(`Cannot reach the server at ${origin}: `.length);
142
151
  const plain = PLAIN_REASONS.find(([pattern]) => pattern.test(raw));
143
- return plain === undefined ? unreachable : new CliError(`Cannot reach the pool at ${origin}: ${plain[1]} (${raw}). ${RETRY}`, ExitCode.UNREACHABLE, step);
152
+ return plain === undefined ? unreachable : new CliError(`Cannot reach the pool at ${origin}: ${plain[1]} (${raw}). ${received && plain[2] ? `${NOT_MARKED} ${MAY_HAVE_RECEIVED} ${TRY_AGAIN}` : RETRY}`, ExitCode.UNREACHABLE, step);
144
153
  };
145
154
  /** Prints the request when asked to, then sends exactly those headers and that body. A guarded request also ends when its stall time passes or its signal aborts, and fails in plain words. */
146
155
  const send = async (deps, step, request, show, binary = false, guard) => {
@@ -570,7 +579,7 @@ const sendBatch = async (deps, options) => {
570
579
  if (error instanceof SendLockBusy)
571
580
  throw refuse('send_locked', busyLine(error.detail));
572
581
  if (error instanceof SendInterrupted)
573
- throw new CliError(stoppedText(false), ExitCode.UNEXPECTED, DEPOSIT);
582
+ throw stopped(DEPOSIT, error, false);
574
583
  throw error;
575
584
  }
576
585
  finally {
@@ -604,6 +613,68 @@ export const busyLine = ({ holder, local, ageMs, untouchedMs, waitedMs, staleMs
604
613
  return `${held} A retry succeeds as soon as pid ${String(holder.pid)} ends.`;
605
614
  return `${held} Run the same command again once it ends. If it has hung, oars takes the lock over about ${span(Math.max(1, staleMs - untouchedMs))} from now.`;
606
615
  };
616
+ /** The session id behind an outbox key, found by deriving the key of each session in the store the way `poolDeposit` does: under every account signed in to this server, or under the machine's environment. `undefined` when the store no longer holds the session. */
617
+ const sessionOfKey = async (deps, storeDir, server, key) => {
618
+ const machine = machineOf(deps.env);
619
+ const accounts = machine === undefined ? (await loadSignIns(storeDir, REMOVE)).filter((entry) => entry.server === server).map((entry) => entry.account) : [];
620
+ const keysOf = (session) => (machine === undefined ? accounts.map((account) => contentHash([account, session])) : [machineKeyOf(machine, session)]);
621
+ return (await loadStore(storeDir)).sessions.map((observed) => observed.session).find((session) => keysOf(session).includes(key));
622
+ };
623
+ /**
624
+ * `oars pool remove <hash>`: takes one batch out of this machine's outbox, the batch `oars pool outbox` printed under that hash. It takes the send lock without waiting, because a send in progress may be reading the very file, and refuses while another run holds it.
625
+ * It reads nothing from the pool and sends nothing, so it needs no sign-in, and it changes only files here. The session of a local batch goes on `removed.json`, which `poolDeposit` (and so the background sender) honors: nothing of it is built, queued, or sent until `--restore <hash>` takes it off. One a cloud session held is admitted again only by `oars pool hold pull`.
626
+ */
627
+ export const poolRemove = async (deps, hash, options) => {
628
+ const runtime = runtimeOf(deps);
629
+ const server = poolServer(deps.env, REMOVE, options.server);
630
+ if (!/^[0-9a-f]{64}$/.test(hash))
631
+ throw usage('remove takes the SHA-256 that oars pool outbox printed: 64 hexadecimal characters.', REMOVE);
632
+ const outbox = outboxFolder(runtime.storeDir, server);
633
+ const refuse = (code, title) => new CliError(title, ExitCode.CONFLICT, REMOVE, { code, title, status: 409 });
634
+ try {
635
+ return await withSendLock(outbox, async () => {
636
+ if (options.restore === true) {
637
+ const removed = await readRemoved(outbox, REMOVE);
638
+ const key = Object.keys(removed).find((candidate) => removed[candidate]?.hash === hash);
639
+ if (key === undefined)
640
+ throw refuse('removal_unknown', 'No removed batch has that hash, so nothing was restored. The hash is the one oars pool remove printed when it removed the batch.');
641
+ Reflect.deleteProperty(removed, key);
642
+ await writeRemoved(outbox, removed);
643
+ return { document: { ok: true, server, hash, restored: true }, text: `Restored the session of batch ${hash} for ${server}. Nothing was sent. A later oars pool deposit builds it again while the session passes the gates.` };
644
+ }
645
+ const removed = await readRemoved(outbox, REMOVE);
646
+ const found = (await heldAcross(outbox)).batches.find((batch) => batch.hash === hash);
647
+ if (found === undefined)
648
+ throw refuse('batch_unknown', 'No batch with that hash is in the outbox, so nothing was removed. Run oars pool outbox to list them.');
649
+ // The session goes on the list first: the build pass would otherwise rebuild it on its next run, the background sender's included, and send what the person withdrew (GDPR Article 7(3)). A list that cannot be read has already failed above, with the batch untouched.
650
+ const session = found.pulled ? undefined : await sessionOfKey(deps, runtime.storeDir, server, found.key);
651
+ if (!found.pulled)
652
+ await writeRemoved(outbox, { ...removed, [found.key]: session === undefined ? { hash } : { hash, session } });
653
+ await rm(found.file, { force: true });
654
+ // The session's queued raw copies go in the same lock: the background sender posts every raw copy it holds without looking for its batch, and a batch the person withdrew must take the session's transcript with it (GDPR Article 7(3)). A pulled batch never had one here.
655
+ const rawGone = await removeRawExcept(outbox, new Set(), found.key);
656
+ if (found.pulled) {
657
+ const pulled = await readPulled(outbox, REMOVE);
658
+ Reflect.deleteProperty(pulled, found.key);
659
+ await writePulled(outbox, pulled);
660
+ }
661
+ const sentAt = (await sentLog(outbox)).get(hash);
662
+ const text = [
663
+ `Removed batch ${hash} from the outbox for ${server}. Nothing was sent, and nothing at the pool changed.`,
664
+ ...(rawGone === 0 ? [] : [`${String(rawGone)} queued raw ${rawGone === 1 ? 'copy' : 'copies'} of the same session went with it.`]),
665
+ ...(sentAt === undefined ? [] : [`It was already sent at ${sentAt}; removing it here does not take it back from the pool.`]),
666
+ ...(found.pulled ? ['It came from a cloud session: only oars pool hold pull admits it again.'] : [`Its session stays out of every later oars pool deposit, the background sender's included, until you run oars pool remove ${hash} --restore.`]),
667
+ ...(found.pulled || session !== undefined ? [] : ['No session in the store matches it, so the exclusion holds for this account and machine settings only.']),
668
+ ].join('\n');
669
+ return { document: { ok: true, server, hash, removed: true }, text };
670
+ }, { ...options.lock?.times, waitMs: 0 }, options.lock?.fs);
671
+ }
672
+ catch (error) {
673
+ if (error instanceof SendLockBusy)
674
+ throw refuse('send_locked', `Another oars run${error.detail.holder === undefined ? '' : ` (pid ${String(error.detail.holder.pid)})`} holds this outbox's send lock, so nothing was removed. Run the same command again once it ends.`);
675
+ throw error;
676
+ }
677
+ };
607
678
  /** The covenant version the pool names, or `undefined` when it cannot be asked or names none. The route is public, so no credential goes with it. */
608
679
  const covenantOrUndefined = async (deps, server) => {
609
680
  try {
@@ -655,7 +726,7 @@ export const poolDeposit = async (deps, options) => {
655
726
  const injectedKey = `injected-block:${(await localSalt(runtime.storeDir)).salt}`;
656
727
  const allow = compileAllow(saved.allow);
657
728
  const discarded = await discardStale(outbox);
658
- const counts = { sessions: 0, batched: 0, excluded: { folder: 0, term: 0, noRaw: 0, noTerms: 0, unreadable: 0 }, off: 0, raw: { copies: 0, bytes: 0, masked: 0 }, uncaptured: 0, notEnded: 0, refused: 0, refusedFields: [], discarded, withdrawn: 0 };
729
+ const counts = { sessions: 0, batched: 0, excluded: { folder: 0, term: 0, noRaw: 0, noTerms: 0, unreadable: 0 }, off: 0, raw: { copies: 0, bytes: 0, masked: 0 }, uncaptured: 0, removedByPerson: 0, notEnded: 0, refused: 0, refusedFields: [], discarded, withdrawn: 0 };
659
730
  // The one gate on a name: the operator's redactor and exclusion terms (t881). Everything else the pool classes itself.
660
731
  const termList = await loadTerms(terms);
661
732
  const gate = { redact: makeRedactor(await readRedactDomains(runtime.storeDir)), terms: termList };
@@ -678,6 +749,11 @@ export const poolDeposit = async (deps, options) => {
678
749
  // The sessions a Stop does not name that still pass the read-only gates: they only fill the windows of a delta, and are never counted, withdrawn, or built.
679
750
  const history = [];
680
751
  const localStore = await loadStore(runtime.storeDir);
752
+ // A damaged list stops the pass before anything is built: it must never read as "nothing was removed".
753
+ const removedList = await readRemoved(outbox, DEPOSIT);
754
+ const removedKeys = new Set(Object.keys(removedList));
755
+ // By session id too: the key follows the account and the machine's environment, so a login under another account would otherwise build the same session again.
756
+ const removedSessions = new Set(Object.values(removedList).flatMap((entry) => (entry.session === undefined ? [] : [entry.session])));
681
757
  for (const records of sessionsOf(localStore, roots)) {
682
758
  // The Stop of a machine builds the one session that stopped: the others in the store belong to Stops of their own, so their batches stay as they are. They run the three read-only gates below and no more.
683
759
  const named = options.session === undefined || records.main.session === options.session;
@@ -685,6 +761,12 @@ export const poolDeposit = async (deps, options) => {
685
761
  counts.sessions += 1;
686
762
  // Hashed with the account, so a redeposit replaces its own batch and two accounts' copies of one session cannot be joined.
687
763
  const key = machine === undefined ? contentHash([account, records.main.session]) : machineKeyOf(machine, records.main.session);
764
+ // Withdrawn by the person with `oars pool remove`: nothing of the session is built, queued, or read for a window, and its batch and raw copies are already gone.
765
+ if (removedKeys.has(key) || removedSessions.has(records.main.session)) {
766
+ if (named)
767
+ counts.removedByPerson += 1;
768
+ continue;
769
+ }
688
770
  if (!allow.allows(records.main.project)) {
689
771
  if (named) {
690
772
  counts.excluded.folder += 1;
@@ -793,6 +875,7 @@ export const poolDeposit = async (deps, options) => {
793
875
  ...(discarded === 0 ? [] : [`Discarded ${String(discarded)} ${discarded === 1 ? 'batch' : 'batches'} built under an older vocabulary, field list, or skeleton format.`]),
794
876
  `Raw copies queued: ${String(counts.raw.copies)} (${String(counts.raw.bytes)} bytes, ${String(counts.raw.masked)} values masked). ${rawNote(saved.pool.rawConsent)}`.trim(),
795
877
  ...(rawStale(saved.pool, covenant) ? [`Your yes to the raw prompt was given under covenant ${saved.pool.rawConsentVersion ?? 'none'}, not ${covenant ?? ''}, so no raw copy is queued. Run oars pool raw --consent yes to answer for the covenant now in force.`] : []),
878
+ ...(counts.removedByPerson === 0 ? [] : [`${String(counts.removedByPerson)} ${counts.removedByPerson === 1 ? 'session' : 'sessions'} you removed with oars pool remove: nothing of them is built, queued, or sent. oars pool remove <hash> --restore brings one back.`]),
796
879
  ...(counts.off === 0 ? [] : [`${String(counts.off)} ${counts.off === 1 ? 'session' : 'sessions'} in a folder switched off: nothing of them is queued.`]),
797
880
  ...(termList.length === 0 ? [termsHint(terms)] : []),
798
881
  'Run oars pool outbox to print exactly what would be sent.',
@@ -812,7 +895,10 @@ const explained = (bytes) => {
812
895
  }
813
896
  return rows;
814
897
  };
815
- /** `oars pool outbox`: prints each batch as it would be sent (a batch past one request prints each part's request, in order), the request line and every header included, with the bearer credential as its size and nothing else masked, then its SHA-256, and records that it was shown. `--explain` adds each field's rule. */
898
+ /**
899
+ * `oars pool outbox`: prints each batch as it would be sent (a batch past one request prints each part's request, in order), the request line and every header included, with the bearer credential as its size and nothing else masked, then its SHA-256, and records that it was shown. `--explain` adds each field's rule.
900
+ * With `print` each batch is written as soon as it is built and marked shown right after, and the outcome carries no text: an outbox of thousands of sessions is far past V8's string length as one string. Without it (`--json`) the document carries each batch's request, and `--json` writes it element by element.
901
+ */
816
902
  export const poolOutbox = async (deps, options) => {
817
903
  const runtime = runtimeOf(deps);
818
904
  const server = poolServer(deps.env, OUTBOX, options.server);
@@ -822,25 +908,41 @@ export const poolOutbox = async (deps, options) => {
822
908
  const masker = new Masker();
823
909
  masker.add(token);
824
910
  const sent = await sentLog(outbox);
911
+ const { print } = options;
825
912
  const shown = [];
913
+ // Blocks are separated by a blank line; each is written alone, so no string holds more than one batch.
914
+ const state = { wrote: false };
915
+ const put = (block) => {
916
+ print?.(`${state.wrote ? '\n\n' : ''}${block}`);
917
+ state.wrote = true;
918
+ };
826
919
  for (const batch of batches) {
827
920
  const fields = options.explain === true ? explained(batch.bytes) : undefined;
828
921
  // A batch past one request prints every part's request, in the order they are sent.
829
922
  const request = callsOf(token, batch.bytes, batch.hash).map((call) => printed(outgoing(deps, server, POOL_DEPOSITS, call), masker)).join('\n');
830
- shown.push({ hash: batch.hash, request, ...(sent.has(batch.hash) ? { sent: sent.get(batch.hash) ?? '' } : {}), ...(fields === undefined ? {} : { fields }) });
923
+ const at = sent.get(batch.hash);
924
+ put([request, `sha256: ${batch.hash}`, ...(at === undefined ? [] : [`sent: ${at}`]), ...Object.entries(fields ?? {}).map(([field, row]) => ` ${field}: ${row}`)].join('\n'));
925
+ shown.push({ hash: batch.hash, ...(print === undefined ? { request } : {}), ...(at === undefined ? {} : { sent: at }), ...(fields === undefined ? {} : { fields }) });
926
+ await markShown(outbox, batch.hash, deps.now());
831
927
  }
832
928
  const { copies } = await heldRaw(outbox);
833
- const rawBlocks = copies.flatMap((copy) => rawParts(copy.body).map((part, at, all) => [`raw part ${String(at + 1)}/${String(all.length)} sha256: ${copy.hash} bytes: ${String(Buffer.byteLength(part))}${sent.has(copy.hash) ? ` sent: ${sent.get(copy.hash) ?? ''}` : ''}`, ...(options.explain === true ? [part] : [])].join('\n')));
834
- for (const { hash } of shown)
835
- await markShown(outbox, hash, deps.now());
929
+ for (const copy of copies) {
930
+ const parts = rawParts(copy.body);
931
+ for (const [at, part] of parts.entries())
932
+ put([`raw part ${String(at + 1)}/${String(parts.length)} sha256: ${copy.hash} bytes: ${String(Buffer.byteLength(part))}${sent.has(copy.hash) ? ` sent: ${sent.get(copy.hash) ?? ''}` : ''}`, ...(options.explain === true ? [part] : [])].join('\n'));
933
+ }
836
934
  const stale = await staleBatches(outbox);
837
- const blocks = shown.map(({ hash, request, sent: at, fields }) => [request, `sha256: ${hash}`, ...(at === undefined ? [] : [`sent: ${at}`]), ...Object.entries(fields ?? {}).map(([field, row]) => ` ${field}: ${row}`)].join('\n'));
838
935
  const notes = [
839
936
  ...(edited === 0 ? [] : [`${String(edited)} ${edited === 1 ? 'batch was' : 'batches were'} edited on disk since ${edited === 1 ? 'it was' : 'they were'} written and ${edited === 1 ? 'is' : 'are'} not printed or sent. Run oars pool deposit to build ${edited === 1 ? 'it' : 'them'} again.`]),
840
937
  ...(stale === 0 ? [] : [`${String(stale)} ${stale === 1 ? 'batch was' : 'batches were'} built under an older vocabulary, field list, or skeleton format and will never be sent.`]),
841
938
  ];
842
- const text = shown.length === 0 && rawBlocks.length === 0 ? ['The outbox is empty. Run oars pool deposit first.', ...notes].join('\n') : [...blocks, ...rawBlocks, ...notes].join('\n\n');
843
- return { document: { ok: true, server, batches: shown, raw: copies.map(({ hash, bytes }) => ({ hash, bytes, ...(sent.has(hash) ? { sent: sent.get(hash) ?? '' } : {}) })), edited, stale }, text };
939
+ if (!state.wrote)
940
+ put(['The outbox is empty. Run oars pool deposit first.', ...notes].join('\n'));
941
+ else
942
+ for (const note of notes)
943
+ put(note);
944
+ print?.('\n');
945
+ return { document: { ok: true, server, batches: shown, raw: copies.map(({ hash, bytes }) => ({ hash, bytes, ...(sent.has(hash) ? { sent: sent.get(hash) ?? '' } : {}) })), edited, stale }, text: '', output: print === undefined ? 'chunked' : 'streamed' };
844
946
  };
845
947
  /**
846
948
  * What the pool says it served a bundle at, printed as the bundle states it: the client holds no floor of its own and applies none (a floor is the pool's deploy setting), so a bundle that states none prints nothing.
@@ -949,6 +1051,74 @@ export const poolFetch = async (deps, options) => {
949
1051
  text: `Fetched ${harness} ${harnessVersion}: ${String(reads.length)} reads, ${String(cells.length)} cells, and ${String(motifs.length)} motifs, verified against the pinned key and kept at ${file}.${floorSentence(floors)}`,
950
1052
  };
951
1053
  };
1054
+ const JOIN = 'pool-join';
1055
+ const JOIN_AGAIN = 'Run oars pool join again to continue from here.';
1056
+ /**
1057
+ * `oars pool join`: the five commands of a first deposit in one run, each by the handler the command itself uses: `login`, `deposit`, `outbox --explain`, `deposit --send <hash>` for each batch not yet sent, then `fetch`. It asks and checks
1058
+ * nothing they do not, and a step that stops ends the run with that step's own error. Run again it skips what stands: a held sign-in, a prepared batch not yet sent, a batch in `sent.log`.
1059
+ * Each step's text prints as it finishes, so the explanation is on screen before the send asks anything: on stdout, or with `json` on stderr, so stdout holds the one document.
1060
+ */
1061
+ export const poolJoin = async (deps, options) => {
1062
+ const { storeDir } = runtimeOf(deps);
1063
+ const server = poolServer(deps.env, JOIN, options.server);
1064
+ const say = (line) => {
1065
+ deps.io.stderr(`${line}
1066
+ `);
1067
+ };
1068
+ const screen = options.json === true ? deps.io.stderr : deps.io.stdout;
1069
+ const show = (outcome) => {
1070
+ if (outcome.output !== 'streamed')
1071
+ screen(`${outcome.text}
1072
+ `);
1073
+ return outcome;
1074
+ };
1075
+ const outbox = outboxFolder(storeDir, server);
1076
+ const unsent = async () => {
1077
+ const sent = await sentLog(outbox);
1078
+ return (await heldAcross(outbox)).batches.flatMap(({ hash }) => (sent.has(hash) ? [] : [hash]));
1079
+ };
1080
+ try {
1081
+ const held = await heldSignIn(deps, storeDir, server, LOGIN);
1082
+ let login = { skipped: true, account: held?.account };
1083
+ if (held === undefined) {
1084
+ say(`Signing in to ${server}.`);
1085
+ login = show(await poolLogin(deps, { server, provider: options.provider, verbose: options.verbose })).document;
1086
+ }
1087
+ else {
1088
+ say(`Already signed in to ${server} as ${held.account}.`);
1089
+ }
1090
+ let prepared = { skipped: true };
1091
+ if ((await unsent()).length > 0) {
1092
+ say('Your outbox already holds a prepared batch; not preparing it again.');
1093
+ }
1094
+ else {
1095
+ say('Preparing your deposit in the outbox.');
1096
+ prepared = show(await poolDeposit(deps, { server })).document;
1097
+ }
1098
+ say('Showing what will be sent, field by field.');
1099
+ const shown = show(await poolOutbox(deps, { server, explain: true, print: screen })).document;
1100
+ const hashes = await unsent();
1101
+ const sends = [];
1102
+ if (hashes.length === 0) {
1103
+ say((await heldAcross(outbox)).batches.length === 0 ? 'No session is ready to deposit yet; sending nothing.' : 'Everything prepared is already sent; sending nothing.');
1104
+ }
1105
+ else {
1106
+ say(`Sending ${plural(hashes.length, 'batch', 'batches')} to ${server}.`);
1107
+ for (const hash of hashes)
1108
+ sends.push(show(await poolDeposit(deps, { server, send: hash, acceptCovenant: options.acceptCovenant, inEuEeaUk: options.inEuEeaUk })).document);
1109
+ }
1110
+ say(`Fetching the bundle for ${options.harness ?? 'your most used harness'} ${options.harnessVersion}.`);
1111
+ const fetched = show(await poolFetch(deps, { server, harness: options.harness, harnessVersion: options.harnessVersion }));
1112
+ return {
1113
+ document: { ok: true, server, login, deposit: prepared, outbox: shown, sends, fetch: fetched.document },
1114
+ text: `Joined ${server}: ${plural(sends.length, 'batch', 'batches')} sent this run, and the ${options.harnessVersion} bundle is kept.`,
1115
+ };
1116
+ }
1117
+ catch (error) {
1118
+ say(JOIN_AGAIN);
1119
+ throw error;
1120
+ }
1121
+ };
952
1122
  const RAW = 'pool-raw';
953
1123
  /** The raw prompt, in plain words: shown by `oars pool raw` until it is answered, and what the detached sender relies on. Redaction is best effort and the text says so. */
954
1124
  export const RAW_PROMPT = [
@@ -94,6 +94,19 @@ export interface Pulled {
94
94
  export declare const readPulled: (outbox: string, step: string) => Promise<Pulled>;
95
95
  /** Staged and renamed, so a crash leaves the whole old file or the whole new one. */
96
96
  export declare const writePulled: (outbox: string, pulled: Pulled) => Promise<void>;
97
+ /** One session a person took out with `oars pool remove`: the hash of the batch that was removed, which is how `--restore` names the way back, and the session id when it could be resolved. The outbox key derives from the account or the machine's environment, so only the session id keeps the session out after either changes; an entry with no id (the session was gone from the store, or the file is older) skips by key alone. */
98
+ export interface RemovedEntry {
99
+ hash: string;
100
+ session?: string;
101
+ }
102
+ /** The sessions a person took out of the outbox with `oars pool remove`, by outbox key. Local only; nothing in it goes to the pool. */
103
+ export interface Removed {
104
+ [key: string]: RemovedEntry;
105
+ }
106
+ /** `removed.json` as it stands; none when the file is missing. A file that is not a list of keys and entries fails the command that reads it, so a damaged list never becomes "send everything". A bare string is an entry with only a hash, the form before session ids were kept. */
107
+ export declare const readRemoved: (outbox: string, step: string) => Promise<Removed>;
108
+ /** Staged and renamed, like `pulled.json`: a crash leaves the whole old list or the whole new one. */
109
+ export declare const writeRemoved: (outbox: string, removed: Removed) => Promise<void>;
97
110
  /** Whether any string or key in `value` holds one of `terms`, compared lowercased the way the local gate compares a transcript. */
98
111
  export declare const holdsTerm: (value: unknown, terms: readonly string[]) => boolean;
99
112
  /**
@@ -385,6 +385,52 @@ export const writePulled = async (outbox, pulled) => {
385
385
  await writeOwnerOnly(staged, `${JSON.stringify(Object.fromEntries(Object.entries(pulled).sort(([a], [b]) => (a < b ? -1 : 1))), null, 2)}\n`);
386
386
  await rename(staged, join(outbox, PULLED));
387
387
  };
388
+ const REMOVED = 'removed.json';
389
+ const removedEntryOf = (value) => {
390
+ if (typeof value === 'string')
391
+ return { hash: value };
392
+ if (typeof value !== 'object' || value === null || Array.isArray(value) || !('hash' in value) || typeof value.hash !== 'string')
393
+ return undefined;
394
+ if (!('session' in value))
395
+ return { hash: value.hash };
396
+ return typeof value.session === 'string' ? { hash: value.hash, session: value.session } : undefined;
397
+ };
398
+ /** `removed.json` as it stands; none when the file is missing. A file that is not a list of keys and entries fails the command that reads it, so a damaged list never becomes "send everything". A bare string is an entry with only a hash, the form before session ids were kept. */
399
+ export const readRemoved = async (outbox, step) => {
400
+ let text;
401
+ try {
402
+ text = await readFile(join(outbox, REMOVED), 'utf8');
403
+ }
404
+ catch (error) {
405
+ if (isMissing(error))
406
+ return {};
407
+ throw error;
408
+ }
409
+ let value;
410
+ try {
411
+ value = JSON.parse(text);
412
+ }
413
+ catch {
414
+ value = undefined;
415
+ }
416
+ const entries = typeof value !== 'object' || value === null || Array.isArray(value) ? undefined : Object.entries(value).map(([key, entry]) => [key, removedEntryOf(entry)]);
417
+ const kept = [];
418
+ for (const [key, entry] of entries ?? [])
419
+ if (entry !== undefined)
420
+ kept.push([key, entry]);
421
+ if (entries === undefined || kept.length !== entries.length) {
422
+ throw new CliError(`${join(outbox, REMOVED)} is not a list of removed sessions. Move it aside and run the command again; nothing was changed, built, or sent.`, ExitCode.INVALID, step);
423
+ }
424
+ return Object.fromEntries(kept);
425
+ };
426
+ /** Staged and renamed, like `pulled.json`: a crash leaves the whole old list or the whole new one. */
427
+ export const writeRemoved = async (outbox, removed) => {
428
+ await mkdirOwnerOnly(outbox);
429
+ const staged = join(outbox, `${REMOVED}.tmp`);
430
+ await writeOwnerOnly(staged, `${JSON.stringify(Object.fromEntries(Object.entries(removed).sort(([a], [b]) => (a < b ? -1 : 1))), null, 2)}
431
+ `);
432
+ await rename(staged, join(outbox, REMOVED));
433
+ };
388
434
  const POOL_STEP = 'pool-outbox';
389
435
  /** Whether any string or key in `value` holds one of `terms`, compared lowercased the way the local gate compares a transcript. */
390
436
  export const holdsTerm = (value, terms) => {
@@ -68,7 +68,8 @@ export declare const withSendLock: <T>(outbox: string, run: () => Promise<T>, ti
68
68
  /** Why an interactive send ended before it finished: a signal reached it, or the process that launched it is gone. */
69
69
  export declare class SendInterrupted extends Error {
70
70
  readonly kind: 'signal' | 'launcher';
71
- constructor(kind: 'signal' | 'launcher', message: string);
71
+ readonly signal?: NodeJS.Signals | undefined;
72
+ constructor(kind: 'signal' | 'launcher', message: string, signal?: NodeJS.Signals | undefined);
72
73
  }
73
74
  /** What a test replaces: the process's signal hooks, its launcher, and how that launcher's liveness is read. */
74
75
  export interface InterruptHost {
@@ -147,9 +147,11 @@ export const withSendLock = async (outbox, run, times = {}, fs = { stat }, wait
147
147
  /** Why an interactive send ended before it finished: a signal reached it, or the process that launched it is gone. */
148
148
  export class SendInterrupted extends Error {
149
149
  kind;
150
- constructor(kind, message) {
150
+ signal;
151
+ constructor(kind, message, signal) {
151
152
  super(message);
152
153
  this.kind = kind;
154
+ this.signal = signal;
153
155
  this.name = 'SendInterrupted';
154
156
  }
155
157
  }
@@ -164,7 +166,7 @@ export const watchInterrupt = (host = { on: (event, listener) => process.on(even
164
166
  const listeners = SIGNALS.map((name) => [
165
167
  name,
166
168
  () => {
167
- controller.abort(new SendInterrupted('signal', `Interrupted by ${name}.`));
169
+ controller.abort(new SendInterrupted('signal', `Interrupted by ${name}.`, name));
168
170
  },
169
171
  ]);
170
172
  for (const [name, listener] of listeners)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manyoars/oars",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "mcpName": "io.github.manyoars/oars",
5
5
  "description": "A local, searchable record of your coding agent sessions. Reads Claude Code, Cursor, Codex, Copilot CLI, Gemini CLI, and more, and keeps the record on your machine.",
6
6
  "homepage": "https://manyoars.com/",