operon-agents 0.1.0-alpha.2 → 0.1.0-alpha.21

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.
@@ -1,5 +1,6 @@
1
- import type { Capability } from "operon-agents-core";
2
- import type { ExtensionDefinition, ExtensionHost } from "./types.ts";
1
+ import { type Capability } from "operon-agents-core";
2
+ import type { ExtensionDefinition, ExtensionHost, SessionStartReason } from "./types.ts";
3
+ import { ExtensionRuntime } from "./runtime.ts";
3
4
  export { ExtensionRuntime } from "./runtime.ts";
4
5
  export { ExtensionLoader, createExtensionLoader } from "./loader.ts";
5
6
  export { ServiceRegistry, ServiceUnavailableError, deadServiceHandle } from "./services.ts";
@@ -7,17 +8,30 @@ export { HarnessExtensionManager, stageDefinition } from "./manager.ts";
7
8
  export type { ExtensionHostBridge, StagingOptions, HeldSession, StagedDefinition } from "./manager.ts";
8
9
  export type { ServiceOptions, ServiceUnavailableReason } from "./services.ts";
9
10
  export type { ExtensionManifest, ExtensionFileState, ExtensionFileStatus, ExtensionAttachTarget } from "./loader.ts";
10
- export type { ExtensionActions, ExtensionAPI, ExtensionCommand, ExtensionCommandResult, ExtensionRecordEntry, ExtensionSteerOptions, ExtensionDefinition, ExtensionHostContext, ExtensionEventContext, ExtensionEventMap, ExtensionEventName, ExtensionHandler, ExtensionHost, ExtensionModelRequestEvent, ExtensionModelRequestResult, ExtensionModelResponseEvent, ExtensionResultMap, ExtensionRunSettledEvent, ExtensionRunSettledResult, ExtensionRunStartEvent, ExtensionRunStartResult, ExtensionSessionContext, ExtensionSetupContext, ExtensionSessionEndEvent, ExtensionSessionStartEvent, ExtensionState, ExtensionToolSpec, ExtensionStepEndEvent, ExtensionStepEndResult, ExtensionStepStartEvent, ExtensionStepStartResult, ExtensionToolAuthorizeEvent, ExtensionToolAuthorizeResult, ExtensionToolCallEvent, ExtensionToolCallResult, ExtensionToolResultEvent, ExtensionCompactionBeforeEvent, ExtensionCompactionBeforeResult, ExtensionProviderHeadersEvent, ExtensionProviderHeadersResult, ExtensionProviderPayloadEvent, ExtensionProviderPayloadResult, ExtensionProviderResponseEvent, ProviderHeaders, HarnessSessionHandle, ModelProvider, SessionEndReason, SessionStartReason, } from "./types.ts";
11
+ export type { ExtensionActions, ExtensionAPI, ExtensionCommand, ExtensionCommandResult, ExtensionRecordEntry, ExtensionSteerOptions, ExtensionDefinition, ExtensionHostContext, ExtensionEventContext, ExtensionEventMap, ExtensionEventName, ExtensionHandler, ExtensionHost, ExtensionModelRequestEvent, ExtensionModelRequestResult, ExtensionModelResponseEvent, ExtensionResultMap, ExtensionRunSettledEvent, ExtensionRunSettledResult, ExtensionRunStartEvent, ExtensionRunStartResult, ExtensionSessionContext, ExtensionSessionEventContext, ExtensionSessionEndEvent, ExtensionSessionStartEvent, ExtensionState, ExtensionToolSpec, ExtensionStepEndEvent, ExtensionStepEndResult, ExtensionStepStartEvent, ExtensionStepStartResult, ExtensionToolAuthorizeEvent, ExtensionToolAuthorizeResult, ExtensionToolCallEvent, ExtensionToolCallResult, ExtensionToolResultEvent, ExtensionCompactionBeforeEvent, ExtensionCompactionBeforeResult, ExtensionProviderHeadersEvent, ExtensionProviderHeadersResult, ExtensionProviderPayloadEvent, ExtensionProviderPayloadResult, ExtensionProviderResponseEvent, ProviderHeaders, HarnessSessionHandle, ModelProvider, SessionEndReason, SessionStartReason, } from "./types.ts";
12
+ /** The extension runtime behind `capability`, or undefined when it is not an extension. */
13
+ export declare function extensionRuntimeOf(capability: Capability): ExtensionRuntime | undefined;
11
14
  /**
12
- * Build one isolated, session-scoped runtime for programmatic extensions.
15
+ * One extension, as one of the session's capabilities — named by its id, `detachable`, and
16
+ * assembled, isolated, attached and detached exactly like any other capability.
13
17
  *
14
18
  * Every decision point in the extension contract is a `LoopHooks` slot — that is the only
15
19
  * channel where a handler's return value can reach the loop. Observation does NOT come through
16
20
  * here: observation goes through `api.onEvent`, which passes the session's event stream straight
17
21
  * through (with fault isolation and teardown), rather than through this hook table.
18
22
  */
