@nonbot/cli 0.5.14 → 0.6.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 +32 -0
- package/dist/commands/choir.js +110 -0
- package/dist/commands/daemon.js +63 -3
- package/dist/index.js +6 -0
- package/dist/lib/choir/brief.js +55 -0
- package/dist/lib/choir/debounce.js +0 -0
- package/dist/lib/choir/health.js +59 -0
- package/dist/lib/choir/hub-reducer.js +190 -0
- package/dist/lib/choir/hub.js +478 -0
- package/dist/lib/choir/journal.js +59 -0
- package/dist/lib/choir/launcher.js +133 -0
- package/dist/lib/choir/names.js +39 -0
- package/dist/lib/choir/needs-you.js +210 -0
- package/dist/lib/choir/progress-events.js +228 -0
- package/dist/lib/choir/reconcile.js +271 -0
- package/dist/lib/choir/types.js +53 -0
- package/dist/lib/choir/worktree.js +128 -0
- package/dist/lib/completion.js +2 -1
- package/dist/lib/output.js +27 -7
- package/dist/lib/pane-title.js +51 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { appendFileSync as nodeAppendFileSync, statSync as nodeStatSync, renameSync as nodeRenameSync, writeFileSync as nodeWriteFileSync, } from 'node:fs';
|
|
4
|
+
import { applyAction, selectRadar } from './hub-reducer.js';
|
|
5
|
+
import { appendAction, replayJournal } from './journal.js';
|
|
6
|
+
import { pollGitStatus } from './worktree.js';
|
|
7
|
+
import { EmissionGate } from './debounce.js';
|
|
8
|
+
import { makeEvent, sanitizeForEgress, assertNoForbiddenFields } from './progress-events.js';
|
|
9
|
+
import { LOCAL_TEXT_MAX, SUMMARY_MAX, } from './types.js';
|
|
10
|
+
const DEFAULT_POLL_MS = 5_000;
|
|
11
|
+
const DEFAULT_HEARTBEAT_MS = 30_000;
|
|
12
|
+
const DEFAULT_WRITE_RATE_LIMIT = 60;
|
|
13
|
+
const DEFAULT_WRITE_RATE_WINDOW_MS = 60_000;
|
|
14
|
+
const READ_TOOLS = new Set(['choir_radar', 'choir_check', 'choir_status']);
|
|
15
|
+
const WRITE_TOOLS = new Set(['choir_announce', 'choir_release', 'choir_broadcast']);
|
|
16
|
+
const ALL_TOOLS = new Set([...READ_TOOLS, ...WRITE_TOOLS]);
|
|
17
|
+
function constantTimeEqual(a, b) {
|
|
18
|
+
if (typeof a !== 'string' || typeof b !== 'string')
|
|
19
|
+
return false;
|
|
20
|
+
if (a.length !== b.length) {
|
|
21
|
+
let acc = 1;
|
|
22
|
+
const n = Math.max(a.length, b.length);
|
|
23
|
+
for (let i = 0; i < n; i++) {
|
|
24
|
+
acc |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
|
|
25
|
+
}
|
|
26
|
+
return false && acc === 0;
|
|
27
|
+
}
|
|
28
|
+
let result = 0;
|
|
29
|
+
for (let i = 0; i < a.length; i++) {
|
|
30
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
31
|
+
}
|
|
32
|
+
return result === 0;
|
|
33
|
+
}
|
|
34
|
+
export function createHub(opts) {
|
|
35
|
+
const now = opts.now ?? (() => Date.now());
|
|
36
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
37
|
+
const spawnImpl = opts.spawnImpl;
|
|
38
|
+
const journalFs = opts.journalFs ?? {
|
|
39
|
+
appendFileSync: nodeAppendFileSync,
|
|
40
|
+
statSync: nodeStatSync,
|
|
41
|
+
renameSync: nodeRenameSync,
|
|
42
|
+
writeFileSync: nodeWriteFileSync,
|
|
43
|
+
};
|
|
44
|
+
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_MS;
|
|
45
|
+
const heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_MS;
|
|
46
|
+
const writeRateLimit = opts.writeRateLimit ?? DEFAULT_WRITE_RATE_LIMIT;
|
|
47
|
+
const writeRateWindowMs = opts.writeRateWindowMs ?? DEFAULT_WRITE_RATE_WINDOW_MS;
|
|
48
|
+
const sockPath = opts.sockPath ??
|
|
49
|
+
process.env.CHOIR_SOCK ??
|
|
50
|
+
path.join(homedir(), '.nonbot', 'choir.sock');
|
|
51
|
+
const journalPath = opts.journalPath ?? path.join(opts.repoRoot, '.choir', 'journal.ndjson');
|
|
52
|
+
const baseUrl = opts.baseUrl ?? process.env.NONBOT_BASE_URL ?? 'https://non.bot';
|
|
53
|
+
const pat = opts.pat ?? process.env.NONBOT_PAT ?? '';
|
|
54
|
+
let state = bootstrapState();
|
|
55
|
+
const rateWindows = new Map();
|
|
56
|
+
const gate = new EmissionGate();
|
|
57
|
+
let pendingEvents = [];
|
|
58
|
+
let server = null;
|
|
59
|
+
let pollTimer = null;
|
|
60
|
+
let heartbeatTimer = null;
|
|
61
|
+
let egressTimer = null;
|
|
62
|
+
function bootstrapState() {
|
|
63
|
+
let recovered;
|
|
64
|
+
try {
|
|
65
|
+
const text = safeReadJournal();
|
|
66
|
+
if (text) {
|
|
67
|
+
recovered = replayJournal(text.split('\n'), applyAction, undefined);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
recovered = undefined;
|
|
72
|
+
}
|
|
73
|
+
if (recovered && recovered.sessionId)
|
|
74
|
+
return recovered;
|
|
75
|
+
return applyAction(undefined, {
|
|
76
|
+
type: 'session-init',
|
|
77
|
+
sessionId: opts.sessionId,
|
|
78
|
+
repoRoot: opts.repoRoot,
|
|
79
|
+
baseBranch: opts.baseBranch,
|
|
80
|
+
ts: now(),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function safeReadJournal() {
|
|
84
|
+
try {
|
|
85
|
+
const anyFs = journalFs;
|
|
86
|
+
if (typeof anyFs.readFileSync === 'function') {
|
|
87
|
+
return anyFs.readFileSync(journalPath, 'utf-8');
|
|
88
|
+
}
|
|
89
|
+
const fs = require('node:fs');
|
|
90
|
+
if (!fs.existsSync(journalPath))
|
|
91
|
+
return null;
|
|
92
|
+
return fs.readFileSync(journalPath, 'utf-8');
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function dispatch(action) {
|
|
99
|
+
const before = state;
|
|
100
|
+
let next;
|
|
101
|
+
try {
|
|
102
|
+
next = applyAction(state, action);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (next === before)
|
|
108
|
+
return;
|
|
109
|
+
state = next;
|
|
110
|
+
try {
|
|
111
|
+
appendAction(journalPath, action, journalFs);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
enqueueEgress(action);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function handleCall(ident, tool, claimedPaneId, args) {
|
|
122
|
+
if (!ident.authenticated || !ident.paneId) {
|
|
123
|
+
return { ok: false, error: 'not authenticated' };
|
|
124
|
+
}
|
|
125
|
+
if (!ALL_TOOLS.has(tool)) {
|
|
126
|
+
return { ok: false, error: 'unknown tool' };
|
|
127
|
+
}
|
|
128
|
+
if (claimedPaneId !== ident.paneId) {
|
|
129
|
+
return { ok: false, error: 'pane identity mismatch' };
|
|
130
|
+
}
|
|
131
|
+
const paneId = ident.paneId;
|
|
132
|
+
if (WRITE_TOOLS.has(tool) && !allowWrite(paneId)) {
|
|
133
|
+
return { ok: false, error: 'rate limited' };
|
|
134
|
+
}
|
|
135
|
+
const ts = now();
|
|
136
|
+
switch (tool) {
|
|
137
|
+
case 'choir_radar':
|
|
138
|
+
return { ok: true, result: selectRadar(state) };
|
|
139
|
+
case 'choir_check': {
|
|
140
|
+
const paths = toStringArray(args.paths);
|
|
141
|
+
const overlaps = state.claims
|
|
142
|
+
.filter((c) => c.paneId !== paneId && c.paths.some((p) => paths.includes(p)))
|
|
143
|
+
.map((c) => ({
|
|
144
|
+
paneId: c.paneId,
|
|
145
|
+
area: c.area,
|
|
146
|
+
overlap: c.paths.filter((p) => paths.includes(p)),
|
|
147
|
+
}));
|
|
148
|
+
return { ok: true, result: { overlaps } };
|
|
149
|
+
}
|
|
150
|
+
case 'choir_status': {
|
|
151
|
+
const pane = state.panes[paneId];
|
|
152
|
+
if (!pane)
|
|
153
|
+
return { ok: false, error: 'unknown pane' };
|
|
154
|
+
return {
|
|
155
|
+
ok: true,
|
|
156
|
+
result: {
|
|
157
|
+
paneId: pane.paneId,
|
|
158
|
+
branch: pane.branch,
|
|
159
|
+
worktreePath: pane.worktreePath,
|
|
160
|
+
status: pane.status,
|
|
161
|
+
dirtyFiles: pane.dirtyFiles,
|
|
162
|
+
commitsAhead: pane.commitsAhead,
|
|
163
|
+
mergeReady: pane.mergeReady,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
case 'choir_announce': {
|
|
168
|
+
dispatch({
|
|
169
|
+
type: 'announce',
|
|
170
|
+
paneId,
|
|
171
|
+
area: clampLocal(String(args.area ?? '')),
|
|
172
|
+
paths: toStringArray(args.paths),
|
|
173
|
+
summary: clampSummary(String(args.summary ?? '')),
|
|
174
|
+
ts,
|
|
175
|
+
});
|
|
176
|
+
return { ok: true, result: selectRadar(state) };
|
|
177
|
+
}
|
|
178
|
+
case 'choir_release': {
|
|
179
|
+
dispatch({ type: 'release', paneId, paths: toStringArray(args.paths), ts });
|
|
180
|
+
return { ok: true, result: selectRadar(state) };
|
|
181
|
+
}
|
|
182
|
+
case 'choir_broadcast': {
|
|
183
|
+
const kind = args.kind === 'contract-change' ? 'contract-change' : 'note';
|
|
184
|
+
dispatch({
|
|
185
|
+
type: 'broadcast',
|
|
186
|
+
paneId,
|
|
187
|
+
kind,
|
|
188
|
+
msg: clampLocal(String(args.msg ?? '')),
|
|
189
|
+
ts,
|
|
190
|
+
});
|
|
191
|
+
return { ok: true, result: selectRadar(state) };
|
|
192
|
+
}
|
|
193
|
+
default:
|
|
194
|
+
return { ok: false, error: 'unknown tool' };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function allowWrite(paneId) {
|
|
198
|
+
const t = now();
|
|
199
|
+
let w = rateWindows.get(paneId);
|
|
200
|
+
if (!w || t - w.windowStart >= writeRateWindowMs) {
|
|
201
|
+
w = { count: 0, windowStart: t };
|
|
202
|
+
rateWindows.set(paneId, w);
|
|
203
|
+
}
|
|
204
|
+
if (w.count >= writeRateLimit)
|
|
205
|
+
return false;
|
|
206
|
+
w.count += 1;
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
function onConnection(sock) {
|
|
210
|
+
const ident = { authenticated: false, paneId: null };
|
|
211
|
+
let buffer = '';
|
|
212
|
+
const respond = (obj) => {
|
|
213
|
+
try {
|
|
214
|
+
sock.write(JSON.stringify(obj) + '\n');
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const onData = (chunk) => {
|
|
220
|
+
try {
|
|
221
|
+
buffer += chunk.toString();
|
|
222
|
+
let nl;
|
|
223
|
+
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
224
|
+
const line = buffer.slice(0, nl).trim();
|
|
225
|
+
buffer = buffer.slice(nl + 1);
|
|
226
|
+
if (line === '')
|
|
227
|
+
continue;
|
|
228
|
+
handleLine(line);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const handleLine = (line) => {
|
|
235
|
+
let msg;
|
|
236
|
+
try {
|
|
237
|
+
msg = JSON.parse(line);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
respond({ ok: false, error: 'bad json' });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (msg && msg.type === 'hello') {
|
|
244
|
+
const tokenOk = constantTimeEqual(String(msg.token ?? ''), opts.token);
|
|
245
|
+
const pane = state.panes[String(msg.paneId ?? '')];
|
|
246
|
+
const nonceOk = !!pane && constantTimeEqual(String(msg.nonce ?? ''), pane.nonce);
|
|
247
|
+
if (!tokenOk || !nonceOk) {
|
|
248
|
+
respond({ ok: false, error: 'unauthorized' });
|
|
249
|
+
try {
|
|
250
|
+
sock.destroy();
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
ident.authenticated = true;
|
|
257
|
+
ident.paneId = String(msg.paneId);
|
|
258
|
+
respond({ ok: true, result: { bound: ident.paneId } });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (msg && msg.type === 'call') {
|
|
262
|
+
const res = handleCall(ident, String(msg.tool ?? ''), String(msg.paneId ?? ''), msg.args ?? {});
|
|
263
|
+
respond(res);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
respond({ ok: false, error: 'unknown message type' });
|
|
267
|
+
};
|
|
268
|
+
try {
|
|
269
|
+
sock.on('data', onData);
|
|
270
|
+
sock.on('error', () => {
|
|
271
|
+
});
|
|
272
|
+
sock.on('close', () => {
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function pollOnce() {
|
|
279
|
+
if (!spawnImpl)
|
|
280
|
+
return;
|
|
281
|
+
for (const pane of Object.values(state.panes)) {
|
|
282
|
+
try {
|
|
283
|
+
const poll = pollGitStatus(pane.worktreePath, spawnImpl, opts.baseBranch);
|
|
284
|
+
dispatch({
|
|
285
|
+
type: 'infer',
|
|
286
|
+
paneId: pane.paneId,
|
|
287
|
+
paths: poll.dirtyPaths,
|
|
288
|
+
dirtyFiles: poll.dirtyFiles,
|
|
289
|
+
commitsAhead: poll.commitsAhead,
|
|
290
|
+
ts: now(),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function enqueueEgress(action) {
|
|
298
|
+
const mapping = stageForAction(action);
|
|
299
|
+
if (!mapping)
|
|
300
|
+
return;
|
|
301
|
+
const ts = now();
|
|
302
|
+
const event = makeEvent({
|
|
303
|
+
sessionId: state.sessionId,
|
|
304
|
+
engine: 'choir',
|
|
305
|
+
paneId: 'paneId' in action ? action.paneId : null,
|
|
306
|
+
stage: mapping.stage,
|
|
307
|
+
summary: mapping.summary,
|
|
308
|
+
ts,
|
|
309
|
+
metrics: mapping.metrics,
|
|
310
|
+
refs: mapping.branch ? { branch: mapping.branch } : undefined,
|
|
311
|
+
}, { now });
|
|
312
|
+
const decision = gate.consider(event, ts, mapping.hints);
|
|
313
|
+
if (decision.action === 'suppress')
|
|
314
|
+
return;
|
|
315
|
+
pendingEvents.push(event);
|
|
316
|
+
}
|
|
317
|
+
function stageForAction(action) {
|
|
318
|
+
switch (action.type) {
|
|
319
|
+
case 'register': {
|
|
320
|
+
const pane = state.panes[action.paneId];
|
|
321
|
+
return {
|
|
322
|
+
stage: 'pane-spawned',
|
|
323
|
+
summary: `pane ${pane?.name ?? ''} spawned`,
|
|
324
|
+
branch: pane?.branch,
|
|
325
|
+
metrics: { paneTotal: Object.keys(state.panes).length },
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
case 'announce':
|
|
329
|
+
return {
|
|
330
|
+
stage: 'claim-announced',
|
|
331
|
+
summary: clampSummary(action.summary || `claim on ${action.area}`),
|
|
332
|
+
metrics: { claimCount: state.claims.length },
|
|
333
|
+
};
|
|
334
|
+
case 'broadcast':
|
|
335
|
+
return {
|
|
336
|
+
stage: 'broadcast-sent',
|
|
337
|
+
summary: action.kind === 'contract-change' ? 'contract change broadcast' : 'broadcast sent',
|
|
338
|
+
};
|
|
339
|
+
case 'status': {
|
|
340
|
+
const pane = state.panes[action.paneId];
|
|
341
|
+
if (action.status === 'complete')
|
|
342
|
+
return { stage: 'pane-complete', summary: 'pane complete', branch: pane?.branch };
|
|
343
|
+
if (action.status === 'failed')
|
|
344
|
+
return { stage: 'pane-failed', summary: 'pane failed', branch: pane?.branch };
|
|
345
|
+
if (action.status === 'stopped')
|
|
346
|
+
return { stage: 'pane-stopped', summary: 'pane stopped', branch: pane?.branch };
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
default:
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function flushEgress() {
|
|
354
|
+
if (pendingEvents.length === 0)
|
|
355
|
+
return;
|
|
356
|
+
const batch = pendingEvents.map((e) => sanitizeForEgress(e));
|
|
357
|
+
pendingEvents = [];
|
|
358
|
+
const safe = [];
|
|
359
|
+
for (const ev of batch) {
|
|
360
|
+
try {
|
|
361
|
+
assertNoForbiddenFields(ev);
|
|
362
|
+
safe.push(ev);
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (safe.length === 0)
|
|
368
|
+
return;
|
|
369
|
+
postJson(`${baseUrl}/api/cli/choir/events`, { sessionId: state.sessionId, events: safe });
|
|
370
|
+
}
|
|
371
|
+
function sendHeartbeat() {
|
|
372
|
+
const panes = Object.values(state.panes);
|
|
373
|
+
const body = {
|
|
374
|
+
sessionId: state.sessionId,
|
|
375
|
+
lastEventSeq: state.lastEventSeq,
|
|
376
|
+
ts: now(),
|
|
377
|
+
metrics: {
|
|
378
|
+
paneTotal: panes.length,
|
|
379
|
+
panesComplete: panes.filter((p) => p.status === 'complete').length,
|
|
380
|
+
overlapCount: state.collisions.length,
|
|
381
|
+
claimCount: state.claims.length,
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
const safe = {
|
|
385
|
+
sessionId: body.sessionId,
|
|
386
|
+
lastEventSeq: body.lastEventSeq,
|
|
387
|
+
ts: body.ts,
|
|
388
|
+
metrics: sanitizeForEgress({ metrics: body.metrics }).metrics ?? {},
|
|
389
|
+
};
|
|
390
|
+
try {
|
|
391
|
+
assertNoForbiddenFields(safe);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
postJson(`${baseUrl}/api/cli/choir/heartbeat`, safe);
|
|
397
|
+
}
|
|
398
|
+
function postJson(url, body) {
|
|
399
|
+
try {
|
|
400
|
+
const p = fetchImpl(url, {
|
|
401
|
+
method: 'POST',
|
|
402
|
+
headers: {
|
|
403
|
+
Authorization: `Bearer ${pat}`,
|
|
404
|
+
'Content-Type': 'application/json',
|
|
405
|
+
'X-Requested-With': 'ConradPM-Native',
|
|
406
|
+
'X-Requested-With-Engine': 'choir',
|
|
407
|
+
},
|
|
408
|
+
body: JSON.stringify(body),
|
|
409
|
+
});
|
|
410
|
+
if (p && typeof p.catch === 'function') {
|
|
411
|
+
;
|
|
412
|
+
p.catch(() => {
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
start() {
|
|
421
|
+
try {
|
|
422
|
+
const impl = opts.netImpl;
|
|
423
|
+
if (!impl)
|
|
424
|
+
return;
|
|
425
|
+
server = impl.createServer((sock) => onConnection(sock));
|
|
426
|
+
server.listen(sockPath, () => {
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
server = null;
|
|
431
|
+
}
|
|
432
|
+
if (opts.autoTimers) {
|
|
433
|
+
try {
|
|
434
|
+
pollTimer = setInterval(() => pollOnce(), pollIntervalMs);
|
|
435
|
+
pollTimer.unref?.();
|
|
436
|
+
egressTimer = setInterval(() => flushEgress(), Math.min(pollIntervalMs, 5_000));
|
|
437
|
+
egressTimer.unref?.();
|
|
438
|
+
heartbeatTimer = setInterval(() => sendHeartbeat(), heartbeatIntervalMs);
|
|
439
|
+
heartbeatTimer.unref?.();
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
stop() {
|
|
446
|
+
try {
|
|
447
|
+
if (pollTimer)
|
|
448
|
+
clearInterval(pollTimer);
|
|
449
|
+
if (egressTimer)
|
|
450
|
+
clearInterval(egressTimer);
|
|
451
|
+
if (heartbeatTimer)
|
|
452
|
+
clearInterval(heartbeatTimer);
|
|
453
|
+
server?.close(() => {
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
getState() {
|
|
460
|
+
return state;
|
|
461
|
+
},
|
|
462
|
+
dispatch,
|
|
463
|
+
pollOnce,
|
|
464
|
+
flushEgress,
|
|
465
|
+
sendHeartbeat,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
function toStringArray(v) {
|
|
469
|
+
if (!Array.isArray(v))
|
|
470
|
+
return [];
|
|
471
|
+
return v.filter((x) => typeof x === 'string');
|
|
472
|
+
}
|
|
473
|
+
function clampLocal(s) {
|
|
474
|
+
return s.slice(0, LOCAL_TEXT_MAX);
|
|
475
|
+
}
|
|
476
|
+
function clampSummary(s) {
|
|
477
|
+
return s.slice(0, SUMMARY_MAX);
|
|
478
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { appendFileSync as nodeAppendFileSync, statSync as nodeStatSync, renameSync as nodeRenameSync, writeFileSync as nodeWriteFileSync, } from 'node:fs';
|
|
2
|
+
export const JOURNAL_MODE = 0o600;
|
|
3
|
+
export const JOURNAL_SIZE_CAP = 2 * 1024 * 1024;
|
|
4
|
+
export function serializeAction(action) {
|
|
5
|
+
return JSON.stringify(action) + '\n';
|
|
6
|
+
}
|
|
7
|
+
export function parseJournal(text) {
|
|
8
|
+
return parseLines(text.split('\n'));
|
|
9
|
+
}
|
|
10
|
+
function parseLines(lines) {
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const raw of lines) {
|
|
13
|
+
const line = raw.trim();
|
|
14
|
+
if (line === '')
|
|
15
|
+
continue;
|
|
16
|
+
try {
|
|
17
|
+
out.push(JSON.parse(line));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
export function replayJournal(lines, apply, initial) {
|
|
25
|
+
const actions = parseLines(lines);
|
|
26
|
+
let state = initial;
|
|
27
|
+
for (const action of actions) {
|
|
28
|
+
state = apply(state, action);
|
|
29
|
+
}
|
|
30
|
+
return state;
|
|
31
|
+
}
|
|
32
|
+
const defaultFs = {
|
|
33
|
+
appendFileSync: nodeAppendFileSync,
|
|
34
|
+
statSync: nodeStatSync,
|
|
35
|
+
renameSync: nodeRenameSync,
|
|
36
|
+
writeFileSync: nodeWriteFileSync,
|
|
37
|
+
};
|
|
38
|
+
export function appendAction(filePath, action, fs = defaultFs) {
|
|
39
|
+
const line = serializeAction(action);
|
|
40
|
+
const size = currentSize(filePath, fs);
|
|
41
|
+
if (size === null) {
|
|
42
|
+
fs.writeFileSync(filePath, line, { mode: JOURNAL_MODE });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (size > JOURNAL_SIZE_CAP) {
|
|
46
|
+
fs.renameSync(filePath, `${filePath}.1`);
|
|
47
|
+
fs.writeFileSync(filePath, line, { mode: JOURNAL_MODE });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
fs.appendFileSync(filePath, line);
|
|
51
|
+
}
|
|
52
|
+
function currentSize(filePath, fs) {
|
|
53
|
+
try {
|
|
54
|
+
return fs.statSync(filePath).size;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { spawnSync as nodeSpawnSync } from 'node:child_process';
|
|
2
|
+
import nodeFs from 'node:fs';
|
|
3
|
+
import nodePath from 'node:path';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { assertValidName, assertValidBranch } from './names.js';
|
|
6
|
+
import { addWorktree as defaultAddWorktree } from './worktree.js';
|
|
7
|
+
import { buildChoirBrief } from './brief.js';
|
|
8
|
+
const defaultFs = {
|
|
9
|
+
mkdirSync: (p, opts) => nodeFs.mkdirSync(p, opts),
|
|
10
|
+
writeFileSync: (p, data, opts) => nodeFs.writeFileSync(p, data, opts),
|
|
11
|
+
};
|
|
12
|
+
const MAX_PANES = 16;
|
|
13
|
+
function defaultRandom() {
|
|
14
|
+
return randomBytes(24).toString('hex');
|
|
15
|
+
}
|
|
16
|
+
function buildPaneShell(worktreePath, briefRelPath) {
|
|
17
|
+
const safeWt = worktreePath.replace(/'/g, `'\\''`);
|
|
18
|
+
const safeBrief = briefRelPath.replace(/'/g, `'\\''`);
|
|
19
|
+
const prompt = `Read ${safeBrief} (your Choir brief) and start work in this worktree.`;
|
|
20
|
+
const safePrompt = prompt.replace(/'/g, `'\\''`);
|
|
21
|
+
return (`cd '${safeWt}' && claude '${safePrompt}'` +
|
|
22
|
+
`; printf '\\n%s\\n' 'Choir pane finished — press enter to close'` +
|
|
23
|
+
`; read _` +
|
|
24
|
+
`; tmux kill-pane`);
|
|
25
|
+
}
|
|
26
|
+
function spawnPane(spawnImpl, shell, env) {
|
|
27
|
+
const r = spawnImpl('tmux', ['split-window', '-P', '-F', '#{pane_id}', shell, ';', 'select-layout', 'tiled'], {
|
|
28
|
+
encoding: 'utf-8',
|
|
29
|
+
timeout: 15_000,
|
|
30
|
+
windowsHide: true,
|
|
31
|
+
env,
|
|
32
|
+
});
|
|
33
|
+
if (r.status !== 0) {
|
|
34
|
+
throw new Error('choir pane spawn failed (tmux split-window)');
|
|
35
|
+
}
|
|
36
|
+
const out = typeof r.stdout === 'string' ? r.stdout : '';
|
|
37
|
+
const first = out.trim().split(/\s+/)[0] ?? '';
|
|
38
|
+
if (!/^%\d+$/.test(first)) {
|
|
39
|
+
throw new Error('choir pane spawn produced no pane id');
|
|
40
|
+
}
|
|
41
|
+
return first;
|
|
42
|
+
}
|
|
43
|
+
function buildMcpJson() {
|
|
44
|
+
const obj = {
|
|
45
|
+
mcpServers: {
|
|
46
|
+
choir: {
|
|
47
|
+
command: 'npx',
|
|
48
|
+
args: ['-y', '@nonbot/choir-mcp'],
|
|
49
|
+
env: {
|
|
50
|
+
CHOIR_SESSION_TOKEN: '',
|
|
51
|
+
CHOIR_PANE_NONCE: '',
|
|
52
|
+
CHOIR_PANE_ID: '',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
return JSON.stringify(obj, null, 2);
|
|
58
|
+
}
|
|
59
|
+
export function launchChoir(args) {
|
|
60
|
+
const { repoRoot, sessionName: rawSession, paneCount, baseBranch: rawBase, joinMode, spawnImpl = nodeSpawnSync, fsImpl = defaultFs, randomImpl = defaultRandom, addWorktreeImpl = defaultAddWorktree, startHub, } = args;
|
|
61
|
+
const sessionName = assertValidName(rawSession, 'session');
|
|
62
|
+
assertValidBranch(rawBase, 'base branch');
|
|
63
|
+
const baseBranch = rawBase;
|
|
64
|
+
if (!Number.isInteger(paneCount) ||
|
|
65
|
+
paneCount < 1 ||
|
|
66
|
+
paneCount > MAX_PANES) {
|
|
67
|
+
throw new Error(`choir pane count must be an integer in 1..${MAX_PANES}`);
|
|
68
|
+
}
|
|
69
|
+
const paneNames = Array.from({ length: paneCount }, (_v, i) => assertValidName(`pane${i + 1}`, 'pane'));
|
|
70
|
+
const token = randomImpl();
|
|
71
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
72
|
+
throw new Error('choir token minting produced an empty token');
|
|
73
|
+
}
|
|
74
|
+
const sessionId = `choir_${sessionName}_${token.slice(0, 8)}`;
|
|
75
|
+
const panes = [];
|
|
76
|
+
for (const paneName of paneNames) {
|
|
77
|
+
const { worktreePath, branch } = addWorktreeImpl({
|
|
78
|
+
repoRoot,
|
|
79
|
+
sessionName,
|
|
80
|
+
paneName,
|
|
81
|
+
baseBranch,
|
|
82
|
+
});
|
|
83
|
+
const nonce = randomImpl();
|
|
84
|
+
const briefRelPath = nodePath.join('.choir', `brief-${paneName}.md`);
|
|
85
|
+
const briefAbsPath = nodePath.join(worktreePath, briefRelPath);
|
|
86
|
+
const brief = buildChoirBrief({ paneName, sessionName, area: paneName });
|
|
87
|
+
fsImpl.mkdirSync(nodePath.dirname(briefAbsPath), { recursive: true });
|
|
88
|
+
fsImpl.writeFileSync(briefAbsPath, brief, { mode: 0o600 });
|
|
89
|
+
if (joinMode === 'launched-only') {
|
|
90
|
+
const mcpPath = nodePath.join(worktreePath, '.mcp.json');
|
|
91
|
+
fsImpl.writeFileSync(mcpPath, buildMcpJson(), { mode: 0o600 });
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
}
|
|
95
|
+
const shell = buildPaneShell(worktreePath, briefRelPath);
|
|
96
|
+
const childEnv = {
|
|
97
|
+
...process.env,
|
|
98
|
+
CHOIR_SESSION_TOKEN: token,
|
|
99
|
+
CHOIR_PANE_NONCE: nonce,
|
|
100
|
+
CHOIR_PANE_ID: branch,
|
|
101
|
+
};
|
|
102
|
+
const paneId = spawnPane(spawnImpl, shell, childEnv);
|
|
103
|
+
panes.push({ name: paneName, paneId, branch, worktreePath });
|
|
104
|
+
}
|
|
105
|
+
if (startHub !== false) {
|
|
106
|
+
const start = startHub ?? resolveDefaultStartHub();
|
|
107
|
+
start({ repoRoot, sessionId, baseBranch, token });
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
sessionName,
|
|
111
|
+
sessionId,
|
|
112
|
+
repoRoot,
|
|
113
|
+
baseBranch,
|
|
114
|
+
joinMode,
|
|
115
|
+
panes,
|
|
116
|
+
tokenForTest: token,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function resolveDefaultStartHub() {
|
|
120
|
+
return ((arg) => {
|
|
121
|
+
return import('./hub.js')
|
|
122
|
+
.then((m) => {
|
|
123
|
+
const mod = m;
|
|
124
|
+
if (typeof mod.createHub !== 'function') {
|
|
125
|
+
throw new Error('choir hub module is missing createHub');
|
|
126
|
+
}
|
|
127
|
+
return mod.createHub(arg);
|
|
128
|
+
})
|
|
129
|
+
.catch((e) => {
|
|
130
|
+
throw new Error(`choir hub failed to start: ${e instanceof Error ? e.message : 'unknown'}`);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|