@matatbread/matbot-plugin-api 0.4.17 → 0.4.18

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.17",
7
+ "version": "0.4.18",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
package/src/hooks.ts CHANGED
@@ -59,16 +59,30 @@ export class HookRegistry implements HookRegistrar {
59
59
  // than per-channel because it is the one place every handler invocation passes through — and because
60
60
  // the site must be in force when the handler *starts*, so work it kicks off detached stays attributed
61
61
  // to it however late it settles.
62
- private async invoke<R>(hook: Hook, run: () => R | Promise<R>): Promise<R | undefined> {
62
+ private async invoke<R>(hook: Hook, signal: AbortSignal, run: () => R | Promise<R>): Promise<R | undefined> {
63
63
  try {
64
64
  return await withUsageSite(
65
65
  { kind: 'hook', channel: hook.on, ...(hook.pluginName !== undefined ? { plugin: hook.pluginName } : {}) },
66
66
  run,
67
67
  );
68
68
  } catch (err) {
69
- const message = err instanceof Error ? err.message : String(err);
70
69
  const owner = hook.pluginName !== undefined ? ` from "${hook.pluginName}"` : '';
71
- console.error(`[matbot] ${hook.on} hook${owner} threw; skipping it for this turn:`, message);
70
+ // An aborted turn (a steer, a cancel) rejects whatever was awaiting its signal with the abort REASON —
71
+ // often a bare token like 'steer', not an Error — which logged as a failure whose whole message was
72
+ // "steer". The hook was cut off, not broken, so it is said as such and leaves no failure marker.
73
+ //
74
+ // Which is asked of the THROWN value, not of the signal alone. `aborted` stays true for the rest of
75
+ // the turn, so testing it alone reported every later throw as a cut-off — masking a genuine hook bug,
76
+ // marker and stack included, from the first steer onwards. Interrupt is the default disposition for a
77
+ // mid-turn message, so that window is the common case, not a corner of one. A cut-off throw is either
78
+ // the reason itself (what a signal rejects with) or the standard AbortError.
79
+ if (signal.aborted && (err === signal.reason || (err as { name?: unknown } | null)?.name === 'AbortError')) {
80
+ console.warn(`[matbot] ${hook.on} hook${owner} was cut off: its turn was aborted (reason: ${describeThrown(signal.reason)}).`);
81
+ return undefined;
82
+ }
83
+ const message = err instanceof Error ? err.message : `threw a non-Error value: ${describeThrown(err)}`;
84
+ // The Error itself, not its message, so the log carries the stack — the message alone rarely says where.
85
+ console.error(`[matbot] ${hook.on} hook${owner} threw; skipping it for this turn:`, err instanceof Error ? err : message);
72
86
  if (!this.markedFailures.has(hook)) {
73
87
  this.markedFailures.add(hook);
74
88
  this.pendingFailureMarkers.push({
@@ -134,7 +148,7 @@ export class HookRegistry implements HookRegistrar {
134
148
  session = folded;
135
149
  };
136
150
  for (const hook of this.on('screen')) {
137
- const r = await this.invoke(hook, () => hook.handler({ ...ctx, session, removeHook: () => this.removeOne('screen', hook) }));
151
+ const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, session, removeHook: () => this.removeOne('screen', hook) }));
138
152
  if (!r) continue;
139
153
  if (r.session) session = r.session;
140
154
  if (r.ephemeral) ephemeral.push(...r.ephemeral);
@@ -155,7 +169,7 @@ export class HookRegistry implements HookRegistrar {
155
169
  async runContribute(ctx: Omit<ContributeContext, 'removeHook'>): Promise<Message[]> {
156
170
  let outgoing = ctx.outgoing as Message[];
157
171
  for (const hook of this.on('contribute')) {
158
- const r = await this.invoke(hook, () => hook.handler({ ...ctx, outgoing, removeHook: () => this.removeOne('contribute', hook) }));
172
+ const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, outgoing, removeHook: () => this.removeOne('contribute', hook) }));
159
173
  if (r) outgoing = r;
160
174
  }
161
175
  return outgoing;
@@ -164,7 +178,7 @@ export class HookRegistry implements HookRegistrar {
164
178
  // toolcall stops at the first hook that rejects or aborts; the rest don't run.
165
179
  async runToolCall(ctx: Omit<ToolCallContext, 'removeHook'>): Promise<ToolCallResult> {
166
180
  for (const hook of this.on('toolcall')) {
167
- const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('toolcall', hook) }));
181
+ const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('toolcall', hook) }));
168
182
  if (r && (r.rejectTool || r.abort)) return r;
169
183
  }
