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/dist/tools.js CHANGED
@@ -1,40 +1,47 @@
1
- import { execFileSync, execSync } from 'node:child_process';
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
- import { readConfig } from "./launch.js";
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 = path.relative(dir, path.resolve(dir, file));
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 and
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
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) || !repoDir || repoDir === '.' || /^\.\.(\/|$)/.test(asGiven))
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 if (asGiven === repoDir || asGiven.startsWith(repoDir + '/')) {
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
- export const ASK_WINDOW_MS = 2 * 60 * 1000;
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
- /** Worktree-relative path with forward slashes, so the same file matches across worktrees. */
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 path.relative(ctx.cwd, path.resolve(ctx.cwd, file)).split(path.sep).join('/');
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
- const symbolSet = (s) => new Set((s ?? '').toLowerCase().match(/[a-z_$][\w$.]*/g)?.filter(w => !NOT_SYMBOLS.has(w)) ?? []);
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
- export function disjointEdits(a, b) {
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
- const before = (c) => c.created_at < mine.created_at || (c.created_at === mine.created_at && c.rid < mine.rid);
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 (!db.repliedSince(ctx.db, o.agent, ctx.agent, since) && now - askedAt < REPLY_TIMEOUT_MS)
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
- // 64 MB of output: a project's post-merge hook may print a great deal, and the default 1 MB would turn a merge that
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 = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
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`; a lock older than `staleMs` is taken over. */
152
- export function withLock(file, fn, waitMs = 240_000, staleMs = 180_000) {
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
- if (Date.now() - fs.statSync(file).mtimeMs > staleMs) {
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.unlinkSync(file);
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 = path.resolve(wt, git(wt, 'rev-parse', '--git-path', 'chord-finish'));
197
- for (const dir of ['rebase-merge', 'rebase-apply']) {
198
- if (!fs.existsSync(path.resolve(wt, git(wt, 'rev-parse', '--git-path', dir))))
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
- export const claudeConfigPath = () => path.join(os.homedir(), '.claude.json');
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
- export const codexConfigPath = () => path.join(os.homedir(), '.codex', 'config.toml');
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.0",
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",