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/README.md +23 -4
- package/README.zh-CN.md +169 -0
- package/dist/clean.js +14 -12
- package/dist/cli.js +158 -57
- package/dist/db.js +56 -22
- package/dist/hooks.js +45 -9
- package/dist/launch.js +15 -9
- package/dist/pty.js +52 -13
- package/dist/serve.js +2 -1
- package/dist/tools.js +51 -15
- package/dist/trust.js +64 -34
- package/package.json +5 -2
package/dist/cli.js
CHANGED
|
@@ -1,10 +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
|
|
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 {
|
|
19
|
-
const USAGE =
|
|
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
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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');
|
|
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}
|
|
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
|
-
|
|
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 =
|
|
93
|
+
const SCHEMA_VERSION = 3;
|
|
93
94
|
function migrate(db) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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,
|
|
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
|
|
108
|
-
|
|
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
|
|
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)) {
|