@byokit/herdr 0.1.0 → 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 CHANGED
@@ -2,21 +2,42 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.1 (2026-09-29)
6
+
7
+ - SECURITY: (from @byokit/relay 0.2.0) Removing a phone now reliably stops its push notifications, even if the relay was down or restarting when you removed it. Before, `RelayClient.revoke` dropped the device's grant and sent one unsubscribe that was lost if the offline queue overflowed or the host stopped or restarted before the relay was back, so the relay kept the phone's push address. Now the unsubscribe is saved first and resent on every connection until the relay confirms it; `revoke()` resolves then. Pass a durable `store` to `RelayClient` so a pending unsubscribe survives a restart (the default is memory).
8
+ - FIX: `prompt` now reaches an idle agent that was already running when the app connected. The kit
9
+ read every live status push as the agent kind (the push carries the kind as a plain `agent`
10
+ string) and dropped the status, so an agent caught at `unknown` stayed there and the readiness
11
+ gate refused every prompt. The push now lands; and a status change between the snapshot read and
12
+ the per-pane watch's ack, which reached no socket, is re-read once the watch is live.
13
+ - FIX subscribe errors and type (K4): `kit.subscribe(subs, on, onError?)` forwards `onError`
14
+ to the transport and returns its control surface (`HerdrSubscribeStop`: `ready`/`onReconnect`/
15
+ `onDisconnect`); a rejected filtered batch reports `invalid_subscription` once and never retries.
16
+ The kit's own event and per-pane watches log rejections to a new `HerdrKitOptions.onLog` hook
17
+ instead of swallowing them.
18
+ - Depends on @byokit/relay 0.2.0.
19
+ - Depends on @byokit/link 0.3.2.
20
+ - K8 fake coverage (`./testing`): `fake.world` is the live mutable world the server answers from
21
+ (seeded ids via `world.seed`, seeded plugins via `world.plugins`); `workspace.rename`, `tab.rename`,
22
+ `pane.rename` and `agent.rename` (refusing bad names with `invalid_agent_name` and taken ones with
23
+ `agent_name_taken`); `plugin.list`; the boot-window `agent.start` reply both shapes read
24
+ (`{ type: 'agent_started', agent, argv }`); filtered emit with `watching()`, `snapshotCount()` and the
25
+ `holdSnapshot`/`failNextSnapshot`/`holdAck`/`failNextAck` hooks; real terminal frames
26
+ (`{ type: 'terminal.frame', full, bytes }` plus the `data` alias) with `terminal.resize`/`terminal.input`;
27
+ scroll state on `pane.get` with `pane.scroll`; and `api schema` printing the pinned v0.9.1 snapshot.
28
+
29
+ ## 0.1.0 (2026-09-29)
5
30
  - Opt-in pinned Herdr fetch (G11, `./binary`): `ensureHerdr({ dir, platform? })` (also spelled
6
31
  `fetchHerdr`) downloads the pinned v0.9.1 asset for the current platform into an app-owned `dir`
7
32
  only when called, verifies it against a committed per-platform sha256 table (linux x64/arm64,
8
33
  darwin x64/arm64 — each hash recomputed over the official release asset), writes atomically, marks
9
34
  executable, and returns the absolute `own`-mode `bin`. Never PATH, never `~/.local/bin`, nothing on
10
35
  install or import; a hash mismatch (nothing left behind) or unsupported platform fails in plain words.
11
-
12
36
  - K11 protocol range: `HerdrKitOptions` takes `protocolRange?: { min?: number; max?: number }`
13
37
  (each bound defaults to the pinned `HERDR_PROTOCOL`). A server below the floor still fails
14
38
  closed; a newer server is the steady state `needs-update`/`version` instead of a throw — the
15
39
  snapshot installs and the kit stays usable — so a Herdr protocol bump does not take the host
16
40
  down before the kit's pin moves.
17
- - `herdrContract(make, { test })` accepts the runner's `test` (node:test's by default), so a vitest host suite can run the contract against its lab Herdr.
18
-
19
- ## 0.1.0 (2026-09-29)
20
41
  - Typed device pass-through (G4, docs/runtime-kits.md 7.1/7.2): `herdrDevice(link).call<M>(method, params)` returns
21
42
  `HerdrResult<M>` from the generated table, `events()` yields `HerdrLinkEvent` frames, and `./device` re-exports the
22
43
  table types. New link ops `hd.kinds` (*view*, `agentKinds()`), `hd.wait` (control, pane in scope) and `hd.subscribe`
@@ -30,6 +51,7 @@
30
51
  titles and labels merge in place with no re-bootstrap; a per-pane lifecycle epoch keeps a status
31
52
  push that races a snapshot read (the live status and revision win over the read). The fake seeds
32
53
  and serves every field (agent `title`, workspace `number`/`tokens`, `workspace.report_metadata`).
54
+
33
55
  - `words`, `stateWords`, `agentWords` and `WORDS` are exported from `.` and `./device`, so a host or phone UI
34
56
  shows the kit's own sentences for Herdr and agent states instead of writing its own.
