@byokit/openclaw 0.19.0 → 0.21.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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.21.0 (2026-10-19)
6
+
7
+ - FEAT: `run({ helperDelegates: { writer: ['report'] } })` lets a run hand work to the member's own helpers: the engine's native `sessions_send` to the exact key `agent:<the helper's agent>:h-<helper>:<rest>`, on an ordinary account or the plan-key lane, is admitted from its receipt as that helper's delegated run (`RunRef.helper`), so its app-tool calls cross the same gate, approvals and tool subset as any delegated child. `abort(rootSessionKey)` revokes them.
8
+ - SECURITY: a helper key is entered only on this path, never through `delegates` or a key prefix. A target that is not a helper key, a helper on another member's or account's agent, a helper never ensured, or one the run was not given is refused before dispatch with a typed `HelperError` code in the parent's tool result; a helper whose account is removed or rebound since admission ends its children's admission at their next gate or call, its unspent permits included.
9
+ - FIX: an OpenRouter API key with funds accepts and runs the model the person chooses (pay-as-you-go, no plan check). Before, `addKey` refused every `model` on the OpenRouter key route with `{ status: 'not_included', reason: 'model' }` (only the OpenCode Go route took a model) and `models()` listed nothing for it; now the key's own OpenRouter live catalog decides, `models()` lists it as `openrouter/<id>` (one prefix, and the engine's canonical id for a Google preview), and only the chosen model is registered with the engine.
10
+
11
+ ## 0.20.0 (2026-10-19)
12
+
13
+ - FEAT: `ensureHelper(member, helper, { account, persona })` gives a member several helpers, each with its own persona and its own sessions (`agent:<member>:h-<helper>:…`) on one account the member already signed in, the API-key lane included: runs go to the engine agent already holding that account, so no credential is copied and no sign-in starts. `run({ helper })` speaks as that helper with its persona as the system text; `RunRef.helper` names it on `ToolHost.gate` and `call`, and its approvals stay the member's.
14
+ - SECURITY: helper requests fail closed with a typed `HelperError` before any engine request: an invalid id or persona, caller `system` text on a helper run, an account the member does not hold, a removed account (never moved to another sign-in), a session key outside the helper, a helper key run without `helper`, or `move` of a helper key. A helper gains no tool, builtin or session-key prefix the member's run would not.
15
+
5
16
  ## 0.19.0 (2026-10-19)
6
17
 
7
18
  - FEAT: connect Composio through the pinned engine's native MCP OAuth flow, with browser URL handoff and native sign-out.
package/README.md CHANGED
@@ -174,11 +174,17 @@ for entry, checking, success and failure states, using the kit's `key.*` words.
174
174
  To use the saved key for one run, pass `auth: 'apiKey'`. The key lives in the member's separate
175
175
  `byokit-key-<member>` agent and workspace, with `copyToAgents: false` and only that key in its local auth order.
176
176
  List the route's models with `kit.models('ana', { authChoice: 'opencode-go' })` after a key is saved; this reads the
177
- live catalog. Before a key exists it returns `[]` and queries no agent. Only available key-lane
177
+ live catalog. Before a key exists it returns `[]` (a failed catalog probe with no saved key for the provider is read as
178
+ empty; with one it is an error) and runs no engine discovery. Only available key-lane
178
179
  models can be saved; the ordinary member's sign-ins never supply the list. Pass a full catalog id, e.g.
179
180
  `model: 'opencode-go/muse-spark-1.3-contributor'`, to `addKey` to save it as the lane's default after activation.
180
- Model listing and selection are Go-only: other key routes list no models, and a `model` on them returns `not_included`/`model`
181
- before activation.
181
+ Model listing and selection belong to the live-catalog routes, OpenCode Go and OpenRouter. An OpenRouter key is
182
+ pay-as-you-go with no plan, so it takes any model its own live catalog lists, named `openrouter/<id>`, e.g.
183
+ `model: 'openrouter/deepseek/deepseek-v4.1-flash-1017'`; an id already beginning with its own provider keeps one
184
+ prefix (`openrouter/auto`). A Google preview id is named and saved as the canonical id the engine keeps
185
+ (`openrouter/google/gemini-3.1-flash-lite-preview` becomes `openrouter/google/gemini-3.1-flash-lite`); the add
186
+ registers that one model with the engine. Other key routes list no models, and a `model` on them returns
187
+ `not_included`/`model` before activation.
182
188
  An unknown model returns `{ status: 'not_included', reason: 'model' }` and clears the replacement.
183
189
  Before success, `addKey` waits for catalog registration and prepares the new key agent's full-catalog owner once.
184
190
  For a live-catalog provider, a chosen model must also be in the engine's registered `models.providers[provider].models`
@@ -403,7 +409,7 @@ Entries:
403
409
 
404
410
  | Export | What it does |
405
411
  | --- | --- |
