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/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 its workspace, not the repository, so Claude Code would not pick up the repository's
215
- // own CLAUDE.md, .claude/settings*.json or .mcp.json. --add-dir covers CLAUDE.md; the others are passed along.
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 setup = cfg.setup;
368
- if (setup !== undefined && typeof setup !== 'string')
369
- throw new Error(`${file}: "setup" must be a string (a shell command)`);
370
- return setup ? { setup } : {};
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 typed text, never when the text was not echoed, and nothing new is typed while the last text is fresh. */
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.cwd, env,
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 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, 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; any other relative path is read as
13
- * repository-relative (what the schema used to say, and what agents working inside the repo naturally
14
- * write). Paths outside the workspace are refused: chord cannot coordinate them.
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
- /** Online agents who claimed `file` before `mine` did. */
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
- return db.claimsFor(ctx.db, mine.file).filter(c => c.agent !== ctx.agent && before(c) && db.getAgent(ctx.db, c.agent)?.status !== 'offline');
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. Ask the user how to resolve it.\n${stderrOf(e)}`);
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')}\nNo action is needed: finish() rebases your branch for you when you call it. This is a notification; do not reply.`, 'notice');
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 (e.g. <repo>/src/x.ts) or relative to the repository root (src/x.ts)'),
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(o => ({ agent: o.agent, intent: o.intent, kind: o.kind, symbols: o.symbols })),
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; stop and ask the user.',
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 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';
@@ -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.1.2",
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": ["win32", "linux"],
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",