@byokit/openclaw 0.16.6 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.18.0 (2026-10-18)
6
+
7
+ - Reattach a paired device to a live-kit run by its existing idempotency key: sequenced replay then live events, a bounded journal with typed truncation, and grant-member isolation. Device disconnect does not interrupt the run; engine-restart reconstruction is not included.
8
+
9
+ ## 0.17.0 (2026-10-18)
10
+
11
+ - Add opt-in `RunSpec.delegates` for native `sessions_send`/`sessions_spawn` app-tool admission and typed delegated events, retained exact receipts and subtree cancellation. Targets retain their own accounts; helper/person account sharing is not included.
12
+ - SECURITY: Delegated authority requires a trusted native parent/tool-call receipt and exact child run/session identity; target tools intersect the parent subset, builtins remain gated, unknown children fail closed, and cancellation revokes parked and active calls before awaiting engine cleanup.
13
+
5
14
  ## 0.16.6 (2026-10-18)
6
15
 
7
16
  - SECURITY: Run `openai-codex/<model>` through the installed Codex CLI on the ChatGPT subscription using the pinned engine's supported CLI backend; keep engine state isolated and prevent the engine from importing or refreshing the installed Codex login.
package/README.md CHANGED
@@ -422,6 +422,29 @@ of 394 methods' params and 193 of their results, plus 21 of 55 event payloads; t
422
422
  the kit, they are not published by the engine, and typing them here would be a guess that does not match the
423
423
  gateway. `callDynamic` stays the same shape for a method the pin never declared.
424
424
 
425
+ ## Native delegated runs (opt-in)
426
+
427
+ Pass `delegates: { scout: ['report'] }` on `kit.run` to permit native delegation to that exact engine agent with
428
+ that app-tool subset. Ensure the target member first and configure the engine's own A2A/visibility/subagent policy;
429
+ this option does not enable engine tools or bypass `host.gate`. The parent subset narrows descendants too.
430
+ Tracked `sessions_send` requires an explicit full `sessionKey` and `timeoutSeconds: 0`; tracked `sessions_spawn`
431
+ requires an explicit allowed `agentId` and native runtime (not ACP). Unknown children fail closed.
432
+
433
+ `kit.delegatedRuns()` lists admitted children. `kit.onEvent('byokit.delegated', fn)` reports admission, native streams
434
+ and terminal observations with exact `runId`, `agentId`, `member`, `sessionKey`, `parent` and `root` identity.
435
+ The same identity reaches `ToolHost`; each delegated action has its own gate/approval. Tool arguments/results
436
+ are omitted from this event surface. An accepted native dispatch is not finished work or proof of delivery.
437
+
438
+ Admitted exact run IDs survive parent settlement and are reconciled on reconnect/start. `abort(rootSessionKey)`
439
+ durably revokes its recorded subtree, closes parked gates/active calls, then asks the engine to abort; a child key
440
+ revokes its subtree. An engine resume with a different run ID, or a dispatch with no retained receipt, is not
441
+ silently granted authority. No prefix grants or `allowOnce` bypass are used.
442
+
443
+ **Per-helper account binding is not included:** targets retain their own existing engine credentials, persona,
444
+ workspace and history. This option never copies credentials or gives helpers another person's account. Legacy
445
+ in-flight jobs may retain their exact old session keys and approval owners; all new helper work uses the new
446
+ mapping. Never copy agent SQLite or replay old sent actions. See the [binding contract](../../docs/runtime-kits.md#native-delegated-tool-admission-opt-in).
447
+
425
448
  ## Owned browser (unqualified handoff)
426
449
 
427
450
  `browser: { executablePath, members }` opts into isolated member Chromium profiles. It requires a closed
package/dist/bridge.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Delegations } from './delegations.ts';
1
2
  import type { Approval, ChannelMessage, Decision, Member, RunRef, ToolHost, ToolSpec } from './types.ts';
2
3
  /**
3
4
  * Asks must expire before the plugin's 195 s gate timeout, or the run gets a generic block while the approval
@@ -42,6 +43,8 @@ export declare class Bridge {
42
43
  private server?;
43
44
  private closing;
44
45
  private readonly sockets;
46
+ private readonly owners;
47
+ private readonly delegations?;
45
48
  private readonly runs;
46
49
  private readonly live;
47
50
  private readonly permits;
@@ -51,6 +54,7 @@ export declare class Bridge {
51
54
  private stopped;
52
55
  constructor(o: {
53
56
  path: string;
57
+ delegations?: Delegations;
54
58
  host?: ToolHost;
55
59
  tools: ReadonlySet<string>;
56
60
  permitted: (tool: string) => boolean;
@@ -64,6 +68,7 @@ export declare class Bridge {
64
68
  });
65
69
  start(): Promise<void>;
66
70
  stop(): void;
71
+ revoke(key: string, runId: string): void;
67
72
  isRegisteredRun(key: string, runId?: string): boolean;
68
73
  /** Register one run; release revokes only this exact registration, even when sessions overlap. */
69
74
  register(run: RunRef, tools?: readonly string[], runId?: string): () => void;
