@byokit/ui 0.0.0-stage → 0.6.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,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
+ }
@@ -0,0 +1,92 @@
1
+ import WORDS from './words.json';
2
+ import { keyView, type KeyAction, type KeyState } from './key.ts';
3
+ import type { UseSignIn } from './useSignIn.ts';
4
+ export type Billing = 'subscription' | 'api' | 'local' | 'free' | 'unknown';
5
+ export type Via = 'browser' | 'code' | 'paste' | 'key' | 'session' | 'setup_token' | 'cli' | 'plan_key' | 'cloud' | 'local' | 'endpoint';
6
+ export type Readiness = 'ready' | 'needs_binary' | 'needs_plugin' | 'needs_host' | 'needs_client' | 'unsupported_platform' | 'no_upstream_flow';
7
+ /** One route as any kit's `routes()` lists it, restated structurally (D3): `@byokit/accounts` and
8
+ * `@byokit/openclaw` route views fit as they are. Only these fields are read, so nothing else (a credential
9
+ * included) can reach the view. */
10
+ export type ConnectRoute = {
11
+ /** openclaw's is optional; a row without one is keyed `<provider>:<via>`. */
12
+ id?: string;
13
+ provider: string;
14
+ name?: string;
15
+ company?: string;
16
+ via: Via;
17
+ billing: Billing;
18
+ /** accounts: `'default' | 'explicit'`; openclaw: `offerPolicy` plus its legacy ready-only boolean `offer`. */
19
+ offer?: 'default' | 'explicit' | boolean;
20
+ offerPolicy?: 'default' | 'explicit';
21
+ /** openclaw: `services` marks speech, search, media and other non-chat rows. */
22
+ group?: 'models' | 'services';
23
+ upstream?: {
24
+ surface: 'accounts' | 'openclaw' | 'herdr';
25
+ };
26
+ readiness: Readiness;
27
+ };
28
+ export type ConnectGroupId = 'plans' | 'perUse' | 'local' | 'server' | 'cloud' | 'other' | 'services';
29
+ /** What choosing a row leads to: a sign-in sheet (`useSignIn`), a key card (`keyView`), the app's own setup form
30
+ * (cloud account, local model, server address and its billing), or the reason it cannot be used here. */
31
+ export type ConnectDoes = 'signin' | 'key' | 'setup' | 'unavailable';
32
+ export type ConnectRow = {
33
+ /** Unique across kits: `<surface>/<id>`. */
34
+ key: string;
35
+ id: string;
36
+ provider: string;
37
+ surface?: 'accounts' | 'openclaw' | 'herdr';
38
+ name: string;
39
+ company: string;
40
+ via: Via;
41
+ billing: Billing;
42
+ group: ConnectGroupId;
43
+ /** Offered by default: a ready subscription row. Never true for any other billing. */
44
+ offered: boolean;
45
+ ready: boolean;
46
+ readiness: Readiness;
47
+ does: ConnectDoes;
48
+ /** Plain words: how it is paid for, how you sign in, and whether it works here. */
49
+ billingWords: string;
50
+ method: string;
51
+ status: string;
52
+ };
53
+ export type ConnectGroup = {
54
+ id: ConnectGroupId;
55
+ title: string;
56
+ note: string;
57
+ rows: ConnectRow[];
58
+ };
59
+ export type ConnectStep = {
60
+ at: 'list';
61
+ } | {
62
+ at: 'route';
63
+ key: string;
64
+ entry: KeyState;
65
+ };
66
+ export type ConnectAction = {
67
+ type: 'pick';
68
+ key: string;
69
+ } | {
70
+ type: 'back';
71
+ } | KeyAction;
72
+ export type ConnectView = {
73
+ groups: ConnectGroup[];
74
+ /** The picked row and, for a key route, its key card. Undefined on the list or when the row is gone. */
75
+ chosen?: {
76
+ row: ConnectRow;
77
+ key?: ReturnType<typeof keyView>;
78
+ };
79
+ };
80
+ export type ConnectWords = typeof WORDS;
81
+ export declare function connectStep(step: ConnectStep, action: ConnectAction): ConnectStep;
82
+ /** Every route, grouped in a fixed order (empty groups left out), rows in the kits' own order. */
83
+ export declare function connectView(routes: readonly ConnectRoute[], step?: ConnectStep, words?: ConnectWords): ConnectView;
84
+ /** What the app does for the picked row, given that row; credentials go straight from the app to its kit. */
85
+ export type UseConnectSignIn = Omit<UseSignIn, 'read' | 'start' | 'cancel'> & {
86
+ read: (row: ConnectRow) => ReturnType<UseSignIn['read']>;
87
+ start?: (row: ConnectRow, body?: Parameters<NonNullable<UseSignIn['start']>>[0]) => Promise<unknown>;
88
+ cancel: (row: ConnectRow) => Promise<unknown>;
89
+ };
90
+ /** `useSignIn`'s options for the chosen sign-in row, or undefined for any other step. Draw the sheet as its own
91
+ * component keyed by `chosen.row.key` so a new pick starts with fresh sign-in state. */
92
+ export declare function signInFor(view: ConnectView, o: UseConnectSignIn): UseSignIn | undefined;
@@ -0,0 +1,74 @@
1
+ // "Connect an AI account" as headless view state: every route the kits list (D18), grouped by how the person pays,
2
+ // with plain words for the billing, the method and whether it works here. Plans come first and are the only rows
3
+ // offered by default; every other row is listed and used only when the person picks it. Nothing here names a
4
+ // provider: groups and words come from each row's billing, method and readiness alone. Framework-free.
5
+ import WORDS from './words.json' with { type: 'json' };
6
+ import { keyStep, keyView } from "./key.js";
7
+ const ORDER = ['plans', 'perUse', 'local', 'server', 'cloud', 'other', 'services'];
8
+ const SIGNIN = ['browser', 'code', 'paste', 'session', 'cli'];
9
+ const KEY = ['key', 'plan_key', 'setup_token'];
10
+ const fill = (s, r) => s.replace(/\{(name|company)\}/g, (_, k) => r[k]);
11
+ /** Cloud, server and local methods are always explicit and go to their own groups; then billing decides: only a
12
+ * subscription row named default is a plan, so an API, free or unknown row never lands in Plans whatever its
13
+ * `offer` says. */
14
+ function groupOf(r) {
15
+ if (r.group === 'services')
16
+ return 'services';
17
+ if (r.via === 'cloud')
18
+ return 'cloud';
19
+ if (r.via === 'endpoint')
20
+ return 'server';
21
+ if (r.via === 'local')
22
+ return 'local';
23
+ const byDefault = r.offer === 'default' || r.offerPolicy === 'default' || r.offer === true;
24
+ if (r.billing === 'subscription' && byDefault)
25
+ return 'plans';
26
+ if (r.billing === 'local')
27
+ return 'local';
28
+ if (r.billing === 'api')
29
+ return 'perUse';
30
+ return 'other';
31
+ }
32
+ function rowOf(r, w) {
33
+ const name = r.name || r.provider, company = r.company || name, id = r.id ?? `${r.provider}:${r.via}`;
34
+ const group = groupOf(r);
35
+ const ready = r.readiness === 'ready';
36
+ const billing = r.via === 'endpoint' ? w['billing.server'] : r.via === 'local' ? w['billing.local'] : w[`billing.${r.billing}`];
37
+ return {
38
+ key: `${r.upstream?.surface ?? ''}/${id}`, id, provider: r.provider,
39
+ ...(r.upstream ? { surface: r.upstream.surface } : {}),
40
+ name, company, via: r.via, billing: r.billing, group,
41
+ offered: group === 'plans' && ready, ready, readiness: r.readiness,
42
+ does: !ready ? 'unavailable' : SIGNIN.includes(r.via) ? 'signin' : KEY.includes(r.via) ? 'key' : 'setup',
43
+ billingWords: fill(billing, { name, company }), method: w[`via.${r.via}`], status: w[`ready.${r.readiness}`],
44
+ };
45
+ }
46
+ export function connectStep(step, action) {
47
+ if (action.type === 'pick')
48
+ return { at: 'route', key: action.key, entry: 'entry' };
49
+ if (action.type === 'back')
50
+ return { at: 'list' };
51
+ return step.at === 'route' ? { ...step, entry: keyStep(step.entry, action) } : step;
52
+ }
53
+ /** Every route, grouped in a fixed order (empty groups left out), rows in the kits' own order. */
54
+ export function connectView(routes, step = { at: 'list' }, words = WORDS) {
55
+ const rows = routes.map((r) => rowOf(r, words));
56
+ const groups = ORDER.map((id) => ({ id, title: words[`connect.${id}`], note: words[`connect.${id}.note`], rows: rows.filter((r) => r.group === id) }))
57
+ .filter((g) => g.rows.length);
58
+ const row = step.at === 'route' ? rows.find((r) => r.key === step.key) : undefined;
59
+ if (!row || step.at !== 'route')
60
+ return { groups };
61
+ if (row.does !== 'key')
62
+ return { groups, chosen: { row } };
63
+ const label = words[row.group === 'plans' ? 'key.label.plan' : row.billing === 'api' ? 'key.label.perUse' : 'key.label.other'];
64
+ return { groups, chosen: { row, key: keyView(step.entry, (k) => (k === 'key.label' ? label : words[k])) } };
65
+ }
66
+ /** `useSignIn`'s options for the chosen sign-in row, or undefined for any other step. Draw the sheet as its own
67
+ * component keyed by `chosen.row.key` so a new pick starts with fresh sign-in state. */
68
+ export function signInFor(view, o) {
69
+ const row = view.chosen?.row;
70
+ if (row?.does !== 'signin')
71
+ return undefined;
72
+ const { read, start, cancel, ...rest } = o;
73
+ return { ...rest, read: () => read(row), cancel: () => cancel(row), ...(start ? { start: (body) => start(row, body) } : {}) };
74
+ }
@@ -0,0 +1,17 @@
1
+ export type Stop = () => void;
2
+ /** A view any UI can draw from: the current state, and a way to hear each change (the returned function stops). */
3
+ export type Store<T> = {
4
+ get(): T;
5
+ subscribe(fn: (state: T) => void): () => void;
6
+ };
7
+ type Live = {
8
+ hold(it: AsyncIterator<unknown>): void;
9
+ stopped(): boolean;
10
+ ok(): void;
11
+ };
12
+ export declare function retrying(attempt: (live: Live) => Promise<void>, retryMs: number): Stop;
13
+ /** A store that follows its source only while someone listens: the first listener starts it, the last one stops it. */
14
+ export declare function store<T>(initial: T, follow: (set: (state: T) => void) => Stop): Store<T> & {
15
+ set(state: T): void;
16
+ };
17
+ export {};
package/dist/follow.js ADDED
@@ -0,0 +1,72 @@
1
+ // Keeping a live view going over a link: a view's event stream ends whenever the link drops or the host restarts, so
2
+ // it opens again after a pause until the view stops or the pairing is gone for good. Framework-free.
3
+ // Only a removed pairing never comes back; a stopped or refused link comes back with its `retry()`.
4
+ const final = (e) => typeof e === 'object' && e !== null && e.code === 'removed';
5
+ // ponytail: the pause doubles from 250 ms up to `retryMs` while tries fail, and starts short again once one works;
6
+ // the link paces its own reconnects, so a stream only reopens into a link that is either back (it works, soon after
7
+ // the link does) or still away (it fails at once, cheaply).
8
+ export function retrying(attempt, retryMs) {
9
+ let stopped = false;
10
+ let pause = 0;
11
+ let current;
12
+ let timer;
13
+ let wake;
14
+ const live = {
15
+ hold: (it) => { current = it; if (stopped)
16
+ void it.return?.(); },
17
+ stopped: () => stopped,
18
+ ok: () => { pause = 0; },
19
+ };
20
+ void (async () => {
21
+ while (!stopped) {
22
+ try {
23
+ await attempt(live);
24
+ }
25
+ catch (e) {
26
+ if (final(e))
27
+ return;
28
+ }
29
+ current = undefined;
30
+ if (stopped)
31
+ return;
32
+ pause = Math.min(pause ? pause * 2 : 250, retryMs);
33
+ await new Promise((resolve) => { wake = resolve; timer = setTimeout(resolve, pause); });
34
+ }
35
+ })();
36
+ return () => {
37
+ if (stopped)
38
+ return;
39
+ stopped = true;
40
+ clearTimeout(timer);
41
+ wake?.();
42
+ void current?.return?.();
43
+ };
44
+ }
45
+ /** A store that follows its source only while someone listens: the first listener starts it, the last one stops it. */
46
+ export function store(initial, follow) {
47
+ let state = initial;
48
+ let stop;
49
+ const listeners = new Set();
50
+ const set = (next) => {
51
+ if (next === state)
52
+ return;
53
+ state = next;
54
+ for (const fn of [...listeners])
55
+ fn(state);
56
+ };
57
+ return {
58
+ get: () => state,
59
+ set,
60
+ subscribe: (fn) => {
61
+ listeners.add(fn);
62
+ stop ??= follow(set);
63
+ return () => {
64
+ if (!listeners.delete(fn) || listeners.size > 0)
65
+ return;
66
+ const s = stop;
67
+ stop = undefined;
68
+ s?.();
69
+ };
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,103 @@
1
+ import { type Store } from './follow.ts';
2
+ export type AgentStatus = 'idle' | 'working' | 'blocked' | 'done' | 'unknown';
3
+ export type HerdrAgent = {
4
+ kind?: string;
5
+ name?: string;
6
+ status: AgentStatus;
7
+ revision: number;
8
+ launchPending?: boolean;
9
+ interactiveReady?: boolean;
10
+ };
11
+ /** The tree as the Herdr kit reports it. */
12
+ export type HerdrTree = {
13
+ connected: boolean;
14
+ workspaces: {
15
+ id: string;
16
+ label: string;
17
+ tabs: {
18
+ id: string;
19
+ label: string;
20
+ panes: {
21
+ id: string;
22
+ cwd?: string;
23
+ agent?: HerdrAgent;
24
+ }[];
25
+ }[];
26
+ }[];
27
+ };
28
+ /** An agent waiting for an answer, as the Herdr kit reports it; `revision` goes back with the answer. */
29
+ export type BlockedAgent = {
30
+ paneId: string;
31
+ workspaceId: string;
32
+ tabId: string;
33
+ kind?: string;
34
+ revision: number;
35
+ prompt: string;
36
+ since: number;
37
+ };
38
+ /** One `hd.events` frame. */
39
+ export type HerdrFrame = {
40
+ type: 'snapshot';
41
+ snapshot: HerdrTree;
42
+ } | {
43
+ type: 'blocked';
44
+ change: 'added' | 'resolved';
45
+ blocked: BlockedAgent;
46
+ } | {
47
+ type: 'raw';
48
+ line: string;
49
+ };
50
+ export type HerdrAction = HerdrFrame | {
51
+ type: 'listed';
52
+ blocked: BlockedAgent[];
53
+ };
54
+ /** `tree` is null until the computer first says what it has. */
55
+ export type HerdrState = {
56
+ tree: HerdrTree | null;
57
+ blocked: BlockedAgent[];
58
+ };
59
+ export declare const HERDR_EMPTY: HerdrState;
60
+ export declare function herdrStep(s: HerdrState, a: HerdrAction): HerdrState;
61
+ /** An agent as a list row: its status is what the kit's `agentWords` takes ('starting' while it launches). */
62
+ export type AgentRow = {
63
+ paneId: string;
64
+ name: string;
65
+ kind?: string;
66
+ status: AgentStatus | 'starting';
67
+ agent: HerdrAgent;
68
+ };
69
+ /** The agents grouped by where they run ("project · tab"), tabs without an agent left out. `project` is the
70
+ * folder's own name alone, for rows that name the agent without the tab's internal label. */
71
+ export declare function herdrTreeView(tree: HerdrTree | null): {
72
+ workspaceId: string;
73
+ tabId: string;
74
+ project: string;
75
+ where: string;
76
+ agents: AgentRow[];
77
+ }[];
78
+ /** The agent in one pane, if that pane has one. */
79
+ export declare function agentIn(tree: HerdrTree | null, paneId: string | undefined): AgentRow | undefined;
80
+ /** The waiting agents, each with the name to show for it. */
81
+ export declare const blockedView: (s: HerdrState) => {
82
+ name: string;
83
+ paneId: string;
84
+ workspaceId: string;
85
+ tabId: string;
86
+ kind?: string;
87
+ revision: number;
88
+ prompt: string;
89
+ since: number;
90
+ }[];
91
+ /** What `herdrStore` needs from a device client: `herdrDevice(link)` fits. */
92
+ export type HerdrSource = {
93
+ blocked(): Promise<BlockedAgent[]>;
94
+ events(): AsyncIterable<HerdrFrame>;
95
+ };
96
+ /**
97
+ * The tree and the waiting agents, live from one `hd.events` stream per device however many views watch it: the
98
+ * tree from its frames, the waiting list read once the stream is open and then kept by its frames. Opened again
99
+ * after the link comes back.
100
+ */
101
+ export declare function herdrStore(source: HerdrSource, { retryMs }?: {
102
+ retryMs?: number;
103
+ }): Store<HerdrState>;