170
184
  return {};
@@ -175,7 +189,7 @@ export class HookRegistry implements HookRegistrar {
175
189
  async runToolResult(ctx: Omit<ToolResultContext, 'removeHook'>): Promise<unknown> {
176
190
  let result = ctx.result;
177
191
  for (const hook of this.on('toolresult')) {
178
- const r = await this.invoke(hook, () => hook.handler({ ...ctx, result, removeHook: () => this.removeOne('toolresult', hook) }));
192
+ const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, result, removeHook: () => this.removeOne('toolresult', hook) }));
179
193
  if (r) result = r.result;
180
194
  }
181
195
  return result;
@@ -195,7 +209,7 @@ export class HookRegistry implements HookRegistrar {
195
209
  // asking to pop and re-run with nothing added was answered by doing nothing at all, silently.
196
210
  let retracting = false;
197
211
  for (const hook of this.on('followup')) {
198
- const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('followup', hook) }));
212
+ const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('followup', hook) }));
199
213
  if (r?.resubmit) resubmits.push(r.resubmit.content);
200
214
  if (r?.retractAndRerun) {
201
215
  retracting = true;
@@ -207,3 +221,9 @@ export class HookRegistry implements HookRegistrar {
207
221
  return { resubmits, markers, ...(retracting ? { retract: { context: retractCtx, durable: retractDur } } : {}) };
208
222
  }
209
223
  }
224
+
225
+ function describeThrown(value: unknown): string {
226
+ if (value instanceof Error) return `${value.name}: ${value.message}`;
227
+ if (typeof value === 'string') return JSON.stringify(value);
228
+ try { return JSON.stringify(value) ?? String(value); } catch { return String(value); }
229
+ }
package/src/index.ts CHANGED
@@ -29,10 +29,10 @@ export { PLUGIN_API_VERSION } from './plugin.js';
29
29
  // the host's job (`/host`).
30
30
  export { currentPrincipal, tryCurrentPrincipal, runAs } from './principal-context.js';
31
31
 
32
- // Notifications: publish, consume, and the two kinds plugin-api itself defines. Minting the process
32
+ // Notifications: publish, consume, and the three kinds plugin-api itself defines. Minting the process
33
33
  // notifier is the host's job (`/host`).
34
34
  export type * from './notify.js';
35
- export { ItemChangeKind, RegistryChangeKind, notifyingStore } from './notify.js';
35
+ export { ItemChangeKind, RegistryChangeKind, SessionAppendKind, notifyingStore } from './notify.js';
36
36
 
37
37
  // The quiescent edge: defer work to the next moment it is safe to touch machine state. A plugin that has
38
38
  // something to do after the current operation — a store edit the running turn would otherwise write over —
package/src/notify.ts CHANGED
@@ -89,7 +89,22 @@ export interface RegistryChange extends NotificationBase {
89
89
  }
90
90
 
91
91
  /**
92
- * The wire tags for the two kinds defined here. A `kind` is a globally-scoped token — unlike a type
92
+ * Messages were appended to a session outside any turn (see `SessionAppender`) — not by the session's
93
+ * own pump, so nothing on that session's turn stream says so.
94
+ *
95
+ * The same write raises an {@link ItemChange} for the session; this exists beside it because it carries
96
+ * the one thing a re-read cannot recover: WHICH messages arrived this way. A turn adds messages too, so a
97
+ * consumer diffing the document would have to keep a copy of it to tell them apart. A frontend draws
98
+ * these into the open conversation; a chat bridge forwards them to the chat the session belongs to.
99
+ * Identity only, as ever — a consumer re-reads the session for the content.
100
+ */
101
+ export interface SessionAppend extends NotificationBase {
102
+ readonly sessionId: string;
103
+ readonly messageIds: readonly string[];
104
+ }
105
+
106
+ /**
107
+ * The wire tags for the kinds defined here. A `kind` is a globally-scoped token — unlike a type
93
108
  * name, which an importer can rename out of a clash — so these exist to give one back: import the const
94
109
  * under whatever local name suits (`import { ItemChangeKind as Changed }`) and use it as a `case` label
95
110
  * or in a filter. `satisfies` ties each to a real key, so a typo fails the build here instead of
@@ -97,6 +112,7 @@ export interface RegistryChange extends NotificationBase {
97
112
  */
