@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/accounts.js CHANGED
@@ -1,17 +1,20 @@
1
1
  // The per-member account index (5.15, O14 step S5): `<root>/accounts/<member>.json` (0600, whole-file temp
2
2
  // plus rename) is the only kit state: `{ v: 1, accounts: { [id]: { agent, name, route, addedAt, until?, model?,
3
- // who? } }, defaults, gone? }`, where `gone` names the agent dirs a removal left for the orphan sweep. No credential,
4
- // token, email or engine line. Listing, rename, defaults, models, room, adding with its identity merge (O14 steps
5
- // S6, S7), the start-of-run pick (S10), session binding (S11), moving a conversation (S12) and removal with its
6
- // orphan sweep (S8) live here.
3
+ // who?, priorModel? } }, defaults, gone?, dropped? }`, where `priorModel` is the member agent's primary before a
4
+ // member-agent sign-in pinned it (empty when none), `gone` names account-agent dirs a removal left for the orphan
5
+ // sweep, and `dropped` names providers removed from the member agent so a late refresh cannot restore them. No
6
+ // credential, token, email or engine line. Listing, rename, defaults, models, room, adding with its identity merge
7
+ // (O14 steps S6, S7), the start-of-run pick (S10), session binding (S11), moving a conversation (S12) and removal
8
+ // with its orphan sweep (S8) live here.
7
9
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
8
10
  import { randomBytes, randomUUID } from 'node:crypto';
9
11
  import { join } from 'node:path';
10
- import { providerOf, resolveSelection } from "./pick.js";
11
- import { agentOf, memberOf as baseMemberOf } from "./attribution.js";
12
- import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, untilListed } from "./members.js";
13
- import { routes, routeFor } from "./routes.js";
14
- import { words } from "./words.js";
12
+ import { DatabaseSync } from 'node:sqlite';
13
+ import { providerOf, resolveSelection } from './pick.js';
14
+ import { agentOf, memberOf as baseMemberOf } from './attribution.js';
15
+ import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, keyAgentId, untilListed } from './members.js';
16
+ import { routes, routeFor } from './routes.js';
17
+ import { words } from './words.js';
15
18
  const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
16
19
  // `models.list { agentId, view: 'default' }` rows of the account's provider, mapped to `provider/model`. The pin
17
20
  // reports no per-plan availability, so `why: 'plan'` only comes from a run ending `plan`, never from here.
@@ -60,19 +63,18 @@ const validEntry = (v) => !!v && typeof v === 'object'
60
63
  && typeof v.route === 'string' && Number.isFinite(v.addedAt)
61
64
  && (v.until === undefined || Number.isFinite(v.until))
62
65
  && (v.model === undefined || typeof v.model === 'string')
63
- && (v.who === undefined || typeof v.who === 'string');
66
+ && (v.who === undefined || typeof v.who === 'string')
67
+ && (v.priorModel === undefined || typeof v.priorModel === 'string');
68
+ /** The member agent's primary as a durable index string; empty when there was none (or it is not a string primary). */
69
+ const priorOf = (model) => {
70
+ if (typeof model === 'string')
71
+ return model;
72
+ if (model && typeof model === 'object' && typeof model.primary === 'string')
73
+ return model.primary;
74
+ return '';
75
+ };
64
76
  const indexPath = (root, member) => join(root, 'accounts', `${member}.json`);
65
- const readIndex = (root, member) => {
66
- let text;
67
- try {
68
- text = readFileSync(indexPath(root, member), 'utf8');
69
- }
70
- catch (error) {
71
- if (error.code === 'ENOENT')
72
- return empty();
73
- throw error;
74
- }
75
- const file = JSON.parse(text);
77
+ const normalizeIndex = (file) => {
76
78
  if (!file || typeof file !== 'object' || file.v !== 1)
77
79
  return empty();
78
80
  const accounts = {};
@@ -88,8 +90,44 @@ const readIndex = (root, member) => {
88
90
  if (typeof raw.auto === 'boolean')
89
91
  defaults.auto = raw.auto;
90
92
  const gone = Array.isArray(file.gone) ? file.gone.filter((a) => typeof a === 'string' && ACCOUNT_AGENT.test(a)) : [];
91
- return { v: 1, accounts, defaults, ...(gone.length ? { gone } : {}) };
93
+ const dropped = Array.isArray(file.dropped)
94
+ ? file.dropped.filter((p) => typeof p === 'string' && !!p && !p.includes('.')) : [];
95
+ return { v: 1, accounts, defaults, ...(gone.length ? { gone } : {}), ...(dropped.length ? { dropped } : {}) };
96
+ };
97
+ /** Load one member's index. `damaged` means the file exists but is not JSON; the bytes are left alone for repair. */
98
+ export function loadIndex(root, member) {
99
+ let text;
100
+ try {
101
+ text = readFileSync(indexPath(root, member), 'utf8');
102
+ }
103
+ catch (error) {
104
+ if (error.code === 'ENOENT')
105
+ return { file: empty(), damaged: false };
106
+ throw error;
107
+ }
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(text);
111
+ }
112
+ catch {
113
+ return { file: empty(), damaged: true };
114
+ }
115
+ return { file: normalizeIndex(parsed), damaged: false };
116
+ }
117
+ const readIndex = (root, member) => {
118
+ const loaded = loadIndex(root, member);
119
+ if (loaded.damaged)
120
+ throw new Error(words('account.indexDamaged', { member }));
121
+ return loaded.file;
92
122
  };
123
+ /** True when `agent` is the member agent or an `<member>--<hex6>` account agent of a damaged index. */
124
+ export function heldBackAgent(agent, damaged) {
125
+ if (damaged.has(agent))
126
+ return true;
127
+ if (!ACCOUNT_AGENT.test(agent))
128
+ return false;
129
+ return damaged.has(agent.slice(0, agent.lastIndexOf('--')));
130
+ }
93
131
  // Attribution (5.15, O14m): the member an agent id or `agent:<agent>:<tail>` session key belongs to. A member agent is its
94
132
  // own, and so is an agent whose workspace the kit created for it (a legacy member id may end in `--<hex6>`); any other
95
133
  // agent, an `<member>--<hex6>` account agent included, maps only while the index names it under that member. Account
@@ -110,29 +148,43 @@ export function memberOfAccount(root, ref) {
110
148
  return undefined;
111
149
  }
112
150
  }
