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/cli.js CHANGED
@@ -1,11 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // chord <claude|codex> [--name <agent>] [tool args...] launch an agent in its own workspace
3
- // chord ls [--all] agents and file claims, this project or all
4
- // chord messages [agent] [-n N] message history
5
- // chord events [agent] [-n N] claim, release, merge and conflict history
6
- // chord stop <agent> quit an agent once it is idle
7
- // chord clean [--force] [agent...] remove offline agents' workspaces, worktrees and branches
8
- import { execFileSync } from 'node:child_process';
2
+ // The chord command line; see USAGE.
9
3
  import fs from 'node:fs';
10
4
  import path from 'node:path';
11
5
  import { createInterface } from 'node:readline/promises';
@@ -14,45 +8,94 @@ import { clean } from "./clean.js";
14
8
  import * as db from "./db.js";
15
9
  import { mergeUserSettings, prepare } from "./launch.js";
16
10
  import { NeedsRestructure, knownProjects, resolveProject, restructure, restructureBlocker, validateRepoName } from "./project.js";
17
- import { resolveCommand, run } from "./pty.js";
18
- import { codexFirstStart, disabledCodexHooks, pretrustClaude } from "./trust.js";
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
- const gitOut = (cwd, ...args) => {
21
- try {
22
- return execFileSync('git', args, { cwd, encoding: 'utf8', env: db.gitEnv(), stdio: ['ignore', 'pipe', 'ignore'] }).trim();
23
- }
24
- catch {
25
- return undefined;
26
- }
27
- };
11
+ import { ensureSpawnHelper, resolveCommand, run } from "./pty.js";
12
+ import { codexFirstStart, disabledCodexHooks } from "./trust.js";
13
+ const USAGE = [
14
+ 'usage: chord <command> [arguments]',
15
+ '',
16
+ ' chord claude [--name <agent>] ... start a Claude Code agent here',
17
+ ' chord codex [--name <agent>] ... start a Codex agent here',
18
+ " chord resume <agent> ... reopen an agent's conversation",
19
+ ' chord ls [--all] agents and claims (--all: every project)',
20
+ ' chord messages [agent] [-n N] message history, latest N',
21
+ ' chord events [agent] [-n N] claims, releases, merges, conflicts',
22
+ ' chord stop <agent> quit an agent once it is idle',
23
+ " chord clean [--force] [agent...] remove offline agents' worktrees",
24
+ ' chord --version',
25
+ '',
26
+ ' ... stands for arguments passed on to the tool.',
27
+ '',
28
+ ].join('\n');
29
+ const gitOut = (cwd, ...args) => { try {
30
+ return db.git(cwd, ...args);
31
+ }
32
+ catch {
33
+ return undefined;
34
+ } };
28
35
  /** "12s", "3m", "2h", "5d": how long ago. */
