@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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +202 -0
- package/README.md +18 -0
- package/dist/agents.d.ts +7 -0
- package/dist/agents.js +141 -0
- package/dist/approvals.d.ts +24 -0
- package/dist/approvals.js +81 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +89 -0
- package/dist/close.d.ts +4 -0
- package/dist/close.js +85 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +5 -0
- package/dist/device.d.ts +40 -0
- package/dist/device.js +128 -0
- package/dist/generated/events.d.ts +371 -0
- package/dist/generated/events.js +3 -0
- package/dist/generated/methods.d.ts +2299 -0
- package/dist/generated/methods.js +3 -0
- package/dist/generated/report.json +50 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/kit.d.ts +73 -0
- package/dist/kit.js +209 -0
- package/dist/link.d.ts +24 -0
- package/dist/link.js +324 -0
- package/dist/notices.d.ts +14 -0
- package/dist/notices.js +88 -0
- package/dist/socket.d.ts +2 -0
- package/dist/socket.js +137 -0
- package/dist/supervise.d.ts +17 -0
- package/dist/supervise.js +137 -0
- package/dist/terminal.d.ts +6 -0
- package/dist/terminal.js +105 -0
- package/dist/testing/contract.d.ts +9 -0
- package/dist/testing/contract.js +308 -0
- package/dist/testing/fake-herdr/bin.d.ts +5 -0
- package/dist/testing/fake-herdr/bin.js +210 -0
- package/dist/testing/fake-herdr/server.d.ts +21 -0
- package/dist/testing/fake-herdr/server.js +604 -0
- package/dist/testing/fake-herdr/world.d.ts +84 -0
- package/dist/testing/fake-herdr/world.js +85 -0
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +7 -0
- package/dist/types.d.ts +115 -0
- package/dist/types.js +3 -0
- package/dist/words.d.ts +10 -0
- package/dist/words.js +18 -0
- package/dist/words.json +19 -0
- package/package.json +60 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
// The fake Herdr control plane (docs/runtime-kits.md 6.8): NDJSON JSON-RPC on a unix socket, one
|
|
2
|
+
// request per connection — the server closes after answering; only `events.subscribe` is held open.
|
|
3
|
+
// Ported from muxr `perf/fake-herdr/server.mjs` with the UI plugins, title churn, graphics and perf
|
|
4
|
+
// byte rates removed.
|
|
5
|
+
import { mkdirSync, unlinkSync } from 'node:fs';
|
|
6
|
+
import { createServer } from 'node:net';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { HERDR_PROTOCOL, HERDR_VERSION } from "../../constants.js";
|
|
9
|
+
import { writeBinShim } from "./bin.js";
|
|
10
|
+
import { agentRecord, createWorld, freezeWorld, relayoutTab } from "./world.js";
|
|
11
|
+
const PROMPT_SETTLE_MS = 50; // 6.8: the agent settles 50 ms after a prompt
|
|
12
|
+
const WAIT_POLL_MS = 5;
|
|
13
|
+
export async function startFakeHerdr(options) {
|
|
14
|
+
const dir = options.dir;
|
|
15
|
+
if (typeof dir !== 'string' || dir.length === 0)
|
|
16
|
+
throw new Error('fake-herdr: dir is required');
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
const socketPath = options.socketPath ?? join(dir, 'herdr.sock');
|
|
19
|
+
const cwd = join(dir, 'project');
|
|
20
|
+
mkdirSync(cwd, { recursive: true });
|
|
21
|
+
const kinds = [...(options.world?.kinds ?? ['pi'])];
|
|
22
|
+
const live = Object.assign(createWorld({ cwd, kinds }), {
|
|
23
|
+
nextPane: 3,
|
|
24
|
+
nextWorkspace: 2,
|
|
25
|
+
nextRevision: 0,
|
|
26
|
+
zoomed: false,
|
|
27
|
+
});
|
|
28
|
+
const sockets = new Set();
|
|
29
|
+
const eventSubs = new Set();
|
|
30
|
+
const statusSubs = new Set();
|
|
31
|
+
const timers = [];
|
|
32
|
+
let stopping = false;
|
|
33
|
+
const server = createServer((socket) => {
|
|
34
|
+
sockets.add(socket);
|
|
35
|
+
let buffer = '';
|
|
36
|
+
let answered = false; // one request per socket; only events.subscribe stays open
|
|
37
|
+
socket.setEncoding('utf8');
|
|
38
|
+
socket.on('data', (chunk) => {
|
|
39
|
+
buffer += chunk;
|
|
40
|
+
const lines = buffer.split('\n');
|
|
41
|
+
buffer = lines.pop() ?? '';
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
if (line.trim().length === 0)
|
|
44
|
+
continue;
|
|
45
|
+
if (answered) {
|
|
46
|
+
socket.destroy();
|
|
47
|
+
return;
|
|
48
|
+
} // a second request on one socket is not Herdr
|
|
49
|
+
answered = true;
|
|
50
|
+
void handleLine(socket, line);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
socket.on('close', () => {
|
|
54
|
+
sockets.delete(socket);
|
|
55
|
+
for (const sub of eventSubs)
|
|
56
|
+
if (sub.socket === socket)
|
|
57
|
+
eventSubs.delete(sub);
|
|
58
|
+
for (const sub of statusSubs)
|
|
59
|
+
if (sub.socket === socket)
|
|
60
|
+
statusSubs.delete(sub);
|
|
61
|
+
});
|
|
62
|
+
socket.on('error', () => { });
|
|
63
|
+
});
|
|
64
|
+
unlinkQuiet(socketPath);
|
|
65
|
+
await listen(server, socketPath);
|
|
66
|
+
const bin = writeBinShim({ dir, socketPath });
|
|
67
|
+
const methods = {
|
|
68
|
+
ping: () => ({ protocol: HERDR_PROTOCOL, version: HERDR_VERSION }),
|
|
69
|
+
'session.snapshot': () => ({ snapshot: snapshotOf() }),
|
|
70
|
+
'workspace.list': () => ({ workspaces: live.workspaces.map((w) => workspaceView(w)) }),
|
|
71
|
+
'workspace.get': (p) => {
|
|
72
|
+
const workspace = live.workspaces.find((row) => row.workspace_id === p.workspace_id);
|
|
73
|
+
if (workspace === undefined)
|
|
74
|
+
throw fail('workspace_not_found', 'workspace not found');
|
|
75
|
+
return { workspace: workspaceView(workspace) };
|
|
76
|
+
},
|
|
77
|
+
'workspace.create': (p) => {
|
|
78
|
+
const workspace = addWorkspace(label(p) ?? live.cwd);
|
|
79
|
+
// A new workspace comes with its root tab and root pane (6.4: the kit reads
|
|
80
|
+
// result.root_pane.pane_id from workspace.create and tab.create alike).
|
|
81
|
+
const tab = addTab(workspace.workspace_id, 'main');
|
|
82
|
+
const pane = addPane(tab.tab_id, workspace.workspace_id, workspace.label);
|
|
83
|
+
if (p.focus === true)
|
|
84
|
+
focusWorkspace(workspace.workspace_id);
|
|
85
|
+
return { workspace: workspaceView(workspace), root_pane: { pane_id: pane.pane_id } };
|
|
86
|
+
},
|
|
87
|
+
'workspace.close': (p) => {
|
|
88
|
+
if (!live.workspaces.some((row) => row.workspace_id === p.workspace_id)) {
|
|
89
|
+
throw fail('workspace_not_found', 'workspace not found');
|
|
90
|
+
}
|
|
91
|
+
const tabs = live.tabs.filter((tab) => tab.workspace_id === p.workspace_id).map((tab) => tab.tab_id);
|
|
92
|
+
for (const tabId of tabs)
|
|
93
|
+
removeTab(tabId);
|
|
94
|
+
live.workspaces = live.workspaces.filter((row) => row.workspace_id !== p.workspace_id);
|
|
95
|
+
return {};
|
|
96
|
+
},
|
|
97
|
+
'workspace.focus': (p) => {
|
|
98
|
+
if (!live.workspaces.some((row) => row.workspace_id === p.workspace_id)) {
|
|
99
|
+
throw fail('workspace_not_found', 'workspace not found');
|
|
100
|
+
}
|
|
101
|
+
focusWorkspace(p.workspace_id);
|
|
102
|
+
return {};
|
|
103
|
+
},
|
|
104
|
+
'worktree.create': (p) => {
|
|
105
|
+
const checkout = typeof p.cwd === 'string' && p.cwd.length > 0 ? p.cwd : join(live.cwd, `wt-${live.nextWorkspace}`);
|
|
106
|
+
const workspace = addWorkspace(checkout);
|
|
107
|
+
workspace.worktree = {
|
|
108
|
+
repo_key: 'fake-herdr',
|
|
109
|
+
repo_name: 'fake-herdr',
|
|
110
|
+
repo_root: live.cwd,
|
|
111
|
+
checkout_path: checkout,
|
|
112
|
+
is_linked_worktree: true,
|
|
113
|
+
};
|
|
114
|
+
return { workspace: { ...workspaceView(workspace), worktree: workspace.worktree } };
|
|
115
|
+
},
|
|
116
|
+
'tab.list': (p) => ({ tabs: live.tabs.filter((tab) => tab.workspace_id === p.workspace_id) }),
|
|
117
|
+
'tab.get': (p) => {
|
|
118
|
+
const tab = live.tabs.find((row) => row.tab_id === p.tab_id);
|
|
119
|
+
if (tab === undefined)
|
|
120
|
+
throw fail('tab_not_found', 'tab not found');
|
|
121
|
+
return { tab: tabView(tab) };
|
|
122
|
+
},
|
|
123
|
+
'tab.create': (p) => {
|
|
124
|
+
const workspace = live.workspaces.find((row) => row.workspace_id === p.workspace_id);
|
|
125
|
+
if (workspace === undefined)
|
|
126
|
+
throw fail('workspace_not_found', 'workspace not found');
|
|
127
|
+
const tab = addTab(workspace.workspace_id, label(p) ?? workspace.label ?? live.cwd);
|
|
128
|
+
const pane = addPane(tab.tab_id, workspace.workspace_id, p.cwd ?? workspace.label ?? live.cwd);
|
|
129
|
+
if (p.focus === true)
|
|
130
|
+
focusPane(pane.pane_id);
|
|
131
|
+
return { tab: tabView(tab), root_pane: { pane_id: pane.pane_id } };
|
|
132
|
+
},
|
|
133
|
+
'tab.close': (p) => {
|
|
134
|
+
if (!live.tabs.some((row) => row.tab_id === p.tab_id))
|
|
135
|
+
throw fail('tab_not_found', 'tab not found');
|
|
136
|
+
removeTab(p.tab_id);
|
|
137
|
+
return {};
|
|
138
|
+
},
|
|
139
|
+
'tab.focus': (p) => {
|
|
140
|
+
const tab = live.tabs.find((row) => row.tab_id === p.tab_id);
|
|
141
|
+
if (tab === undefined)
|
|
142
|
+
throw fail('tab_not_found', 'tab not found');
|
|
143
|
+
const pane = live.panes.find((row) => row.tab_id === tab.tab_id);
|
|
144
|
+
if (pane !== undefined)
|
|
145
|
+
focusPane(pane.pane_id);
|
|
146
|
+
return {};
|
|
147
|
+
},
|
|
148
|
+
'pane.get': (p) => ({ pane: paneView(paneOf(p.pane_id)) }),
|
|
149
|
+
'pane.close': (p) => {
|
|
150
|
+
const pane = paneOf(p.pane_id);
|
|
151
|
+
live.panes = live.panes.filter((row) => row.pane_id !== pane.pane_id);
|
|
152
|
+
live.agents = live.agents.filter((row) => row.pane_id !== pane.pane_id);
|
|
153
|
+
relayoutTab(live, pane.tab_id);
|
|
154
|
+
return {};
|
|
155
|
+
},
|
|
156
|
+
'pane.focus': (p) => {
|
|
157
|
+
focusPane(paneOf(p.pane_id).pane_id);
|
|
158
|
+
return {};
|
|
159
|
+
},
|
|
160
|
+
'pane.split': (p) => {
|
|
161
|
+
const target = paneOf(p.target_pane_id);
|
|
162
|
+
const pane = addPane(target.tab_id, target.workspace_id, target.cwd);
|
|
163
|
+
if (p.focus === true)
|
|
164
|
+
focusPane(pane.pane_id);
|
|
165
|
+
return { pane: { pane_id: pane.pane_id } };
|
|
166
|
+
},
|
|
167
|
+
'pane.read': (p) => {
|
|
168
|
+
const pane = paneOf(p.pane_id);
|
|
169
|
+
const body = paneText(pane, p.source);
|
|
170
|
+
const lines = typeof p.lines === 'number' ? body.split('\n').slice(0, p.lines).join('\n') : body;
|
|
171
|
+
return { read: { text: lines, truncated: false } };
|
|
172
|
+
},
|
|
173
|
+
'pane.send_keys': (p) => {
|
|
174
|
+
const pane = paneOf(p.pane_id);
|
|
175
|
+
const keys = Array.isArray(p.keys) ? p.keys : [];
|
|
176
|
+
pane.text.push(keys.join(''));
|
|
177
|
+
return { pane_id: pane.pane_id, keys };
|
|
178
|
+
},
|
|
179
|
+
'pane.report_metadata': (p) => {
|
|
180
|
+
const pane = live.panes.find((row) => row.pane_id === p.pane_id);
|
|
181
|
+
if (pane !== undefined && p.tokens !== undefined && typeof p.tokens === 'object') {
|
|
182
|
+
pane.tokens = { ...pane.tokens, ...p.tokens };
|
|
183
|
+
}
|
|
184
|
+
return {};
|
|
185
|
+
},
|
|
186
|
+
'pane.zoom': (p) => {
|
|
187
|
+
paneOf(p.pane_id);
|
|
188
|
+
const mode = p.mode ?? 'toggle';
|
|
189
|
+
const next = mode === 'on' ? true : mode === 'off' ? false : !live.zoomed;
|
|
190
|
+
const changed = next !== live.zoomed;
|
|
191
|
+
live.zoomed = next;
|
|
192
|
+
return { zoom: { changed, zoomed: live.zoomed } };
|
|
193
|
+
},
|
|
194
|
+
'pane.layout': (p) => ({ layout: tabLayout(paneOf(p.pane_id).tab_id) }),
|
|
195
|
+
'agent.start': (p) => {
|
|
196
|
+
const pane = paneOf(p.pane_id);
|
|
197
|
+
const kind = typeof p.kind === 'string' && p.kind.length > 0 ? p.kind : 'pi';
|
|
198
|
+
const name = typeof p.name === 'string' && p.name.length > 0 ? p.name : kind;
|
|
199
|
+
pane.agent_status = 'idle';
|
|
200
|
+
pane.label = name;
|
|
201
|
+
pane.terminal_title = `${kind} · ${name}`;
|
|
202
|
+
pane.terminal_title_stripped = pane.terminal_title;
|
|
203
|
+
const agent = agentRecord({
|
|
204
|
+
paneId: pane.pane_id, tabId: pane.tab_id, workspaceId: pane.workspace_id, cwd: pane.cwd,
|
|
205
|
+
name, kind, revision: ++live.nextRevision,
|
|
206
|
+
});
|
|
207
|
+
const index = live.agents.findIndex((row) => row.pane_id === pane.pane_id);
|
|
208
|
+
if (index === -1)
|
|
209
|
+
live.agents.push(agent);
|
|
210
|
+
else
|
|
211
|
+
live.agents[index] = agent;
|
|
212
|
+
return { agent };
|
|
213
|
+
},
|
|
214
|
+
'agent.prompt': (p) => {
|
|
215
|
+
const pane = paneOf(p.target);
|
|
216
|
+
const agent = agentOf(pane);
|
|
217
|
+
transition(pane, 'working');
|
|
218
|
+
const text = typeof p.text === 'string' ? p.text : '';
|
|
219
|
+
const revision = agent !== undefined ? agent.revision : live.nextRevision;
|
|
220
|
+
timers.push(setTimeout(() => {
|
|
221
|
+
if (stopping)
|
|
222
|
+
return;
|
|
223
|
+
if (text === 'ask permission') {
|
|
224
|
+
if (agent !== undefined)
|
|
225
|
+
agent.detection = 'Allow this? (y/n)';
|
|
226
|
+
pane.text.push('Allow this? (y/n)');
|
|
227
|
+
transition(pane, 'blocked');
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
pane.text.push(`fake ${agent?.agent ?? 'pi'}: ${text}`);
|
|
231
|
+
transition(pane, 'idle');
|
|
232
|
+
}
|
|
233
|
+
}, PROMPT_SETTLE_MS));
|
|
234
|
+
return {
|
|
235
|
+
type: 'agent_prompted',
|
|
236
|
+
agent: {
|
|
237
|
+
terminal_id: `term-${pane.pane_id}`,
|
|
238
|
+
agent_status: 'working',
|
|
239
|
+
workspace_id: pane.workspace_id,
|
|
240
|
+
tab_id: pane.tab_id,
|
|
241
|
+
pane_id: pane.pane_id,
|
|
242
|
+
focused: pane.focused,
|
|
243
|
+
revision,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
'agent.wait': (p) => {
|
|
248
|
+
const paneId = p.target;
|
|
249
|
+
const statuses = ['idle', 'working', 'blocked', 'done', 'unknown'];
|
|
250
|
+
const until = Array.isArray(p.until) && p.until.length > 0 ? p.until : statuses;
|
|
251
|
+
const deadline = Date.now() + (typeof p.timeout_ms === 'number' ? p.timeout_ms : 0);
|
|
252
|
+
return new Promise((resolve) => {
|
|
253
|
+
const tick = () => {
|
|
254
|
+
const agent = live.agents.find((row) => row.pane_id === paneId);
|
|
255
|
+
if (stopping || Date.now() >= deadline || (agent !== undefined && until.includes(agent.agent_status))) {
|
|
256
|
+
resolve(agentView(paneId));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
timers.push(setTimeout(tick, WAIT_POLL_MS));
|
|
260
|
+
};
|
|
261
|
+
tick();
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
'agent.send_keys': (p) => {
|
|
265
|
+
const agent = live.agents.find((row) => row.pane_id === p.target);
|
|
266
|
+
const keys = Array.isArray(p.keys) ? p.keys : [];
|
|
267
|
+
if (agent !== undefined) {
|
|
268
|
+
const pane = live.panes.find((row) => row.pane_id === agent.pane_id);
|
|
269
|
+
if (pane !== undefined)
|
|
270
|
+
pane.text.push(keys.join(''));
|
|
271
|
+
// Answering a blocked agent resolves it (6.4: the kit never interprets the TUI, the agent does).
|
|
272
|
+
if (agent.agent_status === 'blocked' && keys.some((k) => k === 'y' || k === 'n')) {
|
|
273
|
+
agent.detection = undefined;
|
|
274
|
+
if (pane !== undefined)
|
|
275
|
+
transition(pane, 'idle');
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { target: p.target, keys };
|
|
279
|
+
},
|
|
280
|
+
'agent.list': () => ({ agents: live.agents.map((agent) => ({ ...agent })) }),
|
|
281
|
+
'agent.get': (p) => {
|
|
282
|
+
const agent = live.agents.find((row) => row.pane_id === p.target);
|
|
283
|
+
if (agent === undefined)
|
|
284
|
+
throw fail('agent_not_found', 'agent not found');
|
|
285
|
+
return { agent };
|
|
286
|
+
},
|
|
287
|
+
'server.agent_manifests': () => ({ manifests: kinds.map((kind) => ({ agent: kind })) }),
|
|
288
|
+
'server.stop': () => {
|
|
289
|
+
timers.push(setTimeout(() => { void shutdown(); }, 0));
|
|
290
|
+
return {};
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
async function shutdown() {
|
|
294
|
+
if (stopping)
|
|
295
|
+
return;
|
|
296
|
+
stopping = true;
|
|
297
|
+
for (const timer of timers)
|
|
298
|
+
clearTimeout(timer);
|
|
299
|
+
timers.length = 0;
|
|
300
|
+
for (const socket of sockets)
|
|
301
|
+
socket.destroy();
|
|
302
|
+
sockets.clear();
|
|
303
|
+
eventSubs.clear();
|
|
304
|
+
statusSubs.clear();
|
|
305
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
306
|
+
unlinkQuiet(socketPath);
|
|
307
|
+
options.onStop?.();
|
|
308
|
+
}
|
|
309
|
+
function handleLine(socket, line) {
|
|
310
|
+
let message;
|
|
311
|
+
try {
|
|
312
|
+
message = JSON.parse(line);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
socket.destroy();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const id = message.id;
|
|
319
|
+
const method = message.method;
|
|
320
|
+
const params = message.params ?? {};
|
|
321
|
+
if (typeof method !== 'string' || typeof params !== 'object' || params === null) {
|
|
322
|
+
socket.destroy();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (method === 'events.subscribe') {
|
|
326
|
+
subscribe(socket, id, params);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const handler = methods[method];
|
|
330
|
+
if (handler === undefined) {
|
|
331
|
+
writeJson(socket, { id, error: { code: 'unknown_method', message: `unknown method ${method}` } });
|
|
332
|
+
socket.end();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
void Promise.resolve()
|
|
336
|
+
.then(() => handler(params))
|
|
337
|
+
.then((result) => writeJson(socket, { id, result }))
|
|
338
|
+
.catch((error) => {
|
|
339
|
+
writeJson(socket, { id, error: { code: error.code ?? 'error', message: error.message } });
|
|
340
|
+
})
|
|
341
|
+
.finally(() => socket.end());
|
|
342
|
+
}
|
|
343
|
+
function subscribe(socket, id, params) {
|
|
344
|
+
const subs = Array.isArray(params.subscriptions) ? params.subscriptions : [];
|
|
345
|
+
const invalid = (message) => {
|
|
346
|
+
// A rejected subscribe answers `id: ""` once and the server closes; the client never retries it.
|
|
347
|
+
writeJson(socket, { id: '', error: { code: 'invalid_subscription', message } });
|
|
348
|
+
socket.end();
|
|
349
|
+
};
|
|
350
|
+
for (const s of subs) {
|
|
351
|
+
if (typeof s?.type !== 'string')
|
|
352
|
+
return invalid('every subscription needs a type');
|
|
353
|
+
if (s.type === 'pane.agent_status_changed' && typeof s.pane_id !== 'string') {
|
|
354
|
+
return invalid('pane.agent_status_changed needs a pane_id');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const filtered = subs.filter((s) => s.type === 'pane.agent_status_changed');
|
|
358
|
+
if (filtered.length > 0 && filtered.length !== subs.length) {
|
|
359
|
+
return invalid('pane.agent_status_changed cannot share a batch');
|
|
360
|
+
}
|
|
361
|
+
writeJson(socket, { id, result: { type: 'subscribed' } });
|
|
362
|
+
if (filtered.length > 0) {
|
|
363
|
+
for (const s of filtered)
|
|
364
|
+
statusSubs.add({ socket, paneId: s.pane_id });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
eventSubs.add({ socket, kinds: new Set(subs.map((s) => s.type)) });
|
|
368
|
+
}
|
|
369
|
+
function emitEvent(type, data) {
|
|
370
|
+
const frame = `${JSON.stringify({ event: type, data: { type, ...data } })}\n`;
|
|
371
|
+
for (const sub of eventSubs) {
|
|
372
|
+
if (sub.kinds.size > 0 && !sub.kinds.has(type))
|
|
373
|
+
continue;
|
|
374
|
+
try {
|
|
375
|
+
sub.socket.write(frame);
|
|
376
|
+
}
|
|
377
|
+
catch { /* closed mid-emit */ }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function emitStatus(paneId, agentStatus, revision) {
|
|
381
|
+
const frame = `${JSON.stringify({
|
|
382
|
+
event: 'pane.agent_status_changed',
|
|
383
|
+
data: { type: 'pane.agent_status_changed', pane_id: paneId, agent_status: agentStatus, revision },
|
|
384
|
+
})}\n`;
|
|
385
|
+
for (const sub of statusSubs) {
|
|
386
|
+
if (sub.paneId !== paneId)
|
|
387
|
+
continue;
|
|
388
|
+
try {
|
|
389
|
+
sub.socket.write(frame);
|
|
390
|
+
}
|
|
391
|
+
catch { /* closed mid-emit */ }
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function paneOf(paneId) {
|
|
395
|
+
const pane = live.panes.find((row) => row.pane_id === paneId);
|
|
396
|
+
if (pane === undefined)
|
|
397
|
+
throw fail('pane_not_found', 'pane not found');
|
|
398
|
+
return pane;
|
|
399
|
+
}
|
|
400
|
+
function agentOf(pane) {
|
|
401
|
+
return live.agents.find((row) => row.pane_id === pane.pane_id);
|
|
402
|
+
}
|
|
403
|
+
function agentView(paneId) {
|
|
404
|
+
const agent = live.agents.find((row) => row.pane_id === paneId);
|
|
405
|
+
if (agent !== undefined)
|
|
406
|
+
return { agent: { ...agent } };
|
|
407
|
+
const pane = live.panes.find((row) => row.pane_id === paneId);
|
|
408
|
+
return { agent: { pane_id: paneId, agent_status: pane?.agent_status ?? 'unknown', interactive_ready: true } };
|
|
409
|
+
}
|
|
410
|
+
function transition(pane, status) {
|
|
411
|
+
const agent = agentOf(pane);
|
|
412
|
+
pane.agent_status = status;
|
|
413
|
+
if (agent !== undefined) {
|
|
414
|
+
agent.agent_status = status;
|
|
415
|
+
agent.revision = ++live.nextRevision;
|
|
416
|
+
// A blocked agent always has detection text (6.4 reads it as the question), whichever path
|
|
417
|
+
// got it there — `agent.prompt` sets it first, `setStatus` arrives with none.
|
|
418
|
+
if (status === 'blocked' && agent.detection === undefined)
|
|
419
|
+
agent.detection = 'Allow this? (y/n)';
|
|
420
|
+
if (status !== 'blocked')
|
|
421
|
+
agent.detection = undefined;
|
|
422
|
+
emitStatus(pane.pane_id, status, agent.revision);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function paneText(pane, source) {
|
|
426
|
+
if (source === 'detection')
|
|
427
|
+
return agentOf(pane)?.detection ?? '';
|
|
428
|
+
return pane.text.join('\n');
|
|
429
|
+
}
|
|
430
|
+
function label(p) {
|
|
431
|
+
return typeof p.label === 'string' && p.label.length > 0 ? p.label : undefined;
|
|
432
|
+
}
|
|
433
|
+
function snapshotOf() {
|
|
434
|
+
return {
|
|
435
|
+
workspaces: live.workspaces.map(workspaceView),
|
|
436
|
+
tabs: live.tabs.map((tab) => ({ ...tab })),
|
|
437
|
+
panes: live.panes.map(paneView),
|
|
438
|
+
agents: live.agents.map((agent) => ({ ...agent })),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function addWorkspace(labelText) {
|
|
442
|
+
const workspaceId = `w${live.nextWorkspace++}`;
|
|
443
|
+
const workspace = {
|
|
444
|
+
workspace_id: workspaceId,
|
|
445
|
+
label: labelText,
|
|
446
|
+
focused: false,
|
|
447
|
+
worktree: {
|
|
448
|
+
// A unique key per plain workspace keeps it out of every worktree group; only
|
|
449
|
+
// worktree.create links a checkout back into the root workspace's group.
|
|
450
|
+
repo_key: `fake-${workspaceId}`,
|
|
451
|
+
repo_name: 'fake-herdr',
|
|
452
|
+
repo_root: labelText,
|
|
453
|
+
checkout_path: labelText,
|
|
454
|
+
is_linked_worktree: false,
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
live.workspaces.push(workspace);
|
|
458
|
+
return workspace;
|
|
459
|
+
}
|
|
460
|
+
function addTab(workspaceId, tabLabel) {
|
|
461
|
+
const existing = live.tabs.filter((tab) => tab.workspace_id === workspaceId).length;
|
|
462
|
+
const tab = { tab_id: `${workspaceId}:t${existing + 1}`, workspace_id: workspaceId, label: tabLabel };
|
|
463
|
+
live.tabs.push(tab);
|
|
464
|
+
return tab;
|
|
465
|
+
}
|
|
466
|
+
function addPane(tabId, workspaceId, paneCwd) {
|
|
467
|
+
const paneNum = workspaceId === 'w1'
|
|
468
|
+
? live.nextPane++
|
|
469
|
+
: live.panes.filter((row) => row.workspace_id === workspaceId).length + 1;
|
|
470
|
+
const pane = {
|
|
471
|
+
pane_id: `${workspaceId}:p${paneNum}`,
|
|
472
|
+
tab_id: tabId,
|
|
473
|
+
workspace_id: workspaceId,
|
|
474
|
+
cwd: paneCwd,
|
|
475
|
+
foreground_cwd: paneCwd,
|
|
476
|
+
label: 'zsh',
|
|
477
|
+
terminal_title: `zsh · ${paneCwd}`,
|
|
478
|
+
terminal_title_stripped: `zsh · ${paneCwd}`,
|
|
479
|
+
focused: false,
|
|
480
|
+
tokens: {},
|
|
481
|
+
rect: { x: 0, y: 0, width: 80, height: 24 },
|
|
482
|
+
text: ['ready.'],
|
|
483
|
+
};
|
|
484
|
+
live.panes.push(pane);
|
|
485
|
+
relayoutTab(live, tabId);
|
|
486
|
+
return pane;
|
|
487
|
+
}
|
|
488
|
+
function removeTab(tabId) {
|
|
489
|
+
const paneIds = live.panes.filter((pane) => pane.tab_id === tabId).map((pane) => pane.pane_id);
|
|
490
|
+
for (const paneId of paneIds) {
|
|
491
|
+
live.panes = live.panes.filter((row) => row.pane_id !== paneId);
|
|
492
|
+
live.agents = live.agents.filter((row) => row.pane_id !== paneId);
|
|
493
|
+
}
|
|
494
|
+
live.tabs = live.tabs.filter((tab) => tab.tab_id !== tabId);
|
|
495
|
+
}
|
|
496
|
+
function focusWorkspace(workspaceId) {
|
|
497
|
+
for (const workspace of live.workspaces)
|
|
498
|
+
workspace.focused = workspace.workspace_id === workspaceId;
|
|
499
|
+
const pane = live.panes.find((row) => row.workspace_id === workspaceId);
|
|
500
|
+
if (pane !== undefined)
|
|
501
|
+
focusPane(pane.pane_id);
|
|
502
|
+
}
|
|
503
|
+
function focusPane(paneId) {
|
|
504
|
+
const pane = live.panes.find((row) => row.pane_id === paneId);
|
|
505
|
+
if (pane === undefined)
|
|
506
|
+
return;
|
|
507
|
+
for (const row of live.panes)
|
|
508
|
+
row.focused = row.pane_id === paneId;
|
|
509
|
+
for (const workspace of live.workspaces)
|
|
510
|
+
workspace.focused = workspace.workspace_id === pane.workspace_id;
|
|
511
|
+
}
|
|
512
|
+
function tabLayout(tabId) {
|
|
513
|
+
const panes = live.panes.filter((pane) => pane.tab_id === tabId);
|
|
514
|
+
let width = 80;
|
|
515
|
+
let height = 24;
|
|
516
|
+
for (const pane of panes) {
|
|
517
|
+
width = Math.max(width, pane.rect.x + pane.rect.width);
|
|
518
|
+
height = Math.max(height, pane.rect.y + pane.rect.height);
|
|
519
|
+
}
|
|
520
|
+
const focused = panes.find((pane) => pane.focused) ?? panes[0];
|
|
521
|
+
return {
|
|
522
|
+
workspace_id: focused?.workspace_id,
|
|
523
|
+
tab_id: tabId,
|
|
524
|
+
focused_pane_id: focused?.pane_id,
|
|
525
|
+
zoomed: live.zoomed,
|
|
526
|
+
area: { x: 0, y: 0, width, height },
|
|
527
|
+
panes: panes.map((pane) => ({ pane_id: pane.pane_id, focused: pane.focused, rect: pane.rect })),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function workspaceView(workspace) {
|
|
531
|
+
const tabs = live.tabs.filter((tab) => tab.workspace_id === workspace.workspace_id);
|
|
532
|
+
const focusedPane = live.panes.find((pane) => pane.workspace_id === workspace.workspace_id && pane.focused);
|
|
533
|
+
return {
|
|
534
|
+
workspace_id: workspace.workspace_id,
|
|
535
|
+
label: workspace.label,
|
|
536
|
+
focused: workspace.focused,
|
|
537
|
+
tab_count: tabs.length,
|
|
538
|
+
active_tab_id: focusedPane?.tab_id ?? tabs[0]?.tab_id,
|
|
539
|
+
...(workspace.worktree === undefined ? {} : { worktree: workspace.worktree }),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
function tabView(tab) {
|
|
543
|
+
return {
|
|
544
|
+
...tab,
|
|
545
|
+
pane_count: live.panes.filter((pane) => pane.tab_id === tab.tab_id).length,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
socketPath,
|
|
550
|
+
bin,
|
|
551
|
+
world: freezeWorld(live),
|
|
552
|
+
emit(event) {
|
|
553
|
+
if (event.type === 'pane.agent_status_changed' && typeof event.pane_id === 'string') {
|
|
554
|
+
emitStatus(event.pane_id, String(event.agent_status ?? 'unknown'), Number(event.revision ?? 0));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const { type, ...data } = event;
|
|
558
|
+
emitEvent(type, data);
|
|
559
|
+
},
|
|
560
|
+
setStatus(paneId, status) {
|
|
561
|
+
const pane = live.panes.find((row) => row.pane_id === paneId);
|
|
562
|
+
if (pane !== undefined)
|
|
563
|
+
transition(pane, status);
|
|
564
|
+
},
|
|
565
|
+
stop: shutdown,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
function paneView(pane) {
|
|
569
|
+
return {
|
|
570
|
+
pane_id: pane.pane_id,
|
|
571
|
+
tab_id: pane.tab_id,
|
|
572
|
+
workspace_id: pane.workspace_id,
|
|
573
|
+
cwd: pane.cwd,
|
|
574
|
+
foreground_cwd: pane.foreground_cwd,
|
|
575
|
+
...(pane.agent_status === undefined ? {} : { agent_status: pane.agent_status }),
|
|
576
|
+
label: pane.label,
|
|
577
|
+
terminal_title: pane.terminal_title,
|
|
578
|
+
terminal_title_stripped: pane.terminal_title_stripped,
|
|
579
|
+
focused: pane.focused,
|
|
580
|
+
...(Object.keys(pane.tokens).length === 0 ? {} : { tokens: pane.tokens }),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
function fail(code, message) {
|
|
584
|
+
return Object.assign(new Error(message), { code });
|
|
585
|
+
}
|
|
586
|
+
function writeJson(socket, value) {
|
|
587
|
+
socket.write(`${JSON.stringify(value)}\n`);
|
|
588
|
+
}
|
|
589
|
+
function unlinkQuiet(path) {
|
|
590
|
+
try {
|
|
591
|
+
unlinkSync(path);
|
|
592
|
+
}
|
|
593
|
+
catch { /* absent */ }
|
|
594
|
+
}
|
|
595
|
+
function listen(server, socketPath) {
|
|
596
|
+
return new Promise((resolve, reject) => {
|
|
597
|
+
const onError = (error) => reject(error);
|
|
598
|
+
server.once('error', onError);
|
|
599
|
+
server.listen(socketPath, () => {
|
|
600
|
+
server.off('error', onError);
|
|
601
|
+
resolve();
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export type FakeRect = {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
};
|
|
7
|
+
export type FakePane = {
|
|
8
|
+
pane_id: string;
|
|
9
|
+
tab_id: string;
|
|
10
|
+
workspace_id: string;
|
|
11
|
+
cwd: string;
|
|
12
|
+
foreground_cwd: string;
|
|
13
|
+
agent_status?: string;
|
|
14
|
+
label: string;
|
|
15
|
+
terminal_title: string;
|
|
16
|
+
terminal_title_stripped: string;
|
|
17
|
+
focused: boolean;
|
|
18
|
+
tokens: Record<string, unknown>;
|
|
19
|
+
rect: FakeRect;
|
|
20
|
+
text: string[];
|
|
21
|
+
};
|
|
22
|
+
export type FakeAgent = {
|
|
23
|
+
pane_id: string;
|
|
24
|
+
tab_id: string;
|
|
25
|
+
workspace_id: string;
|
|
26
|
+
cwd: string;
|
|
27
|
+
foreground_cwd: string;
|
|
28
|
+
name: string;
|
|
29
|
+
agent: string;
|
|
30
|
+
display_agent: string;
|
|
31
|
+
agent_status: string;
|
|
32
|
+
interactive_ready: boolean;
|
|
33
|
+
launch_pending: boolean;
|
|
34
|
+
revision: number;
|
|
35
|
+
detection?: string;
|
|
36
|
+
agent_session: {
|
|
37
|
+
source: string;
|
|
38
|
+
agent: string;
|
|
39
|
+
kind: string;
|
|
40
|
+
value: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
export type FakeTab = {
|
|
44
|
+
tab_id: string;
|
|
45
|
+
workspace_id: string;
|
|
46
|
+
label: string;
|
|
47
|
+
};
|
|
48
|
+
export type FakeWorkspace = {
|
|
49
|
+
workspace_id: string;
|
|
50
|
+
label: string;
|
|
51
|
+
focused: boolean;
|
|
52
|
+
worktree?: {
|
|
53
|
+
repo_key: string;
|
|
54
|
+
repo_name: string;
|
|
55
|
+
repo_root: string;
|
|
56
|
+
checkout_path: string;
|
|
57
|
+
is_linked_worktree: boolean;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
export type FakeWorld = {
|
|
61
|
+
cwd: string;
|
|
62
|
+
workspaces: FakeWorkspace[];
|
|
63
|
+
tabs: FakeTab[];
|
|
64
|
+
panes: FakePane[];
|
|
65
|
+
agents: FakeAgent[];
|
|
66
|
+
};
|
|
67
|
+
export declare const COLS = 80;
|
|
68
|
+
export declare const ROWS = 24;
|
|
69
|
+
export declare function paneRect(indexInTab: number, panesInTab: number): FakeRect;
|
|
70
|
+
export declare function agentRecord(o: {
|
|
71
|
+
paneId: string;
|
|
72
|
+
tabId: string;
|
|
73
|
+
workspaceId: string;
|
|
74
|
+
cwd: string;
|
|
75
|
+
name: string;
|
|
76
|
+
kind: string;
|
|
77
|
+
revision: number;
|
|
78
|
+
}): FakeAgent;
|
|
79
|
+
export declare function createWorld(o: {
|
|
80
|
+
cwd: string;
|
|
81
|
+
kinds: readonly string[];
|
|
82
|
+
}): FakeWorld;
|
|
83
|
+
export declare function relayoutTab(world: FakeWorld, tabId: string): void;
|
|
84
|
+
export declare function freezeWorld(world: FakeWorld): FakeWorld;
|