@adecore/agents 0.46.3 → 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.
@@ -1,4 +1,4 @@
1
- import { type AgentKind, type ChatAttachment, type ChatAttachResult, type ChatAttachmentUpload, type ChatBookmark, type ChatCheckpointDiff, type ChatConfigurePayload, type ChatCreatePayload, type ChatEvent, type ChatHistoryResult, type ChatGoalAction, type ChatInfo, type ChatItem, type ChatQueuedMessage, type ChatUiChoicePayload, type ChatUiQueryPayload, type ChatUiLinkPayload, type ChatSkill, type ChatSubagentPayload, type ChatSubagentResult, type ChatTurnItem, type ChatVisual, type ModelSelection, type RuntimeMode } from '@adecore/agent-contracts';
1
+ import { type AgentKind, type ChatAttachment, type ChatAttachResult, type ChatAttachmentUpload, type ChatBookmark, type ChatCheckpointDiff, type ChatConfigurePayload, type ChatCreatePayload, type ChatEvent, type ChatHistoryResult, type ChatGoalAction, type ChatInfo, type ChatItem, type ChatNoteMessage, type ChatQueuedMessage, type ChatUiChoicePayload, type ChatUiQueryPayload, type ChatUiLinkPayload, type ChatSkill, type ChatSubagentPayload, type ChatSubagentResult, type ChatTurnItem, type ChatVisual, type ModelSelection, type RuntimeMode } from '@adecore/agent-contracts';
2
2
  import type { AgentEvent, AgentSink } from '../events.ts';
3
3
  import type { OutboxOutcome } from '../outbox/outbox-worker.ts';
4
4
  import { type AccountLaunches } from '../providers/accounts/launch.ts';
