@byokit/herdr 0.1.0-next.dbe7142 → 0.1.1

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,18 @@ 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); }
142
+ // The kit's own watches report rejections here so the host keeps its invalid_subscription
143
+ // logging without tapping the transport.
144
+ watchError(where) {
145
+ return (code, message) => this.o.onLog?.(`herdr: ${where} subscription rejected: ${code}: ${message}`);
146
+ }
33
147
  update(e) {
34
148
  const raw = e;
149
+ // The pinned schema spells subscription kinds with dots (`pane.created`) while live
150
+ // frames carry the underscore const (`pane_created`, schema/SOURCE.md); match both.
151
+ const name = typeof e.type === 'string' ? e.type.replace(/_/g, '.') : '';
35
152
  const paneId = (raw.pane_id ?? raw.pane?.pane_id);
36
153
  let owner;
37
154
  let pane;
@@ -45,60 +162,112 @@ export class HerdrKit {
45
162
  }
46
163
  }
47
164
  if (pane !== undefined && owner !== undefined &&
48
- (e.type === 'pane.agent_status_changed' || e.type === 'pane.agent_detected' || e.type === 'pane.updated')) {
49
- 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
- };
165
+ (name === 'pane.agent.status.changed' || name === 'pane.agent.detected' || name === 'pane.updated')) {
166
+ // `pane.updated` nests its record under `pane`; the status push is flat and its `agent` is
167
+ // the kind string (PaneAgentStatusChangedEvent), never the record to merge.
168
+ const a = typeof raw.pane === 'object' && raw.pane !== null ? raw.pane : raw;
169
+ mergePane(pane, a);
170
+ pane.agent = mergeAgent(pane.agent, a);
171
+ // Every asserted status stamps the pane's lifecycle epoch — even an unchanged one, since a
172
+ // racing snapshot read may carry a staler revision. The bootstrap below keeps the live push.
173
+ if (a.agent_status !== undefined) {
174
+ this.statusEpoch.set(paneId, (this.statusEpoch.get(paneId) ?? 0) + 1);
175
+ }
57
176
  this.blockedList.update(paneId, pane.agent, owner);
58
177
  }
59
- if (e.type === 'pane.closed') {
178
+ if (name === 'pane.closed') {
60
179
  this.blockedList.update(paneId, undefined, owner ?? { workspaceId: '', tabId: '' });
180
+ if (paneId !== undefined)
181
+ this.statusEpoch.delete(paneId);
61
182
  for (const tab of this.tree.workspaces.flatMap((w) => w.tabs))
62
183
  tab.panes = tab.panes.filter((p) => p.id !== paneId);
63
184
  }
64
- if (e.type === 'tab.closed')
65
- for (const w of this.tree.workspaces)
185
+ if (name === 'tab.closed') {
186
+ for (const w of this.tree.workspaces) {
187
+ const tab = w.tabs.find((t) => t.id === raw.tab_id);
188
+ if (tab !== undefined)
189
+ for (const p of tab.panes)
190
+ this.statusEpoch.delete(p.id);
66
191
  w.tabs = w.tabs.filter((t) => t.id !== raw.tab_id);
67
- if (e.type === 'workspace.closed')
192
+ }
193
+ }
194
+ if (name === 'workspace.closed') {
195
+ const workspace = this.tree.workspaces.find((w) => w.id === raw.workspace_id);
196
+ if (workspace !== undefined)
197
+ for (const t of workspace.tabs)
198
+ for (const p of t.panes)
199
+ this.statusEpoch.delete(p.id);
68
200
  this.tree.workspaces = this.tree.workspaces.filter((w) => w.id !== raw.workspace_id);
201
+ }
69
202
  this.publish();
70
203
  }
71
204
  async bootstrap(token, buffered) {
72
- const result = await this.transport.call('session.snapshot', {});
73
- if (token !== this.generation)
205
+ let resolveStatus;
206
+ const statusReady = new Promise((resolve) => { resolveStatus = resolve; });
207
+ this.statusReadyPromise = statusReady;
208
+ this.statusReadyResolve = resolveStatus;
209
+ const epochAtRead = new Map(this.statusEpoch);
210
+ let result;
211
+ try {
212
+ result = await this.transport.call('session.snapshot', {});
213
+ }
214
+ catch (error) {
215
+ resolveStatus();
216
+ throw error;
217
+ }
218
+ if (token !== this.generation) {
219
+ resolveStatus();
74
220
  return;
221
+ }
75
222
  const snap = result.snapshot;
76
- if ((snap.protocol !== undefined && snap.protocol !== HERDR_PROTOCOL) ||
77
- (result.protocol !== undefined && result.protocol !== HERDR_PROTOCOL)) {
223
+ // K11: the declared range gates the snapshot metadata too. Older than `min` fails closed;
224
+ // newer than `max` installs the snapshot and stays usable with the steady state
225
+ // `needs-update` instead of throwing, so a protocol bump never takes the host down.
226
+ const { min, max } = protocolBounds(this.o.protocolRange);
227
+ const seen = [result.protocol, snap?.protocol].filter((v) => typeof v === 'number');
228
+ if (seen.some((v) => v < min)) {
78
229
  this.current = { phase: 'needs-update', why: 'version' };
79
230
  this.o.onState?.(this.current);
231
+ resolveStatus();
80
232
  throw new Error('herdr: snapshot protocol mismatch');
81
233
  }
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
- }));
234
+ const newer = seen.some((v) => v > max);
235
+ const workspaces = snap.workspaces.map((w) => {
236
+ const tokens = tokensOf(w.tokens);
237
+ const worktree = worktreeOf(w.worktree);
238
+ return {
239
+ id: w.workspace_id, label: w.label,
240
+ focused: w.focused === true, number: typeof w.number === 'number' ? w.number : 0,
241
+ ...(tokens === undefined ? {} : { tokens }),
242
+ ...(worktree === undefined ? {} : { worktree }),
243
+ tabs: snap.tabs.filter((t) => t.workspace_id === w.workspace_id).map((t) => ({
244
+ id: t.tab_id, label: t.label,
245
+ 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))),
246
+ })),
247
+ };
248
+ });
96
249
  for (const stop of this.statusStops) {
97
250
  stop();
98
251
  this.stops.delete(stop);
99
252
  }
