@byokit/openclaw 0.20.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,12 @@
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
+
5
11
  ## 0.20.0 (2026-10-19)
6
12
 
7
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.
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`, `ensureHelper`, `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 |
@@ -478,6 +484,29 @@ was removed: never moved to another sign-in) and `session` (a key outside the he
478
484
  or `move` of one). Helpers share their account's engine agent (its id and workspace files); a helper gains no tool,
479
485
  builtin or key prefix the member's run would not. See [Helpers](../../docs/runtime-kits.md#583-helpers).
480
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
+
481
510
  ## Owned browser (unqualified handoff)
482
511
 
483
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) {
package/dist/helpers.d.ts CHANGED
@@ -3,6 +3,8 @@ import type { Account, AccountId, Helper, Member } from './types.ts';
3
3
  export declare const HELPER_ID: RegExp;
4
4
  /** A session key tail of some helper: only a run naming that helper speaks there. */
5
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;
6
8
  export type HelperErrorCode = 'invalid' | 'unknown_account' | 'bound' | 'unknown_helper' | 'session';
7
9
  /** A refused helper request, before any run: `code` says which rule refused it. */
8
10
  export declare class HelperError extends Error {
@@ -18,6 +20,7 @@ export declare function createHelpers(ctx: {
18
20
  persona: string;
19
21
  }): Promise<Helper>;
20
22
  resolve(member: Member, helper: string): {
23
+ account: AccountId;
21
24
  agentId: string;
22
25
  binding: Binding;
23
26
  persona: string;
package/dist/helpers.js CHANGED
@@ -9,6 +9,8 @@ import { KEY_PREFIX, MEMBER_ID } from './members.js';
9
9
  export const HELPER_ID = /^[a-z][a-z0-9-]{0,15}$/;
10
10
  /** A session key tail of some helper: only a run naming that helper speaks there. */
11
11
  export const HELPER_TAIL = /^h-[a-z][a-z0-9-]{0,15}:/;
12
+ /** The helper an engine key `agent:<agent>:h-<helper>:<rest>` belongs to, from its tail; undefined for any other key. */
13
+ export const helperOf = (key) => /^agent:[^:]+:h-([a-z][a-z0-9-]{0,15}):/.exec(key)?.[1];
12
14
  const PERSONA_MAX = 8192;
13
15
  /** A refused helper request, before any run: `code` says which rule refused it. */
14
16
  export class HelperError extends Error {
@@ -63,7 +65,7 @@ export function createHelpers(ctx) {
63
65
  const entry = saved && loadIndex(ctx.root, member).file.accounts[saved.account];
64
66
  if (!saved || entry?.agent !== saved.agent)
65
67
  throw new HelperError('unknown_helper', `unknown helper: ${String(helper)}`);
66
- return { agentId: saved.agent, persona: saved.persona,
68
+ return { account: saved.account, agentId: saved.agent, persona: saved.persona,
67
69
  binding: saved.agent === member ? { agentId: member } : { agentId: saved.agent, id: saved.account } };
68
70
  },
69
71
  };
package/dist/keys.d.ts CHANGED
@@ -37,9 +37,11 @@ export declare function createKeys(ctx: ActivationCtx & {
37
37
  indexLane(member: Member, route: RouteView): Promise<void>;
38
38
  dropLane(member: Member, provider?: string): Promise<void>;
39
39
  backfillLane(member: Member, model: string | undefined): Promise<void>;
40
- /** The provider config headers a live-catalog key's calls and probes must carry (`null` clears them),
40
+ /** The provider config headers a Go key's calls and probes must carry (`null` clears them),
41
41
  * written through the kit's own config patch. The Go workspace header is the one user. */
42
42
  setProviderHeaders(provider: string, headers: Record<string, string> | null): Promise<void>;
43
+ /** Adds catalog rows to the engine's `models.providers[provider].models`, serialized with catalog sync. */
44
+ registerModels(provider: string, rows: Record<string, unknown>[]): Promise<void>;
43
45
  }): {
44
46
  models: (member: Member, o: {
45
47
  authChoice: string;
package/dist/keys.js CHANGED
@@ -4,8 +4,8 @@ import { openCodeWorkspaces } from '@byokit/accounts/opencode-go';
4
4
  import { activate } from './activation.js';
5
5
  import { keyAgentId, MEMBER_ID, KEY_PREFIX, untilListed } from './members.js';
6
6
  import { providerOf } from './pick.js';
7
- import { modelsOf } from './accounts.js';
8
- import { LIVE_CATALOGS } from './constants.js';
7
+ import { kitId, modelsOf } from './accounts.js';
8
+ import { LIVE_CATALOGS, REGISTERED_CATALOGS } from './constants.js';
9
9
  export function createKeys(ctx) {
10
10
  // Serialize key replacement and key runs, so an in-flight run never sees a half-installed replacement.
11
11
  const tails = new Map();
@@ -47,7 +47,7 @@ export function createKeys(ctx) {
47
47
  };
48
48
  const registered = async (provider, model) => {
49
49
  const { config } = await ctx.request('config.get');
50
- return Boolean(config?.models?.providers?.[provider]?.models?.some((m) => `${provider}/${m.id}` === model));
50
+ return Boolean(config?.models?.providers?.[provider]?.models?.some((m) => kitId(provider, m.id) === model));
51
51
  };
52
52
  const errorReason = (error, sent) => {
53
53
  if (transient(error))
@@ -58,19 +58,46 @@ export function createKeys(ctx) {
58
58
  return ['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT', 'CLIENT_TIMEOUT', 'NETWORK_ERROR'].includes(e?.code ?? '')
59
59
  || e?.name === 'TimeoutError' ? 'network' : 'engine';
60
60
  };
61
+ // The engine writes a Google preview id without its `-preview` suffix, so the kit names the id it keeps.
62
+ const canonical = (id) => id.startsWith('google/') && id.endsWith('-preview')
63
+ ? id.slice(0, -'-preview'.length) : id;
64
+ const canonicalModel = (model) => {
65
+ const at = model.indexOf('/');
66
+ return at < 0 ? model : `${model.slice(0, at)}/${canonical(model.slice(at + 1))}`;
67
+ };
68
+ // The live catalog's rows, as the engine registers them (provider-local ids, deduped by the id it keeps).
69
+ const catalog = async (member, provider) => {
70
+ await ctx.ensure(member);
71
+ const answer = await ctx.request('byokit.catalog', { provider });
72
+ if (!answer.ok) {
73
+ if (answer.reason !== 'no-key')
74
+ throw new Error('Model catalog is unavailable.');
75
+ return [];
76
+ }
77
+ const rows = (answer.models ?? []).map((m) => ({ ...m, id: canonical(String(m.id)) }));
78
+ return [...new Map(rows.map((m) => [String(m.id), m])).values()];
79
+ };
80
+ const listed = (provider, rows) => modelsOf(provider, { models: rows.map((m) => ({ ...m, provider })) });
61
81
  const models = async (member, o) => {
62
82
  if (!check(member))
63
83
  throw new Error('Choose your own member.');
64
84
  const route = ctx.routes().find((r) => r.choice === o.authChoice && r.keyEntry && r.readiness === 'ready');
65
85
  if (!route || !LIVE_CATALOGS.includes(route.provider))
66
86
  return [];
67
- await ctx.ensure(member);
68
- const catalog = await ctx.request('byokit.catalog', { provider: route.provider });
69
- if (catalog.ok)
70
- return modelsOf(route.provider, { models: catalog.models?.map((m) => ({ ...m, provider: route.provider })) });
71
- if (catalog.reason !== 'no-key')
72
- throw new Error('Model catalog is unavailable.');
73
- return [];
87
+ try {
88
+ return listed(route.provider, await catalog(member, route.provider));
89
+ }
90
+ catch (error) {
91
+ // The engine answers `failed` when no agent holds a key: a lane without the provider's profile lists nothing,
92
+ // a saved one is a real failure.
93
+ const held = await ctx.request('models.authStatus', { agentId: keyAgentId(member) }).then((answer) => {
94
+ const rows = answer.providers ?? [];
95
+ return rows.some((row) => (typeof row === 'string' ? row : row?.provider) === route.provider);
96
+ }, () => undefined);
97
+ if (held !== false)
98
+ throw error;
99
+ return [];
100
+ }
74
101
  };
75
102
  return {
76
103
  models,
@@ -87,13 +114,14 @@ export function createKeys(ctx) {
87
114
  const route = ctx.routes().find((r) => r.choice === o.authChoice && r.keyEntry && r.readiness === 'ready');
88
115
  if (!route || (o.model !== undefined && !LIVE_CATALOGS.includes(route.provider)))
89
116
  return failure('not_included', 'model');
90
- // A workspace names a live-catalog login's own workspace only; another route takes no header, and a
117
+ const model = o.model === undefined ? undefined : canonicalModel(o.model);
118
+ // A workspace names a Go login's own workspace only; another route takes no header, and a
91
119
  // refusal here leaves the member's lanes untouched (it lands before any prepare or activation).
92
120
  let orgId = typeof o.orgId === 'string' ? o.orgId.trim() : '';
93
121
  if (orgId) {
94
122
  if (orgId.length > 256)
95
123
  return failure('invalid', 'auth');
96
- if (!LIVE_CATALOGS.includes(route.provider))
124
+ if (route.provider !== 'opencode-go')
97
125
  return failure('not_included', 'model');
98
126
  }
99
127
  else if (route.provider === 'opencode-go') {
@@ -157,7 +185,7 @@ export function createKeys(ctx) {
157
185
  await ctx.setProviderHeaders(route.provider, { 'x-opencode-org-id': orgId });
158
186
  wroteHeader = true;
159
187
  }
160
- else if (LIVE_CATALOGS.includes(route.provider)) {
188
+ else if (route.provider === 'opencode-go') {
161
189
  await ctx.setProviderHeaders(route.provider, null);
162
190
  wroteHeader = true;
163
191
  }
@@ -172,7 +200,8 @@ export function createKeys(ctx) {
172
200
  return abandon(probeResult(result.status));
173
201
  if (result.late)
174
202
  return abandon(failure('retry', 'busy'));
175
- if (o.model !== undefined && !(await models(member, o)).some((m) => m.id === o.model && m.available))
203
+ const rows = model === undefined ? [] : await catalog(member, route.provider);
204
+ if (model !== undefined && !listed(route.provider, rows).some((m) => m.id === model && m.available))
176
205
  return abandon(failure('not_included', 'model'));
177
206
  // Prepare before publishing the ready marker: failed preparation remains unusable even if clear fails.
178
207
  try {
@@ -183,9 +212,12 @@ export function createKeys(ctx) {
183
212
  }
184
213
  if (ac.signal.aborted)
185
214
  return abandon(cancelled());
186
- if (o.model !== undefined && LIVE_CATALOGS.includes(route.provider) && !(await registered(route.provider, o.model)))
215
+ // A live catalog the kit does not register whole (OpenRouter) registers the chosen model alone.
216
+ if (model !== undefined && !REGISTERED_CATALOGS.includes(route.provider))
217
+ await ctx.registerModels(route.provider, rows.filter((m) => kitId(route.provider, String(m.id)) === model)).catch(() => { });
218
+ if (model !== undefined && LIVE_CATALOGS.includes(route.provider) && !(await registered(route.provider, model)))
187
219
  return abandon(failure('retry', 'model'));
188
- if (!(await control(member, 'seal', undefined, o.model)).ok)
220
+ if (!(await control(member, 'seal', undefined, model)).ok)
189
221
  return abandon(failure('retry', 'engine'));
190
222
  await ctx.indexLane(member, route);
191
223
  return { status: 'ok' };
@@ -241,7 +273,7 @@ export function createKeys(ctx) {
241
273
  if (cleared?.ok !== true && cleared?.absent !== true)
242
274
  refused();
243
275
  // A workspace header the removed key named must not outlive it; the next add names its own (or none).
244
- if (o.provider !== undefined && LIVE_CATALOGS.includes(o.provider))
276
+ if (o.provider === 'opencode-go')
245
277
  await ctx.setProviderHeaders(o.provider, null).catch(() => { throw new Error('The key could not be removed.'); });
246
278
  await ctx.dropLane(member, o.provider);
247
279
  });
package/dist/kit.d.ts CHANGED
@@ -135,6 +135,11 @@ export declare class OpenClawKit {
135
135
  account: AccountId;
136
136
  persona: string;
137
137
  }): Promise<Helper>;
138
+ /** Adopt a legacy conversation of the member into one of its helpers (5.8.3, S4): its whole transcript forks to
139
+ * `agent:<member>:h-<helper>:<tail>` on the helper's account agent (as `move` does), then the old session goes.
140
+ * One still in flight (a live run, a parked approval or an admitted delegated child under it) is refused `busy`
141
+ * and keeps its exact old key until it settles; nothing is replayed and no approval changes owner. */
142
+ adoptHelperSession(ref: RunRef, helper: string): Promise<MoveResult>;
138
143
  private signInCtx;
139
144
  routes(): RouteView[];
140
145
  /** Start Composio's hosted MCP OAuth flow through the pinned engine; return the URL for the app to open. */
@@ -214,6 +219,7 @@ export declare class OpenClawKit {
214
219
  private backfillKeyLane;
215
220
  private prepareKeyOwner;
216
221
  private syncCatalogs;
222
+ private registerModels;
217
223
  migrateRetainedLogin(member: Member, source: RetainedLogin): Promise<'staged' | 'nothing' | 'failed'>;
218
224
  confirmRetainedLogin(member: Member, source: RetainedLogin): Promise<boolean>;
219
225
  private runs;
package/dist/kit.js CHANGED
@@ -5,7 +5,7 @@ import { join } from 'node:path';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { isDeepStrictEqual } from 'node:util';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { PROTOCOL_VERSION, LIVE_CATALOGS } from './constants.js';
8
+ import { PROTOCOL_VERSION, REGISTERED_CATALOGS } from './constants.js';
9
9
  import { atomic } from './engine-patches.js';
10
10
  import { Approvals } from './approvals.js';
11
11
  import { Bridge } from './bridge.js';
@@ -22,6 +22,7 @@ import { confirmRetainedLogin as confirmLogin, migrateRetainedLogin as migrateLo
22
22
  import { createRuns, RunReplay, signedInProviders } from './runs.js';
23
23
  import { authStatus, createAuthStatus } from './auth-status.js';
24
24
  import { outputSchema } from './output.js';
25
+ import { words } from './words.js';
25
26
  import { createKeys } from './keys.js';
26
27
  import { routes as routeTable } from './routes.js';
27
28
  import { installedPlugins, pinnedSpec, recordPlugin, PluginInstallError } from './plugins.js';
@@ -699,7 +700,12 @@ export class OpenClawKit {
699
700
  ensure: (member) => this.ensureMember(member),
700
701
  authConfig: () => this.authSnapshot(),
701
702
  restarted: (authBefore, signal) => this.restarted(authBefore, signal),
702
- setProviderHeaders: (provider, headers) => this.patchConfig({ models: { providers: { [provider]: { headers: headers ?? null } } } }) });
703
+ setProviderHeaders: (provider, headers) => this.patchConfig({ models: { providers: { [provider]: { headers: headers ?? null } } } }),
704
+ registerModels: (provider, rows) => {
705
+ const done = this.catalogs.then(() => this.registerModels(provider, rows));
706
+ this.catalogs = done.catch(() => { });
707
+ return done;
708
+ } });
703
709
  const slot = {};
704
710
  this.approvalsCtl = new Approvals({
705
711
  request: (method, params, co) => this.request()(method, params, co),
@@ -726,6 +732,16 @@ export class OpenClawKit {
726
732
  } : o.host;
727
733
  this.delegations = new Delegations({ file: join(this.engine.root, 'delegated-runs.json'), tools: new Set(this.toolNames()),
728
734
  memberOf: agent => this.memberOf(agent), request: (method, params, options) => this.request()(method, params, options),
735
+ helper: (member, helper) => {
736
+ if (!this.helpersCtl)
737
+ throw new Error('gateway not ready');
738
+ return this.helpersCtl.resolve(member, helper);
739
+ },
740
+ rest: async (member, account, until) => {
741
+ if (!this.accountsCtl)
742
+ throw new Error('gateway not ready');
743
+ await this.accountsCtl.rest(member, account, until);
744
+ },
729
745
  emit: payload => { for (const fn of this.listeners)
730
746
  fn({ event: 'byokit.delegated', payload }); },
731
747
  revoke: (key, runId) => this.bridge.revoke(key, runId) });
@@ -1077,6 +1093,25 @@ export class OpenClawKit {
1077
1093
  return Promise.reject(new Error('gateway not ready'));
1078
1094
  return this.helpersCtl.ensure(member, helper, o);
1079
1095
  }
1096
+ /** Adopt a legacy conversation of the member into one of its helpers (5.8.3, S4): its whole transcript forks to
1097
+ * `agent:<member>:h-<helper>:<tail>` on the helper's account agent (as `move` does), then the old session goes.
1098
+ * One still in flight (a live run, a parked approval or an admitted delegated child under it) is refused `busy`
1099
+ * and keeps its exact old key until it settles; nothing is replayed and no approval changes owner. */
1100
+ async adoptHelperSession(ref, helper) {
1101
+ if (!this.accountsCtl || !this.helpersCtl)
1102
+ throw new Error('gateway not ready');
1103
+ const tailOf = (key) => key.slice(key.indexOf(':', 'agent:'.length) + 1);
1104
+ const tail = tailOf(String(ref.sessionKey));
1105
+ if (!String(ref.sessionKey).startsWith(`agent:${ref.member}:`) || HELPER_TAIL.test(tail))
1106
+ throw new HelperError('session', `refused: "${String(ref.sessionKey)}" is not a legacy session of member "${String(ref.member)}"`);
1107
+ const { account } = this.helpersCtl.resolve(ref.member, helper);
1108
+ const owns = (key) => key !== undefined && tailOf(key) === tail && this.memberOf(key) === ref.member;
1109
+ if (this.approvals(ref.member).some((a) => owns(a.sessionKey))
1110
+ || this.delegatedRuns().some((r) => owns(r.parent.sessionKey) || owns(r.root.sessionKey)))
1111
+ return { ok: false, code: 'busy', message: words('move.busy') };
1112
+ const moved = await this.accountsCtl.move(ref.member, ref.sessionKey, account, `h-${helper}:${tail}`);
1113
+ return moved.ok ? { ok: true, session: `agent:${ref.member}:h-${helper}:${tail}` } : moved;
1114
+ }
1080
1115
  // sign-in (5.7): every body here is the module's, with this kit's transport, members and ports.
1081
1116
  signInCtx() {
1082
1117
  return { request: (method, params, o) => this.request()(method, params, o),
@@ -1311,7 +1346,7 @@ export class OpenClawKit {
1311
1346
  // Live provider catalogs (5.6): registers each live model with its api from the provider's catalog data, so
1312
1347
  // embedded runs resolve it. Serialized and in the background; a failure is logged and keeps the registered list.
1313
1348
  syncCatalogs() {
1314
- for (const provider of LIVE_CATALOGS) {
1349
+ for (const provider of REGISTERED_CATALOGS) {
1315
1350
  if (this.o.config?.models?.providers?.[provider]?.models !== undefined)
1316
1351
  continue;
1317
1352
  this.catalogs = this.catalogs.then(async () => {
@@ -1321,18 +1356,22 @@ export class OpenClawKit {
1321
1356
  this.o.log?.(`catalog sync ${provider}: ${answer.reason ?? 'failed'}`);
1322
1357
  return;
1323
1358
  }
1324
- const { config } = await this.request()('config.get');
1325
- const registered = config?.models?.providers?.[provider]?.models ?? [];
1326
- const byId = new Map(registered.map((m) => [m.id, m]));
1327
- for (const m of answer.models ?? [])
1328
- byId.set(m.id, m);
1329
- const models = [...byId.values()];
1330
- if (!isDeepStrictEqual(models, registered))
1331
- await this.patchConfig({ models: { providers: { [provider]: { models } } } });
1359
+ await this.registerModels(provider, answer.models ?? []);
1332
1360
  }).catch(() => { if (this.current.phase === 'ready')
1333
1361
  this.o.log?.(`catalog sync ${provider}: failed`); });
1334
1362
  }
1335
1363
  }
1364
+ // Merges catalog rows into the registered list by id; an unchanged list is never rewritten.
1365
+ async registerModels(provider, rows) {
1366
+ const { config } = await this.request()('config.get');
1367
+ const registered = config?.models?.providers?.[provider]?.models ?? [];
1368
+ const byId = new Map(registered.map((m) => [m.id, m]));
1369
+ for (const m of rows)
1370
+ byId.set(m.id, m);
1371
+ const models = [...byId.values()];
1372
+ if (!isDeepStrictEqual(models, registered))
1373
+ await this.patchConfig({ models: { providers: { [provider]: { models } } } });
1374
+ }
1336
1375
  migrateRetainedLogin(member, source) {
1337
1376
  return migrateLogin({ root: this.engine.root, prepare: () => this.prepare(), doctor: () => this.engine.doctor(120_000),
1338
1377
  seal: this.o.authSeal, log: this.o.log, withStore: (task) => this.engine.withAuthStore(task) }, member, source);
@@ -1368,7 +1407,7 @@ export class OpenClawKit {
1368
1407
  return (this.o.tools ?? []).map((t) => t.name);
1369
1408
  }
1370
1409
  async run(spec, on) {
1371
- if (spec.delegates && this.o.gateBuiltins === false)
1410
+ if ((spec.delegates || spec.helperDelegates) && this.o.gateBuiltins === false)
1372
1411
  throw new Error('delegation requires builtin gating');
1373
1412
  if (this.o.browser) {
1374
1413
  if (!await this.checkBrowserTools())
package/dist/runs.js CHANGED
@@ -7,6 +7,7 @@ import { outputSchema } from './output.js';
7
7
  import { words } from './words.js';
8
8
  import { classify } from './classify.js';
9
9
  import { providerOf } from './pick.js';
10
+ import { engineId } from './accounts.js';
10
11
  import { catalogModelOf, clampThinking, levelsOf } from './thinking.js';
11
12
  // The gateway gives up on a silent run after an hour; we wait ten minutes longer for the reply frame itself.
12
13
  const WAIT_MS = 3_600_000;
@@ -172,10 +173,10 @@ export function createRuns(ctx) {
172
173
  const helper = spec.helper === undefined ? undefined : ctx.helpers.resolve(spec.member, spec.helper);
173
174
  if (helper ? !spec.sessionKey.startsWith(`agent:${spec.member}:h-${spec.helper}:`) : HELPER_TAIL.test(tail))
174
175
  throw new HelperError('session', `refused: "${spec.sessionKey}" is not a session of ${helper ? `helper "${spec.helper}"` : 'this run'}`);
175
- if (spec.delegates) {
176
+ if (spec.delegates || spec.helperDelegates) {
176
177
  if (spec.register === false || !ctx.delegations)
177
178
  throw new Error('delegation requires a registered run');
178
- ctx.delegations.validate(spec.delegates);
179
+ ctx.delegations.validate(spec.delegates ?? {}, spec.member, spec.helperDelegates);
179
180
  }
180
181
  // Same validation as the pin's NonEmptyString; preserve the caller's bytes (including whitespace).
181
182
  if (spec.idempotencyKey !== undefined && (typeof spec.idempotencyKey !== 'string' || !spec.idempotencyKey.length))
@@ -183,9 +184,16 @@ export function createRuns(ctx) {
183
184
  const idempotencyKey = spec.idempotencyKey ?? randomUUID();
184
185
  const output = preparedOutput ?? (spec.schema === undefined ? undefined : outputSchema(spec.schema));
185
186
  const system = [helper?.persona ?? spec.system, output?.prompt].filter(Boolean).join('\n\n');
186
- const picked = spec.model === undefined ? undefined : account(spec.model);
187
+ let catalogs;
188
+ const accountOf = async (model) => {
189
+ const picked = account(model);
190
+ catalogs ??= ctx.request('config.get', {}).then((answer) => answer.config?.models?.providers);
191
+ const providers = await catalogs;
192
+ return { provider: picked.provider, model: engineId(picked.provider, model, providers?.[picked.provider]?.models) };
193
+ };
194
+ const picked = spec.model === undefined ? undefined : await accountOf(spec.model);
187
195
  for (const need of spec.needs ?? [])
188
- account(need);
196
+ await accountOf(need);
189
197
  for (const tool of spec.tools ?? [])
190
198
  if (!ctx.tools.has(tool))
191
199
  throw new Error(`refused: "${tool}" is not one of this kit's tools`);
@@ -262,7 +270,7 @@ export function createRuns(ctx) {
262
270
  if (!ready)
263
271
  return { ok: false, kind: 'signed-out', message: `${pick.account.name} is not signed in` };
264
272
  return await start(spec, on, { agentId: agent, sessionKey, idempotencyKey, system,
265
- picked: pick.model ? account(pick.model) : undefined, output, chosen: pick }); // '' = no override
273
+ picked: pick.model ? await accountOf(pick.model) : undefined, output, chosen: pick }); // '' = no override
266
274
  }
267
275
  finally {
268
276
  if (ctx.routes.get(spec.sessionKey) === route)
@@ -335,7 +343,8 @@ export function createRuns(ctx) {
335
343
  });
336
344
  try {
337
345
  const ref = { sessionKey, member: spec.member, ...(spec.helper !== undefined ? { helper: spec.helper } : {}) };
338
- releaseDelegation = spec.delegates ? ctx.delegations.register(ref, idempotencyKey, spec.tools, spec.delegates, spec.sessionKey) : undefined;
346
+ releaseDelegation = spec.delegates || spec.helperDelegates
347
+ ? ctx.delegations.register(ref, idempotencyKey, spec.tools, spec.delegates ?? {}, spec.sessionKey, spec.helperDelegates) : undefined;
339
348
  release = spec.register !== false ? ctx.bridge.register(ref, spec.tools, idempotencyKey) : undefined;
340
349
  if (picked && !chosen) {
341
350
  // The picked account must be the one called: check it is signed in for this member before the run, since
package/dist/signin.js CHANGED
@@ -4,7 +4,6 @@ import { randomUUID } from 'node:crypto';
4
4
  import { createServer } from 'node:http';
5
5
  import { activate, LATE_RESTART } from './activation.js';
6
6
  import { authStatus } from './auth-status.js';
7
- import { LIVE_CATALOGS } from './constants.js';
8
7
  import { routes } from './routes.js';
9
8
  import { signedInProviders } from './runs.js';
10
9
  import { words } from './words.js';
@@ -301,13 +300,13 @@ export function signIn(ctx, member, o, on) {
301
300
  if (!apiKey)
302
301
  return { state: 'failed', via, why: signal.aborted ? 'declined' : 'expired' };
303
302
  secret = apiKey.trim() || undefined;
304
- // A live-catalog key can act for several workspaces, and the Go gateway refuses every call until one is
303
+ // A Go key can act for several workspaces, and the Go gateway refuses every call until one is
305
304
  // named (403 Workspace selection is required): the engine carries the workspace only from its provider
306
305
  // config headers, so resolve it here the way the kit's key lane does, and write the header before the
307
306
  // activation so the activation probe itself carries it. One workspace is used unasked; several ask here,
308
307
  // answered through the same paste (an id or an exact name); a key the console refuses (a workspace-bound
309
308
  // service key) needs none; a listing that cannot be read fails the sign-in before anything changes.
310
- if (secret && keyRoute && LIVE_CATALOGS.includes(keyRoute.provider) && ctx.setProviderHeaders && ctx.listWorkspaces) {
309
+ if (secret && keyRoute?.provider === 'opencode-go' && ctx.setProviderHeaders && ctx.listWorkspaces) {
311
310
  let workspaces;
312
311
  try {
313
312
  workspaces = await ctx.listWorkspaces(secret, signal);
@@ -281,15 +281,45 @@ export function fakeGateway(script, o) {
281
281
  for (const waiter of run.waiters.splice(0))
282
282
  waiter(result);
283
283
  };
284
- const emitRun = (run, stream, data) => emit('agent', { runId: run.runId, seq: run.seq++, stream, ts: Date.now(), data });
285
- /** The `agent` handler: the run's scripted turn, its `[tool NAME {json}]` calls through the real bridge. */
286
- const startRun = (params, bridgeSock) => {
284
+ const emitRun = (run, stream, data) => {
285
+ emit('agent', { runId: run.runId, seq: run.seq++, stream, ts: Date.now(), data });
286
+ const child = run.child;
287
+ if (child)
288
+ child.relay = child.relay.then(() => bridgeRequest(child.sock, { kind: 'delegation-event',
289
+ event: { runId: run.runId, sessionKey: run.sessionKey, stream, data } })).catch(() => { });
290
+ };
291
+ /**
292
+ * A native `sessions_send` as the engine and plugin play it: the parent's dispatch is tracked before it goes, then the
293
+ * exact target session runs as an engine-created child and the receipt names its run (delegated admission, 5.8).
294
+ */
295
+ const nativeSend = async (run, parentRunId, input, callId, sock) => {
296
+ const ids = { key: run.sessionKey, runId: parentRunId, callId };
297
+ const prepared = await bridgeRequest(sock, { kind: 'delegation-prepare', ...ids, tool: 'sessions_send', input });
298
+ if (prepared.allow === false)
299
+ return { isError: true, output: String(prepared.reason) };
300
+ const sessionKey = String(input.sessionKey);
301
+ const agentId = /^agent:([^:]+):/.exec(sessionKey)?.[1];
302
+ // Like the pin: a send reaches only a session that already exists (its agent's main excepted).
303
+ const child = agentId && (sessions.has(`${agentId}\n${sessionKey}`) || sessionKey === `agent:${agentId}:main`)
304
+ ? startRun({ agentId, sessionKey, message: input.message }, sock, true) : undefined;
305
+ const details = child ? { status: 'accepted', runId: child.runId, sessionKey }
306
+ : { status: 'error', error: `No session found: ${sessionKey}`, sessionKey };
307
+ await bridgeRequest(sock, { kind: 'delegation-receipt', ...ids, result: { details } });
308
+ return { isError: !child, output: JSON.stringify(details) };
309
+ };
310
+ /** The `agent` handler: the run's scripted turn, its `[tool NAME {json}]` calls through the real bridge. An
311
+ * engine-created `child` names its own run on the bridge, as the plugin does with the engine's run id. */
312
+ const startRun = (params, bridgeSock, child = false) => {
287
313
  if (params.agentId != null && !agents.has(String(params.agentId)))
288
314
  throw new Error(`unknown agent: ${params.agentId}`);
289
315
  // Like the engine: the provider/model the run is called on, its own override or the member's (here openai).
290
316
  const run = { runId: randomUUID(), agentId: String(params.agentId ?? 'main'), sessionKey: String(params.sessionKey ?? ''), seq: 0,
291
317
  provider: String(params.provider ?? 'openai'), model: String(params.model ?? 'gpt-5.1'), waiters: [] };
292
318
  runs.set(run.runId, run);
319
+ if (child) {
320
+ run.child = { sock: bridgeSock, relay: Promise.resolve() };
321
+ emitRun(run, 'lifecycle', { phase: 'start' });
322
+ }
293
323
  const session = sessionOf(run.agentId, run.sessionKey);
294
324
  session.provider = run.provider;
295
325
  session.model = run.model;
@@ -309,13 +339,17 @@ export function fakeGateway(script, o) {
309
339
  let isError = false;
310
340
  if (bridgeSock && !(builtin && table?.gateBuiltins === false)) {
311
341
  try {
312
- const gate = await bridgeRequest(bridgeSock, { kind: 'gate', key: run.sessionKey, tool: call.name, input: call.input });
342
+ const owner = child ? { runId: run.runId } : {};
343
+ const gate = await bridgeRequest(bridgeSock, { kind: 'gate', key: run.sessionKey, tool: call.name, input: call.input, ...owner });
313
344
  if (!gate.allow) {
314
345
  isError = true;
315
346
  output = String(gate.reason ?? 'blocked');
316
347
  }
348
+ else if (builtin && call.name === 'sessions_send') {
349
+ ({ isError, output } = await nativeSend(run, child ? run.runId : String(params.idempotencyKey ?? ''), call.input, toolCallId, bridgeSock));
350
+ }
317
351
  else if (!builtin) {
318
- const done = await bridgeRequest(bridgeSock, { kind: 'call', key: run.sessionKey, permit: gate.permit, tool: call.name, input: call.input });
352
+ const done = await bridgeRequest(bridgeSock, { kind: 'call', key: run.sessionKey, permit: gate.permit, tool: call.name, input: call.input, ...owner });
319
353
  isError = done.ok !== true;
320
354
  output = String(done.ok === true ? done.text : done.reason);
321
355
  }
@@ -332,7 +366,14 @@ export function fakeGateway(script, o) {
332
366
  append(session, { role: 'assistant', content: [{ type: 'text', text }] });
333
367
  // The engine sums the run's usage (zero buckets omitted); the fake counts characters.
334
368
  run.usage = { input: message.length, output: text.length, total: message.length + text.length };
335
- setTimeout(() => finish(run, { status: 'ok', terminalReply: { text } }), ABORT_WINDOW_MS);
369
+ setTimeout(() => {
370
+ if (run.result || !child)
371
+ return finish(run, { status: 'ok', terminalReply: { text } });
372
+ // A child's lifecycle end, as the engine streams it: `hit the limit` plays the model's usage-limit error.
373
+ const error = /hit the limit/i.test(message) ? 'You have hit your ChatGPT usage limit (plus plan). Try again in ~30 min.' : undefined;
374
+ emitRun(run, 'lifecycle', error ? { phase: 'error', error } : { phase: 'end' });
375
+ finish(run, error ? { status: 'error', error } : { status: 'ok', terminalReply: { text } });
376
+ }, ABORT_WINDOW_MS);
336
377
  })();
337
378
  }, 0);
338
379
  return { runId: run.runId, status: 'accepted' };
package/dist/types.d.ts CHANGED
@@ -41,11 +41,19 @@ export type Helper = {
41
41
  agentId: string;
42
42
  sessionPrefix: string;
43
43
  };
44
- export type DelegatedRun = RunRef & Required<Pick<RunRef, 'runId' | 'agentId' | 'parent' | 'root'>>;
44
+ /** An admitted engine-created child (5.8): `tools` is the app-tool subset it may call (the parent's, narrowed). */
45
+ export type DelegatedRun = RunRef & Required<Pick<RunRef, 'runId' | 'agentId' | 'parent' | 'root'>> & {
46
+ tools: string[];
47
+ };
48
+ /** A delegated child's lifecycle (5.8.3 S3): `admitted`, its engine `event`s, then one `ended` whose `reason` is
49
+ * `terminal` (finished), `error` (`message`), `resting` (its helper's account hit its limit and rests `until`) or
50
+ * `cancelled` (aborted by the kit or the engine, or its admission ended). */
45
51
  export type DelegatedEvent = {
46
52
  type: 'admitted' | 'event' | 'ended';
47
53
  run: DelegatedRun;
48
- reason?: 'terminal' | 'cancelled';
54
+ reason?: 'terminal' | 'error' | 'resting' | 'cancelled';
55
+ until?: number;
56
+ message?: string;
49
57
  event?: {
50
58
  stream: string;
51
59
  data?: Record<string, unknown>;
@@ -87,6 +95,9 @@ export interface RunSpec<S extends OutputSchema | undefined = OutputSchema | und
87
95
  tools?: string[];
88
96
  /** Exact target engine agent IDs and their app-tool subsets; does not share credentials. */
89
97
  delegates?: Record<string, string[]>;
98
+ /** The member's ensured helpers (5.8.3) this run may hand work to, and their app-tool subsets: an exact native send to
99
+ * `agent:<the helper's agent>:h-<helper>:<rest>` is admitted as that helper's run (RunRef.helper). */
100
+ helperDelegates?: Record<string, string[]>;
90
101
  register?: boolean;
91
102
  }
92
103
  export type RunEvent = {
@@ -191,6 +202,7 @@ export type Approval = {
191
202
  source: 'gate' | 'exec' | 'plugin' | 'question';
192
203
  member: Member;
193
204
  sessionKey?: string;
205
+ helper?: string;
194
206
  tool?: string;
195
207
  summary: string;
196
208
  input?: unknown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "The OpenClaw runtime kit: the pinned engine's full operator surface with plain-words helpers for members, sign-in, runs and approvals.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",