@byokit/openclaw 0.1.4 → 0.2.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 CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Depends on @byokit/relay 0.3.1.
6
+ - Depends on @byokit/reach 0.4.0.
7
+ - Depends on @byokit/link 0.5.0.
8
+ - FIX: (from @byokit/link 0.5.0) `decodeOffer()` reads legacy compact direct pairing codes with checksum, padding, bounds and expiry validation; new encodings keep the complete current format.
9
+ ## 0.2.1 (2026-09-30)
10
+
11
+ - Dependency update: pins @byokit/link 0.5.0, @byokit/reach 0.4.0 and @byokit/relay 0.3.1.
12
+
13
+ ## 0.2.0 (2026-09-30)
14
+
15
+ - Full run options over the link (v1 A6): `oc.run` and `openclawDevice(link).run(message, o)` forward `system`,
16
+ `images`, `thinking` and `tools`, each type-checked. `RunSpec.tools` is the run's subset of the app's tools
17
+ (`KitOptions.tools` names, listed by the new `kit.toolNames()`): the bridge refuses any other app tool before
18
+ `ToolHost.gate` sees it, engine builtins are unaffected, and a name the kit does not register is refused before the
19
+ engine is called (over the link, `link.notAllowed`). The pinned engine takes no per-run tool list, so the model
20
+ still sees every tool; the gate is the enforcement. Runs sharing a session key share the narrowest subset.
21
+ - Structured tool events (v1 A7): a `tool` `RunEvent` carries the engine's `id` (its toolCallId) on both ends, the
22
+ call's `input` on `start`, and the engine's `output` and `error` on `end`.
23
+ - Usage (v1 A8): `RunEnd.ok` carries `usage` (`input`, `output`, `cacheRead`, `cacheWrite`, `reasoning`, `total`,
24
+ `costUsd`), the engine's own total for the run from the `agent` request's final frame, and `planWindow`
25
+ (`{ provider, plan?, windows: { label, usedPercent, resetAt? }[] }`), the engine's cached read of the
26
+ subscription's quota for the run's provider. Each is present only when the engine reports it; nothing is estimated.
27
+ New types `RunUsage` and `PlanWindow` (`.`, `./device`).
28
+ - Presence and version (v1 A12, device side): `oc.state` (`openclawDevice(link).state()`, typed `DeviceState`) adds
29
+ `version` (this kit), `engine` (`ENGINE_VERSION`) and `signedIn`, the providers the device's member is usably
30
+ signed in to (not an expired or unfinished sign-in), absent while the engine can't say.
31
+ - FIX: a run ending no longer drops another live run's registration on the same session key (its tool calls then
32
+ failed as an unknown run); a key stays registered until its last run ends. `Bridge.register` returns the release.
33
+ - FIX: the engine's `update`, `input_delta` and `review` tool phases no longer end a tool pair early; only `result`
34
+ (or `end`) does.
35
+ - Testing: the fake Gateway's runs are engine-shaped (`toolCallId`, `args`, `result`, `isError`; an `accepted` frame
36
+ and, with `expectFinal`, a final frame with `agentMeta.usage`), and the model stub ends a reply asked
37
+ `stream_options.include_usage` with a `STUB_USAGE` chunk (exported from `./testing`). `GatewayTransport.request`
38
+ options take `expectFinal` and `onAccepted`.
39
+
5
40
  ## 0.1.4 (2026-09-30)
6
41
 
7
42
  - Depends on @byokit/relay 0.3.0.
package/README.md CHANGED
@@ -54,6 +54,14 @@ provider or model. If the member isn't signed in to it, the run ends `{ ok: fals
54
54
  engine is never called. Leave `model` out to keep the engine's own choice. A specific sign-in (`@profile`) can't be
55
55
  picked per run: the pinned engine may still switch to another sign-in for the same provider.
56
56
 
