@rebon/cli-win32-x64 0.23.2 → 1.0.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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/payload/compose-runtime/src/bridge.mjs +55 -2
- package/payload/plugin-host/src/cli.mjs +42 -0
- package/payload/plugin-host/src/host.mjs +70 -3
- package/payload/plugin-host/src/loader.mjs +46 -5
- package/payload/plugin-host/src/methods.mjs +127 -6
- package/payload/plugin-host/src/ownership.mjs +57 -0
- package/payload/rebon-boa-helper.exe +0 -0
- package/payload/rebon-browser-mcp.exe +0 -0
- package/payload/rebon-computer-use.exe +0 -0
- package/payload/rebon-lsp-mcp.exe +0 -0
- package/payload/rebon.exe +0 -0
- package/payload/sandbox-win.exe +0 -0
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ an optional dependency. Supported platforms: `win32-x64`, `darwin-x64`,
|
|
|
29
29
|
rebon
|
|
30
30
|
|
|
31
31
|
# Resume a previous session
|
|
32
|
-
rebon --resume
|
|
32
|
+
rebon --resume k7m2q-4xr9t-hb3wz-p8ncv
|
|
33
33
|
|
|
34
34
|
# Override the active provider / model
|
|
35
35
|
rebon --provider openrouter --model gpt-5.5
|
package/package.json
CHANGED
|
@@ -125,16 +125,69 @@ export function callSignal() {
|
|
|
125
125
|
/// attributed to a scope; outside one it goes to stderr, which the supervisor
|
|
126
126
|
/// already collects as the host's diagnostic tail. stdout is never touched —
|
|
127
127
|
/// that is the protocol's channel.
|
|
128
|
+
/// A token bucket per level, so one plugin in a logging loop cannot fill the
|
|
129
|
+
/// worker's log.
|
|
130
|
+
///
|
|
131
|
+
/// Here rather than on rebon's side because the cheapest byte is the one that
|
|
132
|
+
/// never crosses the process boundary, and because this side already knows how
|
|
133
|
+
/// many it dropped. Drops are reported, at most once a second: replacing a
|
|
134
|
+
/// blind spot with a quieter blind spot is not an improvement, and a reader who
|
|
135
|
+
/// sees a gap deserves to know it is a gap.
|
|
136
|
+
const RATE = { perSecond: 20, burst: 100 };
|
|
137
|
+
const buckets = new Map();
|
|
138
|
+
|
|
139
|
+
function admit(level) {
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
let bucket = buckets.get(level);
|
|
142
|
+
if (bucket === undefined) {
|
|
143
|
+
bucket = { tokens: RATE.burst, last: now, dropped: 0, reportedAt: 0 };
|
|
144
|
+
buckets.set(level, bucket);
|
|
145
|
+
}
|
|
146
|
+
bucket.tokens = Math.min(
|
|
147
|
+
RATE.burst,
|
|
148
|
+
bucket.tokens + ((now - bucket.last) / 1000) * RATE.perSecond,
|
|
149
|
+
);
|
|
150
|
+
bucket.last = now;
|
|
151
|
+
if (bucket.tokens >= 1) {
|
|
152
|
+
bucket.tokens -= 1;
|
|
153
|
+
return { allowed: true, dropped: 0 };
|
|
154
|
+
}
|
|
155
|
+
bucket.dropped += 1;
|
|
156
|
+
if (now - bucket.reportedAt >= 1000) {
|
|
157
|
+
bucket.reportedAt = now;
|
|
158
|
+
const dropped = bucket.dropped;
|
|
159
|
+
bucket.dropped = 0;
|
|
160
|
+
return { allowed: false, dropped };
|
|
161
|
+
}
|
|
162
|
+
return { allowed: false, dropped: 0 };
|
|
163
|
+
}
|
|
164
|
+
|
|
128
165
|
const LEVELS = ['trace', 'debug', 'info', 'warn', 'error'];
|
|
129
166
|
export const logger = Object.freeze(Object.fromEntries(LEVELS.map((level) => [
|
|
130
167
|
level,
|
|
131
|
-
(message) => {
|
|
168
|
+
(message, options = {}) => {
|
|
132
169
|
const line = String(message);
|
|
133
|
-
|
|
170
|
+
// Inside a call, that call is who is speaking. Outside one — a timer, an
|
|
171
|
+
// agent loop driving its own turn — `via` names the plugin, and the
|
|
172
|
+
// session it is attached to is the handle that can still reach the seat.
|
|
173
|
+
//
|
|
174
|
+
// This is the same ladder `require()` climbs, with one difference that
|
|
175
|
+
// matters: it does not throw. A log line is not worth failing a plugin
|
|
176
|
+
// over, and a refusal here would be the very silence this is fixing.
|
|
177
|
+
// T19/T19b lost three rounds to lines that went to stderr and were only
|
|
178
|
+
// ever read when the host died.
|
|
179
|
+
const ctx = calls.getStore() ?? sessionOf(options.via);
|
|
134
180
|
if (ctx === undefined) {
|
|
135
181
|
process.stderr.write(`[compose:${level}] ${line}\n`);
|
|
136
182
|
return;
|
|
137
183
|
}
|
|
184
|
+
const { allowed, dropped } = admit(level);
|
|
185
|
+
if (dropped > 0) {
|
|
186
|
+
void ctx
|
|
187
|
+
.seat('logger', 'warn', { message: `${dropped} ${level} line(s) dropped: too many, too fast` })
|
|
188
|
+
.catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
if (!allowed) return;
|
|
138
191
|
void ctx.seat('logger', level, { message: line }).catch(() => {
|
|
139
192
|
process.stderr.write(`[compose:${level}] ${line}\n`);
|
|
140
193
|
});
|
|
@@ -3,6 +3,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
3
3
|
import { NdjsonDecoder, SerializedWriter } from './framing.mjs';
|
|
4
4
|
import { PluginHost } from './host.mjs';
|
|
5
5
|
import { loadPlugin } from './loader.mjs';
|
|
6
|
+
import { currentOwner } from './ownership.mjs';
|
|
6
7
|
import { ProtocolError } from './protocol.mjs';
|
|
7
8
|
|
|
8
9
|
export function diagnostic(stream, code, message) {
|
|
@@ -84,7 +85,48 @@ export async function runHost({ input, output, error, loader }) {
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/// A rejection nobody handled, said out loud instead of swallowed, and
|
|
89
|
+
/// attributed where the async chain reaches back to an entry.
|
|
90
|
+
///
|
|
91
|
+
/// Node does not stop this process for one, and before T20 nothing else looked
|
|
92
|
+
/// either: a plugin could start an async task, have it fail, and leave no trace
|
|
93
|
+
/// anywhere rebon reads. Three rounds of T19 were spent on exactly that.
|
|
94
|
+
///
|
|
95
|
+
/// T20 recorded these as unattributed, believing a failed promise cannot say
|
|
96
|
+
/// who created it. It can: `plugin/load` runs inside a store that survives
|
|
97
|
+
/// timers, microtasks and awaits, so this handler reads the owner straight out
|
|
98
|
+
/// of it. What escapes the chain — through an `EventEmitter`, a native callback
|
|
99
|
+
/// or a third-party library — is still `unattributed`, which is the truthful
|
|
100
|
+
/// answer and is kept distinct from a wrong one.
|
|
101
|
+
///
|
|
102
|
+
/// It never exits. A stray rejection in one plugin must not take down a host
|
|
103
|
+
/// other plugins are being served from; a load still in flight is failed by its
|
|
104
|
+
/// own path instead, which is the caller's to run.
|
|
105
|
+
export function installRejectionDiagnostic(stream) {
|
|
106
|
+
process.on('unhandledRejection', (reason) => {
|
|
107
|
+
const first = String(reason?.stack ?? reason ?? '').split('\n')[0];
|
|
108
|
+
const owner = currentOwner();
|
|
109
|
+
if (owner === undefined) {
|
|
110
|
+
diagnostic(stream, 'unhandled_rejection', `unattributed: ${first}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// `loading` says which of the two this is. While a load is in flight the
|
|
114
|
+
// entry did not install correctly and nothing depends on it yet, so the
|
|
115
|
+
// load fails and the composition skips it with a reason. Afterwards the
|
|
116
|
+
// plugin stays ready: a load that already succeeded cannot be un-failed,
|
|
117
|
+
// and tearing down a plane other entries serve from would cost far more
|
|
118
|
+
// than the one bad promise.
|
|
119
|
+
if (owner.loading) {
|
|
120
|
+
owner.rejection = first;
|
|
121
|
+
diagnostic(stream, 'unhandled_rejection', `plugin=${owner.entry} during load: ${first}`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
diagnostic(stream, 'unhandled_rejection', `plugin=${owner.entry}: ${first}`);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
87
128
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
129
|
+
installRejectionDiagnostic(process.stderr);
|
|
88
130
|
const flag = process.argv.indexOf('--loader');
|
|
89
131
|
const specifier = flag >= 0 ? process.argv[flag + 1] : process.env.REBON_PLUGIN_LOADER;
|
|
90
132
|
let loader;
|
|
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { createPluginBridge } from './bridge.mjs';
|
|
3
3
|
import { CallLedger } from './lifecycle.mjs';
|
|
4
4
|
import { loadPlugin } from './loader.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { asEntryLoad } from './ownership.mjs';
|
|
6
|
+
import { commandInvokeRequest, eventDelivery, eventEmitRequest, eventSubscribeRequest, llmControlRequest, llmStreamRequest, pluginLoadRequest, pluginUnloadRequest, seatCallRequest, serviceCallRequest, toolInvokeRequest } from './methods.mjs';
|
|
6
7
|
import { FramingError } from './framing.mjs';
|
|
7
8
|
import { CALL_CANCEL_METHOD, chunk as chunkFrame, identityOf, isPlatformControl, ProtocolError, terminal } from './protocol.mjs';
|
|
8
9
|
|
|
@@ -335,6 +336,8 @@ export class PluginHost {
|
|
|
335
336
|
case 'service/call': return this.#planServiceCall(value);
|
|
336
337
|
case 'event/deliver': return this.#planEventDeliver(value);
|
|
337
338
|
case 'llm/stream': return this.#planLlmStream(value);
|
|
339
|
+
case 'llm/control': return this.#planLlmControl(value);
|
|
340
|
+
case 'command/invoke': return this.#planCommandInvoke(value);
|
|
338
341
|
case 'tool/call': return this.#planToolCall(value);
|
|
339
342
|
default: return { error: new ProtocolError('unknown_method', 'method is not supported by this host slice') };
|
|
340
343
|
}
|
|
@@ -431,7 +434,10 @@ export class PluginHost {
|
|
|
431
434
|
const request = pluginLoadRequest(value.message.payload);
|
|
432
435
|
const current = this.#plugins.get(request.pluginId);
|
|
433
436
|
if (current && current.phase !== 'unloaded') throw new ProtocolError('[PLUGIN_ALREADY_LOADED]', `plugin ${request.pluginId} is already loaded`);
|
|
434
|
-
|
|
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 () => {
|
|
435
441
|
const loaded = await this.load(request);
|
|
436
442
|
this.#plugins.set(request.pluginId, {
|
|
437
443
|
phase: 'ready',
|
|
@@ -441,6 +447,7 @@ export class PluginHost {
|
|
|
441
447
|
topicHandlers: loaded.topicHandlers,
|
|
442
448
|
llmAdapters: loaded.llmAdapters ?? new Map(),
|
|
443
449
|
toolHandlers: loaded.toolHandlers ?? new Map(),
|
|
450
|
+
commandHandlers: loaded.commandHandlers ?? new Map(),
|
|
444
451
|
// The consuming direction: what this plugin may call, from the
|
|
445
452
|
// manifest alone.
|
|
446
453
|
tools: new Set(request.invokableTools),
|
|
@@ -455,9 +462,11 @@ export class PluginHost {
|
|
|
455
462
|
services: [...loaded.services],
|
|
456
463
|
eventTopics: [...loaded.eventTopics],
|
|
457
464
|
llmProviders: [...(loaded.llmProviders ?? [])],
|
|
465
|
+
llmAdapters: { ...(loaded.llmAdapterInfo ?? {}) },
|
|
458
466
|
tools: [...(loaded.tools ?? [])],
|
|
467
|
+
commands: [...(loaded.commands ?? [])],
|
|
459
468
|
};
|
|
460
|
-
} };
|
|
469
|
+
}) };
|
|
461
470
|
}
|
|
462
471
|
|
|
463
472
|
// Draining, not deleting: routing stops immediately and whatever was already
|
|
@@ -591,6 +600,64 @@ export class PluginHost {
|
|
|
591
600
|
} };
|
|
592
601
|
}
|
|
593
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
|
+
|
|
594
661
|
// The mirror of a service call: rebon is the caller and the plugin owns the
|
|
595
662
|
// tool. Whether the user permitted this run was settled before it got here.
|
|
596
663
|
#planToolCall(value) {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { pathToFileURL } from 'node:url';
|
|
15
15
|
import { ProtocolError } from './protocol.mjs';
|
|
16
|
-
import { pluginToolDefinition, validateName } from './methods.mjs';
|
|
16
|
+
import { pluginCommandDefinition, pluginToolDefinition, validateName } from './methods.mjs';
|
|
17
17
|
|
|
18
18
|
/// Resolves an entry against its package root and refuses anything outside it.
|
|
19
19
|
///
|
|
@@ -30,14 +30,36 @@ export function resolveEntry(root, entry) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
class Registrar {
|
|
33
|
-
#declaredServices; #declaredTopics; #declaredProviders; #declaredTools;
|
|
33
|
+
#declaredServices; #declaredTopics; #declaredProviders; #declaredTools; #declaredCommands;
|
|
34
|
+
#commands = new Map(); #commandHandlers = new Map();
|
|
34
35
|
#services = new Map(); #topics = new Map(); #adapters = new Map();
|
|
36
|
+
#adapterInfo = new Map();
|
|
35
37
|
#tools = new Map(); #toolHandlers = new Map(); #scopes = []; #sealed = false;
|
|
36
|
-
constructor(declaredServices, declaredTopics, declaredProviders, declaredTools) {
|
|
38
|
+
constructor(declaredServices, declaredTopics, declaredProviders, declaredTools, declaredCommands) {
|
|
37
39
|
this.#declaredServices = new Set(declaredServices);
|
|
38
40
|
this.#declaredTopics = new Set(declaredTopics);
|
|
39
41
|
this.#declaredProviders = new Set(declaredProviders);
|
|
40
42
|
this.#declaredTools = new Set(declaredTools);
|
|
43
|
+
this.#declaredCommands = new Set(declaredCommands);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// A command is a tool for a person: the manifest declares the name, and
|
|
47
|
+
/// the registration says what it looks like in a menu. Only a `prompt`
|
|
48
|
+
/// command carries a handler — an `explain` says its sentence at
|
|
49
|
+
/// registration and a `panel` names a dialog, so neither has anything left
|
|
50
|
+
/// to ask the plugin at invoke time.
|
|
51
|
+
#admitCommand(definition, handler) {
|
|
52
|
+
if (this.#sealed) throw new ProtocolError('[REGISTRATION_CLOSED]', 'a command was registered after activation finished');
|
|
53
|
+
const command = pluginCommandDefinition(definition);
|
|
54
|
+
if (!this.#declaredCommands.has(command.name)) throw new ProtocolError('[UNAUTHORIZED_REGISTER]', `command ${JSON.stringify(command.name)} is not declared by this plugin's manifest`);
|
|
55
|
+
if (this.#commands.has(command.name)) throw new ProtocolError('[DUPLICATE_DECLARATION]', `command ${JSON.stringify(command.name)} is registered twice`);
|
|
56
|
+
if (command.kind.type === 'prompt') {
|
|
57
|
+
if (typeof handler !== 'function') throw new ProtocolError('[WRONG_SHAPE]', `prompt command ${JSON.stringify(command.name)} needs a function`);
|
|
58
|
+
this.#commandHandlers.set(command.name, handler);
|
|
59
|
+
} else if (handler !== undefined) {
|
|
60
|
+
throw new ProtocolError('[WRONG_SHAPE]', `${command.kind.type} command ${JSON.stringify(command.name)} takes no handler`);
|
|
61
|
+
}
|
|
62
|
+
this.#commands.set(command.name, command);
|
|
41
63
|
}
|
|
42
64
|
|
|
43
65
|
/// A tool differs from a service in one way that matters: it has to be
|
|
@@ -78,8 +100,24 @@ class Registrar {
|
|
|
78
100
|
// An llm adapter is a service under a different routing key: declared in
|
|
79
101
|
// the manifest, registered here, and answered with chunks instead of one
|
|
80
102
|
// value.
|
|
81
|
-
|
|
103
|
+
//
|
|
104
|
+
// The optional third argument is what the adapter says about itself —
|
|
105
|
+
// which models, which default, what the provider can do. It is reported
|
|
106
|
+
// once with the ready report rather than asked for per turn, because a
|
|
107
|
+
// caller has to know whether a provider streams reasoning text before it
|
|
108
|
+
// decides how to budget the turn it is about to send. Opaque to the
|
|
109
|
+
// host: reading it is the model layer's business.
|
|
110
|
+
llm: (provider, adapter, info) => {
|
|
111
|
+
this.#admit('provider', provider, adapter, this.#declaredProviders, this.#adapters);
|
|
112
|
+
if (info !== undefined) {
|
|
113
|
+
if (info === null || typeof info !== 'object' || Array.isArray(info)) {
|
|
114
|
+
throw new ProtocolError('[WRONG_SHAPE]', `adapter info for ${JSON.stringify(provider)} must be a plain object`);
|
|
115
|
+
}
|
|
116
|
+
this.#adapterInfo.set(provider, info);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
82
119
|
tool: (definition, handler) => this.#admitTool(definition, handler),
|
|
120
|
+
command: (definition, handler) => this.#admitCommand(definition, handler),
|
|
83
121
|
// Not a capability, so nothing to declare: everything a scope handle can
|
|
84
122
|
// do is gated by the declarations above. What it is, is a lifetime — the
|
|
85
123
|
// session, rather than one call inside it. A plugin that produces facts
|
|
@@ -100,7 +138,10 @@ class Registrar {
|
|
|
100
138
|
services: Object.freeze([...this.#services.keys()]),
|
|
101
139
|
eventTopics: Object.freeze([...this.#topics.keys()]),
|
|
102
140
|
llmProviders: Object.freeze([...this.#adapters.keys()]),
|
|
141
|
+
llmAdapterInfo: Object.freeze(Object.fromEntries(this.#adapterInfo)),
|
|
103
142
|
tools: Object.freeze([...this.#tools.values()]),
|
|
143
|
+
commands: Object.freeze([...this.#commands.values()]),
|
|
144
|
+
commandHandlers: this.#commandHandlers,
|
|
104
145
|
serviceHandlers: this.#services,
|
|
105
146
|
topicHandlers: this.#topics,
|
|
106
147
|
llmAdapters: this.#adapters,
|
|
@@ -131,7 +172,7 @@ export async function loadPlugin(request, importModule = (url) => import(url)) {
|
|
|
131
172
|
if (typeof activate !== 'function') {
|
|
132
173
|
throw new ProtocolError('[NO_ACTIVATE]', `plugin entry ${request.entry} exports no activate function`);
|
|
133
174
|
}
|
|
134
|
-
const registrar = new Registrar(request.services, request.eventTopics, request.llmProviders, request.tools);
|
|
175
|
+
const registrar = new Registrar(request.services, request.eventTopics, request.llmProviders, request.tools, request.commands);
|
|
135
176
|
try {
|
|
136
177
|
await activate(registrar.api(), request.config);
|
|
137
178
|
} catch (cause) {
|
|
@@ -18,6 +18,15 @@ export const EVENT_UNSUBSCRIBE_METHOD = 'event/unsubscribe';
|
|
|
18
18
|
export const EVENT_EMIT_METHOD = 'event/emit';
|
|
19
19
|
export const TOOL_INVOKE_METHOD = 'tool/invoke';
|
|
20
20
|
export const LLM_STREAM_METHOD = 'llm/stream';
|
|
21
|
+
export const LLM_CONTROL_METHOD = 'llm/control';
|
|
22
|
+
export const COMMAND_INVOKE_METHOD = 'command/invoke';
|
|
23
|
+
// The surfaces a command can say it works on, and the two groupings a menu
|
|
24
|
+
// knows. Closed sets, mirroring `rebon-slash-commands`.
|
|
25
|
+
export const COMMAND_SURFACES = Object.freeze(['tui', 'desktop', 'acp', 'web', 'mobile']);
|
|
26
|
+
export const COMMAND_CATEGORIES = Object.freeze(['command', 'agent']);
|
|
27
|
+
// The conversation-level signals an adapter can be told about. A closed set:
|
|
28
|
+
// an open string would make every unknown signal look like a typo.
|
|
29
|
+
export const LLM_CONTROL_SIGNALS = Object.freeze(['reset', 'endTurn', 'invalidate']);
|
|
21
30
|
export const TOOL_CALL_METHOD = 'tool/call';
|
|
22
31
|
export const SEAT_CALL_METHOD = 'seat/call';
|
|
23
32
|
|
|
@@ -112,8 +121,64 @@ function declarations(value, kind, where) {
|
|
|
112
121
|
return Object.freeze([...value]);
|
|
113
122
|
}
|
|
114
123
|
|
|
124
|
+
// A command as a plugin describes it: one-to-one with `CommandSpec`, minus
|
|
125
|
+
// the two kinds a plugin may not claim (`native` has no front-end function to
|
|
126
|
+
// map an id to, `session` is rebon's own engine state).
|
|
127
|
+
export function pluginCommandDefinition(input) {
|
|
128
|
+
shape(input, ['name', 'description', 'kind'], ['aliases', 'zhAliases', 'hint', 'category', 'surfaces'], 'command definition');
|
|
129
|
+
const name = validateName('command', string(input.name, 'name'));
|
|
130
|
+
for (const alias of input.aliases ?? []) validateName('command', string(alias, 'alias'));
|
|
131
|
+
for (const alias of input.zhAliases ?? []) validateName('command', string(alias, 'zh alias'));
|
|
132
|
+
const description = string(input.description, 'description');
|
|
133
|
+
if (description.length === 0) throw new ProtocolError('[EMPTY_DESCRIPTION]', `command ${JSON.stringify(name)} has no description`);
|
|
134
|
+
if (byteLength(description) > MAX_DESCRIPTION_BYTES) throw new ProtocolError('[DESCRIPTION_TOO_LONG]', `command ${JSON.stringify(name)} has a description over the ${MAX_DESCRIPTION_BYTES} byte limit`);
|
|
135
|
+
if (input.category !== undefined && !COMMAND_CATEGORIES.includes(input.category)) {
|
|
136
|
+
throw new ProtocolError('[WRONG_SHAPE]', `command category must be one of ${COMMAND_CATEGORIES.join(', ')}`);
|
|
137
|
+
}
|
|
138
|
+
for (const surface of input.surfaces ?? []) {
|
|
139
|
+
if (!COMMAND_SURFACES.includes(surface)) {
|
|
140
|
+
throw new ProtocolError('[WRONG_SHAPE]', `command surface must be one of ${COMMAND_SURFACES.join(', ')}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const kind = input.kind;
|
|
144
|
+
if (!plain(kind)) throw new ProtocolError('[WRONG_SHAPE]', 'command kind must be a plain object');
|
|
145
|
+
switch (kind.type) {
|
|
146
|
+
case 'prompt':
|
|
147
|
+
shape(kind, ['type'], [], 'command kind');
|
|
148
|
+
break;
|
|
149
|
+
case 'explain': {
|
|
150
|
+
shape(kind, ['type', 'text'], [], 'command kind');
|
|
151
|
+
const text = string(kind.text, 'kind text');
|
|
152
|
+
if (text.length === 0) throw new ProtocolError('[EMPTY_DESCRIPTION]', `command ${JSON.stringify(name)} explains nothing`);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case 'panel':
|
|
156
|
+
shape(kind, ['type', 'dialog'], [], 'command kind');
|
|
157
|
+
validateName('dialog', string(kind.dialog, 'kind dialog'));
|
|
158
|
+
break;
|
|
159
|
+
default:
|
|
160
|
+
throw new ProtocolError('[WRONG_SHAPE]', 'command kind must be prompt, explain or panel');
|
|
161
|
+
}
|
|
162
|
+
return Object.freeze({ ...input });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function commandDefinitions(value, where) {
|
|
166
|
+
if (value === undefined) return Object.freeze([]);
|
|
167
|
+
if (!Array.isArray(value)) throw new ProtocolError('[WRONG_SHAPE]', `${where} must be an array`);
|
|
168
|
+
if (value.length > MAX_DECLARATIONS) throw new ProtocolError('[TOO_MANY_DECLARATIONS]', `${where} declares ${value.length} entries, over the ${MAX_DECLARATIONS} limit`);
|
|
169
|
+
const seen = new Set();
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const entry of value) {
|
|
172
|
+
const command = pluginCommandDefinition(entry);
|
|
173
|
+
if (seen.has(command.name)) throw new ProtocolError('[DUPLICATE_DECLARATION]', `${where} declares ${JSON.stringify(command.name)} twice`);
|
|
174
|
+
seen.add(command.name);
|
|
175
|
+
out.push(command);
|
|
176
|
+
}
|
|
177
|
+
return Object.freeze(out);
|
|
178
|
+
}
|
|
179
|
+
|
|
115
180
|
export function pluginLoadRequest(input) {
|
|
116
|
-
shape(input, ['pluginId', 'root', 'entry'], ['services', 'eventTopics', 'publishedTopics', 'llmProviders', 'tools', 'invokableTools', 'seats', 'config'], 'plugin/load payload');
|
|
181
|
+
shape(input, ['pluginId', 'root', 'entry'], ['services', 'eventTopics', 'publishedTopics', 'llmProviders', 'tools', 'commands', 'invokableTools', 'seats', 'config'], 'plugin/load payload');
|
|
117
182
|
return Object.freeze({
|
|
118
183
|
pluginId: validatePluginId(string(input.pluginId, 'pluginId')),
|
|
119
184
|
root: validateAbsolutePath('root', string(input.root, 'root')),
|
|
@@ -125,6 +190,10 @@ export function pluginLoadRequest(input) {
|
|
|
125
190
|
publishedTopics: declarations(input.publishedTopics, 'topic', 'publishedTopics'),
|
|
126
191
|
llmProviders: declarations(input.llmProviders, 'provider', 'llmProviders'),
|
|
127
192
|
tools: declarations(input.tools, 'tool', 'tools'),
|
|
193
|
+
// Command names only. What a command looks like in a menu comes with the
|
|
194
|
+
// registration; the name is the reviewable part, and the part that can
|
|
195
|
+
// collide with a built-in.
|
|
196
|
+
commands: declarations(input.commands, 'command', 'commands'),
|
|
128
197
|
// The one declared list with no registered counterpart: a plugin calls
|
|
129
198
|
// these rather than providing them, which is why its name says so.
|
|
130
199
|
invokableTools: declarations(input.invokableTools, 'tool', 'invokableTools'),
|
|
@@ -160,14 +229,41 @@ function toolDefinitions(value, where) {
|
|
|
160
229
|
return Object.freeze(out);
|
|
161
230
|
}
|
|
162
231
|
|
|
232
|
+
// What each adapter says about itself, keyed by the provider it serves. The
|
|
233
|
+
// values stay opaque — the wire layer decides only whether a route may be
|
|
234
|
+
// served, and `llmProviders` answers that — so the only rule here is that a
|
|
235
|
+
// description must belong to a route the same report claims.
|
|
236
|
+
function llmAdapters(value, providers, where) {
|
|
237
|
+
if (value === undefined) return Object.freeze({});
|
|
238
|
+
if (!plain(value)) throw new ProtocolError('[WRONG_SHAPE]', `${where} must be a plain object`);
|
|
239
|
+
const declared = new Set(providers);
|
|
240
|
+
for (const provider of Object.keys(value)) {
|
|
241
|
+
validateName('provider', provider);
|
|
242
|
+
if (!declared.has(provider)) {
|
|
243
|
+
throw new ProtocolError('[UNDECLARED_ADAPTER]', `adapter info describes provider ${JSON.stringify(provider)}, which this report does not serve`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return Object.freeze({ ...value });
|
|
247
|
+
}
|
|
248
|
+
|
|
163
249
|
export function pluginReadyReport(input) {
|
|
164
|
-
shape(input, ['pluginId'], ['services', 'eventTopics', 'llmProviders', 'tools'], 'plugin/load terminal payload');
|
|
250
|
+
shape(input, ['pluginId'], ['services', 'eventTopics', 'llmProviders', 'llmAdapters', 'tools', 'commands'], 'plugin/load terminal payload');
|
|
251
|
+
// Field order, not convenience order: a payload with two problems has to
|
|
252
|
+
// report the same one here and in Rust, and `llmAdapters` is checked against
|
|
253
|
+
// `llmProviders` so the providers have to be read first — but not before the
|
|
254
|
+
// fields Rust reads before them.
|
|
255
|
+
const pluginId = validatePluginId(string(input.pluginId, 'pluginId'));
|
|
256
|
+
const services = declarations(input.services, 'service', 'services');
|
|
257
|
+
const eventTopics = declarations(input.eventTopics, 'topic', 'eventTopics');
|
|
258
|
+
const providers = declarations(input.llmProviders, 'provider', 'llmProviders');
|
|
165
259
|
return Object.freeze({
|
|
166
|
-
pluginId
|
|
167
|
-
services
|
|
168
|
-
eventTopics
|
|
169
|
-
llmProviders:
|
|
260
|
+
pluginId,
|
|
261
|
+
services,
|
|
262
|
+
eventTopics,
|
|
263
|
+
llmProviders: providers,
|
|
264
|
+
llmAdapters: llmAdapters(input.llmAdapters, providers, 'llmAdapters'),
|
|
170
265
|
tools: toolDefinitions(input.tools, 'tools'),
|
|
266
|
+
commands: commandDefinitions(input.commands, 'commands'),
|
|
171
267
|
});
|
|
172
268
|
}
|
|
173
269
|
|
|
@@ -230,6 +326,29 @@ export function llmStreamRequest(input) {
|
|
|
230
326
|
return Object.freeze({ provider: validateName('provider', string(input.provider, 'provider')), request: input.request });
|
|
231
327
|
}
|
|
232
328
|
|
|
329
|
+
export function llmControlRequest(input) {
|
|
330
|
+
shape(input, ['provider', 'signal'], [], 'llm/control payload');
|
|
331
|
+
// Provider first, matching the Rust validator's field order.
|
|
332
|
+
const provider = validateName('provider', string(input.provider, 'provider'));
|
|
333
|
+
const signal = string(input.signal, 'signal');
|
|
334
|
+
if (!LLM_CONTROL_SIGNALS.includes(signal)) {
|
|
335
|
+
throw new ProtocolError('[UNKNOWN_LLM_SIGNAL]', `llm control signal ${JSON.stringify(signal)} is not one of ${LLM_CONTROL_SIGNALS.join(', ')}`);
|
|
336
|
+
}
|
|
337
|
+
return Object.freeze({ provider, signal });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function commandInvokeRequest(input) {
|
|
341
|
+
shape(input, ['name', 'raw', 'rest', 'surface'], [], 'command/invoke payload');
|
|
342
|
+
const name = validateName('command', string(input.name, 'name'));
|
|
343
|
+
const raw = string(input.raw, 'raw');
|
|
344
|
+
const rest = string(input.rest, 'rest');
|
|
345
|
+
const surface = string(input.surface, 'surface');
|
|
346
|
+
if (!COMMAND_SURFACES.includes(surface)) {
|
|
347
|
+
throw new ProtocolError('[WRONG_SHAPE]', `surface must be one of ${COMMAND_SURFACES.join(', ')}`);
|
|
348
|
+
}
|
|
349
|
+
return Object.freeze({ name, raw, rest, surface });
|
|
350
|
+
}
|
|
351
|
+
|
|
233
352
|
export function seatCallRequest(input) {
|
|
234
353
|
shape(input, ['seat', 'method', 'params'], [], 'seat/call payload');
|
|
235
354
|
return Object.freeze({
|
|
@@ -252,5 +371,7 @@ export const VALIDATORS = Object.freeze({
|
|
|
252
371
|
event_emit: eventEmitRequest,
|
|
253
372
|
tool_invoke: toolInvokeRequest,
|
|
254
373
|
llm_stream: llmStreamRequest,
|
|
374
|
+
llm_control: llmControlRequest,
|
|
375
|
+
command_invoke: commandInvokeRequest,
|
|
255
376
|
seat_call: seatCallRequest,
|
|
256
377
|
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Which entry a stray promise belonged to.
|
|
2
|
+
//
|
|
3
|
+
// Node does not stop this process for an unhandled rejection, and before T20
|
|
4
|
+
// nothing looked either: a plugin could start an async task, have it fail, and
|
|
5
|
+
// leave no trace anywhere rebon reads. T20 made it visible but unattributed,
|
|
6
|
+
// on the belief that a failed promise cannot say who created it.
|
|
7
|
+
//
|
|
8
|
+
// Measured on Node 24.19.0, it can. An `AsyncLocalStorage` store survives
|
|
9
|
+
// `setTimeout`, `queueMicrotask` and `await`, and the `unhandledRejection`
|
|
10
|
+
// handler reads it directly — so wrapping each `plugin/load` is enough, with no
|
|
11
|
+
// per-promise `async_hooks` and therefore no standing cost.
|
|
12
|
+
//
|
|
13
|
+
// **The store follows the async chain, not the clock**, and that distinction is
|
|
14
|
+
// the whole reason `loading` exists. A timer started during `apply` that
|
|
15
|
+
// rejects ten seconds later still carries its entry, long after that load
|
|
16
|
+
// returned. So there are two questions, not one:
|
|
17
|
+
//
|
|
18
|
+
// * whose is it — `entry`, always, whenever the chain reaches back;
|
|
19
|
+
// * does it fail the load — `loading`, true only while the load is in flight,
|
|
20
|
+
// because a load that already succeeded cannot be un-failed.
|
|
21
|
+
//
|
|
22
|
+
// The flag is flipped on the same object rather than by entering a second
|
|
23
|
+
// store: a new store would not be seen by promises already rooted in the first.
|
|
24
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
25
|
+
|
|
26
|
+
const owners = new AsyncLocalStorage();
|
|
27
|
+
|
|
28
|
+
/// Runs `fn` as `entry`'s work, with the load marked in flight for its
|
|
29
|
+
/// duration. Returns whatever `fn` returns; the flag is cleared either way,
|
|
30
|
+
/// because a load that threw is over too.
|
|
31
|
+
export async function asEntryLoad(entry, fn) {
|
|
32
|
+
const store = { entry, loading: true };
|
|
33
|
+
try {
|
|
34
|
+
const value = await owners.run(store, fn);
|
|
35
|
+
// One turn of the loop before deciding. `unhandledRejection` fires after
|
|
36
|
+
// the microtask queue drains, so a task this load started that failed
|
|
37
|
+
// immediately has not been reported yet at the moment `fn` resolves —
|
|
38
|
+
// checking without this yield would call a broken load a good one.
|
|
39
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
40
|
+
if (store.rejection !== undefined) {
|
|
41
|
+
throw new Error(`[ENTRY_FAILED] ${entry} left a rejection unhandled while loading: ${store.rejection}`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
} finally {
|
|
45
|
+
store.loading = false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// The entry whose async chain this code is on, or `undefined` outside one.
|
|
50
|
+
///
|
|
51
|
+
/// Undefined is normal and not a failure: a rejection can escape the chain
|
|
52
|
+
/// through an `EventEmitter`, a native callback or a third-party library, and
|
|
53
|
+
/// "could not be determined" is a truthful answer worth keeping distinct from a
|
|
54
|
+
/// wrong one.
|
|
55
|
+
export function currentOwner() {
|
|
56
|
+
return owners.getStore();
|
|
57
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/payload/rebon.exe
CHANGED
|
Binary file
|
|
Binary file
|