chord-cli 0.1.3 → 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/pty.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Run the agent under a pseudo-terminal: pass the user's keyboard through, and when the
2
2
  // agent is idle with unread messages, type a nudge in as if the user had.
3
3
  import fs from 'node:fs';
4
+ import { createRequire } from 'node:module';
4
5
  import path from 'node:path';
5
6
  import { StringDecoder } from 'node:string_decoder';
6
7
  import pty from 'node-pty';
@@ -32,6 +33,27 @@ export function resolveCommand(tool, readFile = (f) => fs.readFileSync(f, 'utf8'
32
33
  throw new Error(`cannot read the script path from ${hit}`);
33
34
  return { file: process.execPath, args: [path.join(path.dirname(hit), m[1])] };
34
35
  }
36
+ /** On macOS node-pty starts the tool through its `spawn-helper` binary, which the prebuilt package ships without the
37
+ * execute bit (and npm no longer runs install scripts that might set it), so every launch would fail with
38
+ * "posix_spawnp failed". The bit is set here, or the error says how when the install directory is not writable. */
39
+ export function ensureSpawnHelper(root = path.dirname(createRequire(import.meta.url).resolve('node-pty/package.json'))) {
40
+ for (const dir of ['build/Release', 'build/Debug', `prebuilds/${process.platform}-${process.arch}`]) { // where node-pty looks
41
+ const helper = path.join(root, dir, 'spawn-helper');
42
+ if (!fs.existsSync(helper))
43
+ continue;
44
+ try {
45
+ fs.accessSync(helper, fs.constants.X_OK);
46
+ continue;
47
+ }
48
+ catch { /* not executable */ }
49
+ try {
50
+ fs.chmodSync(helper, 0o755);
51
+ }
52
+ catch (e) {
53
+ throw new Error(`${helper} is not executable, so no agent can start (${e.message}); run: chmod +x "${helper}"`);
54
+ }
55
+ }
56
+ }
35
57
  const ESC_KEY = ''; // a decoded Esc key, kept apart from escape sequences while they are stripped
36
58
  const SOFT_NL = ''; // Shift+Enter: a newline inside the draft, not a submit
37
59
  /**
@@ -142,6 +164,15 @@ export function typedEchoed(output, text) {
142
164
  const squash = (s) => stripAnsi(s).replace(/\s+/g, '');
143
165
  return squash(output).includes(squash(text).slice(0, 25));
144
166
  }
167
+ /** The Esc key as the tool reads it: in win32-input-mode (ConPTY turns a bare ESC byte into nothing, waiting for a
168
+ * sequence) a key-down and key-up record; elsewhere the CSI u form the tools ask for (`ESC [ > 5 u`), which a legacy
169
+ * reader ignores harmlessly. Verified against Claude Code on macOS; on Linux not yet. */
170
+ const ESC_INPUT = process.platform === 'win32' ? '\x1b[27;1;27;1;0;1_\x1b[27;1;27;0;0;1_' : '\x1b[27u';
171
+ /** Did the tool answer the exit command with its "background work is running, exit and stop it or stay?" dialogue?
172
+ * Claude Code asks this while a shell the agent started (a monitor it is waiting on, say) still runs. */
173
+ export function exitDeclined(output) {
174
+ return /Backgroundworkisrunning|willstopwhenyouexit/i.test(stripAnsi(output).replace(/\s+/g, ''));
175
+ }
145
176
  /** The terminal title is chord's: "chord <agent> · <task>", so a window or tab can be matched to a `chord ls` row.
146
177
  * The tools set their own titles (Claude Code its session summary, Codex the folder name), so their title
147
178
  * sequences (OSC 0, 1, 2) are removed from the forwarded output; everything else, including Codex's OSC 7501
@@ -169,9 +200,14 @@ export const titleSequence = (title) => `\x1b]0;${title.replace(/[\x00-\x1f\x7f]
169
200
  * stop`), press Enter once more, or nothing. Messages stay in the inbox until a hook takes them, so a lost
170
201
  * keystroke is retried, never dropped. `typed`/`typedAt` say what was last typed and when; `echoed` whether the TUI
171
202
  * showed it; `enters` how many times Enter was pressed for it and `enteredAt` when last. Enter is pressed at most
172
- * twice per typed text, never when the text was not echoed, and nothing new is typed while the last text is fresh. */
203
+ * twice per nudge, never when the text was not echoed, and nothing new is typed while the last text is fresh.
204
+ * `quietFor`: milliseconds since the tool last wrote to the terminal. A Stop hook does not mean the turn is over: a
205
+ * goal (/goal) or another plugin's Stop hook keeps the tool working after it, with the spinner still redrawing, so
206
+ * nothing is typed until the screen has been still for a few seconds.
207
+ * The exit command gets no second Enter: the first one quits the tool, and anything that then appears is a dialogue
208
+ * (Claude Code asks about background tasks it would stop), where Enter would pick the default for the user. */
173
209
  export function nextAction(s, now) {
174
- if (s.status !== 'idle' || !s.gateClear)
210
+ if (s.status !== 'idle' || !s.gateClear || s.quietFor < 3000)
175
211
  return null;
176
212
  const want = s.stop ? 'stop' : s.unread ? 'nudge' : null;
177
213
  if (!want)
@@ -180,20 +216,24 @@ export function nextAction(s, now) {
180
216
  return want; // first try, or the typed text was lost or swallowed
181
217
  if (s.typed !== want)
182
218
  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)
219
+ if (s.typed === 'stop' || !s.echoed || s.enters !== 1)
184
220
  return null; // swallowed: hands off; never pressed: the user got there first; pressed twice: enough
185
221
  if (now - s.enteredAt > 5_000)
186
222
  return 'enter'; // typed and echoed but not submitted: press Enter once more
187
223
  return null;
188
224
  }
189
- /** Codex reports its state to the terminal as OSC 7501 ("state=idle" / "state=working"); Claude Code does not. */
225
+ /** Codex reports its state to the terminal as OSC 7501 ("state=idle" / "state=working", and "state=done" when a turn
226
+ * ends, as of 0.164); Claude Code does not. */
190
227
  export function oscState(chunk) {
191
- const m = /\x1b\]7501;state=(\w+)/g;
228
+ const m = /\x1b\]7501;state=(\w+)[^\x07\x1b]*(?:\x07|\x1b\\)/g; // complete reports only: "state=id" cut off by a chunk is not "busy"
192
229
  let last = null;
