@byokit/herdr 0.1.0-next.dbe7142

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +202 -0
  3. package/README.md +18 -0
  4. package/dist/agents.d.ts +7 -0
  5. package/dist/agents.js +141 -0
  6. package/dist/approvals.d.ts +24 -0
  7. package/dist/approvals.js +81 -0
  8. package/dist/cli.d.ts +10 -0
  9. package/dist/cli.js +89 -0
  10. package/dist/close.d.ts +4 -0
  11. package/dist/close.js +85 -0
  12. package/dist/constants.d.ts +2 -0
  13. package/dist/constants.js +5 -0
  14. package/dist/device.d.ts +40 -0
  15. package/dist/device.js +128 -0
  16. package/dist/generated/events.d.ts +371 -0
  17. package/dist/generated/events.js +3 -0
  18. package/dist/generated/methods.d.ts +2299 -0
  19. package/dist/generated/methods.js +3 -0
  20. package/dist/generated/report.json +50 -0
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.js +4 -0
  23. package/dist/kit.d.ts +73 -0
  24. package/dist/kit.js +209 -0
  25. package/dist/link.d.ts +24 -0
  26. package/dist/link.js +324 -0
  27. package/dist/notices.d.ts +14 -0
  28. package/dist/notices.js +88 -0
  29. package/dist/socket.d.ts +2 -0
  30. package/dist/socket.js +137 -0
  31. package/dist/supervise.d.ts +17 -0
  32. package/dist/supervise.js +137 -0
  33. package/dist/terminal.d.ts +6 -0
  34. package/dist/terminal.js +105 -0
  35. package/dist/testing/contract.d.ts +9 -0
  36. package/dist/testing/contract.js +308 -0
  37. package/dist/testing/fake-herdr/bin.d.ts +5 -0
  38. package/dist/testing/fake-herdr/bin.js +210 -0
  39. package/dist/testing/fake-herdr/server.d.ts +21 -0
  40. package/dist/testing/fake-herdr/server.js +604 -0
  41. package/dist/testing/fake-herdr/world.d.ts +84 -0
  42. package/dist/testing/fake-herdr/world.js +85 -0
  43. package/dist/testing/index.d.ts +4 -0
  44. package/dist/testing/index.js +7 -0
  45. package/dist/types.d.ts +115 -0
  46. package/dist/types.js +3 -0
  47. package/dist/words.d.ts +10 -0
  48. package/dist/words.js +18 -0
  49. package/dist/words.json +19 -0
  50. package/package.json +60 -0
