pi-roundtable 0.17.0 → 0.17.3

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/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.17.3] - 2026-10-16
9
+
10
+ ### Fixed
11
+
12
+ - The 0.17.1 entry and the plugin guide called `turnTimeoutMs` and `turnMaxMs` options of the built-in runtime that a host can set. Nothing public sets them: the runtime plugin passes neither, and the runtime class is not exported. Every host runs a turn with 10 minutes of work, renewed by each steer up to 30 minutes in all. The guide now says the two limits are fixed, and its startup order lists the runtime's own `preflight`, which 0.17.2 moved after every plugin's.
13
+ - `testPlugin` runs the runtime's `preflight` where the host does, right after the plugin's own and before the plugin's services and surfaces start, not after they have. A runtime whose `preflight` throws fails `testPlugin` with that error, with nothing started and the runtime disposed.
14
+ - A time limit on a turn, or on a coding worker (`workTimeout`), that comes due while an owner's card is open looks at the turn again no oftener than once a second, not once a millisecond for as long as the card waits. The limit still fires once; a limit that waiting on a card pushed back can fire up to a second late.
15
+
16
+ ### Changed
17
+
18
+ - 0.17.2 was tagged but never published: its release run stopped on a LINE plugin test that raced its own frozen clock. 0.17.3 carries 0.17.2's fix, the runtime's startup probe after every plugin's `preflight`; the LINE tests that raced now step their clock, and no shipped LINE code changed.
19
+
20
+ ## [0.17.2] - 2026-10-16
21
+
22
+ ### Fixed
23
+
24
+ - The runtime's startup probe of the model and the required tools runs after every plugin's `preflight`, not in the runtime plugin's own preflight, which ran first. Since 0.17.0 a plugin that resolves its MCP servers does so in its preflight, so the probe no longer saw the tools they yield, and a required tool that was missing failed only the first session instead of the start. The host now runs the `RUNTIME` service's `preflight` once every plugin's preflight has finished, still before any service starts or any listener opens; a failure stops the start, and the same host may try again. A custom runtime provider's `preflight` moves to the same place. The runtime plugin's claude-bridge memory warning stays in its own preflight. `Roundtable.check()` still runs no preflight.
25
+
26
+ ## [0.17.1] - 2026-10-16
27
+
28
+ ### Changed
29
+
30
+ - A message steered into a running turn renews the turn's time budget: the built-in runtime gives the turn its full `turnTimeoutMs` (10 minutes by default) again from the steer, so a turn the owner kept steering is no longer aborted, and its final reply lost, at 10 minutes from its start. The new runtime option `turnMaxMs` caps the work of one turn, steered or not, at three times `turnTimeoutMs` by default (30 minutes); once it is reached the turn takes no more steering, the message waits for the next turn, and the turn is aborted as on its timeout. Time waiting on the owner's cards counts towards neither. A renewal is logged at info (`steer renewed the turn's time budget`, with `channel` and `deadlineMs`, the work time left), and the timeout warning names the limit that fired (`limit: "turn"` or `"turnMax"`). Task workers, coding workers and sandbox guests keep their own timeouts.
31
+
8
32
  ## [0.17.0] - 2026-10-16
9
33
 
10
34
  ### Added
package/docs/plugins.md CHANGED
@@ -1443,7 +1443,7 @@ An `AgentRuntime` has these methods:
1443
1443
  | `pendingConfirmation(conversation)`, `heldActions(conversation)` | The held actions known in memory, and those restored from the store after a restart: the agent server reads `heldActions` to show an approval card again after a restart. After a restart `pendingConfirmation` knows a conversation's held actions only once `heldActions` or `recentTranscript` has restored them, so a host that decides an approval asks one of them first. A `PendingConfirmation` carries the `selectionId` of the `TurnSelection` whose turn held the calls, an opaque string that the caller resolves again when the owner confirms; the core stores it and never reads it |
1444
1444
  | `recentTranscript(conversation, limit)` | The latest messages, for the owner's and the dashboard's views of a conversation; the built-in runtime gives none of a history recorded, or a session opened, for someone else than the conversation serves now |
1445
1445
  | `contextUsage?(conversation)` | How full the conversation's context is, `{ tokens, contextWindow }`; leave it out and the team status shows no context bar |