193
230
  for (const x of chunk.matchAll(m))
194
- last = x[1] === 'idle' ? 'idle' : 'busy';
231
+ last = x[1] === 'idle' || x[1] === 'done' ? 'idle' : 'busy';
195
232
  return last;
196
233
  }
234
+ /** An OSC sequence a chunk ends in the middle of, to prepend to the next chunk; '' when the chunk ends cleanly, so a
235
+ * complete report is never read twice. */
236
+ export const oscTail = (chunk) => /\x1b\][^\x07\x1b]*$/.exec(chunk)?.[0] ?? '';
197
237
  /** A database call from the wrapper's own callbacks. One that fails (a lock held past busy_timeout while the disk
198
238
  * stalls) is logged and dropped: thrown from a timer or a PTY callback it would end the wrapper and the agent in
199
239
  * it, and the next tick or terminal report puts the state right anyway. */
@@ -212,15 +252,19 @@ export function run(tool, launch, extraArgs) {
212
252
  if (/^CLAUDECODE/.test(k))
213
253
  delete env[k]; // allow launching from inside a Claude session
214
254
  const term = pty.spawn(file, [...args, ...launch.args, ...extraArgs], {
215
- name: 'xterm-256color', cols: process.stdout.columns || 120, rows: process.stdout.rows || 40, cwd: launch.cwd, env,
255
+ 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)
216
256
  });
