@byokit/openclaw 0.20.0 → 0.22.1
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 +16 -0
- package/README.md +39 -5
- 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 +3 -0
- package/dist/helpers.js +3 -1
- package/dist/keys.d.ts +3 -1
- package/dist/keys.js +70 -22
- package/dist/kit.d.ts +6 -0
- package/dist/kit.js +51 -12
- package/dist/runs.js +15 -6
- package/dist/signin.js +2 -3
- package/dist/testing/fake-gateway.js +47 -6
- package/dist/types.d.ts +14 -2
- package/engine/patches.json +12 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.22.1 (2026-10-19)
|
|
6
|
+
|
|
7
|
+
- FIX: a funded OpenRouter key now signs in and runs the model the person chooses. Before, `addKey` answered `{ status: 'not_included', reason: 'model' }` for every OpenRouter key, so apps showed a plan refusal and never reached the model: the pinned engine planned the key's live test on `openrouter/auto` but staged the route as the provider normalizes it, and refused the key as unavailable before sending it. A bundled engine patch now plans the test on the staged route; a key whose account has no credits (OpenRouter's HTTP 402) now answers `{ status: 'invalid', reason: 'billing' }`, never a plan refusal. An OpenRouter key pasted under OpenRouter's browser sign-in choice (`addKey(member, { authChoice: 'openrouter-oauth', apiKey })`) also takes OpenRouter's one key route (`openrouter-api-key`); a choice of a provider with exactly one key route billed alike maps the same way, and any other choice that takes no key throws its cause (`openai takes no API key: add the key with openai-api-key.`) instead of a plan refusal.
|
|
8
|
+
|
|
9
|
+
## 0.22.0 (2026-10-19)
|
|
10
|
+
|
|
11
|
+
- FEAT: a helper's delegated run is visible from start to finish on `onEvent('byokit.delegated')`: `admitted` carries `run.tools` (its app-tool subset, also on `delegatedRuns()`), and the one `ended` says why in `reason`: `terminal`, `cancelled` (a kit or engine abort, or ended admission), `error` (with the engine's `message`) or `resting`, where the model's usage limit ended a helper's run and the kit rests that helper's account `until` the stated reset, as a kit run's resting end does.
|
|
12
|
+
- FEAT: `Approval.helper` names the helper whose run asks, for gate approvals and for native approvals on a helper's `agent:<agent>:h-<helper>:` key.
|
|
13
|
+
- FEAT: `kit.adoptHelperSession({ member, sessionKey }, helper)` moves a conversation from before helpers into a helper: its whole transcript forks to `agent:<member>:h-<helper>:<tail>` on the helper's account agent and the old session is deleted, with no model request, credential or SQLite copy. A conversation still in flight (live run, parked approval or admitted delegated child) is refused `busy` and keeps its exact old key and approval owner until it settles.
|
|
14
|
+
|
|
15
|
+
## 0.21.0 (2026-10-19)
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
5
21
|
## 0.20.0 (2026-10-19)
|
|
6
22
|
|
|
7
23
|
- 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
|
@@ -32,6 +32,8 @@ hashes. The Gateway Workshop review patch writes content-free, durable usage fac
|
|
|
32
32
|
accounting environment. The separately bundled worker and all other detached kinds remain uncovered.
|
|
33
33
|
The Claude CLI route also uses the bundled [Tooling-name correction](../../docs/runtime-kits.md#516-bundled-engine-patches-and-engine-started-usage-d19);
|
|
34
34
|
apps do not need consumer tool aliases.
|
|
35
|
+
A key setup plan patch lets an OpenRouter API key activate: stock plans the key's live test on a route other than the
|
|
36
|
+
one it stages and refuses every OpenRouter key before sending it.
|
|
35
37
|
The full upstream MIT notice ships in [engine/OPENCLAW-LICENSE](engine/OPENCLAW-LICENSE).
|
|
36
38
|
|
|
37
39
|
Each `prepare()` verifies the **entire** selected engine tree, including unpatched files. The kit installs stock
|
|
@@ -149,7 +151,10 @@ returns what that run would choose now, refusals included, without running. `ste
|
|
|
149
151
|
|
|
150
152
|
For an owner who explicitly offers keys, `kit.addKey('ana', { authChoice: 'openai-api-key', apiKey })` is a thenable
|
|
151
153
|
handle: `await` it for `{ status: 'ok' }` or `{ status: 'invalid' | 'not_included' | 'retry', reason }`, and call
|
|
152
|
-
`cancel()` to stop a pending add (clears any partial key; settles `retry`/`busy`).
|
|
154
|
+
`cancel()` to stop a pending add (clears any partial key; settles `retry`/`busy`). `authChoice` may also name another
|
|
155
|
+
sign-in of a provider with exactly one key route billed alike (`openrouter-oauth` adds an `openrouter-api-key`); any
|
|
156
|
+
other choice that takes no key (a subscription such as `openai`, a provider with several key routes, an unknown choice)
|
|
157
|
+
throws its cause before anything is sent.
|
|
153
158
|
`reason` is `auth` (provider refused the key) or `billing` (the provider's billing refused it; the kit words it and never passes
|
|
154
159
|
provider text): both are `invalid`, and an unchanged key must not be retried. `empty` is a blank key, rejected locally before any
|
|
155
160
|
provider call, also `invalid`. `model` is an unsupported route or unknown model (`not_included`), or a live model the engine did not
|
|
@@ -174,11 +179,17 @@ for entry, checking, success and failure states, using the kit's `key.*` words.
|
|
|
174
179
|
To use the saved key for one run, pass `auth: 'apiKey'`. The key lives in the member's separate
|
|
175
180
|
`byokit-key-<member>` agent and workspace, with `copyToAgents: false` and only that key in its local auth order.
|
|
176
181
|
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 `[]`
|
|
182
|
+
live catalog. Before a key exists it returns `[]` (a failed catalog probe with no saved key for the provider is read as
|
|
183
|
+
empty; with one it is an error) and runs no engine discovery. Only available key-lane
|
|
178
184
|
models can be saved; the ordinary member's sign-ins never supply the list. Pass a full catalog id, e.g.
|
|
179
185
|
`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
|
-
|
|
186
|
+
Model listing and selection belong to the live-catalog routes, OpenCode Go and OpenRouter. An OpenRouter key is
|
|
187
|
+
pay-as-you-go with no plan, so it takes any model its own live catalog lists, named `openrouter/<id>`, e.g.
|
|
188
|
+
`model: 'openrouter/deepseek/deepseek-v4.1-flash-1017'`; an id already beginning with its own provider keeps one
|
|
189
|
+
prefix (`openrouter/auto`). A Google preview id is named and saved as the canonical id the engine keeps
|
|
190
|
+
(`openrouter/google/gemini-3.1-flash-lite-preview` becomes `openrouter/google/gemini-3.1-flash-lite`); the add
|
|
191
|
+
registers that one model with the engine. Other key routes list no models, and a `model` on them returns
|
|
192
|
+
`not_included`/`model` before activation.
|
|
182
193
|
An unknown model returns `{ status: 'not_included', reason: 'model' }` and clears the replacement.
|
|
183
194
|
Before success, `addKey` waits for catalog registration and prepares the new key agent's full-catalog owner once.
|
|
184
195
|
For a live-catalog provider, a chosen model must also be in the engine's registered `models.providers[provider].models`
|
|
@@ -403,7 +414,7 @@ Entries:
|
|
|
403
414
|
|
|
404
415
|
| Export | What it does |
|
|
405
416
|
| --- | --- |
|
|
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` |
|
|
417
|
+
| `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
418
|
| `ENGINE_VERSION`, `PROTOCOL_VERSION`, `OPERATOR_SCOPES` (`.`) | The pinned engine version, its protocol and the operator scopes the kit connects with |
|
|
408
419
|
| `KitOptions`, `RunSpec`, `RunEvent`, `RunEnd`, `RunUsage`, `PlanWindow`, `Route`, `Approval`, `Decision`, `ToolSpec`, `ToolHost`, `KitState`, ... (`.`) | Public types (docs/runtime-kits.md §5.2) |
|
|
409
420
|
| `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 +489,29 @@ was removed: never moved to another sign-in) and `session` (a key outside the he
|
|
|
478
489
|
or `move` of one). Helpers share their account's engine agent (its id and workspace files); a helper gains no tool,
|
|
479
490
|
builtin or key prefix the member's run would not. See [Helpers](../../docs/runtime-kits.md#583-helpers).
|
|
480
491
|
|
|
492
|
+
A run hands work to the member's helpers with `helperDelegates: { writer: ['report'] }` (on the native delegated path
|
|
493
|
+
above, builtin gating on): the engine's own `sessions_send` to the exact key `agent:<writer's agent>:h-writer:<rest>`
|
|
494
|
+
(`timeoutSeconds: 0`) is admitted from its receipt as writer's run, `RunRef.helper: 'writer'` on every gate and call.
|
|
495
|
+
A target that is not a helper key, a helper on another account, one never ensured, or one the run was not given is
|
|
496
|
+
refused before dispatch; the parent's tool result reads `session: …`, `bound: …` or `unknown_helper: …`.
|
|
497
|
+
`abort(rootSessionKey)` revokes the children, and a helper whose account is removed loses them at their next call. The
|
|
498
|
+
child runs without the helper's persona (a native send carries no system text).
|
|
499
|
+
|
|
500
|
+
Conversations from before helpers (plain member sessions) move into a helper with
|
|
501
|
+
`kit.adoptHelperSession({ member, sessionKey }, helper)`: the whole transcript forks to
|
|
502
|
+
`agent:<member>:h-<helper>:<tail>` on the helper's account agent, the old session goes, and nothing is sent to the
|
|
503
|
+
model. The result is `move`'s `MoveResult` with `session` set to the helper key to run on. A conversation still in
|
|
504
|
+
flight (a live run, a parked approval or an admitted delegated child under it) is refused `busy` and keeps its exact
|
|
505
|
+
old key and approval owner until it settles, so adopt again later; a target key already taken is refused and leaves
|
|
506
|
+
both. A helper key, another member's key or a helper never ensured is a typed `HelperError` (`session`,
|
|
507
|
+
`unknown_helper`).
|
|
508
|
+
|
|
509
|
+
`kit.onEvent('byokit.delegated', fn)` follows each helper child start to finish: `admitted` (with `run.helper` and
|
|
510
|
+
`run.tools`, its app-tool subset), the engine's events, then one `ended` whose `reason` is `terminal`, `cancelled` (an
|
|
511
|
+
abort, or its admission ended), `error` (with `message`) or `resting`: the model's usage limit ended it, and the kit
|
|
512
|
+
rests the helper's account `until` the stated reset, as a kit run's resting end does. An approval raised by a helper's
|
|
513
|
+
run names it in `Approval.helper`.
|
|
514
|
+
|
|
481
515
|
## Owned browser (unqualified handoff)
|
|
482
516
|
|
|
483
517
|
`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) {
|
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
|
|
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) =>
|
|
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,62 @@ 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
|
+
// The key route a choice names: its own, or the only key route of the provider whose other sign-in it names, billed
|
|
81
|
+
// alike (an OpenRouter key pasted under `openrouter-oauth` is an `openrouter-api-key`). Anything else is the app's
|
|
82
|
+
// mistake, refused with its cause before anything is sent: never a plan refusal, never a subscription turned per-use.
|
|
83
|
+
const keyRoute = (authChoice) => {
|
|
84
|
+
const all = ctx.routes();
|
|
85
|
+
const named = all.find((r) => r.choice === authChoice);
|
|
86
|
+
if (!named)
|
|
87
|
+
throw new Error(`No sign-in is named ${authChoice}.`);
|
|
88
|
+
if (named.keyEntry)
|
|
89
|
+
return named;
|
|
90
|
+
const keyed = all.filter((r) => r.provider === named.provider && r.keyEntry);
|
|
91
|
+
if (keyed.length === 1 && keyed[0].billing === named.billing)
|
|
92
|
+
return keyed[0];
|
|
93
|
+
throw new Error(keyed.length ? `${authChoice} takes no API key: add the key with ${keyed.map((r) => r.choice).join(' or ')}.`
|
|
94
|
+
: `${authChoice} takes no API key.`);
|
|
95
|
+
};
|
|
96
|
+
const listed = (provider, rows) => modelsOf(provider, { models: rows.map((m) => ({ ...m, provider })) });
|
|
61
97
|
const models = async (member, o) => {
|
|
62
98
|
if (!check(member))
|
|
63
99
|
throw new Error('Choose your own member.');
|
|
64
|
-
const route =
|
|
65
|
-
if (
|
|
100
|
+
const route = keyRoute(o.authChoice);
|
|
101
|
+
if (route.readiness !== 'ready' || !LIVE_CATALOGS.includes(route.provider))
|
|
66
102
|
return [];
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
103
|
+
try {
|
|
104
|
+
return listed(route.provider, await catalog(member, route.provider));
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
// The engine answers `failed` when no agent holds a key: a lane without the provider's profile lists nothing,
|
|
108
|
+
// a saved one is a real failure.
|
|
109
|
+
const held = await ctx.request('models.authStatus', { agentId: keyAgentId(member) }).then((answer) => {
|
|
110
|
+
const rows = answer.providers ?? [];
|
|
111
|
+
return rows.some((row) => (typeof row === 'string' ? row : row?.provider) === route.provider);
|
|
112
|
+
}, () => undefined);
|
|
113
|
+
if (held !== false)
|
|
114
|
+
throw error;
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
74
117
|
};
|
|
75
118
|
return {
|
|
76
119
|
models,
|
|
@@ -84,16 +127,17 @@ export function createKeys(ctx) {
|
|
|
84
127
|
throw new Error('Choose your own member.');
|
|
85
128
|
if (!o.apiKey.trim())
|
|
86
129
|
return failure('invalid', 'empty');
|
|
87
|
-
const route =
|
|
88
|
-
if (
|
|
130
|
+
const route = keyRoute(o.authChoice);
|
|
131
|
+
if (route.readiness !== 'ready' || (o.model !== undefined && !LIVE_CATALOGS.includes(route.provider)))
|
|
89
132
|
return failure('not_included', 'model');
|
|
90
|
-
|
|
133
|
+
const model = o.model === undefined ? undefined : canonicalModel(o.model);
|
|
134
|
+
// A workspace names a Go login's own workspace only; another route takes no header, and a
|
|
91
135
|
// refusal here leaves the member's lanes untouched (it lands before any prepare or activation).
|
|
92
136
|
let orgId = typeof o.orgId === 'string' ? o.orgId.trim() : '';
|
|
93
137
|
if (orgId) {
|
|
94
138
|
if (orgId.length > 256)
|
|
95
139
|
return failure('invalid', 'auth');
|
|
96
|
-
if (
|
|
140
|
+
if (route.provider !== 'opencode-go')
|
|
97
141
|
return failure('not_included', 'model');
|
|
98
142
|
}
|
|
99
143
|
else if (route.provider === 'opencode-go') {
|
|
@@ -157,7 +201,7 @@ export function createKeys(ctx) {
|
|
|
157
201
|
await ctx.setProviderHeaders(route.provider, { 'x-opencode-org-id': orgId });
|
|
158
202
|
wroteHeader = true;
|
|
159
203
|
}
|
|
160
|
-
else if (
|
|
204
|
+
else if (route.provider === 'opencode-go') {
|
|
161
205
|
await ctx.setProviderHeaders(route.provider, null);
|
|
162
206
|
wroteHeader = true;
|
|
163
207
|
}
|
|
@@ -165,14 +209,15 @@ export function createKeys(ctx) {
|
|
|
165
209
|
sent = true;
|
|
166
210
|
if (ac.signal.aborted)
|
|
167
211
|
return abandon(cancelled());
|
|
168
|
-
const result = await activate(ctx, { kind: 'api-key', agentId, authChoice:
|
|
212
|
+
const result = await activate(ctx, { kind: 'api-key', agentId, authChoice: route.choice, apiKey: o.apiKey }, { timeoutMs: 180_000, signal: ac.signal });
|
|
169
213
|
if (ac.signal.aborted)
|
|
170
214
|
return abandon(cancelled());
|
|
171
215
|
if (!result.ok && !result.late)
|
|
172
216
|
return abandon(probeResult(result.status));
|
|
173
217
|
if (result.late)
|
|
174
218
|
return abandon(failure('retry', 'busy'));
|
|
175
|
-
|
|
219
|
+
const rows = model === undefined ? [] : await catalog(member, route.provider);
|
|
220
|
+
if (model !== undefined && !listed(route.provider, rows).some((m) => m.id === model && m.available))
|
|
176
221
|
return abandon(failure('not_included', 'model'));
|
|
177
222
|
// Prepare before publishing the ready marker: failed preparation remains unusable even if clear fails.
|
|
178
223
|
try {
|
|
@@ -183,9 +228,12 @@ export function createKeys(ctx) {
|
|
|
183
228
|
}
|
|
184
229
|
if (ac.signal.aborted)
|
|
185
230
|
return abandon(cancelled());
|
|
186
|
-
|
|
231
|
+
// A live catalog the kit does not register whole (OpenRouter) registers the chosen model alone.
|
|
232
|
+
if (model !== undefined && !REGISTERED_CATALOGS.includes(route.provider))
|
|
233
|
+
await ctx.registerModels(route.provider, rows.filter((m) => kitId(route.provider, String(m.id)) === model)).catch(() => { });
|
|
234
|
+
if (model !== undefined && LIVE_CATALOGS.includes(route.provider) && !(await registered(route.provider, model)))
|
|
187
235
|
return abandon(failure('retry', 'model'));
|
|
188
|
-
if (!(await control(member, 'seal', undefined,
|
|
236
|
+
if (!(await control(member, 'seal', undefined, model)).ok)
|
|
189
237
|
return abandon(failure('retry', 'engine'));
|
|
190
238
|
await ctx.indexLane(member, route);
|
|
191
239
|
return { status: 'ok' };
|
|
@@ -241,7 +289,7 @@ export function createKeys(ctx) {
|
|
|
241
289
|
if (cleared?.ok !== true && cleared?.absent !== true)
|
|
242
290
|
refused();
|
|
243
291
|
// A workspace header the removed key named must not outlive it; the next add names its own (or none).
|
|
244
|
-
if (o.provider
|
|
292
|
+
if (o.provider === 'opencode-go')
|
|
245
293
|
await ctx.setProviderHeaders(o.provider, null).catch(() => { throw new Error('The key could not be removed.'); });
|
|
246
294
|
await ctx.dropLane(member, o.provider);
|
|
247
295
|
});
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
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(() =>
|
|
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
|
-
|
|
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/engine/patches.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"v": 1,
|
|
3
|
-
"id": "
|
|
3
|
+
"id": "dcd1cf72add44075",
|
|
4
4
|
"upstream": {
|
|
5
5
|
"name": "openclaw",
|
|
6
6
|
"version": "2026.8.35",
|
|
@@ -65,6 +65,17 @@
|
|
|
65
65
|
"replace": "let systemPrompt = !skipsTurnPreparation ? backendResolved.transformSystemPrompt?.({\n\t\t\tconfig: params.config,\n\t\t\tworkspaceDir,\n\t\t\tprovider: params.provider,\n\t\t\tmodelId,\n\t\t\tmodelDisplay,\n\t\t\tagentId: sessionAgentId,\n\t\t\tsystemPrompt: builtSystemPrompt\n\t\t}) ?? builtSystemPrompt : builtSystemPrompt;\n\t\t// BYOKit: Claude wire names for the Tooling list (ch-cli-tool-prefix). The Claude wire catalog\n\t\t// exposes OpenClaw tools as mcp__openclaw__<name>, so the printed Tooling list on that route must\n\t\t// use those exact names. Every other route keeps the stock prompt byte-identical; already-prefixed\n\t\t// names are never prefixed again. Order, summaries and error semantics are unchanged.\n\t\tif (!skipsTurnPreparation && isClaudeCli && promptTools.length > 0) {\n\t\t\tconst byokitBareNames = new Set(promptTools.map((tool) => tool?.name).filter((name) => typeof name === \"string\" && name.length > 0 && !name.startsWith(\"mcp__openclaw__\")));\n\t\t\tif (byokitBareNames.size > 0) {\n\t\t\t\tconst byokitLines = systemPrompt.split(\"\\n\");\n\t\t\t\tlet byokitInTooling = false;\n\t\t\t\tlet byokitToolingArmed = false;\n\t\t\t\tfor (let byokitI = 0; byokitI < byokitLines.length; byokitI++) {\n\t\t\t\t\tconst byokitLine = byokitLines[byokitI];\n\t\t\t\t\tif (byokitLine.startsWith(\"## \")) {\n\t\t\t\t\t\tbyokitInTooling = byokitLine === \"## Tooling\";\n\t\t\t\t\t\tbyokitToolingArmed = false;\n\t\t\t\t\t} else if (byokitInTooling && !byokitToolingArmed) {\n\t\t\t\t\t\tif (byokitLine === \"Tools policy-filtered. Names case-sensitive; call exact.\") byokitToolingArmed = true;\n\t\t\t\t\t} else if (byokitInTooling && byokitToolingArmed && byokitLine.startsWith(\"- \")) {\n\t\t\t\t\t\tconst byokitMatch = /^- (\\S+?)(:.*)?$/.exec(byokitLine);\n\t\t\t\t\t\tif (byokitMatch && byokitBareNames.has(byokitMatch[1])) byokitLines[byokitI] = `- mcp__openclaw__${byokitMatch[1]}${byokitMatch[2] ?? \"\"}`;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tsystemPrompt = byokitLines.join(\"\\n\");\n\t\t\t}\n\t\t}\n"
|
|
66
66
|
}
|
|
67
67
|
]
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"path": "dist/setup-inference-Dpvv_U7x.js",
|
|
71
|
+
"before": "e38ba4aa4a31376a03609b8732fcd69026df17149849d15caa6a8a59a865d6d7",
|
|
72
|
+
"after": "71f7103b5f88db7e3b98433a54b05a5a18557d6e83385d09cddaabe053254823",
|
|
73
|
+
"edits": [
|
|
74
|
+
{
|
|
75
|
+
"find": "\t\t\treturn {\n\t\t\t\trunner: \"embedded\",\n\t\t\t\t...ref,\n\t\t\t\tmodelRef,\n\t\t\t\tagentDir: params.agentDir,\n\t\t\t\tconfig: preparedAuth.config,",
|
|
76
|
+
"replace": "\t\t\t// BYOKit: plan the route the engine stages. A provider-normalized starter (OpenRouter keeps its bare `auto` as\n\t\t\t// `openrouter/auto`) is planned as the staged route names it, so a key activation matches its staged route.\n\t\t\tconst byokitModelRef = canonicalizeSetupModelRef({\n\t\t\t\tcfg: preparedAuth.config,\n\t\t\t\traw: modelRef,\n\t\t\t\tdefaultProvider: ref.provider\n\t\t\t});\n\t\t\treturn {\n\t\t\t\trunner: \"embedded\",\n\t\t\t\t...parseRef(byokitModelRef),\n\t\t\t\tmodelRef: byokitModelRef,\n\t\t\t\tagentDir: params.agentDir,\n\t\t\t\tconfig: preparedAuth.config,"
|
|
77
|
+
}
|
|
78
|
+
]
|
|
68
79
|
}
|
|
69
80
|
]
|
|
70
81
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@byokit/openclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
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",
|