chord-cli 0.2.0 → 0.2.2
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 +9 -8
- package/dist/clean.js +8 -4
- package/dist/cli.js +10 -11
- package/dist/db.js +48 -24
- package/dist/hooks.js +66 -60
- package/dist/launch.js +26 -54
- package/dist/mcp.js +3 -1
- package/dist/project.js +6 -7
- package/dist/pty.js +42 -8
- package/dist/serve.js +5 -7
- package/dist/tools.js +89 -40
- package/dist/trust.js +2 -2
- package/package.json +3 -2
- package/README.zh-CN.md +0 -169
package/README.md
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<p align="center">
|
|
6
6
|
<a href="LICENSE"><img alt="MIT licence" src="https://img.shields.io/badge/licence-MIT-blue"></a>
|
|
7
7
|
<img alt="Node 24 or later" src="https://img.shields.io/badge/node-%E2%89%A5%2024-339933?logo=nodedotjs&logoColor=white">
|
|
8
|
-
<img alt="Windows and Linux" src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux-blue">
|
|
9
|
-
<a href="README
|
|
8
|
+
<img alt="Windows, macOS and Linux" src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue">
|
|
9
|
+
<a href="README-zh-CN.md"><img alt="中文说明" src="https://img.shields.io/badge/README-%E4%B8%AD%E6%96%87-2F7CF6"></a>
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
One repository, as many agents as you care to start. Each agent works in its own worktree; a shared board records who holds which file; an agent that wants a file another agent holds asks first and waits for the answer; when the work is committed, one call rebases the branch and fast-forwards main. Idle agents are woken the moment a message arrives, so the agents negotiate among themselves while you keep handing out work in plain language.
|
|
@@ -30,7 +30,7 @@ One repository, as many agents as you care to start. Each agent works in its own
|
|
|
30
30
|
|
|
31
31
|
## 📊 Benchmark
|
|
32
32
|
|
|
33
|
-
On [CooperBench](https://github.com/cooperbench/CooperBench) two agents each take one feature in the same repository, and a pair passes when the merged result passes the tests of both features.
|
|
33
|
+
On [CooperBench](https://github.com/cooperbench/CooperBench) two agents each take one feature in the same repository, and a pair passes when the merged result passes the tests of both features.
|
|
34
34
|
|
|
35
35
|
| chord vs | gpt-6.1-sol | gpt-6-luna |
|
|
36
36
|
|---|---|---|
|
|
@@ -67,7 +67,7 @@ A new agent needs three things from its tool: a way to inject hooks and an MCP s
|
|
|
67
67
|
|
|
68
68
|
1. **Isolation.** Every agent works on its own branch in its own worktree. The main checkout changes through `finish` alone.
|
|
69
69
|
2. **Coordination.** A shared board holds claims, messages and events. Agents register, claim and talk through tools chord gives them; hooks check claims before each write and hand new messages to the agent as it works.
|
|
70
|
-
3. **Merging.** `finish` rebases the branch onto main and fast-forwards. A clean rebase merges;
|
|
70
|
+
3. **Merging.** `finish` rebases the branch onto main and fast-forwards. A clean rebase that passes the project's test command merges; on a conflict nothing moves, and the agent resolves it and finishes again.
|
|
71
71
|
|
|
72
72
|
## 📦 Install
|
|
73
73
|
|
|
@@ -77,7 +77,7 @@ With Node 24 and git on the machine:
|
|
|
77
77
|
npm install -g chord-cli
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
On Linux add `--allow-scripts=node-pty`, with python3, make and g++ present: node-pty ships no Linux binary and builds one on install.
|
|
80
|
+
On Linux add `--allow-scripts=node-pty`, with python3, make and g++ present: node-pty ships no Linux binary and builds one on install. macOS needs nothing more: node-pty ships a binary for it.
|
|
81
81
|
|
|
82
82
|
Or from source:
|
|
83
83
|
|
|
@@ -129,6 +129,7 @@ Agents start in the project folder, the same directory you run plain `claude` or
|
|
|
129
129
|
| `chord events [agent] [-n N]` | claim, release, merge and conflict history |
|
|
130
130
|
| `chord stop <agent>` | quit an agent once its turn ends and its input box is empty |
|
|
131
131
|
| `chord clean [--force] [agent...]` | remove offline agents' workspaces, worktrees and branches; work not yet on main stays unless `--force` |
|
|
132
|
+
| `chord --version` | the installed version |
|
|
132
133
|
|
|
133
134
|
## ⚙️ Configuration
|
|
134
135
|
|
|
@@ -142,20 +143,20 @@ Agents start in the project folder, the same directory you run plain `claude` or
|
|
|
142
143
|
|
|
143
144
|
`test`, when set, runs in the agent's worktree each time `finish` has rebased the branch onto main, before main moves. If it fails nothing is merged and the agent gets the end of the output: a clean rebase can still break what another agent merged. Other agents' `finish` waits while it runs.
|
|
144
145
|
|
|
145
|
-
A repository's own Claude configuration (`.claude/settings*.json`, `.mcp.json`) reaches an agent once you have trusted that repository in Claude Code yourself; the MCP servers you approved there are the ones that start.
|
|
146
|
+
A repository's own Claude configuration (`.claude/settings*.json`, `.mcp.json`) reaches an agent once you have trusted that repository, or the project folder, in Claude Code yourself; the MCP servers you approved there are the ones that start.
|
|
146
147
|
|
|
147
148
|
## 🔌 How chord works with your tools
|
|
148
149
|
|
|
149
150
|
- **Waking an agent** means typing a short note into its terminal. chord does this only when the input box is empty, the keyboard has been quiet for a few seconds, and the terminal has echoed the note; a dialogue never receives the Enter.
|
|
150
151
|
- **Trust**: agents run in the project folder, so Claude asks about it once, as it does for plain `claude`; chord writes nothing to `~/.claude.json`. Codex asks you to review chord's hooks once, and again whenever their definition changes; answer "trust all". chord says so at launch if a hook has been switched off.
|
|
151
152
|
- **Branches and worktrees.** Each agent adds a `chord/<name>` branch and a worktree; `chord clean` removes them when the agent has left and keeps anything not yet on main.
|
|
152
|
-
- **Permissions.** chord's own tools are pre-approved for Claude, so an agent working on its own is never parked at a prompt for them; your usual shell and edit approvals apply as before. Codex
|
|
153
|
+
- **Permissions.** chord's own tools are pre-approved for Claude, so an agent working on its own is never parked at a prompt for them; your usual shell and edit approvals apply as before. Codex keeps the sandbox and approval mode from your own configuration. On Windows chord refuses a sandbox given on the command line, because that sandbox cannot run the hooks; on Linux and macOS the hooks run inside the sandbox, but Codex then asks you to approve each chord tool call.
|
|
153
154
|
- **The terminal title** reads `chord <agent> · <task>`, so each window says which agent it is.
|
|
154
155
|
- **Hooks gate the editor tools.** Files changed from the shell are reported by name after each command, with the claim to make.
|
|
155
156
|
|
|
156
157
|
## 🗺️ Scope
|
|
157
158
|
|
|
158
|
-
- Runs on Windows 11 and Linux.
|
|
159
|
+
- Runs on Windows 11, macOS and Linux.
|
|
159
160
|
- One repository per project.
|
|
160
161
|
- Project paths up to about 190 characters.
|
|
161
162
|
- A repository with CRLF files and a `* text` attribute that was never renormalised shows phantom changes in every worktree; chord names the files and the command that puts the repository right.
|
package/dist/clean.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
// chord clean: remove the workspaces, worktrees and branches of agents that are gone.
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
2
|
import fs from 'node:fs';
|
|
4
3
|
import path from 'node:path';
|
|
5
4
|
import * as db from "./db.js";
|
|
6
5
|
import { setupMarker } from "./launch.js";
|
|
7
|
-
const
|
|
6
|
+
const { git } = db;
|
|
8
7
|
/** Remove offline agents (all of them, or the named ones). An agent whose branch has commits that are not on
|
|
9
8
|
* main, or whose worktree has uncommitted work, is kept unless `force`; an online agent is always kept. Directories
|
|
10
9
|
* under chord/agents/ with no database row (a launch that died before the tool started) are treated the same way. */
|
|
@@ -40,13 +39,18 @@ export function clean(project, d, names = [], force = false) {
|
|
|
40
39
|
}
|
|
41
40
|
// work that was never committed (an agent stopped half-way) is just as much work as an unmerged commit
|
|
42
41
|
if (!force && fs.existsSync(path.join(wt, '.git'))) {
|
|
42
|
+
const rel = path.relative(project.dir, wt).split(path.sep).join('/');
|
|
43
43
|
let dirty = '';
|
|
44
|
+
// a worktree git cannot read (its link broken by a move) may still hold work: not knowing is not clean
|
|
44
45
|
try {
|
|
45
46
|
dirty = git(wt, 'status', '--porcelain');
|
|
46
47
|
}
|
|
47
|
-
catch {
|
|
48
|
+
catch {
|
|
49
|
+
kept(a.name, `uncommitted changes unknown: git status failed in ${rel}`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
48
52
|
if (dirty) {
|
|
49
|
-
kept(a.name, `uncommitted changes in ${
|
|
53
|
+
kept(a.name, `uncommitted changes in ${rel}`);
|
|
50
54
|
return;
|
|
51
55
|
}
|
|
52
56
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The chord command line; see USAGE.
|
|
3
|
-
import { execFileSync } from 'node:child_process';
|
|
4
3
|
import fs from 'node:fs';
|
|
5
4
|
import path from 'node:path';
|
|
6
5
|
import { createInterface } from 'node:readline/promises';
|
|
@@ -9,7 +8,7 @@ import { clean } from "./clean.js";
|
|
|
9
8
|
import * as db from "./db.js";
|
|
10
9
|
import { mergeUserSettings, prepare } from "./launch.js";
|
|
11
10
|
import { NeedsRestructure, knownProjects, resolveProject, restructure, restructureBlocker, validateRepoName } from "./project.js";
|
|
12
|
-
import { resolveCommand, run } from "./pty.js";
|
|
11
|
+
import { ensureSpawnHelper, resolveCommand, run } from "./pty.js";
|
|
13
12
|
import { codexFirstStart, disabledCodexHooks } from "./trust.js";
|
|
14
13
|
const USAGE = [
|
|
15
14
|
'usage: chord <command> [arguments]',
|
|
@@ -27,14 +26,12 @@ const USAGE = [
|
|
|
27
26
|
' ... stands for arguments passed on to the tool.',
|
|
28
27
|
'',
|
|
29
28
|
].join('\n');
|
|
30
|
-
const gitOut = (cwd, ...args) => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
};
|
|
29
|
+
const gitOut = (cwd, ...args) => { try {
|
|
30
|
+
return db.git(cwd, ...args);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
} };
|
|
38
35
|
/** "12s", "3m", "2h", "5d": how long ago. */
|
|
39
36
|
export function ago(ms) {
|
|
40
37
|
const s = Math.max(0, Math.round(ms / 1000));
|
|
@@ -188,7 +185,6 @@ export function listArgs(rest) {
|
|
|
188
185
|
}
|
|
189
186
|
return { agent, limit };
|
|
190
187
|
}
|
|
191
|
-
/** `--name <x>` or `--name=<x>` anywhere before the tool's own arguments. */
|
|
192
188
|
/** The user's own `--settings <json|file>` for Claude, removed from the tool arguments: passed through as is it would
|
|
193
189
|
* replace chord's --settings wholesale and switch the hooks off, so chord folds it under its own instead. */
|
|
194
190
|
export function takeSettings(rest) {
|
|
@@ -256,6 +252,7 @@ export function cleanArgs(rest) {
|
|
|
256
252
|
}
|
|
257
253
|
return { names, force };
|
|
258
254
|
}
|
|
255
|
+
/** `--name <x>` or `--name=<x>` anywhere before the tool's own arguments. */
|
|
259
256
|
export function takeName(rest) {
|
|
260
257
|
for (let i = 0; i < rest.length; i++) {
|
|
261
258
|
const a = rest[i];
|
|
@@ -392,6 +389,8 @@ async function main(argv) {
|
|
|
392
389
|
const userSettings = cmd === 'claude' ? takeSettings(rest) : undefined;
|
|
393
390
|
refuseHookKillers(cmd, rest);
|
|
394
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
|
|
395
394
|
let launch;
|
|
396
395
|
try {
|
|
397
396
|
launch = prepare(cmd, process.cwd(), name);
|
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,7 +53,6 @@ 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',
|
|
@@ -65,7 +69,6 @@ function setup(db) {
|
|
|
65
69
|
kind text not null,
|
|
66
70
|
symbols text,
|
|
67
71
|
created_at integer not null,
|
|
68
|
-
asked_at integer,
|
|
69
72
|
primary key (agent, file)
|
|
70
73
|
);
|
|
71
74
|
create table if not exists messages (
|
|
@@ -90,7 +93,7 @@ function setup(db) {
|
|
|
90
93
|
}
|
|
91
94
|
/** Bump when the schema changes and add a step below; `pragma user_version` records what a database has. A database
|
|
92
95
|
* from before versioning reads as 0 and gets every step, each of which checks what is actually there. */
|
|
93
|
-
const SCHEMA_VERSION =
|
|
96
|
+
const SCHEMA_VERSION = 4;
|
|
94
97
|
function migrate(db) {
|
|
95
98
|
// under a write lock: several processes opening an old database together must not each add the same column
|
|
96
99
|
db.exec('begin immediate');
|
|
@@ -104,6 +107,12 @@ function migrate(db) {
|
|
|
104
107
|
db.exec('alter table agents add column stop_requested_at integer'); // 2026-10-17; the events table is created above
|
|
105
108
|
if (v < 3 && !agentCols().includes('session_id'))
|
|
106
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
|
+
}
|
|
107
116
|
db.exec(`pragma user_version = ${SCHEMA_VERSION}`);
|
|
108
117
|
}
|
|
109
118
|
db.exec('commit');
|
|
@@ -116,20 +125,25 @@ function migrate(db) {
|
|
|
116
125
|
// agents
|
|
117
126
|
export function addAgent(db, a) {
|
|
118
127
|
const now = Date.now();
|
|
119
|
-
db.prepare(`insert into agents (name, tool, pid, cwd,
|
|
120
|
-
values (?, ?, ?, ?,
|
|
121
|
-
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,
|
|
122
131
|
status = 'busy', task = null, scope = null, started_at = excluded.started_at, updated_at = excluded.updated_at, heartbeat_at = excluded.heartbeat_at,
|
|
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,
|
|
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);
|
|
124
133
|
// a relaunch under the same name must not inherit messages meant for the previous session
|
|
125
134
|
db.prepare('delete from messages where recipient = ? and read_at is null').run(a.name);
|
|
126
135
|
}
|
|
127
|
-
/** 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. */
|
|
128
138
|
export function removeAgent(db, name) {
|
|
129
139
|
db.prepare('delete from claims where agent = ?').run(name);
|
|
130
|
-
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);
|
|
131
141
|
db.prepare('delete from agents where name = ?').run(name);
|
|
132
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
|
+
}
|
|
133
147
|
/** The tool's conversation id, from the hook payloads; a relaunch keeps the old one until the new session reports its own. */
|
|
134
148
|
export function setSession(db, name, id) {
|
|
135
149
|
db.prepare('update agents set session_id = ? where name = ?').run(id, name);
|
|
@@ -138,9 +152,9 @@ export function setTask(db, name, task, scope) {
|
|
|
138
152
|
db.prepare('update agents set task = ?, scope = ?, updated_at = ? where name = ?').run(task, scope, Date.now(), name);
|
|
139
153
|
}
|
|
140
154
|
/** Offline is sticky here: a hook that outlives its killed agent must not revive it. Only the wrapper does, through
|
|
141
|
-
* addAgent (relaunch) or heartbeat. */
|
|
155
|
+
* addAgent (relaunch) or heartbeat. Returns whether a row changed. */
|
|
142
156
|
export function setStatus(db, name, status) {
|
|
143
|
-
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;
|
|
144
158
|
}
|
|
145
159
|
export function getAgent(db, name) {
|
|
146
160
|
return db.prepare('select * from agents where name = ?').get(name);
|
|
@@ -148,7 +162,7 @@ export function getAgent(db, name) {
|
|
|
148
162
|
export function listAgents(db) {
|
|
149
163
|
return db.prepare('select * from agents order by started_at').all();
|
|
150
164
|
}
|
|
151
|
-
function pidAlive(pid) {
|
|
165
|
+
export function pidAlive(pid) {
|
|
152
166
|
try {
|
|
153
167
|
process.kill(pid, 0);
|
|
154
168
|
return true;
|
|
@@ -161,12 +175,21 @@ function pidAlive(pid) {
|
|
|
161
175
|
return code === 'EPERM';
|
|
162
176
|
}
|
|
163
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;
|
|
164
181
|
/** Append a line to chord.db.log next to the database, when CHORD_DB is known. Silent otherwise. */
|
|
165
182
|
export function log(line) {
|
|
166
183
|
if (!process.env.CHORD_DB)
|
|
167
184
|
return;
|
|
185
|
+
const file = process.env.CHORD_DB + '.log';
|
|
168
186
|
try {
|
|
169
|
-
|
|
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}
|
|
170
193
|
`);
|
|
171
194
|
}
|
|
172
195
|
catch { /* best effort */ }
|
|
@@ -181,6 +204,12 @@ export function heartbeat(db, name) {
|
|
|
181
204
|
// working all along; the next Stop, idle report or idle_prompt makes it idle again
|
|
182
205
|
db.prepare(`update agents set heartbeat_at = ?, status = case when status = 'offline' then 'busy' else status end where name = ?`).run(Date.now(), name);
|
|
183
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
|
+
}
|
|
184
213
|
/** How long after launch an agent may go without a heartbeat before the watch in sweep calls it dead. */
|
|
185
214
|
const STARTUP_GRACE_MS = 10 * 60_000;
|
|
186
215
|
export const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
@@ -209,8 +238,10 @@ export function sweep(db, alive = pidAlive, now = Date.now(), watchMs = 0, sleep
|
|
|
209
238
|
dead = online.filter(a => !alive(a.pid) || (!slept && stale(a))).map(a => a.name);
|
|
210
239
|
}
|
|
211
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;
|
|
212
244
|
log(`sweep: ${name} marked offline, claims released`);
|
|
213
|
-
setStatus(db, name, 'offline');
|
|
214
245
|
releaseAll(db, name, 'swept');
|
|
215
246
|
}
|
|
216
247
|
return dead;
|
|
@@ -221,9 +252,6 @@ export function addClaim(db, c) {
|
|
|
221
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());
|
|
222
253
|
addEvent(db, c.agent, 'claim', c.file, `${c.kind}: ${c.intent}`);
|
|
223
254
|
}
|
|
224
|
-
export function setAskedAt(db, agent, file, at) {
|
|
225
|
-
db.prepare('update claims set asked_at = ? where agent = ? and file = ?').run(at, agent, file);
|
|
226
|
-
}
|
|
227
255
|
export function removeClaim(db, agent, file) {
|
|
228
256
|
const gone = db.prepare('delete from claims where agent = ? and file = ?').run(agent, file).changes;
|
|
229
257
|
if (gone)
|
|
@@ -278,7 +306,7 @@ export function send(db, sender, recipients, body, kind = 'message') {
|
|
|
278
306
|
ins.run(sender, r, kind, body, now);
|
|
279
307
|
}
|
|
280
308
|
/** Roughly what a hook may attach without the tool truncating it (Codex caps additionalContext near 2500 tokens). */
|
|
281
|
-
|
|
309
|
+
const DELIVERY_BUDGET_CHARS = 6000;
|
|
282
310
|
/** Unread messages for `recipient`, oldest first, marked read as they are returned. Stops after `budget`
|
|
283
311
|
* characters (always at least one message) so a flood is delivered in several rounds instead of truncated. */
|
|
284
312
|
export function takeUnread(db, recipient, budget = DELIVERY_BUDGET_CHARS) {
|
|
@@ -309,10 +337,6 @@ export function takeUnread(db, recipient, budget = DELIVERY_BUDGET_CHARS) {
|
|
|
309
337
|
export function hasUnread(db, recipient) {
|
|
310
338
|
return !!db.prepare(`select 1 from messages where recipient = ? and read_at is null and kind = 'message' limit 1`).get(recipient);
|
|
311
339
|
}
|
|
312
|
-
/** Has `sender` sent `recipient` a real message (not a notice) at or after `since`? */
|
|
313
|
-
export function repliedSince(db, sender, recipient, since) {
|
|
314
|
-
return firstMessageAt(db, sender, recipient, since) !== null;
|
|
315
|
-
}
|
|
316
340
|
/** When `sender` first messaged `recipient` at or after `since`; null if never. Notices do not count. */
|
|
317
341
|
export function firstMessageAt(db, sender, recipient, since) {
|
|
318
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);
|
package/dist/hooks.js
CHANGED
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import * as db from "./db.js";
|
|
8
8
|
import { patchFiles } from "./patch.js";
|
|
9
|
-
import { canWrite, insideDir, normFile } from "./tools.js";
|
|
9
|
+
import { canWrite, dirtyTracked, insideDir, normFile } from "./tools.js";
|
|
10
10
|
const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
11
11
|
export function filesTouched(tool, payload) {
|
|
12
12
|
const name = String(payload.tool_name ?? '');
|
|
@@ -26,10 +26,10 @@ export function filesTouched(tool, payload) {
|
|
|
26
26
|
return [];
|
|
27
27
|
}
|
|
28
28
|
/** Tools that write files and must therefore name them: a call that names none is refused rather than let through. */
|
|
29
|
-
|
|
29
|
+
const isEditTool = (tool, name) => (tool === 'claude' ? EDIT_TOOLS.has(String(name)) : name === 'apply_patch');
|
|
30
30
|
const isShell = (name) => /^(bash|powershell|shell|exec|exec_command|local_shell)$/i.test(String(name ?? ''));
|
|
31
31
|
const hhmm = (t) => new Date(t).toTimeString().slice(0, 5); // local time
|
|
32
|
-
|
|
32
|
+
function formatMessages(ms) {
|
|
33
33
|
return ms.map(m => `[chord] message from ${m.sender} at ${hhmm(m.created_at)}:\n${m.body}`).join('\n\n');
|
|
34
34
|
}
|
|
35
35
|
const MAX_CLAIM_LINES = 60;
|
|
@@ -45,6 +45,17 @@ function ownCopy(ctx, abs, tool) {
|
|
|
45
45
|
const tail = parts[0] === repoName ? parts.slice(1) : parts[0] === 'chord' && parts[1] === 'agents' && parts[3] === repoName ? parts.slice(4) : [];
|
|
46
46
|
return tool === 'claude' ? path.join(ctx.worktree, ...tail) : `${repoDirOf(ctx)}/${tail.join('/')}`; // Claude's editor wants absolute paths
|
|
47
47
|
}
|
|
48
|
+
/** The path with links resolved, so a write through a junction or symlink is judged by where it lands. The file may
|
|
49
|
+
* not exist yet: the nearest existing ancestor is resolved and the rest appended. */
|
|
50
|
+
function realPath(p) {
|
|
51
|
+
try {
|
|
52
|
+
return fs.realpathSync.native(p);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
const parent = path.dirname(p);
|
|
56
|
+
return parent === p ? p : path.join(realPath(parent), path.basename(p));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
48
59
|
/** The repository's own agent instructions. The agent's cwd is the project folder, which is not a git repository, so
|
|
49
60
|
* Codex does not load the worktree's AGENTS.md and Claude only reads a nested CLAUDE.md once it opens a file there. */
|
|
50
61
|
function projectInstructions(ctx, repoDir) {
|
|
@@ -126,7 +137,7 @@ export function handleHook(ctx, tool, event, payload) {
|
|
|
126
137
|
const base = ctx.project ?? ctx.cwd;
|
|
127
138
|
const workdir = tool === 'codex' && typeof payload.tool_input?.workdir === 'string' ? path.resolve(base, payload.tool_input.workdir) : base;
|
|
128
139
|
for (const f of files) {
|
|
129
|
-
const abs = path.resolve(workdir, f);
|
|
140
|
+
const abs = realPath(path.resolve(workdir, f));
|
|
130
141
|
if (!insideDir(ctx.worktree, abs)) {
|
|
131
142
|
// the main checkout and the other agents' workspaces are off limits; anything else is not ours to police
|
|
132
143
|
if (ctx.project && insideDir(ctx.project, abs) && !insideDir(ctx.cwd, abs)) {
|
|
@@ -149,18 +160,21 @@ export function handleHook(ctx, tool, event, payload) {
|
|
|
149
160
|
db.setStatus(ctx.db, ctx.agent, 'busy');
|
|
150
161
|
const parts = [];
|
|
151
162
|
if (isShell(payload.tool_name))
|
|
152
|
-
parts.push(...shellWarnings(ctx, payload));
|
|
153
|
-
|
|
163
|
+
parts.push(...shellWarnings(ctx, payload), ...commandWarnings(ctx, payload));
|
|
164
|
+
// a Claude Code subagent's tool calls fire this hook too (with its agent_id); context given to it never reaches
|
|
165
|
+
// the main conversation, so the messages wait for a call of the agent's own
|
|
166
|
+
const ms = payload.agent_id ? [] : db.takeUnread(ctx.db, ctx.agent);
|
|
154
167
|
if (ms.length)
|
|
155
168
|
parts.push(`${formatMessages(ms)}\n\n[chord] ${ms.length} message(s) above arrived from other agents while you were working. Deal with them when convenient (reply with the chord send tool where a reply is asked for), then carry on with your task.`);
|
|
156
169
|
return parts.length ? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: parts.join('\n\n') } } : undefined;
|
|
157
170
|
}
|
|
158
171
|
case 'stop': {
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
172
|
+
// only a message is worth another turn; a notice (a merge broadcast) waits for the next hook, as it does for the wake-up
|
|
173
|
+
if (!db.hasUnread(ctx.db, ctx.agent)) {
|
|
174
|
+
db.setStatus(ctx.db, ctx.agent, 'idle');
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
return { decision: 'block', reason: formatMessages(db.takeUnread(ctx.db, ctx.agent)) + '\n\nHandle these messages before stopping.' };
|
|
164
178
|
}
|
|
165
179
|
case 'notification':
|
|
166
180
|
// Claude Code: "idle_prompt" fires about a minute after Claude stopped and nobody typed. It is the only
|
|
@@ -176,30 +190,9 @@ export function handleHook(ctx, tool, event, payload) {
|
|
|
176
190
|
return undefined;
|
|
177
191
|
}
|
|
178
192
|
}
|
|
179
|
-
/** Paths changed according to `git status --porcelain=v1 -z -uall`: entries are NUL-separated, a rename
|
|
180
|
-
* or copy is followed by the original path, and nothing is quoted or folded into directories. */
|
|
181
|
-
export function changedPaths(statusZ) {
|
|
182
|
-
const parts = statusZ.split('\0');
|
|
183
|
-
const out = [];
|
|
184
|
-
for (let i = 0; i < parts.length; i++) {
|
|
185
|
-
const entry = parts[i];
|
|
186
|
-
if (!entry)
|
|
187
|
-
continue;
|
|
188
|
-
out.push(entry.slice(3));
|
|
189
|
-
const xy = entry.slice(0, 2);
|
|
190
|
-
if (/R/.test(xy)) {
|
|
191
|
-
i++;
|
|
192
|
-
if (parts[i])
|
|
193
|
-
out.push(parts[i]);
|
|
194
|
-
} // a rename changes its "from" path too
|
|
195
|
-
else if (/C/.test(xy))
|
|
196
|
-
i++; // a copy leaves its source alone
|
|
197
|
-
}
|
|
198
|
-
return out;
|
|
199
|
-
}
|
|
200
193
|
/** The old names of renames in NUL-separated status output: a rename changes that path too, and diff --name-only
|
|
201
194
|
* lists only the new one. */
|
|
202
|
-
|
|
195
|
+
function renameSources(statusZ) {
|
|
203
196
|
const parts = statusZ.split('\0');
|
|
204
197
|
const out = [];
|
|
205
198
|
for (let i = 0; i < parts.length; i++) {
|
|
@@ -225,30 +218,47 @@ const gitOut = (cwd, ...args) => {
|
|
|
225
218
|
return '';
|
|
226
219
|
}
|
|
227
220
|
};
|
|
228
|
-
/** What a shell command may have done behind chord's back: edits or commits to unclaimed files,
|
|
229
|
-
* changes to the main checkout. */
|
|
230
|
-
|
|
221
|
+
/** What a shell command may have done behind chord's back, according to git: edits or commits to unclaimed files,
|
|
222
|
+
* and changes to the main checkout. */
|
|
223
|
+
function shellWarnings(ctx, payload) {
|
|
231
224
|
const out = [];
|
|
232
225
|
const mine = new Set(db.claimsBy(ctx.db, ctx.agent).map(c => c.file.toLowerCase()));
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
// checked out does not make every shell command cry wolf; untracked files from status, directories folded
|
|
226
|
+
// Everything the worktree differs in from where its branch left main, committed or not: a commit made from the
|
|
227
|
+
// shell is no more a claim than an edit is. Tracked files by one diff against the merge base with the main
|
|
228
|
+
// checkout's HEAD (real changes only, so a repository whose files differ in line endings from the moment they are
|
|
229
|
+
// checked out does not make every shell command cry wolf); untracked files from status, directories folded.
|
|
237
230
|
const status = gitOut(ctx.worktree, 'status', '--porcelain=v1', '-z', '-unormal');
|
|
238
231
|
const untracked = status.split('\0').filter(e => e.startsWith('?? ')).map(e => e.slice(3));
|
|
239
|
-
const modified =
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
const unclaimed = touched.map(f => normFile(ctx, path.join(ctx.worktree, f))).filter(f => !
|
|
232
|
+
const modified = gitOut(ctx.worktree, 'diff', '--name-only', '-z', '--ignore-cr-at-eol', '--merge-base', 'main-worktree/HEAD').split('\0');
|
|
233
|
+
// the main checkout is <project>/<repoName>; asking git costs a subprocess on every shell command
|
|
234
|
+
const root = ctx.project ? path.join(ctx.project, path.basename(ctx.worktree)) : db.mainRoot(ctx.worktree);
|
|
235
|
+
const touched = [...new Set([...modified.filter(Boolean), ...untracked, ...renameSources(status)])];
|
|
236
|
+
// a folded untracked directory counts as claimed when a claim names a file inside it
|
|
237
|
+
const claimed = (f) => mine.has(f) || [...mine].some(m => m.startsWith(f + '/'));
|
|
238
|
+
const unclaimed = touched.map(f => normFile(ctx, path.join(ctx.worktree, f))).filter(f => !claimed(f.toLowerCase()));
|
|
246
239
|
const shown = unclaimed.slice(0, MAX_WARNED_FILES);
|
|
247
240
|
if (unclaimed.length)
|
|
248
241
|
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.`);
|
|
242
|
+
if (path.resolve(root) !== path.resolve(ctx.worktree)) {
|
|
243
|
+
// the same rule finish applies, so what finish will not mind (line endings, submodule insides) is not reported
|
|
244
|
+
const dirty = (() => { try {
|
|
245
|
+
return dirtyTracked(root).split('\n').filter(Boolean);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return [];
|
|
249
|
+
} })();
|
|
250
|
+
if (dirty.length)
|
|
251
|
+
out.push(`[chord] The main checkout (${root}) has uncommitted changes, which blocks everyone's finish(): ${dirty.slice(0, 10).join(', ')}. If your command did this, undo it there; the main branch is only ever changed through finish().`);
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
/** What the command's text alone gives away: it ran in or named the main checkout, or used git stash. Heuristics,
|
|
256
|
+
* so warnings, not gates; no git involved, unlike shellWarnings. */
|
|
257
|
+
export function commandWarnings(ctx, payload) {
|
|
258
|
+
const out = [];
|
|
249
259
|
const cmd = typeof payload.tool_input?.command === 'string' ? payload.tool_input.command : '';
|
|
250
260
|
// 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.
|
|
261
|
+
// named a path there did not touch the agent's own copy.
|
|
252
262
|
if (ctx.project) {
|
|
253
263
|
const repoName = path.basename(ctx.worktree);
|
|
254
264
|
const workdir = typeof payload.tool_input?.workdir === 'string' ? path.resolve(ctx.project, payload.tool_input.workdir) : null;
|
|
@@ -266,11 +276,6 @@ export function shellWarnings(ctx, payload) {
|
|
|
266
276
|
}
|
|
267
277
|
if (/\bgit\b[^|;&\n]*\bstash\b/.test(cmd))
|
|
268
278
|
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.");
|
|
269
|
-
if (root && path.resolve(root) !== path.resolve(ctx.worktree)) {
|
|
270
|
-
const dirty = gitOut(root, 'status', '--porcelain', '--untracked-files=no').split('\n').filter(Boolean).map(l => l.slice(3));
|
|
271
|
-
if (dirty.length)
|
|
272
|
-
out.push(`[chord] The main checkout (${root}) has uncommitted changes, which blocks everyone's finish(): ${dirty.slice(0, 10).join(', ')}. If your command did this, undo it there; the main branch is only ever changed through finish().`);
|
|
273
|
-
}
|
|
274
279
|
return out;
|
|
275
280
|
}
|
|
276
281
|
/** handleHook, but never lets an exception reach the tool: a broken hook must not fail open on a write. */
|
|
@@ -293,8 +298,11 @@ async function main() {
|
|
|
293
298
|
const agent = process.env.CHORD_AGENT_ID;
|
|
294
299
|
const file = process.env.CHORD_DB;
|
|
295
300
|
const tool = process.env.CHORD_TOOL;
|
|
296
|
-
|
|
297
|
-
|
|
301
|
+
// the workspace is fixed per agent and comes from the launch; the tool's own cwd is the project folder, which would
|
|
302
|
+
// make every claim path wrong, so it is never used as a stand-in
|
|
303
|
+
const { CHORD_WORKSPACE: cwd, CHORD_WORKTREE: worktree } = process.env;
|
|
304
|
+
if (!agent || !file || !tool || !cwd || !worktree) {
|
|
305
|
+
process.stderr.write('chord hook: CHORD_AGENT_ID, CHORD_DB, CHORD_TOOL, CHORD_WORKSPACE and CHORD_WORKTREE must be set\n');
|
|
298
306
|
process.exit(0);
|
|
299
307
|
}
|
|
300
308
|
const chunks = [];
|
|
@@ -306,11 +314,9 @@ async function main() {
|
|
|
306
314
|
payload = JSON.parse(raw);
|
|
307
315
|
}
|
|
308
316
|
catch { /* no payload */ }
|
|
309
|
-
// the workspace is fixed per agent; the tool's own cwd moves when the agent runs `cd`, so it is not used as the base
|
|
310
|
-
const cwd = process.env.CHORD_WORKSPACE ?? (typeof payload.cwd === 'string' ? payload.cwd : process.cwd());
|
|
311
317
|
let ctx;
|
|
312
318
|
try {
|
|
313
|
-
ctx = { db: db.openDb(file), agent, cwd, worktree
|
|
319
|
+
ctx = { db: db.openDb(file), agent, cwd, worktree, project: process.env.CHORD_PROJECT };
|
|
314
320
|
}
|
|
315
321
|
catch (e) {
|
|
316
322
|
// the database would not open: a write must still not go through unchecked
|
|
@@ -324,9 +330,9 @@ async function main() {
|
|
|
324
330
|
if (process.env.CHORD_DEBUG_HOOKS)
|
|
325
331
|
db.log(`hook payload ${event}: keys=${Object.keys(payload).join(',')} agent_id=${payload.agent_id ?? '-'} agent_type=${payload.agent_type ?? '-'} source=${payload.source ?? '-'} reason=${payload.reason ?? '-'}`);
|
|
326
332
|
const out = safeHandle(ctx, tool, event, payload);
|
|
327
|
-
db.log(`hook ${event} (${tool}, tool_name=${payload.tool_name ?? '-'}): ${out === undefined ? 'no output' :
|
|
333
|
+
db.log(`hook ${event} (${tool}, tool_name=${payload.tool_name ?? '-'}): ${out === undefined ? 'no output' : `json ${JSON.stringify(out).length} chars`} in ${Date.now() - started}ms`);
|
|
328
334
|
if (out !== undefined)
|
|
329
|
-
process.stdout.write(
|
|
335
|
+
process.stdout.write(JSON.stringify(out));
|
|
330
336
|
}
|
|
331
337
|
const isMain = (() => { try {
|
|
332
338
|
return fs.realpathSync.native(process.argv[1] ?? '') === fs.realpathSync.native(fileURLToPath(new URL(import.meta.url)));
|