@byokit/herdr 0.1.0-next.dbe7142

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +202 -0
  3. package/README.md +18 -0
  4. package/dist/agents.d.ts +7 -0
  5. package/dist/agents.js +141 -0
  6. package/dist/approvals.d.ts +24 -0
  7. package/dist/approvals.js +81 -0
  8. package/dist/cli.d.ts +10 -0
  9. package/dist/cli.js +89 -0
  10. package/dist/close.d.ts +4 -0
  11. package/dist/close.js +85 -0
  12. package/dist/constants.d.ts +2 -0
  13. package/dist/constants.js +5 -0
  14. package/dist/device.d.ts +40 -0
  15. package/dist/device.js +128 -0
  16. package/dist/generated/events.d.ts +371 -0
  17. package/dist/generated/events.js +3 -0
  18. package/dist/generated/methods.d.ts +2299 -0
  19. package/dist/generated/methods.js +3 -0
  20. package/dist/generated/report.json +50 -0
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.js +4 -0
  23. package/dist/kit.d.ts +73 -0
  24. package/dist/kit.js +209 -0
  25. package/dist/link.d.ts +24 -0
  26. package/dist/link.js +324 -0
  27. package/dist/notices.d.ts +14 -0
  28. package/dist/notices.js +88 -0
  29. package/dist/socket.d.ts +2 -0
  30. package/dist/socket.js +137 -0
  31. package/dist/supervise.d.ts +17 -0
  32. package/dist/supervise.js +137 -0
  33. package/dist/terminal.d.ts +6 -0
  34. package/dist/terminal.js +105 -0
  35. package/dist/testing/contract.d.ts +9 -0
  36. package/dist/testing/contract.js +308 -0
  37. package/dist/testing/fake-herdr/bin.d.ts +5 -0
  38. package/dist/testing/fake-herdr/bin.js +210 -0
  39. package/dist/testing/fake-herdr/server.d.ts +21 -0
  40. package/dist/testing/fake-herdr/server.js +604 -0
  41. package/dist/testing/fake-herdr/world.d.ts +84 -0
  42. package/dist/testing/fake-herdr/world.js +85 -0
  43. package/dist/testing/index.d.ts +4 -0
  44. package/dist/testing/index.js +7 -0
  45. package/dist/types.d.ts +115 -0
  46. package/dist/types.js +3 -0
  47. package/dist/words.d.ts +10 -0
  48. package/dist/words.js +18 -0
  49. package/dist/words.json +19 -0
  50. package/package.json +60 -0
