@manyoars/oars 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/README.md +132 -28
  2. package/dist/api.js +1 -1
  3. package/dist/bin.js +6 -29
  4. package/dist/cli.d.ts +6 -3
  5. package/dist/cli.js +367 -88
  6. package/dist/commands/keys.d.ts +31 -0
  7. package/dist/commands/keys.js +65 -0
  8. package/dist/commands/login.js +2 -2
  9. package/dist/errors.d.ts +13 -13
  10. package/dist/errors.js +28 -11
  11. package/dist/gate/checks.d.ts +68 -0
  12. package/dist/gate/checks.js +584 -0
  13. package/dist/gate/command.d.ts +14 -0
  14. package/dist/gate/command.js +61 -0
  15. package/dist/gate/hook-fill-main.d.ts +1 -0
  16. package/dist/gate/hook-fill-main.js +16 -0
  17. package/dist/gate/hook-fill.d.ts +38 -0
  18. package/dist/gate/hook-fill.js +94 -0
  19. package/dist/gate/hook-main.d.ts +1 -0
  20. package/dist/gate/hook-main.js +35 -0
  21. package/dist/gate/hook-process.d.ts +14 -0
  22. package/dist/gate/hook-process.js +64 -0
  23. package/dist/gate/hook.d.ts +46 -0
  24. package/dist/gate/hook.js +256 -0
  25. package/dist/gate/install.d.ts +25 -0
  26. package/dist/gate/install.js +126 -0
  27. package/dist/gate/local-bin.d.ts +8 -0
  28. package/dist/gate/local-bin.js +36 -0
  29. package/dist/gate/notes.d.ts +19 -0
  30. package/dist/gate/notes.js +27 -0
  31. package/dist/gate/novelty.d.ts +35 -0
  32. package/dist/gate/novelty.js +99 -0
  33. package/dist/gate/policy.d.ts +25 -0
  34. package/dist/gate/policy.js +73 -0
  35. package/dist/gate/record.d.ts +120 -0
  36. package/dist/gate/record.js +125 -0
  37. package/dist/gate/registry-state.d.ts +10 -0
  38. package/dist/gate/registry-state.js +30 -0
  39. package/dist/gate/registry.d.ts +56 -0
  40. package/dist/gate/registry.js +94 -0
  41. package/dist/gate/replay.d.ts +20 -0
  42. package/dist/gate/replay.js +257 -0
  43. package/dist/gate/report.d.ts +50 -0
  44. package/dist/gate/report.js +89 -0
  45. package/dist/gate/scope.d.ts +7 -0
  46. package/dist/gate/scope.js +27 -0
  47. package/dist/gate/transcripts.d.ts +68 -0
  48. package/dist/gate/transcripts.js +232 -0
  49. package/dist/hook-bin.d.ts +1 -0
  50. package/dist/hook-bin.js +25 -0
  51. package/dist/issuer.d.ts +7 -1
  52. package/dist/issuer.js +18 -1
  53. package/dist/main.d.ts +1 -0
  54. package/dist/main.js +64 -0
  55. package/dist/oidc.js +1 -1
  56. package/dist/sessions/actions.d.ts +35 -0
  57. package/dist/sessions/actions.js +351 -0
  58. package/dist/sessions/allow.d.ts +14 -0
  59. package/dist/sessions/allow.js +51 -0
  60. package/dist/sessions/archive.d.ts +11 -0
  61. package/dist/sessions/archive.js +38 -0
  62. package/dist/sessions/audit.d.ts +42 -0
  63. package/dist/sessions/audit.js +104 -0
  64. package/dist/sessions/baseline.d.ts +62 -0
  65. package/dist/sessions/baseline.js +188 -0
  66. package/dist/sessions/capture-runtime.d.ts +10 -0
  67. package/dist/sessions/capture-runtime.js +12 -0
  68. package/dist/sessions/cell-increments.d.ts +43 -0
  69. package/dist/sessions/cell-increments.js +209 -0
  70. package/dist/sessions/cells.d.ts +79 -0
  71. package/dist/sessions/cells.js +181 -0
  72. package/dist/sessions/codex.d.ts +53 -0
  73. package/dist/sessions/codex.js +679 -0
  74. package/dist/sessions/command.d.ts +117 -0
  75. package/dist/sessions/command.js +373 -0
  76. package/dist/sessions/copilot.d.ts +40 -0
  77. package/dist/sessions/copilot.js +432 -0
  78. package/dist/sessions/cursor.d.ts +24 -0
  79. package/dist/sessions/cursor.js +324 -0
  80. package/dist/sessions/derive.d.ts +57 -0
  81. package/dist/sessions/derive.js +320 -0
  82. package/dist/sessions/exclusion.d.ts +81 -0
  83. package/dist/sessions/exclusion.js +353 -0
  84. package/dist/sessions/extract.d.ts +179 -0
  85. package/dist/sessions/extract.js +1774 -0
  86. package/dist/sessions/fold.d.ts +17 -0
  87. package/dist/sessions/fold.js +71 -0
  88. package/dist/sessions/frame.d.ts +24 -0
  89. package/dist/sessions/frame.js +95 -0
  90. package/dist/sessions/gemini.d.ts +37 -0
  91. package/dist/sessions/gemini.js +613 -0
  92. package/dist/sessions/gold-arms.d.ts +67 -0
  93. package/dist/sessions/gold-arms.js +172 -0
  94. package/dist/sessions/gold.d.ts +197 -0
  95. package/dist/sessions/gold.js +463 -0
  96. package/dist/sessions/graph.d.ts +12 -0
  97. package/dist/sessions/graph.js +34 -0
  98. package/dist/sessions/grok.d.ts +36 -0
  99. package/dist/sessions/grok.js +421 -0
  100. package/dist/sessions/hook-calls.d.ts +3 -0
  101. package/dist/sessions/hook-calls.js +47 -0
  102. package/dist/sessions/hook-index.d.ts +53 -0
  103. package/dist/sessions/hook-index.js +157 -0
  104. package/dist/sessions/hooks.d.ts +13 -0
  105. package/dist/sessions/hooks.js +170 -0
  106. package/dist/sessions/identity.d.ts +22 -0
  107. package/dist/sessions/identity.js +101 -0
  108. package/dist/sessions/install.d.ts +54 -0
  109. package/dist/sessions/install.js +201 -0
  110. package/dist/sessions/lineage.d.ts +19 -0
  111. package/dist/sessions/lineage.js +118 -0
  112. package/dist/sessions/mcp.d.ts +6 -0
  113. package/dist/sessions/mcp.js +153 -0
  114. package/dist/sessions/merkle.d.ts +7 -0
  115. package/dist/sessions/merkle.js +28 -0
  116. package/dist/sessions/motif.d.ts +43 -0
  117. package/dist/sessions/motif.js +59 -0
  118. package/dist/sessions/package-resolve.d.ts +30 -0
  119. package/dist/sessions/package-resolve.js +183 -0
  120. package/dist/sessions/pending.d.ts +34 -0
  121. package/dist/sessions/pending.js +142 -0
  122. package/dist/sessions/pool-command.d.ts +44 -0
  123. package/dist/sessions/pool-command.js +542 -0
  124. package/dist/sessions/pool-delta.d.ts +54 -0
  125. package/dist/sessions/pool-delta.js +190 -0
  126. package/dist/sessions/pool-outbox.d.ts +63 -0
  127. package/dist/sessions/pool-outbox.js +260 -0
  128. package/dist/sessions/pool-print.d.ts +26 -0
  129. package/dist/sessions/pool-print.js +68 -0
  130. package/dist/sessions/pool-wire.d.ts +225 -0
  131. package/dist/sessions/pool-wire.js +620 -0
  132. package/dist/sessions/pool.d.ts +32 -0
  133. package/dist/sessions/pool.js +164 -0
  134. package/dist/sessions/projection.d.ts +75 -0
  135. package/dist/sessions/projection.js +165 -0
  136. package/dist/sessions/public-vocabulary.d.ts +73 -0
  137. package/dist/sessions/public-vocabulary.js +302 -0
  138. package/dist/sessions/query.d.ts +111 -0
  139. package/dist/sessions/query.js +246 -0
  140. package/dist/sessions/read-adoption.d.ts +2 -0
  141. package/dist/sessions/read-adoption.js +27 -0
  142. package/dist/sessions/read-call-shape.d.ts +2 -0
  143. package/dist/sessions/read-call-shape.js +25 -0
  144. package/dist/sessions/read-change.d.ts +2 -0
  145. package/dist/sessions/read-change.js +30 -0
  146. package/dist/sessions/read-cost.d.ts +4 -0
  147. package/dist/sessions/read-cost.js +37 -0
  148. package/dist/sessions/read-day-move.d.ts +3 -0
  149. package/dist/sessions/read-day-move.js +65 -0
  150. package/dist/sessions/read-fit.d.ts +7 -0
  151. package/dist/sessions/read-fit.js +41 -0
  152. package/dist/sessions/read-interaction.d.ts +5 -0
  153. package/dist/sessions/read-interaction.js +62 -0
  154. package/dist/sessions/read-lead-lag.d.ts +15 -0
  155. package/dist/sessions/read-lead-lag.js +81 -0
  156. package/dist/sessions/read-live.d.ts +6 -0
  157. package/dist/sessions/read-live.js +38 -0
  158. package/dist/sessions/read-method.d.ts +2 -0
  159. package/dist/sessions/read-method.js +29 -0
  160. package/dist/sessions/read-next-step.d.ts +2 -0
  161. package/dist/sessions/read-next-step.js +35 -0
  162. package/dist/sessions/read-order.d.ts +54 -0
  163. package/dist/sessions/read-order.js +62 -0
  164. package/dist/sessions/read-readable.d.ts +8 -0
  165. package/dist/sessions/read-readable.js +24 -0
  166. package/dist/sessions/read-recovery.d.ts +20 -0
  167. package/dist/sessions/read-recovery.js +97 -0
  168. package/dist/sessions/read-rows.d.ts +13 -0
  169. package/dist/sessions/read-rows.js +47 -0
  170. package/dist/sessions/read-substitution.d.ts +5 -0
  171. package/dist/sessions/read-substitution.js +51 -0
  172. package/dist/sessions/read-support.d.ts +48 -0
  173. package/dist/sessions/read-support.js +73 -0
  174. package/dist/sessions/read-tool-day.d.ts +2 -0
  175. package/dist/sessions/read-tool-day.js +26 -0
  176. package/dist/sessions/read-version-boundary.d.ts +31 -0
  177. package/dist/sessions/read-version-boundary.js +95 -0
  178. package/dist/sessions/read.d.ts +79 -0
  179. package/dist/sessions/read.js +84 -0
  180. package/dist/sessions/recall-consequence.d.ts +45 -0
  181. package/dist/sessions/recall-consequence.js +179 -0
  182. package/dist/sessions/recall-object.d.ts +137 -0
  183. package/dist/sessions/recall-object.js +77 -0
  184. package/dist/sessions/recall-pool.d.ts +19 -0
  185. package/dist/sessions/recall-pool.js +96 -0
  186. package/dist/sessions/recall-push.d.ts +26 -0
  187. package/dist/sessions/recall-push.js +95 -0
  188. package/dist/sessions/recall-reads.d.ts +117 -0
  189. package/dist/sessions/recall-reads.js +410 -0
  190. package/dist/sessions/recall-render.d.ts +19 -0
  191. package/dist/sessions/recall-render.js +168 -0
  192. package/dist/sessions/redact.d.ts +6 -0
  193. package/dist/sessions/redact.js +54 -0
  194. package/dist/sessions/repo-root.d.ts +10 -0
  195. package/dist/sessions/repo-root.js +101 -0
  196. package/dist/sessions/schema.d.ts +644 -0
  197. package/dist/sessions/schema.js +148 -0
  198. package/dist/sessions/session-shape.d.ts +73 -0
  199. package/dist/sessions/session-shape.js +342 -0
  200. package/dist/sessions/sources.d.ts +50 -0
  201. package/dist/sessions/sources.js +159 -0
  202. package/dist/sessions/steps.d.ts +105 -0
  203. package/dist/sessions/steps.js +250 -0
  204. package/dist/sessions/store.d.ts +297 -0
  205. package/dist/sessions/store.js +1478 -0
  206. package/dist/sessions/strata.d.ts +26 -0
  207. package/dist/sessions/strata.js +77 -0
  208. package/dist/sessions/survival.d.ts +25 -0
  209. package/dist/sessions/survival.js +230 -0
  210. package/dist/sessions/verify.d.ts +53 -0
  211. package/dist/sessions/verify.js +286 -0
  212. package/dist/sessions/vocab-load.d.ts +26 -0
  213. package/dist/sessions/vocab-load.js +61 -0
  214. package/dist/sessions/vscode-copilot.d.ts +38 -0
  215. package/dist/sessions/vscode-copilot.js +477 -0
  216. package/package.json +4 -5
  217. package/vocab/crates.json +1 -0
  218. package/vocab/go.json +1 -0
  219. package/vocab/harness.json +1 -0
  220. package/vocab/homebrew.json +1 -0
  221. package/vocab/manifest.json +1 -0
  222. package/vocab/maven.json +1 -0
  223. package/vocab/models.json +1 -0
  224. package/vocab/npm.json +1 -0
  225. package/vocab/programs.json +1 -0
  226. package/vocab/pypi.json +1 -0
  227. package/vocab/rubygems.json +1 -0
  228. package/dist/commands/deploy-input.d.ts +0 -38
  229. package/dist/commands/deploy-input.js +0 -150
  230. package/dist/commands/deploy.d.ts +0 -22
  231. package/dist/commands/deploy.js +0 -124
  232. package/dist/commands/status.d.ts +0 -7
  233. package/dist/commands/status.js +0 -18
  234. package/dist/commands/templates.d.ts +0 -11
  235. package/dist/commands/templates.js +0 -32
  236. package/dist/commands/test-charge.d.ts +0 -11
  237. package/dist/commands/test-charge.js +0 -34
  238. package/dist/journal.d.ts +0 -26
  239. package/dist/journal.js +0 -58
  240. package/parity/deploy-plans/paid-api.json +0 -34
  241. package/parity/deploy-plans/per-task-agent.json +0 -34
