@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 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 sess-0-1712937600000
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rebon/cli-win32-x64",
3
- "version": "0.23.2",
3
+ "version": "1.0.0",
4
4
  "description": "Agent cli for coding and more.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "engines": {
@@ -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
- const ctx = calls.getStore();
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 { eventDelivery, eventEmitRequest, eventSubscribeRequest, llmStreamRequest, pluginLoadRequest, pluginUnloadRequest, seatCallRequest, serviceCallRequest, toolInvokeRequest } from './methods.mjs';
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
- return { run: async () => {
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
- llm: (provider, adapter) => this.#admit('provider', provider, adapter, this.#declaredProviders, this.#adapters),
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: validatePluginId(string(input.pluginId, 'pluginId')),
167
- services: declarations(input.services, 'service', 'services'),
168
- eventTopics: declarations(input.eventTopics, 'topic', 'eventTopics'),
169
- llmProviders: declarations(input.llmProviders, 'provider', '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