@byokit/openclaw 0.26.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.26.1 (2026-10-21)
6
+
7
+ - FIX: a key sign-in through `@byokit/openclaw` no longer fires one slow `openclaw.setup.detect` per reader: the
8
+ readiness read, the account list and a sign-in share one detection per agent (one probe in flight, its answer reused
9
+ briefly, and every sign-in, sign-out, key change and account change drops it), and the pin's own status and the
10
+ detection now run together. The app's poll after a sign-in costs one probe instead of fifteen.
11
+ - FIX: a key lane lists the models the provider's live catalog offers, so a model the picker showed (MiniMax Flash)
12
+ stays listed and pickable instead of being refused as "not included" while the engine's own list still lags the
13
+ registration. The kit keeps no model list of its own: the rows are the ones it registered from the live catalog.
14
+ - FIX: an OpenRouter key sign-in now registers the provider's live list beside the model the person chose, so the
15
+ member keeps every model the vendor lists to pick from (the MiniMax models, `minimax/minimax-m3.1-flash` and its
16
+ free variant, among them) and `openrouter/auto` stays beside them. The engine's catalog stops at a registered list,
17
+ so an add that registered the chosen model alone left the member with that one row and nothing else to pick from.
18
+ The rows are deduped by the id the engine keeps, so the Google preview pair is one row and the list is never
19
+ refused, and the chosen model is registered on its own as well, so a list the engine refuses never costs the member
20
+ their own choice.
21
+
5
22
  ## 0.26.0 (2026-10-20)
6
23
 
7
24
  - Dependency update: pins @byokit/accounts 0.30.0.
package/README.md CHANGED
@@ -192,6 +192,11 @@ pay-as-you-go with no plan, so it takes any model its own live catalog lists, na
192
192
  prefix (`openrouter/auto`). A Google preview id is named and saved as the canonical id the engine keeps
193
193
  (`openrouter/google/gemini-3.1-flash-lite-preview` becomes `openrouter/google/gemini-3.1-flash-lite`); the add
194
194
  registers that one model with the engine (every provider but OpenCode Go and MiniMax, whose whole lists are registered).
195
+ An OpenRouter key add registers the provider's live list beside the chosen model, so the member keeps every model the
196
+ vendor lists to pick from (the MiniMax models, `minimax/minimax-m3.1-flash` and its free variant, among them) and
197
+ `openrouter/auto` stays beside them; the engine's catalog stops at a registered list, so registering the chosen model
198
+ alone would leave the member with that one row. The rows are deduped by the id the engine keeps, so the Google preview
199
+ pair is one row and the list is never refused.
195
200
  A vendor's list can lag a model it already runs, so an id the catalog lacks is taken, not refused: it rides the route's
196
201
  own models (the richest row's api, base URL and context), unpriced, and the app sees it missing from `models()` to warn.
197
202
  A route with no catalog rows (its plugin has none) lists nothing, and a `model` there returns
@@ -1,7 +1,11 @@
1
1
  import { createLocks } from './locks.ts';
2
+ import type { Detector } from './detect.ts';
2
3
  import { type RouteView } from './routes.ts';
3
4
  import type { Account, AccountId, AccountPick, Defaults, GatewayTransport, Member, ModelInfo, MoveResult, Room, RunSelection, SignInOptions, SignInView } from './types.ts';
4
5
  export declare const kitId: (provider: string, id: string) => string;
6
+ /** The id the engine keeps for a row of `provider` (5.6): a Google preview id (a row of a provider that carries
7
+ * Google's models) and a bare id of a provider that names it. Every other id is the vendor's own, kept as it is. */
8
+ export declare const engineModelId: (provider: string, id: string) => string;
5
9
  export declare const engineId: (provider: string, model: string, rows: readonly unknown[] | undefined) => string;
6
10
  export declare const modelsOf: (provider: string, answer: unknown) => ModelInfo[];
7
11
  export declare const modelsOfAccount: (row: Pick<Account, 'provider' | 'state'>, answer: unknown) => ModelInfo[];
@@ -73,6 +77,8 @@ export declare function createAccounts(ctx: {
73
77
  }>;
74
78
  log?: (line: string) => void;
75
79
  reseal?: () => Promise<void>;
80
+ detect?: Detector;
81
+ catalog?: (provider: string) => ModelInfo[];
76
82
  }): {
77
83
  list(member: Member): Promise<Account[]>;
78
84
  add(member: Member, o: AddOptions, on: (v: SignInView) => void): Promise<AccountSignIn>;
package/dist/accounts.js CHANGED
@@ -10,6 +10,7 @@ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync
10
10
  import { randomBytes, randomUUID } from 'node:crypto';
11
11
  import { join } from 'node:path';
12
12
  import { DatabaseSync } from 'node:sqlite';
13
+ import { detection } from './auth-status.js';
13
14
  import { providerOf, resolveSelection } from './pick.js';
14
15
  import { agentOf, memberOf as baseMemberOf } from './attribution.js';
15
16
  import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, keyAgentId, untilListed, untilUnlisted } from './members.js';
