@byokit/ui 0.0.0-stage → 0.7.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.
@@ -0,0 +1,148 @@
1
+ export type Billing = 'subscription' | 'api' | 'local' | 'free' | 'unknown';
2
+ export type SignInState = 'ready' | 'signing' | 'resting' | 'signed_out' | 'needs_again' | 'not_included';
3
+ export type RoomSpan = 'session' | 'week' | 'month' | 'tightest';
4
+ /** A room-left reading, restated structurally from `@byokit/accounts` (multi.ts): a known percentage or 'unknown'. */
5
+ export type Room = {
6
+ left: number;
7
+ span: RoomSpan;
8
+ resetsAt?: number;
9
+ at?: number;
10
+ } | {
11
+ left: 'unknown';
12
+ at?: number;
13
+ };
14
+ /** One account as `@byokit/accounts` `list()` reports it; only these fields are read, extra ones (a token included) are ignored. */
15
+ export type Account = {
16
+ id: string;
17
+ provider: string;
18
+ name: string;
19
+ label?: string;
20
+ state: SignInState;
21
+ billing: Billing;
22
+ plan?: string;
23
+ until?: number;
24
+ };
25
+ /** What an app supplies: the accounts it lists and one room reading per account. `AccountsSource` is the seam between
26
+ * any back end and these rows; an at-most-one-line-per-member adapter fits it. */
27
+ export type AccountsSource = {
28
+ accounts: () => Promise<readonly Account[]> | readonly Account[];
29
+ room: (account: Account) => Promise<Room> | Room;
30
+ };
31
+ export type AccountAction = 'sign_in' | 'wait';
32
+ export type AccountRow = {
33
+ id: string;
34
+ title: string;
35
+ detail: string;
36
+ state: SignInState;
37
+ billing: Billing;
38
+ /** Room left is at or below 20%: true at exactly 20, false above it or when the room is unknown. */
39
+ low: boolean;
40
+ /** What the person can do: sign in again, or wait for a resting account to refill. Absent when nothing helps. */
41
+ action?: AccountAction;
42
+ /** A resting account's next refill, epoch milliseconds. */
43
+ until?: number;
44
+ };
45
+ /** One row per account, reading one room per account; a missing or throwing reading is 'Room left unknown'. */
46
+ export declare function accountRows(accounts: readonly Account[], room: (account: Account) => Room, now: number): AccountRow[];
47
+ /** `accountRows` over an `AccountsSource`: the app's own `accounts()` and `room()`, awaited; a failing room reads unknown. */
48
+ export declare function rowsOf(source: AccountsSource, now: number): Promise<AccountRow[]>;
49
+ /** Untaken names an app can offer for a new account, from the email's local part (never the email itself), else the
50
+ * provider's name. Dedupes case-insensitively against `taken`; up to three. */
51
+ export declare function nameSuggestions(email: string | undefined, taken: readonly string[], provider?: string): string[];
52
+ /** Why one of an account's models cannot be used right now, restated from `@byokit/accounts` (multi.ts). */
53
+ export type ModelWhy = 'plan' | 'resting' | 'signed_out';
54
+ /** One model as `@byokit/accounts` `models()` lists it: a strong/fast tier, whether it can run, and, when it can't, why. */
55
+ export type ModelInfo = {
56
+ id: string;
57
+ name: string;
58
+ tier?: 'strong' | 'fast';
59
+ available: boolean;
60
+ why?: ModelWhy;
61
+ until?: number;
62
+ };
63
+ /** What a run asks for, restated structurally: an account (or `default`/`auto`) and one model. */
64
+ export type RunSelection = {
65
+ account: string | 'default' | 'auto';
66
+ model?: string;
67
+ };
68
+ /** Why Auto named (or could not name) an account, restated from `@byokit/accounts` `resolveSelection`. */
69
+ export type PickWhy = 'chosen' | 'default' | 'first_ready' | 'only' | 'most_room' | 'earlier_reset' | 'list_order' | 'no_reading' | 'refills_first';
70
+ /** Why an account cannot be used, as Auto judges it, restated from `@byokit/accounts` `Considered`. */
71
+ export type PickOut = 'state' | 'resting' | 'billing' | 'model' | 'provider';
72
+ /** One account's line in the pick's explanation; only the id, the why and the refill time are read here. */
73
+ type ConsideredRow = {
74
+ id: string;
75
+ out?: PickOut;
76
+ until?: number;
77
+ };
78
+ /** `@byokit/accounts` `resolveSelection`/`pick`, restated structurally: what Auto chose, or why it could not. */
79
+ export type Pick = {
80
+ ok: true;
81
+ account: Account;
82
+ model: string;
83
+ how: 'chosen' | 'default' | 'auto';
84
+ why: PickWhy;
85
+ reason: string;
86
+ considered?: readonly ConsideredRow[];
87
+ } | {
88
+ ok: false;
89
+ code: 'none' | 'not_included' | 'unknown_account';
90
+ reason: string;
91
+ considered?: readonly ConsideredRow[];
92
+ };
93
+ /** One model row under an account, marked `selected` when it is the run's model. */
94
+ export type PickerModel = ModelInfo & {
95
+ selected: boolean;
96
+ };
97
+ /** One picker row: the `Auto — most room left` row first, then one per account in list order. */
98
+ export type PickerRow = {
99
+ /** `'auto'`, or the account id. */
100
+ key: string;
101
+ kind: 'auto' | 'account';
102
+ /** `Auto — most room left`, or the account's own plain title (`ChatGPT · Work`). */
103
+ title: string;
104
+ /** Auto's own reason in words on the Auto row; an account row's why, or its status when it can be used. */
105
+ detail: string;
106
+ /** True for the one row the run is set to use. */
107
+ selected: boolean;
108
+ /** True when the row cannot be chosen; only Auto, when it has no account to name. */
109
+ disabled: boolean;
110
+ /** Why Auto is disabled, typed from the pick's failure code; present only when `disabled`. */
111
+ disabledReason?: 'none' | 'not_included' | 'unknown_account';
112
+ /** Why the account cannot be used right now: a sign-in problem, a rest, per-use billing, a missing model, or
113
+ * another service. Absent when it can be used. */
114
+ out?: PickOut;
115
+ /** A resting account's next refill, epoch milliseconds. */
116
+ until?: number;
117
+ /** This account's models, each marked when it is the run's model; empty for the Auto row. */
118
+ models: PickerModel[];
119
+ };
120
+ /** What a run or settings sheet draws: the rows, the named selection, and what it resolves to. */
121
+ export type PickerView = {
122
+ rows: PickerRow[];
123
+ /** Exactly what `sel.account` named: `'auto'` or an account id. */
124
+ selection: string;
125
+ /** True when the selection resolves to a usable account and model. */
126
+ ok: boolean;
127
+ /** The account the run uses, when `ok`. */
128
+ account?: string;
129
+ /** The model the run uses, when `ok`. */
130
+ model?: string;
131
+ };
132
+ /** The one sentence a Move sheet shows: `Move to Work` after a tap, `Couldn't move to Work` after a refusal. */
133
+ export declare function moveWords(name: string, moved?: boolean): string;
134
+ /**
135
+ * The picker rows for a run selection, with the selection marked: the `Auto — most room left` row carrying Auto's
136
+ * own `pick.reason`, then one row per account with its model rows and, when it cannot be used, Auto's why and
137
+ * `until`. `auto` is the pick `@byokit/accounts` `resolveSelection` made for `{ account: 'auto' }`; a signed-out
138
+ * explicit pick is shown as `Sign in first` and never replaced by another account.
139
+ */
140
+ export declare function modelPicker(accounts: readonly Account[], models: Record<string, readonly ModelInfo[]>, sel: RunSelection, auto: Pick, now?: number): PickerView;
141
+ /**
142
+ * The account a running session should move to, or undefined when there is nowhere better to go: `@byokit/accounts`
143
+ * `chooseAccount`'s own winner (`moveToAccount` carries the session there). Nothing to move to with one account,
144
+ * with only signed-out peers, when nobody has room left, or when the list is API keys only; the current account is
145
+ * never named back to itself. `now` defaults to the current time, as `accountRows` does.
146
+ */
147
+ export declare function bestMoveTarget(accounts: readonly Account[], room: Record<string, Room>, current: string, now?: number): string | undefined;
148
+ export {};
@@ -0,0 +1,175 @@
1
+ // One row per account, in plain words: the provider and the person's own name for it, then the plan and the room
2
+ // left, or the honest reason it is not usable right now. Framework-free and portable: it reads only the fields it
3
+ // names (D3), so no credential on an account ever reaches a row. `AccountsSource` is the shape an app fills from its
4
+ // own back end; @byokit/accounts `list()` and a room reading fit it with at most one line per member.
5
+ import WORDS from './words.json' with { type: 'json' };
6
+ const UNKNOWN = { left: 'unknown' };
7
+ const LOW_AT = 20;
8
+ const roomWords = (r) => r.left === 'unknown' ? WORDS['account.room.unknown'] : WORDS[`account.room.${r.span}`].replace('{left}', `${Math.round(r.left)}%`);
9
+ const planLabel = (plan) => (plan ? plan.charAt(0).toUpperCase() + plan.slice(1) : '');
10
+ /** 'ChatGPT · Work', or just the provider's own name when the account keeps it. An email is never used as a name. */
11
+ function titleOf(a) {
12
+ const provider = a.label || a.provider;
13
+ const name = a.name && !a.name.includes('@') && a.name.toLowerCase() !== provider.toLowerCase() ? a.name : '';
14
+ return name ? `${provider} · ${name}` : provider;
15
+ }
16
+ /** A resting account whose refill time has passed reads as ready again, exactly as the Auto chooser treats it. */
17
+ const refilled = (a, now) => a.state === 'resting' && typeof a.until === 'number' && a.until <= now;
18
+ function rowOf(a, room, now) {
19
+ const state = refilled(a, now) ? 'ready' : a.state;
20
+ const title = titleOf(a);
21
+ if (state === 'ready') {
22
+ const words = roomWords(room);
23
+ return { id: a.id, title, detail: a.plan && room.left !== 'unknown' ? `${planLabel(a.plan)} · ${words}` : words, state, billing: a.billing,
24
+ low: room.left !== 'unknown' && room.left <= LOW_AT };
25
+ }
26
+ if (state === 'resting') {
27
+ return { id: a.id, title, detail: WORDS['account.resting'], state, billing: a.billing, low: false, action: 'wait',
28
+ ...(typeof a.until === 'number' ? { until: a.until } : {}) };
29
+ }
30
+ const action = state === 'signed_out' || state === 'needs_again' ? 'sign_in' : undefined;
31
+ return { id: a.id, title, detail: WORDS[`account.${state}`], state, billing: a.billing, low: false, ...(action ? { action } : {}) };
32
+ }
33
+ /** One row per account, reading one room per account; a missing or throwing reading is 'Room left unknown'. */
34
+ export function accountRows(accounts, room, now) {
35
+ return accounts.map((a) => {
36
+ let r = UNKNOWN;
37
+ try {
38
+ r = room(a) ?? UNKNOWN;
39
+ }
40
+ catch {
41
+ r = UNKNOWN;
42
+ }
43
+ return rowOf(a, r, now);
44
+ });
45
+ }
46
+ /** `accountRows` over an `AccountsSource`: the app's own `accounts()` and `room()`, awaited; a failing room reads unknown. */
47
+ export async function rowsOf(source, now) {
48
+ const accounts = await source.accounts();
49
+ const rooms = new Map();
50
+ for (const a of accounts) {
51
+ try {
52
+ rooms.set(a.id, (await source.room(a)) ?? UNKNOWN);
53
+ }
54
+ catch {
55
+ rooms.set(a.id, UNKNOWN);
56
+ }
57
+ }
58
+ return accountRows(accounts, (a) => rooms.get(a.id) ?? UNKNOWN, now);
59
+ }
60
+ /** Untaken names an app can offer for a new account, from the email's local part (never the email itself), else the
61
+ * provider's name. Dedupes case-insensitively against `taken`; up to three. */
62
+ export function nameSuggestions(email, taken, provider = 'Account') {
63
+ const used = new Set(taken.map((t) => t.trim().toLowerCase()).filter(Boolean));
64
+ const local = email?.split('@')[0]?.split(/[._-]+/).find(Boolean) ?? '';
65
+ const base = (local ? local.charAt(0).toUpperCase() + local.slice(1).toLowerCase() : provider).slice(0, 64) || provider;
66
+ const out = [];
67
+ for (let n = 1; out.length < 3 && n < 100; n++) {
68
+ const name = n === 1 ? base : `${base} ${n}`;
69
+ if (!used.has(name.toLowerCase())) {
70
+ out.push(name);
71
+ used.add(name.toLowerCase());
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+ /** An account is usable when it is ready, or resting with its refill time already passed, exactly as a row reads. */
77
+ const usableNow = (a, now) => a.state === 'ready' || refilled(a, now);
78
+ /** The one sentence a Move sheet shows: `Move to Work` after a tap, `Couldn't move to Work` after a refusal. */
79
+ export function moveWords(name, moved = true) {
80
+ return WORDS[moved ? 'move.to' : 'move.failed'].replace('{name}', name);
81
+ }
82
+ /**
83
+ * The picker rows for a run selection, with the selection marked: the `Auto — most room left` row carrying Auto's
84
+ * own `pick.reason`, then one row per account with its model rows and, when it cannot be used, Auto's why and
85
+ * `until`. `auto` is the pick `@byokit/accounts` `resolveSelection` made for `{ account: 'auto' }`; a signed-out
86
+ * explicit pick is shown as `Sign in first` and never replaced by another account.
87
+ */
88
+ export function modelPicker(accounts, models, sel, auto, now = Date.now()) {
89
+ const considered = new Map((auto.considered ?? []).map((r) => [r.id, r]));
90
+ let account;
91
+ let model;
92
+ if (sel.account === 'auto') {
93
+ if (auto.ok) {
94
+ account = auto.account.id;
95
+ model = auto.model;
96
+ }
97
+ }
98
+ else {
99
+ const chosen = accounts.find((a) => a.id === sel.account);
100
+ if (chosen && usableNow(chosen, now)) {
101
+ const available = (models[chosen.id] ?? []).filter((m) => m.available);
102
+ const pick = sel.model && available.some((m) => m.id === sel.model) ? sel.model : available[0]?.id;
103
+ if (pick) {
104
+ account = chosen.id;
105
+ model = pick;
106
+ }
107
+ }
108
+ }
109
+ const rows = [{
110
+ key: 'auto', kind: 'auto', title: WORDS['picker.auto'], detail: auto.reason,
111
+ selected: sel.account === 'auto', disabled: !auto.ok, ...(auto.ok ? {} : { disabledReason: auto.code }), models: [],
112
+ }];
113
+ for (const a of accounts) {
114
+ const isSelected = sel.account === a.id;
115
+ const why = considered.get(a.id);
116
+ const out = why?.out ?? (usableNow(a, now) ? undefined : a.state === 'resting' ? 'resting' : 'state');
117
+ const rowModels = (models[a.id] ?? []).map((m) => ({ ...m, selected: a.id === account && m.id === model && m.available }));
118
+ rows.push({
119
+ key: a.id, kind: 'account', title: titleOf(a), detail: out ? WORDS[`picker.out.${out}`] : '', selected: isSelected, disabled: false,
120
+ ...(out ? { out } : {}), ...(out && why?.until !== undefined ? { until: why.until } : {}), models: rowModels,
121
+ });
122
+ }
123
+ const ok = account !== undefined && model !== undefined;
124
+ return { rows, selection: sel.account, ok, ...(ok ? { account, model } : {}) };
125
+ }
126
+ const MOVE_DAY_MS = 24 * 60 * 60 * 1000;
127
+ const TIER_ORDER = { room: 0, unknown: 1, exhausted: 2 };
128
+ /**
129
+ * Auto's winner among `accounts`, mirroring `@byokit/accounts` `chooseAccount` (multi.ts): subscriptions only, most
130
+ * room, unknown above exhausted, earliest reset then list order. Used by `bestMoveTarget`, and cross-checked against
131
+ * the real chooser over `fixtures/conformance/auto-pick-typescript.json`.
132
+ */
133
+ function autoCandidate(accounts, room, now) {
134
+ const rows = accounts.map((a) => {
135
+ const r = room(a);
136
+ const age = typeof r.at === 'number' ? Math.max(0, now - r.at) : 'unknown';
137
+ const stale = typeof age === 'number' && age > MOVE_DAY_MS;
138
+ const left = r.left === 'unknown' || typeof r.left !== 'number' || stale ? 'unknown' : Math.max(0, Math.min(100, r.left));
139
+ let out;
140
+ if (a.state === 'resting' && typeof a.until === 'number' && a.until > now)
141
+ out = 'resting';
142
+ else if (a.state !== 'ready' && !(a.state === 'resting' && typeof a.until === 'number' && a.until <= now))
143
+ out = 'state';
144
+ else if (a.billing !== 'subscription')
145
+ out = 'billing';
146
+ const reset = r.left !== 'unknown' && typeof r.resetsAt === 'number' ? r.resetsAt : undefined;
147
+ const tier = left === 'unknown' ? 'unknown' : left > 0 || (reset !== undefined && reset <= now) ? 'room' : 'exhausted';
148
+ return { account: a, tier, left, ...(out ? { out } : {}), ...(reset !== undefined ? { resetsAt: reset } : {}) };
149
+ });
150
+ const ranked = rows.filter((c) => !c.out).sort((x, y) => {
151
+ const tier = TIER_ORDER[x.tier] - TIER_ORDER[y.tier];
152
+ if (tier)
153
+ return tier;
154
+ if (x.tier === 'unknown')
155
+ return 0;
156
+ if (x.tier === 'room' && x.left !== y.left)
157
+ return Number(y.left) - Number(x.left);
158
+ const rx = x.resetsAt ?? Infinity;
159
+ const ry = y.resetsAt ?? Infinity;
160
+ return rx === ry ? 0 : rx < ry ? -1 : 1;
161
+ });
162
+ return ranked[0];
163
+ }
164
+ /**
165
+ * The account a running session should move to, or undefined when there is nowhere better to go: `@byokit/accounts`
166
+ * `chooseAccount`'s own winner (`moveToAccount` carries the session there). Nothing to move to with one account,
167
+ * with only signed-out peers, when nobody has room left, or when the list is API keys only; the current account is
168
+ * never named back to itself. `now` defaults to the current time, as `accountRows` does.
169
+ */
170
+ export function bestMoveTarget(accounts, room, current, now = Date.now()) {
171
+ const pick = autoCandidate(accounts, (a) => room[a.id] ?? { left: 'unknown' }, now);
172
+ if (!pick || pick.account.id === current || pick.tier === 'exhausted')
173
+ return undefined;
174
+ return pick.account.id;
175
+ }
@@ -0,0 +1,47 @@
1
+ import { type Store } from './follow.ts';
2
+ /** One approval as the OpenClaw kit reports it. */
3
+ export type Approval = {
4
+ id: string;
5
+ source: 'gate' | 'exec' | 'plugin' | 'question';
6
+ member: string;
7
+ sessionKey?: string;
8
+ tool?: string;
9
+ summary: string;
10
+ input?: unknown;
11
+ at: number;
12
+ /** When the computer stops waiting and says no by itself. */
13
+ expires: number;
14
+ };
15
+ /** An `oc.events` frame: an approval added or resolved, or any other event (ignored here). */
16
+ export type ApprovalFrame = {
17
+ event: string;
18
+ change?: 'added' | 'resolved';
19
+ approval?: Approval;
20
+ };
21
+ export type ApprovalsAction = ApprovalFrame | {
22
+ type: 'set';
23
+ list: Approval[];
24
+ } | {
25
+ type: 'tick';
26
+ now: number;
27
+ };
28
+ export declare function approvalsStep(list: Approval[], a: ApprovalsAction): Approval[];
29
+ /** The question for one approval, in the kit's own sentence (`@byokit/openclaw/device`'s `words` fits). */
30
+ export declare const approvalWords: (a: Approval, words: (key: "approval.ask", vars: {
31
+ helper: string;
32
+ summary: string;
33
+ }) => string, helper: string) => string;
34
+ /** What `approvalsStore` needs from a device client: `openclawDevice(link)` fits. */
35
+ export type ApprovalsSource = {
36
+ approvals(): Promise<Approval[]>;
37
+ events(): AsyncIterable<ApprovalFrame>;
38
+ };
39
+ export type FollowOptions = {
40
+ retryMs?: number;
41
+ now?: () => number;
42
+ };
43
+ /**
44
+ * The approvals still waiting, live: listed once the event stream is open (so nothing falls between the two), then
45
+ * added and resolved from its frames, and each dropped as it expires. Opened again after the link comes back.
46
+ */
47
+ export declare function approvalsStore(source: ApprovalsSource, { retryMs, now }?: FollowOptions): Store<Approval[]>;
@@ -0,0 +1,63 @@
1
+ // What is waiting for a person's yes: the approvals list kept current from the kit's event stream, each dropped when
2
+ // it is answered anywhere or runs out of time. Framework-free; typed structurally against `@byokit/openclaw/device`.
3
+ import { retrying, store } from "./follow.js";
4
+ export function approvalsStep(list, a) {
5
+ if ('type' in a) {
6
+ if (a.type === 'set')
7
+ return a.list;
8
+ const live = list.filter((x) => x.expires > a.now);
9
+ return live.length === list.length ? list : live;
10
+ }
11
+ if (a.event !== 'approval' || !a.approval)
12
+ return list;
13
+ const { id } = a.approval;
14
+ const rest = list.filter((x) => x.id !== id);
15
+ if (a.change === 'resolved')
16
+ return rest.length === list.length ? list : rest;
17
+ if (a.change !== 'added')
18
+ return list;
19
+ const at = list.findIndex((x) => x.id === id);
20
+ return at < 0 ? [...list, a.approval] : list.map((x, i) => (i === at ? a.approval : x));
21
+ }
22
+ /** The question for one approval, in the kit's own sentence (`@byokit/openclaw/device`'s `words` fits). */
23
+ export const approvalWords = (a, words, helper) => words('approval.ask', { helper, summary: a.summary });
24
+ /**
25
+ * The approvals still waiting, live: listed once the event stream is open (so nothing falls between the two), then
26
+ * added and resolved from its frames, and each dropped as it expires. Opened again after the link comes back.
27
+ */
28
+ export function approvalsStore(source, { retryMs = 2000, now = Date.now } = {}) {
29
+ const made = store([], (set) => {
30
+ let list = made.get();
31
+ let timer;
32
+ const apply = (a) => {
33
+ list = approvalsStep(approvalsStep(list, a), { type: 'tick', now: now() });
34
+ clearTimeout(timer);
35
+ if (list.length > 0) {
36
+ const next = Math.min(...list.map((x) => x.expires)) - now();
37
+ timer = setTimeout(() => apply({ type: 'tick', now: now() }), Math.min(Math.max(next, 0), 2 ** 31 - 1));
38
+ }
39
+ set(list);
40
+ };
41
+ apply({ type: 'tick', now: now() }); // what was kept since the last watcher left may have expired meanwhile
42
+ const stop = retrying(async (live) => {
43
+ const it = source.events()[Symbol.asyncIterator]();
44
+ live.hold(it);
45
+ try {
46
+ const first = it.next(); // opens the stream before the list is read
47
+ first.catch(() => { });
48
+ const listed = await source.approvals();
49
+ if (live.stopped())
50
+ return;
51
+ apply({ type: 'set', list: listed });
52
+ live.ok();
53
+ for (let r = await first; !r.done && !live.stopped(); r = await it.next())
54
+ apply(r.value);
55
+ }
56
+ finally {
57
+ void it.return?.();
58
+ }
59
+ }, retryMs);
60
+ return () => { stop(); clearTimeout(timer); };
61
+ });
62
+ return made;
63
+ }
@@ -0,0 +1,101 @@
1
+ import { type Store } from './follow.ts';
2
+ import type { FollowOptions } from './approvals.ts';
3
+ /** One sign-in request as the OpenClaw kit reports it (`NeedSignIn`). */
4
+ export type SignInRequest = {
5
+ id: string;
6
+ gen: number;
7
+ member: string;
8
+ origin: string;
9
+ site: string;
10
+ secure: boolean;
11
+ firstTime: boolean;
12
+ agentNote?: string;
13
+ hints: string[];
14
+ choices: {
15
+ kind: 'takeover' | 'not-now' | 'cancel';
16
+ }[];
17
+ state: 'waiting' | 'held' | 'checking' | 'parked' | 'settled';
18
+ settled?: {
19
+ state: 'verified' | 'entered-unverified' | 'cancelled' | 'expired' | 'failed';
20
+ reason?: string;
21
+ resume?: {
22
+ state: 'pending' | 'accepted' | 'submitted' | 'indeterminate' | 'failed';
23
+ };
24
+ };
25
+ expires: number;
26
+ };
27
+ export type SignInAction = 'takeover' | 'notNow' | 'cancel' | 'done' | 'reopen' | 'retry';
28
+ export type SignInWordKey = 'signin.waiting' | 'signin.private' | 'signin.checking' | 'signin.parked' | 'signin.verified' | 'signin.noVerifier' | 'signin.stillSignedOut' | 'signin.expired' | 'signin.cancelled' | 'signin.runReplaced' | 'signin.superseded' | 'signin.originMismatch' | 'signin.browserGone' | 'signin.resumeFailed' | 'signin.resumeUnknown' | 'signin.insecureRemote';
29
+ export type SignInVars = {
30
+ site: string;
31
+ name: string;
32
+ origin: string;
33
+ };
34
+ export type SignInSheet = {
35
+ id: string;
36
+ gen: number;
37
+ /** `signin.title` with `site`. */
38
+ site: string;
39
+ /** The address the person is signing in on, shown in full; it states the address bar, never that a site is safe. */
40
+ origin: string;
41
+ secure: boolean;
42
+ /** First time on this site: `signin.confirmSite`, and the person types or picks the site before taking over. */
43
+ confirmSite: boolean;
44
+ /** The helper's own words, to show only as a quote (`signin.agentNote`), never as a title or button. */
45
+ note?: string;
46
+ hints: string[];
47
+ /** The one sentence under the title. */
48
+ line: {
49
+ key: SignInWordKey;
50
+ vars: SignInVars;
51
+ };
52
+ /** Buttons, in order. Empty while someone else signs in or the check runs. */
53
+ actions: SignInAction[];
54
+ /** The chip in the chat: `entered` ("Sign-in details entered"), `verified` ("Signed in to {site}"). */
55
+ chip?: 'entered' | 'verified';
56
+ /** Still waiting on the person: every outcome but a verified sign-in the helper went on from. */
57
+ needsYou: boolean;
58
+ };
59
+ /**
60
+ * What to draw for one request. `holder` is whether this device holds the takeover lease; `name` is the helper's
61
+ * name for the sentences.
62
+ */
63
+ export declare function signInSheetView(r: SignInRequest, { name, holder }: {
64
+ name: string;
65
+ holder?: boolean;
66
+ }): SignInSheet;
67
+ /** A frame of the kit's event stream; only `byokit.browser` sign-in pings matter here. */
68
+ export type BrowserFrame = {
69
+ event: string;
70
+ payload?: unknown;
71
+ };
72
+ /** What `signInsStore` needs from a device client: the kit's device client fits once its browser ops land (O22). */
73
+ export type SignInsSource = {
74
+ signIns(): Promise<SignInRequest[]>;
75
+ events(): AsyncIterable<BrowserFrame>;
76
+ };
77
+ /**
78
+ * The member's sign-in requests, live: listed once the event stream is open (so no ping falls between the two), and
79
+ * listed again on every sign-in ping, which carries no origin or URL. Opened again after the link comes back.
80
+ */
81
+ export declare function signInsStore(source: SignInsSource, { retryMs }?: Pick<FollowOptions, 'retryMs'>): Store<SignInRequest[]>;
82
+ /** One live view's state as the kit reports it (`LiveViewState`), as far as drawing it needs. */
83
+ export type LiveState = {
84
+ phase: 'connecting' | 'live' | 'reconnecting' | 'private' | 'ended' | 'failed';
85
+ mode: 'observe' | 'control';
86
+ origin?: string;
87
+ offOrigin?: boolean;
88
+ };
89
+ export type LivePanel = {
90
+ /** Draw frames; otherwise keep the last one dimmed under `line`, or a blank panel. */
91
+ showFrames: boolean;
92
+ /** Input goes to the page only while this is true: in control, live, and not paused on an unexpected address. */
93
+ input: boolean;
94
+ /** `confirmOrigin` button: the page moved to `origin`, which the person hasn't confirmed for this sign-in. */
95
+ confirmOrigin?: string;
96
+ line?: {
97
+ key: 'live.reconnecting' | 'signin.private' | 'signin.offOrigin';
98
+ };
99
+ };
100
+ /** What to draw for one live view. Watching never asks the helper anything. */
101
+ export declare function livePanelView(s: LiveState): LivePanel;
@@ -0,0 +1,97 @@
1
+ // The browser sign-in sheet and live view as a person sees them: which sentence to show, which buttons to offer, and
2
+ // the chip under the chat. Framework-free; typed structurally against `@byokit/openclaw`'s `NeedSignIn` and
3
+ // `LiveViewState` (spec 5.17), so nothing here imports a kit. Sentences are the kit's word keys plus their values,
4
+ // for the kit's own `words` to say. Nothing here ever holds what the person types.
5
+ import { retrying, store } from "./follow.js";
6
+ const settledLine = (s) => {
7
+ switch (s.state) {
8
+ case 'verified':
9
+ return s.resume?.state === 'failed' ? 'signin.resumeFailed'
10
+ : s.resume?.state === 'indeterminate' ? 'signin.resumeUnknown' : 'signin.verified';
11
+ case 'entered-unverified': return s.reason === 'still-signed-out' ? 'signin.stillSignedOut' : 'signin.noVerifier';
12
+ case 'cancelled':
13
+ return s.reason === 'run-replaced' ? 'signin.runReplaced' : s.reason === 'superseded' ? 'signin.superseded' : 'signin.cancelled';
14
+ case 'expired': return 'signin.expired';
15
+ case 'failed': return s.reason === 'origin-mismatch' ? 'signin.originMismatch' : 'signin.browserGone';
16
+ }
17
+ };
18
+ /**
19
+ * What to draw for one request. `holder` is whether this device holds the takeover lease; `name` is the helper's
20
+ * name for the sentences.
21
+ */
22
+ export function signInSheetView(r, { name, holder = false }) {
23
+ const vars = { site: r.site, name, origin: r.origin };
24
+ const can = (k) => r.choices.some((c) => c.kind === k);
25
+ const base = {
26
+ id: r.id, gen: r.gen, site: r.site, origin: r.origin, secure: r.secure, hints: r.hints,
27
+ confirmSite: r.firstTime && r.state === 'waiting', ...(r.agentNote ? { note: r.agentNote } : {}),
28
+ };
29
+ const line = (key) => ({ key, vars });
30
+ switch (r.state) {
31
+ case 'waiting': {
32
+ const actions = [];
33
+ if (can('takeover'))
34
+ actions.push('takeover');
35
+ if (can('not-now'))
36
+ actions.push('notNow');
37
+ return { ...base, line: line(r.secure ? 'signin.waiting' : 'signin.insecureRemote'), actions, needsYou: true };
38
+ }
39
+ case 'held':
40
+ return holder
41
+ ? { ...base, line: line('signin.waiting'), actions: ['done', 'notNow', 'cancel'], needsYou: true }
42
+ : { ...base, line: line('signin.private'), actions: [], needsYou: true };
43
+ case 'checking': return { ...base, line: line('signin.checking'), actions: [], chip: 'entered', needsYou: true };
44
+ case 'parked': return { ...base, line: line('signin.parked'), actions: ['reopen'], needsYou: true };
45
+ case 'settled': {
46
+ const s = r.settled ?? { state: 'failed' };
47
+ const key = settledLine(s);
48
+ const verified = s.state === 'verified';
49
+ const chip = verified ? 'verified' : s.state === 'entered-unverified' ? 'entered' : undefined;
50
+ return {
51
+ ...base, line: line(key), actions: verified ? [] : ['retry'], ...(chip ? { chip } : {}),
52
+ needsYou: key !== 'signin.verified',
53
+ };
54
+ }
55
+ }
56
+ }
57
+ const ping = (f) => f.event === 'byokit.browser' && f.payload?.kind === 'signin';
58
+ /**
59
+ * The member's sign-in requests, live: listed once the event stream is open (so no ping falls between the two), and
60
+ * listed again on every sign-in ping, which carries no origin or URL. Opened again after the link comes back.
61
+ */
62
+ export function signInsStore(source, { retryMs = 2000 } = {}) {
63
+ return store([], (set) => retrying(async (live) => {
64
+ const it = source.events()[Symbol.asyncIterator]();
65
+ live.hold(it);
66
+ try {
67
+ const first = it.next();
68
+ first.catch(() => { });
69
+ set(await source.signIns());
70
+ if (live.stopped())
71
+ return;
72
+ live.ok();
73
+ for (let r = await first; !r.done && !live.stopped(); r = await it.next()) {
74
+ if (ping(r.value))
75
+ set(await source.signIns());
76
+ }
77
+ }
78
+ finally {
79
+ void it.return?.();
80
+ }
81
+ }, retryMs));
82
+ }
83
+ /** What to draw for one live view. Watching never asks the helper anything. */
84
+ export function livePanelView(s) {
85
+ switch (s.phase) {
86
+ case 'live':
87
+ if (s.mode === 'control' && s.offOrigin) {
88
+ return { showFrames: true, input: false, line: { key: 'signin.offOrigin' }, ...(s.origin ? { confirmOrigin: s.origin } : {}) };
89
+ }
90
+ return { showFrames: true, input: s.mode === 'control' };
91
+ case 'connecting':
92
+ case 'reconnecting': return { showFrames: false, input: false, line: { key: 'live.reconnecting' } };
93
+ case 'private': return { showFrames: false, input: false, line: { key: 'signin.private' } };
94
+ case 'ended':
95
+ case 'failed': return { showFrames: false, input: false };
96
+ }
97
+ }