@byokit/openclaw 0.23.0 → 0.24.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,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.24.0 (2026-10-20)
6
+
7
+ - FEAT: a helper's delegated run now speaks as its helper. A native `sessions_send` carries no system text, so the kit's plugin adds the helper's recorded persona (from `ensureHelper`) as system context to the admitted helper child's run, and only to it. No app change: Chief's `helperDelegates` send to `agent:<agent>:h-<helper>:<rest>` is enough. The kit plugin's config entry now sets `hooks.allowConversationAccess: true` for this hook. `sessions_spawn` children get no persona.
8
+ - FEAT: a tracked `sessions_spawn` into the run's own agent (`delegates` naming it, e.g. Chief spawning under its own signed-in agent) reaches the engine as its own-agent spawn, so an app `subagents.allowAgents` list that names only other agents no longer refuses it.
9
+ - FIX: a delegated child the engine no longer knows (as after an engine restart, when its end never reached the kit) now ends `lost` (new `DelegatedEvent.reason`, outcome unknown) at reconnect instead of staying admitted forever, and a dispatch that finds the 1024-entry journal full reconciles with the engine first, so those children can no longer block delegation. One unreadable `agent.wait` no longer fails the whole reconnect.
10
+
5
11
  ## 0.23.0 (2026-10-20)
6
12
 
7
13
  - FEATURE: A signed-out `claude-cli` run now says why: its end carries `cause: { code: 'needs_cli_login', cli: 'claude', home, configDir?, signIn }` (the new `CliLoginCause` type, also on `./device`). `signIn: true` means `kit.signIn(member, { authChoice: 'anthropic-cli' })` runs the Claude Code login into the kit's own folder (subscription billing); `signIn: false` means the engine reads the person's own Claude Code login, so they run `claude` on that machine. An `anthropic-cli` sign-in the kit cannot run carries the same `cause` on its failed view. `kind`, `message` and every other run end are unchanged.
package/README.md CHANGED
@@ -443,14 +443,17 @@ Pass `delegates: { scout: ['report'] }` on `kit.run` to permit native delegation
443
443
  that app-tool subset. Ensure the target member first and configure the engine's own A2A/visibility/subagent policy;
444
444
  this option does not enable engine tools or bypass `host.gate`. The parent subset narrows descendants too.
445
445
  Tracked `sessions_send` requires an explicit full `sessionKey` and `timeoutSeconds: 0`; tracked `sessions_spawn`
446
- requires an explicit allowed `agentId` and native runtime (not ACP). Unknown children fail closed.
446
+ requires an explicit allowed `agentId` and native runtime (not ACP). A tracked spawn into the run's own agent
447
+ (`delegates: { chief: [...] }` on a `chief` run) goes to the engine as its own-agent spawn, so an app
448
+ `subagents.allowAgents` list for other agents does not refuse it. Unknown children fail closed.
447
449
 
448
450
  `kit.delegatedRuns()` lists admitted children. `kit.onEvent('byokit.delegated', fn)` reports admission, native streams
449
451
  and terminal observations with exact `runId`, `agentId`, `member`, `sessionKey`, `parent` and `root` identity.
450
452
  The same identity reaches `ToolHost`; each delegated action has its own gate/approval. Tool arguments/results
451
453
  are omitted from this event surface. An accepted native dispatch is not finished work or proof of delivery.
452
454
 
453
- Admitted exact run IDs survive parent settlement and are reconciled on reconnect/start. `abort(rootSessionKey)`
455
+ Admitted exact run IDs survive parent settlement and are reconciled on reconnect/start; a child the engine no longer
456
+ knows (as after an engine restart) ends `lost`, and a dispatch that finds the journal full reconciles first. `abort(rootSessionKey)`
454
457
  durably revokes its recorded subtree, closes parked gates/active calls, then asks the engine to abort; a child key
455
458
  revokes its subtree. An engine resume with a different run ID, or a dispatch with no retained receipt, is not
456
459
  silently granted authority. No prefix grants or `allowOnce` bypass are used.
@@ -496,8 +499,9 @@ above, builtin gating on): the engine's own `sessions_send` to the exact key `ag
496
499
  (`timeoutSeconds: 0`) is admitted from its receipt as writer's run, `RunRef.helper: 'writer'` on every gate and call.
497
500
  A target that is not a helper key, a helper on another account, one never ensured, or one the run was not given is
