@byokit/openclaw 0.11.1 → 0.12.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,49 @@
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
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';
4
+ // adding with its identity merge (O14 steps S6, S7), the start-of-run pick (S10), session binding (S11) and moving a
5
+ // conversation (S12) live here; remove arrives in a later step.
6
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
7
  import { randomBytes, randomUUID } from 'node:crypto';
7
8
  import { join } from 'node:path';
8
9
  import { providerOf, resolveSelection } from "./pick.js";
10
+ import { agentOf, memberOf as baseMemberOf } from "./attribution.js";
9
11
  import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, untilListed } from "./members.js";
10
12
  import { routes, routeFor } from "./routes.js";
11
13
  import { words } from "./words.js";
14
+ const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
15
+ // `models.list { agentId, view: 'default' }` rows of the account's provider, mapped to `provider/model`. The pin
16
+ // reports no per-plan availability, so `why: 'plan'` only comes from a run ending `plan`, never from here.
17
+ export const modelsOf = (provider, answer) => {
18
+ const rows = record(answer) && Array.isArray(answer.models) ? answer.models : [];
19
+ const out = [];
20
+ for (const candidate of rows) {
21
+ if (!record(candidate) || typeof candidate.provider !== 'string'
22
+ || candidate.provider.toLowerCase() !== provider.toLowerCase())
23
+ continue;
24
+ if (typeof candidate.id !== 'string' || !candidate.id || typeof candidate.name !== 'string')
25
+ continue;
26
+ const id = candidate.id.includes('/') ? candidate.id : `${candidate.provider}/${candidate.id}`;
27
+ if (providerOf(id) !== provider.toLowerCase())
28
+ continue;
29
+ const available = candidate.available === undefined
30
+ ? candidate.unavailableReason === undefined : candidate.available === true;
31
+ const info = { id, name: candidate.name, available };
32
+ if ((candidate.tier === 'strong' || candidate.tier === 'fast'))
33
+ info.tier = candidate.tier;
34
+ if (!available) {
35
+ if (candidate.unavailableReason === 'missing-auth' || candidate.unavailableReason === 'auth-failed')
36
+ info.why = 'signed_out';
37
+ else if (candidate.unavailableReason === 'cooldown') {
38
+ info.why = 'resting';
39
+ if (typeof candidate.unavailableUntil === 'number' && Number.isFinite(candidate.unavailableUntil))
40
+ info.until = candidate.unavailableUntil;
41
+ }
42
+ }
43
+ out.push(info);
44
+ }
45
+ return out;
46
+ };
12
47
  const ACCOUNT_AGENT = /^[a-z][a-z0-9-]{0,31}--[0-9a-f]{6}$/;
13
48
  const usable = (status) => status === 'ok' || status === 'expiring' || status === 'static';
14
49
  const IDENTITY = 'IDENTITY.md';
@@ -25,8 +60,57 @@ const validEntry = (v) => !!v && typeof v === 'object'
25
60
  && (v.until === undefined || Number.isFinite(v.until))
26
61
  && (v.model === undefined || typeof v.model === 'string')
27
62
  && (v.who === undefined || typeof v.who === 'string');
