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