498
501
  refused before dispatch; the parent's tool result reads `session: …`, `bound: …` or `unknown_helper: …`.
499
- `abort(rootSessionKey)` revokes the children, and a helper whose account is removed loses them at their next call. The
500
- child runs without the helper's persona (a native send carries no system text).
502
+ `abort(rootSessionKey)` revokes the children, and a helper whose account is removed loses them at their next call. A
503
+ native send carries no system text, so the kit adds the helper's recorded persona as system context to the admitted
504
+ child's run; a `sessions_spawn` child gets none.
501
505
 
502
506
  Conversations from before helpers (plain member sessions) move into a helper with
503
507
  `kit.adoptHelperSession({ member, sessionKey }, helper)`: the whole transcript forks to
@@ -510,8 +514,9 @@ both. A helper key, another member's key or a helper never ensured is a typed `H
510
514
 
511
515
  `kit.onEvent('byokit.delegated', fn)` follows each helper child start to finish: `admitted` (with `run.helper` and
512
516
  `run.tools`, its app-tool subset), the engine's events, then one `ended` whose `reason` is `terminal`, `cancelled` (an
513
- abort, or its admission ended), `error` (with `message`) or `resting`: the model's usage limit ended it, and the kit
514
- rests the helper's account `until` the stated reset, as a kit run's resting end does. An approval raised by a helper's
517
+ abort, or its admission ended), `error` (with `message`), `lost` (the engine no longer knows the run: outcome unknown)
518
+ or `resting`: the model's usage limit ended it, and the kit rests the helper's account `until` the stated reset, as a
519
+ kit run's resting end does. An approval raised by a helper's
515
520
  run names it in `Approval.helper`.
516
521
 
517
522
  ## Owned browser (unqualified handoff)
package/dist/bridge.js CHANGED
@@ -316,28 +316,35 @@ export class Bridge {
316
316
  return;
317
317
  }
318
318
  if (message.kind.startsWith('delegation-') && this.delegations) {
319
- try {
319
+ const delegations = this.delegations;
320
+ void (async () => {
320
321
  if (message.kind === 'delegation-prepare') {
321
322
  if (typeof message.key !== 'string' || typeof message.runId !== 'string'
322
- || (!this.isRegisteredRun(message.key, message.runId) && !this.delegations.has(message.key, message.runId)))
323
+ || (!this.isRegisteredRun(message.key, message.runId) && !delegations.has(message.key, message.runId)))
323
324
  return this.deny(socket, 'unknown delegation parent');
324
- this.reply(socket, { tracked: this.delegations.prepare(message) });
325
+ this.reply(socket, { tracked: await delegations.prepare(message) });
325
326
  }
326
327
  else if (message.kind === 'delegation-receipt') {
327
- this.delegations.receipt(message);
328
+ delegations.receipt(message);
328
329
  this.reply(socket, { ok: true });
329
330
  }
330
331
  else if (message.kind === 'delegation-event' && isRecord(message.event)) {
331
- this.delegations.event(message.event);
332
+ delegations.event(message.event);
332
333
  this.reply(socket, { ok: true });
333
334
  }
335
+ // The plugin's prompt hook on a helper key: an admitted helper child speaks as its helper (5.8.3).
336
+ else if (message.kind === 'delegation-persona' && typeof message.key === 'string') {
337
+ const runId = typeof message.runId === 'string' ? message.runId : undefined;
338
+ // A kit run carries its own persona; only an engine-created child is looked up (it may wait for its receipt).
339
+ const persona = runId && this.isRegisteredRun(message.key, runId) ? undefined : await delegations.persona(message.key, runId);
340
+ this.reply(socket, persona === undefined ? {} : { persona });
341
+ }
334
342
  else
335
343
  this.deny(socket, 'invalid delegation message');
336
- }
337
- catch (error) {
344
+ })().catch((error) => {
338
345
  // A typed helper refusal (5.8.3) reaches the parent's tool result with its code.
339
346
  this.deny(socket, error instanceof HelperError ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : 'delegation unavailable');
340
- }
347
+ });
341
348
  return;
342
349
  }
343
350
  if (message.kind === 'gate')
package/dist/config.js CHANGED
@@ -100,7 +100,9 @@ export function reconcileConfig(saved, o) {
100
100
  ...o.plugins ?? [],
101
101
  ...telegram ? ['telegram'] : []])];
102
102
  c.plugins.entries ??= {};