29
36
  export function ago(ms) {
30
37
  const s = Math.max(0, Math.round(ms / 1000));
31
38
  return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`;
32
39
  }
33
- /** Local time of day, with the date in front when it is not today. */
40
+ /** Local time of day; another day reads "Oct 17 23:39" (a numeric date differs between the UK and the US), another
41
+ * year adds the year. */
34
42
  export function when(t, now = Date.now()) {
35
- const d = new Date(t), pad = (n) => String(n).padStart(2, '0');
43
+ const d = new Date(t), n = new Date(now), pad = (x) => String(x).padStart(2, '0');
36
44
  const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
37
- return d.toDateString() === new Date(now).toDateString() ? time : `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`;
45
+ if (d.toDateString() === n.toDateString())
46
+ return time;
47
+ return `${d.toDateString().slice(4, 10)}${d.getFullYear() === n.getFullYear() ? '' : ` ${d.getFullYear()}`} ${time.slice(0, 5)}`;
48
+ }
49
+ // East Asian wide and fullwidth ranges, plus emoji: two terminal cells each
50
+ const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u;
51
+ /** Terminal cells `s` takes. */
52
+ export function width(s) {
53
+ let n = 0;
54
+ for (const ch of s)
55
+ n += WIDE.test(ch) ? 2 : 1;
56
+ return n;
38
57
  }
39
- /** Agents (with unread messages, time since the last sign of life, commits on their branch not yet on main) and
40
- * claims. `repo` is the main checkout; without it the branch column shows "?". */
41
- export function ls(d, repo, now = Date.now()) {
42
- db.sweep(d);
58
+ /** `s` cut to `max` cells with an ASCII "..." (the ellipsis character is ambiguous-width). */
59
+ export function fit(s, max) {
60
+ if (width(s) <= max)
61
+ return s;
62
+ let out = '', n = 0;
63
+ for (const ch of s) {
64
+ const w = WIDE.test(ch) ? 2 : 1;
65
+ if (n + w > max - 3)
66
+ break;
67
+ out += ch;
68
+ n += w;
69
+ }
70
+ return out + '...';
71
+ }
72
+ /** Agents (status, unread messages, commits on their branch not yet on main, task), each followed by its claims. `repo`
73
+ * is the main checkout; without it the branch column shows "?". `cols` is the terminal width: lines are cut to fit. */
74
+ export function ls(d, repo, now = Date.now(), cols = Infinity) {
75
+ db.sweep(d, undefined, undefined, 2500); // a short watch, so a killed wrapper whose pid was reused is not shown online for ever
43
76
  const agents = db.listAgents(d);
44
- const claims = db.allClaims(d);
45
77
  const main = repo ? gitOut(repo, 'rev-parse', '--abbrev-ref', 'HEAD') : undefined;
46
78
  const ahead = (name) => { if (!repo || !main)
47
79
  return '?'; const n = gitOut(repo, 'rev-list', '--count', `${main}..chord/${name}`); return n === undefined ? '-' : `+${n}`; };
48
- const unread = (name) => d.prepare('select count(*) c from messages where recipient = ? and read_at is null').get(name).c;
49
- const row = (a) => [a.name.padEnd(28), a.tool.padEnd(7), a.status.padEnd(8), String(unread(a.name)).padEnd(6), ago(now - (a.heartbeat_at ?? a.updated_at)).padEnd(7), ahead(a.name).padEnd(6), `${a.task ?? '-'}${a.scope ? ` [${a.scope}]` : ''}`].join(' ');
50
- const header = ['agent'.padEnd(28), 'tool'.padEnd(7), 'status'.padEnd(8), 'unread', 'active'.padEnd(7), 'branch', 'task'].join(' ');
51
- const out = agents.length ? [header, ...agents.map(row)] : ['no agents'];
52
- out.push('', ...(claims.length ? claims.map(c => `${c.file.padEnd(40)} ${c.agent.padEnd(28)} ${c.kind.padEnd(7)} ${c.intent}`) : ['no claims']));
53
- return out.join('\n') + '\n';
80
+ // messages only: a merge notice never wakes an agent, so it is not waiting on anyone
81
+ const unread = (name) => d.prepare("select count(*) c from messages where recipient = ? and read_at is null and kind = 'message'").get(name).c;
82
+ const label = (a) => (a.name.startsWith(`${a.tool}-`) ? a.name : `${a.name} (${a.tool})`);
83
+ const nameW = Math.max(5, ...agents.map(a => label(a).length));
84
+ // a live agent's heartbeat is always seconds old, so the age only means something once it is offline
85
+ const state = (a) => (a.status === 'offline' ? `off ${ago(now - (a.heartbeat_at ?? a.updated_at))}` : a.status);
86
+ const row = (a) => {
87
+ const head = [label(a).padEnd(nameW), state(a).padEnd(8), String(unread(a.name)).padEnd(6), ahead(a.name).padEnd(6), ''].join(' ');
88
+ return head + fit(`${a.task ?? '-'}${a.scope ? ` [${a.scope}]` : ''}`, cols - head.length);
89
+ };
90
+ const header = ['agent'.padEnd(nameW), 'status'.padEnd(8), 'unread', 'branch', 'task'].join(' ');
91
+ // each agent's claims under its row; a file held by several agents says so on each of their lines
92
+ const also = (c) => { const o = db.claimsFor(d, c.file).filter(x => x.agent !== c.agent).map(x => x.agent); return o.length ? ` (also ${o.join(', ')})` : ''; };
93
+ const claimLines = (a) => db.claimsBy(d, a.name).map(c => fit(` ${c.kind.padEnd(6)} ${c.file} ${c.intent}${also(c)}`, cols));
94
+ if (!agents.length)
95
+ return 'no agents\n';
96
+ return [header, ...agents.flatMap(a => [row(a), ...claimLines(a)])].join('\n') + '\n';
54
97
  }
55
- export function lsAll() {
98
+ export function lsAll(cols = Infinity) {
56
99
  const projects = knownProjects();
57
100
  if (!projects.length)
58
101
  return 'no chord projects on this machine yet\n';
@@ -64,34 +107,66 @@ export function lsAll() {
64
107
  } };
65
108
  return projects.map(p => {
66
109
  try {
67
- return `== ${p.dir ?? p.stateDir}\n${ls(db.openDb(p.dbFile), repoOf(p.dir ?? undefined))}`;
110
+ return `== ${p.dir ?? p.stateDir}\n${ls(db.openDb(p.dbFile), repoOf(p.dir ?? undefined), Date.now(), cols)}`;
68
111
  }
69
112
  catch (e) {
70
113
  return `== ${p.dir ?? p.stateDir}\n(unreadable: ${e.message})\n`;
71
114
  }
72
115
  }).join('\n');
73
116
  }
74
- /** chord messages [agent] [-n N]: the latest messages, oldest first, one line each. */
117
+ /** chord messages [agent] [-n N]: the latest sends, oldest first, one line each. */
75
118
  export function messages(d, agent, limit = 50, now = Date.now()) {
76
119
  const ms = db.listMessages(d, agent, limit);
77
120
  if (!ms.length)
78
121
  return agent ? `no messages to or from ${agent}\n` : 'no messages\n';
79
122
  const oneLine = (s) => s.replace(/\s+/g, ' ').trim();
80
- return ms.map(m => `${when(m.created_at, now)} ${m.sender} -> ${m.recipient}${m.kind !== 'message' ? ` [${m.kind}]` : ''}${m.read_at ? '' : ' (unread)'}: ${oneLine(m.body)}`).join('\n') + '\n';
123
+ const body = (m) => (m.kind === 'notice' && m.body.endsWith('\n' + db.NOTICE_TAIL) ? m.body.slice(0, -db.NOTICE_TAIL.length - 1) : m.body);
124
+ const to = (m) => (m.n > 3 ? `${m.n} agents` : m.recipient);
125
+ const flag = (m) => (m.unread === 0 ? '' : m.unread === m.n ? ' (unread)' : ` (${m.unread}/${m.n} unread)`);
126
+ return ms.map(m => `${when(m.created_at, now)} ${m.sender} -> ${to(m)}${m.kind !== 'message' ? ` [${m.kind}]` : ''}${flag(m)}: ${oneLine(body(m))}`).join('\n') + '\n';
81
127
  }
82
128
  /** chord events [agent] [-n N]: claims, releases, merges and conflicts, oldest first. */
83
129
  export function events(d, agent, limit = 50, now = Date.now()) {
84
130
  const es = db.listEvents(d, agent, limit);
85
131
  if (!es.length)
86
132
  return agent ? `no events for ${agent}\n` : 'no events\n';
87
- return es.map(e => `${when(e.at, now)} ${e.agent.padEnd(28)} ${e.kind.padEnd(8)} ${[e.file, e.detail].filter(Boolean).join(' ')}`).join('\n') + '\n';
133
+ const w = Math.max(...es.map(e => e.agent.length));
134
+ return es.map(e => `${when(e.at, now)} ${e.agent.padEnd(w)} ${e.kind.padEnd(8)} ${[e.file, e.detail].filter(Boolean).join(' ')}`).join('\n') + '\n';
135
+ }
136
+ /** What chord says when the tool has exited: how to carry on, or why resume cannot yet. */
137
+ export function quitHint(d, name) {
138
+ const a = db.getAgent(d, name);
139
+ return a?.session_id
140
+ ? `chord: ${name} quit; \`chord resume ${name}\` continues it\n`
141
+ : `chord: ${name} quit before its conversation was recorded; start it again with chord ${a?.tool ?? 'claude'} --name ${name}\n`;
88
142
  }
