@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.
package/dist/herdr.js ADDED
@@ -0,0 +1,88 @@
1
+ // Herdr's agents as a person sees them: the tree (where each agent is, and how it is doing) and the agents waiting
2
+ // for an answer, kept current from one `hd.events` stream. Framework-free; typed structurally against
3
+ // `@byokit/herdr/device`, so nothing here imports the kit.
4
+ import { retrying, store } from "./follow.js";
5
+ export const HERDR_EMPTY = { tree: null, blocked: [] };
6
+ export function herdrStep(s, a) {
7
+ if (a.type === 'snapshot') {
8
+ // A still-waiting agent can move on to a new revision with no question frame; the tree carries it, and the
9
+ // answer must go back with the current one.
10
+ const blocked = s.blocked.map((b) => {
11
+ const revision = agentIn(a.snapshot, b.paneId)?.agent.revision;
12
+ return revision === undefined || revision === b.revision ? b : { ...b, revision };
13
+ });
14
+ return { tree: a.snapshot, blocked: blocked.some((b, i) => b !== s.blocked[i]) ? blocked : s.blocked };
15
+ }
16
+ if (a.type === 'listed')
17
+ return { ...s, blocked: a.blocked };
18
+ if (a.type !== 'blocked')
19
+ return s;
20
+ const rest = s.blocked.filter((b) => b.paneId !== a.blocked.paneId);
21
+ if (a.change === 'resolved')
22
+ return rest.length === s.blocked.length ? s : { ...s, blocked: rest };
23
+ const at = s.blocked.findIndex((b) => b.paneId === a.blocked.paneId);
24
+ return { ...s, blocked: at < 0 ? [...s.blocked, a.blocked] : s.blocked.map((b, i) => (i === at ? a.blocked : b)) };
25
+ }
26
+ const row = (paneId, agent) => ({
27
+ paneId, name: agent.name ?? agent.kind ?? 'Agent', kind: agent.kind,
28
+ status: agent.launchPending ? 'starting' : agent.status, agent,
29
+ });
30
+ // A workspace labelled with its folder reads as the folder's own name.
31
+ const place = (label) => label.split('/').filter(Boolean).pop() ?? label;
32
+ /** The agents grouped by where they run ("project · tab"), tabs without an agent left out. `project` is the
33
+ * folder's own name alone, for rows that name the agent without the tab's internal label. */
34
+ export function herdrTreeView(tree) {
35
+ return (tree?.workspaces ?? []).flatMap((w) => w.tabs.map((t) => ({
36
+ workspaceId: w.id, tabId: t.id, project: place(w.label), where: `${place(w.label)} · ${t.label}`,
37
+ agents: t.panes.flatMap((p) => (p.agent ? [row(p.id, p.agent)] : [])),
38
+ }))).filter((g) => g.agents.length > 0);
39
+ }
40
+ /** The agent in one pane, if that pane has one. */
41
+ export function agentIn(tree, paneId) {
42
+ for (const w of tree?.workspaces ?? [])
43
+ for (const t of w.tabs)
44
+ for (const p of t.panes) {
45
+ if (p.id === paneId)
46
+ return p.agent ? row(p.id, p.agent) : undefined;
47
+ }
48
+ return undefined;
49
+ }
50
+ /** The waiting agents, each with the name to show for it. */
51
+ export const blockedView = (s) => s.blocked.map((b) => ({ ...b, name: agentIn(s.tree, b.paneId)?.name ?? b.kind ?? 'Agent' }));
52
+ const shared = new WeakMap();
53
+ /**
54
+ * The tree and the waiting agents, live from one `hd.events` stream per device however many views watch it: the
55
+ * tree from its frames, the waiting list read once the stream is open and then kept by its frames. Opened again
56
+ * after the link comes back.
57
+ */
58
+ export function herdrStore(source, { retryMs = 2000 } = {}) {
59
+ const known = shared.get(source);
60
+ if (known)
61
+ return known;
62
+ const made = store(HERDR_EMPTY, (set) => {
63
+ let s = made.get();
64
+ const apply = (a) => set(s = herdrStep(s, a));
65
+ return retrying(async (live) => {
66
+ const it = source.events()[Symbol.asyncIterator]();
67
+ live.hold(it);
68
+ try {
69
+ const r = await it.next(); // the first frame is the tree, sent once the stream is open
70
+ if (r.done || live.stopped())
71
+ return;
72
+ const listed = await source.blocked(); // frames meanwhile wait in the stream, applied after the list
73
+ if (live.stopped())
74
+ return;
75
+ apply(r.value);
76
+ apply({ type: 'listed', blocked: listed });
77
+ live.ok();
78
+ for (let r = await it.next(); !r.done && !live.stopped(); r = await it.next())
79
+ apply(r.value);
80
+ }
81
+ finally {
82
+ void it.return?.();
83
+ }
84
+ }, retryMs);
85
+ });
86
+ shared.set(source, made);
87
+ return made;
88
+ }
@@ -0,0 +1,9 @@
1
+ export { phaseOf, stepOf, type AccountView, type Phase, type SignInView } from './phase.ts';
2
+ export { connectedWords, describeRoute, routeChoices, routeShort, type Route, type RouteChoice, type RouteCode } from './route.ts';
3
+ export { useSignIn, type UseSignIn } from './useSignIn.ts';
4
+ export { stepsText, stepsView, type StepInput, type StepRow, type StepState } from './steps.ts';
5
+ export { consentWords, linkWords, pairErrorWords, pairingView, qrMatrix, qrText, type DeviceKind, type LinkStatus, type PairPhase, type Role } from './link.ts';
6
+ export * from './kits.ts';
7
+ export { useApprovals, useBlocked, useHerdrTree, useRun } from './useKits.ts';
8
+ export { connectStep, connectView, signInFor, type ConnectAction, type ConnectDoes, type ConnectGroup, type ConnectGroupId, type ConnectRoute, type ConnectRow, type ConnectStep, type ConnectView, type ConnectWords, type UseConnectSignIn } from './connect.ts';
9
+ export { useConnect, type UseConnect } from './useConnect.ts';
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export { phaseOf, stepOf } from "./phase.js";
2
+ export { connectedWords, describeRoute, routeChoices, routeShort } from "./route.js";
3
+ export { useSignIn } from "./useSignIn.js";
4
+ export { stepsText, stepsView } from "./steps.js";
5
+ export { consentWords, linkWords, pairErrorWords, pairingView, qrMatrix, qrText } from "./link.js";
6
+ export * from "./kits.js";
7
+ export { useApprovals, useBlocked, useHerdrTree, useRun } from "./useKits.js";
8
+ export { connectStep, connectView, signInFor } from "./connect.js";
9
+ export { useConnect } from "./useConnect.js";
package/dist/key.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /** A key-entry card never holds a credential: the host submits the input directly to kit.addKey. */
2
+ export type KeyState = 'entry' | 'checking' | 'ok' | 'invalid' | 'not_included';
3
+ export type KeyAction = {
4
+ type: 'submit';
5
+ } | {
6
+ type: 'edit';
7
+ } | {
8
+ type: 'result';
9
+ result: 'ok' | 'invalid' | 'not_included';
10
+ };
11
+ export type KeyWords = (key: 'key.label' | 'key.entry' | 'key.checking' | 'key.ok' | 'key.invalid' | 'key.notIncluded') => string;
12
+ export declare function keyStep(state: KeyState, action: KeyAction): KeyState;
13
+ export declare function keyView(state: KeyState, words: KeyWords): {
14
+ state: KeyState;
15
+ label: string;
16
+ message: string;
17
+ busy: boolean;
18
+ editable: boolean;
19
+ };
package/dist/key.js ADDED
@@ -0,0 +1,12 @@
1
+ export function keyStep(state, action) {
2
+ if (action.type === 'submit')
3
+ return 'checking';
4
+ if (action.type === 'edit')
5
+ return 'entry';
6
+ return state === 'checking' ? action.result : state;
7
+ }
8
+ export function keyView(state, words) {
9
+ const keys = { entry: 'key.entry', checking: 'key.checking', ok: 'key.ok', invalid: 'key.invalid', not_included: 'key.notIncluded' };
10
+ return { state, label: words('key.label'), message: words(keys[state]), busy: state === 'checking',
11
+ editable: state === 'entry' || state === 'invalid' };
12
+ }
package/dist/kits.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { RUN_IDLE, runStep, runStore, runView, type RunAction, type RunEnd, type RunEvent, type RunFrame, type RunPhase, type RunSource, type RunState, type RunWords, type RunWordsOptions } from './run.ts';
2
+ export { approvalsStep, approvalsStore, approvalWords, type Approval, type ApprovalFrame, type ApprovalsAction, type ApprovalsSource, type FollowOptions } from './approvals.ts';
3
+ export { HERDR_EMPTY, agentIn, blockedView, herdrStep, herdrStore, herdrTreeView, type AgentRow, type AgentStatus, type BlockedAgent, type HerdrAction, type HerdrAgent, type HerdrFrame, type HerdrSource, type HerdrState, type HerdrTree } from './herdr.ts';
4
+ export type { Store } from './follow.ts';
5
+ export { keyStep, keyView, type KeyAction, type KeyState, type KeyWords } from './key.ts';
6
+ export { livePanelView, signInSheetView, signInsStore, type BrowserFrame, type LivePanel, type LiveState, type SignInAction, type SignInRequest, type SignInSheet, type SignInsSource, type SignInVars, type SignInWordKey } from './browser.ts';
package/dist/kits.js ADDED
@@ -0,0 +1,7 @@
1
+ // View state for the runtime kits, with no React: reducers, views and live stores any UI can draw from (plain DOM,
2
+ // React, React Native). The hooks in `@byokit/ui` read the same stores.
3
+ export { RUN_IDLE, runStep, runStore, runView } from "./run.js";
4
+ export { approvalsStep, approvalsStore, approvalWords } from "./approvals.js";
5
+ export { HERDR_EMPTY, agentIn, blockedView, herdrStep, herdrStore, herdrTreeView } from "./herdr.js";
6
+ export { keyStep, keyView } from "./key.js";
7
+ export { livePanelView, signInSheetView, signInsStore } from "./browser.js";
package/dist/link.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ /** The QR for a pairing offer's text (`offer().text` from @byokit/link) as rows of dark (true) and light modules, with
2
+ * the quiet border scanners need. Draw each true as a filled square in any UI: View grid, canvas, SVG or terminal.
3
+ * `border` is that quiet border in modules, 2 unless given. */
4
+ export declare const qrMatrix: (text: string, opts?: {
5
+ border?: number;
6
+ }) => boolean[][];
7
+ /** The same QR as text rows for a terminal: each line draws two module rows as half blocks (`█` both dark, `▀` top
8
+ * only, `▄` bottom only), so `console.log(qrText(offer.text))` in setup scripts shows a code a phone can scan. */
9
+ export declare function qrText(text: string, opts?: {
10
+ border?: number;
11
+ }): string;
12
+ /** What a pairing grants: `control` changes things on the computer, `view` only sees them. */
13
+ export type Role = 'control' | 'view';
14
+ /** Which device is pairing: a phone holding the app, or a browser that opened the pairing link. */
15
+ export type DeviceKind = 'phone' | 'browser';
16
+ /** The question before pairing: which computer, what this device may do, and for how long. `device` names the
17
+ * pairing side when it isn't the app ("This browser …"), `detail` adds one app sentence after ("It can also …"). */
18
+ export declare function consentWords(o: {
19
+ hostName: string;
20
+ role: Role;
21
+ device?: DeviceKind;
22
+ detail?: string;
23
+ }): string;
24
+ /** The pairing sheet's phases: `scan` the QR (or type the code), `compare` the two words with the computer, `waiting`
25
+ * for a yes there, `paired`, or `failed` with the link's own sentence. */
26
+ export type PairPhase = 'scan' | 'compare' | 'waiting' | 'paired' | 'failed';
27
+ export declare function pairingView(o: {
28
+ phase: PairPhase;
29
+ hostName?: string;
30
+ words?: string;
31
+ error?: string;
32
+ device?: DeviceKind;
33
+ detail?: string;
34
+ }): {
35
+ phase: PairPhase;
36
+ title: string;
37
+ words?: string;
38
+ };
39
+ /** A failed pairing as words for the phone's error line: `expired` when the code ran out (get a new one), `wrong`
40
+ * when it didn't match (check and retry), else the link's own sentence. Reads @byokit/link's error `code` when it is
41
+ * there (`wrong-code`, `expired`), else the message, so every path lands in the same plain words. */
42
+ export declare function pairErrorWords(error: unknown): {
43
+ kind: 'expired' | 'wrong' | 'other';
44
+ words: string;
45
+ };
46
+ /** The link's status (`LinkStatus` from @byokit/link) as one sentence. */
47
+ export type LinkStatus = 'connecting' | 'online' | 'offline' | 'refused' | 'removed';
48
+ export declare function linkWords(status: LinkStatus, hostName?: string): string;
package/dist/link.js ADDED
@@ -0,0 +1,63 @@
1
+ // What a pairing and a link put in front of a person, framework-free: the QR to show, the consent before pairing, the
2
+ // two words to compare and the link's status, each in plain words. Pairs with @byokit/link.
3
+ import { encode } from 'uqr';
4
+ /** The QR for a pairing offer's text (`offer().text` from @byokit/link) as rows of dark (true) and light modules, with
5
+ * the quiet border scanners need. Draw each true as a filled square in any UI: View grid, canvas, SVG or terminal.
6
+ * `border` is that quiet border in modules, 2 unless given. */
7
+ export const qrMatrix = (text, opts) => encode(text, { ecc: 'M', border: opts?.border ?? 2 }).data;
8
+ /** The same QR as text rows for a terminal: each line draws two module rows as half blocks (`█` both dark, `▀` top
9
+ * only, `▄` bottom only), so `console.log(qrText(offer.text))` in setup scripts shows a code a phone can scan. */
10
+ export function qrText(text, opts) {
11
+ const m = qrMatrix(text, opts);
12
+ const rows = [];
13
+ for (let y = 0; y < m.length; y += 2) {
14
+ const top = m[y], bottom = m[y + 1] ?? [];
15
+ rows.push(top.map((dark, x) => {
16
+ const below = bottom[x] ?? false;
17
+ return dark ? (below ? '█' : '▀') : (below ? '▄' : ' ');
18
+ }).join('').replace(/\s+$/, ''));
19
+ }
20
+ return rows.join('\n');
21
+ }
22
+ const deviceWords = (device) => device === 'phone' ? 'This phone' : device === 'browser' ? 'This browser' : 'This device';
23
+ /** The question before pairing: which computer, what this device may do, and for how long. `device` names the
24
+ * pairing side when it isn't the app ("This browser …"), `detail` adds one app sentence after ("It can also …"). */
25
+ export function consentWords(o) {
26
+ const may = o.role === 'control' ? 'see and change things on it' : 'see it, but not change anything';
27
+ const base = `Pair with ${o.hostName}? ${deviceWords(o.device)} will be able to ${may}, until you remove it there.`;
28
+ return o.detail ? `${base} ${o.detail}` : base;
29
+ }
30
+ export function pairingView(o) {
31
+ const name = o.hostName || 'your computer';
32
+ const title = (s) => (o.detail ? `${s} ${o.detail}` : s);
33
+ switch (o.phase) {
34
+ case 'scan': return { phase: o.phase, title: title(o.device === 'browser'
35
+ ? `Open the pairing link on ${name}, or type the code it shows.`
36
+ : `Scan the code on ${name}, or type the code it shows.`) };
37
+ case 'compare': return { phase: o.phase, title: title(`Check ${name} shows these two words, then say yes there.`), words: o.words };
38
+ case 'waiting': return { phase: o.phase, title: title(`Waiting for you to say yes on ${name}.`), words: o.words };
39
+ case 'paired': return { phase: o.phase, title: title(`${deviceWords(o.device)} is paired with ${name}.`) };
40
+ case 'failed': return { phase: o.phase, title: title(o.error || "Pairing didn't finish. Try again.") };
41
+ }
42
+ }
43
+ /** A failed pairing as words for the phone's error line: `expired` when the code ran out (get a new one), `wrong`
44
+ * when it didn't match (check and retry), else the link's own sentence. Reads @byokit/link's error `code` when it is
45
+ * there (`wrong-code`, `expired`), else the message, so every path lands in the same plain words. */
46
+ export function pairErrorWords(error) {
47
+ const code = typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : '';
48
+ const message = error instanceof Error ? error.message : String(error ?? '');
49
+ if (code === 'expired' || (code !== 'wrong-code' && /run out|expired/i.test(message)))
50
+ return { kind: 'expired', words: 'That pairing code has run out. Show a new one on your computer.' };
51
+ if (code === 'wrong-code' || /isn't a pairing code|didn't match|wrong/i.test(message))
52
+ return { kind: 'wrong', words: "That code didn't match. Check it, or show a new one on your computer." };
53
+ return { kind: 'other', words: message || "Pairing didn't finish. Try again." };
54
+ }
55
+ export function linkWords(status, hostName = 'your computer') {
56
+ return {
57
+ connecting: `Connecting to ${hostName}…`,
58
+ online: `Connected to ${hostName}.`,
59
+ offline: `Can't reach ${hostName} right now. This device keeps trying by itself.`,
60
+ refused: `${hostName} didn't let this device in. Pair it again there.`,
61
+ removed: `This device was removed on ${hostName}.`,
62
+ }[status];
63
+ }
@@ -0,0 +1,23 @@
1
+ export type Phase = 'opening' | 'waiting' | 'code' | 'done' | 'work' | 'cancelled' | 'busy' | 'expired' | 'failed' | 'offline';
2
+ /** A sign-in in progress, as `@byokit/accounts` `view()` reports it. */
3
+ export type SignInView = {
4
+ state: 'waiting' | 'done' | 'failed';
5
+ via?: 'browser' | 'code';
6
+ url?: string;
7
+ code?: string;
8
+ error?: string;
9
+ why?: string;
10
+ };
11
+ /** One account as the app's back end reports it; `work` is set when the sign-in is a work plan. */
12
+ export type AccountView = {
13
+ ready?: boolean;
14
+ work?: string | boolean;
15
+ signIn?: SignInView | null;
16
+ };
17
+ export declare function phaseOf(a: AccountView | null, { offline, cancelled, keepWork }?: {
18
+ offline?: boolean | undefined;
19
+ cancelled?: boolean | undefined;
20
+ keepWork?: boolean | undefined;
21
+ }): Phase;
22
+ /** Where the three-step progress bar ("Open", "Say yes", "Done") stands. */
23
+ export declare const stepOf: (p: Phase) => 0 | 1 | 3;
package/dist/phase.js ADDED
@@ -0,0 +1,21 @@
1
+ export function phaseOf(a, { offline = false, cancelled = false, keepWork = false } = {}) {
2
+ const s = a?.signIn;
3
+ if (offline)
4
+ return 'offline';
5
+ if (cancelled)
6
+ return 'cancelled';
7
+ if (a?.ready)
8
+ return a.work && !keepWork ? 'work' : 'done';
9
+ if (s?.state === 'waiting')
10
+ return s.code ? 'code' : s.url ? 'waiting' : 'opening';
11
+ if (s?.state === 'failed') {
12
+ if (s.why === 'busy')
13
+ return 'busy';
14
+ if (s.why === 'declined')
15
+ return 'cancelled';
16
+ return s.why === 'expired' || s.why === 'tooLong' || /expired|too long/i.test(s.error ?? '') ? 'expired' : 'failed';
17
+ }
18
+ return 'opening';
19
+ }
20
+ /** Where the three-step progress bar ("Open", "Say yes", "Done") stands. */
21
+ export const stepOf = (p) => (p === 'done' || p === 'work' ? 3 : p === 'waiting' || p === 'code' ? 1 : 0);
@@ -0,0 +1,28 @@
1
+ export type Route = 'Tailscale' | 'Temporary public link' | 'Same Wi-Fi' | 'Private network' | 'Away from home' | 'Your own server';
2
+ export type RouteKind = 'tailscale' | 'private' | 'lan' | 'direct';
3
+ /** An onboarding route code. A superset of reach's `Via`: reach has no Cloudflare or own-server route. */
4
+ export type RouteCode = 'tailscale' | 'tailscale-direct' | 'private' | 'lan' | 'cloudflare' | 'external';
5
+ /** One onboarding route in everyday words: the list title, the sentence under it, and what it needs first. */
6
+ export type RouteChoice = {
7
+ code: RouteCode;
8
+ /** The list title, e.g. `Same Wi-Fi`. */
9
+ title: string;
10
+ /** One everyday sentence saying what it gives, e.g. where it works. */
11
+ sentence: string;
12
+ /** What has to be true first, in words, e.g. which app to install. */
13
+ needs: string;
14
+ };
15
+ /**
16
+ * Every onboarding route in everyday words, in recommendation order. The one word table: reach's `recommend()`
17
+ * takes `sentence` and `needs` from here and only adds availability, so the order and the copy live in one place.
18
+ */
19
+ export declare function routeChoices(): RouteChoice[];
20
+ /** The route `url` takes, or undefined when it isn't a URL. Loopback is the page on the computer itself, so it reads
21
+ * as the same-network route; this kit's `/link/v1/…` away address reads as away from home, whatever its host. */
22
+ export declare function describeRoute(url: string | undefined, kind?: RouteKind): Route | undefined;
23
+ /** The route as words inside a connected line (`same Wi-Fi`): lowercase, so a consumer renders one sentence
24
+ * (`Connected to Kitchen computer - ${routeShort(route)}.`). Tailscale keeps its capital: it is a name. */
25
+ export declare function routeShort(route: Route | undefined): string | undefined;
26
+ /** The connected line a consumer renders: the computer with the route just dialled
27
+ * (`Connected to Kitchen computer - same Wi-Fi.`), or plain (`Connected to Kitchen computer.`) with no route. */
28
+ export declare function connectedWords(hostName: string, url: string | undefined, kind?: RouteKind): string;
package/dist/route.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Every onboarding route in everyday words, in recommendation order. The one word table: reach's `recommend()`
3
+ * takes `sentence` and `needs` from here and only adds availability, so the order and the copy live in one place.
4
+ */
5
+ export function routeChoices() {
6
+ return [
7
+ {
8
+ code: 'tailscale',
9
+ title: 'Tailscale — works anywhere',
10
+ sentence: 'Your phone reaches this computer from anywhere.',
11
+ needs: 'The free Tailscale app on this computer and your phone, signed in to the same account.',
12
+ },
13
+ {
14
+ code: 'tailscale-direct',
15
+ title: 'Tailscale — direct (phone app only)',
16
+ sentence: 'Same as above without Tailscale Serve. Pick this if Serve is already used on this computer for something else.',
17
+ needs: 'The free Tailscale app on this computer and your phone, signed in to the same account. No Serve setup.',
18
+ },
19
+ {
20
+ code: 'private',
21
+ title: 'Private network you already use (NetBird, WireGuard, ZeroTier)',
22
+ sentence: 'Pick this if this computer and phone are already on one.',
23
+ needs: 'This computer and your phone on the same private network.',
24
+ },
25
+ {
26
+ code: 'lan',
27
+ title: 'Same Wi-Fi',
28
+ sentence: 'Easiest. Works while your phone is on the same Wi-Fi as this computer. Nothing else to install.',
29
+ needs: 'Your phone on the same Wi-Fi as this computer.',
30
+ },
31
+ {
32
+ code: 'cloudflare',
33
+ title: 'Temporary public link (Cloudflare)',
34
+ sentence: 'Works anywhere without a VPN, but the link changes when it restarts.',
35
+ needs: 'cloudflared installed on this computer.',
36
+ },
37
+ {
38
+ code: 'external',
39
+ title: 'Your own server',
40
+ sentence: "For people who already run their own secure server. You'll paste its address next.",
41
+ needs: 'A stable server address you manage.',
42
+ },
43
+ ];
44
+ }
45
+ const privateIp = (host) => /^(?:127\.|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|100\.(?:6[4-9]|[78]\d|9\d|1[01]\d|12[0-7])\.)/.test(host);
46
+ const ipv4 = (host) => /^(?:\d{1,3}\.){3}\d{1,3}$/.test(host) && host.split('.').every((part) => +part <= 255);
47
+ /** The scheme, host and path of a `scheme://authority…` address, without touching `URL`: the React Native polyfill has no
48
+ * `URL.canParse`, and leaves `hostname` empty for that scheme. */
49
+ function schemeHost(url) {
50
+ const m = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^\/?#]*)([^?#]*)/.exec(url);
51
+ if (!m)
52
+ return undefined;
53
+ let host = m[2];
54
+ if (!host)
55
+ return undefined;
56
+ const at = host.lastIndexOf('@');
57
+ if (at >= 0)
58
+ host = host.slice(at + 1);
59
+ if (host.startsWith('[')) {
60
+ const end = host.indexOf(']');
61
+ if (end < 0)
62
+ return undefined;
63
+ host = host.slice(1, end);
64
+ }
65
+ else {
66
+ const colon = host.indexOf(':');
67
+ if (colon >= 0)
68
+ host = host.slice(0, colon);
69
+ }
70
+ host = host.toLowerCase();
71
+ if (!host)
72
+ return undefined;
73
+ return { scheme: m[1].toLowerCase(), hostname: host, path: m[3] ?? '' };
74
+ }
75
+ /** The route `url` takes, or undefined when it isn't a URL. Loopback is the page on the computer itself, so it reads
76
+ * as the same-network route; this kit's `/link/v1/…` away address reads as away from home, whatever its host. */
77
+ export function describeRoute(url, kind) {
78
+ if (url === undefined)
79
+ return undefined;
80
+ const parsed = schemeHost(url);
81
+ if (!parsed)
82
+ return undefined;
83
+ const { scheme, hostname, path } = parsed;
84
+ if (kind === 'tailscale' || kind === 'direct' || hostname.endsWith('.ts.net'))
85
+ return 'Tailscale';
86
+ if (kind === 'private')
87
+ return 'Private network';
88
+ if (kind === 'lan')
89
+ return 'Same Wi-Fi';
90
+ if (hostname.endsWith('.trycloudflare.com'))
91
+ return 'Temporary public link';
92
+ if ((scheme === 'ws' || scheme === 'wss') && path.startsWith('/link/v1/'))
93
+ return 'Away from home';
94
+ if ((scheme === 'ws' || scheme === 'wss') && ipv4(hostname) && privateIp(hostname)) {
95
+ return hostname.startsWith('100.') ? 'Private network' : 'Same Wi-Fi';
96
+ }
97
+ if ((scheme === 'ws' || scheme === 'wss') && (hostname === 'localhost' || hostname === '::1'))
98
+ return 'Same Wi-Fi';
99
+ return 'Your own server';
100
+ }
101
+ /** The route as words inside a connected line (`same Wi-Fi`): lowercase, so a consumer renders one sentence
102
+ * (`Connected to Kitchen computer - ${routeShort(route)}.`). Tailscale keeps its capital: it is a name. */
103
+ export function routeShort(route) {
104
+ return route === undefined ? undefined : {
105
+ 'Tailscale': 'Tailscale',
106
+ 'Temporary public link': 'a temporary public link',
107
+ 'Same Wi-Fi': 'same Wi-Fi',
108
+ 'Private network': 'a private network',
109
+ 'Away from home': 'away from home',
110
+ 'Your own server': 'your own server',
111
+ }[route];
112
+ }
113
+ /** The connected line a consumer renders: the computer with the route just dialled
114
+ * (`Connected to Kitchen computer - same Wi-Fi.`), or plain (`Connected to Kitchen computer.`) with no route. */
115
+ export function connectedWords(hostName, url, kind) {
116
+ const short = routeShort(describeRoute(url, kind));
117
+ return short ? `Connected to ${hostName} - ${short}.` : `Connected to ${hostName}.`;
118
+ }
package/dist/run.d.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { type Store } from './follow.ts';
2
+ export type RunEvent = {
3
+ type: 'started';
4
+ } | {
5
+ type: 'thinking';
6
+ tokens: number;
7
+ } | {
8
+ type: 'text';
9
+ text: string;
10
+ } | {
11
+ type: 'tool';
12
+ name: string;
13
+ phase: 'start' | 'end';
14
+ };
15
+ export type RunEnd = {
16
+ ok: true;
17
+ text: string;
18
+ } | {
19
+ ok: false;
20
+ aborted: true;
21
+ } | {
22
+ ok: false;
23
+ kind: 'signed-out' | 'resting' | 'plan' | 'network' | 'other' | 'output';
24
+ until?: number;
25
+ message: string;
26
+ };
27
+ /** One frame of the run stream: an event, then one `end`. */
28
+ export type RunFrame = RunEvent | {
29
+ type: 'end';
30
+ end: RunEnd;
31
+ };
32
+ export type RunPhase = 'idle' | 'running' | 'done' | 'stopped' | 'failed';
33
+ export type RunState = {
34
+ phase: RunPhase;
35
+ /** The reply so far (cumulative), the final reply once done. */
36
+ text: string;
37
+ /** Every tool the run used, in the order it started them; `done` once it finished. */
38
+ tools: {
39
+ name: string;
40
+ done: boolean;
41
+ }[];
42
+ end?: RunEnd;
43
+ /** The host's own words when the stream ended without an end (e.g. this device may not run). */
44
+ error?: string;
45
+ };
46
+ export type RunAction = RunFrame | {
47
+ type: 'start';
48
+ } | {
49
+ type: 'stop';
50
+ } | {
51
+ type: 'error';
52
+ message: string;
53
+ };
54
+ export declare const RUN_IDLE: RunState;
55
+ export declare function runStep(s: RunState, a: RunAction): RunState;
56
+ /** The kit's sentences a failed run needs (`@byokit/openclaw/device`'s `words` fits). */
57
+ export type RunWords = (key: 'member.signedOut' | 'member.resting' | 'member.plan' | 'member.network' | 'member.output', vars: {
58
+ name: string;
59
+ time: string;
60
+ }) => string;
61
+ export type RunWordsOptions = {
62
+ words: RunWords;
63
+ /** Who answers, as the person knows it: "ChatGPT". */
64
+ name: string;
65
+ /** How to say the time a resting plan is back; defaults to the device's short local time. */
66
+ time?: (ms: number) => string;
67
+ };
68
+ /** What to draw: the reply, the tool at work (if any), and the sentence for how it ended ('' when there is none). */
69
+ export declare function runView(s: RunState, { words, name, time }: RunWordsOptions): {
70
+ phase: RunPhase;
71
+ text: string;
72
+ tools: {
73
+ name: string;
74
+ done: boolean;
75
+ }[];
76
+ /** The tool running right now, the latest one started. */
77
+ tool: string | undefined;
78
+ words: string;
79
+ };
80
+ /** What `runStore` needs from a device client: `openclawDevice(link)` fits. */
81
+ export type RunSource = {
82
+ run(message: string, o?: {
83
+ sessionKey?: string;
84
+ }): AsyncIterable<RunFrame>;
85
+ };
86
+ /**
87
+ * One run at a time from a device: `send` starts one (stopping any still streaming), `stop` stops listening to it.
88
+ * Stopping here does not abort the run on the computer; pair it with the device's `abort(sessionKey)` for that.
89
+ */
90
+ export declare function runStore(source: RunSource): Store<RunState> & {
91
+ send(message: string, o?: {
92
+ sessionKey?: string;
93
+ }): void;
94
+ stop(): void;
95
+ };
package/dist/run.js ADDED
@@ -0,0 +1,85 @@
1
+ // One agent run as a person sees it: the reply streaming in, the tools it uses, and how it ended in plain words.
2
+ // Framework-free; typed structurally against `@byokit/openclaw/device`'s `run()`, so nothing here imports a kit.
3
+ // idle nothing sent yet running the reply is streaming in
4
+ // done the reply is complete stopped stopped before the end (here, or aborted on the computer)
5
+ // failed it ended with a problem: `words` says which, in the kit's own sentence
6
+ import { store } from "./follow.js";
7
+ export const RUN_IDLE = { phase: 'idle', text: '', tools: [] };
8
+ export function runStep(s, a) {
9
+ if (a.type === 'start')
10
+ return { phase: 'running', text: '', tools: [] };
11
+ if (s.phase !== 'running')
12
+ return s; // a finished run hears nothing more
13
+ switch (a.type) {
14
+ case 'started':
15
+ case 'thinking': return s;
16
+ case 'text': return { ...s, text: a.text };
17
+ case 'tool': {
18
+ if (a.phase === 'start')
19
+ return { ...s, tools: [...s.tools, { name: a.name, done: false }] };
20
+ const at = s.tools.findIndex((t) => t.name === a.name && !t.done);
21
+ return at < 0 ? s : { ...s, tools: s.tools.map((t, i) => (i === at ? { ...t, done: true } : t)) };
22
+ }
23
+ case 'end': {
24
+ const tools = s.tools.map((t) => (t.done ? t : { ...t, done: true }));
25
+ if (a.end.ok)
26
+ return { phase: 'done', text: a.end.text || s.text, tools, end: a.end };
27
+ return { ...s, phase: 'aborted' in a.end ? 'stopped' : 'failed', tools, end: a.end };
28
+ }
29
+ case 'error': return { ...s, phase: 'failed', error: a.message };
30
+ case 'stop': return { ...s, phase: 'stopped' };
31
+ }
32
+ }
33
+ const KEY = { 'signed-out': 'member.signedOut', resting: 'member.resting', plan: 'member.plan', network: 'member.network', output: 'member.output' };
34
+ const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
35
+ /** What to draw: the reply, the tool at work (if any), and the sentence for how it ended ('' when there is none). */
36
+ export function runView(s, { words, name, time = clock }) {
37
+ const end = s.end && !s.end.ok && 'kind' in s.end ? s.end : undefined;
38
+ const said = end && end.kind !== 'other'
39
+ ? words(KEY[end.kind], { name, time: end.until === undefined ? 'later' : time(end.until) })
40
+ : s.error ?? '';
41
+ return {
42
+ phase: s.phase, text: s.text, tools: s.tools,
43
+ /** The tool running right now, the latest one started. */
44
+ tool: s.tools.filter((t) => !t.done).pop()?.name,
45
+ words: said,
46
+ };
47
+ }
48
+ /**
49
+ * One run at a time from a device: `send` starts one (stopping any still streaming), `stop` stops listening to it.
50
+ * Stopping here does not abort the run on the computer; pair it with the device's `abort(sessionKey)` for that.
51
+ */
52
+ export function runStore(source) {
53
+ const s = store(RUN_IDLE, () => () => { });
54
+ let current;
55
+ const send = (message, o) => {
56
+ current?.();
57
+ let stopped = false;
58
+ let it;
59
+ const step = (a) => { if (!stopped)
60
+ s.set(runStep(s.get(), a)); };
61
+ current = () => {
62
+ if (stopped)
63
+ return;
64
+ step({ type: 'stop' });
65
+ stopped = true;
66
+ void it?.return?.();
67
+ };
68
+ step({ type: 'start' });
69
+ void (async () => {
70
+ try {
71
+ it = source.run(message, o)[Symbol.asyncIterator]();
72
+ for (let r = await it.next(); !r.done && !stopped; r = await it.next())
73
+ step(r.value);
74
+ step({ type: 'stop' }); // the stream ended without an end frame
75
+ }
76
+ catch (e) {
77
+ const message = e instanceof Error ? e.message : String(e);
78
+ // A stream the link cut ends with the link's bare reason ('unreachable'), not words: the run stopped here,
79
+ // and the link's own status says why.
80
+ step(/^[a-z][a-z-]*$/.test(message) ? { type: 'stop' } : { type: 'error', message });
81
+ }
82
+ })();
83
+ };
84
+ return { get: s.get, subscribe: s.subscribe, send, stop: () => current?.() };
85
+ }