@matatbread/matbot-core 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.3.7",
7
+ "version": "0.4.0",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@matatbread/matbot-plugin-api": "^0.3.7"
38
+ "@matatbread/matbot-plugin-api": "^0.4.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
@@ -26,6 +26,11 @@ function asRecord(v: YamlValue | undefined, label: string): YamlMap {
26
26
  throw new Error(`Config: expected mapping for "${label}", got ${v === undefined ? 'undefined' : typeof v}`);
27
27
  }
28
28
 
29
+ function asNumber(v: YamlValue | undefined, label: string): number {
30
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
31
+ throw new Error(`Config: expected number for "${label}", got ${v === undefined ? 'undefined' : typeof v}`);
32
+ }
33
+
29
34
  function toModelParameters(raw: YamlMap): ModelParameters {
30
35
  const params: ModelParameters = {};
31
36
  for (const [k, v] of Object.entries(raw)) {
@@ -52,8 +57,14 @@ function toProviderConfig(name: string, raw: YamlMap): ProviderConfig {
52
57
  if (raw['endpoint'] !== undefined) {
53
58
  config.endpoint = asString(raw['endpoint'], `providers.${name}.endpoint`);
54
59
  }
55
- if (raw['fallback'] !== undefined) {
56
- config.fallback = asString(raw['fallback'], `providers.${name}.fallback`);
60
+ if (raw['maxRounds'] !== undefined) {
61
+ const rounds = asNumber(raw['maxRounds'], `providers.${name}.maxRounds`);
62
+ // Rejected at the boundary rather than clamped: 0 or a fraction is a typo, and silently treating it
63
+ // as "no turn may do anything" would look like the provider was broken.
64
+ if (!Number.isInteger(rounds) || rounds < 1) {
65
+ throw new Error(`Config: "providers.${name}.maxRounds" must be a positive integer, got ${rounds}`);
66
+ }
67
+ config.maxRounds = rounds;
57
68
  }
58
69
  if (raw['parameters'] !== undefined) {
59
70
  config.parameters = toModelParameters(asRecord(raw['parameters'], `providers.${name}.parameters`));
package/src/hooks.ts CHANGED
@@ -1 +1 @@
1
- export { HookRegistry } from '@matatbread/matbot-plugin-api';
1
+ export { HookRegistry } from '@matatbread/matbot-plugin-api/host';
package/src/index.ts CHANGED
@@ -2,32 +2,38 @@ export type * from './types.js';
2
2
  // matbot's typed errors are duck-typed: factory `xError()` builders + `isXError()` brand guards are
3
3
  // runtime values (re-exported here, since the `export type *` above would strip their value meaning),
4
4
  // while the `XError` shapes are types (re-exported below). StoreQueryError is still a class value.
5
+ // Every branded error, uniformly: a partial list meant a consumer importing from core got some guards and
6
+ // silently missed others (readOnlyError/isReadOnlyError were absent, though the pump depends on the guard).
5
7
  export {
6
8
  missingSecretError, isMissingSecretError,
7
9
  incompatibleRuntimeError, isIncompatibleRuntimeError,
8
10
  notAPluginError, isNotAPluginError,
9
11
  promptCancelledError, isPromptCancelledError,
12
+ readOnlyError, isReadOnlyError,
10
13
  StoreQueryError,
11
14
  } from '@matatbread/matbot-plugin-api';
12
15
  export type {
13
- MissingSecretError, IncompatibleRuntimeError, NotAPluginError, PromptCancelledError, MatbotErrorKind,
16
+ MissingSecretError, IncompatibleRuntimeError, NotAPluginError, PromptCancelledError, ReadOnlyError,
17
+ MatbotErrorKind,
14
18
  } from '@matatbread/matbot-plugin-api';
15
19
  export { applyCreateSecret } from '@matatbread/matbot-plugin-api';
16
- // unifyServices is a runtime fn; the `export type *` above would otherwise strip its value meaning.
17
- export { unifyServices } from '@matatbread/matbot-plugin-api';
18
- // Same: pure runtime helpers that would otherwise be stripped to type-only by `export type *`.
19
- export { forwardingProxy, makeSwappable, singleTurnRequest, createMountTable } from '@matatbread/matbot-plugin-api';
20
- // Observation-stream primitives (broadcaster + the bare-subscribe wrapper); the host wires `mounted`.
21
- export { createBroadcaster, subscribable } from '@matatbread/matbot-plugin-api';
22
- export type { Subscribable, Broadcaster } from '@matatbread/matbot-plugin-api';
23
- // Ambient security principal: interface is a type (carried by `export type *`); these are the values.
20
+ export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind } from '@matatbread/matbot-plugin-api';
21
+ // Ambient security principal: the interface is a type (carried by `export type *`); these are the values.
22
+ export { currentPrincipal, tryCurrentPrincipal, runAs, lastActivityAt } from '@matatbread/matbot-plugin-api';
23
+ // Host boot assembly, from plugin-api's `/host` subpath. An embedding app gets these through core and
24
+ // needs no direct dependency on plugin-api; a *plugin* has no business with any of them, which is why
25
+ // they are not on the plugin-api root. Re-exported here (not `export type *`ed) so their value meaning
26
+ // survives.
24
27
  export {
25
- installPrincipalCarrier, currentPrincipal, tryCurrentPrincipal,
26
- runAs, enterPrincipal, createConstantPrincipalCarrier,
28
+ unifyServices, forwardingProxy, makeSwappable, singleTurnRequest, createMountTable,
29
+ createBroadcaster, subscribable, createNotifier, scopedNotifier,
30
+ installPrincipalCarrier, enterPrincipal, createConstantPrincipalCarrier,
27
31
  contextSwitch, onContextQuiesce, flushIfQuiescent,
28
32
  installUsageCarrier, createSerialUsageCarrier, recordUsage, currentUsageSink, withUsageScope,
29
- lastActivityAt,
30
- } from '@matatbread/matbot-plugin-api';
33
+ } from '@matatbread/matbot-plugin-api/host';
34
+ export type {
35
+ Subscribable, Broadcaster, Routed, RoutedFilter, SwapFn, MountTable, PrincipalCarrier, UsageCarrier,
36
+ } from '@matatbread/matbot-plugin-api/host';
31
37
  export * from './session.js';
32
38
  export * from './usage.js';
33
39
  export * from './hooks.js';
package/src/loader.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { MatbotPlugin, MatbotPluginSpec, MatbotMachine, PluginSource, Runtime } from './plugin.js';
2
2
  import type { PromptFn } from './types.js';
3
3
  import { incompatibleRuntimeError, notAPluginError } from '@matatbread/matbot-plugin-api';
4
- import { registerPlugin, setupPlugin, unloadPlugin, recordFailedPlugin, clearFailedPlugin } from './registry.js';
4
+ import { registerPlugin, setupPlugin, unloadPlugin, recordFailedPlugin, clearFailedPlugin, announcePluginLoaded } from './registry.js';
5
5
 
6
6
  /**
7
7
  * The runtime this process is executing in. Detected the same way the rejected-import branch below
@@ -235,6 +235,9 @@ export async function loadPlugins(
235
235
  try {
236
236
  registerPlugin(plugin);
237
237
  registered = true;
238
+ // Announced at registration, not after setup: a setup throw rolls back through unloadPlugin, which
239
+ // announces the removal — so an observer sees a matched add/remove pair rather than a bare removal.
240
+ announcePluginLoaded(services, plugin.name);
238
241
  await setupPlugin(plugin, services, prompt);
239
242
  loaded.push(plugin);
240
243
  // Successful (re)load: a fixed plugin drops off the failed list (reload runs this same path).
package/src/plugin.ts CHANGED
@@ -1 +1,5 @@
1
+ // Core's view of the plugin API is the whole of it: core *is* a host, so it needs the boot-assembly
2
+ // half (`/host`) as well as the author-facing root. Everything reached through this module is therefore
3
+ // available to core internals and, via core's index, to an embedding app.
1
4
  export * from '@matatbread/matbot-plugin-api';
5
+ export * from '@matatbread/matbot-plugin-api/host';
package/src/registry.ts CHANGED
@@ -1,11 +1,11 @@
1
- import type { Tool, ToolRegistry, Hook, PromptFn, FormField, FrontendInfo, PluginRegistryEvent, ProviderAdapter, ProviderConfig } from './types.js';
2
- import { createBroadcaster } from '@matatbread/matbot-plugin-api';
1
+ import type { Tool, ToolRegistry, Hook, PromptFn, FormField, FrontendInfo, ProviderAdapter, ProviderConfig } from './types.js';
2
+ import { RegistryChangeKind } from '@matatbread/matbot-plugin-api';
3
+ import { scopedNotifier } from '@matatbread/matbot-plugin-api/host';
3
4
  import type {
4
5
  MatbotPlugin, MatbotMachine, MatbotRuntime, Mounted,
5
6
  ProviderAdapterFactory, StoreFactory,
6
7
  } from './plugin.js';
7
8
  import { PLUGIN_API_VERSION, unifyServices } from './plugin.js';
8
- import { HookRegistry } from './hooks.js';
9
9
  import { makePluginSettings } from './settings.js';
10
10
  import type { SettingsDoc } from './settings.js';
11
11
 
@@ -30,18 +30,18 @@ const state = {
30
30
  serviceKeys: new Map<string, string[]>(), // pluginName → MatbotMachine keys it registered
31
31
  hookPlugins: new Set<string>(), // plugins that registered at least one hook
32
32
  systemContextPlugins: new Set<string>(), // plugins that registered a system-context contributor
33
+ lifecycles: new Map<string, AbortController>(), // pluginName → "this plugin is loaded" signal, aborted on unload
33
34
  failedPlugins: [] as FailedPlugin[], // plugins the loader skipped, keyed by specifier (last failure wins)
34
35
  overwriteAllTools: undefined as boolean | undefined, // persisted "overwrite on collision, this install" choice, loaded lazily
35
36
  };
36
37
 
37
- // Read-only observation of plugin load/unload, for consumers that key off plugin presence (e.g. the
38
- // web plugins panel refreshing live when a backend restores a plugin set out of band). Module-level,
39
- // not in `state` — it owns subscribers across the registry's lifetime, not per-plugin data.
40
- const pluginEvents = createBroadcaster<PluginRegistryEvent>();
41
-
42
- export async function* watchPlugins(signal?: AbortSignal): AsyncIterable<PluginRegistryEvent> {
43
- // Plugin CRUD is global — no origin — so unwrap the envelope to the bare event the panel expects.
44
- for await (const { value } of pluginEvents.subscribe(signal)) yield value;
38
+ // Plugin load/unload is announced on the Notifier as a `RegistryChange` with `registry: 'plugins'`, for
39
+ // consumers that key off plugin presence (e.g. the web plugins panel refreshing live when a backend
40
+ // restores a plugin set out of band). It was a module-level broadcaster of its own, which is the same
41
+ // primitive the bus already is. `loaded` is announced by the loader — the only caller of
42
+ // registerPlugin, and the one place holding the machine this registry deliberately doesn't.
43
+ export function announcePluginLoaded(services: MatbotMachine, name: string): void {
44
+ services.Notifier.notify({ kind: RegistryChangeKind, source: 'plugins', registry: 'plugins', name, operation: 'added' });
45
45
  }
46
46
 
47
47
  // Settings namespace + key under which the user's "overwrite all colliding tools" choice
@@ -150,8 +150,6 @@ export function registerPlugin(plugin: MatbotPlugin): void {
150
150
  for (const [type, factory] of Object.entries(plugin.storage ?? {})) {
151
151
  state.storage.set(type, factory);
152
152
  }
153
-
154
- pluginEvents.emit({ type: 'loaded', name: plugin.name });
155
153
  }
156
154
 
157
155
  // ── Resolution ────────────────────────────────────────────────────────────────
@@ -288,16 +286,43 @@ export function getSpecifierForPlugin(pluginName: string): string | undefined {
288
286
  export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine, prompt?: PromptFn): Promise<void> {
289
287
  state.toolRegistry ??= services.tools;
290
288
 
289
+ // This plugin's load extent. The mount table's only cleanup path is an aborted signal, and the
290
+ // option is optional — so an author who omits it left an interest that outlives the unload and
291
+ // keeps firing into a torn-down closure (once per reload generation). Ownership is the host's to
292
+ // know, not the author's to remember: every scoped observe() is bound to this signal, so `signal`
293
+ // in the options is a narrowing convenience ("stop earlier than my unload"), never load-bearing.
294
+ const previous = state.lifecycles.get(plugin.name);
295
+ previous?.abort(); // a reload that skipped unloadPlugin
296
+ const lifecycle = new AbortController();
297
+ state.lifecycles.set(plugin.name, lifecycle);
298
+
291
299
  // Single choke point for every plugin tool registration (static `plugin.tools` and in-setup
292
300
  // `services.tools.register`). Stamps ownership and resolves name collisions. The no-collision
293
301
  // path runs synchronously (an async fn yields nothing before its first await), so fire-and-forget
294
- // callers that don't await still get the tool registered in the same tick.
302
+ // callers that don't await still get the tool registered in the same tick — which is the whole
303
+ // reason `ToolRegistry.register` returns `void`, and every one of its ~34 call sites relies on it.
304
+ //
305
+ // That makes the collision branch the one place with an await and no caller to own its outcome, so
306
+ // it owns its own: a throw from the settings read/write or the prompt (a non-interactive `PromptFn`
307
+ // rejects with PromptCancelledError) would otherwise be an unhandled rejection — process exit under
308
+ // Node's default — reached by the ordinary act of two plugins claiming one tool name. Keep-existing
309
+ // is the safe resolution, so a failure resolves to it. The abort check closes the other end: setup()
310
+ // has already returned by the time a slow collision resolves, so an unload (or a setup() throw and
311
+ // its rollback) can land first, and re-inserting the tool would revive one owned by a gone plugin.
295
312
  const registerTool = async (tool: Tool): Promise<void> => {
296
313
  const stamped: Tool = { ...tool, pluginName: plugin.name };
297
314
  const existing = services.tools.resolve(stamped.name);
298
315
  if (existing !== null && existing.pluginName !== plugin.name) {
299
- const overwrite = await resolveToolCollision(services, stamped.name, existing.pluginName, plugin.name, prompt);
316
+ const overwrite = await resolveToolCollision(services, stamped.name, existing.pluginName, plugin.name, prompt)
317
+ .catch((e: unknown) => {
318
+ console.error(
319
+ `[matbot] Could not resolve the "${stamped.name}" tool collision for plugin "${plugin.name}" ` +
320
+ `— keeping the existing tool:`, e instanceof Error ? e.message : e,
321
+ );
322
+ return false;
323
+ });
300
324
  if (!overwrite) return;
325
+ if (lifecycle.signal.aborted) return;
301
326
  }
302
327
  services.tools.register(stamped);
303
328
  };
@@ -315,9 +340,11 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
315
340
  observe(options, handler) {
316
341
  // Forward to the host mount table but deliver *this plugin's* scoped machine — it reads through
317
342
  // the same proxies/registry, so scoped[key] is the host's live service. onUnmount is scoped too.
318
- const forwarded = options.onUnmount !== undefined
319
- ? { ...options, onUnmount: () => options.onUnmount!(scoped) }
320
- : options;
343
+ const forwarded = {
344
+ ...options,
345
+ signal: options.signal !== undefined ? AbortSignal.any([lifecycle.signal, options.signal]) : lifecycle.signal,
346
+ ...(options.onUnmount !== undefined ? { onUnmount: () => options.onUnmount!(scoped) } : {}),
347
+ };
321
348
  services.mounted.observe(forwarded, () => handler(scoped as never));
322
349
  },
323
350
  };
@@ -331,13 +358,16 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
331
358
  specifier: plugin.specifier,
332
359
  ...(plugin.source !== undefined ? { source: plugin.source } : {}),
333
360
  },
361
+ // Everything this plugin publishes is attributed to it by default — the notification analogue of
362
+ // stamping `pluginName` on its tools. Reads through the host's swap proxy, so a registered
363
+ // distributed Notifier takes effect for a plugin that captured this in setup().
364
+ get Notifier() { return scopedNotifier(services.Notifier, plugin.name); },
334
365
  tools: {
335
366
  register: registerTool,
336
367
  remove: (name: string) => services.tools.remove(name),
337
368
  resolve: (name: string) => services.tools.resolve(name),
338
369
  list: () => services.tools.list(),
339
370
  removeByPlugin:(name: string) => services.tools.removeByPlugin(name),
340
- watch: (signal?: AbortSignal) => services.tools.watch(signal),
341
371
  },
342
372
  hooks: {
343
373
  register(hook: Hook) {
@@ -345,7 +375,7 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
345
375
  services.hooks.register({ ...hook, pluginName: plugin.name } as Hook);
346
376
  },
347
377
  removeByPlugin: (name: string) => services.hooks.removeByPlugin(name),
348
- } as unknown as HookRegistry,
378
+ },
349
379
  systemContext: {
350
380
  register(contributor) {
351
381
  state.systemContextPlugins.add(plugin.name);
@@ -382,6 +412,10 @@ export async function unloadPlugin(pluginName: string, services: MatbotMachine):
382
412
  services.tools.removeByPlugin(pluginName);
383
413
  services.hooks.removeByPlugin(pluginName);
384
414
  services.systemContext.removeByPlugin(pluginName);
415
+ // Drops this plugin's mount interests. Before teardown(), with the other synchronous removals: a
416
+ // mount handler firing between here and teardown would run against half-removed state.
417
+ state.lifecycles.get(pluginName)?.abort();
418
+ state.lifecycles.delete(pluginName);
385
419
 
386
420
  for (const key of state.serviceKeys.get(pluginName) ?? []) {
387
421
  services.unregister(key);
@@ -396,7 +430,7 @@ export async function unloadPlugin(pluginName: string, services: MatbotMachine):
396
430
  state.frontendPlugins.delete(pluginName);
397
431
 
398
432
  state.plugins.splice(idx, 1);
399
- pluginEvents.emit({ type: 'unloaded', name: pluginName });
433
+ services.Notifier.notify({ kind: RegistryChangeKind, source: 'plugins', registry: 'plugins', name: pluginName, operation: 'removed' });
400
434
  await Promise.race([
401
435
  plugin.teardown?.(),
402
436
  new Promise<void>((_, reject) => setTimeout(() => reject(new Error(`Teardown timeout for plugin ${pluginName}`)), 10000))
@@ -406,10 +440,13 @@ export async function unloadPlugin(pluginName: string, services: MatbotMachine):
406
440
 
407
441
  /** Run each plugin's teardown() in reverse-registration order. Errors are logged, not thrown. */
408
442
  export async function teardownPlugins(): Promise<void> {
409
- const results = await Promise.allSettled([...state.plugins].reverse().map(plugin => plugin.teardown?.()));
443
+ // Reverse once and keep it: indexing the *unreversed* array to name a result reported the wrong
444
+ // plugin for every failure but the middle one.
445
+ const ordered = [...state.plugins].reverse();
446
+ const results = await Promise.allSettled(ordered.map(plugin => plugin.teardown?.()));
410
447
  results.forEach((result, i) => {
411
448
  if (result.status === 'rejected') {
412
- console.error(`[matbot] teardown error in plugin "${state.plugins[i]?.name}":`, result.reason);
449
+ console.error(`[matbot] teardown error in plugin "${ordered[i]?.name}":`, result.reason);
413
450
  }
414
451
  });
415
452
  }
package/src/runner.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import type {
2
- Session, MessageContent, Usage, ProviderMeta, DeferredCorrection,
3
- PipelineEvent, RunConfig, ProviderAdapter, ProviderConfig,
2
+ Session, Message, MessageContent, ModelContent, Usage, ProviderMeta, TruncatedToolResult,
3
+ TurnEvent, RunConfig, ProviderAdapter, ProviderConfig,
4
4
  Tool, ToolRegistry, ToolContext, Store, FileStore, SystemContextRegistry, Vault, PromptFn, FormField,
5
5
  } from './types.js';
6
6
  import type { MatbotPlugin } from './plugin.js';
7
7
  import type { ToolPresenter } from '@matatbread/matbot-plugin-api';
8
- import { currentUsageSink } from '@matatbread/matbot-plugin-api';
8
+ import { currentUsageSink } from '@matatbread/matbot-plugin-api/host';
9
9
  import { HookRegistry } from './hooks.js';
10
10
  import { appendMessage, createMessage } from './session.js';
11
+ import { foldOntoUserTurn, bindPluginOps } from '@matatbread/matbot-plugin-api';
11
12
  import { addUsage } from './usage.js';
12
13
 
13
14
  // Substituted for an errored tool result when the turn was aborted (e.g. a mid-turn steer interrupt):
@@ -15,6 +16,16 @@ import { addUsage } from './usage.js';
15
16
  // internal token that reads as a real failure. A steer's continuation turn is the one place a model
16
17
  // reads this, so the wording tells it the call was interrupted and leaves re-running to its judgement —
17
18
  // deliberately NOT "re-run it", so a side-effecting tool isn't reflexively repeated.
19
+ // Creator of the marker recording that a provider cut a response short. Marker-role, so it is durable
20
+ // and visible to a reader while elided from every submission: the model's own text is already truncated
21
+ // in the transcript, and a block telling it so invites narrating the cut-off rather than continuing.
22
+ const TRUNCATION_CREATOR = 'matbot-truncation';
23
+
24
+ /** An AbortSignal's reason as a turn-terminal string. `reason` is `any` by spec (a caller may abort with
25
+ * anything), so the two abort exits both had to narrow it; they narrowed it identically. */
26
+ const abortReason = (signal: AbortSignal): string =>
27
+ typeof signal.reason === 'string' ? signal.reason : 'user-abort';
28
+
18
29
  const INTERRUPTED_TOOL_RESULT = {
19
30
  error: 'Tool call interrupted before completion — the turn was interrupted while it was running. It may not have run to completion, and any side effect may or may not have occurred. Re-run it only if you still need its result.',
20
31
  } as const;
@@ -64,7 +75,9 @@ export interface RunSessionOpts {
64
75
  unloadPlugin: (specifier: string) => Promise<boolean>;
65
76
  }
66
77
 
67
- export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineEvent> {
78
+ // Yields TurnEvent, not PipelineEvent: a single turn never produces the session-level `idle` — that is
79
+ // the pump's, emitted once its queue drains. Saying so in the type is the point of the two-union split.
80
+ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent> {
68
81
  const { config, provider, providerConfig, store, signal } = opts;
69
82
  const tools = opts.tools ?? new Map<string, Tool>();
70
83
  const hookReg = opts.hooks ?? new HookRegistry();
@@ -83,15 +96,29 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
83
96
  };
84
97
  const traceId = config.traceId ?? crypto.randomUUID();
85
98
 
99
+ let session = opts.session;
100
+
101
+ // Every way a turn can end funnels through here: commit the session as it stands, then yield exactly
102
+ // one terminal event. Each exit has its own legitimately different preamble — screen markers, a
103
+ // streamed assistant partial, closing an unpaired tool_use — but the terminal itself must not vary,
104
+ // and twice it had not: both the `toolcall`-abort and failed-provider-call exits returned with no
105
+ // commit at all, silently discarding every completed round of the turn.
106
+ // Nothing is written mid-turn, so "commit" here means the whole turn or none of it.
107
+ // Closes over the reassigned `session` deliberately, so no exit can commit a stale copy.
108
+ async function* end(terminal: TurnEvent): AsyncIterable<TurnEvent> {
109
+ await store.set(session.id, session);
110
+ yield terminal;
111
+ }
112
+
86
113
  // ── 1. screen — once per turn: shape/abort the incoming submission ──────────
87
114
 
88
- const screen = await hookReg.runScreen({ session: opts.session, config, signal, prompt: promptFn });
115
+ const screen = await hookReg.runScreen({ session, config, signal, prompt: promptFn });
116
+ session = screen.session;
89
117
  if (screen.abort) {
90
118
  // Hook-failure (and any other screen-injected) markers carried live, even on abort, so a
91
119
  // misconfigured hook surfaces this turn rather than only on a later reload.
92
120
  if (screen.markers.length > 0) yield { type: 'marker', content: screen.markers, traceId };
93
- await store.set(screen.session.id, screen.session);
94
- yield { type: 'aborted', reason: screen.abort, session: screen.session, traceId };
121
+ yield* end({ type: 'aborted', reason: screen.abort, session, traceId });
95
122
  return;
96
123
  }
97
124
  // Durable context a screen hook folded onto the user message (e.g. a fired `contextual` trigger),
@@ -107,7 +134,6 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
107
134
  if (screen.markers.length > 0) {
108
135
  yield { type: 'marker', content: screen.markers, traceId };
109
136
  }
110
- let session = screen.session;
111
137
 
112
138
  // Turn-scoped ephemeral context from screen — appended to the TAIL of the outgoing messages
113
139
  // (onto the content of the freshest history message, preserving its role), never persisted.
@@ -124,12 +150,19 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
124
150
  // in here when it fires, and the loop re-runs with it.
125
151
  let ephemeral = [...(opts.injectedEphemeral ?? []), ...screen.ephemeral];
126
152
 
127
- // Raced screen verdicts (see DeferredScreen): polled — synchronously, never awaited — at each loop
128
- // edge. `pollDeferred` claims any that have fired this poll (exactly once each) and merges their
129
- // corrections; `applyCorrection` then folds them in and returns any durable blocks to emit live.
153
+ // Raced screen verdicts (see DeferredScreen): claimed — synchronously, never awaited — at each of the
154
+ // three loop edges (before generating, mid-stream, and pre-commit). Claiming is one step, so the three
155
+ // sites cannot disagree about it: take every verdict that has fired since the last claim (exactly once
156
+ // each), fold `ephemeral` in ahead of what is already queued so the re-run reads it, fold `durable`
157
+ // onto the user turn (persisted — the re-run and every later turn see it in history), and carry that
158
+ // durable half live as a `robo-user`, which is what preserves a contextual fire's visible persistence
159
+ // even though the verdict landed mid-turn.
160
+ //
161
+ // Returns whether anything was claimed. What the caller does with that is the part that legitimately
162
+ // differs — proceed, abort the in-flight provider call, or re-run the loop — so it stays at the site.
130
163
  const deferred = screen.deferred;
131
- const pollDeferred = (): DeferredCorrection | undefined => {
132
- if (deferred.length === 0) return undefined;
164
+ async function* claimVerdicts(): AsyncGenerator<TurnEvent, boolean, undefined> {
165
+ if (deferred.length === 0) return false;
133
166
  const ephemeralC: MessageContent[] = [];
134
167
  const durableC: MessageContent[] = [];
135
168
  for (const d of deferred) {
@@ -138,24 +171,20 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
138
171
  if (c.ephemeral) ephemeralC.push(...c.ephemeral);
139
172
  if (c.durable) durableC.push(...c.durable);
140
173
  }
141
- return (ephemeralC.length > 0 || durableC.length > 0) ? { ephemeral: ephemeralC, durable: durableC } : undefined;
142
- };
143
- // Apply a claimed correction: `ephemeral` leads the tail-fold for the re-run; `durable` is folded onto
144
- // the turn's user message (persisted, re-run sees it in history) and RETURNED so the caller emits it
145
- // live as a `robo-user` event — the durable twin, preserving a contextual fire's persistence even
146
- // though the verdict landed mid-turn. Mutates `ephemeral`/`session` in place.
147
- const applyCorrection = (c: DeferredCorrection): MessageContent[] => {
148
- if (c.ephemeral && c.ephemeral.length > 0) ephemeral = [...c.ephemeral, ...ephemeral];
149
- if (c.durable && c.durable.length > 0) {
150
- const idx = session.messages.findLastIndex(m => m.role === 'user');
151
- if (idx >= 0) {
152
- session = { ...session, messages: session.messages.map((m, i) =>
153
- i === idx ? { ...m, content: [...m.content, ...c.durable!] } : m) };
154
- return c.durable;
174
+ if (ephemeralC.length === 0 && durableC.length === 0) return false;
175
+
176
+ if (ephemeralC.length > 0) ephemeral = [...ephemeralC, ...ephemeral];
177
+ if (durableC.length > 0) {
178
+ const folded = foldOntoUserTurn(session, durableC);
179
+ // Identity-unchanged ⇒ no user message to attach to, so the blocks were dropped: nothing durable
180
+ // landed, hence nothing to carry live either.
181
+ if (folded !== session) {
182
+ session = folded;
183
+ yield { type: 'robo-user', content: durableC, traceId };
155
184
  }
156
185
  }
157
- return [];
158
- };
186
+ return true;
187
+ }
159
188
 
160
189
  // ── 2. System context (built once per submit, never persisted) ─────────────
161
190
 
@@ -173,24 +202,44 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
173
202
 
174
203
  // ── 3. Agentic loop ────────────────────────────────────────────────────────
175
204
 
205
+ // Rounds already run against this provider, where a round is one provider call plus the tool batch it
206
+ // asked for. Compared against the provider profile's own `maxRounds` — a spend ceiling denominated in
207
+ // the unit spend varies by. Absent ⇒ unbounded (`?? Infinity`), the historical behaviour.
208
+ let round = 0;
209
+
210
+ // Tool-supplied media, keyed by the tool message it answers — see the `model-content` ToolEvent.
211
+ // Turn-scoped and wire-only: it is spliced into the outgoing copy below and dies with this call.
212
+ const attachments = new Map<string, Message>();
213
+
176
214
  for (;;) {
177
215
  // Respect an abort that arrived between turns (e.g. during tool execution).
178
216
  if (signal.aborted) {
179
- await store.set(session.id, session);
180
- yield { type: 'aborted', reason: typeof signal.reason === 'string' ? signal.reason : 'user-abort', session, traceId };
217
+ yield* end({ type: 'aborted', reason: abortReason(signal), session, traceId });
181
218
  return;
182
219
  }
183
220
 
221
+ // Checked HERE — before starting a round, not part-way through one — so the previous round's tool
222
+ // results are already appended and there is nothing to reconcile: the same persist-and-yield as the
223
+ // abort above. A turn stopped by the ceiling was cut short, not faulty, so it ends `aborted` rather
224
+ // than `error`, and the reason is machine-readable for a frontend that wants to say why.
225
+ if (round >= (providerConfig.maxRounds ?? Infinity)) {
226
+ const reason = `round-limit: provider "${config.provider}" allows ${providerConfig.maxRounds} rounds per turn`;
227
+ yield* end({ type: 'aborted', reason, session, traceId });
228
+ return;
229
+ }
230
+ round += 1;
231
+
184
232
  // A raced screen verdict that already fired (settled during setup, or while a previous tool round
185
233
  // ran): fold its correction in before generating, so this call is informed directly rather than
186
234
  // discarded. This is the no-waste path — the verdict landed before we spent any tokens.
187
- const early = pollDeferred();
188
- if (early) { const dur = applyCorrection(early); if (dur.length > 0) yield { type: 'robo-user', content: dur, traceId }; }
235
+ yield* claimVerdicts();
189
236
 
190
- const pendingCalls: Array<{ id: string; name: string; input: unknown; meta?: ProviderMeta }> = [];
237
+ const pendingCalls: Array<{ id: string; name: string; input: unknown; meta?: ProviderMeta;
238
+ truncated?: { bytes: number; stopReason?: string } }> = [];
191
239
  const assistantParts: MessageContent[] = [];
192
240
  let textAcc = '';
193
241
  let turnUsage: Usage | undefined;
242
+ let truncation: { reason: 'max-tokens' | 'stream-end'; raw?: string } | undefined;
194
243
  // Set if a raced verdict fires mid-generation: discard this response and re-run the loop (below).
195
244
  let restart = false;
196
245
 
@@ -201,9 +250,12 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
201
250
  // message can be a marker (e.g. a retract-redo leaves the retraction marker as the tail) — folding
202
251
  // there would drop the ephemeral with it. -1 ⇒ nothing to fold onto (no non-marker history).
203
252
  const foldIdx = ephemeral.length > 0 ? session.messages.findLastIndex(m => m.role !== 'marker') : -1;
204
- const history = foldIdx >= 0
205
- ? session.messages.map((m, i) =>
206
- i === foldIdx ? { ...m, content: [...m.content, ...ephemeral] } : m)
253
+ const history = foldIdx >= 0 || attachments.size > 0
254
+ ? session.messages.flatMap((m, i) => {
255
+ const folded = i === foldIdx ? { ...m, content: [...m.content, ...ephemeral] } : m;
256
+ const media = attachments.get(m.id);
257
+ return media ? [folded, media] : [folded];
258
+ })
207
259
  : session.messages;
208
260
  const outgoing = await hookReg.runContribute({
209
261
  outgoing: [...systemMsg, ...history],
@@ -224,12 +276,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
224
276
  // A raced verdict firing mid-generation changes the premise: discard this doomed response and
225
277
  // restart the loop with the correction folded in. Polled BEFORE the event is emitted, so a
226
278
  // classifier faster than time-to-first-token is caught before any token reaches the frontend.
227
- const hit = pollDeferred();
228
- if (hit) {
229
- const dur = applyCorrection(hit);
230
- if (dur.length > 0) yield { type: 'robo-user', content: dur, traceId };
231
- restart = true; callAc.abort('screen-restart'); break;
232
- }
279
+ if (yield* claimVerdicts()) { restart = true; callAc.abort('screen-restart'); break; }
233
280
  switch (ev.type) {
234
281
  case 'text-delta':
235
282
  textAcc += ev.delta;
@@ -252,7 +299,11 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
252
299
  break;
253
300
  case 'tool-call':
254
301
  pendingCalls.push({ id: ev.id, name: ev.name, input: ev.input,
255
- ...(ev.meta !== undefined ? { meta: ev.meta } : {}) });
302
+ ...(ev.meta !== undefined ? { meta: ev.meta } : {}),
303
+ ...(ev.truncated !== undefined ? { truncated: ev.truncated } : {}) });
304
+ break;
305
+ case 'truncated':
306
+ truncation = { reason: ev.reason, ...(ev.raw !== undefined ? { raw: ev.raw } : {}) };
256
307
  break;
257
308
  case 'usage':
258
309
  turnUsage = addUsage(turnUsage, ev);
@@ -279,11 +330,20 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
279
330
  ...(turnUsage !== undefined ? { usage: turnUsage } : {}),
280
331
  }));
281
332
  }
282
- await store.set(session.id, session);
283
- yield { type: 'aborted', reason: typeof signal.reason === 'string' ? signal.reason : 'user-abort', session, traceId };
333
+ yield* end({ type: 'aborted', reason: abortReason(signal), session, traceId });
284
334
  return;
285
335
  } else {
286
- yield { type: 'error', error: String(e) + (('cause' in e && e.cause) ? ' ('+String(e.cause)+')' : '' ), traceId };
336
+ // A failed provider call still commits the rounds that succeeded. Nothing is written mid-turn, so
337
+ // returning without one discarded the whole turn — and the multi-round case is exactly the
338
+ // tool-using one, so a provider 500 or a dropped connection on round 3 threw away two completed
339
+ // rounds of assistant messages and tool results the frontend had already drawn, and which the
340
+ // next turn (which re-reads from the store) would then be missing.
341
+ //
342
+ // Nothing partial is appended here, unlike the abort branch above: `textAcc` and `assistantParts`
343
+ // belong to the call that failed, and the model never finished the thought. The `error` event
344
+ // carries no session (a failure is not a transcript), so this yields the terminal itself rather
345
+ // than the session — but it commits first, exactly like every other exit.
346
+ yield* end({ type: 'error', error: String(e) + (('cause' in e && e.cause) ? ' ('+String(e.cause)+')' : '' ), traceId });
287
347
  return;
288
348
  }
289
349
  }
@@ -295,8 +355,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
295
355
 
296
356
  // Pre-commit poll: the verdict may have settled exactly as the stream ended. Discard and re-run
297
357
  // rather than commit-then-retract, keeping the correction on the cheaper in-situ path.
298
- const late = pollDeferred();
299
- if (late) { const dur = applyCorrection(late); if (dur.length > 0) yield { type: 'robo-user', content: dur, traceId }; continue; }
358
+ if (yield* claimVerdicts()) continue;
300
359
 
301
360
  // Build and store assistant message
302
361
  if (textAcc) assistantParts.push({ type: 'text', text: textAcc });
@@ -314,7 +373,19 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
314
373
  ...(turnUsage !== undefined ? { usage: turnUsage } : {}),
315
374
  });
