@rebon/cli-win32-x64 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +239 -144
  4. package/THIRD_PARTY_NOTICES.txt +1232 -1232
  5. package/bin/rebon.js +22 -22
  6. package/lib/windows-managed.js +390 -390
  7. package/package.json +9 -2
  8. package/payload/compose-runtime/package.json +17 -17
  9. package/payload/compose-runtime/payload/compose/shims/dsh-anonymous-user-id.js +10 -10
  10. package/payload/compose-runtime/payload/compose/shims/dsh-credentials.js +13 -13
  11. package/payload/compose-runtime/payload/compose/shims/dsh-launch-environment.js +21 -21
  12. package/payload/compose-runtime/payload/compose/shims/dsh-llm.js +313 -313
  13. package/payload/compose-runtime/payload/compose/shims/dsh-settings.js +43 -43
  14. package/payload/compose-runtime/payload/compose/shims/dsh-tools.js +20 -20
  15. package/payload/compose-runtime/payload/compose/shims/dsh-web.js +10 -10
  16. package/payload/compose-runtime/payload/compose/shims/llm-adapter.js +32 -32
  17. package/payload/compose-runtime/payload/compose/shims/zod-lite.js +49 -49
  18. package/payload/compose-runtime/payload/vendor/cordis/LICENSE +21 -21
  19. package/payload/compose-runtime/payload/vendor/cordis/index.js +1530 -1530
  20. package/payload/compose-runtime/payload/vendor/cosmokit/LICENSE +21 -21
  21. package/payload/compose-runtime/payload/vendor/cosmokit/index.mjs +357 -357
  22. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-agent +21 -21
  23. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-agent-loop +21 -21
  24. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-llm-core +21 -21
  25. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-llm-deepseek +21 -21
  26. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-logger-console +21 -21
  27. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-scope +21 -21
  28. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-session +21 -21
  29. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-system-prompt +21 -21
  30. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-timeout +21 -21
  31. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-timer +21 -21
  32. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-tool-todo +21 -21
  33. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-tool-web +21 -21
  34. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-tools-schema +21 -21
  35. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-turndown +21 -21
  36. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-turndown-plugin-gfm +21 -21
  37. package/payload/compose-runtime/payload/vendor/dsh/LICENSE-web-search-exa +21 -21
  38. package/payload/compose-runtime/payload/vendor/dsh/agent-loop.js +1193 -1193
  39. package/payload/compose-runtime/payload/vendor/dsh/agent.js +714 -714
  40. package/payload/compose-runtime/payload/vendor/dsh/llm-core.js +269 -269
  41. package/payload/compose-runtime/payload/vendor/dsh/llm-deepseek.js +672 -672
  42. package/payload/compose-runtime/payload/vendor/dsh/logger-console.js +83 -83
  43. package/payload/compose-runtime/payload/vendor/dsh/scope.js +287 -287
  44. package/payload/compose-runtime/payload/vendor/dsh/session.js +1668 -1668
  45. package/payload/compose-runtime/payload/vendor/dsh/system-prompt.js +309 -309
  46. package/payload/compose-runtime/payload/vendor/dsh/timeout.js +100 -100
  47. package/payload/compose-runtime/payload/vendor/dsh/timer.js +128 -128
  48. package/payload/compose-runtime/payload/vendor/dsh/tool-todo.js +143 -143
  49. package/payload/compose-runtime/payload/vendor/dsh/tool-web.js +1527 -1527
  50. package/payload/compose-runtime/payload/vendor/dsh/tools-schema.js +849 -849
  51. package/payload/compose-runtime/payload/vendor/dsh/web-search-exa.js +122 -122
  52. package/payload/compose-runtime/payload/vendor/eventsource-parser/LICENSE +21 -21
  53. package/payload/compose-runtime/payload/vendor/eventsource-parser/index.js +177 -177
  54. package/payload/compose-runtime/payload/vendor/eventsource-parser/stream.js +48 -48
  55. package/payload/compose-runtime/payload/vendor/schemastery/LICENSE +21 -21
  56. package/payload/compose-runtime/payload/vendor/schemastery/index.mjs +656 -656
  57. package/payload/compose-runtime/payload-manifests.json +48 -48
  58. package/payload/compose-runtime/src/bridge.mjs +193 -195
  59. package/payload/compose-runtime/src/credentials-runtime.mjs +50 -50
  60. package/payload/compose-runtime/src/eventsource-stream.mjs +48 -48
  61. package/payload/compose-runtime/src/index.mjs +7 -7
  62. package/payload/compose-runtime/src/llm-runtime.mjs +326 -326
  63. package/payload/compose-runtime/src/loader.mjs +78 -78
  64. package/payload/compose-runtime/src/loop-assembly.mjs +237 -237
  65. package/payload/compose-runtime/src/payload.mjs +49 -49
  66. package/payload/compose-runtime/src/plugin.mjs +59 -59
  67. package/payload/compose-runtime/src/realm.mjs +223 -223
  68. package/payload/compose-runtime/src/registry.mjs +191 -191
  69. package/payload/compose-runtime/src/resolve.mjs +115 -115
  70. package/payload/compose-runtime/src/systemprompt-runtime.mjs +67 -67
  71. package/payload/compose-runtime/src/tools-runtime.mjs +239 -239
  72. package/payload/compose-runtime/src/web-runtime.mjs +209 -209
  73. package/payload/plugin-host/package.json +11 -11
  74. package/payload/plugin-host/src/bridge.mjs +64 -64
  75. package/payload/plugin-host/src/cli.mjs +147 -147
  76. package/payload/plugin-host/src/framing.mjs +113 -113
  77. package/payload/plugin-host/src/host.mjs +691 -691
  78. package/payload/plugin-host/src/json.mjs +208 -208
  79. package/payload/plugin-host/src/lifecycle.mjs +114 -114
  80. package/payload/plugin-host/src/loader.mjs +183 -183
  81. package/payload/plugin-host/src/methods.mjs +377 -377
  82. package/payload/plugin-host/src/ownership.mjs +57 -57
  83. package/payload/plugin-host/src/protocol.mjs +129 -129
  84. package/payload/plugin-host/src/sdk.mjs +7 -7
  85. package/payload/rebon-boa-helper.exe +0 -0
  86. package/payload/rebon-browser-mcp.exe +0 -0
  87. package/payload/rebon-computer-use.exe +0 -0
  88. package/payload/rebon-lsp-mcp.exe +0 -0
  89. package/payload/rebon.exe +2 -2
  90. package/payload/sandbox-win.exe +0 -0
  91. package/scripts/postinstall.js +112 -112
