@byokit/herdr 0.1.0-next.dbe7142 → 0.1.0

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.
@@ -2,10 +2,12 @@
2
2
  // in later work packages' tests (H3/H5 wire `make` up) and against the real pinned Herdr in the lab
3
3
  // (H9, which passes no `fake`). Cases that need to script the transport's wire answers use
4
4
  // `withTransport`; when a `make` omits it, those cases skip.
5
- import { test } from 'node:test';
5
+ import { test as nodeTest } from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
7
  import { HERDR_PROTOCOL } from "../constants.js";
8
- export function herdrContract(make) {
8
+ export function herdrContract(make, options) {
9
+ const runTest = typeof options === 'function' ? options : (options?.test ?? nodeTest);
10
+ const test = runTest;
9
11
  const settle = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
12
  async function until(probe, ok, ms = 2000) {
11
13
  const deadline = Date.now() + ms;
@@ -52,24 +54,96 @@ export function herdrContract(make) {
52
54
  assert.equal(typeof ping.version, 'string');
53
55
  });
54
56
  });
55
- test('contract: a protocol mismatch reports needs-update', async (t) => {
57
+ test('contract: a newer server is needs-update state, not a throw — the kit stays usable', async (t) => {
56
58
  await bench(async ({ withTransport }) => {
57
59
  if (!withTransport)
58
60
  return t.skip('make provides no transport double');
61
+ const snapshot = {
62
+ protocol: HERDR_PROTOCOL + 999,
63
+ snapshot: {
64
+ protocol: HERDR_PROTOCOL + 999,
65
+ workspaces: [{ workspace_id: 'w1', label: 'x' }],
66
+ tabs: [{ tab_id: 'w1:t1', workspace_id: 'w1', label: 'main' }],
67
+ panes: [{ pane_id: 'w1:p2', tab_id: 'w1:t1', workspace_id: 'w1' }],
68
+ agents: [{ pane_id: 'w1:p2', agent_status: 'idle', revision: 1 }],
69
+ },
70
+ };
59
71
  const kit = withTransport({
60
- call: async () => ({ protocol: HERDR_PROTOCOL + 999 }),
72
+ call: async (method) => (method === 'session.snapshot' ? snapshot : { protocol: HERDR_PROTOCOL + 999 }),
61
73
  subscribe: () => () => { },
62
74
  close: () => { },
63
75
  });
64
76
  try {
65
- await kit.start().catch(() => { });
77
+ await kit.start();
66
78
  assert.equal(kit.state.phase, 'needs-update');
79
+ assert.equal(kit.state.why, 'version');
80
+ assert.equal(kit.snapshot().workspaces.length, 1, 'the snapshot still installs');
81
+ assert.ok(kit.snapshot().connected, 'the kit stays connected');
82
+ await kit.call('ping', {});
67
83
  }
68
84
  finally {
69
85
  await kit.stop().catch(() => { });
70
86
  }
71
87
  });
72
88
  });
89
+ test('contract: an older server fails closed', async (t) => {
90
+ await bench(async ({ withTransport }) => {
91
+ if (!withTransport)
92
+ return t.skip('make provides no transport double');
93
+ const kit = withTransport({
94
+ call: async () => ({ protocol: HERDR_PROTOCOL - 1 }),
95
+ subscribe: () => () => { },
96
+ close: () => { },
97
+ });
98
+ try {
99
+ await assert.rejects(kit.start(), /protocol mismatch/);
100
+ assert.equal(kit.state.phase, 'needs-update');
101
+ }
102
+ finally {
103
+ await kit.stop().catch(() => { });
104
+ }
105
+ });
106
+ });
107
+ test('contract: a declared protocol range accepts a newer server as ready', async (t) => {
108
+ await bench(async ({ withTransport }) => {
109
+ if (!withTransport)
110
+ return t.skip('make provides no transport double');
111
+ const snapshot = {
112
+ protocol: HERDR_PROTOCOL + 1,
113
+ snapshot: {
114
+ protocol: HERDR_PROTOCOL + 1,
115
+ workspaces: [{ workspace_id: 'w1', label: 'x' }],
116
+ tabs: [{ tab_id: 'w1:t1', workspace_id: 'w1', label: 'main' }],
117
+ panes: [{ pane_id: 'w1:p2', tab_id: 'w1:t1', workspace_id: 'w1' }],
118
+ agents: [{ pane_id: 'w1:p2', agent_status: 'idle', revision: 1 }],
119
+ },
120
+ };
121
+ const transport = {
122
+ call: async (method) => (method === 'session.snapshot' ? snapshot : { protocol: HERDR_PROTOCOL + 1 }),
123
+ subscribe: () => () => { },
124
+ close: () => { },
125
+ };
126
+ const inRange = withTransport(transport);
127
+ try {
128
+ await inRange.start();
129
+ assert.equal(inRange.state.phase, 'needs-update', 'without the range a newer server is needs-update');
130
+ }
131
+ finally {
132
+ await inRange.stop().catch(() => { });
133
+ }
134
+ const ranged = withTransport(transport, {
135
+ protocolRange: { min: HERDR_PROTOCOL, max: HERDR_PROTOCOL + 1 },
136
+ });
137
+ try {
138
+ await ranged.start();
139
+ assert.equal(ranged.state.phase, 'ready', 'the declared range accepts the newer server');
140
+ assert.equal(ranged.snapshot().workspaces.length, 1);
141
+ }
142
+ finally {
143
+ await ranged.stop().catch(() => { });
144
+ }
145
+ });
146
+ });
73
147
  test('contract: an event racing the bootstrap snapshot is applied after it, once', async (t) => {
74
148
  await bench(async ({ withTransport }) => {
75
149
  if (!withTransport)
@@ -83,11 +157,11 @@ export function herdrContract(make) {
83
157
  },
84
158
  };
85
159
  const kit = withTransport({
86
- // The ack carries the race: `pane.updated` lands after the subscribe but before the kit
160
+ // The ack carries the race: `pane_updated` lands after the subscribe but before the kit
87
161
  // installs the snapshot, so buffering must order it after — and never duplicate it.
88
162
  call: async (method) => (method === 'session.snapshot' ? snapshot : { protocol: HERDR_PROTOCOL }),
89
163
  subscribe: (_subs, on) => {
90
- queueMicrotask(() => on({ type: 'pane.updated', pane: { pane_id: 'w1:p2', agent_status: 'working' } }));
164
+ queueMicrotask(() => on({ type: 'pane_updated', pane: { pane_id: 'w1:p2', agent_status: 'working' } }));
91
165
  return () => { };
92
166
  },
93
167
  close: () => { },
@@ -145,6 +219,8 @@ export function herdrContract(make) {
145
219
  await settle(100); // the subscribe registers on its own socket; an emit before that never arrives
146
220
  fake.emit({ type: 'pane.created', pane_id: 'w9:p9' });
147
221
  await until(() => seen.length, (n) => n >= 1);
222
+ // The fake emits the live underscore spelling; the kit matches both spellings.
223
+ assert.equal(seen[0].type, 'pane_created');
148
224
  assert.equal(seen[0].pane_id, 'w9:p9');
149
225
  stop();
150
226
  fake.emit({ type: 'pane.created', pane_id: 'w9:p10' });
@@ -191,6 +267,18 @@ export function herdrContract(make) {
191
267
  assert.ok(workspaces.workspaces?.some((workspace) => workspace.worktree?.is_linked_worktree === true), 'the worktree placement registers a linked checkout');
192
268
  });
193
269
  });
270
+ test('contract: a started agent joins snapshot() and its status stays current there', async () => {
271
+ await bench(async ({ kit }) => {
272
+ await kit.start();
273
+ const { paneId } = await kit.startAgent({ kind: 'pi', cwd: '/tmp/h6', place: { workspace: 'new' } });
274
+ await until(() => agentStatusOf(kit.snapshot(), paneId).status, (status) => status === 'idle');
275
+ await kit.prompt({ paneId }, 'ask permission');
276
+ await until(() => agentStatusOf(kit.snapshot(), paneId).status, (status) => status === 'blocked');
277
+ const blocked = await until(() => kit.blocked().find((b) => b.paneId === paneId), () => true);
278
+ await kit.answer(paneId, ['y'], { revision: blocked.revision });
279
+ await until(() => agentStatusOf(kit.snapshot(), paneId).status, (status) => status === 'idle');
280
+ });
281
+ });
194
282
  test('contract: prompt validates the receipt and appends the reply text', async () => {
195
283
  await bench(async ({ kit }) => {
196
284
  await kit.start();
@@ -6,16 +6,23 @@ export type FakeHerdrOptions = {
6
6
  cwd?: string;
7
7
  kinds?: readonly string[];
8
8
  };
9
+ agentStartFaults?: AgentStartFault[];
9
10
  onStop?: () => void;
10
11
  };
12
+ export type AgentStartFault = {
13
+ code: string;
14
+ message?: string;
15
+ };
11
16
  export type FakeHerdr = {
12
17
  socketPath: string;
13
18
  bin: string;
14
19
  world: FakeWorld;
20
+ agentStartFaults: AgentStartFault[];
15
21
  emit(event: {
16
22
  type: string;
17
23
  } & Record<string, unknown>): void;
18
24
  setStatus(paneId: string, status: string): void;
25
+ subscriptionCount(): number;
19
26
  stop(): Promise<void>;
20
27
  };
21
28
  export declare function startFakeHerdr(options: FakeHerdrOptions): Promise<FakeHerdr>;
@@ -25,6 +25,10 @@ export async function startFakeHerdr(options) {
25
25
  nextRevision: 0,
26
26
  zoomed: false,
27
27
  });
28
+ // Queued `agent.start` failures (K7): each start shifts one and fails with its code, so a test
29
+ // scripts busy-then-ok or a permanent failure. The array is live on the handle — push more faults
30
+ // any time before (or during) the starts under test.
31
+ const agentStartFaults = [...(options.agentStartFaults ?? [])];
28
32
  const sockets = new Set();
29
33
  const eventSubs = new Set();
30
34
  const statusSubs = new Set();
@@ -80,8 +84,12 @@ export async function startFakeHerdr(options) {
80
84
  // result.root_pane.pane_id from workspace.create and tab.create alike).
81
85
  const tab = addTab(workspace.workspace_id, 'main');
82
86
  const pane = addPane(tab.tab_id, workspace.workspace_id, workspace.label);
87
+ const env = envOf(p);
88
+ if (env !== undefined)
89
+ pane.env = env;
83
90
  if (p.focus === true)
84
91
  focusWorkspace(workspace.workspace_id);
92
+ emitEvent('workspace.created', { workspace: workspaceView(workspace) });
85
93
  return { workspace: workspaceView(workspace), root_pane: { pane_id: pane.pane_id } };
86
94
  },
87
95
  'workspace.close': (p) => {
@@ -92,6 +100,7 @@ export async function startFakeHerdr(options) {
92
100
  for (const tabId of tabs)
93
101
  removeTab(tabId);
94
102
  live.workspaces = live.workspaces.filter((row) => row.workspace_id !== p.workspace_id);
103
+ emitEvent('workspace.closed', { workspace_id: p.workspace_id });
95
104
  return {};
96
105
  },
97
106
  'workspace.focus': (p) => {
@@ -101,6 +110,15 @@ export async function startFakeHerdr(options) {
101
110
  focusWorkspace(p.workspace_id);
102
111
  return {};
103
112
  },
113
+ 'workspace.report_metadata': (p) => {
114
+ const workspace = live.workspaces.find((row) => row.workspace_id === p.workspace_id);
115
+ if (workspace === undefined)
116
+ throw fail('workspace_not_found', 'workspace not found');
117
+ if (p.tokens !== undefined && typeof p.tokens === 'object') {
118
+ workspace.tokens = { ...workspace.tokens, ...p.tokens };
119
+ }
120
+ return {};
121
+ },
104
122
  'worktree.create': (p) => {
105
123
  const checkout = typeof p.cwd === 'string' && p.cwd.length > 0 ? p.cwd : join(live.cwd, `wt-${live.nextWorkspace}`);
106
124
  const workspace = addWorkspace(checkout);
@@ -111,7 +129,18 @@ export async function startFakeHerdr(options) {
111
129
  checkout_path: checkout,
112
130
  is_linked_worktree: true,
113
131
  };
114
- return { workspace: { ...workspaceView(workspace), worktree: workspace.worktree } };
132
+ // Like the pinned server, the linked checkout opens with its root tab and root pane in the
133
+ // checkout, carrying the placement env; the kit reads result.root_pane.pane_id (K7).
134
+ const tab = addTab(workspace.workspace_id, 'main');
135
+ const pane = addPane(tab.tab_id, workspace.workspace_id, checkout);
136
+ const env = envOf(p);
137
+ if (env !== undefined)
138
+ pane.env = env;
139
+ if (p.focus === true)
140
+ focusWorkspace(workspace.workspace_id);
141
+ emitEvent('workspace.created', { workspace: workspaceView(workspace) });
142
+ return { workspace: { ...workspaceView(workspace), worktree: workspace.worktree },
143
+ tab: tabView(tab), root_pane: { pane_id: pane.pane_id } };
115
144
  },
116
145
  'tab.list': (p) => ({ tabs: live.tabs.filter((tab) => tab.workspace_id === p.workspace_id) }),
117
146
  'tab.get': (p) => {
@@ -126,14 +155,20 @@ export async function startFakeHerdr(options) {
126
155
  throw fail('workspace_not_found', 'workspace not found');
127
156
  const tab = addTab(workspace.workspace_id, label(p) ?? workspace.label ?? live.cwd);
128
157
  const pane = addPane(tab.tab_id, workspace.workspace_id, p.cwd ?? workspace.label ?? live.cwd);
158
+ const env = envOf(p);
159
+ if (env !== undefined)
160
+ pane.env = env;
129
161
  if (p.focus === true)
130
162
  focusPane(pane.pane_id);
163
+ emitEvent('tab.created', { tab: tabView(tab) });
131
164
  return { tab: tabView(tab), root_pane: { pane_id: pane.pane_id } };
132
165
  },
133
166
  'tab.close': (p) => {
134
- if (!live.tabs.some((row) => row.tab_id === p.tab_id))
167
+ const tabWorkspace = live.tabs.find((row) => row.tab_id === p.tab_id)?.workspace_id;
168
+ if (tabWorkspace === undefined)
135
169
  throw fail('tab_not_found', 'tab not found');
136
170
  removeTab(p.tab_id);
171
+ emitEvent('tab.closed', { tab_id: p.tab_id, workspace_id: tabWorkspace });
137
172
  return {};
138
173
  },
139
174
  'tab.focus': (p) => {
@@ -151,6 +186,7 @@ export async function startFakeHerdr(options) {
151
186
  live.panes = live.panes.filter((row) => row.pane_id !== pane.pane_id);
152
187
  live.agents = live.agents.filter((row) => row.pane_id !== pane.pane_id);
153
188
  relayoutTab(live, pane.tab_id);
189
+ emitEvent('pane.closed', { pane_id: pane.pane_id, workspace_id: pane.workspace_id });
154
190
  return {};
155
191
  },
156
192
  'pane.focus': (p) => {
@@ -160,8 +196,12 @@ export async function startFakeHerdr(options) {
160
196
  'pane.split': (p) => {
161
197
  const target = paneOf(p.target_pane_id);
162
198
  const pane = addPane(target.tab_id, target.workspace_id, target.cwd);
199
+ const env = envOf(p);
200
+ if (env !== undefined)
201
+ pane.env = env;
163
202
  if (p.focus === true)
164
203
  focusPane(pane.pane_id);
204
+ emitEvent('pane.created', { pane: paneView(pane) });
165
205
  return { pane: { pane_id: pane.pane_id } };
166
206
  },
167
207
  'pane.read': (p) => {
@@ -193,6 +233,9 @@ export async function startFakeHerdr(options) {
193
233
  },
194
234
  'pane.layout': (p) => ({ layout: tabLayout(paneOf(p.pane_id).tab_id) }),
195
235
  'agent.start': (p) => {
236
+ const fault = agentStartFaults.shift();
237
+ if (fault !== undefined)
238
+ throw fail(fault.code, fault.message ?? fault.code);
196
239
  const pane = paneOf(p.pane_id);
197
240
  const kind = typeof p.kind === 'string' && p.kind.length > 0 ? p.kind : 'pi';
198
241
  const name = typeof p.name === 'string' && p.name.length > 0 ? p.name : kind;
@@ -209,6 +252,7 @@ export async function startFakeHerdr(options) {
209
252
  live.agents.push(agent);
210
253
  else
211
254
  live.agents[index] = agent;
255
+ emitEvent('pane.agent_detected', { pane_id: pane.pane_id, workspace_id: pane.workspace_id, agent: kind });
212
256
  return { agent };
213
257
  },
214
258
  'agent.prompt': (p) => {
@@ -340,6 +384,10 @@ export async function startFakeHerdr(options) {
340
384
  })
341
385
  .finally(() => socket.end());
342
386
  }
387
+ // Subscription kinds use the schema's dot spelling (`pane.created`); live frames carry
388
+ // the underscore const (`pane_created`, schema/SOURCE.md). The fake mirrors the real
389
+ // server: it accepts both spellings in subscriptions and emits underscore wire frames.
390
+ const wireName = (type) => type.replace(/\./g, '_');
343
391
  function subscribe(socket, id, params) {
344
392
  const subs = Array.isArray(params.subscriptions) ? params.subscriptions : [];
345
393
  const invalid = (message) => {
@@ -350,11 +398,11 @@ export async function startFakeHerdr(options) {
350
398
  for (const s of subs) {
351
399
  if (typeof s?.type !== 'string')
352
400
  return invalid('every subscription needs a type');
353
- if (s.type === 'pane.agent_status_changed' && typeof s.pane_id !== 'string') {
401
+ if (wireName(s.type) === 'pane_agent_status_changed' && typeof s.pane_id !== 'string') {
354
402
  return invalid('pane.agent_status_changed needs a pane_id');
355
403
  }
356
404
  }
357
- const filtered = subs.filter((s) => s.type === 'pane.agent_status_changed');
405
+ const filtered = subs.filter((s) => typeof s.type === 'string' && wireName(s.type) === 'pane_agent_status_changed');
358
406
  if (filtered.length > 0 && filtered.length !== subs.length) {
359
407
  return invalid('pane.agent_status_changed cannot share a batch');
360
408
  }
@@ -364,12 +412,14 @@ export async function startFakeHerdr(options) {
364
412
  statusSubs.add({ socket, paneId: s.pane_id });
365
413
  return;
366
414
  }
367
- eventSubs.add({ socket, kinds: new Set(subs.map((s) => s.type)) });
415
+ eventSubs.add({ socket, kinds: new Set(subs.map((s) => wireName(s.type))) });
368
416
  }
417
+ // Payloads follow the pinned schema's event data (src/generated/events.ts).
369
418
  function emitEvent(type, data) {
370
- const frame = `${JSON.stringify({ event: type, data: { type, ...data } })}\n`;
419
+ const wire = wireName(type);
420
+ const frame = `${JSON.stringify({ event: wire, data: { type: wire, ...data } })}\n`;
371
421
  for (const sub of eventSubs) {
372
- if (sub.kinds.size > 0 && !sub.kinds.has(type))
422
+ if (sub.kinds.size > 0 && !sub.kinds.has(wire))
373
423
  continue;
374
424
  try {
375
425
  sub.socket.write(frame);
@@ -379,8 +429,8 @@ export async function startFakeHerdr(options) {
379
429
  }
380
430
  function emitStatus(paneId, agentStatus, revision) {
381
431
  const frame = `${JSON.stringify({
382
- event: 'pane.agent_status_changed',
383
- data: { type: 'pane.agent_status_changed', pane_id: paneId, agent_status: agentStatus, revision },
432
+ event: 'pane_agent_status_changed',
433
+ data: { type: 'pane_agent_status_changed', pane_id: paneId, agent_status: agentStatus, revision },
384
434
  })}\n`;
385
435
  for (const sub of statusSubs) {
386
436
  if (sub.paneId !== paneId)
@@ -430,6 +480,19 @@ export async function startFakeHerdr(options) {
430
480
  function label(p) {
431
481
  return typeof p.label === 'string' && p.label.length > 0 ? p.label : undefined;
432
482
  }
483
+ // Placement env (K7): `workspace.create`, `tab.create`, `pane.split` and `worktree.create` carry
484
+ // the start's env for the fresh pane's shell. The fake records it on the pane, where `pane.get`
485
+ // surfaces it, so a test proves the pane received the env.
486
+ function envOf(p) {
487
+ if (typeof p.env !== 'object' || p.env === null || Array.isArray(p.env))
488
+ return undefined;
489
+ const env = {};
490
+ for (const [key, value] of Object.entries(p.env)) {
491
+ if (typeof value === 'string')
492
+ env[key] = value;
493
+ }
494
+ return env;
495
+ }
433
496
  function snapshotOf() {
434
497
  return {
435
498
  workspaces: live.workspaces.map(workspaceView),
@@ -439,11 +502,14 @@ export async function startFakeHerdr(options) {
439
502
  };
440
503
  }
441
504
  function addWorkspace(labelText) {
442
- const workspaceId = `w${live.nextWorkspace++}`;
505
+ const n = live.nextWorkspace++;
506
+ const workspaceId = `w${n}`;
443
507
  const workspace = {
444
508
  workspace_id: workspaceId,
445
509
  label: labelText,
446
510
  focused: false,
511
+ number: n,
512
+ tokens: {},
447
513
  worktree: {
448
514
  // A unique key per plain workspace keeps it out of every worktree group; only
449
515
  // worktree.create links a checkout back into the root workspace's group.
@@ -534,8 +600,10 @@ export async function startFakeHerdr(options) {
534
600
  workspace_id: workspace.workspace_id,
535
601
  label: workspace.label,
536
602
  focused: workspace.focused,
603
+ number: workspace.number,
537
604
  tab_count: tabs.length,
538
605
  active_tab_id: focusedPane?.tab_id ?? tabs[0]?.tab_id,
606
+ ...(Object.keys(workspace.tokens).length === 0 ? {} : { tokens: { ...workspace.tokens } }),
539
607
  ...(workspace.worktree === undefined ? {} : { worktree: workspace.worktree }),
540
608
  };
541
609
  }
@@ -549,8 +617,9 @@ export async function startFakeHerdr(options) {
549
617
  socketPath,
550
618
  bin,
551
619
  world: freezeWorld(live),
620
+ agentStartFaults,
552
621
  emit(event) {
553
- if (event.type === 'pane.agent_status_changed' && typeof event.pane_id === 'string') {
622
+ if (wireName(event.type) === 'pane_agent_status_changed' && typeof event.pane_id === 'string') {
554
623
  emitStatus(event.pane_id, String(event.agent_status ?? 'unknown'), Number(event.revision ?? 0));
555
624
  return;
556
625
  }
@@ -562,6 +631,14 @@ export async function startFakeHerdr(options) {
562
631
  if (pane !== undefined)
563
632
  transition(pane, status);
564
633
  },
634
+ subscriptionCount() {
635
+ const held = new Set();
636
+ for (const sub of eventSubs)
637
+ held.add(sub.socket);
638
+ for (const sub of statusSubs)
639
+ held.add(sub.socket);
640
+ return held.size;
641
+ },
565
642
  stop: shutdown,
566
643
  };
567
644
  }
@@ -577,6 +654,7 @@ function paneView(pane) {
577
654
  terminal_title: pane.terminal_title,
578
655
  terminal_title_stripped: pane.terminal_title_stripped,
579
656
  focused: pane.focused,
657
+ ...(pane.env === undefined ? {} : { env: { ...pane.env } }),
580
658
  ...(Object.keys(pane.tokens).length === 0 ? {} : { tokens: pane.tokens }),
581
659
  };
582
660
  }
@@ -16,6 +16,7 @@ export type FakePane = {
16
16
  terminal_title_stripped: string;
17
17
  focused: boolean;
18
18
  tokens: Record<string, unknown>;
19
+ env?: Record<string, string>;
19
20
  rect: FakeRect;
20
21
  text: string[];
21
22
  };
@@ -28,6 +29,7 @@ export type FakeAgent = {
28
29
  name: string;
29
30
  agent: string;
30
31
  display_agent: string;
32
+ title: string;
31
33
  agent_status: string;
32
34
  interactive_ready: boolean;
33
35
  launch_pending: boolean;
@@ -49,6 +51,8 @@ export type FakeWorkspace = {
49
51
  workspace_id: string;
50
52
  label: string;
51
53
  focused: boolean;
54
+ number: number;
55
+ tokens: Record<string, string>;
52
56
  worktree?: {
53
57
  repo_key: string;
54
58
  repo_name: string;
@@ -24,6 +24,7 @@ export function agentRecord(o) {
24
24
  name: o.name,
25
25
  agent: o.kind,
26
26
  display_agent: o.name,
27
+ title: o.name,
27
28
  agent_status: 'idle',
28
29
  interactive_ready: true,
29
30
  launch_pending: false,
@@ -36,6 +37,8 @@ export function createWorld(o) {
36
37
  workspace_id: 'w1',
37
38
  label: o.cwd,
38
39
  focused: true,
40
+ number: 1,
41
+ tokens: {},
39
42
  worktree: {
40
43
  repo_key: 'fake-herdr',
41
44
  repo_name: 'fake-herdr',
@@ -1,4 +1,4 @@
1
- export { startFakeHerdr, type FakeHerdr, type FakeHerdrOptions } from './fake-herdr/server.ts';
1
+ export { startFakeHerdr, type AgentStartFault, type FakeHerdr, type FakeHerdrOptions } from './fake-herdr/server.ts';
2
2
  export { writeBinShim, main as fakeHerdrBinMain } from './fake-herdr/bin.ts';
3
3
  export { createWorld, type FakeWorld } from './fake-herdr/world.ts';
4
- export { herdrContract, type HerdrContractBench } from './contract.ts';
4
+ export { herdrContract, type HerdrContractBench, type HerdrContractOptions, type HerdrContractTestFn, type HerdrContractTestContext } from './contract.ts';
package/dist/types.d.ts CHANGED
@@ -2,6 +2,10 @@ export type HerdrState = {
2
2
  phase: 'stopped' | 'connecting' | 'ready' | 'reconnecting' | 'needs-update' | 'missing' | 'failed';
3
3
  why?: 'binary' | 'socket' | 'version' | 'server-exited';
4
4
  };
5
+ export type HerdrProtocolRange = {
6
+ min?: number;
7
+ max?: number;
8
+ };
5
9
  export type HerdrKitOptions = {
6
10
  mode: 'adopt';
7
11
  bin: string;
@@ -9,6 +13,7 @@ export type HerdrKitOptions = {
9
13
  env?: Record<string, string>;
10
14
  path?: string[];
11
15
  transport?: HerdrTransport;
16
+ protocolRange?: HerdrProtocolRange;
12
17
  onState?: (s: HerdrState) => void;
13
18
  } | {
14
19
  mode: 'own';
@@ -17,6 +22,7 @@ export type HerdrKitOptions = {
17
22
  env?: Record<string, string>;
18
23
  path?: string[];
19
24
  transport?: HerdrTransport;
25
+ protocolRange?: HerdrProtocolRange;
20
26
  onState?: (s: HerdrState) => void;
21
27
  };
22
28
  export interface HerdrTransport {
@@ -56,7 +62,7 @@ export type StartAgent = {
56
62
  pane: string;
57
63
  };
58
64
  worktree?: {
59
- branch: string;
65
+ branch?: string;
60
66
  base?: string;
61
67
  };
62
68
  args?: string[];
@@ -78,28 +84,57 @@ export type BlockedAgent = {
78
84
  prompt: string;
79
85
  since: number;
80
86
  };
87
+ export type AgentSessionRef = {
88
+ source: string;
89
+ agent: string;
90
+ kind: string;
91
+ value: string;
92
+ };
93
+ export type HerdrSnapshotWorktree = {
94
+ repoKey: string;
95
+ repoName: string;
96
+ repoRoot: string;
97
+ checkoutPath: string;
98
+ isLinkedWorktree: boolean;
99
+ };
100
+ export type HerdrSnapshotAgent = {
101
+ kind?: string;
102
+ name?: string;
103
+ displayAgent?: string;
104
+ title?: string;
105
+ status: AgentStatus;
106
+ revision: number;
107
+ launchPending?: boolean;
108
+ interactiveReady?: boolean;
109
+ agentSession?: AgentSessionRef;
110
+ foregroundCwd?: string;
111
+ };
112
+ export type HerdrSnapshotPane = {
113
+ id: string;
114
+ cwd?: string;
115
+ label?: string;
116
+ focused: boolean;
117
+ terminalTitle?: string;
118
+ tokens?: Record<string, string>;
119
+ agent?: HerdrSnapshotAgent;
120
+ };
121
+ export type HerdrSnapshotTab = {
122
+ id: string;
123
+ label: string;
124
+ panes: HerdrSnapshotPane[];
125
+ };
126
+ export type HerdrSnapshotWorkspace = {
127
+ id: string;
128
+ label: string;
129
+ focused: boolean;
130
+ number: number;
131
+ tokens?: Record<string, string>;
132
+ worktree?: HerdrSnapshotWorktree;
133
+ tabs: HerdrSnapshotTab[];
134
+ };
81
135
  export type HerdrSnapshot = {
82
136
  connected: boolean;
83
- workspaces: {
84
- id: string;
85
- label: string;
86
- tabs: {
87
- id: string;
88
- label: string;
89
- panes: {
90
- id: string;
91
- cwd?: string;
92
- agent?: {
93
- kind?: string;
94
- name?: string;
95
- status: AgentStatus;
96
- revision: number;
97
- launchPending?: boolean;
98
- interactiveReady?: boolean;
99
- };
100
- }[];
101
- }[];
102
- }[];
137
+ workspaces: HerdrSnapshotWorkspace[];
103
138
  };
104
139
  export type TerminalSession = {
105
140
  ready: Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/herdr",
3
- "version": "0.1.0-next.dbe7142",
3
+ "version": "0.1.0",
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",
@@ -17,6 +17,10 @@
17
17
  "types": "./dist/index.d.ts",
18
18
  "default": "./dist/index.js"
19
19
  },
20
+ "./binary": {
21
+ "types": "./dist/binary.d.ts",
22
+ "default": "./dist/binary.js"
23
+ },
20
24
  "./device": {
21
25
  "react-native": {
22
26
  "types": "./dist/device.d.ts",