100
253
  this.statusStops.clear();
254
+ const previous = this.tree;
101
255
  this.tree = { connected: true, workspaces };
256
+ // Lifecycle-epoch guard: a per-pane status push that raced the snapshot read landed on the
257
+ // previous tree (and stamped its epoch) while the read was in flight — the read predates it,
258
+ // so the live status and revision win over the snapshot's.
259
+ for (const w of this.tree.workspaces)
260
+ for (const t of w.tabs)
261
+ for (const p of t.panes) {
262
+ if (p.agent === undefined)
263
+ continue;
264
+ if (epochAtRead.get(p.id) === this.statusEpoch.get(p.id))
265
+ continue;
266
+ const live = findPane(previous, p.id)?.agent;
267
+ if (live === undefined)
268
+ continue;
269
+ p.agent = { ...p.agent, status: live.status, revision: live.revision };
270
+ }
102
271
  for (const event of buffered.splice(0))
103
272
  this.update(event);
104
273
  // Agents already blocked in the snapshot join the list; entries whose pane is gone resolve.
@@ -114,16 +283,46 @@ export class HerdrKit {
114
283
  this.blockedList.update(p.id, p.agent, { workspaceId: w.id, tabId: t.id });
115
284
  }
116
285
  this.publish();
286
+ const statusAcks = [];
117
287
  for (const pane of this.tree.workspaces.flatMap((w) => w.tabs).flatMap((t) => t.panes)) {
118
288
  if (!pane.agent)
119
289
  continue;
120
- const stop = this.transport.subscribe([{ type: 'pane.agent_status_changed', pane_id: pane.id }], (e) => this.update(e), () => { });
290
+ const stop = this.transport.subscribe([{ type: 'pane.agent_status_changed', pane_id: pane.id }], (e) => { this.emitRaw(e); this.update(e); }, this.watchError('status'));
121
291
  this.stops.add(stop);
122
292
  this.statusStops.add(stop);
293
+ const ready = stop.ready;
294
+ if (ready)
295
+ statusAcks.push(ready.then((ok) => (ok === false ? undefined : this.catchUp(token, pane.id))));
123
296
  }
124
- this.current = { phase: 'ready' };
297
+ void Promise.allSettled(statusAcks).then(() => { if (token === this.generation)
298
+ resolveStatus(); });
299
+ this.current = newer ? { phase: 'needs-update', why: 'version' } : { phase: 'ready' };
125
300
  this.o.onState?.(this.current);
126
301
  }
