@manyoars/oars 0.5.2 → 0.5.3

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/LICENSE CHANGED
@@ -187,7 +187,7 @@
187
187
  same "printed page" as the copyright notice for easier
188
188
  identification within third-party archives.
189
189
 
190
- Copyright 2026 Switchyard Software Company
190
+ Copyright 2026 Many Oars Co.
191
191
 
192
192
  Licensed under the Apache License, Version 2.0 (the "License");
193
193
  you may not use this file except in compliance with the License.
@@ -1,6 +1,7 @@
1
1
  import type { Outcome, RunDeps } from '../cli.js';
2
2
  import { CliError } from '../errors.js';
3
3
  import { Masker, type Outgoing } from './pool-print.js';
4
+ import { type BusyDetail, type LockFs, type LockTimes, type Waiting } from './pool-send-lock.js';
4
5
  import { isObject, loadSignIns, textIn, type SignIn } from './pool-replica.js';
5
6
  import { type MachineConfig } from './machine.js';
6
7
  import { type SessionsRuntime } from './store.js';
@@ -46,8 +47,19 @@ interface Call {
46
47
  export type Sender = (request: Outgoing) => Promise<Reply>;
47
48
  /** One call to the pool. A redirect is an error: it could carry a batch or a token somewhere the checks on the named server never saw. */
48
49
  export declare const ask: (deps: RunDeps, step: string, server: string, path: string, init: Call, via?: Sender) => Promise<Reply>;
49
- /** The sender `deposit --send` uses when none is given: the request goes out over the network as built. The loopback test passes it to read what reaches the socket. */
50
- export declare const networkSender: (deps: RunDeps, step: string) => Sender;
50
+ /** What a guarded send runs under: the longest the pool gets to answer one request, and (for an interactive `--send`) the signal that ends the request when the person, or the program that launched the send, ends the run. The background sender has no one to interrupt it. */
51
+ export interface Guard {
52
+ stallMs: number;
53
+ signal?: AbortSignal;
54
+ }
55
+ /**
56
+ * What a person reads after a send they stopped (Ctrl-C, a closed terminal, a launcher that died): the batch stays queued, and the background sender (hook-bin.js nightly, started by a later oars command
57
+ * or the end of a session, about once an hour at most, not on a timer) sends it once the covenant is accepted, whether or not this command ever finishes. The second line is added only when a request carrying the batch had gone out.
58
+ * The folder switch and the sign-out are the two real ways to stop that: the sender builds the outbox again first, which withdraws a folder that is off, and it sends nothing without a sign-in.
59
+ */
60
+ export declare const stoppedText: (received: boolean) => string;
61
+ /** The sender `deposit --send` uses when none is given: the request goes out over the network as built. The loopback test passes it to read what reaches the socket. An interactive send passes a `Guard`. */
62
+ export declare const networkSender: (deps: RunDeps, step: string, guard?: Guard) => Sender;
51
63
  /** The server's named refusal, as a failure: its `error` is the problem's code in `--json`. */
52
64
  export declare const refusal: (step: string, { status, body }: Reply) => CliError;
53
65
  /**
@@ -66,6 +78,12 @@ export declare const poolLogin: (deps: RunDeps, options: {
66
78
  export declare const poolLogout: (deps: RunDeps, options: {
67
79
  server?: string | undefined;
68
80
  }) => Promise<Outcome>;
81
+ /**
82
+ * The CLI's own words around the covenant screen, never inside it: the screen text and the hash of it that the pool records stay exactly what the pool served. They say what a deposit gives and what to run next, and
83
+ * they promise only access, as covenant 2.1 does ("What you get" and Section 4.3). The third line points at the full text because Part A is a summary and Section 16.2 makes the full covenant the whole agreement.
84
+ */
85
+ export declare const OFFER_BEFORE: readonly string[];
86
+ export declare const OFFER_AFTER: readonly string[];
69
87
  /** A person is here when input and the screen's stream are both terminals: a typed phrase counts only for a text that reached a screen. */
70
88
  export declare const isTerminal: (deps: RunDeps) => boolean;
71
89
  /** The next line a person types, or `undefined` when the input ends first. The interface is closed after it, so the terminal is released. */
@@ -94,6 +112,25 @@ export declare const postRaw: (deps: RunDeps, options: {
94
112
  }) => Promise<void>;
95
113
  /** The exclusion terms in force now, re-read from the term file each time it is asked: empty when the file is missing or unreadable. */
96
114
  export declare const currentTerms: (deps: RunDeps) => Promise<string[]>;
115
+ /** What a test replaces in the send lock: its times and its clock, pause, and process checks, so a wait runs on driven time. */
116
+ export interface SendLock {
117
+ times: Partial<LockTimes>;
118
+ fs: LockFs;
119
+ }
120
+ /**
121
+ * How long the pool gets to answer one request of a send (an interactive `--send` and the background sender) before the send ends. It is a deadline for the whole request, the upload and the answer together,
122
+ * not an idle timeout: a batch is one session's skeleton, whose largest fixture is 887 KB (docs/pool.md), which a link of 240 kbit/s or better uploads inside 30 seconds. A slower link fails each try with the stall
123
+ * sentence; resetting the timer once headers arrive would not help it, because the upload is what is slow.
124
+ */
125
+ export declare const SEND_STALL_MS = 30000;
126
+ /** What the person reads within a second of a send that has to wait: whose lock it is, how old it is, and how long this run waits. */
127
+ export declare const waitingLine: ({ holder, ageMs, waitMs }: Waiting) => string;
128
+ /**
129
+ * What a send that gave up says: nothing was sent, who held the lock, and when a retry can succeed. A live holder on this host touches the lock every quarter of staleMs, even while it waits on a stalled pool, so the clock
130
+ * never frees its lock: the true condition is its end (a dead holder is taken over at once). Only a holder the lock does not name, or one on another host, whose liveness cannot be read, is decided by the clock: a
131
+ * retry is certain once the lock has gone untouched for staleMs.
132
+ */
133
+ export declare const busyLine: ({ holder, local, ageMs, untouchedMs, waitedMs, staleMs }: BusyDetail) => string;
97
134
  /** 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. */
98
135
  export declare const machineKeyOf: (machine: MachineConfig, session: string) => string;
99
136
  /**
@@ -110,6 +147,7 @@ export declare const poolDeposit: (deps: RunDeps, options: {
110
147
  git?: Git | undefined;
111
148
  session?: string | undefined;
112
149
  covenant?: string | undefined;
150
+ lock?: SendLock | undefined;
113
151
  }) => Promise<Outcome>;
114
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. */
115
153
  export declare const poolOutbox: (deps: RunDeps, options: {
@@ -19,7 +19,7 @@ import { carriedDeltas, heldDeltas, keptDeltas, markDeltasSent, placedOf, sentDe
19
19
  import { Masker, printed, TRANSPORT_HEADERS, waitWords } from './pool-print.js';
20
20
  import { rawAllowed, rawStale, sendsOf } from './pool-choices.js';
21
21
  import { heldRaw, rawParts, removeRawExcept, writeRaw } from './pool-raw.js';
22
- import { SendLockBusy, withSendLock } from './pool-send-lock.js';
22
+ import { SendInterrupted, SendLockBusy, watchInterrupt, withSendLock } from './pool-send-lock.js';
23
23
  import { markSent, retirePulled, sentLog } from './pool-sent.js';
24
24
  import { shellReader } from './projection.js';
25
25
  import { isVocabularyExpired, REGISTRY_MAX_AGE_DAYS, VOCAB_SNAPSHOT_DATE } from './public-vocabulary.js';
@@ -102,29 +102,92 @@ export const ask = (deps, step, server, path, init, via) => {
102
102
  const request = outgoing(deps, server, path, init);
103
103
  return via === undefined ? send(deps, step, request, init.show, init.binary === true) : via(request);
104
104
  };
105
- /** Prints the request when asked to, then sends exactly those headers and that body. */
106
- const send = async (deps, step, request, show, binary = false) => {
105
+ /** The pool took the request and then said nothing for `Guard.stallMs`. */
106
+ class PoolStalled extends Error {
107
+ }
108
+ /** A newcomer's words for the network failures a stalled or restarted pool produces; the raw reason follows the sentence in parentheses. */
109
+ 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'],
115
+ ];
116
+ const NOT_MARKED = 'Nothing was marked sent.';
117
+ const TRY_AGAIN = 'Run the same command to try again.';
118
+ const RETRY = `${NOT_MARKED} ${TRY_AGAIN}`;
119
+ /** Said after a stop or a stall once the request carrying the batch had gone out: the pool may hold it even though nothing here saw the answer. */
120
+ const MAY_HAVE_RECEIVED = 'The pool may already have received it.';
121
+ /** What `deposit` says after it writes the outbox: the covenant (the person's consent, docs/covenant 2.1) already lets the background sender send what is queued, with or without this command. A machine's batches are never sent by it. */
122
+ const SENT_LATER = 'Nothing was sent yet. Once the covenant is accepted, oars sends what is queued automatically, in the background.';
123
+ /**
124
+ * What a person reads after a send they stopped (Ctrl-C, a closed terminal, a launcher that died): the batch stays queued, and the background sender (hook-bin.js nightly, started by a later oars command
125
+ * or the end of a session, about once an hour at most, not on a timer) sends it once the covenant is accepted, whether or not this command ever finishes. The second line is added only when a request carrying the batch had gone out.
126
+ * The folder switch and the sign-out are the two real ways to stop that: the sender builds the outbox again first, which withdraws a folder that is off, and it sends nothing without a sign-in.
127
+ */
128
+ export const stoppedText = (received) => [
129
+ 'Stopped. This batch stays queued, and once the covenant is accepted oars sends it automatically in the background.',
130
+ ...(received ? [MAY_HAVE_RECEIVED] : []),
131
+ 'To keep a folder out, run oars pool raw --folder <folder> --mode off.',
132
+ 'To stop this machine sending, run oars pool logout.',
133
+ ].join('\n');
134
+ /** 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
+ const guardedFailure = (step, cause, origin, guard, received) => {
136
+ if (cause instanceof PoolStalled)
137
+ 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
+ if (cause instanceof SendInterrupted)
139
+ return new CliError(stoppedText(received), ExitCode.UNEXPECTED, step);
140
+ const unreachable = unreachableError(step, cause, origin);
141
+ const raw = unreachable.message.slice(`Cannot reach the server at ${origin}: `.length);
142
+ 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);
144
+ };
145
+ /** 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
+ const send = async (deps, step, request, show, binary = false, guard) => {
107
147
  if (show !== undefined)
108
148
  deps.io.stderr(`${printed(request, show)}
109
149
  `);
110
- let response;
111
- try {
112
- response = await fetch(request.url, { method: request.method, headers: request.headers, redirect: 'error', ...(request.body === undefined ? {} : { body: request.body }) });
113
- }
114
- catch (error) {
115
- throw unreachableError(step, error, request.url.origin);
116
- }
117
- let body;
150
+ const stop = new AbortController();
151
+ const relay = () => {
152
+ stop.abort(guard?.signal?.reason);
153
+ };
154
+ guard?.signal?.addEventListener('abort', relay, { once: true });
155
+ if (guard?.signal?.aborted === true)
156
+ relay();
157
+ const timer = guard === undefined ? undefined : setTimeout(() => {
158
+ stop.abort(new PoolStalled());
159
+ }, guard.stallMs);
160
+ // Whether the batch itself had gone out when the run ended: only the deposit request carries it, and only when it went out before the run was stopped.
161
+ let started = false;
162
+ const failure = (error) => (guard === undefined ? unreachableError(step, error, request.url.origin) : guardedFailure(step, stop.signal.aborted ? stop.signal.reason : error, request.url.origin, guard, started && request.url.pathname === POOL_DEPOSITS));
118
163
  try {
119
- body = binary && response.status === 200 ? new Uint8Array(await response.arrayBuffer()) : await response.json();
164
+ let response;
165
+ try {
166
+ started = !stop.signal.aborted;
167
+ response = await fetch(request.url, { method: request.method, headers: request.headers, redirect: 'error', ...(guard === undefined ? {} : { signal: stop.signal }), ...(request.body === undefined ? {} : { body: request.body }) });
168
+ }
169
+ catch (error) {
170
+ throw failure(error);
171
+ }
172
+ let body;
173
+ try {
174
+ body = binary && response.status === 200 ? new Uint8Array(await response.arrayBuffer()) : await response.json();
175
+ }
176
+ catch (error) {
177
+ // An answer that began and then stalled is no answer; one that ended badly is read as no body, as before.
178
+ if (stop.signal.aborted)
179
+ throw failure(error);
180
+ body = undefined;
181
+ }
182
+ return { status: response.status, body, headers: response.headers };
120
183
  }
121
- catch {
122
- body = undefined;
184
+ finally {
185
+ clearTimeout(timer);
186
+ guard?.signal?.removeEventListener('abort', relay);
123
187
  }
124
- return { status: response.status, body, headers: response.headers };
125
188
  };
126
- /** The sender `deposit --send` uses when none is given: the request goes out over the network as built. The loopback test passes it to read what reaches the socket. */
127
- export const networkSender = (deps, step) => (request) => send(deps, step, request, undefined);
189
+ /** The sender `deposit --send` uses when none is given: the request goes out over the network as built. The loopback test passes it to read what reaches the socket. An interactive send passes a `Guard`. */
190
+ export const networkSender = (deps, step, guard) => (request) => send(deps, step, request, undefined, false, guard);
128
191
  /** The server's named refusal, as a failure: its `error` is the problem's code in `--json`. */
129
192
  export const refusal = (step, { status, body }) => {
130
193
  const code = isObject(body) ? textIn(body, 'error') : undefined;
@@ -296,8 +359,18 @@ const regionOf = (text) => {
296
359
  const answer = text?.trim().toLowerCase();
297
360
  return answer === 'yes' || answer === 'no' ? answer : undefined;
298
361
  };
362
+ /**
363
+ * The CLI's own words around the covenant screen, never inside it: the screen text and the hash of it that the pool records stay exactly what the pool served. They say what a deposit gives and what to run next, and
364
+ * they promise only access, as covenant 2.1 does ("What you get" and Section 4.3). The third line points at the full text because Part A is a summary and Section 16.2 makes the full covenant the whole agreement.
365
+ */
366
+ export const OFFER_BEFORE = [
367
+ 'Depositing gives your account access to the pool.',
368
+ 'A counting deposit lifts the weekly limit on pool fetches for 90 days.',
369
+ 'The covenant is the whole deal. Its full text is linked below. Read it before you type.',
370
+ ];
371
+ export const OFFER_AFTER = ['Accepted. Sending your batch now.', 'Run oars pool fetch --help for your first pool download.'];
299
372
  /** A send stops here when the server is closed or wants a covenant accepted, before the batch is read for the last time. */
300
- const openCovenant = async (deps, server, token, accept, region, via) => {
373
+ const openCovenant = async (deps, server, token, accept, region, via, signal) => {
301
374
  const reply = await ask(deps, DEPOSIT, server, '/v1/covenant', { token }, via);
302
375
  if (reply.status !== 200 || !isObject(reply.body))
303
376
  throw refusal(DEPOSIT, reply);
@@ -321,13 +394,17 @@ const openCovenant = async (deps, server, token, accept, region, via) => {
321
394
  if (!isTerminal(deps)) {
322
395
  throw notAccepted(`Covenant ${version} is not accepted for this account. Accepting it takes a person at a terminal who reads it and types its phrase: run this command from one. Nothing was accepted.`);
323
396
  }
397
+ deps.io.stderr(`${OFFER_BEFORE.join('\n')}\n`);
324
398
  deps.io.stderr(`\n${screen.text}\n\n> `);
325
399
  emitOf(deps)('covenant-shown', { version });
326
400
  // One reader for both lines: a second one opened after the first closed would lose a line the first had already taken from a pipe.
327
- const lines = createInterface({ input: runtimeOf(deps).stdin, terminal: false });
401
+ // A stop signal (Ctrl-C, a closed terminal) closes the reader, so a read that is waiting for the person ends with the signal's reason instead of holding the process open.
402
+ const lines = createInterface({ input: runtimeOf(deps).stdin, terminal: false, ...(signal === undefined ? {} : { signal }) });
328
403
  const iterator = lines[Symbol.asyncIterator]();
329
404
  const nextLine = async () => {
330
405
  const next = await iterator.next();
406
+ if (signal?.aborted === true)
407
+ throw signal.reason;
331
408
  return next.done === true ? undefined : next.value;
332
409
  };
333
410
  let answered = region;
@@ -349,6 +426,7 @@ const openCovenant = async (deps, server, token, accept, region, via) => {
349
426
  const accepted = await ask(deps, DEPOSIT, server, '/v1/covenant/accept', { token, body }, via);
350
427
  if (accepted.status !== 200)
351
428
  throw refusal(DEPOSIT, accepted);
429
+ deps.io.stderr(`${OFFER_AFTER.join('\n')}\n`);
352
430
  // A yes to the raw prompt belongs to the covenant it was given under, and a covenant can change what that prompt covers: accepting another version asks the raw question again.
353
431
  const reset = { done: false };
354
432
  await updatePoolChoices(runtimeOf(deps).storeDir, (choices) => {
@@ -449,10 +527,12 @@ const termsHint = (file) => `No exclusion terms file at ${file}: until it holds
449
527
  /** `deposit --send <hash>`: the send lock the detached sender (hook-bin.js nightly) takes too, then a skip for a hash already in `sent.log`, then the batch lock, then the covenant (accepted here or before, or nothing is sent), then the request. The body is read and hashed again just before the request. */
450
528
  const sendBatch = async (deps, options) => {
451
529
  const { outbox, server, token, hash } = options;
452
- const via = options.via ?? networkSender(deps, DEPOSIT);
453
530
  if (!/^[0-9a-f]{64}$/.test(hash))
454
531
  throw usage('--send takes the SHA-256 that oars pool outbox printed: 64 hexadecimal characters.', DEPOSIT);
455
532
  const refuse = (code, title) => new CliError(title, ExitCode.CONFLICT, DEPOSIT, { code, title, status: 409 });
533
+ // Ctrl-C, a kill, a closed terminal, or a launcher that died ends the wait for the lock and the request in flight, so a send the person gave up on cannot finish later.
534
+ const interrupt = watchInterrupt();
535
+ const via = options.via ?? networkSender(deps, DEPOSIT, { stallMs: SEND_STALL_MS, signal: interrupt.signal });
456
536
  try {
457
537
  return await withSendLock(outbox, async () => {
458
538
  // The rule the nightly follows: a hash `sent.log` holds is not posted again. Read under the lock, so two runs cannot both see it unsent.
@@ -470,21 +550,59 @@ const sendBatch = async (deps, options) => {
470
550
  throw refuse(locked.code, lockMessages[locked.code]);
471
551
  if (!scan.open && (await heldAcross(outbox)).batches.some((batch) => batch.hash === hash && batch.pulled))
472
552
  throw refuse('terms_missing', 'This batch was held by a cloud session, and your exclusion term file is missing or empty, so it cannot be checked and was not sent.');
473
- await openCovenant(deps, server, token, options.acceptCovenant, options.region, via);
553
+ await openCovenant(deps, server, token, options.acceptCovenant, options.region, via, interrupt.signal);
474
554
  const bytes = await unchangedBytes(locked.file, hash);
475
555
  if (bytes === undefined)
476
556
  throw refuse('batch_changed', lockMessages.batch_changed);
557
+ deps.io.stderr(`Sending batch ${hash.slice(0, 12)} to ${server}...\n`);
477
558
  await postBatch(deps, { server, token, bytes, hash, via });
478
559
  await markSent(outbox, hash, deps.now());
479
560
  await retirePulled(outbox, DEPOSIT);
480
561
  return { document: { ok: true, server, hash, sent: true }, text: `Sent batch ${hash} to ${server}.` };
562
+ }, options.lock?.times, options.lock?.fs, {
563
+ onWait: (waiting) => {
564
+ deps.io.stderr(`${waitingLine(waiting)}\n`);
565
+ },
566
+ signal: interrupt.signal,
481
567
  });
482
568
  }
483
569
  catch (error) {
484
570
  if (error instanceof SendLockBusy)
485
- throw refuse('send_locked', 'Another oars run is sending from this outbox. Try again in a minute.');
571
+ throw refuse('send_locked', busyLine(error.detail));
572
+ if (error instanceof SendInterrupted)
573
+ throw new CliError(stoppedText(false), ExitCode.UNEXPECTED, DEPOSIT);
486
574
  throw error;
487
575
  }
576
+ finally {
577
+ interrupt.stop();
578
+ }
579
+ };
580
+ /**
581
+ * How long the pool gets to answer one request of a send (an interactive `--send` and the background sender) before the send ends. It is a deadline for the whole request, the upload and the answer together,
582
+ * not an idle timeout: a batch is one session's skeleton, whose largest fixture is 887 KB (docs/pool.md), which a link of 240 kbit/s or better uploads inside 30 seconds. A slower link fails each try with the stall
583
+ * sentence; resetting the timer once headers arrive would not help it, because the upload is what is slow.
584
+ */
585
+ export const SEND_STALL_MS = 30_000;
586
+ /** A span in words a person can plan around: seconds under two minutes, else whole minutes. */
587
+ const span = (ms) => {
588
+ const seconds = Math.max(1, Math.round(ms / 1000));
589
+ return seconds < 120 ? `${String(seconds)} second${seconds === 1 ? '' : 's'}` : `${String(Math.round(seconds / 60))} minutes`;
590
+ };
591
+ /** What the person reads within a second of a send that has to wait: whose lock it is, how old it is, and how long this run waits. */
592
+ export const waitingLine = ({ holder, ageMs, waitMs }) => holder === undefined
593
+ ? `Another oars run holds this outbox's send lock (the lock does not name its process; it was last touched ${span(ageMs)} ago). Waiting up to ${span(waitMs)} for it; Ctrl-C stops this run.`
594
+ : `Another oars run (pid ${String(holder.pid)}) took this outbox's send lock ${span(ageMs)} ago and is still sending. Waiting up to ${span(waitMs)} for it; Ctrl-C stops this run.`;
595
+ /**
596
+ * What a send that gave up says: nothing was sent, who held the lock, and when a retry can succeed. A live holder on this host touches the lock every quarter of staleMs, even while it waits on a stalled pool, so the clock
597
+ * never frees its lock: the true condition is its end (a dead holder is taken over at once). Only a holder the lock does not name, or one on another host, whose liveness cannot be read, is decided by the clock: a
598
+ * retry is certain once the lock has gone untouched for staleMs.
599
+ */
600
+ export const busyLine = ({ holder, local, ageMs, untouchedMs, waitedMs, staleMs }) => {
601
+ const who = holder === undefined ? 'Another oars run' : `Another oars run (pid ${String(holder.pid)})`;
602
+ const held = `${who} has held this outbox's send lock for ${span(ageMs)} and was still sending after the ${span(waitedMs)} this run waited, so nothing was sent.`;
603
+ if (holder !== undefined && local)
604
+ return `${held} A retry succeeds as soon as pid ${String(holder.pid)} ends.`;
605
+ 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.`;
488
606
  };
489
607
  /** 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. */
490
608
  const covenantOrUndefined = async (deps, server) => {
@@ -527,7 +645,7 @@ export const poolDeposit = async (deps, options) => {
527
645
  const outbox = outboxFolder(runtime.storeDir, server);
528
646
  await mkdirOwnerOnly(outbox);
529
647
  if (options.send !== undefined)
530
- return sendBatch(deps, { outbox, server, token, hash: options.send, acceptCovenant: options.acceptCovenant, region, via: options.via });
648
+ return sendBatch(deps, { outbox, server, token, hash: options.send, acceptCovenant: options.acceptCovenant, region, via: options.via, lock: options.lock });
531
649
  const { terms } = exclusionPaths(runtime.projectsRoot, deps.env);
532
650
  const saved = await readState(runtime.storeDir);
533
651
  // A yes to the raw prompt counts only for the covenant it was given under, so an interactive build asks the pool which is in force; the nightly already knows and passes it. A pool that cannot be asked, or names none, leaves it unknown and the build goes on as before.
@@ -668,7 +786,7 @@ export const poolDeposit = async (deps, options) => {
668
786
  // Counts only: how many sessions each gate kept out, never a folder, a term, or a name.
669
787
  emitOf(deps)('deposit', { step: 'build', sessions: counts.sessions, batched: counts.batched, bytes: builtBytes, excludedFolder: folder, excludedTerm: term, excludedNoRaw: noRaw, excludedNoTerms: noTerms, excludedUnreadable: unreadable, notEnded: counts.notEnded, uncaptured: counts.uncaptured, refused: counts.refused, withdrawn: counts.withdrawn, discarded });
670
788
  const lines = [
671
- `Wrote ${String(counts.batched)} of ${String(counts.sessions)} sessions to the outbox for ${server}. Nothing was sent.`,
789
+ `Wrote ${String(counts.batched)} of ${String(counts.sessions)} sessions to the outbox for ${server}. ${machine === undefined ? SENT_LATER : 'Nothing was sent.'}`,
672
790
  `Kept out by the session gates, never built: ${String(folder)} in a folder not on the allowlist, ${String(term)} for a term, ${String(noRaw)} with no raw to scan, ${String(noTerms)} with no term file, ${String(unreadable)} unreadable.`,
673
791
  `Not built: ${String(counts.uncaptured)} captured before steps and frames or with no root, ${String(counts.notEnded)} not ended (a transcript gained a line within ${String(SESSION_IDLE_HOURS)} hours), ${String(counts.refused)} refused by the vocabulary${counts.refusedFields.length === 0 ? '' : ` (${counts.refusedFields.join(', ')})`}.`,
674
792
  ...(counts.withdrawn === 0 ? [] : [`Removed ${String(counts.withdrawn)} ${counts.withdrawn === 1 ? 'batch' : 'batches'} of sessions that no longer pass the gates, have not ended, or are no longer in the store.`]),
@@ -5,7 +5,7 @@ import { writeFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { CliError } from '../errors.js';
7
7
  import { machineOf } from './machine.js';
8
- import { ask, currentTerms, DEPOSIT, isObject, networkSender, poolDeposit, poolServer, postBatch, postRaw, runtimeOf, signedIn, textIn, vocabularyExpired } from './pool-command.js';
8
+ import { ask, currentTerms, DEPOSIT, isObject, networkSender, poolDeposit, poolServer, postBatch, postRaw, runtimeOf, SEND_STALL_MS, signedIn, textIn, vocabularyExpired } from './pool-command.js';
9
9
  import { rawStale } from './pool-choices.js';
10
10
  import { heldAcross, lockedBatch, outboxFolder, rescanPulled } from './pool-outbox.js';
11
11
  import { heldRaw } from './pool-raw.js';
@@ -119,7 +119,8 @@ export const nightlyPoolSend = async (deps, via) => {
119
119
  const tally = { outcome: 'failed', batches: 0, raw: 0 };
120
120
  let result;
121
121
  try {
122
- result = await night(deps, via ?? networkSender(deps, DEPOSIT), tally);
122
+ // A pool that goes silent ends the night after SEND_STALL_MS, so it cannot hold the send lock (and with it every `deposit --send`) for as long as the pool stalls. The next run sends what this one did not.
123
+ result = await night(deps, via ?? networkSender(deps, DEPOSIT, { stallMs: SEND_STALL_MS }), tally);
123
124
  }
124
125
  catch {
125
126
  result = { ...tally, outcome: 'failed' };
@@ -5,6 +5,8 @@ export interface LockTimes {
5
5
  /** How long a run waits for the lock before it gives up. */
6
6
  waitMs: number;
7
7
  pollMs: number;
8
+ /** How long a run waits behind a live holder before it tells the person it is waiting. */
9
+ noticeMs: number;
8
10
  }
9
11
  /** 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
12
  export interface LockFs {
@@ -13,8 +15,76 @@ export interface LockFs {
13
15
  }>;
14
16
  open?: (path: string, flags: 'wx') => Promise<FileHandle>;
15
17
  platform?: NodeJS.Platform;
18
+ /** Whether a process with this id is running on this machine. A test answers for a pid it made up. */
19
+ running?: (pid: number) => boolean;
20
+ /** This run's pid and host, recorded in the lock; a test names the holder it plays. */
21
+ self?: {
22
+ pid: number;
23
+ host: string;
24
+ };
25
+ /** The clock and the pause between polls; a test drives both instead of sleeping. */
26
+ clock?: {
27
+ now: () => number;
28
+ sleep: (ms: number) => Promise<void>;
29
+ };
30
+ }
31
+ export declare const DEFAULT_TIMES: LockTimes;
32
+ /** Who holds the lock, as the holder wrote it into the file: its run, pid, host, and when it took the lock (epoch ms). */
33
+ export interface Holder {
34
+ run: string;
35
+ pid: number;
36
+ host: string;
37
+ since: number;
38
+ }
39
+ /** What a run that waits tells its caller once, when the wait has lasted `noticeMs`: the holder when the file names one (an empty or older-format file names none), how old the lock is, and how long this run will wait. */
40
+ export interface Waiting {
41
+ holder: Holder | undefined;
42
+ ageMs: number;
43
+ waitMs: number;
44
+ }
45
+ /** What a run that gave up knows: the holder, the lock's age now, the time already waited, and the constants the takeover rules use. */
46
+ export interface BusyDetail {
47
+ holder: Holder | undefined;
48
+ /** Time since the holder took the lock, else since the lock file was last touched. */
49
+ ageMs: number;
50
+ /** Time since the lock file was last touched: a live holder touches it every `staleMs / 4`. */
51
+ untouchedMs: number;
52
+ waitedMs: number;
53
+ staleMs: number;
54
+ /** Whether the holder is a process on this host: its heartbeat then keeps the lock fresh while it lives, so only its end (not the clock) frees the lock. */
55
+ local: boolean;
56
+ }
57
+ export interface LockWait {
58
+ /** Called once if the lock is still held after `noticeMs`. */
59
+ onWait?: (waiting: Waiting) => void;
60
+ /** Ends the wait: the run throws the signal's reason. An interrupted `--send` leaves nothing waiting. */
61
+ signal?: AbortSignal;
16
62
  }
17
63
  export declare class SendLockBusy extends Error {
18
- constructor();
64
+ readonly detail: BusyDetail;
65
+ constructor(detail?: BusyDetail);
66
+ }
67
+ export declare const withSendLock: <T>(outbox: string, run: () => Promise<T>, times?: Partial<LockTimes>, fs?: LockFs, wait?: LockWait) => Promise<T>;
68
+ /** Why an interactive send ended before it finished: a signal reached it, or the process that launched it is gone. */
69
+ export declare class SendInterrupted extends Error {
70
+ readonly kind: 'signal' | 'launcher';
71
+ constructor(kind: 'signal' | 'launcher', message: string);
72
+ }
73
+ /** What a test replaces: the process's signal hooks, its launcher, and how that launcher's liveness is read. */
74
+ export interface InterruptHost {
75
+ on: (event: NodeJS.Signals, listener: () => void) => unknown;
76
+ off: (event: NodeJS.Signals, listener: () => void) => unknown;
77
+ /** The pid of the process that started this one; checked every `checkMs`. */
78
+ parent: number;
79
+ running: (pid: number) => boolean;
80
+ checkMs: number;
19
81
  }
20
- export declare const withSendLock: <T>(outbox: string, run: () => Promise<T>, times?: Partial<LockTimes>, fs?: LockFs) => Promise<T>;
82
+ /**
83
+ * The signal an interactive `oars pool deposit --send` waits and sends under. Ctrl-C, a closed terminal, and a kill all end it. A launcher that is killed without a signal reaching its child (Windows `taskkill` of the
84
+ * shell or the `oars.cmd` shim, a `timeout` wrapper) leaves the child running, so the launcher's pid is read every second and a run whose launcher is gone ends too. The detached nightly sender is not watched:
85
+ * it is meant to outlive its starter.
86
+ */
87
+ export declare const watchInterrupt: (host?: InterruptHost) => {
88
+ signal: AbortSignal;
89
+ stop: () => void;
90
+ };
@@ -1,15 +1,46 @@
1
1
  // One sender at a time per outbox: the detached sender (hook-bin.js nightly) and `deposit --send` both take this lock, so a batch is never posted twice by two runs that overlap. The lock is a file created
2
- // with `wx` beside `sent.log`, so it holds across processes as well as inside one. A run that finds it held waits; a lock older than `staleMs` belongs to a run that died, and is taken over by one waiter only, under a guard file. The holder touches the file while it runs, so a long send (a first night of raw parts) is never mistaken for a dead run, and it removes the file only if the file still names its own run.
2
+ // with `wx` beside `sent.log`, so it holds across processes as well as inside one. A run that finds it held waits; a holder on this host whose pid is not running is taken over at once, and a lock untouched for `staleMs` belongs to a run that died (the rule for a holder whose liveness cannot be read: an empty or older-format file, another host's pid). Either takeover is by one waiter only, under a guard file. The holder touches the file while it runs, so a long send (a first night of raw parts) is never mistaken for a dead run, and it removes the file only if the file still names its own run.
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { mkdir, open, readFile, rm, stat, utimes } from 'node:fs/promises';
5
+ import { hostname } from 'node:os';
5
6
  import { join } from 'node:path';
6
7
  import { setTimeout as sleep } from 'node:timers/promises';
7
- const DEFAULT_TIMES = { staleMs: 10 * 60 * 1000, waitMs: 2 * 60 * 1000, pollMs: 50 };
8
+ export const DEFAULT_TIMES = { staleMs: 10 * 60 * 1000, waitMs: 2 * 60 * 1000, pollMs: 50, noticeMs: 500 };
8
9
  export class SendLockBusy extends Error {
9
- constructor() {
10
+ detail;
11
+ constructor(detail = { holder: undefined, ageMs: 0, untouchedMs: 0, waitedMs: 0, staleMs: DEFAULT_TIMES.staleMs, local: false }) {
10
12
  super('Another oars run is sending from this outbox.');
13
+ this.detail = detail;
11
14
  }
12
15
  }
16
+ const running = (pid) => {
17
+ try {
18
+ process.kill(pid, 0);
19
+ return true;
20
+ }
21
+ catch (error) {
22
+ // EPERM: the process exists but belongs to someone else.
23
+ return error instanceof Error && Reflect.get(error, 'code') === 'EPERM';
24
+ }
25
+ };
26
+ /** The holder a lock file names, or `undefined` for a file that is empty, half written, or not in this format: such a lock is judged by the clock alone. */
27
+ const holderOf = async (file) => {
28
+ const text = await readFile(file, 'utf8').catch(() => '');
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(text);
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ if (typeof parsed !== 'object' || parsed === null)
37
+ return undefined;
38
+ const run = Reflect.get(parsed, 'run');
39
+ const pid = Reflect.get(parsed, 'pid');
40
+ const host = Reflect.get(parsed, 'host');
41
+ const since = Reflect.get(parsed, 'since');
42
+ return typeof run === 'string' && typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && typeof host === 'string' && typeof since === 'number' ? { run, pid, host, since } : undefined;
43
+ };
13
44
  // EEXIST means another run holds the file. On Windows an open of a file whose delete is still pending (the holder just released it) answers EPERM or EACCES instead: the same contention, so retry within waitMs.
14
45
  const contended = (error, platform) => {
15
46
  if (!(error instanceof Error))
@@ -18,24 +49,37 @@ const contended = (error, platform) => {
18
49
  return code === 'EEXIST' || (platform === 'win32' && (code === 'EPERM' || code === 'EACCES'));
19
50
  };
20
51
  /**
21
- * A stale lock is removed under a takeover guard (`send.lock.takeover`, created with `wx`), so one waiter at a time reads the lock again and removes it only if it is still stale. A live lock is never moved
22
- * or removed: a waiter that read a lock as stale, but meets a lock another run took over and refreshed in the meantime, finds it fresh under the guard and leaves it. A guard left by a taker that died is
52
+ * Whether a lock can be removed now: its holder is a process on this machine that is not running (taken over at once, whatever the clock says), or the file has not been touched for `staleMs` (the holder's heartbeat stopped).
53
+ * A holder whose liveness cannot be read (an empty file, another host's pid) is judged by the clock alone, which is the only reason `staleMs` stays at ten minutes. A pid the system has handed to another process since reads as
54
+ * running, so pid reuse can only make a waiter wait for the clock, never take a live holder's lock; the lock's age is not taken from the pid for that reason.
55
+ */
56
+ const reclaimable = async (file, staleMs, fs, now) => {
57
+ const held = await fs.stat(file).catch(() => undefined);
58
+ if (held === undefined)
59
+ return false;
60
+ if (now() - held.mtimeMs > staleMs)
61
+ return true;
62
+ const holder = await holderOf(file);
63
+ return holder !== undefined && holder.host === (fs.self?.host ?? hostname()) && !(fs.running ?? running)(holder.pid);
64
+ };
65
+ /**
66
+ * A reclaimable lock is removed under a takeover guard (`send.lock.takeover`, created with `wx`), so one waiter at a time reads the lock again and removes it only if it is still reclaimable. A live lock is never moved
67
+ * or removed: a waiter that read a lock as reclaimable, but meets a lock another run took over and refreshed in the meantime, finds it live under the guard and leaves it. A guard left by a taker that died is
23
68
  * removed once it is older than `staleMs`.
24
69
  */
25
- const takeOver = async (file, staleMs, fs) => {
70
+ const takeOver = async (file, staleMs, fs, now) => {
26
71
  const guard = `${file}.takeover`;
27
72
  try {
28
73
  await (await open(guard, 'wx')).close();
29
74
  }
30
75
  catch {
31
76
  const left = await stat(guard).catch(() => undefined);
32
- if (left !== undefined && Date.now() - left.mtimeMs > staleMs)
77
+ if (left !== undefined && now() - left.mtimeMs > staleMs)
33
78
  await rm(guard, { force: true });
34
79
  return false;
35
80
  }
36
81
  try {
37
- const held = await fs.stat(file).catch(() => undefined);
38
- if (held === undefined || Date.now() - held.mtimeMs <= staleMs)
82
+ if (!(await reclaimable(file, staleMs, fs, now)))
39
83
  return false;
40
84
  await rm(file, { force: true });
41
85
  return true;
@@ -44,18 +88,25 @@ const takeOver = async (file, staleMs, fs) => {
44
88
  await rm(guard, { force: true });
45
89
  }
46
90
  };
47
- export const withSendLock = async (outbox, run, times = {}, fs = { stat }) => {
48
- const { staleMs, waitMs, pollMs } = { ...DEFAULT_TIMES, ...times };
91
+ export const withSendLock = async (outbox, run, times = {}, fs = { stat }, wait = {}) => {
92
+ const { staleMs, waitMs, pollMs, noticeMs } = { ...DEFAULT_TIMES, ...times };
49
93
  const file = join(outbox, 'send.lock');
50
94
  const runId = randomUUID();
51
95
  const create = fs.open ?? open;
52
96
  const platform = fs.platform ?? process.platform;
97
+ const clock = fs.clock ?? { now: () => Date.now(), sleep: (ms) => sleep(ms) };
98
+ const self = fs.self ?? { pid: process.pid, host: hostname() };
53
99
  await mkdir(outbox, { recursive: true });
54
- const giveUp = Date.now() + waitMs;
100
+ const began = clock.now();
101
+ const giveUp = began + waitMs;
102
+ let noticed = false;
55
103
  for (;;) {
104
+ if (wait.signal?.aborted === true)
105
+ throw wait.signal.reason;
56
106
  try {
57
107
  const handle = await create(file, 'wx');
58
- await handle.writeFile(runId);
108
+ const holder = { run: runId, pid: self.pid, host: self.host, since: clock.now() };
109
+ await handle.writeFile(JSON.stringify(holder));
59
110
  await handle.close();
60
111
  break;
61
112
  }
@@ -65,11 +116,19 @@ export const withSendLock = async (outbox, run, times = {}, fs = { stat }) => {
65
116
  }
66
117
  const held = await fs.stat(file).catch(() => undefined);
67
118
  // A takeover that cleared the lock goes straight back to taking it; any other outcome waits, and gives up once waitMs has passed.
68
- if (held !== undefined && Date.now() - held.mtimeMs > staleMs && (await takeOver(file, staleMs, fs)))
119
+ if (held !== undefined && (await reclaimable(file, staleMs, fs, clock.now)) && (await takeOver(file, staleMs, fs, clock.now)))
69
120
  continue;
70
- if (Date.now() >= giveUp)
71
- throw new SendLockBusy();
72
- await sleep(pollMs);
121
+ const at = clock.now();
122
+ const holder = await holderOf(file);
123
+ const untouchedMs = held === undefined ? 0 : Math.max(0, at - held.mtimeMs);
124
+ const ageMs = holder === undefined ? untouchedMs : Math.max(0, at - holder.since);
125
+ if (!noticed && at - began >= noticeMs) {
126
+ noticed = true;
127
+ wait.onWait?.({ holder, ageMs, waitMs });
128
+ }
129
+ if (at >= giveUp)
130
+ throw new SendLockBusy({ holder, ageMs, untouchedMs, waitedMs: at - began, staleMs, local: holder?.host === self.host });
131
+ await clock.sleep(pollMs);
73
132
  }
74
133
  const beat = setInterval(() => {
75
134
  const now = new Date();
@@ -81,7 +140,46 @@ export const withSendLock = async (outbox, run, times = {}, fs = { stat }) => {
81
140
  finally {
82
141
  clearInterval(beat);
83
142
  // The file is ours only while it still names this run; a lock another run took over is theirs.
84
- if ((await readFile(file, 'utf8').catch(() => undefined)) === runId)
143
+ if ((await holderOf(file))?.run === runId)
85
144
  await rm(file, { force: true });
86
145
  }
87
146
  };
147
+ /** Why an interactive send ended before it finished: a signal reached it, or the process that launched it is gone. */
148
+ export class SendInterrupted extends Error {
149
+ kind;
150
+ constructor(kind, message) {
151
+ super(message);
152
+ this.kind = kind;
153
+ this.name = 'SendInterrupted';
154
+ }
155
+ }
156
+ const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
157
+ /**
158
+ * The signal an interactive `oars pool deposit --send` waits and sends under. Ctrl-C, a closed terminal, and a kill all end it. A launcher that is killed without a signal reaching its child (Windows `taskkill` of the
159
+ * shell or the `oars.cmd` shim, a `timeout` wrapper) leaves the child running, so the launcher's pid is read every second and a run whose launcher is gone ends too. The detached nightly sender is not watched:
160
+ * it is meant to outlive its starter.
161
+ */
162
+ export const watchInterrupt = (host = { on: (event, listener) => process.on(event, listener), off: (event, listener) => process.off(event, listener), parent: process.ppid, running, checkMs: 1000 }) => {
163
+ const controller = new AbortController();
164
+ const listeners = SIGNALS.map((name) => [
165
+ name,
166
+ () => {
167
+ controller.abort(new SendInterrupted('signal', `Interrupted by ${name}.`));
168
+ },
169
+ ]);
170
+ for (const [name, listener] of listeners)
171
+ host.on(name, listener);
172
+ const timer = setInterval(() => {
173
+ if (!host.running(host.parent))
174
+ controller.abort(new SendInterrupted('launcher', 'The program that started this send is gone.'));
175
+ }, host.checkMs);
176
+ timer.unref();
177
+ return {
178
+ signal: controller.signal,
179
+ stop: () => {
180
+ clearInterval(timer);
181
+ for (const [name, listener] of listeners)
182
+ host.off(name, listener);
183
+ },
184
+ };
185
+ };
@@ -17,10 +17,13 @@ export interface Vocabulary {
17
17
  models: ReadonlySet<string>;
18
18
  harnessVersions: ReadonlyMap<string, ReadonlySet<string>>;
19
19
  }
20
- /** packages/cli/vocab/, from src/sessions/ in a test and from dist/sessions/ once built: the same depth. */
21
- export declare const VOCAB_DIR: import("url").URL;
20
+ /**
21
+ * packages/cli/vocab/, from src/sessions/ in a test and from dist/sessions/ once built: the same depth. A function, not a constant: a bundle imported from a
22
+ * `data:` URL (the pool's sample-pack command) has no file location, and building this URL at load time would throw before that caller could pass its own folder.
23
+ */
24
+ export declare const vocabDir: () => URL;
22
25
  export declare const PACKAGE_FILES: readonly ["npm.json", "pypi.json", "crates.json", "rubygems.json", "homebrew.json", "go.json", "maven.json"];
23
26
  /** Reads and checks every snapshot under `dir` (default: the shipped folder). */
24
27
  export declare const readVocabulary: (dir?: URL) => Vocabulary;
25
- /** The shipped vocabulary, read once on first use: reading 6 MB of JSON is not free, and most commands never project. */
26
- export declare const vocabulary: () => Vocabulary;
28
+ /** The vocabulary of a folder (the shipped one by default), read once per folder on first use: reading 6 MB of JSON is not free, and most commands never project. `dir` is for a caller that cannot locate the folder itself. */
29
+ export declare const vocabulary: (dir?: URL) => Vocabulary;
@@ -3,8 +3,11 @@
3
3
  // A snapshot that does not parse is an error and never an empty list: an empty list would quietly project every name as `private`.
4
4
  import { readFileSync } from 'node:fs';
5
5
  import { VOCAB_SNAPSHOT_DATE, VOCAB_VERSION } from './public-vocabulary.js';
6
- /** packages/cli/vocab/, from src/sessions/ in a test and from dist/sessions/ once built: the same depth. */
7
- export const VOCAB_DIR = new URL('../../vocab/', import.meta.url);
6
+ /**
7
+ * packages/cli/vocab/, from src/sessions/ in a test and from dist/sessions/ once built: the same depth. A function, not a constant: a bundle imported from a
8
+ * `data:` URL (the pool's sample-pack command) has no file location, and building this URL at load time would throw before that caller could pass its own folder.
9
+ */
10
+ export const vocabDir = () => new URL('../../vocab/', import.meta.url);
8
11
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
9
12
  const recordAt = (value, where) => {
10
13
  if (!isRecord(value))
@@ -35,7 +38,7 @@ const packageSnapshot = (dir, file) => {
35
38
  };
36
39
  export const PACKAGE_FILES = ['npm.json', 'pypi.json', 'crates.json', 'rubygems.json', 'homebrew.json', 'go.json', 'maven.json'];
37
40
  /** Reads and checks every snapshot under `dir` (default: the shipped folder). */
38
- export const readVocabulary = (dir = VOCAB_DIR) => {
41
+ export const readVocabulary = (dir = vocabDir()) => {
39
42
  const manifest = readJson(dir, 'manifest.json');
40
43
  const snapshotDate = manifest['snapshotDate'];
41
44
  if (snapshotDate !== VOCAB_SNAPSHOT_DATE) {
@@ -56,6 +59,13 @@ export const readVocabulary = (dir = VOCAB_DIR) => {
56
59
  harnessVersions: new Map(Object.entries(harnesses).map(([harness, versions]) => [harness, new Set(stringsAt(versions, `harness.${harness}`))])),
57
60
  };
58
61
  };
59
- let loaded;
60
- /** The shipped vocabulary, read once on first use: reading 6 MB of JSON is not free, and most commands never project. */
61
- export const vocabulary = () => (loaded ??= readVocabulary());
62
+ const loaded = new Map();
63
+ /** The vocabulary of a folder (the shipped one by default), read once per folder on first use: reading 6 MB of JSON is not free, and most commands never project. `dir` is for a caller that cannot locate the folder itself. */
64
+ export const vocabulary = (dir = vocabDir()) => {
65
+ const held = loaded.get(dir.href);
66
+ if (held !== undefined)
67
+ return held;
68
+ const read = readVocabulary(dir);
69
+ loaded.set(dir.href, read);
70
+ return read;
71
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manyoars/oars",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
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/",
@@ -49,7 +49,7 @@
49
49
  "test:live": "vitest run --config vitest.live.config.ts"
50
50
  },
51
51
  "dependencies": {
52
- "@manyoars/oars-client": "^0.1.2",
52
+ "@manyoars/oars-client": "^0.1.3",
53
53
  "commander": "^15.0.0",
54
54
  "openid-client": "^6.8.8"
55
55
  },