package/dist/link.js ADDED
@@ -0,0 +1,324 @@
1
+ // Host-side link adapter: the hd.* ops over @byokit/link, and the serve() wiring (duplicated per kit by design,
2
+ // D3) — built in H7 (docs/runtime-kits.md 7.1-7.3).
3
+ //
4
+ // Permission shape: `allow` lets every request through and `handle`/`stream` refuse with the `link.notAllowed`
5
+ // words sentence, so a refused device hears the kit's sentence (a bare `allow: false` would surface link's own
6
+ // generic `view-only`/`not-allowed` words instead).
7
+ import { createServer } from 'node:http';
8
+ import { WebSocketServer } from 'ws';
9
+ import { PublicLinkError, } from '@byokit/link';
10
+ import { reach } from '@byokit/reach';
11
+ import { stateWords, words } from "./words.js";
12
+ import { decodeB64Url, sealNotice } from "./notices.js";
13
+ // Ops a view-role grant may open; every other op needs the control role (7.1, *view* marks).
14
+ const VIEW_OPS = new Set(['hd.state', 'hd.tree', 'hd.read', 'hd.blocked', 'hd.events']);
15
+ const deny = () => {
16
+ throw new PublicLinkError(words('link.notAllowed'));
17
+ };
18
+ const findPane = (snap, paneId) => {
19
+ for (const w of snap.workspaces) {
20
+ for (const t of w.tabs) {
21
+ if (t.panes.some((p) => p.id === paneId))
22
+ return { workspaceId: w.id, tabId: t.id };
23
+ }
24
+ }
25
+ return undefined;
26
+ };
27
+ const inScope = (scope, workspaceId) => scope.workspaces === 'all' || scope.workspaces.includes(workspaceId);
28
+ const filterSnapshot = (snap, scope) => {
29
+ if (scope.workspaces === 'all')
30
+ return snap;
31
+ return { ...snap, workspaces: snap.workspaces.filter((w) => scope.workspaces.includes(w.id)) };
32
+ };
33
+ const asRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) ? v : {};
34
+ const asString = (v) => (typeof v === 'string' ? v : undefined);
35
+ const asStrings = (v) => Array.isArray(v) && v.every((e) => typeof e === 'string') ? v : undefined;
36
+ const asNumber = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
37
+ export function herdrLink(kit, o) {
38
+ if (!kit || typeof o?.scopeOf !== 'function')
39
+ throw new Error('herdr: herdrLink needs a kit and scopeOf');
40
+ const passThrough = o.passThrough ?? (() => false);
41
+ const boxes = new Map(); // grant id → registered notice box key
42
+ // The latest grant object per id, refreshed on every request so scopeOf sees current meta.
43
+ const currentGrants = new Map();
44
+ const scopeOfSafe = (grant) => {
45
+ const scope = o.scopeOf(grant);
46
+ if (scope && (scope.workspaces === 'all' || Array.isArray(scope.workspaces)))
47
+ return scope;
48
+ return { workspaces: [] };
49
+ };
50
+ if (o.relay) {
51
+ const relay = o.relay;
52
+ // Herdr pushes carry no actions (7.3): the notice is the question, the answer rides hd.answer.
53
+ // The relay reads only the generic title; the sealed body opens with the device's own seed.
54
+ // (Herdr words have no approval.notice key — the title is the blocked sentence.)
55
+ kit.onBlocked((b, change) => {
56
+ if (change !== 'added')
57
+ return;
58
+ for (const [id, box] of boxes) {
59
+ const grant = currentGrants.get(id);
60
+ if (!grant || !inScope(scopeOfSafe(grant), b.workspaceId))
61
+ continue;
62
+ const data = sealNotice(b, box);
63
+ void relay.notify({
64
+ id: b.paneId, title: words('agent.blocked'),
65
+ data: { v: 1, sealed: data.sealed }, to: [id], urgency: 'high',
66
+ }, { includeContent: true }).catch(() => { });
67
+ }
68
+ });
69
+ }
70
+ const handle = async (req, grant) => {
71
+ currentGrants.set(grant.id, grant);
72
+ const scope = scopeOfSafe(grant);
73
+ const args = asRecord(req.args);
74
+ switch (req.op) {
75
+ case 'hd.state': {
76
+ return { state: kit.state, words: stateWords(kit.state) };
77
+ }
78
+ case 'hd.tree': {
79
+ return filterSnapshot(kit.snapshot(), scope);
80
+ }
81
+ case 'hd.agent.start': {
82
+ if (grant.role !== 'control')
83
+ deny();
84
+ const place = asRecord(args.place);
85
+ // New workspaces/tabs are creatable; existing anchors must already be in scope.
86
+ const anchorPane = asString(place.split) ?? asString(place.pane);
87
+ if (anchorPane !== undefined) {
88
+ const where = findPane(kit.snapshot(), anchorPane);
89
+ if (!where || !inScope(scope, where.workspaceId))
90
+ deny();
91
+ }
92
+ if (place.tab !== undefined && typeof place.tab === 'object' && place.tab !== null) {
93
+ const wid = asString(place.tab.workspaceId);
94
+ if (!wid || !kit.snapshot().workspaces.some((w) => w.id === wid && inScope(scope, w.id)))
95
+ deny();
96
+ }
97
+ return kit.startAgent(args);
98
+ }
99
+ case 'hd.prompt': {
100
+ if (grant.role !== 'control')
101
+ deny();
102
+ const paneId = asString(args.paneId);
103
+ if (!paneId)
104
+ throw new Error('herdr: hd.prompt needs a paneId');
105
+ const where = findPane(kit.snapshot(), paneId);
106
+ if (!where || !inScope(scope, where.workspaceId))
107
+ deny();
108
+ return kit.prompt({ paneId }, String(args.text ?? ''));
109
+ }
110
+ case 'hd.keys': {
111
+ if (grant.role !== 'control')
112
+ deny();
113
+ const paneId = asString(args.paneId);
114
+ const keys = asStrings(args.keys);
115
+ if (!paneId || !keys)
116
+ throw new Error('herdr: hd.keys needs a paneId and keys');
117
+ const where = findPane(kit.snapshot(), paneId);
118
+ if (!where || !inScope(scope, where.workspaceId))
119
+ deny();
120
+ await kit.sendKeys({ paneId }, keys);
121
+ return null;
122
+ }
123
+ case 'hd.read': {
124
+ const paneId = asString(args.paneId);
125
+ if (!paneId)
126
+ throw new Error('herdr: hd.read needs a paneId');
127
+ const where = findPane(kit.snapshot(), paneId);
128
+ if (!where || !inScope(scope, where.workspaceId))
129
+ deny();
130
+ return kit.read(paneId, {
131
+ ...(typeof args.source === 'string' ? { source: args.source } : {}),
132
+ ...(asNumber(args.lines) !== undefined ? { lines: asNumber(args.lines) } : {}),
133
+ });
134
+ }
135
+ case 'hd.blocked': {
136
+ return kit.blocked().filter((b) => inScope(scope, b.workspaceId));
137
+ }
138
+ case 'hd.answer': {
139
+ if (grant.role !== 'control')
140
+ deny();
141
+ const paneId = asString(args.paneId);
142
+ const keys = asStrings(args.keys);
143
+ const revision = asNumber(args.revision);
144
+ if (!paneId || !keys || revision === undefined) {
145
+ throw new Error('herdr: hd.answer needs paneId, keys and revision');
146
+ }
147
+ const where = findPane(kit.snapshot(), paneId);
148
+ if (!where || !inScope(scope, where.workspaceId))
149
+ deny();
150
+ try {
151
+ await kit.answer(paneId, keys, { revision });
152
+ }
153
+ catch (e) {
154
+ // A stale revision is the question changing under the device, not a refusal.
155
+ if (e?.code === 'approval-stale') {
156
+ throw new PublicLinkError(words('approval.stale'));
157
+ }
158
+ throw e;
159
+ }
160
+ return null;
161
+ }
162
+ case 'hd.close': {
163
+ if (grant.role !== 'control')
164
+ deny();
165
+ const snap = kit.snapshot();
166
+ const paneId = asString(args.pane);
167
+ const tabId = asString(args.tab);
168
+ const workspaceId = asString(args.workspace);
169
+ const picked = [paneId, tabId, workspaceId].filter((v) => v !== undefined);
170
+ if (picked.length !== 1)
171
+ throw new Error('herdr: hd.close picks exactly one of pane, tab or workspace');
172
+ if (paneId !== undefined) {
173
+ const where = findPane(snap, paneId);
174
+ if (!where || !inScope(scope, where.workspaceId))
175
+ deny();
176
+ await kit.closePane(paneId);
177
+ return null;
178
+ }
179
+ if (tabId !== undefined) {
180
+ const owner = snap.workspaces.find((w) => w.tabs.some((t) => t.id === tabId));
181
+ if (!owner || !inScope(scope, owner.id))
182
+ deny();
183
+ await kit.closeTab(tabId);
184
+ return null;
185
+ }
186
+ if (!snap.workspaces.some((w) => w.id === workspaceId && inScope(scope, w.id)))
187
+ deny();
188
+ await kit.closeWorkspace(workspaceId);
189
+ return null;
190
+ }
191
+ case 'hd.notices.register': {
192
+ if (grant.role !== 'control')
193
+ deny();
194
+ const box = decodeB64Url(asString(args.boxPublicKey) ?? '');
195
+ if (!box || box.length !== 32)
196
+ throw new Error('herdr: hd.notices.register needs a 32-byte boxPublicKey');
197
+ boxes.set(grant.id, box);
198
+ return null;
199
+ }
200
+ case 'hd.call': {
201
+ const method = asString(args.method);
202
+ if (!method)
203
+ throw new Error('herdr: hd.call needs a method');
204
+ if (!passThrough(method, grant))
205
+ deny();
206
+ return kit.call(method, asRecord(args.params));
207
+ }
208
+ default:
209
+ throw new Error(`herdr: unknown link op ${req.op}`);
210
+ }
211
+ };
212
+ const stream = (s, req, grant) => {
213
+ currentGrants.set(grant.id, grant);
214
+ const args = asRecord(req.args);
215
+ if (req.op === 'hd.events') {
216
+ const send = (frame) => {
217
+ void s.write(`${JSON.stringify(frame)}\n`).catch(() => { });
218
+ };
219
+ send({ type: 'snapshot', snapshot: filterSnapshot(kit.snapshot(), scopeOfSafe(grant)) });
220
+ const stopTree = kit.onChange((snap) => {
221
+ send({ type: 'snapshot', snapshot: filterSnapshot(snap, scopeOfSafe(currentGrants.get(grant.id) ?? grant)) });
222
+ });
223
+ const stopBlocked = kit.onBlocked((b, change) => {
224
+ if (inScope(scopeOfSafe(currentGrants.get(grant.id) ?? grant), b.workspaceId)) {
225
+ send({ type: 'blocked', change, blocked: b });
226
+ }
227
+ });
228
+ s.onEnd = () => { stopTree(); stopBlocked(); };
229
+ return;
230
+ }
231
+ if (req.op === 'hd.terminal') {
232
+ const scope = scopeOfSafe(grant);
233
+ const paneId = asString(args.paneId);
234
+ if (!paneId) {
235
+ s.end('herdr: hd.terminal needs a paneId');
236
+ return;
237
+ }
238
+ const where = findPane(kit.snapshot(), paneId);
239
+ if (!where || !inScope(scope, where.workspaceId)) {
240
+ s.end(words('link.notAllowed'));
241
+ return;
242
+ }
243
+ // View grants get observe only; control grants get the mode they asked for (7.1).
244
+ const want = args.mode === 'control' ? 'control' : 'observe';
245
+ const mode = grant.role === 'control' ? want : 'observe';
246
+ const cols = asNumber(args.cols) ?? 80;
247
+ const rows = asNumber(args.rows) ?? 24;
248
+ let session;
249
+ try {
250
+ session = kit.terminal(paneId, { mode, cols, rows });
251
+ }
252
+ catch (e) {
253
+ s.end(e instanceof PublicLinkError ? e.message : 'failed');
254
+ return;
255
+ }
256
+ const text = new TextDecoder();
257
+ let pending = '';
258
+ session.onFrame((line) => {
259
+ void s.write(`${line}\n`).catch(() => { });
260
+ });
261
+ s.onData = (chunk) => {
262
+ pending += text.decode(chunk, { stream: true });
263
+ const lines = pending.split('\n');
264
+ pending = lines.pop() ?? '';
265
+ for (const line of lines) {
266
+ if (line.length > 0)
267
+ session.send(line);
268
+ }
269
+ };
270
+ s.onEnd = () => {
271
+ if (pending.length > 0)
272
+ session.send(pending);
273
+ session.close();
274
+ };
275
+ void session.ready.catch((e) => {
276
+ s.end(e instanceof Error && e.code === 'missing/binary'
277
+ ? words('herdr.missing') : 'failed');
278
+ });
279
+ void session.exited.then(() => s.end());
280
+ return;
281
+ }
282
+ s.end(`herdr: unknown link stream ${req.op}`);
283
+ };
284
+ return {
285
+ handle,
286
+ stream,
287
+ // Every refusal carries the kit's sentence from handle/stream (see the module note).
288
+ allow: () => true,
289
+ };
290
+ }
291
+ export function serve(o) {
292
+ return (async () => {
293
+ const r = await reach({ port: o.port, via: o.via, previous: o.previous });
294
+ const server = createServer((req, res) => {
295
+ if (o.http) {
296
+ o.http(req, res);
297
+ return;
298
+ }
299
+ res.statusCode = 404;
300
+ res.end();
301
+ });
302
+ const wss = new WebSocketServer({ server });
303
+ wss.on('connection', (ws, req) => {
304
+ o.host.accept(ws, { peer: req.socket.remoteAddress });
305
+ });
306
+ await new Promise((resolve, reject) => {
307
+ server.once('error', reject);
308
+ server.listen(o.port, r.bind, () => {
309
+ server.off('error', reject);
310
+ resolve();
311
+ });
312
+ });
313
+ return {
314
+ urls: r.urls,
315
+ ...(r.ingress ? { ingress: r.ingress } : {}),
316
+ close: async () => {
317
+ wss.close();
318
+ await new Promise((resolve, reject) => {
319
+ server.close((e) => (e ? reject(e) : resolve()));
320
+ });
321
+ },
322
+ };
323
+ })();
324
+ }
@@ -0,0 +1,14 @@
1
+ import type { BlockedAgent } from './types.ts';
2
+ /** Base64url without padding, hand-rolled so this file stays portable (no Buffer, no atob). */
3
+ export declare function encodeB64Url(bytes: Uint8Array): string;
4
+ /** The inverse; null when the text is not base64url. */
5
+ export declare function decodeB64Url(s: string): Uint8Array | null;
6
+ /** Seal a blocked agent for one device's box key; the relay carries only the returned envelope. */
7
+ export declare function sealNotice(b: BlockedAgent, boxPublicKey: Uint8Array): {
8
+ v: 1;
9
+ sealed: string;
10
+ };
11
+ /** Open a notice envelope with the device's 32-byte seed; null when it is not ours or not a notice. */
12
+ export declare function openNotice(data: Record<string, unknown>, seed: Uint8Array): BlockedAgent | null;
13
+ /** The box public key a device registers for a seed (the host seals to this). */
14
+ export declare function boxPublicKeyB64(seed: Uint8Array): string;
@@ -0,0 +1,88 @@
1
+ // Sealed blocked-approval notices: the relay reads only a generic title, the phone opens the sealed body with its
2
+ // own key (docs/runtime-kits.md 7.3). Portable: no Node import anywhere in it — `./device` re-exports `openNotice`.
3
+ import { boxKeyPairFromSeed, openBox, sealBox } from '@byokit/seal';
4
+ const ABC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
5
+ /** Base64url without padding, hand-rolled so this file stays portable (no Buffer, no atob). */
6
+ export function encodeB64Url(bytes) {
7
+ let out = '';
8
+ for (let i = 0; i < bytes.length; i += 3) {
9
+ const a = bytes[i];
10
+ const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
11
+ const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
12
+ out += ABC[a >> 2] + ABC[((a & 3) << 4) | (b >> 4)];
13
+ if (i + 1 < bytes.length)
14
+ out += ABC[((b & 15) << 2) | (c >> 6)];
15
+ if (i + 2 < bytes.length)
16
+ out += ABC[c & 63];
17
+ }
18
+ return out;
19
+ }
20
+ /** The inverse; null when the text is not base64url. */
21
+ export function decodeB64Url(s) {
22
+ if (typeof s !== 'string' || s.length === 0 || /[^A-Za-z0-9\-_]/.test(s))
23
+ return null;
24
+ const vals = new Uint8Array(s.length);
25
+ for (let i = 0; i < s.length; i++) {
26
+ const n = ABC.indexOf(s[i]);
27
+ if (n < 0)
28
+ return null;
29
+ vals[i] = n;
30
+ }
31
+ const out = new Uint8Array(Math.floor((s.length * 6) / 8));
32
+ let at = 0;
33
+ let hold = 0;
34
+ let bits = 0;
35
+ for (const n of vals) {
36
+ hold = (hold << 6) | n;
37
+ bits += 6;
38
+ if (bits >= 8) {
39
+ bits -= 8;
40
+ out[at++] = (hold >> bits) & 0xff;
41
+ }
42
+ }
43
+ return out.subarray(0, at);
44
+ }
45
+ const isBlockedAgent = (v) => {
46
+ const b = v;
47
+ return typeof b === 'object' && b !== null &&
48
+ typeof b.paneId === 'string' && typeof b.workspaceId === 'string' && typeof b.tabId === 'string' &&
49
+ typeof b.revision === 'number' && Number.isSafeInteger(b.revision) &&
50
+ typeof b.prompt === 'string' && typeof b.since === 'number' &&
51
+ (b.kind === undefined || typeof b.kind === 'string');
52
+ };
53
+ /** Seal a blocked agent for one device's box key; the relay carries only the returned envelope. */
54
+ export function sealNotice(b, boxPublicKey) {
55
+ if (!(boxPublicKey instanceof Uint8Array) || boxPublicKey.length !== 32) {
56
+ throw new Error('herdr: box public key must be 32 bytes');
57
+ }
58
+ if (!isBlockedAgent(b))
59
+ throw new Error('herdr: cannot seal that notice');
60
+ return { v: 1, sealed: encodeB64Url(sealBox(new TextEncoder().encode(JSON.stringify(b)), boxPublicKey)) };
61
+ }
62
+ /** Open a notice envelope with the device's 32-byte seed; null when it is not ours or not a notice. */
63
+ export function openNotice(data, seed) {
64
+ if (typeof data !== 'object' || data === null || data.v !== 1 || typeof data.sealed !== 'string')
65
+ return null;
66
+ if (!(seed instanceof Uint8Array) || seed.length !== 32)
67
+ return null;
68
+ const bundle = decodeB64Url(data.sealed);
69
+ if (bundle === null)
70
+ return null;
71
+ const bytes = openBox(bundle, seed);
72
+ if (bytes === null)
73
+ return null;
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ return isBlockedAgent(parsed) ? parsed : null;
82
+ }
83
+ /** The box public key a device registers for a seed (the host seals to this). */
84
+ export function boxPublicKeyB64(seed) {
85
+ if (!(seed instanceof Uint8Array) || seed.length !== 32)
86
+ throw new Error('herdr: notices seed must be 32 bytes');
87
+ return encodeB64Url(boxKeyPairFromSeed(seed).publicKey);
88
+ }
@@ -0,0 +1,2 @@
1
+ import type { HerdrTransport } from './types.ts';
2
+ export declare function socketTransport(socketPath: string): HerdrTransport;
package/dist/socket.js ADDED
@@ -0,0 +1,137 @@
1
+ import { createConnection } from 'node:net';
2
+ export function socketTransport(socketPath) {
3
+ const sockets = new Set();
4
+ const timers = new Set();
5
+ let closed = false;
6
+ let nextId = 0;
7
+ const later = (fn, ms) => {
8
+ const timer = setTimeout(() => { timers.delete(timer); fn(); }, ms);
9
+ timers.add(timer);
10
+ };
11
+ const connect = (onLine, onClose) => {
12
+ const socket = createConnection(socketPath);
13
+ sockets.add(socket);
14
+ let buffer = '';
15
+ let ended = false;
16
+ const end = () => {
17
+ if (ended)
18
+ return;
19
+ ended = true;
20
+ sockets.delete(socket);
21
+ onClose();
22
+ };
23
+ socket.setEncoding('utf8');
24
+ socket.on('data', (chunk) => {
25
+ buffer += chunk;
26
+ let pos;
27
+ while ((pos = buffer.indexOf('\n')) >= 0) {
28
+ const line = buffer.slice(0, pos);
29
+ buffer = buffer.slice(pos + 1);
30
+ if (line.trim()) {
31
+ try {
32
+ onLine(JSON.parse(line));
33
+ }
34
+ catch { /* malformed frame */ }
35
+ }
36
+ }
37
+ });
38
+ socket.on('error', end);
39
+ socket.on('close', end);
40
+ return socket;
41
+ };
42
+ return {
43
+ call(method, params, timeoutMs = 15_000) {
44
+ if (closed)
45
+ return Promise.reject(new Error('herdr: transport closed'));
46
+ return new Promise((resolve, reject) => {
47
+ const id = `byokit-${++nextId}`;
48
+ let done = false;
49
+ const finish = (error, result) => {
50
+ if (done)
51
+ return;
52
+ done = true;
53
+ clearTimeout(timer);
54
+ socket.destroy();
55
+ if (error)
56
+ reject(error);
57
+ else
58
+ resolve(result);
59
+ };
60
+ const socket = connect((frame) => {
61
+ if (frame.id !== id)
62
+ return;
63
+ if (frame.error) {
64
+ const error = new Error(`herdr: ${frame.error.code}: ${frame.error.message}`);
65
+ error.code = frame.error.code;
66
+ finish(error);
67
+ }
68
+ else
69
+ finish(undefined, frame.result);
70
+ }, () => finish(new Error('herdr: socket closed')));
71
+ const timer = setTimeout(() => finish(new Error('herdr: request timed out')), timeoutMs);
72
+ socket.on('connect', () => socket.write(`${JSON.stringify({ id, method, params })}\n`));
73
+ });
74
+ },
75
+ subscribe(subs, on, onError) {
76
+ let stopped = false;
77
+ let current;
78
+ let attempts = 0;
79
+ let acknowledge;
80
+ const ready = new Promise((resolve) => { acknowledge = resolve; });
81
+ let reconnect;
82
+ let disconnect;
83
+ let connectedBefore = false;
84
+ const open = () => {
85
+ if (stopped || closed)
86
+ return;
87
+ const id = `byokit-${++nextId}`;
88
+ let ack = false;
89
+ let rejected = false;
90
+ current = connect((frame) => {
91
+ if (frame.error && (frame.id === '' || frame.id === id)) {
92
+ rejected = true;
93
+ acknowledge?.(false);
94
+ onError(String(frame.error.code), String(frame.error.message));
95
+ current?.destroy();
96
+ }
97
+ else if (frame.id === id) {
98
+ const again = connectedBefore;
99
+ connectedBefore = true;
100
+ ack = true;
101
+ attempts = 0;
102
+ acknowledge?.(true);
103
+ if (again)
104
+ reconnect?.();
105
+ }
106
+ else {
107
+ const data = frame.data ?? frame;
108
+ const type = frame.event ?? data.type;
109
+ if (ack && typeof type === 'string')
110
+ on({ ...data, type });
111
+ }
112
+ }, () => {
113
+ if (stopped || closed || rejected)
114
+ return;
115
+ if (ack)
116
+ disconnect?.();
117
+ later(open, ack ? (subs.some((s) => s.type === 'pane.agent_status_changed') ? 2000 : 1000)
118
+ : [250, 500, 1000, 2000][Math.min(attempts++, 3)]);
119
+ });
120
+ current.on('connect', () => current?.write(`${JSON.stringify({ id, method: 'events.subscribe', params: { subscriptions: subs } })}\n`));
121
+ };
122
+ open();
123
+ const stop = () => { stopped = true; current?.destroy(); };
124
+ return Object.assign(stop, { ready, onReconnect(fn) { reconnect = fn; },
125
+ onDisconnect(fn) { disconnect = fn; } });
126
+ },
127
+ close() {
128
+ closed = true;
129
+ for (const timer of timers)
130
+ clearTimeout(timer);
131
+ timers.clear();
132
+ for (const socket of sockets)
133
+ socket.destroy();
134
+ sockets.clear();
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,17 @@
1
+ import type { HerdrKitOptions, HerdrState, HerdrTransport } from './types.ts';
2
+ export declare class Supervisor {
3
+ private transport?;
4
+ private child?;
5
+ private stopping;
6
+ private retry?;
7
+ private failures;
8
+ private readonly root?;
9
+ private readonly socketPath;
10
+ private readonly o;
11
+ private readonly onState;
12
+ constructor(o: HerdrKitOptions, onState: (s: HerdrState) => void);
13
+ env(): Record<string, string>;
14
+ private state;
15
+ start(): Promise<HerdrTransport>;
16
+ stop(): Promise<void>;
17
+ }