operon-agents 0.1.0-alpha.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 (71) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +18 -0
  3. package/dist/app-server/client.d.ts +80 -0
  4. package/dist/app-server/client.js +187 -0
  5. package/dist/app-server/codec.d.ts +52 -0
  6. package/dist/app-server/codec.js +252 -0
  7. package/dist/app-server/index.d.ts +22 -0
  8. package/dist/app-server/index.js +22 -0
  9. package/dist/app-server/main.d.ts +3 -0
  10. package/dist/app-server/main.js +85 -0
  11. package/dist/app-server/protocol.d.ts +217 -0
  12. package/dist/app-server/protocol.js +100 -0
  13. package/dist/app-server/server.d.ts +51 -0
  14. package/dist/app-server/server.js +274 -0
  15. package/dist/core.d.ts +12 -0
  16. package/dist/core.js +12 -0
  17. package/dist/cron/clock.d.ts +11 -0
  18. package/dist/cron/clock.js +19 -0
  19. package/dist/cron/cron-expr.d.ts +15 -0
  20. package/dist/cron/cron-expr.js +267 -0
  21. package/dist/cron/extension.d.ts +25 -0
  22. package/dist/cron/extension.js +141 -0
  23. package/dist/cron/index.d.ts +20 -0
  24. package/dist/cron/index.js +11 -0
  25. package/dist/cron/jitter.d.ts +18 -0
  26. package/dist/cron/jitter.js +46 -0
  27. package/dist/cron/manager.d.ts +55 -0
  28. package/dist/cron/manager.js +123 -0
  29. package/dist/cron/persist.d.ts +10 -0
  30. package/dist/cron/persist.js +22 -0
  31. package/dist/cron/scheduler.d.ts +25 -0
  32. package/dist/cron/scheduler.js +192 -0
  33. package/dist/cron/session-store.d.ts +14 -0
  34. package/dist/cron/session-store.js +50 -0
  35. package/dist/cron/time-format.d.ts +2 -0
  36. package/dist/cron/time-format.js +12 -0
  37. package/dist/cron/tools.d.ts +7 -0
  38. package/dist/cron/tools.js +161 -0
  39. package/dist/cron/types.d.ts +9 -0
  40. package/dist/cron/types.js +2 -0
  41. package/dist/extensions/index.d.ts +23 -0
  42. package/dist/extensions/index.js +41 -0
  43. package/dist/extensions/loader.d.ts +121 -0
  44. package/dist/extensions/loader.js +285 -0
  45. package/dist/extensions/manager.d.ts +103 -0
  46. package/dist/extensions/manager.js +190 -0
  47. package/dist/extensions/runtime.d.ts +188 -0
  48. package/dist/extensions/runtime.js +1127 -0
  49. package/dist/extensions/services.d.ts +85 -0
  50. package/dist/extensions/services.js +265 -0
  51. package/dist/extensions/types.d.ts +594 -0
  52. package/dist/extensions/types.js +2 -0
  53. package/dist/harness.d.ts +746 -0
  54. package/dist/harness.js +1483 -0
  55. package/dist/index.d.ts +24 -0
  56. package/dist/index.js +29 -0
  57. package/dist/internal.d.ts +9 -0
  58. package/dist/internal.js +9 -0
  59. package/dist/llm.d.ts +2 -0
  60. package/dist/llm.js +2 -0
  61. package/dist/local.d.ts +24 -0
  62. package/dist/local.js +58 -0
  63. package/dist/mcp.d.ts +2 -0
  64. package/dist/mcp.js +2 -0
  65. package/dist/protocol.d.ts +2 -0
  66. package/dist/protocol.js +2 -0
  67. package/dist/tools.d.ts +2 -0
  68. package/dist/tools.js +2 -0
  69. package/dist/tracing.d.ts +2 -0
  70. package/dist/tracing.js +2 -0
  71. package/package.json +78 -0
