@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 +11 -0
- package/README.md +66 -6
- package/dist/accounts.d.ts +3 -1
- package/dist/accounts.js +30 -9
- package/dist/approvals.js +5 -2
- package/dist/bridge.js +8 -4
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +6 -2
- package/dist/delegations.d.ts +10 -2
- package/dist/delegations.js +83 -18
- package/dist/helpers.d.ts +28 -0
- package/dist/helpers.js +72 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/keys.d.ts +3 -1
- package/dist/keys.js +49 -17
- package/dist/kit.d.ts +13 -1
- package/dist/kit.js +63 -12
- package/dist/runs.d.ts +2 -0
- package/dist/runs.js +30 -11
- package/dist/signin.js +2 -3
- package/dist/testing/fake-gateway.js +47 -6
- package/dist/types.d.ts +24 -2
- package/package.json +1 -1
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 `[]`
|
|
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
|
|
181
|
-
|
|
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
|
-
**
|
|
446
|
-
|
|
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
|
package/dist/accounts.d.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 (
|
|
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
|
|
833
|
-
|
|
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
|
-
: (
|
|
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, ...(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
|
6
|
-
|
|
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',
|
package/dist/delegations.d.ts
CHANGED
|
@@ -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>;
|
package/dist/delegations.js
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
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
|
-
|
|
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.
|
|
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),
|
|
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
|
+
};
|