1446
- | `preflight?()` | Runs in the host's preflight, before anything starts; a throw stops the boot |
1446
+ | `preflight?()` | Runs after every plugin's preflight, before anything starts; a throw stops the boot |
1447
1447
  | `dispose?()` | Runs when the host stops the runtime plugin's `runtime` service |
1448
1448
 
1449
1449
  A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker` (required: the person the turn is for, at whose tier it runs; the built-in runtime refuses a request without one with an `AgentRunError` before asking the model, and a plugin that starts a turn on someone's behalf gets theirs from `IDENTITY.speakerFor`), `conversation` (`TurnConversation`: `private` to a `principalId`, or `shared`, as `context.turns` records it; shared when absent), flags (`steerable`, `interactive`, `confirmed`: the speaker's message approved the conversation's held calls, which the runtime releases for the speaker, except those that [name approvers](#a-hold-that-names-its-approvers) the speaker is neither among nor an owner of, which stay held), and `interim`, where the turn may post the text it writes before its final answer ([interim text](#interim-text-what-a-turn-writes-before-its-final-answer)).
@@ -1698,6 +1698,10 @@ test("only one plugin may fill the runtime slot", async () => {
1698
1698
  `preflight` also sits on the plugin.
1699
1699
  It runs once every plugin is set up and linked, before any command is registered or any service starts.
1700
1700
  A throw stops the boot, so a missing setting is caught before Discord connects.
1701
+ Plugins' preflights run in plugin order.
1702
+ Once every one has finished, the host runs the runtime's own `preflight` (the `RUNTIME` service's), which for Pi's runtime builds throwaway sessions and checks the model and the required tools.
1703
+ So tools a plugin fills in during its preflight, such as MCP servers it resolves there, are in those sessions, and a required tool still missing stops the start before any service starts or any listener opens.
1704
+ `Roundtable.check()` runs no preflight.
1701
1705
 
1702
1706
  <!-- example: examples/preflight.ts -->
1703
1707
  ```ts
@@ -3367,6 +3371,7 @@ The host's `run()` handles startup:
3367
3371
  From here `sessions()`, `conversations`, `surfaces`, `turns`, and `dashboard()` work.
3368
3372
  5. Every plugin's `preflight` runs, in plugin order.
3369
3373
  The Discord plugin's preflight is where it composes the slash commands every plugin added in `setup`; a clash stops the start here.
3374
+ Once every one has finished, the runtime's own `preflight` (the `RUNTIME` service's) runs, so its check of the model and the required tools sees the tools that other plugins filled in during theirs.
3370
3375
  6. Every service starts: the plugins' in plugin order, and each plugin's own in the order it listed them, after the plugin's chat surfaces (the `surface:<prefix>` services).
3371
3376
  7. The HTTP listeners open.
3372
3377
  8. Every service's `startInBackground` runs, at once and without holding up the boot, and every plugin hears `serviceStarted` as each ends; the agent server's `team` service starts the agents' channels and the dashboard there.
@@ -3395,6 +3400,7 @@ On `SIGTERM` or `SIGINT` the bot takes no new work at once, finishes what runs,
3395
3400
  `shutdown()` returns exit code `0`, or `1` if a listener, service, or pool failed to stop.
3396
3401
  Every call shares the same shutdown.
3397
3402
  Give the service manager a stop timeout longer than the drain limit plus about a minute for the steps after it, such as systemd's `TimeoutStopSec=5min` with the default three-minute drain; a shorter one has the manager kill the process in the middle of the drain.
3403
+ The built-in runtime aborts a turn that works 10 minutes, not counting time spent waiting on the owner's cards. Each message steered into the running turn gives it the full 10 minutes again from that moment, up to a cap of 30 minutes of work for the whole turn; once the cap is reached the turn takes no more steering, so a message waits for the next turn, and the turn is aborted as on its timeout. Both limits are fixed: the runtime plugin passes no option for them, so a host cannot change them, and a limit that waiting on an owner's card pushed back can fire up to a second late. The log says `steer renewed the turn's time budget` with `deadlineMs`, the work time left, and the timeout warning names the limit that fired (`limit: "turn"` or `"turnMax"`). Task workers (`task.timeoutMs`), coding workers (`workTimeout`) and sandbox guests (their own `turnTimeoutMs`) keep their own limits, which steering does not renew.
3398
3404
  A turn whose model ignores its abort (a subprocess that does not exit) is given up on too: a turn that outlives its timeout, or is stopped, gets `turnAbortGraceMs` (15 seconds by default) to end, and then its session is disposed of and the turn is reported failed, so it cannot hold the queue.
