@byokit/openclaw 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/accounts.js CHANGED
@@ -1,14 +1,50 @@
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 }`. 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';
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.
7
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
8
  import { randomBytes, randomUUID } from 'node:crypto';
7
9
  import { join } from 'node:path';
8
10
  import { providerOf, resolveSelection } from "./pick.js";
11
+ import { agentOf, memberOf as baseMemberOf } from "./attribution.js";
9
12
  import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, untilListed } from "./members.js";
10
13
  import { routes, routeFor } from "./routes.js";
11
14
  import { words } from "./words.js";
15
+ const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
16
+ // `models.list { agentId, view: 'default' }` rows of the account's provider, mapped to `provider/model`. The pin
17
+ // reports no per-plan availability, so `why: 'plan'` only comes from a run ending `plan`, never from here.
18
+ export const modelsOf = (provider, answer) => {
19
+ const rows = record(answer) && Array.isArray(answer.models) ? answer.models : [];
20
+ const out = [];
21
+ for (const candidate of rows) {
22
+ if (!record(candidate) || typeof candidate.provider !== 'string'
23
+ || candidate.provider.toLowerCase() !== provider.toLowerCase())
24
+ continue;
25
+ if (typeof candidate.id !== 'string' || !candidate.id || typeof candidate.name !== 'string')
26
+ continue;
27
+ const id = candidate.id.includes('/') ? candidate.id : `${candidate.provider}/${candidate.id}`;
28
+ if (providerOf(id) !== provider.toLowerCase())
29
+ continue;
30
+ const available = candidate.available === undefined
31
+ ? candidate.unavailableReason === undefined : candidate.available === true;
32
+ const info = { id, name: candidate.name, available };
33
+ if ((candidate.tier === 'strong' || candidate.tier === 'fast'))
34
+ info.tier = candidate.tier;
35
+ if (!available) {
36
+ if (candidate.unavailableReason === 'missing-auth' || candidate.unavailableReason === 'auth-failed')
37
+ info.why = 'signed_out';
38
+ else if (candidate.unavailableReason === 'cooldown') {
39
+ info.why = 'resting';
40
+ if (typeof candidate.unavailableUntil === 'number' && Number.isFinite(candidate.unavailableUntil))
41
+ info.until = candidate.unavailableUntil;
42
+ }
43
+ }
44
+ out.push(info);
45
+ }
46
+ return out;
47
+ };
12
48
  const ACCOUNT_AGENT = /^[a-z][a-z0-9-]{0,31}--[0-9a-f]{6}$/;
13
49
  const usable = (status) => status === 'ok' || status === 'expiring' || status === 'static';
14
50
  const IDENTITY = 'IDENTITY.md';
@@ -25,8 +61,116 @@ const validEntry = (v) => !!v && typeof v === 'object'
25
61
  && (v.until === undefined || Number.isFinite(v.until))
26
62
  && (v.model === undefined || typeof v.model === 'string')
27
63
  && (v.who === undefined || typeof v.who === 'string');
64
+ 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);
76
+ if (!file || typeof file !== 'object' || file.v !== 1)
77
+ return empty();
78
+ const accounts = {};
79
+ for (const [id, entry] of Object.entries(file.accounts ?? {}))
80
+ if (validEntry(entry))
81
+ accounts[id] = entry;
82
+ const raw = file.defaults && typeof file.defaults === 'object' ? file.defaults : {};
83
+ const defaults = {};
84
+ if (typeof raw.account === 'string')
85
+ defaults.account = raw.account;
86
+ if (typeof raw.model === 'string')
87
+ defaults.model = raw.model;
88
+ if (typeof raw.auto === 'boolean')
89
+ defaults.auto = raw.auto;
90
+ 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 } : {}) };
92
+ };
93
+ // Attribution (5.15, O14m): the member an agent id or `agent:<agent>:<tail>` session key belongs to. A member agent is its
94
+ // own, and so is an agent whose workspace the kit created for it (a legacy member id may end in `--<hex6>`); any other
95
+ // agent, an `<member>--<hex6>` account agent included, maps only while the index names it under that member. Account
96
+ // agents share their member's workspace, so an unindexed account-shaped agent maps nowhere and stays unreachable.
97
+ export function memberOfAccount(root, ref) {
98
+ const agent = agentOf(ref);
99
+ if (agent === undefined)
100
+ return undefined;
101
+ const member = baseMemberOf(agent);
102
+ if (member === undefined || agent === member)
103
+ return member;
104
+ if (MEMBER_ID.test(agent) && existsSync(join(root, 'workspaces', agent)))
105
+ return agent;
106
+ try {
107
+ return Object.values(readIndex(root, member).accounts).some((e) => e.agent === agent) ? member : undefined;
108
+ }
109
+ catch {
110
+ return undefined;
111
+ }
112
+ }
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.
115
+ function indexedAgents(root) {
116
+ const named = new Set();
117
+ const gone = new Set();
118
+ let files;
119
+ try {
120
+ files = readdirSync(join(root, 'accounts'));
121
+ }
122
+ catch (error) {
123
+ if (error.code === 'ENOENT')
124
+ return { named, gone };
125
+ throw error;
126
+ }
127
+ for (const file of files) {
128
+ if (!file.endsWith('.json'))
129
+ continue;
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(readFileSync(join(root, 'accounts', file), 'utf8'));
133
+ }
134
+ catch (error) {
135
+ throw new Error(`account index is unreadable: accounts/${file}`, { cause: error });
136
+ }
137
+ for (const entry of Object.values(parsed.accounts ?? {}))
138
+ if (typeof entry?.agent === 'string')
139
+ named.add(entry.agent);
140
+ for (const agent of Array.isArray(parsed.gone) ? parsed.gone : [])
141
+ if (typeof agent === 'string')
142
+ gone.add(agent);
143
+ }
144
+ return { named, gone };
145
+ }
146
+ // Durable removal tombstones, except ids an index now names again. Shared by the live and sealed-store sweeps.
147
+ export function removedAccountAgents(root) {
148
+ const { named, gone } = indexedAgents(root);
149
+ return new Set([...gone].filter((agent) => ACCOUNT_AGENT.test(agent) && !named.has(agent)));
150
+ }
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;
156
+ try {
157
+ dirs = readdirSync(join(root, 'state', 'agents'));
158
+ }
159
+ catch (error) {
160
+ if (error.code === 'ENOENT')
161
+ return;
162
+ throw error;
163
+ }
164
+ const gone = removedAccountAgents(root);
165
+ for (const dir of dirs) {
166
+ if (!gone.has(dir))
167
+ continue;
168
+ rmSync(join(root, 'state', 'agents', dir), { recursive: true, force: true });
169
+ }
170
+ }
28
171
  export function createAccounts(ctx) {
29
- const path = (member) => join(ctx.root, 'accounts', `${member}.json`);
172
+ const path = (member) => indexPath(ctx.root, member);
173
+ const read = (member) => readIndex(ctx.root, member);
30
174
  // Accounts whose sign-in is running, as `<member>\n<id>`, counted per add: listed `signing` until the last one ends.
31
175
  const signing = new Map();
32
176
  // The index file's own lock, apart from any agent's: a run's shared hold on the member agent never blocks an edit.
@@ -35,33 +179,13 @@ export function createAccounts(ctx) {
35
179
  if (!MEMBER_ID.test(member) || member.startsWith(KEY_PREFIX))
36
180
  throw new Error(`invalid member id: ${member}`);
37
181
  };
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 };
182
+ const dropEntry = (file, id) => {
183
+ delete file.accounts[id];
184
+ if (file.defaults.account === id)
185
+ delete file.defaults.account;
64
186
  };
187
+ const markGone = (file, agent) => { if (!file.gone?.includes(agent))
188
+ file.gone = [...(file.gone ?? []), agent]; };
65
189
  const write = (member, file) => {
66
190
  mkdirSync(join(ctx.root, 'accounts'), { recursive: true, mode: 0o700 });
67
191
  const tmp = `${path(member)}.${randomUUID()}.tmp`;
@@ -84,8 +208,8 @@ export function createAccounts(ctx) {
84
208
  const synthesizedRoute = (provider) => routeFor(provider, 'browser')?.choice
85
209
  ?? routes().find((r) => r.provider === provider && r.billing === 'subscription')?.choice
86
210
  ?? provider;
87
- const rosterOf = () => ctx.request('agents.list')
88
- .then((r) => new Set(r.agents.map((a) => a.id)), () => new Set());
211
+ const agentRoster = () => ctx.request('agents.list').then((r) => new Set(r.agents.map((a) => a.id)));
212
+ const rosterOf = () => agentRoster().catch(() => new Set());
89
213
  const profilesOf = (agentId) => ctx.request('byokit.accounts', { action: 'list', agentId })
90
214
  .then((r) => Array.isArray(r.profiles) ? r.profiles : [], () => []);
91
215
  const stateOf = (route, status, until) => {
@@ -118,7 +242,8 @@ export function createAccounts(ctx) {
118
242
  file.accounts[provider] = entry;
119
243
  dirty = true;
120
244
  }
121
- if (!entry.who && typeof profiles[0]?.who === 'string') {
245
+ // Not while the member agent's own sign-in runs: settle decides whose that `who` is (it may be adopted away).
246
+ if (typeof profiles[0]?.who === 'string' && entry.who !== profiles[0].who && !signing.has(`${member}\n${provider}`)) {
122
247
  entry.who = profiles[0].who;
123
248
  dirty = true;
124
249
  }
@@ -147,6 +272,11 @@ export function createAccounts(ctx) {
147
272
  const provider = id.split('.')[0];
148
273
  const meta = metaOf(entry.route);
149
274
  const profiles = await profilesOf(entry.agent);
275
+ const who = profiles.find((p) => p.provider === provider)?.who;
276
+ if (!entry.who && typeof who === 'string' && !signing.has(`${member}\n${id}`)) {
277
+ entry.who = who;
278
+ dirty = true;
279
+ }
150
280
  agentRows.push({ id, provider, route: entry.route, name: entry.name, label: meta.label,
151
281
  billing: meta.billing, state: stateOf(entry.route, profiles[0]?.status, entry.until),
152
282
  ...(entry.until !== undefined ? { until: entry.until } : {}), addedAt: entry.addedAt });
@@ -178,39 +308,6 @@ export function createAccounts(ctx) {
178
308
  throw new Error(`unknown account: ${id}`);
179
309
  return { row, entry };
180
310
  };
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
311
  const spanOf = (label) => {
215
312
  const lower = label.toLowerCase();
216
313
  if (lower === 'session')
@@ -343,25 +440,229 @@ export function createAccounts(ctx) {
343
440
  }
344
441
  return roomOf(row.provider, answer);
345
442
  });
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.
443
+ // 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
445
+ // gives the accounts a binding holds, in list order, and the name a refusal shows (`account.another` when it holds none).
446
+ const boundOf = (file, binding) => binding.id !== undefined ? [binding.id]
447
+ : Object.entries(file.accounts).filter(([, e]) => e.agent === binding.agentId)
448
+ .sort(([a, x], [b, y]) => x.addedAt - y.addedAt || (a < b ? -1 : 1)).map(([id]) => id);
449
+ const nameOf = (file, ids) => {
450
+ const first = ids[0] === undefined ? undefined : file.accounts[ids[0]];
451
+ return first?.name ?? words('account.another');
452
+ };
453
+ // A pick's refusal: `bound` names an id outside the binding's accounts, `paid` an id other than an API-billed
454
+ // binding's own. A named id the index does not hold is left to the pick, which reports it as unknown.
455
+ const refusalOf = (file, binding, sel) => {
456
+ const named = sel.account === 'auto' || sel.account === 'default' ? undefined : sel.account;
457
+ if (named !== undefined && file.accounts[named] === undefined)
458
+ return undefined;
459
+ const ids = boundOf(file, binding);
460
+ const held = binding.id === undefined ? undefined : file.accounts[binding.id];
461
+ if (held && metaOf(held.route).billing === 'api' && named !== binding.id)
462
+ return { code: 'paid', name: held.name };
463
+ if (named !== undefined && !ids.includes(named))
464
+ return { code: 'bound', name: nameOf(file, ids) };
465
+ return undefined;
466
+ };
467
+ // An API-key run (Runs step 2): its key continues only an API-billed binding; any other binding is refused.
468
+ 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')
471
+ return undefined;
472
+ await list(member);
473
+ const file = read(member);
474
+ return words('account.bound', { name: nameOf(file, boundOf(file, binding)) });
475
+ };
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.
480
+ async function bind(member, sessionKey) {
481
+ check(member);
482
+ const agents = new Map(Object.entries(read(member).accounts)
483
+ .filter(([, e]) => ACCOUNT_AGENT.test(e.agent) && e.agent.startsWith(`${member}--`)).map(([id, e]) => [e.agent, id]));
484
+ if (!sessionKey.startsWith(`agent:${member}:`)) {
485
+ const agent = /^agent:([^:]+):/.exec(sessionKey)?.[1];
486
+ const id = agent === undefined ? undefined : agents.get(agent);
487
+ if (id === undefined)
488
+ throw new Error(`refused: "${sessionKey}" is not a session of member "${member}"`);
489
+ return { agentId: agent, id };
490
+ }
491
+ if (!agents.size)
492
+ return undefined;
493
+ const tail = sessionKey.slice(`agent:${member}:`.length);
494
+ const { agentId } = await ctx.ensure(member);
495
+ const roster = new Set((await ctx.request('agents.list')).agents.map((a) => a.id));
496
+ for (const [agent, id] of [[agentId, undefined], ...agents]) {
497
+ if (agent !== agentId && !roster.has(agent))
498
+ continue;
499
+ // The pin answers a found session with its canonical `key` and a missing one with `{ ok: false }`.
500
+ const found = await ctx.request('sessions.resolve', { key: `agent:${agent}:${tail}`, agentId: agent, allowMissing: true });
501
+ if (found.key !== undefined)
502
+ return id === undefined ? { agentId: agent } : { agentId: agent, id };
503
+ }
504
+ return undefined;
505
+ }
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.
510
+ // An unknown id stays the caller bug it always was. `agent` is the picked account's engine agent. A model unavailable
511
+ // only because the account is signed out or resting is still in its plan: the account's state and the run's ready
512
+ // check judge that.
349
513
  // The pinned engine may hold no prepared model catalog for an agent added since it started, and refuses its models; that
350
514
  // 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));
515
+ async function pick(member, sel, skip = [], binding) {
516
+ check(member);
517
+ const refused = (refusal) => ({ pick: { ok: false, code: refusal.code,
518
+ reason: words(`account.${refusal.code}`, { name: refusal.name }), considered: [] } });
519
+ const early = binding?.id !== undefined ? refusalOf(read(member), binding, sel) : undefined;
520
+ if (early)
521
+ return refused(early);
522
+ const listed = await list(member);
353
523
  const file = read(member);
524
+ const late = binding && refusalOf(file, binding, sel);
525
+ if (late)
526
+ return refused(late);
527
+ const rows = listed.filter((r) => !skip.includes(r.id));
528
+ const named = sel.account === 'auto' || sel.account === 'default' ? undefined : sel.account;
529
+ const known = named === undefined || listed.some((r) => r.id === named);
530
+ if (binding?.id !== undefined && known)
531
+ sel = { ...sel, account: binding.id };
532
+ const bound = binding && known ? boundOf(file, binding) : undefined;
354
533
  const reads = new Map(await Promise.all(rows.map(async (row) => {
355
534
  const entry = file.accounts[row.id];
356
535
  const models = await readModels(row, entry).then((ms) => ms.map((m) => (m.why ? { ...m, available: true } : m)), () => undefined);
357
536
  return [row.id, { room: await readRoom(row, entry), models }];
358
537
  })));
359
- const chosen = resolveSelection(rows, file.defaults, sel, (a) => reads.get(a.id).room, Date.now(), (a) => reads.get(a.id).models);
538
+ const chosen = resolveSelection(rows, file.defaults, sel, (a) => reads.get(a.id).room, Date.now(), (a) => reads.get(a.id).models, bound);
360
539
  return { pick: chosen, ...(chosen.ok ? { agent: file.accounts[chosen.account.id].agent } : {}) };
361
540
  }
541
+ // Moving a conversation (5.15, O14 step S12): the key binds as a run's does (with no account agent binding asks
542
+ // nothing, and the member agent holds it if anything does), and its `sessions.list` row decides unsupported and busy
543
+ // before any lock. Same agent: nothing moves, the next run names the account. Otherwise, under both agents' locks and
544
+ // on the row read again there, the source's whole transcript forks into `agent:<target>:<tail>` (never `chat.history`
545
+ // plus `chat.inject`, which drop tool rows), then the source is deleted only if it is still the session read. A
546
+ // refused fork leaves the source untouched; a refused delete leaves both, the new key live.
547
+ async function move(member, sessionKey, to) {
548
+ check(member);
549
+ const failed = (code, message, live) => ({ ok: false, code, message, ...(live === undefined ? {} : { live }) });
550
+ const { agentId: memberAgent } = await ctx.ensure(member);
551
+ const { agentId: source } = await bind(member, sessionKey) ?? { agentId: memberAgent };
552
+ const { row, entry } = await entryOf(member, to);
553
+ const tail = sessionKey.slice(sessionKey.indexOf(':', 'agent:'.length) + 1);
554
+ const from = `agent:${source}:${tail}`;
555
+ const into = `agent:${entry.agent}:${tail}`;
556
+ const read = async () => {
557
+ const listed = await ctx.request('sessions.list', { agentId: source, search: from });
558
+ const found = (Array.isArray(listed.sessions) ? listed.sessions : [])
559
+ .find((r) => record(r) && typeof r.key === 'string' && r.key.toLowerCase() === from.toLowerCase());
560
+ if (!found)
561
+ return { refused: failed('unsupported', words('move.unsupported')) };
562
+ const live = found.hasActiveRun === true || [...ctx.routes].some(([key, route]) => key === sessionKey || route.session === from);
563
+ return live ? { refused: failed('busy', words('move.busy')) } : { found };
564
+ };
565
+ const first = await read();
566
+ if ('refused' in first)
567
+ return first.refused;
568
+ if (row.state !== 'ready')
569
+ return failed('start_failed', words('move.notReady', { name: row.name }));
570
+ if (entry.agent === source)
571
+ return { ok: true, session: into };
572
+ return ctx.locks.exclusive([source, entry.agent], async () => {
573
+ const again = await read();
574
+ if ('refused' in again)
575
+ return again.refused;
576
+ const session = again.found;
577
+ // A conversation already under the target key is never overwritten: the move is refused, the source untouched.
578
+ const taken = await ctx.request('sessions.resolve', { key: into, agentId: entry.agent, allowMissing: true });
579
+ if (taken.key !== undefined)
580
+ return failed('start_failed', words('move.taken', { name: row.name }));
581
+ // The session's model when the target lists it, else the target's first available; the read takes no shared
582
+ // hold (this move holds the target exclusively). With no answer the fork keeps the parent's own selection.
583
+ const models = await ctx.request('models.list', { agentId: entry.agent, view: 'default' })
584
+ .then((answer) => modelsOf(row.provider, answer), () => []);
585
+ const current = typeof session.model !== 'string' ? undefined
586
+ : (typeof session.modelProvider === 'string' && !session.model.includes('/') ? `${session.modelProvider}/${session.model}` : session.model).toLowerCase();
587
+ const model = models.find((m) => m.id.toLowerCase() === current)?.id ?? models.find((m) => m.available)?.id;
588
+ try {
589
+ await ctx.request('sessions.create', { agentId: entry.agent, key: into, parentSessionKey: from, fork: true, ...(model ? { model } : {}) });
590
+ }
591
+ catch (error) {
592
+ return failed('start_failed', (error instanceof Error ? error.message : String(error)).slice(0, 200));
593
+ }
594
+ const closed = await ctx.request('sessions.delete', { key: from, agentId: source, deleteTranscript: true,
595
+ ...(typeof session.sessionId === 'string' ? { expectedSessionId: session.sessionId } : {}),
596
+ ...(typeof session.updatedAt === 'number' ? { expectedSessionUpdatedAt: session.updatedAt } : {}) })
597
+ .then((answer) => record(answer) && answer.deleted === true, (error) => {
598
+ ctx.log?.(`move: ${from} is kept: ${error instanceof Error ? error.message : String(error)}`);
599
+ return false;
600
+ });
601
+ if (!closed)
602
+ return failed('close_failed', words('move.closeFailed', { name: row.name }), into);
603
+ return { ok: true, session: into };
604
+ });
605
+ }
362
606
  // A run on the account ended resting: Auto skips it until `until` (its state reads `resting` until then).
363
607
  const rest = (member, id, until) => edit(member, (file) => { const entry = file.accounts[id]; if (entry)
364
608
  entry.until = until; });
609
+ 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).
614
+ const providerProfileIds = async (agentId, provider) => {
615
+ const listed = await ctx.request('byokit.accounts', { action: 'list', agentId });
616
+ if (listed.ok !== true || !Array.isArray(listed.profiles))
617
+ throw new Error('removeAccount: the sign-ins could not be read');
618
+ return listed.profiles.filter((p) => p.provider === provider && typeof p.profileId === 'string' && p.profileId).map((p) => p.profileId);
619
+ };
620
+ async function remove(member, id) {
621
+ check(member);
622
+ const { agentId: memberAgent } = await ctx.ensure(member);
623
+ const entry = read(member).accounts[id];
624
+ if (!entry || (entry.agent !== memberAgent && !(ACCOUNT_AGENT.test(entry.agent) && entry.agent.startsWith(`${member}--`))))
625
+ throw new Error(`unknown account: ${id}`);
626
+ const agent = entry.agent;
627
+ const provider = id.split('.')[0];
628
+ if (ctx.locks.live(agent) || signing.has(`${member}\n${id}`))
629
+ throw new Error('removeAccount: busy');
630
+ if (agent === memberAgent) {
631
+ await ctx.locks.exclusive([agent, indexLock(member)], async () => {
632
+ const profileIds = await providerProfileIds(agent, provider);
633
+ if (profileIds.length)
634
+ await ctx.request('models.authLogout', { provider, agentId: agent, profileIds });
635
+ if ((await providerProfileIds(agent, provider)).length)
636
+ throw new Error('removeAccount: the account is still signing in');
637
+ const file = read(member);
638
+ dropEntry(file, id);
639
+ write(member, file);
640
+ // The live store changed: re-seal at once so the sealed copy stops holding the removed profile.
641
+ await ctx.reseal?.();
642
+ });
643
+ return;
644
+ }
645
+ await ctx.locks.exclusive([agent, indexLock(member)], async () => {
646
+ const roster = await agentRoster();
647
+ if (roster.has(agent)) {
648
+ await ctx.request('byokit.accounts', { action: 'clear', agentId: agent });
649
+ const listed = await ctx.request('byokit.accounts', { action: 'list', agentId: agent });
650
+ if (listed.ok !== true || !Array.isArray(listed.profiles))
651
+ throw new Error('removeAccount: the sign-ins could not be read');
652
+ if (listed.profiles.length)
653
+ throw new Error('removeAccount: the account is still signing in');
654
+ await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
655
+ }
656
+ rmSync(agentDir(agent), { recursive: true, force: true });
657
+ // Already holding the index lock: write directly instead of `edit`, which would wait on itself.
658
+ const file = read(member);
659
+ dropEntry(file, id);
660
+ markGone(file, agent);
661
+ write(member, file);
662
+ // The live store changed: re-seal at once so the sealed copy stops holding the removed profile.
663
+ await ctx.reseal?.();
664
+ });
665
+ }
365
666
  // Index edits take the index lock, as listing's own write does.
366
667
  const edit = (member, fn) => ctx.locks.exclusive([indexLock(member)], async () => { const file = read(member); fn(file); write(member, file); });
367
668
  const identityOf = async (agentId) => {
@@ -372,6 +673,28 @@ export function createAccounts(ctx) {
372
673
  if (before !== undefined && await identityOf(memberAgent) !== before)
373
674
  await ctx.request('agents.files.set', { agentId: memberAgent, name: IDENTITY, content: before });
374
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
+ };
375
698
  const onGateway = (agent) => untilListed(ctx.request, agent)
376
699
  .then(() => true, () => ctx.request('agents.list')
377
700
  .then((r) => r.agents.some((a) => a.id === agent)));
@@ -382,11 +705,18 @@ export function createAccounts(ctx) {
382
705
  await ctx.request('byokit.accounts', { action: 'clear', agentId: agent });
383
706
  await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
384
707
  }
385
- rmSync(join(ctx.root, 'state', 'agents', agent), { recursive: true, force: true });
708
+ rmSync(agentDir(agent), { recursive: true, force: true });
386
709
  await edit(member, (file) => { delete file.accounts[id]; });
387
710
  })().catch((error) => ctx.log?.(`addAccount: ${agent} is not removed yet: ${error instanceof Error ? error.message : String(error)}`));
711
+ // Member-agent adds of one provider take turns from the sign-in's start to its settle, so a cleanup clears only the
712
+ // profile its own add activated, and the next add's read sees what the earlier one left.
713
+ const holdAdd = (member, provider) => new Promise((held) => {
714
+ void ctx.locks.exclusive([`add:${member}:${provider}`], () => new Promise((release) => held(release)));
715
+ });
388
716
  async function add(member, o, on) {
389
717
  check(member);
718
+ if (o && typeof o === 'object' && 'key' in o)
719
+ throw new Error('addAccount: keys are host-only');
390
720
  if (!NEW_MEMBER_ID.test(member))
391
721
  throw new Error(words('account.legacyMember'));
392
722
  const choice = o?.authChoice === 'openai-codex' ? 'openai' : o?.authChoice;
@@ -398,102 +728,225 @@ export function createAccounts(ctx) {
398
728
  const { agentId: memberAgent, workspace } = await ctx.ensure(member);
399
729
  if (o.again !== undefined && (!read(member).accounts[o.again] || o.again.split('.')[0] !== provider))
400
730
  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);
731
+ let releaseAdd = () => { };
732
+ try {
733
+ // The member agent is the target only while it holds no local profile of the provider (5.15).
734
+ const holdsProvider = async () => {
735
+ const own = await ctx.request('byokit.accounts', { action: 'list', agentId: memberAgent });
736
+ if (!own.ok || !Array.isArray(own.profiles))
737
+ throw new Error('addAccount: the member\'s sign-ins could not be read');
738
+ return own.profiles.some((p) => p.provider === provider);
739
+ };
740
+ let fresh = o.again !== undefined || await holdsProvider();
741
+ // Only the member-agent path clears a profile it did not create, so only it takes the provider's add lock, held to
742
+ // settle. The read repeats under the lock: the add that held it may have sealed its sign-in on the member agent.
743
+ if (!fresh) {
744
+ releaseAdd = await holdAdd(member, provider);
745
+ fresh = await holdsProvider();
746
+ if (fresh)
747
+ releaseAdd();
748
+ }
749
+ // The member agent's primary model before this sign-in or any merge on it: restored when its profile moves away.
750
+ 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}` });
753
+ let id = provider;
754
+ let agent = memberAgent;
755
+ let added = false;
756
+ if (!fresh) {
757
+ await edit(member, (file) => { if (!file.accounts[id]) {
758
+ file.accounts[id] = entryOf(file, agent);
759
+ added = true;
760
+ } });
761
+ }
762
+ else {
763
+ // `<member>--<hex6>`, drawn again while that agent exists; its entry is written before the agent.
764
+ const roster = new Set((await ctx.request('agents.list')).agents.map((a) => a.id));
765
+ await edit(member, (file) => {
766
+ const taken = new Set([...roster, ...Object.values(file.accounts).map((e) => e.agent)]);
767
+ do {
768
+ const hex = randomBytes(3).toString('hex');
769
+ id = `${provider}.${hex}`;
770
+ agent = `${member}--${hex}`;
771
+ } while (taken.has(agent) || file.accounts[id]);
772
+ file.accounts[id] = entryOf(file, agent);
773
+ });
414
774
  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);
