chord-cli 0.1.1 β†’ 0.1.3

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 CHANGED
@@ -22,7 +22,7 @@ One repository, as many agents as you care to start. Each agent works in its own
22
22
  - One repository, N agents, one worktree each: parallel work without stepping on each other
23
23
  - A file-level claims board: register before editing, talk to the holder first, and a hook turns away any write that was never claimed
24
24
  - Asynchronous messaging between agents: idle ones are woken, busy ones hear at their next tool call
25
- - `finish` in one step: rebase, fast-forward main, release the claims, tell the others; a conflict stops for a human instead of guessing
25
+ - `finish` in one step: rebase, fast-forward main, release the claims, tell the others; on a conflict nothing changes and the agent is told to rebase, resolve it and finish again, asking you only if it cannot
26
26
  - Agents learn the rules from chord itself, at start-up and on every prompt; you give tasks, not instructions about chord
27
27
  - Agent-agnostic by design: adding a tool means one launch recipe, not a new coordinator
28
28
  - `chord ls` for the whole picture, `messages` and `events` to replay what happened, `stop` and `clean` to wind down
@@ -118,17 +118,19 @@ chord clean # remove the workspaces and branches of agents that hav
118
118
  `<project>/chord/config.json`:
119
119
 
120
120
  ```json
121
- { "setup": "npm ci" }
121
+ { "setup": "npm ci", "test": "npm test" }
122
122
  ```
123
123
 
124
124
  `setup` runs once in every new worktree before the agent starts, with the `CHORD_*` variables in its environment, so a fresh worktree arrives with its dependencies in place. A failed setup abandons the launch and removes the worktree.
125
125
 
126
+ `test`, when set, runs in the agent's worktree each time `finish` has rebased the branch onto main, before main moves. If it fails nothing is merged and the agent gets the end of the output: a clean rebase can still break what another agent merged. Other agents' `finish` waits while it runs.
127
+
126
128
  A repository's own Claude configuration (`.claude/settings*.json`, `.mcp.json`) reaches an agent once you have trusted that repository in Claude Code yourself; the MCP servers you approved there are the ones that start.
127
129
 
128
130
  ## πŸ”Œ How chord works with your tools
129
131
 
130
132
  - **Waking an agent** means typing a short note into its terminal. chord does this only when the input box is empty, the keyboard has been quiet for a few seconds, and the terminal has echoed the note; a dialogue never receives the Enter.
131
- - **Trust** for each agent workspace is recorded in `~/.claude.json`, so Claude asks about a folder once rather than for every agent. Codex asks you to review chord's hooks once, and again whenever their definition changes; answer "trust all". chord says so at launch if a hook has been switched off.
133
+ - **Trust**: chord records the project's agents folder as trusted in `~/.claude.json` once, so Claude does not ask about each agent's workspace. Codex asks you to review chord's hooks once, and again whenever their definition changes; answer "trust all". chord says so at launch if a hook has been switched off.
132
134
  - **Branches and worktrees.** Each agent adds a `chord/<name>` branch and a worktree; `chord clean` removes them when the agent has left and keeps anything not yet on main.
133
135
  - **Permissions.** chord's own tools are pre-approved for Claude, so an agent working on its own is never parked at a prompt for them; your usual shell and edit approvals apply as before. Codex runs with full access. On Windows chord refuses a sandboxed launch, because that sandbox cannot run the hooks; on Linux the hooks run inside the sandbox, but Codex then asks you to approve each chord tool call.
134
136
  - **The terminal title** reads `chord <agent> Β· <task>`, so each window says which agent it is.
@@ -136,7 +138,7 @@ A repository's own Claude configuration (`.claude/settings*.json`, `.mcp.json`)
136
138
 
137
139
  ## πŸ—ΊοΈ Scope
138
140
 
139
- - Runs on Windows 11 and Linux. Windows has had the whole end-to-end suite; Linux the unit tests and part of it so far.
141
+ - Runs on Windows 11 and Linux.
140
142
  - One repository per project.
141
143
  - Project paths up to about 190 characters.
142
144
  - A repository with CRLF files and a `* text` attribute that was never renormalised shows phantom changes in every worktree; chord names the files and the command that puts the repository right.
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ import * as db from "./db.js";
15
15
  import { mergeUserSettings, prepare } from "./launch.js";
