@adecore/agents 0.47.0 → 0.48.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.
@@ -26,6 +26,7 @@ export interface InterruptedRun {
26
26
  export interface ChatCoreHooks {
27
27
  admit?(chatId: string, cwd: string): Promise<void>;
28
28
  openingSelection?(chatId: string, kind: AgentKind): ModelSelection | undefined;
29
+ openingAccount?(chatId: string, kind: AgentKind, named: string | undefined): string | undefined;
29
30
  runtimeModeFor?(chatId: string, mode: RuntimeMode): RuntimeMode;
30
31
  instructionsFor?(chatId: string): string | null;
31
32
  resumeNoteFor?(chatId: string): string | null;
@@ -131,6 +132,7 @@ export declare class ChatCore {
131
132
  codexProcess(info: Pick<ChatInfo, 'cwd' | 'account'>): CodexProcessSpec;
132
133
  claudeProjectsDirOf(info: Pick<ChatInfo, 'account'>): string;
133
134
  canContinue(kind: AgentKind, from: string | undefined, to: string | undefined): boolean;
135
+ openingAccount(chatId: string, kind: AgentKind, named: string | undefined): string | undefined;
134
136
  requireAccount(kind: AgentKind, account: string | undefined): void;
135
137
  accountLabel(kind: AgentKind, account: string | undefined): string;
136
138
  readChat(chatId: string): Promise<{
@@ -185,6 +185,10 @@ export class ChatCore {
185
185
  return this.accounts?.canContinue(kind, from, to) ?? false;
186
186
  }
187
187
  /* Throws for an account a CLI of this kind cannot start under on this machine. */
188
+ /* The account a start names, after the host had its say; with no account named, the top account of the CLI that is on. */
189
+ openingAccount(chatId, kind, named) {
190
+ return this.hooks.openingAccount?.(chatId, kind, named) ?? named ?? this.accounts?.defaultFor?.(kind);
191
+ }
188
192
  requireAccount(kind, account) {
189
193
  launchEnv(this.accounts, kind, account, this.env);
190
194
  }
@@ -257,9 +261,9 @@ export class ChatCore {
257
261
  const cwd = stored?.info.cwd ?? payload.cwd ?? homeFolder(this.env);
258
262
  await this.hooks.admit?.(payload.chatId, cwd);
259
263
  // A thread on disk keeps its account too. One it lost still opens, and says why on its next turn.
260
- // A new one without an account takes the person's pick for this CLI, which is refused rather than replaced when it went.
261
- // The pick is checked as named, before the default account loses its id, so one that is turned off is refused too.
262
- const requested = payload.account ?? this.composerPreferences.for(kind).account;
264
+ // A new one without an account takes the host's choice, else the top account of this CLI that is on.
265
+ // It is checked as named, before the default account loses its id, so one that is turned off is refused too.
266
+ const requested = stored ? undefined : this.openingAccount(payload.chatId, kind, payload.account);
263
267
  if (!stored) {
264
268
  this.requireAccount(kind, requested);
265
269
  }
@@ -377,7 +381,8 @@ export class ChatCore {
377
381
  if (to === from) {
378
382
  return;
379
383
  }
380
- // As named, so a switch to the default account is refused while it is turned off.
384
+ // As named, so a switch to the default account is refused while it is turned off, and one the host turned off for this chat too.
385
+ this.hooks.openingAccount?.(session.id, kind, requested);
381
386
  this.requireAccount(kind, requested);
382
387
  if (holdsConversation(session.info) && !this.canContinue(kind, from, to)) {
383
388
  const label = this.accountLabel(kind, to);
@@ -2,7 +2,6 @@ import type { AgentKind, ChatPreferencesPayload, ModelSelection, RuntimeMode } f
2
2
  export interface ComposerPreference {
3
3
  runtimeMode?: RuntimeMode;
4
4
  selection?: ModelSelection;
5
- account?: string;
6
5
  }
7
6
  export declare class ComposerPreferences {
8
7
  private readonly byClient;
@@ -23,10 +23,10 @@ export class ComposerPreferences {
23
23
  if (newest === null) {
24
24
  return {};
25
25
  }
26
- const { runtimeMode, selections, accounts } = newest.preference;
26
+ // The accounts an older client still sends are left out: the order of the accounts decides where a start goes.
27
+ const { runtimeMode, selections } = newest.preference;
27
28
  const selection = selections?.[provider];
28
- const account = accounts?.[provider];
29
- return { ...(runtimeMode ? { runtimeMode } : {}), ...(selection ? { selection } : {}), ...(account ? { account } : {}) };
29
+ return { ...(runtimeMode ? { runtimeMode } : {}), ...(selection ? { selection } : {}) };
30
30
  }
31
31
  newest() {
32
32
  let newest = null;
@@ -101,6 +101,7 @@ export function accountHandlers(accounts) {
101
101
  return {
102
102
  'accounts.list': () => accounts.snapshot(),
103
103
  'accounts.save': (payload) => accounts.save(payload.accounts),
104
+ 'accounts.reorder': (payload) => accounts.reorder(payload.kind, payload.ids),
104
105
  'accounts.refresh': () => accounts.refresh(),
105
106
  'accounts.create': (payload) => accounts.create(payload),
106
107
  'accounts.watchLogin': (payload) => {
@@ -0,0 +1,18 @@
1
+ import { type AgentKind, type ProviderAccountMap } from '@adecore/agent-contracts';
2
+ export type AccountUnusable = 'turned off on this machine' | 'signed out' | 'at its usage limit' | 'not available on this machine';
3
+ export interface AccountChoiceInput {
4
+ kind: AgentKind;
5
+ accounts: ProviderAccountMap;
6
+ named: string | undefined;
7
+ preferred: string | undefined;
8
+ off: readonly string[];
9
+ unusable(id: string): AccountUnusable | null;
10
+ }
11
+ export interface ChosenAccount {
12
+ account: string;
13
+ passedOver?: {
14
+ account: string;
15
+ reason: AccountUnusable;
16
+ };
17
+ }
18
+ export declare function chooseAccount(input: AccountChoiceInput): ChosenAccount;
@@ -0,0 +1,27 @@
1
+ import { orderedAccountIds } from '@adecore/agent-contracts';
2
+ import { AccountError } from './launch.js';
3
+ /*
4
+ * The account a start takes: a named one unless the place turned it off, which refuses the start; else
5
+ * the preferred one while it is usable; else the first usable account in the person's order that the
6
+ * place offers; else the CLI's default account, which the launch refuses as before when it cannot run.
7
+ */
8
+ export function chooseAccount(input) {
9
+ const { kind, accounts, named, preferred, off } = input;
10
+ if (named !== undefined) {
11
+ if (off.includes(named)) {
12
+ throw new AccountError('account-off-in-project', `The account '${named}' is turned off in this project.`);
13
+ }
14
+ return { account: named };
15
+ }
16
+ const offered = orderedAccountIds(accounts, kind).filter((id) => !off.includes(id));
17
+ const known = preferred !== undefined && offered.includes(preferred) ? preferred : undefined;
18
+ const reason = known === undefined ? null : input.unusable(known);
19
+ if (known !== undefined && reason === null) {
20
+ return { account: known };
21
+ }
22
+ const next = offered.find((id) => id !== known && input.unusable(id) === null) ?? kind;
23
+ if (off.includes(next)) {
24
+ throw new AccountError('account-off-in-project', 'No account this project offers can start right now.');
25
+ }
26
+ return known === undefined || reason === null ? { account: next } : { account: next, passedOver: { account: known, reason } };
27
+ }
@@ -1,7 +1,7 @@
1
1
  import type { AgentKind } from '@adecore/agent-contracts';
2
2
  import { CodedError } from '../../coded-error.ts';
3
3
  import type { Env } from './accounts.ts';
4
- export declare class AccountError extends CodedError<'account-unavailable' | 'account-incompatible'> {
4
+ export declare class AccountError extends CodedError<'account-unavailable' | 'account-incompatible' | 'account-off-in-project'> {
5
5
  }
6
6
  export interface AccountLaunches {
7
7
  envFor(kind: AgentKind, account: string | undefined, baseEnv: Env): Env;
@@ -10,6 +10,7 @@ export interface AccountLaunches {
10
10
  canContinue(kind: AgentKind, from: string | undefined, to: string | undefined): boolean;
11
11
  labelOf(account: string): string;
12
12
  launched?(kind: AgentKind, account: string | undefined): void;
13
+ defaultFor?(kind: AgentKind): string;
13
14
  }
14
15
  export declare function isDefaultAccountOf(kind: AgentKind, account: string | undefined): boolean;
15
16
  export declare function storedAccount(kind: AgentKind, account: string | undefined): string | undefined;
@@ -67,6 +67,8 @@ export declare class ProviderAccountsService implements AccountLaunches {
67
67
  start(): void;
68
68
  stop(): void;
69
69
  save(accounts: ProviderAccountMap): Promise<ProviderAccounts>;
70
+ defaultFor(kind: AgentKind): string;
71
+ reorder(kind: AgentKind, ids: readonly string[]): Promise<ProviderAccounts>;
70
72
  create(payload: ProviderAccountCreatePayload): Promise<ProviderAccountCreateResult>;
71
73
  watchLogin(id: string): Promise<void>;
72
74
  refresh(): Promise<ProviderAccounts>;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, statSync } from 'node:fs';
2
2
  import { mkdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { KNOWN_AGENT_KINDS } from '@adecore/agent-contracts';
4
+ import { KNOWN_AGENT_KINDS, topAccountOf } from '@adecore/agent-contracts';
5
5
  import { DEFAULT_CODEX_CLIENT } from '../../chat/codex-transport.js';
6
6
  import { ClientSinks } from '../../client-sinks.js';
7
7
  import { errorText } from '../../error-text.js';
@@ -10,7 +10,7 @@ import { accountEnv, accountFolder, canContinue, defaultFolder, expandHome, isDe
10
10
  import { AccountError, isDefaultAccountOf } from './launch.js';
11
11
  import { prepareShadowHome } from './shadow-home.js';
12
12
  import { ASKED_KINDS, askAccountAs } from './status.js';
13
- import { acceptAccounts, accountsPath, InvalidAccountError, readAccounts, wireAccounts, writeAccounts } from './store.js';
13
+ import { acceptAccounts, accountsPath, InvalidAccountError, readAccounts, reorderedAccounts, wireAccounts, writeAccounts } from './store.js';
14
14
  import { DEFAULT_ACCOUNTS_HOST, platformSecrets, secretKey, SecretsUnavailableError } from './variables.js';
15
15
  /* Who is signed in changes by hand and rarely; often enough to notice, rarely enough not to start CLIs all day. */
16
16
  const CHECK_INTERVAL_MS = 15 * 60_000;
@@ -232,6 +232,13 @@ export class ProviderAccountsService {
232
232
  save(accounts) {
233
233
  return this.writes.run(() => this.commit(accounts, this.folderExists));
234
234
  }
235
+ defaultFor(kind) {
236
+ return topAccountOf(this.accounts, kind);
237
+ }
238
+ /* Puts the accounts of one CLI in this order; an id of another CLI or one this machine does not have refuses it. */
239
+ reorder(kind, ids) {
240
+ return this.writes.run(() => this.commit(reorderedAccounts(this.accounts, kind, ids), this.folderExists, true));
241
+ }
235
242
  /*
236
243
  * An account in a folder of its own under `<home>/accounts`, signed in by nobody yet. A
237
244
  * Codex account is a shadow home over the CLI's own folder, so its threads go on under the other.
@@ -305,8 +312,8 @@ export class ProviderAccountsService {
305
312
  accountProviders() {
306
313
  return { kinds: this.kinds(), get: (kind) => this.providers.get(kind) };
307
314
  }
308
- async commit(accounts, isDirectory) {
309
- const stored = acceptAccounts(accounts, this.stored, this.accountProviders(), this.env, isDirectory, this.host);
315
+ async commit(accounts, isDirectory, takeOrder = false) {
316
+ const stored = acceptAccounts(accounts, this.stored, this.accountProviders(), this.env, isDirectory, this.host, takeOrder);
310
317
  const { writes, removals } = this.planSecrets(stored);
311
318
  for (const [key, value] of writes) {
312
319
  await this.requireSecrets().write(key, value);
@@ -10,4 +10,5 @@ export declare function wireAccounts(stored: StoredAccounts, kinds: readonly Age
10
10
  export declare class InvalidAccountError extends CodedError<'invalid-account'> {
11
11
  constructor(message: string);
12
12
  }
13
- export declare function acceptAccounts(saved: ProviderAccountMap, before: StoredAccounts, providers: AccountProviders, env: Env, isDirectory: (path: string) => boolean, host?: AccountsHost): StoredAccounts;
13
+ export declare function acceptAccounts(saved: ProviderAccountMap, before: StoredAccounts, providers: AccountProviders, env: Env, isDirectory: (path: string) => boolean, host?: AccountsHost, takeOrder?: boolean): StoredAccounts;
14
+ export declare function reorderedAccounts(accounts: ProviderAccountMap, kind: AgentKind, ids: readonly string[]): ProviderAccountMap;
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { ProviderAccountIdSchema, ProviderAccountSchema } from '@adecore/agent-contracts';
3
+ import { orderedAccountIds, ProviderAccountIdSchema, ProviderAccountSchema } from '@adecore/agent-contracts';
4
4
  import { CodedError } from '../../coded-error.js';
5
5
  import { isNotFound, writeAtomic } from '../../fs.js';
6
6
  import { accountFolder, accountProblem, defaultFolder, expandHome, folderVariablesOf, isDefaultAccount, isOfferedKind, readAccount, withDefaults } from './accounts.js';
@@ -88,7 +88,9 @@ function linkProblem(id, account, before, provider, env, isDirectory) {
88
88
  * client that knows the kind no better than this host could only have cut short. Sensitive values
89
89
  * are still in it: the caller puts them in the keychain before the file is written.
90
90
  */
91
- export function acceptAccounts(saved, before, providers, env, isDirectory, host = DEFAULT_ACCOUNTS_HOST) {
91
+ export function acceptAccounts(saved, before, providers, env, isDirectory, host = DEFAULT_ACCOUNTS_HOST,
92
+ // Only `accounts.reorder` takes the order it is given.
93
+ takeOrder = false) {
92
94
  const stored = {};
93
95
  const reserved = { host, folderVariables: folderVariablesOf(providers) };
94
96
  for (const [id, account] of Object.entries(withDefaults(saved, providers.kinds))) {
@@ -106,7 +108,37 @@ export function acceptAccounts(saved, before, providers, env, isDirectory, host
106
108
  if (problem !== null) {
107
109
  throw new InvalidAccountError(`${id}: ${problem}`);
108
110
  }
109
- stored[id] = account;
111
+ stored[id] = takeOrder ? account : withKeptOrder(account, before[id]);
110
112
  }
111
113
  return stored;
112
114
  }
115
+ /*
116
+ * Only `accounts.reorder` moves an account, so a save keeps the place the file has: an older client
117
+ * strips `order`, and a newer one may send the order it read before a reorder elsewhere.
118
+ */
119
+ function withKeptOrder(account, before) {
120
+ const { order: _sent, ...rest } = account;
121
+ const order = typeof before === 'object' && before !== null ? before.order : undefined;
122
+ return typeof order === 'number' ? { ...rest, order } : rest;
123
+ }
124
+ /* The map with the accounts of one CLI in this order; one of that CLI not named, such as one made meanwhile, follows in the order it had. */
125
+ export function reorderedAccounts(accounts, kind, ids) {
126
+ if (new Set(ids).size !== ids.length) {
127
+ throw new InvalidAccountError('an account is named twice in the order');
128
+ }
129
+ for (const id of ids) {
130
+ const account = accounts[id];
131
+ if (account === undefined) {
132
+ throw new InvalidAccountError(`${id}: no account on this machine has that id`);
133
+ }
134
+ if (account.kind !== kind) {
135
+ throw new InvalidAccountError(`${id}: it is an account of ${account.kind}, not of ${kind}`);
136
+ }
137
+ }
138
+ const order = [...ids, ...orderedAccountIds(accounts, kind).filter((id) => !ids.includes(id))];
139
+ const reordered = { ...accounts };
140
+ for (const [place, id] of order.entries()) {
141
+ reordered[id] = { ...accounts[id], order: place };
142
+ }
143
+ return reordered;
144
+ }
@@ -1,6 +1,7 @@
1
1
  export { type AcpCatalogStore } from './acp-agent-state.ts';
2
2
  export { ACP_CAPABILITIES, type AcpProviderSpec, createAcpProvider } from './acp-provider.ts';
3
3
  export { type AcpAccountProbe, askAcpAccount, type ProbeTimer } from './accounts/acp-account.ts';
4
+ export { chooseAccount, type AccountChoiceInput, type AccountUnusable, type ChosenAccount } from './accounts/choose.ts';
4
5
  export { AccountError, type AccountLaunches, storedAccount, launchEnv } from './accounts/launch.ts';
5
6
  export { ProviderAccountsService, type ProviderAccountsOptions } from './accounts/service.ts';
6
7
  export type { ShadowHomeReport } from './accounts/shadow-home.ts';
@@ -2,6 +2,7 @@
2
2
  export {} from './acp-agent-state.js';
3
3
  export { ACP_CAPABILITIES, createAcpProvider } from './acp-provider.js';
4
4
  export { askAcpAccount } from './accounts/acp-account.js';
5
+ export { chooseAccount } from './accounts/choose.js';
5
6
  export { AccountError, storedAccount, launchEnv } from './accounts/launch.js';
6
7
  export { ProviderAccountsService } from './accounts/service.js';
7
8
  export { variablesProblem, keychainService } from './accounts/variables.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -93,8 +93,8 @@
93
93
  "test": "bun --conditions=source test"
94
94
  },
95
95
  "dependencies": {
96
- "@adecore/agent-contracts": "0.47.0",
97
- "@adecore/intelligent-ui": "0.47.0",
96
+ "@adecore/agent-contracts": "0.48.0",
97
+ "@adecore/intelligent-ui": "0.48.0",
98
98
  "zod": "^4.6.7"
99
99
  },
100
100
  "devDependencies": {
@@ -117,6 +117,11 @@ export interface ChatCoreHooks {
117
117
  admit?(chatId: string, cwd: string): Promise<void>;
118
118
  /* The model a new chat starts on when the host already knows; the explicit one still wins. */
119
119
  openingSelection?(chatId: string, kind: AgentKind): ModelSelection | undefined;
120
+ /*
121
+ * The account a new chat starts under, given the one its start named (undefined for none);
122
+ * undefined leaves it to the order of the accounts. Throws an `AccountError` to refuse the start.
123
+ */
124
+ openingAccount?(chatId: string, kind: AgentKind, named: string | undefined): string | undefined;
120
125
  /* The mode a chat runs in, which a host may narrow whatever its record or its client says. */
121
126
  runtimeModeFor?(chatId: string, mode: RuntimeMode): RuntimeMode;
122
127
  /* What the agent is told once at the start of every process of this chat, in place of `instructions`; null for nothing. */
@@ -387,6 +392,11 @@ export class ChatCore {
387
392
  }
388
393
 
389
394
  /* Throws for an account a CLI of this kind cannot start under on this machine. */
395
+ /* The account a start names, after the host had its say; with no account named, the top account of the CLI that is on. */
396
+ openingAccount(chatId: string, kind: AgentKind, named: string | undefined): string | undefined {
397
+ return this.hooks.openingAccount?.(chatId, kind, named) ?? named ?? this.accounts?.defaultFor?.(kind);
398
+ }
399
+
390
400
  requireAccount(kind: AgentKind, account: string | undefined): void {
391
401
  launchEnv(this.accounts, kind, account, this.env);
392
402
  }
@@ -479,9 +489,9 @@ export class ChatCore {
479
489
  const cwd = stored?.info.cwd ?? payload.cwd ?? homeFolder(this.env);
480
490
  await this.hooks.admit?.(payload.chatId, cwd);
481
491
  // A thread on disk keeps its account too. One it lost still opens, and says why on its next turn.
482
- // A new one without an account takes the person's pick for this CLI, which is refused rather than replaced when it went.
483
- // The pick is checked as named, before the default account loses its id, so one that is turned off is refused too.
484
- const requested = payload.account ?? this.composerPreferences.for(kind).account;
492
+ // A new one without an account takes the host's choice, else the top account of this CLI that is on.
493
+ // It is checked as named, before the default account loses its id, so one that is turned off is refused too.
494
+ const requested = stored ? undefined : this.openingAccount(payload.chatId, kind, payload.account);
485
495
 
486
496
  if (!stored) {
487
497
  this.requireAccount(kind, requested);
@@ -626,7 +636,8 @@ export class ChatCore {
626
636
  return;
627
637
  }
628
638
 
629
- // As named, so a switch to the default account is refused while it is turned off.
639
+ // As named, so a switch to the default account is refused while it is turned off, and one the host turned off for this chat too.
640
+ this.hooks.openingAccount?.(session.id, kind, requested);
630
641
  this.requireAccount(kind, requested);
631
642
  if (holdsConversation(session.info) && !this.canContinue(kind, from, to)) {
632
643
  const label = this.accountLabel(kind, to);
@@ -3,8 +3,6 @@ import type { AgentKind, ChatPreferencesPayload, ModelSelection, RuntimeMode } f
3
3
  export interface ComposerPreference {
4
4
  runtimeMode?: RuntimeMode;
5
5
  selection?: ModelSelection;
6
- // The account a new chat or agent of this CLI starts under when nothing names one.
7
- account?: string;
8
6
  }
9
7
 
10
8
  interface Held {
@@ -44,11 +42,11 @@ export class ComposerPreferences {
44
42
  return {};
45
43
  }
46
44
 
47
- const { runtimeMode, selections, accounts } = newest.preference;
45
+ // The accounts an older client still sends are left out: the order of the accounts decides where a start goes.
46
+ const { runtimeMode, selections } = newest.preference;
48
47
  const selection = selections?.[provider];
49
- const account = accounts?.[provider];
50
48
 
51
- return { ...(runtimeMode ? { runtimeMode } : {}), ...(selection ? { selection } : {}), ...(account ? { account } : {}) };
49
+ return { ...(runtimeMode ? { runtimeMode } : {}), ...(selection ? { selection } : {}) };
52
50
  }
53
51
 
54
52
  private newest(): Held | null {
@@ -150,6 +150,7 @@ export function accountHandlers(accounts: ProviderAccountsService): { [T in Acco
150
150
  return {
151
151
  'accounts.list': () => accounts.snapshot(),
152
152
  'accounts.save': (payload) => accounts.save(payload.accounts),
153
+ 'accounts.reorder': (payload) => accounts.reorder(payload.kind, payload.ids),
153
154
  'accounts.refresh': () => accounts.refresh(),
154
155
  'accounts.create': (payload) => accounts.create(payload),
155
156
  'accounts.watchLogin': (payload) => {
@@ -0,0 +1,57 @@
1
+ import { orderedAccountIds, type AgentKind, type ProviderAccountMap } from '@adecore/agent-contracts';
2
+ import { AccountError } from './launch.ts';
3
+
4
+ /* Why an account cannot take a start right now, in words that finish "is ...". */
5
+ export type AccountUnusable = 'turned off on this machine' | 'signed out' | 'at its usage limit' | 'not available on this machine';
6
+
7
+ export interface AccountChoiceInput {
8
+ kind: AgentKind;
9
+ accounts: ProviderAccountMap;
10
+ /* The account the start named; undefined for none. */
11
+ named: string | undefined;
12
+ /* The account the place of the start prefers, such as a project's default. */
13
+ preferred: string | undefined;
14
+ /* The accounts the place of the start never offers. */
15
+ off: readonly string[];
16
+ /* Why an account cannot take a start right now, or null when it can. */
17
+ unusable(id: string): AccountUnusable | null;
18
+ }
19
+
20
+ export interface ChosenAccount {
21
+ account: string;
22
+ /* The preferred account the start passed over, and why; absent when it took that one or none was preferred. */
23
+ passedOver?: { account: string; reason: AccountUnusable };
24
+ }
25
+
26
+ /*
27
+ * The account a start takes: a named one unless the place turned it off, which refuses the start; else
28
+ * the preferred one while it is usable; else the first usable account in the person's order that the
29
+ * place offers; else the CLI's default account, which the launch refuses as before when it cannot run.
30
+ */
31
+ export function chooseAccount(input: AccountChoiceInput): ChosenAccount {
32
+ const { kind, accounts, named, preferred, off } = input;
33
+
34
+ if (named !== undefined) {
35
+ if (off.includes(named)) {
36
+ throw new AccountError('account-off-in-project', `The account '${named}' is turned off in this project.`);
37
+ }
38
+
39
+ return { account: named };
40
+ }
41
+
42
+ const offered = orderedAccountIds(accounts, kind).filter((id) => !off.includes(id));
43
+ const known = preferred !== undefined && offered.includes(preferred) ? preferred : undefined;
44
+ const reason = known === undefined ? null : input.unusable(known);
45
+
46
+ if (known !== undefined && reason === null) {
47
+ return { account: known };
48
+ }
49
+
50
+ const next = offered.find((id) => id !== known && input.unusable(id) === null) ?? kind;
51
+
52
+ if (off.includes(next)) {
53
+ throw new AccountError('account-off-in-project', 'No account this project offers can start right now.');
54
+ }
55
+
56
+ return known === undefined || reason === null ? { account: next } : { account: next, passedOver: { account: known, reason } };
57
+ }
@@ -2,7 +2,7 @@ import type { AgentKind } from '@adecore/agent-contracts';
2
2
  import { CodedError } from '../../coded-error.ts';
3
3
  import type { Env } from './accounts.ts';
4
4
 
5
- export class AccountError extends CodedError<'account-unavailable' | 'account-incompatible'> {}
5
+ export class AccountError extends CodedError<'account-unavailable' | 'account-incompatible' | 'account-off-in-project'> {}
6
6
 
7
7
  /*
8
8
  * The accounts as a terminal or a chat starts a CLI under one. Absent, or the CLI's own kind, is the
@@ -21,6 +21,8 @@ export interface AccountLaunches {
21
21
  labelOf(account: string): string;
22
22
  /* A CLI started under this account a moment ago, which is what keeps its plan read. */
23
23
  launched?(kind: AgentKind, account: string | undefined): void;
24
+ /* The account a start of this CLI takes when nothing names one: the top one in the person's order that is on. */
25
+ defaultFor?(kind: AgentKind): string;
24
26
  }
25
27
 
26
28
  export function isDefaultAccountOf(kind: AgentKind, account: string | undefined): boolean {
@@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import {
5
5
  KNOWN_AGENT_KINDS,
6
+ topAccountOf,
6
7
  type AgentKind,
7
8
  type ProviderAccount,
8
9
  type ProviderAccountCreatePayload,
@@ -34,7 +35,16 @@ import {
34
35
  import { AccountError, isDefaultAccountOf, type AccountLaunches } from './launch.ts';
35
36
  import { prepareShadowHome, type ShadowHomeReport } from './shadow-home.ts';
36
37
  import { ASKED_KINDS, askAccountAs, type AskAccount } from './status.ts';
37
- import { acceptAccounts, accountsPath, InvalidAccountError, readAccounts, wireAccounts, writeAccounts, type StoredAccounts } from './store.ts';
38
+ import {
39
+ acceptAccounts,
40
+ accountsPath,
41
+ InvalidAccountError,
42
+ readAccounts,
43
+ reorderedAccounts,
44
+ wireAccounts,
45
+ writeAccounts,
46
+ type StoredAccounts
47
+ } from './store.ts';
38
48
  import { DEFAULT_ACCOUNTS_HOST, platformSecrets, secretKey, SecretsUnavailableError, type AccountsHost, type SecretStore } from './variables.ts';
39
49
 
40
50
  /* Who is signed in changes by hand and rarely; often enough to notice, rarely enough not to start CLIs all day. */
@@ -343,6 +353,15 @@ export class ProviderAccountsService implements AccountLaunches {
343
353
  return this.writes.run(() => this.commit(accounts, this.folderExists));
344
354
  }
345
355
 
356
+ defaultFor(kind: AgentKind): string {
357
+ return topAccountOf(this.accounts, kind);
358
+ }
359
+
360
+ /* Puts the accounts of one CLI in this order; an id of another CLI or one this machine does not have refuses it. */
361
+ reorder(kind: AgentKind, ids: readonly string[]): Promise<ProviderAccounts> {
362
+ return this.writes.run(() => this.commit(reorderedAccounts(this.accounts, kind, ids), this.folderExists, true));
363
+ }
364
+
346
365
  /*
347
366
  * An account in a folder of its own under `<home>/accounts`, signed in by nobody yet. A
348
367
  * Codex account is a shadow home over the CLI's own folder, so its threads go on under the other.
@@ -437,8 +456,8 @@ export class ProviderAccountsService implements AccountLaunches {
437
456
  return { kinds: this.kinds(), get: (kind) => this.providers.get(kind) };
438
457
  }
439
458
 
440
- private async commit(accounts: ProviderAccountMap, isDirectory: (path: string) => boolean): Promise<ProviderAccounts> {
441
- const stored = acceptAccounts(accounts, this.stored, this.accountProviders(), this.env, isDirectory, this.host);
459
+ private async commit(accounts: ProviderAccountMap, isDirectory: (path: string) => boolean, takeOrder = false): Promise<ProviderAccounts> {
460
+ const stored = acceptAccounts(accounts, this.stored, this.accountProviders(), this.env, isDirectory, this.host, takeOrder);
442
461
  const { writes, removals } = this.planSecrets(stored);
443
462
 
444
463
  for (const [key, value] of writes) {
@@ -1,6 +1,13 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { ProviderAccountIdSchema, ProviderAccountSchema, type AgentKind, type ProviderAccount, type ProviderAccountMap } from '@adecore/agent-contracts';
3
+ import {
4
+ orderedAccountIds,
5
+ ProviderAccountIdSchema,
6
+ ProviderAccountSchema,
7
+ type AgentKind,
8
+ type ProviderAccount,
9
+ type ProviderAccountMap
10
+ } from '@adecore/agent-contracts';
4
11
  import { CodedError } from '../../coded-error.ts';
5
12
  import { isNotFound, writeAtomic } from '../../fs.ts';
6
13
  import type { ChatProvider } from '../provider.ts';
@@ -146,7 +153,9 @@ export function acceptAccounts(
146
153
  providers: AccountProviders,
147
154
  env: Env,
148
155
  isDirectory: (path: string) => boolean,
149
- host: AccountsHost = DEFAULT_ACCOUNTS_HOST
156
+ host: AccountsHost = DEFAULT_ACCOUNTS_HOST,
157
+ // Only `accounts.reorder` takes the order it is given.
158
+ takeOrder = false
150
159
  ): StoredAccounts {
151
160
  const stored: StoredAccounts = {};
152
161
  const reserved = { host, folderVariables: folderVariablesOf(providers) };
@@ -173,8 +182,47 @@ export function acceptAccounts(
173
182
  throw new InvalidAccountError(`${id}: ${problem}`);
174
183
  }
175
184
 
176
- stored[id] = account;
185
+ stored[id] = takeOrder ? account : withKeptOrder(account, before[id]);
177
186
  }
178
187
 
179
188
  return stored;
180
189
  }
190
+
191
+ /*
192
+ * Only `accounts.reorder` moves an account, so a save keeps the place the file has: an older client
193
+ * strips `order`, and a newer one may send the order it read before a reorder elsewhere.
194
+ */
195
+ function withKeptOrder(account: ProviderAccount, before: unknown): ProviderAccount {
196
+ const { order: _sent, ...rest } = account;
197
+ const order = typeof before === 'object' && before !== null ? (before as { order?: unknown }).order : undefined;
198
+
199
+ return typeof order === 'number' ? { ...rest, order } : rest;
200
+ }
201
+
202
+ /* The map with the accounts of one CLI in this order; one of that CLI not named, such as one made meanwhile, follows in the order it had. */
203
+ export function reorderedAccounts(accounts: ProviderAccountMap, kind: AgentKind, ids: readonly string[]): ProviderAccountMap {
204
+ if (new Set(ids).size !== ids.length) {
205
+ throw new InvalidAccountError('an account is named twice in the order');
206
+ }
207
+
208
+ for (const id of ids) {
209
+ const account = accounts[id];
210
+
211
+ if (account === undefined) {
212
+ throw new InvalidAccountError(`${id}: no account on this machine has that id`);
213
+ }
214
+
215
+ if (account.kind !== kind) {
216
+ throw new InvalidAccountError(`${id}: it is an account of ${account.kind}, not of ${kind}`);
217
+ }
218
+ }
219
+
220
+ const order = [...ids, ...orderedAccountIds(accounts, kind).filter((id) => !ids.includes(id))];
221
+ const reordered: ProviderAccountMap = { ...accounts };
222
+
223
+ for (const [place, id] of order.entries()) {
224
+ reordered[id] = { ...accounts[id], order: place } as ProviderAccount;
225
+ }
226
+
227
+ return reordered;
228
+ }
@@ -2,6 +2,7 @@
2
2
  export { type AcpCatalogStore } from './acp-agent-state.ts';
3
3
  export { ACP_CAPABILITIES, type AcpProviderSpec, createAcpProvider } from './acp-provider.ts';
4
4
  export { type AcpAccountProbe, askAcpAccount, type ProbeTimer } from './accounts/acp-account.ts';
5
+ export { chooseAccount, type AccountChoiceInput, type AccountUnusable, type ChosenAccount } from './accounts/choose.ts';
5
6
  export { AccountError, type AccountLaunches, storedAccount, launchEnv } from './accounts/launch.ts';
6
7
  export { ProviderAccountsService, type ProviderAccountsOptions } from './accounts/service.ts';
7
8
  export type { ShadowHomeReport } from './accounts/shadow-home.ts';