antechamber 1.0.40 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +88 -141
  3. package/bin/opencode-antechamber +14 -4
  4. package/dist/approvals.d.ts +9 -1
  5. package/dist/approvals.js +10 -2
  6. package/dist/catalog.d.ts +13 -2
  7. package/dist/catalog.js +88 -52
  8. package/dist/cli.d.ts +1 -1
  9. package/dist/cli.js +1 -1
  10. package/dist/commands/context.d.ts +5 -2
  11. package/dist/commands/context.js +8 -4
  12. package/dist/commands/doctor.js +4 -3
  13. package/dist/commands/setup.js +9 -28
  14. package/dist/config.d.ts +6 -2
  15. package/dist/config.js +18 -4
  16. package/dist/file-watch.d.ts +35 -0
  17. package/dist/file-watch.js +163 -0
  18. package/dist/latency.d.ts +10 -0
  19. package/dist/latency.js +115 -0
  20. package/dist/misc-routes.js +14 -12
  21. package/dist/pi-event-translation.js +44 -2
  22. package/dist/route-services.d.ts +8 -3
  23. package/dist/routes-v1.d.ts +1 -1
  24. package/dist/routes-v1.js +29 -418
  25. package/dist/routes-v2.js +252 -34
  26. package/dist/self-update.d.ts +2 -1
  27. package/dist/self-update.js +5 -1
  28. package/dist/server.d.ts +17 -3
  29. package/dist/server.js +191 -18
  30. package/dist/session-commands.d.ts +3 -1
  31. package/dist/session-commands.js +13 -7
  32. package/dist/session-history.js +3 -0
  33. package/dist/session-store.d.ts +19 -10
  34. package/dist/session-store.js +266 -128
  35. package/dist/session-tree.d.ts +15 -1
  36. package/dist/session-tree.js +2 -2
  37. package/dist/sessions.d.ts +85 -1
  38. package/dist/sessions.js +238 -10
  39. package/dist/sse.d.ts +0 -2
  40. package/dist/sse.js +13 -46
  41. package/dist/text-generation.d.ts +20 -0
  42. package/dist/text-generation.js +93 -0
  43. package/dist/tool-map.js +1 -3
  44. package/dist/types.d.ts +60 -0
  45. package/dist/v2.d.ts +8 -1
  46. package/dist/v2.js +24 -4
  47. package/dist/work-status.js +1 -1
  48. package/extensions/antechamber/README.md +100 -0
  49. package/extensions/antechamber/bin/opencode-antechamber +42 -0
  50. package/extensions/{omo-dag/docs/DESIGN.md → antechamber/docs/GRAPH.md} +5 -5
  51. package/extensions/{omo-work/docs/DESIGN.md → antechamber/docs/WORK.md} +3 -3
  52. package/extensions/antechamber/package.json +60 -0
  53. package/extensions/{omo-dag → antechamber}/panel/index.html +8 -1
  54. package/extensions/antechamber/panel/main.js +261 -0
  55. package/extensions/antechamber/runtime/launcher.js +201 -0
  56. package/extensions/antechamber/service/main.js +884 -0
  57. package/package.json +75 -26
  58. package/extensions/omo-compat/README.md +0 -48
  59. package/extensions/omo-compat/icon.svg +0 -1
  60. package/extensions/omo-compat/package.json +0 -43
  61. package/extensions/omo-dag/README.md +0 -167
  62. package/extensions/omo-dag/package.json +0 -33
  63. package/extensions/omo-dag/panel/main.js +0 -176
  64. package/extensions/omo-dag/service/main.js +0 -457
  65. package/extensions/omo-work/README.md +0 -88
  66. package/extensions/omo-work/package.json +0 -30
  67. package/extensions/omo-work/service/main.js +0 -149
  68. /package/extensions/{omo-dag → antechamber}/icon.svg +0 -0
  69. /package/extensions/{omo-work → antechamber}/status/index.html +0 -0
  70. /package/extensions/{omo-work → antechamber}/status/main.js +0 -0
package/dist/catalog.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Model/provider catalog: obtained from a throwaway `pi --mode rpc` process
3
3
  * (`get_available_models` + `get_state`), translated into OpenCode v2
4
- * Provider/Model shapes, cached for the adapter lifetime.
4
+ * Provider/Model shapes, refreshed from live agents or bounded cold reads.
5
5
  */
6
6
  import { PiRpcClient } from "./pi-rpc.js";
7
7
  import { thinkingVariants } from "./thinking-levels.js";
