@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 +26 -4
- package/README.md +10 -2
- package/dist/index.d.ts +1 -1
- package/dist/kit.d.ts +4 -2
- package/dist/kit.js +30 -4
- package/dist/testing/contract.js +23 -7
- package/dist/testing/fake-herdr/bin.js +41 -12
- package/dist/testing/fake-herdr/server.d.ts +10 -1
- package/dist/testing/fake-herdr/server.js +228 -31
- package/dist/testing/fake-herdr/world.d.ts +31 -0
- package/dist/testing/fake-herdr/world.js +21 -1
- package/dist/testing/index.js +9 -0
- package/dist/types.d.ts +8 -1
- package/package.json +3 -3
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
|
+
[](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
|
-
|
|
31
|
-
[`examples/herdr-kit`](../../examples/herdr-kit) does
|
|
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):
|
|
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
|
-
|
|
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;
|
package/dist/testing/contract.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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(
|
|
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`
|
|
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
|
-
|
|
106
|
-
|
|
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(
|
|
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
|
-
//
|
|
190
|
-
|
|
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,
|
|
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
|
|
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
|
});
|
|
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
|
|
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
|
|
86
|
+
for (const sub of filteredSubs)
|
|
63
87
|
if (sub.socket === socket)
|
|
64
|
-
|
|
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': () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
402
|
-
|
|
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)
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
782
|
+
world: live,
|
|
620
783
|
agentStartFaults,
|
|
621
784
|
emit(event) {
|
|
622
|
-
|
|
623
|
-
|
|
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
|
|
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
|
-
|
|
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);
|
package/dist/testing/index.js
CHANGED
|
@@ -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):
|
|
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.
|
|
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.
|
|
56
|
-
"@byokit/relay": "0.
|
|
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"
|