217
257
  const d = db.openDb(launch.dbFile);
218
- db.addAgent(d, { name: launch.name, tool, pid: term.pid, cwd: launch.cwd, worktree: launch.worktree, heartbeat: true });
219
- let outTail = '', echoBuf = '', awaitingEcho = false;
258
+ // prepare registered the agent; the wrapper only takes it over (a fresh registration would drop messages that
259
+ // arrived during setup and a stop requested meanwhile)
260
+ db.setPid(d, launch.name, term.pid);
261
+ db.heartbeat(d, launch.name);
262
+ let outTail = '', echoBuf = '', awaitingEcho = false, lastOutputAt = Date.now();
220
263
  const titles = new TitleFilter();
221
264
  let title = titleFor(launch.name, null);
222
265
  process.stdout.write(titleSequence(title));
223
266
  term.onData(x => {
267
+ lastOutputAt = Date.now();
224
268
  process.stdout.write(titles.push(x));
225
269
  if (awaitingEcho)
226
270
  echoBuf = (echoBuf + x).slice(-16000);
@@ -228,7 +272,7 @@ export function run(tool, launch, extraArgs) {
228
272
  const state = oscState(outTail + x);
229
273
  if (state)
230
274
  tolerant('terminal state report', () => db.setStatus(d, launch.name, state));
231
- outTail = x.slice(-64);
275
+ outTail = oscTail(x);
232
276
  }
233
277
  });
234
278
  const gate = new InputGate();
@@ -243,8 +287,13 @@ export function run(tool, launch, extraArgs) {
243
287
  const onResize = () => term.resize(process.stdout.columns || 120, process.stdout.rows || 40);
244
288
  process.stdout.on('resize', onResize);
245
289
  Object.assign(process.env, launch.env); // so the wrapper writes to the same chord.db.log as the hooks
246
- let typed = null, typedAt = 0, enteredAt = 0, enters = 0, echoed = false, lastState = '';
290
+ let typed = null, typedAt = 0, enteredAt = 0, enters = 0, echoed = false, lastState = '', lastTick = Date.now();
247
291
  const timer = setInterval(() => tolerant('wake loop', () => {
292
+ // a tick long overdue means the machine slept: vouch for every wrapper before a hook sweeps the ones not yet awake
293
+ const now = Date.now();
294
+ if (now - lastTick > db.HEARTBEAT_STALE_MS)
295
+ db.heartbeatAll(d);
296
+ lastTick = now;
248
297
  db.heartbeat(d, launch.name);
249
298
  const me = db.getAgent(d, launch.name);
250
299
  const wanted = titleFor(launch.name, me?.task);
@@ -258,11 +307,11 @@ export function run(tool, launch, extraArgs) {
258
307
  typedAt = enteredAt = enters = 0;
259
308
  echoed = false;
260
309
  }
261
- const s = { status, unread: db.hasUnread(d, launch.name), stop: !!me?.stop_requested_at, gateClear: gate.clear() && gate.settled(), typed, typedAt, echoed, enters, enteredAt };
310
+ 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 };
262
311
  const action = nextAction(s, Date.now());
263
- const state = `${status}/${s.unread}/${s.stop}/${s.gateClear}/${action}`;
312
+ const state = `${status}/${s.unread}/${s.stop}/${s.gateClear}/${s.quietFor >= 3000}/${action}`;
264
313
  if (state !== lastState) {
265
- db.log(`wake loop: status=${status} unread=${s.unread} stop=${s.stop} gateClear=${s.gateClear} action=${action}`);
314
+ db.log(`wake loop: status=${status} unread=${s.unread} stop=${s.stop} gateClear=${s.gateClear} quiet=${s.quietFor >= 3000} action=${action}`);
266
315
  lastState = state;
267
316
  }
268
317
  if (!action)
@@ -297,6 +346,19 @@ export function run(tool, launch, extraArgs) {
297
346
  term.write('\r');
298
347
  enteredAt = Date.now();
299
348
  enters = 1;
349
+ if (action !== 'stop')
350
+ return;
351
+ // Quitting over the agent's background work would kill it, so chord stays (Esc) and tries again once the exit
352
+ // command has expired, by which time the work may be done.
353
+ echoBuf = '';
354
+ awaitingEcho = true;
355
+ setTimeout(() => {
356
+ awaitingEcho = false;
357
+ if (!exitDeclined(echoBuf))
358
+ return;
359
+ db.log('wake loop: the tool asked before quitting because the agent has background work running; staying, will try again later');
360
+ term.write(ESC_INPUT);
361
+ }, 2000);
300
362
  }, 1500);
301
363
  }), 1000);