@@ -1,691 +1,691 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { createPluginBridge } from './bridge.mjs';
3
- import { CallLedger } from './lifecycle.mjs';
4
- import { loadPlugin } from './loader.mjs';
5
- import { asEntryLoad } from './ownership.mjs';
6
- import { commandInvokeRequest, eventDelivery, eventEmitRequest, eventSubscribeRequest, llmControlRequest, llmStreamRequest, pluginLoadRequest, pluginUnloadRequest, seatCallRequest, serviceCallRequest, toolInvokeRequest } from './methods.mjs';
7
- import { FramingError } from './framing.mjs';
8
- import { CALL_CANCEL_METHOD, chunk as chunkFrame, identityOf, isPlatformControl, ProtocolError, terminal } from './protocol.mjs';
9
-
10
- const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
11
- && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
12
- const scopeKey = (value) => JSON.stringify([value.plugin_id, value.scope_id]);
13
-
14
- export class PluginHost {
15
- state = 'pre_initialize'; hostEpoch; ledger; stopped = false; admissionClosed = false;
16
- // Set while `platform/shutdown` is still being planned, so the read loop can
17
- // see it without waiting for the dispatch it scheduled to finish.
18
- shuttingDown = false;
19
- #scopes = new Map(); #order = [];
20
- // pluginId -> { phase, services, eventTopics, serviceHandlers, topicHandlers,
21
- // llmAdapters, toolHandlers, tools:Set(invokable), inFlight:Set,
22
- // scopeHandlers, scopeTeardown:Map(scopeKey -> [fn]) }
23
- #plugins = new Map();
24
- // subscriptionId -> { pluginId, scopeId, generation, topic, handler }
25
- #subscriptions = new Map(); #subscriptionSeq = 0;
26
- // callId -> the bridge that owns an outbound call, so its terminal comes back
27
- // to the promise that is waiting for it rather than to a guess.
28
- #outbound = new Map();
29
- // callId -> the AbortController a running handler is watching, and the ids a
30
- // cancel arrived for. Kept apart: a handler may ignore its signal and finish
31
- // normally, and the terminal has to say which of those happened.
32
- #running = new Map(); #cancelled = new Set();
33
- constructor(writer, options = {}) {
34
- this.writer = writer;
35
- this.load = options.load ?? loadPlugin;
36
- // Called once a plugin has finished draining, so whatever the loader built
37
- // for it can be taken down. The host knows a plugin by its declarations; it
38
- // has no idea what a loader put behind them, which is exactly why the
39
- // loader is the one told that the plugin is over.
40
- this.unload = options.unload ?? (async () => {});
41
- this.newCallId = options.newCallId ?? randomUUID;
42
- }
43
-
44
- /// Ends a plugin's life once nothing of it is still running.
45
- ///
46
- /// Awaited by every caller, and always before the terminal that lets rebon
47
- /// believe the drain is done: a loader's teardown that ran after rebon had
48
- /// already loaded a replacement would be disposing the new one's world.
49
- async #retire(pluginId, plugin) {
50
- plugin.phase = 'unloaded';
51
- for (const key of [...plugin.scopeTeardown.keys()]) await this.#closeScopeHandles(pluginId, key);
52
- await this.unload(pluginId);
53
- }
54
-
55
- /// Routes one frame by what it is. Requests are work; terminals answer work
56
- /// this host asked for; a notification is neither and owes nothing back.
57
- async accept(envelope) {
58
- switch (envelope.message.type) {
59
- case 'request': return this.dispatch(envelope);
60
- case 'terminal': return this.settleUpstream(envelope);
61
- case 'notification': return this.#acceptNotification(envelope);
62
- default: throw new ProtocolError('unexpected_message', 'message type is not routable');
63
- }
64
- }
65
-
66
- /// Cancel is the only notification the protocol defines, and it asks rather
67
- /// than commands: the handler may ignore its signal and finish anyway. What
68
- /// this does is record the intent and raise the signal; the terminal still
69
- /// comes from whatever the handler does next.
70
- #acceptNotification(envelope) {
71
- if (envelope.message.method !== CALL_CANCEL_METHOD) return undefined;
72
- this.ledger.cancel(envelope);
73
- const controller = this.#running.get(envelope.call_id);
74
- if (!controller) return undefined;
75
- this.#cancelled.add(envelope.call_id);
76
- controller.abort();
77
- return undefined;
78
- }
79
-
80
- /// Hands a terminal back to the call that is waiting for it.
81
- settleUpstream(envelope) {
82
- const owner = this.#outbound.get(envelope.call_id);
83
- if (!owner) throw new ProtocolError('unknown_call', 'terminal does not answer any call this host made');
84
- this.#outbound.delete(envelope.call_id);
85
- owner.bridge.receive(envelope);
86
- }
87
-
88
- /// An upstream caller bound to one scope incarnation.
89
- ///
90
- /// A new one per incarnation on purpose: the identity a call carries is the
91
- /// scope's generation at the time it was made, and reusing a bridge across an
92
- /// advance would send calls stamped with an incarnation that has ended.
93
- #bridgeFor(pluginId, scopeId, generation) {
94
- const holder = {};
95
- holder.bridge = createPluginBridge({
96
- trustedPluginId: pluginId,
97
- scopeBinding: { hostEpoch: this.hostEpoch, scopeId, scopeGeneration: generation },
98
- transport: this.writer,
99
- ledger: this.ledger,
100
- idFactory: () => { const id = this.newCallId(); this.#outbound.set(id, holder); return id; },
101
- });
102
- return holder.bridge;
103
- }
104
-
105
- /// The one bridge belonging to a scope's current incarnation.
106
- ///
107
- /// Cached on the scope record, which is replaced whenever the generation
108
- /// advances — so a new incarnation gets a new bridge without anyone having to
109
- /// remember to invalidate the old one.
110
- #bridgeOf(value) {
111
- const scope = this.#scopes.get(scopeKey(value));
112
- scope.bridge ??= this.#bridgeFor(value.plugin_id, value.scope_id, value.scope_generation);
113
- return scope.bridge;
114
- }
115
-
116
- /// What a plugin is given for the lifetime of one scope incarnation.
117
- ///
118
- /// The same powers a call context carries, minus the two that only make sense
119
- /// inside a call: there is no `emit`, because there is no call to put a chunk
120
- /// on, and no `signal`, because nothing is being cancelled. What is left is
121
- /// the session — which is what a plugin that acts on its own schedule needs,
122
- /// and the reason this exists at all.
123
- ///
124
- /// It stops working when its incarnation does. A handle captured across a
125
- /// generation advance would be speaking for a session that has ended, and
126
- /// saying so here names that, rather than letting rebon refuse a frame whose
127
- /// origin is no longer obvious.
128
- #scopeContextFor(value) {
129
- const scope = this.#scopes.get(scopeKey(value));
130
- const plugin = this.#plugins.get(value.plugin_id);
131
- const gate = { open: true };
132
- const guard = (what) => {
133
- if (!gate.open) {
134
- throw new ProtocolError('[SCOPE_CLOSED]', `${what} belongs to a scope incarnation that has ended`);
135
- }
136
- };
137
- const ctx = Object.freeze({
138
- scopeId: value.scope_id,
139
- workspaceRoot: scope?.workspaceRoot,
140
- publish: async (topic, event = null, options = {}) => {
141
- guard(`publishing ${JSON.stringify(topic)}`);
142
- if (!plugin.publishedTopics.has(topic)) {
143
- throw new ProtocolError('[UNAUTHORIZED_TOPIC]', `topic ${JSON.stringify(topic)} is not declared by the manifest for ${JSON.stringify(value.plugin_id)}`);
144
- }
145
- return this.#upstream(value, 'event/emit', eventEmitRequest({ topic, event }), options, `topic ${topic}`);
146
- },
147
- seat: async (seat, method, params = null, options = {}) => {
148
- guard(`seat ${JSON.stringify(seat)}`);
149
- if (!plugin.seats.has(seat)) {
150
- throw new ProtocolError('[UNAUTHORIZED_SEAT]', `seat ${JSON.stringify(seat)} is not declared by the manifest for ${JSON.stringify(value.plugin_id)}`);
151
- }
152
- return this.#upstream(value, 'seat/call', seatCallRequest({ seat, method, params }), options, `seat ${seat}`);
153
- },
154
- invoke: async (tool, input = null, options = {}) => {
155
- guard(`tool ${JSON.stringify(tool)}`);
156
- if (!plugin.tools.has(tool)) {
157
- throw new ProtocolError('[UNAUTHORIZED_TOOL]', `tool ${JSON.stringify(tool)} is not declared by the manifest for ${JSON.stringify(value.plugin_id)}`);
158
- }
159
- return this.#upstream(value, 'tool/invoke', toolInvokeRequest({ tool, input }), options, `tool ${tool}`);
160
- },
161
- });
162
- return { ctx, close: () => { gate.open = false; } };
163
- }
164
-
165
- /// Runs the plugin's scope handlers for one incarnation, keeping whatever
166
- /// they hand back so closing the scope can undo it.
167
- async #openScopeHandles(value) {
168
- const plugin = this.#plugins.get(value.plugin_id);
169
- if (!plugin || plugin.phase !== 'ready' || plugin.scopeHandlers.length === 0) return;
170
- const key = scopeKey(value);
171
- const teardown = [];
172
- for (const handler of plugin.scopeHandlers) {
173
- const { ctx, close } = this.#scopeContextFor(value);
174
- // A handler that throws fails the open: a plugin that believes it is
175
- // attached to a session and is not would be worse than a refused scope.
176
- const disposer = await handler(ctx);
177
- teardown.push(async () => {
178
- close();
179
- if (typeof disposer === 'function') await disposer();
180
- });
181
- }
182
- plugin.scopeTeardown.set(key, teardown);
183
- }
184
-
185
- /// Undoes them. Failures are diagnostics: a scope is closing either way, and
186
- /// one plugin's bad teardown must not strand the rest.
187
- async #closeScopeHandles(pluginId, key) {
188
- const plugin = this.#plugins.get(pluginId);
189
- const teardown = plugin?.scopeTeardown.get(key);
190
- if (teardown === undefined) return;
191
- plugin.scopeTeardown.delete(key);
192
- for (const undo of teardown.reverse()) {
193
- try {
194
- await undo();
195
- } catch { /* a scope closes whatever its plugins think about it */ }
196
- }
197
- }
198
-
199
- /// What a handler is given alongside its request.
200
- ///
201
- /// This is the only thing a plugin ever holds that reaches back into rebon,
202
- /// and it is bound to the call's own scope incarnation: a handler cannot act
203
- /// on a session other than the one it was called for, because it has no way
204
- /// to name one.
205
- #contextFor(value) {
206
- const scope = this.#scopes.get(scopeKey(value));
207
- const plugin = this.#plugins.get(value.plugin_id);
208
- // `open` closes when the handler returns. A ctx captured and used later
209
- // would be emitting into a call that has already ended, and the refusal has
210
- // to name that rather than letting the frame reach a caller who stopped
211
- // reading.
212
- const gate = { open: true };
213
- const controller = new AbortController();
214
- this.#running.set(value.call_id, controller);
215
- const ctx = Object.freeze({
216
- scopeId: value.scope_id,
217
- workspaceRoot: scope?.workspaceRoot,
218
- // Raised when rebon asks this call to stop. A handler that watches it can
219
- // stop early; one that ignores it finishes normally, and the terminal
220
- // says so.
221
- signal: controller.signal,
222
- emit: async (piece) => {
223
- if (!gate.open) throw new ProtocolError('[STREAM_CLOSED]', 'the call this context belongs to has already ended');
224
- const frame = chunkFrame(identityOf(value), piece);
225
- this.ledger.chunk(frame, 'inbound');
226
- await this.writer.send(frame);
227
- },
228
- // Publishes one event onto rebon's event plane.
229
- //
230
- // Not `emit`: that puts a piece on *this* call, for the caller who is
231
- // reading it. This puts a fact on the plane, for whoever is listening —
232
- // a different audience and a different lifetime. Declared as
233
- // `publishedTopics`, separately from the topics the plugin listens to,
234
- // because publishing and listening are different powers.
235
- publish: async (topic, event = null, options = {}) => {
236
- if (!plugin.publishedTopics.has(topic)) {
237
- throw new ProtocolError('[UNAUTHORIZED_TOPIC]', `topic ${JSON.stringify(topic)} is not declared by the manifest for ${JSON.stringify(value.plugin_id)}`);
238
- }
239
- return this.#upstream(value, 'event/emit', eventEmitRequest({ topic, event }), options, `topic ${topic}`);
240
- },
241
- // Kernel seats the plugin was installed to use. A request, so it has an
242
- // answer — and therefore cannot serve a cordis disposer, which is
243
- // synchronous. Teardown relies on unload draining, not on calls made on
244
- // the way out.
245
- seat: async (seat, method, params = null, options = {}) => {
246
- if (!plugin.seats.has(seat)) {
247
- throw new ProtocolError('[UNAUTHORIZED_SEAT]', `seat ${JSON.stringify(seat)} is not declared by the manifest for ${JSON.stringify(value.plugin_id)}`);
248
- }
249
- return this.#upstream(value, 'seat/call', seatCallRequest({ seat, method, params }), options, `seat ${seat}`);
250
- },
251
- invoke: async (tool, input = null, options = {}) => {
252
- // Refused here as well as by rebon, for the same reason registration is
253
- // checked on both sides: this refusal points at the plugin's own line,
254
- // rather than arriving as a rejection from across a process boundary.
255
- if (!plugin.tools.has(tool)) {
256
- throw new ProtocolError('[UNAUTHORIZED_TOOL]', `tool ${JSON.stringify(tool)} is not declared by the manifest for ${JSON.stringify(value.plugin_id)}`);
257
- }
258
- return this.#upstream(value, 'tool/invoke', toolInvokeRequest({ tool, input }), options, `tool ${tool}`);
259
- },
260
- });
261
- return { ctx, close: () => { gate.open = false; this.#running.delete(value.call_id); } };
262
- }
263
-
264
- /// One upstream call, with rebon's own refusal code passed through.
265
- ///
266
- /// The bridge reports *that* a call failed; rebon's code and message are in
267
- /// the payload. A plugin author needs the latter — "the call failed" sends
268
- /// them nowhere.
269
- async #upstream(value, method, payload, options, what) {
270
- try {
271
- return await this.#bridgeOf(value).call(method, payload, options);
272
- } catch (cause) {
273
- const refusal = cause?.payload;
274
- if (!refusal?.code) throw cause;
275
- throw new ProtocolError(refusal.code, refusal.message ?? `${what} was refused`);
276
- }
277
- }
278
-
279
- #dropSubscriptions(matches) {
280
- const dropped = [];
281
- for (const [id, record] of this.#subscriptions) {
282
- if (!matches(record)) continue;
283
- this.#subscriptions.delete(id);
284
- dropped.push(id);
285
- }
286
- return dropped;
287
- }
288
-
289
- async dispatch(envelope) {
290
- if (this.admissionClosed) throw new ProtocolError('admission_closed', 'host no longer accepts requests');
291
- if (envelope.message.type !== 'request') throw new ProtocolError('unexpected_message', 'host accepts lifecycle requests only');
292
-
293
- let plan;
294
- try { plan = this.#plan(envelope); }
295
- catch (error) {
296
- if (!(error instanceof ProtocolError)) throw error;
297
- plan = { error };
298
- }
299
-
300
- const ledger = this.ledger ?? new CallLedger(envelope.host_epoch);
301
- ledger.register(identityOf(envelope), 'inbound', 'plugin_host');
302
- this.ledger ??= ledger;
303
-
304
- if (plan.error) return this.#sendTerminal(envelope, 'error', {
305
- code: plan.error.code,
306
- message: plan.error.message.slice(0, 256),
307
- });
308
- let payload;
309
- try { payload = await plan.run(); }
310
- catch (error) {
311
- // Only the transport failing is fatal. A plugin's own exception is *that
312
- // call* failing — letting it out of here would take the host and every
313
- // other plugin down with one bad handler, which is the opposite of the
314
- // isolation loading them separately is for.
315
- if (error instanceof FramingError) throw error;
316
- // A handler that stopped because it was asked to did not fail. Reporting
317
- // it as an error would make a deliberate stop look like a fault.
318
- const status = this.#cancelled.delete(envelope.call_id) ? 'cancelled' : 'error';
319
- return this.#sendTerminal(envelope, status, {
320
- code: error?.code ?? '[HANDLER_FAILED]',
321
- message: String(error?.message ?? 'the handler failed').slice(0, 256),
322
- });
323
- }
324
- this.#cancelled.delete(envelope.call_id);
325
- await this.#sendTerminal(envelope, 'success', payload);
326
- if (plan.finalize) await plan.finalize();
327
- }
328
- #plan(value) {
329
- switch (value.message.method) {
330
- case 'platform/initialize': return this.#planInitialize(value);
331
- case 'scope/open': return this.#planOpen(value);
332
- case 'scope/close': return this.#planClose(value);
333
- case 'platform/shutdown': return this.#planShutdown(value);
334
- case 'plugin/load': return this.#planLoad(value);
335
- case 'plugin/unload': return this.#planUnload(value);
336
- case 'service/call': return this.#planServiceCall(value);
337
- case 'event/deliver': return this.#planEventDeliver(value);
338
- case 'llm/stream': return this.#planLlmStream(value);
339
- case 'llm/control': return this.#planLlmControl(value);
340
- case 'command/invoke': return this.#planCommandInvoke(value);
341
- case 'tool/call': return this.#planToolCall(value);
342
- default: return { error: new ProtocolError('unknown_method', 'method is not supported by this host slice') };
343
- }
344
- }
345
- #planInitialize(value) {
346
- if (this.state !== 'pre_initialize') throw new ProtocolError('already_initialized', 'initialize is accepted exactly once');
347
- if (!isPlatformControl(value)) throw new ProtocolError('control_identity_required', 'initialize requires reserved control identity');
348
- const payload = value.message.payload;
349
- if (payload !== null) {
350
- if (!plain(payload)) throw new ProtocolError('initialize_payload', 'initialize payload must be null or an object');
351
- if (Object.hasOwn(payload, 'workspace') || Object.hasOwn(payload, 'workspace_root')) throw new ProtocolError('workspace_in_initialize', 'workspace belongs only in scope/open');
352
- if (Object.hasOwn(payload, 'host_epoch') && payload.host_epoch !== value.host_epoch) throw new ProtocolError('epoch_mismatch', 'payload host_epoch must equal envelope host_epoch');
353
- }
354
- return { run: async () => {
355
- this.hostEpoch = value.host_epoch;
356
- this.state = 'initialized';
357
- return { capabilities: { scope_lifecycle: true } };
358
- } };
359
- }
360
- #planOpen(value) {
361
- this.#requireInitialized();
362
- if (isPlatformControl(value)) throw new ProtocolError('scope_identity_required', 'scope/open cannot use control identity');
363
- const payload = value.message.payload;
364
- if (!plain(payload) || Object.keys(payload).length !== 1 || typeof payload.workspace_root !== 'string')
365
- throw new ProtocolError('scope_open_payload', 'scope/open payload is exactly {workspace_root:string}');
366
- const key = scopeKey(value); const current = this.#scopes.get(key);
367
- if (current && value.scope_generation < current.generation) throw new ProtocolError('scope_generation_regression', 'scope generation regressed');
368
- if (current && value.scope_generation > current.generation && current.open) throw new ProtocolError('future_scope_generation', 'open cannot advance an already-open scope');
369
- return { run: async () => {
370
- this.ledger.advanceScope(value.plugin_id, value.scope_id, value.scope_generation, true);
371
- if (!current) this.#order.push(key);
372
- this.#scopes.set(key, { pluginId: value.plugin_id, scopeId: value.scope_id, generation: value.scope_generation, open: true, workspaceRoot: payload.workspace_root });
373
- const subscribed = await this.#subscribeTopics(value);
374
- await this.#openScopeHandles(value);
375
- return { opened: true, subscriptions: subscribed };
376
- } };
377
- }
378
- #planClose(value) {
379
- this.#requireInitialized();
380
- if (value.message.payload !== null) throw new ProtocolError('scope_close_payload', 'scope/close payload must be null');
381
- const key = scopeKey(value); const current = this.#scopes.get(key);
382
- if (!current) throw new ProtocolError('unknown_scope', 'scope is not registered');
383
- if (value.scope_generation < current.generation) throw new ProtocolError('scope_generation_regression', 'scope generation regressed');
384
- if (value.scope_generation === current.generation && current.open) throw new ProtocolError('scope_close_not_advanced', 'scope close must carry an advanced generation');
385
- return { run: async () => {
386
- this.ledger.closeScope(value.plugin_id, value.scope_id, value.scope_generation);
387
- // State first, teardown after. Handlers may await, and a window between
388
- // the ledger knowing the scope has advanced and this table knowing it is
389
- // a window in which a concurrent shutdown reads a generation the ledger
390
- // has already left behind.
391
- this.#scopes.set(key, { ...current, generation: value.scope_generation, open: false });
392
- await this.#closeScopeHandles(value.plugin_id, key);
393
- // The generation advance is the invalidation. Nothing bound to the old
394
- // incarnation survives it, so the records go with it rather than waiting
395
- // to be discovered as stale on a later delivery.
396
- const revoked = this.#dropSubscriptions((record) => record.pluginId === value.plugin_id && record.scopeId === value.scope_id);
397
- return { closed: true, revokedSubscriptions: revoked };
398
- } };
399
- }
400
- #planShutdown(value) {
401
- this.#requireInitialized();
402
- if (!isPlatformControl(value)) throw new ProtocolError('control_identity_required', 'shutdown requires reserved control identity');
403
- if (value.message.payload !== null) throw new ProtocolError('shutdown_payload', 'shutdown payload must be null');
404
- // Set while planning rather than while running: the read loop has to be
405
- // able to tell that a frame arrived after shutdown without first awaiting
406
- // the dispatch it just scheduled.
407
- this.shuttingDown = true;
408
- return {
409
- run: async () => {
410
- this.admissionClosed = true;
411
- this.state = 'shutting_down';
412
- for (const key of [...this.#order].reverse()) {
413
- const scope = this.#scopes.get(key);
414
- if (scope?.open) {
415
- this.ledger.advanceScope(scope.pluginId, scope.scopeId, scope.generation, false);
416
- this.#scopes.set(key, { ...scope, open: false });
417
- await this.#closeScopeHandles(scope.pluginId, key);
418
- }
419
- }
420
- return { shutdown: true };
421
- },
422
- finalize: async () => {
423
- await this.writer.flush();
424
- this.state = 'stopped'; this.stopped = true;
425
- },
426
- };
427
- }
428
- // Loading and unloading are addressed to the reserved control identity: the
429
- // plugin being named has no scope of its own until it is loaded, and after an
430
- // unload it has none again.
431
- #planLoad(value) {
432
- this.#requireInitialized();
433
- if (!isPlatformControl(value)) throw new ProtocolError('control_identity_required', 'plugin/load requires reserved control identity');
434
- const request = pluginLoadRequest(value.message.payload);
435
- const current = this.#plugins.get(request.pluginId);
436
- if (current && current.phase !== 'unloaded') throw new ProtocolError('[PLUGIN_ALREADY_LOADED]', `plugin ${request.pluginId} is already loaded`);
437
- // Wrapped so an async task this plugin starts can be traced back to it,
438
- // and so a rejection that arrives while the load is still in flight is
439
- // known to be this load's rather than someone else's later work.
440
- return { run: () => asEntryLoad(request.pluginId, async () => {
441
- const loaded = await this.load(request);
442
- this.#plugins.set(request.pluginId, {
443
- phase: 'ready',
444
- services: loaded.services,
445
- eventTopics: loaded.eventTopics,
446
- serviceHandlers: loaded.serviceHandlers,
447
- topicHandlers: loaded.topicHandlers,
448
- llmAdapters: loaded.llmAdapters ?? new Map(),
449
- toolHandlers: loaded.toolHandlers ?? new Map(),
450
- commandHandlers: loaded.commandHandlers ?? new Map(),
451
- // The consuming direction: what this plugin may call, from the
452
- // manifest alone.
453
- tools: new Set(request.invokableTools),
454
- seats: new Set(request.seats),
455
- publishedTopics: new Set(request.publishedTopics),
456
- scopeHandlers: loaded.scopeHandlers ?? [],
457
- scopeTeardown: new Map(),
458
- inFlight: new Set(),
459
- });
460
- return {
461
- pluginId: request.pluginId,
462
- services: [...loaded.services],
463
- eventTopics: [...loaded.eventTopics],
464
- llmProviders: [...(loaded.llmProviders ?? [])],
465
- llmAdapters: { ...(loaded.llmAdapterInfo ?? {}) },
466
- tools: [...(loaded.tools ?? [])],
467
- commands: [...(loaded.commands ?? [])],
468
- };
469
- }) };
470
- }
471
-
472
- // Draining, not deleting: routing stops immediately and whatever was already
473
- // running is reported rather than forgotten.
474
- #planUnload(value) {
475
- this.#requireInitialized();
476
- if (!isPlatformControl(value)) throw new ProtocolError('control_identity_required', 'plugin/unload requires reserved control identity');
477
- const request = pluginUnloadRequest(value.message.payload);
478
- const current = this.#plugins.get(request.pluginId);
479
- if (!current) throw new ProtocolError('[UNKNOWN_PLUGIN]', `plugin ${request.pluginId} is not loaded`);
480
- if (current.phase !== 'ready') throw new ProtocolError('[STALE_PROVIDER]', `plugin ${request.pluginId} is ${current.phase}`);
481
- return { run: async () => {
482
- current.phase = 'draining';
483
- const revokedSubscriptions = this.#dropSubscriptions((record) => record.pluginId === request.pluginId);
484
- const outstandingCalls = [...current.inFlight];
485
- if (outstandingCalls.length === 0) await this.#retire(request.pluginId, current);
486
- return { pluginId: request.pluginId, outstandingCalls, revokedSubscriptions };
487
- } };
488
- }
489
-
490
- #planServiceCall(value) {
491
- this.#requireInitialized();
492
- if (isPlatformControl(value)) throw new ProtocolError('scope_identity_required', 'service/call cannot use control identity');
493
- const request = serviceCallRequest(value.message.payload);
494
- const current = this.#plugins.get(value.plugin_id);
495
- if (!current) throw new ProtocolError('[UNKNOWN_PLUGIN]', `plugin ${value.plugin_id} is not loaded`);
496
- if (current.phase !== 'ready') throw new ProtocolError('[STALE_PROVIDER]', `plugin ${value.plugin_id} is ${current.phase}`);
497
- const handler = current.serviceHandlers.get(request.service);
498
- if (!handler) throw new ProtocolError('[UNKNOWN_SERVICE]', `plugin ${value.plugin_id} does not provide service ${request.service}`);
499
- return { run: async () => {
500
- current.inFlight.add(value.call_id);
501
- const { ctx, close } = this.#contextFor(value);
502
- try {
503
- return await handler(request.request, ctx);
504
- } finally {
505
- close();
506
- current.inFlight.delete(value.call_id);
507
- // A drain that was waiting on this call finishes here, which is the
508
- // only place it can: nothing else knows the call ended.
509
- if (current.phase === 'draining' && current.inFlight.size === 0) await this.#retire(value.plugin_id, current);
510
- }
511
- } };
512
- }
513
-
514
- // A plugin that registered a topic handler has said it wants that topic's
515
- // events; a subscription is that wish bound to one scope incarnation, which
516
- // is why it is made here and not at load time — at load time there is no
517
- // scope to pin it to.
518
- //
519
- // Awaited on purpose: `scope/open` succeeding while its subscriptions silently
520
- // did not would leave a plugin that believes it is listening and is not.
521
- async #subscribeTopics(value) {
522
- const plugin = this.#plugins.get(value.plugin_id);
523
- if (!plugin || plugin.phase !== 'ready' || plugin.topicHandlers.size === 0) return [];
524
- const bridge = this.#bridgeOf(value);
525
- const registered = [];
526
- for (const [topic, handler] of plugin.topicHandlers) {
527
- const subscription = `sub-${++this.#subscriptionSeq}`;
528
- try {
529
- await bridge.call('event/subscribe', eventSubscribeRequest({ subscription, topic }));
530
- } catch (cause) {
531
- // The bridge reports *that* a call was refused; rebon's own code and
532
- // message are in the payload. Passing them through is the difference
533
- // between "scope/open failed" and "that topic is not declared".
534
- const refusal = cause?.payload;
535
- throw new ProtocolError(refusal?.code ?? cause?.code ?? '[SUBSCRIBE_FAILED]',
536
- `subscribing ${topic} was refused: ${refusal?.message ?? cause?.message ?? 'no reason given'}`);
537
- }
538
- this.#subscriptions.set(subscription, {
539
- pluginId: value.plugin_id, scopeId: value.scope_id, generation: value.scope_generation, topic, handler,
540
- });
541
- registered.push(subscription);
542
- }
543
- return registered;
544
- }
545
-
546
- // Delivery is refused before the handler runs whenever the subscription no
547
- // longer describes what is being delivered. A generation mismatch is the one
548
- // that matters most: running the handler would hand a plugin an event from a
549
- // session it has already finished.
550
- #planEventDeliver(value) {
551
- this.#requireInitialized();
552
- if (isPlatformControl(value)) throw new ProtocolError('scope_identity_required', 'event/deliver cannot use control identity');
553
- const delivery = eventDelivery(value.message.payload);
554
- const record = this.#subscriptions.get(delivery.subscription);
555
- if (!record || record.pluginId !== value.plugin_id) throw new ProtocolError('[UNKNOWN_SUBSCRIPTION]', `subscription ${delivery.subscription} is not registered for ${value.plugin_id}`);
556
- if (record.topic !== delivery.topic) throw new ProtocolError('[TOPIC_MISMATCH]', `subscription ${delivery.subscription} is on ${record.topic}, not ${delivery.topic}`);
557
- if (record.scopeId !== value.scope_id || record.generation !== value.scope_generation)
558
- throw new ProtocolError('[STALE_SUBSCRIPTION]', `subscription ${delivery.subscription} belongs to another scope incarnation`);
559
- const plugin = this.#plugins.get(value.plugin_id);
560
- if (!plugin) throw new ProtocolError('[UNKNOWN_PLUGIN]', `plugin ${value.plugin_id} is not loaded`);
561
- if (plugin.phase !== 'ready') throw new ProtocolError('[STALE_PROVIDER]', `plugin ${value.plugin_id} is ${plugin.phase}`);
562
- return { run: async () => {
563
- // A delivery in progress is work a drain has to wait for, exactly like a
564
- // service call: a plugin torn down mid-handler is the same hazard.
565
- plugin.inFlight.add(value.call_id);
566
- const { ctx, close } = this.#contextFor(value);
567
- try {
568
- await record.handler(delivery.event, ctx);
569
- return { delivered: true };
570
- } finally {
571
- close();
572
- plugin.inFlight.delete(value.call_id);
573
- if (plugin.phase === 'draining' && plugin.inFlight.size === 0) await this.#retire(value.plugin_id, plugin);
574
- }
575
- } };
576
- }
577
-
578
- // A model turn: routed by provider, answered with chunks and then one
579
- // terminal. The turn's contents are opaque here — what a chunk means belongs
580
- // to the model contract, and this host only carries it.
581
- #planLlmStream(value) {
582
- this.#requireInitialized();
583
- if (isPlatformControl(value)) throw new ProtocolError('scope_identity_required', 'llm/stream cannot use control identity');
584
- const request = llmStreamRequest(value.message.payload);
585
- const current = this.#plugins.get(value.plugin_id);
586
- if (!current) throw new ProtocolError('[UNKNOWN_PLUGIN]', `plugin ${value.plugin_id} is not loaded`);
587
- if (current.phase !== 'ready') throw new ProtocolError('[STALE_PROVIDER]', `plugin ${value.plugin_id} is ${current.phase}`);
588
- const adapter = current.llmAdapters.get(request.provider);
589
- if (!adapter) throw new ProtocolError('[UNKNOWN_PROVIDER]', `plugin ${value.plugin_id} has no adapter for ${request.provider}`);
590
- return { run: async () => {
591
- current.inFlight.add(value.call_id);
592
- const { ctx, close } = this.#contextFor(value);
593
- try {
594
- return await adapter(request.request, ctx);
595
- } finally {
596
- close();
597
- current.inFlight.delete(value.call_id);
598
- if (current.phase === 'draining' && current.inFlight.size === 0) await this.#retire(value.plugin_id, current);
599
- }
600
- } };
601
- }
602
-
603
- // Something about the conversation around an adapter's turns, rather than
604
- // about one turn. An adapter that keeps no state has nothing to do with any
605
- // of the three signals, so a missing handler is not an error — the answer is
606
- // the same either way, and refusing would make every stateless provider
607
- // implement a no-op to stay loadable.
608
- #planLlmControl(value) {
609
- this.#requireInitialized();
610
- if (isPlatformControl(value)) throw new ProtocolError('scope_identity_required', 'llm/control cannot use control identity');
611
- const request = llmControlRequest(value.message.payload);
612
- const current = this.#plugins.get(value.plugin_id);
613
- if (!current) throw new ProtocolError('[UNKNOWN_PLUGIN]', `plugin ${value.plugin_id} is not loaded`);
614
- if (current.phase !== 'ready') throw new ProtocolError('[STALE_PROVIDER]', `plugin ${value.plugin_id} is ${current.phase}`);
615
- const adapter = current.llmAdapters.get(request.provider);
616
- if (!adapter) throw new ProtocolError('[UNKNOWN_PROVIDER]', `plugin ${value.plugin_id} has no adapter for ${request.provider}`);
617
- return { run: async () => {
618
- current.inFlight.add(value.call_id);
619
- const { ctx, close } = this.#contextFor(value);
620
- try {
621
- return typeof adapter.control === 'function'
622
- ? await adapter.control(request.signal, ctx) ?? null
623
- : null;
624
- } finally {
625
- close();
626
- current.inFlight.delete(value.call_id);
627
- if (current.phase === 'draining' && current.inFlight.size === 0) await this.#retire(value.plugin_id, current);
628
- }
629
- } };
630
- }
631
-
632
- // A slash command someone typed. Like a tool call in direction and unlike it
633
- // in audience: the answer is text for the person who typed it, so there is
634
- // no schema to validate against and no permission to have asked for — the
635
- // person asking *is* the permission.
636
- //
637
- // Only a `prompt` command has a handler here. An `explain` and a `panel`
638
- // answered at registration, so rebon never sends one.
639
- #planCommandInvoke(value) {
640
- this.#requireInitialized();
641
- if (isPlatformControl(value)) throw new ProtocolError('scope_identity_required', 'command/invoke cannot use control identity');
642
- const request = commandInvokeRequest(value.message.payload);
643
- const current = this.#plugins.get(value.plugin_id);
644
- if (!current) throw new ProtocolError('[UNKNOWN_PLUGIN]', `plugin ${value.plugin_id} is not loaded`);
645
- if (current.phase !== 'ready') throw new ProtocolError('[STALE_PROVIDER]', `plugin ${value.plugin_id} is ${current.phase}`);
646
- const handler = current.commandHandlers.get(request.name);
647
- if (!handler) throw new ProtocolError('[UNKNOWN_COMMAND]', `plugin ${value.plugin_id} does not provide command ${request.name}`);
648
- return { run: async () => {
649
- current.inFlight.add(value.call_id);
650
- const { ctx, close } = this.#contextFor(value);
651
- try {
652
- return await handler(request, ctx) ?? null;
653
- } finally {
654
- close();
655
- current.inFlight.delete(value.call_id);
656
- if (current.phase === 'draining' && current.inFlight.size === 0) await this.#retire(value.plugin_id, current);
657
- }
658
- } };
659
- }
660
-
661
- // The mirror of a service call: rebon is the caller and the plugin owns the
662
- // tool. Whether the user permitted this run was settled before it got here.
663
- #planToolCall(value) {
664
- this.#requireInitialized();
665
- if (isPlatformControl(value)) throw new ProtocolError('scope_identity_required', 'tool/call cannot use control identity');
666
- const request = toolInvokeRequest(value.message.payload);
667
- const current = this.#plugins.get(value.plugin_id);
668
- if (!current) throw new ProtocolError('[UNKNOWN_PLUGIN]', `plugin ${value.plugin_id} is not loaded`);
669
- if (current.phase !== 'ready') throw new ProtocolError('[STALE_PROVIDER]', `plugin ${value.plugin_id} is ${current.phase}`);
670
- const handler = current.toolHandlers.get(request.tool);
671
- if (!handler) throw new ProtocolError('[UNKNOWN_TOOL]', `plugin ${value.plugin_id} does not provide tool ${request.tool}`);
672
- return { run: async () => {
673
- current.inFlight.add(value.call_id);
674
- const { ctx, close } = this.#contextFor(value);
675
- try {
676
- return await handler(request.input, ctx);
677
- } finally {
678
- close();
679
- current.inFlight.delete(value.call_id);
680
- if (current.phase === 'draining' && current.inFlight.size === 0) await this.#retire(value.plugin_id, current);
681
- }
682
- } };
683
- }
684
-
685
- #requireInitialized() { if (this.state !== 'initialized') throw new ProtocolError('not_initialized', 'host is not initialized'); }
686
- async #sendTerminal(value, status, payload) {
687
- const reply = terminal(identityOf(value), status, payload);
688
- this.ledger.terminal(reply, 'inbound');
689
- await this.writer.send(reply);
690
- }
691
- }
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createPluginBridge } from './bridge.mjs';
3
+ import { CallLedger } from './lifecycle.mjs';
4
+ import { loadPlugin } from './loader.mjs';
5
+ import { asEntryLoad } from './ownership.mjs';
6
+ import { commandInvokeRequest, eventDelivery, eventEmitRequest, eventSubscribeRequest, llmControlRequest, llmStreamRequest, pluginLoadRequest, pluginUnloadRequest, seatCallRequest, serviceCallRequest, toolInvokeRequest } from './methods.mjs';
7
+ import { FramingError } from './framing.mjs';
8
+ import { CALL_CANCEL_METHOD, chunk as chunkFrame, identityOf, isPlatformControl, ProtocolError, terminal } from './protocol.mjs';
9
+
10
+ const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
11
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
12
+ const scopeKey = (value) => JSON.stringify([value.plugin_id, value.scope_id]);
13
+
14
+ export class PluginHost {
15
+ state = 'pre_initialize'; hostEpoch; ledger; stopped = false; admissionClosed = false;
16
+ // Set while `platform/shutdown` is still being planned, so the read loop can
17
+ // see it without waiting for the dispatch it scheduled to finish.
18
+ shuttingDown = false;
19
+ #scopes = new Map(); #order = [];
20
+ // pluginId -> { phase, services, eventTopics, serviceHandlers, topicHandlers,
21
+ // llmAdapters, toolHandlers, tools:Set(invokable), inFlight:Set,
22
+ // scopeHandlers, scopeTeardown:Map(scopeKey -> [fn]) }
23
+ #plugins = new Map();
24
+ // subscriptionId -> { pluginId, scopeId, generation, topic, handler }
25
+ #subscriptions = new Map(); #subscriptionSeq = 0;
26
+ // callId -> the bridge that owns an outbound call, so its terminal comes back
27
+ // to the promise that is waiting for it rather than to a guess.
28
+ #outbound = new Map();
29
+ // callId -> the AbortController a running handler is watching, and the ids a
30
+ // cancel arrived for. Kept apart: a handler may ignore its signal and finish
31
+ // normally, and the terminal has to say which of those happened.
32
+ #running = new Map(); #cancelled = new Set();
33
+ constructor(writer, options = {}) {
34
+ this.writer = writer;
35
+ this.load = options.load ?? loadPlugin;
36
+ // Called once a plugin has finished draining, so whatever the loader built
37
+ // for it can be taken down. The host knows a plugin by its declarations; it
38
+ // has no idea what a loader put behind them, which is exactly why the
39
+ // loader is the one told that the plugin is over.
40
+ this.unload = options.unload ?? (async () => {});
41
+ this.newCallId = options.newCallId ?? randomUUID;
42
+ }
43
+
44
+ /// Ends a plugin's life once nothing of it is still running.
45
+ ///
46
+ /// Awaited by every caller, and always before the terminal that lets rebon
47
+ /// believe the drain is done: a loader's teardown that ran after rebon had
48
+ /// already loaded a replacement would be disposing the new one's world.
49
+ async #retire(pluginId, plugin) {
50
+ plugin.phase = 'unloaded';
51
+ for (const key of [...plugin.scopeTeardown.keys()]) await this.#closeScopeHandles(pluginId, key);
52
+ await this.unload(pluginId);
53
+ }
54
+
55
+ /// Routes one frame by what it is. Requests are work; terminals answer work
56
+ /// this host asked for; a notification is neither and owes nothing back.
57
+ async accept(envelope) {
58
+ switch (envelope.message.type) {
59
+ case 'request': return this.dispatch(envelope);
60
+ case 'terminal': return this.settleUpstream(envelope);
61
+ case 'notification': return this.#acceptNotification(envelope);
62
+ default: throw new ProtocolError('unexpected_message', 'message type is not routable');
63
+ }
64
+ }
65
+
66
+ /// Cancel is the only notification the protocol defines, and it asks rather
67
+ /// than commands: the handler may ignore its signal and finish anyway. What
68
+ /// this does is record the intent and raise the signal; the terminal still
69
+ /// comes from whatever the handler does next.
70
+ #acceptNotification(envelope) {
71
+ if (envelope.message.method !== CALL_CANCEL_METHOD) return undefined;
72
+ this.ledger.cancel(envelope);
73
+ const controller = this.#running.get(envelope.call_id);
74
+ if (!controller) return undefined;
75
+ this.#cancelled.add(envelope.call_id);
76
+ controller.abort();
77
+ return undefined;
78
+ }
79
+
80
+ /// Hands a terminal back to the call that is waiting for it.
81
+ settleUpstream(envelope) {
82
+ const owner = this.#outbound.get(envelope.call_id);
83
+ if (!owner) throw new ProtocolError('unknown_call', 'terminal does not answer any call this host made');
84
+ this.#outbound.delete(envelope.call_id);
85
+ owner.bridge.receive(envelope);
86
+ }
87
+
88
+ /// An upstream caller bound to one scope incarnation.
89
+ ///
90
+ /// A new one per incarnation on purpose: the identity a call carries is the
91
+ /// scope's generation at the time it was made, and reusing a bridge across an
92
+ /// advance would send calls stamped with an incarnation that has ended.
93
+ #bridgeFor(pluginId, scopeId, generation) {
94
+ const holder = {};
95
+ holder.bridge = createPluginBridge({
96
+ trustedPluginId: pluginId,
97
+ scopeBinding: { hostEpoch: this.hostEpoch, scopeId, scopeGeneration: generation },
98
+ transport: this.writer,
99
+ ledger: this.ledger,
100
+ idFactory: () => { const id = this.newCallId(); this.#outbound.set(id, holder); return id; },
101
+ });
102
+ return holder.bridge;
103
+ }
104
+
105
+ /// The one bridge belonging to a scope's current incarnation.
106
+ ///
107
+ /// Cached on the scope record, which is replaced whenever the generation
108
+ /// advances — so a new incarnation gets a new bridge without anyone having to
109
+ /// remember to invalidate the old one.
110
+ #bridgeOf(value) {
111
+ const scope = this.#scopes.get(scopeKey(value));
112
+ scope.bridge ??= this.#bridgeFor(value.plugin_id, value.scope_id, value.scope_generation);
113
+ return scope.bridge;
114
+ }
115
+
116
+ /// What a plugin is given for the lifetime of one scope incarnation.
117
+ ///
118
+ /// The same powers a call context carries, minus the two that only make sense
119
+ /// inside a call: there is no `emit`, because there is no call to put a chunk
120
+ /// on, and no `signal`, because nothing is being cancelled. What is left is
121
+ /// the session — which is what a plugin that acts on its own schedule needs,
122
+ /// and the reason this exists at all.
123
+ ///
124
+ /// It stops working when its incarnation does. A handle captured across a
125
+ /// generation advance would be speaking for a session that has ended, and
126
+ /// saying so here names that, rather than letting rebon refuse a frame whose
127
+ /// origin is no longer obvious.
128
+ #scopeContextFor(value) {
129
+ const scope = this.#scopes.get(scopeKey(value));
130
+ const plugin = this.#plugins.get(value.plugin_id);
131
+ const gate = { open: true };
132
+ const guard = (what) => {
133
+ if (!gate.open) {
134
+ throw new ProtocolError('[SCOPE_CLOSED]', `${what} belongs to a scope incarnation that has ended`);
135
+ }
136
+ };
137
+ const ctx = Object.freeze({
138
+ scopeId: value.scope_id,
139
+ workspaceRoot: scope?.workspaceRoot,
140
+ publish: async (topic, event = null, options = {}) => {
141
+ guard(`publishing ${JSON.stringify(topic)}`);
142
+ if (!plugin.publishedTopics.has(topic)) {
143
+ throw new ProtocolError('[UNAUTHORIZED_TOPIC]', `topic ${JSON.stringify(topic)} is not declared by the manifest for ${JSON.stringify(value.plugin_id)}`);
144
+ }
145
+ return this.#upstream(value, 'event/emit', eventEmitRequest({ topic, event }), options, `topic ${topic}`);
146
+ },
147
+ seat: async (seat, method, params = null, options = {}) => {
148
+ guard(`seat ${JSON.stringify(seat)}`);
149
+ if (!plugin.seats.has(seat)) {
150
+ throw new ProtocolError('[UNAUTHORIZED_SEAT]', `seat ${JSON.stringify(seat)} is not declared by the manifest for ${JSON.stringify(value.plugin_id)}`);
151
+ }
152
+ return this.#upstream(value, 'seat/call', seatCallRequest({ seat, method, params }), options, `seat ${seat}`);
153
+ },
154
+ invoke: async (tool, input = null, options = {}) => {
155
+ guard(`tool ${JSON.stringify(tool)}`);
156
+ if (!plugin.tools.has(tool)) {
157
+ throw new ProtocolError('[UNAUTHORIZED_TOOL]', `tool ${JSON.stringify(tool)} is not declared by the manifest for ${JSON.stringify(value.plugin_id)}`);
158
+ }
159
+ return this.#upstream(value, 'tool/invoke', toolInvokeRequest({ tool, input }), options, `tool ${tool}`);
160
+ },
161
+ });
162
+ return { ctx, close: () => { gate.open = false; } };
163
+ }
164
+
165
+ /// Runs the plugin's scope handlers for one incarnation, keeping whatever
166
+ /// they hand back so closing the scope can undo it.
167
+ async #openScopeHandles(value) {
168
+ const plugin = this.#plugins.get(value.plugin_id);
169
+ if (!plugin || plugin.phase !== 'ready' || plugin.scopeHandlers.length === 0) return;
170
+ const key = scopeKey(value);
171
+ const teardown = [];
172
+ for (const handler of plugin.scopeHandlers) {
173
+ const { ctx, close } = this.#scopeContextFor(value);
174
+ // A handler that throws fails the open: a plugin that believes it is
175
+ // attached to a session and is not would be worse than a refused scope.
176
+ const disposer = await handler(ctx);
177
+ teardown.push(async () => {
178
+ close();
179
+ if (typeof disposer === 'function') await disposer();
180
+ });
181
+ }
182
+ plugin.scopeTeardown.set(key, teardown);
183
+ }
184
+
185
+ /// Undoes them. Failures are diagnostics: a scope is closing either way, and
186
+ /// one plugin's bad teardown must not strand the rest.
187
+ async #closeScopeHandles(pluginId, key) {
188
+ const plugin = this.#plugins.get(pluginId);
189
+ const teardown = plugin?.scopeTeardown.get(key);
190
+ if (teardown === undefined) return;
191
+ plugin.scopeTeardown.delete(key);
192
+ for (const undo of teardown.reverse()) {
193
+ try {
194
+ await undo();
195
+ } catch { /* a scope closes whatever its plugins think about it */ }
196
+ }
197
+ }
198
+
199
+ /// What a handler is given alongside its request.
200
+ ///
201
+ /// This is the only thing a plugin ever holds that reaches back into rebon,
202
+ /// and it is bound to the call's own scope incarnation: a handler cannot act
203
+ /// on a session other than the one it was called for, because it has no way
204
+ /// to name one.
205
+ #contextFor(value) {
206
+ const scope = this.#scopes.get(scopeKey(value));
207
+ const plugin = this.#plugins.get(value.plugin_id);
208
+ // `open` closes when the handler returns. A ctx captured and used later
209
+ // would be emitting into a call that has already ended, and the refusal has
210
+ // to name that rather than letting the frame reach a caller who stopped
211
+ // reading.
212
+ const gate = { open: true };
213
+ const controller = new AbortController();
214
+ this.#running.set(value.call_id, controller);
215
+ const ctx = Object.freeze({
216
+ scopeId: value.scope_id,
217
+ workspaceRoot: scope?.workspaceRoot,
218
+ // Raised when rebon asks this call to stop. A handler that watches it can
219
+ // stop early; one that ignores it finishes normally, and the terminal
220
+ // says so.
221
+ signal: controller.signal,
222
+ emit: async (piece) => {
223
+ if (!gate.open) throw new ProtocolError('[STREAM_CLOSED]', 'the call this context belongs to has already ended');
224
+ const frame = chunkFrame(identityOf(value), piece);
225
+ this.ledger.chunk(frame, 'inbound');
226
+ await this.writer.send(frame);
227
+ },
228
+ // Publishes one event onto rebon's event plane.
229
+ //
230
+ // Not `emit`: that puts a piece on *this* call, for the caller who is
231
+ // reading it. This puts a fact on the plane, for whoever is listening —
232
+ // a different audience and a different lifetime. Declared as
233
+ // `publishedTopics`, separately from the topics the plugin listens to,
234
+ // because publishing and listening are different powers.
235
+ publish: async (topic, event = null, options = {}) => {
236
+ if (!plugin.publishedTopics.has(topic)) {
237
+ throw new ProtocolError('[UNAUTHORIZED_TOPIC]', `topic ${JSON.stringify(topic)} is not declared by the manifest for ${JSON.stringify(value.plugin_id)}`);
238
+ }
239
+ return this.#upstream(value, 'event/emit', eventEmitRequest({ topic, event }), options, `topic ${topic}`);
240
+ },
241
+ // Kernel seats the plugin was installed to use. A request, so it has an
242
+ // answer — and therefore cannot serve a cordis disposer, which is
243
+ // synchronous. Teardown relies on unload draining, not on calls made on
244
+ // the way out.
245
+ seat: async (seat, method, params = null, options = {}) => {
246
+ if (!plugin.seats.has(seat)) {
247
+ throw new ProtocolError('[UNAUTHORIZED_SEAT]', `seat ${JSON.stringify(seat)} is not declared by the manifest for ${JSON.stringify(value.plugin_id)}`);
248
+ }
249
+ return this.#upstream(value, 'seat/call', seatCallRequest({ seat, method, params }), options, `seat ${seat}`);
250
+ },
251
+ invoke: async (tool, input = null, options = {}) => {
252
+ // Refused here as well as by rebon, for the same reason registration is
253
+ // checked on both sides: this refusal points at the plugin's own line,
254
+ // rather than arriving as a rejection from across a process boundary.
255
+ if (!plugin.tools.has(tool)) {
256
+ throw new ProtocolError('[UNAUTHORIZED_TOOL]', `tool ${JSON.stringify(tool)} is not declared by the manifest for ${JSON.stringify(value.plugin_id)}`);
257
+ }
258
+ return this.#upstream(value, 'tool/invoke', toolInvokeRequest({ tool, input }), options, `tool ${tool}`);
259
+ },
260
+ });
261
+ return { ctx, close: () => { gate.open = false; this.#running.delete(value.call_id); } };
262
+ }
263
+
264
+ /// One upstream call, with rebon's own refusal code passed through.
265
+ ///
266
+ /// The bridge reports *that* a call failed; rebon's code and message are in
267
+ /// the payload. A plugin author needs the latter — "the call failed" sends
268
+ /// them nowhere.
269
+ async #upstream(value, method, payload, options, what) {
270
+ try {
271
+ return await this.#bridgeOf(value).call(method, payload, options);
272
+ } catch (cause) {
273
+ const refusal = cause?.payload;
274
+ if (!refusal?.code) throw cause;
275
+ throw new ProtocolError(refusal.code, refusal.message ?? `${what} was refused`);
276
+ }
277
+ }
278
+
279
+ #dropSubscriptions(matches) {
280
+ const dropped = [];
281
+ for (const [id, record] of this.#subscriptions) {
282
+ if (!matches(record)) continue;
283
+ this.#subscriptions.delete(id);
284
+ dropped.push(id);
285
+ }
286
+ return dropped;
287
+ }
288
+
289
+ async dispatch(envelope) {
290
+ if (this.admissionClosed) throw new ProtocolError('admission_closed', 'host no longer accepts requests');
291
+ if (envelope.message.type !== 'request') throw new ProtocolError('unexpected_message', 'host accepts lifecycle requests only');
292
+
293
+ let plan;
294
+ try { plan = this.#plan(envelope); }
295
+ catch (error) {
296
+ if (!(error instanceof ProtocolError)) throw error;
297
+ plan = { error };
298
+ }
299
+
300
+ const ledger = this.ledger ?? new CallLedger(envelope.host_epoch);
301
+ ledger.register(identityOf(envelope), 'inbound', 'plugin_host');
302
+ this.ledger ??= ledger;
303
+
304
+ if (plan.error) return this.#sendTerminal(envelope, 'error', {
305
+ code: plan.error.code,
306
+ message: plan.error.message.slice(0, 256),
307
+ });
308
+ let payload;
309
+ try { payload = await plan.run(); }
310
+ catch (error) {
311
+ // Only the transport failing is fatal. A plugin's own exception is *that
312
+ // call* failing — letting it out of here would take the host and every
313
+ // other plugin down with one bad handler, which is the opposite of the
314
+ // isolation loading them separately is for.
315
+ if (error instanceof FramingError) throw error;
316
+ // A handler that stopped because it was asked to did not fail. Reporting
317
+ // it as an error would make a deliberate stop look like a fault.
318
+ const status = this.#cancelled.delete(envelope.call_id) ? 'cancelled' : 'error';
319
+ return this.#sendTerminal(envelope, status, {
320
+ code: error?.code ?? '[HANDLER_FAILED]',
321
+ message: String(error?.message ?? 'the handler failed').slice(0, 256),
322
+ });
323
+ }
324
+ this.#cancelled.delete(envelope.call_id);
325
+ await this.#sendTerminal(envelope, 'success', payload);
326
+ if (plan.finalize) await plan.finalize();
327
+ }
328
+ #plan(value) {
329
+ switch (value.message.method) {
330
+ case 'platform/initialize': return this.#planInitialize(value);
331
+ case 'scope/open': return this.#planOpen(value);
332
+ case 'scope/close': return this.#planClose(value);
333
+ case 'platform/shutdown': return this.#planShutdown(value);
334
+ case 'plugin/load': return this.#planLoad(value);
335
+ case 'plugin/unload': return this.#planUnload(value);
336
+ case 'service/call': return this.#planServiceCall(value);
337
+ case 'event/deliver': return this.#planEventDeliver(value);
338
+ case 'llm/stream': return this.#planLlmStream(value);
339
+ case 'llm/control': return this.#planLlmControl(value);
340
+ case 'command/invoke': return this.#planCommandInvoke(value);
341
+ case 'tool/call': return this.#planToolCall(value);
342
+ default: return { error: new ProtocolError('unknown_method', 'method is not supported by this host slice') };
343
+ }
344
+ }
345
+ #planInitialize(value) {
346
+ if (this.state !== 'pre_initialize') throw new ProtocolError('already_initialized', 'initialize is accepted exactly once');
347
+ if (!isPlatformControl(value)) throw new ProtocolError('control_identity_required', 'initialize requires reserved control identity');
348
+ const payload = value.message.payload;
349
+ if (payload !== null) {
350
+ if (!plain(payload)) throw new ProtocolError('initialize_payload', 'initialize payload must be null or an object');
351
+ if (Object.hasOwn(payload, 'workspace') || Object.hasOwn(payload, 'workspace_root')) throw new ProtocolError('workspace_in_initialize', 'workspace belongs only in scope/open');
352
+ if (Object.hasOwn(payload, 'host_epoch') && payload.host_epoch !== value.host_epoch) throw new ProtocolError('epoch_mismatch', 'payload host_epoch must equal envelope host_epoch');
353
+ }
354
+ return { run: async () => {
355
+ this.hostEpoch = value.host_epoch;
356
+ this.state = 'initialized';
357
+ return { capabilities: { scope_lifecycle: true } };
358
+ } };
359
+ }
360
+ #planOpen(value) {
361
+ this.#requireInitialized();
362
+ if (isPlatformControl(value)) throw new ProtocolError('scope_identity_required', 'scope/open cannot use control identity');
363
+ const payload = value.message.payload;
364
+ if (!plain(payload) || Object.keys(payload).length !== 1 || typeof payload.workspace_root !== 'string')
365
+ throw new ProtocolError('scope_open_payload', 'scope/open payload is exactly {workspace_root:string}');
366
+ const key = scopeKey(value); const current = this.#scopes.get(key);
367
+ if (current && value.scope_generation < current.generation) throw new ProtocolError('scope_generation_regression', 'scope generation regressed');
368
+ if (current && value.scope_generation > current.generation && current.open) throw new ProtocolError('future_scope_generation', 'open cannot advance an already-open scope');
369
+ return { run: async () => {
370
+ this.ledger.advanceScope(value.plugin_id, value.scope_id, value.scope_generation, true);
371
+ if (!current) this.#order.push(key);
372
+ this.#scopes.set(key, { pluginId: value.plugin_id, scopeId: value.scope_id, generation: value.scope_generation, open: true, workspaceRoot: payload.workspace_root });
373
+ const subscribed = await this.#subscribeTopics(value);
374
+ await this.#openScopeHandles(value);
375
+ return { opened: true, subscriptions: subscribed };
376
+ } };
377
+ }
378
+ #planClose(value) {
379
+ this.#requireInitialized();
380
+ if (value.message.payload !== null) throw new ProtocolError('scope_close_payload', 'scope/close payload must be null');
381
+ const key = scopeKey(value); const current = this.#scopes.get(key);
382
+ if (!current) throw new ProtocolError('unknown_scope', 'scope is not registered');
383
+ if (value.scope_generation < current.generation) throw new ProtocolError('scope_generation_regression', 'scope generation regressed');
384
+ if (value.scope_generation === current.generation && current.open) throw new ProtocolError('scope_close_not_advanced', 'scope close must carry an advanced generation');
385
+ return { run: async () => {
386
+ this.ledger.closeScope(value.plugin_id, value.scope_id, value.scope_generation);
387
+ // State first, teardown after. Handlers may await, and a window between
388
+ // the ledger knowing the scope has advanced and this table knowing it is
389
+ // a window in which a concurrent shutdown reads a generation the ledger
390
+ // has already left behind.
391
+ this.#scopes.set(key, { ...current, generation: value.scope_generation, open: false });
392
+ await this.#closeScopeHandles(value.plugin_id, key);
393
+ // The generation advance is the invalidation. Nothing bound to the old
394
+ // incarnation survives it, so the records go with it rather than waiting
395
+ // to be discovered as stale on a later delivery.
396
+ const revoked = this.#dropSubscriptions((record) => record.pluginId === value.plugin_id && record.scopeId === value.scope_id);
397
+ return { closed: true, revokedSubscriptions: revoked };
398
+ } };
399
+ }
400
+ #planShutdown(value) {
401
+ this.#requireInitialized();
402
+ if (!isPlatformControl(value)) throw new ProtocolError('control_identity_required', 'shutdown requires reserved control identity');
403
+ if (value.message.payload !== null) throw new ProtocolError('shutdown_payload', 'shutdown payload must be null');
404
+ // Set while planning rather than while running: the read loop has to be
405
+ // able to tell that a frame arrived after shutdown without first awaiting
406
+ // the dispatch it just scheduled.
407
+ this.shuttingDown = true;
408
+ return {
409
+ run: async () => {
410
+ this.admissionClosed = true;
411
+ this.state = 'shutting_down';
412
+ for (const key of [...this.#order].reverse()) {
413
+ const scope = this.#scopes.get(key);
414
+ if (scope?.open) {
415
+ this.ledger.advanceScope(scope.pluginId, scope.scopeId, scope.generation, false);
416
+ this.#scopes.set(key, { ...scope, open: false });
417
+ await this.#closeScopeHandles(scope.pluginId, key);
418
+ }
419
+ }
420
+ return { shutdown: true };
421
+ },
422
+ finalize: async () => {
423
+ await this.writer.flush();
424
+ this.state = 'stopped'; this.stopped = true;
425
+ },
426
+ };
427
+ }
428
+ // Loading and unloading are addressed to the reserved control identity: the
429
+ // plugin being named has no scope of its own until it is loaded, and after an
430
+ // unload it has none again.
431
+ #planLoad(value) {
432
+ this.#requireInitialized();
433
+ if (!isPlatformControl(value)) throw new ProtocolError('control_identity_required', 'plugin/load requires reserved control identity');
434
+ const request = pluginLoadRequest(value.message.payload);
435
+ const current = this.#plugins.get(request.pluginId);
436
+ if (current && current.phase !== 'unloaded') throw new ProtocolError('[PLUGIN_ALREADY_LOADED]', `plugin ${request.pluginId} is already loaded`);
437
+ // Wrapped so an async task this plugin starts can be traced back to it,
438
+ // and so a rejection that arrives while the load is still in flight is
439
+ // known to be this load's rather than someone else's later work.
440
+ return { run: () => asEntryLoad(request.pluginId, async () => {
441
+ const loaded = await this.load(request);
442
+ this.#plugins.set(request.pluginId, {
443
+ phase: 'ready',
444
+ services: loaded.services,
445
+ eventTopics: loaded.eventTopics,
446
+ serviceHandlers: loaded.serviceHandlers,
447
+ topicHandlers: loaded.topicHandlers,
448
+ llmAdapters: loaded.llmAdapters ?? new Map(),
449
+ toolHandlers: loaded.toolHandlers ?? new Map(),
450
+ commandHandlers: loaded.commandHandlers ?? new Map(),
451
+ // The consuming direction: what this plugin may call, from the
452
+ // manifest alone.
453
+ tools: new Set(request.invokableTools),
454
+ seats: new Set(request.seats),
455
+ publishedTopics: new Set(request.publishedTopics),
456
+ scopeHandlers: loaded.scopeHandlers ?? [],
457
+ scopeTeardown: new Map(),
458
+ inFlight: new Set(),
459
+ });
460
+ return {
461
+ pluginId: request.pluginId,
462
+ services: [...loaded.services],
463
+ eventTopics: [...loaded.eventTopics],
464
+ llmProviders: [...(loaded.llmProviders ?? [])],
465
+ llmAdapters: { ...(loaded.llmAdapterInfo ?? {}) },
466
+ tools: [...(loaded.tools ?? [])],
467
+ commands: [...(loaded.commands ?? [])],
468
+ };
469
+ }) };
470
+ }
471
+
472
+ // Draining, not deleting: routing stops immediately and whatever was already
473
+ // running is reported rather than forgotten.
474
+ #planUnload(value) {
475
+ this.#requireInitialized();
476
+ if (!isPlatformControl(value)) throw new ProtocolError('control_identity_required', 'plugin/unload requires reserved control identity');
477
+ const request = pluginUnloadRequest(value.message.payload);
478
+ const current = this.#plugins.get(request.pluginId);
479
+ if (!current) throw new ProtocolError('[UNKNOWN_PLUGIN]', `plugin ${request.pluginId} is not loaded`);
480
+ if (current.phase !== 'ready') throw new ProtocolError('[STALE_PROVIDER]', `plugin ${request.pluginId} is ${current.phase}`);
481
+ return { run: async () => {
482
+ current.phase = 'draining';
483
+ const revokedSubscriptions = this.#dropSubscriptions((record) => record.pluginId === request.pluginId);
484
+ const outstandingCalls = [...current.inFlight];
485
+ if (outstandingCalls.length === 0) await this.#retire(request.pluginId, current);
486
+ return { pluginId: request.pluginId, outstandingCalls, revokedSubscriptions };
487
+ } };
488
+ }
489
+
490
+ #planServiceCall(value) {
491
+ this.#requireInitialized();
492
+ if (isPlatformControl(value)) throw new ProtocolError('scope_identity_required', 'service/call cannot use control identity');
493
+ const request = serviceCallRequest(value.message.payload);
494
+ const current = this.#plugins.get(value.plugin_id);
495
+ if (!current) throw new ProtocolError('[UNKNOWN_PLUGIN]', `plugin ${value.plugin_id} is not loaded`);
496
+ if (current.phase !== 'ready') throw new ProtocolError('[STALE_PROVIDER]', `plugin ${value.plugin_id} is ${current.phase}`);
497
+ const handler = current.serviceHandlers.get(request.service);
498
+ if (!handler) throw new ProtocolError('[UNKNOWN_SERVICE]', `plugin ${value.plugin_id} does not provide service ${request.service}`);
499
+ return { run: async () => {
500
+ current.inFlight.add(value.call_id);
501
+ const { ctx, close } = this.#contextFor(value);
502
+ try {
503
+ return await handler(request.request, ctx);
504
+ } finally {
505
+ close();
506
+ current.inFlight.delete(value.call_id);
507
+ // A drain that was waiting on this call finishes here, which is the
508
+ // only place it can: nothing else knows the call ended.
509
+ if (current.phase === 'draining' && current.inFlight.size === 0) await this.#retire(value.plugin_id, current);
510
+ }
511
+ } };
512
+ }
513
+
514
+ // A plugin that registered a topic handler has said it wants that topic's
515
+ // events; a subscription is that wish bound to one scope incarnation, which
516
+ // is why it is made here and not at load time — at load time there is no
517
+ // scope to pin it to.
518
+ //
519
+ // Awaited on purpose: `scope/open` succeeding while its subscriptions silently
520
+ // did not would leave a plugin that believes it is listening and is not.
521
+ async #subscribeTopics(value) {
522
+ const plugin = this.#plugins.get(value.plugin_id);
523
+ if (!plugin || plugin.phase !== 'ready' || plugin.topicHandlers.size === 0) return [];
524
+ const bridge = this.#bridgeOf(value);
525
+ const registered = [];
526
+ for (const [topic, handler] of plugin.topicHandlers) {
527
+ const subscription = `sub-${++this.#subscriptionSeq}`;
528
+ try {
529
+ await bridge.call('event/subscribe', eventSubscribeRequest({ subscription, topic }));
530
+ } catch (cause) {
531
+ // The bridge reports *that* a call was refused; rebon's own code and
532
+ // message are in the payload. Passing them through is the difference
533
+ // between "scope/open failed" and "that topic is not declared".
534
+ const refusal = cause?.payload;
535
+ throw new ProtocolError(refusal?.code ?? cause?.code ?? '[SUBSCRIBE_FAILED]',
536
+ `subscribing ${topic} was refused: ${refusal?.message ?? cause?.message ?? 'no reason given'}`);
537
+ }
538
+ this.#subscriptions.set(subscription, {
539
+ pluginId: value.plugin_id, scopeId: value.scope_id, generation: value.scope_generation, topic, handler,
540
+ });
541
+ registered.push(subscription);
542
+ }
543
+ return registered;
544
+ }
545
+
546
+ // Delivery is refused before the handler runs whenever the subscription no
547
+ // longer describes what is being delivered. A generation mismatch is the one
548
+ // that matters most: running the handler would hand a plugin an event from a
549
+ // session it has already finished.
550
+ #planEventDeliver(value) {
551
+ this.#requireInitialized();
552
+ if (isPlatformControl(value)) throw new ProtocolError('scope_identity_required', 'event/deliver cannot use control identity');
553
+ const delivery = eventDelivery(value.message.payload);
554
+ const record = this.#subscriptions.get(delivery.subscription);
555
+ if (!record || record.pluginId !== value.plugin_id) throw new ProtocolError('[UNKNOWN_SUBSCRIPTION]', `subscription ${delivery.subscription} is not registered for ${value.plugin_id}`);
556
+ if (record.topic !== delivery.topic) throw new ProtocolError('[TOPIC_MISMATCH]', `subscription ${delivery.subscription} is on ${record.topic}, not ${delivery.topic}`);
557
+ if (record.scopeId !== value.scope_id || record.generation !== value.scope_generation)
558
+ throw new ProtocolError('[STALE_SUBSCRIPTION]', `subscription ${delivery.subscription} belongs to another scope incarnation`);
559
+ const plugin = this.#plugins.get(value.plugin_id);
560
+ if (!plugin) throw new ProtocolError('[UNKNOWN_PLUGIN]', `plugin ${value.plugin_id} is not loaded`);
561
+ if (plugin.phase !== 'ready') throw new ProtocolError('[STALE_PROVIDER]', `plugin ${value.plugin_id} is ${plugin.phase}`);
562
+ return { run: async () => {
563
+ // A delivery in progress is work a drain has to wait for, exactly like a
564
+ // service call: a plugin torn down mid-handler is the same hazard.
565
+ plugin.inFlight.add(value.call_id);
566
+ const { ctx, close } = this.#contextFor(value);
567
+ try {
568
+ await record.handler(delivery.event, ctx);
569
+ return { delivered: true };
570
+ } finally {
571
+ close();
572
+ plugin.inFlight.delete(value.call_id);
573
+ if (plugin.phase === 'draining' && plugin.inFlight.size === 0) await this.#retire(value.plugin_id, plugin);
574
+ }
575
+ } };
576
+ }
577
+
578
+ // A model turn: routed by provider, answered with chunks and then one
579
+ // terminal. The turn's contents are opaque here — what a chunk means belongs
580
+ // to the model contract, and this host only carries it.
581
+ #planLlmStream(value) {
582
+ this.#requireInitialized();
583
+ if (isPlatformControl(value)) throw new ProtocolError('scope_identity_required', 'llm/stream cannot use control identity');
584
+ const request = llmStreamRequest(value.message.payload);
585
+ const current = this.#plugins.get(value.plugin_id);
586
+ if (!current) throw new ProtocolError('[UNKNOWN_PLUGIN]', `plugin ${value.plugin_id} is not loaded`);
587
+ if (current.phase !== 'ready') throw new ProtocolError('[STALE_PROVIDER]', `plugin ${value.plugin_id} is ${current.phase}`);
588
+ const adapter = current.llmAdapters.get(request.provider);
589
+ if (!adapter) throw new ProtocolError('[UNKNOWN_PROVIDER]', `plugin ${value.plugin_id} has no adapter for ${request.provider}`);
590
+ return { run: async () => {
591
+ current.inFlight.add(value.call_id);
592
+ const { ctx, close } = this.#contextFor(value);
593
+ try {
594
+ return await adapter(request.request, ctx);
595
+ } finally {
596
+ close();
597
+ current.inFlight.delete(value.call_id);
598
+ if (current.phase === 'draining' && current.inFlight.size === 0) await this.#retire(value.plugin_id, current);
599
+ }
600
+ } };
601
+ }
602
+
603
+ // Something about the conversation around an adapter's turns, rather than
604
+ // about one turn. An adapter that keeps no state has nothing to do with any
605
+ // of the three signals, so a missing handler is not an error — the answer is
606
+ // the same either way, and refusing would make every stateless provider
607
+ // implement a no-op to stay loadable.
608
+ #planLlmControl(value) {
609
+ this.#requireInitialized();
610
+ if (isPlatformControl(value)) throw new ProtocolError('scope_identity_required', 'llm/control cannot use control identity');
611
+ const request = llmControlRequest(value.message.payload);
612
+ const current = this.#plugins.get(value.plugin_id);
613
+ if (!current) throw new ProtocolError('[UNKNOWN_PLUGIN]', `plugin ${value.plugin_id} is not loaded`);
614
+ if (current.phase !== 'ready') throw new ProtocolError('[STALE_PROVIDER]', `plugin ${value.plugin_id} is ${current.phase}`);
615
+ const adapter = current.llmAdapters.get(request.provider);
616
+ if (!adapter) throw new ProtocolError('[UNKNOWN_PROVIDER]', `plugin ${value.plugin_id} has no adapter for ${request.provider}`);
617
+ return { run: async () => {
618
+ current.inFlight.add(value.call_id);
619
+ const { ctx, close } = this.#contextFor(value);
620
+ try {
621
+ return typeof adapter.control === 'function'
622
+ ? await adapter.control(request.signal, ctx) ?? null
623
+ : null;
624
+ } finally {
625
+ close();
626
+ current.inFlight.delete(value.call_id);
627
+ if (current.phase === 'draining' && current.inFlight.size === 0) await this.#retire(value.plugin_id, current);
628
+ }
629
+ } };
630
+ }
631
+
632
+ // A slash command someone typed. Like a tool call in direction and unlike it
633
+ // in audience: the answer is text for the person who typed it, so there is
634
+ // no schema to validate against and no permission to have asked for — the
635
+ // person asking *is* the permission.
636
+ //
637
+ // Only a `prompt` command has a handler here. An `explain` and a `panel`
638
+ // answered at registration, so rebon never sends one.
639
+ #planCommandInvoke(value) {
640
+ this.#requireInitialized();
641
+ if (isPlatformControl(value)) throw new ProtocolError('scope_identity_required', 'command/invoke cannot use control identity');
642
+ const request = commandInvokeRequest(value.message.payload);
643
+ const current = this.#plugins.get(value.plugin_id);
644
+ if (!current) throw new ProtocolError('[UNKNOWN_PLUGIN]', `plugin ${value.plugin_id} is not loaded`);
645
+ if (current.phase !== 'ready') throw new ProtocolError('[STALE_PROVIDER]', `plugin ${value.plugin_id} is ${current.phase}`);
646
+ const handler = current.commandHandlers.get(request.name);
647
+ if (!handler) throw new ProtocolError('[UNKNOWN_COMMAND]', `plugin ${value.plugin_id} does not provide command ${request.name}`);
648
+ return { run: async () => {
649
+ current.inFlight.add(value.call_id);
650
+ const { ctx, close } = this.#contextFor(value);
651
+ try {
652
+ return await handler(request, ctx) ?? null;
653
+ } finally {
654
+ close();
655
+ current.inFlight.delete(value.call_id);
656
+ if (current.phase === 'draining' && current.inFlight.size === 0) await this.#retire(value.plugin_id, current);
657
+ }
658
+ } };
659
+ }
660
+
661
+ // The mirror of a service call: rebon is the caller and the plugin owns the
662
+ // tool. Whether the user permitted this run was settled before it got here.
663
+ #planToolCall(value) {
664
+ this.#requireInitialized();
665
+ if (isPlatformControl(value)) throw new ProtocolError('scope_identity_required', 'tool/call cannot use control identity');
666
+ const request = toolInvokeRequest(value.message.payload);
667
+ const current = this.#plugins.get(value.plugin_id);
668
+ if (!current) throw new ProtocolError('[UNKNOWN_PLUGIN]', `plugin ${value.plugin_id} is not loaded`);
669
+ if (current.phase !== 'ready') throw new ProtocolError('[STALE_PROVIDER]', `plugin ${value.plugin_id} is ${current.phase}`);
670
+ const handler = current.toolHandlers.get(request.tool);
671
+ if (!handler) throw new ProtocolError('[UNKNOWN_TOOL]', `plugin ${value.plugin_id} does not provide tool ${request.tool}`);
672
+ return { run: async () => {
673
+ current.inFlight.add(value.call_id);
674
+ const { ctx, close } = this.#contextFor(value);
675
+ try {
676
+ return await handler(request.input, ctx);
677
+ } finally {
678
+ close();
679
+ current.inFlight.delete(value.call_id);
680
+ if (current.phase === 'draining' && current.inFlight.size === 0) await this.#retire(value.plugin_id, current);
681
+ }
682
+ } };
683
+ }
684
+
685
+ #requireInitialized() { if (this.state !== 'initialized') throw new ProtocolError('not_initialized', 'host is not initialized'); }
686
+ async #sendTerminal(value, status, payload) {
687
+ const reply = terminal(identityOf(value), status, payload);
688
+ this.ledger.terminal(reply, 'inbound');
689
+ await this.writer.send(reply);
690
+ }
691
+ }