302
+ // A status change between the snapshot read and a watch's ack reached no socket: once the watch
303
+ // is live, re-read that agent. A push landing meanwhile is newer and wins (epoch guard).
304
+ async catchUp(token, paneId) {
305
+ const epoch = this.statusEpoch.get(paneId);
306
+ let made;
307
+ try {
308
+ made = await this.transport?.call('agent.get', { target: paneId });
309
+ }
310
+ catch {
311
+ return;
312
+ }
313
+ const agent = made?.agent;
314
+ if (token !== this.generation || this.statusEpoch.get(paneId) !== epoch ||
315
+ typeof agent?.agent_status !== 'string')
316
+ return;
317
+ // Status and revision only: the read's other fields may be staler than a merged pane.updated.
318
+ this.update({ type: 'pane_agent_status_changed', pane_id: paneId, agent_status: agent.agent_status,
319
+ ...(typeof agent.revision === 'number' ? { revision: agent.revision } : {}) });
320
+ }
321
+ /**
322
+ * Connect and bootstrap. Non-fatal: a rejected `start()` (e.g. `failed/socket` while Herdr is
323
+ * down) leaves the kit stopped, and `start()` may be called again afterwards — the host comes up
324
+ * while Herdr is down by retrying `start()` in a backoff loop until it reaches `ready`.
325
+ */
127
326
  async start() {
128
327
  const token = ++this.generation;
129
328
  this.transport = await this.supervisor.start();
@@ -136,39 +335,57 @@ export class HerdrKit {
136
335
  void this.bootstrap(token, buffered).then(() => { booting = false; }).catch(() => { booting = false; });
137
336
  };
138
337
  const stop = this.transport.subscribe(kinds.map((type) => ({ type })), (e) => {
338
+ // Raw tap (K3): fire on arrival, before buffering/refresh/update, so listeners see every
339
+ // wire payload (pane.moved.previous_pane_id, workspace.*) exactly once. Buffered replays
340
+ // in bootstrap() call update() only and never re-emit.
341
+ this.emitRaw(e);
342
+ // Live frames carry the underscore spelling (`pane_created`); see update().
343
+ const name = typeof e.type === 'string' ? e.type.replace(/_/g, '.') : '';
139
344
  if (booting)
140
345
  buffered.push(e);
141
- else if (e.type === 'pane.agent_detected' || /^(pane|tab|workspace)\.(created|closed|moved|renamed)$/.test(e.type)) {
346
+ else if (name === 'pane.agent.detected' || /^(pane|tab|workspace)\.(created|closed|moved|renamed)$/.test(name)) {
142
347
  buffered.push(e);
143
348
  refresh();
144
349
  }
145
350
  else
146
351
  this.update(e);
147
- }, () => { });
352
+ }, this.watchError('events'));
148
353
  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;
354
+ try {
355
+ const control = stop;
356
+ control.onDisconnect?.(() => {
357
+ if (token !== this.generation)
358
+ return;
359
+ this.current = { phase: 'reconnecting' };
360
+ this.o.onState?.(this.current);
361
+ this.tree.connected = false;
362
+ this.publish();
363
+ });
364
+ control.onReconnect?.(refresh);
365
+ if (await control.ready === false)
366
+ throw new Error('herdr: event subscription rejected');
367
+ await this.bootstrap(token, buffered);
368
+ booting = false;
369
+ }
370
+ catch (error) {
371
+ // The attempt owns this subscription: drop it so a retried `start()` dials clean.
372
+ this.stops.delete(stop);
373
+ stop();
374
+ throw error;
375
+ }
163
376
  }
164
377
  async stop() {
165
378
  ++this.generation;
379
+ this.statusReadyResolve?.();
380
+ this.statusReadyPromise = Promise.resolve();
381
+ this.statusReadyResolve = undefined;
166
382
  for (const entry of this.blockedList.list())
167
383
  this.blockedList.update(entry.paneId, undefined, entry);
168
384
  for (const stop of this.stops)
169
385
  stop();
170
386
  this.stops.clear();
171
387
  this.statusStops.clear();
388
+ this.statusEpoch.clear();
172
389
  await this.supervisor.stop();
173
390
  this.transport = undefined;
174
391
  this.tree = empty();
@@ -184,15 +401,30 @@ export class HerdrKit {
184
401
  ? wait.timeout_ms + 5000 : undefined);
185
402
  return this.transport.call(method, values, timeout);
186
403
  }
187
- subscribe(subs, on) {
404
+ subscribe(subs, on, onError) {
188
405
  if (!this.transport)
189
406
  throw new Error('herdr: not connected');
190
- return this.transport.subscribe(subs, on, () => { });
407
+ return this.transport.subscribe(subs, on, onError ?? (() => { }));
191
408
  }
192
409
  cli(args, o) { return runCli(this.o.bin, this.supervisor.env(), args, o?.timeoutMs); }
193
410
  terminal(paneId, o) { return openTerminal(this.o.bin, this.supervisor.env(), paneId, o); }
194
411
  snapshot() { return structuredClone(this.tree); }
195
412
  onChange(fn) { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }
413
+ /**
414
+ * Raw event tap (K3): delivers every event arriving on the kit's own batch and per-pane
415
+ * status sockets, with wire payloads intact (`pane.moved.previous_pane_id`, `workspace.*`).
416
+ * Fires on arrival — before the snapshot update/refresh for that event — so a listener sees
417
+ * the frame even when it triggers a re-bootstrap. Buffered replays never re-fire. Sockets
418
+ * opened via `subscribe()` are NOT tapped; `onEvent` itself opens no socket.
419
+ */
420
+ onEvent(fn) { this.rawListeners.add(fn); return () => { this.rawListeners.delete(fn); }; }
421
+ /**
422
+ * Ready signal for the status-watch set (K3): resolves when the latest bootstrap's per-pane
423
+ * `pane.agent_status_changed` subscriptions have acked (immediately when no pane needs one).
424
+ * Await after `start()` — and after any reconnect-driven re-bootstrap — before relying on
425
+ * status events instead of opening a second watch.
426
+ */
427
+ statusWatchReady() { return this.statusReadyPromise; }
196
428
  startAgent(o) { return this.agents.startAgent(o); }
197
429
  prompt(target, text, o) { return this.agents.prompt(target, text, o); }
198
430
  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
  }