98
113
  export const ItemChangeKind = '@matatbread/matbot-plugin-api#ItemChange' satisfies keyof Notifications;
99
114
  export const RegistryChangeKind = '@matatbread/matbot-plugin-api#RegistryChange' satisfies keyof Notifications;
115
+ export const SessionAppendKind = '@matatbread/matbot-plugin-api#SessionAppend' satisfies keyof Notifications;
100
116
 
101
117
  /**
102
118
  * The registry of notification shapes, keyed by `kind` — augment it exactly like `MatbotServices` /
@@ -128,6 +144,7 @@ export const RegistryChangeKind = '@matatbread/matbot-plugin-api#RegistryChange'
128
144
  export interface Notifications {
129
145
  '@matatbread/matbot-plugin-api#ItemChange': ItemChange;
130
146
  '@matatbread/matbot-plugin-api#RegistryChange': RegistryChange;
147
+ '@matatbread/matbot-plugin-api#SessionAppend': SessionAppend;
131
148
  }
132
149
 
133
150
  /** A notification as a consumer sees it: the arm, plus the `kind` its registry key supplies. Grafting
package/src/plugin.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  ProviderAdapter, ProviderConfig, ProviderRegistry, Tool, ToolRegistry, FrontendInfo,
4
4
  Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner, Usage, HookRegistrar,
5
5
  TypeScriptStripper, FunctionRunner, ToolTypeIndex, ToolPresenter, SteeringPolicy, UserContent, PermissionGate,
6
+ SessionAppender, EphemeralRun,
6
7
  } from './types.js';
7
8
  import type { Notifications, Notifier } from './notify.js';
8
9
 
@@ -193,6 +194,11 @@ export interface MatbotServices {
193
194
  * one; absent ⇒ such code runs unbounded, as it must where synchronous code cannot be interrupted.
194
195
  * See {@link FunctionRunner}. */
195
196
  readonly FunctionRunner?: FunctionRunner | undefined;
197
+ /** Appends messages to a session without running a turn, once no turn holds it. The host seeds
198
+ * one wherever it holds the session store; a background job registers one that forwards to its parent.
199
+ * Absent ⇒ this process has no safe way to append (a job spawned without that channel), and a caller
200
+ * refuses rather than writing the store itself. See {@link SessionAppender}. */
201
+ readonly SessionAppender?: SessionAppender | undefined;
196
202
  }
197
203
 
198
204
  /** The assembled machine: registry services wired to the fixed runtime — what `setup()` receives. */
