@byokit/openclaw 0.11.0 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +45 -7
  3. package/SECURITY.md +3 -2
  4. package/dist/accounts.d.ts +45 -1
  5. package/dist/accounts.js +641 -46
  6. package/dist/activation.d.ts +17 -0
  7. package/dist/activation.js +9 -0
  8. package/dist/approvals.d.ts +2 -0
  9. package/dist/approvals.js +9 -9
  10. package/dist/attribution.d.ts +3 -0
  11. package/dist/attribution.js +25 -0
  12. package/dist/bridge.d.ts +3 -1
  13. package/dist/bridge.js +4 -6
  14. package/dist/config.js +10 -0
  15. package/dist/constants.d.ts +1 -0
  16. package/dist/constants.js +2 -0
  17. package/dist/device.d.ts +5 -2
  18. package/dist/device.js +11 -1
  19. package/dist/engine.js +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/keys.d.ts +19 -5
  22. package/dist/keys.js +135 -23
  23. package/dist/kit.d.ts +38 -1
  24. package/dist/kit.js +153 -28
  25. package/dist/link.js +21 -11
  26. package/dist/members.d.ts +3 -0
  27. package/dist/members.js +13 -9
  28. package/dist/pick.d.ts +7 -3
  29. package/dist/pick.js +34 -5
  30. package/dist/routes.d.ts +1 -0
  31. package/dist/routes.js +5 -3
  32. package/dist/routes.json +31 -31
  33. package/dist/runs.d.ts +7 -0
  34. package/dist/runs.js +111 -11
  35. package/dist/signin.d.ts +3 -0
  36. package/dist/signin.js +51 -17
  37. package/dist/testing/fake-gateway.d.ts +18 -0
  38. package/dist/testing/fake-gateway.js +210 -16
  39. package/dist/testing/model-stub.d.ts +1 -0
  40. package/dist/testing/model-stub.js +2 -1
  41. package/dist/types.d.ts +19 -0
  42. package/dist/words.json +8 -1
  43. package/package.json +2 -2
  44. package/plugin/accounts.js +12 -3
  45. package/plugin/catalog.js +48 -0
  46. package/plugin/index.js +2 -1
  47. package/plugin/keys.js +22 -4
package/dist/accounts.js CHANGED
@@ -1,14 +1,58 @@
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 and defaults live here;
4
- // add, remove, models, room, pick and move arrive in later steps.
5
- import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
- import { randomUUID } from 'node:crypto';
3
+ // who? } }, defaults }`. No credential, token, email or engine line. Listing, rename, defaults, models, room,
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';
7
+ import { randomBytes, randomUUID } from 'node:crypto';
7
8
  import { join } from 'node:path';
8
- import { KEY_PREFIX, MEMBER_ID } from "./members.js";
9
+ import { providerOf, resolveSelection } from "./pick.js";
10
+ import { agentOf, memberOf as baseMemberOf } from "./attribution.js";
11
+ import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, untilListed } from "./members.js";
9
12
  import { routes, routeFor } from "./routes.js";
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
+ };
10
47
  const ACCOUNT_AGENT = /^[a-z][a-z0-9-]{0,31}--[0-9a-f]{6}$/;
11
48
  const usable = (status) => status === 'ok' || status === 'expiring' || status === 'static';
49
+ const IDENTITY = 'IDENTITY.md';
50
+ const named = (name, op) => {
51
+ const trimmed = typeof name === 'string' ? name.trim() : '';
52
+ if (!trimmed || trimmed.length > 100 || /[\u0000-\u001f\u007f]/.test(trimmed))
53
+ throw new Error(`${op}: name is empty`);
54
+ return trimmed;
55
+ };
12
56
  const empty = () => ({ v: 1, accounts: {}, defaults: {} });
13
57
  const validEntry = (v) => !!v && typeof v === 'object'
14
58
  && typeof v.agent === 'string' && typeof v.name === 'string'
@@ -16,39 +60,65 @@ const validEntry = (v) => !!v && typeof v === 'object'
16
60
  && (v.until === undefined || Number.isFinite(v.until))
17
61
  && (v.model === undefined || typeof v.model === 'string')
18
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
+ }
19
111
  export function createAccounts(ctx) {
20
- 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);
114
+ // Accounts whose sign-in is running, as `<member>\n<id>`, counted per add: listed `signing` until the last one ends.
115
+ const signing = new Map();
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.
117
+ const indexLock = (member) => `${member}#index`;
21
118
  const check = (member) => {
22
119
  if (!MEMBER_ID.test(member) || member.startsWith(KEY_PREFIX))
23
120
  throw new Error(`invalid member id: ${member}`);
24
121
  };