406
- | `OpenClawKit` (`.`) | Prepares, starts, supervises and stops the engine. `call` / `callDynamic` pass through to the Gateway, `onEvent` listens. Helpers: `ensureMember`, `routes`, `providers`, `signedIn`, `signIn`, `signOut`, `removeKey`, `migrateRetainedLogin`, `confirmRetainedLogin`, `toolNames`, `run`, `steer`, `abort`, `approvals`, `onApproval`, `decide`, `allowOnce`, `disallowOnce`, `patchConfig`, `getConfigKey`, `setConfigKey`, `memoryLimited`, `doctorContext` |
412
+ | `OpenClawKit` (`.`) | Prepares, starts, supervises and stops the engine. `call` / `callDynamic` pass through to the Gateway, `onEvent` listens. Helpers: `ensureMember`, `ensureHelper`, `adoptHelperSession`, `routes`, `providers`, `signedIn`, `signIn`, `signOut`, `removeKey`, `migrateRetainedLogin`, `confirmRetainedLogin`, `toolNames`, `run`, `steer`, `abort`, `approvals`, `onApproval`, `decide`, `allowOnce`, `disallowOnce`, `patchConfig`, `getConfigKey`, `setConfigKey`, `memoryLimited`, `doctorContext` |
407
413
  | `ENGINE_VERSION`, `PROTOCOL_VERSION`, `OPERATOR_SCOPES` (`.`) | The pinned engine version, its protocol and the operator scopes the kit connects with |
408
414
  | `KitOptions`, `RunSpec`, `RunEvent`, `RunEnd`, `RunUsage`, `PlanWindow`, `Route`, `Approval`, `Decision`, `ToolSpec`, `ToolHost`, `KitState`, ... (`.`) | Public types (docs/runtime-kits.md §5.2) |
409
415
  | `GatewayMethods`, `GatewayMethod`, `GatewayParams`, `GatewayResult`, `GatewayEventName`, `GatewayEventPayload` (`.`) | The generated pass-through tables, so an app can write its own generic wrapper over `call` / `onEvent` once |
@@ -442,11 +448,65 @@ durably revokes its recorded subtree, closes parked gates/active calls, then ask
442
448
  revokes its subtree. An engine resume with a different run ID, or a dispatch with no retained receipt, is not
443
449
  silently granted authority. No prefix grants or `allowOnce` bypass are used.
444
450
 
445
- **Per-helper account binding is not included:** targets retain their own existing engine credentials, persona,
446
- workspace and history. This option never copies credentials or gives helpers another person's account. Legacy
451
+ **Targets keep their own sign-in:** a target retains its own engine credentials, persona, workspace and history; a
452
+ member's own helpers run on the member's account (below). This option never copies credentials or gives helpers
453
+ another person's account. Legacy
447
454
  in-flight jobs may retain their exact old session keys and approval owners; all new helper work uses the new
