@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.
@@ -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, freezeWorld, relayoutTab } from "./world.js";
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 live = Object.assign(createWorld({ cwd, kinds }), {
23
- nextPane: 3,
24
- nextWorkspace: 2,
25
- nextRevision: 0,
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 statusSubs = new Set();
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 statusSubs)
86
+ for (const sub of filteredSubs)
59
87
  if (sub.socket === socket)
60
- statusSubs.delete(sub);
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': () => ({ snapshot: snapshotOf() }),
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
- return { workspace: { ...workspaceView(workspace), worktree: workspace.worktree } };
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
- if (!live.tabs.some((row) => row.tab_id === p.tab_id))
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
- return { agent };
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
- statusSubs.clear();
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 invalid = (message) => {
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: 'invalid_subscription', message } });
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
- if (s.type === 'pane.agent_status_changed' && typeof s.pane_id !== 'string') {
354
- return invalid('pane.agent_status_changed needs a pane_id');
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 === 'pane.agent_status_changed');
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
- for (const s of filtered)
364
- statusSubs.add({ socket, paneId: s.pane_id });
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
- eventSubs.add({ socket, kinds: new Set(subs.map((s) => s.type)) });
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 frame = `${JSON.stringify({ event: type, data: { type, ...data } })}\n`;
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(type))
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: 'pane.agent_status_changed',
383
- data: { type: 'pane.agent_status_changed', pane_id: paneId, agent_status: agentStatus, revision },
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 statusSubs) {
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 workspaceId = `w${live.nextWorkspace++}`;
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: freezeWorld(live),
782
+ world: live,
783
+ agentStartFaults,
552
784
  emit(event) {
553
- if (event.type === 'pane.agent_status_changed' && typeof event.pane_id === 'string') {
554
- emitStatus(event.pane_id, String(event.agent_status ?? 'unknown'), Number(event.revision ?? 0));
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;