@manyoars/oars 0.4.1 → 0.5.2

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 (188) hide show
  1. package/README.md +79 -29
  2. package/dist/bin.js +3 -1
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +207 -21
  5. package/dist/club.d.ts +19 -0
  6. package/dist/club.js +132 -0
  7. package/dist/gate/hook-fill-main.js +1 -1
  8. package/dist/gate/hook-fill.d.ts +1 -1
  9. package/dist/gate/hook-fill.js +1 -1
  10. package/dist/gate/hook-main.js +7 -5
  11. package/dist/gate/hook.d.ts +12 -1
  12. package/dist/gate/hook.js +38 -7
  13. package/dist/hook-bin.js +23 -4
  14. package/dist/main.js +5 -0
  15. package/dist/node-floor.d.ts +10 -0
  16. package/dist/node-floor.js +23 -0
  17. package/dist/oars-hook.d.ts +2 -0
  18. package/dist/oars-hook.js +7 -0
  19. package/dist/sessions/allow.d.ts +1 -0
  20. package/dist/sessions/allow.js +1 -1
  21. package/dist/sessions/call-shape.d.ts +111 -0
  22. package/dist/sessions/call-shape.js +464 -0
  23. package/dist/sessions/candidates.d.ts +65 -0
  24. package/dist/sessions/candidates.js +236 -0
  25. package/dist/sessions/card-command.d.ts +24 -0
  26. package/dist/sessions/card-command.js +71 -0
  27. package/dist/sessions/card-font.d.ts +6 -0
  28. package/dist/sessions/card-font.js +36 -0
  29. package/dist/sessions/card-line.d.ts +30 -0
  30. package/dist/sessions/card-line.js +105 -0
  31. package/dist/sessions/card-render.d.ts +15 -0
  32. package/dist/sessions/card-render.js +146 -0
  33. package/dist/sessions/card.d.ts +59 -0
  34. package/dist/sessions/card.js +107 -0
  35. package/dist/sessions/cell-increments.d.ts +14 -3
  36. package/dist/sessions/cell-increments.js +79 -12
  37. package/dist/sessions/cells.d.ts +42 -4
  38. package/dist/sessions/cells.js +72 -18
  39. package/dist/sessions/codex-hook-bin.d.ts +1 -0
  40. package/dist/sessions/codex-hook-bin.js +24 -0
  41. package/dist/sessions/codex-hook-install.d.ts +21 -0
  42. package/dist/sessions/codex-hook-install.js +47 -0
  43. package/dist/sessions/codex-hook-view.d.ts +20 -0
  44. package/dist/sessions/codex-hook-view.js +20 -0
  45. package/dist/sessions/codex-hook.d.ts +77 -0
  46. package/dist/sessions/codex-hook.js +255 -0
  47. package/dist/sessions/codex.d.ts +9 -0
  48. package/dist/sessions/codex.js +104 -18
  49. package/dist/sessions/command.d.ts +17 -2
  50. package/dist/sessions/command.js +145 -33
  51. package/dist/sessions/copilot.d.ts +3 -0
  52. package/dist/sessions/copilot.js +12 -4
  53. package/dist/sessions/derive.js +2 -1
  54. package/dist/sessions/empty-state.d.ts +17 -0
  55. package/dist/sessions/empty-state.js +38 -0
  56. package/dist/sessions/exclusion.js +48 -3
  57. package/dist/sessions/extract.d.ts +16 -3
  58. package/dist/sessions/extract.js +281 -24
  59. package/dist/sessions/gemini.d.ts +2 -0
  60. package/dist/sessions/gemini.js +29 -2
  61. package/dist/sessions/harness-files.d.ts +27 -0
  62. package/dist/sessions/harness-files.js +109 -0
  63. package/dist/sessions/head-only.d.ts +7 -0
  64. package/dist/sessions/head-only.js +21 -0
  65. package/dist/sessions/hook-calls.js +4 -3
  66. package/dist/sessions/hooks.d.ts +1 -1
  67. package/dist/sessions/hooks.js +6 -2
  68. package/dist/sessions/identity.d.ts +6 -0
  69. package/dist/sessions/injected.d.ts +13 -0
  70. package/dist/sessions/injected.js +260 -0
  71. package/dist/sessions/install.d.ts +31 -20
  72. package/dist/sessions/install.js +80 -82
  73. package/dist/sessions/load-status.d.ts +41 -0
  74. package/dist/sessions/load-status.js +156 -0
  75. package/dist/sessions/machine-child-deps.d.ts +5 -0
  76. package/dist/sessions/machine-child-deps.js +83 -0
  77. package/dist/sessions/machine-child.d.ts +43 -0
  78. package/dist/sessions/machine-child.js +120 -0
  79. package/dist/sessions/machine-credential.d.ts +10 -0
  80. package/dist/sessions/machine-credential.js +20 -0
  81. package/dist/sessions/machine-enroll.d.ts +47 -0
  82. package/dist/sessions/machine-enroll.js +230 -0
  83. package/dist/sessions/machine-hold.d.ts +49 -0
  84. package/dist/sessions/machine-hold.js +88 -0
  85. package/dist/sessions/machine-ids.d.ts +6 -0
  86. package/dist/sessions/machine-ids.js +9 -0
  87. package/dist/sessions/machine-notice.d.ts +21 -0
  88. package/dist/sessions/machine-notice.js +89 -0
  89. package/dist/sessions/machine-pull.d.ts +40 -0
  90. package/dist/sessions/machine-pull.js +220 -0
  91. package/dist/sessions/machine-repo.d.ts +7 -0
  92. package/dist/sessions/machine-repo.js +30 -0
  93. package/dist/sessions/machine-scan-cache.d.ts +12 -0
  94. package/dist/sessions/machine-scan-cache.js +47 -0
  95. package/dist/sessions/machine-send.d.ts +12 -0
  96. package/dist/sessions/machine-send.js +156 -0
  97. package/dist/sessions/machine-stop.d.ts +22 -0
  98. package/dist/sessions/machine-stop.js +53 -0
  99. package/dist/sessions/machine.d.ts +41 -0
  100. package/dist/sessions/machine.js +89 -0
  101. package/dist/sessions/opencode.d.ts +2 -0
  102. package/dist/sessions/opencode.js +6 -0
  103. package/dist/sessions/otel.d.ts +21 -0
  104. package/dist/sessions/otel.js +109 -38
  105. package/dist/sessions/owner-only.d.ts +7 -0
  106. package/dist/sessions/owner-only.js +34 -0
  107. package/dist/sessions/pi.d.ts +13 -1
  108. package/dist/sessions/pi.js +27 -13
  109. package/dist/sessions/pool-books.d.ts +1 -1
  110. package/dist/sessions/pool-books.js +1 -1
  111. package/dist/sessions/pool-choices.d.ts +4 -2
  112. package/dist/sessions/pool-choices.js +4 -2
  113. package/dist/sessions/pool-command.d.ts +46 -6
  114. package/dist/sessions/pool-command.js +613 -136
  115. package/dist/sessions/pool-delta.d.ts +7 -0
  116. package/dist/sessions/pool-delta.js +28 -5
  117. package/dist/sessions/pool-due.d.ts +43 -0
  118. package/dist/sessions/pool-due.js +113 -0
  119. package/dist/sessions/pool-nightly.d.ts +9 -1
  120. package/dist/sessions/pool-nightly.js +65 -21
  121. package/dist/sessions/pool-outbox.d.ts +51 -5
  122. package/dist/sessions/pool-outbox.js +217 -24
  123. package/dist/sessions/pool-print.d.ts +1 -1
  124. package/dist/sessions/pool-raw.js +4 -3
  125. package/dist/sessions/pool-replica.d.ts +4 -4
  126. package/dist/sessions/pool-replica.js +26 -7
  127. package/dist/sessions/pool-send-lock.js +1 -1
  128. package/dist/sessions/pool-sent.d.ts +2 -0
  129. package/dist/sessions/pool-sent.js +21 -4
  130. package/dist/sessions/pool-wire.d.ts +176 -15
  131. package/dist/sessions/pool-wire.js +297 -33
  132. package/dist/sessions/pool.js +4 -2
  133. package/dist/sessions/public-vocabulary.d.ts +5 -0
  134. package/dist/sessions/public-vocabulary.js +16 -0
  135. package/dist/sessions/read-call-shape.js +12 -6
  136. package/dist/sessions/read-fit.d.ts +5 -0
  137. package/dist/sessions/read-fit.js +23 -2
  138. package/dist/sessions/read-interaction.js +1 -1
  139. package/dist/sessions/read-lead-lag.js +1 -1
  140. package/dist/sessions/read-readable.js +1 -1
  141. package/dist/sessions/read.d.ts +1 -1
  142. package/dist/sessions/recall-consequence.d.ts +2 -2
  143. package/dist/sessions/recall-consequence.js +14 -9
  144. package/dist/sessions/recall-object.d.ts +9 -2
  145. package/dist/sessions/receipt.d.ts +8 -0
  146. package/dist/sessions/receipt.js +78 -0
  147. package/dist/sessions/recovery-calls.d.ts +3 -2
  148. package/dist/sessions/recovery-calls.js +9 -3
  149. package/dist/sessions/redact.d.ts +11 -2
  150. package/dist/sessions/redact.js +201 -23
  151. package/dist/sessions/repo-root.d.ts +7 -0
  152. package/dist/sessions/repo-root.js +17 -13
  153. package/dist/sessions/reversion.d.ts +8 -0
  154. package/dist/sessions/reversion.js +114 -0
  155. package/dist/sessions/schema.d.ts +78 -4
  156. package/dist/sessions/schema.js +31 -1
  157. package/dist/sessions/session-shape.d.ts +2 -1
  158. package/dist/sessions/session-shape.js +15 -0
  159. package/dist/sessions/sources.d.ts +9 -0
  160. package/dist/sessions/sources.js +16 -5
  161. package/dist/sessions/steps.js +3 -2
  162. package/dist/sessions/store.d.ts +41 -2
  163. package/dist/sessions/store.js +68 -16
  164. package/dist/sessions/try-demo.d.ts +8 -0
  165. package/dist/sessions/try-demo.js +90 -0
  166. package/dist/sessions/try.d.ts +13 -0
  167. package/dist/sessions/try.js +50 -0
  168. package/dist/sessions/vscode-copilot.d.ts +4 -0
  169. package/dist/sessions/vscode-copilot.js +25 -18
  170. package/dist/telemetry/command.js +12 -6
  171. package/dist/telemetry/consent.d.ts +9 -3
  172. package/dist/telemetry/consent.js +25 -9
  173. package/dist/telemetry/fields.d.ts +11 -0
  174. package/dist/telemetry/fields.js +56 -0
  175. package/dist/telemetry/measures.d.ts +13 -0
  176. package/dist/telemetry/measures.js +30 -0
  177. package/dist/telemetry/observe.d.ts +15 -2
  178. package/dist/telemetry/observe.js +34 -7
  179. package/dist/telemetry/region.d.ts +6 -0
  180. package/dist/telemetry/region.js +28 -0
  181. package/dist/telemetry/report.js +1 -1
  182. package/dist/telemetry/schema.d.ts +3 -0
  183. package/dist/telemetry/schema.js +11 -1
  184. package/dist/telemetry/send.d.ts +5 -2
  185. package/dist/telemetry/send.js +15 -7
  186. package/dist/telemetry/words.d.ts +6 -0
  187. package/dist/telemetry/words.js +6 -0
  188. package/package.json +24 -5