16
16
  import { NeedsRestructure, knownProjects, resolveProject, restructure, restructureBlocker, validateRepoName } from "./project.js";
17
17
  import { resolveCommand, run } from "./pty.js";
18
- import { disabledCodexHooks, pretrustClaude } from "./trust.js";
18
+ import { codexFirstStart, disabledCodexHooks, pretrustClaude } from "./trust.js";
19
19
  const USAGE = 'usage: chord <claude|codex> [--name <agent>] [tool args...]\n chord ls [--all]\n chord messages [agent] [-n N]\n chord events [agent] [-n N]\n chord stop <agent>\n chord clean [--force] [agent...]\n';
20
20
  const gitOut = (cwd, ...args) => {
21
21
  try {
@@ -301,11 +301,13 @@ async function main(argv) {
301
301
  if (userSettings)
302
302
  launch.args = mergeUserSettings(launch.args, userSettings);
303
303
  if (cmd === 'claude') { // otherwise every new workspace asks "do you trust this folder?"; a failure here only brings that dialogue back
304
+ // Claude inherits trust from a parent folder, so the project's agents folder is trusted once. One entry per
305
+ // workspace was lost whenever two agents started together: each launch, and every running Claude, rewrites the file.
304
306
  try {
305
- pretrustClaude(launch.cwd);
307
+ pretrustClaude(path.dirname(launch.cwd));
306
308
  }
307
309
  catch (e) {
308
- process.stderr.write(`chord: could not record the workspace as trusted in ~/.claude.json (${e.message}); Claude may ask you to trust the folder\n`);
310
+ process.stderr.write(`chord: could not record the agents folder as trusted in ~/.claude.json (${e.message}); Claude may ask you to trust the folder\n`);
309
311
  }
310
312
  }
311
313
  if (cmd === 'codex') {
@@ -313,7 +315,10 @@ async function main(argv) {
313
315
  if (off.length)
314
316
  process.stderr.write(`chord: Codex has chord's ${off.join(', ')} hook(s) switched off in ~/.codex/config.toml, so the matching chord features will not work. Remove the "enabled = false" line(s) under the [hooks.state.'...<session-flags>...'] tables to switch them back on.\n`);
315
317
  }
316
- return run(cmd, launch, rest);
318
+ const spawned = cmd === 'codex' ? await codexFirstStart() : () => { };
319
+ const exit = run(cmd, launch, rest);
320
+ spawned();
321
+ return exit;
317
322
  }
318
323
  // run only as the entry point (argv[1] may be a symlink such as an npm bin link)
319
324
  const isMain = (() => { try {
package/dist/db.js CHANGED
@@ -23,6 +23,22 @@ export function openDb(file) {
23
23
  // the busy timeout must be in force before the first statement: switching to WAL below does not wait otherwise,
24
24
  // and several agents starting at once on a new database all hit it together
25
25
  const db = new DatabaseSync(file, { timeout: 5000 });
26
+ // A brand-new database opened by several processes at once: SQLite answers the WAL switch and the schema with an
27
+ // immediate SQLITE_BUSY instead of waiting (seen on Linux, never on Windows), so the setup is retried briefly.
28
+ for (let attempt = 0;; attempt++) {
29
+ try {
30
+ setup(db);
31
+ break;
32
+ }
33
+ catch (e) {
34
+ if (attempt >= 50 || !/database is locked/.test(e.message))
35
+ throw e;
36
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
37
+ }
38
+ }
39
+ return db;
40
+ }
41
+ function setup(db) {
26
42
  db.exec(`
27
43
  pragma journal_mode = wal;
28
44
  pragma busy_timeout = 5000;
@@ -70,7 +86,6 @@ export function openDb(file) {
70
86
  );
71
87
  `);
72
88
  migrate(db);
73
- return db;
74
89
  }
75
90
  /** Bump when the schema changes and add a step below; `pragma user_version` records what a database has. A database
76
91
  * from before versioning reads as 0 and gets every step, each of which checks what is actually there. */
package/dist/hooks.js CHANGED
@@ -58,12 +58,12 @@ export function briefing(ctx) {
58
58
  '',
59
59
  'Rules:',
60
60
  '1. First thing: call register(task, scope) to say what you are doing.',
61
- '2. Before creating or changing any file, call claim(file, intent, kind). Writes to unclaimed files are refused.',
61
+ '2. Before creating or changing any file, call claim(file, intent, kind), naming in symbols the functions or blocks you will touch. Writes to unclaimed files are refused.',
62
62
  '3. If claim() lists agents under must_talk_to, read their intent and send() each of them a message saying what you will change. You may write once each has replied, or after 10 minutes. Do other work meanwhile; never wait idle.',
63
63
  '4. Do not modify files through shell commands (sed, echo >, etc.). Use the editor tools so claims are checked.',
64
64
  '5. To talk to other agents use only chord send()/inbox(); do not use any other agent-messaging feature. Messages are asynchronous.',
65
65
  '6. To learn about the project, read the agent list and claims below and ask a specific agent with send(); do not ask the user.',
66
- '7. When your change is complete and committed on your branch, call finish() to rebase onto main and fast-forward it. If finish() reports a conflict, stop and ask the user.',
66
+ '7. When your change is complete and committed on your branch, call finish() to rebase onto main and fast-forward it, unless the user has asked you to hold off merging. If finish() reports a conflict, rebase onto main yourself, resolve it so both changes survive (ask the agent whose work you meet with send() if its intent is unclear), run the tests, commit and call finish() again; ask the user only if you cannot resolve it.',
67
67
  '8. Never merge, rebase onto, push or otherwise update the main branch yourself, and never touch the main checkout: finish() is the only way changes reach main. This holds even if the project\'s own instructions say otherwise.',
68
68
  '9. Do not use git stash: every worktree of this repository shares one stash list, so a stash pop can hand you another agent\'s changes. Make a work-in-progress commit instead.',
69
69
  '',
package/dist/launch.js CHANGED
@@ -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
@@ -194,6 +194,17 @@ export function oscState(chunk) {
194
194
  last = x[1] === 'idle' ? 'idle' : 'busy';
195
195
  return last;
196
196
  }
197
+ /** A database call from the wrapper's own callbacks. One that fails (a lock held past busy_timeout while the disk
198
+ * stalls) is logged and dropped: thrown from a timer or a PTY callback it would end the wrapper and the agent in
199
+ * it, and the next tick or terminal report puts the state right anyway. */
200
+ function tolerant(what, f) {
201
+ try {
202
+ f();
203
+ }
204
+ catch (e) {
205
+ db.log(`${what}: ${e.message}; skipped`);
206
+ }
207
+ }
197
208
  export function run(tool, launch, extraArgs) {
198
209
  const { file, args } = resolveCommand(tool);
199
210
  const env = withNodeOnPath({ ...process.env, ...launch.env });
@@ -216,7 +227,7 @@ export function run(tool, launch, extraArgs) {
216
227
  if (tool === 'codex') { // the hooks set status too; the terminal report catches turns the hooks miss (Esc, untrusted hooks)
217
228
  const state = oscState(outTail + x);
218
229
  if (state)
219
- db.setStatus(d, launch.name, state);
230
+ tolerant('terminal state report', () => db.setStatus(d, launch.name, state));
220
231
  outTail = x.slice(-64);
221
232
  }
222
233
  });
@@ -233,7 +244,7 @@ export function run(tool, launch, extraArgs) {
233
244
  process.stdout.on('resize', onResize);
234
245
  Object.assign(process.env, launch.env); // so the wrapper writes to the same chord.db.log as the hooks
235
246
  let typed = null, typedAt = 0, enteredAt = 0, enters = 0, echoed = false, lastState = '';
236
- const timer = setInterval(() => {
247
+ const timer = setInterval(() => tolerant('wake loop', () => {
237
248
  db.heartbeat(d, launch.name);
238
249
  const me = db.getAgent(d, launch.name);
239
250
  const wanted = titleFor(launch.name, me?.task);
@@ -287,7 +298,7 @@ export function run(tool, launch, extraArgs) {
287
298
  enteredAt = Date.now();
288
299
  enters = 1;
289
300
  }, 1500);
290
- }, 1000);
301
+ }), 1000);
291
302
  return new Promise(resolve => {
292
303
  term.onExit(({ exitCode }) => {
293
304
  clearInterval(timer);
@@ -296,8 +307,8 @@ export function run(tool, launch, extraArgs) {
296
307
  if (process.stdin.isTTY)
297
308
  process.stdin.setRawMode(false);
298
309
  process.stdin.pause();
299
- db.setStatus(d, launch.name, 'offline');
300
- db.releaseAll(d, launch.name, 'exit');
310
+ tolerant('exit', () => db.setStatus(d, launch.name, 'offline'));
311
+ tolerant('exit', () => db.releaseAll(d, launch.name, 'exit')); // missed here, the next sweep releases them
301
312
  resolve(exitCode);
302
313
  });
303
314
  });
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));
@@ -54,10 +55,23 @@ export function peers(ctx) {
54
55
  name: a.name, tool: a.tool, status: a.status, cwd: a.cwd, task: a.task, scope: a.scope,
55
56
  }));
56
57
  }
57
- /** Online agents who claimed `file` before `mine` did. */
58
+ // words that describe code rather than name it: "the parse function" and "parse tests" share only "parse"
59
+ 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)) ?? []);
61
+ /** Two modify claims on one file that each name the functions or blocks they touch, with none in common, do not
62
+ * 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) {
64
+ if (a.kind !== 'modify' || b.kind !== 'modify')
65
+ return false;
66
+ const x = symbolSet(a.symbols), y = symbolSet(b.symbols);
67
+ return x.size > 0 && y.size > 0 && ![...x].some(w => y.has(w));
68
+ }
69
+ /** Online agents who claimed `file` before `mine` did, split into those `mine` overlaps and must talk to and those
70
+ * editing other parts of it. */
58
71
  function earlierOccupants(ctx, mine) {
59
72
  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');
73
+ 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
+ return { overlap: all.filter(c => !disjointEdits(c, mine)), elsewhere: all.filter(c => disjointEdits(c, mine)) };
61
75
  }