63
+ const indexPath = (root, member) => join(root, 'accounts', `${member}.json`);
64
+ const readIndex = (root, member) => {
65
+ let text;
66
+ try {
67
+ text = readFileSync(indexPath(root, member), 'utf8');
68
+ }
69
+ catch (error) {
70
+ if (error.code === 'ENOENT')
71
+ return empty();
72
+ throw error;
73
+ }
74
+ const file = JSON.parse(text);
75
+ if (!file || typeof file !== 'object' || file.v !== 1)
76
+ return empty();
77
+ const accounts = {};
78
+ for (const [id, entry] of Object.entries(file.accounts ?? {}))
79
+ if (validEntry(entry))
80
+ accounts[id] = entry;
81
+ const raw = file.defaults && typeof file.defaults === 'object' ? file.defaults : {};
82
+ const defaults = {};
83
+ if (typeof raw.account === 'string')
84
+ defaults.account = raw.account;
85
+ if (typeof raw.model === 'string')
86
+ defaults.model = raw.model;
87
+ if (typeof raw.auto === 'boolean')
88
+ defaults.auto = raw.auto;
89
+ return { v: 1, accounts, defaults };
90
+ };
91
+ // Attribution (5.15, O14m): the member an agent id or `agent:<agent>:<tail>` session key belongs to. A member agent is its
92
+ // own, and so is an agent whose workspace the kit created for it (a legacy member id may end in `--<hex6>`); any other
93
+ // agent, an `<member>--<hex6>` account agent included, maps only while the index names it under that member. Account
94
+ // agents share their member's workspace, so an unindexed account-shaped agent maps nowhere and stays unreachable.
95
+ export function memberOfAccount(root, ref) {
96
+ const agent = agentOf(ref);
97
+ if (agent === undefined)
98
+ return undefined;
99
+ const member = baseMemberOf(agent);
100
+ if (member === undefined || agent === member)
101
+ return member;
102
+ if (MEMBER_ID.test(agent) && existsSync(join(root, 'workspaces', agent)))
103
+ return agent;
104
+ try {
105
+ return Object.values(readIndex(root, member).accounts).some((e) => e.agent === agent) ? member : undefined;
106
+ }
107
+ catch {
108
+ return undefined;
109
+ }
110
+ }
28
111
  export function createAccounts(ctx) {
29
- const path = (member) => join(ctx.root, 'accounts', `${member}.json`);
112
+ const path = (member) => indexPath(ctx.root, member);
113
+ const read = (member) => readIndex(ctx.root, member);
30
114
  // Accounts whose sign-in is running, as `<member>\n<id>`, counted per add: listed `signing` until the last one ends.
31
115
  const signing = new Map();
32
116
  // 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 +119,6 @@ export function createAccounts(ctx) {
35
119
  if (!MEMBER_ID.test(member) || member.startsWith(KEY_PREFIX))
36
120
  throw new Error(`invalid member id: ${member}`);
37
121
  };
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
122
  const write = (member, file) => {
66
123
  mkdirSync(join(ctx.root, 'accounts'), { recursive: true, mode: 0o700 });
67
124
  const tmp = `${path(member)}.${randomUUID()}.tmp`;
@@ -118,7 +175,8 @@ export function createAccounts(ctx) {
118
175
  file.accounts[provider] = entry;
119
176
  dirty = true;
120
177
  }
121
- if (!entry.who && typeof profiles[0]?.who === 'string') {
178
+ // Not while the member agent's own sign-in runs: settle decides whose that `who` is (it may be adopted away).
179
+ if (typeof profiles[0]?.who === 'string' && entry.who !== profiles[0].who && !signing.has(`${member}\n${provider}`)) {
122
180
  entry.who = profiles[0].who;
123
181
  dirty = true;
124
182
  }
@@ -147,6 +205,11 @@ export function createAccounts(ctx) {
147
205
  const provider = id.split('.')[0];
148
206
  const meta = metaOf(entry.route);
149
207
  const profiles = await profilesOf(entry.agent);
208
+ const who = profiles.find((p) => p.provider === provider)?.who;
209
+ if (!entry.who && typeof who === 'string' && !signing.has(`${member}\n${id}`)) {
210
+ entry.who = who;
211
+ dirty = true;
212
+ }
150
213
  agentRows.push({ id, provider, route: entry.route, name: entry.name, label: meta.label,
151
214
  billing: meta.billing, state: stateOf(entry.route, profiles[0]?.status, entry.until),
152
215
  ...(entry.until !== undefined ? { until: entry.until } : {}), addedAt: entry.addedAt });
@@ -178,39 +241,6 @@ export function createAccounts(ctx) {
178
241
  throw new Error(`unknown account: ${id}`);
179
242
  return { row, entry };
180
243
  };
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
244
  const spanOf = (label) => {
215
245
  const lower = label.toLowerCase();
216
246
  if (lower === 'session')
@@ -343,22 +373,169 @@ export function createAccounts(ctx) {
343
373
  }
344
374
  return roomOf(row.provider, answer);
345
375
  });
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.
376
+ // Runs step 2 reads the index alone: the index-only refusal makes no request, and a cold index adds one account
377
+ // listing (the pick's late check after `list`; `keyRefusal` lists when the held account is not API-billed). This
378
+ // gives the accounts a binding holds, in list order, and the name a refusal shows (`account.another` when it holds none).
379
+ const boundOf = (file, binding) => binding.id !== undefined ? [binding.id]
380
+ : Object.entries(file.accounts).filter(([, e]) => e.agent === binding.agentId)
381
+ .sort(([a, x], [b, y]) => x.addedAt - y.addedAt || (a < b ? -1 : 1)).map(([id]) => id);
382
+ const nameOf = (file, ids) => {
383
+ const first = ids[0] === undefined ? undefined : file.accounts[ids[0]];
384
+ return first?.name ?? words('account.another');
385
+ };
386
+ // A pick's refusal: `bound` names an id outside the binding's accounts, `paid` an id other than an API-billed
387
+ // binding's own. A named id the index does not hold is left to the pick, which reports it as unknown.
388
+ const refusalOf = (file, binding, sel) => {
389
+ const named = sel.account === 'auto' || sel.account === 'default' ? undefined : sel.account;
390
+ if (named !== undefined && file.accounts[named] === undefined)
391
+ return undefined;
392
+ const ids = boundOf(file, binding);
393
+ const held = binding.id === undefined ? undefined : file.accounts[binding.id];
394
+ if (held && metaOf(held.route).billing === 'api' && named !== binding.id)
395
+ return { code: 'paid', name: held.name };
396
+ if (named !== undefined && !ids.includes(named))
397
+ return { code: 'bound', name: nameOf(file, ids) };
398
+ return undefined;
399
+ };
400
+ // An API-key run (Runs step 2): its key continues only an API-billed binding; any other binding is refused.
401
+ const keyRefusal = async (member, binding) => {
402
+ const held = binding.id === undefined ? undefined : read(member).accounts[binding.id];
403
+ if (held && metaOf(held.route).billing === 'api')
404
+ return undefined;
405
+ await list(member);
406
+ const file = read(member);
407
+ return words('account.bound', { name: nameOf(file, boundOf(file, binding)) });
408
+ };
409
+ // Runs step 1 (5.15): a key `agent:<member>--<hex6>:<tail>` of an indexed account agent is bound to that account; a
410
+ // key `agent:<member>:<tail>` to whichever of the member's agents holds `agent:<that agent>:<tail>` (member agent
411
+ // first), else unbound. Any other key is not the member's. With no account agent, the member agent and an unbound
412
+ // key run alike, so nothing is asked.
413
+ async function bind(member, sessionKey) {
414
+ check(member);
415
+ const agents = new Map(Object.entries(read(member).accounts)
416
+ .filter(([, e]) => ACCOUNT_AGENT.test(e.agent) && e.agent.startsWith(`${member}--`)).map(([id, e]) => [e.agent, id]));
417
+ if (!sessionKey.startsWith(`agent:${member}:`)) {
418
+ const agent = /^agent:([^:]+):/.exec(sessionKey)?.[1];
419
+ const id = agent === undefined ? undefined : agents.get(agent);
420
+ if (id === undefined)
421
+ throw new Error(`refused: "${sessionKey}" is not a session of member "${member}"`);
422
+ return { agentId: agent, id };
423
+ }
424
+ if (!agents.size)
425
+ return undefined;
426
+ const tail = sessionKey.slice(`agent:${member}:`.length);
427
+ const { agentId } = await ctx.ensure(member);
428
+ const roster = new Set((await ctx.request('agents.list')).agents.map((a) => a.id));
429
+ for (const [agent, id] of [[agentId, undefined], ...agents]) {
430
+ if (agent !== agentId && !roster.has(agent))
431
+ continue;
432
+ // The pin answers a found session with its canonical `key` and a missing one with `{ ok: false }`.
433
+ const found = await ctx.request('sessions.resolve', { key: `agent:${agent}:${tail}`, agentId: agent, allowMissing: true });
434
+ if (found.key !== undefined)
435
+ return id === undefined ? { agentId: agent } : { agentId: agent, id };
436
+ }
437
+ return undefined;
438
+ }
439
+ // Choosing (5.15): every listed account's room and models are read live once, then the pure `resolveSelection`
440
+ // decides. A binding narrows it (Runs step 2): a key bound to an account agent resolves 'auto', 'default' and no id to
441
+ // that account; a key bound to the member agent chooses only among its own accounts. A refusal (`bound`, `paid`) is
442
+ // decided from the index, after the account listing when the index is cold, and so has no `considered` rows.
443
+ // An unknown id stays the caller bug it always was. `agent` is the picked account's engine agent. A model unavailable
444
+ // only because the account is signed out or resting is still in its plan: the account's state and the run's ready
445
+ // check judge that.
349
446
  // The pinned engine may hold no prepared model catalog for an agent added since it started, and refuses its models; that
350
447
  // 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));
448
+ async function pick(member, sel, skip = [], binding) {
449
+ check(member);
450
+ const refused = (refusal) => ({ pick: { ok: false, code: refusal.code,
451
+ reason: words(`account.${refusal.code}`, { name: refusal.name }), considered: [] } });
452
+ const early = binding?.id !== undefined ? refusalOf(read(member), binding, sel) : undefined;
453
+ if (early)
454
+ return refused(early);
455
+ const listed = await list(member);
353
456
  const file = read(member);
457
+ const late = binding && refusalOf(file, binding, sel);
458
+ if (late)
459
+ return refused(late);
460
+ const rows = listed.filter((r) => !skip.includes(r.id));
461
+ const named = sel.account === 'auto' || sel.account === 'default' ? undefined : sel.account;
462
+ const known = named === undefined || listed.some((r) => r.id === named);
463
+ if (binding?.id !== undefined && known)
464
+ sel = { ...sel, account: binding.id };
465
+ const bound = binding && known ? boundOf(file, binding) : undefined;
354
466
  const reads = new Map(await Promise.all(rows.map(async (row) => {
355
467
  const entry = file.accounts[row.id];
356
468
  const models = await readModels(row, entry).then((ms) => ms.map((m) => (m.why ? { ...m, available: true } : m)), () => undefined);
357
469
  return [row.id, { room: await readRoom(row, entry), models }];
358
470
  })));
359
- const chosen = resolveSelection(rows, file.defaults, sel, (a) => reads.get(a.id).room, Date.now(), (a) => reads.get(a.id).models);
471
+ const chosen = resolveSelection(rows, file.defaults, sel, (a) => reads.get(a.id).room, Date.now(), (a) => reads.get(a.id).models, bound);
360
472
  return { pick: chosen, ...(chosen.ok ? { agent: file.accounts[chosen.account.id].agent } : {}) };
361
473
  }
474
+ // Moving a conversation (5.15, O14 step S12): the key binds as a run's does (with no account agent binding asks
475
+ // nothing, and the member agent holds it if anything does), and its `sessions.list` row decides unsupported and busy
476
+ // before any lock. Same agent: nothing moves, the next run names the account. Otherwise, under both agents' locks and
477
+ // on the row read again there, the source's whole transcript forks into `agent:<target>:<tail>` (never `chat.history`
478
+ // plus `chat.inject`, which drop tool rows), then the source is deleted only if it is still the session read. A
479
+ // refused fork leaves the source untouched; a refused delete leaves both, the new key live.
480
+ async function move(member, sessionKey, to) {
481
+ check(member);
482
+ const failed = (code, message, live) => ({ ok: false, code, message, ...(live === undefined ? {} : { live }) });
483
+ const { agentId: memberAgent } = await ctx.ensure(member);
484
+ const { agentId: source } = await bind(member, sessionKey) ?? { agentId: memberAgent };
485
+ const { row, entry } = await entryOf(member, to);
486
+ const tail = sessionKey.slice(sessionKey.indexOf(':', 'agent:'.length) + 1);
487
+ const from = `agent:${source}:${tail}`;
488
+ const into = `agent:${entry.agent}:${tail}`;
489
+ const read = async () => {
490
+ const listed = await ctx.request('sessions.list', { agentId: source, search: from });
491
+ const found = (Array.isArray(listed.sessions) ? listed.sessions : [])
492
+ .find((r) => record(r) && typeof r.key === 'string' && r.key.toLowerCase() === from.toLowerCase());
493
+ if (!found)
494
+ return { refused: failed('unsupported', words('move.unsupported')) };
495
+ const live = found.hasActiveRun === true || [...ctx.routes].some(([key, route]) => key === sessionKey || route.session === from);
496
+ return live ? { refused: failed('busy', words('move.busy')) } : { found };
497
+ };
498
+ const first = await read();
499
+ if ('refused' in first)
500
+ return first.refused;
501
+ if (row.state !== 'ready')
502
+ return failed('start_failed', words('move.notReady', { name: row.name }));
503
+ if (entry.agent === source)
504
+ return { ok: true, session: into };
505
+ return ctx.locks.exclusive([source, entry.agent], async () => {
506
+ const again = await read();
507
+ if ('refused' in again)
508
+ return again.refused;
509
+ const session = again.found;
510
+ // A conversation already under the target key is never overwritten: the move is refused, the source untouched.
511
+ const taken = await ctx.request('sessions.resolve', { key: into, agentId: entry.agent, allowMissing: true });
512
+ if (taken.key !== undefined)
513
+ return failed('start_failed', words('move.taken', { name: row.name }));
514
+ // The session's model when the target lists it, else the target's first available; the read takes no shared
515
+ // hold (this move holds the target exclusively). With no answer the fork keeps the parent's own selection.
516
+ const models = await ctx.request('models.list', { agentId: entry.agent, view: 'default' })
517
+ .then((answer) => modelsOf(row.provider, answer), () => []);
518
+ const current = typeof session.model !== 'string' ? undefined
519
+ : (typeof session.modelProvider === 'string' && !session.model.includes('/') ? `${session.modelProvider}/${session.model}` : session.model).toLowerCase();
520
+ const model = models.find((m) => m.id.toLowerCase() === current)?.id ?? models.find((m) => m.available)?.id;
521
+ try {
522
+ await ctx.request('sessions.create', { agentId: entry.agent, key: into, parentSessionKey: from, fork: true, ...(model ? { model } : {}) });
523
+ }
524
+ catch (error) {
525
+ return failed('start_failed', (error instanceof Error ? error.message : String(error)).slice(0, 200));
526
+ }
527
+ const closed = await ctx.request('sessions.delete', { key: from, agentId: source, deleteTranscript: true,
528
+ ...(typeof session.sessionId === 'string' ? { expectedSessionId: session.sessionId } : {}),
529
+ ...(typeof session.updatedAt === 'number' ? { expectedSessionUpdatedAt: session.updatedAt } : {}) })
530
+ .then((answer) => record(answer) && answer.deleted === true, (error) => {
531
+ ctx.log?.(`move: ${from} is kept: ${error instanceof Error ? error.message : String(error)}`);
532
+ return false;
533
+ });
534
+ if (!closed)
535
+ return failed('close_failed', words('move.closeFailed', { name: row.name }), into);
536
+ return { ok: true, session: into };
537
+ });
538
+ }
362
539
  // A run on the account ended resting: Auto skips it until `until` (its state reads `resting` until then).
363
540
  const rest = (member, id, until) => edit(member, (file) => { const entry = file.accounts[id]; if (entry)
364
541
  entry.until = until; });
@@ -372,6 +549,28 @@ export function createAccounts(ctx) {
372
549
  if (before !== undefined && await identityOf(memberAgent) !== before)
373
550
  await ctx.request('agents.files.set', { agentId: memberAgent, name: IDENTITY, content: before });
374
551
  };
552
+ const readModel = async (agentId) => {
553
+ const got = await ctx.request('config.get');
554
+ return got.config?.agents?.entries?.[agentId]?.model;
555
+ };
556
+ // ponytail: a merge patch cannot delete a key, so a member with no model entry before its sign-in keeps the pinned one.
557
+ const restoreModel = async (memberAgent, model) => {
558
+ if (model === undefined) {
559
+ ctx.log?.(`addAccount: ${memberAgent} had no model entry to restore`);
560
+ return;
561
+ }
562
+ // A config write that lands between the read and the patch moves the hash: read and patch once more.
563
+ const patch = async () => {
564
+ const { hash } = await ctx.request('config.get');
565
+ await ctx.request('config.patch', { raw: JSON.stringify({ agents: { entries: { [memberAgent]: { model } } } }), baseHash: hash });
566
+ };
567
+ try {
568
+ await patch().catch(() => patch());
569
+ }
570
+ catch (error) {
571
+ ctx.log?.(`addAccount: ${memberAgent} model is not restored: ${error instanceof Error ? error.message : String(error)}`);
572
+ }
573
+ };
375
574
  const onGateway = (agent) => untilListed(ctx.request, agent)
376
575
  .then(() => true, () => ctx.request('agents.list')
377
576
  .then((r) => r.agents.some((a) => a.id === agent)));
@@ -385,8 +584,15 @@ export function createAccounts(ctx) {
385
584
  rmSync(join(ctx.root, 'state', 'agents', agent), { recursive: true, force: true });
386
585
  await edit(member, (file) => { delete file.accounts[id]; });
387
586
  })().catch((error) => ctx.log?.(`addAccount: ${agent} is not removed yet: ${error instanceof Error ? error.message : String(error)}`));
587
+ // Member-agent adds of one provider take turns from the sign-in's start to its settle, so a cleanup clears only the
588
+ // profile its own add activated, and the next add's read sees what the earlier one left.
589
+ const holdAdd = (member, provider) => new Promise((held) => {
590
+ void ctx.locks.exclusive([`add:${member}:${provider}`], () => new Promise((release) => held(release)));
591
+ });
388
592
  async function add(member, o, on) {
389
593
  check(member);
594
+ if (o && typeof o === 'object' && 'key' in o)
595
+ throw new Error('addAccount: keys are host-only');
390
596
  if (!NEW_MEMBER_ID.test(member))
391
597
  throw new Error(words('account.legacyMember'));
392
598
  const choice = o?.authChoice === 'openai-codex' ? 'openai' : o?.authChoice;
@@ -398,102 +604,214 @@ export function createAccounts(ctx) {
398
604
  const { agentId: memberAgent, workspace } = await ctx.ensure(member);
399
605
  if (o.again !== undefined && (!read(member).accounts[o.again] || o.again.split('.')[0] !== provider))
400
606
  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);
607
+ let releaseAdd = () => { };
608
+ try {
609
+ // The member agent is the target only while it holds no local profile of the provider (5.15).
610
+ const holdsProvider = async () => {
611
+ const own = await ctx.request('byokit.accounts', { action: 'list', agentId: memberAgent });
612
+ if (!own.ok || !Array.isArray(own.profiles))
613
+ throw new Error('addAccount: the member\'s sign-ins could not be read');
614
+ return own.profiles.some((p) => p.provider === provider);
615
+ };
616
+ let fresh = o.again !== undefined || await holdsProvider();
617
+ // Only the member-agent path clears a profile it did not create, so only it takes the provider's add lock, held to
618
+ // settle. The read repeats under the lock: the add that held it may have sealed its sign-in on the member agent.
619
+ if (!fresh) {
620
+ releaseAdd = await holdAdd(member, provider);
621
+ fresh = await holdsProvider();
622
+ if (fresh)
623
+ releaseAdd();
624
+ }
625
+ // The member agent's primary model before this sign-in or any merge on it: restored when its profile moves away.
626
+ const memberModel = await readModel(memberAgent);
627
+ const entryOf = (file, agent) => ({ agent, route: route.choice, addedAt: Date.now(),
628
+ name: name ?? `${route.label} ${Object.values(file.accounts).filter((e) => e.route === route.choice).length + 1}` });
629
+ let id = provider;
630
+ let agent = memberAgent;
631
+ let added = false;
632
+ if (!fresh) {
633
+ await edit(member, (file) => { if (!file.accounts[id]) {
634
+ file.accounts[id] = entryOf(file, agent);
635
+ added = true;
636
+ } });
637
+ }
638
+ else {
639
+ // `<member>--<hex6>`, drawn again while that agent exists; its entry is written before the agent.
640
+ const roster = new Set((await ctx.request('agents.list')).agents.map((a) => a.id));
641
+ await edit(member, (file) => {
642
+ const taken = new Set([...roster, ...Object.values(file.accounts).map((e) => e.agent)]);
643
+ do {
644
+ const hex = randomBytes(3).toString('hex');
645
+ id = `${provider}.${hex}`;
646
+ agent = `${member}--${hex}`;
647
+ } while (taken.has(agent) || file.accounts[id]);
648
+ file.accounts[id] = entryOf(file, agent);
649
+ });
414
650
  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);
651
+ let created = false;
652
+ // The pin rewrites the shared workspace's IDENTITY.md Name line on create, so the create window is one lock per member.
653
+ await ctx.locks.exclusive([`${member}#identity`], async () => {
654
+ let before;
655
+ try {
656
+ before = await identityOf(memberAgent);
657
+ const made = await ctx.request('agents.create', { name: agent, workspace });
658
+ created = true;
659
+ if (made.agentId !== agent)
660
+ throw new Error(`addAccount: agents.create answered ${String(made.agentId)}, not ${agent}`);
661
+ await untilListed(ctx.request, agent);
662
+ await restoreIdentity(memberAgent, before);
663
+ }
664
+ catch (error) {
665
+ await restoreIdentity(memberAgent, before)
666
+ .catch((restoreError) => ctx.log?.(`addAccount: IDENTITY.md is not restored: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`));
667
+ const exists = created || await onGateway(agent).catch((listError) => {
668
+ ctx.log?.(`addAccount: ${agent} is not removed yet: ${listError instanceof Error ? listError.message : String(listError)}`);
669
+ return undefined;
670
+ });
671
+ if (exists !== undefined)
672
+ await discard(member, id, agent, exists);
673
+ throw error;
674
+ }
675
+ });
676
+ }
677
+ const key = `${member}\n${id}`;
678
+ signing.set(key, (signing.get(key) ?? 0) + 1);
679
+ const handle = ctx.signIn(agent, { authChoice: route.choice, ...(o.via ? { via: o.via } : {}) }, (v) => { if (v.state === 'waiting')
680
+ on(v); }, fresh);
681
+ // Moves the profile `who` names from `from` into `to`, under both agents' locks; refused, nothing changes.
682
+ const profilesOf = (agent) => ctx.request('byokit.accounts', { action: 'list', agentId: agent })
683
+ .then((r) => (r.ok === true && Array.isArray(r.profiles) ? r.profiles : undefined), () => undefined);
684
+ const profileIdOf = (profiles, who) => profiles?.find((p) => p.provider === provider && p.who === who)?.profileId;
685
+ const moveTo = (from, to, who) => ctx.locks.exclusive([memberAgent, from, to], async () => {
686
+ const sent = profileIdOf(await profilesOf(from), who);
687
+ const answer = await ctx.request('byokit.accounts', { action: 'adopt', from, to, provider, who })
688
+ .catch(async (error) => {
689
+ ctx.log?.(`addAccount: adopt has no answer: ${error instanceof Error ? error.message : String(error)}`);
690
+ // A lost reply can follow a committed move: the destination holding the sign-in's own profile means it moved.
691
+ const landed = sent !== undefined && profileIdOf(await profilesOf(to), who) === sent;
692
+ return { ok: landed };
693
+ });
694
+ if (!answer.ok)
695
+ return false;
696
+ if (from === memberAgent)
697
+ await restoreModel(memberAgent, memberModel);
698
+ return true;
699
+ }).catch(() => false);
700
+ // `adopt` moves the sign-in into X's agent and clears it from the target; refused, the sign-in stays a new
701
+ // account and keeps its `who`.
702
+ const adopt = (who, to) => moveTo(agent, to, who).then(async (ok) => {
703
+ if (ok)
704
+ return true;
705
+ await edit(member, (file) => { const own = file.accounts[id]; if (own)
706
+ own.who = who; });
707
+ return false;
708
+ }).catch(() => false);
709
+ const removeEntry = () => agent !== memberAgent ? discard(member, id, agent, true)
710
+ : edit(member, (file) => { delete file.accounts[id]; })
711
+ .catch((error) => ctx.log?.(`addAccount: ${id} entry is kept: ${error instanceof Error ? error.message : String(error)}`));
712
+ const drop = () => agent !== memberAgent || added ? removeEntry() : Promise.resolve();
713
+ // A live account holding the same `who` merges into the earliest one before the sign-in does. When the earliest is
714
+ // the sign-in's own row, the duplicate is discarded so the sign-in's credential survives; otherwise its profile is
715
+ // moved into the earliest one, then an account agent is removed and the member agent's entry dropped. A refused
716
+ // move keeps that duplicate, logged.
717
+ const collapse = async (dup, keep, who) => {
718
+ if (keep[0] === id) {
719
+ if (dup[1].agent !== memberAgent) {
720
+ await discard(member, dup[0], dup[1].agent, true);
721
+ return;
722
+ }
723
+ ctx.log?.(`addAccount: ${dup[0]} is kept: the member agent's profile is not cleared alone`);
724
+ return;
725
+ }
726
+ if (!await moveTo(dup[1].agent, keep[1].agent, who)) {
727
+ ctx.log?.(`addAccount: ${dup[0]} is kept: its sign-in is not moved`);
728
+ return;
442
729
  }
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;
730
+ if (dup[1].agent !== memberAgent) {
731
+ await discard(member, dup[0], dup[1].agent, true);
732
+ return;
733
+ }
734
+ await edit(member, (file) => { delete file.accounts[dup[0]]; })
735
+ .catch((error) => ctx.log?.(`addAccount: ${dup[0]} entry is kept: ${error instanceof Error ? error.message : String(error)}`));
736
+ };
737
+ // A row holds the identity only while its agent holds it: the member's row while the member holds that `who`, an
738
+ // account while its agent is on the roster (a removal that stopped partway leaves an entry whose agent is gone).
739
+ const liveEntry = (roster, memberHolds, e) => e.agent === memberAgent ? memberHolds
740
+ : ACCOUNT_AGENT.test(e.agent) && e.agent.startsWith(`${member}--`) && roster.has(e.agent);
741
+ // Sealed before the person sees done: the agent keeps only this sign-in. Done only when sealed; anything else leaves
742
+ // nothing behind. Identity is its `who`, compared only
743
+ // within this member and provider: the live accounts holding it (the target's own included; several only from
744
+ // before O14) merge into the earliest `addedAt`, whatever `again` names; no `who` never merges. The email never decides.
745
+ const settle = async (view) => {
746
+ if (view.state === 'done') {
747
+ const sealed = await ctx.locks.exclusive([memberAgent, agent, indexLock(member)], async () => {
748
+ const answer = await ctx.request('byokit.accounts', { action: 'seal', agentId: agent, provider,
749
+ ...(route.keyEntry ? { planKey: true } : {}) });
750
+ if (!answer.ok)
751
+ return 'Sign-in could not be saved. Try again.';
752
+ const roster = await ctx.request('agents.list')
753
+ .then((r) => new Set(r.agents.map((a) => a.id)), () => undefined);
754
+ const held = await ctx.request('byokit.accounts', { action: 'list', agentId: memberAgent })
755
+ .catch(() => undefined);
756
+ const mergeable = roster !== undefined && held?.ok === true && Array.isArray(held.profiles);
757
+ if (!mergeable)
758
+ ctx.log?.(`addAccount: the roster or ${memberAgent} profiles are unreadable; the sign-in is kept as its own row`);
759
+ const memberHolds = (held?.profiles ?? []).some((p) => p.provider === provider && p.who === answer.who);
760
+ const file = read(member);
761
+ if (roster && mergeable && (await build(member, memberAgent, file, roster, held?.profiles ?? [])).dirty)
762
+ write(member, file);
763
+ const own = file.accounts[id];
764
+ const sharing = answer.who && roster && mergeable ? Object.entries(file.accounts)
765
+ .filter(([other, e]) => other.split('.')[0] === provider && e.who === answer.who && liveEntry(roster, memberHolds, e))
766
+ .sort(([, a], [, b]) => a.addedAt - b.addedAt) : [];
767
+ const keep = sharing[0];
768
+ const dups = sharing.slice(1).filter(([other]) => other !== id);
769
+ if (!(keep && keep[0] !== id) && own) {
770
+ if (answer.who)
771
+ own.who = answer.who;
772
+ else
773
+ delete own.who;
774
+ write(member, file);
775
+ }
776
+ return { ...(answer.who ? { who: answer.who } : {}), ...(keep ? { keep } : {}), dups };
777
+ }).catch((error) => route.keyEntry ? 'Sign-in could not be saved. Try again.'
778
+ : (error instanceof Error ? error.message : String(error)).slice(0, 200));
779
+ if (typeof sealed === 'string')
780
+ view = { state: 'failed', via: view.via, why: 'failed', error: sealed };
781
+ else {
782
+ for (const dup of sealed.dups)
783
+ await collapse(dup, sealed.keep, sealed.who);
784
+ const into = sealed.keep?.[0] !== id ? sealed.keep : undefined;
785
+ if (!into || !await adopt(sealed.who, into[1].agent))
786
+ return { view, id };
787
+ await removeEntry();
788
+ return { view, id: into[0] };
789
+ }
453
790
  }
791
+ // A member agent held no profile of this provider before a non-fresh add, so clearing the provider removes only this one.
792
+ if (agent === memberAgent)
793
+ await ctx.request('byokit.accounts', { action: 'clear', agentId: memberAgent, provider })
794
+ .catch((error) => ctx.log?.(`addAccount: ${provider} is not cleared yet: ${error instanceof Error ? error.message : String(error)}`));
795
+ await drop();
796
+ return { view, id };
797
+ };
798
+ const done = handle.done.then(settle).finally(() => {
799
+ releaseAdd();
800
+ const left = signing.get(key) - 1;
801
+ if (left)
802
+ signing.set(key, left);
803
+ else
804
+ signing.delete(key);
805
+ }).then((result) => {
806
+ on(result.view);
807
+ return result;
454
808
  });
809
+ return { id, paste: handle.paste, cancel: handle.cancel, done };
810
+ }
811
+ catch (error) {
812
+ releaseAdd();
813
+ throw error;
455
814
  }
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
815
  }
498
- return { list, add, rename, defaults, setDefaults, models, room, pick, rest };
816
+ return { list, add, rename, defaults, setDefaults, models, room, bind, keyRefusal, pick, rest, move };
499
817
  }