@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/CHANGELOG.md +94 -0
- package/README.md +192 -14
- package/dist/agents.js +48 -7
- package/dist/binary.d.ts +29 -0
- package/dist/binary.js +132 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.js +4 -0
- package/dist/device.d.ts +34 -8
- package/dist/device.js +152 -49
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/kit.d.ts +30 -2
- package/dist/kit.js +284 -52
- package/dist/link.js +118 -2
- package/dist/supervise.js +11 -2
- package/dist/testing/contract.d.ts +13 -3
- package/dist/testing/contract.js +116 -12
- package/dist/testing/fake-herdr/bin.js +41 -12
- package/dist/testing/fake-herdr/server.d.ts +17 -1
- package/dist/testing/fake-herdr/server.js +311 -36
- package/dist/testing/fake-herdr/world.d.ts +35 -0
- package/dist/testing/fake-herdr/world.js +24 -1
- package/dist/testing/index.d.ts +2 -2
- package/dist/testing/index.js +9 -0
- package/dist/types.d.ts +64 -22
- package/package.json +7 -3
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 {
|
|
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
|
-
(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|