62
76
  /**
63
77
  * May `ctx.agent` write `file` now? It must hold a claim, and if others claimed the file first it must
@@ -70,7 +84,7 @@ export function canWrite(ctx, file, now = Date.now()) {
70
84
  const mine = db.claimsFor(ctx.db, rel).find(c => c.agent === ctx.agent);
71
85
  if (!mine)
72
86
  return { ok: false, reason: `${rel} is not claimed by you. Call claim(file, intent, kind) first.` };
73
- const others = earlierOccupants(ctx, mine);
87
+ const others = earlierOccupants(ctx, mine).overlap;
74
88
  if (!others.length)
75
89
  return { ok: true };
76
90
  const myStart = db.getAgent(ctx.db, ctx.agent)?.started_at ?? mine.created_at;
@@ -230,7 +244,23 @@ function finishLocked(ctx) {
230
244
  throw new Error(`rebase onto ${main} failed (not a conflict); nothing was merged.\n${stderrOf(e)}`);
231
245
  db.log(`finish: rebase of ${branch} onto ${main} conflicted; aborted`);
232
246
  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)}`);
247
+ 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)}`);
248
+ }
249
+ // The project's tests, on the branch as it now sits on top of main: a clean rebase can still break what another
250
+ // agent merged. ponytail: they run inside the finish lock, so other agents' finish waits for them (withLock gives
251
+ // up after 240 s); run them before taking the lock and re-check main if test suites get long.
252
+ const test = ctx.project ? readConfig({ dir: ctx.project }).test : undefined;
253
+ if (test) {
254
+ db.log(`finish: running the test command in ${wt}: ${test}`);
255
+ try {
256
+ execSync(test, { cwd: wt, stdio: 'pipe', timeout: 600_000, maxBuffer: 64 << 20 });
257
+ }
258
+ catch (e) {
259
+ const x = e;
260
+ const out = `${x.stdout ?? ''}${x.stderr ?? ''}`.trimEnd().split('\n').slice(-40).join('\n');
261
+ db.log(`finish: the test command failed${x.status != null ? ` with exit code ${x.status}` : ''}; nothing merged`);
262
+ 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.`);
263
+ }
234
264
  }
235
265
  const subjects = git(root, 'log', '--format=%s', `${main}..${branch}`).split('\n').filter(Boolean);
236
266
  // A submodule commit the branch points at must exist in the main checkout's copy of the submodule, or the
@@ -309,7 +339,7 @@ export function tools(ctx) {
309
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)'),
310
340
  intent: str('One sentence: what you will change and why'),
311
341
  kind: { type: 'string', enum: ['add', 'modify', 'delete', 'rename'] },
312
- symbols: str('Optional: functions or blocks you will touch'),
342
+ 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
343
  },
314
344
  required: ['file', 'intent', 'kind'],
315
345
  },
@@ -320,11 +350,13 @@ export function tools(ctx) {
320
350
  throw new Error('kind must be add, modify, delete or rename');
321
351
  db.addClaim(ctx.db, { agent: ctx.agent, file, intent: req(a, 'intent'), kind, symbols: typeof a.symbols === 'string' ? a.symbols : null });
322
352
  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)}`);
353
+ 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
+ const brief = (o) => ({ agent: o.agent, intent: o.intent, kind: o.kind, symbols: o.symbols });
325
356
  return {
326
357
  claimed: file,
327
- must_talk_to: others.map(o => ({ agent: o.agent, intent: o.intent, kind: o.kind, symbols: o.symbols })),
358
+ must_talk_to: others.map(brief),
359
+ ...(elsewhere.length ? { also_editing: elsewhere.map(brief) } : {}),
328
360
  ...(others.length ? { next: 'Send each agent above a message describing your change. You may write once each has replied, or after 10 minutes.' } : {}),
329
361
  };
330
362
  },
@@ -366,7 +398,7 @@ export function tools(ctx) {
366
398
  },
367
399
  {
368
400
  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.',
401
+ 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
402
  inputSchema: { type: 'object', properties: {} },
371
403
  handler: () => finish(ctx),
372
404
  },
package/dist/trust.js CHANGED
@@ -88,3 +88,65 @@ export function disabledCodexHooks(file = codexConfigPath()) {
88
88
  }
89
89
  return off;
90
90
  }
91
+ const hasCodexState = (home) => { try {
92
+ return fs.readdirSync(home).some(f => /^state_\d+\.sqlite$/.test(f));
93
+ }
94
+ catch {
95
+ return false;
96
+ } };
97
+ /** Codex creates and migrates its state database (~/.codex/state_N.sqlite) on its first start, and when two start at
98
+ * once the second exits with "local database appears to be damaged". Until that database exists, launches take
99
+ * turns: the first holds a lock while its Codex creates it, the others wait for the lock. Call the returned
100
+ * function once Codex has been spawned; it lets go of the lock when the database is there, or after a minute. */
101
+ export async function codexFirstStart(home = path.join(os.homedir(), '.codex')) {
102
+ if (hasCodexState(home))
103
+ return () => { };
104
+ const lock = path.join(home, '.chord-first-start');
105
+ const t0 = Date.now();
106
+ try {
107
+ fs.mkdirSync(home, { recursive: true });
108
+ }
109
+ catch {
110
+ return () => { };
111
+ }
112
+ for (;;) {
113
+ try {
114
+ fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
115
+ break;
116
+ }
117
+ catch (e) {
118
+ if (e.code !== 'EEXIST')
119
+ return () => { };
120
+ try {
121
+ if (Date.now() - fs.statSync(lock).mtimeMs > 60_000) {
122
+ fs.unlinkSync(lock);
123
+ continue;
124
+ }
125
+ }
126
+ catch {
127
+ continue;
128
+ }
129
+ if (Date.now() - t0 > 90_000)
130
+ return () => { }; // a launch that never lets go must not hold this one up for good
131
+ await new Promise(r => setTimeout(r, 250));
132
+ }
133
+ }
134
+ const unlock = () => { try {
135
+ fs.unlinkSync(lock);
136
+ }
137
+ catch { /* gone */ } };
138
+ if (hasCodexState(home)) {
139
+ unlock();
140
+ return () => { };
141
+ } // the launch before this one created it
142
+ return () => {
143
+ const started = Date.now();
144
+ const poll = setInterval(() => {
145
+ if (!hasCodexState(home) && Date.now() - started < 60_000)
146
+ return;
147
+ clearInterval(poll);
148
+ setTimeout(unlock, 2000).unref(); // the file appears before its migrations have run
149
+ }, 250);
150
+ poll.unref();
151
+ };
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chord-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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": [