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/README.md +26 -8
- package/README.zh-CN.md +170 -0
- package/dist/clean.js +21 -15
- package/dist/cli.js +166 -69
- package/dist/db.js +102 -44
- package/dist/hooks.js +108 -66
- package/dist/launch.js +30 -58
- package/dist/mcp.js +3 -1
- package/dist/project.js +6 -7
- package/dist/pty.js +76 -14
- package/dist/serve.js +6 -7
- package/dist/tools.js +95 -42
- package/dist/trust.js +4 -36
- package/package.json +6 -2
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// chord
|
|
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
|
|
19
|
-
const USAGE =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
export function
|
|
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
|
|
273
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
96
|
+
const SCHEMA_VERSION = 4;
|
|
93
97
|
function migrate(db) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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,
|
|
108
|
-
values (?, ?, ?, ?,
|
|
109
|
-
on conflict(name) do update set tool = excluded.tool, pid = excluded.pid, cwd = excluded.cwd,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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);
|