3399
3405
  `listen()`, called by the command line and a host's own entry point, exits the process with that code.
3400
3406
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.17.0",
3
+ "version": "0.17.3",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -72,7 +72,7 @@ export interface AgentRuntime {
72
72
  ): Promise<TranscriptEntry[]>;
73
73
  /** Undefined when unknown; the team status shows no context bar for the conversation then. */
74
74
  contextUsage?(conversation: ChannelKey): ContextUse | undefined;
75
- /** Runs in the host's preflight, before anything starts; a throw stops the boot. */
75
+ /** Runs after every plugin's preflight, before anything starts; a throw stops the boot. */
76
76
  preflight?(): Promise<void>;
77
77
  /** Runs when the host stops the agent server's runtime service. */
78
78
  dispose?(): Promise<void> | void;
package/src/core/host.ts CHANGED
@@ -11,6 +11,7 @@ import type { Locale } from "./i18n/index.ts";
11
11
  import type { JudgeModel } from "./judging/model-judge.ts";
12
12
  import type { Logger } from "./log.ts";
13
13
  import type { RoundtablePlugin, Service } from "./plugin.ts";
14
+ import { RUNTIME } from "./services.ts";
14
15
  import type { ChannelKey } from "./sessions.ts";
15
16
  import type { ToolTierTable } from "./tool-tiers.ts";
16
17
 
@@ -149,6 +150,8 @@ export class Roundtable {
149
150
  return this.#pool;
150
151
  });
151
152
  for (const plugin of link.active) await plugin.preflight?.();