@@ -0,0 +1,22 @@
1
+ import type { SenderSpawn } from './pool-due.js';
2
+ export type MachineStopResult = 'not-machine' | 'no-input' | 'started' | 'failed';
3
+ export interface MachineStopDeps {
4
+ env: {
5
+ [name: string]: string | undefined;
6
+ };
7
+ /** The hook's stdin text. */
8
+ stdin: string;
9
+ /** The `hook-bin.js` that is running: the child is the same file with `machine-child`. */
10
+ hookBin: string;
11
+ /** The child's working folder, the home folder, so it never holds the session's repository open. */
12
+ home: string;
13
+ /** The session store, where the notice file is. */
14
+ storeDir: string;
15
+ /** The hook's stdout. */
16
+ write: (text: string) => void;
17
+ spawn: SenderSpawn;
18
+ }
19
+ /** What the child needs of the hook's input: the session, its transcript, and the folder it ran in. The rest of the input (a long last message, say) never goes onto a command line. `undefined` when it has no session or no transcript. */
20
+ export declare const forwardedInput: (stdin: string) => string | undefined;
21
+ /** Never throws: a hook that cannot start its child must not fail the session. A child that cannot start later (its `error` event) is dropped; the next Stop builds the same batch. */
22
+ export declare const machineStop: (deps: MachineStopDeps) => MachineStopResult;
@@ -0,0 +1,53 @@
1
+ // The Stop of a machine (a cloud VM, a VPS, a self-hosted runner): `oars-hook stop --machine`. The hook's own body does one thing, it starts a detached child with its output redirected and returns, so the
2
+ // harness is never held by a capture, a settle wait, or a build; every one of those runs in the child (`machine-child.ts`). The gate is `OARS_HOST_CLASS` (`machineOf`): on a laptop, or anywhere the variable is not set to a
3
+ // machine class, this does nothing and starts nothing. Before it starts the child it reads the notice file (`machine-notice.ts`) the last child may have left and prints it once as the hook's `systemMessage`.
4
+ // It imports no store module, so the hook's cost is the node start plus this.
5
+ import { machineOf } from './machine.js';
6
+ import { takeNotice } from './machine-notice.js';
7
+ const text = (value) => (typeof value === 'string' && value !== '' ? value : undefined);
8
+ /** What the child needs of the hook's input: the session, its transcript, and the folder it ran in. The rest of the input (a long last message, say) never goes onto a command line. `undefined` when it has no session or no transcript. */
9
+ export const forwardedInput = (stdin) => {
10
+ let parsed;
11
+ try {
12
+ parsed = JSON.parse(stdin);
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ if (typeof parsed !== 'object' || parsed === null)
18
+ return undefined;
19
+ const session = text(Reflect.get(parsed, 'session_id'));
20
+ const transcript = text(Reflect.get(parsed, 'transcript_path'));
21
+ if (session === undefined || transcript === undefined)
22
+ return undefined;
23
+ return JSON.stringify({ session_id: session, transcript_path: transcript, cwd: text(Reflect.get(parsed, 'cwd')) });
24
+ };
25
+ /** The notice the last child left, printed once as one `systemMessage` and cleared, so it is shown by the next Stop and not again. A notice that cannot be read or printed is dropped: the condition, still true, is written again by the next child. */
26
+ const announce = (deps) => {
27
+ try {
28
+ const notice = takeNotice(deps.storeDir);
29
+ if (notice !== undefined)
30
+ deps.write(JSON.stringify({ systemMessage: notice }));
31
+ }
32
+ catch {
33
+ // A hook never fails the session over a message.
34
+ }
35
+ };
36
+ /** Never throws: a hook that cannot start its child must not fail the session. A child that cannot start later (its `error` event) is dropped; the next Stop builds the same batch. */
37
+ export const machineStop = (deps) => {
38
+ if (machineOf(deps.env) === undefined)
39
+ return 'not-machine';
40
+ announce(deps);
41
+ const input = forwardedInput(deps.stdin);
42
+ if (input === undefined)
43
+ return 'no-input';
44
+ try {
45
+ const child = deps.spawn(process.execPath, [deps.hookBin, 'machine-child', input], { detached: true, stdio: 'ignore', windowsHide: true, cwd: deps.home });
46
+ child.on('error', () => undefined);
47
+ child.unref();
48
+ return 'started';
49
+ }
50
+ catch {
51
+ return 'failed';
52
+ }
53
+ };
@@ -0,0 +1,41 @@
1
+ /** Where a session runs: `local` is every ordinary machine and the only class that is not a machine here. */
2
+ export declare const HOST_CLASSES: readonly ["local", "vps", "cloud", "ci", "hosted"];
3
+ export type HostClass = (typeof HOST_CLASSES)[number];
4
+ export type MachineClass = Exclude<HostClass, 'local'>;
5
+ /** Every variable of the enrollment block; an install writes these and refuses any other name, so a typo is never silently dropped. */
6
+ export declare const ENROLLMENT_NAMES: readonly ["OARS_HOST_CLASS", "OARS_LAUNCHER", "OARS_MACHINE", "OARS_ALLOW_REPOS", "OARS_REPO_SALT", "OARS_SESSION_SALT", "OARS_EXCLUSION_TERMS", "OARS_EXCLUSION_TERMS_INLINE"];
7
+ export interface MachineConfig {
8
+ hostClass: MachineClass;
9
+ /** `OARS_LAUNCHER`, as the enrollment printed it; absent when unset. */
10
+ launcher: string | undefined;
11
+ /** `OARS_MACHINE`, the opaque id minted at enrollment; absent when unset. */
12
+ machine: string | undefined;
13
+ /** `OARS_SESSION_SALT`: the key of a session's skeleton `id` and of the terms digest; absent when unset. */
14
+ sessionSalt: string | undefined;
15
+ /** `CLAUDE_CODE_REMOTE_SESSION_ID`: the cloud's own name for the session; absent when unset. */
16
+ remoteSession: string | undefined;
17
+ /** `OARS_ALLOW_REPOS` split on commas and whitespace, lowercased; empty means no repository is allowed. */
18
+ allowRepos: string[];
19
+ }
20
+ /** The machine configuration in `env`, or `undefined` for a local runtime (no `OARS_HOST_CLASS`, `local`, or a value that is not a class). A value that is not a class is not a machine: nothing is built. */
21
+ export declare const machineOf: (env: {
22
+ [name: string]: string | undefined;
23
+ }) => MachineConfig | undefined;
24
+ /**
25
+ * The `env` entries a user-scope machine install writes, from the `NAME=value` pairs of `--env`. `OARS_HOST_CLASS` is required and is the gate, so it must be a class other than `local`. With
26
+ * `--terms-file`, the inline terms go to that file (owner-only) and `OARS_EXCLUSION_TERMS` names it, so the settings file holds no term. Nothing is written until every pair is valid.
27
+ */
28
+ export declare const enrollmentOf: (pairs: readonly string[], termsFile: string | undefined, cwd: string) => Promise<{
29
+ env: {
30
+ [name: string]: string;
31
+ };
32
+ termsPath: string | undefined;
33
+ }>;
34
+ /** Where a machine's child keeps the terms it was given in `OARS_EXCLUSION_TERMS_INLINE`: in the store, owner-only. */
35
+ export declare const inlineTermsFile: (storeDir: string) => string;
36
+ /**
37
+ * A machine whose enrollment put the exclusion terms in one variable (a cloud environment has no file to point at) gets them written, owner-only, into its store at every Stop, with `OARS_EXCLUSION_TERMS` pointed at that file, so the
38
+ * file is always the variable's current value. The inline terms win over a file named by `OARS_EXCLUSION_TERMS`, because the enrollment is where the operator stated them. With no inline terms (unset or blank) `env` is returned as it is,
39
+ * and a machine with no term file at all fails closed, as everywhere. The given `env` is not changed.
40
+ */
41
+ export declare const withInlineTerms: (env: NodeJS.ProcessEnv, storeDir: string) => Promise<NodeJS.ProcessEnv>;
@@ -0,0 +1,89 @@
1
+ // What makes a runtime a machine: a cloud VM, a VPS, or a self-hosted runner that builds its session's deposit batch at every Stop. The gate is one variable, `OARS_HOST_CLASS`, set to a class
2
+ // other than `local`; a laptop never sets it, so it keeps its own hooks and its end-of-session gate untouched. The rest of the enrollment block (launcher, machine id, allowed repositories,
3
+ // salts, exclusion terms) is read where it is used; this file holds the names, the gate, and the install-time parsing of the block.
4
+ import { rename } from 'node:fs/promises';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { CliError, ExitCode } from '../errors.js';
7
+ import { mkdirOwnerOnly, writeOwnerOnly } from './owner-only.js';
8
+ /** Where a session runs: `local` is every ordinary machine and the only class that is not a machine here. */
9
+ export const HOST_CLASSES = ['local', 'vps', 'cloud', 'ci', 'hosted'];
10
+ /** Every variable of the enrollment block; an install writes these and refuses any other name, so a typo is never silently dropped. */
11
+ export const ENROLLMENT_NAMES = [
12
+ 'OARS_HOST_CLASS',
13
+ 'OARS_LAUNCHER',
14
+ 'OARS_MACHINE',
15
+ 'OARS_ALLOW_REPOS',
16
+ 'OARS_REPO_SALT',
17
+ 'OARS_SESSION_SALT',
18
+ 'OARS_EXCLUSION_TERMS',
19
+ 'OARS_EXCLUSION_TERMS_INLINE',
20
+ ];
21
+ const isMachineClass = (value) => HOST_CLASSES.some((name) => name === value && name !== 'local');
22
+ const present = (value) => (value === undefined || value.trim() === '' ? undefined : value.trim());
23
+ /** The machine configuration in `env`, or `undefined` for a local runtime (no `OARS_HOST_CLASS`, `local`, or a value that is not a class). A value that is not a class is not a machine: nothing is built. */
24
+ export const machineOf = (env) => {
25
+ const hostClass = present(env.OARS_HOST_CLASS);
26
+ if (!isMachineClass(hostClass))
27
+ return undefined;
28
+ return {
29
+ hostClass,
30
+ launcher: present(env.OARS_LAUNCHER),
31
+ machine: present(env.OARS_MACHINE),
32
+ sessionSalt: present(env.OARS_SESSION_SALT),
33
+ remoteSession: present(env.CLAUDE_CODE_REMOTE_SESSION_ID),
34
+ allowRepos: (env.OARS_ALLOW_REPOS ?? '')
35
+ .split(/[\s,]+/u)
36
+ .map((entry) => entry.trim().toLowerCase())
37
+ .filter((entry) => entry !== ''),
38
+ };
39
+ };
40
+ const usage = (message) => new CliError(message, ExitCode.USAGE, 'usage');
41
+ const isEnrollmentName = (name) => ENROLLMENT_NAMES.some((known) => known === name);
42
+ /**
43
+ * The `env` entries a user-scope machine install writes, from the `NAME=value` pairs of `--env`. `OARS_HOST_CLASS` is required and is the gate, so it must be a class other than `local`. With
44
+ * `--terms-file`, the inline terms go to that file (owner-only) and `OARS_EXCLUSION_TERMS` names it, so the settings file holds no term. Nothing is written until every pair is valid.
45
+ */
46
+ export const enrollmentOf = async (pairs, termsFile, cwd) => {
47
+ const env = {};
48
+ for (const pair of pairs) {
49
+ const at = pair.indexOf('=');
50
+ const name = at < 0 ? pair : pair.slice(0, at);
51
+ if (at < 0 || !isEnrollmentName(name))
52
+ throw usage(`--env takes NAME=value with a name from the enrollment block (${ENROLLMENT_NAMES.join(', ')}); got ${at < 0 ? 'no "="' : name}. Nothing was written.`);
53
+ env[name] = pair.slice(at + 1);
54
+ }
55
+ if (!isMachineClass(present(env.OARS_HOST_CLASS)))
56
+ throw usage('--scope user --machine needs --env OARS_HOST_CLASS=<vps|cloud|ci|hosted>: that variable is the gate, and local is not a machine. Nothing was written.');
57
+ const inline = env.OARS_EXCLUSION_TERMS_INLINE;
58
+ if (inline !== undefined && env.OARS_EXCLUSION_TERMS !== undefined)
59
+ throw usage('Give the exclusion terms once: OARS_EXCLUSION_TERMS_INLINE or OARS_EXCLUSION_TERMS, not both. Nothing was written.');
60
+ if (termsFile === undefined)
61
+ return { env, termsPath: undefined };
62
+ if (inline === undefined)
63
+ throw usage('--terms-file moves the terms of --env OARS_EXCLUSION_TERMS_INLINE into an owner-only file, and none was given. Nothing was written.');
64
+ const termsPath = resolve(cwd, termsFile);
65
+ await mkdirOwnerOnly(dirname(termsPath));
66
+ await writeOwnerOnly(termsPath, inline.endsWith('\n') ? inline : `${inline}\n`);
67
+ Reflect.deleteProperty(env, 'OARS_EXCLUSION_TERMS_INLINE');
68
+ env.OARS_EXCLUSION_TERMS = termsPath;
69
+ return { env, termsPath };
70
+ };
71
+ /** Where a machine's child keeps the terms it was given in `OARS_EXCLUSION_TERMS_INLINE`: in the store, owner-only. */
72
+ export const inlineTermsFile = (storeDir) => join(storeDir, 'exclusion-terms-inline.txt');
73
+ /**
74
+ * A machine whose enrollment put the exclusion terms in one variable (a cloud environment has no file to point at) gets them written, owner-only, into its store at every Stop, with `OARS_EXCLUSION_TERMS` pointed at that file, so the
75
+ * file is always the variable's current value. The inline terms win over a file named by `OARS_EXCLUSION_TERMS`, because the enrollment is where the operator stated them. With no inline terms (unset or blank) `env` is returned as it is,
76
+ * and a machine with no term file at all fails closed, as everywhere. The given `env` is not changed.
77
+ */
78
+ export const withInlineTerms = async (env, storeDir) => {
79
+ const inline = env.OARS_EXCLUSION_TERMS_INLINE;
80
+ if (inline === undefined || inline.trim() === '')
81
+ return env;
82
+ const file = inlineTermsFile(storeDir);
83
+ await mkdirOwnerOnly(storeDir);
84
+ // Written beside the file and renamed onto it: a Stop still in its gates reads the old list or the new one, never a half-written one that would fail closed and withdraw its batch.
85
+ const staged = `${file}.${String(process.pid)}`;
86
+ await writeOwnerOnly(staged, inline.endsWith('\n') ? inline : `${inline}\n`);
87
+ await rename(staged, file);
88
+ return { ...env, OARS_EXCLUSION_TERMS: file };
89
+ };
@@ -25,6 +25,8 @@ export interface OpencodeStore {
25
25
  sessions: OpencodeSession[];
26
26
  /** The session as transcript text for `captureSession`; throws when it holds no parseable message. */
27
27
  transcript: (session: OpencodeSession) => string;
28
+ /** True when every row of the session is a type the format defines that carries no turn (`NO_TURN_ROWS`); `sessions verify` counts such a session as empty, not unreadable. */
29
+ headOnly: (session: OpencodeSession) => boolean;
28
30
  /** Sessions skipped because their id is not a safe file name (it names a skeleton), counted so the skip is never silent. */
29
31
  rejected: number;
30
32
  close: () => void;
@@ -247,6 +247,8 @@ const linesOf = (session, rows, parts) => {
247
247
  }
248
248
  return { lines, bad };
249
249
  };
250
+ /** The `session_message` types the format defines that are not a prompt and not read (the header comment): a session of these alone was opened and left. */
251
+ const NO_TURN_ROWS = new Set(['location-switched', 'synthetic', 'system', 'skill', 'compaction', 'idle', 'agent-switched', 'model-switched']);
250
252
  /** Opens the OpenCode store under `root`; undefined when it holds no database, so a machine without OpenCode is skipped silently. A database that cannot be read throws. */
251
253
  export const openOpencodeStore = async (root) => {
252
254
  const file = join(root, DATABASE);
@@ -300,6 +302,10 @@ export const openOpencodeStore = async (root) => {
300
302
  close: () => {
301
303
  db.close();
302
304
  },
305
+ headOnly: (session) => {
306
+ const types = db.prepare('select distinct type from session_message where session_id = ?').all(session.id);
307
+ return types.length > 0 && types.every((row) => typeof row.type === 'string' && NO_TURN_ROWS.has(row.type));
308
+ },
303
309
  transcript: (session) => {
304
310
  const rows = db.prepare('select id, type, time_created, data from session_message where session_id = ? order by seq').all(session.id);
305
311
  const parts = new Map();
@@ -34,7 +34,18 @@ export interface OtelSession {
34
34
  spawnedBy: string | null;
35
35
  } | undefined;
36
36
  file: string;
37
+ /** True when a span of the session holds a `gen_ai.*` attribute or a name leading with an operation. A session with none (ordinary HTTP spans) is a known form with no turn. */
38
+ genai: boolean;
37
39
  }
40
+ /**
41
+ * The single-trace sessions a conversation id folds into a merged one: a trace that names a conversation is no session of its own, but an earlier deposit that read
42
+ * it before the id arrived (the id sits on the root agent span, which a batch exporter writes last) holds one under the trace's own id. The deposit supersedes that
43
+ * session with a later rev, only if it holds one.
44
+ */
45
+ export declare const supersededSessions: (spans: readonly Span[]) => {
46
+ id: string;
47
+ by: string;
48
+ }[];
38
49
  /** The sessions of a set of spans (every trace of every file). */
39
50
  export declare const otelSessions: (spans: readonly Span[]) => OtelSession[];
40
51
  /** A trace session found in the folder, before its lines are read. */
@@ -46,11 +57,21 @@ export interface OtelFound {
46
57
  mtimeMs: number;
47
58
  subagent: OtelSession['subagent'];
48
59
  file: string;
60
+ genai: boolean;
49
61
  }
50
62
  export interface OtelStore {
51
63
  sessions: OtelFound[];
64
+ /** The single-trace sessions a conversation id has folded into a merged one (`supersededSessions`). */
65
+ superseded: {
66
+ id: string;
67
+ by: string;
68
+ }[];
52
69
  transcript: (session: OtelFound) => string;
70
+ /** True when a session with no turn holds no GenAI span (ordinary HTTP spans): a known form, so `sessions verify` counts it as empty, not unreadable. */
71
+ headOnly: (session: OtelFound) => boolean;
53
72
  rejected: number;
73
+ /** Files that held a request but gave no span because the request's shape is not one the reader knows. Read only by `sessions verify`; selection is unchanged. */
74
+ shapeMoved: number;
54
75
  unreadable: {
55
76
  file: string;
56
77
  reason: string;
@@ -4,8 +4,8 @@
4
4
  // conventions moved there from open-telemetry/semantic-conventions, which has a release but only "Moved" stubs for GenAI, and the new repository has no release)
5
5
  // and the OTLP JSON encoding (opentelemetry-proto v1.11.1, docs/specification.md:453-484, trace.proto:96-327; the file exporter,
6
6
  // opentelemetry-specification specification/protocol/file-exporter.md:64-75, writes one `ExportTraceServiceRequest` a line with no order guarantee). Nothing was
7
- // copied and no real trace was read: `docs/sessions-store.md` names the one hand run that would prove the reader. Every GenAI attribute below is marked Development
8
- // in the registry, so each one is optional here: a missing or renamed attribute means less is read, never a throw.
7
+ // copied; a real Pydantic AI trace is pinned in `otel-real-trace.test.ts`. Every GenAI attribute below is marked Development in the registry, so each one is
8
+ // optional here: a missing or renamed attribute means less is read, never a throw.
9
9
  //
10
10
  // Where it reads from (`otelRoot`): `$OARS_OTEL_TRACES`, else `<config>/oars/otel-traces` (a configured folder, so the nightly run is deterministic). Every `*.jsonl` and
11
11
  // `*.json` file in it is read; the traces of all files are joined by `traceId` first, because a collector may split one trace across files.
@@ -13,11 +13,15 @@
13
13
  // How a trace becomes a session:
14
14
  // session one `traceId`; traces that share `gen_ai.conversation.id` are one session, each trace a turn in start-time order. An agent span nested inside another
15
15
  // agent's (directly or through an agent-as-tool `execute_tool` span) is its own session `<traceId>-<spanId>`, a subagent of the span's session.
16
- // prompt one per root agent span (`invoke_agent`, `invoke_workflow`, any `invoke_*` operation), else per trace root; its text is the last user message of
17
- // `gen_ai.input.messages` (Opt-In: absent by default, and then the turn is written content-less, as an image-only prompt is).
16
+ // prompt one per root agent span (`invoke_agent`, `invoke_workflow`, any `invoke_*` operation), else one per trace, from its earliest root (by start time, then input
17
+ // order; a root whose parent never reached the data joins it); its text is the last user message of `gen_ai.input.messages`, else of the earliest chat child span
18
+ // that has them (Opt-In: absent by default, and then the turn is written content-less, as an image-only prompt is).
18
19
  // model call a span whose `gen_ai.operation.name` is `chat`, `generate_content`, or `text_completion`: one assistant line, its `requestId` the span id.
19
- // tool call an `execute_tool` span: a block in the model call that ended before it began (or whose output messages name its `gen_ai.tool.call.id`). It failed iff the
20
- // span's status code is ERROR (2) or `error.type` is set (stable, recording-errors.md): the error line is `error.type: status.message`, never the result.
20
+ // tool call an `execute_tool` span, put under the model call that ended before it began (or whose output messages name its `gen_ai.tool.call.id`): a block in
21
+ // that call's line, or, with both times and end not before start (a directly nested agent span too), its own assistant line at the span's start under
22
+ // the call's `requestId`, so its duration is the span's length. It failed iff the span's status code is ERROR (2) or `error.type` is set (stable,
23
+ // recording-errors.md): the error line is `error.type: status.message`, never the result; a call that did not fail keeps `gen_ai.tool.call.result` as
24
+ // its result content.
21
25
  // usage `gen_ai.usage.*`; `input_tokens` includes the cache reads and writes (the registry says it SHOULD), and capture's fields are disjoint, so the uncached
22
26
  // part is input - read - write. A figure the span did not report is not written as zero.
23
27
  // harness `otel`; the version slot holds the emitting framework from the instrumentation scope name (a closed list plus `other`). Nothing names a working folder.
@@ -138,6 +142,18 @@ const spansOf = (request, order, file) => {
138
142
  }
139
143
  return spans;
140
144
  };
145
+ /**
146
+ * True when a request holds something the reader found no span in: no `resourceSpans` list, or a span that is not an object or lacks `traceId` or `spanId`. An export with an
147
+ * empty list is routine (nothing was traced) and is not this.
148
+ */
149
+ const holdsUnreadSpans = (request) => {
150
+ // A metrics or logs export in the same folder is another signal, not a moved trace.
151
+ if (isObj(request) && ('resourceMetrics' in request || 'resourceLogs' in request))
152
+ return false;
153
+ if (!isObj(request) || !Array.isArray(request.resourceSpans))
154
+ return true;
155
+ return request.resourceSpans.some((resource) => (isObj(resource) && Array.isArray(resource.scopeSpans) ? resource.scopeSpans : []).some((scoped) => (isObj(scoped) && Array.isArray(scoped.spans) ? scoped.spans : []).some((raw) => !isObj(raw) || str(raw.traceId) === undefined || str(raw.spanId) === undefined)));
156
+ };
141
157
  /** The requests of one file: the whole text as one JSON value (a `.json` file), else one request a line (the file exporter's JSON Lines). Throws when nothing parses. */
142
158
  const requestsOf = (text) => {
143
159
  const whole = parse(text);
@@ -169,21 +185,58 @@ const messagesOf = (value) => {
169
185
  const list = structured(value);
170
186
  return Array.isArray(list) ? list.filter(isObj) : [];
171
187
  };
172
- const promptText = (span) => {
173
- const users = messagesOf(span.attrs['gen_ai.input.messages']).filter((message) => message.role === 'user');
188
+ const promptText = (span, models) => {
189
+ // Pydantic AI's agent span has no `gen_ai.input.messages` (they sit on its chat spans), so the earliest chat span that carries them stands in; a span that has them keeps its own.
190
+ const source = span.attrs['gen_ai.input.messages'] === undefined ? (models.find((model) => model.attrs['gen_ai.input.messages'] !== undefined) ?? span) : span;
191
+ const users = messagesOf(source.attrs['gen_ai.input.messages']).filter((message) => message.role === 'user');
174
192
  return textParts(users[users.length - 1]);
175
193
  };
194
+ /** A tool span's result as the content of its result line: a string as written, anything else as JSON; `""` when the span names none. */
195
+ const resultContent = (span) => {
196
+ const value = span.attrs['gen_ai.tool.call.result'];
197
+ if (value === undefined)
198
+ return '';
199
+ return typeof value === 'string' ? value : JSON.stringify(value);
200
+ };
176
201
  /** The tool call ids a model call's output messages name. */
177
202
  const namedCalls = (span) => messagesOf(span.attrs['gen_ai.output.messages']).flatMap((message) => (Array.isArray(message.parts) ? message.parts : []).flatMap((part) => (isObj(part) && part.type === 'tool_call' && str(part.id) !== undefined ? [String(part.id)] : [])));
178
203
  const count = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined);
179
204
  /** A tool span's error line: `error.type: status.message`, either alone when the other is missing, else the exception event's message. */
180
205
  const failureText = (span) => {
181
- const type = str(span.attrs['error.type']);
206
+ // `error.type` is the stable name; a framework that records the failure only as an exception event (Pydantic AI) still names the class there.
207
+ const type = str(span.attrs['error.type']) ?? str(span.events.find((event) => event.name === 'exception')?.['exception.type']);
182
208
  const message = span.statusMessage ?? str(span.events.find((event) => event.name === 'exception')?.['exception.message']);
183
209
  if (type !== undefined && message !== undefined)
184
210
  return `${type}: ${message}`;
185
211
  return type ?? message ?? 'tool failed';
186
212
  };
213
+ const isGenAi = (span) => Object.keys(span.attrs).some((key) => key.startsWith('gen_ai.')) || ops(span) !== '';
214
+ /** The id a trace's own session takes, and the one nested agents build on: the trace id when it is a plain id, else a digest of it. */
215
+ const tracePart = (trace) => (/^[A-Za-z0-9]{1,64}$/.test(trace) ? trace : digest(trace));
216
+ /** The conversation each trace names, from the earliest span that names one. */
217
+ const conversationsOf = (spans) => {
218
+ const conversationOf = new Map();
219
+ for (const span of [...spans].sort((a, b) => (a.start ?? a.end ?? 0) - (b.start ?? b.end ?? 0))) {
220
+ const conversation = str(span.attrs['gen_ai.conversation.id']);
221
+ if (conversation !== undefined && !conversationOf.has(span.trace))
222
+ conversationOf.set(span.trace, conversation);
223
+ }
224
+ return conversationOf;
225
+ };
226
+ /** The session a trace's turns belong to: its conversation's, else its own. */
227
+ const sessionIdOf = (trace, conversationOf) => {
228
+ const conversation = conversationOf.get(trace);
229
+ return conversation === undefined ? tracePart(trace) : `conv-${digest(conversation)}`;
230
+ };
231
+ /**
232
+ * The single-trace sessions a conversation id folds into a merged one: a trace that names a conversation is no session of its own, but an earlier deposit that read
233
+ * it before the id arrived (the id sits on the root agent span, which a batch exporter writes last) holds one under the trace's own id. The deposit supersedes that
234
+ * session with a later rev, only if it holds one.
235
+ */
236
+ export const supersededSessions = (spans) => {
237
+ const conversationOf = conversationsOf(spans);
238
+ return [...conversationOf.keys()].map((trace) => ({ id: tracePart(trace), by: sessionIdOf(trace, conversationOf) }));
239
+ };
187
240
  /** The sessions of a set of spans (every trace of every file). */
188
241
  export const otelSessions = (spans) => {
189
242
  const byId = new Map(spans.map((span) => [`${span.trace}/${span.id}`, span]));
@@ -216,17 +269,9 @@ export const otelSessions = (spans) => {
216
269
  }
217
270
  const roots = spans.filter((span) => parentOf(span) === undefined);
218
271
  const startOf = (span) => span.start ?? span.end ?? 0;
219
- const conversationOf = new Map();
220
- for (const span of [...spans].sort((a, b) => startOf(a) - startOf(b))) {
221
- const conversation = str(span.attrs['gen_ai.conversation.id']);
222
- if (conversation !== undefined && !conversationOf.has(span.trace))
223
- conversationOf.set(span.trace, conversation);
224
- }
225
- const tracePart = (trace) => (/^[A-Za-z0-9]{1,64}$/.test(trace) ? trace : digest(trace));
226
- const sessionOfTrace = (trace) => {
227
- const conversation = conversationOf.get(trace);
228
- return conversation === undefined ? tracePart(trace) : `conv-${digest(conversation)}`;
229
- };
272
+ const timed = (span) => span.start !== undefined && span.end !== undefined && span.end >= span.start;
273
+ const conversationOf = conversationsOf(spans);
274
+ const sessionOfTrace = (trace) => sessionIdOf(trace, conversationOf);
230
275
  // A unit is an agent span, or a trace root that is not one: whatever owns the model calls and tools beneath it.
231
276
  const unitOf = new Map();
232
277
  const agentsAbove = (span) => ancestors(span).find(isAgent);
@@ -285,6 +330,15 @@ export const otelSessions = (spans) => {
285
330
  else
286
331
  held.push(span);
287
332
  }
333
+ // A GenAI attribute (or an operation-leading name) on any span of a unit's trace marks its session, even an ordinary child span that is no model or tool span: a renamed attribute there is a moved form, not an empty session.
334
+ const genaiUnits = new Set();
335
+ for (const span of spans) {
336
+ if (!isGenAi(span))
337
+ continue;
338
+ const owner = ownerOf(span);
339
+ if (owner !== undefined)
340
+ genaiUnits.add(owner);
341
+ }
288
342
  const out = new Map();
289
343
  const framework = (unit) => unit.span.framework;
290
344
  const orderedUnits = [...unitOf.values()].sort((a, b) => startOf(a.span) - startOf(b.span) || a.span.order - b.span.order);
@@ -303,7 +357,7 @@ export const otelSessions = (spans) => {
303
357
  held.push(child.span);
304
358
  }
305
359
  for (const unit of orderedUnits) {
306
- const entry = out.get(unit.session) ?? { lines: [], mtime: 0, spans: 0, subagent: undefined, file: unit.span.file };
360
+ const entry = out.get(unit.session) ?? { lines: [], mtime: 0, spans: 0, subagent: undefined, file: unit.span.file, genai: false };
307
361
  out.set(unit.session, entry);
308
362
  if (unit.spawn !== undefined) {
309
363
  const agentName = str(unit.span.attrs['gen_ai.agent.name']);
@@ -327,7 +381,7 @@ export const otelSessions = (spans) => {
327
381
  return at !== undefined && (least === undefined || at < least) ? at : least;
328
382
  }, undefined);
329
383
  const begin = stamp(unit.span.start ?? unit.span.end ?? (entry.mtime === 0 ? earliest : undefined));
330
- const text = promptText(unit.span);
384
+ const text = promptText(unit.span, mine.filter(isModel));
331
385
  const pieces = [];
332
386
  pieces.push({ t: begin, rank: -1, line: { ...base, type: 'user', uuid: `${unit.session}-${unit.span.id}-u`, timestamp: iso(begin), message: { role: 'user', content: [text === '' ? { type: 'image' } : { type: 'text', text }] } } });
333
387
  const calls = [];
@@ -340,25 +394,31 @@ export const otelSessions = (spans) => {
340
394
  pieces.push({ t, rank: 0, line });
341
395
  return { content, line, message };
342
396
  };
343
- const attach = (callId, tool) => {
344
- const named = calls.find((call) => call.names.includes(callId));
345
- const before = calls.findLast((call) => call.end <= startOf(tool));
346
- const chosen = named ?? before;
347
- if (chosen !== undefined)
348
- return chosen;
349
- return assistant(tool, `${tool.id}-call`);
397
+ const chooseCall = (callId, tool) => calls.find((call) => call.names.includes(callId)) ?? calls.findLast((call) => call.end <= startOf(tool));
398
+ const attach = (callId, tool) => chooseCall(callId, tool) ?? assistant(tool, `${tool.id}-call`);
399
+ // A timed tool is its own assistant line at the span's start under the chat's `requestId` (the one-block-per-line shape Claude Code transcripts have), so extract reads the span's own length as the call's duration.
400
+ const place = (callId, tool, block) => {
401
+ const chosen = timed(tool) ? chooseCall(callId, tool) : undefined;
402
+ if (chosen === undefined) {
403
+ attach(callId, tool).content.push(block);
404
+ return;
405
+ }
406
+ const { content, line } = assistant(tool, String(chosen.line.requestId));
407
+ if (chosen.line.requestedModel !== undefined)
408
+ line.requestedModel = chosen.line.requestedModel;
409
+ content.push(block);
350
410
  };
351
- const result = (id, tool, failure) => {
411
+ const result = (id, tool, failure, output = '') => {
352
412
  const t = Math.max(stamp(tool.end ?? tool.start), begin);
353
413
  pieces.push({
354
414
  t,
355
415
  rank: 1,
356
- line: { ...base, type: 'user', uuid: `${unit.session}-${tool.id}-r-${id}`, timestamp: iso(t), noDuration: true, message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, is_error: failure !== undefined, content: failure ?? '' }] } },
416
+ line: { ...base, type: 'user', uuid: `${unit.session}-${tool.id}-r-${id}`, timestamp: iso(t), ...(timed(tool) ? {} : { noDuration: true }), message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, is_error: failure !== undefined, content: failure ?? output }] } },
357
417
  });
358
418
  };
359
419
  for (const span of mine) {
360
420
  if (direct.has(span)) {
361
- attach(span.id, span).content.push({ type: 'tool_use', id: span.id, name: 'invoke_agent', native: 'invoke_agent', input: {} });
421
+ place(span.id, span, { type: 'tool_use', id: span.id, name: 'invoke_agent', native: 'invoke_agent', input: {} });
362
422
  result(span.id, span, span.failed ? failureText(span) : undefined);
363
423
  }
364
424
  else if (isModel(span)) {
@@ -418,15 +478,18 @@ export const otelSessions = (spans) => {
418
478
  block.name = `mcp__unnamed__${toolName}`;
419
479
  block.native = block.name;
420
480
  }
421
- attach(id, span).content.push(block);
422
- result(id, span, failed ? (exit !== undefined && exit !== 0 && !span.failed ? `Exit code ${String(exit)}` : failureText(span)) : undefined);
481
+ place(id, span, block);
482
+ result(id, span, failed ? (exit !== undefined && exit !== 0 && !span.failed ? `Exit code ${String(exit)}` : failureText(span)) : undefined, resultContent(span));
423
483
  }
424
484
  }
425
485
  pieces.sort((a, b) => a.t - b.t || a.rank - b.rank);
426
486
  entry.lines.push(...pieces.map((piece) => piece.line));
427
- entry.spans += 1 + mine.length;
487
+ // The unit's own span counts once, though it is also a model or tool span when the earliest root of an agentless trace is one.
488
+ entry.spans += 1 + mine.filter((span) => span !== unit.span).length;
489
+ if (mine.some(isGenAi) || genaiUnits.has(unit))
490
+ entry.genai = true;
428
491
  }
429
- return [...out.entries()].map(([id, held]) => ({ id, size: held.spans, mtimeMs: held.mtime, lines: held.lines, subagent: held.subagent, file: held.file }));
492
+ return [...out.entries()].map(([id, held]) => ({ id, size: held.spans, mtimeMs: held.mtime, lines: held.lines, subagent: held.subagent, file: held.file, genai: held.genai }));
430
493
  };
431
494
  const SAFE = /^(?!\.+$)[A-Za-z0-9._-]+$/;
432
495
  export const openOtelStore = async (root) => {
@@ -437,11 +500,12 @@ export const openOtelStore = async (root) => {
437
500
  catch (error) {
438
501
  if (missing(error))
439
502
  return undefined;
440
- return { sessions: [], transcript: () => '', rejected: 0, unreadable: [{ file: root, reason: reasonOf(error, root) }] };
503
+ return { sessions: [], superseded: [], transcript: () => '', headOnly: () => false, rejected: 0, shapeMoved: 0, unreadable: [{ file: root, reason: reasonOf(error, root) }] };
441
504
  }
442
505
  const unreadable = [];
443
506
  const spans = [];
444
507
  const order = { next: 0 };
508
+ let shapeMoved = 0;
445
509
  // The torn lines of each file stay with that file: a session that came from it counts them, else the file is named.
446
510
  const torn = new Map();
447
511
  for (const name of names) {
@@ -452,15 +516,19 @@ export const openOtelStore = async (root) => {
452
516
  const { requests, bad } = requestsOf(await readFile(file, 'utf8'));
453
517
  if (bad.length > 0)
454
518
  torn.set(file, bad);
519
+ const before = spans.length;
455
520
  for (const request of requests) {
456
521
  spans.push(...spansOf(request, order, file));
457
522
  }
523
+ if (spans.length === before && requests.some(holdsUnreadSpans))
524
+ shapeMoved += 1;
458
525
  }
459
526
  catch (error) {
460
527
  unreadable.push({ file, reason: reasonOf(error, file) });
461
528
  }
462
529
  }
463
530
  const built = otelSessions(spans);
531
+ const superseded = supersededSessions(spans).filter((entry) => SAFE.test(entry.id) && SAFE.test(entry.by));
464
532
  let rejected = 0;
465
533
  const sessions = [];
466
534
  const lines = new Map();
@@ -470,7 +538,7 @@ export const openOtelStore = async (root) => {
470
538
  continue;
471
539
  }
472
540
  lines.set(session.id, session.lines);
473
- sessions.push({ id: session.id, folder: null, size: session.size, mtimeMs: session.mtimeMs, subagent: session.subagent, file: session.file });
541
+ sessions.push({ id: session.id, folder: null, size: session.size, mtimeMs: session.mtimeMs, subagent: session.subagent, file: session.file, genai: session.genai });
474
542
  }
475
543
  sessions.sort((a, b) => a.mtimeMs - b.mtimeMs || a.id.localeCompare(b.id));
476
544
  const tornOf = new Map();
@@ -483,7 +551,10 @@ export const openOtelStore = async (root) => {
483
551
  }
484
552
  return {
485
553
  sessions,
554
+ superseded,
555
+ headOnly: (session) => !session.genai,
486
556
  rejected,
557
+ shapeMoved,
487
558
  unreadable,
488
559
  transcript: (session) => {
489
560
  const held = lines.get(session.id) ?? [];
@@ -0,0 +1,7 @@
1
+ export declare const isMissing: (error: unknown) => boolean;
2
+ /** Appends `text` to `file`, which stays readable and writable by the owner only whether or not it existed. */
3
+ export declare const appendOwnerOnly: (file: string, text: string) => Promise<void>;
4
+ /** Writes `text` (or bytes, kept exactly as they are) to `file`, readable and writable by the owner only, whether or not the file existed. */
5
+ export declare const writeOwnerOnly: (file: string, text: string | Uint8Array) => Promise<void>;
6
+ /** Makes `folder` (and any parent it has to create) 0700; a `folder` that exists is narrowed to 0700 as well. */
7
+ export declare const mkdirOwnerOnly: (folder: string) => Promise<void>;
@@ -0,0 +1,34 @@
1
+ // What the pool commands keep on this machine is the operator's alone: the pool account file holds a credential, and the outbox holds redacted transcripts and batches (census S9, S10). Files are written 0600 and folders 0700.
2
+ // `writeFile` and `mkdir` apply a mode only to what they create, so a file or folder an older client made wider is narrowed here too, and a file is narrowed before new bytes go into it.
3
+ // Windows has no POSIX mode bits (its own file ACLs apply), so there the calls are skipped.
4
+ import { appendFile, chmod, mkdir, writeFile } from 'node:fs/promises';
5
+ const POSIX = process.platform !== 'win32';
6
+ export const isMissing = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
7
+ /** Narrows a file that exists to 0600 before bytes go into it; a file not made yet is created 0600 by the write itself. */
8
+ const narrow = async (file) => {
9
+ if (!POSIX)
10
+ return;
11
+ try {
12
+ await chmod(file, 0o600);
13
+ }
14
+ catch (error) {
15
+ if (!isMissing(error))
16
+ throw error;
17
+ }
18
+ };
19
+ /** Appends `text` to `file`, which stays readable and writable by the owner only whether or not it existed. */
20
+ export const appendOwnerOnly = async (file, text) => {
21
+ await narrow(file);
22
+ await appendFile(file, text, { encoding: 'utf8', mode: 0o600 });
23
+ };
24
+ /** Writes `text` (or bytes, kept exactly as they are) to `file`, readable and writable by the owner only, whether or not the file existed. */
25
+ export const writeOwnerOnly = async (file, text) => {
26
+ await narrow(file);
27
+ await writeFile(file, text, { encoding: 'utf8', mode: 0o600 });
28
+ };
29
+ /** Makes `folder` (and any parent it has to create) 0700; a `folder` that exists is narrowed to 0700 as well. */
30
+ export const mkdirOwnerOnly = async (folder) => {
31
+ await mkdir(folder, { recursive: true, mode: 0o700 });
32
+ if (POSIX)
33
+ await chmod(folder, 0o700);
34
+ };