package/dist/bridge.js CHANGED
@@ -74,6 +74,8 @@ export class Bridge {
74
74
  server;
75
75
  closing = Promise.resolve();
76
76
  sockets = new Set();
77
+ owners = new Map();
78
+ delegations;
77
79
  runs = new Map();
78
80
  // Every live registration per session key, each with its run's subset of the app's tools (RunSpec.tools; none: all
79
81
  // of them). Runs sharing a key share the narrowest: a tool must be in every live subset.
@@ -85,6 +87,7 @@ export class Bridge {
85
87
  stopped = false;
86
88
  constructor(o) {
87
89
  this.path = o.path;
90
+ this.delegations = o.delegations;
88
91
  this.host = o.host;
89
92
  this.tools = o.tools;
90
93
  this.permitted = o.permitted;
@@ -107,7 +110,7 @@ export class Bridge {
107
110
  rmSync(this.path, { force: true });
108
111
  const server = createServer(socket => {
109
112
  this.sockets.add(socket);
110
- socket.once('close', () => this.sockets.delete(socket));
113
+ socket.once('close', () => { this.sockets.delete(socket); this.owners.delete(socket); });
111
114
  this.serve(socket);
112
115
  });
113
116
  this.server = server;
@@ -142,6 +145,27 @@ export class Bridge {
142
145
  this.closing = new Promise(resolve => server.close(() => resolve()))
143
146
  .then(() => rm(this.path, { force: true }).catch(() => { }));
144
147
  }
148
+ revoke(key, runId) {
149
+ for (const entry of this.live.get(key) ?? [])
150
+ if (entry.runId === runId)
151
+ entry.runId = undefined;
152
+ for (const [id, p] of this.parked)
153
+ if (p.run.sessionKey === key && p.run.runId === runId) {
154
+ clearTimeout(p.timer);
155
+ this.parked.delete(id);
156
+ this.onAskGone(id);
157
+ p.socket.destroy();
158
+ }
159
+ for (const [socket, owner] of this.owners)
160
+ if (owner.key === key && owner.runId === runId)
161
+ socket.destroy();
162
+ for (const [id, p] of this.permits)
163
+ if (p.key === key && p.runId === runId)
164
+ this.permits.delete(id);
165
+ for (let i = this.tickets.length - 1; i >= 0; i--)
166
+ if (this.tickets[i].key === key && this.tickets[i].runId === runId)
167
+ this.tickets.splice(i, 1);
168
+ }
145
169
  isRegisteredRun(key, runId) {
146
170
  return !this.stopped && typeof runId === 'string' && !!runId
147
171
  && (this.live.get(key) ?? []).some(entry => entry.runId === runId);
@@ -206,17 +230,17 @@ export class Bridge {
206
230
  return;
207
231
  }
208
232
  if (this.permitted(tool)) {
209
- this.reply(socket, { allow: true, permit: this.mint(key, tool, input) });
233
+ this.reply(socket, { allow: true, permit: this.mint(key, tool, input, this.owners.get(socket)?.runId) });
210
234
  return;
211
235
  }
212
- this.tickets.push({ key, tool, input: JSON.stringify(input) });
236
+ this.tickets.push({ key, tool, input: JSON.stringify(input), runId: this.owners.get(socket)?.runId });
213
237
  while (this.tickets.length > 1024)
214
238
  this.tickets.shift();
215
239
  this.reply(socket, { allow: true });
216
240
  }
217
- mint(key, tool, input) {
241
+ mint(key, tool, input, runId) {
218
242
  const permit = randomBytes(16).toString('hex');
219
- this.permits.set(permit, { permit, key, tool, input: JSON.stringify(input) });
243
+ this.permits.set(permit, { permit, key, tool, input: JSON.stringify(input), runId });
220
244
  // Single-use entries are consumed on the next call; evict the oldest if the plugin never calls back.
221
245
  while (this.permits.size > 1024)
222
246
  this.permits.delete(this.permits.keys().next().value);
@@ -287,22 +311,51 @@ export class Bridge {
287
311
  catch { /* the app's handler, not the engine's */ }
288
312
  return;
289
313
  }
314
+ if (message.kind.startsWith('delegation-') && this.delegations) {
315
+ try {
316
+ if (message.kind === 'delegation-prepare') {
317
+ if (typeof message.key !== 'string' || typeof message.runId !== 'string'
318
+ || (!this.isRegisteredRun(message.key, message.runId) && !this.delegations.has(message.key, message.runId)))
319
+ return this.deny(socket, 'unknown delegation parent');
320
+ this.reply(socket, { tracked: this.delegations.prepare(message) });
321
+ }
322
+ else if (message.kind === 'delegation-receipt') {
323
+ this.delegations.receipt(message);
324
+ this.reply(socket, { ok: true });
325
+ }
326
+ else if (message.kind === 'delegation-event' && isRecord(message.event)) {
327
+ this.delegations.event(message.event);
328
+ this.reply(socket, { ok: true });
329
+ }
330
+ else
331
+ this.deny(socket, 'invalid delegation message');
332
+ }
333
+ catch (error) {
334
+ this.deny(socket, error instanceof Error ? error.message : 'delegation unavailable');
335
+ }
336
+ return;
337
+ }
290
338
  if (message.kind === 'gate')
291
- return void this.gate(socket, message);
339
+ return void this.gate(socket, message).catch(() => this.deny(socket, 'run unavailable'));
292
340
  if (message.kind === 'call')
293
- return void this.call(socket, message);
341
+ return void this.call(socket, message).catch(() => this.reply(socket, { ok: false, reason: 'run unavailable' }));
294
342
  return this.deny(socket, 'not a gate request');
295
343
  }
296
- gate(socket, message) {
344
+ async gate(socket, message) {
297
345
  const key = message.key;
298
346
  const tool = message.tool;
299
347
  const input = message.input;
300
348
  if (typeof key !== 'string' || typeof tool !== 'string' || !isRecord(input)) {
301
349
  return this.deny(socket, 'not a gate request');
302
350
  }
303
- const run = this.runs.get(key);
351
+ const runId = typeof message.runId === 'string' ? message.runId : undefined;
352
+ this.owners.set(socket, { key, runId });
353
+ const delegated = await this.delegations?.resolve(key, runId);
354
+ if (this.stopped || socket.destroyed)
355
+ return;
356
+ const run = delegated?.ref ?? (!runId || this.isRegisteredRun(key, runId) ? this.runs.get(key) : undefined);
304
357
  if (run)
305
- return void this.gateRun(socket, run, tool, input);
358
+ return void this.gateRun(socket, run, tool, input, delegated?.tools);
306
359
  const armed = this.armed;
307
360
  if (armed && Date.now() < armed.until && key.startsWith(armed.keyPrefix) && tool === armed.tool &&
308
361
  (!armed.input || armed.input(input))) {
@@ -314,11 +367,11 @@ export class Bridge {
314
367
  }
315
368
  return this.deny(socket, 'unknown run');
316
369
  }
317
- async gateRun(socket, run, tool, input) {
370
+ async gateRun(socket, run, tool, input, subset) {
318
371
  if (!this.host)
319
372
  return this.deny(socket, "can't check this action right now");
320
373
  // An app tool outside the run's own subset never reaches the host; builtins stay with the gate.
321
- if (this.tools.has(tool) && (this.live.get(run.sessionKey) ?? []).some((e) => e.tools && !e.tools.has(tool)))
374
+ if (this.tools.has(tool) && ((subset && !subset.includes(tool)) || (this.live.get(run.sessionKey) ?? []).some((e) => e.tools && !e.tools.has(tool))))
322
375
  return this.deny(socket, 'this tool is not available in this run');
323
376
  let result;
324
377
  try {
@@ -327,7 +380,7 @@ export class Bridge {
327
380
  catch {
328
381
  return this.deny(socket, "can't check this action right now");
329
382
  }
330
- if (this.stopped)
383
+ if (this.stopped || socket.destroyed)
331
384
  return;
332
385
  if ('ask' in result) {
333
386
  const now = Date.now();
@@ -383,11 +436,13 @@ export class Bridge {
383
436
  // Only the app's own tools run through host.call; a builtin never calls back.
384
437
  if (!this.tools.has(tool))
385
438
  return this.reply(socket, { ok: false, reason: 'this call was not allowed' });
439
+ const runId = typeof message.runId === 'string' ? message.runId : undefined;
440
+ this.owners.set(socket, { key, runId });
386
441
  const wanted = JSON.stringify(input);
387
442
  const permit = message.permit;
388
443
  if (typeof permit === 'string') {
389
444
  const p = this.permits.get(permit);
390
- if (!p || p.key !== key || p.tool !== tool || p.input !== wanted) {
445
+ if (!p || p.key !== key || p.runId !== runId || p.tool !== tool || p.input !== wanted) {
391
446
  return this.reply(socket, { ok: false, reason: 'this call was not allowed' });
392
447
  }
393
448
  this.permits.delete(permit);
@@ -397,14 +452,17 @@ export class Bridge {
397
452
  // key + tool + input, so no run registration is needed (N10: allowOnce admits unregistered keys).
398
453
  if (this.permitted(tool))
399
454
  return this.reply(socket, { ok: false, reason: 'this call was not allowed' });
400
- const ticket = this.tickets.findIndex((t) => t.key === key && t.tool === tool && t.input === wanted);
455
+ const ticket = this.tickets.findIndex((t) => t.key === key && t.runId === runId && t.tool === tool && t.input === wanted);
401
456
  if (ticket < 0)
402
457
  return this.reply(socket, { ok: false, reason: 'this call was not allowed' });
403
458
  this.tickets.splice(ticket, 1);
404
459
  }
405
460
  if (!this.host)
406
461
  return this.reply(socket, { ok: false, reason: "can't run this action right now" });
407
- const run = this.runs.get(key) ?? { sessionKey: key, member: this.memberOf(key) ?? '' };
462
+ const delegated = await this.delegations?.resolve(key, runId);
463
+ if (this.stopped || socket.destroyed)
464
+ return;
465
+ const run = delegated?.ref ?? this.runs.get(key) ?? { sessionKey: key, member: this.memberOf(key) ?? '' };
408
466
  // The plugin destroys its socket when the run aborts (run abort, hook timeout); without this the host's
409
467
  // sandboxed command keeps running after the tool is gone.
410
468
  const controller = new AbortController();
@@ -0,0 +1,38 @@
1
+ import type { DelegatedEvent, DelegatedRun, GatewayTransport, RunRef } from './types.ts';
2
+ type Grant = {
3
+ ref: DelegatedRun;
4
+ tools: string[];
5
+ delegates: Record<string, string[]>;
6
+ sourceKey: string;
7
+ };
8
+ export declare class Delegations {
9
+ private loaded;
10
+ private roots;
11
+ private children;
12
+ private pending;
13
+ private changed;
14
+ private early;
15
+ constructor(privateOptions: {
16
+ file: string;
17
+ tools: ReadonlySet<string>;
18
+ memberOf(agent: string): string | undefined;
19
+ request: GatewayTransport['request'];
20
+ emit(e: DelegatedEvent): void;
21
+ revoke(key: string, runId: string): void;
22
+ });
23
+ private readonly o;
24
+ private load;
25
+ 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;
28
+ list(): DelegatedRun[];
29
+ has(key: string, runId: string): boolean;
30
+ prepare(m: Record<string, any>): boolean;
31
+ receipt(m: Record<string, any>): void;
32
+ resolve(key: string, runId?: string): Promise<Grant | undefined>;
33
+ event(event: Record<string, any>): void;
34
+ private end;
35
+ reconcile(): Promise<void>;
36
+ abort(key: string): Promise<void>;
37
+ }
38
+ export {};
@@ -0,0 +1,204 @@
1
+ // Native receipts, never session-prefix authority. Persist identity/tool policy only, not prompts or credentials.
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { atomic } from './engine-patches.js';
4
+ const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
5
+ const agentOf = (key) => /^agent:([^:]+):.+/.exec(key)?.[1];
6
+ const actionKey = (m) => JSON.stringify([m.key, m.runId, m.callId]);
7
+ export class Delegations {
8
+ loaded = false;
9
+ roots = new Map();
10
+ children = new Map();
11
+ pending = new Map();
12
+ changed = new Set();
13
+ early = new Map();
14
+ constructor(privateOptions) { this.o = privateOptions; }
15
+ o;
16
+ load() {
17
+ if (this.loaded)
18
+ return;
19
+ if (existsSync(this.o.file)) {
20
+ const saved = JSON.parse(readFileSync(this.o.file, 'utf8'));
21
+ if (saved.version !== 1 || !Array.isArray(saved.children) || !Array.isArray(saved.pending))
22
+ throw new Error('invalid delegated run journal');
23
+ this.children = new Map(saved.children);
24
+ this.pending = new Map(saved.pending);
25
+ }
26
+ this.loaded = true;
27
+ }
28
+ save() {
29
+ for (const [key, p] of this.pending)
30
+ if (p.expires < Date.now())
31
+ this.pending.delete(key);
32
+ const bytes = JSON.stringify({ version: 1, children: [...this.children], pending: [...this.pending] });
33
+ atomic(this.o.file, bytes);
34
+ for (const notify of this.changed)
35
+ notify();
36
+ }
37
+ validate(delegates) {
38
+ if (!object(delegates) || Object.keys(delegates).length > 64)
39
+ throw new Error('invalid delegates');
40
+ for (const [agent, tools] of Object.entries(delegates)) {
41
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(agent) || !this.o.memberOf(agent) || !Array.isArray(tools)
42
+ || tools.some(t => !this.o.tools.has(t)))
43
+ throw new Error(`invalid delegate: ${agent}`);
44
+ }
45
+ }
46
+ register(ref, runId, tools, delegates, sourceKey = ref.sessionKey) {
47
+ this.load();
48
+ this.validate(delegates);
49
+ const root = { ref: { ...ref, runId, root: { sessionKey: ref.sessionKey, runId } },
50
+ tools: [...tools ?? this.o.tools], delegates: structuredClone(delegates), sourceKey };
51
+ this.roots.set(runId, root);
52
+ return () => { if (this.roots.get(runId) === root)
53
+ this.roots.delete(runId); };
54
+ }
55
+ list() { this.load(); return [...this.children.values()].map(g => structuredClone(g.ref)); }
56
+ has(key, runId) {
57
+ this.load();
58
+ return (this.roots.get(runId) ?? this.children.get(runId))?.ref.sessionKey === key;
59
+ }
60
+ prepare(m) {
61
+ this.load();
62
+ const parent = this.roots.get(m.runId) ?? this.children.get(m.runId);
63
+ if (!parent || parent.ref.sessionKey !== m.key)
64
+ return false;
65
+ if (typeof m.callId !== 'string' || !m.callId || !object(m.input))
66
+ throw new Error('delegation identity unavailable');
67
+ const send = m.tool === 'sessions_send';
68
+ if (!send && m.tool !== 'sessions_spawn')
69
+ throw new Error('unsupported delegation tool');
70
+ const agent = send ? typeof m.input.sessionKey === 'string' && agentOf(m.input.sessionKey) : m.input.agentId;
71
+ if (typeof agent !== 'string' || !Object.hasOwn(parent.delegates, agent)
72
+ || (send && (m.input.timeoutSeconds !== 0 || m.input.label !== undefined))
73
+ || (!send && m.input.runtime !== undefined && m.input.runtime !== 'subagent'))
74
+ throw new Error('delegation target or mode refused');
75
+ for (const [id, p] of this.pending)
76
+ if (p.expires < Date.now())
77
+ this.pending.delete(id);
78
+ if (this.pending.size + this.children.size >= 1024)
79
+ 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 });
82
+ this.save();
83
+ return true;
84
+ }
85
+ receipt(m) {
86
+ this.load();
87
+ const id = actionKey(m), p = this.pending.get(id);
88
+ if (!p)
89
+ return;
90
+ this.pending.delete(id);
91
+ const d = m.result?.details;
92
+ const key = d?.childSessionKey ?? d?.sessionKey;
93
+ if (p.expires < Date.now() || !object(d) || !['accepted', 'ok', 'queued'].includes(d.status)
94
+ || typeof d.runId !== 'string' || !d.runId || typeof key !== 'string' || agentOf(key) !== p.agent
95
+ || (p.key !== undefined && p.key !== key)) {
96
+ this.save();
97
+ return;
98
+ }
99
+ const member = this.o.memberOf(p.agent);
100
+ if (!member) {
101
+ this.save();
102
+ return;
103
+ }
104
+ const ref = { sessionKey: key, member, runId: d.runId, agentId: p.agent,
105
+ parent: { sessionKey: p.parent.ref.sessionKey, runId: p.parent.ref.runId }, root: p.parent.ref.root };
106
+ const grant = { ref, delegates: p.parent.delegates, sourceKey: p.parent.sourceKey,
107
+ tools: p.parent.tools.filter(t => p.parent.delegates[p.agent].includes(t)) };
108
+ // A steered/already-admitted run keeps its original parent and cannot acquire a broader grant.
109
+ if (this.children.has(ref.runId)) {
110
+ this.save();
111
+ return;
112
+ }
113
+ this.children.set(ref.runId, grant);
114
+ this.save();
115
+ this.o.emit({ type: 'admitted', run: structuredClone(ref) });
116
+ for (const event of this.early.get(ref.runId) ?? [])
117
+ this.event(event);
118
+ this.early.delete(ref.runId);
119
+ }
120
+ async resolve(key, runId) {
121
+ this.load();
122
+ if (!runId)
123
+ return undefined;
124
+ const get = () => { const g = this.children.get(runId); return g?.ref.sessionKey === key ? g : undefined; };
125
+ if (!get() && [...this.pending.values()].some(p => p.expires > Date.now())) {
126
+ await new Promise(resolve => {
127
+ const done = () => { clearTimeout(timer); this.changed.delete(check); resolve(); };
128
+ const check = () => { if (get() || !this.pending.size)
129
+ done(); };
130
+ const timer = setTimeout(done, 5_000);
131
+ this.changed.add(check);
132
+ check();
133
+ });
134
+ }
135
+ return get();
136
+ }
137
+ event(event) {
138
+ this.load();
139
+ const grant = this.children.get(event.runId);
140
+ if (!grant) {
141
+ if (this.pending.size && typeof event.runId === 'string' && this.early.size < 64) {
142
+ const events = this.early.get(event.runId) ?? [];
143
+ if (events.length < 32)
144
+ events.push(event);
145
+ this.early.set(event.runId, events);
146
+ }
147
+ return;
148
+ }
149
+ if (event.sessionKey !== grant.ref.sessionKey)
150
+ return;
151
+ this.o.emit({ type: 'event', run: structuredClone(grant.ref), event: { stream: event.stream, data: event.data } });
152
+ if (event.stream === 'lifecycle' && ['end', 'error'].includes(event.data?.phase))
153
+ this.end(grant);
154
+ }
155
+ end(grant) {
156
+ this.children.delete(grant.ref.runId);
157
+ this.save();
158
+ this.o.revoke(grant.ref.sessionKey, grant.ref.runId);
159
+ this.o.emit({ type: 'ended', run: structuredClone(grant.ref), reason: 'terminal' });
160
+ }
161
+ async reconcile() {
162
+ for (const ref of this.list()) {
163
+ const end = await this.o.request('agent.wait', { runId: ref.runId, timeoutMs: 1 });
164
+ const grant = this.children.get(ref.runId);
165
+ if (grant && ['ok', 'error'].includes(end.status ?? ''))
166
+ this.end(grant);
167
+ }
168
+ }
169
+ async abort(key) {
170
+ this.load();
171
+ const ids = new Set();
172
+ for (const g of [...this.roots.values(), ...this.children.values()])
173
+ if (g.ref.sessionKey === key || g.ref.parent?.sessionKey === key || g.ref.root.sessionKey === key || g.sourceKey === key)
174
+ ids.add(g.ref.runId);
175
+ for (;;) {
176
+ const size = ids.size;
177
+ for (const g of this.children.values())
178
+ if (ids.has(g.ref.parent.runId))
179
+ ids.add(g.ref.runId);
180
+ if (ids.size === size)
181
+ break;
182
+ }
183
+ let removed = false;
184
+ for (const [id, p] of this.pending)
185
+ if (ids.has(p.parent.ref.runId) || p.parent.ref.sessionKey === key || p.parent.ref.root.sessionKey === key || p.parent.sourceKey === key) {
186
+ this.pending.delete(id);
187
+ removed = true;
188
+ }
189
+ if (!ids.size && !removed)
190
+ return;
191
+ const cancelled = [...this.children.values()].filter(g => ids.has(g.ref.runId));
192
+ const revoked = [...this.roots.values(), ...cancelled].filter(g => ids.has(g.ref.runId));
193
+ for (const id of ids)
194
+ this.roots.delete(id);
195
+ for (const g of cancelled)
196
+ this.children.delete(g.ref.runId);
197
+ this.save(); // revoke durably before any awaited transport call
198
+ for (const g of revoked)
199
+ this.o.revoke(g.ref.sessionKey, g.ref.runId);
200
+ for (const g of cancelled)
201
+ this.o.emit({ type: 'ended', run: structuredClone(g.ref), reason: 'cancelled' });
202
+ await Promise.all(cancelled.map(g => this.o.request('chat.abort', { sessionKey: g.ref.sessionKey, runId: g.ref.runId })));
203
+ }
204
+ }
package/dist/device.d.ts CHANGED
@@ -2,9 +2,9 @@ import type { BrowserDevice, SignInRefusedWhy } from './browser.ts';
2
2
  import type { DeviceLink } from '@byokit/pair';
3
3
  import type { RouteView } from './routes.ts';
4
4
  import { type AccountView } from './words.ts';
5
- import type { Approval, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, KitState, RunEnd, OutputSchema, SchemaOutput, RunEvent, SignInView, AccountRef } from './types.ts';
5
+ import type { Approval, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, KitState, RunEnd, OutputSchema, SchemaOutput, RunEvent, RunFrame, SignInView, AccountRef } from './types.ts';
6
6
  export type { GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult } from './types.ts';
7
- export type { Approval, Decision, KitState, PlanWindow, Route, RunEnd, RunEvent, RunUsage, SignInView, OutputSchema, SchemaOutput } from './types.ts';
7
+ export type { Approval, Decision, KitState, PlanWindow, Route, RunEnd, RunEvent, RunFrame, RunUsage, SignInView, OutputSchema, SchemaOutput } from './types.ts';
8
8
  /** One `oc.events` frame: a member's Gateway event, typed by name, or an approval add/resolve (7.2). */
9
9
  export type OpenClawLinkEvent = {
10
10
  [E in GatewayEventName]: {
@@ -30,11 +30,13 @@ export type SessionRow = {
30
30
  /** The frame `oc.run`'s stream ends with: the run's `RunEnd`, beside its events. */
31
31
  export type DeviceEndFrame<T = unknown> = {
32
32
  type: 'end';
33
+ seq: number;
33
34
  end: RunEnd<T>;
34
35
  };
35
36
  /** `oc.run`'s options beside the message: the member's own session, account, and the kit's run options. */
36
37
  export type DeviceRunOptions<S extends OutputSchema | undefined = OutputSchema | undefined> = {
37
38
  schema?: S;
39
+ idempotencyKey?: string;
38
40
  sessionKey?: string;
39
41
  account?: AccountRef | 'default' | 'auto';
40
42
  needs?: string[];
@@ -96,6 +98,9 @@ export type OpenClawDevice = {
96
98
  signOut(p: string): Promise<void>;
97
99
  sessions(): Promise<SessionRow[]>;
98
100
  run<const S extends OutputSchema | undefined = undefined>(message: string, o?: DeviceRunOptions<S>): AsyncIterable<DeviceRunEvent | DeviceEndFrame<SchemaOutput<S>>>;
101
+ attach(runId: string, o: {
102
+ fromSeq: number;
103
+ }): AsyncIterable<RunFrame>;
99
104
  steer(k: string, t: string, o?: {
100
105
  auth?: 'apiKey';
101
106
  }): Promise<void>;
package/dist/device.js CHANGED
@@ -361,8 +361,9 @@ export function openclawDevice(link) {
361
361
  await link.request('oc.signout', { provider: p });
362
362
  },
363
363
  sessions: () => link.request('oc.sessions'),
364
+ attach: (runId, o) => liveStream(() => link.stream('oc.attach', { runId, fromSeq: o.fromSeq })),
364
365
  run: (message, o) => {
365
- const inner = liveStream(() => link.stream('oc.run', { message, ...(o?.sessionKey ? { sessionKey: checkKey(o.sessionKey) } : {}),
366
+ const inner = liveStream(() => link.stream('oc.run', { message, ...(o?.idempotencyKey !== undefined ? { idempotencyKey: o.idempotencyKey } : {}), ...(o?.sessionKey ? { sessionKey: checkKey(o.sessionKey) } : {}),
366
367
  ...(o?.account !== undefined ? { account: o.account } : {}),
367
368
  ...(o?.needs !== undefined ? { needs: o.needs } : {}),
368
369
  ...(o?.model !== undefined ? { model: o.model } : {}), ...(o?.system !== undefined ? { system: o.system } : {}),
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { ENGINE_VERSION, OPERATOR_SCOPES, PROTOCOL_VERSION } from './constants.ts';
2
- export type { Account, AccountId, AccountPick, AccountRef, Approval, CallOptions, ChannelChat, ChannelMessage, Considered, Decision, Defaults, ModelInfo, MoveResult, PickWhy, Room, RoomSpan, RunSelection, SignInState, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, GateResult, Hello, KitState, KitEventName, KitEventPayload, BrowserPing, LearningCapture, LearningMode, Member, PlanWindow, Route, RunEnd, RunEvent, RunRef, RunSpec, OutputSchema, SchemaOutput, RunUsage, ThinkingAsked, ThinkingReport, ThinkingUsed, SignInOptions, SignInView, TelegramChannel, ToolHost, ToolSpec, } from './types.ts';
2
+ export type { Account, AccountId, AccountPick, AccountRef, Approval, CallOptions, ChannelChat, ChannelMessage, Considered, Decision, Defaults, ModelInfo, MoveResult, PickWhy, Room, RoomSpan, RunSelection, SignInState, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, GateResult, Hello, KitState, KitEventName, KitEventPayload, BrowserPing, LearningCapture, LearningMode, Member, PlanWindow, Route, RunEnd, RunEvent, RunFrame, RunRef, DelegatedRun, DelegatedEvent, RunSpec, OutputSchema, SchemaOutput, RunUsage, ThinkingAsked, ThinkingReport, ThinkingUsed, SignInOptions, SignInView, TelegramChannel, ToolHost, ToolSpec, } from './types.ts';
3
3
  export type { GatewayMethods } from './generated/methods.ts';
4
4
  export type { JsonValue } from './output.ts';
5
5
  export type { RouteFacts, RouteView } from './routes.ts';
package/dist/kit.d.ts CHANGED
@@ -64,9 +64,12 @@ export declare class OpenClawKit {
64
64
  private members?;
65
65
  private readonly locks;
66
66
  private readonly routeMap;
67
+ private readonly runReplay;
68
+ attach(member: Member, runId: string, fromSeq: number, signal: AbortSignal): AsyncIterable<import("./types.ts").RunFrame>;
67
69
  private installs;
68
70
  private accountsCtl?;
69
71
  private readonly bridge;
72
+ private readonly delegations;
70
73
  private readonly keys;
71
74
  private readonly admission;
72
75
  private readonly approvalsCtl;
@@ -207,6 +210,7 @@ export declare class OpenClawKit {
207
210
  steer(sessionKey: string, text: string, o?: {
208
211
  auth?: 'apiKey';
209
212
  }): Promise<void>;
213
+ delegatedRuns(): import('./types.ts').DelegatedRun[];
210
214
  abort(sessionKey: string, o?: {
211
215
  auth?: 'apiKey';
212
216
  }): Promise<void>;
package/dist/kit.js CHANGED
@@ -9,6 +9,7 @@ import { PROTOCOL_VERSION, LIVE_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';
12
+ import { Delegations } from './delegations.js';
12
13
  import { Engine, canonicalStateDir, refusedOnly } from './engine.js';
13
14
  import { AuthStoreSealSizeError } from './auth-store.js';
14
15
  import { gatewayTransport } from './transport.js';
@@ -17,7 +18,7 @@ import { createAccounts, memberOfAccount } from './accounts.js';
17
18
  import { createLocks } from './locks.js';
18
19
  import { keyAgentId, KEY_PREFIX, MEMBER_ID } from './members.js';
19
20
  import { confirmRetainedLogin as confirmLogin, migrateRetainedLogin as migrateLogin } from './migrate.js';
20
- import { createRuns, signedInProviders } from './runs.js';
21
+ import { createRuns, RunReplay, signedInProviders } from './runs.js';
21
22
  import { authStatus, createAuthStatus } from './auth-status.js';
22
23
  import { outputSchema } from './output.js';
23
24
  import { createKeys } from './keys.js';
@@ -441,11 +442,16 @@ export class OpenClawKit {
441
442
  members;
442
443
  locks = createLocks();
443
444
  routeMap = new Map();
445
+ runReplay = new RunReplay();
446
+ attach(member, runId, fromSeq, signal) {
447
+ return this.runReplay.attach(member, runId, fromSeq, signal);
448
+ }
444
449
  installs = Promise.resolve();
445
450
  accountsCtl;
446
451
  // One bridge and one approval surface per kit: listeners and run registrations survive reconnects (B3).
447
452
  // Only the socket itself is per-connection. Approvals always reaches the live transport through this.request().
448
453
  bridge;
454
+ delegations;
449
455
  keys;
450
456
  admission;
451
457
  approvalsCtl;
@@ -716,7 +722,12 @@ export class OpenClawKit {
716
722
  return o.host.call(run, tool, input, signal);
717
723
  },
718
724
  } : o.host;
719
- this.bridge = new Bridge({ path: this.engine.bridgeSock, host, tools: new Set([...this.toolNames(), ...(o.browser ? ['request_sign_in'] : [])]),
725
+ this.delegations = new Delegations({ file: join(this.engine.root, 'delegated-runs.json'), tools: new Set(this.toolNames()),
726
+ memberOf: agent => this.memberOf(agent), request: (method, params, options) => this.request()(method, params, options),
727
+ emit: payload => { for (const fn of this.listeners)
728
+ fn({ event: 'byokit.delegated', payload }); },
729
+ revoke: (key, runId) => this.bridge.revoke(key, runId) });
730
+ this.bridge = new Bridge({ path: this.engine.bridgeSock, delegations: this.delegations, host, tools: new Set([...this.toolNames(), ...(o.browser ? ['request_sign_in'] : [])]),
720
731
  ...(o.browser ? { browserCapabilities: () => [...this.browserCapabilities],
721
732
  beforeAgentRun: async (key, runId) => {
722
733
  // No action-id submission proof or redispatch claim. Open and unproven resumes are always refused.
@@ -825,6 +836,7 @@ export class OpenClawKit {
825
836
  // The fail-closed tool bridge feeds the kit's single Approval surface (5.9, O5).
826
837
  await Promise.race([this.bridge.start(), exit]);
827
838
  this.off.push(transport.onEvent((e) => this.approvalsCtl.handleEvent(e)));
839
+ await this.delegations.reconcile();
828
840
  this.failures = 0;
829
841
  this.reachedReady = true;
830
842
  this.setState({ phase: 'ready' });
@@ -940,6 +952,8 @@ export class OpenClawKit {
940
952
  });
941
953
  }
942
954
  closed(why) {
955
+ if (why === 'engine exited')
956
+ this.runReplay.clear();
943
957
  // A first start's loop owns an engine exit until it reaches ready; other closes are the transport client's to retry.
944
958
  if (this.startRun && !this.reachedReady) {
945
959
  if (why === 'engine exited')
@@ -968,6 +982,7 @@ export class OpenClawKit {
968
982
  return delay(retryAt - Date.now(), undefined, { signal });
969
983
  }
970
984
  async stop() {
985
+ this.runReplay.clear();
971
986
  this.stopping = true;
972
987
  this.reachedReady = false;
973
988
  this.startRun?.abort();
@@ -1303,10 +1318,11 @@ export class OpenClawKit {
1303
1318
  confirmRetainedLogin(member, source) {
1304
1319
  return confirmLogin({ ...this.signInCtx(), seal: this.o.authSeal, log: this.o.log }, member, source);
1305
1320
  }
1306
- // runs (5.8): the facade owns no run state; every call delegates to the O8 module
1321
+ // runs (5.8): the facade retains the live journal; every dispatch delegates to the O8 module
1307
1322
  // with this kit's live transport, member table and bridge (spec item O9).
1308
1323
  runs() {
1309
1324
  return createRuns({
1325
+ replay: this.runReplay,
1310
1326
  request: (method, params, o) => this.request()(method, params, o),
1311
1327
  authStatus: this.admission.read,
1312
1328
  invalidateAuth: this.admission.invalidate,
@@ -1318,7 +1334,7 @@ export class OpenClawKit {
1318
1334
  },
1319
1335
  ensure: (member) => this.ensureMember(member),
1320
1336
  bridge: { register: (run, tools, runId) => this.bridge.register(run, tools && this.o.browser ? [...tools, 'request_sign_in'] : tools, runId) },
1321
- tools: new Set(this.toolNames()),
1337
+ tools: new Set(this.toolNames()), delegations: this.delegations,
1322
1338
  ...(this.accountsCtl ? { accounts: this.accountsCtl } : {}),
1323
1339
  locks: this.locks,
1324
1340
  routes: this.routeMap,
@@ -1329,6 +1345,8 @@ export class OpenClawKit {
1329
1345
  return (this.o.tools ?? []).map((t) => t.name);
1330
1346
  }
1331
1347
  async run(spec, on) {
1348
+ if (spec.delegates && this.o.gateBuiltins === false)
1349
+ throw new Error('delegation requires builtin gating');
1332
1350
  if (this.o.browser) {
1333
1351
  if (!await this.checkBrowserTools())
1334
1352
  return { ok: false, kind: 'other', message: 'browser tool policy refused' };
@@ -1372,8 +1390,10 @@ export class OpenClawKit {
1372
1390
  async steer(sessionKey, text, o) {
1373
1391
  return this.runs().steer(this.runKey(sessionKey, o), text);
1374
1392
  }
1393
+ delegatedRuns() { return this.delegations.list(); }
1375
1394
  async abort(sessionKey, o) {
1376
- return this.runs().abort(this.runKey(sessionKey, o));
1395
+ const key = this.runKey(sessionKey, o);
1396
+ await Promise.all([this.delegations.abort(key), this.runs().abort(key)]);
1377
1397
  }
1378
1398
  // approvals (5.9)
1379
1399
  approvals(member) {
package/dist/link.js CHANGED
@@ -30,8 +30,9 @@ function memberSessions(result, owns) {
30
30
  }
31
31
  /** `oc.run`'s run options, type-checked; the kit's own checks (account, tool names) still apply. */
32
32
  function runOptions(args) {
33
- const { message, model, account, needs, auth, system, images, thinking, tools, schema } = args;
33
+ const { message, idempotencyKey, model, account, needs, auth, system, images, thinking, tools, schema } = args;
34
34
  const ok = typeof message === 'string'
35
+ && (idempotencyKey === undefined || typeof idempotencyKey === 'string' && idempotencyKey.length > 0)
35
36
  && (model === undefined || typeof model === 'string')
36
37
  && (account === undefined || typeof account === 'string')
37
38
  && (needs === undefined || (Array.isArray(needs) && needs.every((n) => typeof n === 'string')))
@@ -46,6 +47,7 @@ function runOptions(args) {
46
47
  outputSchema(schema);
47
48
  return {
48
49
  message: message,
50
+ ...(idempotencyKey === undefined ? {} : { idempotencyKey: idempotencyKey }),
49
51
  ...(schema === undefined ? {} : { schema: schema }),
50
52
  ...(model === undefined ? {} : { model: model }),
51
53
  ...(account === undefined ? {} : { account: account }),
@@ -561,6 +563,31 @@ export function openclawLink(kit, o) {
561
563
  }
562
564
  return;
563
565
  }
566
+ if (req.op === 'oc.attach') {
567
+ if (grant.role === 'view')
568
+ throw refused();
569
+ const args = isRecord(req.args) ? req.args : {};
570
+ if (typeof args.runId !== 'string' || !args.runId.length || !Number.isSafeInteger(args.fromSeq) || args.fromSeq < 0)
571
+ throw refused();
572
+ const controller = new AbortController();
573
+ let frames;
574
+ try {
575
+ frames = kit.attach(member, args.runId, args.fromSeq, controller.signal);
576
+ }
577
+ catch {
578
+ throw refused();
579
+ }
580
+ s.onEnd = () => controller.abort();
581
+ try {
582
+ for await (const frame of frames)
583
+ await s.write(`${JSON.stringify(frame)}\n`);
584
+ s.end();
585
+ }
586
+ finally {
587
+ controller.abort();
588
+ }
589
+ return;
590
+ }
564
591
  if (req.op === 'oc.run') {
565
592
  if (grant.role === 'view')
566
593
  throw refused();
@@ -579,8 +606,9 @@ export function openclawLink(kit, o) {
579
606
  tail = tail.then(() => s.write(`${JSON.stringify(frame)}\n`)).catch(() => { });
580
607
  };
581
608
  try {
582
- const end = await kit.run({ member, sessionKey, ...options }, (e) => send(e));
583
- send({ type: 'end', end });
609
+ let seq = 0;
610
+ const end = await kit.run({ member, sessionKey, ...options }, (e) => { seq = e.seq; send(e); });
611
+ send({ type: 'end', seq: seq + 1, end });
584
612
  await tail;
585
613
  s.end();
586
614
  }
package/dist/runs.d.ts CHANGED
@@ -1,16 +1,36 @@
1
1
  import type { Bridge } from './bridge.ts';
2
+ import type { Delegations } from './delegations.ts';
2
3
  import type { createAccounts } from './accounts.ts';
3
4
  import type { createLocks } from './locks.ts';
4
5
  import { authStatus } from './auth-status.ts';
5
6
  import { outputSchema, type OutputSchema, type SchemaOutput } from './output.ts';
6
- import type { GatewayTransport, Member, RunEnd, RunEvent, RunSpec } from './types.ts';
7
+ import type { GatewayTransport, Member, RunEnd, RunEvent, RunFrame, RunSpec } from './types.ts';
7
8
  export declare const USABLE: Set<string>;
8
9
  /**
9
10
  * The providers a `models.authStatus` answer shows usable (the rule a picked account is checked by), lowercased and
10
11
  * unique; undefined while the engine has no prepared status (unknown, not none).
11
12
  */
12
13
  export declare function signedInProviders(status: unknown): string[] | undefined;
14
+ type Journal = {
15
+ member: Member;
16
+ frames: Exclude<RunFrame, {
17
+ type: 'truncated';
18
+ }>[];
19
+ done: boolean;
20
+ wake: Set<() => void>;
21
+ };
22
+ /** Live-kit only: bounded history, with readers pulling by cursor rather than growing per-reader queues. */
23
+ export declare class RunReplay {
24
+ private readonly runs;
25
+ begin(member: Member, id: string): Journal | undefined;
26
+ push(row: Journal | undefined, frame: Exclude<RunFrame, {
27
+ type: 'truncated';
28
+ }>): void;
29
+ clear(): void;
30
+ attach(member: Member, id: string, fromSeq: number, signal: AbortSignal): AsyncIterable<RunFrame>;
31
+ }
13
32
  export declare function createRuns(ctx: {
33
+ replay?: RunReplay;
14
34
  request: GatewayTransport['request'];
15
35
  authStatus?: (agentId: string, refresh: boolean, native: boolean) => ReturnType<typeof authStatus>;
16
36
  invalidateAuth?: () => void;
@@ -19,6 +39,7 @@ export declare function createRuns(ctx: {
19
39
  agentId: string;
20
40
  }>;
21
41
  bridge: Pick<Bridge, 'register'>;
42
+ delegations?: Delegations;
22
43
  tools: ReadonlySet<string>;
23
44
  accounts?: Pick<ReturnType<typeof createAccounts>, 'bind' | 'keyRefusal' | 'pick' | 'rest'>;
24
45
  locks?: ReturnType<typeof createLocks>;
@@ -30,3 +51,4 @@ export declare function createRuns(ctx: {
30
51
  steer(k: string, t: string): Promise<void>;
31
52
  abort(k: string): Promise<void>;
32
53
  };
54
+ export {};
package/dist/runs.js CHANGED
@@ -71,6 +71,74 @@ function account(model) {
71
71
  throw new Error(`refused: "${model}" is not a provider/model reference`);
72
72
  return { provider, model: model.slice(model.indexOf('/') + 1) }; // the engine lowercases ids
73
73
  }
74
+ /** Live-kit only: bounded history, with readers pulling by cursor rather than growing per-reader queues. */
75
+ export class RunReplay {
76
+ runs = new Map();
77
+ begin(member, id) {
78
+ if (this.runs.has(id))
79
+ return undefined; // engine-cache retry must not replace the original stream
80
+ const row = { member, frames: [], done: false, wake: new Set() };
81
+ this.runs.set(id, row);
82
+ return row;
83
+ }
84
+ push(row, frame) {
85
+ if (!row)
86
+ return;
87
+ row.frames.push(frame);
88
+ if (row.frames.length > 32)
89
+ row.frames.shift();
90
+ row.done = frame.type === 'end';
91
+ for (const wake of row.wake)
92
+ wake();
93
+ if (row.done) {
94
+ const completed = [...this.runs].filter(([, r]) => r.done);
95
+ for (const [id] of completed.slice(0, -128))
96
+ this.runs.delete(id);
97
+ }
98
+ }
99
+ clear() {
100
+ for (const row of this.runs.values())
101
+ for (const wake of row.wake)
102
+ wake();
103
+ this.runs.clear();
104
+ }
105
+ attach(member, id, fromSeq, signal) {
106
+ const row = this.runs.get(id);
107
+ if (row && row.member !== member)
108
+ throw new Error('foreign run');
109
+ const runs = this.runs;
110
+ return (async function* () {
111
+ let cursor = fromSeq;
112
+ while (!signal.aborted) {
113
+ if (!row || runs.get(id) !== row) {
114
+ yield { type: 'truncated', reason: 'not-found' };
115
+ return;
116
+ }
117
+ const firstSeq = row.frames[0]?.seq ?? 1;
118
+ const lastSeq = row.frames.at(-1)?.seq ?? 0;
119
+ if (cursor < firstSeq - 1 || cursor > lastSeq) {
120
+ yield { type: 'truncated', reason: 'truncated', firstSeq };
121
+ return;
122
+ }
123
+ const next = row.frames.find(f => f.seq > cursor);
124
+ if (next) {
125
+ cursor = next.seq;
126
+ yield next;
127
+ if (next.type === 'end')
128
+ return;
129
+ continue;
130
+ }
131
+ if (row.done)
132
+ return;
133
+ await new Promise(resolve => {
134
+ const wake = () => { row.wake.delete(wake); signal.removeEventListener('abort', wake); resolve(); };
135
+ row.wake.add(wake);
136
+ signal.addEventListener('abort', wake, { once: true });
137
+ });
138
+ }
139
+ })();
140
+ }
141
+ }
74
142
  export function createRuns(ctx) {
75
143
  // The plan window the engine last read for the run's provider: `models.authStatus` answers from its cache without
76
144
  // blocking. Nothing reported, or no answer in time, is no window.
@@ -90,11 +158,16 @@ export function createRuns(ctx) {
90
158
  ctx.invalidateAuth?.();
91
159
  return { ok: false, ...classified, message };
92
160
  };
93
- const run = async (spec, on, keyAgent, preparedOutput) => {
161
+ const execute = async (spec, on, keyAgent, preparedOutput) => {
94
162
  // Member boundary first: a member never speaks in another member's session, refused before any request. A key
95
163
  // of one of the member's account agents is the member's too; binding below checks the index names it.
96
164
  if (!spec.sessionKey.startsWith(`agent:${spec.member}:`) && !(ctx.accounts && !keyAgent && spec.sessionKey.startsWith(`agent:${spec.member}--`)))
97
165
  throw new Error(`refused: "${spec.sessionKey}" is not a session of member "${spec.member}"`);
166
+ if (spec.delegates) {
167
+ if (spec.register === false || !ctx.delegations)
168
+ throw new Error('delegation requires a registered run');
169
+ ctx.delegations.validate(spec.delegates);
170
+ }
98
171
  // Same validation as the pin's NonEmptyString; preserve the caller's bytes (including whitespace).
99
172
  if (spec.idempotencyKey !== undefined && (typeof spec.idempotencyKey !== 'string' || !spec.idempotencyKey.length))
100
173
  throw new Error('refused: idempotencyKey must be a non-empty string');
@@ -213,7 +286,8 @@ export function createRuns(ctx) {
213
286
  };
214
287
  const start = async (spec, on, t) => {
215
288
  const { agentId, sessionKey, idempotencyKey, system, picked, output, chosen } = t;
216
- const release = spec.register !== false ? ctx.bridge.register({ sessionKey, member: spec.member }, spec.tools, idempotencyKey) : undefined;
289
+ let release;
290
+ let releaseDelegation;
217
291
  let last;
218
292
  let runId = ''; // gateway events for other runs carry a real runId and never match the empty one
219
293
  let ended = false;
@@ -251,6 +325,8 @@ export function createRuns(ctx) {
251
325
  }
252
326
  });
253
327
  try {
328
+ releaseDelegation = spec.delegates ? ctx.delegations.register({ sessionKey, member: spec.member }, idempotencyKey, spec.tools, spec.delegates, spec.sessionKey) : undefined;
329
+ release = spec.register !== false ? ctx.bridge.register({ sessionKey, member: spec.member }, spec.tools, idempotencyKey) : undefined;
254
330
  if (picked && !chosen) {
255
331
  // The picked account must be the one called: check it is signed in for this member before the run, since
256
332
  // the engine would otherwise fail the run only after admitting it. An explicit provider/model is strict on
@@ -340,10 +416,28 @@ export function createRuns(ctx) {
340
416
  ended = true;
341
417
  unsubscribe();
342
418
  release?.();
419
+ releaseDelegation?.();
343
420
  }
344
421
  };
422
+ const replay = ctx.replay ?? new RunReplay();
345
423
  return {
346
- run,
424
+ run: async (spec, on, keyAgent, preparedOutput) => {
425
+ if (spec.idempotencyKey !== undefined && (typeof spec.idempotencyKey !== 'string' || !spec.idempotencyKey.length))
426
+ throw new Error('refused: idempotencyKey must be a non-empty string');
427
+ const id = spec.idempotencyKey ?? randomUUID();
428
+ const row = replay.begin(spec.member, id);
429
+ let seq = 0;
430
+ const emit = (e) => { const event = { ...e, seq: ++seq }; replay.push(row, event); on?.(event); };
431
+ try {
432
+ const end = await execute({ ...spec, idempotencyKey: id }, emit, keyAgent, preparedOutput);
433
+ replay.push(row, { type: 'end', seq: ++seq, end });
434
+ return end;
435
+ }
436
+ catch (error) {
437
+ replay.push(row, { type: 'end', seq: ++seq, end: failure(error instanceof Error ? error.message : String(error)) });
438
+ throw error;
439
+ }
440
+ },
347
441
  steer: (k, t) => ctx.request('sessions.steer', { sessionKey: ctx.routes.get(k)?.session ?? k, message: t }),
348
442
  abort: async (k) => { await ctx.request('chat.abort', { sessionKey: ctx.routes.get(k)?.session ?? k }); },
349
443
  };
package/dist/types.d.ts CHANGED
@@ -20,7 +20,27 @@ export interface RunRef {
20
20
  sessionKey: string;
21
21
  member: Member;
22
22
  meta?: unknown;
23
+ runId?: string;
24
+ agentId?: string;
25
+ parent?: {
26
+ sessionKey: string;
27
+ runId: string;
28
+ };
29
+ root?: {
30
+ sessionKey: string;
31
+ runId: string;
32
+ };
23
33
  }
34
+ export type DelegatedRun = RunRef & Required<Pick<RunRef, 'runId' | 'agentId' | 'parent' | 'root'>>;
35
+ export type DelegatedEvent = {
36
+ type: 'admitted' | 'event' | 'ended';
37
+ run: DelegatedRun;
38
+ reason?: 'terminal' | 'cancelled';
39
+ event?: {
40
+ stream: string;
41
+ data?: Record<string, unknown>;
42
+ };
43
+ };
24
44
  export type GateResult = {
25
45
  allow: true;
26
46
  } | {
@@ -55,9 +75,13 @@ export interface RunSpec<S extends OutputSchema | undefined = OutputSchema | und
55
75
  /** Explicit paid key choice. Uses a separate member-owned agent and separate session history. */
56
76
  auth?: 'apiKey';
57
77
  tools?: string[];
78
+ /** Exact target engine agent IDs and their app-tool subsets; does not share credentials. */
79
+ delegates?: Record<string, string[]>;
58
80
  register?: boolean;
59
81
  }
60
82
  export type RunEvent = {
83
+ seq: number;
84
+ } & ({
61
85
  type: 'started';
62
86
  } | {
63
87
  type: 'thinking';
@@ -81,6 +105,15 @@ export type RunEvent = {
81
105
  input?: Record<string, unknown>;
82
106
  output?: unknown;
83
107
  error?: boolean;
108
+ });
109
+ export type RunFrame = RunEvent | {
110
+ type: 'end';
111
+ seq: number;
112
+ end: RunEnd;
113
+ } | {
114
+ type: 'truncated';
115
+ reason: 'truncated' | 'not-found';
116
+ firstSeq?: number;
84
117
  };
85
118
  /** Token usage of one run as the engine totals it (every model call of the run, compaction included). */
86
119
  export type RunUsage = {
@@ -282,8 +315,8 @@ export type BrowserPing = {
282
315
  member: Member;
283
316
  kind: 'state' | 'signin';
284
317
  };
285
- export type KitEventName = GatewayEventName | 'byokit.browser';
286
- export type KitEventPayload<E extends KitEventName> = E extends 'byokit.browser' ? BrowserPing : GatewayEventPayload<Extract<E, GatewayEventName>>;
318
+ export type KitEventName = GatewayEventName | 'byokit.browser' | 'byokit.delegated';
319
+ export type KitEventPayload<E extends KitEventName> = E extends 'byokit.delegated' ? DelegatedEvent : E extends 'byokit.browser' ? BrowserPing : GatewayEventPayload<Extract<E, GatewayEventName>>;
287
320
  export type { GatewayMethod, GatewayParams, GatewayResult } from './generated/methods.ts';
288
321
  export type { GatewayEventName, GatewayEventPayload } from './generated/events.ts';
289
322
  export type AccountId = string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.16.6",
3
+ "version": "0.18.0",
4
4
  "description": "The OpenClaw runtime kit: the pinned engine's full operator surface with plain-words helpers for members, sign-in, runs and approvals.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/plugin/index.js CHANGED
@@ -15,6 +15,7 @@ const Kind = Symbol.for('TypeBox.Kind');
15
15
  const table = JSON.parse(readFileSync(new URL('./tools.json', import.meta.url), 'utf8'));
16
16
  const RUN_PARAM = table.runParam;
17
17
  const PERMIT_PARAM = table.permitParam;
18
+ const RUN_ID_PARAM = `${RUN_PARAM}_id`;
18
19
  const TOOLS = new Map(table.tools.map((t) => [t.name, t]));
19
20
  // Absent (a table from before the flag) reads as true: fail closed.
20
21
  const GATE_BUILTINS = table.gateBuiltins !== false;
@@ -131,8 +132,8 @@ function bridgeRequest(message, { timeoutMs, signal } = {}) {
131
132
  });
132
133
  }
133
134
 
134
- async function gate(key, tool, input, signal) {
135
- const reply = await bridgeRequest({ kind: 'gate', key, tool, input }, { timeoutMs: 195_000, signal });
135
+ async function gate(key, tool, input, signal, runId) {
136
+ const reply = await bridgeRequest({ kind: 'gate', key, tool, input, runId }, { timeoutMs: 195_000, signal });
136
137
  return reply && typeof reply === 'object' ? reply : { allow: false, reason: "can't check this action right now" };
137
138
  }
138
139
 
@@ -151,6 +152,21 @@ export default {
151
152
  },
152
153
  register(api) {
153
154
  registerCodex(api);
155
+ api.on('after_tool_call', async (event, ctx) => {
156
+ if (!['sessions_send', 'sessions_spawn'].includes(event.toolName)) return;
157
+ const d = event.result?.details;
158
+ await bridgeRequest({ kind: 'delegation-receipt', key: ctx?.sessionKey, runId: ctx?.runId ?? event.runId,
159
+ callId: ctx?.toolCallId ?? event.toolCallId,
160
+ result: { details: d && { status: d.status, runId: d.runId, sessionKey: d.sessionKey, childSessionKey: d.childSessionKey } } });
161
+ });
162
+ api.registerAgentEventSubscription?.({ id: 'byokit-delegated-events', handle(event) {
163
+ if (!['assistant', 'tool', 'thinking', 'lifecycle'].includes(event.stream)) return;
164
+ const data = Object.fromEntries(['text', 'name', 'phase', 'toolCallId', 'isError', 'progressTokens', 'aborted', 'error', 'provider', 'model']
165
+ .filter(key => ['string', 'number', 'boolean'].includes(typeof event.data?.[key]))
166
+ .map(key => [key, event.data[key]]));
167
+ void bridgeRequest({ kind: 'delegation-event', event: { runId: event.runId, sessionKey: event.sessionKey,
168
+ stream: event.stream, data } }).catch(() => {});
169
+ } });
154
170
  if (api.registerGatewayMethod) { registerKeys(api); registerUsage(api); registerAccounts(api); registerCatalog(api); }
155
171
  if (BROWSER) {
156
172
  if (typeof api.registerAgentToolResultMiddleware !== 'function') throw new Error('browser result protection unavailable');
@@ -189,13 +205,15 @@ export default {
189
205
  const input = { ...(params ?? {}) };
190
206
  const run = input[RUN_PARAM];
191
207
  const permit = input[PERMIT_PARAM];
208
+ const runId = input[RUN_ID_PARAM];
192
209
  delete input[RUN_PARAM];
193
210
  delete input[PERMIT_PARAM];
211
+ delete input[RUN_ID_PARAM];
194
212
  // The run key is always required; the permit only rides along when the gate minted one (N1).
195
213
  if (typeof run !== 'string') {
196
214
  throw new Error('this tool needs approval before it runs');
197
215
  }
198
- const call = { kind: 'call', key: run, tool: spec.name, input };
216
+ const call = { kind: 'call', key: run, runId, tool: spec.name, input };
199
217
  if (typeof permit === 'string') call.permit = permit;
200
218
  let reply;
201
219
  try {
@@ -247,7 +265,7 @@ export default {
247
265
  }
248
266
  let decision;
249
267
  try {
250
- decision = await gate(key, event.toolName, params, ctx?.abortSignal);
268
+ decision = await gate(key, event.toolName, params, ctx?.abortSignal, ctx?.runId ?? event.runId);
251
269
  } catch (error) {
252
270
  return { block: true, blockReason: error instanceof Error ? error.message : "can't check this action right now" };
253
271
  }
@@ -257,6 +275,13 @@ export default {
257
275
  blockReason: typeof decision.reason === 'string' ? decision.reason : "can't check this action right now",
258
276
  };
259
277
  }
278
+ if (builtin && ['sessions_send', 'sessions_spawn'].includes(event.toolName)) {
279
+ try {
280
+ const prepared = await bridgeRequest({ kind: 'delegation-prepare', key, runId: ctx?.runId ?? event.runId,
281
+ callId: ctx?.toolCallId ?? event.toolCallId, tool: event.toolName, input: params });
282
+ if (prepared.allow === false) return { block: true, blockReason: prepared.reason };
283
+ } catch { return { block: true, blockReason: 'delegation admission unavailable' }; }
284
+ }
260
285
  if (builtin) return BROWSER && event.toolName === 'browser' ? { params } : undefined;
261
286
  // The bridge mints a permit for permitted tools and admits the rest ticket-side; either way the run key
262
287
  // rides along and execute sends back only what the gate gave it (N1).
@@ -264,6 +289,7 @@ export default {
264
289
  params: {
265
290
  ...params,
266
291
  [RUN_PARAM]: key,
292
+ ...(typeof (ctx?.runId ?? event.runId) === 'string' ? { [RUN_ID_PARAM]: ctx?.runId ?? event.runId } : {}),
267
293
  ...(typeof decision.permit === 'string' ? { [PERMIT_PARAM]: decision.permit } : {}),
268
294
  },
269
295
  };