@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
|
@@ -7,9 +7,16 @@ import { createServer } from 'node:net';
|
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { HERDR_PROTOCOL, HERDR_VERSION } from "../../constants.js";
|
|
9
9
|
import { writeBinShim } from "./bin.js";
|
|
10
|
-
import { agentRecord, createWorld,
|
|
10
|
+
import { agentRecord, createWorld, paneScroll, relayoutTab } from "./world.js";
|
|
11
11
|
const PROMPT_SETTLE_MS = 50; // 6.8: the agent settles 50 ms after a prompt
|
|
12
12
|
const WAIT_POLL_MS = 5;
|
|
13
|
+
// The subscription kinds that need a `pane_id` filter and ride their own socket each
|
|
14
|
+
// (src/generated/events.ts HerdrEventFilters), in the live underscore wire spelling —
|
|
15
|
+
// subscriptions arrive in either spelling and are normalized with wireName below. A batch
|
|
16
|
+
// holding one rejects the whole batch when the filter is missing or when it shares the
|
|
17
|
+
// batch with another kind.
|
|
18
|
+
const FILTERED = new Set(['pane_agent_status_changed', 'pane_output_matched', 'pane_scroll_changed']);
|
|
19
|
+
const AGENT_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
13
20
|
export async function startFakeHerdr(options) {
|
|
14
21
|
const dir = options.dir;
|
|
15
22
|
if (typeof dir !== 'string' || dir.length === 0)
|
|
@@ -19,17 +26,38 @@ export async function startFakeHerdr(options) {
|
|
|
19
26
|
const cwd = join(dir, 'project');
|
|
20
27
|
mkdirSync(cwd, { recursive: true });
|
|
21
28
|
const kinds = [...(options.world?.kinds ?? ['pi'])];
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
const seed = options.world?.seed ?? 0;
|
|
30
|
+
const live = Object.assign(createWorld({ cwd, kinds, plugins: options.world?.plugins }), {
|
|
31
|
+
nextPane: 3 + seed,
|
|
32
|
+
nextWorkspace: 2 + seed,
|
|
33
|
+
nextRevision: seed,
|
|
26
34
|
zoomed: false,
|
|
27
35
|
});
|
|
36
|
+
// Queued `agent.start` failures (K7): each start shifts one and fails with its code, so a test
|
|
37
|
+
// scripts busy-then-ok or a permanent failure. The array is live on the handle — push more faults
|
|
38
|
+
// any time before (or during) the starts under test.
|
|
39
|
+
const agentStartFaults = [...(options.agentStartFaults ?? [])];
|
|
28
40
|
const sockets = new Set();
|
|
29
41
|
const eventSubs = new Set();
|
|
30
|
-
const
|
|
42
|
+
const filteredSubs = new Set();
|
|
31
43
|
const timers = [];
|
|
32
44
|
let stopping = false;
|
|
45
|
+
let snapshots = 0;
|
|
46
|
+
let snapshotGates = [];
|
|
47
|
+
let nextSnapshotFailure;
|
|
48
|
+
let ackGates = [];
|
|
49
|
+
let nextAckFailure;
|
|
50
|
+
const gate = (list) => {
|
|
51
|
+
let release;
|
|
52
|
+
const promise = new Promise((resolve) => { release = resolve; });
|
|
53
|
+
const entry = { promise, release: () => { } };
|
|
54
|
+
entry.release = () => {
|
|
55
|
+
release();
|
|
56
|
+
list.splice(list.indexOf(entry), 1);
|
|
57
|
+
};
|
|
58
|
+
list.push(entry);
|
|
59
|
+
return entry.release;
|
|
60
|
+
};
|
|
33
61
|
const server = createServer((socket) => {
|
|
34
62
|
sockets.add(socket);
|
|
35
63
|
let buffer = '';
|
|
@@ -55,9 +83,9 @@ export async function startFakeHerdr(options) {
|
|
|
55
83
|
for (const sub of eventSubs)
|
|
56
84
|
if (sub.socket === socket)
|
|
57
85
|
eventSubs.delete(sub);
|
|
58
|
-
for (const sub of
|
|
86
|
+
for (const sub of filteredSubs)
|
|
59
87
|
if (sub.socket === socket)
|
|
60
|
-
|
|
88
|
+
filteredSubs.delete(sub);
|
|
61
89
|
});
|
|
62
90
|
socket.on('error', () => { });
|
|
63
91
|
});
|
|
@@ -66,7 +94,18 @@ export async function startFakeHerdr(options) {
|
|
|
66
94
|
const bin = writeBinShim({ dir, socketPath });
|
|
67
95
|
const methods = {
|
|
68
96
|
ping: () => ({ protocol: HERDR_PROTOCOL, version: HERDR_VERSION }),
|
|
69
|
-
'session.snapshot': () =>
|
|
97
|
+
'session.snapshot': async () => {
|
|
98
|
+
snapshots += 1;
|
|
99
|
+
for (const held of snapshotGates)
|
|
100
|
+
await held.promise;
|
|
101
|
+
if (stopping)
|
|
102
|
+
throw fail('error', 'the fake is stopping');
|
|
103
|
+
const failure = nextSnapshotFailure;
|
|
104
|
+
nextSnapshotFailure = undefined;
|
|
105
|
+
if (failure !== undefined)
|
|
106
|
+
throw fail(failure.code, failure.message);
|
|
107
|
+
return { type: 'session_snapshot', snapshot: snapshotOf() };
|
|
108
|
+
},
|
|
70
109
|
'workspace.list': () => ({ workspaces: live.workspaces.map((w) => workspaceView(w)) }),
|
|
71
110
|
'workspace.get': (p) => {
|
|
72
111
|
const workspace = live.workspaces.find((row) => row.workspace_id === p.workspace_id);
|
|
@@ -80,8 +119,12 @@ export async function startFakeHerdr(options) {
|
|
|
80
119
|
// result.root_pane.pane_id from workspace.create and tab.create alike).
|
|
81
120
|
const tab = addTab(workspace.workspace_id, 'main');
|
|
82
121
|
const pane = addPane(tab.tab_id, workspace.workspace_id, workspace.label);
|
|
122
|
+
const env = envOf(p);
|
|
123
|
+
if (env !== undefined)
|
|
124
|
+
pane.env = env;
|
|
83
125
|
if (p.focus === true)
|
|
84
126
|
focusWorkspace(workspace.workspace_id);
|
|
127
|
+
emitEvent('workspace.created', { workspace: workspaceView(workspace) });
|
|
85
128
|
return { workspace: workspaceView(workspace), root_pane: { pane_id: pane.pane_id } };
|
|
86
129
|
},
|
|
87
130
|
'workspace.close': (p) => {
|
|
@@ -92,6 +135,7 @@ export async function startFakeHerdr(options) {
|
|
|
92
135
|
for (const tabId of tabs)
|
|
93
136
|
removeTab(tabId);
|
|
94
137
|
live.workspaces = live.workspaces.filter((row) => row.workspace_id !== p.workspace_id);
|
|
138
|
+
emitEvent('workspace.closed', { workspace_id: p.workspace_id });
|
|
95
139
|
return {};
|
|
96
140
|
},
|
|
97
141
|
'workspace.focus': (p) => {
|
|
@@ -101,6 +145,25 @@ export async function startFakeHerdr(options) {
|
|
|
101
145
|
focusWorkspace(p.workspace_id);
|
|
102
146
|
return {};
|
|
103
147
|
},
|
|
148
|
+
'workspace.report_metadata': (p) => {
|
|
149
|
+
const workspace = live.workspaces.find((row) => row.workspace_id === p.workspace_id);
|
|
150
|
+
if (workspace === undefined)
|
|
151
|
+
throw fail('workspace_not_found', 'workspace not found');
|
|
152
|
+
if (p.tokens !== undefined && typeof p.tokens === 'object') {
|
|
153
|
+
workspace.tokens = { ...workspace.tokens, ...p.tokens };
|
|
154
|
+
}
|
|
155
|
+
return {};
|
|
156
|
+
},
|
|
157
|
+
'workspace.rename': (p) => {
|
|
158
|
+
const workspace = live.workspaces.find((row) => row.workspace_id === p.workspace_id);
|
|
159
|
+
if (workspace === undefined)
|
|
160
|
+
throw fail('workspace_not_found', 'workspace not found');
|
|
161
|
+
if (typeof p.label !== 'string' || p.label.length === 0)
|
|
162
|
+
throw fail('invalid_label', 'a label is required');
|
|
163
|
+
workspace.label = p.label;
|
|
164
|
+
emitEvent('workspace.renamed', { workspace_id: workspace.workspace_id, label: workspace.label });
|
|
165
|
+
return { type: 'workspace_info', workspace: workspaceView(workspace) };
|
|
166
|
+
},
|
|
104
167
|
'worktree.create': (p) => {
|
|
105
168
|
const checkout = typeof p.cwd === 'string' && p.cwd.length > 0 ? p.cwd : join(live.cwd, `wt-${live.nextWorkspace}`);
|
|
106
169
|
const workspace = addWorkspace(checkout);
|
|
@@ -111,7 +174,18 @@ export async function startFakeHerdr(options) {
|
|
|
111
174
|
checkout_path: checkout,
|
|
112
175
|
is_linked_worktree: true,
|
|
113
176
|
};
|
|
114
|
-
|
|
177
|
+
// Like the pinned server, the linked checkout opens with its root tab and root pane in the
|
|
178
|
+
// checkout, carrying the placement env; the kit reads result.root_pane.pane_id (K7).
|
|
179
|
+
const tab = addTab(workspace.workspace_id, 'main');
|
|
180
|
+
const pane = addPane(tab.tab_id, workspace.workspace_id, checkout);
|
|
181
|
+
const env = envOf(p);
|
|
182
|
+
if (env !== undefined)
|
|
183
|
+
pane.env = env;
|
|
184
|
+
if (p.focus === true)
|
|
185
|
+
focusWorkspace(workspace.workspace_id);
|
|
186
|
+
emitEvent('workspace.created', { workspace: workspaceView(workspace) });
|
|
187
|
+
return { workspace: { ...workspaceView(workspace), worktree: workspace.worktree },
|
|
188
|
+
tab: tabView(tab), root_pane: { pane_id: pane.pane_id } };
|
|
115
189
|
},
|
|
116
190
|
'tab.list': (p) => ({ tabs: live.tabs.filter((tab) => tab.workspace_id === p.workspace_id) }),
|
|
117
191
|
'tab.get': (p) => {
|
|
@@ -126,14 +200,20 @@ export async function startFakeHerdr(options) {
|
|
|
126
200
|
throw fail('workspace_not_found', 'workspace not found');
|
|
127
201
|
const tab = addTab(workspace.workspace_id, label(p) ?? workspace.label ?? live.cwd);
|
|
128
202
|
const pane = addPane(tab.tab_id, workspace.workspace_id, p.cwd ?? workspace.label ?? live.cwd);
|
|
203
|
+
const env = envOf(p);
|
|
204
|
+
if (env !== undefined)
|
|
205
|
+
pane.env = env;
|
|
129
206
|
if (p.focus === true)
|
|
130
207
|
focusPane(pane.pane_id);
|
|
208
|
+
emitEvent('tab.created', { tab: tabView(tab) });
|
|
131
209
|
return { tab: tabView(tab), root_pane: { pane_id: pane.pane_id } };
|
|
132
210
|
},
|
|
133
211
|
'tab.close': (p) => {
|
|
134
|
-
|
|
212
|
+
const tabWorkspace = live.tabs.find((row) => row.tab_id === p.tab_id)?.workspace_id;
|
|
213
|
+
if (tabWorkspace === undefined)
|
|
135
214
|
throw fail('tab_not_found', 'tab not found');
|
|
136
215
|
removeTab(p.tab_id);
|
|
216
|
+
emitEvent('tab.closed', { tab_id: p.tab_id, workspace_id: tabWorkspace });
|
|
137
217
|
return {};
|
|
138
218
|
},
|
|
139
219
|
'tab.focus': (p) => {
|
|
@@ -145,12 +225,23 @@ export async function startFakeHerdr(options) {
|
|
|
145
225
|
focusPane(pane.pane_id);
|
|
146
226
|
return {};
|
|
147
227
|
},
|
|
228
|
+
'tab.rename': (p) => {
|
|
229
|
+
const tab = live.tabs.find((row) => row.tab_id === p.tab_id);
|
|
230
|
+
if (tab === undefined)
|
|
231
|
+
throw fail('tab_not_found', 'tab not found');
|
|
232
|
+
if (typeof p.label !== 'string' || p.label.length === 0)
|
|
233
|
+
throw fail('invalid_label', 'a label is required');
|
|
234
|
+
tab.label = p.label;
|
|
235
|
+
emitEvent('tab.renamed', { tab_id: tab.tab_id, workspace_id: tab.workspace_id, label: tab.label });
|
|
236
|
+
return { type: 'tab_info', tab: tabView(tab) };
|
|
237
|
+
},
|
|
148
238
|
'pane.get': (p) => ({ pane: paneView(paneOf(p.pane_id)) }),
|
|
149
239
|
'pane.close': (p) => {
|
|
150
240
|
const pane = paneOf(p.pane_id);
|
|
151
241
|
live.panes = live.panes.filter((row) => row.pane_id !== pane.pane_id);
|
|
152
242
|
live.agents = live.agents.filter((row) => row.pane_id !== pane.pane_id);
|
|
153
243
|
relayoutTab(live, pane.tab_id);
|
|
244
|
+
emitEvent('pane.closed', { pane_id: pane.pane_id, workspace_id: pane.workspace_id });
|
|
154
245
|
return {};
|
|
155
246
|
},
|
|
156
247
|
'pane.focus': (p) => {
|
|
@@ -160,8 +251,12 @@ export async function startFakeHerdr(options) {
|
|
|
160
251
|
'pane.split': (p) => {
|
|
161
252
|
const target = paneOf(p.target_pane_id);
|
|
162
253
|
const pane = addPane(target.tab_id, target.workspace_id, target.cwd);
|
|
254
|
+
const env = envOf(p);
|
|
255
|
+
if (env !== undefined)
|
|
256
|
+
pane.env = env;
|
|
163
257
|
if (p.focus === true)
|
|
164
258
|
focusPane(pane.pane_id);
|
|
259
|
+
emitEvent('pane.created', { pane: paneView(pane) });
|
|
165
260
|
return { pane: { pane_id: pane.pane_id } };
|
|
166
261
|
},
|
|
167
262
|
'pane.read': (p) => {
|
|
@@ -183,6 +278,31 @@ export async function startFakeHerdr(options) {
|
|
|
183
278
|
}
|
|
184
279
|
return {};
|
|
185
280
|
},
|
|
281
|
+
'pane.rename': (p) => {
|
|
282
|
+
const pane = paneOf(p.pane_id);
|
|
283
|
+
// A null label clears back to the shell default; a rename never moves the revision,
|
|
284
|
+
// so an open approval question stays answerable at its recorded revision.
|
|
285
|
+
if (p.label !== undefined && p.label !== null) {
|
|
286
|
+
if (typeof p.label !== 'string' || p.label.length === 0)
|
|
287
|
+
throw fail('invalid_label', 'a label is required');
|
|
288
|
+
pane.label = p.label;
|
|
289
|
+
}
|
|
290
|
+
else if (p.label === null) {
|
|
291
|
+
pane.label = 'zsh';
|
|
292
|
+
}
|
|
293
|
+
return { type: 'pane_info', pane: paneView(pane) };
|
|
294
|
+
},
|
|
295
|
+
'pane.scroll': (p) => {
|
|
296
|
+
const pane = paneOf(p.pane_id);
|
|
297
|
+
const offset = typeof p.offset_from_bottom === 'number' && Number.isSafeInteger(p.offset_from_bottom)
|
|
298
|
+
? Math.max(0, Math.min(pane.scroll.max_offset_from_bottom, p.offset_from_bottom))
|
|
299
|
+
: pane.scroll.offset_from_bottom;
|
|
300
|
+
pane.scroll = { ...pane.scroll, offset_from_bottom: offset };
|
|
301
|
+
emitEvent('pane.scroll_changed', {
|
|
302
|
+
pane_id: pane.pane_id, workspace_id: pane.workspace_id, scroll: { ...pane.scroll },
|
|
303
|
+
});
|
|
304
|
+
return { type: 'pane_info', pane: paneView(pane) };
|
|
305
|
+
},
|
|
186
306
|
'pane.zoom': (p) => {
|
|
187
307
|
paneOf(p.pane_id);
|
|
188
308
|
const mode = p.mode ?? 'toggle';
|
|
@@ -193,6 +313,9 @@ export async function startFakeHerdr(options) {
|
|
|
193
313
|
},
|
|
194
314
|
'pane.layout': (p) => ({ layout: tabLayout(paneOf(p.pane_id).tab_id) }),
|
|
195
315
|
'agent.start': (p) => {
|
|
316
|
+
const fault = agentStartFaults.shift();
|
|
317
|
+
if (fault !== undefined)
|
|
318
|
+
throw fail(fault.code, fault.message ?? fault.code);
|
|
196
319
|
const pane = paneOf(p.pane_id);
|
|
197
320
|
const kind = typeof p.kind === 'string' && p.kind.length > 0 ? p.kind : 'pi';
|
|
198
321
|
const name = typeof p.name === 'string' && p.name.length > 0 ? p.name : kind;
|
|
@@ -202,14 +325,37 @@ export async function startFakeHerdr(options) {
|
|
|
202
325
|
pane.terminal_title_stripped = pane.terminal_title;
|
|
203
326
|
const agent = agentRecord({
|
|
204
327
|
paneId: pane.pane_id, tabId: pane.tab_id, workspaceId: pane.workspace_id, cwd: pane.cwd,
|
|
205
|
-
name, kind, revision: ++live.nextRevision,
|
|
328
|
+
name, kind, revision: ++live.nextRevision, focused: pane.focused,
|
|
206
329
|
});
|
|
207
330
|
const index = live.agents.findIndex((row) => row.pane_id === pane.pane_id);
|
|
208
331
|
if (index === -1)
|
|
209
332
|
live.agents.push(agent);
|
|
210
333
|
else
|
|
211
334
|
live.agents[index] = agent;
|
|
212
|
-
|
|
335
|
+
pane.revision = agent.revision;
|
|
336
|
+
emitEvent('pane.agent_detected', { pane_id: pane.pane_id, workspace_id: pane.workspace_id, agent: kind });
|
|
337
|
+
// The boot-window reply: the 0.8 shape muxr reads (`agent`) plus the pinned 0.9.1 envelope
|
|
338
|
+
// (`type`/`argv`), so a launch flow keeps its assertions against either shape.
|
|
339
|
+
return { type: 'agent_started', agent, argv: Array.isArray(p.args) ? p.args : [] };
|
|
340
|
+
},
|
|
341
|
+
'agent.rename': (p) => {
|
|
342
|
+
const agent = live.agents.find((row) => row.pane_id === p.target);
|
|
343
|
+
if (agent === undefined)
|
|
344
|
+
throw fail('agent_not_found', 'agent not found');
|
|
345
|
+
const name = p.name;
|
|
346
|
+
if (name !== undefined && name !== null) {
|
|
347
|
+
if (typeof name !== 'string' || !AGENT_NAME.test(name)) {
|
|
348
|
+
throw fail('invalid_agent_name', 'an agent name matches /^[a-z][a-z0-9_-]{0,31}$/');
|
|
349
|
+
}
|
|
350
|
+
if (live.agents.some((row) => row.pane_id !== agent.pane_id && row.name === name)) {
|
|
351
|
+
throw fail('agent_name_taken', `another agent is already named ${name}`);
|
|
352
|
+
}
|
|
353
|
+
const pane = live.panes.find((row) => row.pane_id === agent.pane_id);
|
|
354
|
+
if (pane !== undefined && pane.label === agent.name)
|
|
355
|
+
pane.label = name;
|
|
356
|
+
agent.name = name;
|
|
357
|
+
}
|
|
358
|
+
return { type: 'agent_info', agent: { ...agent } };
|
|
213
359
|
},
|
|
214
360
|
'agent.prompt': (p) => {
|
|
215
361
|
const pane = paneOf(p.target);
|
|
@@ -285,6 +431,13 @@ export async function startFakeHerdr(options) {
|
|
|
285
431
|
return { agent };
|
|
286
432
|
},
|
|
287
433
|
'server.agent_manifests': () => ({ manifests: kinds.map((kind) => ({ agent: kind })) }),
|
|
434
|
+
'plugin.list': (p) => {
|
|
435
|
+
const plugins = live.plugins ?? [];
|
|
436
|
+
const wanted = typeof p.plugin_id === 'string' ? p.plugin_id : undefined;
|
|
437
|
+
const list = (wanted === undefined ? plugins : plugins.filter((row) => row.plugin_id === wanted))
|
|
438
|
+
.map((row) => ({ ...row }));
|
|
439
|
+
return { type: 'plugin_list', plugins: list };
|
|
440
|
+
},
|
|
288
441
|
'server.stop': () => {
|
|
289
442
|
timers.push(setTimeout(() => { void shutdown(); }, 0));
|
|
290
443
|
return {};
|
|
@@ -297,11 +450,17 @@ export async function startFakeHerdr(options) {
|
|
|
297
450
|
for (const timer of timers)
|
|
298
451
|
clearTimeout(timer);
|
|
299
452
|
timers.length = 0;
|
|
453
|
+
for (const held of snapshotGates)
|
|
454
|
+
held.release();
|
|
455
|
+
snapshotGates = [];
|
|
456
|
+
for (const held of ackGates)
|
|
457
|
+
held.release();
|
|
458
|
+
ackGates = [];
|
|
300
459
|
for (const socket of sockets)
|
|
301
460
|
socket.destroy();
|
|
302
461
|
sockets.clear();
|
|
303
462
|
eventSubs.clear();
|
|
304
|
-
|
|
463
|
+
filteredSubs.clear();
|
|
305
464
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
306
465
|
unlinkQuiet(socketPath);
|
|
307
466
|
options.onStop?.();
|
|
@@ -340,36 +499,68 @@ export async function startFakeHerdr(options) {
|
|
|
340
499
|
})
|
|
341
500
|
.finally(() => socket.end());
|
|
342
501
|
}
|
|
502
|
+
// Subscription kinds use the schema's dot spelling (`pane.created`); live frames carry
|
|
503
|
+
// the underscore const (`pane_created`, schema/SOURCE.md). The fake mirrors the real
|
|
504
|
+
// server: it accepts both spellings in subscriptions and emits underscore wire frames.
|
|
505
|
+
const wireName = (type) => type.replace(/\./g, '_');
|
|
343
506
|
function subscribe(socket, id, params) {
|
|
344
507
|
const subs = Array.isArray(params.subscriptions) ? params.subscriptions : [];
|
|
345
|
-
const
|
|
508
|
+
const ackFailure = nextAckFailure;
|
|
509
|
+
nextAckFailure = undefined;
|
|
510
|
+
const invalid = (message, code = 'invalid_subscription') => {
|
|
346
511
|
// A rejected subscribe answers `id: ""` once and the server closes; the client never retries it.
|
|
347
|
-
writeJson(socket, { id: '', error: { code
|
|
512
|
+
writeJson(socket, { id: '', error: { code, message } });
|
|
348
513
|
socket.end();
|
|
349
514
|
};
|
|
515
|
+
if (ackFailure !== undefined) {
|
|
516
|
+
invalid(ackFailure.message, ackFailure.code);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
350
519
|
for (const s of subs) {
|
|
351
520
|
if (typeof s?.type !== 'string')
|
|
352
521
|
return invalid('every subscription needs a type');
|
|
353
|
-
|
|
354
|
-
|
|
522
|
+
const wire = wireName(s.type);
|
|
523
|
+
if (FILTERED.has(wire) && typeof s.pane_id !== 'string') {
|
|
524
|
+
return invalid(`${s.type} needs a pane_id`);
|
|
525
|
+
}
|
|
526
|
+
if (wire === 'pane_output_matched'
|
|
527
|
+
&& (typeof s.match !== 'object' || s.match === null || typeof s.source !== 'string')) {
|
|
528
|
+
return invalid('pane.output_matched needs a pane_id, a match and a source');
|
|
355
529
|
}
|
|
356
530
|
}
|
|
357
|
-
const filtered = subs.filter((s) => s.type === '
|
|
358
|
-
if (filtered.length > 0 && filtered.length !== subs.length) {
|
|
359
|
-
return invalid('pane.agent_status_changed cannot share a batch');
|
|
360
|
-
}
|
|
361
|
-
writeJson(socket, { id, result: { type: 'subscribed' } });
|
|
531
|
+
const filtered = subs.filter((s) => typeof s.type === 'string' && FILTERED.has(wireName(s.type)));
|
|
362
532
|
if (filtered.length > 0) {
|
|
363
|
-
|
|
364
|
-
|
|
533
|
+
const kinds = new Set(filtered.map((s) => wireName(s.type)));
|
|
534
|
+
if (kinds.size > 1 || filtered.length !== subs.length) {
|
|
535
|
+
return invalid(`${filtered[0].type} cannot share a batch`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const held = [...ackGates];
|
|
539
|
+
const answer = () => {
|
|
540
|
+
writeJson(socket, { id, result: { type: 'subscribed' } });
|
|
541
|
+
if (filtered.length > 0) {
|
|
542
|
+
for (const s of filtered) {
|
|
543
|
+
filteredSubs.add({ socket, type: wireName(s.type), paneId: s.pane_id });
|
|
544
|
+
}
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
eventSubs.add({ socket, kinds: new Set(subs.map((s) => wireName(s.type))) });
|
|
548
|
+
};
|
|
549
|
+
if (held.length === 0) {
|
|
550
|
+
answer();
|
|
365
551
|
return;
|
|
366
552
|
}
|
|
367
|
-
|
|
553
|
+
void Promise.all(held.map((h) => h.promise)).then(() => {
|
|
554
|
+
if (!socket.destroyed)
|
|
555
|
+
answer();
|
|
556
|
+
});
|
|
368
557
|
}
|
|
558
|
+
// Payloads follow the pinned schema's event data (src/generated/events.ts).
|
|
369
559
|
function emitEvent(type, data) {
|
|
370
|
-
const
|
|
560
|
+
const wire = wireName(type);
|
|
561
|
+
const frame = `${JSON.stringify({ event: wire, data: { type: wire, ...data } })}\n`;
|
|
371
562
|
for (const sub of eventSubs) {
|
|
372
|
-
if (sub.kinds.size > 0 && !sub.kinds.has(
|
|
563
|
+
if (sub.kinds.size > 0 && !sub.kinds.has(wire))
|
|
373
564
|
continue;
|
|
374
565
|
try {
|
|
375
566
|
sub.socket.write(frame);
|
|
@@ -378,12 +569,28 @@ export async function startFakeHerdr(options) {
|
|
|
378
569
|
}
|
|
379
570
|
}
|
|
380
571
|
function emitStatus(paneId, agentStatus, revision) {
|
|
572
|
+
const agent = live.agents.find((row) => row.pane_id === paneId);
|
|
381
573
|
const frame = `${JSON.stringify({
|
|
382
|
-
event: '
|
|
383
|
-
|
|
574
|
+
event: 'pane_agent_status_changed',
|
|
575
|
+
// Schema-shaped (PaneAgentStatusChangedEvent): the agent kind rides as a plain `agent`
|
|
576
|
+
// string beside `workspace_id`, exactly like the real server's frame.
|
|
577
|
+
data: { type: 'pane_agent_status_changed', pane_id: paneId, workspace_id: agent?.workspace_id,
|
|
578
|
+
agent: agent?.agent, display_agent: agent?.display_agent, agent_status: agentStatus, revision },
|
|
384
579
|
})}\n`;
|
|
385
|
-
for (const sub of
|
|
386
|
-
if (sub.paneId !== paneId)
|
|
580
|
+
for (const sub of filteredSubs) {
|
|
581
|
+
if (sub.type !== 'pane_agent_status_changed' || sub.paneId !== paneId)
|
|
582
|
+
continue;
|
|
583
|
+
try {
|
|
584
|
+
sub.socket.write(frame);
|
|
585
|
+
}
|
|
586
|
+
catch { /* closed mid-emit */ }
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// Filtered emit: a frame for a filtered kind reaches only the sockets watching that pane.
|
|
590
|
+
function emitFiltered(type, paneId, data) {
|
|
591
|
+
const frame = `${JSON.stringify({ event: type, data: { type, ...data } })}\n`;
|
|
592
|
+
for (const sub of filteredSubs) {
|
|
593
|
+
if (sub.type !== type || sub.paneId !== paneId)
|
|
387
594
|
continue;
|
|
388
595
|
try {
|
|
389
596
|
sub.socket.write(frame);
|
|
@@ -413,6 +620,7 @@ export async function startFakeHerdr(options) {
|
|
|
413
620
|
if (agent !== undefined) {
|
|
414
621
|
agent.agent_status = status;
|
|
415
622
|
agent.revision = ++live.nextRevision;
|
|
623
|
+
pane.revision = agent.revision;
|
|
416
624
|
// A blocked agent always has detection text (6.4 reads it as the question), whichever path
|
|
417
625
|
// got it there — `agent.prompt` sets it first, `setStatus` arrives with none.
|
|
418
626
|
if (status === 'blocked' && agent.detection === undefined)
|
|
@@ -430,20 +638,39 @@ export async function startFakeHerdr(options) {
|
|
|
430
638
|
function label(p) {
|
|
431
639
|
return typeof p.label === 'string' && p.label.length > 0 ? p.label : undefined;
|
|
432
640
|
}
|
|
641
|
+
// Placement env (K7): `workspace.create`, `tab.create`, `pane.split` and `worktree.create` carry
|
|
642
|
+
// the start's env for the fresh pane's shell. The fake records it on the pane, where `pane.get`
|
|
643
|
+
// surfaces it, so a test proves the pane received the env.
|
|
644
|
+
function envOf(p) {
|
|
645
|
+
if (typeof p.env !== 'object' || p.env === null || Array.isArray(p.env))
|
|
646
|
+
return undefined;
|
|
647
|
+
const env = {};
|
|
648
|
+
for (const [key, value] of Object.entries(p.env)) {
|
|
649
|
+
if (typeof value === 'string')
|
|
650
|
+
env[key] = value;
|
|
651
|
+
}
|
|
652
|
+
return env;
|
|
653
|
+
}
|
|
433
654
|
function snapshotOf() {
|
|
434
655
|
return {
|
|
656
|
+
protocol: HERDR_PROTOCOL,
|
|
657
|
+
version: HERDR_VERSION,
|
|
435
658
|
workspaces: live.workspaces.map(workspaceView),
|
|
436
659
|
tabs: live.tabs.map((tab) => ({ ...tab })),
|
|
437
660
|
panes: live.panes.map(paneView),
|
|
438
661
|
agents: live.agents.map((agent) => ({ ...agent })),
|
|
662
|
+
layouts: [],
|
|
439
663
|
};
|
|
440
664
|
}
|
|
441
665
|
function addWorkspace(labelText) {
|
|
442
|
-
const
|
|
666
|
+
const n = live.nextWorkspace++;
|
|
667
|
+
const workspaceId = `w${n}`;
|
|
443
668
|
const workspace = {
|
|
444
669
|
workspace_id: workspaceId,
|
|
445
670
|
label: labelText,
|
|
446
671
|
focused: false,
|
|
672
|
+
number: n,
|
|
673
|
+
tokens: {},
|
|
447
674
|
worktree: {
|
|
448
675
|
// A unique key per plain workspace keeps it out of every worktree group; only
|
|
449
676
|
// worktree.create links a checkout back into the root workspace's group.
|
|
@@ -480,6 +707,8 @@ export async function startFakeHerdr(options) {
|
|
|
480
707
|
tokens: {},
|
|
481
708
|
rect: { x: 0, y: 0, width: 80, height: 24 },
|
|
482
709
|
text: ['ready.'],
|
|
710
|
+
revision: 0,
|
|
711
|
+
scroll: paneScroll(),
|
|
483
712
|
};
|
|
484
713
|
live.panes.push(pane);
|
|
485
714
|
relayoutTab(live, tabId);
|
|
@@ -534,8 +763,10 @@ export async function startFakeHerdr(options) {
|
|
|
534
763
|
workspace_id: workspace.workspace_id,
|
|
535
764
|
label: workspace.label,
|
|
536
765
|
focused: workspace.focused,
|
|
766
|
+
number: workspace.number,
|
|
537
767
|
tab_count: tabs.length,
|
|
538
768
|
active_tab_id: focusedPane?.tab_id ?? tabs[0]?.tab_id,
|
|
769
|
+
...(Object.keys(workspace.tokens).length === 0 ? {} : { tokens: { ...workspace.tokens } }),
|
|
539
770
|
...(workspace.worktree === undefined ? {} : { worktree: workspace.worktree }),
|
|
540
771
|
};
|
|
541
772
|
}
|
|
@@ -548,10 +779,21 @@ export async function startFakeHerdr(options) {
|
|
|
548
779
|
return {
|
|
549
780
|
socketPath,
|
|
550
781
|
bin,
|
|
551
|
-
world:
|
|
782
|
+
world: live,
|
|
783
|
+
agentStartFaults,
|
|
552
784
|
emit(event) {
|
|
553
|
-
|
|
554
|
-
|
|
785
|
+
// Filtered kinds route to the sockets watching that pane only; every other kind
|
|
786
|
+
// broadcasts to the batch subscriptions holding it. Either spelling is accepted and
|
|
787
|
+
// frames ride the live underscore wire spelling. Emitting never mutates the world —
|
|
788
|
+
// `setStatus` and the `pane.*` methods are the state changes; this only routes frames.
|
|
789
|
+
const wire = wireName(event.type);
|
|
790
|
+
if (FILTERED.has(wire) && typeof event.pane_id === 'string') {
|
|
791
|
+
if (wire === 'pane_agent_status_changed') {
|
|
792
|
+
emitStatus(event.pane_id, String(event.agent_status ?? 'unknown'), Number(event.revision ?? 0));
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const { type: _type, ...data } = event;
|
|
796
|
+
emitFiltered(wire, event.pane_id, data);
|
|
555
797
|
return;
|
|
556
798
|
}
|
|
557
799
|
const { type, ...data } = event;
|
|
@@ -562,6 +804,35 @@ export async function startFakeHerdr(options) {
|
|
|
562
804
|
if (pane !== undefined)
|
|
563
805
|
transition(pane, status);
|
|
564
806
|
},
|
|
807
|
+
watching: ((paneId) => {
|
|
808
|
+
const watched = [...new Set([...filteredSubs].map((sub) => sub.paneId))].sort();
|
|
809
|
+
if (paneId === undefined)
|
|
810
|
+
return watched;
|
|
811
|
+
return watched.includes(paneId);
|
|
812
|
+
}),
|
|
813
|
+
snapshotCount() {
|
|
814
|
+
return snapshots;
|
|
815
|
+
},
|
|
816
|
+
holdSnapshot() {
|
|
817
|
+
return gate(snapshotGates);
|
|
818
|
+
},
|
|
819
|
+
failNextSnapshot(code = 'error', message = 'the next snapshot fails') {
|
|
820
|
+
nextSnapshotFailure = { code, message };
|
|
821
|
+
},
|
|
822
|
+
holdAck() {
|
|
823
|
+
return gate(ackGates);
|
|
824
|
+
},
|
|
825
|
+
failNextAck(code = 'invalid_subscription', message = 'the next subscribe is rejected') {
|
|
826
|
+
nextAckFailure = { code, message };
|
|
827
|
+
},
|
|
828
|
+
subscriptionCount() {
|
|
829
|
+
const held = new Set();
|
|
830
|
+
for (const sub of eventSubs)
|
|
831
|
+
held.add(sub.socket);
|
|
832
|
+
for (const sub of filteredSubs)
|
|
833
|
+
held.add(sub.socket);
|
|
834
|
+
return held.size;
|
|
835
|
+
},
|
|
565
836
|
stop: shutdown,
|
|
566
837
|
};
|
|
567
838
|
}
|
|
@@ -577,6 +848,10 @@ function paneView(pane) {
|
|
|
577
848
|
terminal_title: pane.terminal_title,
|
|
578
849
|
terminal_title_stripped: pane.terminal_title_stripped,
|
|
579
850
|
focused: pane.focused,
|
|
851
|
+
...(pane.env === undefined ? {} : { env: { ...pane.env } }),
|
|
852
|
+
terminal_id: `term-${pane.pane_id}`,
|
|
853
|
+
revision: pane.revision,
|
|
854
|
+
scroll: { ...pane.scroll },
|
|
580
855
|
...(Object.keys(pane.tokens).length === 0 ? {} : { tokens: pane.tokens }),
|
|
581
856
|
};
|
|
582
857
|
}
|
|
@@ -4,6 +4,11 @@ export type FakeRect = {
|
|
|
4
4
|
width: number;
|
|
5
5
|
height: number;
|
|
6
6
|
};
|
|
7
|
+
export type FakeScroll = {
|
|
8
|
+
offset_from_bottom: number;
|
|
9
|
+
max_offset_from_bottom: number;
|
|
10
|
+
viewport_rows: number;
|
|
11
|
+
};
|
|
7
12
|
export type FakePane = {
|
|
8
13
|
pane_id: string;
|
|
9
14
|
tab_id: string;
|
|
@@ -16,8 +21,11 @@ export type FakePane = {
|
|
|
16
21
|
terminal_title_stripped: string;
|
|
17
22
|
focused: boolean;
|
|
18
23
|
tokens: Record<string, unknown>;
|
|
24
|
+
env?: Record<string, string>;
|
|
19
25
|
rect: FakeRect;
|
|
20
26
|
text: string[];
|
|
27
|
+
revision: number;
|
|
28
|
+
scroll: FakeScroll;
|
|
21
29
|
};
|
|
22
30
|
export type FakeAgent = {
|
|
23
31
|
pane_id: string;
|
|
@@ -28,10 +36,13 @@ export type FakeAgent = {
|
|
|
28
36
|
name: string;
|
|
29
37
|
agent: string;
|
|
30
38
|
display_agent: string;
|
|
39
|
+
title: string;
|
|
31
40
|
agent_status: string;
|
|
32
41
|
interactive_ready: boolean;
|
|
33
42
|
launch_pending: boolean;
|
|
34
43
|
revision: number;
|
|
44
|
+
terminal_id: string;
|
|
45
|
+
focused: boolean;
|
|
35
46
|
detection?: string;
|
|
36
47
|
agent_session: {
|
|
37
48
|
source: string;
|
|
@@ -40,6 +51,20 @@ export type FakeAgent = {
|
|
|
40
51
|
value: string;
|
|
41
52
|
};
|
|
42
53
|
};
|
|
54
|
+
export type FakePluginSeed = {
|
|
55
|
+
plugin_id: string;
|
|
56
|
+
name?: string;
|
|
57
|
+
version?: string;
|
|
58
|
+
enabled?: boolean;
|
|
59
|
+
};
|
|
60
|
+
export type FakePlugin = {
|
|
61
|
+
plugin_id: string;
|
|
62
|
+
name: string;
|
|
63
|
+
version: string;
|
|
64
|
+
manifest_path: string;
|
|
65
|
+
plugin_root: string;
|
|
66
|
+
enabled: boolean;
|
|
67
|
+
};
|
|
43
68
|
export type FakeTab = {
|
|
44
69
|
tab_id: string;
|
|
45
70
|
workspace_id: string;
|
|
@@ -49,6 +74,8 @@ export type FakeWorkspace = {
|
|
|
49
74
|
workspace_id: string;
|
|
50
75
|
label: string;
|
|
51
76
|
focused: boolean;
|
|
77
|
+
number: number;
|
|
78
|
+
tokens: Record<string, string>;
|
|
52
79
|
worktree?: {
|
|
53
80
|
repo_key: string;
|
|
54
81
|
repo_name: string;
|
|
@@ -63,6 +90,7 @@ export type FakeWorld = {
|
|
|
63
90
|
tabs: FakeTab[];
|
|
64
91
|
panes: FakePane[];
|
|
65
92
|
agents: FakeAgent[];
|
|
93
|
+
plugins: FakePlugin[];
|
|
66
94
|
};
|
|
67
95
|
export declare const COLS = 80;
|
|
68
96
|
export declare const ROWS = 24;
|
|
@@ -75,10 +103,17 @@ export declare function agentRecord(o: {
|
|
|
75
103
|
name: string;
|
|
76
104
|
kind: string;
|
|
77
105
|
revision: number;
|
|
106
|
+
focused?: boolean;
|
|
78
107
|
}): FakeAgent;
|
|
108
|
+
export declare function pluginRecord(o: {
|
|
109
|
+
dir: string;
|
|
110
|
+
seed: FakePluginSeed;
|
|
111
|
+
}): FakePlugin;
|
|
112
|
+
export declare function paneScroll(): FakeScroll;
|
|
79
113
|
export declare function createWorld(o: {
|
|
80
114
|
cwd: string;
|
|
81
115
|
kinds: readonly string[];
|
|
116
|
+
plugins?: readonly FakePluginSeed[];
|
|
82
117
|
}): FakeWorld;
|
|
83
118
|
export declare function relayoutTab(world: FakeWorld, tabId: string): void;
|
|
84
119
|
export declare function freezeWorld(world: FakeWorld): FakeWorld;
|