316
375
  session = appendMessage(session, assistantMsg);
317
- } else {
376
+ }
377
+
378
+ // The provider cut this response short rather than the model choosing to stop. Recorded whether or
379
+ // not a tool call was caught in it — the commoner case has none, and prose stopping mid-sentence
380
+ // previously left no trace anywhere. What to DO about it (continue, re-ask with a bigger budget) is
381
+ // a `followup` hook's call, not the harness's.
382
+ if (truncation) {
383
+ const marker: MessageContent = { type: 'marker', creator: TRUNCATION_CREATOR, data: truncation };
384
+ session = appendMessage(session, createMessage({ role: 'marker', content: [marker], traceId }));
385
+ yield { type: 'marker', content: [marker], traceId };
386
+ }
387
+
388
+ if (assistantParts.length === 0) {
318
389
  // Diagnostic: the provider returned no text, no tool calls, no thinking — a genuinely empty
319
390
  // completion. The turn will end with no assistant message, which reads as "the agent never
320
391
  // replied". Logged here so a silent no-reply turn is traceable. (textAcc length is shown to
@@ -329,8 +400,9 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
329
400
 
330
401
  const toolResults: MessageContent[] = [];
331
402
  const toolMarkers: MessageContent[] = [];
403
+ const toolMedia: ModelContent[] = [];
332
404
 
333
- for (const tc of pendingCalls) {
405
+ for (const [callIdx, tc] of pendingCalls.entries()) {
334
406
  yield { type: 'tool:start', callId: tc.id, name: tc.name, input: tc.input, traceId };
335
407
 
336
408
  const tool = opts.toolRegistry !== undefined ? opts.toolRegistry.resolve(tc.name) : tools.get(tc.name);
@@ -341,13 +413,68 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
341
413
  continue;
342
414
  }
343
415
 
416
+ // Arguments severed mid-stream (the provider hit its token limit part-way through the call).
417
+ // The call cannot run, but it must still be answered: the assistant message above carries a
418
+ // `tool-call` block for it, and an unpaired `tool_use` is rejected by the next submission. So it
419
+ // is recorded as a call that failed — no execution, no side effect — and the model reads the
420
+ // failure in the slot it expects and retries smaller, which is the ordinary self-correction the
421
+ // loop already does for a rejected or unknown tool. No retry counter: a model that keeps
422
+ // overflowing burns rounds and meets `maxRounds`, exactly as one repeatedly calling any failing
423
+ // tool does.
424
+ //
425
+ // `toolcall` is skipped — there is nothing to judge, the call cannot proceed whatever a hook
426
+ // says — but `toolresult` runs, because it owns the LLM-facing text and is where a consumer
427
+ // folds in advice the harness cannot have: which of THIS tool's parameters offers a cheaper
428
+ // edit. See `isTruncatedToolResult`.
429
+ if (tc.truncated) {
430
+ const truncated: TruncatedToolResult = {
431
+ error:
432
+ `Arguments for "${tc.name}" were cut off after ${tc.truncated.bytes} bytes and could not be parsed` +
433
+ `${tc.truncated.stopReason !== undefined ? ` (stop reason "${tc.truncated.stopReason}")` : ''}. ` +
434
+ 'The call did NOT run and had no effect. Retry it smaller — fewer operations in one call, ' +
435
+ 'or split the work across several calls.',
436
+ truncated: { tool: tc.name, bytes: tc.truncated.bytes,
437
+ ...(tc.truncated.stopReason !== undefined ? { stopReason: tc.truncated.stopReason } : {}) },
438
+ };
439
+ const shaped = await hookReg.runToolResult({
440
+ session, config, signal,
441
+ toolCall: { id: tc.id, name: tc.name, input: tc.input },
442
+ tool, result: truncated, isError: true, durationMs: 0,
443
+ });
444
+ toolResults.push({ type: 'tool-result', id: tc.id, result: shaped, isError: true });
445
+ yield { type: 'tool:end', callId: tc.id, result: shaped, isError: true, traceId };
446
+ continue;
447
+ }
448
+
344
449
  const decision = await hookReg.runToolCall({
345
450
  session, config, signal,
346
451
  toolCall: { id: tc.id, name: tc.name, input: tc.input },
347
452
  tool,
348
453
  });
349
454
  if (decision.abort) {
350
- yield { type: 'aborted', reason: decision.abort, session, traceId };
455
+ // Commit the turn before leaving (`end`, above — nothing is written mid-turn, so an exit that
456
+ // skips it discards the whole turn: every earlier round's assistant message and tool results,
457
+ // plus this round's response, all of which the frontend had already drawn). This path is where
458
+ // that was first found and fixed.
459
+ //
460
+ // Committing requires closing the tool_use/tool_result pairing first: the assistant message
461
+ // above carries a `tool-call` block per pending call, and a `tool_use` left unpaired is
462
+ // rejected by the next submission (Anthropic 400s; nothing downstream reconciles them). The
463
+ // abort is a turn-level stop rather than a verdict on the tools, so the calls that never ran
464
+ // are recorded as not-run. `tool:end` is carried only for the call the hook actually judged —
465
+ // the rest never had a `tool:start`, so a frontend has no bubble to close for them.
466
+ const stopped = { error: `Tool call not executed — the turn was aborted (${decision.abort}).` };
467
+ toolResults.push({ type: 'tool-result', id: tc.id, result: stopped, isError: true });
468
+ yield { type: 'tool:end', callId: tc.id, result: stopped, isError: true, traceId };
469
+ for (const rest of pendingCalls.slice(callIdx + 1)) {
470
+ toolResults.push({ type: 'tool-result', id: rest.id, result: stopped, isError: true });
471
+ }
472
+ session = appendMessage(session, createMessage({ role: 'tool', content: toolResults, traceId }));
473
+ if (toolMarkers.length > 0) {
474
+ session = appendMessage(session, createMessage({ role: 'marker', content: toolMarkers, traceId }));
475
+ yield { type: 'marker', content: toolMarkers, traceId };
476
+ }
477
+ yield* end({ type: 'aborted', reason: decision.abort, session, traceId });
351
478
  return;
352
479
  }
353
480
  if (decision.rejectTool) {
@@ -369,8 +496,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
369
496
  callId: tc.id, session, signal, vault,
370
497
  provider: config.provider,
371
498
  prompt: promptFn,
372
- loadPlugin: (specifier: string, refresh?: boolean) => opts.loadPlugin(specifier, promptFn, refresh),
373
- unloadPlugin: opts.unloadPlugin,
499
+ ...bindPluginOps(opts, promptFn),
374
500
  ...(opts.workdir !== undefined ? { workdir: opts.workdir } : {}),
375
501
  ...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}),
376
502
  ...(opts.files !== undefined ? { files: opts.files } : {}),
@@ -384,6 +510,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
384
510
  case 'file': yield { type: 'file', handle: toolEv.handle, traceId }; break;
385
511
  case 'result': result = toolEv.value; break;
386
512
  case 'marker': toolMarkers.push({ type: 'marker', creator: toolEv.creator, data: toolEv.data }); break;
513
+ case 'model-content': toolMedia.push(...toolEv.content); break;
387
514
  case 'progress': yield { type: 'tool:progress', callId: tc.id, pct: toolEv.pct, ...(toolEv.message !== undefined ? { message: toolEv.message } : {}), traceId }; break;
388
515
  case 'error': result = {
389
516
  error: toolEv.message,
@@ -423,6 +550,15 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
423
550
  const toolMsg = createMessage({ role: 'tool', content: toolResults, traceId });
424
551
  session = appendMessage(session, toolMsg);
425
552
 
553
+ // Media the tools handed over for the model to look at. Held wire-side only — pinned directly
554
+ // after the tool message it came from (so it keeps a stable history position, and so the model
555
+ // reads it as the answer to that call) and spliced into `outgoing` on every remaining round of
556
+ // this turn. Never appended to `session`: the transcript records what the tool returned, not the
557
+ // bytes it showed.
558
+ if (toolMedia.length > 0) {
559
+ attachments.set(toolMsg.id, createMessage({ role: 'user', content: toolMedia, traceId }));
560
+ }
561
+
426
562
  // Markers a tool emitted while running: persist as their own marker-role message (elided from
427
563
  // provider submission) and carry live so a frontend can render them without a reload.
428
564
  if (toolMarkers.length > 0) {
@@ -434,7 +570,5 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
434
570
  // ── 4. Persist and finish ──────────────────────────────────────────────────
435
571
  // `react` fires post-commit, in pump (the queue owner) — not here.
436
572
 
437
- await store.set(session.id, session);
438
-
439
- yield { type: 'done', session, traceId };
573
+ yield* end({ type: 'done', session, traceId });
440
574
  }
@@ -8,7 +8,8 @@ import type { MatbotPlugin } from './plugin.js';
8
8
  import type { HookRegistry } from './hooks.js';
9
9
  import type { ToolTypeIndex, ToolPresenter } from '@matatbread/matbot-plugin-api';
10
10
  import { appendMessage, createMessage } from './session.js';
11
- import { contextSwitch, withUsageScope, isReadOnlyError } from '@matatbread/matbot-plugin-api';
11
+ import { isReadOnlyError, foldOntoUserTurn, lastUserIndex } from '@matatbread/matbot-plugin-api';
12
+ import { contextSwitch, withUsageScope } from '@matatbread/matbot-plugin-api/host';
12
13
  import { runSession } from './runner.js';
13
14
 
14
15
  export interface SessionRunnerDeps {
@@ -289,6 +290,14 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
289
290
  // the callback: an async iterator returned out of the scope would lose it before it pulls.
290
291
  // contextSwitch (not bare runAs): a turn is the transactional unit, so a StorageBackend swap
291
292
  // deferred during it lands at this scope's quiescent edge — never mid-CAS.
293
+ // The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
294
+ // commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
295
+ // refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
296
+ // in both cases a followup hook would judge history with no completed answer in it — and a
297
+ // `resubmit` would undo the very stop that just happened, handing a round ceiling a fresh
298
+ // budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
299
+ // signal-driven aborts and not the policy ones.
300
+ let terminal: PipelineEvent['type'] | undefined;
292
301
  await contextSwitch(head.principal, () => withUsageScope(async () => {
293
302
  for await (const ev of runSession({
294
303
  session,
@@ -311,15 +320,17 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
311
320
  ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
312
321
  ...(inject !== undefined ? { injectedEphemeral: inject } : {}),
313
322
  })) {
323
+ if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
314
324
  emit(s, ev);
315
325
  }
316
326
  }));
317
327
 
318
328
  // followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
319
- // head-enqueue a robo follow-up (its own real turn, running next). Skipped on abort; runs
320
- // under the submitter's principal because a reactor may itself call complete() (a classifier).
329
+ // head-enqueue a robo follow-up (its own real turn, running next). Runs only for a turn that
330
+ // committed (see `terminal`), and under the submitter's principal because a reactor may itself
331
+ // call complete() (a classifier).
321
332
  const hooks = deps.hooks;
322
- if (!ac.signal.aborted && hooks) {
333
+ if (terminal === 'done' && hooks) {
323
334
  const committed = await deps.store.get(id);
324
335
  if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
325
336
  let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } } = { resubmits: [], markers: [] };
@@ -379,14 +390,18 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
379
390
  // head, delivering the trigger tool's output as ephemeral context. Unshifted last so it
380
391
  // sits at the very head (runs next) even if a resubmit was also queued above.
381
392
  if (followup.retract) {
382
- const lastUserIdx = committed.messages.findLastIndex(m => m.role === 'user');
393
+ const lastUserIdx = lastUserIndex(committed);
383
394
  const popped = lastUserIdx >= 0 ? committed.messages.slice(lastUserIdx + 1) : [];
384
395
  // `durable` correction (a contextual fire that landed post-commit) folds onto the user
385
396
  // message we keep — persisted, so the redo AND every later turn see it in history, the
386
397
  // exact durability the clean in-situ path gives. `context` stays ephemeral (redo only).
398
+ // The fold runs on the *truncated* session, whose last user message is the one we keep,
399
+ // so it goes through the same helper as the screen and raced-verdict folds.
387
400
  const durable = followup.retract.durable ?? [];
388
- const kept = (lastUserIdx >= 0 ? committed.messages.slice(0, lastUserIdx + 1) : committed.messages)
389
- .map((m, i) => (durable.length > 0 && i === lastUserIdx ? { ...m, content: [...m.content, ...durable] } : m));
401
+ const truncated = lastUserIdx >= 0
402
+ ? { ...committed, messages: committed.messages.slice(0, lastUserIdx + 1) }
403
+ : committed;
404
+ const kept = foldOntoUserTurn(truncated, durable).messages;
390
405
  const retractionMsg: Message = {
391
406
  id: crypto.randomUUID(),
392
407
  role: 'marker',
package/src/session.ts CHANGED
@@ -2,7 +2,6 @@ import type { Session, Message, MessageContent, MessageRole, Usage } from './typ
2
2
 
3
3
  export interface CreateSessionOpts {
4
4
  title?: string;
5
- contexts?: string[];
6
5
  parentSessionId?: string;
7
6
  branchPointMessageId?: string;
8
7
  }
@@ -16,7 +15,6 @@ export function createSession(opts: CreateSessionOpts = {}): Session {
16
15
  ...(opts.parentSessionId !== undefined ? { parentSessionId: opts.parentSessionId } : {}),
17
16
  ...(opts.branchPointMessageId !== undefined ? { branchPointMessageId: opts.branchPointMessageId } : {}),
18
17
  status: 'active',
19
- contexts: opts.contexts ?? [],
20
18
  messages: [],
21
19
  createdAt: now,
22
20
  updatedAt: now,
package/src/settings.ts CHANGED
@@ -22,45 +22,51 @@ export function slugSettingsNamespace(name: string): string {
22
22
  return name.replace(/[^\w-]+/g, '_');
23
23
  }
24
24
 
25
- /** Build a PluginSettings facade over the shared settings store, scoped to one document id. */
25
+ /**
26
+ * Build a PluginSettings facade over the shared settings store, scoped to one document id.
27
+ *
28
+ * Every read used to also decide whether the document was in the pre-Store flat `{ key: value }` format
29
+ * and, if so, wrap it under a `version: '0'` sentinel that `set`/`delete` then had to recognise and
30
+ * upgrade with `set` instead of `cas` — a three-site migration paid for on every `settings.get()` by
31
+ * every plugin, forever. Dropped at 0.4.0: an unrecognised document is treated as absent, so a flat one
32
+ * would be re-created on first write rather than read. Nothing in the wild is still flat.
33
+ */
26
34
  export function makePluginSettings(store: Store<SettingsDoc>, namespace: string): PluginSettings {
27
35
  const id = slugSettingsNamespace(namespace);
28
36
 
29
- // Handles migration from the old flat-object format (pre-Store).
30
37
  const getDoc = async (): Promise<SettingsDoc | null> => {
31
38
  const raw = await store.get(id);
32
- if (raw === null) return null;
33
- if (isSettingsDoc(raw)) return raw;
34
- // Old format: flat { key: value } — wrap it so subsequent writes upgrade the file.
35
- return { id, version: '0', data: raw as unknown as Record<string, unknown> };
39
+ return raw !== null && isSettingsDoc(raw) ? raw : null;
40
+ };
41
+
42
+ // One CAS retry loop for both writers: read, apply `mutate`, write. A document that vanished between
43
+ // read and write (or never existed) is a plain `set`; otherwise `cas` guards the version we read.
44
+ const update = async (mutate: (data: Record<string, unknown>) => Record<string, unknown> | undefined): Promise<void> => {
45
+ for (;;) {
46
+ const doc = await getDoc();
47
+ const data = mutate(doc?.data ?? {});
48
+ if (data === undefined) return;
49
+ const next: SettingsDoc = { id, version: Date.now().toString(), data };
50
+ if (doc === null) { await store.set(id, next); return; }
51
+ const r = await store.cas(id, doc.version, next);
52
+ if (r.ok) return;
53
+ }
36
54
  };
37
55
 
38
56
  return {
39
57
  async get<T>(key: string): Promise<T | undefined> {
40
58
  return (await getDoc())?.data[key] as T | undefined;
41
59
  },
42
- async set<T>(key: string, value: T): Promise<void> {
43
- for (;;) {
44
- const doc = await getDoc();
45
- const data = { ...(doc?.data ?? {}), [key]: value as unknown };
46
- const next: SettingsDoc = { id, version: Date.now().toString(), data };
47
- // version '0' means migrated-but-not-yet-written — use set to upgrade the file.
48
- if (doc === null || doc.version === '0') { await store.set(id, next); return; }
49
- const r = await store.cas(id, doc.version, next);
50
- if (r.ok) return;
51
- }
60
+ set<T>(key: string, value: T): Promise<void> {
61
+ return update(data => ({ ...data, [key]: value as unknown }));
52
62
  },
53
- async delete(key: string): Promise<void> {
54
- for (;;) {
55
- const doc = await getDoc();
56
- if (doc === null) return;
57
- const data = { ...doc.data };
58
- delete data[key];
59
- const next: SettingsDoc = { id, version: Date.now().toString(), data };
60
- if (doc.version === '0') { await store.set(id, next); return; }
61
- const r = await store.cas(id, doc.version, next);
62
- if (r.ok) return;
63
- }
63
+ delete(key: string): Promise<void> {
64
+ return update(data => {
65
+ if (!(key in data)) return undefined;
66
+ const next = { ...data };
67
+ delete next[key];
68
+ return next;
69
+ });
64
70
  },
65
71
  };
66
72
  }
@@ -93,11 +93,11 @@ function validateFilter(f: Filter, ptr: string): void {
93
93
  }
94
94
  }
95
95
 
96
- const QUERY_KEYS = new Set(['where', 'sort', 'limit', 'cursor']);
96
+ const QUERY_KEYS = new Set(['where', 'sort', 'limit', 'cursor', 'immutable']);
97
97
 
98
98
  export function validateQuery(q: StoreQuery): void {
99
99
  if (q === null || typeof q !== 'object' || Array.isArray(q))
100
- throw new StoreQueryError('query must be an object with optional keys: where, sort, limit, cursor', '/', 'MALFORMED');
100
+ throw new StoreQueryError('query must be an object with optional keys: where, sort, limit, cursor, immutable', '/', 'MALFORMED');
101
101
 
102
102
  // Reject unknown top-level keys. Without this the envelope is silently permissive: a clause placed
103
103
  // under a wrong key (e.g. `filter` instead of `where`, an SQL-ism LLMs reach for) is dropped, the
@@ -1,26 +1,37 @@
1
- import type { Tool, ToolRegistry, ToolRegistryEvent } from './types.js';
2
- import { createBroadcaster } from '@matatbread/matbot-plugin-api';
1
+ import { RegistryChangeKind } from '@matatbread/matbot-plugin-api';
2
+ import type { Tool, ToolRegistry, Notifier } from './types.js';
3
3
 
4
4
  export class ToolRegistryImpl implements ToolRegistry {
5
5
  private readonly tools = new Map<string, Tool>();
6
- private readonly events = createBroadcaster<ToolRegistryEvent>();
6
+ // The host's notifier proxy, injected at boot (both hosts build it before this registry). Held as the
7
+ // proxy, not a resolved impl, so a later register('Notifier', …) takes effect here too. Optional: a
8
+ // registry built without one (tests) simply announces nothing.
9
+ private readonly notifier: Notifier | undefined;
7
10
 
8
- constructor(initial?: Iterable<Tool>) {
11
+ constructor(initial?: Iterable<Tool>, notifier?: Notifier) {
9
12
  if (initial !== undefined) for (const tool of initial) this.tools.set(tool.name, tool);
13
+ this.notifier = notifier;
14
+ }
15
+
16
+ private announce(name: string, operation: 'added' | 'removed', pluginName?: string): void {
17
+ this.notifier?.notify({
18
+ kind: RegistryChangeKind, source: 'tools', registry: 'tools', name, operation,
19
+ ...(pluginName !== undefined ? { detail: { pluginName } } : {}),
20
+ });
10
21
  }
11
22
 
12
23
  register(tool: Tool): void {
13
24
  this.tools.set(tool.name, tool);
14
- this.events.emit({ type: 'registered', name: tool.name, ...(tool.pluginName !== undefined ? { pluginName: tool.pluginName } : {}) });
25
+ this.announce(tool.name, 'added', tool.pluginName);
15
26
  }
16
27
 
17
28
  remove(name: string): void {
18
- if (this.tools.delete(name)) this.events.emit({ type: 'removed', name });
29
+ if (this.tools.delete(name)) this.announce(name, 'removed');
19
30
  }
20
31
 
21
32
  removeByPlugin(pluginName: string): void {
22
33
  for (const [name, tool] of this.tools) {
23
- if (tool.pluginName === pluginName && this.tools.delete(name)) this.events.emit({ type: 'removed', name });
34
+ if (tool.pluginName === pluginName && this.tools.delete(name)) this.announce(name, 'removed');
24
35
  }
25
36
  }
26
37
 
@@ -31,9 +42,4 @@ export class ToolRegistryImpl implements ToolRegistry {
31
42
  list(): Tool[] {
32
43
  return [...this.tools.values()];
33
44
  }
34
-
35
- async *watch(signal?: AbortSignal): AsyncIterable<ToolRegistryEvent> {
36
- // Tool CRUD is global — no origin — so unwrap the envelope to the bare event.
37
- for await (const { value } of this.events.subscribe(signal)) yield value;
38
- }
39
45
  }
package/src/types.ts CHANGED
@@ -1,13 +1 @@
1
1
  export type * from '@matatbread/matbot-plugin-api';
2
-
3
- // ── Internal types (not part of the plugin API) ───────────────────────────────
4
-
5
- import type {
6
- ProviderAdapter,
7
- } from '@matatbread/matbot-plugin-api';
8
-
9
- export interface ProviderRegistry {
10
- register(adapter: ProviderAdapter): void;
11
- resolve(name: string): ProviderAdapter;
12
- }
13
-