89
- /** chord stop <agent>: the wrapper quits the tool once the agent is idle and nothing is typed. */
90
- export function stop(d, name) {
143
+ /** chord clean's report: what went, what stayed and why, and the exact --force that would discard the held work. */
144
+ export function cleanReport(r) {
145
+ const lines = [...r.removed.map(n => `removed ${n}`), ...r.kept.map(k => `kept ${k.name}: ${k.why}`)];
146
+ // named, so the hint never reads as a bare "chord clean --force", which would discard every offline agent's work
147
+ const held = r.kept.filter(k => /^(unmerged|uncommitted)/.test(k.why)).map(k => k.name);
148
+ if (held.length)
149
+ lines.push(`to discard their work: chord clean --force ${held.join(' ')}`);
150
+ return (lines.length ? lines : ['nothing to clean']).join('\n') + '\n';
151
+ }
152
+ /** chord stop <agent>: the wrapper quits the tool once the agent is idle and nothing is typed. `watchMs`: see db.sweep;
153
+ * a wrapper that died with its pid reused must be found out here, or the quit is promised and never comes. */
154
+ export function stop(d, name, watchMs = 2500) {
155
+ db.sweep(d, undefined, undefined, watchMs);
91
156
  if (db.requestStop(d, name))
92
157
  return `stop requested: ${name} will quit when its current turn ends and its input box is empty\n`;
93
158
  throw new Error(db.getAgent(d, name) ? `${name} is already offline` : `no such agent: ${name}`);
94
159
  }
160
+ /** chord resume <agent>: the launch of that agent again under its own tool, reopening the conversation its hooks
161
+ * recorded. Every agent runs in the project folder, so the tools' "latest conversation here" could be anyone's. */
162
+ export function resumeArgv(d, name, more) {
163
+ const a = db.getAgent(d, name);
164
+ if (!a)
165
+ throw new Error(`no such agent: ${name}`);
166
+ if (!a.session_id)
167
+ throw new Error(`no recorded conversation for ${name}; start it again with chord ${a.tool} --name ${name}`);
168
+ return [a.tool, '--name', a.name, ...(a.tool === 'codex' ? ['resume', a.session_id] : ['--resume', a.session_id]), ...more];
169
+ }
95
170
  /** [agent] and -n N for the history commands. */
96
171
  export function listArgs(rest) {
97
172
  let agent, limit = 50;
@@ -110,7 +185,6 @@ export function listArgs(rest) {
110
185
  }
111
186
  return { agent, limit };
112
187
  }
113
- /** `--name <x>` or `--name=<x>` anywhere before the tool's own arguments. */
114
188
  /** The user's own `--settings <json|file>` for Claude, removed from the tool arguments: passed through as is it would
115
189
  * replace chord's --settings wholesale and switch the hooks off, so chord folds it under its own instead. */
116
190
  export function takeSettings(rest) {
@@ -164,6 +238,21 @@ export function refuseHookKillers(tool, rest) {
164
238
  }
165
239
  }
166
240
  }
