chord-cli 0.1.2 → 0.2.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/README.md +23 -4
- package/README.zh-CN.md +169 -0
- package/dist/clean.js +14 -12
- package/dist/cli.js +158 -57
- package/dist/db.js +56 -22
- package/dist/hooks.js +45 -9
- package/dist/launch.js +15 -9
- package/dist/pty.js +52 -13
- package/dist/serve.js +2 -1
- package/dist/tools.js +51 -15
- package/dist/trust.js +64 -34
- package/package.json +5 -2
package/dist/launch.js
CHANGED
|
@@ -16,7 +16,6 @@ const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8',
|
|
|
16
16
|
const here = (f) => fs.realpathSync.native(fileURLToPath(new URL(f + path.extname(fileURLToPath(import.meta.url)), import.meta.url))).split(path.sep).join('/');
|
|
17
17
|
export const HOOKS_TS = here('./hooks');
|
|
18
18
|
export const SERVE_TS = here('./serve');
|
|
19
|
-
const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
20
19
|
/** `staleWaitMs`: how long to watch a stale heartbeat before calling its agent dead (tests shorten it). */
|
|
21
20
|
export function pickName(d, tool, project, override, staleWaitMs = 2500) {
|
|
22
21
|
if (override) {
|
|
@@ -36,7 +35,7 @@ export function pickName(d, tool, project, override, staleWaitMs = 2500) {
|
|
|
36
35
|
// (a live one writes every second), and this launch itself proves the machine is awake.
|
|
37
36
|
const stale = clash.heartbeat_at !== null && Date.now() - clash.heartbeat_at > db.HEARTBEAT_STALE_MS;
|
|
38
37
|
if (stale)
|
|
39
|
-
sleepSync(staleWaitMs);
|
|
38
|
+
db.sleepSync(staleWaitMs);
|
|
40
39
|
const again = stale ? db.getAgent(d, clash.name) : clash;
|
|
41
40
|
if (!stale || again?.heartbeat_at !== clash.heartbeat_at)
|
|
42
41
|
throw new Error(`agent "${override}" is already online`);
|
|
@@ -211,8 +210,9 @@ export function claudeArgs(env, projectConfig = false) {
|
|
|
211
210
|
// chord's own tools are pre-approved: in the default permission mode every claim, send and finish would otherwise
|
|
212
211
|
// stop at a prompt, and an unattended agent would never get past the first one
|
|
213
212
|
const ours = { hooks, permissions: { allow: ['mcp__chord'] } };
|
|
214
|
-
// The agent's cwd is
|
|
215
|
-
//
|
|
213
|
+
// The agent's cwd is the project folder, not the repository, so Claude Code would not pick up the repository's
|
|
214
|
+
// skills, commands and agents under .claude/, nor its settings*.json or .mcp.json. --add-dir covers the first group
|
|
215
|
+
// (the briefing carries CLAUDE.md itself); the others are passed along.
|
|
216
216
|
const wt = env.CHORD_WORKTREE;
|
|
217
217
|
const addDir = wt ? ['--add-dir', wt] : [];
|
|
218
218
|
const source = projectConfig === true ? { repo: wt ?? '' } : projectConfig || null;
|
|
@@ -345,7 +345,8 @@ export function prepare(tool, cwd, override) {
|
|
|
345
345
|
}
|
|
346
346
|
}
|
|
347
347
|
/** <project>/chord/config.json. `setup` is a shell command run in every new worktree before the agent starts,
|
|
348
|
-
* for example "npm install".
|
|
348
|
+
* for example "npm install". `test` is a shell command finish runs in the worktree after rebasing onto main and
|
|
349
|
+
* before fast-forwarding it, for example "npm test"; when it fails nothing is merged. */
|
|
349
350
|
export function readConfig(project) {
|
|
350
351
|
const file = path.join(project.dir, 'chord', 'config.json');
|
|
351
352
|
let raw;
|
|
@@ -364,10 +365,15 @@ export function readConfig(project) {
|
|
|
364
365
|
}
|
|
365
366
|
if (typeof cfg !== 'object' || cfg === null)
|
|
366
367
|
throw new Error(`${file} must hold a JSON object`);
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
368
|
+
const out = {};
|
|
369
|
+
for (const key of ['setup', 'test']) {
|
|
370
|
+
const v = cfg[key];
|
|
371
|
+
if (v !== undefined && typeof v !== 'string')
|
|
372
|
+
throw new Error(`${file}: "${key}" must be a string (a shell command)`);
|
|
373
|
+
if (v)
|
|
374
|
+
out[key] = v;
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
371
377
|
}
|
|
372
378
|
/** Where `chord clean` and a relaunch look to see whether the setup command has run for this agent. */
|
|
373
379
|
export const setupMarker = (project, name) => path.join(project.stateDir, 'setup-done', name);
|
package/dist/pty.js
CHANGED
|
@@ -142,6 +142,15 @@ export function typedEchoed(output, text) {
|
|
|
142
142
|
const squash = (s) => stripAnsi(s).replace(/\s+/g, '');
|
|
143
143
|
return squash(output).includes(squash(text).slice(0, 25));
|
|
144
144
|
}
|
|
145
|
+
/** The Esc key as the tool reads it: in win32-input-mode (ConPTY turns a bare ESC byte into nothing, waiting for a
|
|
146
|
+
* sequence) a key-down and key-up record; elsewhere the CSI u form the tools ask for (`ESC [ > 5 u`), which a legacy
|
|
147
|
+
* reader ignores harmlessly. Linux form not yet verified against the real tools. */
|
|
148
|
+
export const ESC_INPUT = process.platform === 'win32' ? '\x1b[27;1;27;1;0;1_\x1b[27;1;27;0;0;1_' : '\x1b[27u';
|
|
149
|
+
/** Did the tool answer the exit command with its "background work is running, exit and stop it or stay?" dialogue?
|
|
150
|
+
* Claude Code asks this while a shell the agent started (a monitor it is waiting on, say) still runs. */
|
|
151
|
+
export function exitDeclined(output) {
|
|
152
|
+
return /Backgroundworkisrunning|willstopwhenyouexit/i.test(stripAnsi(output).replace(/\s+/g, ''));
|
|
153
|
+
}
|
|
145
154
|
/** The terminal title is chord's: "chord <agent> · <task>", so a window or tab can be matched to a `chord ls` row.
|
|
146
155
|
* The tools set their own titles (Claude Code its session summary, Codex the folder name), so their title
|
|
147
156
|
* sequences (OSC 0, 1, 2) are removed from the forwarded output; everything else, including Codex's OSC 7501
|
|
@@ -169,9 +178,14 @@ export const titleSequence = (title) => `\x1b]0;${title.replace(/[\x00-\x1f\x7f]
|
|
|
169
178
|
* stop`), press Enter once more, or nothing. Messages stay in the inbox until a hook takes them, so a lost
|
|
170
179
|
* keystroke is retried, never dropped. `typed`/`typedAt` say what was last typed and when; `echoed` whether the TUI
|
|
171
180
|
* showed it; `enters` how many times Enter was pressed for it and `enteredAt` when last. Enter is pressed at most
|
|
172
|
-
* twice per
|
|
181
|
+
* twice per nudge, never when the text was not echoed, and nothing new is typed while the last text is fresh.
|
|
182
|
+
* `quietFor`: milliseconds since the tool last wrote to the terminal. A Stop hook does not mean the turn is over: a
|
|
183
|
+
* goal (/goal) or another plugin's Stop hook keeps the tool working after it, with the spinner still redrawing, so
|
|
184
|
+
* nothing is typed until the screen has been still for a few seconds.
|
|
185
|
+
* The exit command gets no second Enter: the first one quits the tool, and anything that then appears is a dialogue
|
|
186
|
+
* (Claude Code asks about background tasks it would stop), where Enter would pick the default for the user. */
|
|
173
187
|
export function nextAction(s, now) {
|
|
174
|
-
if (s.status !== 'idle' || !s.gateClear)
|
|
188
|
+
if (s.status !== 'idle' || !s.gateClear || s.quietFor < 3000)
|
|
175
189
|
return null;
|
|
176
190
|
const want = s.stop ? 'stop' : s.unread ? 'nudge' : null;
|
|
177
191
|
if (!want)
|
|
@@ -180,7 +194,7 @@ export function nextAction(s, now) {
|
|
|
180
194
|
return want; // first try, or the typed text was lost or swallowed
|
|
181
195
|
if (s.typed !== want)
|
|
182
196
|
return null; // something else is still in the input box; wait for it to go through or expire
|
|
183
|
-
if (!s.echoed || s.enters !== 1)
|
|
197
|
+
if (s.typed === 'stop' || !s.echoed || s.enters !== 1)
|
|
184
198
|
return null; // swallowed: hands off; never pressed: the user got there first; pressed twice: enough
|
|
185
199
|
if (now - s.enteredAt > 5_000)
|
|
186
200
|
return 'enter'; // typed and echoed but not submitted: press Enter once more
|
|
@@ -194,6 +208,17 @@ export function oscState(chunk) {
|
|
|
194
208
|
last = x[1] === 'idle' ? 'idle' : 'busy';
|
|
195
209
|
return last;
|
|
196
210
|
}
|
|
211
|
+
/** A database call from the wrapper's own callbacks. One that fails (a lock held past busy_timeout while the disk
|
|
212
|
+
* stalls) is logged and dropped: thrown from a timer or a PTY callback it would end the wrapper and the agent in
|
|
213
|
+
* it, and the next tick or terminal report puts the state right anyway. */
|
|
214
|
+
function tolerant(what, f) {
|
|
215
|
+
try {
|
|
216
|
+
f();
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
db.log(`${what}: ${e.message}; skipped`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
197
222
|
export function run(tool, launch, extraArgs) {
|
|
198
223
|
const { file, args } = resolveCommand(tool);
|
|
199
224
|
const env = withNodeOnPath({ ...process.env, ...launch.env });
|
|
@@ -201,22 +226,23 @@ export function run(tool, launch, extraArgs) {
|
|
|
201
226
|
if (/^CLAUDECODE/.test(k))
|
|
202
227
|
delete env[k]; // allow launching from inside a Claude session
|
|
203
228
|
const term = pty.spawn(file, [...args, ...launch.args, ...extraArgs], {
|
|
204
|
-
name: 'xterm-256color', cols: process.stdout.columns || 120, rows: process.stdout.rows || 40, cwd: launch.
|
|
229
|
+
name: 'xterm-256color', cols: process.stdout.columns || 120, rows: process.stdout.rows || 40, cwd: launch.project.dir, env, // the project folder: the tool then treats it as the project (same /resume list, CLAUDE.md, trust)
|
|
205
230
|
});
|
|
206
231
|
const d = db.openDb(launch.dbFile);
|
|
207
232
|
db.addAgent(d, { name: launch.name, tool, pid: term.pid, cwd: launch.cwd, worktree: launch.worktree, heartbeat: true });
|
|
208
|
-
let outTail = '', echoBuf = '', awaitingEcho = false;
|
|
233
|
+
let outTail = '', echoBuf = '', awaitingEcho = false, lastOutputAt = Date.now();
|
|
209
234
|
const titles = new TitleFilter();
|
|
210
235
|
let title = titleFor(launch.name, null);
|
|
211
236
|
process.stdout.write(titleSequence(title));
|
|
212
237
|
term.onData(x => {
|
|
238
|
+
lastOutputAt = Date.now();
|
|
213
239
|
process.stdout.write(titles.push(x));
|
|
214
240
|
if (awaitingEcho)
|
|
215
241
|
echoBuf = (echoBuf + x).slice(-16000);
|
|
216
242
|
if (tool === 'codex') { // the hooks set status too; the terminal report catches turns the hooks miss (Esc, untrusted hooks)
|
|
217
243
|
const state = oscState(outTail + x);
|
|
218
244
|
if (state)
|
|
219
|
-
db.setStatus(d, launch.name, state);
|
|
245
|
+
tolerant('terminal state report', () => db.setStatus(d, launch.name, state));
|
|
220
246
|
outTail = x.slice(-64);
|
|
221
247
|
}
|
|
222
248
|
});
|
|
@@ -233,7 +259,7 @@ export function run(tool, launch, extraArgs) {
|
|
|
233
259
|
process.stdout.on('resize', onResize);
|
|
234
260
|
Object.assign(process.env, launch.env); // so the wrapper writes to the same chord.db.log as the hooks
|
|
235
261
|
let typed = null, typedAt = 0, enteredAt = 0, enters = 0, echoed = false, lastState = '';
|
|
236
|
-
const timer = setInterval(() => {
|
|
262
|
+
const timer = setInterval(() => tolerant('wake loop', () => {
|
|
237
263
|
db.heartbeat(d, launch.name);
|
|
238
264
|
const me = db.getAgent(d, launch.name);
|
|
239
265
|
const wanted = titleFor(launch.name, me?.task);
|
|
@@ -247,11 +273,11 @@ export function run(tool, launch, extraArgs) {
|
|
|
247
273
|
typedAt = enteredAt = enters = 0;
|
|
248
274
|
echoed = false;
|
|
249
275
|
}
|
|
250
|
-
const s = { status, unread: db.hasUnread(d, launch.name), stop: !!me?.stop_requested_at, gateClear: gate.clear() && gate.settled(), typed, typedAt, echoed, enters, enteredAt };
|
|
276
|
+
const s = { status, unread: db.hasUnread(d, launch.name), stop: !!me?.stop_requested_at, gateClear: gate.clear() && gate.settled(), quietFor: Date.now() - lastOutputAt, typed, typedAt, echoed, enters, enteredAt };
|
|
251
277
|
const action = nextAction(s, Date.now());
|
|
252
|
-
const state = `${status}/${s.unread}/${s.stop}/${s.gateClear}/${action}`;
|
|
278
|
+
const state = `${status}/${s.unread}/${s.stop}/${s.gateClear}/${s.quietFor >= 3000}/${action}`;
|
|
253
279
|
if (state !== lastState) {
|
|
254
|
-
db.log(`wake loop: status=${status} unread=${s.unread} stop=${s.stop} gateClear=${s.gateClear} action=${action}`);
|
|
280
|
+
db.log(`wake loop: status=${status} unread=${s.unread} stop=${s.stop} gateClear=${s.gateClear} quiet=${s.quietFor >= 3000} action=${action}`);
|
|
255
281
|
lastState = state;
|
|
256
282
|
}
|
|
257
283
|
if (!action)
|
|
@@ -286,8 +312,21 @@ export function run(tool, launch, extraArgs) {
|
|
|
286
312
|
term.write('\r');
|
|
287
313
|
enteredAt = Date.now();
|
|
288
314
|
enters = 1;
|
|
315
|
+
if (action !== 'stop')
|
|
316
|
+
return;
|
|
317
|
+
// Quitting over the agent's background work would kill it, so chord stays (Esc) and tries again once the exit
|
|
318
|
+
// command has expired, by which time the work may be done.
|
|
319
|
+
echoBuf = '';
|
|
320
|
+
awaitingEcho = true;
|
|
321
|
+
setTimeout(() => {
|
|
322
|
+
awaitingEcho = false;
|
|
323
|
+
if (!exitDeclined(echoBuf))
|
|
324
|
+
return;
|
|
325
|
+
db.log('wake loop: the tool asked before quitting because the agent has background work running; staying, will try again later');
|
|
326
|
+
term.write(ESC_INPUT);
|
|
327
|
+
}, 2000);
|
|
289
328
|
}, 1500);
|
|
290
|
-
}, 1000);
|
|
329
|
+
}), 1000);
|
|
291
330
|
return new Promise(resolve => {
|
|
292
331
|
term.onExit(({ exitCode }) => {
|
|
293
332
|
clearInterval(timer);
|
|
@@ -296,8 +335,8 @@ export function run(tool, launch, extraArgs) {
|
|
|
296
335
|
if (process.stdin.isTTY)
|
|
297
336
|
process.stdin.setRawMode(false);
|
|
298
337
|
process.stdin.pause();
|
|
299
|
-
db.setStatus(d, launch.name, 'offline');
|
|
300
|
-
db.releaseAll(d, launch.name, 'exit');
|
|
338
|
+
tolerant('exit', () => db.setStatus(d, launch.name, 'offline'));
|
|
339
|
+
tolerant('exit', () => db.releaseAll(d, launch.name, 'exit')); // missed here, the next sweep releases them
|
|
301
340
|
resolve(exitCode);
|
|
302
341
|
});
|
|
303
342
|
});
|
package/dist/serve.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// Entry point for the per-agent MCP server. Spawned by the coding agent over stdio, in the
|
|
1
|
+
// Entry point for the per-agent MCP server. Spawned by the coding agent over stdio, in the project folder; the
|
|
2
|
+
// agent's workspace comes from the CHORD_* environment, not from cwd.
|
|
2
3
|
import { openDb } from "./db.js";
|
|
3
4
|
import { serve } from "./mcp.js";
|
|
4
5
|
import { tools } from "./tools.js";
|
package/dist/tools.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
1
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import * as db from "./db.js";
|
|
5
|
+
import { readConfig } from "./launch.js";
|
|
5
6
|
/** Is `file` inside `dir`? False for paths on another drive or above it. */
|
|
6
7
|
export function insideDir(dir, file) {
|
|
7
8
|
const rel = path.relative(dir, path.resolve(dir, file));
|
|
@@ -9,16 +10,20 @@ export function insideDir(dir, file) {
|
|
|
9
10
|
}
|
|
10
11
|
/**
|
|
11
12
|
* The claim key for a path an agent names: workspace-relative with forward slashes. Absolute paths and
|
|
12
|
-
* paths starting with the repository directory are taken as given;
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* paths starting with the repository directory are taken as given; a path from the project folder
|
|
14
|
+
* (chord/agents/<name>/<repo>/...) is resolved there; any other relative path is read as
|
|
15
|
+
* repository-relative (what agents working inside the repo naturally write). Paths outside the
|
|
16
|
+
* workspace are refused: chord cannot coordinate them.
|
|
15
17
|
*/
|
|
16
18
|
export function claimPath(ctx, file) {
|
|
17
19
|
const repoDir = path.relative(ctx.cwd, ctx.worktree).split(path.sep).join('/');
|
|
18
20
|
const asGiven = normFile(ctx, file);
|
|
19
21
|
let abs;
|
|
22
|
+
const fromProject = 'chord/agents/'; // the spelling from the tool's cwd, the project folder; another agent's folder then fails the check below
|
|
20
23
|
if (path.isAbsolute(file) || !repoDir || repoDir === '.' || /^\.\.(\/|$)/.test(asGiven))
|
|
21
24
|
abs = path.resolve(ctx.cwd, file);
|
|
25
|
+
else if (ctx.project && asGiven.startsWith(fromProject))
|
|
26
|
+
abs = path.resolve(ctx.project, file);
|
|
22
27
|
else if (asGiven === repoDir || asGiven.startsWith(repoDir + '/')) {
|
|
23
28
|
// "rich/console.py" is the workspace path of the repository's console.py, or the repository path of a package
|
|
24
29
|
// named like the repository (rich/rich/console.py); whichever exists is meant, and both existing is ambiguous
|
|
@@ -54,10 +59,23 @@ export function peers(ctx) {
|
|
|
54
59
|
name: a.name, tool: a.tool, status: a.status, cwd: a.cwd, task: a.task, scope: a.scope,
|
|
55
60
|
}));
|
|
56
61
|
}
|
|
57
|
-
|
|
62
|
+
// words that describe code rather than name it: "the parse function" and "parse tests" share only "parse"
|
|
63
|
+
const NOT_SYMBOLS = new Set(['a', 'an', 'and', 'or', 'the', 'of', 'in', 'to', 'for', 'with', 'new', 'its', 'all', 'function', 'functions', 'method', 'methods', 'class', 'classes', 'test', 'tests', 'block', 'blocks', 'section', 'line', 'lines', 'file', 'helper', 'helpers', 'code', 'logic', 'docs', 'docstring']);
|
|
64
|
+
const symbolSet = (s) => new Set((s ?? '').toLowerCase().match(/[a-z_$][\w$.]*/g)?.filter(w => !NOT_SYMBOLS.has(w)) ?? []);
|
|
65
|
+
/** Two modify claims on one file that each name the functions or blocks they touch, with none in common, do not
|
|
66
|
+
* need to talk first. Anything less certain (no symbols on either side, or an add, delete or rename) does. */
|
|
67
|
+
export function disjointEdits(a, b) {
|
|
68
|
+
if (a.kind !== 'modify' || b.kind !== 'modify')
|
|
69
|
+
return false;
|
|
70
|
+
const x = symbolSet(a.symbols), y = symbolSet(b.symbols);
|
|
71
|
+
return x.size > 0 && y.size > 0 && ![...x].some(w => y.has(w));
|
|
72
|
+
}
|
|
73
|
+
/** Online agents who claimed `file` before `mine` did, split into those `mine` overlaps and must talk to and those
|
|
74
|
+
* editing other parts of it. */
|
|
58
75
|
function earlierOccupants(ctx, mine) {
|
|
59
76
|
const before = (c) => c.created_at < mine.created_at || (c.created_at === mine.created_at && c.rid < mine.rid);
|
|
60
|
-
|
|
77
|
+
const all = db.claimsFor(ctx.db, mine.file).filter(c => c.agent !== ctx.agent && before(c) && db.getAgent(ctx.db, c.agent)?.status !== 'offline');
|
|
78
|
+
return { overlap: all.filter(c => !disjointEdits(c, mine)), elsewhere: all.filter(c => disjointEdits(c, mine)) };
|
|
61
79
|
}
|
|
62
80
|
/**
|
|
63
81
|
* May `ctx.agent` write `file` now? It must hold a claim, and if others claimed the file first it must
|
|
@@ -70,7 +88,7 @@ export function canWrite(ctx, file, now = Date.now()) {
|
|
|
70
88
|
const mine = db.claimsFor(ctx.db, rel).find(c => c.agent === ctx.agent);
|
|
71
89
|
if (!mine)
|
|
72
90
|
return { ok: false, reason: `${rel} is not claimed by you. Call claim(file, intent, kind) first.` };
|
|
73
|
-
const others = earlierOccupants(ctx, mine);
|
|
91
|
+
const others = earlierOccupants(ctx, mine).overlap;
|
|
74
92
|
if (!others.length)
|
|
75
93
|
return { ok: true };
|
|
76
94
|
const myStart = db.getAgent(ctx.db, ctx.agent)?.started_at ?? mine.created_at;
|
|
@@ -230,7 +248,23 @@ function finishLocked(ctx) {
|
|
|
230
248
|
throw new Error(`rebase onto ${main} failed (not a conflict); nothing was merged.\n${stderrOf(e)}`);
|
|
231
249
|
db.log(`finish: rebase of ${branch} onto ${main} conflicted; aborted`);
|
|
232
250
|
db.addEvent(ctx.db, ctx.agent, 'conflict', null, `rebase of ${branch} onto ${main} conflicted; aborted, nothing merged`);
|
|
233
|
-
throw new Error(`rebase onto ${main} failed; nothing was merged.
|
|
251
|
+
throw new Error(`rebase onto ${main} failed; nothing was merged. Run \`git rebase ${main}\` in your worktree, resolve the conflicts so both changes survive, run the tests, then call finish again; ask the user only if you cannot resolve them.\n${stderrOf(e)}`);
|
|
252
|
+
}
|
|
253
|
+
// The project's tests, on the branch as it now sits on top of main: a clean rebase can still break what another
|
|
254
|
+
// agent merged. ponytail: they run inside the finish lock, so other agents' finish waits for them (withLock gives
|
|
255
|
+
// up after 240 s); run them before taking the lock and re-check main if test suites get long.
|
|
256
|
+
const test = ctx.project ? readConfig({ dir: ctx.project }).test : undefined;
|
|
257
|
+
if (test) {
|
|
258
|
+
db.log(`finish: running the test command in ${wt}: ${test}`);
|
|
259
|
+
try {
|
|
260
|
+
execSync(test, { cwd: wt, stdio: 'pipe', timeout: 600_000, maxBuffer: 64 << 20 });
|
|
261
|
+
}
|
|
262
|
+
catch (e) {
|
|
263
|
+
const x = e;
|
|
264
|
+
const out = `${x.stdout ?? ''}${x.stderr ?? ''}`.trimEnd().split('\n').slice(-40).join('\n');
|
|
265
|
+
db.log(`finish: the test command failed${x.status != null ? ` with exit code ${x.status}` : ''}; nothing merged`);
|
|
266
|
+
throw new Error(`your branch is now rebased onto ${main}, but the project's test command failed there, so nothing was merged: ${test}\n${out}\nFix it (another agent's merged work may be what broke), commit, and call finish again.`);
|
|
267
|
+
}
|
|
234
268
|
}
|
|
235
269
|
const subjects = git(root, 'log', '--format=%s', `${main}..${branch}`).split('\n').filter(Boolean);
|
|
236
270
|
// A submodule commit the branch points at must exist in the main checkout's copy of the submodule, or the
|
|
@@ -276,7 +310,7 @@ function finishLocked(ctx) {
|
|
|
276
310
|
db.releaseAll(ctx.db, ctx.agent, 'finish');
|
|
277
311
|
const online = db.listAgents(ctx.db).filter(a => a.name !== ctx.agent && a.status !== 'offline').map(a => a.name);
|
|
278
312
|
if (online.length)
|
|
279
|
-
db.send(ctx.db, ctx.agent, online, `${ctx.agent} merged into ${main}:\n${subjects.map(s => `- ${s}`).join('\n')}\
|
|
313
|
+
db.send(ctx.db, ctx.agent, online, `${ctx.agent} merged into ${main}:\n${subjects.map(s => `- ${s}`).join('\n')}\n${db.NOTICE_TAIL}`, 'notice');
|
|
280
314
|
return { merged: subjects, into: main, notified: online };
|
|
281
315
|
}
|
|
282
316
|
export function tools(ctx) {
|
|
@@ -306,10 +340,10 @@ export function tools(ctx) {
|
|
|
306
340
|
inputSchema: {
|
|
307
341
|
type: 'object',
|
|
308
342
|
properties: {
|
|
309
|
-
file: str('The file, as a path from your working directory (
|
|
343
|
+
file: str('The file, as a path from your working directory (chord/agents/<you>/<repo>/src/x.ts) or relative to the repository root (src/x.ts)'),
|
|
310
344
|
intent: str('One sentence: what you will change and why'),
|
|
311
345
|
kind: { type: 'string', enum: ['add', 'modify', 'delete', 'rename'] },
|
|
312
|
-
symbols: str('Optional: functions or blocks you will touch'),
|
|
346
|
+
symbols: str('Optional: the functions or blocks you will touch, comma-separated. When you and an earlier holder of the file both name them and none are shared, you need not talk first.'),
|
|
313
347
|
},
|
|
314
348
|
required: ['file', 'intent', 'kind'],
|
|
315
349
|
},
|
|
@@ -320,11 +354,13 @@ export function tools(ctx) {
|
|
|
320
354
|
throw new Error('kind must be add, modify, delete or rename');
|
|
321
355
|
db.addClaim(ctx.db, { agent: ctx.agent, file, intent: req(a, 'intent'), kind, symbols: typeof a.symbols === 'string' ? a.symbols : null });
|
|
322
356
|
const mine = db.claimsFor(ctx.db, file).find(c => c.agent === ctx.agent);
|
|
323
|
-
const others = earlierOccupants(ctx, mine);
|
|
324
|
-
db.log(`claim ${file} by ${ctx.agent} (cwd ${ctx.cwd}): rows=${JSON.stringify(db.claimsFor(ctx.db, file).map(c => [c.agent, c.created_at]))} agents=${JSON.stringify(db.listAgents(ctx.db).map(a => [a.name, a.status, a.pid]))} others=${others.map(o => o.agent)}`);
|
|
357
|
+
const { overlap: others, elsewhere } = earlierOccupants(ctx, mine);
|
|
358
|
+
db.log(`claim ${file} by ${ctx.agent} (cwd ${ctx.cwd}): rows=${JSON.stringify(db.claimsFor(ctx.db, file).map(c => [c.agent, c.created_at]))} agents=${JSON.stringify(db.listAgents(ctx.db).map(a => [a.name, a.status, a.pid]))} others=${others.map(o => o.agent)} elsewhere=${elsewhere.map(o => o.agent)}`);
|
|
359
|
+
const brief = (o) => ({ agent: o.agent, intent: o.intent, kind: o.kind, symbols: o.symbols });
|
|
325
360
|
return {
|
|
326
361
|
claimed: file,
|
|
327
|
-
must_talk_to: others.map(
|
|
362
|
+
must_talk_to: others.map(brief),
|
|
363
|
+
...(elsewhere.length ? { also_editing: elsewhere.map(brief) } : {}),
|
|
328
364
|
...(others.length ? { next: 'Send each agent above a message describing your change. You may write once each has replied, or after 10 minutes.' } : {}),
|
|
329
365
|
};
|
|
330
366
|
},
|
|
@@ -366,7 +402,7 @@ export function tools(ctx) {
|
|
|
366
402
|
},
|
|
367
403
|
{
|
|
368
404
|
name: 'finish',
|
|
369
|
-
description: 'Your work is committed on your branch: rebase it onto main, fast-forward main, release all your claims and tell the other agents. On a rebase conflict nothing is changed;
|
|
405
|
+
description: 'Your work is committed on your branch: rebase it onto main, fast-forward main, release all your claims and tell the other agents. On a rebase conflict nothing is changed; rebase onto main yourself, resolve it, then call finish again.',
|
|
370
406
|
inputSchema: { type: 'object', properties: {} },
|
|
371
407
|
handler: () => finish(ctx),
|
|
372
408
|
},
|
package/dist/trust.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// Claude Code
|
|
2
|
-
//
|
|
3
|
-
// The workspace only holds a worktree of a repository the user already chose to run chord in.
|
|
1
|
+
// What the user has told Claude Code and Codex to trust, read from their own config files. Agents run in the
|
|
2
|
+
// project folder, which the user trusts (or not) in Claude Code themselves; chord writes no trust of its own.
|
|
4
3
|
import fs from 'node:fs';
|
|
5
4
|
import os from 'node:os';
|
|
6
5
|
import path from 'node:path';
|
|
@@ -18,37 +17,6 @@ export function claudeTrusts(dir, file = claudeConfigPath()) {
|
|
|
18
17
|
return false;
|
|
19
18
|
}
|
|
20
19
|
}
|
|
21
|
-
/** Mark `workspace` as trusted for Claude Code. Returns false when Claude Code has no config yet (nothing to do)
|
|
22
|
-
* or the entry was already there. Writes atomically; other fields of the file are left untouched. */
|
|
23
|
-
export function pretrustClaude(workspace, file = claudeConfigPath()) {
|
|
24
|
-
let raw;
|
|
25
|
-
try {
|
|
26
|
-
raw = fs.readFileSync(file, 'utf8');
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
return false;
|
|
30
|
-
}
|
|
31
|
-
let cfg;
|
|
32
|
-
try {
|
|
33
|
-
cfg = JSON.parse(raw);
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
return false;
|
|
37
|
-
} // not ours to repair
|
|
38
|
-
if (typeof cfg !== 'object' || cfg === null)
|
|
39
|
-
return false;
|
|
40
|
-
const projects = (cfg.projects ??= {});
|
|
41
|
-
const key = Object.keys(projects).find(k => norm(k) === norm(workspace)) ?? workspace.replace(/\\/g, '/');
|
|
42
|
-
if (projects[key]?.hasTrustDialogAccepted === true)
|
|
43
|
-
return false;
|
|
44
|
-
projects[key] = projects[key]
|
|
45
|
-
? { ...projects[key], hasTrustDialogAccepted: true } // existing entry: flip the flag, keep everything else
|
|
46
|
-
: { allowedTools: [], mcpContextUris: [], mcpServers: {}, enabledMcpjsonServers: [], disabledMcpjsonServers: [], hasTrustDialogAccepted: true, hasClaudeMdExternalIncludesApproved: false, hasClaudeMdExternalIncludesWarningShown: false };
|
|
47
|
-
const tmp = `${file}.chord-${process.pid}.tmp`;
|
|
48
|
-
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2));
|
|
49
|
-
fs.renameSync(tmp, file);
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
52
20
|
/** Which of a repository's .mcp.json servers the user has approved or rejected in Claude Code, and whether they
|
|
53
21
|
* approved them all at once. Claude asks per server when it runs in the repository; chord has no prompt, so it only
|
|
54
22
|
* starts what the user has already approved there. */
|
|
@@ -88,3 +56,65 @@ export function disabledCodexHooks(file = codexConfigPath()) {
|
|
|
88
56
|
}
|
|
89
57
|
return off;
|
|
90
58
|
}
|
|
59
|
+
const hasCodexState = (home) => { try {
|
|
60
|
+
return fs.readdirSync(home).some(f => /^state_\d+\.sqlite$/.test(f));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
} };
|
|
65
|
+
/** Codex creates and migrates its state database (~/.codex/state_N.sqlite) on its first start, and when two start at
|
|
66
|
+
* once the second exits with "local database appears to be damaged". Until that database exists, launches take
|
|
67
|
+
* turns: the first holds a lock while its Codex creates it, the others wait for the lock. Call the returned
|
|
68
|
+
* function once Codex has been spawned; it lets go of the lock when the database is there, or after a minute. */
|
|
69
|
+
export async function codexFirstStart(home = path.join(os.homedir(), '.codex')) {
|
|
70
|
+
if (hasCodexState(home))
|
|
71
|
+
return () => { };
|
|
72
|
+
const lock = path.join(home, '.chord-first-start');
|
|
73
|
+
const t0 = Date.now();
|
|
74
|
+
try {
|
|
75
|
+
fs.mkdirSync(home, { recursive: true });
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return () => { };
|
|
79
|
+
}
|
|
80
|
+
for (;;) {
|
|
81
|
+
try {
|
|
82
|
+
fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
if (e.code !== 'EEXIST')
|
|
87
|
+
return () => { };
|
|
88
|
+
try {
|
|
89
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > 60_000) {
|
|
90
|
+
fs.unlinkSync(lock);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (Date.now() - t0 > 90_000)
|
|
98
|
+
return () => { }; // a launch that never lets go must not hold this one up for good
|
|
99
|
+
await new Promise(r => setTimeout(r, 250));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const unlock = () => { try {
|
|
103
|
+
fs.unlinkSync(lock);
|
|
104
|
+
}
|
|
105
|
+
catch { /* gone */ } };
|
|
106
|
+
if (hasCodexState(home)) {
|
|
107
|
+
unlock();
|
|
108
|
+
return () => { };
|
|
109
|
+
} // the launch before this one created it
|
|
110
|
+
return () => {
|
|
111
|
+
const started = Date.now();
|
|
112
|
+
const poll = setInterval(() => {
|
|
113
|
+
if (!hasCodexState(home) && Date.now() - started < 60_000)
|
|
114
|
+
return;
|
|
115
|
+
clearInterval(poll);
|
|
116
|
+
setTimeout(unlock, 2000).unref(); // the file appears before its migrations have run
|
|
117
|
+
}, 250);
|
|
118
|
+
poll.unref();
|
|
119
|
+
};
|
|
120
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chord-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Run several coding agents in one git project, each in its own worktree, with shared file claims and messaging",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -31,7 +31,10 @@
|
|
|
31
31
|
"engines": {
|
|
32
32
|
"node": ">=24"
|
|
33
33
|
},
|
|
34
|
-
"os": [
|
|
34
|
+
"os": [
|
|
35
|
+
"win32",
|
|
36
|
+
"linux"
|
|
37
|
+
],
|
|
35
38
|
"scripts": {
|
|
36
39
|
"build": "tsc -p tsconfig.build.json",
|
|
37
40
|
"prepare": "tsc -p tsconfig.build.json",
|