23
+ export declare function extensionCapability(definition: ExtensionDefinition, options?: {
24
+ readonly host?: ExtensionHost;
25
+ readonly params?: unknown;
26
+ readonly startReason?: SessionStartReason;
27
+ }): Capability;
28
+ /**
29
+ * A capability per extension, in order — what a session mounts for a list of definitions. A
30
+ * definition this session opted out of (`params[id] === false`) is left out entirely: never set
31
+ * up, never reported as attached.
32
+ */
19
33
  export declare function extensionsCapability(definitions: readonly ExtensionDefinition[], options?: {
20
34
  readonly host?: ExtensionHost;
21
35
  readonly params?: Readonly<Record<string, unknown>>;
22
- }): Capability;
36
+ }): Capability[];
23
37
  //# sourceMappingURL=index.d.ts.map
@@ -1,26 +1,63 @@
1
+ import { readSessionLog } from "operon-agents-core";
1
2
  import { ExtensionRuntime } from "./runtime.js";
2
3
  export { ExtensionRuntime } from "./runtime.js";
3
4
  export { ExtensionLoader, createExtensionLoader } from "./loader.js";
4
5
  export { ServiceRegistry, ServiceUnavailableError, deadServiceHandle } from "./services.js";
5
6
  export { HarnessExtensionManager, stageDefinition } from "./manager.js";
7
+ /** The runtime behind each extension capability — how the harness reaches an extension it mounted. */
8
+ const runtimes = new WeakMap();
9
+ /** The extension runtime behind `capability`, or undefined when it is not an extension. */
10
+ export function extensionRuntimeOf(capability) {
11
+ return runtimes.get(capability);
12
+ }
6
13
  /**
7
- * Build one isolated, session-scoped runtime for programmatic extensions.
14
+ * One extension, as one of the session's capabilities — named by its id, `detachable`, and
15
+ * assembled, isolated, attached and detached exactly like any other capability.
8
16
  *
9
17
  * Every decision point in the extension contract is a `LoopHooks` slot — that is the only
10
18
  * channel where a handler's return value can reach the loop. Observation does NOT come through
11
19
  * here: observation goes through `api.onEvent`, which passes the session's event stream straight
12
20
  * through (with fault isolation and teardown), rather than through this hook table.
13
21
  */
