@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.
- package/CHANGELOG.md +72 -0
- package/README.md +184 -14
- package/dist/agents.js +48 -7
- package/dist/binary.d.ts +29 -0
- package/dist/binary.js +132 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.js +4 -0
- package/dist/device.d.ts +34 -8
- package/dist/device.js +152 -49
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/kit.d.ts +28 -2
- package/dist/kit.js +256 -50
- package/dist/link.js +118 -2
- package/dist/supervise.js +11 -2
- package/dist/testing/contract.d.ts +13 -3
- package/dist/testing/contract.js +95 -7
- package/dist/testing/fake-herdr/server.d.ts +7 -0
- package/dist/testing/fake-herdr/server.js +89 -11
- package/dist/testing/fake-herdr/world.d.ts +4 -0
- package/dist/testing/fake-herdr/world.js +3 -0
- package/dist/testing/index.d.ts +2 -2
- package/dist/types.d.ts +56 -21
- package/package.json +5 -1
package/dist/testing/contract.js
CHANGED
|
@@ -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
|
|
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()
|
|
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: `
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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 === '
|
|
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 === '
|
|
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
|
|
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(
|
|
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: '
|
|
383
|
-
data: { type: '
|
|
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
|
|
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 === '
|
|
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',
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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",
|