@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/CHANGELOG.md +72 -0
- package/README.md +184 -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 +28 -2
- package/dist/kit.js +256 -50
- 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 +95 -7
- package/dist/testing/fake-herdr/server.d.ts +7 -0
- package/dist/testing/fake-herdr/server.js +89 -11
- package/dist/testing/fake-herdr/world.d.ts +4 -0
- package/dist/testing/fake-herdr/world.js +3 -0
- package/dist/testing/index.d.ts +2 -2
- package/dist/types.d.ts +56 -21
- package/package.json +5 -1
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,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
|
-
(
|
|
160
|
+
(name === 'pane.agent.status.changed' || name === 'pane.agent.detected' || name === 'pane.updated')) {
|
|
49
161
|
const a = raw.agent ?? raw.pane ?? raw;
|
|
50
|
-
pane
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
7
|
+
withTransport?: (transport: HerdrTransport, o?: {
|
|
8
|
+
protocolRange?: HerdrProtocolRange;
|
|
9
|
+
}) => HerdrKit;
|
|
8
10
|
};
|
|
9
|
-
export
|
|
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;
|