302
364
  return new Promise(resolve => {
package/dist/serve.js CHANGED
@@ -1,12 +1,11 @@
1
- // Entry point for the per-agent MCP server. Spawned by the coding agent over stdio, in the agent's workspace.
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, never from cwd, which would make every claim path wrong.
2
3
  import { openDb } from "./db.js";
3
4
  import { serve } from "./mcp.js";
4
5
  import { tools } from "./tools.js";
5
- const agent = process.env.CHORD_AGENT_ID;
6
- const file = process.env.CHORD_DB;
7
- if (!agent || !file) {
8
- process.stderr.write('chord: CHORD_AGENT_ID and CHORD_DB must be set\n');
6
+ const { CHORD_AGENT_ID: agent, CHORD_DB: file, CHORD_WORKSPACE: cwd, CHORD_WORKTREE: worktree } = process.env;
7
+ if (!agent || !file || !cwd || !worktree) {
8
+ process.stderr.write('chord: CHORD_AGENT_ID, CHORD_DB, CHORD_WORKSPACE and CHORD_WORKTREE must be set\n');
9
9
  process.exit(2);
10
10
  }
11
- const cwd = process.env.CHORD_WORKSPACE ?? process.cwd();
12
- serve(tools({ db: openDb(file), agent, cwd, worktree: process.env.CHORD_WORKTREE ?? cwd, project: process.env.CHORD_PROJECT }));
11
+ serve(tools({ db: openDb(file), agent, cwd, worktree, project: process.env.CHORD_PROJECT }));
package/dist/tools.js CHANGED
@@ -1,36 +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; any other relative path is read as
14
- * repository-relative (what the schema used to say, and what agents working inside the repo naturally
15
- * write). Paths outside the workspace are refused: chord cannot coordinate them.
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
25
+ * workspace are refused: chord cannot coordinate them.
16
26
  */
17
27
  export function claimPath(ctx, file) {
18
- const repoDir = path.relative(ctx.cwd, ctx.worktree).split(path.sep).join('/');
19
28
  const asGiven = normFile(ctx, file);
20
29
  let abs;
21
- if (path.isAbsolute(file) || !repoDir || repoDir === '.' || /^\.\.(\/|$)/.test(asGiven))
30
+ const fromProject = 'chord/agents/'; // the spelling from the tool's cwd, the project folder; another agent's folder then fails the check below
31
+ if (path.isAbsolute(file) || /^\.\.(\/|$)/.test(asGiven))
22
32
  abs = path.resolve(ctx.cwd, file);
23
- else if (asGiven === repoDir || asGiven.startsWith(repoDir + '/')) {
24
- // "rich/console.py" is the workspace path of the repository's console.py, or the repository path of a package
25
- // named like the repository (rich/rich/console.py); whichever exists is meant, and both existing is ambiguous
26
- const asWorkspace = path.resolve(ctx.cwd, file), asRepo = path.resolve(ctx.worktree, file);
27
- const w = fs.existsSync(asWorkspace), r = fs.existsSync(asRepo);
28
- if (w && r && asWorkspace !== asRepo)
29
- throw new Error(`${file} is ambiguous: it could be ${normFile(ctx, asWorkspace)} or ${normFile(ctx, asRepo)}; give the path from your workspace root`);
30
- abs = r && !w ? asRepo : asWorkspace;
31
- }
32
- else
33
+ else if (ctx.project && asGiven.startsWith(fromProject))
34
+ abs = path.resolve(ctx.project, file);
35
+ else {
33
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
+ }
34
45
  if (!insideDir(ctx.cwd, abs))
35
46
  throw new Error(`${file} is outside your workspace; chord only coordinates files under ${ctx.cwd}`);
36
47
  return normFile(ctx, abs);
@@ -38,7 +49,7 @@ export function claimPath(ctx, file) {
38
49
  /** How long a latecomer waits for replies before it may write anyway. */
39
50
  export const REPLY_TIMEOUT_MS = 10 * 60 * 1000;
40
51
  /** How long before its claim a latecomer's message to an occupant still counts as asking about that file. */
41
- export const ASK_WINDOW_MS = 2 * 60 * 1000;
52
+ const ASK_WINDOW_MS = 2 * 60 * 1000;
42
53
  const str = (desc) => ({ type: 'string', description: desc });
43
54
  function req(args, key) {
44
55
  const v = args[key];
@@ -46,9 +57,9 @@ function req(args, key) {
46
57
  throw new Error(`${key} is required`);
47
58
  return v;
48
59
  }
49
- /** 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. */
50
61
  export function normFile(ctx, file) {
51
- 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('/');
52
63
  }
53
64
  export function peers(ctx) {
54
65
  return db.listAgents(ctx.db).filter(a => a.name !== ctx.agent).map(a => ({
@@ -57,10 +68,11 @@ export function peers(ctx) {
57
68
  }
58
69
  // words that describe code rather than name it: "the parse function" and "parse tests" share only "parse"
59
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']);
60
- 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)) ?? []);
61
73
  /** Two modify claims on one file that each name the functions or blocks they touch, with none in common, do not
62
74
  * need to talk first. Anything less certain (no symbols on either side, or an add, delete or rename) does. */
63
- export function disjointEdits(a, b) {
75
+ function disjointEdits(a, b) {
64
76
  if (a.kind !== 'modify' || b.kind !== 'modify')
65
77
  return false;
66
78
  const x = symbolSet(a.symbols), y = symbolSet(b.symbols);
@@ -69,7 +81,9 @@ export function disjointEdits(a, b) {
69
81
  /** Online agents who claimed `file` before `mine` did, split into those `mine` overlaps and must talk to and those
70
82
  * editing other parts of it. */
71
83
  function earlierOccupants(ctx, mine) {
72
- 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;
73
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');
74
88
  return { overlap: all.filter(c => !disjointEdits(c, mine)), elsewhere: all.filter(c => disjointEdits(c, mine)) };
75
89
  }
@@ -99,7 +113,7 @@ export function canWrite(ctx, file, now = Date.now()) {
99
113
  const askedAt = db.firstMessageAt(ctx.db, ctx.agent, o.agent, Math.max(since, mine.created_at - ASK_WINDOW_MS));
100
114
  if (askedAt === null)
101
115
  notAsked.push(o.agent);
102
- 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)
103
117
  waiting.push({ agent: o.agent, left: REPLY_TIMEOUT_MS - (now - askedAt) });
104
118
  }
105
119
  if (notAsked.length) {
@@ -111,9 +125,7 @@ export function canWrite(ctx, file, now = Date.now()) {
111
125
  }
112
126
  return { ok: true };
113
127
  }
114
- // 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
115
- // already happened into an exception
116
- 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;
117
129
  const gitOk = (cwd, ...args) => { try {
118
130
  git(cwd, ...args);
119
131
  return true;
@@ -122,7 +134,7 @@ catch {
122
134
  return false;
123
135
  } };
124
136
  const stderrOf = (e) => (e.stderr ?? e.message).trim();
125
- const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
137
+ const { sleepSync } = db;
126
138
  /** Tracked files with real changes, staged or not, ignoring line-ending differences and whatever happens inside
127
139
  * submodules; '' when clean. */
128
140
  export function dirtyTracked(cwd) {
@@ -144,8 +156,10 @@ function gitRetry(cwd, ...args) {
144
156
  }
145
157
  }
146
158
  }
147
- /** Run fn holding an exclusive lock file. Waits up to `waitMs`; a lock older than `staleMs` is taken over. */
148
- 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) {
149
163
  const t0 = Date.now();
150
164
  for (;;) {
151
165
  try {
@@ -156,7 +170,8 @@ export function withLock(file, fn, waitMs = 240_000, staleMs = 180_000) {
156
170
  if (e.code !== 'EEXIST')
157
171
  throw e;
158
172
  try {
159
- 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) {
160
175
  fs.unlinkSync(file);
161
176
  continue;
162
177
  }
@@ -169,16 +184,50 @@ export function withLock(file, fn, waitMs = 240_000, staleMs = 180_000) {
169
184
  sleepSync(150 + Math.random() * 200);
170
185
  }
171
186
  }
187
+ // only our own lock is removed: one taken over meanwhile belongs to whoever holds it now
172
188
  try {
173
189
  return fn();
174
190
  }
175
191
  finally {
176
192
  try {
177
- fs.unlinkSync(file);
193
+ if (fs.readFileSync(file, 'utf8') === String(process.pid))
194
+ fs.unlinkSync(file);
178
195
  }
179
196
  catch { /* already gone */ }
180
197
  }
181
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
+ }
182
231
  /** Rebase the agent's branch onto main and fast-forward main to it. Atomic from the agent's point of view.
183
232
  * Finishes are serialised with a lock file: git cannot run two merges in one checkout concurrently. */
184
233
  export function finish(ctx) {
@@ -189,9 +238,9 @@ function finishLocked(ctx) {
189
238
  const wt = ctx.worktree;
190
239
  // A rebase in progress is only ours to clean up when finish itself started it (a finish killed mid-rebase leaves
191
240
  // the marker behind). Anyone else's rebase, perhaps with conflicts already resolved, is left alone.
192
- const marker = path.resolve(wt, git(wt, 'rev-parse', '--git-path', 'chord-finish'));
193
- for (const dir of ['rebase-merge', 'rebase-apply']) {
194
- 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))
195
244
  continue;
196
245
  if (!fs.existsSync(marker))
197
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');
@@ -200,7 +249,6 @@ function finishLocked(ctx) {
200
249
  }
201
250
  fs.rmSync(marker, { force: true });
202
251
  // a finish killed mid-operation can leave the worktree's own index.lock behind; one older than a minute is debris
203
- const indexLock = path.resolve(wt, git(wt, 'rev-parse', '--git-path', 'index.lock'));
204
252
  try {
205
253
  if (Date.now() - fs.statSync(indexLock).mtimeMs > 60_000) {
206
254
  fs.unlinkSync(indexLock);
@@ -213,6 +261,12 @@ function finishLocked(ctx) {
213
261
  // such file as modified the moment it is checked out, and no agent can commit that away.
214
262
  if (dirtyTracked(wt))
215
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.`);
216
270
  // What status still reports now differs only in line endings. git will not rebase over it, and nothing done in a
217
271
  // worktree can clear it: the repository needs a renormalised commit, which is the user's call.
218
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+/, ''));
@@ -265,7 +319,7 @@ function finishLocked(ctx) {
265
319
  const subjects = git(root, 'log', '--format=%s', `${main}..${branch}`).split('\n').filter(Boolean);
266
320
  // A submodule commit the branch points at must exist in the main checkout's copy of the submodule, or the
267
321
  // fast-forward leaves main pointing at a commit that is nowhere but inside this agent's worktree.
268
- 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
269
323
  const m = /^:\d{6} 160000 [0-9a-f]+ ([0-9a-f]+) [AM]\t(.+)$/.exec(line);
270
324
  if (!m)
271
325
  continue;
@@ -306,7 +360,7 @@ function finishLocked(ctx) {
306
360
  db.releaseAll(ctx.db, ctx.agent, 'finish');
307
361
  const online = db.listAgents(ctx.db).filter(a => a.name !== ctx.agent && a.status !== 'offline').map(a => a.name);
308
362
  if (online.length)
309
- db.send(ctx.db, ctx.agent, online, `${ctx.agent} merged into ${main}:\n${subjects.map(s => `- ${s}`).join('\n')}\nNo action is needed: finish() rebases your branch for you when you call it. This is a notification; do not reply.`, 'notice');
363
+ db.send(ctx.db, ctx.agent, online, `${ctx.agent} merged into ${main}:\n${subjects.map(s => `- ${s}`).join('\n')}\n${db.NOTICE_TAIL}`, 'notice');
310
364
  return { merged: subjects, into: main, notified: online };
311
365
  }
312
366
  export function tools(ctx) {
@@ -336,7 +390,7 @@ export function tools(ctx) {
336
390
  inputSchema: {
337
391
  type: 'object',
338
392
  properties: {
339
- file: str('The file, as a path from your working directory (e.g. <repo>/src/x.ts) or relative to the repository root (src/x.ts)'),
393
+ 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)'),
340
394
  intent: str('One sentence: what you will change and why'),
341
395
  kind: { type: 'string', enum: ['add', 'modify', 'delete', 'rename'] },
342
396
  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.'),
@@ -351,7 +405,6 @@ export function tools(ctx) {
351
405
  db.addClaim(ctx.db, { agent: ctx.agent, file, intent: req(a, 'intent'), kind, symbols: typeof a.symbols === 'string' ? a.symbols : null });
352
406
  const mine = db.claimsFor(ctx.db, file).find(c => c.agent === ctx.agent);
353
407
  const { overlap: others, elsewhere } = earlierOccupants(ctx, mine);
354
- 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)}`);
355
408
  const brief = (o) => ({ agent: o.agent, intent: o.intent, kind: o.kind, symbols: o.symbols });
356
409
  return {
357
410
  claimed: file,
package/dist/trust.js CHANGED
@@ -1,10 +1,9 @@
1
- // Claude Code asks "do you trust this folder?" once per working directory and records the answer in
2
- // ~/.claude.json. Every agent workspace is a new directory, so without this each launch asks again.
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';
7
- export const claudeConfigPath = () => path.join(os.homedir(), '.claude.json');
6
+ const claudeConfigPath = () => path.join(os.homedir(), '.claude.json');
8
7
  // Claude Code keys projects by the cwd with forward slashes, even on Windows
9
8
  const norm = (p) => p.replace(/\\/g, '/').toLowerCase();
10
9
  /** Whether the user has answered yes to Claude Code's "do you trust this folder?" dialogue for `dir`. */
@@ -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. */
@@ -63,7 +31,7 @@ export function claudeMcpChoices(dir, file = claudeConfigPath()) {
63
31
  return { all: false, enabled: [], disabled: [] };
64
32
  }
65
33
  }
66
- export const codexConfigPath = () => path.join(os.homedir(), '.codex', 'config.toml');
34
+ const codexConfigPath = () => path.join(os.homedir(), '.codex', 'config.toml');
67
35
  /** Codex keeps a trust hash and an on/off switch per hook in ~/.codex/config.toml. One keypress in its
68
36
  * "Hooks need review" screen switches a hook off, and chord then silently loses the feature behind it, so
69
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.1.3",
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": [
@@ -31,7 +31,11 @@
31
31
  "engines": {
32
32
  "node": ">=24"
33
33
  },
34
- "os": ["win32", "linux"],
34
+ "os": [
35
+ "win32",
36
+ "linux",
37
+ "darwin"
38
+ ],
35
39
  "scripts": {
36
40
  "build": "tsc -p tsconfig.build.json",
37
41
  "prepare": "tsc -p tsconfig.build.json",