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/cli.js CHANGED
@@ -1,10 +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
2
+ // The chord command line; see USAGE.
8
3
  import { execFileSync } from 'node:child_process';
9
4
  import fs from 'node:fs';
10
5
  import path from 'node:path';
@@ -15,8 +10,23 @@ import * as db from "./db.js";
15
10
  import { mergeUserSettings, prepare } from "./launch.js";
16
11
  import { NeedsRestructure, knownProjects, resolveProject, restructure, restructureBlocker, validateRepoName } from "./project.js";
17
12
  import { resolveCommand, run } from "./pty.js";
18
- import { 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';
13
+ import { codexFirstStart, disabledCodexHooks } from "./trust.js";
14
+ const USAGE = [
15
+ 'usage: chord <command> [arguments]',
16
+ '',
17
+ ' chord claude [--name <agent>] ... start a Claude Code agent here',
18
+ ' chord codex [--name <agent>] ... start a Codex agent here',
19
+ " chord resume <agent> ... reopen an agent's conversation",
20
+ ' chord ls [--all] agents and claims (--all: every project)',
21
+ ' chord messages [agent] [-n N] message history, latest N',
22
+ ' chord events [agent] [-n N] claims, releases, merges, conflicts',
23
+ ' chord stop <agent> quit an agent once it is idle',
24
+ " chord clean [--force] [agent...] remove offline agents' worktrees",
25
+ ' chord --version',
26
+ '',
27
+ ' ... stands for arguments passed on to the tool.',
28
+ '',
29
+ ].join('\n');
20
30
  const gitOut = (cwd, ...args) => {
21
31
  try {
22
32
  return execFileSync('git', args, { cwd, encoding: 'utf8', env: db.gitEnv(), stdio: ['ignore', 'pipe', 'ignore'] }).trim();
@@ -30,29 +40,65 @@ export function ago(ms) {
30
40
  const s = Math.max(0, Math.round(ms / 1000));
31
41
  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
42
  }
33
- /** Local time of day, with the date in front when it is not today. */
43
+ /** Local time of day; another day reads "Oct 17 23:39" (a numeric date differs between the UK and the US), another
44
+ * year adds the year. */
34
45
  export function when(t, now = Date.now()) {
35
- const d = new Date(t), pad = (n) => String(n).padStart(2, '0');
46
+ const d = new Date(t), n = new Date(now), pad = (x) => String(x).padStart(2, '0');
36
47
  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}`;
48
+ if (d.toDateString() === n.toDateString())
49
+ return time;
50
+ return `${d.toDateString().slice(4, 10)}${d.getFullYear() === n.getFullYear() ? '' : ` ${d.getFullYear()}`} ${time.slice(0, 5)}`;
38
51
  }
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);
52
+ // East Asian wide and fullwidth ranges, plus emoji: two terminal cells each
53
+ 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;
54
+ /** Terminal cells `s` takes. */
55
+ export function width(s) {
56
+ let n = 0;
57
+ for (const ch of s)
58
+ n += WIDE.test(ch) ? 2 : 1;
59
+ return n;
60
+ }
61
+ /** `s` cut to `max` cells with an ASCII "..." (the ellipsis character is ambiguous-width). */
62
+ export function fit(s, max) {
63
+ if (width(s) <= max)
64
+ return s;
65
+ let out = '', n = 0;
66
+ for (const ch of s) {
67
+ const w = WIDE.test(ch) ? 2 : 1;
68
+ if (n + w > max - 3)
69
+ break;
70
+ out += ch;
71
+ n += w;
72
+ }
73
+ return out + '...';
74
+ }
75
+ /** Agents (status, unread messages, commits on their branch not yet on main, task), each followed by its claims. `repo`
76
+ * is the main checkout; without it the branch column shows "?". `cols` is the terminal width: lines are cut to fit. */
77
+ export function ls(d, repo, now = Date.now(), cols = Infinity) {
78
+ db.sweep(d, undefined, undefined, 2500); // a short watch, so a killed wrapper whose pid was reused is not shown online for ever
43
79
  const agents = db.listAgents(d);
44
- const claims = db.allClaims(d);
45
80
  const main = repo ? gitOut(repo, 'rev-parse', '--abbrev-ref', 'HEAD') : undefined;
46
81
  const ahead = (name) => { if (!repo || !main)
47
82
  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';
83
+ // messages only: a merge notice never wakes an agent, so it is not waiting on anyone
84
+ const unread = (name) => d.prepare("select count(*) c from messages where recipient = ? and read_at is null and kind = 'message'").get(name).c;
85
+ const label = (a) => (a.name.startsWith(`${a.tool}-`) ? a.name : `${a.name} (${a.tool})`);
86
+ const nameW = Math.max(5, ...agents.map(a => label(a).length));
87
+ // a live agent's heartbeat is always seconds old, so the age only means something once it is offline
88
+ const state = (a) => (a.status === 'offline' ? `off ${ago(now - (a.heartbeat_at ?? a.updated_at))}` : a.status);
89
+ const row = (a) => {
90
+ const head = [label(a).padEnd(nameW), state(a).padEnd(8), String(unread(a.name)).padEnd(6), ahead(a.name).padEnd(6), ''].join(' ');
91
+ return head + fit(`${a.task ?? '-'}${a.scope ? ` [${a.scope}]` : ''}`, cols - head.length);
92
+ };
93
+ const header = ['agent'.padEnd(nameW), 'status'.padEnd(8), 'unread', 'branch', 'task'].join(' ');
94
+ // each agent's claims under its row; a file held by several agents says so on each of their lines
95
+ 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(', ')})` : ''; };
96
+ const claimLines = (a) => db.claimsBy(d, a.name).map(c => fit(` ${c.kind.padEnd(6)} ${c.file} ${c.intent}${also(c)}`, cols));
97
+ if (!agents.length)
98
+ return 'no agents\n';
99
+ return [header, ...agents.flatMap(a => [row(a), ...claimLines(a)])].join('\n') + '\n';
54
100
  }
55
- export function lsAll() {
101
+ export function lsAll(cols = Infinity) {
56
102
  const projects = knownProjects();
57
103
  if (!projects.length)
58
104
  return 'no chord projects on this machine yet\n';
@@ -64,34 +110,66 @@ export function lsAll() {
64
110
  } };
65
111
  return projects.map(p => {
66
112
  try {
67
- return `== ${p.dir ?? p.stateDir}\n${ls(db.openDb(p.dbFile), repoOf(p.dir ?? undefined))}`;
113
+ return `== ${p.dir ?? p.stateDir}\n${ls(db.openDb(p.dbFile), repoOf(p.dir ?? undefined), Date.now(), cols)}`;
68
114
  }
69
115
  catch (e) {
70
116
  return `== ${p.dir ?? p.stateDir}\n(unreadable: ${e.message})\n`;
71
117
  }
72
118
  }).join('\n');
73
119
  }
74
- /** chord messages [agent] [-n N]: the latest messages, oldest first, one line each. */
120
+ /** chord messages [agent] [-n N]: the latest sends, oldest first, one line each. */
75
121
  export function messages(d, agent, limit = 50, now = Date.now()) {
76
122
  const ms = db.listMessages(d, agent, limit);
77
123
  if (!ms.length)
78
124
  return agent ? `no messages to or from ${agent}\n` : 'no messages\n';
79
125
  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';
126
+ const body = (m) => (m.kind === 'notice' && m.body.endsWith('\n' + db.NOTICE_TAIL) ? m.body.slice(0, -db.NOTICE_TAIL.length - 1) : m.body);
127
+ const to = (m) => (m.n > 3 ? `${m.n} agents` : m.recipient);
128
+ const flag = (m) => (m.unread === 0 ? '' : m.unread === m.n ? ' (unread)' : ` (${m.unread}/${m.n} unread)`);
129
+ 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
130
  }
82
131
  /** chord events [agent] [-n N]: claims, releases, merges and conflicts, oldest first. */
83
132
  export function events(d, agent, limit = 50, now = Date.now()) {
84
133
  const es = db.listEvents(d, agent, limit);
85
134
  if (!es.length)
86
135
  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';
136
+ const w = Math.max(...es.map(e => e.agent.length));
137
+ 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';
138
+ }
139
+ /** What chord says when the tool has exited: how to carry on, or why resume cannot yet. */
140
+ export function quitHint(d, name) {
141
+ const a = db.getAgent(d, name);
142
+ return a?.session_id
143
+ ? `chord: ${name} quit; \`chord resume ${name}\` continues it\n`
144
+ : `chord: ${name} quit before its conversation was recorded; start it again with chord ${a?.tool ?? 'claude'} --name ${name}\n`;
145
+ }
146
+ /** chord clean's report: what went, what stayed and why, and the exact --force that would discard the held work. */
147
+ export function cleanReport(r) {
148
+ const lines = [...r.removed.map(n => `removed ${n}`), ...r.kept.map(k => `kept ${k.name}: ${k.why}`)];
149
+ // named, so the hint never reads as a bare "chord clean --force", which would discard every offline agent's work
150
+ const held = r.kept.filter(k => /^(unmerged|uncommitted)/.test(k.why)).map(k => k.name);
151
+ if (held.length)
152
+ lines.push(`to discard their work: chord clean --force ${held.join(' ')}`);
153
+ return (lines.length ? lines : ['nothing to clean']).join('\n') + '\n';
88
154
  }
89
- /** chord stop <agent>: the wrapper quits the tool once the agent is idle and nothing is typed. */
90
- export function stop(d, name) {
155
+ /** chord stop <agent>: the wrapper quits the tool once the agent is idle and nothing is typed. `watchMs`: see db.sweep;
156
+ * a wrapper that died with its pid reused must be found out here, or the quit is promised and never comes. */
157
+ export function stop(d, name, watchMs = 2500) {
158
+ db.sweep(d, undefined, undefined, watchMs);
91
159
  if (db.requestStop(d, name))
92
160
  return `stop requested: ${name} will quit when its current turn ends and its input box is empty\n`;
93
161
  throw new Error(db.getAgent(d, name) ? `${name} is already offline` : `no such agent: ${name}`);
94
162
  }
163
+ /** chord resume <agent>: the launch of that agent again under its own tool, reopening the conversation its hooks
164
+ * recorded. Every agent runs in the project folder, so the tools' "latest conversation here" could be anyone's. */
165
+ export function resumeArgv(d, name, more) {
166
+ const a = db.getAgent(d, name);
167
+ if (!a)
168
+ throw new Error(`no such agent: ${name}`);
169
+ if (!a.session_id)
170
+ throw new Error(`no recorded conversation for ${name}; start it again with chord ${a.tool} --name ${name}`);
171
+ return [a.tool, '--name', a.name, ...(a.tool === 'codex' ? ['resume', a.session_id] : ['--resume', a.session_id]), ...more];
172
+ }
95
173
  /** [agent] and -n N for the history commands. */
96
174
  export function listArgs(rest) {
97
175
  let agent, limit = 50;
@@ -164,6 +242,20 @@ export function refuseHookKillers(tool, rest) {
164
242
  }
165
243
  }
166
244
  }
245
+ /** `[--force|-f] [agent...]` for chord clean. */
246
+ export function cleanArgs(rest) {
247
+ const names = [];
248
+ let force = false;
249
+ for (const a of rest) {
250
+ if (a === '--force' || a === '-f')
251
+ force = true;
252
+ else if (a.startsWith('-'))
253
+ throw new Error(`unknown option ${a}`);
254
+ else
255
+ names.push(a);
256
+ }
257
+ return { names, force };
258
+ }
167
259
  export function takeName(rest) {
168
260
  for (let i = 0; i < rest.length; i++) {
169
261
  const a = rest[i];
@@ -220,9 +312,30 @@ async function offerRestructure(e) {
220
312
  }
221
313
  async function main(argv) {
222
314
  const [cmd, ...rest] = argv;
315
+ if (cmd === undefined || cmd === 'help' || cmd === '-h' || cmd === '--help') {
316
+ process.stdout.write(USAGE);
317
+ return 0;
318
+ }
319
+ if (cmd === '--version' || cmd === '-V') {
320
+ process.stdout.write(JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version + '\n');
321
+ return 0;
322
+ }
323
+ // argument shapes are checked before the project is looked up, so a typo gets one line, not a project error
324
+ if (cmd === 'stop' && rest.length !== 1) {
325
+ process.stderr.write('usage: chord stop <agent>\n');
326
+ return 2;
327
+ }
328
+ if (cmd === 'resume' && (!rest[0] || rest[0].startsWith('-'))) {
329
+ process.stderr.write('usage: chord resume <agent> [tool args...]\n');
330
+ return 2;
331
+ }
223
332
  if (cmd === 'ls') {
333
+ const bad = rest.find(a => a !== '--all');
334
+ if (bad)
335
+ throw new Error(`unknown argument ${bad}; usage: chord ls [--all]`);
336
+ const cols = process.stdout.isTTY ? process.stdout.columns ?? Infinity : Infinity; // piped output is never cut
224
337
  if (rest.includes('--all')) {
225
- process.stdout.write(lsAll());
338
+ process.stdout.write(lsAll(cols));
226
339
  return 0;
227
340
  }
228
341
  let project;
@@ -239,10 +352,10 @@ async function main(argv) {
239
352
  process.stdout.write(`no agents have run in ${project.dir} yet\n`);
240
353
  return 0;
241
354
  }
242
- process.stdout.write(ls(db.openDb(file), project.repo));
355
+ process.stdout.write(ls(db.openDb(file), project.repo, Date.now(), cols));
243
356
  return 0;
244
357
  }
245
- if (cmd === 'messages' || cmd === 'events' || cmd === 'stop') {
358
+ if (cmd === 'messages' || cmd === 'events' || cmd === 'stop' || cmd === 'resume') {
246
359
  const project = resolveProject(process.cwd());
247
360
  const file = db.dbPath(project.stateDir);
248
361
  if (!fs.existsSync(file)) {
@@ -251,13 +364,11 @@ async function main(argv) {
251
364
  }
252
365
  const d = db.openDb(file);
253
366
  if (cmd === 'stop') {
254
- if (rest.length !== 1) {
255
- process.stderr.write(USAGE);
256
- return 2;
257
- }
258
367
  process.stdout.write(stop(d, rest[0]));
259
368
  return 0;
260
369
  }
370
+ if (cmd === 'resume')
371
+ return main(resumeArgv(d, rest[0], rest.slice(1)));
261
372
  const { agent, limit } = listArgs(rest);
262
373
  process.stdout.write(cmd === 'messages' ? messages(d, agent, limit) : events(d, agent, limit));
263
374
  return 0;
@@ -269,18 +380,12 @@ async function main(argv) {
269
380
  process.stdout.write(`no agents have run in ${project.dir} yet\n`);
270
381
  return 0;
271
382
  }
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');
383
+ const { names, force } = cleanArgs(rest);
384
+ process.stdout.write(cleanReport(clean(project, db.openDb(file), names, force)));
280
385
  return 0;
281
386
  }
282
387
  if (cmd !== 'claude' && cmd !== 'codex') {
283
- process.stderr.write(USAGE);
388
+ process.stderr.write(`chord: unknown command "${cmd}"\n${USAGE}`);
284
389
  return 2;
285
390
  }
286
391
  const name = takeName(rest);
@@ -297,25 +402,21 @@ async function main(argv) {
297
402
  await offerRestructure(e);
298
403
  launch = prepare(cmd, process.cwd(), name);
299
404
  }
300
- process.stderr.write(`chord: agent ${launch.name} in ${launch.cwd}\n`);
405
+ process.stderr.write(`chord: agent ${launch.name}; worktree ${launch.worktree}\n`);
301
406
  if (userSettings)
302
407
  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
408
  if (cmd === 'codex') {
314
409
  const off = disabledCodexHooks();
315
410
  if (off.length)
316
411
  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`);
317
412
  }
318
- return run(cmd, launch, rest);
413
+ const spawned = cmd === 'codex' ? await codexFirstStart() : () => { };
414
+ const exit = run(cmd, launch, rest);
415
+ spawned(); // right after the spawn: it releases the Codex first-start lock
416
+ const code = await exit;
417
+ // the tool's own parting line (Codex prints "codex resume <id>") would start it outside chord
418
+ process.stderr.write(quitHint(db.openDb(launch.dbFile), launch.name));
419
+ return code;
319
420
  }
320
421
  // run only as the entry point (argv[1] may be a symlink such as an npm bin link)
321
422
  const isMain = (() => { try {
package/dist/db.js CHANGED
@@ -55,7 +55,8 @@ function setup(db) {
55
55
  started_at integer not null,
56
56
  updated_at integer not null,
57
57
  heartbeat_at integer,
58
- stop_requested_at integer
58
+ stop_requested_at integer,
59
+ session_id text
59
60
  );
60
61
  create table if not exists claims (
61
62
  agent text not null references agents(name),
@@ -89,17 +90,28 @@ function setup(db) {
89
90
  }
90
91
  /** Bump when the schema changes and add a step below; `pragma user_version` records what a database has. A database
91
92
  * from before versioning reads as 0 and gets every step, each of which checks what is actually there. */
92
- const SCHEMA_VERSION = 2;
93
+ const SCHEMA_VERSION = 3;
93
94
  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}`);
95
+ // under a write lock: several processes opening an old database together must not each add the same column
96
+ db.exec('begin immediate');
97
+ try {
98
+ const v = db.prepare('pragma user_version').get().user_version;
99
+ if (v < SCHEMA_VERSION) {
100
+ const agentCols = () => db.prepare('pragma table_info(agents)').all().map(c => c.name);
101
+ if (v < 1 && !agentCols().includes('heartbeat_at'))
102
+ db.exec('alter table agents add column heartbeat_at integer'); // 2026-10-17
103
+ if (v < 2 && !agentCols().includes('stop_requested_at'))
104
+ db.exec('alter table agents add column stop_requested_at integer'); // 2026-10-17; the events table is created above
105
+ if (v < 3 && !agentCols().includes('session_id'))
106
+ db.exec('alter table agents add column session_id text'); // 2026-10-25
107
+ db.exec(`pragma user_version = ${SCHEMA_VERSION}`);
108
+ }
109
+ db.exec('commit');
110
+ }
111
+ catch (e) {
112
+ db.exec('rollback');
113
+ throw e;
114
+ }
103
115
  }
104
116
  // agents
105
117
  export function addAgent(db, a) {
@@ -108,7 +120,7 @@ export function addAgent(db, a) {
108
120
  values (?, ?, ?, ?, ?, 'busy', ?, ?, ?)
109
121
  on conflict(name) do update set tool = excluded.tool, pid = excluded.pid, cwd = excluded.cwd, worktree = excluded.worktree,
110
122
  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);
123
+ stop_requested_at = null, session_id = case when tool = excluded.tool then session_id end`).run(a.name, a.tool, a.pid, a.cwd, a.worktree ?? a.cwd, now, now, a.heartbeat ? now : null);
112
124
  // a relaunch under the same name must not inherit messages meant for the previous session
113
125
  db.prepare('delete from messages where recipient = ? and read_at is null').run(a.name);
114
126
  }
@@ -118,6 +130,10 @@ export function removeAgent(db, name) {
118
130
  db.prepare('delete from messages where sender = ? or recipient = ?').run(name, name);
119
131
  db.prepare('delete from agents where name = ?').run(name);
120
132
  }
133
+ /** The tool's conversation id, from the hook payloads; a relaunch keeps the old one until the new session reports its own. */
134
+ export function setSession(db, name, id) {
135
+ db.prepare('update agents set session_id = ? where name = ?').run(id, name);
136
+ }
121
137
  export function setTask(db, name, task, scope) {
122
138
  db.prepare('update agents set task = ?, scope = ?, updated_at = ? where name = ?').run(task, scope, Date.now(), name);
123
139
  }
@@ -165,18 +181,33 @@ export function heartbeat(db, name) {
165
181
  // working all along; the next Stop, idle report or idle_prompt makes it idle again
166
182
  db.prepare(`update agents set heartbeat_at = ?, status = case when status = 'offline' then 'busy' else status end where name = ?`).run(Date.now(), name);
167
183
  }
184
+ /** How long after launch an agent may go without a heartbeat before the watch in sweep calls it dead. */
185
+ const STARTUP_GRACE_MS = 10 * 60_000;
186
+ export const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
168
187
  /** 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()) {
188
+ * process or a stale heartbeat both count: Windows reuses pids quickly, so the pid alone can lie.
189
+ * `watchMs`: when every heartbeat is stale, wait that long and see whose heartbeat moves (a live wrapper writes every
190
+ * second); the rest are dead. Zero, the default for hooks and tools, judges by pid alone in that case. */
191
+ export function sweep(db, alive = pidAlive, now = Date.now(), watchMs = 0, sleep = sleepSync) {
171
192
  const online = listAgents(db).filter(a => a.status !== 'offline');
172
193
  // 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.
194
+ // ponytail: a lone dead agent whose pid was reused is therefore missed by the hooks until a second agent comes online.
174
195
  const beating = online.filter(a => a.heartbeat_at !== null);
175
196
  const stale = (a) => a.heartbeat_at !== null && now - a.heartbeat_at > HEARTBEAT_STALE_MS;
176
197
  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);
198
+ let dead;
199
+ if (slept && watchMs > 0) {
200
+ sleep(watchMs);
201
+ 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));
202
+ // no heartbeat yet while the setup command or Codex's first-start wait runs: a live pid that started lately is starting
203
+ const starting = (a) => now - a.started_at < STARTUP_GRACE_MS;
204
+ dead = online.filter(a => !alive(a.pid) || (a.heartbeat_at !== null && !moved.has(a.name) && !starting(a))).map(a => a.name);
205
+ }
206
+ else {
207
+ if (slept)
208
+ log('sweep: every heartbeat is stale, the machine was probably asleep; judging by pid only');
209
+ dead = online.filter(a => !alive(a.pid) || (!slept && stale(a))).map(a => a.name);
210
+ }
180
211
  for (const name of dead) {
181
212
  log(`sweep: ${name} marked offline, claims released`);
182
213
  setStatus(db, name, 'offline');
@@ -216,11 +247,14 @@ export function listEvents(db, agent, limit = 50) {
216
247
  : db.prepare('select * from events order by id desc limit ?').all(limit);
217
248
  return rows.reverse();
218
249
  }
219
- /** The latest `limit` messages (oldest first), all or those sent or received by one agent. */
250
+ /** The closing sentence of a merge notice: for the agents, not for `chord messages`. */
251
+ 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.';
252
+ /** The latest `limit` sends (oldest first), all or those sent or received by one agent. A message to several agents
253
+ * is one row per recipient in the table and one send here. */
220
254
  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);
255
+ const sql = (where) => `select min(id) id, sender, group_concat(recipient, ', ') recipient, kind, body, created_at, max(read_at) read_at,
256
+ count(*) n, count(*) - count(read_at) unread from messages ${where} group by sender, kind, body, created_at order by max(id) desc limit ?`;
257
+ const rows = agent ? db.prepare(sql('where sender = ? or recipient = ?')).all(agent, agent, limit) : db.prepare(sql('')).all(limit);
224
258
  return rows.reverse();
225
259
  }
226
260
  /** `chord stop`: ask the wrapper to quit the tool once the agent is idle. Returns false for an unknown or offline agent. */
package/dist/hooks.js CHANGED
@@ -35,7 +35,18 @@ export function formatMessages(ms) {
35
35
  const MAX_CLAIM_LINES = 60;
36
36
  const MAX_WARNED_FILES = 20;
37
37
  const MAX_INSTRUCTION_CHARS = 8000;
38
- /** The repository's own agent instructions. The agent's cwd is the workspace, so the tools do not load them themselves. */
38
+ /** The agent's worktree as a path from its cwd, the project folder: chord/agents/<name>/<repoName>. */
39
+ const repoDirOf = (ctx) => path.relative(ctx.project ?? ctx.cwd, ctx.worktree).split(path.sep).join('/') || '.';
40
+ /** The same file in the agent's own worktree, for a path that hit the main checkout or another agent's worktree:
41
+ * both are named after the repository, so what follows that directory is the file. */
42
+ function ownCopy(ctx, abs, tool) {
43
+ const parts = path.relative(ctx.project ?? ctx.cwd, abs).split(path.sep), repoName = path.basename(ctx.worktree);
44
+ // P/<repoName>/... is the main checkout; P/chord/agents/<other>/<repoName>/... another agent's worktree
45
+ const tail = parts[0] === repoName ? parts.slice(1) : parts[0] === 'chord' && parts[1] === 'agents' && parts[3] === repoName ? parts.slice(4) : [];
46
+ return tool === 'claude' ? path.join(ctx.worktree, ...tail) : `${repoDirOf(ctx)}/${tail.join('/')}`; // Claude's editor wants absolute paths
47
+ }
48
+ /** The repository's own agent instructions. The agent's cwd is the project folder, which is not a git repository, so
49
+ * Codex does not load the worktree's AGENTS.md and Claude only reads a nested CLAUDE.md once it opens a file there. */
39
50
  function projectInstructions(ctx, repoDir) {
40
51
  const out = [];
41
52
  for (const name of ['AGENTS.md', 'CLAUDE.md']) {
@@ -52,18 +63,18 @@ export function briefing(ctx) {
52
63
  const me = db.getAgent(ctx.db, ctx.agent);
53
64
  const peers = db.listAgents(ctx.db).filter(a => a.name !== ctx.agent && a.status !== 'offline');
54
65
  const claims = db.allClaims(ctx.db);
55
- const repoDir = path.relative(ctx.cwd, ctx.worktree).split(path.sep).join('/') || '.';
66
+ const repoDir = repoDirOf(ctx), repoName = path.basename(ctx.worktree);
56
67
  const lines = [
57
- `You are agent "${ctx.agent}" (${me?.tool ?? 'unknown'}), one of several coding agents working on the same project. Your working directory is your own workspace; the repository is the directory "${repoDir}" inside it, a git worktree on your own branch. Run git commands inside "${repoDir}". Name files as paths from your working directory, e.g. "${repoDir}/src/x.ts" (claim() also accepts repository-relative paths like "src/x.ts"). Other agents have their own worktrees of the same repository; never edit files outside your workspace. Coordination goes through the "chord" MCP tools.`,
68
+ `You are agent "${ctx.agent}" (${me?.tool ?? 'unknown'}), one of several coding agents working on the same project. Your working directory is the project folder. Your own copy of the repository is "${repoDir}", a git worktree on your own branch: edit, build and run git only there. "${repoName}/" in the project folder is the main checkout and other agents have their copies under chord/agents/; never touch those. Name files as "${repoDir}/src/x.ts" or repository-relative "src/x.ts" (claim() accepts both). Coordination goes through the "chord" MCP tools.`,
58
69
  '',
59
70
  'Rules:',
60
71
  '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.',
72
+ '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
73
  '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
74
  '4. Do not modify files through shell commands (sed, echo >, etc.). Use the editor tools so claims are checked.',
64
75
  '5. To talk to other agents use only chord send()/inbox(); do not use any other agent-messaging feature. Messages are asynchronous.',
65
76
  '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, unless the user has asked you to hold off merging. If finish() reports a conflict, stop and ask the user.',
77
+ '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
78
  '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
79
  '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
80
  '',
@@ -80,21 +91,27 @@ export function briefing(ctx) {
80
91
  /** Context for the model. Plain stdout also works for Claude Code and `codex exec`, but the Codex TUI
81
92
  * only takes the JSON form, so both events use it. */
82
93
  const context = (hookEventName, additionalContext) => ({ hookSpecificOutput: { hookEventName, additionalContext } });
94
+ /** Both tools put their conversation id in the payload as session_id; `chord resume` reopens that conversation. Taken on
95
+ * every prompt too, because Codex /new and a /resume start a new conversation without a SessionStart hook. */
96
+ const recordSession = (ctx, payload) => { if (typeof payload.session_id === 'string' && payload.session_id)
97
+ db.setSession(ctx.db, ctx.agent, payload.session_id); };
83
98
  export function handleHook(ctx, tool, event, payload) {
84
99
  db.sweep(ctx.db);
85
100
  switch (event) {
86
101
  case 'session-start':
102
+ recordSession(ctx, payload);
87
103
  // The agent now waits for its first prompt: idle, so messages that arrive before the user types still wake it.
88
104
  // Claude Code also fires this after compacting a long conversation, in the middle of a turn: not idle then.
89
105
  if (payload.source !== 'compact')
90
106
  db.setStatus(ctx.db, ctx.agent, 'idle');
91
107
  return context('SessionStart', briefing(ctx));
92
108
  case 'user-prompt-submit': {
109
+ recordSession(ctx, payload);
93
110
  db.setStatus(ctx.db, ctx.agent, 'busy');
94
111
  const ms = db.takeUnread(ctx.db, ctx.agent);
95
112
  // One line of identity on every prompt: a conversation that started without the briefing (Codex /new fires no
96
113
  // session hooks; a resumed conversation may predate chord) still learns what it is and what the rules are.
97
- const reminder = `[chord] You are agent "${ctx.agent}" in a chord project: claim a file with the chord claim tool before editing it, talk to the other agents with chord send, merge with chord finish.`;
114
+ const reminder = `[chord] You are agent "${ctx.agent}" in a chord project: claim a file with the chord claim tool before editing it, talk to the other agents with chord send, merge with chord finish. Your worktree is "${repoDirOf(ctx)}"; edit and run git only there.`;
98
115
  return context('UserPromptSubmit', ms.length
99
116
  ? `${formatMessages(ms)}\n\n[chord] ${ms.length} message(s) above arrived from other agents. Act on them in this turn as well as on the user's request: reply with the chord send tool where a reply is asked for.\n${reminder}`
100
117
  : reminder);
@@ -104,14 +121,16 @@ export function handleHook(ctx, tool, event, payload) {
104
121
  if (!files.length && isEditTool(tool, payload.tool_name)) {
105
122
  return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'chord could not tell which file this call writes, so it is refused; use the editor tool with a plain file path' } };
106
123
  }
107
- // Codex resolves a patch's relative paths against the call's workdir when it gives one; otherwise the workspace
108
- const workdir = tool === 'codex' && typeof payload.tool_input?.workdir === 'string' ? path.resolve(ctx.cwd, payload.tool_input.workdir) : ctx.cwd;
124
+ // Codex resolves a patch's relative paths against the call's workdir when it gives one, else against its own cwd,
125
+ // the project folder (the workspace when no project is known, as in the tests)
126
+ const base = ctx.project ?? ctx.cwd;
127
+ const workdir = tool === 'codex' && typeof payload.tool_input?.workdir === 'string' ? path.resolve(base, payload.tool_input.workdir) : base;
109
128
  for (const f of files) {
110
129
  const abs = path.resolve(workdir, f);
111
130
  if (!insideDir(ctx.worktree, abs)) {
112
131
  // the main checkout and the other agents' workspaces are off limits; anything else is not ours to police
113
132
  if (ctx.project && insideDir(ctx.project, abs) && !insideDir(ctx.cwd, abs)) {
114
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `${f} is outside your workspace: edit files only inside your own worktree ${ctx.worktree}` } };
133
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `${f} is outside your workspace (the main checkout or another agent's worktree); edit your own copy ${ownCopy(ctx, abs, tool)} instead` } };
115
134
  }
116
135
  continue;
117
136
  }
@@ -228,6 +247,23 @@ export function shellWarnings(ctx, payload) {
228
247
  if (unclaimed.length)
229
248
  out.push(`[chord] The shell command changed files you have not claimed: ${shown.join(', ')}${unclaimed.length > shown.length ? ` and ${unclaimed.length - shown.length} more` : ''}. Claim them now with claim(), or revert the change.`);
230
249
  const cmd = typeof payload.tool_input?.command === 'string' ? payload.tool_input.command : '';
250
+ // From the project folder, "<repo>/" is the main checkout: a command that ran there (Codex's workdir), cd'd there or
251
+ // named a path there did not touch the agent's own copy. A heuristic on the command text, so a warning, not a gate.
252
+ if (ctx.project) {
253
+ const repoName = path.basename(ctx.worktree);
254
+ const workdir = typeof payload.tool_input?.workdir === 'string' ? path.resolve(ctx.project, payload.tool_input.workdir) : null;
255
+ const inMain = workdir !== null && insideDir(ctx.project, workdir) && !insideDir(ctx.cwd, workdir);
256
+ const words = cmd.split(/\s+/);
257
+ const namesMain = words.some((w, i) => {
258
+ if (/chord[\\/]agents[\\/]/.test(w))
259
+ return false;
260
+ const stripped = w.replace(/^["'(]+/, '').replace(/^\.[\\/]/, '');
261
+ const head = stripped.split(/[\\/]/)[0].replace(/["';&|)]+$/, '');
262
+ return head === repoName && (/[\\/]/.test(stripped) || words[i - 1] === 'cd');
263
+ });
264
+ if (inMain || namesMain)
265
+ out.push(`[chord] That command ran in the main checkout (${repoName}/) or named a path there; your copy is ${repoDirOf(ctx)}. Build, test and run git only there.`);
266
+ }
231
267
  if (/\bgit\b[^|;&\n]*\bstash\b/.test(cmd))
232
268
  out.push("[chord] git stash is shared by every worktree of this repository: a stash pop can hand you another agent's changes, and yours to them. Make a work-in-progress commit instead.");
233
269
  if (root && path.resolve(root) !== path.resolve(ctx.worktree)) {