@matatbread/matbot-core 0.4.15 → 0.4.16

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.4.15",
7
+ "version": "0.4.16",
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.4.15"
38
+ "@matatbread/matbot-plugin-api": "^0.4.16"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
@@ -86,6 +86,21 @@ function toProviderConfig(name: string, raw: YamlMap): ProviderConfig {
86
86
  return config;
87
87
  }
88
88
 
89
+ /**
90
+ * Parse a config document, optionally over a `base` one it `extends:`.
91
+ *
92
+ * **The merge is per TOP-LEVEL KEY, and the derived document REPLACES rather than extends.** A child
93
+ * declaring `plugins:` supplies the whole list — the base's entries are not appended — and the same
94
+ * holds for `providers:` and `default_settings:`. There is no deep merge and no per-entry merge: the
95
+ * unit is the section.
96
+ *
97
+ * That is deliberately stated rather than deliberately chosen. Section-replacement is what the one-line
98
+ * spread has always done, and a caller reading "extends" reasonably expects the other reading, so the
99
+ * semantics are pinned by test (config-extends.test.ts) — change them on purpose, not by editing this
100
+ * line. Note also that `extends:` does not today give you a shared base: the CLI chdirs to the base's
101
+ * directory and rewrites `configPath`, so the base becomes the project (`.data`, `.env`, and every yaml
102
+ * write land there). See CLAUDE.md, *Default plugin settings*.
103
+ */
89
104
  export function parseConfig(
90
105
  text: string,
91
106
  base?: string,
@@ -6,7 +6,7 @@
6
6
  * - Block sequences (- item), including an item whose value is an indented block under a bare `-`
7
7
  * - Scalar types: string, number, boolean, null
8
8
  * - Quoted strings (single and double), as values and as keys
9
- * - Comments (# ...)
9
+ * - Comments (# ...), outside quotes and at a line start or after whitespace (see stripComment)
10
10
  *
11
11
  * Does NOT support anchors, aliases, flow syntax, or ${NAME} expansion.
12
12
  * ${NAME} placeholders are left intact for the Vault to resolve.
@@ -36,11 +36,47 @@ interface Token {
36
36
  line: number;
37
37
  }
38
38
 
39
+ // Where a `#` opens a comment, per YAML's own rule: outside a quoted scalar, and at the start of a
40
+ // line or after whitespace. Stripping every `#` truncated values that legitimately contain one —
41
+ // `github:owner/repo#path:sub` lost its subdirectory, and a quoted value lost everything from the `#`
42
+ // on, INCLUDING its closing quote, so it then failed to unquote and kept a stray `"`. Both were silent.
43
+ //
44
+ // A quote only opens a quoted scalar where a scalar can start (line start, or after the `:`/`-` that
45
+ // introduces a value); elsewhere it is an apostrophe in a plain scalar (`title: Matt's config`), and
46
+ // treating it as an opening quote would swallow a real trailing comment.
47
+ //
48
+ // Still unhandled, as before: a `#` inside a block scalar (`|`/`>`) body, and a backslash-escaped quote
49
+ // — this parser does not process escapes anywhere (see `unquote`).
50
+ function stripComment(line: string): string {
51
+ let quote: string | undefined;
52
+ for (let i = 0; i < line.length; i++) {
53
+ const ch = line[i]!;
54
+ if (quote !== undefined) {
55
+ if (ch === quote) quote = undefined;
56
+ continue;
57
+ }
58
+ if ((ch === '"' || ch === "'") && opensScalar(line, i)) { quote = ch; continue; }
59
+ if (ch === '#' && (i === 0 || line[i - 1] === ' ' || line[i - 1] === '\t')) return line.slice(0, i);
60
+ }
61
+ return line;
62
+ }
63
+
64
+ // Is position `i` somewhere a scalar can begin — nothing but whitespace before it, or the `:`/`-` that
65
+ // introduces one? Keys count: `'@scope/pkg':` is a quoted scalar at line start.
66
+ function opensScalar(line: string, i: number): boolean {
67
+ for (let j = i - 1; j >= 0; j--) {
68
+ const ch = line[j]!;
69
+ if (ch === ' ' || ch === '\t') continue;
70
+ return ch === ':' || ch === '-';
71
+ }
72
+ return true;
73
+ }
74
+
39
75
  function tokenize(text: string): Token[] {
40
76
  const tokens: Token[] = [];
41
77
  const lines = text.split('\n');
42
78
  for (let n = 0; n < lines.length; n++) {
43
- const stripped = lines[n]!.replace(/#.*$/, '').trimEnd();
79
+ const stripped = stripComment(lines[n]!).trimEnd();
44
80
  if (stripped.trim() === '') continue;
45
81
  const indent = stripped.length - stripped.trimStart().length;
46
82
  tokens.push({ indent, raw: stripped.trimStart(), line: n + 1 });
package/src/index.ts CHANGED
@@ -46,10 +46,11 @@ export {
46
46
  contextSwitch, machineBusy, quiesced, onContextQuiesce, scheduleAtEdge,
47
47
  installUsageCarrier, createSerialUsageCarrier, recordUsage, recordSpan, currentUsageSink,
48
48
  currentUsageScope, currentUsageSite, withUsageScope, withUsageSite,
49
+ scopeIterator, scopeIterable,
49
50
  } from '@matatbread/matbot-plugin-api/host';
50
51
  export type {
51
52
  Subscribable, Broadcaster, Routed, RoutedFilter, SwapFn, MountTable, PrincipalCarrier, UsageCarrier,
52
- UsageScope, Quiescer,
53
+ UsageScope, Quiescer, ScopeFn,
53
54
  } from '@matatbread/matbot-plugin-api/host';
54
55
  export * from './session.js';
55
56
  export * from './usage.js';
@@ -124,9 +124,10 @@ export class LookupKnowledgeIndex implements KnowledgeIndex {
124
124
  }
125
125
 
126
126
  async search(
127
- terms: Array<{ term: string; context?: string }>,
128
- _signal: AbortSignal,
127
+ terms: Array<{ term: string; context?: string }>,
128
+ signal: AbortSignal,
129
129
  ): Promise<KnowledgeEntry[]> {
130
+ signal.throwIfAborted();
130
131
  if (terms.length === 0) return [];
131
132
 
132
133
  const scored: Array<{ entry: KnowledgeEntry; score: number }> = [];
package/src/loader.ts CHANGED
@@ -4,9 +4,10 @@ import { incompatibleRuntimeError, notAPluginError } from '@matatbread/matbot-pl
4
4
  import { registerPlugin, setupPlugin, unloadPlugin, recordFailedPlugin, clearFailedPlugin, announcePluginLoaded } from './registry.js';
5
5
 
6
6
  /**
7
- * The runtime this process is executing in. Detected the same way the rejected-import branch below
8
- * already distinguishes platforms (`typeof window`). Used only for the declarative pre-import gate;
9
- * an absent `matbotRuntime` declaration bypasses the gate entirely.
7
+ * The runtime this process is executing in — the one place this module decides what platform it is on.
8
+ * Read by the declarative pre-import gate (an absent `matbotRuntime` declaration bypasses it entirely)
9
+ * and by the import-rejection branch, which re-derived `typeof window` inline until the two could have
10
+ * disagreed.
10
11
  */
11
12
  const CURRENT_RUNTIME: Runtime = typeof window !== 'undefined' ? 'browser' : 'node';
12
13
 
@@ -30,6 +31,23 @@ const FRESH_PARAM = 'mbfresh';
30
31
  // produce the same stamp and re-hit the cache.
31
32
  let freshSeq = 0;
32
33
 
34
+ /**
35
+ * One entry to load, for a host that has already done its own resolution — the object form of a plain
36
+ * specifier string. Every field is documented on {@link loadPlugins}, which is the only consumer.
37
+ *
38
+ * Named, because it was written out twice: once in the parameter list and once again in the `as` cast
39
+ * of the line that normalises the strings. Two spellings of one shape, where adding a field to the
40
+ * wrong one narrows what the loader will read from a host that is already passing it.
41
+ */
42
+ export interface LoadRequest {
43
+ spec: string;
44
+ importSpec?: string;
45
+ name?: string;
46
+ version?: string;
47
+ runtimes?: readonly Runtime[];
48
+ notes?: readonly string[];
49
+ }
50
+
33
51
  /**
34
52
  * Load plugins from the given specifiers, register them, then run setup().
35
53
  *
@@ -100,13 +118,13 @@ let freshSeq = 0;
100
118
  * only as a confusing empty-result error downstream — fail loudly with the reason instead.
101
119
  */
102
120
  export async function loadPlugins(
103
- specifiers: readonly (string | { spec: string; importSpec?: string; name?: string; version?: string; runtimes?: readonly Runtime[]; notes?: readonly string[] })[],
121
+ specifiers: readonly (string | LoadRequest)[],
104
122
  services: MatbotMachine,
105
123
  bustCache = false,
106
124
  prompt?: PromptFn,
107
125
  onLoadError: 'skip' | 'throw' = 'skip',
108
126
  ): Promise<MatbotPlugin[]> {
109
- const reqs = specifiers.map(s => (typeof s === 'string' ? { spec: s } : s) as { spec: string; importSpec?: string; name?: string; version?: string; runtimes?: readonly Runtime[]; notes?: readonly string[] });
127
+ const reqs: LoadRequest[] = specifiers.map(s => (typeof s === 'string' ? { spec: s } : s));
110
128
 
111
129
  // A reason reaching a user, decorated with what the host noticed while resolving this spec. The
112
130
  // failure a load reports is usually downstream of its cause, and the host is the only layer that
@@ -200,7 +218,7 @@ export async function loadPlugins(
200
218
 
201
219
  if (result.status === 'rejected') {
202
220
  const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
203
- if (typeof window !== 'undefined') {
221
+ if (CURRENT_RUNTIME === 'browser') {
204
222
  // Browser: warn and skip — browser environments have no node_modules fallback.
205
223
  console.warn(`[matbot] Could not load plugin "${spec}" (browser: use a URL path or configure an import map): ${reason}`);
206
224
  recordFailedPlugin({ specifier: spec, error: reason });
@@ -307,19 +325,6 @@ export async function loadPlugins(
307
325
  return loaded;
308
326
  }
309
327
 
310
- /**
311
- * Stamp a plugin entry URL with a unique `${FRESH_PARAM}` value to force a fresh
312
- * evaluation (and, with the node resolve hook, a fresh subtree — see FRESH_PARAM).
313
- *
314
- * Diagnostics: cache-busting silently degrades in two ways that are
315
- * indistinguishable from "it worked" at the call site, so both are logged here.
316
- * 1. import.meta.resolve throws (e.g. a cwd-relative spec that does not resolve
317
- * relative to *this* module's URL) — we fall back to the bare spec, which
318
- * re-imports the *cached* module. No busting happens at all.
319
- * 2. Resolution succeeds but no resolve hook is installed: only the entry is
320
- * re-evaluated, while everything it statically imports stays cached. We can
321
- * detect (1) here; (2) is noted at the call site.
322
- */
323
328
  /**
324
329
  * Classify how a specifier resolves to code, from its shape alone (platform-neutral; no fs).
325
330
  * github shorthand is only recognised via the explicit `github:` prefix — a bare `owner/repo`
@@ -379,6 +384,18 @@ function stampFresh(url: string): string {
379
384
  return u.href;
380
385
  }
381
386
 
387
+ /**
388
+ * Stamp a plugin entry URL with a unique `${FRESH_PARAM}` value to force a fresh evaluation (and, with
389
+ * the node resolve hook, a fresh subtree — see FRESH_PARAM).
390
+ *
391
+ * Diagnostics: cache-busting silently degrades in two ways that are indistinguishable from "it worked"
392
+ * at the call site, so both are logged.
393
+ * 1. import.meta.resolve throws (e.g. a cwd-relative spec that does not resolve relative to *this*
394
+ * module's URL) — we fall back to the bare spec, which re-imports the *cached* module. No busting
395
+ * happens at all. Detected here.
396
+ * 2. Resolution succeeds but no resolve hook is installed: only the entry is re-evaluated, while
397
+ * everything it statically imports stays cached. Noted at the call site.
398
+ */
382
399
  function toFreshUrl(spec: string): string {
383
400
  try {
384
401
  const fresh = stampFresh(import.meta.resolve(spec));
package/src/media.ts CHANGED
@@ -151,8 +151,13 @@ export async function ingestMedia(
151
151
  }
152
152
 
153
153
  // Derived, not counted — see MAX_MEDIA_BYTES_PER_SESSION. One listing for the whole batch.
154
+ //
155
+ // Filtered by NAMESPACE as well as session: the media store is routinely the host's own file area, so
156
+ // a `sessionId`-tagged workspace file or a detached job's output shares the listing and was being
157
+ // charged to the media quota. The quota is over what THIS path stored, which is what the namespace
158
+ // names — and it is the same pair the per-ref ownership check below tests against.
154
159
  let sessionBytes = 0;
155
- for await (const held of store.list({ sessionId })) sessionBytes += held.size;
160
+ for await (const held of store.list({ sessionId, namespace: MEDIA_NAMESPACE })) sessionBytes += held.size;
156
161
 
157
162
  const out: MessageContent[] = [];
158
163
  for (const c of content) {
package/src/registry.ts CHANGED
@@ -3,7 +3,7 @@ import { RegistryChangeKind } from '@matatbread/matbot-plugin-api';
3
3
  import { scopedNotifier, askPermissionGate } from '@matatbread/matbot-plugin-api/host';
4
4
  import type {
5
5
  MatbotPlugin, MatbotMachine, MatbotRuntime, Mounted,
6
- ProviderAdapterFactory, StoreFactory,
6
+ ProviderAdapterFactory,
7
7
  } from './plugin.js';
8
8
  import { PLUGIN_API_VERSION, unifyServices } from './plugin.js';
9
9
  import { makePluginSettings } from './settings.js';
@@ -18,7 +18,6 @@ import type { SettingsDoc } from './settings.js';
18
18
  const state = {
19
19
  plugins: [] as MatbotPlugin[],
20
20
  providers: new Map<string, ProviderAdapterFactory>(),
21
- storage: new Map<string, StoreFactory>(),
22
21
  toolRegistry: undefined as ToolRegistry | undefined,
23
22
  frontendPlugins: new Map<string, FrontendInfo>(), // pluginName → info, written by services.registerFrontend()
24
23
  serviceKeys: new Map<string, string[]>(), // pluginName → MatbotMachine keys it registered
@@ -114,24 +113,11 @@ export function registerPlugin(plugin: MatbotPlugin): void {
114
113
  throw new Error(`Provider "${plugin.name}" is already registered.`);
115
114
  }
116
115
 
117
- for (const type of Object.keys(plugin.storage ?? {})) {
118
- if (state.storage.has(type)) {
119
- const owner = state.plugins.find(p => p.storage?.[type] !== undefined)?.name ?? '?';
120
- throw new Error(
121
- `Storage type "${type}" is already registered by "${owner}". ` +
122
- `"${plugin.name}" cannot register it again.`,
123
- );
124
- }
125
- }
126
-
127
116
  state.plugins.push(plugin);
128
117
 
129
118
  if (plugin.provider !== undefined) {
130
119
  state.providers.set(plugin.name, plugin.provider);
131
120
  }
132
- for (const [type, factory] of Object.entries(plugin.storage ?? {})) {
133
- state.storage.set(type, factory);
134
- }
135
121
  }
136
122
 
137
123
  // ── Resolution ────────────────────────────────────────────────────────────────
@@ -313,6 +299,9 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
313
299
  // own settings — there is no way to name another's.
314
300
  const ownSettings = makePluginSettings(services.createStore<SettingsDoc>('settings'), plugin.name);
315
301
 
302
+ // Plugin-scoped notifier, likewise built once. See the `Notifier` member below.
303
+ const ownNotifier = scopedNotifier(services.Notifier, plugin.name);
304
+
316
305
  // Per-plugin `mounted`: a thin adapter over the host mount table that delivers *this plugin's* scoped
317
306
  // machine (and scoped onUnmount) to handlers. The stable `scoped` object reads through the host's
318
307
  // re-pointing proxies/registry, so `scoped[key]` is the host's live service by the time a transition
@@ -353,9 +342,11 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
353
342
  // being a deliberate read of the concrete gate at that moment.
354
343
  get PermissionGate() { return services.PermissionGate; },
355
344
  // Everything this plugin publishes is attributed to it by default — the notification analogue of
356
- // stamping `pluginName` on its tools. Reads through the host's swap proxy, so a registered
357
- // distributed Notifier takes effect for a plugin that captured this in setup().
358
- get Notifier() { return scopedNotifier(services.Notifier, plugin.name); },
345
+ // stamping `pluginName` on its tools. Wrapped ONCE per plugin (below), not per property read: this
346
+ // was a getter, so every `services.Notifier.notify(...)` minted a fresh three-method object. Swap
347
+ // safety is unaffected, because what is wrapped is the host's capture-safe proxy — both hosts put
348
+ // that stable object on the machine — so a registered distributed Notifier still takes effect.
349
+ Notifier: ownNotifier,
359
350
  tools: {
360
351
  register: registerTool,
361
352
  remove: (name: string) => services.tools.remove(name),
@@ -420,16 +411,25 @@ export async function unloadPlugin(pluginName: string, services: MatbotMachine):
420
411
  state.systemContextPlugins.delete(pluginName);
421
412
 
422
413
  if (plugin.provider !== undefined) state.providers.delete(plugin.name);
423
- for (const type of Object.keys(plugin.storage ?? {})) state.storage.delete(type);
424
414
 
425
415
  state.frontendPlugins.delete(pluginName);
426
416
 
427
417
  state.plugins.splice(idx, 1);
428
418
  services.Notifier.notify({ kind: RegistryChangeKind, source: 'plugins', registry: 'plugins', name: pluginName, operation: 'removed' });
429
- await Promise.race([
430
- plugin.teardown?.(),
431
- new Promise<void>((_, reject) => setTimeout(() => reject(new Error(`Teardown timeout for plugin ${pluginName}`)), 10000))
432
- ]);
419
+ // The timer is cleared however the race settles. Left dangling it outlived every SUCCESSFUL unload,
420
+ // holding the event loop open for the rest of its 10s — so `plugin unload` followed by exit sat there
421
+ // with nothing to wait for.
422
+ let timer: ReturnType<typeof setTimeout> | undefined;
423
+ try {
424
+ await Promise.race([
425
+ plugin.teardown?.(),
426
+ new Promise<void>((_, reject) => {
427
+ timer = setTimeout(() => reject(new Error(`Teardown timeout for plugin ${pluginName}`)), 10_000);
428
+ }),
429
+ ]);
430
+ } finally {
431
+ if (timer !== undefined) clearTimeout(timer);
432
+ }
433
433
  return true;
434
434
  }
435
435
 
package/src/runner.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  import type {
2
2
  Session, Message, MessageContent, ModelContent, Usage, UsageSite, ProviderMeta, TruncatedToolResult,
3
3
  TurnEvent, RunConfig, ProviderAdapter, ProviderConfig,
4
- Tool, ToolRegistry, ToolContext, Store, FileStore, MediaStore, SystemContextRegistry, Vault, PromptFn, FormField,
4
+ Tool, ToolRegistry, ToolContext, Store, FileStore, MediaStore, SystemContextRegistry, Vault, PromptFn,
5
5
  PermissionGate,
6
6
  } from './types.js';
7
7
  import type { MatbotPlugin } from './plugin.js';
8
8
  import type { ToolPresenter } from '@matatbread/matbot-plugin-api';
9
- import { recordSpan, recordUsage, withUsageSite } from '@matatbread/matbot-plugin-api/host';
9
+ import { recordSpan, recordUsage, withUsageSite, scopeIterable } from '@matatbread/matbot-plugin-api/host';
10
10
  import { HookRegistry } from './hooks.js';
11
11
  import { appendMessage, createMessage } from './session.js';
12
- import { foldOntoUserTurn, bindPluginOps, bindGate } from '@matatbread/matbot-plugin-api';
12
+ import { foldOntoUserTurn, bindPluginOps, bindGate, defaultingPrompt } from '@matatbread/matbot-plugin-api';
13
13
  import { addUsage } from './usage.js';
14
14
  import { MEDIA_RESIDENCY_BYTES, resolveSessionMedia } from './media.js';
15
15
 
@@ -17,24 +17,13 @@ import { MEDIA_RESIDENCY_BYTES, resolveSessionMedia } from './media.js';
17
17
  // runner is itself an async generator, so it suspends at every `yield` and resumes under the consumer's
18
18
  // context, losing any scope entered around the loop body. Scoping each `next()` instead means the
19
19
  // tool's body — and anything it kicks off while running — always resumes under its own site.
20
- function siteScoped<T>(site: UsageSite, src: AsyncIterable<T>): AsyncIterable<T> {
21
- return {
22
- [Symbol.asyncIterator]: () => {
23
- const it = src[Symbol.asyncIterator]();
24
- return {
25
- next: () => withUsageSite(site, () => it.next()),
26
- ...(it.return ? { return: (v?: never) => withUsageSite(site, () => it.return!(v)) } : {}),
27
- ...(it.throw ? { throw: (e?: unknown) => withUsageSite(site, () => it.throw!(e)) } : {}),
28
- };
29
- },
30
- };
31
- }
20
+ //
21
+ // `scopeIterable` is the shared per-pull wrap, as used by `runAs` for the principal: this had its own
22
+ // object-literal copy, which replaced the iterator wholesale and so dropped a class-based tool
23
+ // iterator's own members, prototype and `instanceof`.
24
+ const siteScoped = <T>(site: UsageSite, src: AsyncIterable<T>): AsyncIterable<T> =>
25
+ scopeIterable(src, f => withUsageSite(site, f));
32
26
 
33
- // Substituted for an errored tool result when the turn was aborted (e.g. a mid-turn steer interrupt):
34
- // the tool was cut off, not genuinely faulty, and the raw abort reason ("Error: steer") is a leaked
35
- // internal token that reads as a real failure. A steer's continuation turn is the one place a model
36
- // reads this, so the wording tells it the call was interrupted and leaves re-running to its judgement —
37
- // deliberately NOT "re-run it", so a side-effecting tool isn't reflexively repeated.
38
27
  // Creator of the marker recording that a provider cut a response short. Marker-role, so it is durable
39
28
  // and visible to a reader while elided from every submission: the model's own text is already truncated
40
29
  // in the transcript, and a block telling it so invites narrating the cut-off rather than continuing.
@@ -45,6 +34,19 @@ const TRUNCATION_CREATOR = 'matbot-truncation';
45
34
  const abortReason = (signal: AbortSignal): string =>
46
35
  typeof signal.reason === 'string' ? signal.reason : 'user-abort';
47
36
 
37
+ /**
38
+ * A thrown value as a turn-terminal message, with its `cause` appended when it has one.
39
+ *
40
+ * A `throw` carries anything, so this narrows before reaching for `cause`: `'cause' in e` throws
41
+ * TypeError on a primitive, which made the catch handler ITSELF throw for a provider that rejected
42
+ * with a string — escaping the generator and skipping the `end()` commit that the branch calling this
43
+ * exists to perform. The one place in this package that took `any` rather than narrow.
44
+ */
45
+ const errorText = (e: unknown): string => {
46
+ const cause = typeof e === 'object' && e !== null && 'cause' in e ? (e as { cause?: unknown }).cause : undefined;
47
+ return cause ? `${String(e)} (${String(cause)})` : String(e);
48
+ };
49
+
48
50
  /** How long a tool executor may keep the turn alive AFTER the turn was aborted, before the runner stops
49
51
  * reading it. An abort must abort: any executor can fail to end — a child process whose stdout was
50
52
  * inherited by an orphan, a generator awaiting something that never settles, a bridged remote that went
@@ -73,6 +75,11 @@ function abandonDeadline(signal: AbortSignal, ms: number): { wait: Promise<typeo
73
75
  };
74
76
  }
75
77
 
78
+ // Substituted for an errored tool result when the turn was aborted (e.g. a mid-turn steer interrupt):
79
+ // the tool was cut off, not genuinely faulty, and the raw abort reason ("Error: steer") is a leaked
80
+ // internal token that reads as a real failure. A steer's continuation turn is the one place a model
81
+ // reads this, so the wording tells it the call was interrupted and leaves re-running to its judgement —
82
+ // deliberately NOT "re-run it", so a side-effecting tool isn't reflexively repeated.
76
83
  const INTERRUPTED_TOOL_RESULT = {
77
84
  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.',
78
85
  } as const;
@@ -137,12 +144,9 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
137
144
  const { config, provider, providerConfig, store, signal } = opts;
138
145
  const tools = opts.tools ?? new Map<string, Tool>();
139
146
  const hookReg = opts.hooks ?? new HookRegistry();
140
- const promptFn: PromptFn = opts.prompt ?? (((p: string | FormField, def?: string): Promise<string> => {
141
- const fallback = typeof p === 'string' ? def : p.default;
142
- if (fallback !== undefined) return Promise.resolve(fallback);
143
- const label = typeof p === 'string' ? p : p.label;
144
- return Promise.reject(new Error(`Non-interactive context: cannot prompt for "${label}"`));
145
- }) as PromptFn);
147
+ // The stand-in answers with a field's own default where it has one — which is precisely why the gate
148
+ // below is handed `opts.prompt` raw instead (see bindGate).
149
+ const promptFn: PromptFn = opts.prompt ?? defaultingPrompt;
146
150
  const vault: Vault = opts.vault ?? {
147
151
  async createSecret() { throw new Error('No vault configured'); },
148
152
  async writeSecret() { throw new Error('No vault configured'); },
@@ -408,7 +412,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
408
412
  break;
409
413
  }
410
414
  }
411
- } catch (e: any) {
415
+ } catch (e: unknown) {
412
416
  // A provider call cancelled by an in-situ restart (callAc, not the turn signal) is expected — fall
413
417
  // through to the restart below rather than surfacing it as a turn abort or an error.
414
418
  if (restart || (callAc.signal.aborted && !signal.aborted)) {
@@ -436,7 +440,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
436
440
  // carries no session (a failure is not a transcript), so this yields the terminal itself rather
437
441
  // than the session — but it commits first, exactly like every other exit.
438
442
  bookRound();
439
- yield* end({ type: 'error', error: String(e) + (('cause' in e && e.cause) ? ' ('+String(e.cause)+')' : '' ), traceId });
443
+ yield* end({ type: 'error', error: errorText(e), traceId });
440
444
  return;
441
445
  }
442
446
  }
@@ -165,10 +165,16 @@ function createSink(dispose: () => void): Sink {
165
165
  };
166
166
  }
167
167
 
168
- export function wireDescription(description: string, wc: { params: string; result: string } | undefined) {
169
- return wc
170
- ? `${description}\n\nTypeScript params:\n\`\`\`\n${wc.params}\n\`\`\`\n\nTypeScript result:\`\`\`${wc.result}\n\`\`\``
171
- : description;
168
+ /**
169
+ * A tool's description with its wire contract appended — the text the model actually reads for every
170
+ * tool, every turn. Both blocks are fenced identically; the result block used to omit the newline after
171
+ * its label and before its fence, so the type opened inline against "TypeScript result:```" and the
172
+ * block never closed where a reader expects it.
173
+ */
174
+ export function wireDescription(description: string, wc: { params: string; result: string } | undefined): string {
175
+ if (wc === undefined) return description;
176
+ const block = (label: string, type: string): string => `${label}:\n\`\`\`\n${type}\n\`\`\``;
177
+ return `${description}\n\n${block('TypeScript params', wc.params)}\n\n${block('TypeScript result', wc.result)}`;
172
178
  }
173
179
 
174
180
  export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
@@ -280,295 +286,288 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
280
286
  // site, and spelling it here made it something the next caller of `machineBusy` could omit without
281
287
  // any symptom — so it moved inside the hold it guards.
282
288
  return machineBusy(async () => {
283
- try {
284
- while (s.queue.length > 0) {
285
- // Per-submission concat: the head always runs; if the head is a concat submission it absorbs
286
- // the following submissions while they too are concat, stopping at the first non-concat (a turn
287
- // boundary). Net effect: maximal runs of consecutive concat submissions merge into one turn,
288
- // while every queued/robo submission stays its own ordered turn — concat and queued mix freely.
289
- const head = s.queue.shift()!;
290
- const batch = [head];
291
- if (head.concatQueue) {
292
- while (s.queue.length > 0 && s.queue[0]!.concatQueue) batch.push(s.queue.shift()!);
293
- }
294
- const content = batch.flatMap(i => i.content);
295
- s.runningTraceId = head.traceId;
296
- s.replay = [];
297
- // Seed replay with the running turn's user message as a single merged `queued`, mirroring the
298
- // message persisted just below. notify() (in open()) reaches only subscribers that are live at
299
- // enqueue time; a GET /events/sessions/:id that connects after this synchronous preamble — the
300
- // common case when the submit POST wins the race against the events stream — would otherwise
301
- // find an empty queue and a cleared replay, and never render the user bubble. One merged event
302
- // (not one per batch item) matches both stored history on reload and the live fold, so a late
303
- // subscriber reconstructs exactly one bubble. A redo carries no new user message (and no
304
- // content), so it seeds nothing — its retraction marker already went out at enqueue time.
305
- if (head.redo === undefined) {
306
- s.replay.push({ type: 'queued', content, queued: 0, concatQueue: false, traceId: head.traceId, rootTraceId: head.rootTraceId });
307
- }
289
+ try {
290
+ while (s.queue.length > 0) {
291
+ // Per-submission concat: the head always runs; if the head is a concat submission it absorbs
292
+ // the following submissions while they too are concat, stopping at the first non-concat (a turn
293
+ // boundary). Net effect: maximal runs of consecutive concat submissions merge into one turn,
294
+ // while every queued/robo submission stays its own ordered turn — concat and queued mix freely.
295
+ const head = s.queue.shift()!;
296
+ const batch = [head];
297
+ if (head.concatQueue) {
298
+ while (s.queue.length > 0 && s.queue[0]!.concatQueue) batch.push(s.queue.shift()!);
299
+ }
300
+ const content = batch.flatMap(i => i.content);
301
+ s.runningTraceId = head.traceId;
302
+ s.replay = [];
303
+ // Seed replay with the running turn's user message as a single merged `queued`, mirroring the
304
+ // message persisted just below. notify() (in open()) reaches only subscribers that are live at
305
+ // enqueue time; a GET /events/sessions/:id that connects after this synchronous preamble — the
306
+ // common case when the submit POST wins the race against the events stream — would otherwise
307
+ // find an empty queue and a cleared replay, and never render the user bubble. One merged event
308
+ // (not one per batch item) matches both stored history on reload and the live fold, so a late
309
+ // subscriber reconstructs exactly one bubble. A redo carries no new user message (and no
310
+ // content), so it seeds nothing — its retraction marker already went out at enqueue time.
311
+ if (head.redo === undefined) {
312
+ s.replay.push({ type: 'queued', content, queued: 0, concatQueue: false, traceId: head.traceId, rootTraceId: head.rootTraceId });
313
+ }
308
314
 
309
- let session = await deps.store.get(id);
310
- if (session === null) {
311
- emit(s, { type: 'error', error: `Session "${id}" not found`, traceId: head.traceId });
312
- continue;
313
- }
315
+ let session = await deps.store.get(id);
316
+ if (session === null) {
317
+ emit(s, { type: 'error', error: `Session "${id}" not found`, traceId: head.traceId });
318
+ continue;
319
+ }
320
+
321
+ // A redo re-runs the existing committed user turn — no title derivation, no new user message.
322
+ if (head.redo === undefined) {
323
+ if (!session.title && !session.messages.some(m => m.role === 'user')) {
324
+ // Falls back to the attachment names when the turn has no words of its own: an image-only
325
+ // first turn is a real submission, and got no title at all — which reads in the session
326
+ // list as a session that failed to start.
327
+ const text = content
328
+ .filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text')
329
+ .map(c => c.text).join(' ').trim()
330
+ || content
331
+ .filter((c): c is Extract<MessageContent, { type: 'file-ref' }> => c.type === 'file-ref')
332
+ .map(c => c.name).join(', ');
333
+ if (text) {
334
+ const words = text.split(/\s+/).slice(0, 8).join(' ');
335
+ session = { ...session, title: words.length > 60 ? `${words.slice(0, 60)}…` : words };
336
+ }
337
+ }
314
338
 
315
- // A redo re-runs the existing committed user turn — no title derivation, no new user message.
316
- if (head.redo === undefined) {
317
- if (!session.title && !session.messages.some(m => m.role === 'user')) {
318
- // Falls back to the attachment names when the turn has no words of its own: an image-only
319
- // first turn is a real submission, and got no title at all — which reads in the session
320
- // list as a session that failed to start.
321
- const text = content
322
- .filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text')
323
- .map(c => c.text).join(' ').trim()
324
- || content
325
- .filter((c): c is Extract<MessageContent, { type: 'file-ref' }> => c.type === 'file-ref')
326
- .map(c => c.name).join(', ');
327
- if (text) {
328
- const words = text.split(/\s+/).slice(0, 8).join(' ');
329
- session = { ...session, title: words.length > 60 ? `${words.slice(0, 60)}…` : words };
339
+ // Persist-at-turn-start: the user message only hits the store when its turn begins, never
340
+ // while queued. That is what stops a mid-turn submit from clobbering session state. A robo
341
+ // resubmission's blocks already carry `origin: 'robo'` (stamped where it was enqueued).
342
+ session = appendMessage(session, createMessage({ role: 'user', content, traceId: head.traceId, providerName: head.provider }));
343
+ try {
344
+ await deps.store.set(session.id, session);
345
+ } catch (e) {
346
+ // A read-only store rejects the persist-at-turn-start write (e.g. a session shared read-only
347
+ // from another profile's partition). That is a per-turn condition, not a host fault: surface
348
+ // it as a turn error and drop this submission rather than let it escape the detached pump and
349
+ // crash the process. Any other write failure stays fatal.
350
+ if (!isReadOnlyError(e)) throw e;
351
+ emit(s, { type: 'error', error: e.message, traceId: head.traceId });
352
+ continue;
330
353
  }
331
354
  }
332
355
 
333
- // Persist-at-turn-start: the user message only hits the store when its turn begins, never
334
- // while queued. That is what stops a mid-turn submit from clobbering session state. A robo
335
- // resubmission's blocks already carry `origin: 'robo'` (stamped where it was enqueued).
336
- session = appendMessage(session, createMessage({ role: 'user', content, traceId: head.traceId, providerName: head.provider }));
337
- try {
338
- await deps.store.set(session.id, session);
339
- } catch (e) {
340
- // A read-only store rejects the persist-at-turn-start write (e.g. a session shared read-only
341
- // from another profile's partition). That is a per-turn condition, not a host fault: surface
342
- // it as a turn error and drop this submission rather than let it escape the detached pump and
343
- // crash the process. Any other write failure stays fatal.
344
- if (!isReadOnlyError(e)) throw e;
345
- emit(s, { type: 'error', error: e.message, traceId: head.traceId });
356
+ const resolved = await deps.resolveProvider(head.provider);
357
+ if (resolved === null) {
358
+ emit(s, { type: 'error', error: `Unknown provider "${head.provider}"`, traceId: head.traceId });
346
359
  continue;
347
360
  }
348
- }
349
-
350
- const resolved = await deps.resolveProvider(head.provider);
351
- if (resolved === null) {
352
- emit(s, { type: 'error', error: `Unknown provider "${head.provider}"`, traceId: head.traceId });
353
- continue;
354
- }
355
361
 
356
- // A redo re-runs an existing turn with its ephemeral; a steer carries an interrupt's nudge. The
357
- // two never coincide on one item, so either supplies runSession's injectedEphemeral.
358
- const inject = head.redo?.ephemeral ?? head.injectEphemeral;
359
-
360
- const ac = new AbortController();
361
- s.ac = ac;
362
- // Fold each tool's wire contract — the `params`/`result` text flattened from its single contract
363
- // (a source tool's ToolContracts arms, or a source-less tool's `toolContract`) — into its description
364
- // for this turn, so the model reasons about the real TS shapes, not just the loose inputSchema.
365
- // Derived here because `services`/ToolTypeIndex are live at dispatch. Tools with only a loose
366
- // inputSchema (no contract) are left as-is; absent ToolTypeIndex (browser) ⇒ plain descriptions.
367
- const wire = await deps.toolTypeIndex?.()?.wireContracts();
368
- const toolPresenter = deps.toolPresenter?.();
369
- const mediaStore = deps.mediaStore?.();
370
- const permissionGate = deps.permissionGate?.();
371
- const toolMap = deps.tools !== undefined
372
- ? new Map<string, Tool>(deps.tools.list().map(t => {
373
- const wc = wire?.[t.name];
374
- return [t.name, wc !== undefined
375
- ? { ...t, description: wireDescription(t.description, wc) }
376
- : t];
377
- }))
378
- : undefined;
379
-
380
- try {
381
- // Establish the submitter's principal for the whole turn here, not inside runSession:
382
- // pump runs detached (`void pump`), so this scope — not the request that enqueued — is the
383
- // turn's async root. Everything downstream (hooks, tools, and any Store/FileStore/Vault
384
- // access they trigger) reads it via currentPrincipal(). The consumption is inside the callback
385
- // because the pump owns it, not because it must be: runAs rescopes a returned iterator itself.
386
- // runAs, not a context switch of its own: the pump holds the machine across the whole queue
387
- // (see above), so a turn declares only its owner. Each item carries its own submitter.
388
- // The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
389
- // commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
390
- // refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
391
- // in both cases a followup hook would judge history with no completed answer in it — and a
392
- // `resubmit` would undo the very stop that just happened, handing a round ceiling a fresh
393
- // budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
394
- // signal-driven aborts and not the policy ones.
395
- let terminal: PipelineEvent['type'] | undefined;
396
- // One usage scope per pump ITERATION rather than per turn. `followup` is post-commit by
397
- // definition and a detached classifier settles whenever it settles, so a scope that ended at
398
- // the turn's commit would drop precisely the spend that is hardest to account for otherwise.
399
- // The entries array is registered for flush before any work starts, so a late arrival lands
400
- // in something already tracked and needs no coordination of its own.
401
- await withUsageScope(async usage => {
402
- s.pendingUsage.push(usage.entries);
403
- await runAs(head.principal, async () => {
404
- for await (const ev of runSession({
405
- session,
406
- config: { provider: head.provider, traceId: head.traceId },
407
- provider: resolved.adapter,
408
- providerConfig: resolved.config,
409
- store: deps.store,
410
- signal: ac.signal,
411
- loadPlugin: deps.loadPlugin,
412
- unloadPlugin: deps.unloadPlugin,
413
- ...(toolMap !== undefined ? { tools: toolMap } : {}),
414
- ...(deps.tools !== undefined ? { toolRegistry: deps.tools } : {}),
415
- ...(toolPresenter !== undefined ? { toolPresenter } : {}),
416
- ...(deps.hooks !== undefined ? { hooks: deps.hooks } : {}),
417
- ...(deps.systemContext !== undefined ? { systemContext: deps.systemContext } : {}),
418
- ...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
419
- ...(deps.files !== undefined ? { files: deps.files } : {}),
420
- ...(mediaStore !== undefined ? { mediaStore } : {}),
421
- ...(permissionGate !== undefined ? { permissionGate } : {}),
422
- ...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
423
- ...(deps.vault !== undefined ? { vault: deps.vault } : {}),
424
- ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
425
- ...(inject !== undefined ? { injectedEphemeral: inject } : {}),
426
- })) {
427
- if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
428
- emit(s, ev);
429
- }
430
- });
362
+ // A redo re-runs an existing turn with its ephemeral; a steer carries an interrupt's nudge. The
363
+ // two never coincide on one item, so either supplies runSession's injectedEphemeral.
364
+ const inject = head.redo?.ephemeral ?? head.injectEphemeral;
365
+
366
+ const ac = new AbortController();
367
+ s.ac = ac;
368
+ // Fold each tool's wire contract — the `params`/`result` text flattened from its single contract
369
+ // (a source tool's ToolContracts arms, or a source-less tool's `toolContract`) — into its description
370
+ // for this turn, so the model reasons about the real TS shapes, not just the loose inputSchema.
371
+ // Derived here because `services`/ToolTypeIndex are live at dispatch. Tools with only a loose
372
+ // inputSchema (no contract) are left as-is; absent ToolTypeIndex (browser) ⇒ plain descriptions.
373
+ const wire = await deps.toolTypeIndex?.()?.wireContracts();
374
+ const toolPresenter = deps.toolPresenter?.();
375
+ const mediaStore = deps.mediaStore?.();
376
+ const permissionGate = deps.permissionGate?.();
377
+ const toolMap = deps.tools !== undefined
378
+ ? new Map<string, Tool>(deps.tools.list().map(t => {
379
+ const wc = wire?.[t.name];
380
+ return [t.name, wc !== undefined
381
+ ? { ...t, description: wireDescription(t.description, wc) }
382
+ : t];
383
+ }))
384
+ : undefined;
431
385
 
432
- // followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
433
- // head-enqueue a robo follow-up (its own real turn, running next). Runs only for a turn that
434
- // committed (see `terminal`), and under the submitter's principal because a reactor may itself
435
- // call complete() (a classifier).
436
- const hooks = deps.hooks;
437
- if (terminal === 'done' && hooks) {
438
- const committed = await deps.store.get(id);
439
- if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
440
- let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } } = { resubmits: [], markers: [] };
386
+ try {
387
+ // Establish the submitter's principal for the whole turn here, not inside runSession:
388
+ // pump runs detached (`void pump`), so this scope — not the request that enqueued — is the
389
+ // turn's async root. Everything downstream (hooks, tools, and any Store/FileStore/Vault
390
+ // access they trigger) reads it via currentPrincipal(). The consumption is inside the callback
391
+ // because the pump owns it, not because it must be: runAs rescopes a returned iterator itself.
392
+ // runAs, not a context switch of its own: the pump holds the machine across the whole queue
393
+ // (see above), so a turn declares only its owner. Each item carries its own submitter.
394
+ // The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
395
+ // commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
396
+ // refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
397
+ // in both cases a followup hook would judge history with no completed answer in it — and a
398
+ // `resubmit` would undo the very stop that just happened, handing a round ceiling a fresh
399
+ // budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
400
+ // signal-driven aborts and not the policy ones.
401
+ let terminal: PipelineEvent['type'] | undefined;
402
+ // One usage scope per pump ITERATION rather than per turn. `followup` is post-commit by
403
+ // definition and a detached classifier settles whenever it settles, so a scope that ended at
404
+ // the turn's commit would drop precisely the spend that is hardest to account for otherwise.
405
+ // The entries array is registered for flush before any work starts, so a late arrival lands
406
+ // in something already tracked and needs no coordination of its own.
407
+ await withUsageScope(async usage => {
408
+ s.pendingUsage.push(usage.entries);
441
409
  await runAs(head.principal, async () => {
442
- followup = await hooks.runFollowup({
443
- session: committed,
444
- resubmitDepth: head.resubmitDepth,
445
- config: { provider: head.provider, traceId: head.traceId },
446
- signal: ac.signal,
447
- ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
448
- });
410
+ for await (const ev of runSession({
411
+ session,
412
+ config: { provider: head.provider, traceId: head.traceId },
413
+ provider: resolved.adapter,
414
+ providerConfig: resolved.config,
415
+ store: deps.store,
416
+ signal: ac.signal,
417
+ loadPlugin: deps.loadPlugin,
418
+ unloadPlugin: deps.unloadPlugin,
419
+ ...(toolMap !== undefined ? { tools: toolMap } : {}),
420
+ ...(deps.tools !== undefined ? { toolRegistry: deps.tools } : {}),
421
+ ...(toolPresenter !== undefined ? { toolPresenter } : {}),
422
+ ...(deps.hooks !== undefined ? { hooks: deps.hooks } : {}),
423
+ ...(deps.systemContext !== undefined ? { systemContext: deps.systemContext } : {}),
424
+ ...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
425
+ ...(deps.files !== undefined ? { files: deps.files } : {}),
426
+ ...(mediaStore !== undefined ? { mediaStore } : {}),
427
+ ...(permissionGate !== undefined ? { permissionGate } : {}),
428
+ ...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
429
+ ...(deps.vault !== undefined ? { vault: deps.vault } : {}),
430
+ ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
431
+ ...(inject !== undefined ? { injectedEphemeral: inject } : {}),
432
+ })) {
433
+ if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
434
+ emit(s, ev);
435
+ }
449
436
  });
450
- const resubmits = followup.resubmits;
451
-
452
- // Durable markers a followup hook returned (e.g. a fired trigger's silent tool tracing
453
- // what it did). Append-only to the just-committed session; the pump is the sole writer
454
- // post-commit, so an unconditional set is safe. Emitted live too (post-`done`, like a
455
- // `queued` event) so a live draw matches a reload — the engine surfaces everything it
456
- // persists; a frontend filters if it wants to. Skipped when retracting: the pop rewrites
457
- // the message tail, so these markers are folded into that single write instead (below)
458
- // to keep ordering sane (a separate append here would be sliced into the popped region).
459
- if (followup.markers.length > 0 && !followup.retract) {
460
- await deps.store.set(id, {
461
- ...committed,
462
- messages: [...committed.messages, {
463
- id: crypto.randomUUID(),
464
- role: 'marker',
465
- createdAt: new Date().toISOString(),
466
- traceId: head.traceId,
467
- content: followup.markers,
468
- }],
469
- });
470
- notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
471
- }
472
- // unshift in reverse so the hooks' order is preserved at the head of the queue. Stamp
473
- // every block `origin: 'robo'` here — once — so it rides into both the live `queued`
474
- // event and the persisted user message the pump builds from this item.
475
- for (const raw of resubmits.reverse()) {
476
- const rt = crypto.randomUUID();
477
- // Every block, not just text: `origin` is authorship, orthogonal to what the block
478
- // carries, and a resubmission that attached an image was silently presenting it as the
479
- // user's own. The arms a stamp would be wrong on don't occur here — a hook returns
480
- // content, not a thinking block — so a blanket stamp beats an arm-by-arm list.
481
- const content = raw.map(c => ({ ...c, origin: 'robo' as const }));
482
- s.queue.unshift({
483
- traceId: rt,
484
- rootTraceId: head.rootTraceId,
485
- content,
486
- provider: head.provider,
487
- principal: head.principal,
488
- concatQueue: false,
489
- resubmitDepth: head.resubmitDepth + 1,
490
- });
491
- notify(s, { type: 'queued', content, queued: 0, concatQueue: false, traceId: rt, rootTraceId: head.rootTraceId });
492
- }
493
437
 
494
- // retract-and-rerun: pop the just-committed turn back to (and excluding) the last user
495
- // message, stash the popped messages in a durable retraction marker (LLM-elided like any
496
- // marker — so the model never re-reads its superseded answer — but carried in `data` for
497
- // a strike-through render and audit), then re-enqueue a redo of that same user turn at the
498
- // head, delivering the trigger tool's output as ephemeral context. Unshifted last so it
499
- // sits at the very head (runs next) even if a resubmit was also queued above.
500
- if (followup.retract) {
501
- const lastUserIdx = lastUserIndex(committed);
502
- const popped = lastUserIdx >= 0 ? committed.messages.slice(lastUserIdx + 1) : [];
503
- // `durable` correction (a contextual fire that landed post-commit) folds onto the user
504
- // message we keep — persisted, so the redo AND every later turn see it in history, the
505
- // exact durability the clean in-situ path gives. `context` stays ephemeral (redo only).
506
- // The fold runs on the *truncated* session, whose last user message is the one we keep,
507
- // so it goes through the same helper as the screen and raced-verdict folds.
508
- const durable = followup.retract.durable ?? [];
509
- const truncated = lastUserIdx >= 0
510
- ? { ...committed, messages: committed.messages.slice(0, lastUserIdx + 1) }
511
- : committed;
512
- const kept = foldOntoUserTurn(truncated, durable).messages;
513
- const retractionMsg: Message = {
514
- id: crypto.randomUUID(),
515
- role: 'marker',
516
- createdAt: new Date().toISOString(),
517
- traceId: head.traceId,
518
- // `retracted` is what was popped (the superseded answer); `injected` is the ephemeral
519
- // context fed to the redo — neither is otherwise persisted, so the pair fully traces
520
- // the swap for a post-mortem (and a frontend can render the strike-through + the cause).
521
- content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract.context ?? [], traceId: head.traceId } }],
522
- };
523
- // Any followup markers ride along as a trailing marker message (not folded into the
524
- // retraction marker) so each creator's trace stays its own block.
525
- const trailing: Message[] = followup.markers.length > 0
526
- ? [{ id: crypto.randomUUID(), role: 'marker', createdAt: new Date().toISOString(), traceId: head.traceId, content: followup.markers }]
527
- : [];
528
- await deps.store.set(id, { ...committed, messages: [...kept, retractionMsg, ...trailing] });
529
- // Emit the durable fold live (as a robo-user on the kept user turn) before the retraction
530
- // marker, mirroring the screen-time durable path's ordering.
531
- if (durable.length > 0) notify(s, { type: 'robo-user', content: durable, traceId: head.traceId });
532
- notify(s, { type: 'marker', content: retractionMsg.content, traceId: head.traceId });
533
- if (trailing.length > 0) notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
534
-
535
- const rt = crypto.randomUUID();
536
- s.queue.unshift({
537
- traceId: rt,
538
- rootTraceId: head.rootTraceId,
539
- content: [],
540
- provider: head.provider,
541
- principal: head.principal,
542
- concatQueue: false,
543
- resubmitDepth: head.resubmitDepth + 1,
544
- redo: { ephemeral: followup.retract.context ?? [] },
545
- ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
546
- });
438
+ // followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
439
+ // head-enqueue a robo follow-up (its own real turn, running next). Runs only for a turn that
440
+ // committed (see `terminal`), and under the submitter's principal because a reactor may itself
441
+ // call complete() (a classifier).
442
+ const hooks = deps.hooks;
443
+ if (terminal === 'done' && hooks) {
444
+ const committed = await deps.store.get(id);
445
+ if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
446
+ let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } } = { resubmits: [], markers: [] };
447
+ await runAs(head.principal, async () => {
448
+ followup = await hooks.runFollowup({
449
+ session: committed,
450
+ resubmitDepth: head.resubmitDepth,
451
+ config: { provider: head.provider, traceId: head.traceId },
452
+ signal: ac.signal,
453
+ ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
454
+ });
455
+ });
456
+ const resubmits = followup.resubmits;
457
+
458
+ // Durable markers a followup hook returned (e.g. a fired trigger's silent tool tracing
459
+ // what it did). Append-only to the just-committed session; the pump is the sole writer
460
+ // post-commit, so an unconditional set is safe. Emitted live too (post-`done`, like a
461
+ // `queued` event) so a live draw matches a reload — the engine surfaces everything it
462
+ // persists; a frontend filters if it wants to. Skipped when retracting: the pop rewrites
463
+ // the message tail, so these markers are folded into that single write instead (below)
464
+ // to keep ordering sane (a separate append here would be sliced into the popped region).
465
+ if (followup.markers.length > 0 && !followup.retract) {
466
+ await deps.store.set(id, {
467
+ ...committed,
468
+ messages: [...committed.messages,
469
+ createMessage({ role: 'marker', content: followup.markers, traceId: head.traceId })],
470
+ });
471
+ notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
472
+ }
473
+ // unshift in reverse so the hooks' order is preserved at the head of the queue. Stamp
474
+ // every block `origin: 'robo'` here — once — so it rides into both the live `queued`
475
+ // event and the persisted user message the pump builds from this item.
476
+ for (const raw of resubmits.reverse()) {
477
+ const rt = crypto.randomUUID();
478
+ // Every block, not just text: `origin` is authorship, orthogonal to what the block
479
+ // carries, and a resubmission that attached an image was silently presenting it as the
480
+ // user's own. The arms a stamp would be wrong on don't occur here — a hook returns
481
+ // content, not a thinking block — so a blanket stamp beats an arm-by-arm list.
482
+ const content = raw.map(c => ({ ...c, origin: 'robo' as const }));
483
+ s.queue.unshift({
484
+ traceId: rt,
485
+ rootTraceId: head.rootTraceId,
486
+ content,
487
+ provider: head.provider,
488
+ principal: head.principal,
489
+ concatQueue: false,
490
+ resubmitDepth: head.resubmitDepth + 1,
491
+ });
492
+ notify(s, { type: 'queued', content, queued: 0, concatQueue: false, traceId: rt, rootTraceId: head.rootTraceId });
493
+ }
494
+
495
+ // retract-and-rerun: pop the just-committed turn back to (and excluding) the last user
496
+ // message, stash the popped messages in a durable retraction marker (LLM-elided like any
497
+ // marker — so the model never re-reads its superseded answer — but carried in `data` for
498
+ // a strike-through render and audit), then re-enqueue a redo of that same user turn at the
499
+ // head, delivering the trigger tool's output as ephemeral context. Unshifted last so it
500
+ // sits at the very head (runs next) even if a resubmit was also queued above.
501
+ if (followup.retract) {
502
+ const lastUserIdx = lastUserIndex(committed);
503
+ const popped = lastUserIdx >= 0 ? committed.messages.slice(lastUserIdx + 1) : [];
504
+ // `durable` correction (a contextual fire that landed post-commit) folds onto the user
505
+ // message we keep — persisted, so the redo AND every later turn see it in history, the
506
+ // exact durability the clean in-situ path gives. `context` stays ephemeral (redo only).
507
+ // The fold runs on the *truncated* session, whose last user message is the one we keep,
508
+ // so it goes through the same helper as the screen and raced-verdict folds.
509
+ const durable = followup.retract.durable ?? [];
510
+ const truncated = lastUserIdx >= 0
511
+ ? { ...committed, messages: committed.messages.slice(0, lastUserIdx + 1) }
512
+ : committed;
513
+ const kept = foldOntoUserTurn(truncated, durable).messages;
514
+ const retractionMsg: Message = createMessage({
515
+ role: 'marker',
516
+ traceId: head.traceId,
517
+ // `retracted` is what was popped (the superseded answer); `injected` is the ephemeral
518
+ // context fed to the redo — neither is otherwise persisted, so the pair fully traces
519
+ // the swap for a post-mortem (and a frontend can render the strike-through + the cause).
520
+ content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract.context ?? [], traceId: head.traceId } }],
521
+ });
522
+ // Any followup markers ride along as a trailing marker message (not folded into the
523
+ // retraction marker) so each creator's trace stays its own block.
524
+ const trailing: Message[] = followup.markers.length > 0
525
+ ? [createMessage({ role: 'marker', content: followup.markers, traceId: head.traceId })]
526
+ : [];
527
+ await deps.store.set(id, { ...committed, messages: [...kept, retractionMsg, ...trailing] });
528
+ // Emit the durable fold live (as a robo-user on the kept user turn) before the retraction
529
+ // marker, mirroring the screen-time durable path's ordering.
530
+ if (durable.length > 0) notify(s, { type: 'robo-user', content: durable, traceId: head.traceId });
531
+ notify(s, { type: 'marker', content: retractionMsg.content, traceId: head.traceId });
532
+ if (trailing.length > 0) notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
533
+
534
+ const rt = crypto.randomUUID();
535
+ s.queue.unshift({
536
+ traceId: rt,
537
+ rootTraceId: head.rootTraceId,
538
+ content: [],
539
+ provider: head.provider,
540
+ principal: head.principal,
541
+ concatQueue: false,
542
+ resubmitDepth: head.resubmitDepth + 1,
543
+ redo: { ephemeral: followup.retract.context ?? [] },
544
+ ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
545
+ });
546
+ }
547
+ }
547
548
  }
548
- }
549
+ }, { traceId: head.traceId, rootTraceId: head.rootTraceId });
550
+ } catch (e) {
551
+ emit(s, { type: 'error', error: String(e), traceId: head.traceId });
552
+ } finally {
553
+ s.ac = undefined;
549
554
  }
550
- }, { traceId: head.traceId, rootTraceId: head.rootTraceId });
551
- } catch (e) {
552
- emit(s, { type: 'error', error: String(e), traceId: head.traceId });
553
- } finally {
554
- s.ac = undefined;
555
555
  }
556
+ } finally {
557
+ s.running = false;
558
+ s.runningTraceId = undefined;
559
+ s.replay = [];
560
+ // Accounting is flushed HERE — at the drained queue, not at a turn boundary. "The end of a turn"
561
+ // is not a well-defined moment to total anything at: steers terminate and resume, a retract
562
+ // re-enqueues the turn it just popped, followup enqueues resubmissions, and a detached classifier
563
+ // settles whenever it settles. The queue draining is unambiguous, and it is after all of them.
564
+ await flushUsage(id, s);
565
+ // Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
566
+ // (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
567
+ // the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
568
+ notify(s, { type: 'idle', sessionId: id });
569
+ maybeCleanup(id, s);
556
570
  }
557
- } finally {
558
- s.running = false;
559
- s.runningTraceId = undefined;
560
- s.replay = [];
561
- // Accounting is flushed HERE — at the drained queue, not at a turn boundary. "The end of a turn"
562
- // is not a well-defined moment to total anything at: steers terminate and resume, a retract
563
- // re-enqueues the turn it just popped, followup enqueues resubmissions, and a detached classifier
564
- // settles whenever it settles. The queue draining is unambiguous, and it is after all of them.
565
- await flushUsage(id, s);
566
- // Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
567
- // (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
568
- // the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
569
- notify(s, { type: 'idle', sessionId: id });
570
- maybeCleanup(id, s);
571
- }
572
571
  });
573
572
  };
574
573
 
package/src/session.ts CHANGED
@@ -1,4 +1,8 @@
1
- import type { Session, Message, MessageContent, MessageRole } from './types.js';
1
+ import type { Session, Message } from './types.js';
2
+
3
+ // `createMessage` lives in plugin-api, where the hook registry can also reach it (core is downstream of
4
+ // that package). Re-exported here so it stays where every consumer already imports it from.
5
+ export { createMessage } from '@matatbread/matbot-plugin-api';
2
6
 
3
7
  export interface CreateSessionOpts {
4
8
  title?: string;
@@ -32,20 +36,3 @@ export function appendMessage(session: Session, message: Message): Session {
32
36
  };
33
37
  }
34
38
 
35
- export function createMessage(opts: {
36
- role: MessageRole;
37
- content: MessageContent[];
38
- traceId: string;
39
- providerName?: string;
40
- metadata?: Record<string, unknown>;
41
- }): Message {
42
- return {
43
- id: crypto.randomUUID(),
44
- role: opts.role,
45
- content: opts.content,
46
- createdAt: new Date().toISOString(),
47
- traceId: opts.traceId,
48
- ...(opts.providerName !== undefined ? { providerName: opts.providerName } : {}),
49
- ...(opts.metadata !== undefined ? { metadata: opts.metadata } : {}),
50
- };
51
- }
package/src/settings.ts CHANGED
@@ -70,6 +70,9 @@ export function settingsDefaultNamespaces(): readonly string[] {
70
70
  * consumer filters `kind === ItemChangeKind && namespace === SETTINGS_NAMESPACE && id === slug(mine)`. */
71
71
  export const SETTINGS_NAMESPACE = 'settings';
72
72
 
73
+ /** How many times a settings write re-reads and re-compares before giving up. See `update` below. */
74
+ const SETTINGS_CAS_ATTEMPTS = 8;
75
+
73
76
  /**
74
77
  * The bus settings writes announce on, installed by the host at boot alongside {@link installSettingsDefaults}.
75
78
  *
@@ -134,8 +137,14 @@ export function makePluginSettings(rawStore: Store<SettingsDoc>, namespace: stri
134
137
 
135
138
  // One CAS retry loop for both writers: read, apply `mutate`, write. A document that vanished between
136
139
  // read and write (or never existed) is a plain `set`; otherwise `cas` guards the version we read.
140
+ //
141
+ // Bounded, because the loop cannot tell a lost race from a `cas` that will never succeed: a backend
142
+ // whose compare always fails (a version it does not mint, a medium generation it cannot reconcile)
143
+ // turned this into a hot spin with no error and no exit. A settings namespace is one document with few
144
+ // writers, so real contention resolves in one or two passes; exhausting the budget is a fault to
145
+ // report, not a state to keep retrying.
137
146
  const update = async (mutate: (data: Record<string, unknown>) => Record<string, unknown> | undefined): Promise<void> => {
138
- for (;;) {
147
+ for (let attempt = 1; ; attempt++) {
139
148
  const doc = await getDoc();
140
149
  const data = mutate(doc?.data ?? {});
141
150
  if (data === undefined) return;
@@ -143,6 +152,13 @@ export function makePluginSettings(rawStore: Store<SettingsDoc>, namespace: stri
143
152
  if (doc === null) { await store.set(id, next); return; }
144
153
  const r = await store.cas(id, doc.version, next);
145
154
  if (r.ok) return;
155
+ if (attempt >= SETTINGS_CAS_ATTEMPTS) {
156
+ throw new Error(
157
+ `Settings for "${namespace}" could not be written: ${SETTINGS_CAS_ATTEMPTS} compare-and-swap ` +
158
+ 'attempts all lost. Either something is writing this namespace continuously, or the storage ' +
159
+ 'backend is rejecting every compare.',
160
+ );
161
+ }
146
162
  }
147
163
  };
148
164