@byokit/openclaw 0.10.0 → 0.11.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,27 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.11.1 (2026-10-12)
6
+
7
+ - Dependency update: pins @byokit/discover 0.8.1.
8
+ - FIX: (from @byokit/discover 0.8.1) `browse()` and `scan()` accept resolved services whose `fullName` carries the service type without a trailing dot (`host._type._tcp`, as Android's react-native-zeroconf delivers) or with a trailing `.local` domain suffix, case-insensitive, instead of only the `host.local._type._tcp.` shape. Android phones now discover computers over mDNS instead of always scanning empty.
9
+
10
+ - FIX: restoring an engine home no longer rewrites a live file whose bytes already match the sealed object. `restore()` compares each decrypted object against the live file at the restore mode (0600) — size first, then bytes — and skips the temp write, file fsync, atomic rename and directory fsync when they match, so a start over an untouched tree pays no per-object writes. A missing, changed, different-mode or unreadable file restores exactly as before, and a skipped file changes nothing on disk, so the crash-durability contract is unchanged; nothing new is written to disk.
11
+ - FEAT: OpenClaw kit S5 account spine: per-member `accounts/<member>.json` index (0600, atomic, no email/token/engine line) behind `kit.accounts`, `renameAccount` and `defaults`/`setDefaults`; a pre-O14 sign-in synthesizes an `addedAt: 0` entry, a default naming a removed account drops on read, and an unindexed `<member>--<hex6>` agent is never listed. `src/locks.ts` adds the per-agent shared/exclusive/live locks later steps use for runs, removal, moves and sealing.
12
+ - FEAT: OpenClaw kit S6 `kit.addAccount(member, { authChoice, via?, name?, again? }, on)`: a member's first sign-in of a provider stays on its own agent, and each further one (or `again`) gets a new sealed agent `<member>--<hex6>` (drawn again on a collision) with account id `<provider>.<hex6>`, its index entry written before the agent and IDENTITY.md kept byte-identical. The ChatGPT authorize URL carries `prompt=login` so a second account can be chosen; the row reads `signing` until the sign-in ends; `done` comes only after the agent is sealed to that one sign-in and its `who` hash is indexed; a sign-in that fails, ends busy or is cancelled removes the new agent and its entry. `authChoice: 'openai-codex'` (the engine's id for ChatGPT) signs in on the `openai` route.
13
+ - FEAT: OpenClaw kit S9 Auto input: `kit.models(member, id)` maps the account provider's `models.list { agentId, view: 'default' }` rows to `provider/model` (`missing-auth`/`auth-failed` → `why: 'signed_out'`, `cooldown` → `why: 'resting'` with `until = unavailableUntil`), and `kit.room(member, id, demand?)` reads the `models.authStatus { agentId }` row for the provider's tightest applying usage window (`left`, `span`, `resetsAt`; no `at`, no kit cache, unknown for API-key accounts and unanswered reads). Both hold the account agent's shared lock for the read. The fake gateway serves `models.list { agentId }` per agent and a cold per-provider usage cache (`setUsage`; no `usage` on the first answer carrying the provider).
14
+ - FEAT: OpenClaw kit S10 account runs: `kit.run({ member, sessionKey, account: 'auto' | 'default' | id, model?, needs? })` picks one account once, before any request (Choosing, 5.15), checks its own sealed sign-in with `byokit.accounts ready` and runs on its agent; an unbound key that picks an account agent runs as `agent:<account agent>:<tail>`. The first event is `{ type: 'account', account, model, sessionKey, how, why, considered }`, an ok end carries `account` and `model`, a chosen account that fails its ready check ends `signed-out` after its account event, with no agent request (Auto skips such an account for the next one), an empty pick ends `signed-out`, a missing model ends `plan`, and a resting end writes `until` (now + 5 min when the message names none) so the next Auto skips the account. `needs` alone resolves as `account: 'default'`; with `account` absent and `needs` absent or empty a run is unchanged. Account runs hold the agent's shared lock to their end, and index edits now take their own per-member lock, so a live run never blocks a rename or a listing. A model the engine reports unavailable only because its account is signed out or resting still counts as that account's plan. Session binding arrives in the next step.
15
+
16
+ ## 0.11.0 (2026-10-12)
17
+
18
+ - Dependency update: pins @byokit/ui 0.7.0.
19
+ - Dependency update: pins @byokit/secrets 0.5.1.
20
+ - Dependency update: pins @byokit/discover 0.8.0.
21
+ - Dependency update: pins @byokit/relay 0.6.1.
22
+ - Dependency update: pins @byokit/pair 0.10.0.
23
+
24
+ - FEAT: `byokit.accounts` seals an agent's own sign-ins (list/seal/ready/adopt/clear), loaded as the `plugin/accounts.js` gateway method; a failed `adopt` rolls the model and both profile stores back so no partial move or dangling pin survives, a self-adopt is refused, and the fake gateway mirrors every branch.
25
+
5
26
  ## 0.10.0 (2026-10-11)
6
27
 
7
28
  - Dependency update: pins @byokit/discover 0.7.0.
