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

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.
@@ -9,7 +9,7 @@
9
9
  * redirect console.log/info/debug to stderr so a stray log can never corrupt the
10
10
  * protocol stream.
11
11
  */
12
- import { defineModel, DiskSessionRepository } from "operon-agents-core";
12
+ import { defineModel, DiskSessionRepository, T } from "operon-agents-core";
13
13
  import { createHarness } from "../harness.js";
14
14
  import { nodeStreamTransport } from "./codec.js";
15
15
  import { AppServer } from "./server.js";
@@ -63,7 +63,7 @@ async function main() {
63
63
  const harness = createHarness({
64
64
  model: args.model ?? "anthropic/claude-opus-4-8",
65
65
  resolveModel,
66
- ...(args.home !== undefined ? { repository: new DiskSessionRepository(args.home) } : {}),
66
+ ...(args.home !== undefined ? { harness: (scope) => scope.register(T.SessionRepository, new DiskSessionRepository(args.home)) } : {}),
67
67
  ...(args.workdir !== undefined ? { workDir: args.workdir } : {}),
68
68
  permission: { mode: args.permission ?? "yolo" },
69
69
  });
@@ -29,8 +29,8 @@ export function normalizeCronTaskInput(manager, input) {
29
29
  export function cronExtension(options = {}) {
30
30
  return {
31
31
  id: "cron",
32
- async setup(api) {
33
- // One manager per session: setup runs per session open, so scheduling state is
32
+ async session(api) {
33
+ // One manager per session: session() runs per session open, so scheduling state is
34
34
  // session-scoped exactly as the capability's openSession was.
35
35
  const manager = new CronManager(options);
36
36
  manager.attach({
@@ -3,11 +3,11 @@ import type { ExtensionDefinition, ExtensionHost } from "./types.ts";
3
3
  export { ExtensionRuntime } from "./runtime.ts";
4
4
  export { ExtensionLoader, createExtensionLoader } from "./loader.ts";
5
5
  export { ServiceRegistry, ServiceUnavailableError, deadServiceHandle } from "./services.ts";
6
- export { HarnessExtensionManager, stageDefinition } from "./manager.ts";
6
+ export { HarnessExtensionManager, stageDefinition, assertOneSharedHalf } from "./manager.ts";
7
7
  export type { ExtensionHostBridge, StagingOptions, HeldSession, StagedDefinition } from "./manager.ts";
8
- export type { ServiceOptions, ServiceUnavailableReason } from "./services.ts";
8
+ export type { ServiceOptions, ServiceTier, ServiceUnavailableReason } from "./services.ts";
9
9
  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";
10
+ export type { ExtensionActions, ExtensionAPI, ExtensionCommand, ExtensionCommandResult, ExtensionRecordEntry, ExtensionSteerOptions, ExtensionDefinition, ExtensionHostContext, ExtensionWorkspaceContext, 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";
11
11
  /**
12
12
  * Build one isolated, session-scoped runtime for programmatic extensions.
13
13
  *
@@ -1,8 +1,9 @@
1
1
  import { ExtensionRuntime } from "./runtime.js";
2
+ import { HT } from "../tokens.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
- export { HarnessExtensionManager, stageDefinition } from "./manager.js";
6
+ export { HarnessExtensionManager, stageDefinition, assertOneSharedHalf } from "./manager.js";
6
7
  /**
7
8
  * Build one isolated, session-scoped runtime for programmatic extensions.
8
9
  *
@@ -15,11 +16,20 @@ export function extensionsCapability(definitions, options = {}) {
15
16
  const runtime = new ExtensionRuntime(definitions, options.host, options.params);
16
17
  return {
17
18
  name: "extensions",
18
- service: runtime,
19
+ provides: [
20
+ {
21
+ token: HT.Extensions,
22
+ create: async (ctx) => {
23
+ await runtime.open(ctx);
24
+ return runtime;
25
+ },
26
+ dispose: () => runtime.close(),
27
+ },
28
+ ],
19
29
  toolProviders: [{ id: "extensions", listTools: () => runtime.listTools() }],
20
30
  toolFilters: [runtime.filterTools],
21
31
  gates: { compaction: runtime.compactionGate },
22
- // Stable array reference — `setup` (openSession) fills it before any run assembles.
32
+ // Stable array reference — `session` (the provision's `harness`) fills it before any run assembles.
23
33
  injectors: runtime.listInjectors(),
24
34
  hooks: {
25
35
  beforeRun: async (ctx) => runtime.beforeRun(ctx, ctx.input),
@@ -32,8 +42,6 @@ export function extensionsCapability(definitions, options = {}) {
32
42
  authorizeToolExecution: async (ctx) => runtime.authorizeToolCall(ctx, ctx.toolCall.name, ctx.toolCall.id, ctx.tool, ctx.args, ctx.plan),
33
43
  finalizeToolResult: async (ctx) => runtime.afterToolResult(ctx, ctx.toolCall.name, ctx.toolCall.id, ctx.tool, ctx.args, ctx.result),
34
44
  },
35
- openSession: (ctx) => runtime.open(ctx),
36
- closeSession: () => runtime.close(),
37
45
  start: (ctx) => runtime.attachRun(ctx),
38
46
  stop: () => runtime.detachRun(),
39
47
  };
@@ -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>;
@@ -155,7 +155,7 @@ export class ExtensionLoader {
155
155
  }
156
156
  /** The definitions currently held. On the standalone-loader path these reach sessions through
157
157
  * `attachExtension`/`reloadInto`; a harness built with `extensionDir` registers them itself
158
- * (`harness.extensions.load`), which is the path that also runs `create` halves. */
158
+ * (`harness.extensions.load`), which is the path that also runs `harness` halves. */
159
159
  definitions() {
160
160
  return [...this.loaded.values()];
161
161
  }
@@ -241,11 +241,11 @@ export class ExtensionLoader {
241
241
  }
242
242
  let candidate = moduleExports.default;
243
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).
244
+ // everything a extension may use arrives later through its session(ctx).
245
245
  if (typeof candidate === "function")
246
246
  candidate = candidate();
247
247
  if (!isExtensionDefinition(candidate)) {
248
- throw new Error(`extension "${scanned.manifest.id}" default export is not an extension definition ({ id, setup })`);
248
+ throw new Error(`extension "${scanned.manifest.id}" default export is not an extension definition ({ id, session })`);
249
249
  }
250
250
  if (candidate.id !== scanned.manifest.id) {
251
251
  throw new Error(`extension id mismatch: manifest says "${scanned.manifest.id}", code says "${candidate.id}"`);
@@ -277,7 +277,7 @@ export function createExtensionLoader(options) {
277
277
  function isExtensionDefinition(value) {
278
278
  return (typeof value === "object" && value !== null
279
279
  && typeof value.id === "string"
280
- && typeof value.setup === "function");
280
+ && typeof value.session === "function");
281
281
  }
282
282
  function messageOf(error) {
283
283
  return error instanceof Error ? error.message : String(error);
@@ -12,9 +12,27 @@ export interface HeldSession {
12
12
  readonly uses: readonly string[];
13
13
  }[];
14
14
  }
15
+ /**
16
+ * The workspace-tier side of the bridge: `workspace` halves live in the harness's workspace
17
+ * scopes, which only the harness can reach. `staged` maps workspace key → the instance the new
18
+ * half produced for it (staging runs BEFORE the barrier, so a throwing half changes nothing).
19
+ */
20
+ export interface WorkspaceHalfBridge {
21
+ /** Run `definition.workspace` against every live workspace; nothing is registered. */
22
+ stage(definition: ExtensionDefinition): Promise<Map<string, unknown>>;
23
+ /** In the quiet moment: give every live workspace its staged instance (replace where one is
24
+ * registered, register where none is; compose fresh for a workspace born after staging).
25
+ * `staged === undefined` ⇒ the definition has no `workspace` half: unregister everywhere. */
26
+ swap(definition: ExtensionDefinition, staged: Map<string, unknown> | undefined): Promise<void>;
27
+ /** Unregister `id` from every live workspace (unload). */
28
+ unregister(id: string): Promise<void>;
29
+ /** Dispose staged instances that were never published (a failed act). */
30
+ discard(id: string, staged: Map<string, unknown>): Promise<void>;
31
+ }
15
32
  /** The narrow harness face the manager operates through (no import cycle with harness.ts). */
16
33
  export interface ExtensionHostBridge {
17
34
  readonly services: ServiceRegistry;
35
+ readonly workspaces: WorkspaceHalfBridge;
18
36
  createSession(options?: Record<string, unknown>): Promise<{
19
37
  readonly id: string;
20
38
  }>;
@@ -24,16 +42,22 @@ export interface ExtensionHostBridge {
24
42
  withBarrier(extensionIds: readonly string[], timeoutMs: number, fn: (held: () => readonly HeldSession[]) => Promise<void>): Promise<void>;
25
43
  warn(message: string): void;
26
44
  }
27
- /** What staging one `create`-bearing definition produced, before anything is published. */
45
+ /**
46
+ * A definition carries at most ONE shared half. Its service (and `ctx.shared`) lives at exactly
47
+ * one tier — the tier of the half that produced it — so consumers and the session half never
48
+ * have to ask which. Checked wherever a definition is registered (by value or from a file).
49
+ */
50
+ export declare function assertOneSharedHalf(definition: ExtensionDefinition): void;
51
+ /** What staging one `harness`-bearing definition produced, before anything is published. */
28
52
  export interface StagedDefinition {
29
- /** The one service `create` returned, to register under the extension's id; absent when the
30
- * definition has no `create`. */
53
+ /** The one service `harness` returned, to register under the extension's id; absent when the
54
+ * definition has no `harness`. */
31
55
  readonly service?: {
32
56
  readonly name: string;
33
57
  readonly instance: unknown;
34
58
  readonly options: ServiceOptions;
35
59
  };
36
- /** The definition itself — its `setup` is the per-session half, mounted into every session. */
60
+ /** The definition itself — its `session` is the per-session half, mounted into every session. */
37
61
  readonly sessionDef: ExtensionDefinition;
38
62
  }
39
63
  export interface StagingOptions {
@@ -44,12 +68,14 @@ export interface StagingOptions {
44
68
  /** The extension's data folder, already created. */
45
69
  readonly dataDir?: string;
46
70
  createSession: ExtensionHostContext["createSession"];
71
+ /** Resolve one of the definition's `uses` names to its service handle. */
72
+ service(name: string): unknown;
47
73
  warn(message: string): void;
48
74
  }
49
75
  /**
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`
76
+ * Run a `harness`-bearing definition's `harness` half against a staging host and collect the one
77
+ * service it returns — nothing is registered here, so a throwing `harness` publishes nothing.
78
+ * Shared by the file manager and the harness's by-value channel. Stays synchronous when `harness`
53
79
  * is, which the by-value channel relies on to have a synchronous extension's service visible the
54
80
  * moment `createHarness` returns.
55
81
  */
@@ -59,7 +85,7 @@ export declare class HarnessExtensionManager {
59
85
  private readonly bridge;
60
86
  /** Root of the extensions' data folders (`<root>/<id>`) — outside every code folder. */
61
87
  private readonly dataRoot;
62
- /** Every loaded extension by id, and whether its `create` published a service. */
88
+ /** Every loaded extension by id, and whether its `harness` published a service. */
63
89
  private readonly loaded;
64
90
  /** Definitions newborn sessions receive: every loaded extension's per-session half. */
65
91
  private readonly sessionDefs;
@@ -73,10 +99,10 @@ export declare class HarnessExtensionManager {
73
99
  sessionDefinitions(): ExtensionDefinition[];
74
100
  /**
75
101
  * 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
102
+ * definition's `harness` (if any) and publishes the result as its service, then hands the
77
103
  * definition to future sessions. Loading an already-loaded extension reloads it: every
78
104
  * 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`.
105
+ * service replaced), and everyone resumes — the same act whether or not there is a `harness`.
80
106
  */
81
107
  load(id: string, options?: {
82
108
  readonly timeoutMs?: number;
@@ -86,14 +112,14 @@ export declare class HarnessExtensionManager {
86
112
  readonly timeoutMs?: number;
87
113
  }): Promise<void>;
88
114
  /**
89
- * Unload an extension. A `create`-bearing one is refused while any session still runs an
115
+ * Unload an extension. A `harness`-bearing one is refused while any session still runs an
90
116
  * extension that `uses` its service — named, so the caller knows what to detach. Its own
91
117
  * per-session half does not block (it is removed as part of the unload).
92
118
  */
93
119
  unload(id: string): Promise<void>;
94
120
  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. */
121
+ /** Run `harness` with a staging host: the service is COLLECTED, not registered, so a throwing
122
+ * `harness` publishes nothing. Services default to replaceable — reload is their point. */
97
123
  private stage;
98
124
  /** Sessions still running an extension that `uses` `id`'s service — read off the attached
99
125
  * 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,29 +10,40 @@
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";
19
19
  import { ExtensionLoader } from "./loader.js";
20
20
  import { ServiceRegistry } from "./services.js";
21
+ /**
22
+ * A definition carries at most ONE shared half. Its service (and `ctx.shared`) lives at exactly
23
+ * one tier — the tier of the half that produced it — so consumers and the session half never
24
+ * have to ask which. Checked wherever a definition is registered (by value or from a file).
25
+ */
26
+ export function assertOneSharedHalf(definition) {
27
+ if (definition.harness !== undefined && definition.workspace !== undefined) {
28
+ throw new Error(`extension "${definition.id}" declares both a harness and a workspace half — a definition carries ONE shared half, so its service lives at exactly one tier`);
29
+ }
30
+ }
21
31
  function isPromiseLike(value) {
22
32
  return typeof value === "object" && value !== null && typeof value.then === "function";
23
33
  }
24
34
  /**
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`
35
+ * Run a `harness`-bearing definition's `harness` half against a staging host and collect the one
36
+ * service it returns — nothing is registered here, so a throwing `harness` publishes nothing.
37
+ * Shared by the file manager and the harness's by-value channel. Stays synchronous when `harness`
28
38
  * is, which the by-value channel relies on to have a synchronous extension's service visible the
29
39
  * moment `createHarness` returns.
30
40
  */
31
41
  export function stageDefinition(definition, options) {
32
- if (definition.create === undefined)
42
+ if (definition.harness === undefined)
33
43
  return { sessionDef: definition };
34
44
  const host = {
35
45
  createSession: (sessionOptions) => options.createSession(sessionOptions),
46
+ services: Object.freeze(Object.fromEntries((definition.uses ?? []).map((name) => [name, options.service(name)]))),
36
47
  ...(options.dir !== undefined ? { dir: options.dir } : {}),
37
48
  ...(options.dataDir !== undefined ? { dataDir: options.dataDir } : {}),
38
49
  warn: (message) => options.warn(`[extension ${definition.id}] ${message}`),
@@ -41,7 +52,7 @@ export function stageDefinition(definition, options) {
41
52
  service: { name: definition.id, instance, options: { replaceable: options.defaultReplaceable } },
42
53
  sessionDef: definition,
43
54
  });
44
- const result = definition.create(host);
55
+ const result = definition.harness(host);
45
56
  return isPromiseLike(result) ? Promise.resolve(result).then(finish) : finish(result);
46
57
  }
47
58
  const DEFAULT_BARRIER_TIMEOUT_MS = 30_000;
@@ -50,7 +61,7 @@ export class HarnessExtensionManager {
50
61
  bridge;
51
62
  /** Root of the extensions' data folders (`<root>/<id>`) — outside every code folder. */
52
63
  dataRoot;
53
- /** 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. */
54
65
  loaded = new Map();
55
66
  /** Definitions newborn sessions receive: every loaded extension's per-session half. */
56
67
  sessionDefs = new Map();
@@ -68,16 +79,23 @@ export class HarnessExtensionManager {
68
79
  }
69
80
  /**
70
81
  * 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
82
+ * definition's `harness` (if any) and publishes the result as its service, then hands the
72
83
  * definition to future sessions. Loading an already-loaded extension reloads it: every
73
84
  * 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`.
85
+ * service replaced), and everyone resumes — the same act whether or not there is a `harness`.
75
86
  */
76
87
  async load(id, options = {}) {
77
88
  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.
89
+ // Shape and `uses` live on the definition, so they are readable only after the import; a
90
+ // bad definition or a missing provider revokes the approval the import just recorded, so a
91
+ // failed load leaves nothing behind. Same rule for both kinds: load providers first.
92
+ try {
93
+ assertOneSharedHalf(definition);
94
+ }
95
+ catch (error) {
96
+ await this.loader.unload(id);
97
+ throw error;
98
+ }
81
99
  for (const name of definition.uses ?? []) {
82
100
  if (!this.bridge.services.has(name)) {
83
101
  await this.loader.unload(id);
@@ -91,7 +109,7 @@ export class HarnessExtensionManager {
91
109
  return this.load(id, options);
92
110
  }
93
111
  /**
94
- * Unload an extension. A `create`-bearing one is refused while any session still runs an
112
+ * Unload an extension. A `harness`-bearing one is refused while any session still runs an
95
113
  * extension that `uses` its service — named, so the caller knows what to detach. Its own
96
114
  * per-session half does not block (it is removed as part of the unload).
97
115
  */
@@ -115,6 +133,10 @@ export class HarnessExtensionManager {
115
133
  this.sessionDefs.delete(id);
116
134
  if (record.hasService)
117
135
  await this.bridge.services.unregister(id);
136
+ if (record.hasWorkspace) {
137
+ await this.bridge.workspaces.unregister(id);
138
+ this.bridge.services.undeclareWorkspace(id);
139
+ }
118
140
  this.loaded.delete(id);
119
141
  await this.loader.unload(id);
120
142
  }
@@ -123,53 +145,81 @@ export class HarnessExtensionManager {
123
145
  // ==========================================================================
124
146
  async register(id, definition, options) {
125
147
  const staged = await this.stage(definition);
148
+ // The workspace half is staged the same way, once per live workspace: run BEFORE anything
149
+ // is published, so a throwing half leaves every workspace on what it had.
150
+ const hasWorkspace = definition.workspace !== undefined;
151
+ const stagedWorkspaces = hasWorkspace ? await this.bridge.workspaces.stage(definition) : undefined;
126
152
  const previous = this.loaded.get(id);
153
+ const record = { hasService: staged.service !== undefined, hasWorkspace };
127
154
  if (previous === undefined) {
128
155
  // First load: register the service, then hand the session half to future sessions.
129
156
  // 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.
157
+ // the half) — attach it explicitly where an open session should get it. Workspaces
158
+ // already open DO get their instance now: a workspace half is composed on first use, and
159
+ // for them first use is this load.
131
160
  if (staged.service !== undefined) {
132
161
  this.bridge.services.register(staged.service.name, staged.service.instance, staged.service.options);
133
162
  }
163
+ if (hasWorkspace)
164
+ this.bridge.services.declareWorkspace(id);
134
165
  this.sessionDefs.set(id, staged.sessionDef);
135
- this.loaded.set(id, { hasService: staged.service !== undefined });
166
+ if (hasWorkspace)
167
+ await this.bridge.workspaces.swap(definition, stagedWorkspaces);
168
+ this.loaded.set(id, record);
136
169
  return;
137
170
  }
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 });
171
+ // Reload: one coordinated act — barrier on the session half, swap half + service(s) in the
172
+ // quiet moment. Version skew between the halves is structurally impossible.
173
+ try {
174
+ await this.bridge.withBarrier([id], options.timeoutMs ?? DEFAULT_BARRIER_TIMEOUT_MS, async (held) => {
175
+ for (const session of held()) {
176
+ if (session.attachedExtensionIds().includes(id))
177
+ await session.detachExtension(id);
178
+ }
179
+ if (staged.service !== undefined) {
180
+ if (this.bridge.services.has(id))
181
+ await this.bridge.services.replace(id, staged.service.instance);
182
+ else
183
+ this.bridge.services.register(staged.service.name, staged.service.instance, staged.service.options);
184
+ }
185
+ else if (previous.hasService) {
186
+ await this.bridge.services.unregister(id);
187
+ }
188
+ this.sessionDefs.set(id, staged.sessionDef);
189
+ if (hasWorkspace) {
190
+ this.bridge.services.declareWorkspace(id);
191
+ await this.bridge.workspaces.swap(definition, stagedWorkspaces);
192
+ }
193
+ else if (previous.hasWorkspace) {
194
+ // The new version dropped its workspace half: every workspace's instance goes with it.
195
+ await this.bridge.workspaces.swap(definition, undefined);
196
+ this.bridge.services.undeclareWorkspace(id);
197
+ }
198
+ for (const session of held()) {
199
+ await session.attachExtension(staged.sessionDef);
200
+ }
201
+ });
202
+ }
203
+ catch (error) {
204
+ if (stagedWorkspaces !== undefined)
205
+ await this.bridge.workspaces.discard(id, stagedWorkspaces);
206
+ throw error;
207
+ }
208
+ this.loaded.set(id, record);
160
209
  }
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. */
210
+ /** Run `harness` with a staging host: the service is COLLECTED, not registered, so a throwing
211
+ * `harness` publishes nothing. Services default to replaceable — reload is their point. */
163
212
  async stage(definition) {
164
- // The data folder exists before `create` runs, so the extension can use it right away.
213
+ // The data folder exists before `harness` runs, so the extension can use it right away.
165
214
  const dataDir = join(this.dataRoot, definition.id);
166
- if (definition.create !== undefined)
215
+ if (definition.harness !== undefined)
167
216
  await mkdir(dataDir, { recursive: true });
168
217
  return stageDefinition(definition, {
169
218
  defaultReplaceable: true,
170
219
  dir: await this.loader.manifestDir(definition.id),
171
220
  dataDir,
172
221
  createSession: (sessionOptions) => this.bridge.createSession(sessionOptions),
222
+ service: (name) => this.bridge.services.handle(name),
173
223
  warn: (message) => this.bridge.warn(message),
174
224
  });
175
225
  }
@@ -1,5 +1,5 @@
1
1
  import type { HeadlessCommand } from "operon-agents-core";
2
- import type { AssistantMessage, CapabilityContext, CompactionGate, ChatModel, ConversationContext, Injector, LlmRequest, Message, SessionContext, StepStopReason, TerminalStepStopReason, Tool, ToolPlan, ToolResult, Usage } from "operon-agents-core";
2
+ import type { AssistantMessage, RunContext, CompactionGate, ChatModel, ConversationContext, Injector, LlmRequest, Message, ProvisionContext, StepStopReason, TerminalStepStopReason, Tool, ToolPlan, ToolResult, Usage } from "operon-agents-core";
3
3
  import type { ExtensionDefinition, ExtensionHost, ExtensionModelRequestResult, ExtensionStepEndResult, ExtensionStepStartResult, ExtensionToolAuthorizeResult, ExtensionToolCallResult, SessionEndReason, SessionStartReason } from "./types.ts";
4
4
  /** Shared shape of the loop-hook contexts the dispatch methods are fed from. */
5
5
  interface StepOrigin {
@@ -13,7 +13,7 @@ export declare class ExtensionRuntime {
13
13
  private readonly definitions;
14
14
  private readonly handlers;
15
15
  private readonly tools;
16
- /** Stable reference: `extensionsCapability` hands this array to the assembler, and `setup`
16
+ /** Stable reference: `extensionsCapability` hands this array to the assembler, and `session`
17
17
  * (which runs at openSession, before any run assembles) fills it in place. */
18
18
  private readonly injectors;
19
19
  /** Slash commands by normalized name — surfaced to the session's registry via the
@@ -25,10 +25,10 @@ export declare class ExtensionRuntime {
25
25
  * memoized log read — zero extra traversal) + everything recorded this session. */
26
26
  private recordSnapshot;
27
27
  private readonly recordWrites;
28
- /** Per-extension teardown: setup's cleanup + every registration, undone in reverse. */
28
+ /** Per-extension teardown: session()'s cleanup + every registration, undone in reverse. */
29
29
  private readonly scopes;
30
30
  /** Extensions whose contributions are currently honored. An id leaves on detach (and on
31
- * setup failure), which turns every `actions`/`api` closure the extension still holds
31
+ * session() failure), which turns every `actions`/`api` closure the extension still holds
32
32
  * into a warn-and-noop — a detached extension must not keep steering the session. */
33
33
  private readonly live;
34
34
  /** Changes wait here until a quiet point: mid-run requests apply at the run's stop
@@ -37,6 +37,7 @@ export declare class ExtensionRuntime {
37
37
  /** Serializes flushes so concurrent attach/detach apply strictly in submission order. */
38
38
  private flushChain;
39
39
  private readonly memoryState;
40
+ /** The session binding: its scope (machine, store, events, steer, log reader, controls) + id + signal. */
40
41
  private session;
41
42
  private run;
42
43
  /** The conversation shard the in-flight decision point belongs to; backs `actions.record`. */
@@ -49,18 +50,18 @@ export declare class ExtensionRuntime {
49
50
  /** Harness reach. Absent when the capability is built standalone (bare Runner / tests). */
50
51
  private readonly host;
51
52
  /** This session's per-session extension arguments, by extension id. A value of `false` means
52
- * "skip this extension for this session"; any other value is passed to `setup` as `params`. */
53
+ * "skip this extension for this session"; any other value is passed to `session` as `params`. */
53
54
  private readonly params;
54
55
  constructor(definitions: readonly ExtensionDefinition[], host?: ExtensionHost, params?: Readonly<Record<string, unknown>>);
55
- open(ctx: SessionContext, reason?: SessionStartReason): Promise<void>;
56
- attachRun(ctx: CapabilityContext): void;
56
+ open(ctx: ProvisionContext, reason?: SessionStartReason): Promise<void>;
57
+ attachRun(ctx: RunContext): void;
57
58
  detachRun(): Promise<void>;
58
59
  close(reason?: SessionEndReason): Promise<void>;
59
60
  /**
60
- * Add an extension to the live session. Resolves once its `setup` has run and its
61
+ * Add an extension to the live session. Resolves once its `session` has run and its
61
62
  * contributions are in place — with no run in flight that is immediately; mid-run it is
62
63
  * the current run's stop boundary, so an in-flight turn never sees its registry change.
63
- * Rejects on duplicate/empty id or when `setup` throws.
64
+ * Rejects on duplicate/empty id or when `session` throws.
64
65
  */
65
66
  attach(definition: ExtensionDefinition): Promise<void>;
66
67
  /** Ids of currently attached extensions — the reshape coordinator's affected-set probe. */
@@ -83,7 +84,7 @@ export declare class ExtensionRuntime {
83
84
  private drainPending;
84
85
  private attachNow;
85
86
  private detachNow;
86
- /** Runs one extension's `setup` and files its scope. Failure ⇒ skipped, contributions undone. */
87
+ /** Runs one extension's `session` and files its scope. Failure ⇒ skipped, contributions undone. */
87
88
  private setupExtension;
88
89
  /**
89
90
  * Durable audit trail: replay needs "tool X only exists after record N" to reconstruct the