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,6 +1,5 @@
1
- import { randomBytes } from "node:crypto";
2
1
  import { ServiceUnavailableError, isProbeProperty } from "./services.js";
3
- import { tagToolSource } from "operon-agents-core";
2
+ import { appendAudit, tagToolSource } from "operon-agents-core";
4
3
  /** Decision points block the loop, so they get room to do real work (network, subprocess). */
5
4
  const DECISION_TIMEOUT_MS = 30_000;
6
5
  /** Observers must not become latency: a slow one is dropped, not waited on. */
@@ -12,37 +11,35 @@ const OBSERVE_EVENTS = new Set([
12
11
  // here is latency on every streamed token, so it gets the short budget.
13
12
  "provider.response",
14
13
  ]);
14
+ /**
15
+ * One extension, running in one session. `extensionCapability` wraps it as that session's
16
+ * capability, so everything around the extension — assembly, hook isolation, attach and detach
17
+ * at a run boundary, its state and records — is the engine's, the same as for any capability.
18
+ * What is left here is the translation the extension API exists for: `api` registrations into
19
+ * capability parts, and the engine's decision points into the extension's event vocabulary.
20
+ */
15
21
  export class ExtensionRuntime {
16
- definitions;
22
+ definition;
17
23
  handlers = new Map();
18
24
  tools = new Map();
19
- /** Stable reference: `extensionsCapability` hands this array to the assembler, and `setup`
25
+ /** Stable reference: `extensionCapability` hands this array to the assembler, and `session`
20
26
  * (which runs at openSession, before any run assembles) fills it in place. */
21
27
  injectors = [];
22
- /** Slash commands by normalized name — surfaced to the session's registry via the
23
- * `sessionCommands()` duck protocol. Two extensions claiming one name fail closed. */
24
- extCommands = new Map();
25
- /** Host-facing control surfaces published via `api.expose`, by extension id. */
26
- exposedHandles = new Map();
27
- /** Records read path: open-time snapshot buckets (built lazily off the session's shared
28
- * memoized log read — zero extra traversal) + everything recorded this session. */
29
- recordSnapshot;
30
- recordWrites = new Map();
31
- /** Per-extension teardown: setup's cleanup + every registration, undone in reverse. */
32
- scopes = new Map();
33
- /** Extensions whose contributions are currently honored. An id leaves on detach (and on
34
- * setup failure), which turns every `actions`/`api` closure the extension still holds
35
- * into a warn-and-noop — a detached extension must not keep steering the session. */
36
- live = new Set();
37
- /** Changes wait here until a quiet point: mid-run requests apply at the run's stop
38
- * boundary, so a turn never sees its toolset change underneath it. */
39
- pending = [];
40
- /** Serializes flushes so concurrent attach/detach apply strictly in submission order. */
41
- flushChain = Promise.resolve();
42
- memoryState = new Map();
28
+ /** Slash commands, filled in place by `api.registerCommand` — the capability's `commands`. */
29
+ commands = [];
30
+ /** The host-facing control surface published via `api.expose`. */
31
+ exposed;
32
+ /** `session()`'s cleanup plus every registration, undone in reverse. */
33
+ teardown;
34
+ /** False until `session` has run, and again once the extension is closed: every `actions` /
35
+ * `api` closure it still holds turns into a warn-and-noop — a detached extension must not
36
+ * keep steering the session. */
37
+ live = false;
38
+ /** The session binding: the services the capability DECLARED, plus the session's id/signal. */
43
39
  session;
44
40
  run;
45
- /** The conversation shard the in-flight decision point belongs to; backs `actions.record`. */
41
+ /** The conversation shard the in-flight decision point belongs to — the address a
42
+ * `compaction.before` handler is told about. */
46
43
  activeContext;
47
44
  /** Snapshot of the last assembled registry, refreshed once per turn by `filterTools`. */
48
45
  allToolNames = [];
@@ -51,210 +48,93 @@ export class ExtensionRuntime {
51
48
  reportingWarning = false;
52
49
  /** Harness reach. Absent when the capability is built standalone (bare Runner / tests). */
53
50
  host;
54
- /** This session's per-session extension arguments, by extension id. A value of `false` means
55
- * "skip this extension for this session"; any other value is passed to `setup` as `params`. */
51
+ /** This session's argument for the extension (`createSession({ params: { [id]: … } })`). */
56
52
  params;
57
- constructor(definitions, host, params = {}) {
58
- this.host = host;
59
- this.params = params;
60
- const seen = new Set();
61
- for (const definition of definitions) {
62
- if (!definition.id.trim())
63
- throw new Error("extension id must not be empty");
64
- // Slug only, colons forbidden: records and state scope by the "extension:<id>:" prefix,
65
- // so an id containing ":" would make one extension's bucket swallow another's.
66
- if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(definition.id)) {
67
- throw new Error(`extension id "${definition.id}" must be a slug ([A-Za-z0-9_.-], no colons)`);
68
- }
69
- if (seen.has(definition.id))
70
- throw new Error(`duplicate extension id "${definition.id}"`);
71
- seen.add(definition.id);
53
+ /** What `session.start` reports: `attach` for an extension added to a live session. */
54
+ startReason;
55
+ constructor(definition, options = {}) {
56
+ if (!definition.id.trim())
57
+ throw new Error("extension id must not be empty");
58
+ // Slug only, colons forbidden: records and state scope by the "extension:<id>:" prefix,
59
+ // so an id containing ":" would make one extension's bucket swallow another's.
60
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(definition.id)) {
61
+ throw new Error(`extension id "${definition.id}" must be a slug ([A-Za-z0-9_.-], no colons)`);
72
62
  }
73
- // A session that opted an extension out (`params[id] === false`) never carries it: not in
74
- // the definition list, never set up, never reported as attached.
75
- this.definitions = definitions.filter((definition) => params[definition.id] !== false);
63
+ this.definition = definition;
64
+ this.host = options.host;
65
+ this.params = options.params;
66
+ this.startReason = options.startReason ?? "open";
67
+ }
68
+ get id() {
69
+ return this.definition.id;
76
70
  }
77
71
  // ==========================================================================
78
72
  // Lifecycle
79
73
  // ==========================================================================
80
- async open(ctx, reason = "open") {
81
- this.session = ctx;
82
- for (const definition of this.definitions) {
83
- await this.setupExtension(definition);
84
- }
74
+ /** Run `session` and fire `session.start`. Throws when `session` does — the capability is then
75
+ * absent from the session, with nothing it registered left behind. */
76
+ async open(services, ctx) {
77
+ this.session = { ...ctx, ...services };
78
+ if (!(await this.setupExtension(this.definition)))
79
+ throw new Error(`extension "${this.id}" session() failed`);
85
80
  for (const registered of [...this.handlersFor("session.start")]) {
86
- await this.invoke("session.start", registered, { ...this.sessionContext(registered.extensionId), reason });
81
+ await this.invoke("session.start", registered, { ...this.sessionContext(this.id), reason: this.startReason });
87
82
  }
83
+ if (this.startReason === "attach")
84
+ await this.logChange("attached");
88
85
  }
89
86
  attachRun(ctx) {
90
87
  this.run = ctx;
91
88
  }
92
- async detachRun() {
89
+ detachRun() {
93
90
  this.run = undefined;
94
91
  this.activeContext = undefined;
95
- // The run just ended — this is the quiet point mid-run attach/detach requests wait for.
96
- await this.flushPending();
97
92
  }
93
+ /** Fire `session.end` while the handlers are still registered, then unwind everything. */
98
94
  async close(reason = "close") {
99
- if (this.session) {
95
+ if (this.session && this.live) {
100
96
  for (const registered of [...this.handlersFor("session.end")]) {
101
- await this.invoke("session.end", registered, { ...this.sessionContext(registered.extensionId), reason });
97
+ await this.invoke("session.end", registered, { ...this.sessionContext(this.id), reason });
102
98
  }
103
99
  }
104
- for (const [id, scope] of [...this.scopes].reverse()) {
100
+ this.live = false;
101
+ const teardown = this.teardown;
102
+ this.teardown = undefined;
103
+ if (teardown) {
105
104
  try {
106
- await scope();
105
+ await teardown();
107
106
  }
108
107
  catch (error) {
109
- await this.warn(id, `teardown failed: ${messageOf(error)}`);
108
+ await this.warn(this.id, `teardown failed: ${messageOf(error)}`);
110
109
  }
111
110
  }
112
- this.scopes.clear();
113
- this.live.clear();
114
- this.extCommands.clear();
115
- this.exposedHandles.clear();
116
- this.recordSnapshot = undefined;
117
- this.recordWrites.clear();
118
- for (const entry of this.pending.splice(0))
119
- entry.reject(new Error("session closed"));
111
+ if (reason === "detach")
112
+ await this.logChange("detached");
113
+ this.commands.length = 0;
114
+ this.exposed = undefined;
120
115
  this.injectors.length = 0;
121
- this.session = undefined;
122
- }
123
- // ==========================================================================
124
- // Hot attach / detach — host API, applied at the next quiet point
125
- // ==========================================================================
126
- /**
127
- * Add an extension to the live session. Resolves once its `setup` has run and its
128
- * contributions are in place — with no run in flight that is immediately; mid-run it is
129
- * the current run's stop boundary, so an in-flight turn never sees its registry change.
130
- * Rejects on duplicate/empty id or when `setup` throws.
131
- */
132
- attach(definition) {
133
- return this.submit({ kind: "attach", definition });
134
- }
135
- /** Ids of currently attached extensions — the reshape coordinator's affected-set probe. */
136
- attachedIds() {
137
- return [...this.live];
138
- }
139
- /** Currently attached extensions with what each declared it `uses` — the unload consumer scan. */
140
- attachedExtensions() {
141
- return this.definitions.filter((definition) => this.live.has(definition.id)).map((definition) => ({ id: definition.id, uses: definition.uses ?? [] }));
142
- }
143
- /** The duck protocol the session's command registry consumes: dynamic commands contributed
144
- * by attached extensions (`api.registerCommand`). */
145
- sessionCommands() {
146
- return [...this.extCommands.values()].map((entry) => entry.command);
147
- }
148
- /** The control surface `extensionId` published via `api.expose`; undefined when absent
149
- * (never attached, detached, or nothing exposed). */
150
- exposedHandle(extensionId) {
151
- return this.exposedHandles.get(extensionId);
116
+ // The session binding stays: a closure the extension stashed must still be able to say, into
117
+ // the event stream, that it was ignored because the extension is gone.
152
118
  }
153
- /** Remove an extension: its `session.end` fires, then its whole scope is torn down. */
154
- detach(id) {
155
- return this.submit({ kind: "detach", id });
119
+ /** The control surface published via `api.expose`; undefined when nothing is (or it was
120
+ * disposed, or the extension is closed). */
121
+ exposedHandle() {
122
+ return this.exposed;
156
123
  }
157
- submit(change) {
158
- return new Promise((resolve, reject) => {
159
- this.pending.push({ ...change, resolve, reject });
160
- if (this.run === undefined)
161
- void this.flushPending();
162
- });
163
- }
164
- flushPending() {
165
- const next = this.flushChain.then(() => this.drainPending());
166
- this.flushChain = next.catch(() => undefined);
167
- return next;
168
- }
169
- async drainPending() {
170
- while (this.pending.length > 0) {
171
- const entry = this.pending.shift();
172
- if (!entry)
173
- break;
174
- try {
175
- if (entry.kind === "attach")
176
- await this.attachNow(entry.definition);
177
- else
178
- await this.detachNow(entry.id);
179
- entry.resolve();
180
- }
181
- catch (error) {
182
- entry.reject(error);
183
- }
184
- }
185
- }
186
- async attachNow(definition) {
187
- if (!definition.id.trim())
188
- throw new Error("extension id must not be empty");
189
- // Slug only, colons forbidden: records and state scope by the "extension:<id>:" prefix,
190
- // so an id containing ":" would make one extension's bucket swallow another's.
191
- if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(definition.id)) {
192
- throw new Error(`extension id "${definition.id}" must be a slug ([A-Za-z0-9_.-], no colons)`);
193
- }
194
- if (this.params[definition.id] === false) {
195
- throw new Error(`extension "${definition.id}" is opted out for this session (params["${definition.id}"] is false)`);
196
- }
197
- if (this.definitions.some((existing) => existing.id === definition.id)) {
198
- throw new Error(`duplicate extension id "${definition.id}"`);
199
- }
200
- this.definitions.push(definition);
201
- // Not open yet: `open()` will run setup with everything else.
202
- if (!this.session)
203
- return;
204
- const ok = await this.setupExtension(definition);
205
- if (!ok) {
206
- const index = this.definitions.findIndex((existing) => existing.id === definition.id);
207
- if (index >= 0)
208
- this.definitions.splice(index, 1);
209
- throw new Error(`extension "${definition.id}" setup failed`);
210
- }
211
- for (const registered of [...this.handlersFor("session.start")]) {
212
- if (registered.extensionId !== definition.id)
213
- continue;
214
- await this.invoke("session.start", registered, { ...this.sessionContext(definition.id), reason: "attach" });
215
- }
216
- await this.logChange("attached", definition.id);
217
- }
218
- async detachNow(id) {
219
- const index = this.definitions.findIndex((existing) => existing.id === id);
220
- if (index < 0)
221
- throw new Error(`unknown extension "${id}"`);
222
- // Notify while its handlers are still registered.
223
- if (this.session && this.live.has(id)) {
224
- for (const registered of [...this.handlersFor("session.end")]) {
225
- if (registered.extensionId !== id)
226
- continue;
227
- await this.invoke("session.end", registered, { ...this.sessionContext(id), reason: "detach" });
228
- }
229
- }
230
- this.definitions.splice(index, 1);
231
- this.live.delete(id);
232
- const scope = this.scopes.get(id);
233
- this.scopes.delete(id);
234
- if (scope) {
235
- try {
236
- await scope();
237
- }
238
- catch (error) {
239
- await this.warn(id, `teardown failed: ${messageOf(error)}`);
240
- }
241
- }
242
- if (this.session)
243
- await this.logChange("detached", id);
244
- }
245
- /** Runs one extension's `setup` and files its scope. Failure ⇒ skipped, contributions undone. */
124
+ /** Runs one extension's `session` and files its scope. Failure ⇒ skipped, contributions undone. */
246
125
  async setupExtension(definition) {
247
- const params = this.params[definition.id];
248
- // A `create`-bearing definition reaches a session only after the harness ran its `create`
249
- // and registered the result under the id. Not registered ⇒ it was handed to a session
250
- // directly instead of being registered — a programming error, so it throws rather than
251
- // being skipped.
252
- if (definition.create !== undefined && this.host?.services?.has(definition.id) !== true) {
253
- throw new Error(`extension "${definition.id}" has a create half but no service "${definition.id}" is registered — its create never ran. Register it in createHarness({ extensions }) or load it from extensionDir; a definition with create cannot be handed to a session directly`);
254
- }
255
- const shared = definition.create !== undefined ? this.serviceHandle(definition.id, definition.id) : undefined;
126
+ const params = this.params;
127
+ // A definition with a shared half reaches a session only after that half ran — the harness
128
+ // ran `harness` once and registered the result under the id. Not registered ⇒ it was handed
129
+ // to a session directly instead of being registered — a programming error, so it throws
130
+ // rather than being skipped.
131
+ const sharedHalf = definition.harness !== undefined;
132
+ if (sharedHalf && this.host?.services?.has(definition.id) !== true) {
133
+ throw new Error(`extension "${definition.id}" has a shared half but no service "${definition.id}" is reachable from this session — its harness half never ran. Register it in createHarness({ extensions }) or load it from extensionDir; a definition with a shared half cannot be handed to a session directly`);
134
+ }
135
+ const shared = sharedHalf ? this.serviceHandle(definition.id, definition.id) : undefined;
256
136
  const registrations = [];
257
- this.live.add(definition.id);
137
+ this.live = true;
258
138
  try {
259
139
  // `uses`: checked at registration, re-checked here (a provider may have unloaded since)
260
140
  // and handed in resolved — an extension never looks a service up by name.
@@ -264,40 +144,41 @@ export class ExtensionRuntime {
264
144
  throw new Error(`uses "${name}": no such service is registered`);
265
145
  services[name] = this.serviceHandle(definition.id, name);
266
146
  }
267
- const teardown = await definition.setup(this.apiFor(definition, registrations), { shared, params, services });
268
- this.scopes.set(definition.id, async () => {
147
+ const teardown = await definition.session(this.apiFor(definition, registrations), { shared, params, services });
148
+ this.teardown = async () => {
269
149
  if (teardown)
270
150
  await teardown();
271
151
  for (const dispose of [...registrations].reverse())
272
152
  dispose();
273
- });
153
+ };
274
154
  return true;
275
155
  }
276
156
  catch (error) {
277
- this.live.delete(definition.id);
157
+ this.live = false;
278
158
  for (const dispose of [...registrations].reverse())
279
159
  dispose();
280
- await this.warn(definition.id, `setup failed; extension skipped: ${messageOf(error)}`);
160
+ await this.warn(definition.id, `session() failed; extension skipped: ${messageOf(error)}`);
281
161
  return false;
282
162
  }
283
163
  }
284
164
  /**
285
- * Durable audit trail: replay needs "tool X only exists after record N" to reconstruct the
286
- * capability timeline. Rides the `custom` record type — audit-only, ignored by reducers.
165
+ * Durable audit trail of when this extension joined or left the session — written to the
166
+ * session's audit shard, which nothing replays from.
287
167
  */
288
- async logChange(kind, extensionId) {
168
+ async logChange(kind) {
169
+ const extensionId = this.id;
289
170
  const store = this.session?.store;
290
171
  if (!store)
291
172
  return;
292
173
  try {
293
- await store.appendRecord({ type: "custom", name: `extensions.${kind}`, data: { extensionId } });
174
+ await appendAudit(store, { type: "custom", name: `extensions.${kind}`, data: { extensionId } });
294
175
  }
295
176
  catch (error) {
296
177
  await this.warn(extensionId, `failed to journal extension ${kind}: ${messageOf(error)}`);
297
178
  }
298
179
  }
299
180
  listTools() {
300
- return [...this.tools.values()].map((entry) => entry.tool);
181
+ return [...this.tools.values()];
301
182
  }
302
183
  listInjectors() {
303
184
  return this.injectors;
@@ -447,75 +328,55 @@ export class ExtensionRuntime {
447
328
  changed = true;
448
329
  }
449
330
  }
450
- // Fold the provider-tier callbacks into the request pi-ai will actually send. They cannot be
451
- // driven from here — pi-ai invokes them from inside its HTTP path — so the only way to reach
452
- // them is to ride along on `providerOptions`.
453
- const withProvider = this.withProviderHooks(current, origin);
454
- if (withProvider !== undefined) {
455
- current = withProvider;
456
- changed = true;
457
- }
458
331
  return changed ? { request: current } : undefined;
459
332
  }
460
333
  /**
461
- * Attach `transformHeaders` / `onPayload` / `onResponse` when anything is listening. Returns
462
- * `undefined` when nothing is, so a request with no provider-tier extensions is left byte-identical.
463
- *
464
- * These fire once per HTTP ATTEMPT, so a retry re-runs them — the contract tells handlers to be
465
- * idempotent, and the runtime does not try to dedupe on their behalf.
334
+ * The capability's provider hooks — `provider.headers` / `payload` / `response`. The engine
335
+ * composes them into the request with every other capability's, so two extensions rewriting
336
+ * headers both take effect instead of the second replacing the first. They fire once per HTTP
337
+ * ATTEMPT, so a retry re-runs them — the contract tells handlers to be idempotent, and the
338
+ * runtime does not try to dedupe on their behalf.
466
339
  */
467
- withProviderHooks(request, origin) {
468
- const headerHandlers = this.handlersFor("provider.headers");
469
- const payloadHandlers = this.handlersFor("provider.payload");
470
- const responseHandlers = this.handlersFor("provider.response");
471
- if (headerHandlers.length === 0 && payloadHandlers.length === 0 && responseHandlers.length === 0) {
472
- return undefined;
473
- }
474
- const base = { turnId: origin.turnId, stepNumber: origin.stepNumber };
475
- const providerOptions = { ...request.providerOptions };
476
- if (headerHandlers.length > 0) {
477
- providerOptions.transformHeaders = async (headers) => {
478
- let current = headers;
479
- for (const registered of headerHandlers) {
480
- const result = await this.invoke("provider.headers", registered, {
481
- ...this.eventContext(registered.extensionId, origin),
482
- ...base,
483
- headers: current,
484
- });
485
- if (result?.headers !== undefined)
486
- current = result.headers;
487
- }
488
- return current;
489
- };
490
- }
491
- if (payloadHandlers.length > 0) {
492
- providerOptions.onPayload = async (payload) => {
493
- let current = payload;
494
- for (const registered of payloadHandlers) {
495
- const result = await this.invoke("provider.payload", registered, {
496
- ...this.eventContext(registered.extensionId, origin),
497
- ...base,
498
- payload: current,
499
- });
500
- if (result !== undefined && "payload" in result)
501
- current = result.payload;
502
- }
503
- return current;
504
- };
505
- }
506
- if (responseHandlers.length > 0) {
507
- providerOptions.onResponse = async (response) => {
508
- for (const registered of responseHandlers) {
509
- await this.invoke("provider.response", registered, {
510
- ...this.eventContext(registered.extensionId, origin),
511
- ...base,
512
- status: response.status,
513
- headers: response.headers,
514
- });
340
+ providerHooks = {
341
+ headers: async (headers, ctx) => {
342
+ let current = headers;
343
+ for (const registered of this.handlersFor("provider.headers")) {
344
+ const result = await this.invoke("provider.headers", registered, { ...this.providerContext(ctx), headers: current });
345
+ if (result?.headers !== undefined)
346
+ current = result.headers;
347
+ }
348
+ return current;
349
+ },
350
+ payload: async (payload, ctx) => {
351
+ let current = payload;
352
+ let changed = false;
353
+ for (const registered of this.handlersFor("provider.payload")) {
354
+ const result = await this.invoke("provider.payload", registered, { ...this.providerContext(ctx), payload: current });
355
+ if (result !== undefined && "payload" in result) {
356
+ current = result.payload;
357
+ changed = true;
515
358
  }
516
- };
517
- }
518
- return { ...request, providerOptions };
359
+ }
360
+ return changed ? { payload: current } : undefined;
361
+ },
362
+ response: async (response, ctx) => {
363
+ for (const registered of this.handlersFor("provider.response")) {
364
+ await this.invoke("provider.response", registered, { ...this.providerContext(ctx), status: response.status, headers: response.headers });
365
+ }
366
+ },
367
+ };
368
+ /** Whether any provider-tier handler is registered — the capability omits `provider` otherwise,
369
+ * so a request with no provider-tier extension is left byte-identical. */
370
+ hasProviderHandlers() {
371
+ return this.handlersFor("provider.headers").length + this.handlersFor("provider.payload").length + this.handlersFor("provider.response").length > 0;
372
+ }
373
+ providerContext(ctx) {
374
+ const session = this.requireSession();
375
+ return {
376
+ ...this.eventContext(this.id, { ...(ctx.address !== undefined ? { address: ctx.address } : {}), signal: session.signal }),
377
+ turnId: ctx.turnId,
378
+ stepNumber: ctx.stepNumber,
379
+ };
519
380
  }
520
381
  async afterModelResponse(origin, request, response, context) {
521
382
  this.activeContext = context;
@@ -609,7 +470,7 @@ export class ExtensionRuntime {
609
470
  apiFor(definition, registrations) {
610
471
  return {
611
472
  on: (event, handler) => {
612
- if (!this.live.has(definition.id)) {
473
+ if (!this.live) {
613
474
  void this.warn(definition.id, `on("${event}") ignored: extension is detached.`);
614
475
  return () => undefined;
615
476
  }
@@ -630,11 +491,11 @@ export class ExtensionRuntime {
630
491
  return dispose;
631
492
  },
632
493
  onEvent: (listener) => {
633
- if (!this.live.has(definition.id)) {
494
+ if (!this.live) {
634
495
  void this.warn(definition.id, "onEvent() ignored: extension is detached.");
635
496
  return () => undefined;
636
497
  }
637
- // `setup` runs inside `open`, so the session context is already in place.
498
+ // `session` runs inside `open`, so the session context is already in place.
638
499
  const sink = this.session?.events;
639
500
  if (sink === undefined)
640
501
  return () => undefined;
@@ -654,27 +515,26 @@ export class ExtensionRuntime {
654
515
  // A plain spec (file extensions — no framework imports, so no `tool()` helper) is
655
516
  // expanded here; a real Tool passes through untouched.
656
517
  const tool = "schema" in toolOrSpec ? toolOrSpec : toolFromSpec(toolOrSpec);
657
- if (!this.live.has(definition.id)) {
518
+ if (!this.live) {
658
519
  void this.warn(definition.id, `registerTool("${tool.schema.name}") ignored: extension is detached.`);
659
520
  return () => undefined;
660
521
  }
661
522
  const name = tool.schema.name;
662
- const existing = this.tools.get(name);
663
- if (existing) {
664
- throw new Error(`extension tool "${name}" from "${definition.id}" collides with "${existing.extensionId}"`);
665
- }
666
- this.tools.set(name, { extensionId: definition.id, tool });
667
- // Core's toolset assembly reads this tag to fail a name collision closed, naming us.
523
+ if (this.tools.has(name))
524
+ throw new Error(`extension "${definition.id}" registered tool "${name}" twice`);
525
+ this.tools.set(name, tool);
526
+ // Core's toolset assembly reads this tag to fail a collision with ANOTHER capability's
527
+ // tool closed, naming us.
668
528
  tagToolSource(tool, definition.id);
669
529
  const dispose = () => {
670
- if (this.tools.get(name)?.tool === tool)
530
+ if (this.tools.get(name) === tool)
671
531
  this.tools.delete(name);
672
532
  };
673
533
  registrations.push(dispose);
674
534
  return dispose;
675
535
  },
676
536
  registerInjector: (injector) => {
677
- if (!this.live.has(definition.id)) {
537
+ if (!this.live) {
678
538
  void this.warn(definition.id, `registerInjector("${injector.id}") ignored: extension is detached.`);
679
539
  return () => undefined;
680
540
  }
@@ -688,14 +548,13 @@ export class ExtensionRuntime {
688
548
  return dispose;
689
549
  },
690
550
  registerCommand: (command) => {
691
- if (!this.live.has(definition.id)) {
551
+ if (!this.live) {
692
552
  void this.warn(definition.id, `registerCommand("/${command.name}") ignored: extension is detached.`);
693
553
  return () => undefined;
694
554
  }
695
555
  const key = command.name.trim().toLowerCase();
696
- const existing = this.extCommands.get(key);
697
- if (existing) {
698
- throw new Error(`extension command "/${key}" from "${definition.id}" collides with "${existing.extensionId}"`);
556
+ if (this.commands.some((existing) => existing.name.trim().toLowerCase() === key)) {
557
+ throw new Error(`extension "${definition.id}" registered command "/${key}" twice`);
699
558
  }
700
559
  const headless = {
701
560
  name: command.name,
@@ -703,16 +562,17 @@ export class ExtensionRuntime {
703
562
  description: command.description,
704
563
  run: async (_ctx, args) => command.run(args),
705
564
  };
706
- this.extCommands.set(key, { extensionId: definition.id, command: headless });
565
+ this.commands.push(headless);
707
566
  const dispose = () => {
708
- if (this.extCommands.get(key)?.command === headless)
709
- this.extCommands.delete(key);
567
+ const index = this.commands.indexOf(headless);
568
+ if (index >= 0)
569
+ this.commands.splice(index, 1);
710
570
  };
711
571
  registrations.push(dispose);
712
572
  return dispose;
713
573
  },
714
574
  emitEvent: (name, data) => {
715
- if (!this.live.has(definition.id)) {
575
+ if (!this.live) {
716
576
  void this.warn(definition.id, `emitEvent("${name}") ignored: extension is detached.`);
717
577
  return;
718
578
  }
@@ -731,27 +591,24 @@ export class ExtensionRuntime {
731
591
  });
732
592
  },
733
593
  expose: (handle) => {
734
- if (!this.live.has(definition.id)) {
594
+ if (!this.live) {
735
595
  void this.warn(definition.id, "expose() ignored: extension is detached.");
736
596
  return () => undefined;
737
597
  }
738
- this.exposedHandles.set(definition.id, handle);
598
+ this.exposed = handle;
739
599
  const dispose = () => {
740
- if (this.exposedHandles.get(definition.id) === handle)
741
- this.exposedHandles.delete(definition.id);
600
+ if (this.exposed === handle)
601
+ this.exposed = undefined;
742
602
  };
743
603
  registrations.push(dispose);
744
604
  return dispose;
745
605
  },
746
- records: async () => {
747
- const snapshot = (await this.ensureRecordSnapshot()).get(definition.id) ?? [];
748
- const writes = this.recordWrites.get(definition.id) ?? [];
749
- return [...snapshot, ...writes];
750
- },
606
+ // The engine keeps both, partitioned by this capability's name — the extension's id.
607
+ records: () => this.requireSession().records(),
751
608
  state: {
752
- get: (key) => this.stateFor(definition.id, this.session?.store).get(key),
753
- set: (key, value) => this.stateFor(definition.id, this.session?.store).set(key, value),
754
- delete: (key) => this.stateFor(definition.id, this.session?.store).delete(key),
609
+ get: (key) => this.requireSession().state.get(key),
610
+ set: (key, value) => this.requireSession().state.set(key, value),
611
+ delete: (key) => this.requireSession().state.delete(key),
755
612
  },
756
613
  actions: this.actionsFor(definition.id),
757
614
  };
@@ -777,13 +634,13 @@ export class ExtensionRuntime {
777
634
  get(_target, prop) {
778
635
  if (isProbeProperty(prop))
779
636
  return undefined;
780
- if (!runtime.live.has(extensionId))
637
+ if (!runtime.live)
781
638
  return dead();
782
639
  const inner = services.handle(name)[prop];
783
640
  if (typeof inner !== "function")
784
641
  return inner;
785
642
  return (...args) => {
786
- if (!runtime.live.has(extensionId))
643
+ if (!runtime.live)
787
644
  return dead()();
788
645
  return inner(...args);
789
646
  };
@@ -791,7 +648,7 @@ export class ExtensionRuntime {
791
648
  // Forward `in` so consumers can probe registration ("route" in handle) through the
792
649
  // collar; a detached extension's probe reads false like a dead handle's.
793
650
  has: (_target, prop) => {
794
- if (!runtime.live.has(extensionId))
651
+ if (!runtime.live)
795
652
  return false;
796
653
  return Reflect.has(services.handle(name), prop);
797
654
  },
@@ -819,7 +676,7 @@ export class ExtensionRuntime {
819
676
  return new Proxy(actions, {
820
677
  get(target, prop, receiver) {
821
678
  const value = Reflect.get(target, prop, receiver);
822
- if (typeof value !== "function" || runtime.live.has(extensionId))
679
+ if (typeof value !== "function" || runtime.live)
823
680
  return value;
824
681
  return (..._args) => {
825
682
  void runtime.warn(extensionId, `${String(prop)}() ignored: extension is detached.`);
@@ -840,19 +697,15 @@ export class ExtensionRuntime {
840
697
  return this.revocable(extensionId, {
841
698
  steer: (content, options) => runtime.enqueue(extensionId, content, "steering", options?.metadata),
842
699
  followUp: (content, options) => runtime.enqueue(extensionId, content, "follow_up", options?.metadata),
700
+ // Written by the engine to the session's audit shard as `extension:<id>:<name>`, and seen
701
+ // by `records()` straight away.
843
702
  record: (name, data) => {
844
- const context = runtime.activeContext;
845
- if (!context) {
846
- void runtime.warn(extensionId, `record("${name}") ignored: no active conversation.`);
703
+ const session = runtime.session;
704
+ if (!session) {
705
+ void runtime.warn(extensionId, `record("${name}") ignored: no open session.`);
847
706
  return;
848
707
  }
849
- context.record({ type: "custom", name: `extension:${extensionId}:${name}`, data });
850
- // Mirror into the in-memory bucket so `records()` sees this session's writes without
851
- // re-reading the log (the open-time memoized read predates them).
852
- const bucket = runtime.recordWrites.get(extensionId) ?? [];
853
- if (!runtime.recordWrites.has(extensionId))
854
- runtime.recordWrites.set(extensionId, bucket);
855
- bucket.push({ name, ...(data !== undefined ? { data } : {}) });
708
+ session.record(name, data);
856
709
  },
857
710
  abort: (reason) => {
858
711
  const controls = runtime.controls();
@@ -942,7 +795,7 @@ export class ExtensionRuntime {
942
795
  * schema untouched. `source` names the extension so a fold can attribute the message.
943
796
  */
944
797
  enqueue(extensionId, content, channel, metadata) {
945
- const bus = this.run?.steer ?? this.session?.steer;
798
+ const bus = this.session?.steer;
946
799
  if (!bus) {
947
800
  void this.warn(extensionId, `${channel === "steering" ? "steer" : "followUp"}() ignored: no steer bus.`);
948
801
  return undefined;
@@ -975,82 +828,31 @@ export class ExtensionRuntime {
975
828
  // ==========================================================================
976
829
  sessionContext(extensionId) {
977
830
  const session = this.requireSession();
831
+ const store = session.store;
978
832
  return {
979
833
  extensionId,
980
834
  sessionId: session.sessionId,
981
835
  signal: session.signal,
982
- machine: session.machine,
983
- store: session.store,
984
- state: this.stateFor(extensionId, session.store),
836
+ environment: session.environment,
837
+ store,
838
+ state: session.state,
985
839
  actions: this.actionsFor(extensionId),
986
840
  };
987
841
  }
988
842
  eventContext(extensionId, origin) {
989
843
  const session = this.requireSession();
844
+ const store = session.store;
990
845
  return {
991
846
  extensionId,
992
847
  sessionId: session.sessionId,
993
848
  address: origin.address ?? "main",
994
849
  signal: origin.signal,
995
- machine: this.run?.machine ?? session.machine,
996
- store: session.store,
997
- state: this.stateFor(extensionId, session.store),
850
+ environment: session.environment,
851
+ store,
852
+ state: session.state,
998
853
  actions: this.actionsFor(extensionId),
999
854
  };
1000
855
  }
1001
- /** Open-time record buckets, built ONCE off the session's shared memoized log read — the
1002
- * same single read goal/plan/todo fold from, so `records()` adds zero log traversals. One
1003
- * filter pass buckets every extension's records; ids are colon-free slugs so the
1004
- * "extension:<id>:" prefix parse is unambiguous. */
1005
- async ensureRecordSnapshot() {
1006
- if (this.recordSnapshot !== undefined)
1007
- return this.recordSnapshot;
1008
- const buckets = new Map();
1009
- const log = this.session?.logRecords !== undefined ? await this.session.logRecords() : [];
1010
- for (const record of log) {
1011
- if (record.type !== "custom")
1012
- continue;
1013
- const full = record.name;
1014
- if (typeof full !== "string" || !full.startsWith("extension:"))
1015
- continue;
1016
- const rest = full.slice("extension:".length);
1017
- const sep = rest.indexOf(":");
1018
- if (sep <= 0)
1019
- continue;
1020
- const id = rest.slice(0, sep);
1021
- const data = record.data;
1022
- const bucket = buckets.get(id) ?? [];
1023
- if (!buckets.has(id))
1024
- buckets.set(id, bucket);
1025
- bucket.push({ name: rest.slice(sep + 1), ...(data !== undefined ? { data } : {}) });
1026
- }
1027
- this.recordSnapshot = buckets;
1028
- return buckets;
1029
- }
1030
- stateFor(extensionId, store) {
1031
- const prefix = `extension:${extensionId}:`;
1032
- return {
1033
- get: async (key) => {
1034
- const full = prefix + key;
1035
- const value = store ? await store.getState(full) : this.memoryState.get(full) ?? null;
1036
- return value;
1037
- },
1038
- set: async (key, value) => {
1039
- const full = prefix + key;
1040
- if (store)
1041
- await store.putState(full, value);
1042
- else
1043
- this.memoryState.set(full, value);
1044
- },
1045
- delete: async (key) => {
1046
- const full = prefix + key;
1047
- if (store)
1048
- await store.deleteState(full);
1049
- else
1050
- this.memoryState.delete(full);
1051
- },
1052
- };
1053
- }
1054
856
  handlersFor(name) {
1055
857
  return (this.handlers.get(name) ?? []);
1056
858
  }