package/README.md CHANGED
@@ -124,12 +124,18 @@ engine text that could echo the token. It is sent only as this member's wizard a
124
124
  results or kit logs. Ordinary browser paste and typed `kit.call('wizard.next', …)` pass-through are unchanged.
125
125
  API key (billed per use) entry still requires explicit selection; there is no subscription-to-key fallback.
126
126
 
127
- To choose which of a member's accounts a run uses, pass `model: 'provider/model'` in the run spec (for example
127
+ To choose the model for a run, pass `model: 'provider/model'` in the run spec (for example
128
128
  `'openai/gpt-5.1'`, where `provider` is an id `kit.providers(member)` lists). That provider is the one called and billed for this run only, with no fallback to another
129
129
  provider or model. If the member isn't signed in to it, the run ends `{ ok: false, kind: 'signed-out' }` and the
130
130
  engine is never called. Leave `model` out to keep the engine's own choice. A specific sign-in (`@profile`) can't be
131
131
  picked per run: the pinned engine may still switch to another sign-in for the same provider.
132
132
 
133
+ To let the kit choose among a member's accounts, pass `account: 'auto'` (the account with the most room left, decided
134
+ once at run start), `'default'` (the saved default) or an id from `kit.accounts(member)`, with `needs: ['provider/model', …]`
135
+ for other models the run may call. The first event is `{ type: 'account', account, model, sessionKey, how, why, considered }`,
136
+ and an ok end carries `account` and `model`. A chosen account that isn't signed in ends `signed-out` with no agent request;
137
+ when every account is resting, the end is `resting` with `until`, and Auto skips a resting account until then.
138
+
133
139
 
134
140
  For an owner who explicitly offers keys, `kit.addKey('ana', { authChoice: 'openai-api-key', apiKey })` returns
135
141
  `ok`, `invalid` or `not_included`. Label the option **API key (billed per use)**. Select only `routes()` rows with