448
455
  mapping. Never copy agent SQLite or replay old sent actions. See the [binding contract](../../docs/runtime-kits.md#native-delegated-tool-admission-opt-in).
449
456
 
457
+ ## Helpers on a member's account
458
+
459
+ A member can keep several helpers, each with its own persona and its own conversations, all on one account the
460
+ member already signed in. Nothing is copied and nobody signs in again: a helper's runs go to the engine agent that
461
+ already holds that account, under the helper's own session keys, with the helper's persona as the system text.
462
+
463
+ ```ts
464
+ import { OpenClawKit, HelperError } from '@byokit/openclaw';
465
+
466
+ declare const kit: OpenClawKit;
467
+ const research = await kit.ensureHelper('umer', 'research', { account: 'openai', persona: 'You are Umer\'s research helper.' });
468
+ // research.sessionPrefix === 'agent:umer:h-research:'
469
+ const end = await kit.run({ member: 'umer', helper: 'research', sessionKey: `${research.sessionPrefix}main`,
470
+ message: 'Summarise the notes' });
471
+ try {
472
+ await kit.run({ member: 'umer', helper: 'ghost', sessionKey: 'agent:umer:h-ghost:main', message: 'hi' });
473
+ } catch (e) {
474
+ if (e instanceof HelperError) console.log(e.code, end.ok); // 'unknown_helper', before any request
475
+ }
476
+ ```
477
+
478
+ `account` is one of `kit.accounts(member)`, the API-key lane (`'<provider>.key'`) included; a key-lane helper's run
479
+ names that account, as any paid run does. A helper's run is bound to its account (pick, rests and locks apply), and
480
+ `RunRef.helper` names it on `ToolHost.gate` and `call`; its approvals are the member's. The persona is the run's whole
481
+ system text: a helper run refuses `system`. Refusals are a typed `HelperError` before any engine request: `invalid`
482
+ (ids, persona, `system`), `unknown_account`, `bound` (another account), `unknown_helper` (not ensured, or its account
483
+ was removed: never moved to another sign-in) and `session` (a key outside the helper, a helper key without `helper`,
484
+ or `move` of one). Helpers share their account's engine agent (its id and workspace files); a helper gains no tool,
485
+ builtin or key prefix the member's run would not. See [Helpers](../../docs/runtime-kits.md#583-helpers).
486
+
487
+ A run hands work to the member's helpers with `helperDelegates: { writer: ['report'] }` (on the native delegated path
488
+ above, builtin gating on): the engine's own `sessions_send` to the exact key `agent:<writer's agent>:h-writer:<rest>`
489
+ (`timeoutSeconds: 0`) is admitted from its receipt as writer's run, `RunRef.helper: 'writer'` on every gate and call.
490
+ A target that is not a helper key, a helper on another account, one never ensured, or one the run was not given is
491
+ refused before dispatch; the parent's tool result reads `session: …`, `bound: …` or `unknown_helper: …`.
492
+ `abort(rootSessionKey)` revokes the children, and a helper whose account is removed loses them at their next call. The
493
+ child runs without the helper's persona (a native send carries no system text).
494
+
495
+ Conversations from before helpers (plain member sessions) move into a helper with
496
+ `kit.adoptHelperSession({ member, sessionKey }, helper)`: the whole transcript forks to
497
+ `agent:<member>:h-<helper>:<tail>` on the helper's account agent, the old session goes, and nothing is sent to the
498
+ model. The result is `move`'s `MoveResult` with `session` set to the helper key to run on. A conversation still in
499
+ flight (a live run, a parked approval or an admitted delegated child under it) is refused `busy` and keeps its exact
500
+ old key and approval owner until it settles, so adopt again later; a target key already taken is refused and leaves
501
+ both. A helper key, another member's key or a helper never ensured is a typed `HelperError` (`session`,
502
+ `unknown_helper`).
503
+
504
+ `kit.onEvent('byokit.delegated', fn)` follows each helper child start to finish: `admitted` (with `run.helper` and
505
+ `run.tools`, its app-tool subset), the engine's events, then one `ended` whose `reason` is `terminal`, `cancelled` (an
506
+ abort, or its admission ended), `error` (with `message`) or `resting`: the model's usage limit ended it, and the kit
507
+ rests the helper's account `until` the stated reset, as a kit run's resting end does. An approval raised by a helper's
508
+ run names it in `Approval.helper`.
509
+
450
510
  ## Owned browser (unqualified handoff)
451
511
 
452
512
  `browser: { executablePath, members }` opts into isolated member Chromium profiles. It requires a closed
@@ -1,6 +1,8 @@
1
1
  import { createLocks } from './locks.ts';
2
2
  import { type RouteView } from './routes.ts';
3
3
  import type { Account, AccountId, AccountPick, Defaults, GatewayTransport, Member, ModelInfo, MoveResult, Room, RunSelection, SignInOptions, SignInView } from './types.ts';
4
+ export declare const kitId: (provider: string, id: string) => string;
5
+ export declare const engineId: (provider: string, model: string, rows: readonly unknown[] | undefined) => string;
4
6
  export declare const modelsOf: (provider: string, answer: unknown) => ModelInfo[];
5
7
  export declare const modelsOfAccount: (row: Pick<Account, 'provider' | 'state'>, answer: unknown) => ModelInfo[];
6
8
  type IndexEntry = {
@@ -89,7 +91,7 @@ export declare function createAccounts(ctx: {
89
91
  agent?: string;
90
92
  }>;
91
93
  rest(member: Member, id: AccountId, until: number): Promise<void>;
92
- move(member: Member, sessionKey: string, to: AccountId): Promise<MoveResult>;
94
+ move(member: Member, sessionKey: string, to: AccountId, intoTail?: string): Promise<MoveResult>;
93
95
  remove(member: Member, id: AccountId): Promise<void>;
94
96
  };
95
97
  export {};
package/dist/accounts.js CHANGED
@@ -16,6 +16,21 @@ import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, keyAgentId, untilListed, untilUnl
16
16
  import { routes, routeFor } from './routes.js';
17
17
  import { words } from './words.js';
18
18
  const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
19
+ // A provider-local engine model id as the kit's `provider/model` reference; an id already prefixed with its own
20
+ // provider is left alone.
21
+ export const kitId = (provider, id) => providerOf(id) === provider.toLowerCase() ? id : `${provider}/${id}`;
22
+ // The engine's provider-local id for a kit `provider/model` reference, resolved against that provider's rows.
23
+ export const engineId = (provider, model, rows) => {
24
+ for (const row of rows ?? []) {
25
+ if (!record(row) || typeof row.id !== 'string')
26
+ continue;
27
+ if (row.provider !== undefined && String(row.provider).toLowerCase() !== provider.toLowerCase())
28
+ continue;
29
+ if (kitId(provider, row.id) === model)
30
+ return row.id;
31
+ }
32
+ return model.slice(model.indexOf('/') + 1);
33
+ };
19
34
  // `models.list { agentId, view: 'all' }` rows of the account's provider, mapped to `provider/model`. The pin
20
35
  // reports no per-plan availability, so `why: 'plan'` only comes from a run ending `plan`, never from here.
21
36
  export const modelsOf = (provider, answer) => {
@@ -27,7 +42,7 @@ export const modelsOf = (provider, answer) => {
27
42
  continue;
28
43
  if (typeof candidate.id !== 'string' || !candidate.id || typeof candidate.name !== 'string')
29
44
  continue;
30
- const id = candidate.id.includes('/') ? candidate.id : `${candidate.provider}/${candidate.id}`;
45
+ const id = kitId(candidate.provider, candidate.id);
31
46
  if (providerOf(id) !== provider.toLowerCase())
32
47
  continue;
33
48
  const available = candidate.available === undefined
@@ -792,8 +807,9 @@ export function createAccounts(ctx) {
792
807
  // before any lock. Same agent: nothing moves, the next run names the account. Otherwise, under both agents' locks and
793
808
  // on the row read again there, the source's whole transcript forks into `agent:<target>:<tail>` (never `chat.history`
794
809
  // plus `chat.inject`, which drop tool rows), then the source is deleted only if it is still the session read. A
795
- // refused fork leaves the source untouched; a refused delete leaves both, the new key live.
796
- async function move(member, sessionKey, to) {
810
+ // refused fork leaves the source untouched; a refused delete leaves both, the new key live. `intoTail` (a helper's
811
+ // adoption, 5.8.3) forks it under another tail on the target, the same agent included.
812
+ async function move(member, sessionKey, to, intoTail) {
797
813
  check(member);
798
814
  const failed = (code, message, live) => ({ ok: false, code, message, ...(live === undefined ? {} : { live }) });
799
815
  const { agentId: memberAgent } = await ctx.ensure(member);
@@ -801,7 +817,7 @@ export function createAccounts(ctx) {
801
817
  const { row, entry } = await entryOf(member, to);
802
818
  const tail = sessionKey.slice(sessionKey.indexOf(':', 'agent:'.length) + 1);
803
819
  const from = `agent:${source}:${tail}`;
804
- const into = `agent:${entry.agent}:${tail}`;
820
+ const into = `agent:${entry.agent}:${intoTail ?? tail}`;
805
821
  const read = async () => {
806
822
  const listed = await ctx.request('sessions.list', { agentId: source, search: from });
807
823
  const found = (Array.isArray(listed.sessions) ? listed.sessions : [])
@@ -816,7 +832,7 @@ export function createAccounts(ctx) {
816
832
  return first.refused;
817
833
  if (row.state !== 'ready')
818
834
  return failed('start_failed', words('move.notReady', { name: row.name }));
819
- if (entry.agent === source)
835
+ if (into === from)
820
836
  return { ok: true, session: into };
821
837
  return ctx.locks.exclusive([source, entry.agent], async () => {
822
838
  const again = await read();
@@ -829,13 +845,18 @@ export function createAccounts(ctx) {
829
845
  return failed('start_failed', words('move.taken', { name: row.name }));
830
846
  // The session's model when the target lists it, else the target's first available; the read takes no shared
831
847
  // hold (this move holds the target exclusively). With no answer the fork keeps the parent's own selection.
832
- const models = await ctx.request('models.list', { agentId: entry.agent, view: 'all' })
833
- .then((answer) => modelsOfAccount(row, answer), () => []);
848
+ const answer = await ctx.request('models.list', { agentId: entry.agent, view: 'all' }).catch(() => undefined);
849
+ const models = answer === undefined ? [] : modelsOfAccount(row, answer);
850
+ const sessionProvider = typeof session.modelProvider === 'string' ? session.modelProvider.toLowerCase() : undefined;
834
851
  const current = typeof session.model !== 'string' ? undefined
835
- : (typeof session.modelProvider === 'string' && !session.model.includes('/') ? `${session.modelProvider}/${session.model}` : session.model).toLowerCase();
852
+ : (sessionProvider === undefined ? session.model : kitId(sessionProvider, session.model)).toLowerCase();
836
853
  const model = models.find((m) => m.id.toLowerCase() === current)?.id ?? models.find((m) => m.available)?.id;
854
+ const rows = record(answer) && Array.isArray(answer.models) ? answer.models : [];
855
+ const local = model === undefined ? undefined : engineId(row.provider, model, rows);
856
+ const ref = model === undefined || local === undefined ? undefined
857
+ : providerOf(model) === row.provider ? `${row.provider}/${local}` : model;
837
858
  try {
838
- await ctx.request('sessions.create', { agentId: entry.agent, key: into, parentSessionKey: from, fork: true, ...(model ? { model } : {}) });
859
+ await ctx.request('sessions.create', { agentId: entry.agent, key: into, parentSessionKey: from, fork: true, ...(ref ? { model: ref } : {}) });
839
860
  }
840
861
  catch (error) {
841
862
  return failed('start_failed', (error instanceof Error ? error.message : String(error)).slice(0, 200));
package/dist/approvals.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Native approvals: exec.approval.*, plugin.approval.* and question.* surfaced through the same Approval shape (5.9, O5).
2
2
  import { randomBytes } from 'node:crypto';
3
+ import { helperOf } from './helpers.js';
3
4
  import { MEMBER_ID } from './members.js';
4
5
  const strip = (a) => {
5
6
  const { native, ...approval } = a;
@@ -27,7 +28,8 @@ function attribute(payload, memberOf) {
27
28
  const fromAgent = agent ? memberOf(agent) : undefined;
28
29
  const fromKey = key ? memberOf(key) : undefined;
29
30
  const member = fromAgent ?? fromKey ?? asString(detail.member) ?? '';
30
- return { member, ...(key ? { sessionKey: key } : {}) };
31
+ const helper = key ? helperOf(key) : undefined; // a helper's run asks as the member, owned by that helper (5.8.3)
32
+ return { member, ...(key ? { sessionKey: key } : {}), ...(helper ? { helper } : {}) };
31
33
  }
32
34
  /** Exec policy modes (`off|on-miss|always`) ride in `request.ask`; they are not something a person said (B8). */
33
35
  const POLICY_MODES = new Set(['off', 'on-miss', 'always']);
@@ -71,7 +73,7 @@ export class Approvals {
71
73
  const id = asString(payload.id) ?? randomBytes(12).toString('base64url');
72
74
  if (this.approvals.has(id))
73
75
  return;
74
- const { member, sessionKey } = attribute(payload, this.memberOf);
76
+ const { member, sessionKey, helper } = attribute(payload, this.memberOf);
75
77
  const detail = isRecord(payload.request) ? payload.request : payload;
76
78
  const now = Date.now();
77
79
  const approval = {
@@ -79,6 +81,7 @@ export class Approvals {
79
81
  source,
80
82
  member,
81
83
  ...(sessionKey ? { sessionKey } : {}),
84
+ ...(helper ? { helper } : {}),
82
85
  ...(asString(detail.tool) ?? asString(detail.toolName) ? { tool: (asString(detail.tool) ?? asString(detail.toolName)) } : {}),
83
86
  summary: summaryOf(source, payload),
84
87
  ...('questions' in detail ? { input: detail.questions } : {}),
package/dist/bridge.js CHANGED
@@ -5,6 +5,7 @@ import { createServer } from 'node:net';
5
5
  import { rm } from 'node:fs/promises';
6
6
  import { provider } from '@byokit/accounts';
7
7
  import { words } from './words.js';
8
+ import { HelperError } from './helpers.js';
8
9
  /** Newline-framed JSON, one request per connection; anything larger is not a gate request. */
9
10
  const FRAME_CAP = 1_000_000;
10
11
  /**
@@ -334,7 +335,8 @@ export class Bridge {
334
335
  this.deny(socket, 'invalid delegation message');
335
336
  }
336
337
  catch (error) {
337
- this.deny(socket, error instanceof Error ? error.message : 'delegation unavailable');
338
+ // A typed helper refusal (5.8.3) reaches the parent's tool result with its code.
339
+ this.deny(socket, error instanceof HelperError ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : 'delegation unavailable');
338
340
  }
339
341
  return;
340
342
  }
@@ -392,6 +394,7 @@ export class Bridge {
392
394
  source: 'gate',
393
395
  member: run.member,
394
396
  sessionKey: run.sessionKey,
397
+ ...(run.helper !== undefined ? { helper: run.helper } : {}),
395
398
  tool,
396
399
  summary: result.ask.summary,
397
400
  input,
@@ -441,6 +444,10 @@ export class Bridge {
441
444
  return this.reply(socket, { ok: false, reason: 'this call was not allowed' });
442
445
  const runId = typeof message.runId === 'string' ? message.runId : undefined;
443
446
  this.owners.set(socket, { key, runId });
447
+ // Resolved before the permit is spent: a delegated run whose admission ended (its helper removed) loses its permits.
448
+ const delegated = await this.delegations?.resolve(key, runId);
449
+ if (this.stopped || socket.destroyed)
450
+ return;
444
451
  const wanted = JSON.stringify(input);
445
452
  const permit = message.permit;
446
453
  if (typeof permit === 'string') {
@@ -462,9 +469,6 @@ export class Bridge {
462
469
  }
463
470
  if (!this.host)
464
471
  return this.reply(socket, { ok: false, reason: "can't run this action right now" });
465
- const delegated = await this.delegations?.resolve(key, runId);
466
- if (this.stopped || socket.destroyed)
467
- return;
468
472
  const run = delegated?.ref ?? this.runs.get(key) ?? { sessionKey: key, member: this.memberOf(key) ?? '' };
469
473
  // The plugin destroys its socket when the run aborts (run abort, hook timeout); without this the host's
470
474
  // sandboxed command keeps running after the tool is gone.
@@ -1,4 +1,5 @@
1
1
  export declare const ENGINE_VERSION = "2026.8.35";
2
2
  export declare const PROTOCOL_VERSION = 4;
3
3
  export declare const LIVE_CATALOGS: string[];
4
+ export declare const REGISTERED_CATALOGS: string[];
4
5
  export declare const OPERATOR_SCOPES: readonly ['operator.read', 'operator.write', 'operator.admin', 'operator.approvals', 'operator.questions', 'operator.pairing', 'operator.talk'];
package/dist/constants.js CHANGED
@@ -2,8 +2,12 @@
2
2
  // `needs-update`, never silently run.
3
3
  export const ENGINE_VERSION = '2026.8.35';
4
4
  export const PROTOCOL_VERSION = 4;
5
- // Providers whose live catalog the kit registers (5.6); key choice uses the same catalog hook.
6
- export const LIVE_CATALOGS = ['opencode-go'];
5
+ // Providers whose live catalog lists and checks a key's chosen model (5.6), through the catalog hook. OpenRouter is
6
+ // pay-as-you-go: its key takes any model its own catalog lists, with no plan to include it.
7
+ export const LIVE_CATALOGS = ['opencode-go', 'openrouter'];
8
+ // Live catalogs the kit registers whole. The engine canonicalizes Google preview ids on config write, so OpenRouter's
9
+ // list collides with itself (`google/x-preview` and `google/x`); an OpenRouter add registers only the chosen model.
10
+ export const REGISTERED_CATALOGS = ['opencode-go'];
7
11
  export const OPERATOR_SCOPES = [
8
12
  'operator.read',
9
13
  'operator.write',
@@ -3,6 +3,7 @@ type Grant = {
3
3
  ref: DelegatedRun;
4
4
  tools: string[];
5
5
  delegates: Record<string, string[]>;
6
+ helpers?: Record<string, string[]>;
6
7
  sourceKey: string;
7
8
  };
8
9
  export declare class Delegations {
@@ -16,6 +17,11 @@ export declare class Delegations {
16
17
  file: string;
17
18
  tools: ReadonlySet<string>;
18
19
  memberOf(agent: string): string | undefined;
20
+ helper(member: string, helper: string): {
21
+ agentId: string;
22
+ account: string;
23
+ };
24
+ rest(member: string, account: string, until: number): Promise<void>;
19
25
  request: GatewayTransport['request'];
20
26
  emit(e: DelegatedEvent): void;
21
27
  revoke(key: string, runId: string): void;
@@ -23,14 +29,16 @@ export declare class Delegations {
23
29
  private readonly o;
24
30
  private load;
25
31
  private save;
26
- validate(delegates: Record<string, string[]>): void;
27
- register(ref: RunRef, runId: string, tools: readonly string[] | undefined, delegates: Record<string, string[]>, sourceKey?: string): () => void;
32
+ validate(delegates: Record<string, string[]>, member?: string, helpers?: Record<string, string[]>): void;
33
+ register(ref: RunRef, runId: string, tools: readonly string[] | undefined, delegates: Record<string, string[]>, sourceKey?: string, helpers?: Record<string, string[]>): () => void;
28
34
  list(): DelegatedRun[];
29
35
  has(key: string, runId: string): boolean;
30
36
  prepare(m: Record<string, any>): boolean;
31
37
  receipt(m: Record<string, any>): void;
32
38
  resolve(key: string, runId?: string): Promise<Grant | undefined>;
39
+ private bound;
33
40
  event(event: Record<string, any>): void;
41
+ private ended;
34
42
  private end;
35
43
  reconcile(): Promise<void>;
36
44
  abort(key: string): Promise<void>;
@@ -1,6 +1,10 @@
1
1
  // Native receipts, never session-prefix authority. Persist identity/tool policy only, not prompts or credentials.
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { atomic } from './engine-patches.js';
4
+ import { classify } from './classify.js';
5
+ import { HELPER_ID, HelperError, helperOf } from './helpers.js';
6
+ // A resting end whose message names no time rests the account this long, as a kit run's does (runs.ts).
7
+ const REST_MS = 300_000;
4
8
  const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
5
9
  const agentOf = (key) => /^agent:([^:]+):.+/.exec(key)?.[1];
6
10
  const actionKey = (m) => JSON.stringify([m.key, m.runId, m.callId]);
@@ -34,7 +38,7 @@ export class Delegations {
34
38
  for (const notify of this.changed)
35
39
  notify();
36
40
  }
37
- validate(delegates) {
41
+ validate(delegates, member, helpers) {
38
42
  if (!object(delegates) || Object.keys(delegates).length > 64)
39
43
  throw new Error('invalid delegates');
40
44
  for (const [agent, tools] of Object.entries(delegates)) {
@@ -42,12 +46,21 @@ export class Delegations {
42
46
  || tools.some(t => !this.o.tools.has(t)))
43
47
  throw new Error(`invalid delegate: ${agent}`);
44
48
  }
49
+ if (helpers === undefined)
50
+ return;
51
+ if (!object(helpers) || Object.keys(helpers).length > 64)
52
+ throw new HelperError('invalid', 'invalid helper delegates');
53
+ for (const [helper, tools] of Object.entries(helpers)) {
54
+ if (!HELPER_ID.test(helper) || !Array.isArray(tools) || tools.some(t => !this.o.tools.has(t)))
55
+ throw new HelperError('invalid', `invalid helper delegate: ${helper}`);
56
+ this.o.helper(member, helper); // never ensured, or its account gone: unknown_helper
57
+ }
45
58
  }
46
- register(ref, runId, tools, delegates, sourceKey = ref.sessionKey) {
59
+ register(ref, runId, tools, delegates, sourceKey = ref.sessionKey, helpers) {
47
60
  this.load();
48
- this.validate(delegates);
61
+ this.validate(delegates, ref.member, helpers);
49
62
  const root = { ref: { ...ref, runId, root: { sessionKey: ref.sessionKey, runId } },
50
- tools: [...tools ?? this.o.tools], delegates: structuredClone(delegates), sourceKey };
63
+ tools: [...tools ?? this.o.tools], delegates: structuredClone(delegates), ...(helpers ? { helpers: structuredClone(helpers) } : {}), sourceKey };
51
64
  this.roots.set(runId, root);
52
65
  return () => { if (this.roots.get(runId) === root)
53
66
  this.roots.delete(runId); };
@@ -68,17 +81,30 @@ export class Delegations {
68
81
  if (!send && m.tool !== 'sessions_spawn')
69
82
  throw new Error('unsupported delegation tool');
70
83
  const agent = send ? typeof m.input.sessionKey === 'string' && agentOf(m.input.sessionKey) : m.input.agentId;
71
- if (typeof agent !== 'string' || !Object.hasOwn(parent.delegates, agent)
72
- || (send && (m.input.timeoutSeconds !== 0 || m.input.label !== undefined))
84
+ // A helper's session is entered only on the helper path, so a run naming the helper always speaks there (5.8.3).
85
+ const helper = send && typeof agent === 'string' ? helperOf(m.input.sessionKey) : undefined;
86
+ if ((send && (m.input.timeoutSeconds !== 0 || m.input.label !== undefined))
73
87
  || (!send && m.input.runtime !== undefined && m.input.runtime !== 'subagent'))
74
88
  throw new Error('delegation target or mode refused');
89
+ if (helper !== undefined) {
90
+ const bound = this.o.helper(parent.ref.member, helper).agentId; // never ensured: unknown_helper
91
+ if (bound !== agent)
92
+ throw new HelperError('bound', `refused: helper ${helper} runs on another account`);
93
+ if (!Object.hasOwn(parent.helpers ?? {}, helper))
94
+ throw new HelperError('session', `refused: this run may not hand work to helper ${helper}`);
95
+ }
96
+ else if (typeof agent !== 'string' || !Object.hasOwn(parent.delegates, agent)) {
97
+ if (Object.keys(parent.helpers ?? {}).length)
98
+ throw new HelperError('session', `refused: "${String(m.input.sessionKey ?? agent)}" is not a helper session`);
99
+ throw new Error('delegation target or mode refused');
100
+ }
75
101
  for (const [id, p] of this.pending)
76
102
  if (p.expires < Date.now())
77
103
  this.pending.delete(id);
78
104
  if (this.pending.size + this.children.size >= 1024)
79
105
  throw new Error('delegated run journal is full');
80
- this.pending.set(actionKey(m), { parent: structuredClone(parent), agent,
81
- ...(send ? { key: m.input.sessionKey } : {}), expires: Date.now() + 60_000 });
106
+ this.pending.set(actionKey(m), { parent: structuredClone(parent), agent: agent,
107
+ ...(send ? { key: m.input.sessionKey } : {}), ...(helper !== undefined ? { helper } : {}), expires: Date.now() + 60_000 });
82
108
  this.save();
83
109
  return true;
84
110
  }
@@ -96,15 +122,20 @@ export class Delegations {
96
122
  this.save();
97
123
  return;
98
124
  }
99
- const member = this.o.memberOf(p.agent);
125
+ // A helper child is the parent member's, attributed to that helper (RunRef.helper), on the helper's own agent.
126
+ const member = p.helper !== undefined ? (this.bound(p.parent.ref.member, p.helper, p.agent) ? p.parent.ref.member : undefined)
127
+ : this.o.memberOf(p.agent);
100
128
  if (!member) {
101
129
  this.save();
102
130
  return;
103
131
  }
104
- const ref = { sessionKey: key, member, runId: d.runId, agentId: p.agent,
105
- parent: { sessionKey: p.parent.ref.sessionKey, runId: p.parent.ref.runId }, root: p.parent.ref.root };
106
- const grant = { ref, delegates: p.parent.delegates, sourceKey: p.parent.sourceKey,
107
- tools: p.parent.tools.filter(t => p.parent.delegates[p.agent].includes(t)) };
132
+ const allowed = p.helper !== undefined ? p.parent.helpers[p.helper] : p.parent.delegates[p.agent];
133
+ const tools = p.parent.tools.filter(t => allowed.includes(t));
134
+ const ref = { sessionKey: key, member, ...(p.helper !== undefined ? { helper: p.helper } : {}), runId: d.runId, agentId: p.agent,
135
+ parent: { sessionKey: p.parent.ref.sessionKey, runId: p.parent.ref.runId }, root: p.parent.ref.root, tools };
136
+ const grant = { ref, delegates: p.parent.delegates,
137
+ ...(p.parent.helpers && ref.member === p.parent.ref.member ? { helpers: p.parent.helpers } : {}),
138
+ sourceKey: p.parent.sourceKey, tools };
108
139
  // A steered/already-admitted run keeps its original parent and cannot acquire a broader grant.
109
140
  if (this.children.has(ref.runId)) {
110
141
  this.save();
@@ -132,7 +163,21 @@ export class Delegations {
132
163
  check();
133
164
  });
134
165
  }
135
- return get();
166
+ const grant = get();
167
+ // A helper removed or rebound since admission ends its runs' admission (5.8.3: never moved to another sign-in).
168
+ if (grant?.ref.helper !== undefined && !this.bound(grant.ref.member, grant.ref.helper, grant.ref.agentId)) {
169
+ this.end(grant, { reason: 'cancelled' });
170
+ return undefined;
171
+ }
172
+ return grant;
173
+ }
174
+ bound(member, helper, agent) {
175
+ try {
176
+ return this.o.helper(member, helper).agentId === agent;
177
+ }
178
+ catch {
179
+ return false;
180
+ }
136
181
  }
137
182
  event(event) {
138
183
  this.load();
@@ -150,20 +195,40 @@ export class Delegations {
150
195
  return;
151
196
  this.o.emit({ type: 'event', run: structuredClone(grant.ref), event: { stream: event.stream, data: event.data } });
152
197
  if (event.stream === 'lifecycle' && ['end', 'error'].includes(event.data?.phase))
153
- this.end(grant);
198
+ void this.ended(grant, event.data);
199
+ }
200
+ // The engine's lifecycle end, mapped: an abort is `cancelled`; an error is `error`, or `resting` when the model's limit
201
+ // ended a helper's run, which rests the helper's account as a kit run's resting end does (5.15 Runs step 7).
202
+ async ended(grant, data) {
203
+ if (data.aborted === true)
204
+ return this.end(grant, { reason: 'cancelled' });
205
+ if (data.phase !== 'error')
206
+ return this.end(grant, { reason: 'terminal' });
207
+ const message = typeof data.error === 'string' ? data.error : '';
208
+ const failed = classify(message);
209
+ const { member, helper } = grant.ref;
210
+ if (failed.kind !== 'resting' || helper === undefined)
211
+ return this.end(grant, { reason: 'error', message });
212
+ this.children.delete(grant.ref.runId); // ended now: a later gate or event finds no admission
213
+ const until = failed.until ?? Date.now() + REST_MS;
214
+ try {
215
+ await this.o.rest(member, this.o.helper(member, helper).account, until);
216
+ }
217
+ catch { /* its account is gone: the run still ended resting */ }
218
+ this.end(grant, { reason: 'resting', until, message });
154
219
  }
155
- end(grant) {
220
+ end(grant, end) {
156
221
  this.children.delete(grant.ref.runId);
157
222
  this.save();
158
223
  this.o.revoke(grant.ref.sessionKey, grant.ref.runId);
159
- this.o.emit({ type: 'ended', run: structuredClone(grant.ref), reason: 'terminal' });
224
+ this.o.emit({ type: 'ended', run: structuredClone(grant.ref), ...end });
160
225
  }
161
226
  async reconcile() {
162
227
  for (const ref of this.list()) {
163
228
  const end = await this.o.request('agent.wait', { runId: ref.runId, timeoutMs: 1 });
164
229
  const grant = this.children.get(ref.runId);
165
230
  if (grant && ['ok', 'error'].includes(end.status ?? ''))
166
- this.end(grant);
231
+ this.end(grant, { reason: end.status === 'ok' ? 'terminal' : 'error' });
167
232
  }
168
233
  }
169
234
  async abort(key) {
@@ -0,0 +1,28 @@
1
+ import { type Binding } from './accounts.ts';
2
+ import type { Account, AccountId, Helper, Member } from './types.ts';
3
+ export declare const HELPER_ID: RegExp;
4
+ /** A session key tail of some helper: only a run naming that helper speaks there. */
5
+ export declare const HELPER_TAIL: RegExp;
6
+ /** The helper an engine key `agent:<agent>:h-<helper>:<rest>` belongs to, from its tail; undefined for any other key. */
7
+ export declare const helperOf: (key: string) => string | undefined;
8
+ export type HelperErrorCode = 'invalid' | 'unknown_account' | 'bound' | 'unknown_helper' | 'session';
9
+ /** A refused helper request, before any run: `code` says which rule refused it. */
10
+ export declare class HelperError extends Error {
11
+ readonly code: HelperErrorCode;
12
+ constructor(code: HelperErrorCode, message: string);
13
+ }
14
+ export declare function createHelpers(ctx: {
15
+ root: string;
16
+ list(member: Member): Promise<Account[]>;
17
+ }): {
18
+ ensure(member: Member, helper: string, o: {
19
+ account: AccountId;
20
+ persona: string;
21
+ }): Promise<Helper>;
22
+ resolve(member: Member, helper: string): {
23
+ account: AccountId;
24
+ agentId: string;
25
+ binding: Binding;
26
+ persona: string;
27
+ };
28
+ };