25
- const read = (member) => {
26
- let text;
27
- try {
28
- text = readFileSync(path(member), 'utf8');
29
- }
30
- catch (error) {
31
- if (error.code === 'ENOENT')
32
- return empty();
33
- throw error;
34
- }
35
- const file = JSON.parse(text);
36
- if (!file || typeof file !== 'object' || file.v !== 1)
37
- return empty();
38
- const accounts = {};
39
- for (const [id, entry] of Object.entries(file.accounts ?? {}))
40
- if (validEntry(entry))
41
- accounts[id] = entry;
42
- const raw = file.defaults && typeof file.defaults === 'object' ? file.defaults : {};
43
- const defaults = {};
44
- if (typeof raw.account === 'string')
45
- defaults.account = raw.account;
46
- if (typeof raw.model === 'string')
47
- defaults.model = raw.model;
48
- if (typeof raw.auto === 'boolean')
49
- defaults.auto = raw.auto;
50
- return { v: 1, accounts, defaults };
51
- };
52
122
  const write = (member, file) => {
53
123
  mkdirSync(join(ctx.root, 'accounts'), { recursive: true, mode: 0o700 });
54
124
  const tmp = `${path(member)}.${randomUUID()}.tmp`;
@@ -105,7 +175,8 @@ export function createAccounts(ctx) {
105
175
  file.accounts[provider] = entry;
106
176
  dirty = true;
107
177
  }
108
- 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}`)) {
109
180
  entry.who = profiles[0].who;
110
181
  dirty = true;
111
182
  }
@@ -134,6 +205,11 @@ export function createAccounts(ctx) {
134
205
  const provider = id.split('.')[0];
135
206
  const meta = metaOf(entry.route);
136
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
+ }
137
213
  agentRows.push({ id, provider, route: entry.route, name: entry.name, label: meta.label,
138
214
  billing: meta.billing, state: stateOf(entry.route, profiles[0]?.status, entry.until),
139
215
  ...(entry.until !== undefined ? { until: entry.until } : {}), addedAt: entry.addedAt });
@@ -141,6 +217,9 @@ export function createAccounts(ctx) {
141
217
  memberRows.sort((a, b) => a.addedAt - b.addedAt || (a.id < b.id ? -1 : 1));
142
218
  agentRows.sort((a, b) => a.addedAt - b.addedAt);
143
219
  const rows = [...memberRows, ...agentRows];
220
+ for (const row of rows)
221
+ if (signing.has(`${member}\n${row.id}`))
222
+ row.state = 'signing';
144
223
  if (file.defaults.account !== undefined && !rows.some((r) => r.id === file.defaults.account)) {
145
224
  delete file.defaults.account;
146
225
  dirty = true;
@@ -153,15 +232,68 @@ export function createAccounts(ctx) {
153
232
  const [roster, profiles] = await gather(agentId);
154
233
  return { ...(await build(member, agentId, file, roster, profiles)), file };
155
234
  };
235
+ // The account's index entry, only when `list` names it: an unindexed agent is never reachable, whatever the
236
+ // file holds.
237
+ const entryOf = async (member, id) => {
238
+ const row = (await list(member)).find((r) => r.id === id);
239
+ const entry = read(member).accounts[id];
240
+ if (!row || !entry)
241
+ throw new Error(`unknown account: ${id}`);
242
+ return { row, entry };
243
+ };
244
+ const spanOf = (label) => {
245
+ const lower = label.toLowerCase();
246
+ if (lower === 'session')
247
+ return 'session';
248
+ if (lower === 'weekly')
249
+ return 'week';
250
+ if (lower === 'monthly')
251
+ return 'month';
252
+ return 'tightest';
253
+ };
254
+ // The `models.authStatus { agentId }` row for the account's provider: of its `usage.windows` entries the one
255
+ // with the highest numeric `usedPercent` gives `left`, `resetsAt` and `span`. A window applies to every model
256
+ // unless the engine scopes it to models; the pin's rows carry no scope, so the demand never narrows them today
257
+ // (the tightest known). A window without a numeric `usedPercent` is skipped, never read as unused. `at` is left
258
+ // out: the pin exposes no reading age, so the kit never stamps receipt time as reading time. The kit adds no
259
+ // cache: every call reads live.
260
+ const roomOf = (provider, answer) => {
261
+ const unknown = { left: 'unknown' };
262
+ const rows = record(answer) && Array.isArray(answer.providers) ? answer.providers : undefined;
263
+ if (!rows)
264
+ return unknown;
265
+ const row = rows.find((r) => record(r) && typeof r.provider === 'string'
266
+ && r.provider.toLowerCase() === provider.toLowerCase());
267
+ if (!row || !record(row))
268
+ return unknown;
269
+ const windows = record(row.usage) && Array.isArray(row.usage.windows) ? row.usage.windows : undefined;
270
+ if (!windows)
271
+ return unknown;
272
+ let best;
273
+ for (const candidate of windows) {
274
+ if (!record(candidate))
275
+ continue;
276
+ if (typeof candidate.usedPercent !== 'number' || !Number.isFinite(candidate.usedPercent))
277
+ continue;
278
+ const span = spanOf(typeof candidate.label === 'string' ? candidate.label : '');
279
+ const resetAt = typeof candidate.resetAt === 'number' && Number.isFinite(candidate.resetAt)
280
+ ? candidate.resetAt : undefined;
281
+ if (!best || candidate.usedPercent > best.used)
282
+ best = { used: candidate.usedPercent, ...(resetAt !== undefined ? { resetAt } : {}), span };
283
+ }
284
+ if (!best)
285
+ return unknown;
286
+ return { left: 100 - best.used, span: best.span, ...(best.resetAt !== undefined ? { resetsAt: best.resetAt } : {}) };
287
+ };
156
288
  async function list(member) {
157
289
  check(member);
158
290
  const { agentId } = await ctx.ensure(member);
159
- const first = await ctx.locks.shared(agentId, () => buildRows(member, agentId));
291
+ const first = await ctx.locks.shared(indexLock(member), () => buildRows(member, agentId));
160
292
  if (!first.dirty)
161
293
  return first.rows;
162
294
  // A write is needed (synthesized entry, `who` backfill or dropped default): redo it under the
163
295
  // exclusive lock so two first-lists cannot interleave their read-modify-write.
164
- return ctx.locks.exclusive([agentId], async () => {
296
+ return ctx.locks.exclusive([indexLock(member)], async () => {
165
297
  const { rows, dirty, file } = await buildRows(member, agentId);
166
298
  if (dirty)
167
299
  write(member, file);
@@ -170,15 +302,10 @@ export function createAccounts(ctx) {
170
302
  }
171
303
  async function rename(member, id, name) {
172
304
  check(member);
173
- if (typeof name !== 'string')
174
- throw new Error('renameAccount: name is empty');
175
- const trimmed = name.trim();
176
- if (!trimmed || trimmed.length > 100 || /[\u0000-\u001f\u007f]/.test(trimmed))
177
- throw new Error('renameAccount: name is empty');
178
- const { agentId } = await ctx.ensure(member);
305
+ const trimmed = named(name, 'renameAccount');
179
306
  if (!(await list(member)).some((r) => r.id === id))
180
307
  throw new Error(`unknown account: ${id}`);
181
- await ctx.locks.exclusive([agentId], async () => {
308
+ await ctx.locks.exclusive([indexLock(member)], async () => {
182
309
  const file = read(member);
183
310
  const entry = file.accounts[id];
184
311
  if (!entry)
@@ -205,8 +332,8 @@ export function createAccounts(ctx) {
205
332
  throw new Error('setDefaults: defaults are invalid');
206
333
  if (d.auto !== undefined && typeof d.auto !== 'boolean')
207
334
  throw new Error('setDefaults: defaults are invalid');
208
- const { agentId } = await ctx.ensure(member);
209
- return ctx.locks.exclusive([agentId], async () => {
335
+ await ctx.ensure(member);
336
+ return ctx.locks.exclusive([indexLock(member)], async () => {
210
337
  const file = read(member);
211
338
  const next = {};
212
339
  if (d.account !== undefined)
@@ -218,5 +345,473 @@ export function createAccounts(ctx) {
218
345
  write(member, { ...file, defaults: next });
219
346
  });
220
347
  }
221
- return { list, rename, defaults, setDefaults };
348
+ async function models(member, id) {
349
+ check(member);
350
+ await ctx.ensure(member);
351
+ const { row, entry } = await entryOf(member, id);
352
+ return readModels(row, entry);
353
+ }
354
+ // The shared hold covers the read, so no kit read of the agent is in flight while it is removed.
355
+ const readModels = (row, entry) => ctx.locks.shared(entry.agent, async () => modelsOf(row.provider, await ctx.request('models.list', { agentId: entry.agent, view: 'default' })));
356
+ async function room(member, id, demand) {
357
+ check(member);
358
+ await ctx.ensure(member);
359
+ const { row, entry } = await entryOf(member, id);
360
+ return readRoom(row, entry);
361
+ }
362
+ // An API-key account is used only when chosen, never by Auto: its room is always unknown, with no read. A demand
363
+ // never narrows the windows (see `roomOf`), so the read takes none.
364
+ const readRoom = (row, entry) => row.billing === 'api'
365
+ ? Promise.resolve({ left: 'unknown' })
366
+ : ctx.locks.shared(entry.agent, async () => {
367
+ let answer;
368
+ try {
369
+ answer = await ctx.request('models.authStatus', { agentId: entry.agent }, { timeoutMs: 20_000 });
370
+ }
371
+ catch {
372
+ return { left: 'unknown' };
373
+ }
374
+ return roomOf(row.provider, answer);
375
+ });
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.
446
+ // The pinned engine may hold no prepared model catalog for an agent added since it started, and refuses its models; that
447
+ // row's models are then unknown (`undefined`), and `resolveSelection` host-validates its membership by provider.
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);
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;
466
+ const reads = new Map(await Promise.all(rows.map(async (row) => {
467
+ const entry = file.accounts[row.id];
468
+ const models = await readModels(row, entry).then((ms) => ms.map((m) => (m.why ? { ...m, available: true } : m)), () => undefined);
469
+ return [row.id, { room: await readRoom(row, entry), models }];
470
+ })));
471
+ const chosen = resolveSelection(rows, file.defaults, sel, (a) => reads.get(a.id).room, Date.now(), (a) => reads.get(a.id).models, bound);
472
+ return { pick: chosen, ...(chosen.ok ? { agent: file.accounts[chosen.account.id].agent } : {}) };
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
+ }
539
+ // A run on the account ended resting: Auto skips it until `until` (its state reads `resting` until then).
540
+ const rest = (member, id, until) => edit(member, (file) => { const entry = file.accounts[id]; if (entry)
541
+ entry.until = until; });
542
+ // Index edits take the index lock, as listing's own write does.
543
+ const edit = (member, fn) => ctx.locks.exclusive([indexLock(member)], async () => { const file = read(member); fn(file); write(member, file); });
544
+ const identityOf = async (agentId) => {
545
+ const { file } = await ctx.request('agents.files.get', { agentId, name: IDENTITY });
546
+ return file.missing ? undefined : file.content;
547
+ };
548
+ const restoreIdentity = async (memberAgent, before) => {
549
+ if (before !== undefined && await identityOf(memberAgent) !== before)
550
+ await ctx.request('agents.files.set', { agentId: memberAgent, name: IDENTITY, content: before });
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
+ };
574
+ const onGateway = (agent) => untilListed(ctx.request, agent)
575
+ .then(() => true, () => ctx.request('agents.list')
576
+ .then((r) => r.agents.some((a) => a.id === agent)));
577
+ // A new agent whose sign-in did not finish goes with its entry (5.15); the shared workspace stays. The entry is
578
+ // dropped only once the agent's directory is gone, so a later removal can finish what this one could not.
579
+ const discard = (member, id, agent, created) => (async () => {
580
+ if (created) {
581
+ await ctx.request('byokit.accounts', { action: 'clear', agentId: agent });
582
+ await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
583
+ }
584
+ rmSync(join(ctx.root, 'state', 'agents', agent), { recursive: true, force: true });
585
+ await edit(member, (file) => { delete file.accounts[id]; });
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
+ });
592
+ async function add(member, o, on) {
593
+ check(member);
594
+ if (o && typeof o === 'object' && 'key' in o)
595
+ throw new Error('addAccount: keys are host-only');
596
+ if (!NEW_MEMBER_ID.test(member))
597
+ throw new Error(words('account.legacyMember'));
598
+ const choice = o?.authChoice === 'openai-codex' ? 'openai' : o?.authChoice;
599
+ const route = routes().find((r) => r.choice === choice && r.billing === 'subscription');
600
+ if (!route)
601
+ throw new Error(`addAccount: ${String(o?.authChoice)} is not a subscription sign-in`);
602
+ const name = o.name === undefined ? undefined : named(o.name, 'addAccount');
603
+ const provider = route.provider;
604
+ const { agentId: memberAgent, workspace } = await ctx.ensure(member);
605
+ if (o.again !== undefined && (!read(member).accounts[o.again] || o.again.split('.')[0] !== provider))
606
+ throw new Error(`unknown account: ${o.again}`);
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
+ });
650
+ added = true;
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;
729
+ }
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
+ }
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;
808
+ });
809
+ return { id, paste: handle.paste, cancel: handle.cancel, done };
810
+ }
811
+ catch (error) {
812
+ releaseAdd();
813
+ throw error;
814
+ }
815
+ }
816
+ return { list, add, rename, defaults, setDefaults, models, room, bind, keyRefusal, pick, rest, move };
222
817
  }