@byokit/openclaw 0.13.0 → 0.14.2

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/dist/runs.js CHANGED
@@ -1,11 +1,12 @@
1
1
  // Runs: one Gateway run per spec, streamed through typed events, ending in a classified RunEnd (5.8, O8).
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { setTimeout as delay } from 'node:timers/promises';
4
- import { authStatus } from "./auth-status.js";
5
- import { outputSchema } from "./output.js";
6
- import { words } from "./words.js";
7
- import { classify } from "./classify.js";
8
- import { providerOf } from "./pick.js";
4
+ import { authStatus } from './auth-status.js';
5
+ import { outputSchema } from './output.js';
6
+ import { words } from './words.js';
7
+ import { classify } from './classify.js';
8
+ import { providerOf } from './pick.js';
9
+ import { catalogModelOf, clampThinking, levelsOf } from './thinking.js';
9
10
  // The gateway gives up on a silent run after an hour; we wait ten minutes longer for the reply frame itself.
10
11
  const WAIT_MS = 3_600_000;
11
12
  const WAIT_CLIENT_MS = 3_610_000;
@@ -109,7 +110,7 @@ export function createRuns(ctx) {
109
110
  const tail = spec.sessionKey.slice(spec.sessionKey.indexOf(':', 'agent:'.length) + 1);
110
111
  // Runs step 1 (5.15): every run first finds the account its session is bound to, the API-key lane included.
111
112
  const binding = ctx.accounts ? await ctx.accounts.bind(spec.member, spec.sessionKey) : undefined;
112
- // Step 2 on the API-key lane: only an API-billed conversation continues on its key; any other binding is refused.
113
+ // Step 2 on the API-key lane: its own lane binding continues; any other binding is refused.
113
114
  if (keyAgent && ctx.accounts && binding) {
114
115
  const message = await ctx.accounts.keyRefusal(spec.member, binding);
115
116
  if (message !== undefined)
@@ -119,8 +120,8 @@ export function createRuns(ctx) {
119
120
  // session went to another account, so it ends before any event and the caller's retry lands there.
120
121
  const moved = async () => !!binding && (await ctx.accounts.bind(spec.member, spec.sessionKey))?.agentId !== binding.agentId;
121
122
  const movedEnd = { ok: false, kind: 'other', message: words('run.moved') };
122
- // Step 3: with `account` and `needs` both absent, on a key no account agent holds (or an API-key run, whose
123
- // binding is API-billed), exactly the run on the agent it always was (a bound key under its shared lock).
123
+ // Step 3: with `account` and `needs` both absent, on a key no account agent holds (or an API-key run, which binds only
124
+ // to its lane), exactly the run on the agent it always was (a bound key under its shared lock).
124
125
  if ((keyAgent || binding?.id === undefined) && spec.account === undefined && !spec.needs?.length) {
125
126
  if (keyAgent)
126
127
  return start(spec, on, { agentId: keyAgent, sessionKey: `agent:${keyAgent}:${tail}`, idempotencyKey, system, picked, output });
@@ -197,6 +198,19 @@ export function createRuns(ctx) {
197
198
  return { ...end, until };
198
199
  }
199
200
  };
201
+ const thinkingOf = async (picked, asked) => {
202
+ if (asked === undefined)
203
+ return undefined;
204
+ if (!picked)
205
+ return { asked, used: asked };
206
+ try {
207
+ const snap = await ctx.request('config.get', {});
208
+ return clampThinking(asked, levelsOf(catalogModelOf(snap.config?.models?.providers, picked.provider, picked.model)));
209
+ }
210
+ catch {
211
+ return { asked, used: asked };
212
+ }
213
+ };
200
214
  const start = async (spec, on, t) => {
201
215
  const { agentId, sessionKey, idempotencyKey, system, picked, output, chosen } = t;
202
216
  const release = spec.register !== false ? ctx.bridge.register({ sessionKey, member: spec.member }, spec.tools, idempotencyKey) : undefined;
@@ -253,7 +267,9 @@ export function createRuns(ctx) {
253
267
  return { ok: false, kind: 'signed-out', message: `${picked.provider} is not signed in for ${spec.member}` };
254
268
  }
255
269
  // One `agent` request: its interim `accepted` frame names the run, its final frame (after the run) carries the
256
- // run's usage. The wait below still decides how the run ended.
270
+ // run's usage. The wait below still decides how the run ended. Thinking is clamped to the catalog before the
271
+ // request so an unsupported level (e.g. Muse's off/high/max) never fails the engine.
272
+ const thinking = await thinkingOf(picked, spec.thinking);
257
273
  let accepted;
258
274
  const ack = new Promise((resolve) => { accepted = resolve; });
259
275
  // The run id is taken as the accepted frame lands, so no event read after it is missed.
@@ -274,7 +290,7 @@ export function createRuns(ctx) {
274
290
  ...(picked ?? {}),
275
291
  ...(system ? { extraSystemPrompt: system } : {}),
276
292
  ...(spec.images ? { attachments: spec.images.map((image) => ({ mimeType: image.mimeType, content: image.data })) } : {}),
277
- ...(spec.thinking ? { thinking: spec.thinking } : {}),
293
+ ...(thinking !== undefined ? { thinking: thinking.used } : {}),
278
294
  }, { expectFinal: true, timeoutMs: WAIT_CLIENT_MS, onAccepted });
279
295
  final.catch(() => { }); // a late failure is the wait's to report
280
296
  const started = await Promise.race([ack, final]);
@@ -307,7 +323,8 @@ export function createRuns(ctx) {
307
323
  const reported = typeof agentMeta.provider === 'string' && typeof agentMeta.model === 'string'
308
324
  ? `${agentMeta.provider}/${agentMeta.model}`.toLowerCase() : '';
309
325
  return { ok: true, text, ...(parsed ? { data: parsed.data } : {}), ...(usage ? { usage } : {}), ...(planWindow ? { planWindow } : {}),
310
- ...(chosen ? { account: chosen.account.id, model: chosen.model || reported } : {}) };
326
+ ...(chosen ? { account: chosen.account.id, model: chosen.model || reported } : {}),
327
+ ...(thinking ? { thinking } : {}) };
311
328
  }
312
329
  if (result.stopReason === 'aborted' || (abortedByEngine && result.status !== 'ok'))
313
330
  return { ok: false, aborted: true };
package/dist/signin.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { remoteCliHost } from '@byokit/accounts/cli';
1
2
  import { authStatus } from './auth-status.ts';
2
3
  import type { GatewayTransport, Member, SignInOptions, SignInView } from './types.ts';
3
4
  export type SignInCtx = {
@@ -7,9 +8,12 @@ export type SignInCtx = {
7
8
  agentId: string;
8
9
  }>;
9
10
  callbackPort: number;
11
+ nativeClaudeLogin?(): ReturnType<typeof remoteCliHost>;
12
+ resetNativeClaudeLogin?(): Promise<void>;
10
13
  onDisconnect?(fn: () => void): () => void;
11
14
  authConfig(): string;
12
15
  restarted(authBefore: string | undefined, signal?: AbortSignal): Promise<boolean>;
16
+ stopSignal?: AbortSignal;
13
17
  fresh?: boolean;
14
18
  };
15
19
  /**
package/dist/signin.js CHANGED
@@ -2,11 +2,11 @@
2
2
  // sign-in; OAuth credentials stay in the engine/CLI. Explicit API keys go only to engine activation (D11).
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { createServer } from 'node:http';
5
- import { activate, LATE_RESTART } from "./activation.js";
6
- import { authStatus } from "./auth-status.js";
7
- import { routes } from "./routes.js";
8
- import { signedInProviders } from "./runs.js";
9
- import { words } from "./words.js";
5
+ import { activate, LATE_RESTART } from './activation.js';
6
+ import { authStatus } from './auth-status.js';
7
+ import { routes } from './routes.js';
8
+ import { signedInProviders } from './runs.js';
9
+ import { words } from './words.js';
10
10
  // The pin's wizard contract (2026.8.1): steps arrive only from `wizard.next` (wizard.status answers {status, error}
11
11
  // and never carries a step), and one session at a time holds the gateway's single setup admission.
12
12
  const BUSY = /setup is already in progress|SETUP_ADMISSION_BUSY/i;
@@ -57,12 +57,14 @@ export function signIn(ctx, member, o, on) {
57
57
  };
58
58
  let server;
59
59
  let sessionId = '';
60
+ let native;
61
+ let nativeId;
60
62
  let starting;
61
63
  let releasing;
62
64
  let settleStart;
63
65
  const startingSettled = new Promise((resolve) => { settleStart = resolve; });
64
66
  let over = false;
65
- let activating = false;
67
+ let committed = false; // the commit point of the native and key drives: set just before setup.activate
66
68
  let recovering = false;
67
69
  let offDisconnect;
68
70
  let agentId;
@@ -127,6 +129,7 @@ export function signIn(ctx, member, o, on) {
127
129
  stopApproval();
128
130
  offDisconnect?.();
129
131
  o.signal?.removeEventListener('abort', cancel);
132
+ ctx.stopSignal?.removeEventListener('abort', cancel);
130
133
  owner.abort();
131
134
  returned = undefined;
132
135
  pasteIn = undefined;
@@ -142,8 +145,16 @@ export function signIn(ctx, member, o, on) {
142
145
  // session only as `openclaw.setup.auth.start` settles, so a cancel sent sooner is answered `wizard not found`
143
146
  // and leaves the person locked out of signing in. Waiting for that settlement is what frees the admission.
144
147
  await startingSettled;
145
- if (sessionId)
146
- await Promise.resolve().then(() => ctx.request('wizard.cancel', { sessionId }, { timeoutMs: CANCEL_MS })).catch(() => { });
148
+ if (nativeId)
149
+ await native?.cancel(nativeId).catch(() => { });
150
+ if (!sessionId)
151
+ return;
152
+ const answer = await Promise.resolve().then(() => ctx.request('wizard.cancel', { sessionId }, { timeoutMs: CANCEL_MS })).catch(() => undefined);
153
+ // The pin answers `cancelled` at once but frees the admission only once the cancelled runner and its setup-target
154
+ // lock settle, and answers wizard.status on that session only after that: done waits for it (CANCEL_MS at most).
155
+ // Not covered: a `running` answer (its commit under way) and a session the engine purged at the cancel.
156
+ if (answer?.status === 'cancelled')
157
+ await Promise.resolve().then(() => ctx.request('wizard.status', { sessionId }, { timeoutMs: CANCEL_MS })).catch(() => { });
147
158
  })();
148
159
  return releasing;
149
160
  };
@@ -162,12 +173,17 @@ export function signIn(ctx, member, o, on) {
162
173
  if (signal.aborted)
163
174
  return;
164
175
  owner.abort();
165
- void (async () => { await release(); if (!activating)
176
+ void (async () => { await release(); if (!committed)
166
177
  await finish(cancelled()); })();
167
178
  };
168
179
  o.signal?.addEventListener('abort', cancel, { once: true });
169
180
  if (o.signal?.aborted)
170
181
  cancel();
182
+ // An engine shutdown before the commit point is this drive's decline too: without it a key paste would sit out
183
+ // its 15-minute paste wait after the engine is gone.
184
+ ctx.stopSignal?.addEventListener('abort', cancel, { once: true });
185
+ if (ctx.stopSignal?.aborted)
186
+ cancel();
171
187
  /** Hold 127.0.0.1:<callbackPort> for the sign-in's life; a taken port is why the sign-in cannot start. */
172
188
  const holdCallback = () => {
173
189
  let took = false;
@@ -219,9 +235,53 @@ export function signIn(ctx, member, o, on) {
219
235
  settleStart();
220
236
  let apiKey;
221
237
  if (o.authChoice === 'anthropic-cli') {
222
- const detected = await ctx.request('openclaw.setup.detect', { agentId }, { timeoutMs: STATUS_MS });
223
- if (!detected.candidates?.some((candidate) => candidate.kind === 'claude-cli' && candidate.credentials === true))
224
- return { state: 'failed', via, why: 'failed', error: 'Sign in with Claude Code on this machine in the kit’s isolated HOME, then try again. The login stays in Claude Code.' };
238
+ const detected = async () => {
239
+ const value = await ctx.request('openclaw.setup.detect', { agentId }, { timeoutMs: STATUS_MS, signal });
240
+ return value.candidates?.some(candidate => candidate.kind === 'claude-cli' && candidate.credentials === true);
241
+ };
242
+ native = ctx.nativeClaudeLogin?.();
243
+ if (!await detected()) {
244
+ if (!native)
245
+ return { state: 'failed', via, why: 'failed', error: 'Claude Code sign-in is not available on this engine.' };
246
+ let started = await native.start('claude', { signal });
247
+ let ready = false;
248
+ if (!started.ok && started.error === 'already') {
249
+ ready = Boolean(await detected());
250
+ if (!ready && ctx.resetNativeClaudeLogin) {
251
+ if (signal.aborted || native.signal.aborted)
252
+ return cancelled();
253
+ await ctx.resetNativeClaudeLogin();
254
+ started = await native.start('claude', { signal });
255
+ }
256
+ }
257
+ if (!ready) {
258
+ if (!started.ok)
259
+ return { state: 'failed', via, why: started.error === 'busy' ? 'busy' : started.error === 'cancelled' ? 'declined' : 'failed', error: started.words };
260
+ nativeId = started.signIn.id;
261
+ if (signal.aborted) {
262
+ await native.cancel(nativeId);
263
+ return cancelled();
264
+ }
265
+ let pasteFailed = false;
266
+ pasteIn = text => { void native.paste(nativeId, text).catch((error) => { if (error.remoteCliProblem === 'unknown')
267
+ return; pasteFailed = true; void native.cancel(nativeId).catch(() => { }); }); };
268
+ say({ url: started.signIn.verificationUri, prompt: 'Paste the code from the sign-in page' });
269
+ if (returned !== undefined) {
270
+ pasteIn(returned);
271
+ returned = undefined;
272
+ }
273
+ const result = await native.wait(nativeId);
274
+ pasteIn = undefined;
275
+ if (signal.aborted)
276
+ return cancelled();
277
+ if (pasteFailed)
278
+ return { state: 'failed', via, why: 'failed', error: 'The sign-in code could not be sent. Try again.' };
279
+ if (!result.ok)
280
+ return { state: 'failed', via, why: result.error === 'expired' ? 'expired' : result.error === 'cancelled' ? 'declined' : 'failed', error: result.words };
281
+ if (!await detected())
282
+ return { state: 'failed', via, why: 'failed', error: 'Claude Code is still signed out on this engine. Try again.' };
283
+ }
284
+ }
225
285
  }
226
286
  else {
227
287
  say({ prompt: keyRoute?.billing === 'api' ? 'API key (billed per use)' : 'Plan key' });
@@ -239,21 +299,19 @@ export function signIn(ctx, member, o, on) {
239
299
  return { state: 'failed', via, why: signal.aborted ? 'declined' : 'expired' };
240
300
  secret = apiKey.trim() || undefined;
241
301
  }
242
- if (signal.aborted)
243
- return { state: 'failed', via, why: 'declined' };
244
- // Done waits for the gateway restart an `auth` change causes (activation.ts); a run started before it runs on the
245
- // old config. A restart that runs late or is cancelled ends failed, and the account is cleaned up by the caller.
246
- activating = true;
247
- const result = await activate(ctx, {
248
- agentId, kind: apiKey === undefined ? 'claude-cli' : 'api-key',
249
- ...(apiKey === undefined ? {} : { authChoice: o.authChoice, apiKey }),
250
- }, { timeoutMs: PULL_MS, signal }).finally(() => { activating = false; });
302
+ // The commit point: a phone cancel or host shutdown that landed before it activates nothing. Done waits for the gateway restart an `auth` change causes (activation.ts).
303
+ // For Claude only host shutdown cuts that wait; a late restart has persisted the route and says saved. A key's cancel still declines its wait; a late key keeps the shared words.
304
+ if (signal.aborted || native?.signal.aborted)
305
+ return cancelled();
306
+ committed = true;
307
+ const result = await activate(ctx, { agentId, kind: apiKey === undefined ? 'claude-cli' : 'api-key', ...(apiKey === undefined ? {} : { authChoice: o.authChoice, apiKey }), }, { timeoutMs: PULL_MS, signal: apiKey === undefined ? native?.signal : signal });
308
+ if (apiKey !== undefined && signal.aborted)
309
+ return cancelled();
251
310
  if (result.late)
252
- return signal.aborted ? cancelled() : { state: 'failed', via, why: 'failed', error: LATE_RESTART };
311
+ return { state: 'failed', via, why: 'failed', error: apiKey === undefined ? words('signin.savedNotRestarted') : LATE_RESTART };
253
312
  if (!result.ok)
254
- return { state: 'failed', via, why: 'failed',
255
- error: apiKey === undefined ? clean(result.error ?? 'Claude Code activation failed') : keyFailed(result.status) };
256
- return signal.aborted ? cancelled() : { state: 'done', via };
313
+ return { state: 'failed', via, why: 'failed', error: apiKey === undefined ? clean(result.error ?? 'Claude Code activation failed') : keyFailed(result.status) };
314
+ return { state: 'done', via };
257
315
  }
258
316
  // The client chooses the wizard id; cancellation waits for the engine to register it.
259
317
  sessionId = `byokit-${randomUUID()}`;
@@ -441,11 +499,11 @@ export function signIn(ctx, member, o, on) {
441
499
  try {
442
500
  const driven = await Promise.race([drive(), disconnected]);
443
501
  view = driven ?? await recover();
444
- if (signal.aborted)
502
+ if (!committed && (signal.aborted || native?.signal.aborted))
445
503
  view = codeExpired ? expired() : cancelled();
446
504
  }
447
505
  catch (error) {
448
- if (signal.aborted)
506
+ if (!committed && (signal.aborted || native?.signal.aborted))
449
507
  view = codeExpired ? expired() : cancelled();
450
508
  else if (sawDeviceCode && /expired_token|code.*expired|device.*(?:expired|timed out)/i.test(cut(error)))
451
509
  view = expired();
@@ -1,5 +1,5 @@
1
- import { fixtureBrowserHost, secureOrigin } from "../browser/host.js";
2
- import { emptySignIns, validateSignIns } from "../browser/store.js";
1
+ import { fixtureBrowserHost, secureOrigin } from '../browser/host.js';
2
+ import { emptySignIns, validateSignIns } from '../browser/store.js';
3
3
  function channel() {
4
4
  let pending;
5
5
  let value;
@@ -7,9 +7,9 @@
7
7
  // and whose calls answer `note: <input.text>`. An optional model is a started model stub the kit was pointed at with `useModelStub`.
8
8
  import { test } from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
- import { PROTOCOL_VERSION } from "../constants.js";
11
- import { GATEWAY_CAPS } from "../transport.js";
12
- import { releaseStub, stubHolding } from "./model-stub.js";
10
+ import { PROTOCOL_VERSION } from '../constants.js';
11
+ import { GATEWAY_CAPS } from '../transport.js';
12
+ import { releaseStub, stubHolding } from './model-stub.js';
13
13
  /** The contract drives pass-through by method name, so results arrive untyped and are narrowed per case. */
14
14
  const call = (kit, method, params) => kit.call(method, params);
15
15
  const REQUIRED_METHODS = [
@@ -5,9 +5,9 @@ import { connect } from 'node:net';
5
5
  import { createHash, randomUUID } from 'node:crypto';
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { dirname, join } from 'node:path';
8
- import { ENGINE_VERSION, PROTOCOL_VERSION } from "../constants.js";
9
- import { toolCalls } from "./model-stub.js";
10
- import { routes } from "../routes.js";
8
+ import { ENGINE_VERSION, PROTOCOL_VERSION } from '../constants.js';
9
+ import { toolCalls } from './model-stub.js';
10
+ import { routes } from '../routes.js';
11
11
  // The device-code script from Crewhouse's openclaw-wizard.test.ts, ported: the step, then progress, then done.
12
12
  const DEVICE_STEP = {
13
13
  id: 'step-device', type: 'note', executor: 'client',
@@ -40,7 +40,9 @@ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArr
40
40
  function mergeInto(target, patch) {
41
41
  for (const [key, value] of Object.entries(patch)) {
42
42
  const current = target[key];
43
- if (isPlainObject(current) && isPlainObject(value))
43
+ if (value === null)
44
+ delete target[key]; // JSON merge-patch: null deletes, as the engine's config.patch does.
45
+ else if (isPlainObject(current) && isPlainObject(value))
44
46
  mergeInto(current, value);
45
47
  else
46
48
  target[key] = value;
@@ -385,6 +387,25 @@ export function fakeGateway(script, o) {
385
387
  models.delete(id);
386
388
  return { ok: true, agentId: id, removedBindings: 0 };
387
389
  },
390
+ // model: null clears the agent's primary (config.patch cannot delete a key); a string sets it.
391
+ 'agents.update': (p) => {
392
+ const id = String(p.agentId ?? '');
393
+ if (!id)
394
+ throw new Error('agents.update requires agentId');
395
+ if ('model' in p) {
396
+ const entries = configBox.config.agents?.entries;
397
+ if (p.model === null) {
398
+ models.delete(id);
399
+ if (entries?.[id] && 'model' in entries[id])
400
+ delete entries[id].model;
401
+ }
402
+ else if (typeof p.model === 'string')
403
+ setPrimaryOfId(id, p.model);
404
+ else
405
+ throw new Error('agents.update model must be a string or null');
406
+ }
407
+ return { ok: true, agentId: id };
408
+ },
388
409
  'agents.files.get': (p) => fileAnswer(String(p.agentId), String(p.name)),
389
410
  'agents.files.set': (p) => {
390
411
  filesOf(String(p.agentId)).files.set(String(p.name), String(p.content));
@@ -447,9 +468,11 @@ export function fakeGateway(script, o) {
447
468
  'byokit.keys': (p) => {
448
469
  const id = `byokit-key-${p.member}`;
449
470
  if (p.action === 'prepare' || p.action === 'clear') {
471
+ // As the shipped plugin: a clear of a lane that was never created has nothing to drop and reports absent.
472
+ const lane = keys.has(id) || agents.has(id);
450
473
  keys.delete(id);
451
474
  ensureAgent(id).providers = [];
452
- return { ok: true };
475
+ return lane ? { ok: true } : { ok: false, absent: true };
453
476
  }
454
477
  const key = keys.get(id);
455
478
  if (!key)
@@ -1,5 +1,5 @@
1
1
  // The kit's fakes, contract suite and scripted model (5.11). Built in O7.
2
- export { fakeGateway } from "./fake-gateway.js";
3
- export { fakeBrowserHost, createBrowserHostFake, browserHostContract, memorySignInStore } from "./browser.js";
4
- export { openclawContract } from "./contract.js";
5
- export { STUB_USAGE, startModelStub, useModelStub, releaseStub, stubHolding, toolCalls } from "./model-stub.js";
2
+ export { fakeGateway } from './fake-gateway.js';
3
+ export { fakeBrowserHost, createBrowserHostFake, browserHostContract, memorySignInStore } from './browser.js';
4
+ export { openclawContract } from './contract.js';
5
+ export { STUB_USAGE, startModelStub, useModelStub, releaseStub, stubHolding, toolCalls } from './model-stub.js';
@@ -17,6 +17,7 @@ export type StubCall = {
17
17
  path: string;
18
18
  body: StubRequest;
19
19
  session?: string;
20
+ org?: string;
20
21
  };
21
22
  /** The token usage every stub reply reports when asked (`stream_options.include_usage`), per model call. */
22
23
  export declare const STUB_USAGE: {
@@ -45,6 +46,9 @@ export type ModelStubOptions = {
45
46
  idPattern?: RegExp;
46
47
  routingMarker?: string;
47
48
  onCall?: (call: StubCall) => void;
49
+ /** The Go gateway's own rule, for tests that prove the workspace header: a Go-route request without
50
+ * `x-opencode-org-id` (or with another workspace's) is refused exactly as the real one refuses it. */
51
+ goWorkspace?: string;
48
52
  };
49
53
  export declare function startModelStub(script?: string[], o?: ModelStubOptions): Promise<ModelStub>;
50
54
  export declare function useModelStub(kit: OpenClawKit, stub: ModelStub): Promise<void>;
@@ -78,9 +78,16 @@ export function startModelStub(script = [], o = {}) {
78
78
  return;
79
79
  }
80
80
  const call = { authorization: String(req.headers.authorization ?? ''), path: req.url ?? '', body,
81
- ...(req.headers['x-opencode-session'] ? { session: String(req.headers['x-opencode-session']) } : {}) };
81
+ ...(req.headers['x-opencode-session'] ? { session: String(req.headers['x-opencode-session']) } : {}),
82
+ ...(req.headers['x-opencode-org-id'] ? { org: String(req.headers['x-opencode-org-id']) } : {}) };
82
83
  calls.push(call);
83
84
  o.onCall?.(call); // Fixture evidence is persisted before any response/hold awaits.
85
+ if (o.goWorkspace !== undefined && call.path.startsWith('/zen/go/')) {
86
+ if (call.org !== o.goWorkspace) {
87
+ res.writeHead(403, { 'content-type': 'application/json' });
88
+ return res.end(JSON.stringify({ type: 'error', error: { type: 'permission_error', message: 'Workspace selection is required' } }));
89
+ }
90
+ }
84
91
  if (req.url === '/api/embed' || req.url === '/api/embeddings') {
85
92
  res.writeHead(200, { 'content-type': 'application/json' });
86
93
  return res.end(JSON.stringify({
@@ -0,0 +1,16 @@
1
+ import type { ThinkingAsked, ThinkingReport, ThinkingUsed } from './types.ts';
2
+ export type CatalogModel = {
3
+ id?: string;
4
+ thinkingLevelMap?: Record<string, string | null | undefined>;
5
+ compat?: {
6
+ supportedReasoningEfforts?: readonly string[] | null;
7
+ };
8
+ };
9
+ /** Supported thinking level ids from one catalog model row, or undefined when the catalog names none. */
10
+ export declare function levelsOf(model: CatalogModel | undefined): ThinkingUsed[] | undefined;
11
+ /** Nearest supported level for `asked`. Empty/absent catalog levels leave `asked` unchanged. */
12
+ export declare function clampThinking(asked: ThinkingAsked, supported: readonly ThinkingUsed[] | undefined): ThinkingReport;
13
+ /** The catalog row for `provider`/`modelId` in a `config.get` models.providers map. */
14
+ export declare function catalogModelOf(providers: Record<string, {
15
+ models?: CatalogModel[];
16
+ }> | undefined, provider: string, modelId: string): CatalogModel | undefined;
@@ -0,0 +1,62 @@
1
+ const ORDER = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
2
+ /** Supported thinking level ids from one catalog model row, or undefined when the catalog names none. */
3
+ export function levelsOf(model) {
4
+ if (!model)
5
+ return undefined;
6
+ const efforts = model.compat?.supportedReasoningEfforts;
7
+ if (Array.isArray(efforts) && efforts.length > 0) {
8
+ const out = [];
9
+ const seen = new Set();
10
+ for (const raw of ['off', ...efforts]) {
11
+ const id = raw === 'none' ? 'off' : raw;
12
+ if (!ORDER.includes(id) || seen.has(id))
13
+ continue;
14
+ seen.add(id);
15
+ out.push(id);
16
+ }
17
+ return out.length ? out : undefined;
18
+ }
19
+ const map = model.thinkingLevelMap;
20
+ if (!map)
21
+ return undefined;
22
+ const out = ORDER.filter((level) => {
23
+ const mapped = map[level];
24
+ if (mapped === null)
25
+ return false;
26
+ if (level === 'xhigh' || level === 'max')
27
+ return mapped !== undefined;
28
+ return true;
29
+ });
30
+ return out.length ? out : undefined;
31
+ }
32
+ /** Nearest supported level for `asked`. Empty/absent catalog levels leave `asked` unchanged. */
33
+ export function clampThinking(asked, supported) {
34
+ if (!supported?.length)
35
+ return { asked, used: asked };
36
+ if (supported.includes(asked))
37
+ return { asked, used: asked };
38
+ const candidates = asked === 'off' ? supported : supported.filter((level) => level !== 'off');
39
+ const pool = candidates.length ? candidates : supported;
40
+ const at = ORDER.indexOf(asked);
41
+ if (at >= 0) {
42
+ for (let i = at; i < ORDER.length; i++) {
43
+ const level = ORDER[i];
44
+ if (pool.includes(level))
45
+ return { asked, used: level };
46
+ }
47
+ for (let i = at - 1; i >= 0; i--) {
48
+ const level = ORDER[i];
49
+ if (pool.includes(level))
50
+ return { asked, used: level };
51
+ }
52
+ }
53
+ return { asked, used: pool[0] };
54
+ }
55
+ /** The catalog row for `provider`/`modelId` in a `config.get` models.providers map. */
56
+ export function catalogModelOf(providers, provider, modelId) {
57
+ const rows = providers?.[provider]?.models;
58
+ if (!rows)
59
+ return undefined;
60
+ const needle = modelId.includes('/') ? modelId.slice(modelId.indexOf('/') + 1) : modelId;
61
+ return rows.find((row) => row.id === needle || row.id === modelId || row.id === `${provider}/${needle}`);
62
+ }
@@ -3,7 +3,7 @@ import type { GatewayTransport } from './types.ts';
3
3
  * Client caps for the operator connection. `approvals` makes this connection an approval client, so the
4
4
  * engine delivers exec/plugin.approval.requested to it (B7); without it the broadcast set is empty.
5
5
  */
6
- export declare const GATEWAY_CAPS: readonly ["tool-events", "approvals"];
6
+ export declare const GATEWAY_CAPS: readonly ['tool-events', 'approvals'];
7
7
  /**
8
8
  * Read the device key pair from `device.json`. The kit writes `{ privateKey, publicKey }` (PEM strings);
9
9
  * existing Crewhouse state carries `{ deviceId, publicKeyPem, privateKeyPem }` instead. Both shapes are
package/dist/transport.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash, createPrivateKey, createPublicKey, sign } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { GatewayClient } from '@openclaw/gateway-client';
4
- import { OPERATOR_SCOPES, PROTOCOL_VERSION } from "./constants.js";
4
+ import { OPERATOR_SCOPES, PROTOCOL_VERSION } from './constants.js';
5
5
  /**
6
6
  * Client caps for the operator connection. `approvals` makes this connection an approval client, so the
7
7
  * engine delivers exec/plugin.approval.requested to it (B7); without it the broadcast set is empty.
package/dist/types.d.ts CHANGED
@@ -102,6 +102,13 @@ export type PlanWindow = {
102
102
  resetAt?: number;
103
103
  }[];
104
104
  };
105
+ /** Thinking levels a run may ask for (`RunSpec.thinking`) and the level the kit actually sent. */
106
+ export type ThinkingAsked = 'off' | 'low' | 'medium' | 'high';
107
+ export type ThinkingUsed = ThinkingAsked | 'minimal' | 'xhigh' | 'max';
108
+ export type ThinkingReport = {
109
+ asked: ThinkingAsked;
110
+ used: ThinkingUsed;
111
+ };
105
112
  export type RunEnd<T = unknown> = {
106
113
  ok: true;
107
114
  text: string;
@@ -110,6 +117,7 @@ export type RunEnd<T = unknown> = {
110
117
  planWindow?: PlanWindow;
111
118
  account?: AccountId;
112
119
  model?: string;
120
+ thinking?: ThinkingReport;
113
121
  } | {
114
122
  ok: false;
115
123
  aborted: true;
package/dist/words.json CHANGED
@@ -21,6 +21,7 @@
21
21
  "signin.returned": "Thanks. Finishing the sign-in — you can go back to the app now.",
22
22
  "signin.busy": "Another sign-in is already in progress. Finish or cancel it, then try again.",
23
23
  "signin.cancelled": "Sign-in cancelled. You can start again whenever you are ready.",
24
+ "signin.savedNotRestarted": "Your Claude sign-in was saved, but the engine did not restart in time. Try again.",
24
25
  "signin.expired": "The sign-in took too long. Start it again.",
25
26
  "signin.title": "Sign in to {site}",
26
27
  "signin.takeover": "Take over to sign in",
@@ -81,6 +82,7 @@
81
82
  "auto.refillsNoTime": "All {provider} accounts are out of room. {name} refills first.",
82
83
  "auto.terms": "Auto may use either of a provider's accounts.",
83
84
  "auto.none": "No signed-in {provider} account.",
85
+ "auto.noneBound": "No account in this conversation is signed in.",
84
86
  "room.unknown": "Room left unknown",
85
87
  "room.session": "{left} left this session",
86
88
  "room.week": "{left} left this week",
@@ -110,10 +112,12 @@
110
112
  "account.another": "another account",
111
113
  "account.signOutFirst": "To add a different {provider} account, sign out of {provider} in your browser first.",
112
114
  "account.legacyMember": "This person needs a new profile before adding more accounts.",
115
+ "account.indexDamaged": "The account list for {member} couldn't be opened, so those accounts are paused. Add the account again to fix this.",
113
116
  "move.unsupported": "This conversation has not started yet, so there is nothing to move.",
114
117
  "move.busy": "Wait for this conversation to finish before moving it.",
115
118
  "move.notReady": "Sign in to {name} first.",
116
119
  "move.closeFailed": "This conversation now continues on {name}, but its earlier copy could not be removed.",
117
120
  "move.taken": "{name} already holds a conversation under this name, so nothing was moved.",
118
- "run.moved": "This conversation just moved to another account. Send it again to continue there."
121
+ "run.moved": "This conversation just moved to another account. Send it again to continue there.",
122
+ "run.thinkingClamped": "Thinking set to {used} because this model does not support {asked}."
119
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.13.0",
3
+ "version": "0.14.2",
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",
@@ -60,9 +60,10 @@
60
60
  "prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
61
61
  },
62
62
  "dependencies": {
63
- "@byokit/pair": "0.10.0",
64
- "@byokit/relay": "0.6.1",
65
- "@byokit/discover": "0.8.1",
63
+ "@byokit/accounts": "0.24.1",
64
+ "@byokit/pair": "0.10.1",
65
+ "@byokit/relay": "0.7.1",
66
+ "@byokit/discover": "0.8.2",
66
67
  "@byokit/seal": "0.3.0",
67
68
  "@byokit/secrets": "0.5.1",
68
69
  "@openclaw/gateway-client": "2026.8.35",
@@ -70,7 +71,7 @@
70
71
  "ws": "8.22.0"
71
72
  },
72
73
  "devDependencies": {
73
- "@byokit/ui": "0.7.0"
74
+ "@byokit/ui": "0.8.0"
74
75
  },
75
76
  "publishConfig": {
76
77
  "access": "public"
package/plugin/keys.js CHANGED
@@ -19,8 +19,9 @@ export function registerKeys(api) {
19
19
  if (diagnostics) diagnostics.stage = 'config';
20
20
  const config = JSON.parse(await readFile(process.env.OPENCLAW_CONFIG_PATH, 'utf8'));
21
21
  const keyId = `byokit-key-${member}`;
22
- if (!config.agents?.entries?.[member] || !config.agents?.entries?.[keyId]) {
23
- reply({ ok: false });
22
+ // A clear for a lane that was never created (or whose entries are gone) has nothing left to drop and reports absent.
23
+ if (!config.agents?.entries?.[keyId] || (params.action !== 'clear' && !config.agents?.entries?.[member])) {
24
+ reply(params.action === 'clear' ? { ok: false, absent: true } : { ok: false });
24
25
  return;
25
26
  }
26
27
  const keyDir = resolveAgentDir(config, keyId);