@@ -44,24 +44,95 @@ function toOCModel(m) {
44
44
  ...(variants !== undefined ? { variants } : {}),
45
45
  };
46
46
  }
47
- export async function loadCatalog(directory) {
48
- const pi = new PiRpcClient({ cwd: directory, args: ["--no-session"] });
47
+ function inventory(res) {
48
+ const providers = new Map();
49
+ if (res.success && res.data) {
50
+ for (const m of res.data.models) {
51
+ let p = providers.get(m.provider);
52
+ if (!p) {
53
+ p = { id: m.provider, name: m.provider, source: "config", env: [], options: {}, models: {} };
54
+ providers.set(m.provider, p);
55
+ }
56
+ p.models[m.id] = toOCModel(m);
57
+ }
58
+ }
59
+ return providers;
60
+ }
61
+ /**
62
+ * Apply the default to the inventory: list it even if get_available_models omitted it (the client drops a
63
+ * selection it cannot find), and put it first, because OpenChamber's picker defaults to the first listed
64
+ * provider/model. `active` is a model from a fresh agent's get_state, or the previous snapshot's default.
65
+ */
66
+ function withDefault(providers, active, thinkingLevel) {
67
+ const defaults = {};
68
+ if (active !== null) {
69
+ defaults[active.providerID] = active.modelID;
70
+ let p = providers.get(active.providerID);
71
+ if (p === undefined) {
72
+ p = { id: active.providerID, name: active.providerID, source: "config", env: [], options: {}, models: {} };
73
+ providers.set(active.providerID, p);
74
+ }
75
+ if (p.models[active.modelID] === undefined)
76
+ p.models[active.modelID] = active.model;
77
+ }
78
+ const sorted = [...providers.values()].sort((a, b) => {
79
+ const aDef = defaults[a.id] !== undefined ? 0 : 1;
80
+ const bDef = defaults[b.id] !== undefined ? 0 : 1;
81
+ return aDef - bDef;
82
+ });
83
+ for (const p of sorted) {
84
+ const def = defaults[p.id];
85
+ if (!def)
86
+ continue;
87
+ const entries = Object.entries(p.models).sort(([a], [b]) => (a === def ? -1 : b === def ? 1 : 0));
88
+ p.models = Object.fromEntries(entries);
89
+ }
90
+ // exactOptionalPropertyTypes: omit `default` rather than set it undefined.
91
+ if (active === null)
92
+ return { providers: sorted, defaults };
93
+ const resolvedDefault = { providerID: active.providerID, modelID: active.modelID };
94
+ // Only a level the catalog offers as a variant is selectable in the picker.
95
+ const offered = sorted.find((p) => p.id === active.providerID)?.models[active.modelID]?.variants;
96
+ return thinkingLevel !== undefined && offered?.[thinkingLevel] !== undefined
97
+ ? { providers: sorted, defaults, default: resolvedDefault, defaultThinkingLevel: thinkingLevel }
98
+ : { providers: sorted, defaults, default: resolvedDefault };
99
+ }
100
+ /**
101
+ * The configured default plus the provider/model inventory in a fixed order, so a reordering alone is not
102
+ * reported as a catalog change but a new default is.
103
+ */
104
+ export function catalogFingerprint(catalog) {
105
+ return JSON.stringify([
106
+ catalog.default ?? null,
107
+ catalog.defaultThinkingLevel ?? null,
108
+ [...catalog.providers]
109
+ .sort((a, b) => a.id.localeCompare(b.id))
110
+ .map((p) => [
111
+ p.id,
112
+ Object.keys(p.models)
113
+ .sort()
114
+ .map((id) => [id, p.models[id]]),
115
+ ]),
116
+ ]);
117
+ }
118
+ /**
119
+ * `configured` is the previous snapshot when `client` is a live session's agent: that agent's `get_state.model`
120
+ * is the session's own selection, not the configured default, so only the model inventory is re-read and the
121
+ * snapshot's default is kept. Only a fresh `--no-session` agent defines the default.
122
+ */
123
+ export async function loadCatalog(directory, client, configured) {
124
+ const pi = client ?? new PiRpcClient({ cwd: directory, args: ["--no-session"] });
49
125
  try {
126
+ if (configured !== undefined) {
127
+ const modelsRes = await pi.command({ type: "get_available_models" });
128
+ const def = configured.default;
129
+ const model = def && configured.providers.find((p) => p.id === def.providerID)?.models[def.modelID];
130
+ return withDefault(inventory(modelsRes), def && model ? { ...def, model } : null, configured.defaultThinkingLevel);
131
+ }
50
132
  const [modelsRes, firstState] = await Promise.all([
51
133
  pi.command({ type: "get_available_models" }),
52
134
  pi.command({ type: "get_state" }),
53
135
  ]);
54
- const providers = new Map();
55
- if (modelsRes.success && modelsRes.data) {
56
- for (const m of modelsRes.data.models) {
57
- let p = providers.get(m.provider);
58
- if (!p) {
59
- p = { id: m.provider, name: m.provider, source: "config", env: [], options: {}, models: {} };
60
- providers.set(m.provider, p);
61
- }
62
- p.models[m.id] = toOCModel(m);
63
- }
64
- }
65
136
  // get_state occasionally races pi's own startup. One retry beats resolving
66
137
  // no active model, which is precisely what leaves the UI picker guessing.
67
138
  let active = firstState.success ? (firstState.data?.model ?? null) : null;
@@ -74,45 +145,10 @@ export async function loadCatalog(directory) {
74
145
  if (active === null) {
75
146
  console.error("[catalog] warning: pi reported no active model — /api/model/default will be null and the UI picker falls back to its own default");
76
147
  }
77
- const defaults = {};
78
- let resolvedDefault;
79
- if (active !== null) {
80
- defaults[active.provider] = active.id;
81
- resolvedDefault = { providerID: active.provider, modelID: active.id };
82
- // The client drops any selection it cannot find in this catalog and picks
83
- // something else instead, so a model get_available_models omitted still
84
- // has to be listed here — otherwise the active model is unselectable.
85
- let p = providers.get(active.provider);
86
- if (p === undefined) {
87
- p = { id: active.provider, name: active.provider, source: "config", env: [], options: {}, models: {} };
88
- providers.set(active.provider, p);
89
- }
90
- if (p.models[active.id] === undefined)
91
- p.models[active.id] = toOCModel(active);
92
- }
93
- // OpenChamber's picker defaults to the first listed provider/model — put pi's active config there.
94
- const sorted = [...providers.values()].sort((a, b) => {
95
- const aDef = defaults[a.id] !== undefined ? 0 : 1;
96
- const bDef = defaults[b.id] !== undefined ? 0 : 1;
97
- return aDef - bDef;
98
- });
99
- for (const p of sorted) {
100
- const def = defaults[p.id];
101
- if (!def)
102
- continue;
103
- const entries = Object.entries(p.models).sort(([a], [b]) => (a === def ? -1 : b === def ? 1 : 0));
104
- p.models = Object.fromEntries(entries);
105
- }
106
- // exactOptionalPropertyTypes: omit `default` rather than set it undefined.
107
- if (resolvedDefault === undefined)
108
- return { providers: sorted, defaults };
109
- // Only a level the catalog offers as a variant is selectable in the picker.
110
- const offered = sorted.find((p) => p.id === resolvedDefault.providerID)?.models[resolvedDefault.modelID]?.variants;
111
- return thinkingLevel !== undefined && offered?.[thinkingLevel] !== undefined
112
- ? { providers: sorted, defaults, default: resolvedDefault, defaultThinkingLevel: thinkingLevel }
113
- : { providers: sorted, defaults, default: resolvedDefault };
148
+ return withDefault(inventory(modelsRes), active === null ? null : { providerID: active.provider, modelID: active.id, model: toOCModel(active) }, thinkingLevel);
114
149
  }
115
150
  finally {
116
- await pi.dispose();
151
+ if (client === undefined)
152
+ await pi.dispose();
117
153
  }
118
154
  }
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
  export {};
package/dist/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
  /** CLI parsing and dispatch. Server code is loaded only for serve/default mode. */
3
3
  import { parseArgs } from "node:util";
4
4
  import { ADAPTER_VERSION, OPENCODE_API_VERSION } from "./version.js";
@@ -7,7 +7,10 @@ export interface CommandContext {
7
7
  readonly exec: (binary: string, args: readonly string[]) => string;
8
8
  readonly now: () => Date;
9
9
  readonly platform: NodeJS.Platform;
10
- readonly nodeVersion: string;
10
+ /** Bun's version, or undefined when this process is not running on Bun. */
11
+ readonly bunVersion: string | undefined;
12
+ /** The running Bun executable, or undefined when this process is not running on Bun. */
13
+ readonly bunBinary: string | undefined;
11
14
  readonly output: (line: string) => void;
12
15
  readonly cliPath: string;
13
16
  readonly launcherPath: string;
@@ -24,6 +27,6 @@ export declare function readSettings(path: string, ctx: CommandContext): Record<
24
27
  export declare function shellQuote(value: string): string;
25
28
  /** OpenChamber launcher written by `setup --auto-update`, kept beside the session registry. */
26
29
  export declare function autoUpdateLauncherPath(ctx: CommandContext): string;
27
- export declare function autoUpdateLauncherText(binary: string): string;
30
+ export declare function autoUpdateLauncherText(binary: string, bunBinary?: string): string;
28
31
  export declare function autoUpdateLauncherTarget(text: string): string | undefined;
29
32
  export declare function isPackageBinary(binary: string, ctx: CommandContext): boolean;
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { delimiter, isAbsolute, join, resolve } from "node:path";
4
+ import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { defaultStateDir, envSetting } from "../config.js";
7
7
  export function createContext(overrides = {}) {
@@ -29,7 +29,8 @@ export function createContext(overrides = {}) {
29
29
  },
30
30
  now: () => new Date(),
31
31
  platform: process.platform,
32
- nodeVersion: process.versions.node,
32
+ bunVersion: process.versions.bun,
33
+ bunBinary: process.versions.bun === undefined ? undefined : process.execPath,
33
34
  output: console.log,
34
35
  cliPath: packageCliPath,
35
36
  launcherPath: fileURLToPath(new URL("../../bin/opencode-antechamber", import.meta.url)),
@@ -85,13 +86,16 @@ export function shellQuote(value) {
85
86
  export function autoUpdateLauncherPath(ctx) {
86
87
  return resolve(ctx.cwd, envSetting("STATE_DIR", ctx.env) ?? defaultStateDir(ctx.home, ctx.fs.existsSync), "opencode-antechamber");
87
88
  }
88
- export function autoUpdateLauncherText(binary) {
89
+ export function autoUpdateLauncherText(binary, bunBinary) {
90
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
89
91
  return [
90
92
  "#!/bin/sh",
91
93
  "# Written by `antechamber setup --auto-update`; rerun `antechamber setup` without it to stop auto-updates.",
92
94
  "ANTECHAMBER_AUTO_UPDATE=1",
93
95
  "export ANTECHAMBER_AUTO_UPDATE",
94
- `exec '${binary.replaceAll("'", "'\\''")}' "$@"`,
96
+ // The CLI's `env bun` shebang must find Bun even when OpenChamber's PATH lacks its directory.
97
+ ...(bunBinary === undefined ? [] : [`PATH=${quote(dirname(bunBinary))}:"$PATH"`, "export PATH"]),
98
+ `exec ${quote(binary)} "$@"`,
95
99
  "",
96
100
  ].join("\n");
97
101
  }
@@ -1,7 +1,7 @@
1
1
  import { dirname, resolve } from "node:path";
2
2
  import { piAgentDir } from "../agent-dir.js";
3
3
  import { detectAgentName } from "../agent-name.js";
4
- import { agentBinary, defaultStateDir, envSetting, isPlainPi } from "../config.js";
4
+ import { agentBinary, defaultStateDir, envSetting, isPlainPi, managedByExtension } from "../config.js";
5
5
  import { findPidfdPython } from "../pidfd.js";
6
6
  import { readRunningServers } from "../running-servers.js";
7
7
  import { isNewerVersion } from "../self-update.js";
@@ -23,7 +23,8 @@ export function doctor(options = {}, ctx = createContext(), latestVersion) {
23
23
  const add = (name, status, message) => {
24
24
  checks.push({ name, status, message });
25
25
  };
26
- add("node", Number(ctx.nodeVersion.split(".")[0]) >= 22 ? "PASS" : "FAIL", `Node ${ctx.nodeVersion}; requires >=22`);
26
+ const [bunMajor = 0, bunMinor = 0] = (ctx.bunVersion ?? "").split(".").map(Number);
27
+ add("bun", bunMajor > 1 || (bunMajor === 1 && bunMinor >= 4) ? "PASS" : "FAIL", ctx.bunVersion === undefined ? "not running on Bun; requires Bun >=1.4" : `Bun ${ctx.bunVersion}; requires >=1.4`);
27
28
  const backend = agentBinary(ctx.env);
28
29
  const binary = resolveBinary(backend, ctx);
29
30
  if (binary === undefined)
@@ -59,7 +60,7 @@ export function doctor(options = {}, ctx = createContext(), latestVersion) {
59
60
  if (latestVersion instanceof Error)
60
61
  add("version", "INFO", `${ADAPTER_VERSION}; latest unknown (${latestVersion.message})`);
61
62
  else if (latest !== undefined && isNewerVersion(latest, ADAPTER_VERSION))
62
- add("version", "WARN", `${ADAPTER_VERSION} installed; ${latest} is released: run antechamber upgrade`);
63
+ add("version", "WARN", `${ADAPTER_VERSION} installed; ${latest} is released: ${managedByExtension(ctx.env) ? "update the antechamber extension in OpenChamber" : "run antechamber upgrade"}`);
63
64
  else
64
65
  add("version", "PASS", ADAPTER_VERSION);
65
66
  // A released fix is live only once OpenChamber relaunches the adapter.
@@ -12,23 +12,12 @@ function writeAtomically(path, content, ctx) {
12
12
  ctx.fs.writeFileSync(temporary, content, { mode: 0o600, flag: "wx" });
13
13
  ctx.fs.renameSync(temporary, path);
14
14
  }
15
- /** The bundled OmO Graph extension folder, when this installation has its built files. */
16
- function omoGraphExtensionDir(ctx) {
17
- const dir = resolve(dirname(ctx.cliPath), "..", "extensions", "omo-dag");
18
- const built = ["package.json", "panel/main.js", "service/main.js"].every((file) => ctx.fs.existsSync(join(dir, file)));
15
+ /** The bundled antechamber OpenChamber extension folder, when this installation has its built files. */
16
+ function antechamberExtensionDir(ctx) {
17
+ const dir = resolve(dirname(ctx.cliPath), "..", "extensions", "antechamber");
18
+ const built = ["package.json", "panel/main.js", "status/main.js", "service/main.js", "runtime/launcher.js"].every((file) => ctx.fs.existsSync(join(dir, file)));
19
19
  return built ? dir : undefined;
20
20
  }
21
- /** The bundled OmO Work status-section extension folder, when this installation has its built files. */
22
- function omoWorkExtensionDir(ctx) {
23
- const dir = resolve(dirname(ctx.cliPath), "..", "extensions", "omo-work");
24
- const built = ["package.json", "status/main.js", "service/main.js"].every((file) => ctx.fs.existsSync(join(dir, file)));
25
- return built ? dir : undefined;
26
- }
27
- /** The bundled OmO Compatibility extension folder; it is a manifest only, so it needs no build. */
28
- function omoCompatExtensionDir(ctx) {
29
- const dir = resolve(dirname(ctx.cliPath), "..", "extensions", "omo-compat");
30
- return ctx.fs.existsSync(join(dir, "package.json")) ? dir : undefined;
31
- }
32
21
  /** Plan all inputs before applying changes; dependencies are injected for hermetic tests. */
33
22
  export function setup(options, ctx = createContext()) {
34
23
  const lines = [];
@@ -49,8 +38,8 @@ export function setup(options, ctx = createContext()) {
49
38
  : ctx.cliPath;
50
39
  if (binary === undefined)
51
40
  throw new Error(`Cannot resolve executable --binary ${options.binary}`);
52
- if (binary === ctx.cliPath && !ctx.fs.readFileSync(binary, "utf8").startsWith("#!/usr/bin/env node\n")) {
53
- throw new Error(`Package CLI ${binary} is missing its node shebang; run npm run build`);
41
+ if (binary === ctx.cliPath && !ctx.fs.readFileSync(binary, "utf8").startsWith("#!/usr/bin/env bun\n")) {
42
+ throw new Error(`Package CLI ${binary} is missing its bun shebang; run npm run build`);
54
43
  }
55
44
  let launcher;
56
45
  let launcherText;
@@ -63,7 +52,7 @@ export function setup(options, ctx = createContext()) {
63
52
  realpath: (path) => ctx.fs.realpathSync(path),
64
53
  }, () => ctx.exec(npmBinaryFor(), ["root", "-g"]));
65
54
  launcher = autoUpdateLauncherPath(ctx);
66
- launcherText = autoUpdateLauncherText(binary);
55
+ launcherText = autoUpdateLauncherText(binary, ctx.bunBinary);
67
56
  if (ctx.fs.existsSync(launcher))
68
57
  oldLauncher = ctx.fs.readFileSync(launcher, "utf8");
69
58
  }
@@ -181,17 +170,9 @@ export function setup(options, ctx = createContext()) {
181
170
  if (options.profile !== undefined && options.settings === undefined) {
182
171
  report(`OPENCHAMBER_DATA_DIR=${shellQuote(resolve(ctx.cwd, options.profile))} openchamber serve`);
183
172
  }
184
- const extension = omoGraphExtensionDir(ctx);
173
+ const extension = antechamberExtensionDir(ctx);
185
174
  if (extension !== undefined) {
186
- report(`Optional OmO Graph extension: in OpenChamber, Settings \u2192 Extensions \u2192 Add \u2192 Folder: ${extension}`);
187
- }
188
- const work = omoWorkExtensionDir(ctx);
189
- if (work !== undefined) {
190
- report(`Optional OmO Work section (todo phases, monitors, eval cells; OpenChamber 2.x): Settings \u2192 Extensions \u2192 Add \u2192 Folder: ${work}`);
191
- }
192
- const compat = omoCompatExtensionDir(ctx);
193
- if (compat !== undefined) {
194
- report(`Optional OmO Compatibility extension (eval card headers): Settings \u2192 Extensions \u2192 Add \u2192 Folder: ${compat}`);
175
+ report(`Optional antechamber extension (OmO Graph, OmO Work, OmO tool headers; OpenChamber 2.3.2+): Settings \u2192 Extensions \u2192 Add \u2192 Folder: ${extension}`);
195
176
  }
196
177
  return lines;
197
178
  }
package/dist/config.d.ts CHANGED
@@ -12,7 +12,11 @@ export declare function envSetting(name: string, env?: NodeJS.ProcessEnv): strin
12
12
  * mappings survive the upgrade.
13
13
  */
14
14
  export declare function defaultStateDir(home?: string, exists?: typeof existsSync): string;
15
- /** Agent executable spawned per session: OmO by default, plain pi when configured. */
16
- export declare function agentBinary(env?: NodeJS.ProcessEnv): string;
15
+ /** The OmO that antechamber's package.json pins, when it is installed beside antechamber. */
16
+ export declare function bundledOmo(): string | undefined;
17
+ /** Agent executable spawned per session: the pinned OmO by default, else `omo` on PATH, plain pi when configured. */
18
+ export declare function agentBinary(env?: NodeJS.ProcessEnv, bundled?: () => string | undefined): string;
19
+ /** Set by the OpenChamber extension's launcher: the extension, not npm, installs and updates antechamber. */
20
+ export declare function managedByExtension(env?: NodeJS.ProcessEnv): boolean;
17
21
  /** True when the configured backend is upstream pi rather than OmO. */
18
22
  export declare function isPlainPi(env?: NodeJS.ProcessEnv): boolean;
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { createRequire } from "node:module";
2
3
  import { homedir } from "node:os";
3
- import { basename, join } from "node:path";
4
+ import { basename, dirname, join } from "node:path";
4
5
  /** Environment variable prefix for all antechamber settings. */
5
6
  export const ENV_PREFIX = "ANTECHAMBER_";
6
7
  /** Prefix used before the project was renamed; still honoured as a fallback. */
@@ -30,9 +31,22 @@ export function defaultStateDir(home = homedir(), exists = existsSync) {
30
31
  return legacy;
31
32
  return current;
32
33
  }
33
- /** Agent executable spawned per session: OmO by default, plain pi when configured. */
34
- export function agentBinary(env = process.env) {
35
- return envSetting("PI_BINARY", env) ?? "omo";
34
+ /** The OmO that antechamber's package.json pins, when it is installed beside antechamber. */
35
+ export function bundledOmo() {
36
+ try {
37
+ return join(dirname(createRequire(import.meta.url).resolve("omo-ai/package.json")), "bin", "omo.js");
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ /** Agent executable spawned per session: the pinned OmO by default, else `omo` on PATH, plain pi when configured. */
44
+ export function agentBinary(env = process.env, bundled = bundledOmo) {
45
+ return envSetting("PI_BINARY", env) ?? bundled() ?? "omo";
46
+ }
47
+ /** Set by the OpenChamber extension's launcher: the extension, not npm, installs and updates antechamber. */
48
+ export function managedByExtension(env = process.env) {
49
+ return envSetting("MANAGED", env) === "openchamber-extension";
36
50
  }
37
51
  /** True when the configured backend is upstream pi rather than OmO. */
38
52
  export function isPlainPi(env = process.env) {
@@ -0,0 +1,35 @@
1
+ import type { OCEvent } from "./types.js";
2
+ export declare class FileWatchError extends Error {
3
+ readonly status: number;
4
+ readonly kind: string;
5
+ constructor(status: number, kind: string, message: string);
6
+ }
7
+ export declare class FileWatchRegistry {
8
+ private emit;
9
+ private watches;
10
+ constructor(emit: (directory: string, event: OCEvent) => void);
11
+ file(directory: string, path: string, required: boolean): Promise<string>;
12
+ watch(directory: string, file: string, reportedFile: string): void;
13
+ /**
14
+ * Watch the file itself, not its directory: on macOS a directory watch goes through FSEvents, which starts
15
+ * asynchronously and under load missed a write made right after the 204, and missed a delete-and-recreate for
16
+ * seconds (reproduced: every run failed with eight test processes in parallel). A file watch (kqueue there,
17
+ * inotify on Linux) starts within a tick. An editor's atomic save replaces the inode and ends the watch with
18
+ * "rename"; the replacement is watched. A removed file keeps its lease, as OpenCode's does: a stat watch looks for
19
+ * it until it exists again, then the native watch resumes.
20
+ */
21
+ private armFile;
22
+ /**
23
+ * Check for the missing file on a timer. Not fs.watchFile: it takes its baseline stat asynchronously and never
24
+ * reports a file that came back before that stat finished.
25
+ */
26
+ private awaitFile;
27
+ /**
28
+ * Runs inside a watch listener, where a throw would end the process: fs.watch throws synchronously for a
29
+ * replacement it cannot open (EACCES, a file gone again, ENOSPC/EMFILE). Such a lease is released instead.
30
+ */
31
+ private rearm;
32
+ private release;
33
+ unwatch(directory: string, file: string): void;
34
+ close(): void;
35
+ }
@@ -0,0 +1,163 @@
1
+ import { existsSync, watch } from "node:fs";
2
+ import { realpath, stat } from "node:fs/promises";
3
+ import { isAbsolute, relative, resolve, sep } from "node:path";
4
+ export class FileWatchError extends Error {
5
+ status;
6
+ kind;
7
+ constructor(status, kind, message) {
8
+ super(message);
9
+ this.status = status;
10
+ this.kind = kind;
11
+ }
12
+ }
13
+ const LEASE_MS = 24 * 60 * 60 * 1000;
14
+ const MAX_PER_DIRECTORY = 256;
15
+ /** How often a missing watched file is checked for (existsSync, nothing spawned). */
16
+ const MISSING_POLL_MS = 1000;
17
+ export class FileWatchRegistry {
18
+ emit;
19
+ watches = new Map();
20
+ constructor(emit) {
21
+ this.emit = emit;
22
+ }
23
+ async file(directory, path, required) {
24
+ const root = await realpath(directory);
25
+ const target = resolve(root, path);
26
+ const inside = relative(root, target);
27
+ if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside))
28
+ throw new FileWatchError(400, "BadRequestError", "Path must remain inside the selected directory");
29
+ // Removal uses the registered path even when the file or symlink no longer exists.
30
+ if (!required)
31
+ return resolve(directory, path);
32
+ try {
33
+ const canonical = await realpath(target);
34
+ const rel = relative(root, canonical);
35
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
36
+ throw new FileWatchError(400, "BadRequestError", "Path must remain inside the selected directory");
37
+ if (!(await stat(canonical)).isFile())
38
+ throw new FileWatchError(404, "FileNotFoundError", `File not found: ${path}`);
39
+ return canonical;
40
+ }
41
+ catch (error) {
42
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
43
+ throw new FileWatchError(404, "FileNotFoundError", `File not found: ${path}`);
44
+ throw error;
45
+ }
46
+ }
47
+ watch(directory, file, reportedFile) {
48
+ const files = this.watches.get(directory) ?? new Map();
49
+ const existing = files.get(reportedFile);
50
+ if (existing !== undefined) {
51
+ existing.expires = Date.now() + LEASE_MS;
52
+ clearTimeout(existing.timer);
53
+ existing.timer = setTimeout(() => this.unwatch(directory, reportedFile), LEASE_MS);
54
+ existing.timer.unref();
55
+ return;
56
+ }
57
+ if (files.size >= MAX_PER_DIRECTORY) {
58
+ const [soonest] = [...files].reduce((a, b) => (b[1].expires < a[1].expires ? b : a));
59
+ this.unwatch(directory, soonest);
60
+ }
61
+ const timer = setTimeout(() => this.unwatch(directory, reportedFile), LEASE_MS);
62
+ timer.unref();
63
+ const lease = { watcher: null, missing: null, expires: Date.now() + LEASE_MS, timer };
64
+ files.set(reportedFile, lease);
65
+ this.watches.set(directory, files);
66
+ lease.watcher = this.armFile(directory, file, reportedFile);
67
+ }
68
+ /**
69
+ * Watch the file itself, not its directory: on macOS a directory watch goes through FSEvents, which starts
70
+ * asynchronously and under load missed a write made right after the 204, and missed a delete-and-recreate for
71
+ * seconds (reproduced: every run failed with eight test processes in parallel). A file watch (kqueue there,
72
+ * inotify on Linux) starts within a tick. An editor's atomic save replaces the inode and ends the watch with
73
+ * "rename"; the replacement is watched. A removed file keeps its lease, as OpenCode's does: a stat watch looks for
74
+ * it until it exists again, then the native watch resumes.
75
+ */
76
+ armFile(directory, file, reportedFile) {
77
+ const watcher = watch(file, (event) => {
78
+ const exists = existsSync(file);
79
+ this.emit(directory, {
80
+ type: "filesystem.changed",
81
+ properties: { file: reportedFile, event: exists ? "change" : "unlink" },
82
+ });
83
+ const lease = this.watches.get(directory)?.get(reportedFile);
84
+ // A rename replaced the inode; an absent file ended the watch, whatever the event (macOS reports a write and
85
+ // a delete arriving together as one "change").
86
+ if (lease?.watcher !== watcher || (exists && event !== "rename"))
87
+ return;
88
+ watcher.close();
89
+ lease.watcher = null;
90
+ if (exists)
91
+ this.rearm(directory, file, reportedFile, lease);
92
+ else
93
+ this.awaitFile(directory, file, reportedFile, lease);
94
+ });
95
+ watcher.on("error", () => {
96
+ if (this.watches.get(directory)?.get(reportedFile)?.watcher === watcher)
97
+ this.unwatch(directory, reportedFile);
98
+ else
99
+ watcher.close();
100
+ });
101
+ return watcher;
102
+ }
103
+ /**
104
+ * Check for the missing file on a timer. Not fs.watchFile: it takes its baseline stat asynchronously and never
105
+ * reports a file that came back before that stat finished.
106
+ */
107
+ awaitFile(directory, file, reportedFile, lease) {
108
+ const check = () => {
109
+ if (!existsSync(file) || this.watches.get(directory)?.get(reportedFile) !== lease)
110
+ return;
111
+ if (lease.missing !== null)
112
+ clearInterval(lease.missing);
113
+ lease.missing = null;
114
+ this.emit(directory, { type: "filesystem.changed", properties: { file: reportedFile, event: "change" } });
115
+ this.rearm(directory, file, reportedFile, lease);
116
+ };
117
+ lease.missing = setInterval(check, MISSING_POLL_MS);
118
+ lease.missing.unref();
119
+ check();
120
+ }
121
+ /**
122
+ * Runs inside a watch listener, where a throw would end the process: fs.watch throws synchronously for a
123
+ * replacement it cannot open (EACCES, a file gone again, ENOSPC/EMFILE). Such a lease is released instead.
124
+ */
125
+ rearm(directory, file, reportedFile, lease) {
126
+ try {
127
+ lease.watcher = this.armFile(directory, file, reportedFile);
128
+ }
129
+ catch (error) {
130
+ // Gone again between the check and the watch: keep the lease and wait for it once more.
131
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
132
+ this.awaitFile(directory, file, reportedFile, lease);
133
+ return;
134
+ }
135
+ console.error(`[file-watch] stopped watching ${reportedFile}:`, error);
136
+ this.unwatch(directory, reportedFile);
137
+ }
138
+ }
139
+ release(lease) {
140
+ clearTimeout(lease.timer);
141
+ lease.watcher?.close();
142
+ if (lease.missing !== null)
143
+ clearInterval(lease.missing);
144
+ }
145
+ unwatch(directory, file) {
146
+ const files = this.watches.get(directory);
147
+ const entry = files?.get(file);
148
+ if (entry === undefined)
149
+ return;
150
+ this.release(entry);
151
+ files?.delete(file);
152
+ if (files?.size === 0)
153
+ this.watches.delete(directory);
154
+ }
155
+ close() {
156
+ for (const files of this.watches.values()) {
157
+ for (const entry of files.values()) {
158
+ this.release(entry);
159
+ }
160
+ }
161
+ this.watches.clear();
162
+ }
163
+ }
@@ -0,0 +1,10 @@
1
+ /** Operator diagnostics. Route labels contain only literals and parameter names. */
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ export declare class LatencyMonitor {
4
+ private readonly lagThreshold;
5
+ private readonly requestThreshold;
6
+ private timer;
7
+ start(): void;
8
+ stop(): void;
9
+ request(req: IncomingMessage, res: ServerResponse, handle: () => Promise<void>): Promise<void>;
10
+ }