35
57
  - FIX `herdrDevice(link).events()` ends its link stream when the reader stops (`break`, or `return()` from a
package/README.md CHANGED
@@ -25,10 +25,18 @@ Herdr.</sub></p>
25
25
  </p>
26
26
  <p align="center"><sub>The same example's host terminal (<code>host.ts</code>), started with <code>BYOKIT_EXAMPLE_FAKE=1</code> so it runs the kit's stand-in Herdr.</sub></p>
27
27
 
28
+ ## Install
29
+
30
+ ```sh
31
+ npm install @byokit/herdr
32
+ ```
33
+
34
+ [![npm](https://img.shields.io/npm/v/@byokit/herdr?style=flat&label=)](https://www.npmjs.com/package/@byokit/herdr) · [Latest release](https://github.com/umeranjum17/byokit/releases?q=herdr-v) · [All releases](https://github.com/umeranjum17/byokit/releases)
35
+
28
36
  ## Quickstart
29
37
 
30
- First release 0.1.0; use it from this repo, or from its packed packages as
31
- [`examples/herdr-kit`](../../examples/herdr-kit) does. From npm once it is published:
38
+ Available on npm; it is also used from this repo, or from its packed packages as
39
+ [`examples/herdr-kit`](../../examples/herdr-kit) does:
32
40
 
33
41
  ```sh
34
42
  npm install @byokit/herdr
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { HERDR_VERSION, HERDR_PROTOCOL } from './constants.ts';
2
2
  export { HerdrKit } from './kit.ts';
3
3
  export { agentWords, stateWords, words, WORDS, type WordKey } from './words.ts';
4
- export type { AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrMethods, HerdrParams, HerdrProtocolRange, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrTransport, PromptReceipt, StartAgent, TerminalSession, } from './types.ts';
4
+ export type { AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrMethods, HerdrParams, HerdrProtocolRange, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, HerdrTransport, PromptReceipt, StartAgent, TerminalSession, } from './types.ts';
package/dist/kit.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrParams, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, PromptReceipt, StartAgent, TerminalSession } from './types.ts';
1
+ import type { AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrParams, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, PromptReceipt, StartAgent, TerminalSession } from './types.ts';
2
2
  export declare class HerdrKit {
3
3
  private readonly supervisor;
4
4
  private transport?;
@@ -20,8 +20,10 @@ export declare class HerdrKit {
20
20
  get state(): HerdrState;
21
21
  private publish;
22
22
  private emitRaw;
23
+ private watchError;
23
24
  private update;
24
25
  private bootstrap;
26
+ private catchUp;
25
27
  /**
26
28
  * Connect and bootstrap. Non-fatal: a rejected `start()` (e.g. `failed/socket` while Herdr is
27
29
  * down) leaves the kit stopped, and `start()` may be called again afterwards — the host comes up
@@ -32,7 +34,7 @@ export declare class HerdrKit {
32
34
  call<M extends HerdrMethod>(method: M, params: HerdrParams<M>, o?: {
33
35
  timeoutMs?: number;
34
36
  }): Promise<HerdrResult<M>>;
35
- subscribe<E extends HerdrEventName>(subs: HerdrSubscription<E>[], on: (e: HerdrEventOf<E>) => void, onError?: (code: string, message: string) => void): () => void;
37
+ subscribe<E extends HerdrEventName>(subs: HerdrSubscription<E>[], on: (e: HerdrEventOf<E>) => void, onError?: (code: string, message: string) => void): HerdrSubscribeStop;
36
38
  cli(args: string[], o?: {
37
39
  timeoutMs?: number;
38
40
  }): Promise<{
package/dist/kit.js CHANGED
@@ -139,6 +139,11 @@ export class HerdrKit {
139
139
  fn(this.snapshot()); }
140
140
  emitRaw(e) { for (const fn of [...this.rawListeners])
141
141
  fn(e); }
142
+ // The kit's own watches report rejections here so the host keeps its invalid_subscription
143
+ // logging without tapping the transport.
144
+ watchError(where) {
145
+ return (code, message) => this.o.onLog?.(`herdr: ${where} subscription rejected: ${code}: ${message}`);
146
+ }
142
147
  update(e) {
143
148
  const raw = e;
144
149
  // The pinned schema spells subscription kinds with dots (`pane.created`) while live
@@ -158,7 +163,9 @@ export class HerdrKit {
158
163
  }
159
164
  if (pane !== undefined && owner !== undefined &&
160
165
  (name === 'pane.agent.status.changed' || name === 'pane.agent.detected' || name === 'pane.updated')) {
161
- const a = raw.agent ?? raw.pane ?? raw;
166
+ // `pane.updated` nests its record under `pane`; the status push is flat and its `agent` is
167
+ // the kind string (PaneAgentStatusChangedEvent), never the record to merge.
168
+ const a = typeof raw.pane === 'object' && raw.pane !== null ? raw.pane : raw;
162
169
  mergePane(pane, a);
163
170
  pane.agent = mergeAgent(pane.agent, a);
164
171
  // Every asserted status stamps the pane's lifecycle epoch — even an unchanged one, since a
@@ -280,18 +287,37 @@ export class HerdrKit {
280
287
  for (const pane of this.tree.workspaces.flatMap((w) => w.tabs).flatMap((t) => t.panes)) {
281
288
  if (!pane.agent)
282
289
  continue;
283
- const stop = this.transport.subscribe([{ type: 'pane.agent_status_changed', pane_id: pane.id }], (e) => { this.emitRaw(e); this.update(e); }, () => { });
290
+ const stop = this.transport.subscribe([{ type: 'pane.agent_status_changed', pane_id: pane.id }], (e) => { this.emitRaw(e); this.update(e); }, this.watchError('status'));
284
291
  this.stops.add(stop);
285
292
  this.statusStops.add(stop);
286
293
  const ready = stop.ready;
287
294
  if (ready)
288
- statusAcks.push(ready);
295
+ statusAcks.push(ready.then((ok) => (ok === false ? undefined : this.catchUp(token, pane.id))));
289
296
  }
290
297
  void Promise.allSettled(statusAcks).then(() => { if (token === this.generation)
291
298
  resolveStatus(); });
292
299
  this.current = newer ? { phase: 'needs-update', why: 'version' } : { phase: 'ready' };
293
300
  this.o.onState?.(this.current);
294
301
  }
302
+ // A status change between the snapshot read and a watch's ack reached no socket: once the watch
303
+ // is live, re-read that agent. A push landing meanwhile is newer and wins (epoch guard).
304
+ async catchUp(token, paneId) {
305
+ const epoch = this.statusEpoch.get(paneId);
306
+ let made;
307
+ try {
308
+ made = await this.transport?.call('agent.get', { target: paneId });
309
+ }
310
+ catch {
311
+ return;
312
+ }
313
+ const agent = made?.agent;
314
+ if (token !== this.generation || this.statusEpoch.get(paneId) !== epoch ||
315
+ typeof agent?.agent_status !== 'string')
316
+ return;
317
+ // Status and revision only: the read's other fields may be staler than a merged pane.updated.
318
+ this.update({ type: 'pane_agent_status_changed', pane_id: paneId, agent_status: agent.agent_status,
319
+ ...(typeof agent.revision === 'number' ? { revision: agent.revision } : {}) });
320
+ }
295
321
  /**
296
322
  * Connect and bootstrap. Non-fatal: a rejected `start()` (e.g. `failed/socket` while Herdr is
297
323
  * down) leaves the kit stopped, and `start()` may be called again afterwards — the host comes up
@@ -323,7 +349,7 @@ export class HerdrKit {
323
349
  }
324
350
  else
325
351
  this.update(e);
326
- }, () => { });
352
+ }, this.watchError('events'));
327
353
  this.stops.add(stop);
328
354
  try {
329
355
  const control = stop;
@@ -31,6 +31,14 @@ export function herdrContract(make, options) {
31
31
  await made.fake?.stop().catch(() => { });
32
32
  }
33
33
  }
34
+ // Transport doubles that never exercise the control surface still satisfy its type.
35
+ function ackedStop() {
36
+ return Object.assign(() => { }, {
37
+ ready: Promise.resolve(true),
38
+ onReconnect() { },
39
+ onDisconnect() { },
40
+ });
41
+ }
34
42
  function agentStatusOf(snapshot, paneId) {
35
43
  let panes = 0;
36
44
  let status;
@@ -70,7 +78,7 @@ export function herdrContract(make, options) {
70
78
  };
71
79
  const kit = withTransport({
72
80
  call: async (method) => (method === 'session.snapshot' ? snapshot : { protocol: HERDR_PROTOCOL + 999 }),
73
- subscribe: () => () => { },
81
+ subscribe: () => ackedStop(),
74
82
  close: () => { },
75
83
  });
76
84
  try {
@@ -92,7 +100,7 @@ export function herdrContract(make, options) {
92
100
  return t.skip('make provides no transport double');
93
101
  const kit = withTransport({
94
102
  call: async () => ({ protocol: HERDR_PROTOCOL - 1 }),
95
- subscribe: () => () => { },
103
+ subscribe: () => ackedStop(),
96
104
  close: () => { },
97
105
  });
98
106
  try {
@@ -120,7 +128,7 @@ export function herdrContract(make, options) {
120
128
  };
121
129
  const transport = {
122
130
  call: async (method) => (method === 'session.snapshot' ? snapshot : { protocol: HERDR_PROTOCOL + 1 }),
123
- subscribe: () => () => { },
131
+ subscribe: () => ackedStop(),
124
132
  close: () => { },
125
133
  };
126
134
  const inRange = withTransport(transport);
@@ -162,7 +170,7 @@ export function herdrContract(make, options) {
162
170
  call: async (method) => (method === 'session.snapshot' ? snapshot : { protocol: HERDR_PROTOCOL }),
163
171
  subscribe: (_subs, on) => {
164
172
  queueMicrotask(() => on({ type: 'pane_updated', pane: { pane_id: 'w1:p2', agent_status: 'working' } }));
165
- return () => { };
173
+ return ackedStop();
166
174
  },
167
175
  close: () => { },
168
176
  });
@@ -190,7 +198,7 @@ export function herdrContract(make, options) {
190
198
  if (subs.some((s) => s.type === 'pane.agent_status_changed')) {
191
199
  onError('invalid_subscription', 'pane.agent_status_changed needs a pane_id');
192
200
  }
193
- return () => { };
201
+ return ackedStop();
194
202
  },
195
203
  close: () => { },
196
204
  });
@@ -200,9 +208,17 @@ export function herdrContract(make, options) {
200
208
  // Deliberately schema-invalid: the pinned schema (6.7) requires pane_id for this filtered
201
209
  // kind, so the server rejects the batch — the exact answer the transport double gives here.
202
210
  const rejected = { type: 'pane.agent_status_changed' };
203
- kit.subscribe([rejected], () => { });
211
+ const errors = [];
212
+ const stop = kit.subscribe([rejected], () => { }, (code, message) => errors.push({ code, message }));
213
+ assert.equal(typeof stop, 'function');
214
+ assert.ok(stop.ready instanceof Promise, 'the stop carries the ack promise');
215
+ assert.equal(typeof stop.onReconnect, 'function');
216
+ assert.equal(typeof stop.onDisconnect, 'function');
204
217
  await settle(1200); // longer than the 1 s reconnect delay a retry would use
218
+ assert.deepEqual(errors.map((e) => e.code), ['invalid_subscription'], 'the rejection reaches onError exactly once');
219
+ assert.match(errors[0].message, /pane_id/);
205
220
  assert.equal(subscribeCalls, bootstrapCalls + 1, 'the rejected subscription is never retried');
221
+ stop();
206
222
  }
207
223
  finally {
208
224
  await kit.stop().catch(() => { });
@@ -311,7 +327,7 @@ export function herdrContract(make, options) {
311
327
  }
312
328
  return { protocol: HERDR_PROTOCOL };
313
329
  },
314
- subscribe: () => () => { },
330
+ subscribe: () => ackedStop(),
315
331
  close: () => { },
316
332
  });
317
333
  try {
@@ -2,9 +2,9 @@
2
2
  // `api schema`, the `server` verb, JSON CLI verbs and `terminal session`. World state lives on the
3
3
  // control socket; this process never speaks JSON-RPC except to forward a CLI verb. Ported from muxr
4
4
  // `perf/fake-herdr/bin.mjs` with the perf byte rates and graphics probes removed.
5
- import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
5
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { createConnection } from 'node:net';
7
- import { dirname, resolve } from 'node:path';
7
+ import { dirname, join, resolve } from 'node:path';
8
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
  import { HERDR_PROTOCOL, HERDR_VERSION } from "../../constants.js";
10
10
  const SELF = fileURLToPath(import.meta.url);
@@ -82,13 +82,20 @@ async function runServer() {
82
82
  process.on('SIGTERM', stop);
83
83
  process.on('SIGINT', stop);
84
84
  }
85
- // `{"type":"fake.stream","count":n,"size":b,"progress":file}` on stdin: n frames with data `<i>:` plus b bytes,
86
- // written as fast as the pipe takes them. After each write the frames-written count lands in `progress`, so a
87
- // test can see how far a paused reader let the stream run.
85
+ // Terminal frames carry the real Herdr shape — `{ type: 'terminal.frame', pane_id, full, bytes }`
86
+ // with `bytes` the UTF-8 length of `full` — plus the fake's `data` alias (same string as `full`),
87
+ // so older assertions reading `data` keep passing. A `{"type":"terminal.resize",cols,rows}` line
88
+ // resizes the session and answers one empty frame stamped with the new size; `terminal.release`
89
+ // exits; `{"type":"fake.stream","count":n,"size":b,"progress":file}` streams n frames with full
90
+ // `<i>:` plus b bytes as fast as the pipe takes them, recording the frames-written count in
91
+ // `progress` after each write so a test can see how far a paused reader let the stream run.
92
+ function frame(paneId, full, extra = {}) {
93
+ return `${JSON.stringify({ type: 'terminal.frame', pane_id: paneId, data: full, full, bytes: Buffer.byteLength(full), ...extra })}\n`;
94
+ }
88
95
  async function stream(paneId, o) {
89
96
  const pad = 'x'.repeat(o.size ?? 1024);
90
97
  for (let i = 0; i < (o.count ?? 0); i += 1) {
91
- const ok = process.stdout.write(`${JSON.stringify({ type: 'terminal.frame', pane_id: paneId, data: `${i}:${pad}` })}\n`);
98
+ const ok = process.stdout.write(frame(paneId, `${i}:${pad}`));
92
99
  if (o.progress)
93
100
  writeFileSync(o.progress, String(i + 1));
94
101
  if (!ok)
@@ -98,12 +105,12 @@ async function stream(paneId, o) {
98
105
  function runTerminal(args) {
99
106
  // `herdr terminal session <control|observe> <pane> [--takeover] --cols <n> --rows <n>`.
100
107
  // The fake needs no server for this: one ready frame, then every send line echoes back as an
101
- // output frame (docs/runtime-kits.md 6.8), except `fake.stream` below.
108
+ // output frame (docs/runtime-kits.md 6.8), except `fake.stream` and `terminal.resize` above.
102
109
  const mode = args[0] === 'observe' ? 'observe' : 'control';
103
110
  const rest = args[0] === 'control' || args[0] === 'observe' ? args.slice(1) : args;
104
111
  const paneId = rest[0] ?? 'w1:p1';
105
- const cols = Number(flag(args, '--cols')) || 80;
106
- const rows = Number(flag(args, '--rows')) || 24;
112
+ let cols = Number(flag(args, '--cols')) || 80;
113
+ let rows = Number(flag(args, '--rows')) || 24;
107
114
  const takeover = args.includes('--takeover');
108
115
  process.stdout.write(`${JSON.stringify({ type: 'terminal.ready', pane_id: paneId, mode, takeover, cols, rows })}\n`);
109
116
  let buffer = '';
@@ -116,6 +123,7 @@ function runTerminal(args) {
116
123
  if (line.trim() === '')
117
124
  continue;
118
125
  let data = line;
126
+ let extra = {};
119
127
  try {
120
128
  const message = JSON.parse(line);
121
129
  if (message.type === 'terminal.release')
@@ -124,11 +132,20 @@ function runTerminal(args) {
124
132
  void stream(paneId, message);
125
133
  continue;
126
134
  }
135
+ if (message.type === 'terminal.resize') {
136
+ if (typeof message.cols === 'number' && message.cols > 0)
137
+ cols = Math.floor(message.cols);
138
+ if (typeof message.rows === 'number' && message.rows > 0)
139
+ rows = Math.floor(message.rows);
140
+ process.stdout.write(frame(paneId, '', { cols, rows }));
141
+ continue;
142
+ }
143
+ // `terminal.input` carries the keys in `data`; anything else with a string `data` echoes it.
127
144
  if (typeof message.data === 'string')
128
145
  data = message.data;
129
146
  }
130
147
  catch { /* a raw line echoes as-is */ }
131
- process.stdout.write(`${JSON.stringify({ type: 'terminal.frame', pane_id: paneId, data })}\n`);
148
+ process.stdout.write(frame(paneId, data, { cols, rows }));
132
149
  }
133
150
  });
134
151
  process.stdin.on('end', () => process.exit(0));
@@ -186,8 +203,20 @@ export async function main(argv) {
186
203
  return;
187
204
  }
188
205
  if (cmd === 'api' && sub === 'schema') {
189
- // Reports the pinned v0.9.1 snapshot identity (schema/herdr-api-0.9.1.json, docs/runtime-kits.md 6.7).
190
- process.stdout.write(`${JSON.stringify({ title: 'herdr api schema', protocol: HERDR_PROTOCOL, version: HERDR_VERSION })}\n`);
206
+ // Prints the pinned v0.9.1 snapshot itself (schema/herdr-api-0.9.1.json, docs/runtime-kits.md
207
+ // 6.7) — the same bytes `scripts/gen-types.ts` generates the typed surface from — exactly as
208
+ // the real `herdr api schema` prints its bundled schema. Only when the snapshot file is
209
+ // unreachable (a packed install without it) does the shim fall back to the pinned identity.
210
+ try {
211
+ const snapshot = readFileSync(join(dirname(SELF), '../../../schema/herdr-api-0.9.1.json'), 'utf8');
212
+ const parsed = JSON.parse(snapshot);
213
+ if (parsed.protocol !== HERDR_PROTOCOL)
214
+ throw new Error('fake-herdr bin: schema snapshot drift');
215
+ process.stdout.write(snapshot.endsWith('\n') ? snapshot : `${snapshot}\n`);
216
+ }
217
+ catch {
218
+ process.stdout.write(`${JSON.stringify({ protocol: HERDR_PROTOCOL, version: HERDR_VERSION })}\n`);
219
+ }
191
220
  return;
192
221
  }
193
222
  if (cmd === 'server') {
@@ -1,10 +1,12 @@
1
- import { type FakeWorld } from './world.ts';
1
+ import { type FakePluginSeed, type FakeWorld } from './world.ts';
2
2
  export type FakeHerdrOptions = {
3
3
  dir: string;
4
4
  socketPath?: string;
5
5
  world?: {
6
6
  cwd?: string;
7
7
  kinds?: readonly string[];
8
+ seed?: number;
9
+ plugins?: readonly FakePluginSeed[];
8
10
  };
9
11
  agentStartFaults?: AgentStartFault[];
10
12
  onStop?: () => void;
@@ -22,6 +24,13 @@ export type FakeHerdr = {
22
24
  type: string;
23
25
  } & Record<string, unknown>): void;
24
26
  setStatus(paneId: string, status: string): void;
27
+ watching(): string[];
28
+ watching(paneId: string): boolean;
29
+ snapshotCount(): number;
30
+ holdSnapshot(): () => void;
31
+ failNextSnapshot(code?: string, message?: string): void;
32
+ holdAck(): () => void;
33
+ failNextAck(code?: string, message?: string): void;
25
34
  subscriptionCount(): number;
26
35
  stop(): Promise<void>;
27
36
  };
@@ -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,10 +26,11 @@ 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
  });
28
36
  // Queued `agent.start` failures (K7): each start shifts one and fails with its code, so a test
@@ -31,9 +39,25 @@ export async function startFakeHerdr(options) {
31
39
  const agentStartFaults = [...(options.agentStartFaults ?? [])];
32
40
  const sockets = new Set();
33
41
  const eventSubs = new Set();
34
- const statusSubs = new Set();
42
+ const filteredSubs = new Set();
35
43
  const timers = [];
36
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
+ };
37
61
  const server = createServer((socket) => {
38
62
  sockets.add(socket);
39
63
  let buffer = '';
@@ -59,9 +83,9 @@ export async function startFakeHerdr(options) {
59
83
  for (const sub of eventSubs)
60
84
  if (sub.socket === socket)
61
85
  eventSubs.delete(sub);
62
- for (const sub of statusSubs)
86
+ for (const sub of filteredSubs)
63
87
  if (sub.socket === socket)
64
- statusSubs.delete(sub);
88
+ filteredSubs.delete(sub);
65
89
  });
66
90
  socket.on('error', () => { });
67
91
  });
@@ -70,7 +94,18 @@ export async function startFakeHerdr(options) {
70
94
  const bin = writeBinShim({ dir, socketPath });
71
95
  const methods = {
72
96
  ping: () => ({ protocol: HERDR_PROTOCOL, version: HERDR_VERSION }),
73
- '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
+ },
74
109
  'workspace.list': () => ({ workspaces: live.workspaces.map((w) => workspaceView(w)) }),
75
110
  'workspace.get': (p) => {
76
111
  const workspace = live.workspaces.find((row) => row.workspace_id === p.workspace_id);
@@ -119,6 +154,16 @@ export async function startFakeHerdr(options) {
119
154
  }
120
155
  return {};
121
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
+ },
122
167
  'worktree.create': (p) => {
123
168
  const checkout = typeof p.cwd === 'string' && p.cwd.length > 0 ? p.cwd : join(live.cwd, `wt-${live.nextWorkspace}`);
124
169
  const workspace = addWorkspace(checkout);
@@ -180,6 +225,16 @@ export async function startFakeHerdr(options) {
180
225
  focusPane(pane.pane_id);
181
226
  return {};
182
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
+ },
183
238
  'pane.get': (p) => ({ pane: paneView(paneOf(p.pane_id)) }),
184
239
  'pane.close': (p) => {
185
240
  const pane = paneOf(p.pane_id);
@@ -223,6 +278,31 @@ export async function startFakeHerdr(options) {
223
278
  }
224
279
  return {};
225
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
+ },
226
306
  'pane.zoom': (p) => {
227
307
  paneOf(p.pane_id);
228
308
  const mode = p.mode ?? 'toggle';
@@ -245,15 +325,37 @@ export async function startFakeHerdr(options) {
245
325
  pane.terminal_title_stripped = pane.terminal_title;
246
326
  const agent = agentRecord({
247
327
  paneId: pane.pane_id, tabId: pane.tab_id, workspaceId: pane.workspace_id, cwd: pane.cwd,
248
- name, kind, revision: ++live.nextRevision,
328
+ name, kind, revision: ++live.nextRevision, focused: pane.focused,
249
329
  });
250
330
  const index = live.agents.findIndex((row) => row.pane_id === pane.pane_id);
251
331
  if (index === -1)
252
332
  live.agents.push(agent);
253
333
  else
254
334
  live.agents[index] = agent;
335
+ pane.revision = agent.revision;
255
336
  emitEvent('pane.agent_detected', { pane_id: pane.pane_id, workspace_id: pane.workspace_id, agent: kind });
256
- return { agent };
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 } };
257
359
  },
258
360
  'agent.prompt': (p) => {
259
361
  const pane = paneOf(p.target);
@@ -329,6 +431,13 @@ export async function startFakeHerdr(options) {
329
431
  return { agent };
330
432
  },
331
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
+ },
332
441
  'server.stop': () => {
333
442
  timers.push(setTimeout(() => { void shutdown(); }, 0));
334
443
  return {};
@@ -341,11 +450,17 @@ export async function startFakeHerdr(options) {
341
450
  for (const timer of timers)
342
451
  clearTimeout(timer);
343
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 = [];
344
459
  for (const socket of sockets)
345
460
  socket.destroy();
346
461
  sockets.clear();
347
462
  eventSubs.clear();
348
- statusSubs.clear();
463
+ filteredSubs.clear();
349
464
  await new Promise((resolve) => server.close(() => resolve()));
350
465
  unlinkQuiet(socketPath);
351
466
  options.onStop?.();
@@ -390,29 +505,55 @@ export async function startFakeHerdr(options) {
390
505
  const wireName = (type) => type.replace(/\./g, '_');
391
506
  function subscribe(socket, id, params) {
392
507
  const subs = Array.isArray(params.subscriptions) ? params.subscriptions : [];
393
- const invalid = (message) => {
508
+ const ackFailure = nextAckFailure;
509
+ nextAckFailure = undefined;
510
+ const invalid = (message, code = 'invalid_subscription') => {
394
511
  // A rejected subscribe answers `id: ""` once and the server closes; the client never retries it.
395
- writeJson(socket, { id: '', error: { code: 'invalid_subscription', message } });
512
+ writeJson(socket, { id: '', error: { code, message } });
396
513
  socket.end();
397
514
  };
515
+ if (ackFailure !== undefined) {
516
+ invalid(ackFailure.message, ackFailure.code);
517
+ return;
518
+ }
398
519
  for (const s of subs) {
399
520
  if (typeof s?.type !== 'string')
400
521
  return invalid('every subscription needs a type');
401
- if (wireName(s.type) === 'pane_agent_status_changed' && typeof s.pane_id !== 'string') {
402
- 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');
403
529
  }
404
530
  }
405
- const filtered = subs.filter((s) => typeof s.type === 'string' && wireName(s.type) === 'pane_agent_status_changed');
406
- if (filtered.length > 0 && filtered.length !== subs.length) {
407
- return invalid('pane.agent_status_changed cannot share a batch');
408
- }
409
- writeJson(socket, { id, result: { type: 'subscribed' } });
531
+ const filtered = subs.filter((s) => typeof s.type === 'string' && FILTERED.has(wireName(s.type)));
410
532
  if (filtered.length > 0) {
411
- for (const s of filtered)
412
- 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();
413
551
  return;
414
552
  }
415
- eventSubs.add({ socket, kinds: new Set(subs.map((s) => wireName(s.type))) });
553
+ void Promise.all(held.map((h) => h.promise)).then(() => {
554
+ if (!socket.destroyed)
555
+ answer();
556
+ });
416
557
  }
417
558
  // Payloads follow the pinned schema's event data (src/generated/events.ts).
418
559
  function emitEvent(type, data) {
@@ -428,12 +569,28 @@ export async function startFakeHerdr(options) {
428
569
  }
429
570
  }
430
571
  function emitStatus(paneId, agentStatus, revision) {
572
+ const agent = live.agents.find((row) => row.pane_id === paneId);
431
573
  const frame = `${JSON.stringify({
432
574
  event: 'pane_agent_status_changed',
433
- data: { type: 'pane_agent_status_changed', pane_id: paneId, agent_status: agentStatus, revision },
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 },
434
579
  })}\n`;
435
- for (const sub of statusSubs) {
436
- 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)
437
594
  continue;
438
595
  try {
439
596
  sub.socket.write(frame);
@@ -463,6 +620,7 @@ export async function startFakeHerdr(options) {
463
620
  if (agent !== undefined) {
464
621
  agent.agent_status = status;
465
622
  agent.revision = ++live.nextRevision;
623
+ pane.revision = agent.revision;
466
624
  // A blocked agent always has detection text (6.4 reads it as the question), whichever path
467
625
  // got it there — `agent.prompt` sets it first, `setStatus` arrives with none.
468
626
  if (status === 'blocked' && agent.detection === undefined)
@@ -495,10 +653,13 @@ export async function startFakeHerdr(options) {
495
653
  }
496
654
  function snapshotOf() {
497
655
  return {
656
+ protocol: HERDR_PROTOCOL,
657
+ version: HERDR_VERSION,
498
658
  workspaces: live.workspaces.map(workspaceView),
499
659
  tabs: live.tabs.map((tab) => ({ ...tab })),
500
660
  panes: live.panes.map(paneView),
501
661
  agents: live.agents.map((agent) => ({ ...agent })),
662
+ layouts: [],
502
663
  };
503
664
  }
504
665
  function addWorkspace(labelText) {
@@ -546,6 +707,8 @@ export async function startFakeHerdr(options) {
546
707
  tokens: {},
547
708
  rect: { x: 0, y: 0, width: 80, height: 24 },
548
709
  text: ['ready.'],
710
+ revision: 0,
711
+ scroll: paneScroll(),
549
712
  };
550
713
  live.panes.push(pane);
551
714
  relayoutTab(live, tabId);
@@ -616,11 +779,21 @@ export async function startFakeHerdr(options) {
616
779
  return {
617
780
  socketPath,
618
781
  bin,
619
- world: freezeWorld(live),
782
+ world: live,
620
783
  agentStartFaults,
621
784
  emit(event) {
622
- if (wireName(event.type) === 'pane_agent_status_changed' && typeof event.pane_id === 'string') {
623
- 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);
624
797
  return;
625
798
  }
626
799
  const { type, ...data } = event;
@@ -631,11 +804,32 @@ export async function startFakeHerdr(options) {
631
804
  if (pane !== undefined)
632
805
  transition(pane, status);
633
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
+ },
634
828
  subscriptionCount() {
635
829
  const held = new Set();
636
830
  for (const sub of eventSubs)
637
831
  held.add(sub.socket);
638
- for (const sub of statusSubs)
832
+ for (const sub of filteredSubs)
639
833
  held.add(sub.socket);
640
834
  return held.size;
641
835
  },
@@ -655,6 +849,9 @@ function paneView(pane) {
655
849
  terminal_title_stripped: pane.terminal_title_stripped,
656
850
  focused: pane.focused,
657
851
  ...(pane.env === undefined ? {} : { env: { ...pane.env } }),
852
+ terminal_id: `term-${pane.pane_id}`,
853
+ revision: pane.revision,
854
+ scroll: { ...pane.scroll },
658
855
  ...(Object.keys(pane.tokens).length === 0 ? {} : { tokens: pane.tokens }),
659
856
  };
660
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;
@@ -19,6 +24,8 @@ export type FakePane = {
19
24
  env?: Record<string, string>;
20
25
  rect: FakeRect;
21
26
  text: string[];
27
+ revision: number;
28
+ scroll: FakeScroll;
22
29
  };
23
30
  export type FakeAgent = {
24
31
  pane_id: string;
@@ -34,6 +41,8 @@ export type FakeAgent = {
34
41
  interactive_ready: boolean;
35
42
  launch_pending: boolean;
36
43
  revision: number;
44
+ terminal_id: string;
45
+ focused: boolean;
37
46
  detection?: string;
38
47
  agent_session: {
39
48
  source: string;
@@ -42,6 +51,20 @@ export type FakeAgent = {
42
51
  value: string;
43
52
  };
44
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
+ };
45
68
  export type FakeTab = {
46
69
  tab_id: string;
47
70
  workspace_id: string;
@@ -67,6 +90,7 @@ export type FakeWorld = {
67
90
  tabs: FakeTab[];
68
91
  panes: FakePane[];
69
92
  agents: FakeAgent[];
93
+ plugins: FakePlugin[];
70
94
  };
71
95
  export declare const COLS = 80;
72
96
  export declare const ROWS = 24;
@@ -79,10 +103,17 @@ export declare function agentRecord(o: {
79
103
  name: string;
80
104
  kind: string;
81
105
  revision: number;
106
+ focused?: boolean;
82
107
  }): FakeAgent;
108
+ export declare function pluginRecord(o: {
109
+ dir: string;
110
+ seed: FakePluginSeed;
111
+ }): FakePlugin;
112
+ export declare function paneScroll(): FakeScroll;
83
113
  export declare function createWorld(o: {
84
114
  cwd: string;
85
115
  kinds: readonly string[];
116
+ plugins?: readonly FakePluginSeed[];
86
117
  }): FakeWorld;
87
118
  export declare function relayoutTab(world: FakeWorld, tabId: string): void;
88
119
  export declare function freezeWorld(world: FakeWorld): FakeWorld;
@@ -29,9 +29,24 @@ export function agentRecord(o) {
29
29
  interactive_ready: true,
30
30
  launch_pending: false,
31
31
  revision: o.revision,
32
+ terminal_id: `term-${o.paneId}`,
33
+ focused: o.focused ?? false,
32
34
  agent_session: { source: `herdr:${o.kind}`, agent: o.kind, kind: 'id', value: `gen-${o.paneId}` },
33
35
  };
34
36
  }
37
+ export function pluginRecord(o) {
38
+ return {
39
+ plugin_id: o.seed.plugin_id,
40
+ name: o.seed.name ?? o.seed.plugin_id,
41
+ version: o.seed.version ?? '0.0.0',
42
+ manifest_path: `${o.dir}/plugins/${o.seed.plugin_id}/herdr.plugin.json`,
43
+ plugin_root: `${o.dir}/plugins/${o.seed.plugin_id}`,
44
+ enabled: o.seed.enabled ?? true,
45
+ };
46
+ }
47
+ export function paneScroll() {
48
+ return { offset_from_bottom: 0, max_offset_from_bottom: 0, viewport_rows: ROWS };
49
+ }
35
50
  export function createWorld(o) {
36
51
  const workspace = {
37
52
  workspace_id: 'w1',
@@ -61,6 +76,8 @@ export function createWorld(o) {
61
76
  tokens: {},
62
77
  rect: paneRect(0, 2),
63
78
  text: ['ready.'],
79
+ revision: 0,
80
+ scroll: paneScroll(),
64
81
  };
65
82
  const kind = o.kinds[0] ?? 'pi';
66
83
  const agentPane = {
@@ -72,12 +89,15 @@ export function createWorld(o) {
72
89
  label: kind,
73
90
  terminal_title: `${kind} · ${kind}`,
74
91
  terminal_title_stripped: `${kind} · ${kind}`,
92
+ revision: 0,
93
+ scroll: paneScroll(), // its own scroll: the spread would alias the shell's object
75
94
  };
76
95
  const agent = agentRecord({
77
96
  paneId: agentPane.pane_id, tabId: tab.tab_id, workspaceId: 'w1', cwd: o.cwd,
78
97
  name: kind, kind, revision: 0,
79
98
  });
80
- return { cwd: o.cwd, workspaces: [workspace], tabs: [tab], panes: [shell, agentPane], agents: [agent] };
99
+ const plugins = (o.plugins ?? []).map((seed) => pluginRecord({ dir: o.cwd, seed }));
100
+ return { cwd: o.cwd, workspaces: [workspace], tabs: [tab], panes: [shell, agentPane], agents: [agent], plugins };
81
101
  }
82
102
  export function relayoutTab(world, tabId) {
83
103
  const panes = world.panes.filter((pane) => pane.tab_id === tabId);
@@ -1,6 +1,15 @@
1
1
  // The kit's fake Herdr and contract suite (docs/runtime-kits.md 6.8). Built in H6; H3/H5 run the
2
2
  // contract against the fake, H9 against the real pinned Herdr. Tests import this by relative path —
3
3
  // the package's public exports stay the kit surface.
4
+ //
5
+ // The fake's documented test handle is `FakeHerdr`: `fake.world` is the LIVE world the server
6
+ // answers from (mutable, with seeded ids from `world.seed`), `fake.emit` routes frames to the
7
+ // sockets watching them (filtered kinds reach only their pane's watchers), `fake.watching()`
8
+ // lists the watched panes, `fake.snapshotCount()` counts `session.snapshot` calls, and the
9
+ // `holdSnapshot`/`failNextSnapshot`/`holdAck`/`failNextAck` hooks script slow or failing answers.
10
+ // The bin shim speaks real terminal frames (`{ type: 'terminal.frame', full, bytes }`, plus a
11
+ // `data` alias) with `terminal.resize`/`terminal.input` on stdin, and `api schema` prints the
12
+ // pinned v0.9.1 snapshot.
4
13
  export { startFakeHerdr } from "./fake-herdr/server.js";
5
14
  export { writeBinShim, main as fakeHerdrBinMain } from "./fake-herdr/bin.js";
6
15
  export { createWorld } from "./fake-herdr/world.js";
package/dist/types.d.ts CHANGED
@@ -15,6 +15,7 @@ export type HerdrKitOptions = {
15
15
  transport?: HerdrTransport;
16
16
  protocolRange?: HerdrProtocolRange;
17
17
  onState?: (s: HerdrState) => void;
18
+ onLog?: (message: string) => void;
18
19
  } | {
19
20
  mode: 'own';
20
21
  bin: string;
@@ -24,13 +25,19 @@ export type HerdrKitOptions = {
24
25
  transport?: HerdrTransport;
25
26
  protocolRange?: HerdrProtocolRange;
26
27
  onState?: (s: HerdrState) => void;
28
+ onLog?: (message: string) => void;
29
+ };
30
+ export type HerdrSubscribeStop = (() => void) & {
31
+ ready: Promise<boolean>;
32
+ onReconnect(fn: () => void): void;
33
+ onDisconnect(fn: () => void): void;
27
34
  };
28
35
  export interface HerdrTransport {
29
36
  call(method: string, params: Record<string, unknown>, timeoutMs?: number): Promise<unknown>;
30
37
  subscribe(subs: {
31
38
  type: string;
32
39
  [k: string]: unknown;
33
- }[], on: (e: HerdrEvent) => void, onError: (code: string, message: string) => void): () => void;
40
+ }[], on: (e: HerdrEvent) => void, onError: (code: string, message: string) => void): HerdrSubscribeStop;
34
41
  close(): void;
35
42
  }
36
43
  export type HerdrEvent = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/herdr",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Drive the Herdr on this computer — workspaces, tabs, panes, agents and blocked-approval answers — from an app, or hand it to a phone over @byokit/link.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -52,8 +52,8 @@
52
52
  "prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
53
53
  },
54
54
  "dependencies": {
55
- "@byokit/link": "0.3.1",
56
- "@byokit/relay": "0.1.3",
55
+ "@byokit/link": "0.3.2",
56
+ "@byokit/relay": "0.2.0",
57
57
  "@byokit/reach": "0.2.0",
58
58
  "@byokit/seal": "0.1.0",
59
59
  "ws": "8.21.3"