@byokit/openclaw 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/kit.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { SealingAdapter } from '@byokit/secrets';
2
2
  import { type AddKeyResult } from './keys.ts';
3
3
  import { type RouteView } from './routes.ts';
4
4
  import type { BrowserHost, BrowserOptions } from './browser.ts';
5
- import type { Approval, CallOptions, Decision, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, Hello, KitState, KitEventName, KitEventPayload, LearningCapture, LearningMode, Member, RunEnd, RunEvent, RunSpec, OutputSchema, SchemaOutput, SignInOptions, SignInView, ToolHost, ToolSpec } from './types.ts';
5
+ import type { Account, AccountId, Approval, CallOptions, Decision, Defaults, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, Hello, KitState, KitEventName, KitEventPayload, LearningCapture, LearningMode, Member, ModelInfo, Room, RunEnd, RunEvent, RunSpec, OutputSchema, SchemaOutput, SignInOptions, SignInView, ToolHost, ToolSpec } from './types.ts';
6
6
  export type KitOptions = {
7
7
  stateDir: string;
8
8
  authSeal?: SealingAdapter;
@@ -54,6 +54,9 @@ export declare class OpenClawKit {
54
54
  private transport?;
55
55
  private greeting?;
56
56
  private members?;
57
+ private readonly locks;
58
+ private readonly routeMap;
59
+ private accountsCtl?;
57
60
  private readonly bridge;
58
61
  private readonly keys;
59
62
  private readonly admission;
@@ -110,6 +113,27 @@ export declare class OpenClawKit {
110
113
  done: Promise<SignInView>;
111
114
  };
112
115
  signOut(member: Member, provider: string): Promise<void>;
116
+ accounts(member: Member): Promise<Account[]>;
117
+ models(member: Member, id: AccountId): Promise<ModelInfo[]>;
118
+ room(member: Member, id: AccountId, demand?: string[]): Promise<Room>;
119
+ /** Sign in one more account: on the member agent while it holds none of the provider, else on a new sealed agent. */
120
+ addAccount(member: Member, o: {
121
+ authChoice: string;
122
+ via?: 'browser' | 'code';
123
+ name?: string;
124
+ again?: AccountId;
125
+ }, on: (v: SignInView) => void): Promise<{
126
+ id: AccountId;
127
+ paste(text: string): void;
128
+ cancel(): void;
129
+ done: Promise<{
130
+ view: SignInView;
131
+ id: AccountId;
132
+ }>;
133
+ }>;
134
+ renameAccount(member: Member, id: AccountId, name: string): Promise<Account>;
135
+ defaults(member: Member): Promise<Defaults>;
136
+ setDefaults(member: Member, d: Defaults): Promise<void>;
113
137
  /** Save an explicitly supplied API key (billed per use) in this member's separate key agent. */
114
138
  addKey(agentId: Member, o: {
115
139
  authChoice: string;
package/dist/kit.js CHANGED
@@ -10,6 +10,8 @@ import { Engine, refusedOnly } from "./engine.js";
10
10
  import { AuthStoreSealSizeError } from "./auth-store.js";
11
11
  import { gatewayTransport } from "./transport.js";
12
12
  import { createMembers } from "./members.js";
13
+ import { createAccounts } from "./accounts.js";
14
+ import { createLocks } from "./locks.js";
13
15
  import { keyAgentId, KEY_PREFIX, MEMBER_ID } from "./members.js";
14
16
  import { confirmRetainedLogin as confirmLogin, migrateRetainedLogin as migrateLogin } from "./migrate.js";
15
17
  import { createRuns, signedInProviders } from "./runs.js";
@@ -429,6 +431,9 @@ export class OpenClawKit {
429
431
  transport;
430
432
  greeting;
431
433
  members;
434
+ locks = createLocks();
435
+ routeMap = new Map();
436
+ accountsCtl;
432
437
  // One bridge and one approval surface per kit: listeners and run registrations survive reconnects (B3).
433
438
  // Only the socket itself is per-connection. Approvals always reaches the live transport through this.request().
434
439
  bridge;
@@ -741,6 +746,10 @@ export class OpenClawKit {
741
746
  if (!operatorMethods.has(name) && !nodeMethods.has(name))
742
747
  this.o.log?.(`gateway has unknown method: ${name}`);
743
748
  this.members = createMembers({ request: transport.request.bind(transport), root: this.engine.root });
749
+ this.accountsCtl = createAccounts({ request: transport.request.bind(transport), root: this.engine.root,
750
+ ensure: (member) => this.ensureMember(member), locks: this.locks,
751
+ signIn: (agentId, o, on, fresh) => startSignIn({ ...this.signInCtx(), ensure: async () => ({ agentId }), fresh }, agentId, o, on),
752
+ log: this.o.log });
744
753
  // The fail-closed tool bridge feeds the kit's single Approval surface (5.9, O5).
745
754
  await this.bridge.start();
746
755
  this.off.push(transport.onEvent((e) => this.approvalsCtl.handleEvent(e)));
@@ -789,6 +798,7 @@ export class OpenClawKit {
789
798
  this.transport = undefined;
790
799
  this.greeting = undefined;
791
800
  this.members = undefined;
801
+ this.accountsCtl = undefined;
792
802
  this.browserPolicySessions.clear();
793
803
  this.bridge.stop();
794
804
  await transport?.stop();
@@ -919,6 +929,44 @@ export class OpenClawKit {
919
929
  signOut(member, provider) {
920
930
  return engineSignOut(this.signInCtx(), member, provider);
921
931
  }
932
+ // accounts (5.15, O14 steps S5, S6, S9 and S10): the per-member index with listing, adding, defaults, rename, models
933
+ // and room; `run({ account })` picks among them. Removing, `pick` and move arrive in later steps.
934
+ accounts(member) {
935
+ if (!this.accountsCtl)
936
+ return Promise.reject(new Error('gateway not ready'));
937
+ return this.accountsCtl.list(member);
938
+ }
939
+ models(member, id) {
940
+ if (!this.accountsCtl)
941
+ return Promise.reject(new Error('gateway not ready'));
942
+ return this.accountsCtl.models(member, id);
943
+ }
944
+ room(member, id, demand) {
945
+ if (!this.accountsCtl)
946
+ return Promise.reject(new Error('gateway not ready'));
947
+ return this.accountsCtl.room(member, id, demand);
948
+ }
949
+ /** Sign in one more account: on the member agent while it holds none of the provider, else on a new sealed agent. */
950
+ addAccount(member, o, on) {
951
+ if (!this.accountsCtl)
952
+ return Promise.reject(new Error('gateway not ready'));
953
+ return this.accountsCtl.add(member, o, on);
954
+ }
955
+ renameAccount(member, id, name) {
956
+ if (!this.accountsCtl)
957
+ return Promise.reject(new Error('gateway not ready'));
958
+ return this.accountsCtl.rename(member, id, name);
959
+ }
960
+ defaults(member) {
961
+ if (!this.accountsCtl)
962
+ return Promise.reject(new Error('gateway not ready'));
963
+ return this.accountsCtl.defaults(member);
964
+ }
965
+ setDefaults(member, d) {
966
+ if (!this.accountsCtl)
967
+ return Promise.reject(new Error('gateway not ready'));
968
+ return this.accountsCtl.setDefaults(member, d);
969
+ }
922
970
  /** Save an explicitly supplied API key (billed per use) in this member's separate key agent. */
923
971
  addKey(agentId, o) {
924
972
  return this.keys.add(agentId, o);
@@ -946,6 +994,9 @@ export class OpenClawKit {
946
994
  ensure: (member) => this.ensureMember(member),
947
995
  bridge: { register: (run, tools, runId) => this.bridge.register(run, tools && this.o.browser ? [...tools, 'request_sign_in'] : tools, runId) },
948
996
  tools: new Set(this.toolNames()),
997
+ ...(this.accountsCtl ? { accounts: this.accountsCtl } : {}),
998
+ locks: this.locks,
999
+ routes: this.routeMap,
949
1000
  });
950
1001
  }
951
1002
  /** The app tool names (`KitOptions.tools`): the only names `RunSpec.tools` may carry. */
@@ -0,0 +1,5 @@
1
+ export declare function createLocks(): {
2
+ shared<T>(agentId: string, work: () => Promise<T>): Promise<T>;
3
+ exclusive<T>(agentIds: string[], work: () => Promise<T>): Promise<T>;
4
+ live(agentId: string): boolean;
5
+ };
package/dist/locks.js ADDED
@@ -0,0 +1,85 @@
1
+ export function createLocks() {
2
+ const slots = new Map();
3
+ const slot = (id) => {
4
+ let s = slots.get(id);
5
+ if (!s) {
6
+ s = { readers: 0, writer: false, queue: [] };
7
+ slots.set(id, s);
8
+ }
9
+ return s;
10
+ };
11
+ // FIFO: an exclusive waiter blocks later shared waiters until the readers ahead of it drain.
12
+ const pump = (s) => {
13
+ if (s.writer)
14
+ return;
15
+ const head = s.queue[0];
16
+ if (!head)
17
+ return;
18
+ if (head.mode === 'exclusive') {
19
+ if (s.readers === 0) {
20
+ s.queue.shift();
21
+ s.writer = true;
22
+ head.grant();
23
+ }
24
+ return;
25
+ }
26
+ while (s.queue[0]?.mode === 'shared') {
27
+ const w = s.queue.shift();
28
+ s.readers++;
29
+ w.grant();
30
+ }
31
+ };
32
+ const acquire = (id, mode) => {
33
+ const s = slot(id);
34
+ if (mode === 'shared' && !s.writer && s.queue.every((w) => w.mode === 'shared')) {
35
+ s.readers++;
36
+ return Promise.resolve();
37
+ }
38
+ if (mode === 'exclusive' && !s.writer && s.readers === 0 && s.queue.length === 0) {
39
+ s.writer = true;
40
+ return Promise.resolve();
41
+ }
42
+ return new Promise((grant) => {
43
+ s.queue.push(mode === 'exclusive' ? { mode, grant: () => { s.writer = true; grant(); } } : { mode, grant });
44
+ pump(s);
45
+ });
46
+ };
47
+ const release = (id, mode) => {
48
+ const s = slot(id);
49
+ if (mode === 'shared')
50
+ s.readers = Math.max(0, s.readers - 1);
51
+ else
52
+ s.writer = false;
53
+ pump(s);
54
+ if (s.readers === 0 && !s.writer && s.queue.length === 0)
55
+ slots.delete(id);
56
+ };
57
+ return {
58
+ async shared(agentId, work) {
59
+ await acquire(agentId, 'shared');
60
+ try {
61
+ return await work();
62
+ }
63
+ finally {
64
+ release(agentId, 'shared');
65
+ }
66
+ },
67
+ async exclusive(agentIds, work) {
68
+ // Sorted so two exclusive takes over overlapping sets cannot deadlock each other.
69
+ const ids = [...new Set(agentIds)].sort();
70
+ for (const id of ids)
71
+ await acquire(id, 'exclusive');
72
+ try {
73
+ return await work();
74
+ }
75
+ finally {
76
+ for (const id of [...ids].reverse())
77
+ release(id, 'exclusive');
78
+ }
79
+ },
80
+ live(agentId) {
81
+ const s = slots.get(agentId);
82
+ return !!s && (s.readers > 0 || s.writer);
83
+ },
84
+ };
85
+ }
package/dist/members.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type { GatewayTransport, Member } from './types.ts';
2
2
  export declare const MEMBER_ID: RegExp;
3
+ export declare const NEW_MEMBER_ID: RegExp;
3
4
  export declare const KEY_PREFIX = "byokit-key-";
4
5
  export declare const BASE_AGENT = "byokit-base";
5
6
  export declare const keyAgentId: (member: Member) => string;
7
+ /** Resolves once agents.list shows `id`, which agents.create answers before. */
8
+ export declare function untilListed(request: GatewayTransport['request'], id: string): Promise<void>;
6
9
  export declare function createMembers(ctx: {
7
10
  request: GatewayTransport['request'];
8
11
  root: string;
package/dist/members.js CHANGED
@@ -2,7 +2,7 @@ import { join } from 'node:path';
2
2
  // Any member that may run: D9's older rule, which a member created before O14 still matches (D17).
3
3
  export const MEMBER_ID = /^[a-z][a-z0-9-]{0,31}$/;
4
4
  // D9 for a member the kit creates: no `--` (account agents are `<member>--<6 hex>`) and at most 24 characters.
5
- const NEW_MEMBER_ID = /^[a-z](?!.*--)[a-z0-9-]{0,23}$/;
5
+ export const NEW_MEMBER_ID = /^[a-z](?!.*--)[a-z0-9-]{0,23}$/;
6
6
  export const KEY_PREFIX = 'byokit-key-';
7
7
  // The inherited auth base every agent reads through (5.15): the kit never creates, signs in to or runs it.
8
8
  export const BASE_AGENT = 'byokit-base';
@@ -13,9 +13,18 @@ export const keyAgentId = (member) => `${KEY_PREFIX}${member}`;
13
13
  // at once fails as 'unknown agent id'. ensure resolves only once agents.list shows the member.
14
14
  const VISIBLE_TIMEOUT_MS = 5_000;
15
15
  const VISIBLE_POLL_MS = 25;
16
+ const listed = (request, id) => request('agents.list').then((list) => list.agents.some((agent) => agent.id === id));
17
+ /** Resolves once agents.list shows `id`, which agents.create answers before. */
18
+ export async function untilListed(request, id) {
19
+ const deadline = Date.now() + VISIBLE_TIMEOUT_MS;
20
+ while (!await listed(request, id)) {
21
+ if (Date.now() >= deadline)
22
+ throw new Error(`agent ${id} is not listed by agents.list after agents.create`);
23
+ await new Promise((resolve) => setTimeout(resolve, VISIBLE_POLL_MS));
24
+ }
25
+ }
16
26
  export function createMembers(ctx) {
17
27
  const cache = new Map();
18
- const listed = (member) => ctx.request('agents.list').then((list) => list.agents.some((agent) => agent.id === member));
19
28
  return { ensure(member) {
20
29
  const invalid = () => new Error(`invalid member id: ${member}`);
21
30
  if (!MEMBER_ID.test(member) || member.startsWith(KEY_PREFIX) || RESERVED.has(member))
@@ -25,16 +34,11 @@ export function createMembers(ctx) {
25
34
  return existing;
26
35
  const workspace = join(ctx.root, 'workspaces', member);
27
36
  const result = (async () => {
28
- if (!await listed(member)) {
37
+ if (!await listed(ctx.request, member)) {
29
38
  if (!NEW_MEMBER_ID.test(member) || member.startsWith('byokit-'))
30
39
  throw invalid();
31
40
  await ctx.request('agents.create', { name: member, workspace });
32
- const deadline = Date.now() + VISIBLE_TIMEOUT_MS;
33
- while (!await listed(member)) {
34
- if (Date.now() >= deadline)
35
- throw new Error(`agent ${member} is not listed by agents.list after agents.create`);
36
- await new Promise((resolve) => setTimeout(resolve, VISIBLE_POLL_MS));
37
- }
41
+ await untilListed(ctx.request, member);
38
42
  }
39
43
  return { agentId: member, workspace };
40
44
  })();
package/dist/pick.d.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import type { Account, AccountId, AccountPick, Considered, Defaults, ModelInfo, PickWhy, Room, RunSelection } from './types.ts';
2
2
  export type RoomOf = (a: Account, demand: readonly string[]) => Room;
3
+ /** The lowercased provider of a well-formed `provider/model` reference; undefined when the reference is malformed. */
4
+ export declare function providerOf(model: string): string | undefined;
3
5
  /** Every account's `Considered` row, in list order, with the kit-only `bound` applied. */
4
- export declare function consider(accounts: readonly Account[], defaults: Defaults, sel: RunSelection, room: RoomOf, nowMs: number, models?: (a: Account) => readonly ModelInfo[], bound?: readonly AccountId[]): Considered[];
6
+ export declare function consider(accounts: readonly Account[], defaults: Defaults, sel: RunSelection, room: RoomOf, nowMs: number, models?: (a: Account) => readonly ModelInfo[] | undefined, bound?: readonly AccountId[]): Considered[];
5
7
  /** Auto's winner only; `candidates` are the eligible accounts. Pure and portable; no timers, reads or mid-run switching. */
6
8
  export declare function chooseAccount(candidates: readonly Account[], room: (a: Account) => Room, nowMs: number): {
7
9
  account: Account;
8
10
  why: PickWhy;
9
11
  } | undefined;
10
- /** Resolve once before starting. Without `models`, membership is host-validated and a missing model is ''. */
11
- export declare function resolveSelection(accounts: readonly Account[], defaults: Defaults, sel: RunSelection, room: RoomOf, nowMs: number, models?: (a: Account) => readonly ModelInfo[], bound?: readonly AccountId[]): AccountPick;
12
+ /** Resolve once before starting. Without `models`, membership is host-validated and a missing model is ''. A row whose
13
+ * models are unknown (`undefined`) host-validates membership by provider; with no model asked for it runs the saved
14
+ * default, else model '' (no override: the engine's own default). */
15
+ export declare function resolveSelection(accounts: readonly Account[], defaults: Defaults, sel: RunSelection, room: RoomOf, nowMs: number, models?: (a: Account) => readonly ModelInfo[] | undefined, bound?: readonly AccountId[]): AccountPick;
package/dist/pick.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { words } from "./words.js";
2
2
  const DAY_MS = 24 * 60 * 60 * 1000;
3
3
  const finite = (n) => typeof n === 'number' && Number.isFinite(n);
4
+ /** The lowercased provider of a well-formed `provider/model` reference; undefined when the reference is malformed. */
5
+ export function providerOf(model) {
6
+ const at = model.indexOf('/');
7
+ return at <= 0 || at === model.length - 1 || /[\s@]/.test(model) ? undefined : model.slice(0, at).toLowerCase();
8
+ }
4
9
  const reset = (r) => r.resetsAt ?? Infinity;
5
10
  const roomWords = (left, span) => words(`room.${span}`, { left: `${Math.round(left)}%` });
6
11
  // "3:40pm", or "Fri 3:40pm" when it isn't today (matches @byokit/accounts' clock).
@@ -35,8 +40,18 @@ function considerRows(accounts, room, nowMs, demand = [], models, provider, name
35
40
  else if (account.billing !== 'subscription')
36
41
  row.out = 'billing';
37
42
  }
43
+ if (!row.out && models) {
44
+ const listed = models(account) ?? [];
45
+ if (listed.length && listed.every((m) => m.why === 'resting')) {
46
+ row.out = 'resting';
47
+ const untils = listed.map((m) => m.until).filter(finite);
48
+ if (untils.length)
49
+ row.until = Math.min(...untils);
50
+ }
51
+ }
38
52
  if (!row.out && demand.length && models) {
39
- const available = models(account);
53
+ const available = models(account) ?? demand.filter((id) => id.split('/')[0].toLowerCase() === account.provider.toLowerCase())
54
+ .map((id) => ({ id, name: id, available: true }));
40
55
  const missing = demand.find((id) => !available.some((m) => m.id === id && m.available));
41
56
  if (missing !== undefined) {
42
57
  row.out = 'model';
@@ -105,7 +120,9 @@ function nameOf(accounts, defaults, sel, bound) {
105
120
  (bound === undefined || bound.includes(defaultAccount.id));
106
121
  return sel.account === 'default' ? (usable ? defaultAccount.id : undefined) : sel.account === 'auto' ? undefined : sel.account;
107
122
  }
108
- /** Resolve once before starting. Without `models`, membership is host-validated and a missing model is ''. */
123
+ /** Resolve once before starting. Without `models`, membership is host-validated and a missing model is ''. A row whose
124
+ * models are unknown (`undefined`) host-validates membership by provider; with no model asked for it runs the saved
125
+ * default, else model '' (no override: the engine's own default). */
109
126
  export function resolveSelection(accounts, defaults, sel, room, nowMs, models, bound) {
110
127
  const demand = [...new Set([...(sel.model ? [sel.model] : []), ...(sel.needs ?? [])])];
111
128
  const defaultAccount = accounts.find((a) => a.id === defaults.account);
@@ -114,10 +131,14 @@ export function resolveSelection(accounts, defaults, sel, room, nowMs, models, b
114
131
  const rows = considerRows(accounts, (a) => room(a, demand), nowMs, demand, models, provider, named, bound);
115
132
  const considered = rows.map((c) => c.row);
116
133
  const auto = rank(rows); // also fills explanations for every eligible row, even on explicit selection
134
+ const restingPick = (c) => ({ ok: false, code: 'resting', account: c.account.id, until: c.row.until,
135
+ reason: words('pick.out.resting', { time: finite(c.row.until) ? clock(c.row.until) : '' }), considered });
117
136
  if (named !== undefined) {
118
137
  const chosen = rows.find((c) => c.account.id === named);
119
138
  if (!chosen)
120
139
  return { ok: false, code: 'unknown_account', reason: words('auto.none', { provider: provider ?? '' }), considered };
140
+ if (chosen.row.out === 'resting')
141
+ return restingPick(chosen);
121
142
  if (chosen.row.out)
122
143
  return { ok: false, code: 'not_included', reason: words(`pick.out.${chosen.row.out}`, { model: chosen.row.missing ?? '' }), considered };
123
144
  return finish(chosen.account, chosen.row, sel.account === 'default' ? 'default' : 'chosen', sel.account === 'default' ? 'default' : 'chosen');
@@ -129,11 +150,19 @@ export function resolveSelection(accounts, defaults, sel, room, nowMs, models, b
129
150
  }
130
151
  else if (auto)
131
152
  return finish(auto.account, auto.row, 'auto', auto.why);
153
+ const eligible = rows.filter((c) => !c.row.out || c.row.out === 'resting');
154
+ const rests = eligible.filter((c) => c.row.out === 'resting');
155
+ if (rests.length)
156
+ return restingPick(rests.reduce((a, b) => ((b.row.until ?? Infinity) < (a.row.until ?? Infinity) ? b : a)));
132
157
  return { ok: false, code: 'none', reason: words('auto.none', { provider: provider ?? '' }), considered };
133
158
  function finish(account, row, how, why) {
134
- const available = models?.(account).filter((m) => m.available);
135
- const model = sel.model ?? (defaults.model && (!available || available.some((m) => m.id === defaults.model)) ? defaults.model : available?.[0]?.id) ?? '';
136
- if (models && !model)
159
+ const listed = models?.(account);
160
+ const ready = listed?.filter((m) => m.available && !m.why);
161
+ const usable = ready?.length ? ready : listed?.filter((m) => m.available && m.why === 'signed_out');
162
+ const saved = defaults.model && (usable ? usable.some((m) => m.id === defaults.model)
163
+ : providerOf(defaults.model) === account.provider.toLowerCase()) ? defaults.model : undefined;
164
+ const model = sel.model ?? saved ?? usable?.[0]?.id ?? '';
165
+ if (listed && !model)
137
166
  return { ok: false, code: 'not_included', reason: words('pick.out.model', { model: sel.model ?? '' }), considered };
138
167
  row.reason = why;
139
168
  const slots = { name: account.name, provider: account.provider };
package/dist/runs.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { Bridge } from './bridge.ts';
2
+ import type { createAccounts } from './accounts.ts';
3
+ import type { createLocks } from './locks.ts';
2
4
  import { authStatus } from './auth-status.ts';
3
5
  import { outputSchema, type OutputSchema, type SchemaOutput } from './output.ts';
4
6
  import type { GatewayTransport, Member, RunEnd, RunEvent, RunSpec } from './types.ts';
@@ -18,6 +20,11 @@ export declare function createRuns(ctx: {
18
20
  }>;
19
21
  bridge: Pick<Bridge, 'register'>;
20
22
  tools: ReadonlySet<string>;
23
+ accounts?: Pick<ReturnType<typeof createAccounts>, 'pick' | 'rest'>;
24
+ locks?: ReturnType<typeof createLocks>;
25
+ routes: Map<string, {
26
+ session: string;
27
+ }>;
21
28
  }): {
22
29
  run<const S extends OutputSchema | undefined = undefined>(spec: RunSpec<S>, on?: (e: RunEvent) => void, keyAgent?: string, preparedOutput?: ReturnType<typeof outputSchema>): Promise<RunEnd<SchemaOutput<S>>>;
23
30
  steer(k: string, t: string): Promise<void>;
package/dist/runs.js CHANGED
@@ -5,12 +5,15 @@ import { authStatus } from "./auth-status.js";
5
5
  import { outputSchema } from "./output.js";
6
6
  import { words } from "./words.js";
7
7
  import { classify } from "./classify.js";
8
+ import { providerOf } from "./pick.js";
8
9
  // The gateway gives up on a silent run after an hour; we wait ten minutes longer for the reply frame itself.
9
10
  const WAIT_MS = 3_600_000;
10
11
  const WAIT_CLIENT_MS = 3_610_000;
11
12
  // The `agent` request's final frame lands with the run's end; a run already over waits no longer than this for it.
12
13
  const FINAL_GRACE_MS = 5_000;
13
14
  const STATUS_MS = 5_000;
15
+ // A resting end whose message names no time rests the account this long (5.15 Runs step 7).
16
+ const REST_MS = 300_000;
14
17
  export const USABLE = new Set(['ok', 'expiring', 'static']);
15
18
  const usable = (row) => typeof row === 'string'
16
19
  || (Array.isArray(row.profiles) && row.profiles.length > 0 ? row.profiles.some((p) => USABLE.has(String(p?.status)))
@@ -62,10 +65,10 @@ function planOf(provider, row) {
62
65
  * a session preference it may rotate away from (docs/runtime-kits.md 5.8), so the kit never sends one.
63
66
  */
64
67
  function account(model) {
65
- const at = model.indexOf('/');
66
- if (at <= 0 || at === model.length - 1 || /[\s@]/.test(model))
68
+ const provider = providerOf(model);
69
+ if (provider === undefined)
67
70
  throw new Error(`refused: "${model}" is not a provider/model reference`);
68
- return { provider: model.slice(0, at).toLowerCase(), model: model.slice(at + 1) }; // the engine lowercases ids
71
+ return { provider, model: model.slice(model.indexOf('/') + 1) }; // the engine lowercases ids
69
72
  }
70
73
  export function createRuns(ctx) {
71
74
  // The plan window the engine last read for the run's provider: `models.authStatus` answers from its cache without
@@ -97,11 +100,83 @@ export function createRuns(ctx) {
97
100
  const output = preparedOutput ?? (spec.schema === undefined ? undefined : outputSchema(spec.schema));
98
101
  const system = [spec.system, output?.prompt].filter(Boolean).join('\n\n');
99
102
  const picked = spec.model === undefined ? undefined : account(spec.model);
103
+ for (const need of spec.needs ?? [])
104
+ account(need);
100
105
  for (const tool of spec.tools ?? [])
101
106
  if (!ctx.tools.has(tool))
102
107
  throw new Error(`refused: "${tool}" is not one of this kit's tools`);
103
- const agentId = keyAgent ?? (await ctx.ensure(spec.member)).agentId;
104
- const sessionKey = keyAgent ? `agent:${keyAgent}:${spec.sessionKey.slice(`agent:${spec.member}:`.length)}` : spec.sessionKey;
108
+ const tail = spec.sessionKey.slice(`agent:${spec.member}:`.length);
109
+ // Runs step 3 (5.15): with `account` and `needs` both absent, exactly the run on the member agent it always was.
110
+ if (spec.account === undefined && !spec.needs?.length) {
111
+ const agentId = keyAgent ?? (await ctx.ensure(spec.member)).agentId;
112
+ return start(spec, on, { agentId, sessionKey: keyAgent ? `agent:${keyAgent}:${tail}` : spec.sessionKey, idempotencyKey, system, picked, output });
113
+ }
114
+ if (keyAgent)
115
+ throw new Error('refused: an API-key run uses its own account');
116
+ if (!ctx.accounts || !ctx.locks)
117
+ throw new Error('gateway not ready');
118
+ const accounts = ctx.accounts;
119
+ const locks = ctx.locks;
120
+ const restAccount = async (id, until) => {
121
+ const at = until ?? Date.now() + REST_MS;
122
+ await accounts.rest(spec.member, id, at);
123
+ return at;
124
+ };
125
+ const signedIn = async (agentId, provider) => !!(await ctx.request('byokit.accounts', { action: 'ready', agentId, provider })).ok;
126
+ const skipped = [];
127
+ for (;;) {
128
+ // Step 4: the selection is resolved once per attempt, before any request; `needs` alone resolves as 'default'.
129
+ const { pick, agent } = await accounts.pick(spec.member, { account: spec.account ?? 'default',
130
+ ...(spec.model !== undefined ? { model: spec.model } : {}), ...(spec.needs ? { needs: spec.needs } : {}) }, skipped);
131
+ if (!pick.ok) {
132
+ if (pick.code === 'unknown_account')
133
+ throw new Error(`unknown account: ${spec.account}`); // a caller bug
134
+ if (pick.code === 'resting')
135
+ return { ok: false, kind: 'resting', until: await restAccount(pick.account, pick.until), message: pick.reason };
136
+ return { ok: false, kind: pick.code === 'none' ? 'signed-out' : pick.code === 'not_included' ? 'plan' : 'other', message: pick.reason };
137
+ }
138
+ const sessionKey = `agent:${agent}:${tail}`;
139
+ const automatic = pick.how === 'auto' || pick.why === 'first_ready';
140
+ // An unbound key runs on the picked account's agent, under the app key's tail. The run holds the agent's shared
141
+ // lock from its ready check to its end. An automatic pick not signed in is skipped before any event; an explicit
142
+ // one announces itself, then ends signed-out.
143
+ const end = await locks.shared(agent, async () => {
144
+ let ready;
145
+ try {
146
+ ready = await signedIn(agent, pick.account.provider);
147
+ }
148
+ catch (error) {
149
+ return failure(error instanceof Error ? error.message : String(error));
150
+ }
151
+ if (!ready && automatic)
152
+ return undefined;
153
+ const route = { session: sessionKey };
154
+ ctx.routes.set(spec.sessionKey, route);
155
+ try {
156
+ on?.({ type: 'account', account: pick.account.id, model: pick.model, sessionKey, how: pick.how, why: pick.why, considered: pick.considered });
157
+ if (!ready)
158
+ return { ok: false, kind: 'signed-out', message: `${pick.account.name} is not signed in` };
159
+ return await start(spec, on, { agentId: agent, sessionKey, idempotencyKey, system,
160
+ picked: pick.model ? account(pick.model) : undefined, output, chosen: pick }); // '' = no override
161
+ }
162
+ finally {
163
+ if (ctx.routes.get(spec.sessionKey) === route)
164
+ ctx.routes.delete(spec.sessionKey);
165
+ }
166
+ });
167
+ if (end === undefined) {
168
+ skipped.push(pick.account.id);
169
+ continue;
170
+ }
171
+ // Step 7: a resting end rests the account, so the next Auto skips it until then.
172
+ if (end.ok || !('kind' in end) || end.kind !== 'resting')
173
+ return end;
174
+ const until = await restAccount(pick.account.id, end.until);
175
+ return { ...end, until };
176
+ }
177
+ };
178
+ const start = async (spec, on, t) => {
179
+ const { agentId, sessionKey, idempotencyKey, system, picked, output, chosen } = t;
105
180
  const release = spec.register !== false ? ctx.bridge.register({ sessionKey, member: spec.member }, spec.tools, idempotencyKey) : undefined;
106
181
  let last;
107
182
  let runId = ''; // gateway events for other runs carry a real runId and never match the empty one
@@ -140,7 +215,7 @@ export function createRuns(ctx) {
140
215
  }
141
216
  });
142
217
  try {
143
- if (picked) {
218
+ if (picked && !chosen) {
144
219
  // The picked account must be the one called: check it is signed in for this member before the run, since
145
220
  // the engine would otherwise fail the run only after admitting it. An explicit provider/model is strict on
146
221
  // the pinned engine (never another provider or model), so this check plus the override is the guarantee.
@@ -207,7 +282,10 @@ export function createRuns(ctx) {
207
282
  const usage = usageOf(agentMeta);
208
283
  const planWindow = typeof agentMeta.provider === 'string'
209
284
  ? await planWindowOf(agentId, agentMeta.provider.toLowerCase()) : undefined;
210
- return { ok: true, text, ...(parsed ? { data: parsed.data } : {}), ...(usage ? { usage } : {}), ...(planWindow ? { planWindow } : {}) };
285
+ const reported = typeof agentMeta.provider === 'string' && typeof agentMeta.model === 'string'
286
+ ? `${agentMeta.provider}/${agentMeta.model}`.toLowerCase() : '';
287
+ return { ok: true, text, ...(parsed ? { data: parsed.data } : {}), ...(usage ? { usage } : {}), ...(planWindow ? { planWindow } : {}),
288
+ ...(chosen ? { account: chosen.account.id, model: chosen.model || reported } : {}) };
211
289
  }
212
290
  if (result.stopReason === 'aborted' || (abortedByEngine && result.status !== 'ok'))
213
291
  return { ok: false, aborted: true };
@@ -227,7 +305,7 @@ export function createRuns(ctx) {
227
305
  };
228
306
  return {
229
307
  run,
230
- steer: (k, t) => ctx.request('sessions.steer', { sessionKey: k, message: t }),
231
- abort: async (k) => { await ctx.request('chat.abort', { sessionKey: k }); },
308
+ steer: (k, t) => ctx.request('sessions.steer', { sessionKey: ctx.routes.get(k)?.session ?? k, message: t }),
309
+ abort: async (k) => { await ctx.request('chat.abort', { sessionKey: ctx.routes.get(k)?.session ?? k }); },
232
310
  };
233
311
  }
package/dist/signin.d.ts CHANGED
@@ -8,6 +8,7 @@ export type SignInCtx = {
8
8
  }>;
9
9
  callbackPort: number;
10
10
  onDisconnect?(fn: () => void): () => void;
11
+ fresh?: boolean;
11
12
  };
12
13
  /**
13
14
  * Drive the engine's provider-owned login. `on` sees every view the person watches; `done` resolves once, and
package/dist/signin.js CHANGED
@@ -41,6 +41,16 @@ export function signIn(ctx, member, o, on) {
41
41
  const { signal } = owner;
42
42
  const driveOwner = new AbortController();
43
43
  const wantsCallback = via === 'browser' && o.authChoice === 'openai';
44
+ // The pin builds ChatGPT's authorize URL unsigned, so a fresh sign-in can ask for the login screen itself.
45
+ const shown = (url) => {
46
+ if (!ctx.fresh || !wantsCallback)
47
+ return url;
48
+ const parsed = URL.canParse(url) ? new URL(url) : undefined;
49
+ if (parsed?.origin !== 'https://auth.openai.com' || parsed.pathname !== '/oauth/authorize')
50
+ return url;
51
+ parsed.searchParams.set('prompt', 'login');
52
+ return parsed.toString();
53
+ };
44
54
  let server;
45
55
  let sessionId = '';
46
56
  let starting;
@@ -295,7 +305,7 @@ export function signIn(ctx, member, o, on) {
295
305
  codeTimer = setTimeout(() => { codeExpired = true; owner.abort(); }, duration);
296
306
  const message = deviceCode.message ?? current.message;
297
307
  // The code is on the card; acknowledging it lets the engine poll the provider itself.
298
- say({ ...(deviceCode.code ? { code: deviceCode.code } : {}), ...(current.externalUrl ? { url: current.externalUrl } : {}),
308
+ say({ ...(deviceCode.code ? { code: deviceCode.code } : {}), ...(current.externalUrl ? { url: shown(current.externalUrl) } : {}),
299
309
  ...(timed ? { expiresAt: Date.now() + duration } : {}), ...(message ? { message } : {}) });
300
310
  const next = await answer(current);
301
311
  if (next.done) {
@@ -326,7 +336,7 @@ export function signIn(ctx, member, o, on) {
326
336
  }
327
337
  if (current.type === 'note' || current.type === 'confirm' || current.type === 'select' || current.type === 'action') {
328
338
  if (current.externalUrl)
329
- say({ url: current.externalUrl });
339
+ say({ url: shown(current.externalUrl) });
330
340
  const next = await answer(current);
331
341
  if (next.done) {
332
342
  terminal = next;
@@ -18,7 +18,6 @@ const REQUIRED_METHODS = [
18
18
  'plugin.approval.resolve', 'question.resolve', 'wizard.cancel', 'wizard.next',
19
19
  ];
20
20
  // Driven by the kit but not advertised in hello (O11 probe, e.g. sessions.steer): routable, just not listed.
21
- // The engine job's generated.test.ts audits the full generated table against hello instead of asserting it here.
22
21
  const REQUIRED_EVENTS = [
23
22
  'agent',
24
23
  'exec.approval.requested', 'exec.approval.resolved',
@@ -20,6 +20,12 @@ export type FakeGateway = {
20
20
  failNext(method: string, message: string): void;
21
21
  drop(why: string): void;
22
22
  handle(method: string, fn: FakeHandler): void;
23
+ /** Usage windows a later `models.authStatus` read reports for the agent's provider (O14: the Auto input). */
24
+ setUsage(agentId: string, provider: string, windows: {
25
+ label: string;
26
+ usedPercent?: number;
27
+ resetAt?: number;
28
+ }[]): void;
23
29
  } & {
24
30
  transport: GatewayTransport;
25
31
  };