@@ -0,0 +1,137 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { accessSync, closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { isAbsolute, join } from 'node:path';
4
+ import { HERDR_PROTOCOL } from "./constants.js";
5
+ import { socketTransport } from "./socket.js";
6
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7
+ export class Supervisor {
8
+ transport;
9
+ child;
10
+ stopping = false;
11
+ retry;
12
+ failures = 0;
13
+ root;
14
+ socketPath;
15
+ o;
16
+ onState;
17
+ constructor(o, onState) {
18
+ this.o = o;
19
+ this.onState = onState;
20
+ this.root = o.mode === 'own' ? join(o.stateDir, 'herdr') : undefined;
21
+ this.socketPath = o.mode === 'own' ? join(o.stateDir, 'herdr', 'herdr.sock') : o.socketPath;
22
+ }
23
+ env() {
24
+ if (this.o.mode === 'adopt') {
25
+ return { HERDR_SOCKET_PATH: this.socketPath, PATH: this.o.path?.join(':') || '/usr/bin:/bin', LANG: 'C.UTF-8', ...this.o.env };
26
+ }
27
+ const home = join(this.root, 'home');
28
+ return {
29
+ HOME: home, XDG_CONFIG_HOME: join(home, '.config'), XDG_STATE_HOME: join(home, '.local/state'),
30
+ XDG_CACHE_HOME: join(home, '.cache'), HERDR_SOCKET_PATH: this.socketPath,
31
+ PATH: this.o.path?.join(':') || '/usr/bin:/bin', LANG: 'C.UTF-8', ...this.o.env,
32
+ };
33
+ }
34
+ state(phase, why) { this.onState({ phase, ...(why ? { why } : {}) }); }
35
+ async start() {
36
+ this.stopping = false;
37
+ this.state('connecting');
38
+ if (this.o.mode === 'own') {
39
+ if (!isAbsolute(this.o.bin) || !existsSync(this.o.bin)) {
40
+ this.state('missing', 'binary');
41
+ throw new Error('herdr: executable binary required');
42
+ }
43
+ try {
44
+ if (!statSync(this.o.bin).isFile())
45
+ throw new Error();
46
+ accessSync(this.o.bin, constants.X_OK);
47
+ }
48
+ catch {
49
+ this.state('missing', 'binary');
50
+ throw new Error('herdr: binary not executable');
51
+ }
52
+ mkdirSync(join(this.root, 'home'), { recursive: true });
53
+ const pidfile = join(this.root, 'server.pid');
54
+ if (existsSync(pidfile)) {
55
+ const pid = Number(readFileSync(pidfile, 'utf8'));
56
+ try {
57
+ const cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
58
+ if (cmdline.includes(this.o.bin) && cmdline.includes('server'))
59
+ process.kill(-pid, 'SIGTERM');
60
+ }
61
+ catch { /* no matching owned process */ }
62
+ unlinkSync(pidfile);
63
+ }
64
+ const log = openSync(join(this.root, 'server.log'), 'a', 0o600);
65
+ this.child = spawn(this.o.bin, ['server'], { env: this.env(), detached: true, stdio: ['ignore', log, log] });
66
+ const child = this.child;
67
+ child.on('error', () => { if (!this.stopping)
68
+ this.state('failed', 'server-exited'); });
69
+ child.on('exit', () => {
70
+ if (this.stopping || this.child !== child)
71
+ return;
72
+ this.state('reconnecting', 'server-exited');
73
+ this.retry = setTimeout(() => { void this.start().catch(() => { }); }, Math.min(30_000, 1000 * 2 ** this.failures++));
74
+ });
75
+ if (child.pid)
76
+ writeFileSync(pidfile, String(child.pid), { mode: 0o600 });
77
+ closeSync(log);
78
+ }
79
+ const transport = this.transport ?? this.o.transport ?? socketTransport(this.socketPath);
80
+ this.transport = transport;
81
+ for (const ms of [0, 250, 500, 1000, 2000]) {
82
+ if (ms)
83
+ await delay(ms);
84
+ if (this.stopping)
85
+ throw new Error('herdr: stopped');
86
+ try {
87
+ const ping = await transport.call('ping', {}, 1000);
88
+ if (ping.protocol !== HERDR_PROTOCOL) {
89
+ this.state('needs-update', 'version');
90
+ throw new Error('herdr: protocol mismatch');
91
+ }
92
+ this.failures = 0;
93
+ return transport;
94
+ }
95
+ catch (error) {
96
+ if (error.message === 'herdr: protocol mismatch')
97
+ throw error;
98
+ }
99
+ }
100
+ this.state('failed', 'socket');
101
+ throw new Error('herdr: socket unavailable');
102
+ }
103
+ async stop() {
104
+ this.stopping = true;
105
+ clearTimeout(this.retry);
106
+ if (this.o.mode === 'own' && this.child?.pid) {
107
+ try {
108
+ await this.transport?.call('server.stop', {}, 1000);
109
+ }
110
+ catch { /* process may have exited */ }
111
+ const child = this.child;
112
+ const exited = new Promise((resolve) => { if (child.exitCode !== null)
113
+ resolve();
114
+ else
115
+ child.once('exit', () => resolve()); });
116
+ for (const signal of ['SIGTERM', 'SIGKILL']) {
117
+ if (child.exitCode !== null)
118
+ break;
119
+ if (signal === 'SIGTERM')
120
+ await Promise.race([exited, delay(3000)]);
121
+ if (child.exitCode !== null)
122
+ break;
123
+ try {
124
+ process.kill(-child.pid, signal);
125
+ }
126
+ catch { /* already gone */ }
127
+ }
128
+ await Promise.race([exited, delay(3000)]);
129
+ try {
130
+ unlinkSync(join(this.root, 'server.pid'));
131
+ }
132
+ catch { /* absent */ }
133
+ }
134
+ this.transport?.close();
135
+ this.state('stopped');
136
+ }
137
+ }
@@ -0,0 +1,6 @@
1
+ import type { TerminalSession } from './types.ts';
2
+ export declare function openTerminal(bin: string, env: Record<string, string>, paneId: string, o: {
3
+ mode: 'control' | 'observe';
4
+ cols: number;
5
+ rows: number;
6
+ }): TerminalSession;
@@ -0,0 +1,105 @@
1
+ // `herdr terminal session <control|observe> <paneId>`: Herdr's own NDJSON terminal frames passed through untouched
2
+ // (docs/runtime-kits.md 6.5). Frames arrive byte-identical (split on `\n` only, no trimming); `send` writes one line
3
+ // plus `\n`. `pause`/`resume` stop and restart reading stdout, so a slow consumer holds Herdr back instead of
4
+ // buffering in the host. The env comes from the caller per 6.5 — never `process.env`.
5
+ import { spawn } from 'node:child_process';
6
+ import { missingBinary } from "./cli.js";
7
+ const STDERR_TAIL_BYTES = 4 * 1024;
8
+ export function openTerminal(bin, env, paneId, o) {
9
+ if (typeof bin !== 'string' || bin.length === 0)
10
+ throw new Error('herdr: bin must be a non-empty string');
11
+ if (typeof paneId !== 'string' || paneId.length === 0 || paneId.includes('\0')) {
12
+ throw new Error('herdr: paneId must be a non-empty string without NUL');
13
+ }
14
+ const args = ['terminal', 'session', o.mode, paneId];
15
+ if (o.mode === 'control')
16
+ args.push('--takeover'); // takeover is a control-only flag (6.5)
17
+ args.push('--cols', String(o.cols), '--rows', String(o.rows));
18
+ const child = spawn(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
19
+ const frames = new Set();
20
+ let readyResolve;
21
+ let readyReject;
22
+ let firstFrame = false;
23
+ let stderrTail = Buffer.alloc(0);
24
+ let pending = Buffer.alloc(0);
25
+ let exitedSettled = false;
26
+ let paused = false;
27
+ const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
28
+ const exited = new Promise((resolve) => {
29
+ const settle = (code) => {
30
+ if (exitedSettled)
31
+ return;
32
+ exitedSettled = true;
33
+ resolve({ code, stderrTail: stderrTail.toString('utf8') });
34
+ };
35
+ child.on('error', (e) => {
36
+ settle(null);
37
+ readyReject?.(e.code === 'ENOENT' ? missingBinary(bin, 'no executable') : e);
38
+ });
39
+ child.on('close', (code) => settle(code));
40
+ });
41
+ child.on('close', () => {
42
+ if (!firstFrame)
43
+ readyReject?.(new Error(`herdr terminal exited before output: ${stderrTail.toString('utf8')}`));
44
+ });
45
+ // While paused, lines already read stay in `pending` and stdout stops reading, so the pipe fills and Herdr blocks.
46
+ // A resume() from inside a handler must not deliver the next frame before this one reaches every handler.
47
+ let delivering = false;
48
+ const deliver = () => {
49
+ if (delivering)
50
+ return;
51
+ delivering = true;
52
+ try {
53
+ for (let i = pending.indexOf(0x0a); i !== -1 && !paused; i = pending.indexOf(0x0a)) {
54
+ const line = pending.subarray(0, i).toString('utf8'); // bytes between newlines, unmodified
55
+ pending = pending.subarray(i + 1);
56
+ if (!firstFrame) {
57
+ firstFrame = true;
58
+ readyResolve?.();
59
+ }
60
+ for (const fn of frames)
61
+ fn(line);
62
+ }
63
+ }
64
+ finally {
65
+ delivering = false;
66
+ }
67
+ };
68
+ child.stdout.on('data', (chunk) => {
69
+ pending = Buffer.concat([pending, chunk]);
70
+ deliver();
71
+ });
72
+ child.stderr.on('data', (chunk) => {
73
+ stderrTail = stderrTail.length + chunk.length > STDERR_TAIL_BYTES
74
+ ? Buffer.concat([stderrTail, chunk]).subarray(-STDERR_TAIL_BYTES)
75
+ : Buffer.concat([stderrTail, chunk]);
76
+ });
77
+ child.stdin.on('error', () => { }); // a send after the child died must not crash the host
78
+ return {
79
+ ready,
80
+ onFrame(fn) {
81
+ frames.add(fn);
82
+ return () => frames.delete(fn);
83
+ },
84
+ send(line) {
85
+ if (child.stdin.writable)
86
+ child.stdin.write(line + '\n');
87
+ },
88
+ pause() {
89
+ paused = true;
90
+ child.stdout.pause();
91
+ },
92
+ resume() {
93
+ paused = false;
94
+ deliver();
95
+ if (!paused)
96
+ child.stdout.resume(); // a frame handler may have paused again
97
+ },
98
+ close() {
99
+ child.kill();
100
+ if (paused)
101
+ child.stdout.removeAllListeners('data').resume(); // drain unread output so `exited` settles
102
+ },
103
+ exited,
104
+ };
105
+ }
@@ -0,0 +1,9 @@
1
+ import type { HerdrKit } from '../kit.ts';
2
+ import type { FakeHerdr } from './fake-herdr/server.ts';
3
+ import type { HerdrTransport } from '../types.ts';
4
+ export type HerdrContractBench = {
5
+ kit: HerdrKit;
6
+ fake?: FakeHerdr;
7
+ withTransport?: (transport: HerdrTransport) => HerdrKit;
8
+ };
9
+ export declare function herdrContract(make: () => Promise<HerdrContractBench>): void;
@@ -0,0 +1,308 @@
1
+ // The contract suite (docs/runtime-kits.md 6.8): the same kit-level assertions run against the fake
2
+ // in later work packages' tests (H3/H5 wire `make` up) and against the real pinned Herdr in the lab
3
+ // (H9, which passes no `fake`). Cases that need to script the transport's wire answers use
4
+ // `withTransport`; when a `make` omits it, those cases skip.
5
+ import { test } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { HERDR_PROTOCOL } from "../constants.js";
8
+ export function herdrContract(make) {
9
+ const settle = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
+ async function until(probe, ok, ms = 2000) {
11
+ const deadline = Date.now() + ms;
12
+ for (;;) {
13
+ const value = probe();
14
+ if (value !== undefined && value !== null && ok(value))
15
+ return value;
16
+ if (Date.now() >= deadline) {
17
+ assert.fail(`condition not reached within ${ms}ms: ${JSON.stringify(value ?? null)}`);
18
+ }
19
+ await settle(10);
20
+ }
21
+ }
22
+ async function bench(run) {
23
+ const made = await make();
24
+ try {
25
+ await run(made);
26
+ }
27
+ finally {
28
+ await made.kit.stop().catch(() => { });
29
+ await made.fake?.stop().catch(() => { });
30
+ }
31
+ }
32
+ function agentStatusOf(snapshot, paneId) {
33
+ let panes = 0;
34
+ let status;
35
+ for (const workspace of snapshot.workspaces) {
36
+ for (const tab of workspace.tabs) {
37
+ for (const pane of tab.panes) {
38
+ panes += 1;
39
+ if (pane.id === paneId)
40
+ status = pane.agent?.status;
41
+ }
42
+ }
43
+ }
44
+ return { status, panes };
45
+ }
46
+ test('contract: start reaches ready and ping speaks the pinned protocol', async () => {
47
+ await bench(async ({ kit }) => {
48
+ await kit.start();
49
+ assert.equal(kit.state.phase, 'ready');
50
+ const ping = (await kit.call('ping', {}));
51
+ assert.equal(ping.protocol, HERDR_PROTOCOL);
52
+ assert.equal(typeof ping.version, 'string');
53
+ });
54
+ });
55
+ test('contract: a protocol mismatch reports needs-update', async (t) => {
56
+ await bench(async ({ withTransport }) => {
57
+ if (!withTransport)
58
+ return t.skip('make provides no transport double');
59
+ const kit = withTransport({
60
+ call: async () => ({ protocol: HERDR_PROTOCOL + 999 }),
61
+ subscribe: () => () => { },
62
+ close: () => { },
63
+ });
64
+ try {
65
+ await kit.start().catch(() => { });
66
+ assert.equal(kit.state.phase, 'needs-update');
67
+ }
68
+ finally {
69
+ await kit.stop().catch(() => { });
70
+ }
71
+ });
72
+ });
73
+ test('contract: an event racing the bootstrap snapshot is applied after it, once', async (t) => {
74
+ await bench(async ({ withTransport }) => {
75
+ if (!withTransport)
76
+ return t.skip('make provides no transport double');
77
+ const snapshot = {
78
+ snapshot: {
79
+ workspaces: [{ workspace_id: 'w1', label: 'x', focused: true, tab_count: 1, active_tab_id: 'w1:t1' }],
80
+ tabs: [{ tab_id: 'w1:t1', workspace_id: 'w1', label: 'main' }],
81
+ panes: [{ pane_id: 'w1:p2', tab_id: 'w1:t1', workspace_id: 'w1', focused: false, agent_status: 'idle' }],
82
+ agents: [{ pane_id: 'w1:p2', agent_status: 'idle', revision: 0 }],
83
+ },
84
+ };
85
+ const kit = withTransport({
86
+ // The ack carries the race: `pane.updated` lands after the subscribe but before the kit
87
+ // installs the snapshot, so buffering must order it after — and never duplicate it.
88
+ call: async (method) => (method === 'session.snapshot' ? snapshot : { protocol: HERDR_PROTOCOL }),
89
+ subscribe: (_subs, on) => {
90
+ queueMicrotask(() => on({ type: 'pane.updated', pane: { pane_id: 'w1:p2', agent_status: 'working' } }));
91
+ return () => { };
92
+ },
93
+ close: () => { },
94
+ });
95
+ try {
96
+ await kit.start();
97
+ assert.equal(kit.state.phase, 'ready');
98
+ assert.equal(kit.snapshot().workspaces.length, 1, 'the snapshot still installs');
99
+ await until(() => agentStatusOf(kit.snapshot(), 'w1:p2').status, (s) => s === 'working');
100
+ assert.equal(agentStatusOf(kit.snapshot(), 'w1:p2').panes, 1, 'no duplicate pane from the race');
101
+ }
102
+ finally {
103
+ await kit.stop().catch(() => { });
104
+ }
105
+ });
106
+ });
107
+ test('contract: a rejected subscription surfaces once and is not retried', async (t) => {
108
+ await bench(async ({ withTransport }) => {
109
+ if (!withTransport)
110
+ return t.skip('make provides no transport double');
111
+ let subscribeCalls = 0;
112
+ const kit = withTransport({
113
+ call: async () => ({ protocol: HERDR_PROTOCOL }),
114
+ subscribe: (subs, _on, onError) => {
115
+ subscribeCalls += 1;
116
+ if (subs.some((s) => s.type === 'pane.agent_status_changed')) {
117
+ onError('invalid_subscription', 'pane.agent_status_changed needs a pane_id');
118
+ }
119
+ return () => { };
120
+ },
121
+ close: () => { },
122
+ });
123
+ try {
124
+ await kit.start().catch(() => { });
125
+ const bootstrapCalls = subscribeCalls;
126
+ // Deliberately schema-invalid: the pinned schema (6.7) requires pane_id for this filtered
127
+ // kind, so the server rejects the batch — the exact answer the transport double gives here.
128
+ const rejected = { type: 'pane.agent_status_changed' };
129
+ kit.subscribe([rejected], () => { });
130
+ await settle(1200); // longer than the 1 s reconnect delay a retry would use
131
+ assert.equal(subscribeCalls, bootstrapCalls + 1, 'the rejected subscription is never retried');
132
+ }
133
+ finally {
134
+ await kit.stop().catch(() => { });
135
+ }
136
+ });
137
+ });
138
+ test('contract: events reach a subscriber; unsubscribe stops them', async () => {
139
+ await bench(async ({ kit, fake }) => {
140
+ if (!fake)
141
+ return;
142
+ await kit.start();
143
+ const seen = [];
144
+ const stop = kit.subscribe([{ type: 'pane.created' }], (event) => seen.push(event));
145
+ await settle(100); // the subscribe registers on its own socket; an emit before that never arrives
146
+ fake.emit({ type: 'pane.created', pane_id: 'w9:p9' });
147
+ await until(() => seen.length, (n) => n >= 1);
148
+ assert.equal(seen[0].pane_id, 'w9:p9');
149
+ stop();
150
+ fake.emit({ type: 'pane.created', pane_id: 'w9:p10' });
151
+ await settle(100);
152
+ assert.equal(seen.length, 1, 'no frames after unsubscribe');
153
+ });
154
+ });
155
+ test('contract: the per-pane status watch keeps the tree and blocked list current', async () => {
156
+ await bench(async ({ kit, fake }) => {
157
+ if (!fake)
158
+ return;
159
+ await kit.start();
160
+ await settle(100); // the bootstrap's per-pane watch registers on its own socket
161
+ fake.setStatus('w1:p2', 'working');
162
+ await until(() => agentStatusOf(kit.snapshot(), 'w1:p2').status, (s) => s === 'working');
163
+ fake.setStatus('w1:p2', 'blocked');
164
+ const blocked = await until(() => kit.blocked(), (list) => list.length === 1);
165
+ assert.equal(blocked[0].paneId, 'w1:p2');
166
+ assert.equal(blocked[0].prompt, 'Allow this? (y/n)');
167
+ assert.equal(typeof blocked[0].revision, 'number');
168
+ assert.ok(blocked[0].revision >= 1);
169
+ fake.setStatus('w1:p2', 'idle');
170
+ await until(() => kit.blocked().length, (n) => n === 0);
171
+ });
172
+ });
173
+ test('contract: startAgent returns fresh pane ids in every placement', async () => {
174
+ await bench(async ({ kit }) => {
175
+ await kit.start();
176
+ const taken = new Set(['w1:p1', 'w1:p2']);
177
+ const start = (place, extra = {}) => kit.startAgent({ kind: 'pi', cwd: '/tmp/h6', place, ...extra });
178
+ const ws = await start({ workspace: 'new' });
179
+ const tab = await start({ tab: 'new', workspaceId: 'w1' });
180
+ const split = await start({ split: 'w1:p1', direction: 'right' });
181
+ const pane = await start({ pane: 'w1:p1' });
182
+ const tree = await start({ workspace: 'new' }, { worktree: { branch: 'h6' } });
183
+ for (const [name, ref] of [['workspace', ws], ['tab', tab], ['split', split], ['worktree', tree]]) {
184
+ assert.match(ref.paneId, /^w\d+:p\d+$/, `${name} placement gets a live-shaped pane id`);
185
+ assert.ok(!taken.has(ref.paneId), `${name} placement never predicts an existing pane id`);
186
+ taken.add(ref.paneId);
187
+ }
188
+ assert.equal(pane.paneId, 'w1:p1', 'the pane placement uses the pane as given');
189
+ assert.equal(typeof ws.name, 'string');
190
+ const workspaces = (await kit.call('workspace.list', {}));
191
+ assert.ok(workspaces.workspaces?.some((workspace) => workspace.worktree?.is_linked_worktree === true), 'the worktree placement registers a linked checkout');
192
+ });
193
+ });
194
+ test('contract: prompt validates the receipt and appends the reply text', async () => {
195
+ await bench(async ({ kit }) => {
196
+ await kit.start();
197
+ const receipt = await kit.prompt({ paneId: 'w1:p2' }, 'hello world');
198
+ const shaped = receipt; // compile-time: the receipt keeps the frozen shape
199
+ assert.equal(shaped.paneId, 'w1:p2');
200
+ assert.equal(typeof shaped.terminalId, 'string');
201
+ assert.equal(shaped.status, 'working');
202
+ assert.ok(shaped.revision >= 1);
203
+ assert.equal(await kit.wait({ paneId: 'w1:p2' }, { until: ['idle'], timeoutMs: 2000 }), 'idle');
204
+ const read = await kit.read('w1:p2');
205
+ assert.match(read.text, /fake pi: hello world/);
206
+ });
207
+ });
208
+ test('contract: a malformed prompt receipt fails', async (t) => {
209
+ await bench(async ({ withTransport }) => {
210
+ if (!withTransport)
211
+ return t.skip('make provides no transport double');
212
+ const kit = withTransport({
213
+ call: async (method) => {
214
+ if (method === 'agent.prompt')
215
+ return { type: 'nonsense' };
216
+ if (method === 'session.snapshot') {
217
+ return { snapshot: {
218
+ workspaces: [{ workspace_id: 'w1', label: 'x' }],
219
+ tabs: [{ tab_id: 'w1:t1', workspace_id: 'w1', label: 'main' }],
220
+ panes: [{ pane_id: 'w1:p2', tab_id: 'w1:t1', workspace_id: 'w1' }],
221
+ agents: [{ pane_id: 'w1:p2', agent_status: 'idle', revision: 1 }],
222
+ } };
223
+ }
224
+ return { protocol: HERDR_PROTOCOL };
225
+ },
226
+ subscribe: () => () => { },
227
+ close: () => { },
228
+ });
229
+ try {
230
+ await kit.start().catch(() => { });
231
+ await assert.rejects(kit.prompt({ paneId: 'w1:p2' }, 'x'), /did not queue/);
232
+ }
233
+ finally {
234
+ await kit.stop().catch(() => { });
235
+ }
236
+ });
237
+ });
238
+ test('contract: wait resolves on a matching status and honors its timeout', async () => {
239
+ await bench(async ({ kit }) => {
240
+ await kit.start();
241
+ assert.equal(await kit.wait({ paneId: 'w1:p2' }, { until: ['idle'], timeoutMs: 2000 }), 'idle');
242
+ const started = Date.now();
243
+ const status = await kit.wait({ paneId: 'w1:p2' }, { until: ['done'], timeoutMs: 60 });
244
+ assert.equal(status, 'idle', 'the deadline returns the current status');
245
+ assert.ok(Date.now() - started < 2000, 'the wait honors its timeout instead of hanging');
246
+ });
247
+ });
248
+ test('contract: read unwraps result.read', async () => {
249
+ await bench(async ({ kit }) => {
250
+ await kit.start();
251
+ assert.deepEqual(await kit.read('w1:p1'), { text: 'ready.', truncated: false });
252
+ assert.deepEqual(await kit.read('w1:p1', { lines: 5 }), { text: 'ready.', truncated: false });
253
+ assert.equal((await kit.read('w1:p2', { source: 'detection' })).text, '', 'an unblocked agent has no detection text');
254
+ });
255
+ });
256
+ test('contract: blocked answers refuse a stale revision and accept the current one', async () => {
257
+ await bench(async ({ kit }) => {
258
+ await kit.start();
259
+ await kit.prompt({ paneId: 'w1:p2' }, 'ask permission');
260
+ const blocked = await until(() => kit.blocked(), (list) => list.length === 1);
261
+ const revision = blocked[0].revision;
262
+ await assert.rejects(kit.answer('w1:p2', ['y'], { revision: revision - 1 }), (e) => e.code === 'approval-stale', 'a stale revision is refused');
263
+ assert.equal(kit.blocked().length, 1, 'the refusal leaves the question open');
264
+ await kit.answer('w1:p2', ['y'], { revision });
265
+ await until(() => kit.blocked().length, (n) => n === 0);
266
+ assert.equal(await kit.wait({ paneId: 'w1:p2' }, { until: ['idle'], timeoutMs: 2000 }), 'idle');
267
+ });
268
+ });
269
+ test('contract: close guards refuse widening and close exact', async () => {
270
+ await bench(async ({ kit }) => {
271
+ await kit.start();
272
+ // A fresh workspace holds exactly one tab with exactly one pane: the widening cases.
273
+ const made = (await kit.call('workspace.create', { cwd: '/tmp/h6-other' }));
274
+ const w2 = made.workspace?.workspace_id;
275
+ assert.equal(typeof w2, 'string');
276
+ await assert.rejects(kit.closePane(`${w2}:p1`), (e) => e.code === 'pane-close-would-widen', 'closing the last pane would close the tab');
277
+ await assert.rejects(kit.closeTab(`${w2}:t1`), (e) => e.code === 'tab-close-would-widen', 'closing the only tab would close the workspace');
278
+ // worktree.create links a checkout into w1's group: the root now widens on close.
279
+ await kit.startAgent({ kind: 'pi', cwd: '/tmp/h6', place: { workspace: 'new' }, worktree: { branch: 'h6' } });
280
+ await assert.rejects(kit.closeWorkspace('w1'), (e) => e.code === 'workspace-close-would-widen', 'closing a worktree group parent would close the group');
281
+ // The exact closes still go through.
282
+ await kit.call('pane.split', { target_pane_id: `${w2}:p1`, direction: 'right' });
283
+ await kit.closePane(`${w2}:p1`);
284
+ await kit.call('tab.create', { workspace_id: w2, cwd: '/tmp/h6-other' });
285
+ await kit.closeTab(`${w2}:t1`);
286
+ await kit.closeWorkspace(w2);
287
+ });
288
+ });
289
+ test('contract: the cli answers --version and the terminal echoes', async () => {
290
+ await bench(async ({ kit }) => {
291
+ await kit.start();
292
+ const cli = await kit.cli(['--version']);
293
+ assert.equal(cli.exitCode, 0);
294
+ assert.match(cli.stdout, /^herdr 0\.9\.1\n$/);
295
+ const terminal = kit.terminal('w1:p1', { mode: 'observe', cols: 80, rows: 24 });
296
+ const frames = [];
297
+ terminal.onFrame((line) => frames.push(line));
298
+ await terminal.ready;
299
+ assert.match(frames[0], /terminal\.ready/);
300
+ terminal.send(`${JSON.stringify({ type: 'terminal.input', data: 'echo hi' })}\n`);
301
+ await until(() => frames.length, (n) => n >= 2);
302
+ assert.match(frames[1], /echo hi/);
303
+ terminal.close();
304
+ const exited = await terminal.exited;
305
+ assert.ok(exited.code === 0 || exited.code === null);
306
+ });
307
+ });
308
+ }
@@ -0,0 +1,5 @@
1
+ export declare function writeBinShim({ dir, socketPath }: {
2
+ dir: string;
3
+ socketPath: string;
4
+ }): string;
5
+ export declare function main(argv: string[]): Promise<void>;