@byokit/herdr 0.1.0-next.dbe7142 → 0.1.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/kit.js CHANGED
@@ -2,21 +2,128 @@ import { createAgents } from "./agents.js";
2
2
  import { Blocked } from "./approvals.js";
3
3
  import { runCli } from "./cli.js";
4
4
  import { openTerminal } from "./terminal.js";
5
- import { HERDR_PROTOCOL } from "./constants.js";
5
+ import { protocolBounds } from "./constants.js";
6
6
  import { closePane, closeTab, closeWorkspace } from "./close.js";
7
7
  import { Supervisor } from "./supervise.js";
8
8
  const kinds = ['pane.agent_detected', 'pane.created', 'pane.closed', 'pane.moved', 'pane.exited', 'pane.updated',
9
9
  'workspace.created', 'workspace.closed', 'workspace.renamed', 'workspace.updated', 'tab.created', 'tab.closed', 'tab.renamed'];
10
10
  const empty = () => ({ connected: false, workspaces: [] });
11
+ const str = (v) => (typeof v === 'string' ? v : undefined);
12
+ function tokensOf(v) {
13
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
14
+ return undefined;
15
+ const out = {};
16
+ for (const [k, val] of Object.entries(v)) {
17
+ if (typeof val === 'string')
18
+ out[k] = val;
19
+ }
20
+ return Object.keys(out).length > 0 ? out : undefined;
21
+ }
22
+ function sessionOf(v) {
23
+ if (typeof v !== 'object' || v === null)
24
+ return undefined;
25
+ const r = v;
26
+ if (typeof r.source !== 'string' || typeof r.agent !== 'string' ||
27
+ typeof r.kind !== 'string' || typeof r.value !== 'string')
28
+ return undefined;
29
+ return { source: r.source, agent: r.agent, kind: r.kind, value: r.value };
30
+ }
31
+ function worktreeOf(v) {
32
+ if (typeof v !== 'object' || v === null)
33
+ return undefined;
34
+ const r = v;
35
+ if (typeof r.repo_key !== 'string' || typeof r.repo_name !== 'string' || typeof r.repo_root !== 'string' ||
36
+ typeof r.checkout_path !== 'string' || typeof r.is_linked_worktree !== 'boolean')
37
+ return undefined;
38
+ return {
39
+ repoKey: r.repo_key, repoName: r.repo_name, repoRoot: r.repo_root,
40
+ checkoutPath: r.checkout_path, isLinkedWorktree: r.is_linked_worktree,
41
+ };
42
+ }
43
+ /** Agent records merge partial events (`{ ...current, ...incoming }`): every field present wins. */
44
+ function mergeAgent(current, a) {
45
+ const kind = str(a.agent);
46
+ const name = str(a.name);
47
+ const displayAgent = str(a.display_agent);
48
+ const title = str(a.title);
49
+ const foregroundCwd = str(a.foreground_cwd);
50
+ const session = sessionOf(a.agent_session);
51
+ return {
52
+ status: 'unknown', revision: 0, ...current,
53
+ ...(kind !== undefined ? { kind } : {}),
54
+ ...(name !== undefined ? { name } : {}),
55
+ ...(displayAgent !== undefined ? { displayAgent } : {}),
56
+ ...(title !== undefined ? { title } : {}),
57
+ ...(foregroundCwd !== undefined ? { foregroundCwd } : {}),
58
+ ...(session !== undefined ? { agentSession: session } : {}),
59
+ ...(a.agent_status !== undefined ? { status: a.agent_status } : {}),
60
+ ...(a.revision !== undefined ? { revision: a.revision } : {}),
61
+ ...(a.launch_pending !== undefined ? { launchPending: a.launch_pending } : {}),
62
+ ...(a.interactive_ready !== undefined ? { interactiveReady: a.interactive_ready } : {}),
63
+ };
64
+ }
65
+ /** A snapshot pane plus its agents-record entry (which wins over the pane-level agent fields). */
66
+ function paneOf(p, a) {
67
+ const cwd = str(p.cwd);
68
+ const label = str(p.label);
69
+ const terminalTitle = str(p.terminal_title_stripped);
70
+ const tokens = tokensOf(p.tokens);
71
+ const pane = {
72
+ id: p.pane_id, focused: p.focused === true,
73
+ ...(cwd === undefined ? {} : { cwd }),
74
+ ...(label === undefined ? {} : { label }),
75
+ ...(terminalTitle === undefined ? {} : { terminalTitle }),
76
+ ...(tokens === undefined ? {} : { tokens }),
77
+ };
78
+ // Pane-level agent fields first, the agents record wins — mirrors the live merge below.
79
+ if (a !== undefined)
80
+ pane.agent = mergeAgent(mergeAgent(undefined, p), a);
81
+ return pane;
82
+ }
83
+ /** A `pane.updated` payload merges in place: titles and labels with no re-bootstrap. */
84
+ function mergePane(pane, p) {
85
+ const cwd = str(p.cwd);
86
+ if (cwd !== undefined)
87
+ pane.cwd = cwd;
88
+ const label = str(p.label);
89
+ if (label !== undefined)
90
+ pane.label = label;
91
+ if (typeof p.focused === 'boolean')
92
+ pane.focused = p.focused;
93
+ const terminalTitle = str(p.terminal_title_stripped);
94
+ if (terminalTitle !== undefined)
95
+ pane.terminalTitle = terminalTitle;
96
+ if (typeof p.tokens === 'object' && p.tokens !== null && !Array.isArray(p.tokens)) {
97
+ const tokens = tokensOf(p.tokens);
98
+ if (tokens === undefined)
99
+ delete pane.tokens;
100
+ else
101
+ pane.tokens = tokens;
102
+ }
103
+ }
104
+ function findPane(tree, paneId) {
105
+ for (const w of tree.workspaces)
106
+ for (const t of w.tabs) {
107
+ const found = t.panes.find((p) => p.id === paneId);
108
+ if (found !== undefined)
109
+ return found;
110
+ }
111
+ return undefined;
112
+ }
11
113
  export class HerdrKit {
12
114
  supervisor;
13
115
  transport;
14
116
  current = { phase: 'stopped' };
15
117
  tree = empty();
16
118
  listeners = new Set();
119
+ rawListeners = new Set();
120
+ statusReadyPromise = Promise.resolve();
121
+ statusReadyResolve;
17
122
  stops = new Set();
18
123
  statusStops = new Set();
19
124
  generation = 0;
125
+ statusEpoch = new Map(); // lifecycle epochs per pane: a status push racing
126
+ // a snapshot read wins over the read (see bootstrap)
20
127
  o;
21
128
  callAny = (method, params, timeoutMs) => this.call(method, params, timeoutMs === undefined ? undefined : { timeoutMs });
22
129
  agents;
@@ -30,8 +137,13 @@ export class HerdrKit {
30
137
  get state() { return this.current; }
31
138
  publish() { for (const fn of this.listeners)
32
139
  fn(this.snapshot()); }
140
+ emitRaw(e) { for (const fn of [...this.rawListeners])
141
+ fn(e); }
33
142
  update(e) {
34
143
  const raw = e;
144
+ // The pinned schema spells subscription kinds with dots (`pane.created`) while live
145
+ // frames carry the underscore const (`pane_created`, schema/SOURCE.md); match both.
146
+ const name = typeof e.type === 'string' ? e.type.replace(/_/g, '.') : '';
35
147
  const paneId = (raw.pane_id ?? raw.pane?.pane_id);
36
148
  let owner;
37
149
  let pane;
@@ -45,60 +157,110 @@ export class HerdrKit {
45
157
  }
46
158
  }
47
159
  if (pane !== undefined && owner !== undefined &&
48
- (e.type === 'pane.agent_status_changed' || e.type === 'pane.agent_detected' || e.type === 'pane.updated')) {
160
+ (name === 'pane.agent.status.changed' || name === 'pane.agent.detected' || name === 'pane.updated')) {
49
161
  const a = raw.agent ?? raw.pane ?? raw;
50
- pane.agent = { status: 'unknown', revision: 0, ...pane.agent,
51
- ...(a.agent !== undefined ? { kind: a.agent } : {}),
52
- ...(a.agent_status !== undefined ? { status: a.agent_status } : {}),
53
- ...(a.revision !== undefined ? { revision: a.revision } : {}),
54
- ...(a.launch_pending !== undefined ? { launchPending: a.launch_pending } : {}),
55
- ...(a.interactive_ready !== undefined ? { interactiveReady: a.interactive_ready } : {}),
56
- };
162
+ mergePane(pane, a);
163
+ pane.agent = mergeAgent(pane.agent, a);
164
+ // Every asserted status stamps the pane's lifecycle epoch — even an unchanged one, since a
165
+ // racing snapshot read may carry a staler revision. The bootstrap below keeps the live push.
166
+ if (a.agent_status !== undefined) {
167
+ this.statusEpoch.set(paneId, (this.statusEpoch.get(paneId) ?? 0) + 1);
168
+ }
57
169
  this.blockedList.update(paneId, pane.agent, owner);
58
170
  }
59
- if (e.type === 'pane.closed') {
171
+ if (name === 'pane.closed') {
60
172
  this.blockedList.update(paneId, undefined, owner ?? { workspaceId: '', tabId: '' });
173
+ if (paneId !== undefined)
174
+ this.statusEpoch.delete(paneId);
61
175
  for (const tab of this.tree.workspaces.flatMap((w) => w.tabs))
62
176
  tab.panes = tab.panes.filter((p) => p.id !== paneId);
63
177
  }
64
- if (e.type === 'tab.closed')
65
- for (const w of this.tree.workspaces)
178
+ if (name === 'tab.closed') {
179
+ for (const w of this.tree.workspaces) {
180
+ const tab = w.tabs.find((t) => t.id === raw.tab_id);
181
+ if (tab !== undefined)
182
+ for (const p of tab.panes)
183
+ this.statusEpoch.delete(p.id);
66
184
  w.tabs = w.tabs.filter((t) => t.id !== raw.tab_id);
67
- if (e.type === 'workspace.closed')
185
+ }
186
+ }
187
+ if (name === 'workspace.closed') {
188
+ const workspace = this.tree.workspaces.find((w) => w.id === raw.workspace_id);
189
+ if (workspace !== undefined)
190
+ for (const t of workspace.tabs)
191
+ for (const p of t.panes)
192
+ this.statusEpoch.delete(p.id);
68
193
  this.tree.workspaces = this.tree.workspaces.filter((w) => w.id !== raw.workspace_id);
194
+ }
69
195
  this.publish();
70
196
  }
71
197
  async bootstrap(token, buffered) {
72
- const result = await this.transport.call('session.snapshot', {});
73
- if (token !== this.generation)
198
+ let resolveStatus;
199
+ const statusReady = new Promise((resolve) => { resolveStatus = resolve; });
200
+ this.statusReadyPromise = statusReady;
201
+ this.statusReadyResolve = resolveStatus;
202
+ const epochAtRead = new Map(this.statusEpoch);
203
+ let result;
204
+ try {
205
+ result = await this.transport.call('session.snapshot', {});
206
+ }
207
+ catch (error) {
208
+ resolveStatus();
209
+ throw error;
210
+ }
211
+ if (token !== this.generation) {
212
+ resolveStatus();
74
213
  return;
214
+ }
75
215
  const snap = result.snapshot;
76
- if ((snap.protocol !== undefined && snap.protocol !== HERDR_PROTOCOL) ||
77
- (result.protocol !== undefined && result.protocol !== HERDR_PROTOCOL)) {
216
+ // K11: the declared range gates the snapshot metadata too. Older than `min` fails closed;
217
+ // newer than `max` installs the snapshot and stays usable with the steady state
218
+ // `needs-update` instead of throwing, so a protocol bump never takes the host down.
219
+ const { min, max } = protocolBounds(this.o.protocolRange);
220
+ const seen = [result.protocol, snap?.protocol].filter((v) => typeof v === 'number');
221
+ if (seen.some((v) => v < min)) {
78
222
  this.current = { phase: 'needs-update', why: 'version' };
79
223
  this.o.onState?.(this.current);
224
+ resolveStatus();
80
225
  throw new Error('herdr: snapshot protocol mismatch');
81
226
  }
82
- const workspaces = snap.workspaces.map((w) => ({
83
- id: w.workspace_id, label: w.label,
84
- tabs: snap.tabs.filter((t) => t.workspace_id === w.workspace_id).map((t) => ({
85
- id: t.tab_id, label: t.label,
86
- panes: snap.panes.filter((p) => p.tab_id === t.tab_id).map((p) => {
87
- const a = snap.agents.find((a) => a.pane_id === p.pane_id);
88
- return { id: p.pane_id, cwd: p.cwd, ...(a ? { agent: {
89
- kind: a.agent, name: a.name,
90
- status: (a.agent_status ?? 'unknown'), revision: (a.revision ?? 0),
91
- launchPending: a.launch_pending, interactiveReady: a.interactive_ready,
92
- } } : {}) };
93
- }),
94
- })),
95
- }));
227
+ const newer = seen.some((v) => v > max);
228
+ const workspaces = snap.workspaces.map((w) => {
229
+ const tokens = tokensOf(w.tokens);
230
+ const worktree = worktreeOf(w.worktree);
231
+ return {
232
+ id: w.workspace_id, label: w.label,
233
+ focused: w.focused === true, number: typeof w.number === 'number' ? w.number : 0,
234
+ ...(tokens === undefined ? {} : { tokens }),
235
+ ...(worktree === undefined ? {} : { worktree }),
236
+ tabs: snap.tabs.filter((t) => t.workspace_id === w.workspace_id).map((t) => ({
237
+ id: t.tab_id, label: t.label,
238
+ panes: snap.panes.filter((p) => p.tab_id === t.tab_id).map((p) => paneOf(p, snap.agents.find((a) => a.pane_id === p.pane_id))),
239
+ })),
240
+ };
241
+ });
96
242
  for (const stop of this.statusStops) {
97
243
  stop();
98
244
  this.stops.delete(stop);
99
245
  }
100
246
  this.statusStops.clear();
247
+ const previous = this.tree;
101
248
  this.tree = { connected: true, workspaces };
249
+ // Lifecycle-epoch guard: a per-pane status push that raced the snapshot read landed on the
250
+ // previous tree (and stamped its epoch) while the read was in flight — the read predates it,
251
+ // so the live status and revision win over the snapshot's.
252
+ for (const w of this.tree.workspaces)
253
+ for (const t of w.tabs)
254
+ for (const p of t.panes) {
255
+ if (p.agent === undefined)
256
+ continue;
257
+ if (epochAtRead.get(p.id) === this.statusEpoch.get(p.id))
258
+ continue;
259
+ const live = findPane(previous, p.id)?.agent;
260
+ if (live === undefined)
261
+ continue;
262
+ p.agent = { ...p.agent, status: live.status, revision: live.revision };
263
+ }
102
264
  for (const event of buffered.splice(0))
103
265
  this.update(event);
104
266
  // Agents already blocked in the snapshot join the list; entries whose pane is gone resolve.
@@ -114,16 +276,27 @@ export class HerdrKit {
114
276
  this.blockedList.update(p.id, p.agent, { workspaceId: w.id, tabId: t.id });
115
277
  }
116
278
  this.publish();
279
+ const statusAcks = [];
117
280
  for (const pane of this.tree.workspaces.flatMap((w) => w.tabs).flatMap((t) => t.panes)) {
118
281
  if (!pane.agent)
119
282
  continue;
120
- const stop = this.transport.subscribe([{ type: 'pane.agent_status_changed', pane_id: pane.id }], (e) => this.update(e), () => { });
283
+ const stop = this.transport.subscribe([{ type: 'pane.agent_status_changed', pane_id: pane.id }], (e) => { this.emitRaw(e); this.update(e); }, () => { });
121
284
  this.stops.add(stop);
122
285
  this.statusStops.add(stop);
286
+ const ready = stop.ready;
287
+ if (ready)
288
+ statusAcks.push(ready);
123
289
  }
124
- this.current = { phase: 'ready' };
290
+ void Promise.allSettled(statusAcks).then(() => { if (token === this.generation)
291
+ resolveStatus(); });
292
+ this.current = newer ? { phase: 'needs-update', why: 'version' } : { phase: 'ready' };
125
293
  this.o.onState?.(this.current);
126
294
  }
295
+ /**
296
+ * Connect and bootstrap. Non-fatal: a rejected `start()` (e.g. `failed/socket` while Herdr is
297
+ * down) leaves the kit stopped, and `start()` may be called again afterwards — the host comes up
298
+ * while Herdr is down by retrying `start()` in a backoff loop until it reaches `ready`.
299
+ */
127
300
  async start() {
128
301
  const token = ++this.generation;
129
302
  this.transport = await this.supervisor.start();
@@ -136,9 +309,15 @@ export class HerdrKit {
136
309
  void this.bootstrap(token, buffered).then(() => { booting = false; }).catch(() => { booting = false; });
137
310
  };
138
311
  const stop = this.transport.subscribe(kinds.map((type) => ({ type })), (e) => {
312
+ // Raw tap (K3): fire on arrival, before buffering/refresh/update, so listeners see every
313
+ // wire payload (pane.moved.previous_pane_id, workspace.*) exactly once. Buffered replays
314
+ // in bootstrap() call update() only and never re-emit.
315
+ this.emitRaw(e);
316
+ // Live frames carry the underscore spelling (`pane_created`); see update().
317
+ const name = typeof e.type === 'string' ? e.type.replace(/_/g, '.') : '';
139
318
  if (booting)
140
319
  buffered.push(e);
141
- else if (e.type === 'pane.agent_detected' || /^(pane|tab|workspace)\.(created|closed|moved|renamed)$/.test(e.type)) {
320
+ else if (name === 'pane.agent.detected' || /^(pane|tab|workspace)\.(created|closed|moved|renamed)$/.test(name)) {
142
321
  buffered.push(e);
143
322
  refresh();
144
323
  }
@@ -146,29 +325,41 @@ export class HerdrKit {
146
325
  this.update(e);
147
326
  }, () => { });
148
327
  this.stops.add(stop);
149
- const control = stop;
150
- control.onDisconnect?.(() => {
151
- if (token !== this.generation)
152
- return;
153
- this.current = { phase: 'reconnecting' };
154
- this.o.onState?.(this.current);
155
- this.tree.connected = false;
156
- this.publish();
157
- });
158
- control.onReconnect?.(refresh);
159
- if (await control.ready === false)
160
- throw new Error('herdr: event subscription rejected');
161
- await this.bootstrap(token, buffered);
162
- booting = false;
328
+ try {
329
+ const control = stop;
330
+ control.onDisconnect?.(() => {
331
+ if (token !== this.generation)
332
+ return;
333
+ this.current = { phase: 'reconnecting' };
334
+ this.o.onState?.(this.current);
335
+ this.tree.connected = false;
336
+ this.publish();
337
+ });
338
+ control.onReconnect?.(refresh);
339
+ if (await control.ready === false)
340
+ throw new Error('herdr: event subscription rejected');
341
+ await this.bootstrap(token, buffered);
342
+ booting = false;
343
+ }
344
+ catch (error) {
345
+ // The attempt owns this subscription: drop it so a retried `start()` dials clean.
346
+ this.stops.delete(stop);
347
+ stop();
348
+ throw error;
349
+ }
163
350
  }
164
351
  async stop() {
165
352
  ++this.generation;
353
+ this.statusReadyResolve?.();
354
+ this.statusReadyPromise = Promise.resolve();
355
+ this.statusReadyResolve = undefined;
166
356
  for (const entry of this.blockedList.list())
167
357
  this.blockedList.update(entry.paneId, undefined, entry);
168
358
  for (const stop of this.stops)
169
359
  stop();
170
360
  this.stops.clear();
171
361
  this.statusStops.clear();
362
+ this.statusEpoch.clear();
172
363
  await this.supervisor.stop();
173
364
  this.transport = undefined;
174
365
  this.tree = empty();
@@ -184,15 +375,30 @@ export class HerdrKit {
184
375
  ? wait.timeout_ms + 5000 : undefined);
185
376
  return this.transport.call(method, values, timeout);
186
377
  }
187
- subscribe(subs, on) {
378
+ subscribe(subs, on, onError) {
188
379
  if (!this.transport)
189
380
  throw new Error('herdr: not connected');
190
- return this.transport.subscribe(subs, on, () => { });
381
+ return this.transport.subscribe(subs, on, onError ?? (() => { }));
191
382
  }
192
383
  cli(args, o) { return runCli(this.o.bin, this.supervisor.env(), args, o?.timeoutMs); }
193
384
  terminal(paneId, o) { return openTerminal(this.o.bin, this.supervisor.env(), paneId, o); }
194
385
  snapshot() { return structuredClone(this.tree); }
195
386
  onChange(fn) { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }
387
+ /**
388
+ * Raw event tap (K3): delivers every event arriving on the kit's own batch and per-pane
389
+ * status sockets, with wire payloads intact (`pane.moved.previous_pane_id`, `workspace.*`).
390
+ * Fires on arrival — before the snapshot update/refresh for that event — so a listener sees
391
+ * the frame even when it triggers a re-bootstrap. Buffered replays never re-fire. Sockets
392
+ * opened via `subscribe()` are NOT tapped; `onEvent` itself opens no socket.
393
+ */
394
+ onEvent(fn) { this.rawListeners.add(fn); return () => { this.rawListeners.delete(fn); }; }
395
+ /**
396
+ * Ready signal for the status-watch set (K3): resolves when the latest bootstrap's per-pane
397
+ * `pane.agent_status_changed` subscriptions have acked (immediately when no pane needs one).
398
+ * Await after `start()` — and after any reconnect-driven re-bootstrap — before relying on
399
+ * status events instead of opening a second watch.
400
+ */
401
+ statusWatchReady() { return this.statusReadyPromise; }
196
402
  startAgent(o) { return this.agents.startAgent(o); }
197
403
  prompt(target, text, o) { return this.agents.prompt(target, text, o); }
198
404
  sendKeys(target, keys) { return this.agents.sendKeys(target, keys); }
package/dist/link.js CHANGED
@@ -11,7 +11,7 @@ import { reach } from '@byokit/reach';
11
11
  import { stateWords, words } from "./words.js";
12
12
  import { decodeB64Url, sealNotice } from "./notices.js";
13
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']);
14
+ const VIEW_OPS = new Set(['hd.state', 'hd.tree', 'hd.kinds', 'hd.read', 'hd.blocked', 'hd.events', 'hd.subscribe']);
15
15
  const deny = () => {
16
16
  throw new PublicLinkError(words('link.notAllowed'));
17
17
  };
@@ -34,6 +34,41 @@ const asRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
34
34
  const asString = (v) => (typeof v === 'string' ? v : undefined);
35
35
  const asStrings = (v) => Array.isArray(v) && v.every((e) => typeof e === 'string') ? v : undefined;
36
36
  const asNumber = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
37
+ const STATUSES = new Set(['idle', 'working', 'blocked', 'done', 'unknown']);
38
+ /**
39
+ * Every workspace an event names (7.1 `hd.subscribe`): any `*workspace_id` field or `workspace_ids` entry at any
40
+ * depth; when it names none, the workspace holding its `pane_id` (e.g. a status event that omits one).
41
+ */
42
+ const eventWorkspaces = (e, snap) => {
43
+ const found = new Set();
44
+ const walk = (v) => {
45
+ if (Array.isArray(v)) {
46
+ for (const item of v)
47
+ walk(item);
48
+ return;
49
+ }
50
+ if (typeof v !== 'object' || v === null)
51
+ return;
52
+ for (const [key, value] of Object.entries(v)) {
53
+ if (key.endsWith('workspace_id') && typeof value === 'string')
54
+ found.add(value);
55
+ else if (key === 'workspace_ids' && Array.isArray(value)) {
56
+ for (const id of value)
57
+ if (typeof id === 'string')
58
+ found.add(id);
59
+ }
60
+ else
61
+ walk(value);
62
+ }
63
+ };
64
+ walk(e);
65
+ if (found.size === 0 && typeof e.pane_id === 'string') {
66
+ const where = findPane(snap, e.pane_id);
67
+ if (where)
68
+ found.add(where.workspaceId);
69
+ }
70
+ return found;
71
+ };
37
72
  export function herdrLink(kit, o) {
38
73
  if (!kit || typeof o?.scopeOf !== 'function')
39
74
  throw new Error('herdr: herdrLink needs a kit and scopeOf');
@@ -78,6 +113,9 @@ export function herdrLink(kit, o) {
78
113
  case 'hd.tree': {
79
114
  return filterSnapshot(kit.snapshot(), scope);
80
115
  }
116
+ case 'hd.kinds': {
117
+ return kit.agentKinds();
118
+ }
81
119
  case 'hd.agent.start': {
82
120
  if (grant.role !== 'control')
83
121
  deny();
@@ -105,7 +143,18 @@ export function herdrLink(kit, o) {
105
143
  const where = findPane(kit.snapshot(), paneId);
106
144
  if (!where || !inScope(scope, where.workspaceId))
107
145
  deny();
108
- return kit.prompt({ paneId }, String(args.text ?? ''));
146
+ try {
147
+ return await kit.prompt({ paneId }, String(args.text ?? ''));
148
+ }
149
+ catch (e) {
150
+ // Not ready yet, or waiting on an answer: the device hears the kit's sentence, not a bare failure.
151
+ const code = e?.code;
152
+ if (code === 'agent-not-ready')
153
+ throw new PublicLinkError(words('agent.notReady'));
154
+ if (code === 'agent-blocked')
155
+ throw new PublicLinkError(words('agent.blocked'));
156
+ throw e;
157
+ }
109
158
  }
110
159
  case 'hd.keys': {
111
160
  if (grant.role !== 'control')
@@ -120,6 +169,21 @@ export function herdrLink(kit, o) {
120
169
  await kit.sendKeys({ paneId }, keys);
121
170
  return null;
122
171
  }
172
+ case 'hd.wait': {
173
+ if (grant.role !== 'control')
174
+ deny();
175
+ const paneId = asString(args.paneId);
176
+ const timeoutMs = asNumber(args.timeoutMs);
177
+ const until = args.until === undefined ? undefined : asStrings(args.until);
178
+ if (!paneId || timeoutMs === undefined || timeoutMs <= 0 || (args.until !== undefined &&
179
+ (!until || !until.every((u) => STATUSES.has(u))))) {
180
+ throw new Error('herdr: hd.wait needs a paneId, a positive timeoutMs and known until statuses');
181
+ }
182
+ const where = findPane(kit.snapshot(), paneId);
183
+ if (!where || !inScope(scope, where.workspaceId))
184
+ deny();
185
+ return kit.wait({ paneId }, { timeoutMs, ...(until ? { until: until } : {}) });
186
+ }
123
187
  case 'hd.read': {
124
188
  const paneId = asString(args.paneId);
125
189
  if (!paneId)
@@ -228,6 +292,58 @@ export function herdrLink(kit, o) {
228
292
  s.onEnd = () => { stopTree(); stopBlocked(); };
229
293
  return;
230
294
  }
295
+ if (req.op === 'hd.subscribe') {
296
+ const scope = scopeOfSafe(grant);
297
+ const subs = Array.isArray(args.subs) ? args.subs : undefined;
298
+ if (!subs || subs.length === 0 || !subs.every((sub) => typeof asRecord(sub).type === 'string')) {
299
+ s.end('herdr: hd.subscribe needs subs, each with a type');
300
+ return;
301
+ }
302
+ // A filter naming a pane must name one in scope, like every other pane-addressed op.
303
+ const snap = kit.snapshot();
304
+ for (const sub of subs) {
305
+ const paneId = asRecord(sub).pane_id;
306
+ if (paneId === undefined)
307
+ continue;
308
+ const where = typeof paneId === 'string' ? findPane(snap, paneId) : undefined;
309
+ if (!where || !inScope(scope, where.workspaceId)) {
310
+ s.end(words('link.notAllowed'));
311
+ return;
312
+ }
313
+ }
314
+ // A rejected batch or a kit that disconnects (stop, restart) ends the stream, so the device hears
315
+ // it through onError and can subscribe again instead of waiting on a dead socket.
316
+ let stop = () => { };
317
+ let offChange = () => { };
318
+ let over = false;
319
+ const finish = (reason) => {
320
+ if (over)
321
+ return;
322
+ over = true;
323
+ stop();
324
+ offChange();
325
+ s.end(reason);
326
+ };
327
+ try {
328
+ stop = kit.subscribe(subs, (e) => {
329
+ const now = scopeOfSafe(currentGrants.get(grant.id) ?? grant);
330
+ if (now.workspaces !== 'all') {
331
+ const named = eventWorkspaces(e, kit.snapshot());
332
+ if (named.size === 0 || ![...named].every((id) => inScope(now, id)))
333
+ return;
334
+ }
335
+ void s.write(`${JSON.stringify(e)}\n`).catch(() => { });
336
+ }, (code) => { finish(`herdr: subscription rejected (${code})`); });
337
+ }
338
+ catch (e) {
339
+ finish(e instanceof PublicLinkError ? e.message : 'failed');
340
+ return;
341
+ }
342
+ offChange = kit.onChange((snap) => { if (!snap.connected)
343
+ finish('herdr: disconnected'); });
344
+ s.onEnd = () => { over = true; stop(); offChange(); };
345
+ return;
346
+ }
231
347
  if (req.op === 'hd.terminal') {
232
348
  const scope = scopeOfSafe(grant);
233
349
  const paneId = asString(args.paneId);
package/dist/supervise.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { accessSync, closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { isAbsolute, join } from 'node:path';
4
- import { HERDR_PROTOCOL } from "./constants.js";
4
+ import { protocolBounds } from "./constants.js";
5
5
  import { socketTransport } from "./socket.js";
6
6
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7
7
  export class Supervisor {
@@ -85,10 +85,16 @@ export class Supervisor {
85
85
  throw new Error('herdr: stopped');
86
86
  try {
87
87
  const ping = await transport.call('ping', {}, 1000);
88
- if (ping.protocol !== HERDR_PROTOCOL) {
88
+ // K11: a server older than the declared range fails closed; a newer one connects
89
+ // anyway with `needs-update` (the kit's bootstrap keeps that steady state), so a
90
+ // Herdr protocol bump does not take the host down before the kit's pin moves.
91
+ const { min, max } = protocolBounds(this.o.protocolRange);
92
+ if (typeof ping.protocol !== 'number' || ping.protocol < min) {
89
93
  this.state('needs-update', 'version');
90
94
  throw new Error('herdr: protocol mismatch');
91
95
  }
96
+ if (ping.protocol > max)
97
+ this.state('needs-update', 'version');
92
98
  this.failures = 0;
93
99
  return transport;
94
100
  }
@@ -132,6 +138,9 @@ export class Supervisor {
132
138
  catch { /* absent */ }
133
139
  }
134
140
  this.transport?.close();
141
+ // A closed transport never answers again: drop it so the next `start()` (a retry
142
+ // after `failed`, or a restart after `stop()`) dials with a fresh transport.
143
+ this.transport = undefined;
135
144
  this.state('stopped');
136
145
  }
137
146
  }
@@ -1,9 +1,19 @@
1
1
  import type { HerdrKit } from '../kit.ts';
2
2
  import type { FakeHerdr } from './fake-herdr/server.ts';
3
- import type { HerdrTransport } from '../types.ts';
3
+ import type { HerdrProtocolRange, HerdrTransport } from '../types.ts';
4
4
  export type HerdrContractBench = {
5
5
  kit: HerdrKit;
6
6
  fake?: FakeHerdr;
7
- withTransport?: (transport: HerdrTransport) => HerdrKit;
7
+ withTransport?: (transport: HerdrTransport, o?: {
8
+ protocolRange?: HerdrProtocolRange;
9
+ }) => HerdrKit;
8
10
  };
9
- export declare function herdrContract(make: () => Promise<HerdrContractBench>): void;
11
+ export type HerdrContractTestContext = {
12
+ skip: (message?: string) => void;
13
+ };
14
+ export type HerdrContractTestFn = (name: string, fn: (t: HerdrContractTestContext) => void | Promise<void>) => void | Promise<void>;
15
+ export type HerdrContractOptions = {
16
+ /** The runner's `test` (node:test's by default; pass vitest's `test` under vitest). */
17
+ test?: HerdrContractTestFn;
18
+ };
19
+ export declare function herdrContract(make: () => Promise<HerdrContractBench>, options?: HerdrContractOptions | HerdrContractTestFn): void;