@@ -294,6 +300,22 @@ export interface MatbotRuntime {
294
300
  /** Per-session turn serialiser. Frontends submit and observe through this rather than calling
295
301
  * runSession directly, so concurrent submits queue instead of clobbering the session. */
296
302
  readonly run?: SessionRunner | undefined;
303
+ /**
304
+ * A private runner over its own in-memory session store, for a turn that should leave no trace: a
305
+ * background job's run, a demonstration whose transcript is analysed and thrown away. Nothing it holds
306
+ * is persisted or announced, and nothing outlives the returned object — so create the session in
307
+ * `sessions` before `run.open`, and drop both when done.
308
+ *
309
+ * Tools on this runner still reach the machine's real stores through what they captured at setup:
310
+ * `session_action list` sees the user's conversations, and `session_edit` of the ephemeral session
311
+ * refuses, finding nothing. `appender` is how such a turn reports somewhere real — it arrives as
312
+ * `ToolContext.appender`, and its `defaultSessionId` is where an append naming no session goes.
313
+ *
314
+ * Its turns hold the machine as any turn does. Its tools use the live machine, so a storage swap or an
315
+ * unload waits until the run ends. Its appends, and its edits of other sessions, do not: each waits only
316
+ * for a turn of the session it writes (`SessionRunner.write`).
317
+ */
318
+ ephemeral?(opts?: { appender?: SessionAppender }): EphemeralRun;
297
319
  readonly files?: FileStore;
298
320
  readonly hooks: HookRegistrar;
299
321
  readonly tools: ToolRegistry;
@@ -55,6 +55,18 @@ export type TurnEvent =
55
55
  // the pump's per-turn `queued` replay seed / committed store order — this is purely live delivery.
56
56
  | { type: 'steer'; content: MessageContent[]; interruptedTraceId: string; traceId: string; rootTraceId: string }
57
57
 
58
+ // A mid-turn submission answered IN PARALLEL with the running turn (`mode: 'parallel'`), which is left
59
+ // untouched. It runs on a private copy of the session's completed turns — everything before
60
+ // `runningTraceId`'s user message — so its events (this one first, then its own turn's, under
61
+ // `traceId`) are live delivery only: its tool calls stay in that copy and are never persisted, and its
62
+ // terminal's `session` is the COPY, not this session. What survives is the submission and its final
63
+ // reply, written back as a user/assistant pair and announced by `merged`.
64
+ | { type: 'parallel'; content: MessageContent[]; runningTraceId: string; traceId: string; rootTraceId: string }
65
+ // A parallel turn's pair has been written into the session. `into` is the turn that was running when
66
+ // it landed: the pair went into that turn's history, ahead of its user message (where the copy it ran
67
+ // on ended), at a round boundary. Absent ⇒ nothing was running and it was appended.
68
+ | { type: 'merged'; traceId: string; into?: string }
69
+
58
70
  // ── terminals: exactly one per turn ──
59
71
  | { type: 'done'; session: Session; traceId: string }
60
72
  | { type: 'aborted'; reason: string; session: Session; traceId: string }
@@ -181,13 +181,13 @@ export interface Session {
181
181
  // Legacy: persisted sessions may still carry `ownerPrincipalId` / `actorPrincipalId` / `persona`, and
182
182
  // `contexts` (a required-but-never-read `string[]`, dropped at 0.4.0 — system context is contributed by
183
183
  // SystemContextContributor, not carried on the session). Never read — ownership-at-rest is structural
184
- // (the storage partition), resolved via the backend, not a field. Left undeclared; old data keeps them
185
- // harmlessly as excess properties.
186
- title?: string;
187
- status: SessionStatus;
188
- messages: Message[];
189
- parentSessionId?: string;
190
- branchPointMessageId?: string;
191
- createdAt: ISODate;
192
- updatedAt: ISODate;
184
+ // (the storage partition), resolved via the backend, not a field. Likewise `parentSessionId` /
185
+ // `branchPointMessageId` (dropped at 0.4.18): a fork or split records its relation as a marker in the
186
+ // session (`peerSessionId` + `targetMsg`), which is what a frontend reads. Left undeclared; old data
187
+ // keeps them harmlessly as excess properties.
188
+ title?: string;
189
+ status: SessionStatus;
190
+ messages: Message[];
191
+ createdAt: ISODate;
192
+ updatedAt: ISODate;
193
193
  }
@@ -0,0 +1,47 @@
1
+ // ── Session appends ─────────────────────────────────────────────────────────────
2
+
3
+ /**
4
+ * A message added to a session outside any turn — a background job's report, an answer merged back
5
+ * from elsewhere.
6
+ *
7
+ * Deliberately narrow: plain text, user or assistant. An append arrives unasked into a history that is
8
+ * replayed to a provider on every later turn, so the arms that carry protocol state are excluded by
9
+ * construction — a `tool-use` without its result, or a `thinking` block whose signature it did not
10
+ * earn, would fail every turn after it rather than this one.
11
+ */
12
+ export interface AppendMessage {
13
+ role: 'user' | 'assistant';
14
+ content: Array<{ type: 'text'; text: string; origin?: 'robo' }>;
15
+ metadata?: Record<string, unknown>;
16
+ }
17
+
18
+ export interface AppendResult {
19
+ sessionId: string;
20
+ /** The ids the appended messages will carry — minted at acceptance, so a caller can name them before
21
+ * the write has landed. */
22
+ messageIds: string[];
23
+ /** The write waits for a turn holding the session, so it has not landed yet. Absent ⇒ it has. */
24
+ deferred?: true;
25
+ }
26
+
27
+ /**
28
+ * Adds messages to the end of a session without running a turn.
29
+ *
30
+ * A running turn owns its session: the runner works on an in-memory copy and writes it back whole when
31
+ * the turn ends, which is what keeps the stored session a set of completed turns. So an append is written
32
+ * by the session's runner once no turn holds that session (`SessionRunner.write`), and announced then as a
33
+ * `SessionAppend` notification. When no turn holds it, that is at once, and the append settles once
34
+ * written, reporting a write that could not land. When one does, it settles once **accepted**: the caller
35
+ * may be that very turn, and awaiting the write from inside it would deadlock.
36
+ *
37
+ * The host seeds one in every process that holds the session store. A background job does not get
38
+ * one: it shares the store's medium with its parent but none of the parent's pumps, so a write it made
39
+ * would race them unseen. A job registers one that forwards to its parent instead; with neither, a
40
+ * caller refuses rather than writing.
41
+ */
42
+ export interface SessionAppender {
43
+ /** Where an append naming no session goes, when this process has a destination of its own — a
44
+ * background job's: the conversation it reports to. Absent ⇒ the caller supplies its own session. */
45
+ readonly defaultSessionId?: string;
46
+ append(sessionId: string | undefined, messages: readonly AppendMessage[]): Promise<AppendResult>;
47
+ }
@@ -4,15 +4,16 @@ import type { MimeType } from './primitives.js';
4
4
  import type { Principal } from './principal.js';
5
5
  import type { SteeringMode } from './steering.js';
6
6
  import type { PromptFn } from './tools.js';
7
+ import type { Store } from './storage.js';
7
8
 
8
9
  // ── Session runner ──────────────────────────────────────────────────────────────
9
10
 
10
11
  /**
11
12
  * A view onto a session returned by `SessionRunner.open`. `session` is the authoritative
12
- * server-side state — committed messages plus an overlay of any queued-but-not-yet-run
13
- * submissions (each carrying `metadata.pending: true`). `events` is a lazy, per-session live
14
- * tap: accessing it subscribes to the turn event stream from now (replaying the in-flight
15
- * turn, if any); never touching it costs nothing.
13
+ * server-side state — committed messages only, ending at the running turn's user message. The
14
+ * in-flight turn and any queued submissions are the delta, delivered over `events`, never overlaid
15
+ * here. `events` is a lazy, per-session live tap: accessing it subscribes to the turn event stream
16
+ * from now (replaying the in-flight turn, then the queue); never touching it costs nothing.
16
17
  */
17
18
  export interface SessionView {
18
19
  session: Session;
@@ -73,8 +74,9 @@ export interface SubmitOpenOpts extends OpenOpts {
73
74
  concatQueue?: boolean;
74
75
  /** Disposition for a submission arriving while a turn is running (see {@link SteeringPolicy}):
75
76
  * 'queue' waits for the turn boundary (default), 'interrupt' stops the running turn — keeping its
76
- * committed partial work — and runs this next, 'auto' defers to the registered SteeringPolicy (else
77
- * the host default). Meaningless when nothing is running (degrades to a plain enqueue). */
77
+ * committed partial work — and runs this next, 'parallel' answers it now beside the running turn
78
+ * (see the `parallel` event), 'auto' defers to the registered SteeringPolicy (else the host
79
+ * default). Meaningless when nothing is running (degrades to a plain enqueue). */
78
80
  mode?: SteeringMode;
79
81
  /** Interactive prompt implementation for this submission's turn. The frontend owns delivery —
80
82
  * it must target the frontend's per-session client connections, not a single request. */
@@ -89,13 +91,54 @@ export interface SubmitOpenOpts extends OpenOpts {
89
91
  */
90
92
  export interface SessionRunner {
91
93
  open(opts: OpenOpts | SubmitOpenOpts): Promise<SessionView>;
92
- /** Abort the running turn (if any) and drop all queued submissions, emitting `cancelled` for each. */
94
+ /** Abort the running turn (if any), any `parallel` turns beside it, and drop all queued submissions,
95
+ * emitting `cancelled` for each. */
93
96
  abort(sessionId: string): void;
97
+ /** Stop ONE `parallel` turn, leaving the running turn, the queue and any other parallel turns alone.
98
+ * A parallel turn is the only kind that can be stopped individually: it runs on a nested runner with
99
+ * a stop of its own, where a queued submission has not started and the running turn is what `abort`
100
+ * and `cancelTurn` already address. It still settles and writes back its pair, carrying a note in
101
+ * place of the reply — the submission was made, and dropping it would lose that. A no-op if no
102
+ * parallel turn of the session carries this traceId. Deliberately its own method rather than an
103
+ * optional argument to `abort`: an implementation that ignored the argument would silently stop
104
+ * everything, which is the opposite of what the caller asked. */
105
+ abortParallel(sessionId: string, traceId: string): void;
94
106
  /** Abandon the running turn (if any) WITHOUT touching the queue — `pump` advances to the next
95
107
  * queued submission, or idles. The "give up on this turn" path (a prompt cancel); contrast
96
108
  * `abort`, which also clears the queue. A no-op if nothing is running. */
97
109
  cancelTurn(sessionId: string): void;
98
- /** Snapshot of a session's live state: whether a turn is running and how many submissions wait
99
- * behind it. `busy` is `running || queued > 0`. */
100
- status(sessionId: string): { busy: boolean; running: boolean; queued: number };
110
+ /** Snapshot of a session's live state: whether a turn is running, how many submissions wait behind
111
+ * it, and how many `parallel` turns are in flight beside it. `busy` is any of the three. */
112
+ status(sessionId: string): { busy: boolean; running: boolean; queued: number; parallel: number };
113
+ /**
114
+ * Write session `sessionId` from outside its turns — an append, a rename, an edit of its history.
115
+ *
116
+ * A turn works on an in-memory copy of its session and writes it back whole when it ends, so a write
117
+ * landing during a turn is undone by that write-back. The runner is therefore the session's one writer:
118
+ * it runs `attempt` between that session's turns, in the order writes arrive, and no turn of the session
119
+ * starts until it has finished. Other sessions' turns do not delay it.
120
+ *
121
+ * `attempt` reads the session itself and writes it with compare-and-swap, which still answers any writer
122
+ * outside this runner (another process sharing the store). It resolves `false` only when that CAS lost,
123
+ * to be read and tried again, and `true` once finished, whether or not it wrote; it reports its own
124
+ * failures. `lost` is logged if every attempt loses. It runs as the principal in force at this call.
125
+ *
126
+ * It never runs before this call returns. See {@link SessionWrite} for when awaiting it is safe.
127
+ */
128
+ write(sessionId: string, attempt: () => Promise<boolean>, lost: string): SessionWrite;
129
+ }
130
+
131
+ /** A write handed to {@link SessionRunner.write}. */
132
+ export interface SessionWrite {
133
+ /** A turn of the session was in progress, so the write waits for it to end. Then never await `done`
134
+ * from that turn: its end is what the write is waiting for. */
135
+ deferred: boolean;
136
+ /** Settles once the write has run, however it ended (each failure is logged). Never rejects. */
137
+ done: Promise<void>;
138
+ }
139
+
140
+ /** A runner and the store it alone reads and writes — see {@link MatbotRuntime.ephemeral}. */
141
+ export interface EphemeralRun {
142
+ sessions: Store<Session>;
143
+ run: SessionRunner;
101
144
  }
@@ -4,8 +4,9 @@ import type { MessageContent, Session } from './messages.js';
4
4
 
5
5
  /** Disposition of a submission that arrives while a turn is running. `interrupt` stops the running
6
6
  * turn (its committed partial work is preserved) and runs the new message next; `queue` waits for the
7
- * turn boundary. */
8
- export type SteeringDecision = 'queue' | 'interrupt';
7
+ * turn boundary; `parallel` leaves the running turn alone and answers the new message at once on a
8
+ * private copy of the session's completed turns, writing back only the message and its final reply. */
9
+ export type SteeringDecision = 'queue' | 'interrupt' | 'parallel';
9
10
  /** The wire-level request (see `SubmitOpenOpts.mode`). `auto` defers the decision to the registered
10
11
  * {@link SteeringPolicy} (else the host default). */
11
12
  export type SteeringMode = SteeringDecision | 'auto';
@@ -4,6 +4,7 @@ import type { MatbotPlugin } from '../plugin.js';
4
4
  import type { JSONSchema } from './primitives.js';
5
5
  import type { PermissionRequest } from './permission.js';
6
6
  import type { Vault } from './vault.js';
7
+ import type { SessionAppender } from './session-append.js';
7
8
 
8
9
  // ── Tools ─────────────────────────────────────────────────────────────────────
9
10
 
@@ -212,6 +213,12 @@ export interface ToolContext {
212
213
  workdir?: string;
213
214
  configPath?: string;
214
215
  files?: FileStore;
216
+ /**
217
+ * The appender belonging to the runner this turn is on, when it is not the machine's — an ephemeral
218
+ * run's ({@link MatbotRuntime.ephemeral}), whose session exists nowhere the machine's appender can
219
+ * reach. Prefer it over `services.SessionAppender`; absent ⇒ the machine's is the right one.
220
+ */
221
+ appender?: SessionAppender;
215
222
  /** Prompt the user for input. The host provides a readline or form implementation. */
216
223
  prompt: PromptFn;
217
224
  /**
package/src/types.ts CHANGED
@@ -26,4 +26,5 @@ export * from './types/health.js';
26
26
  export * from './types/registries.js';
27
27
  export * from './types/events.js';
28
28
  export * from './types/session-runner.js';
29
+ export * from './types/session-append.js';
29
30
  export * from './types/steering.js';