@@ -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<{
@@ -180,7 +182,7 @@ export declare class ChatCore {
180
182
  writeRecord(chatId: string, info: ChatInfo, items: ChatItem[], preambles?: string[]): Promise<void>;
181
183
  deleteRecord(chatId: string): Promise<boolean>;
182
184
  lastTurn(chatId: string): Promise<ChatTurnItem | null>;
183
- addNote(chatId: string, level: 'info' | 'warning' | 'error', text: string): Promise<void>;
185
+ addNote(chatId: string, level: 'info' | 'warning' | 'error', text: string, message?: ChatNoteMessage): Promise<void>;
184
186
  deliverNote(chatId: string, delivery: {
185
187
  noteId: string;
186
188
  note: string;
@@ -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);
@@ -643,7 +648,7 @@ export class ChatCore {
643
648
  return stored?.items.findLast((item) => item.kind === 'turn') ?? null;
644
649
  }
645
650
  /* A line from the host in a chat's thread, loading the chat when nobody has; nothing for a chat that is gone. */
646
- async addNote(chatId, level, text) {
651
+ async addNote(chatId, level, text, message) {
647
652
  await this.loaded(chatId);
648
653
  if (!(await this.hasStored(chatId))) {
649
654
  return;
@@ -651,7 +656,7 @@ export class ChatCore {
651
656
  if (!this.chats.has(chatId)) {
652
657
  await this.create({ chatId });
653
658
  }
654
- this.chats.get(chatId)?.addNote(level, text);
659
+ this.chats.get(chatId)?.addNote(level, text, message);
655
660
  }
656
661
  /*
657
662
  * Leaves a note and its preamble in a chat, loading that chat when nobody has; false when it was
@@ -1,4 +1,4 @@
1
- import type { ChatAttachment, ChatCheckpointDiff, ChatEvent, ChatGoalAction, ChatInfo, ChatItem, ChatQueuedMessage, ChatQuoteSource, ChatSkill, ChatUiChoiceOrigin, ChatUiChoicePayload, ChatUiQueryState, ModelSelection, RuntimeMode } from '@adecore/agent-contracts';
1
+ import type { ChatAttachment, ChatCheckpointDiff, ChatEvent, ChatGoalAction, ChatInfo, ChatItem, ChatNoteMessage, ChatQueuedMessage, ChatQuoteSource, ChatSkill, ChatUiChoiceOrigin, ChatUiChoicePayload, ChatUiQueryState, ModelSelection, RuntimeMode } from '@adecore/agent-contracts';
2
2
  import type { ChatProvider } from '../providers/provider.ts';
3
3
  import type { LimitsUpdate } from '../usage/limits/normalize.ts';
4
4
  import type { BackendLaunch } from './backend.ts';
@@ -211,7 +211,7 @@ export declare class ChatSession {
211
211
  preamble: string;
212
212
  }): boolean;
213
213
  upsertItems(items: readonly ChatItem[]): void;
214
- addNote(level: 'info' | 'warning' | 'error', text: string): void;
214
+ addNote(level: 'info' | 'warning' | 'error', text: string, message?: ChatNoteMessage): void;
215
215
  end(reason: string): void;
216
216
  restart(): Promise<void>;
217
217
  dropProcess(): Promise<void>;
@@ -1057,8 +1057,18 @@ export class ChatSession {
1057
1057
  this.options.persistSoon();
1058
1058
  }
1059
1059
  /* A line in the thread outside any turn, for something the host has to say about work it gave up on. */
1060
- addNote(level, text) {
1061
- this.emit([this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: Date.now(), turnId: null, level, text })]);
1060
+ addNote(level, text, message) {
1061
+ this.emit([
1062
+ this.thread.upsert({
1063
+ id: newId('note'),
1064
+ kind: 'note',
1065
+ createdAt: Date.now(),
1066
+ turnId: null,
1067
+ level,
1068
+ text,
1069
+ ...(message === undefined ? {} : { message })
1070
+ })
1071
+ ]);
1062
1072
  this.options.persist();
1063
1073
  }
1064
1074
  /*
@@ -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) => {
@@ -1,4 +1,4 @@
1
- import type { ChatItem } from '@adecore/agent-contracts';
1
+ import type { ChatItem, ChatNoteMessage } from '@adecore/agent-contracts';
2
2
  import type { ChatCore } from '../chat/chat-core.ts';
3
3
  import type { Notice, NoticeStore } from './notice-store.ts';
4
4
  export interface MessageWords {
@@ -20,6 +20,7 @@ export declare function chatNoticeTargets(chats: Pick<ChatCore, 'get' | 'hasStor
20
20
  export declare function deliverToChat(store: Pick<NoticeStore, 'put'>, targets: ChatNoticeTargets, notice: Omit<Notice, 'createdAt'>): Promise<ChatDelivery>;
21
21
  export interface NoticeChat {
22
22
  has(id: string): Promise<boolean>;
23
- note(id: string, text: string): Promise<void>;
23
+ note(id: string, text: string, message: ChatNoteMessage): Promise<void>;
24
24
  }
25
+ export declare function noticeMessage(notice: Notice): ChatNoteMessage;
25
26
  export declare function showNotices(store: Pick<NoticeStore, 'show'>, chat: NoticeChat, words: Pick<MessageWords, 'shown'>, targetId: string): Promise<void>;
@@ -57,6 +57,10 @@ export async function deliverToChat(store, targets, notice) {
57
57
  }
58
58
  return { outcome: 'wake', waiting };
59
59
  }
60
+ /* The message itself on the line in the thread, so a client can draw it as said to the chat. */
61
+ export function noticeMessage(notice) {
62
+ return { from: notice.from, title: notice.fromTitle, text: notice.text };
63
+ }
60
64
  /*
61
65
  * Shows a person the message in the chat's thread the moment it lands, not when the model gets round
62
66
  * to it. An id no chat holds shows and marks nothing, so a chat that opens on it later still has all of it.
@@ -66,6 +70,6 @@ export async function showNotices(store, chat, words, targetId) {
66
70
  return;
67
71
  }
68
72
  for (const notice of await store.show(targetId)) {
69
- await chat.note(targetId, words.shown(notice));
73
+ await chat.note(targetId, words.shown(notice), noticeMessage(notice));
70
74
  }
71
75
  }
@@ -1,5 +1,6 @@
1
+ import type { ChatNoteMessage } from '@adecore/agent-contracts';
1
2
  import type { PromptNotes } from '../chat/chat-session.ts';
2
- import type { MessageWords } from './deliver-notice.ts';
3
+ import { type MessageWords } from './deliver-notice.ts';
3
4
  import type { NoticeStore } from './notice-store.ts';
4
5
  export declare class NoticeNotes implements PromptNotes {
5
6
  private readonly store;
@@ -12,4 +13,7 @@ export declare class NoticeNotes implements PromptNotes {
12
13
  };
13
14
  reset(): void;
14
15
  }
15
- export declare function unshownNotes(store: Pick<NoticeStore, 'show'>, chatId: string, words: Pick<MessageWords, 'shown'>): Promise<string[]>;
16
+ export declare function unshownNotes(store: Pick<NoticeStore, 'show'>, chatId: string, words: Pick<MessageWords, 'shown'>): Promise<{
17
+ text: string;
18
+ message: ChatNoteMessage;
19
+ }[]>;
@@ -1,3 +1,4 @@
1
+ import { noticeMessage } from './deliver-notice.js';
1
2
  /*
2
3
  * What another node left for a chat, taken from the queue in front of its next prompt so the model
3
4
  * hears it once. Nothing of it is shown: a person read each message in the thread as it landed
@@ -22,5 +23,5 @@ export class NoticeNotes {
22
23
  * reload writes none again. The model still hears the messages themselves from the queue.
23
24
  */
24
25
  export async function unshownNotes(store, chatId, words) {
25
- return (await store.show(chatId)).map((notice) => words.shown(notice));
26
+ return (await store.show(chatId)).map((notice) => ({ text: words.shown(notice), message: noticeMessage(notice) }));
26
27
  }
@@ -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.46.3",
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.46.3",
97
- "@adecore/intelligent-ui": "0.46.3",
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": {
@@ -17,6 +17,7 @@ import {
17
17
  type ChatGoalAction,
18
18
  type ChatInfo,
19
19
  type ChatItem,
20
+ type ChatNoteMessage,
20
21
  type ChatQueuedMessage,
21
22
  type ChatUiChoicePayload,
22
23
  type ChatUiQueryPayload,
@@ -116,6 +117,11 @@ export interface ChatCoreHooks {
116
117
  admit?(chatId: string, cwd: string): Promise<void>;
117
118
  /* The model a new chat starts on when the host already knows; the explicit one still wins. */
118
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;
119
125
  /* The mode a chat runs in, which a host may narrow whatever its record or its client says. */
120
126
  runtimeModeFor?(chatId: string, mode: RuntimeMode): RuntimeMode;
121
127
  /* What the agent is told once at the start of every process of this chat, in place of `instructions`; null for nothing. */
@@ -386,6 +392,11 @@ export class ChatCore {
386
392
  }
387
393
 
388
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
+
389
400
  requireAccount(kind: AgentKind, account: string | undefined): void {
390
401
  launchEnv(this.accounts, kind, account, this.env);
391
402
  }
@@ -478,9 +489,9 @@ export class ChatCore {
478
489
  const cwd = stored?.info.cwd ?? payload.cwd ?? homeFolder(this.env);
479
490
  await this.hooks.admit?.(payload.chatId, cwd);
480
491
  // A thread on disk keeps its account too. One it lost still opens, and says why on its next turn.
481
- // A new one without an account takes the person's pick for this CLI, which is refused rather than replaced when it went.
482
- // The pick is checked as named, before the default account loses its id, so one that is turned off is refused too.
483
- 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);
484
495
 
485
496
  if (!stored) {
486
497
  this.requireAccount(kind, requested);
@@ -625,7 +636,8 @@ export class ChatCore {
625
636
  return;
626
637
  }
627
638
 
628
- // 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);
629
641
  this.requireAccount(kind, requested);
630
642
  if (holdsConversation(session.info) && !this.canContinue(kind, from, to)) {
631
643
  const label = this.accountLabel(kind, to);
@@ -974,7 +986,7 @@ export class ChatCore {
974
986
  }
975
987
 
976
988
  /* A line from the host in a chat's thread, loading the chat when nobody has; nothing for a chat that is gone. */
977
- async addNote(chatId: string, level: 'info' | 'warning' | 'error', text: string): Promise<void> {
989
+ async addNote(chatId: string, level: 'info' | 'warning' | 'error', text: string, message?: ChatNoteMessage): Promise<void> {
978
990
  await this.loaded(chatId);
979
991
 
980
992
  if (!(await this.hasStored(chatId))) {
@@ -985,7 +997,7 @@ export class ChatCore {
985
997
  await this.create({ chatId });
986
998
  }
987
999
 
988
- this.chats.get(chatId)?.addNote(level, text);
1000
+ this.chats.get(chatId)?.addNote(level, text, message);
989
1001
  }
990
1002
 
991
1003
  /*
@@ -6,6 +6,7 @@ import type {
6
6
  ChatGoalAction,
7
7
  ChatInfo,
8
8
  ChatItem,
9
+ ChatNoteMessage,
9
10
  ChatQueuedMessage,
10
11
  ChatQuoteSource,
11
12
  ChatSkill,
@@ -1470,8 +1471,18 @@ export class ChatSession {
1470
1471
  }
1471
1472
 
1472
1473
  /* A line in the thread outside any turn, for something the host has to say about work it gave up on. */
1473
- addNote(level: 'info' | 'warning' | 'error', text: string): void {
1474
- this.emit([this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: Date.now(), turnId: null, level, text })]);
1474
+ addNote(level: 'info' | 'warning' | 'error', text: string, message?: ChatNoteMessage): void {
1475
+ this.emit([
1476
+ this.thread.upsert({
1477
+ id: newId('note'),
1478
+ kind: 'note',
1479
+ createdAt: Date.now(),
1480
+ turnId: null,
1481
+ level,
1482
+ text,
1483
+ ...(message === undefined ? {} : { message })
1484
+ })
1485
+ ]);
1475
1486
  this.options.persist();
1476
1487
  }
1477
1488
 
@@ -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) => {
@@ -1,4 +1,4 @@
1
- import type { ChatItem } from '@adecore/agent-contracts';
1
+ import type { ChatItem, ChatNoteMessage } from '@adecore/agent-contracts';
2
2
  import type { ChatCore } from '../chat/chat-core.ts';
3
3
  import type { Notice, NoticeStore } from './notice-store.ts';
4
4
 
@@ -111,7 +111,12 @@ export interface NoticeChat {
111
111
  /* Whether the host holds a chat under the id, running or on disk. */
112
112
  has(id: string): Promise<boolean>;
113
113
  /* Puts a line in that chat's thread, loading the chat when nobody has. */
114
- note(id: string, text: string): Promise<void>;
114
+ note(id: string, text: string, message: ChatNoteMessage): Promise<void>;
115
+ }
116
+
117
+ /* The message itself on the line in the thread, so a client can draw it as said to the chat. */
118
+ export function noticeMessage(notice: Notice): ChatNoteMessage {
119
+ return { from: notice.from, title: notice.fromTitle, text: notice.text };
115
120
  }
116
121
 
117
122
  /*
@@ -124,6 +129,6 @@ export async function showNotices(store: Pick<NoticeStore, 'show'>, chat: Notice
124
129
  }
125
130
 
126
131
  for (const notice of await store.show(targetId)) {
127
- await chat.note(targetId, words.shown(notice));
132
+ await chat.note(targetId, words.shown(notice), noticeMessage(notice));
128
133
  }
129
134
  }
@@ -1,5 +1,6 @@
1
+ import type { ChatNoteMessage } from '@adecore/agent-contracts';
1
2
  import type { PromptNotes } from '../chat/chat-session.ts';
2
- import type { MessageWords } from './deliver-notice.ts';
3
+ import { noticeMessage, type MessageWords } from './deliver-notice.ts';
3
4
  import type { NoticeStore } from './notice-store.ts';
4
5
 
5
6
  /*
@@ -29,6 +30,10 @@ export class NoticeNotes implements PromptNotes {
29
30
  * The lines a chat that just loaded owes a person for what landed while nobody held it, marked so a
30
31
  * reload writes none again. The model still hears the messages themselves from the queue.
31
32
  */
32
- export async function unshownNotes(store: Pick<NoticeStore, 'show'>, chatId: string, words: Pick<MessageWords, 'shown'>): Promise<string[]> {
33
- return (await store.show(chatId)).map((notice) => words.shown(notice));
33
+ export async function unshownNotes(
34
+ store: Pick<NoticeStore, 'show'>,
35
+ chatId: string,
36
+ words: Pick<MessageWords, 'shown'>
37
+ ): Promise<{ text: string; message: ChatNoteMessage }[]> {
38
+ return (await store.show(chatId)).map((notice) => ({ text: words.shown(notice), message: noticeMessage(notice) }));
34
39
  }
@@ -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';