113
- // Every agent any member's index names. An index that cannot be read or parsed throws: its accounts are unknown,
114
- // so no sweep may run.
151
+ // Every agent any readable member's index names. An unreadable `accounts/<member>.json` quarantines only that
152
+ // member (held back: not restored, not swept, not resealed away); other members keep restoring, starting and
153
+ // persisting. The damaged file is never deleted — re-add rewrites a good index.
115
154
  function indexedAgents(root) {
116
155
  const named = new Set();
117
156
  const gone = new Set();
157
+ const damaged = new Set();
118
158
  let files;
119
159
  try {
120
160
  files = readdirSync(join(root, 'accounts'));
121
161
  }
122
162
  catch (error) {
123
163
  if (error.code === 'ENOENT')
124
- return { named, gone };
164
+ return { named, gone, damaged };
125
165
  throw error;
126
166
  }
127
167
  for (const file of files) {
128
168
  if (!file.endsWith('.json'))
129
169
  continue;
170
+ const member = file.slice(0, -'.json'.length);
171
+ let text;
172
+ try {
173
+ text = readFileSync(join(root, 'accounts', file), 'utf8');
174
+ }
175
+ catch {
176
+ if (MEMBER_ID.test(member))
177
+ damaged.add(member);
178
+ continue;
179
+ }
130
180
  let parsed;
131
181
  try {
132
- parsed = JSON.parse(readFileSync(join(root, 'accounts', file), 'utf8'));
182
+ parsed = JSON.parse(text);
133
183
  }
134
- catch (error) {
135
- throw new Error(`account index is unreadable: accounts/${file}`, { cause: error });
184
+ catch {
185
+ if (MEMBER_ID.test(member))
186
+ damaged.add(member);
187
+ continue;
136
188
  }
137
189
  for (const entry of Object.values(parsed.accounts ?? {}))
138
190
  if (typeof entry?.agent === 'string')
@@ -141,36 +193,170 @@ function indexedAgents(root) {
141
193
  if (typeof agent === 'string')
142
194
  gone.add(agent);
143
195
  }
144
- return { named, gone };
196
+ return { named, gone, damaged };
197
+ }
198
+ /** Members whose `accounts/<member>.json` exists but is not readable JSON. */
199
+ export function damagedAccountMembers(root) {
200
+ return indexedAgents(root).damaged;
145
201
  }
146
202
  // Durable removal tombstones, except ids an index now names again. Shared by the live and sealed-store sweeps.
203
+ // A damaged member contributes no tombstones and never expands the blast radius of another member's removal.
147
204
  export function removedAccountAgents(root) {
148
- const { named, gone } = indexedAgents(root);
149
- return new Set([...gone].filter((agent) => ACCOUNT_AGENT.test(agent) && !named.has(agent)));
205
+ const { named, gone, damaged } = indexedAgents(root);
206
+ return new Set([...gone].filter((agent) => ACCOUNT_AGENT.test(agent) && !named.has(agent) && !heldBackAgent(agent, damaged)));
150
207
  }
151
- // Removes each `<root>/state/agents/<member>--<hex6>` directory that a removal recorded as gone and no index names.
152
- // Only recorded directories go: a legacy member's own directory is never recorded, so it stays. The directories are
153
- // listed before the index is read: each listed one was created after its entry was written.
154
- export function sweepOrphans(root) {
155
- let dirs;
208
+ // Providers a removal recorded as dropped from the member agent, except ones an index entry on that member agent names
209
+ // again. Parallel to `gone` for account-agent directories: only an intentional removeAccount tombstone is swept, so a
210
+ // live subscription that listing synthesizes from the store is never cleared just because the index omitted it.
211
+ // Damaged members contribute no tombstones.
212
+ function droppedMemberProviders(root) {
213
+ const out = new Map();
214
+ const { damaged } = indexedAgents(root);
215
+ let files;
156
216
  try {
157
- dirs = readdirSync(join(root, 'state', 'agents'));
217
+ files = readdirSync(join(root, 'accounts'));
158
218
  }
159
219
  catch (error) {
160
220
  if (error.code === 'ENOENT')
161
- return;
221
+ return out;
162
222
  throw error;
163
223
  }
224
+ for (const file of files) {
225
+ if (!file.endsWith('.json'))
226
+ continue;
227
+ const member = file.slice(0, -'.json'.length);
228
+ if (damaged.has(member))
229
+ continue;
230
+ let parsed;
231
+ try {
232
+ parsed = JSON.parse(readFileSync(join(root, 'accounts', file), 'utf8'));
233
+ }
234
+ catch {
235
+ continue;
236
+ }
237
+ const named = new Set();
238
+ for (const [id, entry] of Object.entries(parsed.accounts ?? {})) {
239
+ if (typeof entry?.agent !== 'string' || entry.agent !== member || !id)
240
+ continue;
241
+ named.add(id.split('.')[0]);
242
+ }
243
+ const drop = new Set();
244
+ for (const provider of Array.isArray(parsed.dropped) ? parsed.dropped : []) {
245
+ if (typeof provider !== 'string' || !provider || provider.includes('.') || named.has(provider))
246
+ continue;
247
+ drop.add(provider);
248
+ }
249
+ if (drop.size)
250
+ out.set(member, drop);
251
+ }
252
+ return out;
253
+ }
254
+ function dropProviders(store, drop) {
255
+ let changed = false;
256
+ const profiles = store.profiles && typeof store.profiles === 'object' ? store.profiles : {};
257
+ for (const [profileId, profile] of Object.entries(profiles)) {
258
+ if (typeof profile?.provider === 'string' && !drop.has(profile.provider))
259
+ continue;
260
+ delete profiles[profileId];
261
+ changed = true;
262
+ }
263
+ store.profiles = profiles;
264
+ if (store.order && typeof store.order === 'object') {
265
+ for (const provider of Object.keys(store.order)) {
266
+ if (!drop.has(provider))
267
+ continue;
268
+ delete store.order[provider];
269
+ changed = true;
270
+ }
271
+ }
272
+ if (store.lastGood && typeof store.lastGood === 'object') {
273
+ for (const provider of Object.keys(store.lastGood)) {
274
+ if (!drop.has(provider))
275
+ continue;
276
+ delete store.lastGood[provider];
277
+ changed = true;
278
+ }
279
+ }
280
+ return changed;
281
+ }
282
+ function putAuthProfilesJson(path, store) {
283
+ const staging = `${path}.staging-${process.pid}`;
284
+ writeFileSync(staging, JSON.stringify(store), { mode: 0o600 });
285
+ renameSync(staging, path);
286
+ try {
287
+ chmodSync(path, 0o600);
288
+ }
289
+ catch { /* best-effort mode */ }
290
+ }
291
+ function sweepMemberAuthProfiles(root) {
292
+ for (const [member, drop] of droppedMemberProviders(root)) {
293
+ const agentDir = join(root, 'state', 'agents', member, 'agent');
294
+ const jsonPath = join(agentDir, 'auth-profiles.json');
295
+ if (existsSync(jsonPath)) {
296
+ try {
297
+ const store = JSON.parse(readFileSync(jsonPath, 'utf8'));
298
+ if (store && typeof store === 'object' && dropProviders(store, drop))
299
+ putAuthProfilesJson(jsonPath, store);
300
+ }
301
+ catch { /* leave an unreadable store alone; prepare must still finish */ }
302
+ }
303
+ const dbPath = join(agentDir, 'openclaw-agent.sqlite');
304
+ if (!existsSync(dbPath))
305
+ continue;
306
+ try {
307
+ const db = new DatabaseSync(dbPath);
308
+ try {
309
+ const row = db.prepare('SELECT store_json FROM auth_profile_store').get();
310
+ if (typeof row?.store_json !== 'string')
311
+ continue;
312
+ const store = JSON.parse(row.store_json);
313
+ if (!store || typeof store !== 'object' || !dropProviders(store, drop))
314
+ continue;
315
+ db.prepare('UPDATE auth_profile_store SET store_json = ?').run(JSON.stringify(store));
316
+ }
317
+ finally {
318
+ db.close();
319
+ }
320
+ }
321
+ catch { /* leave an unreadable native store alone */ }
322
+ }
323
+ }
324
+ // Removes each `<root>/state/agents/<member>--<hex6>` directory that a removal recorded as gone and no index names,
325
+ // and drops member-agent auth profiles a removal recorded as `dropped`. Only recorded directories go: a legacy member's
326
+ // own directory is never recorded, so it stays. The directories are listed before the index is read: each listed one was
327
+ // created after its entry was written. Damaged members' agents are never swept (their gone/dropped lists are unknown);
328
+ // `log` reports each damaged index once.
329
+ export function sweepOrphans(root, log) {
330
+ const { damaged } = indexedAgents(root);
331
+ for (const member of damaged)
332
+ log?.(words('account.indexDamaged', { member }));
333
+ let dirs = [];
334
+ try {
335
+ dirs = readdirSync(join(root, 'state', 'agents'));
336
+ }
337
+ catch (error) {
338
+ if (error.code !== 'ENOENT')
339
+ throw error;
340
+ }
164
341
  const gone = removedAccountAgents(root);
165
342
  for (const dir of dirs) {
166
343
  if (!gone.has(dir))
167
344
  continue;
168
345
  rmSync(join(root, 'state', 'agents', dir), { recursive: true, force: true });
169
346
  }
347
+ sweepMemberAuthProfiles(root);
170
348
  }
171
349
  export function createAccounts(ctx) {
172
350
  const path = (member) => indexPath(ctx.root, member);
173
- const read = (member) => readIndex(ctx.root, member);
351
+ const read = (member) => {
352
+ const loaded = loadIndex(ctx.root, member);
353
+ if (loaded.damaged) {
354
+ const message = words('account.indexDamaged', { member });
355
+ ctx.log?.(message);
356
+ throw new Error(message);
357
+ }
358
+ return loaded.file;
359
+ };
174
360
  // Accounts whose sign-in is running, as `<member>\n<id>`, counted per add: listed `signing` until the last one ends.
175
361
  const signing = new Map();
176
362
  // The index file's own lock, apart from any agent's: a run's shared hold on the member agent never blocks an edit.
@@ -186,6 +372,17 @@ export function createAccounts(ctx) {
186
372
  };
187
373
  const markGone = (file, agent) => { if (!file.gone?.includes(agent))
188
374
  file.gone = [...(file.gone ?? []), agent]; };
375
+ const markDropped = (file, provider) => {
376
+ if (!file.dropped?.includes(provider))
377
+ file.dropped = [...(file.dropped ?? []), provider];
378
+ };
379
+ const clearDropped = (file, provider) => {
380
+ if (!file.dropped?.includes(provider))
381
+ return;
382
+ file.dropped = file.dropped.filter((p) => p !== provider);
383
+ if (!file.dropped.length)
384
+ delete file.dropped;
385
+ };
189
386
  const write = (member, file) => {
190
387
  mkdirSync(join(ctx.root, 'accounts'), { recursive: true, mode: 0o700 });
191
388
  const tmp = `${path(member)}.${randomUUID()}.tmp`;
@@ -234,6 +431,9 @@ export function createAccounts(ctx) {
234
431
  const memberRows = [];
235
432
  const seen = new Set();
236
433
  for (const [provider, profiles] of [...byProvider.entries()].sort(([a], [b]) => a < b ? -1 : 1)) {
434
+ // A removeAccount tombstone wins until the person signs in again: do not synthesize the removed row back.
435
+ if (file.dropped?.includes(provider))
436
+ continue;
237
437
  let entry = file.accounts[provider];
238
438
  if (!entry || entry.agent !== agentId) {
239
439
  const route = entry?.agent === agentId && typeof entry.route === 'string' ? entry.route : synthesizedRoute(provider);
@@ -283,7 +483,26 @@ export function createAccounts(ctx) {
283
483
  }
284
484
  memberRows.sort((a, b) => a.addedAt - b.addedAt || (a.id < b.id ? -1 : 1));
285
485
  agentRows.sort((a, b) => a.addedAt - b.addedAt);
286
- const rows = [...memberRows, ...agentRows];
486
+ // The API-key lane: the member's single key agent, indexed once as `<provider>.key`. Only what the index
487
+ // names is listed; a key agent the index does not name holds no account.
488
+ const keyRows = [];
489
+ for (const [id, entry] of Object.entries(file.accounts)) {
490
+ if (entry.agent !== keyAgentId(member) || !roster.has(entry.agent))
491
+ continue;
492
+ const provider = id.split('.')[0];
493
+ const meta = metaOf(entry.route);
494
+ const profiles = await profilesOf(entry.agent);
495
+ const who = profiles.find((p) => p.provider === provider)?.who;
496
+ if (!entry.who && typeof who === 'string' && !signing.has(`${member}\n${id}`)) {
497
+ entry.who = who;
498
+ dirty = true;
499
+ }
500
+ keyRows.push({ id, provider, route: entry.route, name: entry.name, label: meta.label,
501
+ billing: meta.billing, state: stateOf(entry.route, profiles[0]?.status, entry.until),
502
+ ...(entry.until !== undefined ? { until: entry.until } : {}), addedAt: entry.addedAt });
503
+ }
504
+ keyRows.sort((a, b) => a.addedAt - b.addedAt);
505
+ const rows = [...memberRows, ...agentRows, ...keyRows];
287
506
  for (const row of rows)
288
507
  if (signing.has(`${member}\n${row.id}`))
289
508
  row.state = 'signing';
@@ -441,7 +660,7 @@ export function createAccounts(ctx) {
441
660
  return roomOf(row.provider, answer);
442
661
  });
443
662
  // Runs step 2 reads the index alone: the index-only refusal makes no request, and a cold index adds one account
444
- // listing (the pick's late check after `list`; `keyRefusal` lists when the held account is not API-billed). This
663
+ // listing (the pick's late check after `list`; `keyRefusal` lists unless the binding is the key lane). This
445
664
  // gives the accounts a binding holds, in list order, and the name a refusal shows (`account.another` when it holds none).
446
665
  const boundOf = (file, binding) => binding.id !== undefined ? [binding.id]
447
666
  : Object.entries(file.accounts).filter(([, e]) => e.agent === binding.agentId)
@@ -464,23 +683,27 @@ export function createAccounts(ctx) {
464
683
  return { code: 'bound', name: nameOf(file, ids) };
465
684
  return undefined;
466
685
  };
467
- // An API-key run (Runs step 2): its key continues only an API-billed binding; any other binding is refused.
686
+ // An API-key run (Runs step 2): its key continues its own lane's conversation whatever the key bills (a
687
+ // plan key bills subscription), and any other binding is refused.
468
688
  const keyRefusal = async (member, binding) => {
469
- const held = binding.id === undefined ? undefined : read(member).accounts[binding.id];
470
- if (held && metaOf(held.route).billing === 'api')
689
+ if (binding.agentId === keyAgentId(member))
471
690
  return undefined;
472
691
  await list(member);
473
692
  const file = read(member);
474
693
  return words('account.bound', { name: nameOf(file, boundOf(file, binding)) });
475
694
  };
476
- // Runs step 1 (5.15): a key `agent:<member>--<hex6>:<tail>` of an indexed account agent is bound to that account; a
477
- // key `agent:<member>:<tail>` to whichever of the member's agents holds `agent:<that agent>:<tail>` (member agent
478
- // first), else unbound. Any other key is not the member's. With no account agent, the member agent and an unbound
479
- // key run alike, so nothing is asked.
695
+ // Runs step 1 (5.15): a key `agent:<member>--<hex6>:<tail>` of an indexed account agent is bound to that account. A key
696
+ // `agent:<member>:<tail>` is bound to whichever of the member's agents holds `agent:<that agent>:<tail>` (member agent
697
+ // first, then the key lane, whose account is its `<provider>.key`, then the account agents), else unbound. Any other key
698
+ // is not the member's. With no account agent and no indexed lane, the member agent and an unbound key run alike, so
699
+ // nothing is asked.
480
700
  async function bind(member, sessionKey) {
481
701
  check(member);
482
- const agents = new Map(Object.entries(read(member).accounts)
702
+ const file = read(member);
703
+ const agents = new Map(Object.entries(file.accounts)
483
704
  .filter(([, e]) => ACCOUNT_AGENT.test(e.agent) && e.agent.startsWith(`${member}--`)).map(([id, e]) => [e.agent, id]));
705
+ const lane = keyAgentId(member);
706
+ const laneId = Object.entries(file.accounts).find(([, e]) => e.agent === lane)?.[0];
484
707
  if (!sessionKey.startsWith(`agent:${member}:`)) {
485
708
  const agent = /^agent:([^:]+):/.exec(sessionKey)?.[1];
486
709
  const id = agent === undefined ? undefined : agents.get(agent);
@@ -488,12 +711,13 @@ export function createAccounts(ctx) {
488
711
  throw new Error(`refused: "${sessionKey}" is not a session of member "${member}"`);
489
712
  return { agentId: agent, id };
490
713
  }
491
- if (!agents.size)
714
+ if (!agents.size && laneId === undefined)
492
715
  return undefined;
493
716
  const tail = sessionKey.slice(`agent:${member}:`.length);
494
717
  const { agentId } = await ctx.ensure(member);
495
718
  const roster = new Set((await ctx.request('agents.list')).agents.map((a) => a.id));
496
- for (const [agent, id] of [[agentId, undefined], ...agents]) {
719
+ const laneProbe = laneId === undefined ? [] : [[lane, laneId]];
720
+ for (const [agent, id] of [[agentId, undefined], ...laneProbe, ...agents]) {
497
721
  if (agent !== agentId && !roster.has(agent))
498
722
  continue;
499
723
  // The pin answers a found session with its canonical `key` and a missing one with `{ ok: false }`.
@@ -503,10 +727,11 @@ export function createAccounts(ctx) {
503
727
  }
504
728
  return undefined;
505
729
  }
506
- // Choosing (5.15): every listed account's room and models are read live once, then the pure `resolveSelection`
507
- // decides. A binding narrows it (Runs step 2): a key bound to an account agent resolves 'auto', 'default' and no id to
508
- // that account; a key bound to the member agent chooses only among its own accounts. A refusal (`bound`, `paid`) is
509
- // decided from the index, after the account listing when the index is cold, and so has no `considered` rows.
730
+ // Choosing (5.15): every pick input's room and models are read live once (unnamed key lanes are excluded),
731
+ // then the pure `resolveSelection` decides. A binding narrows it (Runs step 2): a key bound to an account agent resolves 'auto', 'default' and no id to
732
+ // that account; a key bound to the key lane resolves to its `<provider>.key` account; a key bound to the member agent
733
+ // chooses only among its own accounts. A refusal (`bound`, `paid`) is decided from the index, after the account
734
+ // listing when the index is cold, and so has no `considered` rows.
510
735
  // An unknown id stays the caller bug it always was. `agent` is the picked account's engine agent. A model unavailable
511
736
  // only because the account is signed out or resting is still in its plan: the account's state and the run's ready
512
737
  // check judge that.
@@ -524,18 +749,25 @@ export function createAccounts(ctx) {
524
749
  const late = binding && refusalOf(file, binding, sel);
525
750
  if (late)
526
751
  return refused(late);
752
+ if (binding?.id !== undefined && !listed.some((r) => r.id === binding.id))
753
+ return { pick: { ok: false, code: 'none', reason: words('auto.noneBound'), considered: [] } };
527
754
  const rows = listed.filter((r) => !skip.includes(r.id));
528
755
  const named = sel.account === 'auto' || sel.account === 'default' ? undefined : sel.account;
529
756
  const known = named === undefined || listed.some((r) => r.id === named);
530
757
  if (binding?.id !== undefined && known)
531
758
  sel = { ...sel, account: binding.id };
532
759
  const bound = binding && known ? boundOf(file, binding) : undefined;
533
- const reads = new Map(await Promise.all(rows.map(async (row) => {
760
+ // The key lane is reached only by naming it (or the saved default): an unnamed lane row is not a pick input, so
761
+ // Auto and a default fallback see exactly the accounts they saw before the lane was indexed.
762
+ const lanes = new Set(Object.entries(file.accounts).filter(([, e]) => e.agent === keyAgentId(member)).map(([id]) => id));
763
+ const pickRows = rows.filter((r) => !lanes.has(r.id) || sel.account === r.id ||
764
+ (sel.account === 'default' && file.defaults.account === r.id));
765
+ const reads = new Map(await Promise.all(pickRows.map(async (row) => {
534
766
  const entry = file.accounts[row.id];
535
767
  const models = await readModels(row, entry).then((ms) => ms.map((m) => (m.why ? { ...m, available: true } : m)), () => undefined);
536
768
  return [row.id, { room: await readRoom(row, entry), models }];
537
769
  })));
538
- const chosen = resolveSelection(rows, file.defaults, sel, (a) => reads.get(a.id).room, Date.now(), (a) => reads.get(a.id).models, bound);
770
+ const chosen = resolveSelection(pickRows, file.defaults, sel, (a) => reads.get(a.id).room, Date.now(), (a) => reads.get(a.id).models, bound);
539
771
  return { pick: chosen, ...(chosen.ok ? { agent: file.accounts[chosen.account.id].agent } : {}) };
540
772
  }
541
773
  // Moving a conversation (5.15, O14 step S12): the key binds as a run's does (with no account agent binding asks
@@ -607,10 +839,127 @@ export function createAccounts(ctx) {
607
839
  const rest = (member, id, until) => edit(member, (file) => { const entry = file.accounts[id]; if (entry)
608
840
  entry.until = until; });
609
841
  const agentDir = (agent) => join(ctx.root, 'state', 'agents', agent);
610
- // Removing (5.15): a member-agent account goes through `models.authLogout` with its sealed profileIds, then its
611
- // entry is dropped. An account agent goes through `clear`, an empty `list`, `agents.delete` with
612
- // `deleteFiles: false`, removal of its state dir, then the entry is dropped. Refused `busy` while a run holds
613
- // the agent; the member agent stays ready (only the named provider is logged out or cleared).
842
+ const laneEntry = (member, route, addedAt) => ({ agent: keyAgentId(member), name: route.label, route: route.choice, addedAt });
843
+ // The API-key lane's index entry (5.15): one per member, `<provider>.key` on the `byokit-key-<member>` agent.
844
+ // A new key replaces the old one: whatever lane entry the member holds is dropped first, keeping its `addedAt`.
845
+ // The key itself is never stored: only the agent, the route choice and the name.
846
+ async function key(member, route) {
847
+ check(member);
848
+ const lane = keyAgentId(member);
849
+ return ctx.locks.exclusive([indexLock(member)], async () => {
850
+ const loaded = loadIndex(ctx.root, member);
851
+ if (loaded.damaged) {
852
+ ctx.log?.(words('account.indexDamaged', { member }));
853
+ return;
854
+ }
855
+ const file = loaded.file;
856
+ const kept = Object.values(file.accounts).find((e) => e.agent === lane)?.addedAt;
857
+ for (const [other, e] of Object.entries(file.accounts))
858
+ if (e.agent === lane)
859
+ delete file.accounts[other];
860
+ file.accounts[`${route.provider}.key`] = laneEntry(member, route, kept ?? Date.now());
861
+ write(member, file);
862
+ });
863
+ }
864
+ // The removal's counterpart to `key`: drop the lane's index entry, so `list` stops naming the removed key.
865
+ // `provider` scopes it to that provider's entry (a scoped removal that kept another provider's key keeps its
866
+ // entry too); without one every lane entry goes. A damaged index is left alone, like `key` leaves it.
867
+ async function unkey(member, provider) {
868
+ check(member);
869
+ const lane = keyAgentId(member);
870
+ return ctx.locks.exclusive([indexLock(member)], async () => {
871
+ const loaded = loadIndex(ctx.root, member);
872
+ if (loaded.damaged) {
873
+ ctx.log?.(words('account.indexDamaged', { member }));
874
+ return;
875
+ }
876
+ const file = loaded.file;
877
+ for (const [id, e] of Object.entries(file.accounts)) {
878
+ if (e.agent !== lane || (provider !== undefined && id.split('.')[0] !== provider))
879
+ continue;
880
+ dropEntry(file, id);
881
+ }
882
+ write(member, file);
883
+ });
884
+ }
885
+ // The connect sweep's backfill: a lane that holds no entry is indexed under the one route its model's provider has;
886
+ // any other count is logged and left unindexed. A lane that holds an entry is never touched, nor a damaged index.
887
+ async function backfill(member, found) {
888
+ check(member);
889
+ const lane = keyAgentId(member);
890
+ return ctx.locks.exclusive([indexLock(member)], async () => {
891
+ const loaded = loadIndex(ctx.root, member);
892
+ if (loaded.damaged) {
893
+ ctx.log?.(words('account.indexDamaged', { member }));
894
+ return;
895
+ }
896
+ if (Object.values(loaded.file.accounts).some((e) => e.agent === lane))
897
+ return;
898
+ if (found.length !== 1) {
899
+ ctx.log?.(`key lane ${member}: its provider has no single key route, so it is not indexed; add the key again to index it`);
900
+ return;
901
+ }
902
+ loaded.file.accounts[`${found[0].provider}.key`] = laneEntry(member, found[0], Date.now());
903
+ write(member, loaded.file);
904
+ });
905
+ }
906
+ // Index edits take the index lock, as listing's own write does. A damaged index starts empty and is rewritten
907
+ // (re-add's repair path); the bad bytes are replaced, never left as the live index after a successful write.
908
+ const edit = (member, fn) => ctx.locks.exclusive([indexLock(member)], async () => {
909
+ const loaded = loadIndex(ctx.root, member);
910
+ if (loaded.damaged)
911
+ ctx.log?.(words('account.indexDamaged', { member }));
912
+ const file = loaded.damaged ? empty() : loaded.file;
913
+ fn(file);
914
+ write(member, file);
915
+ });
916
+ const identityOf = async (agentId) => {
917
+ const { file } = await ctx.request('agents.files.get', { agentId, name: IDENTITY });
918
+ return file.missing ? undefined : file.content;
919
+ };
920
+ const restoreIdentity = async (memberAgent, before) => {
921
+ if (before !== undefined && await identityOf(memberAgent) !== before)
922
+ await ctx.request('agents.files.set', { agentId: memberAgent, name: IDENTITY, content: before });
923
+ };
924
+ const readModel = async (agentId) => {
925
+ const got = await ctx.request('config.get');
926
+ return got.config?.agents?.entries?.[agentId]?.model;
927
+ };
928
+ // A merge patch cannot delete a key: with no prior primary, `agents.update { model: null }` clears the pin so the
929
+ // engine default applies. A write that lands between the read and the patch moves the hash: try once more.
930
+ const restoreModel = async (memberAgent, model) => {
931
+ const clear = async () => {
932
+ await ctx.request('agents.update', { agentId: memberAgent, model: null });
933
+ };
934
+ const patch = async () => {
935
+ const { hash } = await ctx.request('config.get');
936
+ await ctx.request('config.patch', { raw: JSON.stringify({ agents: { entries: { [memberAgent]: { model } } } }), baseHash: hash });
937
+ };
938
+ try {
939
+ if (model === undefined || model === null) {
940
+ await clear().catch(() => clear());
941
+ return;
942
+ }
943
+ await patch().catch(() => patch());
944
+ }
945
+ catch (error) {
946
+ // A damaged prior must not leave the removed provider's pin: fall back to clearing.
947
+ if (model !== undefined && model !== null) {
948
+ try {
949
+ await clear().catch(() => clear());
950
+ }
951
+ catch (clearError) {
952
+ ctx.log?.(`addAccount: ${memberAgent} model pin is not cleared: ${clearError instanceof Error ? clearError.message : String(clearError)}`);
953
+ }
954
+ }
955
+ ctx.log?.(`addAccount: ${memberAgent} model is not restored: ${error instanceof Error ? error.message : String(error)}`);
956
+ }
957
+ };
958
+ // Removing (5.15): a member-agent account goes through `models.authLogout` with its sealed profileIds, restores or
959
+ // clears the member primary from `priorModel`, then drops its entry. An account agent goes through `clear`, an
960
+ // empty `list`, `agents.delete` with `deleteFiles: false`, removal of its state dir, then the entry is dropped.
961
+ // Refused `busy` while a run holds the agent; the member agent stays ready (only the named provider is logged out
962
+ // or cleared).
614
963
  const providerProfileIds = async (agentId, provider) => {
615
964
  const listed = await ctx.request('byokit.accounts', { action: 'list', agentId });
616
965
  if (listed.ok !== true || !Array.isArray(listed.profiles))
@@ -621,6 +970,8 @@ export function createAccounts(ctx) {
621
970
  check(member);
622
971
  const { agentId: memberAgent } = await ctx.ensure(member);
623
972
  const entry = read(member).accounts[id];
973
+ if (entry?.agent === keyAgentId(member))
974
+ throw new Error(`removeAccount: ${id} is a key lane; use removeKey`);
624
975
  if (!entry || (entry.agent !== memberAgent && !(ACCOUNT_AGENT.test(entry.agent) && entry.agent.startsWith(`${member}--`))))
625
976
  throw new Error(`unknown account: ${id}`);
626
977
  const agent = entry.agent;
@@ -635,7 +986,11 @@ export function createAccounts(ctx) {
635
986
  if ((await providerProfileIds(agent, provider)).length)
636
987
  throw new Error('removeAccount: the account is still signing in');
637
988
  const file = read(member);
989
+ const prior = file.accounts[id]?.priorModel;
990
+ // Restore the pre-sign-in primary (or clear the pin) before dropping the entry; failure must not block removal.
991
+ await restoreModel(memberAgent, typeof prior === 'string' && prior ? prior : undefined);
638
992
  dropEntry(file, id);
993
+ markDropped(file, provider);
639
994
  write(member, file);
640
995
  // The live store changed: re-seal at once so the sealed copy stops holding the removed profile.
641
996
  await ctx.reseal?.();
@@ -663,38 +1018,6 @@ export function createAccounts(ctx) {
663
1018
  await ctx.reseal?.();
664
1019
  });
665
1020
  }
666
- // Index edits take the index lock, as listing's own write does.
667
- const edit = (member, fn) => ctx.locks.exclusive([indexLock(member)], async () => { const file = read(member); fn(file); write(member, file); });
668
- const identityOf = async (agentId) => {
669
- const { file } = await ctx.request('agents.files.get', { agentId, name: IDENTITY });
670
- return file.missing ? undefined : file.content;
671
- };
672
- const restoreIdentity = async (memberAgent, before) => {
673
- if (before !== undefined && await identityOf(memberAgent) !== before)
674
- await ctx.request('agents.files.set', { agentId: memberAgent, name: IDENTITY, content: before });
675
- };
676
- const readModel = async (agentId) => {
677
- const got = await ctx.request('config.get');
678
- return got.config?.agents?.entries?.[agentId]?.model;
679
- };
680
- // ponytail: a merge patch cannot delete a key, so a member with no model entry before its sign-in keeps the pinned one.
681
- const restoreModel = async (memberAgent, model) => {
682
- if (model === undefined) {
683
- ctx.log?.(`addAccount: ${memberAgent} had no model entry to restore`);
684
- return;
685
- }
686
- // A config write that lands between the read and the patch moves the hash: read and patch once more.
687
- const patch = async () => {
688
- const { hash } = await ctx.request('config.get');
689
- await ctx.request('config.patch', { raw: JSON.stringify({ agents: { entries: { [memberAgent]: { model } } } }), baseHash: hash });
690
- };
691
- try {
692
- await patch().catch(() => patch());
693
- }
694
- catch (error) {
695
- ctx.log?.(`addAccount: ${memberAgent} model is not restored: ${error instanceof Error ? error.message : String(error)}`);
696
- }
697
- };
698
1021
  const onGateway = (agent) => untilListed(ctx.request, agent)
699
1022
  .then(() => true, () => ctx.request('agents.list')
700
1023
  .then((r) => r.agents.some((a) => a.id === agent)));
@@ -746,18 +1069,27 @@ export function createAccounts(ctx) {
746
1069
  if (fresh)
747
1070
  releaseAdd();
748
1071
  }
749
- // The member agent's primary model before this sign-in or any merge on it: restored when its profile moves away.
1072
+ // The member agent's primary before this sign-in or any merge on it: restored when its profile moves away or
1073
+ // the member-agent account is removed. Durable on the member entry as `priorModel` (empty when none).
750
1074
  const memberModel = await readModel(memberAgent);
751
- const entryOf = (file, agent) => ({ agent, route: route.choice, addedAt: Date.now(),
752
- name: name ?? `${route.label} ${Object.values(file.accounts).filter((e) => e.route === route.choice).length + 1}` });
1075
+ const entryOf = (file, agent, prior) => ({ agent, route: route.choice, addedAt: Date.now(),
1076
+ name: name ?? `${route.label} ${Object.values(file.accounts).filter((e) => e.route === route.choice).length + 1}`,
1077
+ ...(prior !== undefined ? { priorModel: prior } : {}) });
753
1078
  let id = provider;
754
1079
  let agent = memberAgent;
755
1080
  let added = false;
756
1081
  if (!fresh) {
757
- await edit(member, (file) => { if (!file.accounts[id]) {
758
- file.accounts[id] = entryOf(file, agent);
759
- added = true;
760
- } });
1082
+ const prior = priorOf(memberModel);
1083
+ await edit(member, (file) => {
1084
+ if (!file.accounts[id]) {
1085
+ file.accounts[id] = entryOf(file, agent, prior);
1086
+ added = true;
1087
+ }
1088
+ // Keep the first pre-sign-in primary across a later re-sign-in of the same member row.
1089
+ else if (file.accounts[id].priorModel === undefined)
1090
+ file.accounts[id].priorModel = prior;
1091
+ clearDropped(file, provider);
1092
+ });
761
1093
  }
762
1094
  else {
763
1095
  // `<member>--<hex6>`, drawn again while that agent exists; its entry is written before the agent.
@@ -948,5 +1280,5 @@ export function createAccounts(ctx) {
948
1280
  throw error;
949
1281
  }
950
1282
  }
951
- return { list, add, rename, defaults, setDefaults, models, room, bind, keyRefusal, pick, rest, move, remove };
1283
+ return { list, add, rename, defaults, setDefaults, models, room, bind, keyRefusal, key, unkey, backfill, pick, rest, move, remove };
952
1284
  }