@@ -0,0 +1,47 @@
1
+ import { createLocks } from './locks.ts';
2
+ import type { Account, AccountId, AccountPick, Defaults, GatewayTransport, Member, ModelInfo, Room, RunSelection, SignInOptions, SignInView } from './types.ts';
3
+ type AddOptions = {
4
+ authChoice: string;
5
+ via?: 'browser' | 'code';
6
+ name?: string;
7
+ again?: AccountId;
8
+ };
9
+ type AccountSignIn = {
10
+ id: AccountId;
11
+ paste(text: string): void;
12
+ cancel(): void;
13
+ done: Promise<{
14
+ view: SignInView;
15
+ id: AccountId;
16
+ }>;
17
+ };
18
+ type StartSignIn = (agentId: string, o: SignInOptions, on: (v: SignInView) => void, fresh: boolean) => {
19
+ paste(text: string): void;
20
+ cancel(): void;
21
+ done: Promise<SignInView>;
22
+ };
23
+ export declare function createAccounts(ctx: {
24
+ request: GatewayTransport['request'];
25
+ root: string;
26
+ ensure(member: Member): Promise<{
27
+ agentId: string;
28
+ workspace: string;
29
+ }>;
30
+ locks: ReturnType<typeof createLocks>;
31
+ signIn: StartSignIn;
32
+ log?: (line: string) => void;
33
+ }): {
34
+ list(member: Member): Promise<Account[]>;
35
+ add(member: Member, o: AddOptions, on: (v: SignInView) => void): Promise<AccountSignIn>;
36
+ rename(member: Member, id: AccountId, name: string): Promise<Account>;
37
+ defaults(member: Member): Promise<Defaults>;
38
+ setDefaults(member: Member, d: Defaults): Promise<void>;
39
+ models(member: Member, id: AccountId): Promise<ModelInfo[]>;
40
+ room(member: Member, id: AccountId, demand?: string[]): Promise<Room>;
41
+ pick(member: Member, sel: RunSelection, skip?: readonly AccountId[]): Promise<{
42
+ pick: AccountPick;
43
+ agent?: string;
44
+ }>;
45
+ rest(member: Member, id: AccountId, until: number): Promise<void>;
46
+ };
47
+ export {};
@@ -0,0 +1,499 @@
1
+ // The per-member account index (5.15, O14 step S5): `<root>/accounts/<member>.json` (0600, whole-file temp
2
+ // plus rename) is the only kit state: `{ v: 1, accounts: { [id]: { agent, name, route, addedAt, until?, model?,
3
+ // who? } }, defaults }`. No credential, token, email or engine line. Listing, rename, defaults, models, room,
4
+ // adding (O14 step S6) and the start-of-run pick (S10) live here; remove and move arrive in later steps.
5
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { randomBytes, randomUUID } from 'node:crypto';
7
+ import { join } from 'node:path';
8
+ import { providerOf, resolveSelection } from "./pick.js";
9
+ import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, untilListed } from "./members.js";
10
+ import { routes, routeFor } from "./routes.js";
11
+ import { words } from "./words.js";
12
+ const ACCOUNT_AGENT = /^[a-z][a-z0-9-]{0,31}--[0-9a-f]{6}$/;
13
+ const usable = (status) => status === 'ok' || status === 'expiring' || status === 'static';
14
+ const IDENTITY = 'IDENTITY.md';
15
+ const named = (name, op) => {
16
+ const trimmed = typeof name === 'string' ? name.trim() : '';
17
+ if (!trimmed || trimmed.length > 100 || /[\u0000-\u001f\u007f]/.test(trimmed))
18
+ throw new Error(`${op}: name is empty`);
19
+ return trimmed;
20
+ };
21
+ const empty = () => ({ v: 1, accounts: {}, defaults: {} });
22
+ const validEntry = (v) => !!v && typeof v === 'object'
23
+ && typeof v.agent === 'string' && typeof v.name === 'string'
24
+ && typeof v.route === 'string' && Number.isFinite(v.addedAt)
25
+ && (v.until === undefined || Number.isFinite(v.until))
26
+ && (v.model === undefined || typeof v.model === 'string')
27
+ && (v.who === undefined || typeof v.who === 'string');
28
+ export function createAccounts(ctx) {
29
+ const path = (member) => join(ctx.root, 'accounts', `${member}.json`);
30
+ // Accounts whose sign-in is running, as `<member>\n<id>`, counted per add: listed `signing` until the last one ends.
31
+ const signing = new Map();
32
+ // The index file's own lock, apart from any agent's: a run's shared hold on the member agent never blocks an edit.
33
+ const indexLock = (member) => `${member}#index`;
34
+ const check = (member) => {
35
+ if (!MEMBER_ID.test(member) || member.startsWith(KEY_PREFIX))
36
+ throw new Error(`invalid member id: ${member}`);
37
+ };
38
+ const read = (member) => {
39
+ let text;
40
+ try {
41
+ text = readFileSync(path(member), 'utf8');
42
+ }
43
+ catch (error) {
44
+ if (error.code === 'ENOENT')
45
+ return empty();
46
+ throw error;
47
+ }
48
+ const file = JSON.parse(text);
49
+ if (!file || typeof file !== 'object' || file.v !== 1)
50
+ return empty();
51
+ const accounts = {};
52
+ for (const [id, entry] of Object.entries(file.accounts ?? {}))
53
+ if (validEntry(entry))
54
+ accounts[id] = entry;
55
+ const raw = file.defaults && typeof file.defaults === 'object' ? file.defaults : {};
56
+ const defaults = {};
57
+ if (typeof raw.account === 'string')
58
+ defaults.account = raw.account;
59
+ if (typeof raw.model === 'string')
60
+ defaults.model = raw.model;
61
+ if (typeof raw.auto === 'boolean')
62
+ defaults.auto = raw.auto;
63
+ return { v: 1, accounts, defaults };
64
+ };
65
+ const write = (member, file) => {
66
+ mkdirSync(join(ctx.root, 'accounts'), { recursive: true, mode: 0o700 });
67
+ const tmp = `${path(member)}.${randomUUID()}.tmp`;
68
+ writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n');
69
+ chmodSync(tmp, 0o600);
70
+ try {
71
+ renameSync(tmp, path(member));
72
+ chmodSync(path(member), 0o600);
73
+ }
74
+ finally {
75
+ rmSync(tmp, { force: true });
76
+ }
77
+ };
78
+ const metaOf = (choice) => {
79
+ const row = routes().find((r) => r.choice === choice);
80
+ if (row && (row.billing === 'subscription' || row.billing === 'api'))
81
+ return { label: row.label, billing: row.billing };
82
+ return { label: choice, billing: 'subscription' };
83
+ };
84
+ const synthesizedRoute = (provider) => routeFor(provider, 'browser')?.choice
85
+ ?? routes().find((r) => r.provider === provider && r.billing === 'subscription')?.choice
86
+ ?? provider;
87
+ const rosterOf = () => ctx.request('agents.list')
88
+ .then((r) => new Set(r.agents.map((a) => a.id)), () => new Set());
89
+ const profilesOf = (agentId) => ctx.request('byokit.accounts', { action: 'list', agentId })
90
+ .then((r) => Array.isArray(r.profiles) ? r.profiles : [], () => []);
91
+ const stateOf = (route, status, until) => {
92
+ if (!routes().some((r) => r.choice === route))
93
+ return 'not_included';
94
+ if (until !== undefined && until > Date.now())
95
+ return 'resting';
96
+ if (!status)
97
+ return 'signed_out';
98
+ return usable(status) ? 'ready' : status === 'expired' ? 'needs_again' : 'signed_out';
99
+ };
100
+ // Lock-free row builder over one snapshot: mutates `file` (synthesized entries, `who` backfill, dropped
101
+ // dangling default) and reports whether it did. Callers own the locking.
102
+ const build = async (member, agentId, file, roster, memberProfiles) => {
103
+ let dirty = false;
104
+ const byProvider = new Map();
105
+ for (const p of memberProfiles) {
106
+ if (typeof p.provider !== 'string' || !p.provider)
107
+ continue;
108
+ byProvider.set(p.provider, [...(byProvider.get(p.provider) ?? []), p]);
109
+ }
110
+ const memberRows = [];
111
+ const seen = new Set();
112
+ for (const [provider, profiles] of [...byProvider.entries()].sort(([a], [b]) => a < b ? -1 : 1)) {
113
+ let entry = file.accounts[provider];
114
+ if (!entry || entry.agent !== agentId) {
115
+ const route = entry?.agent === agentId && typeof entry.route === 'string' ? entry.route : synthesizedRoute(provider);
116
+ const same = Object.values(file.accounts).filter((e) => e.agent === agentId).length;
117
+ entry = { agent: agentId, name: `${metaOf(route).label} ${same + 1}`, route, addedAt: 0 };
118
+ file.accounts[provider] = entry;
119
+ dirty = true;
120
+ }
121
+ if (!entry.who && typeof profiles[0]?.who === 'string') {
122
+ entry.who = profiles[0].who;
123
+ dirty = true;
124
+ }
125
+ const meta = metaOf(entry.route);
126
+ memberRows.push({ id: provider, provider, route: entry.route, name: entry.name, label: meta.label,
127
+ billing: meta.billing, state: stateOf(entry.route, profiles[0]?.status, entry.until),
128
+ ...(entry.until !== undefined ? { until: entry.until } : {}), addedAt: entry.addedAt });
129
+ seen.add(provider);
130
+ }
131
+ for (const [id, entry] of Object.entries(file.accounts)) {
132
+ if (entry.agent !== agentId || id.includes('.') || seen.has(id))
133
+ continue;
134
+ const stale = metaOf(entry.route);
135
+ memberRows.push({ id, provider: id, route: entry.route, name: entry.name, label: stale.label,
136
+ billing: stale.billing, state: stateOf(entry.route, undefined, entry.until),
137
+ ...(entry.until !== undefined ? { until: entry.until } : {}), addedAt: entry.addedAt });
138
+ }
139
+ // Account agents: only what the index names, with exactly `<member>--<hex6>` shape. An unindexed
140
+ // agent of that shape is never listed, and an entry whose agent is gone is not listed either.
141
+ const agentRows = [];
142
+ for (const [id, entry] of Object.entries(file.accounts)) {
143
+ if (entry.agent === agentId || !ACCOUNT_AGENT.test(entry.agent) || !entry.agent.startsWith(`${member}--`))
144
+ continue;
145
+ if (!roster.has(entry.agent))
146
+ continue;
147
+ const provider = id.split('.')[0];
148
+ const meta = metaOf(entry.route);
149
+ const profiles = await profilesOf(entry.agent);
150
+ agentRows.push({ id, provider, route: entry.route, name: entry.name, label: meta.label,
151
+ billing: meta.billing, state: stateOf(entry.route, profiles[0]?.status, entry.until),
152
+ ...(entry.until !== undefined ? { until: entry.until } : {}), addedAt: entry.addedAt });
153
+ }
154
+ memberRows.sort((a, b) => a.addedAt - b.addedAt || (a.id < b.id ? -1 : 1));
155
+ agentRows.sort((a, b) => a.addedAt - b.addedAt);
156
+ const rows = [...memberRows, ...agentRows];
157
+ for (const row of rows)
158
+ if (signing.has(`${member}\n${row.id}`))
159
+ row.state = 'signing';
160
+ if (file.defaults.account !== undefined && !rows.some((r) => r.id === file.defaults.account)) {
161
+ delete file.defaults.account;
162
+ dirty = true;
163
+ }
164
+ return { rows, dirty };
165
+ };
166
+ const gather = (agentId) => Promise.all([rosterOf(), profilesOf(agentId)]);
167
+ const buildRows = async (member, agentId) => {
168
+ const file = read(member);
169
+ const [roster, profiles] = await gather(agentId);
170
+ return { ...(await build(member, agentId, file, roster, profiles)), file };
171
+ };
172
+ // The account's index entry, only when `list` names it: an unindexed agent is never reachable, whatever the
173
+ // file holds.
174
+ const entryOf = async (member, id) => {
175
+ const row = (await list(member)).find((r) => r.id === id);
176
+ const entry = read(member).accounts[id];
177
+ if (!row || !entry)
178
+ throw new Error(`unknown account: ${id}`);
179
+ return { row, entry };
180
+ };
181
+ const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
182
+ // `models.list { agentId, view: 'default' }` rows of the account's provider, mapped to `provider/model`. The pin
183
+ // reports no per-plan availability, so `why: 'plan'` only comes from a run ending `plan`, never from here.
184
+ const modelsOf = (provider, answer) => {
185
+ const rows = record(answer) && Array.isArray(answer.models) ? answer.models : [];
186
+ const out = [];
187
+ for (const candidate of rows) {
188
+ if (!record(candidate) || typeof candidate.provider !== 'string'
189
+ || candidate.provider.toLowerCase() !== provider.toLowerCase())
190
+ continue;
191
+ if (typeof candidate.id !== 'string' || !candidate.id || typeof candidate.name !== 'string')
192
+ continue;
193
+ const id = candidate.id.includes('/') ? candidate.id : `${candidate.provider}/${candidate.id}`;
194
+ if (providerOf(id) !== provider.toLowerCase())
195
+ continue;
196
+ const available = candidate.available === undefined
197
+ ? candidate.unavailableReason === undefined : candidate.available === true;
198
+ const info = { id, name: candidate.name, available };
199
+ if ((candidate.tier === 'strong' || candidate.tier === 'fast'))
200
+ info.tier = candidate.tier;
201
+ if (!available) {
202
+ if (candidate.unavailableReason === 'missing-auth' || candidate.unavailableReason === 'auth-failed')
203
+ info.why = 'signed_out';
204
+ else if (candidate.unavailableReason === 'cooldown') {
205
+ info.why = 'resting';
206
+ if (typeof candidate.unavailableUntil === 'number' && Number.isFinite(candidate.unavailableUntil))
207
+ info.until = candidate.unavailableUntil;
208
+ }
209
+ }
210
+ out.push(info);
211
+ }
212
+ return out;
213
+ };
214
+ const spanOf = (label) => {
215
+ const lower = label.toLowerCase();
216
+ if (lower === 'session')
217
+ return 'session';
218
+ if (lower === 'weekly')
219
+ return 'week';
220
+ if (lower === 'monthly')
221
+ return 'month';
222
+ return 'tightest';
223
+ };
224
+ // The `models.authStatus { agentId }` row for the account's provider: of its `usage.windows` entries the one
225
+ // with the highest numeric `usedPercent` gives `left`, `resetsAt` and `span`. A window applies to every model
226
+ // unless the engine scopes it to models; the pin's rows carry no scope, so the demand never narrows them today
227
+ // (the tightest known). A window without a numeric `usedPercent` is skipped, never read as unused. `at` is left
228
+ // out: the pin exposes no reading age, so the kit never stamps receipt time as reading time. The kit adds no
229
+ // cache: every call reads live.
230
+ const roomOf = (provider, answer) => {
231
+ const unknown = { left: 'unknown' };
232
+ const rows = record(answer) && Array.isArray(answer.providers) ? answer.providers : undefined;
233
+ if (!rows)
234
+ return unknown;
235
+ const row = rows.find((r) => record(r) && typeof r.provider === 'string'
236
+ && r.provider.toLowerCase() === provider.toLowerCase());
237
+ if (!row || !record(row))
238
+ return unknown;
239
+ const windows = record(row.usage) && Array.isArray(row.usage.windows) ? row.usage.windows : undefined;
240
+ if (!windows)
241
+ return unknown;
242
+ let best;
243
+ for (const candidate of windows) {
244
+ if (!record(candidate))
245
+ continue;
246
+ if (typeof candidate.usedPercent !== 'number' || !Number.isFinite(candidate.usedPercent))
247
+ continue;
248
+ const span = spanOf(typeof candidate.label === 'string' ? candidate.label : '');
249
+ const resetAt = typeof candidate.resetAt === 'number' && Number.isFinite(candidate.resetAt)
250
+ ? candidate.resetAt : undefined;
251
+ if (!best || candidate.usedPercent > best.used)
252
+ best = { used: candidate.usedPercent, ...(resetAt !== undefined ? { resetAt } : {}), span };
253
+ }
254
+ if (!best)
255
+ return unknown;
256
+ return { left: 100 - best.used, span: best.span, ...(best.resetAt !== undefined ? { resetsAt: best.resetAt } : {}) };
257
+ };
258
+ async function list(member) {
259
+ check(member);
260
+ const { agentId } = await ctx.ensure(member);
261
+ const first = await ctx.locks.shared(indexLock(member), () => buildRows(member, agentId));
262
+ if (!first.dirty)
263
+ return first.rows;
264
+ // A write is needed (synthesized entry, `who` backfill or dropped default): redo it under the
265
+ // exclusive lock so two first-lists cannot interleave their read-modify-write.
266
+ return ctx.locks.exclusive([indexLock(member)], async () => {
267
+ const { rows, dirty, file } = await buildRows(member, agentId);
268
+ if (dirty)
269
+ write(member, file);
270
+ return rows;
271
+ });
272
+ }
273
+ async function rename(member, id, name) {
274
+ check(member);
275
+ const trimmed = named(name, 'renameAccount');
276
+ if (!(await list(member)).some((r) => r.id === id))
277
+ throw new Error(`unknown account: ${id}`);
278
+ await ctx.locks.exclusive([indexLock(member)], async () => {
279
+ const file = read(member);
280
+ const entry = file.accounts[id];
281
+ if (!entry)
282
+ throw new Error(`unknown account: ${id}`);
283
+ entry.name = trimmed;
284
+ write(member, file);
285
+ });
286
+ const done = (await list(member)).find((r) => r.id === id);
287
+ if (!done)
288
+ throw new Error(`unknown account: ${id}`);
289
+ return done;
290
+ }
291
+ async function defaults(member) {
292
+ await list(member);
293
+ return read(member).defaults;
294
+ }
295
+ async function setDefaults(member, d) {
296
+ check(member);
297
+ if (!d || typeof d !== 'object')
298
+ throw new Error('setDefaults: defaults are invalid');
299
+ if (d.account !== undefined && typeof d.account !== 'string')
300
+ throw new Error('setDefaults: defaults are invalid');
301
+ if (d.model !== undefined && typeof d.model !== 'string')
302
+ throw new Error('setDefaults: defaults are invalid');
303
+ if (d.auto !== undefined && typeof d.auto !== 'boolean')
304
+ throw new Error('setDefaults: defaults are invalid');
305
+ await ctx.ensure(member);
306
+ return ctx.locks.exclusive([indexLock(member)], async () => {
307
+ const file = read(member);
308
+ const next = {};
309
+ if (d.account !== undefined)
310
+ next.account = d.account;
311
+ if (d.model !== undefined)
312
+ next.model = d.model;
313
+ if (d.auto !== undefined)
314
+ next.auto = d.auto;
315
+ write(member, { ...file, defaults: next });
316
+ });
317
+ }
318
+ async function models(member, id) {
319
+ check(member);
320
+ await ctx.ensure(member);
321
+ const { row, entry } = await entryOf(member, id);
322
+ return readModels(row, entry);
323
+ }
324
+ // The shared hold covers the read, so no kit read of the agent is in flight while it is removed.
325
+ const readModels = (row, entry) => ctx.locks.shared(entry.agent, async () => modelsOf(row.provider, await ctx.request('models.list', { agentId: entry.agent, view: 'default' })));
326
+ async function room(member, id, demand) {
327
+ check(member);
328
+ await ctx.ensure(member);
329
+ const { row, entry } = await entryOf(member, id);
330
+ return readRoom(row, entry);
331
+ }
332
+ // An API-key account is used only when chosen, never by Auto: its room is always unknown, with no read. A demand
333
+ // never narrows the windows (see `roomOf`), so the read takes none.
334
+ const readRoom = (row, entry) => row.billing === 'api'
335
+ ? Promise.resolve({ left: 'unknown' })
336
+ : ctx.locks.shared(entry.agent, async () => {
337
+ let answer;
338
+ try {
339
+ answer = await ctx.request('models.authStatus', { agentId: entry.agent }, { timeoutMs: 20_000 });
340
+ }
341
+ catch {
342
+ return { left: 'unknown' };
343
+ }
344
+ return roomOf(row.provider, answer);
345
+ });
346
+ // Choosing (5.15) for an unbound key: every listed account's room and models are read live once, then the pure
347
+ // `resolveSelection` decides. `agent` is the picked account's engine agent. A model unavailable only because the
348
+ // account is signed out or resting is still in its plan: the account's state and the run's ready check judge that.
349
+ // The pinned engine may hold no prepared model catalog for an agent added since it started, and refuses its models; that
350
+ // row's models are then unknown (`undefined`), and `resolveSelection` host-validates its membership by provider.
351
+ async function pick(member, sel, skip = []) {
352
+ const rows = (await list(member)).filter((r) => !skip.includes(r.id));
353
+ const file = read(member);
354
+ const reads = new Map(await Promise.all(rows.map(async (row) => {
355
+ const entry = file.accounts[row.id];
356
+ const models = await readModels(row, entry).then((ms) => ms.map((m) => (m.why ? { ...m, available: true } : m)), () => undefined);
357
+ return [row.id, { room: await readRoom(row, entry), models }];
358
+ })));
359
+ const chosen = resolveSelection(rows, file.defaults, sel, (a) => reads.get(a.id).room, Date.now(), (a) => reads.get(a.id).models);
360
+ return { pick: chosen, ...(chosen.ok ? { agent: file.accounts[chosen.account.id].agent } : {}) };
361
+ }
362
+ // A run on the account ended resting: Auto skips it until `until` (its state reads `resting` until then).
363
+ const rest = (member, id, until) => edit(member, (file) => { const entry = file.accounts[id]; if (entry)
364
+ entry.until = until; });
365
+ // Index edits take the index lock, as listing's own write does.
366
+ const edit = (member, fn) => ctx.locks.exclusive([indexLock(member)], async () => { const file = read(member); fn(file); write(member, file); });
367
+ const identityOf = async (agentId) => {
368
+ const { file } = await ctx.request('agents.files.get', { agentId, name: IDENTITY });
369
+ return file.missing ? undefined : file.content;
370
+ };
371
+ const restoreIdentity = async (memberAgent, before) => {
372
+ if (before !== undefined && await identityOf(memberAgent) !== before)
373
+ await ctx.request('agents.files.set', { agentId: memberAgent, name: IDENTITY, content: before });
374
+ };
375
+ const onGateway = (agent) => untilListed(ctx.request, agent)
376
+ .then(() => true, () => ctx.request('agents.list')
377
+ .then((r) => r.agents.some((a) => a.id === agent)));
378
+ // A new agent whose sign-in did not finish goes with its entry (5.15); the shared workspace stays. The entry is
379
+ // dropped only once the agent's directory is gone, so a later removal can finish what this one could not.
380
+ const discard = (member, id, agent, created) => (async () => {
381
+ if (created) {
382
+ await ctx.request('byokit.accounts', { action: 'clear', agentId: agent });
383
+ await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
384
+ }
385
+ rmSync(join(ctx.root, 'state', 'agents', agent), { recursive: true, force: true });
386
+ await edit(member, (file) => { delete file.accounts[id]; });
387
+ })().catch((error) => ctx.log?.(`addAccount: ${agent} is not removed yet: ${error instanceof Error ? error.message : String(error)}`));
388
+ async function add(member, o, on) {
389
+ check(member);
390
+ if (!NEW_MEMBER_ID.test(member))
391
+ throw new Error(words('account.legacyMember'));
392
+ const choice = o?.authChoice === 'openai-codex' ? 'openai' : o?.authChoice;
393
+ const route = routes().find((r) => r.choice === choice && r.billing === 'subscription');
394
+ if (!route)
395
+ throw new Error(`addAccount: ${String(o?.authChoice)} is not a subscription sign-in`);
396
+ const name = o.name === undefined ? undefined : named(o.name, 'addAccount');
397
+ const provider = route.provider;
398
+ const { agentId: memberAgent, workspace } = await ctx.ensure(member);
399
+ if (o.again !== undefined && (!read(member).accounts[o.again] || o.again.split('.')[0] !== provider))
400
+ throw new Error(`unknown account: ${o.again}`);
401
+ // The member agent is the target only while it holds no local profile of the provider (5.15).
402
+ const own = await ctx.request('byokit.accounts', { action: 'list', agentId: memberAgent });
403
+ if (!own.ok || !Array.isArray(own.profiles))
404
+ throw new Error('addAccount: the member\'s sign-ins could not be read');
405
+ const fresh = o.again !== undefined || own.profiles.some((p) => p.provider === provider);
406
+ const entryOf = (file, agent) => ({ agent, route: route.choice, addedAt: Date.now(),
407
+ name: name ?? `${route.label} ${Object.values(file.accounts).filter((e) => e.route === route.choice).length + 1}` });
408
+ let id = provider;
409
+ let agent = memberAgent;
410
+ let added = false;
411
+ if (!fresh) {
412
+ await edit(member, (file) => { if (!file.accounts[id]) {
413
+ file.accounts[id] = entryOf(file, agent);
414
+ added = true;
415
+ } });
416
+ }
417
+ else {
418
+ // `<member>--<hex6>`, drawn again while that agent exists; its entry is written before the agent.
419
+ const roster = new Set((await ctx.request('agents.list')).agents.map((a) => a.id));
420
+ await edit(member, (file) => {
421
+ const taken = new Set([...roster, ...Object.values(file.accounts).map((e) => e.agent)]);
422
+ do {
423
+ const hex = randomBytes(3).toString('hex');
424
+ id = `${provider}.${hex}`;
425
+ agent = `${member}--${hex}`;
426
+ } while (taken.has(agent) || file.accounts[id]);
427
+ file.accounts[id] = entryOf(file, agent);
428
+ });
429
+ added = true;
430
+ let created = false;
431
+ // The pin rewrites the shared workspace's IDENTITY.md Name line on create, so the create window is one lock per member.
432
+ await ctx.locks.exclusive([`${member}#identity`], async () => {
433
+ let before;
434
+ try {
435
+ before = await identityOf(memberAgent);
436
+ const made = await ctx.request('agents.create', { name: agent, workspace });
437
+ created = true;
438
+ if (made.agentId !== agent)
439
+ throw new Error(`addAccount: agents.create answered ${String(made.agentId)}, not ${agent}`);
440
+ await untilListed(ctx.request, agent);
441
+ await restoreIdentity(memberAgent, before);
442
+ }
443
+ catch (error) {
444
+ await restoreIdentity(memberAgent, before)
445
+ .catch((restoreError) => ctx.log?.(`addAccount: IDENTITY.md is not restored: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`));
446
+ const exists = created || await onGateway(agent).catch((listError) => {
447
+ ctx.log?.(`addAccount: ${agent} is not removed yet: ${listError instanceof Error ? listError.message : String(listError)}`);
448
+ return undefined;
449
+ });
450
+ if (exists !== undefined)
451
+ await discard(member, id, agent, exists);
452
+ throw error;
453
+ }
454
+ });
455
+ }
456
+ const key = `${member}\n${id}`;
457
+ signing.set(key, (signing.get(key) ?? 0) + 1);
458
+ const handle = ctx.signIn(agent, { authChoice: route.choice, ...(o.via ? { via: o.via } : {}) }, (v) => { if (v.state === 'waiting')
459
+ on(v); }, fresh);
460
+ // Sealed before the person sees done: the agent keeps only this sign-in, and its `who` goes to the index.
461
+ const settle = async (view) => {
462
+ if (view.state === 'done') {
463
+ const sealed = await ctx.locks.exclusive([memberAgent, agent, indexLock(member)], async () => {
464
+ const answer = await ctx.request('byokit.accounts', { action: 'seal', agentId: agent, provider });
465
+ if (!answer.ok)
466
+ return 'Sign-in could not be saved. Try again.';
467
+ const file = read(member);
468
+ const entry = file.accounts[id];
469
+ if (entry && answer.who) {
470
+ entry.who = answer.who;
471
+ write(member, file);
472
+ }
473
+ return true;
474
+ }).catch((error) => (error instanceof Error ? error.message : String(error)).slice(0, 200));
475
+ if (sealed === true)
476
+ return view;
477
+ view = { state: 'failed', via: view.via, why: 'failed', error: sealed };
478
+ }
479
+ if (agent !== memberAgent)
480
+ await discard(member, id, agent, true);
481
+ else if (added)
482
+ await edit(member, (file) => { delete file.accounts[id]; })
483
+ .catch((error) => ctx.log?.(`addAccount: ${id} entry is kept: ${error instanceof Error ? error.message : String(error)}`));
484
+ return view;
485
+ };
486
+ const done = handle.done.then(settle).finally(() => {
487
+ const left = signing.get(key) - 1;
488
+ if (left)
489
+ signing.set(key, left);
490
+ else
491
+ signing.delete(key);
492
+ }).then((view) => {
493
+ on(view);
494
+ return { view, id };
495
+ });
496
+ return { id, paste: handle.paste, cancel: handle.cancel, done };
497
+ }
498
+ return { list, add, rename, defaults, setDefaults, models, room, pick, rest };
499
+ }
@@ -54,7 +54,27 @@ function put(path, bytes) {
54
54
  // containing directory in `changed` instead of fsyncing it now. restore() fsyncs each distinct directory
55
55
  // once after every file is written, so a restore still makes each rename durable before it removes the
56
56
  // restoring marker, without one directory fsync per object. seal() and the stop path keep put().
57
+ // When the live file already holds the exact bytes at the restore mode, the write (and its file fsync,
58
+ // rename and directory fsync) is skipped: nothing on disk changes, so there is nothing to make durable.
59
+ // Size is compared before reading; any read error (or a missing, changed or different-mode file) writes.
60
+ function sameLiveFile(path, bytes) {
61
+ try {
62
+ const stat = lstatSync(path);
63
+ if (!stat.isFile())
64
+ return false;
65
+ if ((stat.mode & 0o777) !== 0o600)
66
+ return false;
67
+ if (stat.size !== bytes.length)
68
+ return false;
69
+ return readFileSync(path).equals(Buffer.from(bytes));
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
57
75
  function putRestore(path, bytes, changed) {
76
+ if (sameLiveFile(path, bytes))
77
+ return;
58
78
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
59
79
  const tmp = `${path}.sealing-${process.pid}`;
60
80
  try {
package/dist/device.d.ts CHANGED
@@ -79,6 +79,9 @@ export type SignInNotice = {
79
79
  export declare function openSignInNotice(data: Record<string, unknown>, seed: Uint8Array): SignInNotice | null;
80
80
  /** Transient streams bypass link's durable request/answer cache: no lease nonce or frame is persisted. */
81
81
  export declare function browserDevice(link: DeviceLink): BrowserDevice;
82
+ type DeviceRunEvent = Exclude<RunEvent, {
83
+ type: 'account';
84
+ }>;
82
85
  /** `openclawDevice`'s device surface: every `oc.*` op the computer answers, typed. */
83
86
  export type OpenClawDevice = {
84
87
  browser: BrowserDevice;
@@ -92,7 +95,7 @@ export type OpenClawDevice = {
92
95
  };
93
96
  signOut(p: string): Promise<void>;
94
97
  sessions(): Promise<SessionRow[]>;
95
- run<const S extends OutputSchema | undefined = undefined>(message: string, o?: DeviceRunOptions<S>): AsyncIterable<RunEvent | DeviceEndFrame<SchemaOutput<S>>>;
98
+ run<const S extends OutputSchema | undefined = undefined>(message: string, o?: DeviceRunOptions<S>): AsyncIterable<DeviceRunEvent | DeviceEndFrame<SchemaOutput<S>>>;
96
99
  steer(k: string, t: string, o?: {
97
100
  auth?: 'apiKey';
98
101
  }): Promise<void>;
package/dist/engine.js CHANGED
@@ -256,7 +256,7 @@ export class Engine {
256
256
  const pluginDir = join(this.root, 'plugin');
257
257
  mkdirSync(pluginDir, { recursive: true, mode: 0o700 });
258
258
  // The shipped plugin follows the kit on every prepare too: a state dir from an older kit must not keep its gate.
259
- for (const f of ['package.json', 'index.js', 'keys.js', 'usage.js']) {
259
+ for (const f of ['package.json', 'index.js', 'keys.js', 'usage.js', 'accounts.js']) {
260
260
  const source = join(kitDir, 'plugin', f);
261
261
  putChanged(join(pluginDir, f), readFileSync(source, 'utf8'));
262
262
  }