@@ -19,6 +20,35 @@ const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
19
20
  // A provider-local engine model id as the kit's `provider/model` reference; an id already prefixed with its own
20
21
  // provider is left alone.
21
22
  export const kitId = (provider, id) => providerOf(id) === provider.toLowerCase() ? id : `${provider}/${id}`;
23
+ // The id the pinned engine keeps for a catalog row: it rewrites a row id when it writes the config. A Google
24
+ // preview id becomes the id Google calls it (`google/gemini-3.1-flash-lite-preview` becomes
25
+ // `google/gemini-3.1-flash-lite`), and a bare id of a provider that names it carries that provider. The kit names
26
+ // every row by the id the engine keeps, so the engine's list and the kit's never disagree, and two rows the engine
27
+ // would keep under one id are one row: a list with both is refused ("Ambiguous duplicate ID").
28
+ const googlePreviewIds = {
29
+ 'gemini-3-pro': 'gemini-3.1-pro-preview',
30
+ 'gemini-3-pro-preview': 'gemini-3.1-pro-preview',
31
+ 'gemini-3.1-pro': 'gemini-3.1-pro-preview',
32
+ 'gemini-3-flash': 'gemini-3-flash-preview',
33
+ 'gemini-3.1-flash': 'gemini-3-flash-preview',
34
+ 'gemini-3.1-flash-preview': 'gemini-3-flash-preview',
35
+ 'gemini-3.1-flash-lite-preview': 'gemini-3.1-flash-lite',
36
+ 'gemma-4-26b': 'gemma-4-26b-a4b-it',
37
+ };
38
+ /** The id the engine keeps for a row of `provider` (5.6): a Google preview id (a row of a provider that carries
39
+ * Google's models) and a bare id of a provider that names it. Every other id is the vendor's own, kept as it is. */
40
+ export const engineModelId = (provider, id) => {
41
+ const at = id.indexOf('/');
42
+ const maker = at < 0 ? '' : id.slice(0, at);
43
+ const model = at < 0 ? id : id.slice(at + 1);
44
+ if (maker === 'google') {
45
+ const kept = googlePreviewIds[model] ?? model;
46
+ return kept === model ? id : `${maker}/${kept}`;
47
+ }
48
+ if (provider === 'openrouter' && at < 0)
49
+ return `${provider}/${id}`;
50
+ return id;
51
+ };
22
52
  // The engine's provider-local id for a kit `provider/model` reference, resolved against that provider's rows.