@@ -0,0 +1,285 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { readFile, readdir, stat, writeFile } from "node:fs/promises";
10
+ import { join, resolve } from "node:path";
11
+ import { createRequire } from "node:module";
12
+ import { pathToFileURL } from "node:url";
13
+ /**
14
+ * File-extension loader — the shell that turns a extension directory into extension VALUES for
15
+ * `attachExtension`. The framework core stays file-blind: everything here ends in a plain
16
+ * `ExtensionDefinition`, handed over the same channel host-authored extensions use.
17
+ *
18
+ * Layout: one folder per extension under `directory`, holding a `manifest.json` plus a bundled
19
+ * entry file (dependencies baked in by the author at publish time — no install step here).
20
+ *
21
+ * Trust model — loading is consent, and consent is MANUAL:
22
+ * - `load()` / `reload()` are explicit host actions; performing one approves the extension at
23
+ * its current file mtime, recorded in `.approvals.json` beside the extensions.
24
+ * - `loadApproved()` (startup) only honors extensions whose entry is byte-for-byte the one
25
+ * approved (mtime match). A changed or new extension is reported, never silently loaded —
26
+ * an agent with write access must not be able to plant code that runs on next open.
27
+ *
28
+ * Reload mechanics: native `import()` with a `?v=<mtime>` suffix — a changed file gets a
29
+ * fresh module, an unchanged one hits the cache. Old module instances stay resident until
30
+ * process exit; that is the accepted cost of not restarting.
31
+ */
32
+ /** This framework's version — what a manifest's `engine` is checked against. */
33
+ export const FRAMEWORK_VERSION = createRequire(import.meta.url)("../../package.json").version;
34
+ const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
35
+ /** Semver ordering for plain `x.y.z[-pre]` strings (a prerelease sorts below its release). */
36
+ export function compareVersions(a, b) {
37
+ const pa = VERSION_RE.exec(a), pb = VERSION_RE.exec(b);
38
+ if (pa === null || pb === null)
39
+ throw new Error(`not a version: ${JSON.stringify(pa === null ? a : b)}`);
40
+ for (let i = 1; i <= 3; i++) {
41
+ const d = Number(pa[i]) - Number(pb[i]);
42
+ if (d !== 0)
43
+ return d;
44
+ }
45
+ const preA = pa[4], preB = pb[4];
46
+ if (preA === undefined && preB === undefined)
47
+ return 0;
48
+ if (preA === undefined)
49
+ return 1;
50
+ if (preB === undefined)
51
+ return -1;
52
+ const xs = preA.split("."), ys = preB.split(".");
53
+ for (let i = 0; i < Math.max(xs.length, ys.length); i++) {
54
+ const x = xs[i], y = ys[i];
55
+ if (x === undefined)
56
+ return -1;
57
+ if (y === undefined)
58
+ return 1;
59
+ const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
60
+ const d = nx && ny ? Number(x) - Number(y) : nx ? -1 : ny ? 1 : x.localeCompare(y);
61
+ if (d !== 0)
62
+ return d;
63
+ }
64
+ return 0;
65
+ }
66
+ const APPROVALS_FILE = ".approvals.json";
67
+ export class ExtensionLoader {
68
+ directory;
69
+ approvalsPath;
70
+ /** Live definitions by extension id — the values a host hands to `attachExtension`. */
71
+ loaded = new Map();
72
+ constructor(options) {
73
+ this.directory = resolve(options.directory);
74
+ this.approvalsPath = join(this.directory, APPROVALS_FILE);
75
+ }
76
+ /** The parsed manifest of one extension, by id. */
77
+ async manifest(id) {
78
+ return (await this.find(id)).manifest;
79
+ }
80
+ /** The folder a extension lives in — where an extension keeps its data files. */
81
+ async manifestDir(id) {
82
+ return (await this.find(id)).dir;
83
+ }
84
+ /** Every extension folder under the directory, with its trust/load state. */
85
+ async list() {
86
+ const approvals = await this.readApprovals();
87
+ const out = [];
88
+ for (const entry of await this.readExtensionDirs()) {
89
+ try {
90
+ const scanned = await this.scan(entry);
91
+ out.push({
92
+ id: scanned.manifest.id,
93
+ dir: scanned.dir,
94
+ ...(scanned.manifest.version !== undefined ? { version: scanned.manifest.version } : {}),
95
+ ...(scanned.manifest.engine !== undefined ? { engine: scanned.manifest.engine } : {}),
96
+ ...(scanned.manifest.name !== undefined ? { name: scanned.manifest.name } : {}),
97
+ ...(scanned.manifest.description !== undefined ? { description: scanned.manifest.description } : {}),
98
+ state: this.stateOf(scanned, approvals),
99
+ });
100
+ }
101
+ catch (error) {
102
+ out.push({ id: entry, dir: join(this.directory, entry), state: "error", error: messageOf(error) });
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+ /**
108
+ * Import one extension and approve it as it stands. The call itself is the consent — reserve
109
+ * it for user-initiated actions, never for something an agent triggers on its own.
110
+ */
111
+ async load(id) {
112
+ const scanned = await this.find(id);
113
+ const definition = await this.importDefinition(scanned);
114
+ await this.approve(scanned);
115
+ this.loaded.set(id, definition);
116
+ return definition;
117
+ }
118
+ /** Re-import after an edit and roll the approval forward to the file as it is now. */
119
+ async reload(id) {
120
+ return this.load(id);
121
+ }
122
+ /** Forget a extension: drop the held definition and revoke its approval. */
123
+ async unload(id) {
124
+ this.loaded.delete(id);
125
+ const approvals = await this.readApprovals();
126
+ if (id in approvals) {
127
+ const { [id]: _dropped, ...rest } = approvals;
128
+ await this.writeApprovals(rest);
129
+ }
130
+ }
131
+ /**
132
+ * Startup path: import every extension still byte-identical to what was approved. Changed and
133
+ * new extensions are skipped and reported so the host can surface them for a manual decision.
134
+ */
135
+ async loadApproved() {
136
+ const loaded = [];
137
+ const skipped = [];
138
+ for (const status of await this.list()) {
139
+ if (status.state === "approved" || status.state === "loaded") {
140
+ try {
141
+ const scanned = await this.find(status.id);
142
+ const definition = await this.importDefinition(scanned);
143
+ this.loaded.set(status.id, definition);
144
+ loaded.push(definition);
145
+ continue;
146
+ }
147
+ catch (error) {
148
+ skipped.push({ ...status, state: "error", error: messageOf(error) });
149
+ continue;
150
+ }
151
+ }
152
+ skipped.push(status);
153
+ }
154
+ return { loaded, skipped };
155
+ }
156
+ /** The definitions currently held. On the standalone-loader path these reach sessions through
157
+ * `attachExtension`/`reloadInto`; a harness built with `extensionDir` registers them itself
158
+ * (`harness.extensions.load`), which is the path that also runs `create` halves. */
159
+ definitions() {
160
+ return [...this.loaded.values()];
161
+ }
162
+ /** Convenience: reload a extension and swap it on a live session (detach old, attach new). */
163
+ async reloadInto(session, id) {
164
+ const definition = await this.reload(id);
165
+ await session.detachExtension(id).catch(() => undefined);
166
+ await session.attachExtension(definition);
167
+ return definition;
168
+ }
169
+ // ==========================================================================
170
+ // Internals
171
+ // ==========================================================================
172
+ async readExtensionDirs() {
173
+ const entries = await readdir(this.directory, { withFileTypes: true }).catch(() => []);
174
+ // Dot-folders are the loader's own (`.data` holds extensions' data dirs), never extensions.
175
+ return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name).sort();
176
+ }
177
+ async scan(dirName) {
178
+ const dir = join(this.directory, dirName);
179
+ const manifestPath = join(dir, "manifest.json");
180
+ let manifest;
181
+ try {
182
+ manifest = JSON.parse(await readFile(manifestPath, "utf8"));
183
+ }
184
+ catch (error) {
185
+ throw new Error(`unreadable manifest.json: ${messageOf(error)}`);
186
+ }
187
+ if (typeof manifest.id !== "string" || !manifest.id.trim()) {
188
+ throw new Error("manifest.json must declare a non-empty string \"id\"");
189
+ }
190
+ if (manifest.engine !== undefined) {
191
+ if (typeof manifest.engine !== "string" || !VERSION_RE.test(manifest.engine)) {
192
+ throw new Error(`manifest "engine" must be a version like "1.2.0", got ${JSON.stringify(manifest.engine)}`);
193
+ }
194
+ if (compareVersions(FRAMEWORK_VERSION, manifest.engine) < 0) {
195
+ throw new Error(`requires operon-agents >= ${manifest.engine}; this host runs ${FRAMEWORK_VERSION}`);
196
+ }
197
+ }
198
+ const entryPath = join(dir, manifest.entry ?? "index.js");
199
+ const stats = await stat(entryPath).catch(() => {
200
+ throw new Error(`entry file missing: ${entryPath}`);
201
+ });
202
+ return { manifest, dir, entryPath, mtimeMs: stats.mtimeMs };
203
+ }
204
+ async find(id) {
205
+ let failure;
206
+ for (const dirName of await this.readExtensionDirs()) {
207
+ try {
208
+ const scanned = await this.scan(dirName);
209
+ if (scanned.manifest.id === id)
210
+ return scanned;
211
+ }
212
+ catch (error) {
213
+ // A broken sibling folder must not block loading the one asked for — but the folder
214
+ // named like the id IS the one asked for, and its reason (bad manifest, missing entry,
215
+ // an `engine` this host is too old for) is the answer, not "not found".
216
+ if (dirName === id)
217
+ failure = error;
218
+ }
219
+ }
220
+ if (failure !== undefined)
221
+ throw new Error(`extension "${id}": ${messageOf(failure)}`);
222
+ throw new Error(`no extension with id "${id}" under ${this.directory}`);
223
+ }
224
+ stateOf(scanned, approvals) {
225
+ const approval = approvals[scanned.manifest.id];
226
+ if (!approval)
227
+ return "new";
228
+ if (approval.mtimeMs !== scanned.mtimeMs || approval.entry !== scanned.entryPath)
229
+ return "changed";
230
+ return this.loaded.has(scanned.manifest.id) ? "loaded" : "approved";
231
+ }
232
+ async importDefinition(scanned) {
233
+ // The mtime suffix makes an edited file a NEW module while an unchanged one stays cached.
234
+ const url = `${pathToFileURL(scanned.entryPath).href}?v=${scanned.mtimeMs}`;
235
+ let moduleExports;
236
+ try {
237
+ moduleExports = (await import(__rewriteRelativeImportExtension(url)));
238
+ }
239
+ catch (error) {
240
+ throw new Error(`extension "${scanned.manifest.id}" failed to import: ${messageOf(error)}`);
241
+ }
242
+ let candidate = moduleExports.default;
243
+ // A factory default export lets a extension construct per-load state; called with nothing —
244
+ // everything a extension may use arrives later through its setup(ctx).
245
+ if (typeof candidate === "function")
246
+ candidate = candidate();
247
+ if (!isExtensionDefinition(candidate)) {
248
+ throw new Error(`extension "${scanned.manifest.id}" default export is not an extension definition ({ id, setup })`);
249
+ }
250
+ if (candidate.id !== scanned.manifest.id) {
251
+ throw new Error(`extension id mismatch: manifest says "${scanned.manifest.id}", code says "${candidate.id}"`);
252
+ }
253
+ return candidate;
254
+ }
255
+ async approve(scanned) {
256
+ const approvals = await this.readApprovals();
257
+ await this.writeApprovals({
258
+ ...approvals,
259
+ [scanned.manifest.id]: { entry: scanned.entryPath, mtimeMs: scanned.mtimeMs, approvedAt: Date.now() },
260
+ });
261
+ }
262
+ async readApprovals() {
263
+ try {
264
+ return JSON.parse(await readFile(this.approvalsPath, "utf8"));
265
+ }
266
+ catch {
267
+ return {};
268
+ }
269
+ }
270
+ async writeApprovals(approvals) {
271
+ await writeFile(this.approvalsPath, `${JSON.stringify(approvals, null, 2)}\n`, "utf8");
272
+ }
273
+ }
274
+ export function createExtensionLoader(options) {
275
+ return new ExtensionLoader(options);
276
+ }
277
+ function isExtensionDefinition(value) {
278
+ return (typeof value === "object" && value !== null
279
+ && typeof value.id === "string"
280
+ && typeof value.setup === "function");
281
+ }
282
+ function messageOf(error) {
283
+ return error instanceof Error ? error.message : String(error);
284
+ }
285
+ //# sourceMappingURL=loader.js.map
@@ -0,0 +1,103 @@
1
+ import type { ExtensionDefinition, ExtensionHostContext } from "./types.ts";
2
+ import { type ExtensionFileStatus } from "./loader.ts";
3
+ import { ServiceRegistry, type ServiceOptions } from "./services.ts";
4
+ /** The session surface the barrier hands to a swap function. */
5
+ export interface HeldSession {
6
+ readonly id: string;
7
+ attachExtension(definition: ExtensionDefinition): Promise<void>;
8
+ detachExtension(extensionId: string): Promise<void>;
9
+ attachedExtensionIds(): readonly string[];
10
+ attachedExtensions(): readonly {
11
+ readonly id: string;
12
+ readonly uses: readonly string[];
13
+ }[];
14
+ }
15
+ /** The narrow harness face the manager operates through (no import cycle with harness.ts). */
16
+ export interface ExtensionHostBridge {
17
+ readonly services: ServiceRegistry;
18
+ createSession(options?: Record<string, unknown>): Promise<{
19
+ readonly id: string;
20
+ }>;
21
+ sessions(): readonly HeldSession[];
22
+ /** Rendezvous every session holding one of `extensionIds` at its run boundary, run `fn` in
23
+ * the quiet moment (held() reads live — sessions born gated during the barrier appear). */
24
+ withBarrier(extensionIds: readonly string[], timeoutMs: number, fn: (held: () => readonly HeldSession[]) => Promise<void>): Promise<void>;
25
+ warn(message: string): void;
26
+ }
27
+ /** What staging one `create`-bearing definition produced, before anything is published. */
28
+ export interface StagedDefinition {
29
+ /** The one service `create` returned, to register under the extension's id; absent when the
30
+ * definition has no `create`. */
31
+ readonly service?: {
32
+ readonly name: string;
33
+ readonly instance: unknown;
34
+ readonly options: ServiceOptions;
35
+ };
36
+ /** The definition itself — its `setup` is the per-session half, mounted into every session. */
37
+ readonly sessionDef: ExtensionDefinition;
38
+ }
39
+ export interface StagingOptions {
40
+ /** What the registered service's `replaceable` defaults to. File extensions default to true
41
+ * (reload is their point); by-value ones to false (a change is a restart). */
42
+ readonly defaultReplaceable: boolean;
43
+ readonly dir?: string;
44
+ /** The extension's data folder, already created. */
45
+ readonly dataDir?: string;
46
+ createSession: ExtensionHostContext["createSession"];
47
+ warn(message: string): void;
48
+ }
49
+ /**
50
+ * Run a `create`-bearing definition's `create` half against a staging host and collect the one
51
+ * service it returns — nothing is registered here, so a throwing `create` publishes nothing.
52
+ * Shared by the file manager and the harness's by-value channel. Stays synchronous when `create`
53
+ * is, which the by-value channel relies on to have a synchronous extension's service visible the
54
+ * moment `createHarness` returns.
55
+ */
56
+ export declare function stageDefinition(definition: ExtensionDefinition, options: StagingOptions): StagedDefinition | Promise<StagedDefinition>;
57
+ export declare class HarnessExtensionManager {
58
+ private readonly loader;
59
+ private readonly bridge;
60
+ /** Root of the extensions' data folders (`<root>/<id>`) — outside every code folder. */
61
+ private readonly dataRoot;
62
+ /** Every loaded extension by id, and whether its `create` published a service. */
63
+ private readonly loaded;
64
+ /** Definitions newborn sessions receive: every loaded extension's per-session half. */
65
+ private readonly sessionDefs;
66
+ constructor(options: {
67
+ readonly directory: string;
68
+ readonly dataDir: string;
69
+ readonly bridge: ExtensionHostBridge;
70
+ });
71
+ list(): Promise<ExtensionFileStatus[]>;
72
+ /** What every new session is born with. Wire into the harness's extension evaluation. */
73
+ sessionDefinitions(): ExtensionDefinition[];
74
+ /**
75
+ * Load (and approve) one extension — a USER action, like `ExtensionLoader.load`. Runs the
76
+ * definition's `create` (if any) and publishes the result as its service, then hands the
77
+ * definition to future sessions. Loading an already-loaded extension reloads it: every
78
+ * session holding it meets at its run boundary, the per-session half is swapped (and the
79
+ * service replaced), and everyone resumes — the same act whether or not there is a `create`.
80
+ */
81
+ load(id: string, options?: {
82
+ readonly timeoutMs?: number;
83
+ }): Promise<void>;
84
+ /** Alias of `load` — the manager's load already swaps a live extension coordinated. */
85
+ reload(id: string, options?: {
86
+ readonly timeoutMs?: number;
87
+ }): Promise<void>;
88
+ /**
89
+ * Unload an extension. A `create`-bearing one is refused while any session still runs an
90
+ * extension that `uses` its service — named, so the caller knows what to detach. Its own
91
+ * per-session half does not block (it is removed as part of the unload).
92
+ */
93
+ unload(id: string): Promise<void>;
94
+ private register;
95
+ /** Run `create` with a staging host: the service is COLLECTED, not registered, so a throwing
96
+ * `create` publishes nothing. Services default to replaceable — reload is their point. */
97
+ private stage;
98
+ /** Sessions still running an extension that `uses` `id`'s service — read off the attached
99
+ * definitions themselves, so by-value and file consumers count alike. No graph walk: the
100
+ * graph is depth 1. */
101
+ private consumersOf;
102
+ }
103
+ //# sourceMappingURL=manager.d.ts.map
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The file-extension manager (design: docs/architecture.md §5.4-5.5). An extension
3
+ * is a FILE whose default export is an `ExtensionDefinition`; the manager runs its `create` half
4
+ * once if it has one, registers the result as a service under the extension's `id`, and hands
5
+ * the SAME definition to every session (its `setup` is the per-session half). One extension,
6
+ * one approval, one reload — the manager turns a reload into the coordinated act: barrier →
7
+ * swap the session half → replace the service → release.
8
+ *
9
+ * Trust: the code runs at HOST trust and holds `createSession`. The control point is the same
10
+ * single one as for ordinary extensions — manual load/reload IS the approval (mtime-scoped).
11
+ * Nothing here auto-loads or watches.
12
+ *
13
+ * `stageDefinition` — running `create` against a staging host and collecting the one service, so a
14
+ * throwing `create` publishes nothing — is shared with the harness's by-value channel
15
+ * (`createHarness({ extensions })` runs a `create`-bearing definition through it with no loader).
16
+ */
17
+ import { mkdir } from "node:fs/promises";
18
+ import { join, resolve } from "node:path";
19
+ import { ExtensionLoader } from "./loader.js";
20
+ import { ServiceRegistry } from "./services.js";
21
+ function isPromiseLike(value) {
22
+ return typeof value === "object" && value !== null && typeof value.then === "function";
23
+ }
24
+ /**
25
+ * Run a `create`-bearing definition's `create` half against a staging host and collect the one
26
+ * service it returns — nothing is registered here, so a throwing `create` publishes nothing.
27
+ * Shared by the file manager and the harness's by-value channel. Stays synchronous when `create`
28
+ * is, which the by-value channel relies on to have a synchronous extension's service visible the
29
+ * moment `createHarness` returns.
30
+ */
31
+ export function stageDefinition(definition, options) {
32
+ if (definition.create === undefined)
33
+ return { sessionDef: definition };
34
+ const host = {
35
+ createSession: (sessionOptions) => options.createSession(sessionOptions),
36
+ ...(options.dir !== undefined ? { dir: options.dir } : {}),
37
+ ...(options.dataDir !== undefined ? { dataDir: options.dataDir } : {}),
38
+ warn: (message) => options.warn(`[extension ${definition.id}] ${message}`),
39
+ };
40
+ const finish = (instance) => ({
41
+ service: { name: definition.id, instance, options: { replaceable: options.defaultReplaceable } },
42
+ sessionDef: definition,
43
+ });
44
+ const result = definition.create(host);
45
+ return isPromiseLike(result) ? Promise.resolve(result).then(finish) : finish(result);
46
+ }
47
+ const DEFAULT_BARRIER_TIMEOUT_MS = 30_000;
48
+ export class HarnessExtensionManager {
49
+ loader;
50
+ bridge;
51
+ /** Root of the extensions' data folders (`<root>/<id>`) — outside every code folder. */
52
+ dataRoot;
53
+ /** Every loaded extension by id, and whether its `create` published a service. */
54
+ loaded = new Map();
55
+ /** Definitions newborn sessions receive: every loaded extension's per-session half. */
56
+ sessionDefs = new Map();
57
+ constructor(options) {
58
+ this.loader = new ExtensionLoader({ directory: options.directory });
59
+ this.dataRoot = resolve(options.dataDir);
60
+ this.bridge = options.bridge;
61
+ }
62
+ list() {
63
+ return this.loader.list();
64
+ }
65
+ /** What every new session is born with. Wire into the harness's extension evaluation. */
66
+ sessionDefinitions() {
67
+ return [...this.sessionDefs.values()];
68
+ }
69
+ /**
70
+ * Load (and approve) one extension — a USER action, like `ExtensionLoader.load`. Runs the
71
+ * definition's `create` (if any) and publishes the result as its service, then hands the
72
+ * definition to future sessions. Loading an already-loaded extension reloads it: every
73
+ * session holding it meets at its run boundary, the per-session half is swapped (and the
74
+ * service replaced), and everyone resumes — the same act whether or not there is a `create`.
75
+ */
76
+ async load(id, options = {}) {
77
+ const definition = await this.loader.load(id);
78
+ // `uses` lives on the definition, so it is readable only after the import; a missing
79
+ // provider revokes the approval the import just recorded, so a failed load leaves nothing
80
+ // behind. Same rule for both kinds: load providers first.
81
+ for (const name of definition.uses ?? []) {
82
+ if (!this.bridge.services.has(name)) {
83
+ await this.loader.unload(id);
84
+ throw new Error(`extension "${id}" uses service "${name}", which is not registered — load the extension that provides it first`);
85
+ }
86
+ }
87
+ await this.register(id, definition, options);
88
+ }
89
+ /** Alias of `load` — the manager's load already swaps a live extension coordinated. */
90
+ reload(id, options = {}) {
91
+ return this.load(id, options);
92
+ }
93
+ /**
94
+ * Unload an extension. A `create`-bearing one is refused while any session still runs an
95
+ * extension that `uses` its service — named, so the caller knows what to detach. Its own
96
+ * per-session half does not block (it is removed as part of the unload).
97
+ */
98
+ async unload(id) {
99
+ const record = this.loaded.get(id);
100
+ if (record === undefined) {
101
+ // Never loaded: just revoke whatever approval is on file.
102
+ await this.loader.unload(id);
103
+ return;
104
+ }
105
+ const consumers = this.consumersOf(id);
106
+ if (consumers.length > 0) {
107
+ throw new Error(`cannot unload extension "${id}": service still consumed by ${consumers.join(", ")} — detach those first`);
108
+ }
109
+ // Own per-session half: detach everywhere (each session applies it at its own boundary — a
110
+ // pure removal needs no barrier; there is no two-version coexistence to prevent).
111
+ for (const session of this.bridge.sessions()) {
112
+ if (session.attachedExtensionIds().includes(id))
113
+ await session.detachExtension(id);
114
+ }
115
+ this.sessionDefs.delete(id);
116
+ if (record.hasService)
117
+ await this.bridge.services.unregister(id);
118
+ this.loaded.delete(id);
119
+ await this.loader.unload(id);
120
+ }
121
+ // ==========================================================================
122
+ // Internals
123
+ // ==========================================================================
124
+ async register(id, definition, options) {
125
+ const staged = await this.stage(definition);
126
+ const previous = this.loaded.get(id);
127
+ if (previous === undefined) {
128
+ // First load: register the service, then hand the session half to future sessions.
129
+ // Already-open sessions are not touched (a reload only re-attaches to sessions that hold
130
+ // the half) — attach it explicitly where an open session should get it.
131
+ if (staged.service !== undefined) {
132
+ this.bridge.services.register(staged.service.name, staged.service.instance, staged.service.options);
133
+ }
134
+ this.sessionDefs.set(id, staged.sessionDef);
135
+ this.loaded.set(id, { hasService: staged.service !== undefined });
136
+ return;
137
+ }
138
+ // Reload: one coordinated act — barrier on the session half, swap half + service in the
139
+ // quiet moment. Version skew between the two halves is structurally impossible.
140
+ await this.bridge.withBarrier([id], options.timeoutMs ?? DEFAULT_BARRIER_TIMEOUT_MS, async (held) => {
141
+ for (const session of held()) {
142
+ if (session.attachedExtensionIds().includes(id))
143
+ await session.detachExtension(id);
144
+ }
145
+ if (staged.service !== undefined) {
146
+ if (this.bridge.services.has(id))
147
+ await this.bridge.services.replace(id, staged.service.instance);
148
+ else
149
+ this.bridge.services.register(staged.service.name, staged.service.instance, staged.service.options);
150
+ }
151
+ else if (previous.hasService) {
152
+ await this.bridge.services.unregister(id);
153
+ }
154
+ this.sessionDefs.set(id, staged.sessionDef);
155
+ for (const session of held()) {
156
+ await session.attachExtension(staged.sessionDef);
157
+ }
158
+ });
159
+ this.loaded.set(id, { hasService: staged.service !== undefined });
160
+ }
161
+ /** Run `create` with a staging host: the service is COLLECTED, not registered, so a throwing
162
+ * `create` publishes nothing. Services default to replaceable — reload is their point. */
163
+ async stage(definition) {
164
+ // The data folder exists before `create` runs, so the extension can use it right away.
165
+ const dataDir = join(this.dataRoot, definition.id);
166
+ if (definition.create !== undefined)
167
+ await mkdir(dataDir, { recursive: true });
168
+ return stageDefinition(definition, {
169
+ defaultReplaceable: true,
170
+ dir: await this.loader.manifestDir(definition.id),
171
+ dataDir,
172
+ createSession: (sessionOptions) => this.bridge.createSession(sessionOptions),
173
+ warn: (message) => this.bridge.warn(message),
174
+ });
175
+ }
176
+ /** Sessions still running an extension that `uses` `id`'s service — read off the attached
177
+ * definitions themselves, so by-value and file consumers count alike. No graph walk: the
178
+ * graph is depth 1. */
179
+ consumersOf(id) {
180
+ const found = [];
181
+ for (const session of this.bridge.sessions()) {
182
+ for (const attached of session.attachedExtensions()) {
183
+ if (attached.uses.includes(id))
184
+ found.push(`"${attached.id}" (session ${session.id})`);
185
+ }
186
+ }
187
+ return found;
188
+ }
189
+ }
190
+ //# sourceMappingURL=manager.js.map