57
+ A run spec also takes `system`, `images` (`{ data, mimeType }[]`), `thinking` and `tools`, a subset of the app tools
58
+ (`KitOptions.tools` names) this run may call; any other app tool is refused at the gate before `ToolHost.gate` sees
59
+ it. Tool events carry the engine's call `id`, the `input` on `start` and the `output` and `error` on `end`. A run that
60
+ ends ok carries `usage` (the engine's token total for the run, and `costUsd` when it priced the model) and
61
+ `planWindow` (the subscription's quota windows as the engine last read them), each only when the engine reports it.
62
+ `openclawDevice(link).run(message, o)` takes the same options over the link, and `state()` adds the kit and engine
63
+ versions and the providers the device's member is signed in to.
64
+
57
65
  The same kit runs offline against the fake Gateway from `@byokit/openclaw/testing` (no engine, no network, no
58
66
  account), and lists the sign-in routes it offers:
59
67
 
@@ -89,7 +97,7 @@ Its output, run from this repo with Node:
89
97
  state: ready | protocol: 4
90
98
  event: {"type":"text","text":"fake: Say hello."}
91
99
  event: {"type":"text","text":"fake: Say hello."}
92
- end: {"ok":true,"text":"fake: Say hello."}
100
+ end: {"ok":true,"text":"fake: Say hello.","usage":{"input":10,"output":16,"total":26}}
93
101
  route: openai subscription browser
94
102
  route: openai-device-code subscription code
95
103
  route: xai-oauth subscription code
@@ -113,9 +121,9 @@ Entries:
113
121
 
114
122
  | Export | What it does |
115
123
  | --- | --- |
116
- | `OpenClawKit` (`.`) | Prepares, starts, supervises and stops the engine. `call` / `callDynamic` pass through to the Gateway, `onEvent` listens. Helpers: `ensureMember`, `routes`, `providers`, `signedIn`, `signIn`, `signOut`, `migrateRetainedLogin`, `confirmRetainedLogin`, `run`, `steer`, `abort`, `approvals`, `onApproval`, `decide`, `allowOnce`, `disallowOnce`, `patchConfig`, `memoryLimited`, `doctorContext` |
124
+ | `OpenClawKit` (`.`) | Prepares, starts, supervises and stops the engine. `call` / `callDynamic` pass through to the Gateway, `onEvent` listens. Helpers: `ensureMember`, `routes`, `providers`, `signedIn`, `signIn`, `signOut`, `migrateRetainedLogin`, `confirmRetainedLogin`, `toolNames`, `run`, `steer`, `abort`, `approvals`, `onApproval`, `decide`, `allowOnce`, `disallowOnce`, `patchConfig`, `memoryLimited`, `doctorContext` |
117
125
  | `ENGINE_VERSION`, `PROTOCOL_VERSION`, `OPERATOR_SCOPES` (`.`) | The pinned engine version, its protocol and the operator scopes the kit connects with |
118
- | `KitOptions`, `RunSpec`, `RunEvent`, `RunEnd`, `Route`, `Approval`, `Decision`, `ToolSpec`, `ToolHost`, `KitState`, ... (`.`) | Public types (docs/runtime-kits.md §5.2) |
126
+ | `KitOptions`, `RunSpec`, `RunEvent`, `RunEnd`, `RunUsage`, `PlanWindow`, `Route`, `Approval`, `Decision`, `ToolSpec`, `ToolHost`, `KitState`, ... (`.`) | Public types (docs/runtime-kits.md §5.2) |
119
127
  | `openclawDevice(link)` (`./device`) | Portable client: state, routes, sign-in, runs, steer, abort, approvals, events, sealed notices, pass-through `call` |
120
128
  | `LinkRefused`, `openNotice` (`./device`) | The host's own refusal as an error; opens a sealed approval notice |
121
129
  | `words`, `stateWords`, `toAccountView` (`.`, `./device`) | The kit's sentences, so a phone shows the words the computer does; `toAccountView` feeds `@byokit/ui-core`'s `phaseOf` |
package/dist/bridge.d.ts CHANGED
@@ -35,6 +35,7 @@ export declare class Bridge {
35
35
  private readonly onAskGone;
36
36
  private server?;
37
37
  private readonly runs;
38
+ private readonly live;
38
39
  private readonly permits;
39
40
  private readonly tickets;
40
41
  private armed;
@@ -51,7 +52,8 @@ export declare class Bridge {
51
52
  });
52
53
  start(): Promise<void>;
53
54
  stop(): void;
54
- register(run: RunRef): void;
55
+ /** Register one run; the returned release ends only this registration (the key stays while another run holds it). */
56
+ register(run: RunRef, tools?: readonly string[]): () => void;
55
57
  unregister(sessionKey: string): void;
56
58
  allowOnce(rule: {
57
59
  keyPrefix: string;
package/dist/bridge.js CHANGED
@@ -69,6 +69,9 @@ export class Bridge {
69
69
  onAskGone;
70
70
  server;
71
71
  runs = new Map();
72
+ // Every live registration per session key, each with its run's subset of the app's tools (RunSpec.tools; none: all
73
+ // of them). Runs sharing a key share the narrowest: a tool must be in every live subset.
74
+ live = new Map();
72
75
  permits = new Map();
73
76
  tickets = [];
74
77
  armed;
@@ -111,11 +114,28 @@ export class Bridge {
111
114
  this.server = undefined;
112
115
  void rm(this.path, { force: true }).catch(() => { });
113
116
  }
114
- register(run) {
117
+ /** Register one run; the returned release ends only this registration (the key stays while another run holds it). */
118
+ register(run, tools) {
115
119
  this.runs.set(run.sessionKey, run);
120
+ const entry = tools ? { tools: new Set(tools) } : {};
121
+ const list = this.live.get(run.sessionKey);
122
+ if (list)
123
+ list.push(entry);
124
+ else
125
+ this.live.set(run.sessionKey, [entry]);
126
+ return () => {
127
+ const now = this.live.get(run.sessionKey);
128
+ const at = now?.indexOf(entry) ?? -1;
129
+ if (!now || at < 0)
130
+ return;
131
+ now.splice(at, 1);
132
+ if (!now.length)
133
+ this.unregister(run.sessionKey);
134
+ };
116
135
  }
117
136
  unregister(sessionKey) {
118
137
  this.runs.delete(sessionKey);
138
+ this.live.delete(sessionKey);
119
139
  for (const [permit, p] of this.permits)
120
140
  if (p.key === sessionKey)
121
141
  this.permits.delete(permit);
@@ -235,6 +255,9 @@ export class Bridge {
235
255
  async gateRun(socket, run, tool, input) {
236
256
  if (!this.host)
237
257
  return this.deny(socket, "can't check this action right now");
258
+ // An app tool outside the run's own subset never reaches the host; builtins stay with the gate.
259
+ if (this.tools.has(tool) && (this.live.get(run.sessionKey) ?? []).some((e) => e.tools && !e.tools.has(tool)))
260
+ return this.deny(socket, 'this tool is not available in this run');
238
261
  let result;
239
262
  try {
240
263
  result = await this.host.gate(run, tool, input, { builtin: !this.tools.has(tool) });
package/dist/device.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { DeviceLink } from '@byokit/link';
2
2
  import { type AccountView } from './words.ts';
3
3
  import type { Approval, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, KitState, Route, RunEnd, RunEvent, SignInView } from './types.ts';
4
4
  export type { GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult } from './types.ts';
5
- export type { Approval, Decision, KitState, Route, RunEnd, RunEvent, SignInView } from './types.ts';
5
+ export type { Approval, Decision, KitState, PlanWindow, Route, RunEnd, RunEvent, RunUsage, SignInView } from './types.ts';
6
6
  /** One `oc.events` frame: a member's Gateway event, typed by name, or an approval add/resolve (7.2). */
7
7
  export type OpenClawLinkEvent = {
8
8
  [E in GatewayEventName]: {
@@ -19,6 +19,26 @@ export type SessionRow = {
19
19
  sessionKey: string;
20
20
  [k: string]: unknown;
21
21
  };
22
+ /** `oc.run`'s options beside the message: the member's own session, account, and the kit's run options. */
23
+ export type DeviceRunOptions = {
24
+ sessionKey?: string;
25
+ model?: string;
26
+ system?: string;
27
+ images?: {
28
+ data: string;
29
+ mimeType: string;
30
+ }[];
31
+ thinking?: 'off' | 'low' | 'medium' | 'high';
32
+ tools?: string[];
33
+ };
34
+ /** `oc.state`: the engine phase and words, this kit's and the engine's versions, and the device member's sign-ins. */
35
+ export type DeviceState = {
36
+ state: KitState;
37
+ words: string;
38
+ version: string;
39
+ engine: string;
40
+ signedIn?: string[];
41
+ };
22
42
  /**
23
43
  * The host ended a stream with its own words (e.g. a refusal). Portable twin of link's PublicLinkError:
24
44
  * same shape ({ code: 'failed', sealed: true }) so a screen can show .message the same way. A device-side
@@ -30,10 +50,7 @@ export declare class LinkRefused extends Error {
30
50
  constructor(message: string);
31
51
  }
32
52
  export declare function openclawDevice(link: DeviceLink): {
33
- state(): Promise<{
34
- state: KitState;
35
- words: string;
36
- }>;
53
+ state(): Promise<DeviceState>;
37
54
  routes(): Promise<Route[]>;
38
55
  signIn: {
39
56
  start(p: string, via: 'browser' | 'code'): Promise<SignInView>;
@@ -43,10 +60,7 @@ export declare function openclawDevice(link: DeviceLink): {
43
60
  };
44
61
  signOut(p: string): Promise<void>;
45
62
  sessions(): Promise<SessionRow[]>;
46
- run(message: string, o?: {
47
- sessionKey?: string;
48
- model?: string;
49
- }): AsyncIterable<RunEvent | {
63
+ run(message: string, o?: DeviceRunOptions): AsyncIterable<RunEvent | {
50
64
  type: 'end';
51
65
  end: RunEnd;
52
66
  }>;
package/dist/device.js CHANGED
@@ -138,7 +138,9 @@ export function openclawDevice(link) {
138
138
  sessions: () => link.request('oc.sessions'),
139
139
  run: (message, o) => {
140
140
  const inner = liveStream(() => link.stream('oc.run', { message, ...(o?.sessionKey ? { sessionKey: o.sessionKey } : {}),
141
- ...(o?.model !== undefined ? { model: o.model } : {}) }));
141
+ ...(o?.model !== undefined ? { model: o.model } : {}), ...(o?.system !== undefined ? { system: o.system } : {}),
142
+ ...(o?.images !== undefined ? { images: o.images } : {}), ...(o?.thinking !== undefined ? { thinking: o.thinking } : {}),
143
+ ...(o?.tools !== undefined ? { tools: o.tools } : {}) }));
142
144
  let finished = false;
143
145
  return {
144
146
  [Symbol.asyncIterator]() {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { ENGINE_VERSION, OPERATOR_SCOPES, PROTOCOL_VERSION } from './constants.ts';
2
- export type { Approval, CallOptions, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, GateResult, Hello, KitState, Member, Route, RunEnd, RunEvent, RunRef, RunSpec, SignInView, ToolHost, ToolSpec, } from './types.ts';
2
+ export type { Approval, CallOptions, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, GateResult, Hello, KitState, Member, PlanWindow, Route, RunEnd, RunEvent, RunRef, RunSpec, RunUsage, SignInView, ToolHost, ToolSpec, } from './types.ts';
3
3
  export { OpenClawKit, type KitOptions, type RetainedLogin } from './kit.ts';
4
4
  export { stateWords, toAccountView, words, type AccountView, type WordKey } from './words.ts';
package/dist/kit.d.ts CHANGED
@@ -85,6 +85,8 @@ export declare class OpenClawKit {
85
85
  migrateRetainedLogin(member: Member, source: RetainedLogin): Promise<'staged' | 'nothing' | 'failed'>;
86
86
  confirmRetainedLogin(member: Member, source: RetainedLogin): Promise<boolean>;
87
87
  private runs;
88
+ /** The app tool names (`KitOptions.tools`): the only names `RunSpec.tools` may carry. */
89
+ toolNames(): string[];
88
90
  run(spec: RunSpec, on?: (e: RunEvent) => void): Promise<RunEnd>;
89
91
  steer(sessionKey: string, text: string): Promise<void>;
90
92
  abort(sessionKey: string): Promise<void>;
package/dist/kit.js CHANGED
@@ -611,8 +611,13 @@ export class OpenClawKit {
611
611
  },
612
612
  ensure: (member) => this.ensureMember(member),
613
613
  bridge: this.bridge,
614
+ tools: new Set(this.toolNames()),
614
615
  });
615
616
  }
617
+ /** The app tool names (`KitOptions.tools`): the only names `RunSpec.tools` may carry. */
618
+ toolNames() {
619
+ return (this.o.tools ?? []).map((t) => t.name);
620
+ }
616
621
  run(spec, on) {
617
622
  return this.runs().run(spec, on);
618
623
  }
package/dist/link.js CHANGED
@@ -1,14 +1,20 @@
1
1
  // Host-side link adapter (7.1): typed, member-checked ops over @byokit/link, sealed approval push
2
2
  // via the relay (7.3). Node only.
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { readFileSync } from 'node:fs';
4
5
  import { createServer } from 'node:http';
5
6
  import { WebSocketServer } from 'ws';
6
7
  import { PublicLinkError, } from '@byokit/link';
7
8
  import { reach } from '@byokit/reach';
9
+ import { ENGINE_VERSION } from "./constants.js";
8
10
  import { b64urlDecode, sealNotice } from "./notices.js";
9
11
  import { routeFor } from "./routes.js";
12
+ import { signedInProviders } from "./runs.js";
10
13
  import { stateWords, words } from "./words.js";
11
14
  const VIEW_OPS = new Set(['oc.state', 'oc.routes', 'oc.signin.view', 'oc.sessions', 'oc.approvals', 'oc.events']);
15
+ // This kit's own version, read once from its package.json (beside src/ and dist/ alike).
16
+ const KIT_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
17
+ const THINKING = new Set(['off', 'low', 'medium', 'high']);
12
18
  const refused = () => new PublicLinkError(words('link.notAllowed'));
13
19
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
14
20
  const memberKey = (member, sessionKey) => sessionKey.startsWith(`agent:${member}:`);
@@ -16,6 +22,27 @@ function memberSessions(result, member) {
16
22
  const rows = Array.isArray(result) ? result : isRecord(result) && Array.isArray(result.sessions) ? result.sessions : [];
17
23
  return rows.filter((row) => isRecord(row) && typeof row.sessionKey === 'string' && memberKey(member, row.sessionKey));
18
24
  }
25
+ /** `oc.run`'s run options, type-checked; the kit's own checks (account, tool names) still apply. */
26
+ function runOptions(args) {
27
+ const { message, model, system, images, thinking, tools } = args;
28
+ const ok = typeof message === 'string'
29
+ && (model === undefined || typeof model === 'string')
30
+ && (system === undefined || typeof system === 'string')
31
+ && (images === undefined || (Array.isArray(images) && images.every((i) => isRecord(i) && typeof i.data === 'string' && typeof i.mimeType === 'string')))
32
+ && (thinking === undefined || (typeof thinking === 'string' && THINKING.has(thinking)))
33
+ && (tools === undefined || (Array.isArray(tools) && tools.every((t) => typeof t === 'string')));
34
+ if (!ok)
35
+ throw new Error('oc.run needs { message, sessionKey?, model?, system?, images?, thinking?, tools? }');
36
+ return {
37
+ message: message,
38
+ ...(model === undefined ? {} : { model: model }),
39
+ ...(system === undefined ? {} : { system: system }),
40
+ ...(images === undefined ? {} : { images: images
41
+ .map((i) => ({ data: i.data, mimeType: i.mimeType })) }),
42
+ ...(thinking === undefined ? {} : { thinking: thinking }),
43
+ ...(tools === undefined ? {} : { tools: [...tools] }),
44
+ };
45
+ }
19
46
  export function openclawLink(kit, o) {
20
47
  const passThrough = o.passThrough ?? (() => false);
21
48
  // Grants that have called, so sealed pushes and push actions can check the member (7.3).
@@ -69,14 +96,29 @@ export function openclawLink(kit, o) {
69
96
  return null;
70
97
  };
71
98
  const endReason = (error) => error instanceof PublicLinkError ? error.message.slice(0, 200) : 'failed';
99
+ const signIns = async (member) => {
100
+ try {
101
+ const { agentId } = await kit.ensureMember(member);
102
+ const status = await kit.call('models.authStatus', { agentId }, { timeoutMs: 5_000 });
103
+ return signedInProviders(status);
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ };
72
109
  const handle = async (req, grant) => {
73
110
  const member = memberOf(grant);
74
111
  if (grant.role === 'view' && !VIEW_OPS.has(req.op))
75
112
  throw refused();
76
113
  const args = isRecord(req.args) ? req.args : {};
77
114
  switch (req.op) {
78
- case 'oc.state':
79
- return { state: kit.state, words: stateWords(kit.state) };
115
+ case 'oc.state': {
116
+ // signedIn: the providers the device member is usably signed in to (an expired or unfinished sign-in is not),
117
+ // only while the engine can say: absent is unknown, never "none".
118
+ const signedIn = kit.state.phase === 'ready' ? await signIns(member) : undefined;
119
+ return { state: kit.state, words: stateWords(kit.state), version: KIT_VERSION, engine: ENGINE_VERSION,
120
+ ...(signedIn ? { signedIn } : {}) };
121
+ }
80
122
  case 'oc.routes':
81
123
  return kit.routes().filter((route) => route.offer);
82
124
  case 'oc.signin.start': {
@@ -190,19 +232,21 @@ export function openclawLink(kit, o) {
190
232
  if (grant.role === 'view')
191
233
  throw refused();
192
234
  const args = isRecord(req.args) ? req.args : {};
193
- if (typeof args.message !== 'string' || (args.model !== undefined && typeof args.model !== 'string'))
194
- throw new Error('oc.run needs { message, sessionKey?, model? }');
235
+ const options = runOptions(args);
195
236
  const sessionKey = args.sessionKey === undefined ? `agent:${member}:link:${randomUUID()}` : args.sessionKey;
196
237
  if (typeof sessionKey !== 'string' || !memberKey(member, sessionKey))
197
238
  throw refused();
239
+ // A tool the kit does not register is not this device's to name.
240
+ const known = new Set(kit.toolNames());
241
+ if (options.tools?.some((tool) => !known.has(tool)))
242
+ throw refused();
198
243
  // Ordered frames over the paced stream: each write waits for the last.
199
244
  let tail = Promise.resolve();
200
245
  const send = (frame) => {
201
246
  tail = tail.then(() => s.write(`${JSON.stringify(frame)}\n`)).catch(() => { });
202
247
  };
203
248
  try {
204
- const end = await kit.run({ member, sessionKey, message: args.message,
205
- ...(args.model === undefined ? {} : { model: args.model }) }, (e) => send(e));
249
+ const end = await kit.run({ member, sessionKey, ...options }, (e) => send(e));
206
250
  send({ type: 'end', end });
207
251
  await tail;
208
252
  s.end();
package/dist/runs.d.ts CHANGED
@@ -1,12 +1,18 @@
1
1
  import type { Bridge } from './bridge.ts';
2
2
  import type { GatewayTransport, Member, RunEnd, RunEvent, RunSpec } from './types.ts';
3
+ /**
4
+ * The providers a `models.authStatus` answer shows usable (the rule a picked account is checked by), lowercased and
5
+ * unique; undefined while the engine has no prepared status (unknown, not none).
6
+ */
7
+ export declare function signedInProviders(status: unknown): string[] | undefined;
3
8
  export declare function createRuns(ctx: {
4
9
  request: GatewayTransport['request'];
5
10
  onEvent: GatewayTransport['onEvent'];
6
11
  ensure(member: Member): Promise<{
7
12
  agentId: string;
8
13
  }>;
9
- bridge: Pick<Bridge, 'register' | 'unregister'>;
14
+ bridge: Pick<Bridge, 'register'>;
15
+ tools: ReadonlySet<string>;
10
16
  }): {
11
17
  run(spec: RunSpec, on?: (e: RunEvent) => void): Promise<RunEnd>;
12
18
  steer(k: string, t: string): Promise<void>;
package/dist/runs.js CHANGED
@@ -1,13 +1,58 @@
1
1
  // Runs: one Gateway run per spec, streamed through typed events, ending in a classified RunEnd (5.8, O8).
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { setTimeout as delay } from 'node:timers/promises';
3
4
  import { classify } from "./classify.js";
4
5
  // The gateway gives up on a silent run after an hour; we wait ten minutes longer for the reply frame itself.
5
6
  const WAIT_MS = 3_600_000;
6
7
  const WAIT_CLIENT_MS = 3_610_000;
8
+ // The `agent` request's final frame lands with the run's end; a run already over waits no longer than this for it.
9
+ const FINAL_GRACE_MS = 5_000;
10
+ const STATUS_MS = 5_000;
7
11
  const USABLE = new Set(['ok', 'expiring', 'static']);
8
12
  const usable = (row) => typeof row === 'string'
9
13
  || (Array.isArray(row.profiles) && row.profiles.length > 0 ? row.profiles.some((p) => USABLE.has(String(p?.status)))
10
14
  : row.status === undefined || USABLE.has(String(row.status)));
15
+ /**
16
+ * The providers a `models.authStatus` answer shows usable (the rule a picked account is checked by), lowercased and
17
+ * unique; undefined while the engine has no prepared status (unknown, not none).
18
+ */
19
+ export function signedInProviders(status) {
20
+ const auth = (status ?? {});
21
+ if (auth.unavailable || !Array.isArray(auth.providers))
22
+ return undefined;
23
+ const names = auth.providers.filter((row) => row && usable(row))
24
+ .map((row) => (typeof row === 'string' ? row : row.provider))
25
+ .filter((name) => typeof name === 'string' && name !== '');
26
+ return [...new Set(names.map((name) => name.toLowerCase()))];
27
+ }
28
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
29
+ const count = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
30
+ /**
31
+ * The run's usage off the `agent` final frame's `result.meta.agentMeta` (pin: `usage` summed over the run, zero
32
+ * buckets and all-zero usage omitted; `costUsd` only when the engine has a price). Undefined when it reports none.
33
+ */
34
+ function usageOf(agentMeta) {
35
+ const u = isRecord(agentMeta.usage) ? agentMeta.usage : {};
36
+ const usage = {};
37
+ for (const [from, to] of [['input', 'input'], ['output', 'output'], ['cacheRead', 'cacheRead'], ['cacheWrite', 'cacheWrite'],
38
+ ['reasoningTokens', 'reasoning'], ['total', 'total']])
39
+ if (count(u[from]))
40
+ usage[to] = u[from];
41
+ if (count(agentMeta.costUsd))
42
+ usage.costUsd = agentMeta.costUsd;
43
+ return Object.keys(usage).length ? usage : undefined;
44
+ }
45
+ /** The provider's quota windows from a `models.authStatus` row's `usage`, only when it lists at least one. */
46
+ function planOf(provider, row) {
47
+ const usage = row?.usage;
48
+ if (!isRecord(usage) || !Array.isArray(usage.windows))
49
+ return undefined;
50
+ const windows = usage.windows.filter((w) => isRecord(w) && typeof w.label === 'string' && typeof w.usedPercent === 'number' && Number.isFinite(w.usedPercent))
51
+ .map((w) => ({ label: w.label, usedPercent: w.usedPercent, ...(count(w.resetAt) ? { resetAt: w.resetAt } : {}) }));
52
+ if (!windows.length)
53
+ return undefined;
54
+ return { provider, ...(typeof usage.plan === 'string' ? { plan: usage.plan } : {}), windows };
55
+ }
11
56
  /**
12
57
  * The account a run names, `provider/model`, split for the `agent` request's per-run `provider`/`model` override.
13
58
  * Refused before any request when malformed or when it carries an `@profile` pin: the pin only reaches the engine as
@@ -20,15 +65,28 @@ function account(model) {
20
65
  return { provider: model.slice(0, at).toLowerCase(), model: model.slice(at + 1) }; // the engine lowercases ids
21
66
  }
22
67
  export function createRuns(ctx) {
68
+ // The plan window the engine last read for the run's provider: `models.authStatus` answers from its cache without
69
+ // blocking. Nothing reported, or no answer in time, is no window.
70
+ const planWindowOf = async (agentId, provider) => {
71
+ try {
72
+ const auth = await ctx.request('models.authStatus', { agentId }, { timeoutMs: STATUS_MS });
73
+ const row = (auth.providers ?? []).find((e) => typeof e !== 'string' && e?.provider === provider);
74
+ return planOf(provider, row);
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ };
23
80
  const run = async (spec, on) => {
24
81
  // Member boundary first: a member never speaks in another member's session, refused before any request.
25
82
  if (!spec.sessionKey.startsWith(`agent:${spec.member}:`))
26
83
  throw new Error(`refused: "${spec.sessionKey}" is not a session of member "${spec.member}"`);
27
84
  const picked = spec.model === undefined ? undefined : account(spec.model);
85
+ for (const tool of spec.tools ?? [])
86
+ if (!ctx.tools.has(tool))
87
+ throw new Error(`refused: "${tool}" is not one of this kit's tools`);
28
88
  const { agentId } = await ctx.ensure(spec.member);
29
- const registered = spec.register !== false;
30
- if (registered)
31
- ctx.bridge.register({ sessionKey: spec.sessionKey, member: spec.member });
89
+ const release = spec.register !== false ? ctx.bridge.register({ sessionKey: spec.sessionKey, member: spec.member }, spec.tools) : undefined;
32
90
  let last = '';
33
91
  let runId = ''; // gateway events for other runs carry a real runId and never match the empty one
34
92
  let ended = false;
@@ -49,8 +107,17 @@ export function createRuns(ctx) {
49
107
  on?.({ type: 'text', text: p.data.text });
50
108
  }
51
109
  else if (p.stream === 'tool' && typeof p.data?.name === 'string') {
52
- // The real engine marks completion `phase: 'result'` (O11); anything but `start` ends the pair.
53
- on?.({ type: 'tool', name: p.data.name, phase: p.data.phase === 'start' ? 'start' : 'end' });
110
+ // The real engine marks completion `phase: 'result'` (O11). Its `update`, `input_delta` and `review` phases
111
+ // are progress inside the pair, not its end.
112
+ const d = p.data;
113
+ const end = d.phase === 'result' || d.phase === 'end';
114
+ if (d.phase !== 'start' && !end)
115
+ return;
116
+ on?.({ type: 'tool', name: d.name, phase: end ? 'end' : 'start',
117
+ ...(typeof d.toolCallId === 'string' ? { id: d.toolCallId } : {}),
118
+ ...(!end && isRecord(d.args) ? { input: d.args } : {}),
119
+ ...(end && d.result !== undefined ? { output: d.result } : {}),
120
+ ...(end && typeof d.isError === 'boolean' ? { error: d.isError } : {}) });
54
121
  }
55
122
  });
56
123
  try {
@@ -68,7 +135,17 @@ export function createRuns(ctx) {
68
135
  if (!row || !usable(row))
69
136
  return { ok: false, kind: 'signed-out', message: `${picked.provider} is not signed in for ${spec.member}` };
70
137
  }
71
- const started = await ctx.request('agent', {
138
+ // One `agent` request: its interim `accepted` frame names the run, its final frame (after the run) carries the
139
+ // run's usage. The wait below still decides how the run ended.
140
+ let accepted;
141
+ const ack = new Promise((resolve) => { accepted = resolve; });
142
+ // The run id is taken as the accepted frame lands, so no event read after it is missed.
143
+ const onAccepted = (payload) => {
144
+ if (isRecord(payload) && typeof payload.runId === 'string')
145
+ runId = payload.runId;
146
+ accepted(payload);
147
+ };
148
+ const final = ctx.request('agent', {
72
149
  agentId,
73
150
  sessionKey: spec.sessionKey,
74
151
  message: spec.message,
@@ -77,13 +154,21 @@ export function createRuns(ctx) {
77
154
  ...(spec.system ? { extraSystemPrompt: spec.system } : {}),
78
155
  ...(spec.images ? { attachments: spec.images.map((image) => ({ mimeType: image.mimeType, content: image.data })) } : {}),
79
156
  ...(spec.thinking ? { thinking: spec.thinking } : {}),
80
- });
157
+ }, { expectFinal: true, timeoutMs: WAIT_CLIENT_MS, onAccepted });
158
+ final.catch(() => { }); // a late failure is the wait's to report
159
+ const started = await Promise.race([ack, final]);
81
160
  runId = started.runId;
82
161
  const result = await ctx.request('agent.wait', { runId, timeoutMs: WAIT_MS }, { timeoutMs: WAIT_CLIENT_MS });
83
162
  if (result.status === 'ok') {
84
163
  const text = typeof result.terminalReply?.text === 'string' ? result.terminalReply.text : last;
85
164
  on?.({ type: 'text', text }); // the final cumulative text
86
- return { ok: true, text };
165
+ const done = await Promise.race([final.catch(() => undefined), delay(FINAL_GRACE_MS, undefined, { ref: false })]);
166
+ const meta = isRecord(done) && isRecord(done.result) && isRecord(done.result.meta) ? done.result.meta : {};
167
+ const agentMeta = isRecord(meta.agentMeta) ? meta.agentMeta : {};
168
+ const usage = usageOf(agentMeta);
169
+ const planWindow = typeof agentMeta.provider === 'string'
170
+ ? await planWindowOf(agentId, agentMeta.provider.toLowerCase()) : undefined;
171
+ return { ok: true, text, ...(usage ? { usage } : {}), ...(planWindow ? { planWindow } : {}) };
87
172
  }
88
173
  if (result.stopReason === 'aborted' || (abortedByEngine && result.status !== 'ok'))
89
174
  return { ok: false, aborted: true };
@@ -98,8 +183,7 @@ export function createRuns(ctx) {
98
183
  finally {
99
184
  ended = true;
100
185
  unsubscribe();
101
- if (registered)
102
- ctx.bridge.unregister(spec.sessionKey);
186
+ release?.();
103
187
  }
104
188
  };
105
189
  return {
@@ -144,34 +144,65 @@ export function fakeGateway(script) {
144
144
  const startRun = (params, bridgeSock) => {
145
145
  if (params.agentId != null && !agents.has(String(params.agentId)))
146
146
  throw new Error(`unknown agent: ${params.agentId}`);
147
- const run = { runId: randomUUID(), sessionKey: String(params.sessionKey ?? ''), seq: 0, waiters: [] };
147
+ // Like the engine: the provider/model the run is called on, its own override or the member's (here openai).
148
+ const run = { runId: randomUUID(), sessionKey: String(params.sessionKey ?? ''), seq: 0,
149
+ provider: String(params.provider ?? 'openai'), model: String(params.model ?? 'gpt-5.1'), waiters: [] };
148
150
  runs.set(run.runId, run);
149
151
  const message = String(params.message ?? '');
150
152
  const text = `fake: ${message}`;
151
153
  setTimeout(() => {
152
154
  void (async () => {
153
155
  const table = bridgeSock ? pluginTable(bridgeSock) : undefined;
156
+ let n = 0;
154
157
  for (const call of toolCalls(message)) {
155
- emitRun(run, 'tool', { name: call.name, phase: 'start' });
158
+ const toolCallId = `call-${++n}`;
159
+ emitRun(run, 'tool', { phase: 'start', name: call.name, toolCallId, args: call.input });
156
160
  // As the plugin: an engine builtin is gated unless the app opted out, and an allowed one never calls back.
157
161
  const builtin = !!table && !table.tools.some((t) => t.name === call.name);
162
+ let output = `${call.name} ran`;
163
+ let isError = false;
158
164
  if (bridgeSock && !(builtin && table?.gateBuiltins === false)) {
159
165
  try {
160
166
  const gate = await bridgeRequest(bridgeSock, { kind: 'gate', key: run.sessionKey, tool: call.name, input: call.input });
161
- if (gate.allow && !builtin)
162
- await bridgeRequest(bridgeSock, { kind: 'call', key: run.sessionKey, permit: gate.permit, tool: call.name, input: call.input });
167
+ if (!gate.allow) {
168
+ isError = true;
169
+ output = String(gate.reason ?? 'blocked');
170
+ }
171
+ else if (!builtin) {
172
+ const done = await bridgeRequest(bridgeSock, { kind: 'call', key: run.sessionKey, permit: gate.permit, tool: call.name, input: call.input });
173
+ isError = done.ok !== true;
174
+ output = String(done.ok === true ? done.text : done.reason);
175
+ }
163
176
  }
164
177
  catch { /* no bridge or refused: the tool pair still plays, nothing is called */ }
165
178
  }
166
- // Completion rides `phase: 'result'` like the real engine (O11), not `'end'`.
167
- emitRun(run, 'tool', { name: call.name, phase: 'result' });
179
+ // Completion rides `phase: 'result'` like the real engine (O11), not `'end'`, with its result.
180
+ emitRun(run, 'tool', { phase: 'result', name: call.name, toolCallId, isError,
181
+ result: { content: [{ type: 'text', text: output }] } });
168
182
  }
169
183
  emitRun(run, 'assistant', { text });
184
+ // The engine sums the run's usage (zero buckets omitted); the fake counts characters.
185
+ run.usage = { input: message.length, output: text.length, total: message.length + text.length };
170
186
  setTimeout(() => finish(run, { status: 'ok', terminalReply: { text } }), ABORT_WINDOW_MS);
171
187
  })();
172
188
  }, 0);
173
- return { runId: run.runId };
189
+ return { runId: run.runId, status: 'accepted' };
174
190
  };
191
+ /** The `agent` request's final frame (pin): the run's end with `result.meta.agentMeta`, once the run is over. */
192
+ const finalOf = (runId) => new Promise((resolve) => {
193
+ const run = runs.get(runId);
194
+ const settle = (result) => {
195
+ const ok = result.status === 'ok';
196
+ resolve({ runId, status: ok ? 'ok' : 'error', summary: ok ? 'completed' : 'failed',
197
+ ...(ok ? {} : { stopReason: result.stopReason }),
198
+ result: { payloads: ok ? [{ text: result.terminalReply?.text }] : [],
199
+ meta: { agentMeta: { provider: run.provider, model: run.model, ...(run.usage ? { usage: run.usage } : {}) } } } });
200
+ };
201
+ if (run.result)
202
+ settle(run.result);
203
+ else
204
+ run.waiters.push(settle);
205
+ });
175
206
  const defaults = {
176
207
  health: () => ({ ok: true, plugins: { loaded: [] } }),
177
208
  'agents.list': () => ({
@@ -339,10 +370,16 @@ export function fakeGateway(script) {
339
370
  throw new Error(closedWhy());
340
371
  return hello();
341
372
  },
342
- request: async (method, params) => {
373
+ request: async (method, params, o) => {
343
374
  if (!entry.open)
344
375
  throw new Error(closedWhy());
345
- return dispatch(method, params, sock);
376
+ const reply = await dispatch(method, params, sock);
377
+ // expectFinal: the interim accepted frame goes to onAccepted, the request settles with the final one.
378
+ if (!o?.expectFinal || !isPlainObject(reply) || reply.status !== 'accepted' || typeof reply.runId !== 'string'
379
+ || !runs.has(reply.runId))
380
+ return reply;
381
+ o.onAccepted?.(reply);
382
+ return finalOf(reply.runId);
346
383
  },
347
384
  onEvent: (fn) => (entry.events.add(fn), () => entry.events.delete(fn)),
348
385
  onClose: (fn) => (entry.closes.add(fn), () => entry.closes.delete(fn)),
@@ -1,3 +1,3 @@
1
1
  export { fakeGateway, type FakeScript } from './fake-gateway.ts';
2
2
  export { openclawContract, type ContractFixture } from './contract.ts';
3
- export { startModelStub, useModelStub, releaseStub, stubHolding, toolCalls, type ModelStub, type ModelStubOptions, type StubCall } from './model-stub.ts';
3
+ export { STUB_USAGE, startModelStub, useModelStub, releaseStub, stubHolding, toolCalls, type ModelStub, type ModelStubOptions, type StubCall } from './model-stub.ts';
@@ -1,4 +1,4 @@
1
1
  // The kit's fakes, contract suite and scripted model (5.11). Built in O7.
2
2
  export { fakeGateway } from "./fake-gateway.js";
3
3
  export { openclawContract } from "./contract.js";
4
- export { startModelStub, useModelStub, releaseStub, stubHolding, toolCalls } from "./model-stub.js";
4
+ export { STUB_USAGE, startModelStub, useModelStub, releaseStub, stubHolding, toolCalls } from "./model-stub.js";
@@ -4,6 +4,12 @@ export type StubCall = {
4
4
  path: string;
5
5
  body: any;
6
6
  };
7
+ /** The token usage every stub reply reports when asked (`stream_options.include_usage`), per model call. */
8
+ export declare const STUB_USAGE: {
9
+ readonly prompt_tokens: 11;
10
+ readonly completion_tokens: 7;
11
+ readonly total_tokens: 18;
12
+ };
7
13
  export type ModelStub = {
8
14
  port: number;
9
15
  url: string;
@@ -10,10 +10,13 @@
10
10
  // ask permission hold the turn (after its tool calls) until `releaseStub` lets it finish
11
11
  // [route ID] asked who should take a request: ID, fairly sure; [route ?]: torn evenly; neither: Chief, sure
12
12
  // anything else reply `stub <bot>: done with "<the last line of the message>"`
13
+ // Every scripted reply that asks `stream_options.include_usage` ends with a usage chunk of STUB_USAGE, as OpenAI does.
13
14
  // The bot id (`idPattern`, default /Your id is ([a-z0-9-]+)\./) is read from the system prompt, and routing
14
15
  // requests start with `routingMarker` (default '[routing]'); pass both explicitly to mirror another app's grammar.
15
16
  import { createServer } from 'node:http';
16
17
  import { randomUUID } from 'node:crypto';
18
+ /** The token usage every stub reply reports when asked (`stream_options.include_usage`), per model call. */
19
+ export const STUB_USAGE = { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 };
17
20
  /** The tool calls in the script, each with its arguments. Braces are balanced, so a script may nest. */
18
21
  export function toolCalls(text) {
19
22
  const out = [];
@@ -126,9 +129,14 @@ export function startModelStub(script = [], o = {}) {
126
129
  const id = randomUUID();
127
130
  res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
128
131
  const send = (delta, finish = null) => res.write(`data: ${JSON.stringify({ id, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: body.model, choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`);
132
+ const usage = () => {
133
+ if (body.stream_options?.include_usage === true)
134
+ res.write(`data: ${JSON.stringify({ id, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: body.model, choices: [], usage: STUB_USAGE })}\n\n`);
135
+ };
129
136
  if (next) {
130
137
  send({ role: 'assistant', tool_calls: [{ index: 0, id: `call_${id}`, type: 'function', function: { name: next.name, arguments: JSON.stringify(next.input) } }] });
131
138
  send({}, 'tool_calls');
139
+ usage();
132
140
  return void res.end('data: [DONE]\n\n');
133
141
  }
134
142
  let text = queue.length ? queue.shift() : done();
@@ -141,6 +149,7 @@ export function startModelStub(script = [], o = {}) {
141
149
  }
142
150
  send({ role: 'assistant', content: text });
143
151
  send({}, 'stop');
152
+ usage();
144
153
  res.end('data: [DONE]\n\n');
145
154
  });
146
155
  return new Promise((resolve) => {
package/dist/types.d.ts CHANGED
@@ -34,6 +34,7 @@ export interface RunSpec extends RunRef {
34
34
  }[];
35
35
  thinking?: 'off' | 'low' | 'medium' | 'high';
36
36
  model?: string;
37
+ tools?: string[];
37
38
  register?: boolean;
38
39
  }
39
40
  export type RunEvent = {
@@ -43,10 +44,36 @@ export type RunEvent = {
43
44
  type: 'tool';
44
45
  name: string;
45
46
  phase: 'start' | 'end';
47
+ id?: string;
48
+ input?: Record<string, unknown>;
49
+ output?: unknown;
50
+ error?: boolean;
51
+ };
52
+ /** Token usage of one run as the engine totals it (every model call of the run, compaction included). */
53
+ export type RunUsage = {
54
+ input?: number;
55
+ output?: number;
56
+ cacheRead?: number;
57
+ cacheWrite?: number;
58
+ reasoning?: number;
59
+ total?: number;
60
+ costUsd?: number;
61
+ };
62
+ /** A subscription's quota windows, as the engine last read them from the provider (never estimated by the kit). */
63
+ export type PlanWindow = {
64
+ provider: string;
65
+ plan?: string;
66
+ windows: {
67
+ label: string;
68
+ usedPercent: number;
69
+ resetAt?: number;
70
+ }[];
46
71
  };
47
72
  export type RunEnd = {
48
73
  ok: true;
49
74
  text: string;
75
+ usage?: RunUsage;
76
+ planWindow?: PlanWindow;
50
77
  } | {
51
78
  ok: false;
52
79
  aborted: true;
@@ -109,6 +136,8 @@ export interface GatewayTransport {
109
136
  request(method: string, params?: unknown, o?: {
110
137
  timeoutMs?: number;
111
138
  signal?: AbortSignal;
139
+ expectFinal?: boolean;
140
+ onAccepted?: (payload: unknown) => void;
112
141
  }): Promise<unknown>;
113
142
  onEvent(fn: (e: {
114
143
  event: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.1.4",
3
+ "version": "0.2.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",
@@ -51,9 +51,9 @@
51
51
  "prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
52
52
  },
53
53
  "dependencies": {
54
- "@byokit/link": "0.4.0",
55
- "@byokit/relay": "0.3.0",
56
- "@byokit/reach": "0.3.0",
54
+ "@byokit/link": "0.5.0",
55
+ "@byokit/relay": "0.3.1",
56
+ "@byokit/reach": "0.4.0",
57
57
  "@byokit/seal": "0.2.0",
58
58
  "@openclaw/gateway-client": "2026.8.1",
59
59
  "@openclaw/gateway-protocol": "2026.8.1",