@byokit/openclaw 0.17.0 → 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,10 @@
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
+
5
9
  ## 0.17.0 (2026-10-18)
6
10
 
7
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.
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, DelegatedRun, DelegatedEvent, 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,6 +64,8 @@ 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;
package/dist/kit.js CHANGED
@@ -18,7 +18,7 @@ import { createAccounts, memberOfAccount } from './accounts.js';
18
18
  import { createLocks } from './locks.js';
19
19
  import { keyAgentId, KEY_PREFIX, MEMBER_ID } from './members.js';
20
20
  import { confirmRetainedLogin as confirmLogin, migrateRetainedLogin as migrateLogin } from './migrate.js';
21
- import { createRuns, signedInProviders } from './runs.js';
21
+ import { createRuns, RunReplay, signedInProviders } from './runs.js';
22
22
  import { authStatus, createAuthStatus } from './auth-status.js';
23
23
  import { outputSchema } from './output.js';
24
24
  import { createKeys } from './keys.js';
@@ -442,6 +442,10 @@ export class OpenClawKit {
442
442
  members;
443
443
  locks = createLocks();
444
444
  routeMap = new Map();
445
+ runReplay = new RunReplay();
446
+ attach(member, runId, fromSeq, signal) {
447
+ return this.runReplay.attach(member, runId, fromSeq, signal);
448
+ }
445
449
  installs = Promise.resolve();
446
450
  accountsCtl;
447
451
  // One bridge and one approval surface per kit: listeners and run registrations survive reconnects (B3).
@@ -948,6 +952,8 @@ export class OpenClawKit {
948
952
  });
949
953
  }
950
954
  closed(why) {
955
+ if (why === 'engine exited')
956
+ this.runReplay.clear();
951
957
  // A first start's loop owns an engine exit until it reaches ready; other closes are the transport client's to retry.
952
958
  if (this.startRun && !this.reachedReady) {
953
959
  if (why === 'engine exited')
@@ -976,6 +982,7 @@ export class OpenClawKit {
976
982
  return delay(retryAt - Date.now(), undefined, { signal });
977
983
  }
978
984
  async stop() {
985
+ this.runReplay.clear();
979
986
  this.stopping = true;
980
987
  this.reachedReady = false;
981
988
  this.startRun?.abort();
@@ -1311,10 +1318,11 @@ export class OpenClawKit {
1311
1318
  confirmRetainedLogin(member, source) {
1312
1319
  return confirmLogin({ ...this.signInCtx(), seal: this.o.authSeal, log: this.o.log }, member, source);
1313
1320
  }
1314
- // 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
1315
1322
  // with this kit's live transport, member table and bridge (spec item O9).
1316
1323
  runs() {
1317
1324
  return createRuns({
1325
+ replay: this.runReplay,
1318
1326
  request: (method, params, o) => this.request()(method, params, o),
1319
1327
  authStatus: this.admission.read,
1320
1328
  invalidateAuth: this.admission.invalidate,
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
@@ -4,14 +4,33 @@ import type { createAccounts } from './accounts.ts';
4
4
  import type { createLocks } from './locks.ts';
5
5
  import { authStatus } from './auth-status.ts';
6
6
  import { outputSchema, type OutputSchema, type SchemaOutput } from './output.ts';
7
- import type { GatewayTransport, Member, RunEnd, RunEvent, RunSpec } from './types.ts';
7
+ import type { GatewayTransport, Member, RunEnd, RunEvent, RunFrame, RunSpec } from './types.ts';
8
8
  export declare const USABLE: Set<string>;
9
9
  /**
10
10
  * The providers a `models.authStatus` answer shows usable (the rule a picked account is checked by), lowercased and
11
11
  * unique; undefined while the engine has no prepared status (unknown, not none).
12
12
  */
13
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
+ }
14
32
  export declare function createRuns(ctx: {
33
+ replay?: RunReplay;
15
34
  request: GatewayTransport['request'];
16
35
  authStatus?: (agentId: string, refresh: boolean, native: boolean) => ReturnType<typeof authStatus>;
17
36
  invalidateAuth?: () => void;
@@ -32,3 +51,4 @@ export declare function createRuns(ctx: {
32
51
  steer(k: string, t: string): Promise<void>;
33
52
  abort(k: string): Promise<void>;
34
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,7 +158,7 @@ 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}--`)))
@@ -351,8 +419,25 @@ export function createRuns(ctx) {
351
419
  releaseDelegation?.();
352
420
  }
353
421
  };
422
+ const replay = ctx.replay ?? new RunReplay();
354
423
  return {
355
- 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
+ },
356
441
  steer: (k, t) => ctx.request('sessions.steer', { sessionKey: ctx.routes.get(k)?.session ?? k, message: t }),
357
442
  abort: async (k) => { await ctx.request('chat.abort', { sessionKey: ctx.routes.get(k)?.session ?? k }); },
358
443
  };
package/dist/types.d.ts CHANGED
@@ -80,6 +80,8 @@ export interface RunSpec<S extends OutputSchema | undefined = OutputSchema | und
80
80
  register?: boolean;
81
81
  }
82
82
  export type RunEvent = {
83
+ seq: number;
84
+ } & ({
83
85
  type: 'started';
84
86
  } | {
85
87
  type: 'thinking';
@@ -103,6 +105,15 @@ export type RunEvent = {
103
105
  input?: Record<string, unknown>;
104
106
  output?: unknown;
105
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;
106
117
  };
107
118
  /** Token usage of one run as the engine totals it (every model call of the run, compaction included). */
108
119
  export type RunUsage = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.17.0",
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",