153
+ // Last, so the probe sees the tools other plugins fill in during their preflight.
154
+ await link.services?.find(RUNTIME)?.preflight?.();
152
155
  for (const service of [...link.registry.services, ...retention]) {
153
156
  await service.start?.();
154
157
  this.#started.push(service);
@@ -33,7 +33,7 @@ import { loadsMemory } from "./extensions/private-memory.ts";
33
33
  import { missingToolsError } from "./extensions/self-compact-guard.ts";
34
34
  import { interimPoster } from "./interim-text.ts";
35
35
  import { preflightTools } from "./preflight-tools.ts";
36
- import { PromptSlot, workTimeout } from "./prompt-slot.ts";
36
+ import { PromptSlot } from "./prompt-slot.ts";
37
37
  import { carriesMemory, recordMemoryTurn } from "./reader-history.ts";
38
38
  import { type RunningTurn, runningTurn } from "./running-turn.ts";
39
39
  import type { PiAgentRuntimeOptions, TurnMessage } from "./runtime-types.ts";
@@ -48,6 +48,7 @@ import { promptImages, SteerableRun } from "./steerable-run.ts";
48
48
  import { thinkingLevel } from "./thinking-level.ts";
49
49
  import { runningCalls } from "./tool-call-scope.ts";
50
50
  import { transcriptOf, turnAnswer, unspokenTurn } from "./turn-answer.ts";
51
+ import { runtimeTurnBudget, type TurnBudget } from "./turn-budget.ts";
51
52
  import { progressReporter } from "./turn-progress.ts";
52
53
  import { wrapUpTurn } from "./turn-wrapup.ts";
53
54
  import { workerReport } from "./worker-task.ts";
@@ -75,6 +76,8 @@ export class PiAgentRuntime implements AgentRuntime {
75
76
  readonly #turns = new Map<ChannelKey, RunningTurn>();
76
77
  /** Each conversation's turn in progress, which the owner may steer or stop. */
77
78
  readonly #running = new Map<ChannelKey, SteerableRun>();
79
+ /** Each running turn's time limit, which a steered message renews. */
80
+ readonly #budgets = new Map<ChannelKey, TurnBudget>();
78
81
  readonly #memoryTurns = new Map<ChannelKey, () => void>();
79
82
 
80
83
  /** Builds the runtime without starting a session; the preflight proves it can run. */
@@ -114,7 +117,7 @@ export class PiAgentRuntime implements AgentRuntime {
114
117
 
115
118
  // pi-lens-ignore: high-complexity, high-fan-out, mixed-async-styles — one turn's lifecycle end to end; batch 4 splits it with the channel router
116
119
  async runTurn(request: TurnRequest): Promise<TurnResult> {
117
- const { logger, turnTimeoutMs = 10 * 60_000 } = this.#options;
120
+ const { logger } = this.#options;
118
121
  if (!request.speaker) return unspokenTurn();
119
122
  // A turn that is only asked for now, such as a group's next member, starts nothing.
120
123
  if (this.#options.stopping?.())
@@ -272,17 +275,14 @@ export class PiAgentRuntime implements AgentRuntime {
272
275
  this.#sessions.drop(key, channelSession);
273
276
  },
274
277
  });
275
- // Time spent waiting on the owner's cards does not count towards the timeout.
276
- const cancelTimeout = workTimeout(turnTimeoutMs, slot, () => {
277
- logger.warn(
278
- { channel: request.channel, turnTimeoutMs },
279
- "turn timed out; aborting",
280
- );
281
- guard.abort();
282
- });
278
+ // Time spent waiting on the owner's cards does not count towards the timeout or the cap.
279
+ const budget = runtimeTurnBudget(this.#options, slot, request.channel, () =>
280
+ guard.abort(),
281
+ );
282
+ this.#budgets.set(key, budget);
283
283
  const running = new SteerableRun(
284
284
  session,
285
- () => request.steerable === true && !gate.pending(),
285
+ () => request.steerable === true && !gate.pending() && !budget.capped(),
286
286
  () => guard.abort(),
287
287
  );
288
288
  this.#running.set(key, running);
@@ -313,9 +313,10 @@ export class PiAgentRuntime implements AgentRuntime {
313
313
  };
314
314
  } finally {
315
315
  this.#running.delete(key);
316
+ this.#budgets.delete(key);
316
317
  this.#turns.delete(key);
317
318
  this.#memoryTurns.delete(key);
318
- cancelTimeout();
319
+ budget.cancel();
319
320
  guard.release();
320
321
  slot.unbind();
321
322
  unsubscribe();
@@ -358,8 +359,10 @@ export class PiAgentRuntime implements AgentRuntime {
358
359
  withAttachmentsBlock(text, attachments),
359
360
  promptImages(attachments),
360
361
  );
361
- if (steered)
362
+ if (steered) {
362
363
  this.#options.logger.info({ channel }, "message steered into the turn");
364
+ this.#budgets.get(channel)?.renew();
365
+ }
363
366
  return steered;
364
367
  } catch (error) {
365
368
  this.#options.logger.warn({ channel, err: error }, "steer refused");
@@ -1,6 +1,7 @@
1
1
  import type { HoldRefusal } from "../domain/progress.ts";
2
2
  import { assistantName } from "../i18n/index.ts";
3
3
  import type { OwnerQuestion, Prompts } from "../interactions/prompts.ts";
4
+ import { turnBudget } from "./turn-budget.ts";
4
5
 
5
6
  /**
6
7
  * A run's way to ask the owner while it works. A turn they started or a report turn binds the
@@ -101,22 +102,13 @@ export function promptSlot(): PromptSlot {
101
102
 
102
103
  /**
103
104
  * Calls `expire` once the run has worked `ms`, not counting time the slot spent waiting on the
104
- * owner's cards; returns a function that cancels it.
105
+ * owner's cards; returns a function that cancels it. It is a turn budget with nothing to renew,
106
+ * so it looks again at a run that waits on a card no oftener than `turnBudget` does.
105
107
  */
106
108
  export function workTimeout(
107
109
  ms: number,
108
110
  slot: Pick<PromptSlot, "waitedMs">,
109
111
  expire: () => void,
110
112
  ): () => void {
111
- const started = Date.now();
112
- let timer: ReturnType<typeof setTimeout> | undefined;
113
- const arm = (delay: number) => {
114
- timer = setTimeout(() => {
115
- const left = ms - (Date.now() - started - slot.waitedMs());
116
- if (left > 0) return arm(left);
117
- expire();
118
- }, delay);
119
- };
120
- arm(ms);
121
- return () => clearTimeout(timer);
113
+ return turnBudget(ms, ms, slot, () => expire()).cancel;
122
114
  }
@@ -97,7 +97,6 @@ export function runtimePlugin(
97
97
  ) => Promise<HeldActionStore> = (context) =>
98
98
  PendingConfirmationStore.attach(context.database()),
99
99
  ): RoundtablePlugin {
100
- let runtime: AgentRuntime | undefined;
101
100
  let identity: IdentityService | undefined;
102
101
  let warn: ((message: string) => void) | undefined;
103
102
  // Admission is a startup risk warning, not proof that this conversation holds private history.
@@ -119,7 +118,6 @@ export function runtimePlugin(
119
118
  `${bridgeMemoryProblem(formatModelRef(options.model), why)} ${BRIDGE_MEMORY_FIX}`,
120
119
  );
121
120
  }
122
- await runtime?.preflight?.();
123
121
  },
124
122
  setup: async (context) => {
125
123
  const { logger, providers, services } = context;
@@ -194,7 +192,6 @@ export function runtimePlugin(
194
192
  ? { interimPrimaryChars: options.interimPrimaryChars }
195
193
  : {}),
196
194
  });