@@ -0,0 +1,542 @@
1
+ // `oars pool`: the only part of the sessions code that talks to a remote. The server is always named, by flag or environment, so nothing leaves on a default. Sign-in runs the provider's device flow here and reads no harness token; a session leaves only as a closed-vocabulary skeleton, and only after the exclusion check found it clean. Local capture, recall,
2
+ // and reads never come through here.
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { CliError, exitCodeForProblem, ExitCode, unreachableError } from '../errors.js';
6
+ import { compileAllow } from './allow.js';
7
+ import { checkSession, exclusionPaths, lastWritten } from './exclusion.js';
8
+ import { discardStale, FIELD_ROWS, fieldsOf, heldBatches, isMissing, lockedBatch, markShown, outboxFolder, removeBatchesExcept, removeBatchOf, staleBatches, unchangedBytes, writeBatch } from './pool-outbox.js';
9
+ import { HARNESSES, hasEnded, SESSION_IDLE_HOURS, isBundleVersion, isHarness, parseSkeleton, sessionsOf, skeletonOf, VOCAB_VERSION, verifyBundle } from './pool-wire.js';
10
+ import { carriedDeltas, markDeltasSent, placedOf, sentDeltas } from './pool-delta.js';
11
+ import { Masker, printed, TRANSPORT_HEADERS, waitWords } from './pool-print.js';
12
+ import { shellReader } from './projection.js';
13
+ import { isVocabularyExpired, REGISTRY_MAX_AGE_DAYS, VOCAB_SNAPSHOT_DATE } from './public-vocabulary.js';
14
+ import { contentHash } from './read.js';
15
+ import { harnessOf as sessionHarness } from './schema.js';
16
+ import { loadStore, readSaved, readSkeletons } from './store.js';
17
+ import { vocabulary } from './vocab-load.js';
18
+ const LOGIN = 'pool-login';
19
+ const DEPOSIT = 'pool-deposit';
20
+ const OUTBOX = 'pool-outbox';
21
+ const POOL_DEPOSITS = '/v1/deposits';
22
+ const FETCH = 'pool-fetch';
23
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
24
+ export const POOL_PROVIDERS = ['github', 'google'];
25
+ const DEFAULT_INTERVAL_SEC = 5;
26
+ const DEFAULT_EXPIRY_SEC = 900;
27
+ /** RFC 8628: a `slow_down` adds five seconds to every poll after it. */
28
+ const SLOW_DOWN_MS = 5000;
29
+ const usage = (message, step) => new CliError(message, ExitCode.USAGE, step);
30
+ /** `--server`, else `OARS_POOL_URL`; never a default. A token and a session's shape cross this connection, so plain http goes only to this machine. */
31
+ export const poolServer = (env, step, named) => {
32
+ const given = named ?? env.OARS_POOL_URL;
33
+ if (given === undefined || given === '')
34
+ throw usage('Name the pool server with --server or OARS_POOL_URL; there is no default.', step);
35
+ let url;
36
+ try {
37
+ url = new URL(given);
38
+ }
39
+ catch {
40
+ throw usage(`The pool server ${given} is not a URL.`, step);
41
+ }
42
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname)))
43
+ throw usage(`Refusing the pool server ${given}: it takes https, or plain http only to localhost.`, step);
44
+ if (url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== '')
45
+ throw usage(`The pool server ${given} is an origin only: no credentials, path, or query.`, step);
46
+ return url.origin;
47
+ };
48
+ const runtimeOf = (deps) => {
49
+ if (deps.sessions === undefined)
50
+ throw new CliError('The session store is not configured in this run.', ExitCode.UNEXPECTED, 'unexpected');
51
+ return deps.sessions;
52
+ };
53
+ const textIn = (value, key) => {
54
+ const found = Reflect.get(value, key);
55
+ return typeof found === 'string' && found !== '' ? found : undefined;
56
+ };
57
+ const numberIn = (value, key) => {
58
+ const found = Reflect.get(value, key);
59
+ return typeof found === 'number' && Number.isFinite(found) ? found : undefined;
60
+ };
61
+ const isObject = (value) => typeof value === 'object' && value !== null;
62
+ const keyOf = (value) => {
63
+ if (!isObject(value))
64
+ return undefined;
65
+ const [kty, crv, x] = [textIn(value, 'kty'), textIn(value, 'crv'), textIn(value, 'x')];
66
+ return kty === 'OKP' && crv === 'Ed25519' && x !== undefined ? { kty, crv, x } : undefined;
67
+ };
68
+ const signInOf = (value) => {
69
+ if (!isObject(value))
70
+ return undefined;
71
+ const [server, account, token, expiresAt] = [textIn(value, 'server'), textIn(value, 'account'), textIn(value, 'token'), numberIn(value, 'expiresAt')];
72
+ const signingKey = keyOf(Reflect.get(value, 'signingKey'));
73
+ return server === undefined || account === undefined || token === undefined || expiresAt === undefined || signingKey === undefined ? undefined : { server, account, token, expiresAt, signingKey };
74
+ };
75
+ const signInsFile = (storeDir) => join(storeDir, 'pool-accounts.json');
76
+ /** Every saved sign-in. A file that cannot be read stops the command: treating it as empty would forget a pin and trust whatever key the server names next. */
77
+ const loadSignIns = async (storeDir, step) => {
78
+ const file = signInsFile(storeDir);
79
+ let text;
80
+ try {
81
+ text = await readFile(file, 'utf8');
82
+ }
83
+ catch (error) {
84
+ if (isMissing(error))
85
+ return [];
86
+ throw error;
87
+ }
88
+ const damaged = new CliError(`${file} cannot be read. Remove it, then run oars pool login again.`, ExitCode.INVALID, step);
89
+ let parsed;
90
+ try {
91
+ parsed = JSON.parse(text);
92
+ }
93
+ catch {
94
+ throw damaged;
95
+ }
96
+ if (!Array.isArray(parsed))
97
+ throw damaged;
98
+ const entries = parsed;
99
+ return entries.map((entry) => {
100
+ const signIn = signInOf(entry);
101
+ if (signIn === undefined)
102
+ throw damaged;
103
+ return signIn;
104
+ });
105
+ };
106
+ const signedIn = async (deps, storeDir, server, step) => {
107
+ const found = (await loadSignIns(storeDir, step)).find((entry) => entry.server === server);
108
+ if (found === undefined || found.expiresAt <= deps.now())
109
+ throw new CliError(`Not signed in to ${server}. Run oars pool login first.`, ExitCode.AUTH, step);
110
+ return found;
111
+ };
112
+ /** The request exactly as it will be sent: `outbox` prints it, and `ask` hands the same value to `fetch`. */
113
+ const outgoing = (deps, server, path, init) => {
114
+ const headers = [['user-agent', `oars/${deps.version}`], ['accept', 'application/json']];
115
+ if (init.token !== undefined)
116
+ headers.push(['authorization', `Bearer ${init.token}`]);
117
+ // Node's `fetch` writes `cache-control` and `pragma` itself whenever `if-none-match` is set, so they are named here to be printed.
118
+ if (init.ifNoneMatch !== undefined)
119
+ headers.push(['if-none-match', init.ifNoneMatch], ['cache-control', 'no-cache'], ['pragma', 'no-cache']);
120
+ const body = init.bytes ?? (init.body === undefined ? undefined : JSON.stringify(init.body));
121
+ if (body !== undefined)
122
+ headers.push(['content-type', 'application/json']);
123
+ headers.push(...TRANSPORT_HEADERS);
124
+ return { method: body === undefined ? 'GET' : 'POST', url: new URL(path, server), headers, ...(body === undefined ? {} : { body }) };
125
+ };
126
+ /** 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. */
127
+ const ask = (deps, step, server, path, init, via) => {
128
+ const request = outgoing(deps, server, path, init);
129
+ return via === undefined ? send(deps, step, request, init.show) : via(request);
130
+ };
131
+ /** Prints the request when asked to, then sends exactly those headers and that body. */
132
+ const send = async (deps, step, request, show) => {
133
+ if (show !== undefined)
134
+ deps.io.stderr(`${printed(request, show)}
135
+ `);
136
+ let response;
137
+ try {
138
+ response = await fetch(request.url, { method: request.method, headers: request.headers, redirect: 'error', ...(request.body === undefined ? {} : { body: request.body }) });
139
+ }
140
+ catch (error) {
141
+ throw unreachableError(step, error, request.url.origin);
142
+ }
143
+ let body;
144
+ try {
145
+ body = await response.json();
146
+ }
147
+ catch {
148
+ body = undefined;
149
+ }
150
+ return { status: response.status, body, headers: response.headers };
151
+ };
152
+ /** The sender a real send would use: the request goes out over the network as built. The command line does not pass it (sending waits on the covenant); the loopback test does, to read what reaches the socket. */
153
+ export const networkSender = (deps, step) => (request) => send(deps, step, request, undefined);
154
+ /** The server's named refusal, as a failure: its `error` is the problem's code in `--json`. */
155
+ const refusal = (step, { status, body }) => {
156
+ const code = isObject(body) ? textIn(body, 'error') : undefined;
157
+ const message = (isObject(body) ? textIn(body, 'message') : undefined) ?? `The pool answered HTTP ${String(status)}.`;
158
+ return new CliError(message, exitCodeForProblem(status, code === undefined ? undefined : { code }), step, { title: message, status, ...(code === undefined ? {} : { code }) });
159
+ };
160
+ const PROVIDER_FLOWS = new Map([
161
+ ['github', { deviceUrl: 'https://github.com/login/device/code', tokenUrl: 'https://github.com/login/oauth/access_token', scope: '', clientIdVar: 'OARS_GITHUB_CLIENT_ID' }],
162
+ ['google', { deviceUrl: 'https://oauth2.googleapis.com/device/code', tokenUrl: 'https://oauth2.googleapis.com/token', scope: 'openid', clientIdVar: 'OARS_GOOGLE_CLIENT_ID', secretVar: 'OARS_GOOGLE_CLIENT_SECRET' }],
163
+ ]);
164
+ const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
165
+ /** The provider's side of the flow, read from the environment. An unset value stops the command before anything is sent, and names what is missing. */
166
+ const configOf = (env, provider) => {
167
+ const set = (name) => (env[name] === undefined || env[name] === '' ? undefined : env[name]);
168
+ const clientId = set(provider.clientIdVar);
169
+ if (clientId === undefined)
170
+ throw usage(`Set ${provider.clientIdVar} to the client ID of the Many Oars OAuth app; it is not set.`, LOGIN);
171
+ const clientSecret = provider.secretVar === undefined ? undefined : set(provider.secretVar);
172
+ if (provider.secretVar !== undefined && clientSecret === undefined)
173
+ throw usage(`Set ${provider.secretVar} to the client secret of the Many Oars OAuth app; it is not set.`, LOGIN);
174
+ return { clientId, clientSecret };
175
+ };
176
+ /** One form POST to a provider, printed before it is sent. The provider answers JSON on whatever status it chooses (Google's pending poll is a 428), so the caller reads the body and not the status. */
177
+ const askProvider = (deps, url, form, masker) => {
178
+ const headers = [['user-agent', `oars/${deps.version}`], ['accept', 'application/json'], ['content-type', 'application/x-www-form-urlencoded'], ...TRANSPORT_HEADERS];
179
+ return send(deps, LOGIN, { method: 'POST', url: new URL(url), headers, body: new URLSearchParams(form).toString() }, masker);
180
+ };
181
+ const deviceOf = ({ body }) => {
182
+ // Google names the link `verification_url`, against the RFC's `verification_uri`.
183
+ const [deviceCode, userCode, verificationUri] = isObject(body) ? [textIn(body, 'device_code'), textIn(body, 'user_code'), textIn(body, 'verification_uri') ?? textIn(body, 'verification_url')] : [];
184
+ // A provider that refuses here (GitHub's `device_flow_disabled`, Google's `invalid_client`) is how an unfinished OAuth app shows, so its word goes to the operator.
185
+ const refusal = isObject(body) ? textIn(body, 'error') : undefined;
186
+ if (refusal !== undefined) {
187
+ const title = `The provider refused the sign-in: ${refusal}.`;
188
+ throw new CliError(title, ExitCode.AUTH, LOGIN, { code: 'login_refused', title });
189
+ }
190
+ // `openBrowser` parses the link before it returns its promise, so one that cannot parse has to stop here or it throws past the login's catch.
191
+ if (!isObject(body) || deviceCode === undefined || userCode === undefined || verificationUri === undefined || !URL.canParse(verificationUri))
192
+ throw new CliError('The provider did not start a sign-in.', ExitCode.UNEXPECTED, LOGIN);
193
+ // An interval of 0 or below would poll with no wait and never run out the clock, so it takes the default like a missing one.
194
+ const interval = numberIn(body, 'interval');
195
+ return { deviceCode, userCode, verificationUri, intervalMs: (interval === undefined || interval <= 0 ? DEFAULT_INTERVAL_SEC : interval) * 1000, expiresMs: (numberIn(body, 'expires_in') ?? DEFAULT_EXPIRY_SEC) * 1000 };
196
+ };
197
+ /** Polls the provider's token route until the operator approves, and returns the provider's token. Time is counted in the waits asked for, so a machine that slept through the code still gets one poll that says it expired. */
198
+ const awaitToken = async (deps, provider, config, device, masker) => {
199
+ let { intervalMs } = device;
200
+ const form = [['client_id', config.clientId], ['device_code', device.deviceCode], ['grant_type', DEVICE_GRANT]];
201
+ if (config.clientSecret !== undefined)
202
+ form.push(['client_secret', config.clientSecret]);
203
+ for (let waited = 0; waited < device.expiresMs; waited += intervalMs) {
204
+ await deps.sleep(intervalMs);
205
+ const { body } = await askProvider(deps, provider.tokenUrl, form, masker);
206
+ if (!isObject(body))
207
+ throw new CliError('The provider answered a sign-in poll with no body.', ExitCode.UNEXPECTED, LOGIN);
208
+ const accessToken = textIn(body, 'access_token');
209
+ if (accessToken !== undefined)
210
+ return accessToken;
211
+ const error = textIn(body, 'error') ?? 'no_token';
212
+ if (error === 'slow_down')
213
+ intervalMs += SLOW_DOWN_MS;
214
+ else if (error === 'expired_token')
215
+ break;
216
+ else if (error !== 'authorization_pending') {
217
+ const title = `The provider refused the sign-in: ${error}.`;
218
+ throw new CliError(title, ExitCode.AUTH, LOGIN, { code: 'login_refused', title });
219
+ }
220
+ }
221
+ const title = 'The sign-in code expired. Run oars pool login again.';
222
+ throw new CliError(title, ExitCode.AUTH, LOGIN, { code: 'EXPIRED_TOKEN', title });
223
+ };
224
+ /**
225
+ * `oars pool login` (spec Section 1.4): the device flow runs here, against the provider, and the pool sees only the provider's token, once, and answers its own credential. Every request prints before it is sent, with the device code, the provider's token,
226
+ * and the credential masked as `<credential: n bytes>`. A client ID, scope, grant type, and Google's `client_secret` print as they are: none is secret.
227
+ */
228
+ export const poolLogin = async (deps, options) => {
229
+ const { storeDir } = runtimeOf(deps);
230
+ const server = poolServer(deps.env, LOGIN, options.server);
231
+ const { provider } = options;
232
+ const flow = PROVIDER_FLOWS.get(provider ?? '');
233
+ if (provider === undefined || flow === undefined)
234
+ throw usage(`The provider is one of ${POOL_PROVIDERS.join(', ')}.`, LOGIN);
235
+ const config = configOf(deps.env, flow);
236
+ const saved = await loadSignIns(storeDir, LOGIN);
237
+ const masker = new Masker();
238
+ const device = deviceOf(await askProvider(deps, flow.deviceUrl, [['client_id', config.clientId], ['scope', flow.scope]], masker));
239
+ masker.add(device.deviceCode);
240
+ deps.io.stderr(`To sign in, open ${device.verificationUri}\nand confirm the code ${device.userCode}\n`);
241
+ await deps.openBrowser(device.verificationUri).catch(() => {
242
+ deps.io.stderr('Could not open a browser; open the link above yourself.\n');
243
+ });
244
+ const accessToken = await awaitToken(deps, flow, config, device, masker);
245
+ masker.add(accessToken);
246
+ const reply = await ask(deps, LOGIN, server, '/v1/login', { body: { provider, token: accessToken }, show: masker });
247
+ if (reply.status !== 200)
248
+ throw refusal(LOGIN, reply);
249
+ const signIn = signInOf(isObject(reply.body) ? { ...reply.body, server } : undefined);
250
+ if (signIn === undefined)
251
+ throw new CliError('The pool answered a sign-in with no account, token, or signing key.', ExitCode.UNEXPECTED, LOGIN);
252
+ const known = saved.find((entry) => entry.server === server);
253
+ // The key is trusted once, here, and never replaced by a later login: a server that swaps it could otherwise sign anything it likes.
254
+ if (known !== undefined && known.signingKey.x !== signIn.signingKey.x)
255
+ throw new CliError(`${server} now signs with a different key than the one pinned at your first login. Nothing was saved. If the pool really did change its key, remove ${server} from ${signInsFile(storeDir)} and sign in again.`, ExitCode.AUTH, LOGIN, { code: 'signing_key_changed', title: 'The pool signing key changed.' });
256
+ await mkdir(storeDir, { recursive: true });
257
+ await writeFile(signInsFile(storeDir), `${JSON.stringify([...saved.filter((entry) => entry !== known), signIn])}\n`, { mode: 0o600 });
258
+ return { document: { ok: true, server, account: signIn.account, expiresAt: signIn.expiresAt }, text: `Signed in to ${server} as ${signIn.account}.` };
259
+ };
260
+ /** A send stops here when the server is closed or wants a covenant accepted, before the batch is read for the last time. */
261
+ const openCovenant = async (deps, server, token, accept, via) => {
262
+ const reply = await ask(deps, DEPOSIT, server, '/v1/covenant', { token }, via);
263
+ if (reply.status !== 200 || !isObject(reply.body))
264
+ throw refusal(DEPOSIT, reply);
265
+ const version = textIn(reply.body, 'version');
266
+ if (version === undefined) {
267
+ const title = 'This pool is not taking deposits: its covenant is not published.';
268
+ throw new CliError(title, ExitCode.AUTH, DEPOSIT, { code: 'deposits_closed', title, status: 403 });
269
+ }
270
+ if (accept !== undefined) {
271
+ const accepted = await ask(deps, DEPOSIT, server, '/v1/covenant/accept', { token, body: { version: accept } }, via);
272
+ if (accepted.status !== 200)
273
+ throw refusal(DEPOSIT, accepted);
274
+ return;
275
+ }
276
+ if (Reflect.get(reply.body, 'accepted') !== true) {
277
+ const title = `Read covenant ${version} of this pool, then rerun with --accept-covenant ${version}.`;
278
+ throw new CliError(title, ExitCode.AUTH, DEPOSIT, { code: 'covenant_not_accepted', title, status: 403 });
279
+ }
280
+ };
281
+ const excludedKey = (reason) => (reason === 'no-raw' ? 'noRaw' : reason === 'no-terms' ? 'noTerms' : reason);
282
+ /** A deposit answered with this code is sent again: another deposit of the same session landed between the pool's read and its write, and the next one nets against the winner. */
283
+ const DEPOSIT_CONFLICT = 'deposit_conflict';
284
+ const SEND_ATTEMPTS = 3;
285
+ const vocabularyExpired = (deps) => {
286
+ if (!isVocabularyExpired(new Date(deps.now())))
287
+ return undefined;
288
+ const title = `This oars is out of date: its vocabulary ${VOCAB_VERSION} was taken on ${VOCAB_SNAPSHOT_DATE}, over ${String(REGISTRY_MAX_AGE_DAYS)} days ago. Upgrade with npm install -g @manyoars/oars, then run oars pool deposit again. Your sessions stay on this machine.`;
289
+ return new CliError(title, ExitCode.CONFLICT, DEPOSIT, { code: 'vocabulary_expired', title, status: 409 });
290
+ };
291
+ const lockMessages = {
292
+ batch_unknown: 'No batch with that hash is in the outbox. Run oars pool outbox to list them.',
293
+ batch_not_shown: 'That batch was never printed. Run oars pool outbox, read it, then send it.',
294
+ batch_changed: 'That batch changed on disk after it was printed, or since it was written, so it was not sent. Run oars pool deposit to build it again, then oars pool outbox to print it.',
295
+ };
296
+ /** `deposit --send <hash>`: the lock, then (only when a sender is given) the covenant and the request. The body is read and hashed again just before the request. */
297
+ const sendBatch = async (deps, options) => {
298
+ const { outbox, server, token, hash, via } = options;
299
+ if (!/^[0-9a-f]{64}$/.test(hash))
300
+ throw usage('--send takes the SHA-256 that oars pool outbox printed: 64 hexadecimal characters.', DEPOSIT);
301
+ const locked = await lockedBatch(outbox, hash);
302
+ if (!locked.ok) {
303
+ const title = lockMessages[locked.code];
304
+ throw new CliError(title, ExitCode.CONFLICT, DEPOSIT, { code: locked.code, title, status: 409 });
305
+ }
306
+ if (via === undefined) {
307
+ const title = 'Sending a batch waits on the pool covenant: this oars does not send one yet. Nothing was sent.';
308
+ throw new CliError(title, ExitCode.CONFLICT, DEPOSIT, { code: 'sending_disabled', title, status: 409 });
309
+ }
310
+ await openCovenant(deps, server, token, options.acceptCovenant, via);
311
+ const bytes = await unchangedBytes(locked.file, hash);
312
+ if (bytes === undefined) {
313
+ const title = lockMessages.batch_changed;
314
+ throw new CliError(title, ExitCode.CONFLICT, DEPOSIT, { code: 'batch_changed', title, status: 409 });
315
+ }
316
+ let reply = await ask(deps, DEPOSIT, server, POOL_DEPOSITS, { token, bytes }, via);
317
+ for (let attempt = 1; attempt < SEND_ATTEMPTS && reply.status === 409 && isObject(reply.body) && textIn(reply.body, 'error') === DEPOSIT_CONFLICT; attempt += 1) {
318
+ reply = await ask(deps, DEPOSIT, server, POOL_DEPOSITS, { token, bytes }, via);
319
+ }
320
+ if (reply.status !== 201)
321
+ throw refusal(DEPOSIT, reply);
322
+ return { document: { ok: true, server, hash, sent: true }, text: `Sent batch ${hash} to ${server}.` };
323
+ };
324
+ /**
325
+ * `oars pool deposit`: every session in the store that passes both session gates is built into a canonical batch in the outbox, and the command stops: nothing is sent. The gates run before any projection does: the folder
326
+ * allowlist, then the exclusion check. A session that fails either, or has no raw to scan, is counted and never built. A session is built only once it has ended: no transcript of it, main or subagent, has gained a line (the later of its capture
327
+ * and its raw file's last write) for `session_idle_hours` (6 hours). One that has not is counted under `notEnded`, and any batch of it from an earlier pass is withdrawn. `--send <hash>` is the lock in `sendBatch`.
328
+ */
329
+ export const poolDeposit = async (deps, options) => {
330
+ const runtime = runtimeOf(deps);
331
+ const server = poolServer(deps.env, DEPOSIT, options.server);
332
+ const { account, token } = await signedIn(deps, runtime.storeDir, server, DEPOSIT);
333
+ // A client past `registry_max_age` builds no batch (spec Section 1.3): its sessions wait in `A` and nothing is lost. This comes before anything is written or sent.
334
+ const expired = vocabularyExpired(deps);
335
+ if (expired !== undefined)
336
+ throw expired;
337
+ // Accepting the covenant is a step of sending, which is disabled in this release: say so rather than exit 0 as though it was accepted.
338
+ if (options.send === undefined && options.acceptCovenant !== undefined)
339
+ throw usage('--accept-covenant belongs to --send, and sending is disabled in this release. Nothing was accepted.', DEPOSIT);
340
+ const outbox = outboxFolder(runtime.storeDir, server);
341
+ if (options.send !== undefined)
342
+ return sendBatch(deps, { outbox, server, token, hash: options.send, acceptCovenant: options.acceptCovenant, via: options.via });
343
+ const { terms } = exclusionPaths(runtime.projectsRoot, deps.env);
344
+ const allow = compileAllow((await readSaved(runtime.storeDir)).allow);
345
+ const discarded = await discardStale(outbox);
346
+ const counts = { sessions: 0, batched: 0, excluded: { folder: 0, term: 0, noRaw: 0, noTerms: 0, unreadable: 0 }, uncaptured: 0, notEnded: 0, refused: 0, refusedFields: [], discarded, withdrawn: 0 };
347
+ const built = new Set();
348
+ const refusedFields = new Set();
349
+ const roots = new Map((await readSkeletons(runtime.storeDir, undefined, true)).skeletons.map(({ session, merkleRoot, leaves }) => [session, { merkleRoot, leaves }]));
350
+ // The sessions that pass both gates and have ended, in the order the store holds them: the windows of a delta are counted over these alone, and the batch of each is built after, once its deltas are known.
351
+ const ready = [];
352
+ // A session that fails a gate loses its earlier batch at once, so a later throw in this pass cannot leave one in the outbox.
353
+ const withdraw = async (key) => {
354
+ counts.withdrawn += await removeBatchOf(outbox, key);
355
+ };
356
+ for (const records of sessionsOf(await loadStore(runtime.storeDir), roots)) {
357
+ counts.sessions += 1;
358
+ // Hashed with the account, so a redeposit replaces its own batch and two accounts' copies of one session cannot be joined.
359
+ const key = contentHash([account, records.main.session]);
360
+ if (!allow.allows(records.main.project)) {
361
+ counts.excluded.folder += 1;
362
+ await withdraw(key);
363
+ continue;
364
+ }
365
+ const verdict = await checkSession(runtime, terms, records.main.project, records.main.session);
366
+ if (verdict.reason !== 'clean') {
367
+ counts.excluded[excludedKey(verdict.reason)] += 1;
368
+ await withdraw(key);
369
+ continue;
370
+ }
371
+ // The batch of a session is built once the session and its subagents have ended; a session still in progress waits, and an earlier batch of it is withdrawn.
372
+ if (!hasEnded(records, deps.now(), await lastWritten(runtime, records.main.project, records.main.session))) {
373
+ counts.notEnded += 1;
374
+ await withdraw(key);
375
+ continue;
376
+ }
377
+ ready.push({ records, key });
378
+ }
379
+ // A delta is built from the sessions that passed, carried by the batch of the session that filled its after window, and marked sent only once that batch is written: a deposit that stops first builds it again.
380
+ const placed = ready.flatMap(({ records }) => placedOf(records) ?? []);
381
+ const carried = carriedDeltas(account, placed, await sentDeltas(outbox));
382
+ for (const { records, key } of ready) {
383
+ const delta = carried.get(records.main.session);
384
+ const skeleton = skeletonOf(records, key, shellReader(vocabulary()), contentHash, delta?.entries);
385
+ if (skeleton === null) {
386
+ counts.uncaptured += 1;
387
+ await withdraw(key);
388
+ continue;
389
+ }
390
+ const parsed = parseSkeleton(skeleton);
391
+ if (!parsed.ok) {
392
+ counts.refused += 1;
393
+ refusedFields.add(parsed.field);
394
+ await withdraw(key);
395
+ continue;
396
+ }
397
+ await writeBatch(outbox, key, parsed.skeleton);
398
+ await markDeltasSent(outbox, delta?.marks ?? []);
399
+ built.add(key);
400
+ counts.batched += 1;
401
+ }
402
+ // The batch of a session or account this store no longer holds goes too.
403
+ counts.withdrawn += await removeBatchesExcept(outbox, built);
404
+ counts.refusedFields = [...refusedFields].sort();
405
+ const { folder, term, noRaw, noTerms, unreadable } = counts.excluded;
406
+ const lines = [
407
+ `Wrote ${String(counts.batched)} of ${String(counts.sessions)} sessions to the outbox for ${server}. Nothing was sent.`,
408
+ `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.`,
409
+ `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(', ')})`}.`,
410
+ ...(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.`]),
411
+ ...(discarded === 0 ? [] : [`Discarded ${String(discarded)} ${discarded === 1 ? 'batch' : 'batches'} built under an older vocabulary, field list, or skeleton format.`]),
412
+ 'Run oars pool outbox to print exactly what would be sent.',
413
+ ];
414
+ return { document: { ok: true, server, ...counts }, text: lines.join('\n') };
415
+ };
416
+ /** Each field of a batch, at any depth, with its row. A field with no row fails the command rather than printing blank. */
417
+ const explained = (bytes) => {
418
+ const rows = {};
419
+ for (const field of fieldsOf(bytes)) {
420
+ const row = Object.hasOwn(FIELD_ROWS, field) ? FIELD_ROWS[field] : undefined;
421
+ if (row === undefined) {
422
+ const title = `The batch holds a field, ${field}, with no row to explain it. Nothing was printed for it.`;
423
+ throw new CliError(title, ExitCode.INVALID, OUTBOX, { code: 'field_without_row', title });
424
+ }
425
+ rows[field] = row;
426
+ }
427
+ return rows;
428
+ };
429
+ /** `oars pool outbox`: prints each batch as it would be sent, 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. */
430
+ export const poolOutbox = async (deps, options) => {
431
+ const runtime = runtimeOf(deps);
432
+ const server = poolServer(deps.env, OUTBOX, options.server);
433
+ const { token } = await signedIn(deps, runtime.storeDir, server, OUTBOX);
434
+ const outbox = outboxFolder(runtime.storeDir, server);
435
+ const { batches, edited } = await heldBatches(outbox);
436
+ const masker = new Masker();
437
+ masker.add(token);
438
+ const shown = [];
439
+ for (const batch of batches) {
440
+ const fields = options.explain === true ? explained(batch.bytes) : undefined;
441
+ shown.push({ hash: batch.hash, request: printed(outgoing(deps, server, POOL_DEPOSITS, { token, bytes: batch.bytes }), masker), ...(fields === undefined ? {} : { fields }) });
442
+ }
443
+ for (const { hash } of shown)
444
+ await markShown(outbox, hash, deps.now());
445
+ const stale = await staleBatches(outbox);
446
+ const blocks = shown.map(({ hash, request, fields }) => [request, `sha256: ${hash}`, ...Object.entries(fields ?? {}).map(([field, row]) => ` ${field}: ${row}`)].join('\n'));
447
+ const notes = [
448
+ ...(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.`]),
449
+ ...(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.`]),
450
+ ];
451
+ const text = shown.length === 0 ? ['The outbox is empty. Run oars pool deposit first.', ...notes].join('\n') : [...blocks, ...notes].join('\n\n');
452
+ return { document: { ok: true, server, batches: shown, edited, stale }, text };
453
+ };
454
+ /** One folder per harness under the server's, so a fetch for one harness never overwrites another's bundle of the same version. */
455
+ const replicaFile = (storeDir, server, harness, harnessVersion) => join(storeDir, 'pool-replica', contentHash(server), harness, `${encodeURIComponent(harnessVersion)}.json`);
456
+ /** What an earlier fetch kept: the bundle, checked again against the pinned key, and the ETag the pool sent with it. Nothing readable and verified is nothing kept, and the next fetch is then an ordinary one. */
457
+ const keptReplica = async (file, key, harness, harnessVersion) => {
458
+ let parsed;
459
+ try {
460
+ parsed = JSON.parse(await readFile(file, 'utf8'));
461
+ }
462
+ catch {
463
+ return undefined;
464
+ }
465
+ const verified = await verifyBundle(parsed, key, harness, harnessVersion);
466
+ return verified.ok ? { etag: isObject(parsed) ? textIn(parsed, 'etag') : undefined, bundle: verified.bundle } : undefined;
467
+ };
468
+ /** The weekly cap, as a failure of its own code: the bundle on disk is untouched, and the words say when the pool will answer again. */
469
+ const cappedError = (deps, reply, kept) => {
470
+ const retryAfter = reply.headers.get('retry-after');
471
+ const wait = retryAfter === null ? undefined : waitWords(retryAfter, deps.now());
472
+ const when = wait === undefined ? 'The pool did not say when; it counts fetches per ISO week, so try again next week.' : `Try again ${wait}.`;
473
+ const title = `The pool's weekly fetch cap is used up. ${when} ${kept ? 'The bundle already on disk is untouched.' : 'No verified bundle is kept.'}`;
474
+ return new CliError(title, ExitCode.CAPPED, FETCH, { code: 'fetch_cap', title, status: 429 }, { retryAfter });
475
+ };
476
+ /** What a fetch asks for when no `--harness` is given, and what it falls back to when no one harness leads the local store. */
477
+ const DEFAULT_HARNESS = 'claude-code';
478
+ /**
479
+ * The harness a fetch asks for. `--harness` names it, and nothing is printed. Without the flag it is the harness the local store holds the most sessions of, and the choice and the way to
480
+ * change it go to stderr before the request: a fetch never picks silently. An empty store or a tie asks for Claude Code's and says so. It reads counts only, and none of them leaves the machine.
481
+ */
482
+ const fetchHarness = async (deps, storeDir, named) => {
483
+ if (named !== undefined) {
484
+ if (!isHarness(named))
485
+ throw usage(`${named} is not a harness the pool serves. Choose one of ${HARNESSES.join(', ')}.`, FETCH);
486
+ return named;
487
+ }
488
+ const counts = new Map();
489
+ for (const session of (await loadStore(storeDir)).sessions)
490
+ counts.set(sessionHarness(session), (counts.get(sessionHarness(session)) ?? 0) + 1);
491
+ const [top, runnerUp] = [...counts.entries()].sort((a, b) => b[1] - a[1]);
492
+ const lead = top !== undefined && (runnerUp === undefined || top[1] > runnerUp[1]) ? top : undefined;
493
+ const harness = lead === undefined ? DEFAULT_HARNESS : lead[0];
494
+ const total = [...counts.values()].reduce((sum, count) => sum + count, 0);
495
+ const why = lead === undefined ? `${total === 0 ? 'your local store is empty' : 'no one harness leads your local store'}, so Claude Code is the default` : `the harness your local store holds the most sessions of (${String(lead[1])} of ${String(total)})`;
496
+ deps.io.stderr(`No --harness given: fetching ${harness}, ${why}. Pass --harness <name> to fetch another (${HARNESSES.join(', ')}).
497
+ `);
498
+ return harness;
499
+ };
500
+ /** `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. */
501
+ export const poolFetch = async (deps, options) => {
502
+ const { storeDir } = runtimeOf(deps);
503
+ const server = poolServer(deps.env, FETCH, options.server);
504
+ const { harnessVersion } = options;
505
+ if (!isBundleVersion(harnessVersion))
506
+ throw usage(`${harnessVersion} is not a public harness version.`, FETCH);
507
+ const { token, signingKey } = await signedIn(deps, storeDir, server, FETCH);
508
+ const harness = await fetchHarness(deps, storeDir, options.harness);
509
+ const file = replicaFile(storeDir, server, harness, harnessVersion);
510
+ const kept = await keptReplica(file, signingKey, harness, harnessVersion);
511
+ const credential = new Masker();
512
+ credential.add(token);
513
+ const reply = await ask(deps, FETCH, server, `/bundle/${harness}/${encodeURIComponent(harnessVersion)}/${encodeURIComponent(VOCAB_VERSION)}`, { token, show: credential, ...(kept?.etag === undefined ? {} : { ifNoneMatch: kept.etag }) });
514
+ if (reply.status === 429)
515
+ throw cappedError(deps, reply, kept !== undefined);
516
+ if (reply.status === 304) {
517
+ // A 304 answers an If-None-Match, so one to a fetch that sent none is a pool that has lost track of what it was asked.
518
+ if (kept?.etag === undefined)
519
+ throw new CliError('The pool answered 304 to a fetch that named no kept bundle.', ExitCode.UNEXPECTED, FETCH);
520
+ const { cells, motifs, reads, asOf } = kept.bundle;
521
+ return {
522
+ document: { ok: true, server, harness, harnessVersion, file, unchanged: true, cells: cells.length, motifs: motifs.length, reads: reads.length, asOf },
523
+ text: `${harness} ${harnessVersion} is unchanged: the bundle at ${file} is still current (${String(reads.length)} reads, ${String(cells.length)} cells, and ${String(motifs.length)} motifs).`,
524
+ };
525
+ }
526
+ if (reply.status !== 200)
527
+ throw refusal(FETCH, reply);
528
+ const verified = await verifyBundle(reply.body, signingKey, harness, harnessVersion);
529
+ if (!verified.ok) {
530
+ const title = `Refused the bundle: ${verified.reason}.`;
531
+ throw new CliError(title, ExitCode.INVALID, FETCH, { code: 'bundle_unverified', title });
532
+ }
533
+ const etag = reply.headers.get('etag');
534
+ await mkdir(dirname(file), { recursive: true });
535
+ await writeFile(file, `${JSON.stringify({ ...(isObject(reply.body) ? reply.body : {}), ...(etag === null ? {} : { etag }) })}
536
+ `);
537
+ const { cells, motifs, reads, asOf } = verified.bundle;
538
+ return {
539
+ document: { ok: true, server, harness, harnessVersion, file, unchanged: false, cells: cells.length, motifs: motifs.length, reads: reads.length, asOf },
540
+ 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}.`,
541
+ };
542
+ };
@@ -0,0 +1,54 @@
1
+ import { type DeltaEntry, type SessionRecords } from './pool-wire.js';
2
+ /** `delta_days` and `delta_min_sessions` (spec Section 7): a week on each side spans a work cycle, and five sessions keep one session from being the delta. */
3
+ export declare const DELTA_DAYS = 7;
4
+ export declare const DELTA_MIN_SESSIONS = 5;
5
+ /** A session as a window sees it: when it began (epoch milliseconds) and an id that orders two sessions of one instant. */
6
+ export interface Placed {
7
+ id: string;
8
+ at: number;
9
+ }
10
+ /** A before and an after window, each of `DELTA_DAYS`, the after window's place on the grid (its index), and the session that brought the after window to its minimum. */
11
+ export interface Windows<T> {
12
+ index: number;
13
+ before: T[];
14
+ after: T[];
15
+ filler: T;
16
+ }
17
+ /**
18
+ * The pairs of windows a set of sessions holds. Windows are consecutive spans of `days` on a grid counted from the epoch, not from any session, so a session belongs to exactly one window and the grid does
19
+ * not move when a session is added, lost to a gate, or not yet ended: the `index` of a window is the same whenever the deposit runs. A pair is two windows next to each other that each hold at least
20
+ * `min` sessions; a gap of an empty window, or of one with too few, compares nothing across it. The `filler` is the `min`th session of the after window in time order. It chooses which batch carries
21
+ * the delta and can change from one deposit to the next, so nothing that must hold still is keyed on it.
22
+ */
23
+ export declare const windowsOf: <T extends Placed>(sessions: readonly T[], days?: number, min?: number) => Windows<T>[];
24
+ /**
25
+ * The entries a pair of windows holds, each window in time order (the caller sorts). A plugin is added or removed, a `permissionMode` or `effort` changed by name alone (the most common value of each
26
+ * window differs, a value outside its list read as one word), and a `package` removed. Each entry carries the move of one metric in its field's own buckets: calls per session for a plugin, a permission
27
+ * mode, and a package, thinking per prompt for an effort. A name outside its list is `private`, and everything that is `private` in one field is one entry.
28
+ */
29
+ export declare const deltasOf: (before: readonly SessionRecords[], after: readonly SessionRecords[]) => DeltaEntry[];
30
+ /**
31
+ * The once-only mark of a delta: a hash of the account, the entry without the size of its move, and the index of its after window on the grid. The move is left out so a recount of the same windows is
32
+ * the same delta, and the index is in so a later pair that shows the same change is a new one. The filler is not in it: which session fills a window moves with the sessions ready, a window's index does not.
33
+ */
34
+ export declare const deltaMark: (account: string, { deltaKind, field, name, registry, metric }: DeltaEntry, index: number) => string;
35
+ /** The marks of the deltas this outbox has already carried in a written batch. A folder with no log holds none; a log that cannot be read is an error, not an empty one, so a delta is never built twice by mistake. */
36
+ export declare const sentDeltas: (outbox: string) => Promise<Set<string>>;
37
+ /** Marks deltas as sent, one line each. The caller writes this only after the batch that carries them is written, so a deposit that stopped before it builds them again. */
38
+ export declare const markDeltasSent: (outbox: string, marks: readonly string[]) => Promise<void>;
39
+ /** A session that passed the session gates, placed for the windows: its records, when it began, and its id. */
40
+ export interface Gated extends Placed {
41
+ records: SessionRecords;
42
+ }
43
+ /** The session placed by when it began (epoch milliseconds) and its own id; none when it has no start that reads as a time, since no window can hold it. */
44
+ export declare const placedOf: (records: SessionRecords) => Gated | undefined;
45
+ /** What one batch carries: its delta entries and the marks to write once the batch is written. */
46
+ export interface Carried {
47
+ entries: DeltaEntry[];
48
+ marks: string[];
49
+ }
50
+ /**
51
+ * The deltas not yet sent, by the id of the session that filled their after window: that session's batch carries them, and the mark is the window's index, so it holds whichever session fills. A delta whose mark is held is dropped, so a rebuilt session, or a batch written
52
+ * again under the same key, builds none it already carried. Nothing is marked here: the caller marks them once the batch is written.
53
+ */
54
+ export declare const carriedDeltas: (account: string, sessions: readonly Gated[], sent: ReadonlySet<string>) => Map<string, Carried>;