chord-cli 0.2.0 → 0.2.1
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 +8 -7
- package/README.zh-CN.md +9 -8
- package/dist/clean.js +8 -4
- package/dist/cli.js +10 -11
- package/dist/db.js +48 -24
- package/dist/hooks.js +66 -60
- package/dist/launch.js +26 -54
- package/dist/mcp.js +3 -1
- package/dist/project.js +6 -7
- package/dist/pty.js +42 -8
- package/dist/serve.js +5 -7
- package/dist/tools.js +89 -40
- package/dist/trust.js +2 -2
- package/package.json +3 -2
package/dist/tools.js
CHANGED
|
@@ -1,40 +1,47 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { 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
|
-
|
|
5
|
+
/** path.relative, except that on macOS directory names differing only in case match, as path.relative already has them
|
|
6
|
+
* on Windows: both file systems ignore case, so /P/Repo is /P/repo, and a write must not slip past a check that way. */
|
|
7
|
+
function relativePath(from, to) {
|
|
8
|
+
if (process.platform !== 'darwin')
|
|
9
|
+
return path.relative(from, to);
|
|
10
|
+
const a = path.resolve(from).split('/').filter(Boolean), b = path.resolve(to).split('/').filter(Boolean);
|
|
11
|
+
let i = 0;
|
|
12
|
+
while (i < a.length && i < b.length && a[i].toLowerCase() === b[i].toLowerCase())
|
|
13
|
+
i++;
|
|
14
|
+
return [...a.slice(i).map(() => '..'), ...b.slice(i)].join('/');
|
|
15
|
+
}
|
|
6
16
|
/** Is `file` inside `dir`? False for paths on another drive or above it. */
|
|
7
17
|
export function insideDir(dir, file) {
|
|
8
|
-
const rel =
|
|
18
|
+
const rel = relativePath(dir, path.resolve(dir, file));
|
|
9
19
|
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
10
20
|
}
|
|
11
21
|
/**
|
|
12
|
-
* The claim key for a path an agent names: workspace-relative with forward slashes. Absolute paths
|
|
13
|
-
*
|
|
14
|
-
* (
|
|
15
|
-
* repository-relative (what agents working inside the repo naturally write). Paths outside the
|
|
22
|
+
* The claim key for a path an agent names: workspace-relative with forward slashes. Absolute paths are taken as
|
|
23
|
+
* given; a path from the project folder (chord/agents/<name>/<repo>/...) is resolved there; any other relative
|
|
24
|
+
* path is repository-relative (what agents working on the repository naturally write). Paths outside the
|
|
16
25
|
* workspace are refused: chord cannot coordinate them.
|
|
17
26
|
*/
|
|
18
27
|
export function claimPath(ctx, file) {
|
|
19
|
-
const repoDir = path.relative(ctx.cwd, ctx.worktree).split(path.sep).join('/');
|
|
20
28
|
const asGiven = normFile(ctx, file);
|
|
21
29
|
let abs;
|
|
22
30
|
const fromProject = 'chord/agents/'; // the spelling from the tool's cwd, the project folder; another agent's folder then fails the check below
|
|
23
|
-
if (path.isAbsolute(file) ||
|
|
31
|
+
if (path.isAbsolute(file) || /^\.\.(\/|$)/.test(asGiven))
|
|
24
32
|
abs = path.resolve(ctx.cwd, file);
|
|
25
33
|
else if (ctx.project && asGiven.startsWith(fromProject))
|
|
26
34
|
abs = path.resolve(ctx.project, file);
|
|
27
|
-
else
|
|
28
|
-
// "rich/console.py" is the workspace path of the repository's console.py, or the repository path of a package
|
|
29
|
-
// named like the repository (rich/rich/console.py); whichever exists is meant, and both existing is ambiguous
|
|
30
|
-
const asWorkspace = path.resolve(ctx.cwd, file), asRepo = path.resolve(ctx.worktree, file);
|
|
31
|
-
const w = fs.existsSync(asWorkspace), r = fs.existsSync(asRepo);
|
|
32
|
-
if (w && r && asWorkspace !== asRepo)
|
|
33
|
-
throw new Error(`${file} is ambiguous: it could be ${normFile(ctx, asWorkspace)} or ${normFile(ctx, asRepo)}; give the path from your workspace root`);
|
|
34
|
-
abs = r && !w ? asRepo : asWorkspace;
|
|
35
|
-
}
|
|
36
|
-
else
|
|
35
|
+
else {
|
|
37
36
|
abs = path.resolve(ctx.worktree, file);
|
|
37
|
+
// From the project folder "<repo>/x" is the main checkout's x. An agent naming its own copy that way, where the
|
|
38
|
+
// repository has no such path but x exists at its root, is told the spellings that reach it rather than given a
|
|
39
|
+
// claim on a file that is not there.
|
|
40
|
+
const repoName = path.basename(ctx.worktree), inner = asGiven.slice(repoName.length + 1);
|
|
41
|
+
if (asGiven.startsWith(`${repoName}/`) && !fs.existsSync(abs) && fs.existsSync(path.resolve(ctx.worktree, inner))) {
|
|
42
|
+
throw new Error(`there is no ${asGiven} in the repository, but it has ${inner}: name files relative to the repository root ("${inner}") or from the project folder ("chord/agents/<you>/${repoName}/${inner}")`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
38
45
|
if (!insideDir(ctx.cwd, abs))
|
|
39
46
|
throw new Error(`${file} is outside your workspace; chord only coordinates files under ${ctx.cwd}`);
|
|
40
47
|
return normFile(ctx, abs);
|
|
@@ -42,7 +49,7 @@ export function claimPath(ctx, file) {
|
|
|
42
49
|
/** How long a latecomer waits for replies before it may write anyway. */
|
|
43
50
|
export const REPLY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
44
51
|
/** How long before its claim a latecomer's message to an occupant still counts as asking about that file. */
|
|
45
|
-
|
|
52
|
+
const ASK_WINDOW_MS = 2 * 60 * 1000;
|
|
46
53
|
const str = (desc) => ({ type: 'string', description: desc });
|
|
47
54
|
function req(args, key) {
|
|
48
55
|
const v = args[key];
|
|
@@ -50,9 +57,9 @@ function req(args, key) {
|
|
|
50
57
|
throw new Error(`${key} is required`);
|
|
51
58
|
return v;
|
|
52
59
|
}
|
|
53
|
-
/**
|
|
60
|
+
/** Workspace-relative path with forward slashes (repo/src/x.ts), so the same file matches across agents' worktrees. */
|
|
54
61
|
export function normFile(ctx, file) {
|
|
55
|
-
return
|
|
62
|
+
return relativePath(ctx.cwd, path.resolve(ctx.cwd, file)).split(path.sep).join('/');
|
|
56
63
|
}
|
|
57
64
|
export function peers(ctx) {
|
|
58
65
|
return db.listAgents(ctx.db).filter(a => a.name !== ctx.agent).map(a => ({
|
|
@@ -61,10 +68,11 @@ export function peers(ctx) {
|
|
|
61
68
|
}
|
|
62
69
|
// words that describe code rather than name it: "the parse function" and "parse tests" share only "parse"
|
|
63
70
|
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
|
-
|
|
71
|
+
// "Parser.parse" names Parser and parse too: whoever holds either holds part of it
|
|
72
|
+
const symbolSet = (s) => new Set((s ?? '').toLowerCase().match(/[a-z_$][\w$.]*/g)?.flatMap(w => [w, ...w.split('.')]).filter(w => w && !NOT_SYMBOLS.has(w)) ?? []);
|
|
65
73
|
/** Two modify claims on one file that each name the functions or blocks they touch, with none in common, do not
|
|
66
74
|
* need to talk first. Anything less certain (no symbols on either side, or an add, delete or rename) does. */
|
|
67
|
-
|
|
75
|
+
function disjointEdits(a, b) {
|
|
68
76
|
if (a.kind !== 'modify' || b.kind !== 'modify')
|
|
69
77
|
return false;
|
|
70
78
|
const x = symbolSet(a.symbols), y = symbolSet(b.symbols);
|
|
@@ -73,7 +81,9 @@ export function disjointEdits(a, b) {
|
|
|
73
81
|
/** Online agents who claimed `file` before `mine` did, split into those `mine` overlaps and must talk to and those
|
|
74
82
|
* editing other parts of it. */
|
|
75
83
|
function earlierOccupants(ctx, mine) {
|
|
76
|
-
|
|
84
|
+
// by rowid, which the insert gets under the write lock, not by the timestamp read before waiting for it: two
|
|
85
|
+
// agents claiming at once would otherwise each see the other as later and neither would talk first
|
|
86
|
+
const before = (c) => c.rid < mine.rid;
|
|
77
87
|
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
88
|
return { overlap: all.filter(c => !disjointEdits(c, mine)), elsewhere: all.filter(c => disjointEdits(c, mine)) };
|
|
79
89
|
}
|
|
@@ -103,7 +113,7 @@ export function canWrite(ctx, file, now = Date.now()) {
|
|
|
103
113
|
const askedAt = db.firstMessageAt(ctx.db, ctx.agent, o.agent, Math.max(since, mine.created_at - ASK_WINDOW_MS));
|
|
104
114
|
if (askedAt === null)
|
|
105
115
|
notAsked.push(o.agent);
|
|
106
|
-
else if (
|
|
116
|
+
else if (db.firstMessageAt(ctx.db, o.agent, ctx.agent, since) === null && now - askedAt < REPLY_TIMEOUT_MS)
|
|
107
117
|
waiting.push({ agent: o.agent, left: REPLY_TIMEOUT_MS - (now - askedAt) });
|
|
108
118
|
}
|
|
109
119
|
if (notAsked.length) {
|
|
@@ -115,9 +125,7 @@ export function canWrite(ctx, file, now = Date.now()) {
|
|
|
115
125
|
}
|
|
116
126
|
return { ok: true };
|
|
117
127
|
}
|
|
118
|
-
|
|
119
|
-
// already happened into an exception
|
|
120
|
-
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', env: db.gitEnv(), stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 }).trim();
|
|
128
|
+
const { git } = db;
|
|
121
129
|
const gitOk = (cwd, ...args) => { try {
|
|
122
130
|
git(cwd, ...args);
|
|
123
131
|
return true;
|
|
@@ -126,7 +134,7 @@ catch {
|
|
|
126
134
|
return false;
|
|
127
135
|
} };
|
|
128
136
|
const stderrOf = (e) => (e.stderr ?? e.message).trim();
|
|
129
|
-
const sleepSync =
|
|
137
|
+
const { sleepSync } = db;
|
|
130
138
|
/** Tracked files with real changes, staged or not, ignoring line-ending differences and whatever happens inside
|
|
131
139
|
* submodules; '' when clean. */
|
|
132
140
|
export function dirtyTracked(cwd) {
|
|
@@ -148,8 +156,10 @@ function gitRetry(cwd, ...args) {
|
|
|
148
156
|
}
|
|
149
157
|
}
|
|
150
158
|
}
|
|
151
|
-
/** Run fn holding an exclusive lock file. Waits up to `waitMs
|
|
152
|
-
|
|
159
|
+
/** Run fn holding an exclusive lock file that names its holder's pid. Waits up to `waitMs`. A lock whose holder is
|
|
160
|
+
* gone is taken over at once; one older than `staleMs` as well, which must exceed the longest run of the project's
|
|
161
|
+
* test command (ten minutes) so a live holder is never robbed of its lock. */
|
|
162
|
+
export function withLock(file, fn, waitMs = 240_000, staleMs = 15 * 60_000) {
|
|
153
163
|
const t0 = Date.now();
|
|
154
164
|
for (;;) {
|
|
155
165
|
try {
|
|
@@ -160,7 +170,8 @@ export function withLock(file, fn, waitMs = 240_000, staleMs = 180_000) {
|
|
|
160
170
|
if (e.code !== 'EEXIST')
|
|
161
171
|
throw e;
|
|
162
172
|
try {
|
|
163
|
-
|
|
173
|
+
const holder = Number(fs.readFileSync(file, 'utf8'));
|
|
174
|
+
if ((holder > 0 && !db.pidAlive(holder)) || Date.now() - fs.statSync(file).mtimeMs > staleMs) {
|
|
164
175
|
fs.unlinkSync(file);
|
|
165
176
|
continue;
|
|
166
177
|
}
|
|
@@ -173,16 +184,50 @@ export function withLock(file, fn, waitMs = 240_000, staleMs = 180_000) {
|
|
|
173
184
|
sleepSync(150 + Math.random() * 200);
|
|
174
185
|
}
|
|
175
186
|
}
|
|
187
|
+
// only our own lock is removed: one taken over meanwhile belongs to whoever holds it now
|
|
176
188
|
try {
|
|
177
189
|
return fn();
|
|
178
190
|
}
|
|
179
191
|
finally {
|
|
180
192
|
try {
|
|
181
|
-
fs.
|
|
193
|
+
if (fs.readFileSync(file, 'utf8') === String(process.pid))
|
|
194
|
+
fs.unlinkSync(file);
|
|
182
195
|
}
|
|
183
196
|
catch { /* already gone */ }
|
|
184
197
|
}
|
|
185
198
|
}
|
|
199
|
+
/** <project>/chord/config.json. `setup` is a shell command run in every new worktree before the agent starts,
|
|
200
|
+
* for example "npm install". `test` is a shell command finish runs in the worktree after rebasing onto main and
|
|
201
|
+
* before fast-forwarding it, for example "npm test"; when it fails nothing is merged. Lives here, not in launch.ts,
|
|
202
|
+
* so the hooks (which load this module for finish's sake) do not load the launch code on every tool call. */
|
|
203
|
+
export function readConfig(project) {
|
|
204
|
+
const file = path.join(project.dir, 'chord', 'config.json');
|
|
205
|
+
let raw;
|
|
206
|
+
try {
|
|
207
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
let cfg;
|
|
213
|
+
try {
|
|
214
|
+
cfg = JSON.parse(raw);
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
throw new Error(`${file} is not valid JSON: ${e.message}`);
|
|
218
|
+
}
|
|
219
|
+
if (typeof cfg !== 'object' || cfg === null)
|
|
220
|
+
throw new Error(`${file} must hold a JSON object`);
|
|
221
|
+
const out = {};
|
|
222
|
+
for (const key of ['setup', 'test']) {
|
|
223
|
+
const v = cfg[key];
|
|
224
|
+
if (v !== undefined && typeof v !== 'string')
|
|
225
|
+
throw new Error(`${file}: "${key}" must be a string (a shell command)`);
|
|
226
|
+
if (v)
|
|
227
|
+
out[key] = v;
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
186
231
|
/** Rebase the agent's branch onto main and fast-forward main to it. Atomic from the agent's point of view.
|
|
187
232
|
* Finishes are serialised with a lock file: git cannot run two merges in one checkout concurrently. */
|
|
188
233
|
export function finish(ctx) {
|
|
@@ -193,9 +238,9 @@ function finishLocked(ctx) {
|
|
|
193
238
|
const wt = ctx.worktree;
|
|
194
239
|
// A rebase in progress is only ours to clean up when finish itself started it (a finish killed mid-rebase leaves
|
|
195
240
|
// the marker behind). Anyone else's rebase, perhaps with conflicts already resolved, is left alone.
|
|
196
|
-
const marker
|
|
197
|
-
for (const dir of [
|
|
198
|
-
if (!fs.existsSync(
|
|
241
|
+
const [marker, rebaseMerge, rebaseApply, indexLock] = git(wt, 'rev-parse', '--git-path', 'chord-finish', '--git-path', 'rebase-merge', '--git-path', 'rebase-apply', '--git-path', 'index.lock').split('\n').map(p => path.resolve(wt, p));
|
|
242
|
+
for (const dir of [rebaseMerge, rebaseApply]) {
|
|
243
|
+
if (!fs.existsSync(dir))
|
|
199
244
|
continue;
|
|
200
245
|
if (!fs.existsSync(marker))
|
|
201
246
|
throw new Error('a rebase is in progress in your worktree that finish did not start; run `git rebase --continue` (after resolving conflicts) or `git rebase --abort` first');
|
|
@@ -204,7 +249,6 @@ function finishLocked(ctx) {
|
|
|
204
249
|
}
|
|
205
250
|
fs.rmSync(marker, { force: true });
|
|
206
251
|
// a finish killed mid-operation can leave the worktree's own index.lock behind; one older than a minute is debris
|
|
207
|
-
const indexLock = path.resolve(wt, git(wt, 'rev-parse', '--git-path', 'index.lock'));
|
|
208
252
|
try {
|
|
209
253
|
if (Date.now() - fs.statSync(indexLock).mtimeMs > 60_000) {
|
|
210
254
|
fs.unlinkSync(indexLock);
|
|
@@ -217,6 +261,12 @@ function finishLocked(ctx) {
|
|
|
217
261
|
// such file as modified the moment it is checked out, and no agent can commit that away.
|
|
218
262
|
if (dirtyTracked(wt))
|
|
219
263
|
throw new Error('your worktree has uncommitted changes to tracked files; commit them first');
|
|
264
|
+
// Except the files this agent claimed: one it wrote and never added (git commit -a skips new files) would leave
|
|
265
|
+
// main with half the change, and the claim that protected it would be released below.
|
|
266
|
+
const claimed = db.claimsBy(ctx.db, ctx.agent).map(c => relativePath(wt, path.resolve(ctx.cwd, c.file)).split(path.sep).join('/')).filter(f => f && !f.startsWith('..'));
|
|
267
|
+
const lost = claimed.length ? git(wt, 'ls-files', '--others', '--exclude-standard', '--', ...claimed) : '';
|
|
268
|
+
if (lost)
|
|
269
|
+
throw new Error(`you claimed ${lost.split('\n').join(', ')} but never committed it: git add and commit it (or release the claim) and call finish again. Nothing was merged.`);
|
|
220
270
|
// What status still reports now differs only in line endings. git will not rebase over it, and nothing done in a
|
|
221
271
|
// worktree can clear it: the repository needs a renormalised commit, which is the user's call.
|
|
222
272
|
const phantom = git(wt, 'status', '--porcelain', '--untracked-files=no', '--ignore-submodules=dirty').split('\n').filter(Boolean).map(l => l.replace(/^[ MADRCU?!]{1,2}\s+/, ''));
|
|
@@ -269,7 +319,7 @@ function finishLocked(ctx) {
|
|
|
269
319
|
const subjects = git(root, 'log', '--format=%s', `${main}..${branch}`).split('\n').filter(Boolean);
|
|
270
320
|
// A submodule commit the branch points at must exist in the main checkout's copy of the submodule, or the
|
|
271
321
|
// fast-forward leaves main pointing at a commit that is nowhere but inside this agent's worktree.
|
|
272
|
-
for (const line of git(root, 'diff', '--raw', main, branch).split('\n')) {
|
|
322
|
+
for (const line of git(root, '-c', 'core.quotePath=false', 'diff', '--raw', main, branch).split('\n')) { // a non-ASCII path arrives as itself, not quoted and escaped
|
|
273
323
|
const m = /^:\d{6} 160000 [0-9a-f]+ ([0-9a-f]+) [AM]\t(.+)$/.exec(line);
|
|
274
324
|
if (!m)
|
|
275
325
|
continue;
|
|
@@ -355,7 +405,6 @@ export function tools(ctx) {
|
|
|
355
405
|
db.addClaim(ctx.db, { agent: ctx.agent, file, intent: req(a, 'intent'), kind, symbols: typeof a.symbols === 'string' ? a.symbols : null });
|
|
356
406
|
const mine = db.claimsFor(ctx.db, file).find(c => c.agent === ctx.agent);
|
|
357
407
|
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
408
|
const brief = (o) => ({ agent: o.agent, intent: o.intent, kind: o.kind, symbols: o.symbols });
|
|
360
409
|
return {
|
|
361
410
|
claimed: file,
|
package/dist/trust.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
|
|
6
|
+
const claudeConfigPath = () => path.join(os.homedir(), '.claude.json');
|
|
7
7
|
// Claude Code keys projects by the cwd with forward slashes, even on Windows
|
|
8
8
|
const norm = (p) => p.replace(/\\/g, '/').toLowerCase();
|
|
9
9
|
/** Whether the user has answered yes to Claude Code's "do you trust this folder?" dialogue for `dir`. */
|
|
@@ -31,7 +31,7 @@ export function claudeMcpChoices(dir, file = claudeConfigPath()) {
|
|
|
31
31
|
return { all: false, enabled: [], disabled: [] };
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
const codexConfigPath = () => path.join(os.homedir(), '.codex', 'config.toml');
|
|
35
35
|
/** Codex keeps a trust hash and an on/off switch per hook in ~/.codex/config.toml. One keypress in its
|
|
36
36
|
* "Hooks need review" screen switches a hook off, and chord then silently loses the feature behind it, so
|
|
37
37
|
* chord names the switched-off hooks at launch. Returns Codex event names such as "session_start". */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chord-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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": [
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
},
|
|
34
34
|
"os": [
|
|
35
35
|
"win32",
|
|
36
|
-
"linux"
|
|
36
|
+
"linux",
|
|
37
|
+
"darwin"
|
|
37
38
|
],
|
|
38
39
|
"scripts": {
|
|
39
40
|
"build": "tsc -p tsconfig.build.json",
|