197
- runtime = running;
198
195
  context.services.provide(RUNTIME, running);
199
196
  return {
200
197
  services: [{ name: "runtime", stop: () => running.dispose?.() }],
@@ -88,8 +88,18 @@ export interface PiAgentRuntimeOptions {
88
88
  formatting?: (channel: ChannelKey) => string | undefined;
89
89
  /** The lowest tier that may use each tool; the default names the core's tools and leaves the rest to the owner. */
90
90
  toolTiers?: ToolTiers;
91
- /** A run that takes longer, not counting time spent waiting on the owner's cards, is aborted and reported as failed. */
91
+ /**
92
+ * A run that works longer, not counting time spent waiting on the owner's cards, is aborted and
93
+ * reported as failed; default 10 minutes. Each message steered into the turn gives it this
94
+ * budget again from that moment, up to `turnMaxMs`.
95
+ */
92
96
  turnTimeoutMs?: number;
97
+ /**
98
+ * The most one turn may work in all, however often it is steered, not counting time spent
99
+ * waiting on the owner's cards; default three times `turnTimeoutMs`. Once it is reached the turn
100
+ * takes no more steering (the message waits for the next turn) and is aborted as on its timeout.
101
+ */
102
+ turnMaxMs?: number;
93
103
  /**
94
104
  * How long a turn that was aborted, by its timeout or a stop, gets to end before its session is
95
105
  * disposed of and the turn is reported failed; default 15 seconds. A model that ignores the
@@ -0,0 +1,121 @@
1
+ import type { ChannelKey } from "../sessions.ts";
2
+ import type { PromptSlot } from "./prompt-slot.ts";
3
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
4
+
5
+ /** Which limit ended a turn: its budget since the start or the last steer, or its cap. */
6
+ export type TurnLimit = "turn" | "turnMax";
7
+
8
+ /** A turn's time limit, which a steered message renews up to a cap on the whole turn. */
9
+ export interface TurnBudget {
10
+ /**
11
+ * Gives the turn its full budget again from now, up to the cap; the work time left, in ms,
12
+ * or undefined when the cap is reached and the turn takes no more steering.
13
+ */
14
+ renew(): number | undefined;
15
+ /** Whether the turn has worked up to its cap. */
16
+ capped(): boolean;
17
+ cancel(): void;
18
+ }
19
+
20
+ /** The clock a turn budget reads; the real one by default, a fake one in tests. */
21
+ export interface BudgetClock {
22
+ now(): number;
23
+ schedule(run: () => void, ms: number): () => void;
24
+ }
25
+
26
+ const REAL_CLOCK: BudgetClock = {
27
+ now: () => Date.now(),
28
+ schedule: (run, ms) => {
29
+ const timer = setTimeout(run, ms);
30
+ return () => clearTimeout(timer);
31
+ },
32
+ };
33
+
34
+ /**
35
+ * The shortest wait before a budget looks again at a turn that still has time. While a card is
36
+ * open the turn's work time stands still, so a timer that fires with a few milliseconds left would
37
+ * otherwise re-arm for those milliseconds, again and again, for as long as the card waits. The
38
+ * floor bounds that to one look a second; the cost is that a limit that waiting on a card pushed
39
+ * back, by less than the floor, may fire up to this much late.
40
+ */
41
+ const REARM_FLOOR_MS = 1_000;
42
+
43
+ /**
44
+ * Calls `expire` once the turn has worked `ms` since it started or since its last renewal, or
45
+ * `maxMs` in all, whichever comes first; time the slot spent waiting on the owner's cards counts
46
+ * towards neither. `expire` is told which limit fired.
47
+ */
48
+ export function turnBudget(
49
+ ms: number,
50
+ maxMs: number,
51
+ slot: Pick<PromptSlot, "waitedMs">,
52
+ expire: (limit: TurnLimit) => void,
53
+ clock: BudgetClock = REAL_CLOCK,
54
+ ): TurnBudget {
55
+ const started = clock.now();
56
+ const worked = () => {
57
+ const now = clock.now();
58
+ return now - started - slot.waitedMs(now);
59
+ };
60
+ let deadline = Math.min(ms, maxMs);
61
+ let cancelTimer: (() => void) | undefined;
62
+ let expired = false;
63
+ const arm = (delay: number) => {
64
+ cancelTimer = clock.schedule(() => {
65
+ const left = deadline - worked();
66
+ if (left > 0) return arm(Math.max(left, REARM_FLOOR_MS));
67
+ expired = true;
68
+ expire(deadline >= maxMs ? "turnMax" : "turn");
69
+ }, delay);
70
+ };
71
+ arm(deadline);
72
+ return {
73
+ renew() {
74
+ const now = worked();
75
+ if (expired || now >= maxMs) return undefined;
76
+ deadline = Math.min(now + ms, maxMs);
77
+ cancelTimer?.();
78
+ arm(deadline - now);
79
+ return deadline - now;
80
+ },
81
+ capped: () => expired || worked() >= maxMs,
82
+ cancel: () => cancelTimer?.(),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * The time limit of one turn of the built-in runtime, from its options: `turnTimeoutMs` (default
88
+ * 10 minutes) renewed by each steer up to `turnMaxMs` (default three times that). It logs each
89
+ * renewal and, when a limit fires, which one before it calls `abort`.
90
+ */
91
+ export function runtimeTurnBudget(
92
+ options: Pick<
93
+ PiAgentRuntimeOptions,
94
+ "turnTimeoutMs" | "turnMaxMs" | "logger"
95
+ >,
96
+ slot: Pick<PromptSlot, "waitedMs">,
97
+ channel: ChannelKey,
98
+ abort: () => void,
99
+ ): TurnBudget {
100
+ const { logger, turnTimeoutMs = 10 * 60_000 } = options;
101
+ const turnMaxMs = options.turnMaxMs ?? 3 * turnTimeoutMs;
102
+ const budget = turnBudget(turnTimeoutMs, turnMaxMs, slot, (limit) => {
103
+ logger.warn(
104
+ { channel, limit, turnTimeoutMs, turnMaxMs },
105
+ "turn timed out; aborting",
106
+ );
107
+ abort();
108
+ });
109
+ return {
110
+ ...budget,
111
+ renew() {
112
+ const deadlineMs = budget.renew();
113
+ if (deadlineMs !== undefined)
114
+ logger.info(
115
+ { channel, deadlineMs },
116
+ "steer renewed the turn's time budget",
117
+ );
118
+ return deadlineMs;
119
+ },
120
+ };
121
+ }
package/src/testing.ts CHANGED
@@ -608,17 +608,18 @@ export async function testPlugin(
608
608
  // The plugin's own surfaces start as services; the injected ones start beside them, after.
609
609
  const deliver = (message: InboundMessage) =>
610
610
  void conversations.handle(message);
611
- // As the host does, the plugin's preflight runs once everything is set up and before any service starts.
611
+ // As the host does, the plugin's preflight runs once everything is set up and before any service
612
+ // starts, and the runtime's own after it, so it sees what the preflight filled in.
612
613
  try {
613
614
  await plugin.preflight?.();
615
+ await runtime?.preflight?.();
614
616
  } catch (error) {
615
- // The runtime the provider built is not left running when the plugin refuses.
617
+ // The runtime the provider built is not left running when the plugin or the runtime refuses.
616
618
  await runtime?.dispose?.();
617
619
  throw error;
618
620
  }
619
621
  for (const service of registry.services) await service.start?.();
620
622
  for (const surface of options.surfaces ?? []) await surface.start(deliver);
621
- await runtime?.preflight?.();
622
623
  let stopped = false;
623
624
  const files: { channel: ChannelKey; file: ReplyFile }[] = [];
624
625
  const images: { channel: ChannelKey; image: ClientPart }[] = [];