103
- c.plugins.entries[o.pluginId] = merge(c.plugins.entries[o.pluginId] ?? {}, { hooks: { timeouts: { before_tool_call: 200_000 } } });
103
+ // Conversation access lets the plugin's prompt hook give an admitted helper child its persona (5.8.3).
104
+ c.plugins.entries[o.pluginId] = merge(c.plugins.entries[o.pluginId] ?? {}, { hooks: { timeouts: { before_tool_call: 200_000 },
105
+ allowConversationAccess: true } });
104
106
  if (o.browser) {
105
107
  // Replace, never merge caller-controlled profiles/targets. The dead default fails closed without our hook.
106
108
  c.browser = { enabled: true, defaultProfile: 'byokit-none', evaluateEnabled: false,
@@ -108,7 +110,6 @@ export function reconcileConfig(saved, o) {
108
110
  profiles: { 'byokit-none': { cdpUrl: 'http://127.0.0.1:1', attachOnly: true }, ...o.browser.profiles } };
109
111
  c.plugins.allow = [...new Set([...c.plugins.allow, 'browser'])];
110
112
  c.plugins.entries.browser = { enabled: true };
111
- c.plugins.entries[o.pluginId].hooks.allowConversationAccess = true;
112
113
  c.tools ??= {};
113
114
  const browserTools = [...new Set([...(c.tools.alsoAllow ?? []), 'browser', 'request_sign_in', ...o.browser.tools])];
114
115
  // The stock schema rejects allow + alsoAllow in one scope; an explicit closed allow must absorb additions.
@@ -20,6 +20,7 @@ export declare class Delegations {
20
20
  helper(member: string, helper: string): {
21
21
  agentId: string;
22
22
  account: string;
23
+ persona: string;
23
24
  };
24
25
  rest(member: string, account: string, until: number): Promise<void>;
25
26
  request: GatewayTransport['request'];
@@ -33,9 +34,12 @@ export declare class Delegations {
33
34
  register(ref: RunRef, runId: string, tools: readonly string[] | undefined, delegates: Record<string, string[]>, sourceKey?: string, helpers?: Record<string, string[]>): () => void;
34
35
  list(): DelegatedRun[];
35
36
  has(key: string, runId: string): boolean;
36
- prepare(m: Record<string, any>): boolean;
37
+ private full;
38
+ prepare(m: Record<string, any>): Promise<boolean>;
37
39
  receipt(m: Record<string, any>): void;
38
40
  resolve(key: string, runId?: string): Promise<Grant | undefined>;
41
+ /** The persona of the admitted helper child on this exact key and run, as its own run would carry it (5.8.3). */
42
+ persona(key: string, runId?: string): Promise<string | undefined>;
39
43
  private bound;
40
44
  event(event: Record<string, any>): void;
41
45
  private ended;
@@ -70,8 +70,19 @@ export class Delegations {
70
70
  this.load();
71
71
  return (this.roots.get(runId) ?? this.children.get(runId))?.ref.sessionKey === key;
72
72
  }
73
- prepare(m) {
73
+ full() {
74
+ for (const [id, p] of this.pending)
75
+ if (p.expires < Date.now())
76
+ this.pending.delete(id);
77
+ return this.pending.size + this.children.size >= 1024;
78
+ }
79
+ async prepare(m) {
74
80
  this.load();
81
+ // Children whose end never reached the kit would fill the journal for good: settle them with the engine first,
82
+ // before the parent is read, so an abort meanwhile is seen.
83
+ // ponytail: a full journal of live children re-reads all 1024 per dispatch; index ends by age if that shows up.
84
+ if (this.full())
85
+ await this.reconcile();
75
86
  const parent = this.roots.get(m.runId) ?? this.children.get(m.runId);
76
87
  if (!parent || parent.ref.sessionKey !== m.key)
77
88
  return false;
@@ -98,10 +109,7 @@ export class Delegations {
98
109
  throw new HelperError('session', `refused: "${String(m.input.sessionKey ?? agent)}" is not a helper session`);
99
110
  throw new Error('delegation target or mode refused');
100
111
  }
101
- for (const [id, p] of this.pending)
102
- if (p.expires < Date.now())
103
- this.pending.delete(id);
104
- if (this.pending.size + this.children.size >= 1024)
112
+ if (this.full())
105
113
  throw new Error('delegated run journal is full');
106
114
  this.pending.set(actionKey(m), { parent: structuredClone(parent), agent: agent,
107
115
  ...(send ? { key: m.input.sessionKey } : {}), ...(helper !== undefined ? { helper } : {}), expires: Date.now() + 60_000 });
@@ -171,6 +179,11 @@ export class Delegations {
171
179
  }
172
180
  return grant;
173
181
  }
182
+ /** The persona of the admitted helper child on this exact key and run, as its own run would carry it (5.8.3). */
183
+ async persona(key, runId) {
184
+ const helper = (await this.resolve(key, runId))?.ref;
185
+ return helper?.helper === undefined ? undefined : this.o.helper(helper.member, helper.helper).persona;
186
+ }
174
187
  bound(member, helper, agent) {
175
188
  try {
176
189
  return this.o.helper(member, helper).agentId === agent;
@@ -223,12 +236,25 @@ export class Delegations {
223
236
  this.o.revoke(grant.ref.sessionKey, grant.ref.runId);
224
237
  this.o.emit({ type: 'ended', run: structuredClone(grant.ref), ...end });
225
238
  }
239
+ // An exact recorded run the engine finished ends as it ended. One the engine no longer knows (no job, no queued turn,
240
+ // no live run, as after an engine restart: a plain `timeout` in the `queue` phase that never reached a provider) ends
241
+ // `lost`. A live or queued run stays admitted; a run whose wait fails stays for the next reconcile.
226
242
  async reconcile() {
227
243
  for (const ref of this.list()) {
228
- const end = await this.o.request('agent.wait', { runId: ref.runId, timeoutMs: 1 });
244
+ let end;
245
+ try {
246
+ end = await this.o.request('agent.wait', { runId: ref.runId, timeoutMs: 1 });
247
+ }
248
+ catch {
249
+ continue;
250
+ }
229
251
  const grant = this.children.get(ref.runId);
230
- if (grant && ['ok', 'error'].includes(end.status ?? ''))
252
+ if (!grant)
253
+ continue;
254
+ if (end.status === 'ok' || end.status === 'error')
231
255
  this.end(grant, { reason: end.status === 'ok' ? 'terminal' : 'error' });
256
+ else if (end.status === 'timeout' && end.timeoutPhase === 'queue' && end.providerStarted === false)
257
+ this.end(grant, { reason: 'lost' });
232
258
  }
233
259
  }
234
260
  async abort(key) {
@@ -329,6 +329,12 @@ export function fakeGateway(script, o) {
329
329
  setTimeout(() => {
330
330
  void (async () => {
331
331
  const table = bridgeSock ? pluginTable(bridgeSock) : undefined;
332
+ // As the plugin's prompt hook: a child on a helper key asks the kit for its helper's persona (5.8.3).
333
+ if (child && bridgeSock && /^agent:[^:]+:h-[a-z][a-z0-9-]{0,15}:/.test(run.sessionKey)) {
334
+ const reply = await bridgeRequest(bridgeSock, { kind: 'delegation-persona', key: run.sessionKey, runId: run.runId }).catch(() => ({}));
335
+ calls.push({ method: 'before_prompt_build', params: { sessionKey: run.sessionKey, runId: run.runId,
336
+ ...(typeof reply.persona === 'string' ? { appendSystemContext: reply.persona } : {}) } });
337
+ }
332
338
  let n = 0;
333
339
  for (const call of toolCalls(message)) {
334
340
  const toolCallId = `call-${++n}`;
@@ -688,14 +694,18 @@ export function fakeGateway(script, o) {
688
694
  return { status: 'cancelled' };
689
695
  },
690
696
  agent: (p, bridgeSock) => startRun(p, bridgeSock),
691
- 'agent.wait': (p) => new Promise((resolve, reject) => {
692
- const run = runs.get(String(p.runId));
697
+ // The pin: a run it never had (or forgot, as after a restart) waits out as a `timeout` in the `queue` phase that never
698
+ // reached a provider; a live run past `timeoutMs` is a `timeout` while it drains.
699
+ 'agent.wait': (p) => new Promise((resolve) => {
700
+ const runId = String(p.runId);
701
+ const run = runs.get(runId);
693
702
  if (!run)
694
- return reject(new Error(`unknown run: ${p.runId}`));
703
+ return resolve({ runId, status: 'timeout', timeoutPhase: 'queue', providerStarted: false });
695
704
  if (run.result)
696
- resolve(run.result);
697
- else
698
- run.waiters.push(resolve);
705
+ return resolve(run.result);
706
+ run.waiters.push(resolve);
707
+ if (typeof p.timeoutMs === 'number')
708
+ setTimeout(() => resolve({ runId, status: 'timeout', timeoutPhase: 'gateway_draining' }), p.timeoutMs).unref();
699
709
  }),
700
710
  'sessions.steer': () => ({}),
701
711
  // The pin: a key the agent holds answers its canonical key; a missing one errors unless `allowMissing`, which answers `{ ok: false }`.
package/dist/types.d.ts CHANGED
@@ -46,12 +46,13 @@ export type DelegatedRun = RunRef & Required<Pick<RunRef, 'runId' | 'agentId' |
46
46
  tools: string[];
47
47
  };
48
48
  /** A delegated child's lifecycle (5.8.3 S3): `admitted`, its engine `event`s, then one `ended` whose `reason` is
49
- * `terminal` (finished), `error` (`message`), `resting` (its helper's account hit its limit and rests `until`) or
50
- * `cancelled` (aborted by the kit or the engine, or its admission ended). */
49
+ * `terminal` (finished), `error` (`message`), `resting` (its helper's account hit its limit and rests `until`),
50
+ * `cancelled` (aborted by the kit or the engine, or its admission ended) or `lost` (the engine no longer knows the run,
51
+ * as after an engine restart: outcome unknown, admission ended). */
51
52
  export type DelegatedEvent = {
52
53
  type: 'admitted' | 'event' | 'ended';
53
54
  run: DelegatedRun;
54
- reason?: 'terminal' | 'error' | 'resting' | 'cancelled';
55
+ reason?: 'terminal' | 'error' | 'resting' | 'cancelled' | 'lost';
55
56
  until?: number;
56
57
  message?: string;
57
58
  event?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.23.0",
3
+ "version": "0.24.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
@@ -159,6 +159,17 @@ export default {
159
159
  callId: ctx?.toolCallId ?? event.toolCallId,
160
160
  result: { details: d && { status: d.status, runId: d.runId, sessionKey: d.sessionKey, childSessionKey: d.childSessionKey } } });
161
161
  });
162
+ // A native send carries no system text: an admitted helper child gets its helper's persona from the kit (5.8.3).
163
+ // Any other run, or no answer, adds nothing; the persona is the helper's voice, never a grant.
164
+ api.on('before_prompt_build', async (_event, ctx) => {
165
+ const key = ctx?.sessionKey;
166
+ if (typeof key !== 'string' || !/^agent:[^:]+:h-[a-z][a-z0-9-]{0,15}:/.test(key)) return undefined;
167
+ try {
168
+ const reply = await bridgeRequest({ kind: 'delegation-persona', key, runId: ctx?.runId }, { timeoutMs: 10_000 });
169
+ if (typeof reply?.persona === 'string' && reply.persona) return { appendSystemContext: reply.persona };
170
+ } catch { /* no kit: nothing added */ }
171
+ return undefined;
172
+ });
162
173
  api.registerAgentEventSubscription?.({ id: 'byokit-delegated-events', handle(event) {
163
174
  if (!['assistant', 'tool', 'thinking', 'lifecycle'].includes(event.stream)) return;
164
175
  const data = Object.fromEntries(['text', 'name', 'phase', 'toolCallId', 'isError', 'progressTokens', 'aborted', 'error', 'provider', 'model']
@@ -281,6 +292,12 @@ export default {
281
292
  callId: ctx?.toolCallId ?? event.toolCallId, tool: event.toolName, input: params });
282
293
  if (prepared.allow === false) return { block: true, blockReason: prepared.reason };
283
294
  } catch { return { block: true, blockReason: 'delegation admission unavailable' }; }
295
+ // A tracked spawn into the run's own agent goes without `agentId`, the engine's own-agent spawn, so an app's
296
+ // `subagents.allowAgents` list for other agents never refuses it. The kit already admitted that exact agent.
297
+ // Hook params merge over the model's, so the key is overridden, not left out.
298
+ const own = ctx?.agentId ?? /^agent:([^:]+):/.exec(key)?.[1];
299
+ if (event.toolName === 'sessions_spawn' && typeof own === 'string' && params.agentId === own)
300
+ return { params: { ...params, agentId: undefined } };
284
301
  }
285
302
  if (builtin) return BROWSER && event.toolName === 'browser' ? { params } : undefined;
286
303
  // The bridge mints a permit for permitted tools and admits the rest ticket-side; either way the run key