241
+ /** `[--force|-f] [agent...]` for chord clean. */
242
+ export function cleanArgs(rest) {
243
+ const names = [];
244
+ let force = false;
245
+ for (const a of rest) {
246
+ if (a === '--force' || a === '-f')
247
+ force = true;
248
+ else if (a.startsWith('-'))
249
+ throw new Error(`unknown option ${a}`);
250
+ else
251
+ names.push(a);
252
+ }
253
+ return { names, force };
254
+ }
255
+ /** `--name <x>` or `--name=<x>` anywhere before the tool's own arguments. */
167
256
  export function takeName(rest) {
168
257
  for (let i = 0; i < rest.length; i++) {
169
258
  const a = rest[i];
@@ -220,9 +309,30 @@ async function offerRestructure(e) {
220
309
  }
221
310
  async function main(argv) {
222
311
  const [cmd, ...rest] = argv;
312
+ if (cmd === undefined || cmd === 'help' || cmd === '-h' || cmd === '--help') {
313
+ process.stdout.write(USAGE);
314
+ return 0;
315
+ }
316
+ if (cmd === '--version' || cmd === '-V') {
317
+ process.stdout.write(JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version + '\n');
318
+ return 0;
319
+ }
320
+ // argument shapes are checked before the project is looked up, so a typo gets one line, not a project error
321
+ if (cmd === 'stop' && rest.length !== 1) {
322
+ process.stderr.write('usage: chord stop <agent>\n');
323
+ return 2;
324
+ }
325
+ if (cmd === 'resume' && (!rest[0] || rest[0].startsWith('-'))) {
326
+ process.stderr.write('usage: chord resume <agent> [tool args...]\n');
327
+ return 2;
328
+ }
223
329
  if (cmd === 'ls') {
330
+ const bad = rest.find(a => a !== '--all');
331
+ if (bad)
332
+ throw new Error(`unknown argument ${bad}; usage: chord ls [--all]`);
333
+ const cols = process.stdout.isTTY ? process.stdout.columns ?? Infinity : Infinity; // piped output is never cut
224
334
  if (rest.includes('--all')) {
225
- process.stdout.write(lsAll());
335
+ process.stdout.write(lsAll(cols));
226
336
  return 0;
227
337
  }
228
338
  let project;
@@ -239,10 +349,10 @@ async function main(argv) {
239
349
  process.stdout.write(`no agents have run in ${project.dir} yet\n`);
240
350
  return 0;
241
351
  }
242
- process.stdout.write(ls(db.openDb(file), project.repo));
352
+ process.stdout.write(ls(db.openDb(file), project.repo, Date.now(), cols));
243
353
  return 0;
244
354
  }
245
- if (cmd === 'messages' || cmd === 'events' || cmd === 'stop') {
355
+ if (cmd === 'messages' || cmd === 'events' || cmd === 'stop' || cmd === 'resume') {
246
356
  const project = resolveProject(process.cwd());
247
357
  const file = db.dbPath(project.stateDir);
248
358
  if (!fs.existsSync(file)) {
@@ -251,13 +361,11 @@ async function main(argv) {
251
361
  }
252
362
  const d = db.openDb(file);
253
363
  if (cmd === 'stop') {
254
- if (rest.length !== 1) {
255
- process.stderr.write(USAGE);
256
- return 2;
257
- }
258
364
  process.stdout.write(stop(d, rest[0]));
259
365
  return 0;
260
366
  }
367
+ if (cmd === 'resume')
368
+ return main(resumeArgv(d, rest[0], rest.slice(1)));
261
369
  const { agent, limit } = listArgs(rest);
262
370
  process.stdout.write(cmd === 'messages' ? messages(d, agent, limit) : events(d, agent, limit));
263
371
  return 0;
@@ -269,24 +377,20 @@ async function main(argv) {
269
377
  process.stdout.write(`no agents have run in ${project.dir} yet\n`);
270
378
  return 0;
271
379
  }
272
- const force = rest.includes('--force');
273
- const r = clean(project, db.openDb(file), rest.filter(a => a !== '--force'), force);
274
- for (const n of r.removed)
275
- process.stdout.write(`removed ${n}\n`);
276
- for (const k of r.kept)
277
- process.stdout.write(`kept ${k.name}: ${k.why}\n`);
278
- if (!r.removed.length && !r.kept.length)
279
- process.stdout.write('nothing to clean\n');
380
+ const { names, force } = cleanArgs(rest);
381
+ process.stdout.write(cleanReport(clean(project, db.openDb(file), names, force)));
280
382
  return 0;
281
383
  }
282
384
  if (cmd !== 'claude' && cmd !== 'codex') {
283
- process.stderr.write(USAGE);
385
+ process.stderr.write(`chord: unknown command "${cmd}"\n${USAGE}`);
284
386
  return 2;
285
387
  }
286
388
  const name = takeName(rest);
287
389
  const userSettings = cmd === 'claude' ? takeSettings(rest) : undefined;
288
390
  refuseHookKillers(cmd, rest);
289
391
  resolveCommand(cmd); // fail before creating a workspace when the tool is not installed
392
+ if (process.platform === 'darwin')
393
+ ensureSpawnHelper(); // or when node-pty could not start it
290
394
  let launch;
291
395
  try {
292
396
  launch = prepare(cmd, process.cwd(), name);
@@ -297,19 +401,9 @@ async function main(argv) {
297
401
  await offerRestructure(e);
298
402
  launch = prepare(cmd, process.cwd(), name);
299
403
  }
300
- process.stderr.write(`chord: agent ${launch.name} in ${launch.cwd}\n`);
404
+ process.stderr.write(`chord: agent ${launch.name}; worktree ${launch.worktree}\n`);
301
405
  if (userSettings)
302
406
  launch.args = mergeUserSettings(launch.args, userSettings);
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.
306
- try {
307
- pretrustClaude(path.dirname(launch.cwd));
308
- }
309
- catch (e) {
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`);
311
- }
312
- }
313
407
  if (cmd === 'codex') {
314
408
  const off = disabledCodexHooks();
315
409
  if (off.length)
@@ -317,8 +411,11 @@ async function main(argv) {
317
411
  }
318
412
  const spawned = cmd === 'codex' ? await codexFirstStart() : () => { };
319
413
  const exit = run(cmd, launch, rest);
320
- spawned();
321
- return exit;
414
+ spawned(); // right after the spawn: it releases the Codex first-start lock
415
+ const code = await exit;
416
+ // the tool's own parting line (Codex prints "codex resume <id>") would start it outside chord
417
+ process.stderr.write(quitHint(db.openDb(launch.dbFile), launch.name));
418
+ return code;
322
419
  }
323
420
  // run only as the entry point (argv[1] may be a symlink such as an npm bin link)
324
421
  const isMain = (() => { try {
package/dist/db.js CHANGED
@@ -10,7 +10,11 @@ export function gitEnv() {
10
10
  env[k] = v;
11
11
  return env;
12
12
  }
13
- const commonDir = (cwd) => path.resolve(cwd, execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8', env: gitEnv() }).trim());
13
+ /** git run in `cwd`, trimmed stdout; throws with git's stderr on a non-zero exit. 64 MB of output: a project's
14
+ * post-merge hook may print a great deal, and the default 1 MB would turn a merge that already happened into an
15
+ * exception. */
16
+ export const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 }).trim();
17
+ const commonDir = (cwd) => path.resolve(cwd, git(cwd, 'rev-parse', '--git-common-dir'));
14
18
  /** Root of the main checkout, even when run from inside a linked worktree. */
15
19
  export const mainRoot = (cwd) => path.dirname(commonDir(cwd));
16
20
  /** chord.db lives in the project's state directory under ~/.chord; CHORD_DB carries its path to hooks and servers. */
@@ -33,7 +37,7 @@ export function openDb(file) {
33
37
  catch (e) {
34
38
  if (attempt >= 50 || !/database is locked/.test(e.message))
35
39
  throw e;
36
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
40
+ sleepSync(100);
37
41
  }
38
42
  }
39
43
  return db;
@@ -41,6 +45,7 @@ export function openDb(file) {
41
45
  function setup(db) {
42
46
  db.exec(`
43
47
  pragma journal_mode = wal;
48
+ pragma synchronous = normal;
44
49
  pragma busy_timeout = 5000;
45
50
  pragma foreign_keys = on;
46
51
  create table if not exists agents (
@@ -48,14 +53,14 @@ function setup(db) {
48
53
  tool text not null,
49
54
  pid integer not null,
50
55
  cwd text not null,
51
- worktree text not null,
52
56
  task text,
53
57
  scope text,
54
58
  status text not null default 'busy',
55
59
  started_at integer not null,
56
60
  updated_at integer not null,
57
61
  heartbeat_at integer,
58
- stop_requested_at integer
62
+ stop_requested_at integer,
63
+ session_id text
59
64
  );
60
65
  create table if not exists claims (
61
66
  agent text not null references agents(name),
@@ -64,7 +69,6 @@ function setup(db) {
64
69
  kind text not null,
65
70
  symbols text,
66
71
  created_at integer not null,
67
- asked_at integer,
68
72
  primary key (agent, file)
69
73
  );
70
74
  create table if not exists messages (
@@ -89,42 +93,68 @@ function setup(db) {
89
93
  }
90
94
  /** Bump when the schema changes and add a step below; `pragma user_version` records what a database has. A database
91
95
  * from before versioning reads as 0 and gets every step, each of which checks what is actually there. */
92
- const SCHEMA_VERSION = 2;
96
+ const SCHEMA_VERSION = 4;
93
97
  function migrate(db) {
94
- const v = db.prepare('pragma user_version').get().user_version;
95
- if (v >= SCHEMA_VERSION)
96
- return;
97
- const agentCols = () => db.prepare('pragma table_info(agents)').all().map(c => c.name);
98
- if (v < 1 && !agentCols().includes('heartbeat_at'))
99
- db.exec('alter table agents add column heartbeat_at integer'); // 2026-10-17
100
- if (v < 2 && !agentCols().includes('stop_requested_at'))
101
- db.exec('alter table agents add column stop_requested_at integer'); // 2026-10-17; the events table is created above
102
- db.exec(`pragma user_version = ${SCHEMA_VERSION}`);
98
+ // under a write lock: several processes opening an old database together must not each add the same column
99
+ db.exec('begin immediate');
100
+ try {
101
+ const v = db.prepare('pragma user_version').get().user_version;
102
+ if (v < SCHEMA_VERSION) {
103
+ const agentCols = () => db.prepare('pragma table_info(agents)').all().map(c => c.name);
104
+ if (v < 1 && !agentCols().includes('heartbeat_at'))
105
+ db.exec('alter table agents add column heartbeat_at integer'); // 2026-10-17
106
+ if (v < 2 && !agentCols().includes('stop_requested_at'))
107
+ db.exec('alter table agents add column stop_requested_at integer'); // 2026-10-17; the events table is created above
108
+ if (v < 3 && !agentCols().includes('session_id'))
109
+ db.exec('alter table agents add column session_id text'); // 2026-10-25
110
+ if (v < 4) { // 2026-10-28: two columns written and never read
111
+ if (agentCols().includes('worktree'))
112
+ db.exec('alter table agents drop column worktree');
113
+ if (db.prepare('pragma table_info(claims)').all().some(c => c.name === 'asked_at'))
114
+ db.exec('alter table claims drop column asked_at');
115
+ }
116
+ db.exec(`pragma user_version = ${SCHEMA_VERSION}`);
117
+ }
118
+ db.exec('commit');
119
+ }
120
+ catch (e) {
121
+ db.exec('rollback');
122
+ throw e;
123
+ }
103
124
  }
104
125
  // agents
105
126
  export function addAgent(db, a) {
106
127
  const now = Date.now();
107
- db.prepare(`insert into agents (name, tool, pid, cwd, worktree, status, started_at, updated_at, heartbeat_at)
108
- values (?, ?, ?, ?, ?, 'busy', ?, ?, ?)
109
- on conflict(name) do update set tool = excluded.tool, pid = excluded.pid, cwd = excluded.cwd, worktree = excluded.worktree,
128
+ db.prepare(`insert into agents (name, tool, pid, cwd, status, started_at, updated_at, heartbeat_at)
129
+ values (?, ?, ?, ?, 'busy', ?, ?, ?)
130
+ on conflict(name) do update set tool = excluded.tool, pid = excluded.pid, cwd = excluded.cwd,
110
131
  status = 'busy', task = null, scope = null, started_at = excluded.started_at, updated_at = excluded.updated_at, heartbeat_at = excluded.heartbeat_at,
111
- stop_requested_at = null`).run(a.name, a.tool, a.pid, a.cwd, a.worktree ?? a.cwd, now, now, a.heartbeat ? now : null);
132
+ stop_requested_at = null, session_id = case when tool = excluded.tool then session_id end`).run(a.name, a.tool, a.pid, a.cwd, now, now, a.heartbeat ? now : null);
112
133
  // a relaunch under the same name must not inherit messages meant for the previous session
113
134
  db.prepare('delete from messages where recipient = ? and read_at is null').run(a.name);
114
135
  }
115
- /** Forget an agent entirely: its claims and every message it sent or received. */
136
+ /** Forget an agent: its claims, the messages to it and those it sent that have been read. What it said and nobody
137
+ * has read yet stays: "f.ts is yours now" must still reach its recipient after the sender has been cleaned away. */
116
138
  export function removeAgent(db, name) {
117
139
  db.prepare('delete from claims where agent = ?').run(name);
118
- db.prepare('delete from messages where sender = ? or recipient = ?').run(name, name);
140
+ db.prepare('delete from messages where recipient = ? or (sender = ? and read_at is not null)').run(name, name);
119
141
  db.prepare('delete from agents where name = ?').run(name);
120
142
  }
143
+ /** The wrapper's take-over of the row prepare reserved: the tool's own pid replaces chord's. */
144
+ export function setPid(db, name, pid) {
145
+ db.prepare('update agents set pid = ? where name = ?').run(pid, name);
146
+ }
147
+ /** The tool's conversation id, from the hook payloads; a relaunch keeps the old one until the new session reports its own. */
148
+ export function setSession(db, name, id) {
149
+ db.prepare('update agents set session_id = ? where name = ?').run(id, name);
150
+ }
121
151
  export function setTask(db, name, task, scope) {
122
152
  db.prepare('update agents set task = ?, scope = ?, updated_at = ? where name = ?').run(task, scope, Date.now(), name);
123
153
  }
124
154
  /** Offline is sticky here: a hook that outlives its killed agent must not revive it. Only the wrapper does, through
125
- * addAgent (relaunch) or heartbeat. */
155
+ * addAgent (relaunch) or heartbeat. Returns whether a row changed. */
126
156
  export function setStatus(db, name, status) {
127
- db.prepare(`update agents set status = ?, updated_at = ? where name = ? and status != 'offline'`).run(status, Date.now(), name);
157
+ return db.prepare(`update agents set status = ?, updated_at = ? where name = ? and status != 'offline'`).run(status, Date.now(), name).changes > 0;
128
158
  }
129
159
  export function getAgent(db, name) {
130
160
  return db.prepare('select * from agents where name = ?').get(name);
@@ -132,7 +162,7 @@ export function getAgent(db, name) {
132
162
  export function listAgents(db) {
133
163
  return db.prepare('select * from agents order by started_at').all();
134
164
  }
135
- function pidAlive(pid) {
165
+ export function pidAlive(pid) {
136
166
  try {
137
167
  process.kill(pid, 0);
138
168
  return true;
@@ -145,12 +175,21 @@ function pidAlive(pid) {
145
175
  return code === 'EPERM';
146
176
  }
147
177
  }
178
+ /** The log is moved aside to .log.1 once it passes this size, so twenty agents logging every hook for days do not
179
+ * fill the disk; one old copy stays for looking into what just happened. */
180
+ export const LOG_MAX_BYTES = 16 * 1024 * 1024;
148
181
  /** Append a line to chord.db.log next to the database, when CHORD_DB is known. Silent otherwise. */
149
182
  export function log(line) {
150
183
  if (!process.env.CHORD_DB)
151
184
  return;
185
+ const file = process.env.CHORD_DB + '.log';
152
186
  try {
153
- fs.appendFileSync(process.env.CHORD_DB + '.log', `${new Date().toISOString()} [${process.env.CHORD_AGENT_ID ?? '-'}/${process.pid}] ${line}
187
+ try {
188
+ if (fs.statSync(file).size > LOG_MAX_BYTES)
189
+ fs.renameSync(file, file + '.1');
190
+ }
191
+ catch { /* no log yet, or another process just rotated it */ }
192
+ fs.appendFileSync(file, `${new Date().toISOString()} [${process.env.CHORD_AGENT_ID ?? '-'}/${process.pid}] ${line}
154
193
  `);
155
194
  }
156
195
  catch { /* best effort */ }
@@ -165,21 +204,44 @@ export function heartbeat(db, name) {
165
204
  // working all along; the next Stop, idle report or idle_prompt makes it idle again
166
205
  db.prepare(`update agents set heartbeat_at = ?, status = case when status = 'offline' then 'busy' else status end where name = ?`).run(Date.now(), name);
167
206
  }
207
+ /** Every online wrapper's heartbeat refreshed at once. For the first wrapper to wake after the machine slept: its own
208
+ * heartbeat alone would make the others look dead to a sweep in the second before they wake too. Agents without a
209
+ * wrapper yet are left to their pid. */
210
+ export function heartbeatAll(db) {
211
+ db.prepare(`update agents set heartbeat_at = ? where status != 'offline' and heartbeat_at is not null`).run(Date.now());
212
+ }
213
+ /** How long after launch an agent may go without a heartbeat before the watch in sweep calls it dead. */
214
+ const STARTUP_GRACE_MS = 10 * 60_000;
215
+ export const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
168
216
  /** Mark agents whose process is gone as offline and drop their claims. Returns the names swept. A missing
169
- * process or a stale heartbeat both count: Windows reuses pids quickly, so the pid alone can lie. */
170
- export function sweep(db, alive = pidAlive, now = Date.now()) {
217
+ * process or a stale heartbeat both count: Windows reuses pids quickly, so the pid alone can lie.
218
+ * `watchMs`: when every heartbeat is stale, wait that long and see whose heartbeat moves (a live wrapper writes every
219
+ * second); the rest are dead. Zero, the default for hooks and tools, judges by pid alone in that case. */
220
+ export function sweep(db, alive = pidAlive, now = Date.now(), watchMs = 0, sleep = sleepSync) {
171
221
  const online = listAgents(db).filter(a => a.status !== 'offline');
172
222
  // Every heartbeat stale at once means the machine was asleep, not that everyone died: judge by pid alone then.
173
- // ponytail: a lone dead agent whose pid was reused is therefore missed until a second agent comes online.
223
+ // ponytail: a lone dead agent whose pid was reused is therefore missed by the hooks until a second agent comes online.
174
224
  const beating = online.filter(a => a.heartbeat_at !== null);
175
225
  const stale = (a) => a.heartbeat_at !== null && now - a.heartbeat_at > HEARTBEAT_STALE_MS;
176
226
  const slept = beating.length > 0 && beating.every(stale);
177
- if (slept)
178
- log('sweep: every heartbeat is stale, the machine was probably asleep; judging by pid only');
179
- const dead = online.filter(a => !alive(a.pid) || (!slept && stale(a))).map(a => a.name);
227
+ let dead;
228
+ if (slept && watchMs > 0) {
229
+ sleep(watchMs);
230
+ const moved = new Set(listAgents(db).filter(a => beating.some(b => b.name === a.name && b.heartbeat_at !== a.heartbeat_at)).map(a => a.name));
231
+ // no heartbeat yet while the setup command or Codex's first-start wait runs: a live pid that started lately is starting
232
+ const starting = (a) => now - a.started_at < STARTUP_GRACE_MS;
233
+ dead = online.filter(a => !alive(a.pid) || (a.heartbeat_at !== null && !moved.has(a.name) && !starting(a))).map(a => a.name);
234
+ }
235
+ else {
236
+ if (slept)
237
+ log('sweep: every heartbeat is stale, the machine was probably asleep; judging by pid only');
238
+ dead = online.filter(a => !alive(a.pid) || (!slept && stale(a))).map(a => a.name);
239
+ }
180
240
  for (const name of dead) {
241
+ // several hooks sweep at once; the one whose update took the agent offline releases the claims, so each file gets one release event
242
+ if (!setStatus(db, name, 'offline'))
243
+ continue;
181
244
  log(`sweep: ${name} marked offline, claims released`);
182
- setStatus(db, name, 'offline');
183
245
  releaseAll(db, name, 'swept');
184
246
  }
185
247
  return dead;
@@ -190,9 +252,6 @@ export function addClaim(db, c) {
190
252
  on conflict(agent, file) do update set intent = excluded.intent, kind = excluded.kind, symbols = excluded.symbols`).run(c.agent, c.file, c.intent, c.kind, c.symbols ?? null, Date.now());
191
253
  addEvent(db, c.agent, 'claim', c.file, `${c.kind}: ${c.intent}`);
192
254
  }
193
- export function setAskedAt(db, agent, file, at) {
194
- db.prepare('update claims set asked_at = ? where agent = ? and file = ?').run(at, agent, file);
195
- }
196
255
  export function removeClaim(db, agent, file) {
197
256
  const gone = db.prepare('delete from claims where agent = ? and file = ?').run(agent, file).changes;
198
257
  if (gone)
@@ -216,11 +275,14 @@ export function listEvents(db, agent, limit = 50) {
216
275
  : db.prepare('select * from events order by id desc limit ?').all(limit);
217
276
  return rows.reverse();
218
277
  }
219
- /** The latest `limit` messages (oldest first), all or those sent or received by one agent. */
278
+ /** The closing sentence of a merge notice: for the agents, not for `chord messages`. */
279
+ export const NOTICE_TAIL = 'No action is needed: finish() rebases your branch for you when you call it. This is a notification; do not reply.';
280
+ /** The latest `limit` sends (oldest first), all or those sent or received by one agent. A message to several agents
281
+ * is one row per recipient in the table and one send here. */
220
282
  export function listMessages(db, agent, limit = 50) {
221
- const rows = agent
222
- ? db.prepare('select * from messages where sender = ? or recipient = ? order by id desc limit ?').all(agent, agent, limit)
223
- : db.prepare('select * from messages order by id desc limit ?').all(limit);
283
+ const sql = (where) => `select min(id) id, sender, group_concat(recipient, ', ') recipient, kind, body, created_at, max(read_at) read_at,
284
+ count(*) n, count(*) - count(read_at) unread from messages ${where} group by sender, kind, body, created_at order by max(id) desc limit ?`;
285
+ const rows = agent ? db.prepare(sql('where sender = ? or recipient = ?')).all(agent, agent, limit) : db.prepare(sql('')).all(limit);
224
286
  return rows.reverse();
225
287
  }
226
288
  /** `chord stop`: ask the wrapper to quit the tool once the agent is idle. Returns false for an unknown or offline agent. */
@@ -244,7 +306,7 @@ export function send(db, sender, recipients, body, kind = 'message') {
244
306
  ins.run(sender, r, kind, body, now);
245
307
  }
246
308
  /** Roughly what a hook may attach without the tool truncating it (Codex caps additionalContext near 2500 tokens). */
247
- export const DELIVERY_BUDGET_CHARS = 6000;
309
+ const DELIVERY_BUDGET_CHARS = 6000;
248
310
  /** Unread messages for `recipient`, oldest first, marked read as they are returned. Stops after `budget`
249
311
  * characters (always at least one message) so a flood is delivered in several rounds instead of truncated. */
250
312
  export function takeUnread(db, recipient, budget = DELIVERY_BUDGET_CHARS) {
@@ -275,10 +337,6 @@ export function takeUnread(db, recipient, budget = DELIVERY_BUDGET_CHARS) {
275
337
  export function hasUnread(db, recipient) {
276
338
  return !!db.prepare(`select 1 from messages where recipient = ? and read_at is null and kind = 'message' limit 1`).get(recipient);
277
339
  }
278
- /** Has `sender` sent `recipient` a real message (not a notice) at or after `since`? */
279
- export function repliedSince(db, sender, recipient, since) {
280
- return firstMessageAt(db, sender, recipient, since) !== null;
281
- }
282
340
  /** When `sender` first messaged `recipient` at or after `since`; null if never. Notices do not count. */
283
341
  export function firstMessageAt(db, sender, recipient, since) {
284
342
  const row = db.prepare("select min(created_at) t from messages where sender = ? and recipient = ? and kind = 'message' and created_at >= ?").get(sender, recipient, since);