23
53
  export const engineId = (provider, model, rows) => {
24
54
  for (const row of rows ?? []) {
@@ -538,8 +568,9 @@ export function createAccounts(ctx) {
538
568
  return { rows, dirty };
539
569
  };
540
570
  // A detected Claude Code login has no profile in the kit: it stays in Claude Code. The engine's detection says whether
541
- // it is there, and a detected one is the member's signed-in claude-cli profile for this read.
542
- const claudeDetected = (agentId) => ctx.request('openclaw.setup.detect', { agentId }, { timeoutMs: 20_000 })
571
+ // it is there, and a detected one is the member's signed-in claude-cli profile for this read. The shared detection
572
+ // answers it for every reader (one probe per agent, reused briefly), so a listing and a readiness read cost one probe.
573
+ const claudeDetected = (agentId) => detection(ctx.request, agentId, ctx.detect)
543
574
  .then((r) => r.candidates?.some((c) => c.kind === 'claude-cli' && c.credentials === true) === true, () => false);
544
575
  const gather = async (agentId) => {
545
576
  const [roster, profiles, claude] = await Promise.all([rosterOf(), profilesOf(agentId), claudeDetected(agentId)]);
@@ -669,8 +700,21 @@ export function createAccounts(ctx) {
669
700
  const { row, entry } = await entryOf(member, id);
670
701
  return readModels(row, entry);
671
702
  }
703
+ // The account's models: the engine's own rows, joined with the catalog rows the kit registered for that provider
704
+ // (the provider's live list). The engine's list can lag the registration, and a model the picker offered must
705
+ // stay pickable: the kit registered it, so the engine can run it. The engine's own row wins where both name it.
706
+ const withCatalog = (row, own) => {
707
+ const registered = ctx.catalog?.(row.provider) ?? [];
708
+ if (!registered.length)
709
+ return own;
710
+ const byId = new Map(own.map((m) => [m.id, m]));
711
+ for (const model of registered)
712
+ if (!byId.has(model.id))
713
+ byId.set(model.id, model);
714
+ return [...byId.values()];
715
+ };
672
716
  // The shared hold covers the read, so no kit read of the agent is in flight while it is removed.
673
- const readModels = (row, entry) => ctx.locks.shared(entry.agent, async () => modelsOfAccount(row, await ctx.request('models.list', { agentId: entry.agent, view: 'all' })));
717
+ const readModels = (row, entry) => ctx.locks.shared(entry.agent, async () => withCatalog(row, modelsOfAccount(row, await ctx.request('models.list', { agentId: entry.agent, view: 'all' }))));
674
718
  async function room(member, id, demand) {
675
719
  check(member);
676
720
  await ctx.ensure(member);
@@ -1,3 +1,4 @@
1
+ import type { Detection, Detector } from './detect.ts';
1
2
  import type { GatewayTransport } from './types.ts';
2
3
  export type AuthStatus = {
3
4
  providers?: (string | {
@@ -14,14 +15,23 @@ export type AuthStatus = {
14
15
  message?: unknown;
15
16
  };
16
17
  };
18
+ /** The engine's setup detection, through the shared detector when the caller has one: one probe per agent, reused
19
+ * briefly, so a poll that reads readiness and the account list does not pay for the probe twice. A failed probe reads
20
+ * as no detection: the caller decides what an unknown answer means. */
21
+ export declare const detection: (request: GatewayTransport['request'], agentId: string, detect?: Detector, o?: {
22
+ fresh?: boolean;
23
+ signal?: AbortSignal;
24
+ timeoutMs?: number;
25
+ }) => Promise<Detection>;
17
26
  /** Native Claude login stays in Claude Code. The pin's profile status does not include this synthetic auth;
18
- * setup detection asks the CLI itself, without handing credentials to the kit. */
19
- export declare function authStatus(request: GatewayTransport['request'], agentId: string, refresh?: boolean, native?: boolean): Promise<AuthStatus>;
27
+ * setup detection asks the CLI itself, without handing credentials to the kit. The detection and the pin's own status
28
+ * are independent reads, so they run together rather than one after the other. */
29
+ export declare function authStatus(request: GatewayTransport['request'], agentId: string, refresh?: boolean, native?: boolean, detect?: Detector): Promise<AuthStatus>;
20
30
  /** Admission snapshots, never credentials: per kit/agent/native route, at most 30s and never past reported expiry.
21
31
  * Witness only app-owned file metadata (absence included); changed/unreadable state and auth/lifecycle mutations recheck.
22
32
  * Remote revocation still comes from the real run's signed-out error (a local status read cannot prove it either).
23
33
  */
24
- export declare function createAuthStatus(root: string, request: GatewayTransport['request'], codex?: () => Promise<boolean>): {
34
+ export declare function createAuthStatus(root: string, request: GatewayTransport['request'], codex?: () => Promise<boolean>, detect?: Detector): {
25
35
  read: (agentId: string, refresh?: boolean, native?: boolean, reuse?: boolean) => Promise<AuthStatus>;
26
36
  invalidate: () => void;
27
37
  };
@@ -1,16 +1,27 @@
1
1
  import { statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { signedInProviders, USABLE } from './runs.js';
4
+ /** The engine's setup detection, through the shared detector when the caller has one: one probe per agent, reused
5
+ * briefly, so a poll that reads readiness and the account list does not pay for the probe twice. A failed probe reads
6
+ * as no detection: the caller decides what an unknown answer means. */
7
+ export const detection = async (request, agentId, detect, o) => {
8
+ if (detect)
9
+ return detect.detect(agentId, { fresh: o?.fresh, timeoutMs: o?.timeoutMs, ...(o?.signal ? { signal: o.signal } : {}) });
10
+ return await request('openclaw.setup.detect', { agentId }, { timeoutMs: o?.timeoutMs ?? 20_000, ...(o?.signal ? { signal: o.signal } : {}) });
11
+ };
4
12
  /** Native Claude login stays in Claude Code. The pin's profile status does not include this synthetic auth;
5
- * setup detection asks the CLI itself, without handing credentials to the kit. */
6
- export async function authStatus(request, agentId, refresh = false, native = false) {
7
- const status = await request('models.authStatus', { agentId, ...(refresh ? { refresh: true } : {}) }, { timeoutMs: 20_000 });
8
- return native ? nativeStatus(request, agentId, status) : status;
13
+ * setup detection asks the CLI itself, without handing credentials to the kit. The detection and the pin's own status
14
+ * are independent reads, so they run together rather than one after the other. */
15
+ export async function authStatus(request, agentId, refresh = false, native = false, detect) {
16
+ const [status, detected] = await Promise.all([
17
+ request('models.authStatus', { agentId, ...(refresh ? { refresh: true } : {}) }, { timeoutMs: 20_000 }),
18
+ native ? detection(request, agentId, detect).catch(() => ({})) : undefined,
19
+ ]);
20
+ return native ? nativeStatus(status, detected) : status;
9
21
  }
10
- async function nativeStatus(request, agentId, status) {
11
- const detected = await request('openclaw.setup.detect', { agentId }, { timeoutMs: 20_000 }).catch(() => ({}));
22
+ function nativeStatus(status, detected) {
12
23
  const rows = (status.providers ?? []).filter((row) => (typeof row === 'string' ? row : row.provider) !== 'claude-cli');
13
- if (detected.candidates?.some((candidate) => candidate.kind === 'claude-cli' && candidate.credentials === true)) {
24
+ if (detected?.candidates?.some((candidate) => candidate.kind === 'claude-cli' && candidate.credentials === true)) {
14
25
  return { providers: [...rows, { provider: 'claude-cli', status: 'ok' }] };
15
26
  }
16
27
  return { ...status, providers: rows };
@@ -19,8 +30,9 @@ async function nativeStatus(request, agentId, status) {
19
30
  * Witness only app-owned file metadata (absence included); changed/unreadable state and auth/lifecycle mutations recheck.
20
31
  * Remote revocation still comes from the real run's signed-out error (a local status read cannot prove it either).
21
32
  */
22
- export function createAuthStatus(root, request, codex) {
33
+ export function createAuthStatus(root, request, codex, detect) {
23
34
  const snapshots = new Map();
35
+ const witnesses = new Map();
24
36
  let generation = 0;
25
37
  const invalidate = () => { generation++; snapshots.clear(); };
26
38
  const witness = (agentId) => {
@@ -54,13 +66,24 @@ export function createAuthStatus(root, request, codex) {
54
66
  return cached.status;
55
67
  snapshots.delete(key);
56
68
  snapshots.delete(`${agentId}:false`);
57
- const profiles = await authStatus(request, agentId, refresh);
69
+ // The detection answers about the same state this witness covers (the engine's own logins), so a changed witness
70
+ // drops it: the next reader asks the engine again instead of reading an earlier answer.
71
+ const seen = witnesses.get(agentId);
72
+ if (before !== undefined)
73
+ witnesses.set(agentId, before);
74
+ if (before !== undefined && seen !== undefined && seen !== before)
75
+ detect?.invalidate(agentId);
76
+ // The pin's own status and the setup detection are independent reads: they run together, not one after the other.
77
+ const [profiles, detected] = await Promise.all([
78
+ authStatus(request, agentId, refresh),
79
+ native ? detection(request, agentId, detect).catch(() => undefined) : undefined,
80
+ ]);
58
81
  if (codex) {
59
82
  profiles.providers = (profiles.providers ?? []).filter(row => (typeof row === 'string' ? row : row.provider) !== 'openai-codex');
60
83
  if (await codex())
61
84
  profiles.providers.push({ provider: 'openai-codex', status: 'ok' });
62
85
  }
63
- const status = native ? await nativeStatus(request, agentId, profiles) : profiles;
86
+ const status = native ? nativeStatus(profiles, detected) : profiles;
64
87
  if (before !== undefined && epoch === generation && witness(agentId) === before) {
65
88
  for (const [route, answer] of (native ? [[false, profiles], [true, status]] : [[false, profiles]])) {
66
89
  const usable = signedInProviders(answer);
@@ -1,4 +1,5 @@
1
1
  export declare const ENGINE_VERSION = "2026.8.35";
2
2
  export declare const PROTOCOL_VERSION = 4;
3
3
  export declare const REGISTERED_CATALOGS: string[];
4
+ export declare const LIVE_LIST_ADDS: string[];
4
5
  export declare const OPERATOR_SCOPES: readonly ['operator.read', 'operator.write', 'operator.admin', 'operator.approvals', 'operator.questions', 'operator.pairing', 'operator.talk'];
package/dist/constants.js CHANGED
@@ -2,9 +2,15 @@
2
2
  // `needs-update`, never silently run.
3
3
  export const ENGINE_VERSION = '2026.8.35';
4
4
  export const PROTOCOL_VERSION = 4;
5
- // Live catalogs (5.6) the kit registers whole; every other key route registers its chosen model alone. The engine canonicalizes Google preview ids on config write, so OpenRouter's
6
- // list collides with itself (`google/x-preview` and `google/x`); an OpenRouter add registers only the chosen model.
5
+ // Live catalogs (5.6) the kit registers whole; every other key route registers its chosen model alone. The engine
6
+ // canonicalizes Google preview ids on config write, so the rows are deduped by the id it keeps (`engineModelId`)
7
+ // before they are written.
7
8
  export const REGISTERED_CATALOGS = ['opencode-go', 'minimax'];
9
+ // The providers whose key add registers the provider's live list beside the chosen model, so the member keeps every
10
+ // model the vendor lists to pick from (the chosen model id sits beside the provider's own default, `openrouter/auto`).
11
+ // The engine's catalog stops at a registered list, so an add that registered the chosen model alone would leave the
12
+ // member with that one row and nothing else to pick.
13
+ export const LIVE_LIST_ADDS = ['openrouter'];
8
14
  export const OPERATOR_SCOPES = [
9
15
  'operator.read',
10
16
  'operator.write',
@@ -0,0 +1,30 @@
1
+ import type { GatewayTransport } from './types.ts';
2
+ export type Detection = {
3
+ candidates?: {
4
+ kind?: string;
5
+ credentials?: boolean;
6
+ }[];
7
+ };
8
+ export type DetectOptions = {
9
+ timeoutMs?: number;
10
+ signal?: AbortSignal;
11
+ fresh?: boolean;
12
+ };
13
+ export type Detector = {
14
+ /** The engine's setup detection for one agent. `fresh` skips the cache and the in-flight probe: a sign-in that
15
+ * verifies what the person just did must read the engine, not an earlier answer. */
16
+ detect(agentId: string, o?: DetectOptions): Promise<Detection>;
17
+ /** Drops the cached answer (one agent, or every agent). */
18
+ invalidate(agentId?: string): void;
19
+ };
20
+ /** How long a detection answer is reused. The answer is a CLI probe the kit cannot witness (the login lives in the
21
+ * person's own home), so the window bounds how long an outside sign-in stays unseen; every sign-in, sign-out and
22
+ * account change the kit sees drops it at once. */
23
+ export declare const DETECT_TTL_MS = 10000;
24
+ /**
25
+ * The shared detection. One probe per agent at a time, its answer reused briefly, a failure never cached (the next
26
+ * reader tries again) and a fresh read never served from the cache.
27
+ */
28
+ export declare function createDetect(request: GatewayTransport['request'], o?: {
29
+ ttlMs?: number;
30
+ }): Detector;
package/dist/detect.js ADDED
@@ -0,0 +1,39 @@
1
+ /** How long a detection answer is reused. The answer is a CLI probe the kit cannot witness (the login lives in the
2
+ * person's own home), so the window bounds how long an outside sign-in stays unseen; every sign-in, sign-out and
3
+ * account change the kit sees drops it at once. */
4
+ export const DETECT_TTL_MS = 10_000;
5
+ /**
6
+ * The shared detection. One probe per agent at a time, its answer reused briefly, a failure never cached (the next
7
+ * reader tries again) and a fresh read never served from the cache.
8
+ */
9
+ export function createDetect(request, o) {
10
+ const ttl = o?.ttlMs ?? DETECT_TTL_MS;
11
+ const cache = new Map();
12
+ const inflight = new Map();
13
+ const detect = (agentId, options = {}) => {
14
+ const hit = cache.get(agentId);
15
+ if (!options.fresh && hit && hit.until > Date.now())
16
+ return Promise.resolve(hit.value);
17
+ const running = options.fresh ? undefined : inflight.get(agentId);
18
+ if (running)
19
+ return running;
20
+ const call = request('openclaw.setup.detect', { agentId }, { timeoutMs: options.timeoutMs ?? 20_000, ...(options.signal ? { signal: options.signal } : {}) })
21
+ .then((value) => { cache.set(agentId, { until: Date.now() + ttl, value }); return value; })
22
+ .finally(() => { if (inflight.get(agentId) === call)
23
+ inflight.delete(agentId); });
24
+ inflight.set(agentId, call);
25
+ return call;
26
+ };
27
+ return {
28
+ detect,
29
+ invalidate: (agentId) => {
30
+ if (agentId === undefined) {
31
+ cache.clear();
32
+ inflight.clear();
33
+ return;
34
+ }
35
+ cache.delete(agentId);
36
+ inflight.delete(agentId);
37
+ },
38
+ };
39
+ }
package/dist/keys.js CHANGED
@@ -4,8 +4,8 @@ import { openCodeWorkspaces } from '@byokit/accounts/opencode-go';
4
4
  import { activate } from './activation.js';
5
5
  import { keyAgentId, MEMBER_ID, KEY_PREFIX, untilListed } from './members.js';
6
6
  import { providerOf } from './pick.js';
7
- import { kitId, modelsOf } from './accounts.js';
8
- import { REGISTERED_CATALOGS } from './constants.js';
7
+ import { kitId, modelsOf, engineModelId } from './accounts.js';
8
+ import { LIVE_LIST_ADDS } from './constants.js';
9
9
  export function createKeys(ctx) {
10
10
  // Serialize key replacement and key runs, so an in-flight run never sees a half-installed replacement.
11
11
  const tails = new Map();
@@ -58,12 +58,14 @@ export function createKeys(ctx) {
58
58
  return ['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT', 'CLIENT_TIMEOUT', 'NETWORK_ERROR'].includes(e?.code ?? '')
59
59
  || e?.name === 'TimeoutError' ? 'network' : 'engine';
60
60
  };
61
- // The engine writes a Google preview id without its `-preview` suffix, so the kit names the id it keeps.
62
- const canonical = (id) => id.startsWith('google/') && id.endsWith('-preview')
63
- ? id.slice(0, -'-preview'.length) : id;
61
+ // The kit's name for a model: the provider and the row id the engine keeps (a model reference already carries its
62
+ // provider, so the row id is never prefixed twice).
64
63
  const canonicalModel = (model) => {
65
64
  const at = model.indexOf('/');
66
- return at < 0 ? model : `${model.slice(0, at)}/${canonical(model.slice(at + 1))}`;
65
+ if (at < 0)
66
+ return model;
67
+ const provider = model.slice(0, at);
68
+ return kitId(provider, engineModelId(provider, model.slice(at + 1)));
67
69
  };
68
70
  // The live catalog's rows, as the engine registers them (provider-local ids, deduped by the id it keeps).
69
71
  const catalog = async (member, provider) => {
@@ -74,7 +76,7 @@ export function createKeys(ctx) {
74
76
  throw new Error('Model catalog is unavailable.');
75
77
  return [];
76
78
  }
77
- const rows = (answer.models ?? []).map((m) => ({ ...m, id: canonical(String(m.id)) }));
79
+ const rows = (answer.models ?? []).map((m) => ({ ...m, id: engineModelId(provider, String(m.id)) }));
78
80
  return [...new Map(rows.map((m) => [String(m.id), m])).values()];
79
81
  };
80
82
  // The key route a choice names: its own, or the only key route of the provider whose other sign-in it names, billed
@@ -240,8 +242,14 @@ export function createKeys(ctx) {
240
242
  }
241
243
  if (ac.signal.aborted)
242
244
  return abandon(cancelled());
243
- // A catalog the kit does not register whole (OpenRouter's, and any other provider's) registers the chosen model alone.
244
- if (model !== undefined && (!REGISTERED_CATALOGS.includes(route.provider) || lagging.length))
245
+ // The provider's live list, for a provider whose add registers it (the engine's catalog stops at a
246
+ // registered list, so the chosen model alone would leave the member with that one row to pick): the
247
+ // chosen model id sits beside the provider's own default, `openrouter/auto`.
248
+ if (model !== undefined && LIVE_LIST_ADDS.includes(route.provider))
249
+ await ctx.registerModels(route.provider, rows).catch(() => { });
250
+ // The chosen model is registered on its own, whatever the provider's catalog is: a list the engine
251
+ // refuses (two rows it would keep under one id) must never cost the member's own choice.
252
+ if (model !== undefined)
245
253
  await ctx.registerModels(route.provider, rows.filter((m) => kitId(route.provider, String(m.id)) === model)).catch(() => { });
246
254
  if (model !== undefined && !(await registered(route.provider, model)))
247
255
  return abandon(failure('retry', 'model'));
package/dist/kit.d.ts CHANGED
@@ -73,6 +73,7 @@ export declare class OpenClawKit {
73
73
  private readonly delegations;
74
74
  private readonly keys;
75
75
  private readonly admission;
76
+ private readonly detect;
76
77
  private readonly approvalsCtl;
77
78
  private listeners;
78
79
  private off;
@@ -88,6 +89,7 @@ export declare class OpenClawKit {
88
89
  private engineExit?;
89
90
  private swept;
90
91
  private catalogs;
92
+ private readonly registered;
91
93
  browser?: BrowserHost;
92
94
  private browserHost?;
93
95
  private readonly brokers;
package/dist/kit.js CHANGED
@@ -15,12 +15,13 @@ import { AuthStoreSealSizeError } from './auth-store.js';
15
15
  import { gatewayTransport } from './transport.js';
16
16
  import { createMembers } from './members.js';
17
17
  import { createHelpers, HELPER_TAIL, HelperError } from './helpers.js';
18
- import { createAccounts, memberOfAccount } from './accounts.js';
18
+ import { createAccounts, memberOfAccount, modelsOf, engineModelId } from './accounts.js';
19
19
  import { createLocks } from './locks.js';
20
20
  import { keyAgentId, KEY_PREFIX, MEMBER_ID } from './members.js';
21
21
  import { confirmRetainedLogin as confirmLogin, migrateRetainedLogin as migrateLogin } from './migrate.js';
22
22
  import { createRuns, RunReplay, signedInProviders } from './runs.js';
23
23
  import { authStatus, createAuthStatus } from './auth-status.js';
24
+ import { createDetect } from './detect.js';
24
25
  import { outputSchema } from './output.js';
25
26
  import { words } from './words.js';
26
27
  import { createKeys } from './keys.js';
@@ -457,6 +458,9 @@ export class OpenClawKit {
457
458
  delegations;
458
459
  keys;
459
460
  admission;
461
+ // One setup detection for every reader (readiness, the account list, a sign-in): the engine's probe asks the
462
+ // installed CLI itself and takes seconds, so a poll that reads both pays for it once, not once per reader.
463
+ detect;
460
464
  approvalsCtl;
461
465
  listeners = new Set();
462
466
  off = [];
@@ -472,6 +476,8 @@ export class OpenClawKit {
472
476
  engineExit;
473
477
  swept = false;
474
478
  catalogs = Promise.resolve();
479
+ // The catalog rows the kit registered, per provider: the provider's live list, as the engine can run it.
480
+ registered = new Map();
475
481
  browser;
476
482
  browserHost;
477
483
  brokers = new Map();
@@ -679,6 +685,9 @@ export class OpenClawKit {
679
685
  throw new Error(`invalid tool name: ${t.name}`);
680
686
  }
681
687
  this.o = { ...o, stateDir: canonicalStateDir(o.stateDir) };
688
+ // The shared detection, above the request path: the witness check and the engine's probe run only when a probe is
689
+ // really made, so a cached answer costs nothing.
690
+ this.detect = createDetect((method, params, options) => this.request()(method, params, options));
682
691
  this.engine = new Engine({ ...o, pluginId: o.plugin?.id ?? 'byokit', tools: [...o.tools ?? [], ...(o.browser ? [{
683
692
  name: 'request_sign_in', description: 'Ask the person to sign in to the current browser site.',
684
693
  parameters: { type: 'object', additionalProperties: false, properties: {
@@ -688,7 +697,7 @@ export class OpenClawKit {
688
697
  ...(o.browser ? { browserConfig: () => this.browserConfig() } : {}),
689
698
  gateBuiltins: o.gateBuiltins !== false, spawnEngine: o.spawnEngine !== false,
690
699
  onState: (s) => this.setState(s), onExit: () => this.closed('engine exited') });
691
- this.admission = createAuthStatus(this.engine.root, (method, params, options) => this.request()(method, params, options), () => this.engine.installedCodexSignedIn());
700
+ this.admission = createAuthStatus(this.engine.root, (method, params, options) => this.request()(method, params, options), () => this.engine.installedCodexSignedIn(), this.detect);
692
701
  this.keys = createKeys({ root: this.engine.root,
693
702
  prepareOwner: (agentId) => this.prepareKeyOwner(agentId),
694
703
  indexLane: (member, route) => this.indexKeyLane(member, route),
@@ -760,8 +769,10 @@ export class OpenClawKit {
760
769
  slot.bridge = this.bridge;
761
770
  }
762
771
  setState(s) {
763
- if (s.phase !== 'ready')
772
+ if (s.phase !== 'ready') {
764
773
  this.admission?.invalidate();
774
+ this.detect?.invalidate();
775
+ }
765
776
  this.current = { ...s, ...(this.engine.patchSet !== undefined ? { patchSet: this.engine.patchSet } : {}) };
766
777
  this.o.onState?.(this.current);
767
778
  for (const fn of this.phaseListeners)
@@ -850,7 +861,8 @@ export class OpenClawKit {
850
861
  this.accountsCtl = createAccounts({ request: (method, params, o) => this.request()(method, params, o), root: this.engine.root,
851
862
  ensure: (member) => this.ensureMember(member), locks: this.locks,
852
863
  signIn: (agentId, o, on, fresh) => startSignIn({ ...this.signInCtx(), ensure: async () => ({ agentId }), fresh }, agentId, o, on),
853
- routes: this.routeMap, log: this.o.log, reseal: () => this.engine.authStore.reseal() });
864
+ routes: this.routeMap, log: this.o.log, reseal: () => this.engine.authStore.reseal(), detect: this.detect,
865
+ catalog: (provider) => this.registered.get(provider) ?? [] });
854
866
  this.helpersCtl = createHelpers({ root: this.engine.root, list: (member) => this.accounts(member) });
855
867
  // The fail-closed tool bridge feeds the kit's single Approval surface (5.9, O5).
856
868
  await Promise.race([this.bridge.start(), exit]);
@@ -1038,11 +1050,23 @@ export class OpenClawKit {
1038
1050
  await this.start();
1039
1051
  return this.request()(method, params, options);
1040
1052
  }
1053
+ // An account change (a sealed, cleared or adopted account) moves the detection too: a Claude Code login the
1054
+ // person just made is an account the kit now knows about. A read of the accounts changes nothing.
1055
+ if (method === 'byokit.accounts') {
1056
+ const action = params?.action;
1057
+ if (action !== 'list' && action !== 'ready')
1058
+ this.detect.invalidate();
1059
+ }
1041
1060
  const changesAuth = /^(models\.authLogout|openclaw\.setup\.(auth\.start|activate)|wizard\.(next|cancel)|config\.(apply|patch|set)|agents\.(create|update|delete)|secrets\.(reload|store\.(set|delete))|byokit\.keys)$/.test(method);
1042
1061
  if (!changesAuth || (method === 'byokit.keys' && params?.action === 'ready'))
1043
1062
  return transport.request(method, params, options);
1044
1063
  this.admission.invalidate();
1045
- return Promise.resolve().then(() => transport.request(method, params, options)).finally(() => this.admission.invalidate());
1064
+ // A sign-in, a sign-out or a key change moves the detection too: its answer is about this engine's logins.
1065
+ this.detect.invalidate();
1066
+ return Promise.resolve().then(() => transport.request(method, params, options)).finally(() => {
1067
+ this.admission.invalidate();
1068
+ this.detect.invalidate();
1069
+ });
1046
1070
  };
1047
1071
  }
1048
1072
  call(method, params, o) {
@@ -1117,6 +1141,7 @@ export class OpenClawKit {
1117
1141
  return { request: (method, params, o) => this.request()(method, params, o),
1118
1142
  // Public readiness/sign-in reads stay live; their fresh result can prepare the next run's admission.
1119
1143
  authStatus: (agentId, refresh, native) => this.admission.read(agentId, refresh, native, false),
1144
+ detect: this.detect,
1120
1145
  ensure: (member) => this.ensureMember(member), callbackPort: this.o.callbackPort ?? 1455,
1121
1146
  nativeClaudeLogin: () => this.engine.nativeClaudeLogin(),
1122
1147
  resetNativeClaudeLogin: () => this.engine.resetNativeClaudeLogin(),
@@ -1361,14 +1386,19 @@ export class OpenClawKit {
1361
1386
  this.o.log?.(`catalog sync ${provider}: failed`); });
1362
1387
  }
1363
1388
  }
1364
- // Merges catalog rows into the registered list by id; an unchanged list is never rewritten.
1389
+ // Merges catalog rows into the registered list by the id the engine keeps (it rewrites a Google preview id when it
1390
+ // writes the config, so two rows it would keep under one id are one row and never a refused write); an unchanged
1391
+ // list is never rewritten.
1365
1392
  async registerModels(provider, rows) {
1366
1393
  const { config } = await this.request()('config.get');
1367
1394
  const registered = config?.models?.providers?.[provider]?.models ?? [];
1368
- const byId = new Map(registered.map((m) => [m.id, m]));
1395
+ const byId = new Map(registered.map((m) => { const id = engineModelId(provider, m.id); return [id, { ...m, id }]; }));
1369
1396
  for (const m of rows)
1370
- byId.set(m.id, m);
1397
+ byId.set(engineModelId(provider, m.id), { ...m, id: engineModelId(provider, m.id) });
1371
1398
  const models = [...byId.values()];
1399
+ // What the kit registered is what the engine can run for that provider: the account list joins it, so a model
1400
+ // the live catalog offers is pickable even while the engine's own list still lags the registration.
1401
+ this.registered.set(provider, modelsOf(provider, { models: models.map((m) => ({ ...m, provider })) }));
1372
1402
  if (!isDeepStrictEqual(models, registered))
1373
1403
  await this.patchConfig({ models: { providers: { [provider]: { models } } } });
1374
1404
  }
package/dist/signin.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import type { remoteCliHost } from '@byokit/accounts/cli';
2
2
  import { authStatus } from './auth-status.ts';
3
+ import type { Detector } from './detect.ts';
3
4
  import type { CliLoginCause, GatewayTransport, Member, SignInOptions, SignInView } from './types.ts';
4
5
  export type SignInCtx = {
5
6
  request: GatewayTransport['request'];
6
7
  authStatus?: (agentId: string, refresh: boolean, native: boolean) => ReturnType<typeof authStatus>;
8
+ detect?: Detector;
7
9
  ensure(member: Member): Promise<{
8
10
  agentId: string;
9
11
  }>;
package/dist/signin.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { createServer } from 'node:http';
5
5
  import { activate, LATE_RESTART } from './activation.js';
6
- import { authStatus } from './auth-status.js';
6
+ import { authStatus, detection } from './auth-status.js';
7
7
  import { routes } from './routes.js';
8
8
  import { signedInProviders } from './runs.js';
9
9
  import { words } from './words.js';
@@ -134,6 +134,9 @@ export function signIn(ctx, member, o, on) {
134
134
  returned = undefined;
135
135
  pasteIn = undefined;
136
136
  await closeCallback();
137
+ // A drive moves the login state, so the shared detection is dropped: the next reader asks the engine.
138
+ if (agentId !== undefined)
139
+ ctx.detect?.invalidate(agentId);
137
140
  if (sensitive && view.state === 'failed' && view.why === 'failed')
138
141
  view = { ...view, error: secretFailure };
139
142
  on(view);
@@ -222,6 +225,10 @@ export function signIn(ctx, member, o, on) {
222
225
  return { state: 'failed', via, why: 'busy' };
223
226
  }
224
227
  ({ agentId } = await ctx.ensure(member));
228
+ // A drive moves the login state, so the shared detection is dropped before it reads one: the drive's own decision
229
+ // (is a Claude Code login there?) must be the engine's answer now, not an earlier one.
230
+ if (agentId !== undefined)
231
+ ctx.detect?.invalidate(agentId);
225
232
  if (reconfirm && ctx.onDisconnect) {
226
233
  before = profiles(await authStatus(ctx.request, agentId).catch(() => undefined), false);
227
234
  if (signal.aborted)
@@ -235,8 +242,11 @@ export function signIn(ctx, member, o, on) {
235
242
  settleStart();
236
243
  let apiKey;
237
244
  if (o.authChoice === 'anthropic-cli') {
245
+ // The drive reads the engine, never a reused answer: what it decides (is a Claude Code login there? did the
246
+ // one the person just made land?) must be the engine's answer now.
247
+ const probe = agentId;
238
248
  const detected = async () => {
239
- const value = await ctx.request('openclaw.setup.detect', { agentId }, { timeoutMs: STATUS_MS, signal });
249
+ const value = await detection(ctx.request, probe, ctx.detect, { fresh: true, signal, timeoutMs: STATUS_MS });
240
250
  return value.candidates?.some(candidate => candidate.kind === 'claude-cli' && candidate.credentials === true);
241
251
  };
242
252
  native = ctx.nativeClaudeLogin?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
4
4
  "description": "The OpenClaw runtime kit: the pinned engine's full operator surface with plain-words helpers for members, sign-in, runs and approvals.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",