14
- export function extensionsCapability(definitions, options = {}) {
15
- const runtime = new ExtensionRuntime(definitions, options.host, options.params);
16
- return {
17
- name: "extensions",
18
- service: runtime,
19
- toolProviders: [{ id: "extensions", listTools: () => runtime.listTools() }],
22
+ export function extensionCapability(definition, options = {}) {
23
+ const runtime = new ExtensionRuntime(definition, options);
24
+ const capability = {
25
+ name: definition.id,
26
+ contract: "detachable",
27
+ // The runtime already gives every handler its own budget (`timeoutMs`) and isolates its
28
+ // failure; an outer deadline would only cut a chain of individually-healthy handlers short.
29
+ hookTimeoutMs: Number.POSITIVE_INFINITY,
30
+ // Read once the extension has opened: what it published with `api.expose`, so
31
+ // `session.service(id)` is the extension's own control surface.
32
+ get service() {
33
+ return runtime.exposedHandle();
34
+ },
35
+ // An extension can touch the whole session surface, so this list IS the blast radius —
36
+ // written down once, here, instead of discovered by reading the runtime for lookups.
37
+ openSession: async (ctx) => {
38
+ if (ctx.controls === undefined)
39
+ throw new Error("an extension needs the session's controls");
40
+ await runtime.open({
41
+ environment: ctx.environment,
42
+ events: ctx.events,
43
+ steer: ctx.steer,
44
+ controls: ctx.controls,
45
+ readLog: () => readSessionLog(ctx),
46
+ store: ctx.store,
47
+ }, ctx);
48
+ },
49
+ closeSession: (reason) => runtime.close(reason),
50
+ toolProviders: [{ id: definition.id, listTools: () => runtime.listTools() }],
20
51
  toolFilters: [runtime.filterTools],
21
52
  gates: { compaction: runtime.compactionGate },
22
- // Stable array reference — `setup` (openSession) fills it before any run assembles.
53
+ // Stable array references — `session` fills them before any run assembles.
23
54
  injectors: runtime.listInjectors(),
55
+ commands: runtime.commands,
56
+ // Read at each run's assembly: absent while no provider-tier handler is registered, so such a
57
+ // request is left byte-identical.
58
+ get provider() {
59
+ return runtime.hasProviderHandlers() ? runtime.providerHooks : undefined;
60
+ },
24
61
  hooks: {
25
62
  beforeRun: async (ctx) => runtime.beforeRun(ctx, ctx.input),
26
63
  beforeStep: async (ctx) => runtime.beforeStep(ctx, ctx.context, ctx.system),
@@ -32,10 +69,29 @@ export function extensionsCapability(definitions, options = {}) {
32
69
  authorizeToolExecution: async (ctx) => runtime.authorizeToolCall(ctx, ctx.toolCall.name, ctx.toolCall.id, ctx.tool, ctx.args, ctx.plan),
33
70
  finalizeToolResult: async (ctx) => runtime.afterToolResult(ctx, ctx.toolCall.name, ctx.toolCall.id, ctx.tool, ctx.args, ctx.result),
34
71
  },
35
- openSession: (ctx) => runtime.open(ctx),
36
- closeSession: () => runtime.close(),
37
72
  start: (ctx) => runtime.attachRun(ctx),
38
73
  stop: () => runtime.detachRun(),
39
74
  };
75
+ runtimes.set(capability, runtime);
76
+ return capability;
77
+ }
78
+ /**
79
+ * A capability per extension, in order — what a session mounts for a list of definitions. A
80
+ * definition this session opted out of (`params[id] === false`) is left out entirely: never set
81
+ * up, never reported as attached.
82
+ */
83
+ export function extensionsCapability(definitions, options = {}) {
84
+ const seen = new Set();
85
+ const out = [];
86
+ for (const definition of definitions) {
87
+ if (seen.has(definition.id))
88
+ throw new Error(`duplicate extension id "${definition.id}"`);
89
+ seen.add(definition.id);
90
+ const params = options.params?.[definition.id];
91
+ if (params === false)
92
+ continue;
93
+ out.push(extensionCapability(definition, { ...(options.host !== undefined ? { host: options.host } : {}), params }));
94
+ }
95
+ return out;
40
96
  }
41
97
  //# sourceMappingURL=index.js.map
@@ -30,7 +30,7 @@ export declare function compareVersions(a: string, b: string): number;
30
30
  /**
31
31
  * What must be known about an extension BEFORE its code is imported — find it (`id`), import
32
32
  * it (`entry`, gated by `engine`), show it (`version`, `name`, `description`) — and nothing more. What the
33
- * extension provides (`create`) and consumes (`uses`) lives on the definition it exports, the
33
+ * extension provides (`harness`) and consumes (`uses`) lives on the definition it exports, the
34
34
  * same on both channels. Only `id` is validated; unknown fields pass through untouched, so a
35
35
  * manifest written for a newer loader still loads here.
36
36
  */
@@ -102,7 +102,7 @@ export declare class ExtensionLoader {
102
102
  }>;
103
103
  /** The definitions currently held. On the standalone-loader path these reach sessions through
104
104
  * `attachExtension`/`reloadInto`; a harness built with `extensionDir` registers them itself
105
- * (`harness.extensions.load`), which is the path that also runs `create` halves. */
105
+ * (`harness.extensions.load`), which is the path that also runs `harness` halves. */
106
106
  definitions(): ExtensionDefinition[];
107
107
  /** Convenience: reload a extension and swap it on a live session (detach old, attach new). */
108
108
  reloadInto(session: ExtensionAttachTarget, id: string): Promise<ExtensionDefinition>;
@@ -10,6 +10,7 @@ import { readFile, readdir, stat, writeFile } from "node:fs/promises";
10
10
  import { join, resolve } from "node:path";
11
11
  import { createRequire } from "node:module";
12
12
  import { pathToFileURL } from "node:url";
13
+ import { z } from "zod";
13
14
  /**
14
15
  * File-extension loader — the shell that turns a extension directory into extension VALUES for
15
16
  * `attachExtension`. The framework core stays file-blind: everything here ends in a plain
@@ -63,6 +64,15 @@ export function compareVersions(a, b) {
63
64
  }
64
65
  return 0;
65
66
  }
67
+ /** The manifest as read from disk: only the fields above are checked; the rest pass through. */
68
+ const ExtensionManifestSchema = z.looseObject({
69
+ id: z.string().refine((id) => id.trim().length > 0, 'must declare a non-empty string "id"'),
70
+ version: z.string().optional(),
71
+ entry: z.string().optional(),
72
+ engine: z.string().regex(VERSION_RE, 'must be a version like "1.2.0"').optional(),
73
+ name: z.string().optional(),
74
+ description: z.string().optional(),
75
+ });
66
76
  const APPROVALS_FILE = ".approvals.json";
67
77
  export class ExtensionLoader {
68
78
  directory;
@@ -155,7 +165,7 @@ export class ExtensionLoader {
155
165
  }
156
166
  /** The definitions currently held. On the standalone-loader path these reach sessions through
157
167
  * `attachExtension`/`reloadInto`; a harness built with `extensionDir` registers them itself
158
- * (`harness.extensions.load`), which is the path that also runs `create` halves. */
168
+ * (`harness.extensions.load`), which is the path that also runs `harness` halves. */
159
169
  definitions() {
160
170
  return [...this.loaded.values()];
161
171
  }
@@ -177,23 +187,21 @@ export class ExtensionLoader {
177
187
  async scan(dirName) {
178
188
  const dir = join(this.directory, dirName);
179
189
  const manifestPath = join(dir, "manifest.json");
180
- let manifest;
190
+ let raw;
181
191
  try {
182
- manifest = JSON.parse(await readFile(manifestPath, "utf8"));
192
+ raw = JSON.parse(await readFile(manifestPath, "utf8"));
183
193
  }
184
194
  catch (error) {
185
195
  throw new Error(`unreadable manifest.json: ${messageOf(error)}`);
186
196
  }
187
- if (typeof manifest.id !== "string" || !manifest.id.trim()) {
188
- throw new Error("manifest.json must declare a non-empty string \"id\"");
197
+ const parsed = ExtensionManifestSchema.safeParse(raw);
198
+ if (!parsed.success) {
199
+ const issues = parsed.error.issues.map((issue) => `${issue.path.length > 0 ? `"${issue.path.join(".")}" ` : ""}${issue.message}`);
200
+ throw new Error(`manifest.json: ${issues.join("; ")}`);
189
201
  }
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
- }
202
+ const manifest = parsed.data;
203
+ if (manifest.engine !== undefined && compareVersions(FRAMEWORK_VERSION, manifest.engine) < 0) {
204
+ throw new Error(`requires operon-agents >= ${manifest.engine}; this host runs ${FRAMEWORK_VERSION}`);
197
205
  }
198
206
  const entryPath = join(dir, manifest.entry ?? "index.js");
199
207
  const stats = await stat(entryPath).catch(() => {
@@ -241,11 +249,11 @@ export class ExtensionLoader {
241
249
  }
242
250
  let candidate = moduleExports.default;
243
251
  // 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).
252
+ // everything a extension may use arrives later through its session(ctx).
245
253
  if (typeof candidate === "function")
246
254
  candidate = candidate();
247
255
  if (!isExtensionDefinition(candidate)) {
248
- throw new Error(`extension "${scanned.manifest.id}" default export is not an extension definition ({ id, setup })`);
256
+ throw new Error(`extension "${scanned.manifest.id}" default export is not an extension definition ({ id, session })`);
249
257
  }
250
258
  if (candidate.id !== scanned.manifest.id) {
251
259
  throw new Error(`extension id mismatch: manifest says "${scanned.manifest.id}", code says "${candidate.id}"`);
@@ -277,7 +285,7 @@ export function createExtensionLoader(options) {
277
285
  function isExtensionDefinition(value) {
278
286
  return (typeof value === "object" && value !== null
279
287
  && typeof value.id === "string"
280
- && typeof value.setup === "function");
288
+ && typeof value.session === "function");
281
289
  }
282
290
  function messageOf(error) {
283
291
  return error instanceof Error ? error.message : String(error);
@@ -24,16 +24,16 @@ export interface ExtensionHostBridge {
24
24
  withBarrier(extensionIds: readonly string[], timeoutMs: number, fn: (held: () => readonly HeldSession[]) => Promise<void>): Promise<void>;
25
25
  warn(message: string): void;
26
26
  }
27
- /** What staging one `create`-bearing definition produced, before anything is published. */
27
+ /** What staging one `harness`-bearing definition produced, before anything is published. */
28
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`. */
29
+ /** The one service `harness` returned, to register under the extension's id; absent when the
30
+ * definition has no `harness`. */
31
31
  readonly service?: {
32
32
  readonly name: string;
33
33
  readonly instance: unknown;
34
34
  readonly options: ServiceOptions;
35
35
  };
36
- /** The definition itself — its `setup` is the per-session half, mounted into every session. */
36
+ /** The definition itself — its `session` is the per-session half, mounted into every session. */
37
37
  readonly sessionDef: ExtensionDefinition;
38
38
  }
39
39
  export interface StagingOptions {
@@ -44,12 +44,14 @@ export interface StagingOptions {
44
44
  /** The extension's data folder, already created. */
45
45
  readonly dataDir?: string;
46
46
  createSession: ExtensionHostContext["createSession"];
47
+ /** Resolve one of the definition's `uses` names to its service handle. */
48
+ service(name: string): unknown;
47
49
  warn(message: string): void;
48
50
  }
49
51
  /**
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`
52
+ * Run a `harness`-bearing definition's `harness` half against a staging host and collect the one
53
+ * service it returns — nothing is registered here, so a throwing `harness` publishes nothing.
54
+ * Shared by the file manager and the harness's by-value channel. Stays synchronous when `harness`
53
55
  * is, which the by-value channel relies on to have a synchronous extension's service visible the
54
56
  * moment `createHarness` returns.
55
57
  */
@@ -59,7 +61,7 @@ export declare class HarnessExtensionManager {
59
61
  private readonly bridge;
60
62
  /** Root of the extensions' data folders (`<root>/<id>`) — outside every code folder. */
61
63
  private readonly dataRoot;
62
- /** Every loaded extension by id, and whether its `create` published a service. */
64
+ /** Every loaded extension by id, and whether its `harness` published a service. */
63
65
  private readonly loaded;
64
66
  /** Definitions newborn sessions receive: every loaded extension's per-session half. */
65
67
  private readonly sessionDefs;
@@ -73,10 +75,10 @@ export declare class HarnessExtensionManager {
73
75
  sessionDefinitions(): ExtensionDefinition[];
74
76
  /**
75
77
  * 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
78
+ * definition's `harness` (if any) and publishes the result as its service, then hands the
77
79
  * definition to future sessions. Loading an already-loaded extension reloads it: every
78
80
  * 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`.
81
+ * service replaced), and everyone resumes — the same act whether or not there is a `harness`.
80
82
  */
81
83
  load(id: string, options?: {
82
84
  readonly timeoutMs?: number;
@@ -86,14 +88,14 @@ export declare class HarnessExtensionManager {
86
88
  readonly timeoutMs?: number;
87
89
  }): Promise<void>;
88
90
  /**
89
- * Unload an extension. A `create`-bearing one is refused while any session still runs an
91
+ * Unload an extension. A `harness`-bearing one is refused while any session still runs an
90
92
  * extension that `uses` its service — named, so the caller knows what to detach. Its own
91
93
  * per-session half does not block (it is removed as part of the unload).
92
94
  */
93
95
  unload(id: string): Promise<void>;
94
96
  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
+ /** Run `harness` with a staging host: the service is COLLECTED, not registered, so a throwing
98
+ * `harness` publishes nothing. Services default to replaceable — reload is their point. */
97
99
  private stage;
98
100
  /** Sessions still running an extension that `uses` `id`'s service — read off the attached
99
101
  * definitions themselves, so by-value and file consumers count alike. No graph walk: the
@@ -1,8 +1,8 @@
1
1
  /**
2
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
3
+ * is a FILE whose default export is an `ExtensionDefinition`; the manager runs its `harness` half
4
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,
5
+ * the SAME definition to every session (its `session` is the per-session half). One extension,
6
6
  * one approval, one reload — the manager turns a reload into the coordinated act: barrier →
7
7
  * swap the session half → replace the service → release.
8
8
  *
@@ -10,9 +10,9 @@
10
10
  * single one as for ordinary extensions — manual load/reload IS the approval (mtime-scoped).
11
11
  * Nothing here auto-loads or watches.
12
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).
13
+ * `stageDefinition` — running `harness` against a staging host and collecting the one service, so a
14
+ * throwing `harness` publishes nothing — is shared with the harness's by-value channel
15
+ * (`createHarness({ extensions })` runs a `harness`-bearing definition through it with no loader).
16
16
  */
17
17
  import { mkdir } from "node:fs/promises";
18
18
  import { join, resolve } from "node:path";
@@ -22,17 +22,18 @@ function isPromiseLike(value) {
22
22
  return typeof value === "object" && value !== null && typeof value.then === "function";
23
23
  }
24
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`
25
+ * Run a `harness`-bearing definition's `harness` half against a staging host and collect the one
26
+ * service it returns — nothing is registered here, so a throwing `harness` publishes nothing.
27
+ * Shared by the file manager and the harness's by-value channel. Stays synchronous when `harness`
28
28
  * is, which the by-value channel relies on to have a synchronous extension's service visible the
29
29
  * moment `createHarness` returns.
30
30
  */
31
31
  export function stageDefinition(definition, options) {
32
- if (definition.create === undefined)
32
+ if (definition.harness === undefined)
33
33
  return { sessionDef: definition };
34
34
  const host = {
35
35
  createSession: (sessionOptions) => options.createSession(sessionOptions),
36
+ services: Object.freeze(Object.fromEntries((definition.uses ?? []).map((name) => [name, options.service(name)]))),
36
37
  ...(options.dir !== undefined ? { dir: options.dir } : {}),
37
38
  ...(options.dataDir !== undefined ? { dataDir: options.dataDir } : {}),
38
39
  warn: (message) => options.warn(`[extension ${definition.id}] ${message}`),
@@ -41,7 +42,7 @@ export function stageDefinition(definition, options) {
41
42
  service: { name: definition.id, instance, options: { replaceable: options.defaultReplaceable } },
42
43
  sessionDef: definition,
43
44
  });
44
- const result = definition.create(host);
45
+ const result = definition.harness(host);
45
46
  return isPromiseLike(result) ? Promise.resolve(result).then(finish) : finish(result);
46
47
  }
47
48
  const DEFAULT_BARRIER_TIMEOUT_MS = 30_000;
@@ -50,7 +51,7 @@ export class HarnessExtensionManager {
50
51
  bridge;
51
52
  /** Root of the extensions' data folders (`<root>/<id>`) — outside every code folder. */
52
53
  dataRoot;
53
- /** Every loaded extension by id, and whether its `create` published a service. */
54
+ /** Every loaded extension by id, and whether its `harness` published a service. */
54
55
  loaded = new Map();
55
56
  /** Definitions newborn sessions receive: every loaded extension's per-session half. */
56
57
  sessionDefs = new Map();
@@ -68,16 +69,16 @@ export class HarnessExtensionManager {
68
69
  }
69
70
  /**
70
71
  * 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's `harness` (if any) and publishes the result as its service, then hands the
72
73
  * definition to future sessions. Loading an already-loaded extension reloads it: every
73
74
  * 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
+ * service replaced), and everyone resumes — the same act whether or not there is a `harness`.
75
76
  */
76
77
  async load(id, options = {}) {
77
78
  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.
79
+ // Shape and `uses` live on the definition, so they are readable only after the import; a
80
+ // bad definition or a missing provider revokes the approval the import just recorded, so a
81
+ // failed load leaves nothing behind. Same rule for both kinds: load providers first.
81
82
  for (const name of definition.uses ?? []) {
82
83
  if (!this.bridge.services.has(name)) {
83
84
  await this.loader.unload(id);
@@ -91,7 +92,7 @@ export class HarnessExtensionManager {
91
92
  return this.load(id, options);
92
93
  }
93
94
  /**
94
- * Unload an extension. A `create`-bearing one is refused while any session still runs an
95
+ * Unload an extension. A `harness`-bearing one is refused while any session still runs an
95
96
  * extension that `uses` its service — named, so the caller knows what to detach. Its own
96
97
  * per-session half does not block (it is removed as part of the unload).
97
98
  */
@@ -124,6 +125,7 @@ export class HarnessExtensionManager {
124
125
  async register(id, definition, options) {
125
126
  const staged = await this.stage(definition);
126
127
  const previous = this.loaded.get(id);
128
+ const record = { hasService: staged.service !== undefined };
127
129
  if (previous === undefined) {
128
130
  // First load: register the service, then hand the session half to future sessions.
129
131
  // Already-open sessions are not touched (a reload only re-attaches to sessions that hold
@@ -132,11 +134,11 @@ export class HarnessExtensionManager {
132
134
  this.bridge.services.register(staged.service.name, staged.service.instance, staged.service.options);
133
135
  }
134
136
  this.sessionDefs.set(id, staged.sessionDef);
135
- this.loaded.set(id, { hasService: staged.service !== undefined });
137
+ this.loaded.set(id, record);
136
138
  return;
137
139
  }
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
+ // Reload: one coordinated act — barrier on the session half, swap half + service(s) in the
141
+ // quiet moment. Version skew between the halves is structurally impossible.
140
142
  await this.bridge.withBarrier([id], options.timeoutMs ?? DEFAULT_BARRIER_TIMEOUT_MS, async (held) => {
141
143
  for (const session of held()) {
142
144
  if (session.attachedExtensionIds().includes(id))
@@ -156,20 +158,21 @@ export class HarnessExtensionManager {
156
158
  await session.attachExtension(staged.sessionDef);
157
159
  }
158
160
  });
159
- this.loaded.set(id, { hasService: staged.service !== undefined });
161
+ this.loaded.set(id, record);
160
162
  }
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
+ /** Run `harness` with a staging host: the service is COLLECTED, not registered, so a throwing
164
+ * `harness` publishes nothing. Services default to replaceable — reload is their point. */
163
165
  async stage(definition) {
164
- // The data folder exists before `create` runs, so the extension can use it right away.
166
+ // The data folder exists before `harness` runs, so the extension can use it right away.
165
167
  const dataDir = join(this.dataRoot, definition.id);
166
- if (definition.create !== undefined)
168
+ if (definition.harness !== undefined)
167
169
  await mkdir(dataDir, { recursive: true });
168
170
  return stageDefinition(definition, {
169
171
  defaultReplaceable: true,
170
172
  dir: await this.loader.manifestDir(definition.id),
171
173
  dataDir,
172
174
  createSession: (sessionOptions) => this.bridge.createSession(sessionOptions),
175
+ service: (name) => this.bridge.services.handle(name),
173
176
  warn: (message) => this.bridge.warn(message),
174
177
  });
175
178
  }