775
+ let created = false;
776
+ // The pin rewrites the shared workspace's IDENTITY.md Name line on create, so the create window is one lock per member.
777
+ await ctx.locks.exclusive([`${member}#identity`], async () => {
778
+ let before;
779
+ try {
780
+ before = await identityOf(memberAgent);
781
+ const made = await ctx.request('agents.create', { name: agent, workspace });
782
+ created = true;
783
+ if (made.agentId !== agent)
784
+ throw new Error(`addAccount: agents.create answered ${String(made.agentId)}, not ${agent}`);
785
+ await untilListed(ctx.request, agent);
786
+ await restoreIdentity(memberAgent, before);
787
+ }
788
+ catch (error) {
789
+ await restoreIdentity(memberAgent, before)
790
+ .catch((restoreError) => ctx.log?.(`addAccount: IDENTITY.md is not restored: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`));
791
+ const exists = created || await onGateway(agent).catch((listError) => {
792
+ ctx.log?.(`addAccount: ${agent} is not removed yet: ${listError instanceof Error ? listError.message : String(listError)}`);
793
+ return undefined;
794
+ });
795
+ if (exists !== undefined)
796
+ await discard(member, id, agent, exists);
797
+ throw error;
798
+ }
799
+ });
800
+ }
801
+ const key = `${member}\n${id}`;
802
+ signing.set(key, (signing.get(key) ?? 0) + 1);
803
+ const handle = ctx.signIn(agent, { authChoice: route.choice, ...(o.via ? { via: o.via } : {}) }, (v) => { if (v.state === 'waiting')
804
+ on(v); }, fresh);
805
+ // Moves the profile `who` names from `from` into `to`, under both agents' locks; refused, nothing changes.
806
+ const profilesOf = (agent) => ctx.request('byokit.accounts', { action: 'list', agentId: agent })
807
+ .then((r) => (r.ok === true && Array.isArray(r.profiles) ? r.profiles : undefined), () => undefined);
808
+ const profileIdOf = (profiles, who) => profiles?.find((p) => p.provider === provider && p.who === who)?.profileId;
809
+ const moveTo = (from, to, who) => ctx.locks.exclusive([memberAgent, from, to], async () => {
810
+ const sent = profileIdOf(await profilesOf(from), who);
811
+ const answer = await ctx.request('byokit.accounts', { action: 'adopt', from, to, provider, who })
812
+ .catch(async (error) => {
813
+ ctx.log?.(`addAccount: adopt has no answer: ${error instanceof Error ? error.message : String(error)}`);
814
+ // A lost reply can follow a committed move: the destination holding the sign-in's own profile means it moved.
815
+ const landed = sent !== undefined && profileIdOf(await profilesOf(to), who) === sent;
816
+ return { ok: landed };
817
+ });
818
+ if (!answer.ok)
819
+ return false;
820
+ if (from === memberAgent)
821
+ await restoreModel(memberAgent, memberModel);
822
+ return true;
823
+ }).catch(() => false);
824
+ // `adopt` moves the sign-in into X's agent and clears it from the target; refused, the sign-in stays a new
825
+ // account and keeps its `who`.
826
+ const adopt = (who, to) => moveTo(agent, to, who).then(async (ok) => {
827
+ if (ok)
828
+ return true;
829
+ await edit(member, (file) => { const own = file.accounts[id]; if (own)
830
+ own.who = who; });
831
+ return false;
832
+ }).catch(() => false);
833
+ const removeEntry = () => agent !== memberAgent ? discard(member, id, agent, true)
834
+ : edit(member, (file) => { delete file.accounts[id]; })
835
+ .catch((error) => ctx.log?.(`addAccount: ${id} entry is kept: ${error instanceof Error ? error.message : String(error)}`));
836
+ const drop = () => agent !== memberAgent || added ? removeEntry() : Promise.resolve();
837
+ // A live account holding the same `who` merges into the earliest one before the sign-in does. When the earliest is
838
+ // the sign-in's own row, the duplicate is discarded so the sign-in's credential survives; otherwise its profile is
839
+ // moved into the earliest one, then an account agent is removed and the member agent's entry dropped. A refused
840
+ // move keeps that duplicate, logged.
841
+ const collapse = async (dup, keep, who) => {
842
+ if (keep[0] === id) {
843
+ if (dup[1].agent !== memberAgent) {
844
+ await discard(member, dup[0], dup[1].agent, true);
845
+ return;
846
+ }
847
+ ctx.log?.(`addAccount: ${dup[0]} is kept: the member agent's profile is not cleared alone`);
848
+ return;
442
849
  }
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;
850
+ if (!await moveTo(dup[1].agent, keep[1].agent, who)) {
851
+ ctx.log?.(`addAccount: ${dup[0]} is kept: its sign-in is not moved`);
852
+ return;
453
853
  }
854
+ if (dup[1].agent !== memberAgent) {
855
+ await discard(member, dup[0], dup[1].agent, true);
856
+ return;
857
+ }
858
+ await edit(member, (file) => { delete file.accounts[dup[0]]; })
859
+ .catch((error) => ctx.log?.(`addAccount: ${dup[0]} entry is kept: ${error instanceof Error ? error.message : String(error)}`));
860
+ };
861
+ // A row holds the identity only while its agent holds it: the member's row while the member holds that `who`, an
862
+ // account while its agent is on the roster (a removal that stopped partway leaves an entry whose agent is gone).
863
+ const liveEntry = (roster, memberHolds, e) => e.agent === memberAgent ? memberHolds
864
+ : ACCOUNT_AGENT.test(e.agent) && e.agent.startsWith(`${member}--`) && roster.has(e.agent);
865
+ // Sealed before the person sees done: the agent keeps only this sign-in. Done only when sealed; anything else leaves
866
+ // nothing behind. Identity is its `who`, compared only
867
+ // within this member and provider: the live accounts holding it (the target's own included; several only from
868
+ // before O14) merge into the earliest `addedAt`, whatever `again` names; no `who` never merges. The email never decides.
869
+ const settle = async (view) => {
870
+ if (view.state === 'done') {
871
+ const sealed = await ctx.locks.exclusive([memberAgent, agent, indexLock(member)], async () => {
872
+ const answer = await ctx.request('byokit.accounts', { action: 'seal', agentId: agent, provider,
873
+ ...(route.keyEntry ? { planKey: true } : {}) });
874
+ if (!answer.ok)
875
+ return 'Sign-in could not be saved. Try again.';
876
+ const roster = await ctx.request('agents.list')
877
+ .then((r) => new Set(r.agents.map((a) => a.id)), () => undefined);
878
+ const held = await ctx.request('byokit.accounts', { action: 'list', agentId: memberAgent })
879
+ .catch(() => undefined);
880
+ const mergeable = roster !== undefined && held?.ok === true && Array.isArray(held.profiles);
881
+ if (!mergeable)
882
+ ctx.log?.(`addAccount: the roster or ${memberAgent} profiles are unreadable; the sign-in is kept as its own row`);
883
+ const memberHolds = (held?.profiles ?? []).some((p) => p.provider === provider && p.who === answer.who);
884
+ const file = read(member);
885
+ if (roster && mergeable && (await build(member, memberAgent, file, roster, held?.profiles ?? [])).dirty)
886
+ write(member, file);
887
+ const own = file.accounts[id];
888
+ const sharing = answer.who && roster && mergeable ? Object.entries(file.accounts)
889
+ .filter(([other, e]) => other.split('.')[0] === provider && e.who === answer.who && liveEntry(roster, memberHolds, e))
890
+ .sort(([, a], [, b]) => a.addedAt - b.addedAt) : [];
891
+ const keep = sharing[0];
892
+ const dups = sharing.slice(1).filter(([other]) => other !== id);
893
+ if (!(keep && keep[0] !== id) && own) {
894
+ if (answer.who)
895
+ own.who = answer.who;
896
+ else
897
+ delete own.who;
898
+ write(member, file);
899
+ }
900
+ return { ...(answer.who ? { who: answer.who } : {}), ...(keep ? { keep } : {}), dups };
901
+ }).catch((error) => route.keyEntry ? 'Sign-in could not be saved. Try again.'
902
+ : (error instanceof Error ? error.message : String(error)).slice(0, 200));
903
+ if (typeof sealed === 'string') {
904
+ let error = sealed;
905
+ // A seal failure on the member agent (the non-fresh path) leaves its new credential with no index entry,
906
+ // so listing hides it but runs could use it. Clear only this provider: the bare clear wipes every profile.
907
+ if (agent === memberAgent) {
908
+ const cleared = await ctx.request('byokit.accounts', { action: 'clear', agentId: memberAgent, provider })
909
+ .then((answer) => answer?.ok === true, () => false);
910
+ if (!cleared) {
911
+ ctx.log?.(`addAccount: ${memberAgent} ${provider} is not cleared`);
912
+ error = `${sealed} Its credential is still on the member agent: remove this account.`;
913
+ }
914
+ }
915
+ await drop();
916
+ return { view: { state: 'failed', via: view.via, why: 'failed', error }, id };
917
+ }
918
+ for (const dup of sealed.dups)
919
+ await collapse(dup, sealed.keep, sealed.who);
920
+ const into = sealed.keep?.[0] !== id ? sealed.keep : undefined;
921
+ if (!into || !await adopt(sealed.who, into[1].agent))
922
+ return { view, id };
923
+ await removeEntry();
924
+ return { view, id: into[0] };
925
+ }
926
+ // A member agent held no profile of this provider before a non-fresh add, so clearing the provider removes only this one.
927
+ if (agent === memberAgent)
928
+ await ctx.request('byokit.accounts', { action: 'clear', agentId: memberAgent, provider })
929
+ .catch((error) => ctx.log?.(`addAccount: ${provider} is not cleared yet: ${error instanceof Error ? error.message : String(error)}`));
930
+ await drop();
931
+ return { view, id };
932
+ };
933
+ const done = handle.done.then(settle).finally(() => {
934
+ releaseAdd();
935
+ const left = signing.get(key) - 1;
936
+ if (left)
937
+ signing.set(key, left);
938
+ else
939
+ signing.delete(key);
940
+ }).then((result) => {
941
+ on(result.view);
942
+ return result;
454
943
  });
944
+ return { id, paste: handle.paste, cancel: handle.cancel, done };
945
+ }
946
+ catch (error) {
947
+ releaseAdd();
948
+ throw error;
455
949
  }
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
950
  }
498
- return { list, add, rename, defaults, setDefaults, models, room, pick, rest };
951
+ return { list, add, rename, defaults, setDefaults, models, room, bind, keyRefusal, pick, rest, move, remove };
499
952
  }