chord-cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/db.js ADDED
@@ -0,0 +1,271 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { execFileSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ /** process.env without the GIT_* variables that would make git look at some other repository. */
6
+ export function gitEnv() {
7
+ const env = {};
8
+ for (const [k, v] of Object.entries(process.env))
9
+ if (!/^GIT_(DIR|WORK_TREE|INDEX_FILE|COMMON_DIR|OBJECT_DIRECTORY|NAMESPACE|CEILING_DIRECTORIES|ALTERNATE_OBJECT_DIRECTORIES)$/.test(k))
10
+ env[k] = v;
11
+ return env;
12
+ }
13
+ const commonDir = (cwd) => path.resolve(cwd, execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8', env: gitEnv() }).trim());
14
+ /** Root of the main checkout, even when run from inside a linked worktree. */
15
+ export const mainRoot = (cwd) => path.dirname(commonDir(cwd));
16
+ /** chord.db lives in the project's state directory under ~/.chord; CHORD_DB carries its path to hooks and servers. */
17
+ export const dbPath = (stateDir) => path.join(stateDir, 'chord.db');
18
+ /** Lock file that serialises finish() across agents: git cannot run two merges in one checkout at once. */
19
+ export const finishLockPath = (dbFile = process.env.CHORD_DB ?? '') => path.join(path.dirname(dbFile), 'finish.lock');
20
+ /** Windows and macOS file systems are case-insensitive: Src/a.ts and src/a.ts are one file, so one claim key. */
21
+ const FILE_COLLATION = process.platform === 'linux' ? '' : 'collate nocase';
22
+ export function openDb(file) {
23
+ // the busy timeout must be in force before the first statement: switching to WAL below does not wait otherwise,
24
+ // and several agents starting at once on a new database all hit it together
25
+ const db = new DatabaseSync(file, { timeout: 5000 });
26
+ db.exec(`
27
+ pragma journal_mode = wal;
28
+ pragma busy_timeout = 5000;
29
+ pragma foreign_keys = on;
30
+ create table if not exists agents (
31
+ name text primary key,
32
+ tool text not null,
33
+ pid integer not null,
34
+ cwd text not null,
35
+ worktree text not null,
36
+ task text,
37
+ scope text,
38
+ status text not null default 'busy',
39
+ started_at integer not null,
40
+ updated_at integer not null,
41
+ heartbeat_at integer,
42
+ stop_requested_at integer
43
+ );
44
+ create table if not exists claims (
45
+ agent text not null references agents(name),
46
+ file text not null ${FILE_COLLATION},
47
+ intent text not null,
48
+ kind text not null,
49
+ symbols text,
50
+ created_at integer not null,
51
+ asked_at integer,
52
+ primary key (agent, file)
53
+ );
54
+ create table if not exists messages (
55
+ id integer primary key autoincrement,
56
+ sender text not null,
57
+ recipient text not null,
58
+ kind text not null default 'message',
59
+ body text not null,
60
+ created_at integer not null,
61
+ read_at integer
62
+ );
63
+ create table if not exists events (
64
+ id integer primary key autoincrement,
65
+ at integer not null,
66
+ agent text not null,
67
+ kind text not null,
68
+ file text,
69
+ detail text
70
+ );
71
+ `);
72
+ migrate(db);
73
+ return db;
74
+ }
75
+ /** Bump when the schema changes and add a step below; `pragma user_version` records what a database has. A database
76
+ * from before versioning reads as 0 and gets every step, each of which checks what is actually there. */
77
+ const SCHEMA_VERSION = 2;
78
+ function migrate(db) {
79
+ const v = db.prepare('pragma user_version').get().user_version;
80
+ if (v >= SCHEMA_VERSION)
81
+ return;
82
+ const agentCols = () => db.prepare('pragma table_info(agents)').all().map(c => c.name);
83
+ if (v < 1 && !agentCols().includes('heartbeat_at'))
84
+ db.exec('alter table agents add column heartbeat_at integer'); // 2026-10-17
85
+ if (v < 2 && !agentCols().includes('stop_requested_at'))
86
+ db.exec('alter table agents add column stop_requested_at integer'); // 2026-10-17; the events table is created above
87
+ db.exec(`pragma user_version = ${SCHEMA_VERSION}`);
88
+ }
89
+ // agents
90
+ export function addAgent(db, a) {
91
+ const now = Date.now();
92
+ db.prepare(`insert into agents (name, tool, pid, cwd, worktree, status, started_at, updated_at, heartbeat_at)
93
+ values (?, ?, ?, ?, ?, 'busy', ?, ?, ?)
94
+ on conflict(name) do update set tool = excluded.tool, pid = excluded.pid, cwd = excluded.cwd, worktree = excluded.worktree,
95
+ status = 'busy', task = null, scope = null, started_at = excluded.started_at, updated_at = excluded.updated_at, heartbeat_at = excluded.heartbeat_at,
96
+ stop_requested_at = null`).run(a.name, a.tool, a.pid, a.cwd, a.worktree ?? a.cwd, now, now, a.heartbeat ? now : null);
97
+ // a relaunch under the same name must not inherit messages meant for the previous session
98
+ db.prepare('delete from messages where recipient = ? and read_at is null').run(a.name);
99
+ }
100
+ /** Forget an agent entirely: its claims and every message it sent or received. */
101
+ export function removeAgent(db, name) {
102
+ db.prepare('delete from claims where agent = ?').run(name);
103
+ db.prepare('delete from messages where sender = ? or recipient = ?').run(name, name);
104
+ db.prepare('delete from agents where name = ?').run(name);
105
+ }
106
+ export function setTask(db, name, task, scope) {
107
+ db.prepare('update agents set task = ?, scope = ?, updated_at = ? where name = ?').run(task, scope, Date.now(), name);
108
+ }
109
+ /** Offline is sticky here: a hook that outlives its killed agent must not revive it. Only the wrapper does, through
110
+ * addAgent (relaunch) or heartbeat. */
111
+ export function setStatus(db, name, status) {
112
+ db.prepare(`update agents set status = ?, updated_at = ? where name = ? and status != 'offline'`).run(status, Date.now(), name);
113
+ }
114
+ export function getAgent(db, name) {
115
+ return db.prepare('select * from agents where name = ?').get(name);
116
+ }
117
+ export function listAgents(db) {
118
+ return db.prepare('select * from agents order by started_at').all();
119
+ }
120
+ function pidAlive(pid) {
121
+ try {
122
+ process.kill(pid, 0);
123
+ return true;
124
+ }
125
+ catch (e) {
126
+ // EPERM: the process exists but we may not open it (e.g. from inside a sandbox). Only ESRCH means gone.
127
+ const code = e.code;
128
+ if (code !== 'EPERM')
129
+ log(`pid ${pid} judged dead: ${code}`);
130
+ return code === 'EPERM';
131
+ }
132
+ }
133
+ /** Append a line to chord.db.log next to the database, when CHORD_DB is known. Silent otherwise. */
134
+ export function log(line) {
135
+ if (!process.env.CHORD_DB)
136
+ return;
137
+ try {
138
+ fs.appendFileSync(process.env.CHORD_DB + '.log', `${new Date().toISOString()} [${process.env.CHORD_AGENT_ID ?? '-'}/${process.pid}] ${line}
139
+ `);
140
+ }
141
+ catch { /* best effort */ }
142
+ }
143
+ /** The wrapper refreshes this every second; an agent whose heartbeat is this old is dead even if its pid is taken again. */
144
+ export const HEARTBEAT_STALE_MS = 30_000;
145
+ /** The wrapper is the one process that knows the agent is alive, so its heartbeat also revives an agent a sweep
146
+ * marked offline (a machine asleep for over 30 s looks dead to the first sweep after waking). Claims are gone by
147
+ * then; the agent claims again when it next writes. */
148
+ export function heartbeat(db, name) {
149
+ // revived as busy, the safe direction: a wake-up or a stop must not be typed into an agent that was in fact
150
+ // working all along; the next Stop, idle report or idle_prompt makes it idle again
151
+ db.prepare(`update agents set heartbeat_at = ?, status = case when status = 'offline' then 'busy' else status end where name = ?`).run(Date.now(), name);
152
+ }
153
+ /** Mark agents whose process is gone as offline and drop their claims. Returns the names swept. A missing
154
+ * process or a stale heartbeat both count: Windows reuses pids quickly, so the pid alone can lie. */
155
+ export function sweep(db, alive = pidAlive, now = Date.now()) {
156
+ const online = listAgents(db).filter(a => a.status !== 'offline');
157
+ // Every heartbeat stale at once means the machine was asleep, not that everyone died: judge by pid alone then.
158
+ // ponytail: a lone dead agent whose pid was reused is therefore missed until a second agent comes online.
159
+ const beating = online.filter(a => a.heartbeat_at !== null);
160
+ const stale = (a) => a.heartbeat_at !== null && now - a.heartbeat_at > HEARTBEAT_STALE_MS;
161
+ const slept = beating.length > 0 && beating.every(stale);
162
+ if (slept)
163
+ log('sweep: every heartbeat is stale, the machine was probably asleep; judging by pid only');
164
+ const dead = online.filter(a => !alive(a.pid) || (!slept && stale(a))).map(a => a.name);
165
+ for (const name of dead) {
166
+ log(`sweep: ${name} marked offline, claims released`);
167
+ setStatus(db, name, 'offline');
168
+ releaseAll(db, name, 'swept');
169
+ }
170
+ return dead;
171
+ }
172
+ // claims
173
+ export function addClaim(db, c) {
174
+ db.prepare(`insert into claims (agent, file, intent, kind, symbols, created_at) values (?, ?, ?, ?, ?, ?)
175
+ 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());
176
+ addEvent(db, c.agent, 'claim', c.file, `${c.kind}: ${c.intent}`);
177
+ }
178
+ export function setAskedAt(db, agent, file, at) {
179
+ db.prepare('update claims set asked_at = ? where agent = ? and file = ?').run(at, agent, file);
180
+ }
181
+ export function removeClaim(db, agent, file) {
182
+ const gone = db.prepare('delete from claims where agent = ? and file = ?').run(agent, file).changes;
183
+ if (gone)
184
+ addEvent(db, agent, 'release', file, 'released');
185
+ }
186
+ /** `why` is what the events log shows: released (by the agent), finish, swept, offline, exit. */
187
+ export function releaseAll(db, agent, why = 'released') {
188
+ const files = db.prepare('select file from claims where agent = ? order by file').all(agent).map(r => r.file);
189
+ db.prepare('delete from claims where agent = ?').run(agent);
190
+ for (const f of files)
191
+ addEvent(db, agent, 'release', f, why);
192
+ }
193
+ /** Append-only history of claims, releases and merges, for `chord events`. */
194
+ export function addEvent(db, agent, kind, file, detail) {
195
+ db.prepare('insert into events (at, agent, kind, file, detail) values (?, ?, ?, ?, ?)').run(Date.now(), agent, kind, file, detail);
196
+ }
197
+ /** The latest `limit` events (oldest first), for one agent or all. */
198
+ export function listEvents(db, agent, limit = 50) {
199
+ const rows = agent
200
+ ? db.prepare('select * from events where agent = ? order by id desc limit ?').all(agent, limit)
201
+ : db.prepare('select * from events order by id desc limit ?').all(limit);
202
+ return rows.reverse();
203
+ }
204
+ /** The latest `limit` messages (oldest first), all or those sent or received by one agent. */
205
+ export function listMessages(db, agent, limit = 50) {
206
+ const rows = agent
207
+ ? db.prepare('select * from messages where sender = ? or recipient = ? order by id desc limit ?').all(agent, agent, limit)
208
+ : db.prepare('select * from messages order by id desc limit ?').all(limit);
209
+ return rows.reverse();
210
+ }
211
+ /** `chord stop`: ask the wrapper to quit the tool once the agent is idle. Returns false for an unknown or offline agent. */
212
+ export function requestStop(db, name) {
213
+ return db.prepare(`update agents set stop_requested_at = ? where name = ? and status != 'offline'`).run(Date.now(), name).changes > 0;
214
+ }
215
+ export function claimsFor(db, file) {
216
+ return db.prepare('select rowid as rid, * from claims where file = ?').all(file);
217
+ }
218
+ export function claimsBy(db, agent) {
219
+ return db.prepare('select rowid as rid, * from claims where agent = ? order by file').all(agent);
220
+ }
221
+ export function allClaims(db) {
222
+ return db.prepare('select rowid as rid, * from claims order by file, agent').all();
223
+ }
224
+ // messages
225
+ export function send(db, sender, recipients, body, kind = 'message') {
226
+ const now = Date.now();
227
+ const ins = db.prepare('insert into messages (sender, recipient, kind, body, created_at) values (?, ?, ?, ?, ?)');
228
+ for (const r of recipients)
229
+ ins.run(sender, r, kind, body, now);
230
+ }
231
+ /** Roughly what a hook may attach without the tool truncating it (Codex caps additionalContext near 2500 tokens). */
232
+ export const DELIVERY_BUDGET_CHARS = 6000;
233
+ /** Unread messages for `recipient`, oldest first, marked read as they are returned. Stops after `budget`
234
+ * characters (always at least one message) so a flood is delivered in several rounds instead of truncated. */
235
+ export function takeUnread(db, recipient, budget = DELIVERY_BUDGET_CHARS) {
236
+ // one transaction: parallel tool calls run several PostToolUse hooks at once, and each must get different messages
237
+ db.exec('begin immediate');
238
+ try {
239
+ const rows = db.prepare('select * from messages where recipient = ? and read_at is null order by id').all(recipient);
240
+ const out = [];
241
+ let used = 0;
242
+ for (const m of rows) {
243
+ if (out.length && used + m.body.length > budget)
244
+ break;
245
+ out.push(m);
246
+ used += m.body.length;
247
+ }
248
+ if (out.length) {
249
+ db.prepare(`update messages set read_at = ? where recipient = ? and read_at is null and id <= ?`).run(Date.now(), recipient, out[out.length - 1].id);
250
+ }
251
+ db.exec('commit');
252
+ return out;
253
+ }
254
+ catch (e) {
255
+ db.exec('rollback');
256
+ throw e;
257
+ }
258
+ }
259
+ /** Whether a message (not a notice) is waiting: only messages wake an idle agent; notices ride along with the next hook. */
260
+ export function hasUnread(db, recipient) {
261
+ return !!db.prepare(`select 1 from messages where recipient = ? and read_at is null and kind = 'message' limit 1`).get(recipient);
262
+ }
263
+ /** Has `sender` sent `recipient` a real message (not a notice) at or after `since`? */
264
+ export function repliedSince(db, sender, recipient, since) {
265
+ return firstMessageAt(db, sender, recipient, since) !== null;
266
+ }
267
+ /** When `sender` first messaged `recipient` at or after `since`; null if never. Notices do not count. */
268
+ export function firstMessageAt(db, sender, recipient, since) {
269
+ 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);
270
+ return row?.t ?? null;
271
+ }
package/dist/hooks.js ADDED
@@ -0,0 +1,302 @@
1
+ // Hook entry point for both Claude Code and Codex: `node hooks.ts <event>`, payload on stdin.
2
+ // Event names are chord's own; launch.ts maps each tool's hook config onto them.
3
+ import { execFileSync } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import * as db from "./db.js";
8
+ import { patchFiles } from "./patch.js";
9
+ import { canWrite, insideDir, normFile } from "./tools.js";
10
+ const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
11
+ export function filesTouched(tool, payload) {
12
+ const name = String(payload.tool_name ?? '');
13
+ const input = payload.tool_input ?? {};
14
+ if (tool === 'claude' && EDIT_TOOLS.has(name)) {
15
+ const p = input.file_path ?? input.notebook_path;
16
+ return typeof p === 'string' ? [p] : [];
17
+ }
18
+ if (tool === 'codex') {
19
+ // the patch has been seen under "command"; "input", "patch" and a bare string cost nothing to accept as well
20
+ const text = typeof input === 'string' ? input : [input.command, input.input, input.patch].find(v => typeof v === 'string');
21
+ if (name === 'apply_patch' && typeof text === 'string')
22
+ return patchFiles(text);
23
+ if (isShell(name) && typeof text === 'string' && text.includes('*** Begin Patch'))
24
+ return patchFiles(text); // apply_patch run from the shell
25
+ }
26
+ return [];
27
+ }
28
+ /** Tools that write files and must therefore name them: a call that names none is refused rather than let through. */
29
+ export const isEditTool = (tool, name) => (tool === 'claude' ? EDIT_TOOLS.has(String(name)) : name === 'apply_patch');
30
+ const isShell = (name) => /^(bash|powershell|shell|exec|exec_command|local_shell)$/i.test(String(name ?? ''));
31
+ const hhmm = (t) => new Date(t).toTimeString().slice(0, 5); // local time
32
+ export function formatMessages(ms) {
33
+ return ms.map(m => `[chord] message from ${m.sender} at ${hhmm(m.created_at)}:\n${m.body}`).join('\n\n');
34
+ }
35
+ const MAX_CLAIM_LINES = 60;
36
+ const MAX_WARNED_FILES = 20;
37
+ const MAX_INSTRUCTION_CHARS = 8000;
38
+ /** The repository's own agent instructions. The agent's cwd is the workspace, so the tools do not load them themselves. */
39
+ function projectInstructions(ctx, repoDir) {
40
+ const out = [];
41
+ for (const name of ['AGENTS.md', 'CLAUDE.md']) {
42
+ try {
43
+ const text = fs.readFileSync(path.join(ctx.worktree, name), 'utf8');
44
+ const body = text.length > MAX_INSTRUCTION_CHARS ? text.slice(0, MAX_INSTRUCTION_CHARS) + `\n[... truncated, read ${repoDir}/${name} for the rest]` : text;
45
+ out.push('', `Project instructions from ${repoDir}/${name}:`, body.trimEnd());
46
+ }
47
+ catch { /* not present */ }
48
+ }
49
+ return out;
50
+ }
51
+ export function briefing(ctx) {
52
+ const me = db.getAgent(ctx.db, ctx.agent);
53
+ const peers = db.listAgents(ctx.db).filter(a => a.name !== ctx.agent && a.status !== 'offline');
54
+ const claims = db.allClaims(ctx.db);
55
+ const repoDir = path.relative(ctx.cwd, ctx.worktree).split(path.sep).join('/') || '.';
56
+ const lines = [
57
+ `You are agent "${ctx.agent}" (${me?.tool ?? 'unknown'}), one of several coding agents working on the same project. Your working directory is your own workspace; the repository is the directory "${repoDir}" inside it, a git worktree on your own branch. Run git commands inside "${repoDir}". Name files as paths from your working directory, e.g. "${repoDir}/src/x.ts" (claim() also accepts repository-relative paths like "src/x.ts"). Other agents have their own worktrees of the same repository; never edit files outside your workspace. Coordination goes through the "chord" MCP tools.`,
58
+ '',
59
+ 'Rules:',
60
+ '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.',
62
+ '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
+ '4. Do not modify files through shell commands (sed, echo >, etc.). Use the editor tools so claims are checked.',
64
+ '5. To talk to other agents use only chord send()/inbox(); do not use any other agent-messaging feature. Messages are asynchronous.',
65
+ '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. If finish() reports a conflict, stop and ask the user.',
67
+ '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
+ '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
+ '',
70
+ peers.length ? 'Agents online:' : 'No other agents are online yet.',
71
+ ...peers.map(a => `- ${a.name} (${a.tool}, ${a.status}): ${a.task ?? 'not registered yet'}${a.scope ? ` [${a.scope}]` : ''}`),
72
+ '',
73
+ claims.length ? 'Current file claims:' : 'No files are claimed yet.',
74
+ ...claims.slice(0, MAX_CLAIM_LINES).map(c => `- ${c.file} <- ${c.agent}: ${c.kind}, ${c.intent}${c.symbols ? ` (${c.symbols})` : ''}`),
75
+ ...(claims.length > MAX_CLAIM_LINES ? [`- ... and ${claims.length - MAX_CLAIM_LINES} more (the claim tool reports holders of any file you claim)`] : []),
76
+ ...projectInstructions(ctx, repoDir),
77
+ ];
78
+ return lines.join('\n');
79
+ }
80
+ /** Context for the model. Plain stdout also works for Claude Code and `codex exec`, but the Codex TUI
81
+ * only takes the JSON form, so both events use it. */
82
+ const context = (hookEventName, additionalContext) => ({ hookSpecificOutput: { hookEventName, additionalContext } });
83
+ export function handleHook(ctx, tool, event, payload) {
84
+ db.sweep(ctx.db);
85
+ switch (event) {
86
+ case 'session-start':
87
+ // The agent now waits for its first prompt: idle, so messages that arrive before the user types still wake it.
88
+ // Claude Code also fires this after compacting a long conversation, in the middle of a turn: not idle then.
89
+ if (payload.source !== 'compact')
90
+ db.setStatus(ctx.db, ctx.agent, 'idle');
91
+ return context('SessionStart', briefing(ctx));
92
+ case 'user-prompt-submit': {
93
+ db.setStatus(ctx.db, ctx.agent, 'busy');
94
+ const ms = db.takeUnread(ctx.db, ctx.agent);
95
+ // One line of identity on every prompt: a conversation that started without the briefing (Codex /new fires no
96
+ // 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.`;
98
+ return context('UserPromptSubmit', ms.length
99
+ ? `${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
+ : reminder);
101
+ }
102
+ case 'pre-tool-use': {
103
+ const files = filesTouched(tool, payload);
104
+ if (!files.length && isEditTool(tool, payload.tool_name)) {
105
+ 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
+ }
107
+ // Codex resolves a patch's relative paths against the call's workdir when it gives one; otherwise the workspace
108
+ const workdir = tool === 'codex' && typeof payload.tool_input?.workdir === 'string' ? path.resolve(ctx.cwd, payload.tool_input.workdir) : ctx.cwd;
109
+ for (const f of files) {
110
+ const abs = path.resolve(workdir, f);
111
+ if (!insideDir(ctx.worktree, abs)) {
112
+ // the main checkout and the other agents' workspaces are off limits; anything else is not ours to police
113
+ if (ctx.project && insideDir(ctx.project, abs) && !insideDir(ctx.cwd, abs)) {
114
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `${f} is outside your workspace: edit files only inside your own worktree ${ctx.worktree}` } };
115
+ }
116
+ continue;
117
+ }
118
+ const check = canWrite(ctx, abs);
119
+ if (!check.ok) {
120
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: check.reason } };
121
+ }
122
+ }
123
+ return undefined;
124
+ }
125
+ case 'post-tool-use': {
126
+ // Runs after every tool call. Any tool call means the agent is busy (another plugin's Stop hook may have
127
+ // blocked the end of the turn, or compaction may have run). Shell commands are checked for what they did
128
+ // behind chord's back, and messages that arrived mid-turn are handed over so a busy agent hears from its
129
+ // peers at the next tool call, not at turn end.
130
+ db.setStatus(ctx.db, ctx.agent, 'busy');
131
+ const parts = [];
132
+ if (isShell(payload.tool_name))
133
+ parts.push(...shellWarnings(ctx, payload));
134
+ const ms = db.takeUnread(ctx.db, ctx.agent);
135
+ if (ms.length)
136
+ 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.`);
137
+ return parts.length ? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: parts.join('\n\n') } } : undefined;
138
+ }
139
+ case 'stop': {
140
+ const ms = db.takeUnread(ctx.db, ctx.agent);
141
+ if (ms.length)
142
+ return { decision: 'block', reason: formatMessages(ms) + '\n\nHandle these messages before stopping.' };
143
+ db.setStatus(ctx.db, ctx.agent, 'idle');
144
+ return undefined;
145
+ }
146
+ case 'notification':
147
+ // Claude Code: "idle_prompt" fires about a minute after Claude stopped and nobody typed. It is the only
148
+ // signal after an Esc interrupt, which fires no Stop hook, so it is how a cut-off turn becomes idle.
149
+ if (payload.notification_type === 'idle_prompt')
150
+ db.setStatus(ctx.db, ctx.agent, 'idle');
151
+ return undefined;
152
+ case 'session-end':
153
+ // A session ending is not the agent leaving: /clear, /resume and /compact end one and start another in the same
154
+ // process, and the reason strings differ between tools and versions. Going offline and releasing claims is the
155
+ // wrapper's job when the process exits (and the sweep's if it crashes); here the agent only becomes idle.
156
+ db.setStatus(ctx.db, ctx.agent, 'idle');
157
+ return undefined;
158
+ }
159
+ }
160
+ /** Paths changed according to `git status --porcelain=v1 -z -uall`: entries are NUL-separated, a rename
161
+ * or copy is followed by the original path, and nothing is quoted or folded into directories. */
162
+ export function changedPaths(statusZ) {
163
+ const parts = statusZ.split('\0');
164
+ const out = [];
165
+ for (let i = 0; i < parts.length; i++) {
166
+ const entry = parts[i];
167
+ if (!entry)
168
+ continue;
169
+ out.push(entry.slice(3));
170
+ const xy = entry.slice(0, 2);
171
+ if (/R/.test(xy)) {
172
+ i++;
173
+ if (parts[i])
174
+ out.push(parts[i]);
175
+ } // a rename changes its "from" path too
176
+ else if (/C/.test(xy))
177
+ i++; // a copy leaves its source alone
178
+ }
179
+ return out;
180
+ }
181
+ /** The old names of renames in NUL-separated status output: a rename changes that path too, and diff --name-only
182
+ * lists only the new one. */
183
+ export function renameSources(statusZ) {
184
+ const parts = statusZ.split('\0');
185
+ const out = [];
186
+ for (let i = 0; i < parts.length; i++) {
187
+ const entry = parts[i];
188
+ if (!entry)
189
+ continue;
190
+ if (/R/.test(entry.slice(0, 2))) {
191
+ i++;
192
+ if (parts[i])
193
+ out.push(parts[i]);
194
+ }
195
+ else if (/C/.test(entry.slice(0, 2)))
196
+ i++;
197
+ }
198
+ return out;
199
+ }
200
+ /** git output, or '' when the command fails; optional locks off so this read-only look never fights the agent's own git. */
201
+ const gitOut = (cwd, ...args) => {
202
+ try {
203
+ return execFileSync('git', ['--no-optional-locks', ...args], { cwd, encoding: 'utf8', env: db.gitEnv(), maxBuffer: 64 * 1024 * 1024 });
204
+ }
205
+ catch {
206
+ return '';
207
+ }
208
+ };
209
+ /** What a shell command may have done behind chord's back: edits or commits to unclaimed files, git stash, and
210
+ * changes to the main checkout. */
211
+ export function shellWarnings(ctx, payload) {
212
+ const out = [];
213
+ const mine = new Set(db.claimsBy(ctx.db, ctx.agent).map(c => c.file.toLowerCase()));
214
+ // uncommitted changes (-unormal folds an untracked directory into one entry) plus whatever the branch has
215
+ // committed since it left main: a commit made from the shell is no more a claim than an edit is
216
+ // tracked files: real changes only, so a repository whose files differ in line endings from the moment they are
217
+ // checked out does not make every shell command cry wolf; untracked files from status, directories folded
218
+ const status = gitOut(ctx.worktree, 'status', '--porcelain=v1', '-z', '-unormal');
219
+ const untracked = status.split('\0').filter(e => e.startsWith('?? ')).map(e => e.slice(3));
220
+ const modified = ['', '--cached'].flatMap(c => gitOut(ctx.worktree, 'diff', ...(c ? [c] : []), '--name-only', '-z', '--ignore-cr-at-eol').split('\0'));
221
+ const root = db.mainRoot(ctx.worktree);
222
+ const main = root ? gitOut(root, 'rev-parse', '--abbrev-ref', 'HEAD').trim() : '';
223
+ const base = main && main !== 'HEAD' ? gitOut(ctx.worktree, 'merge-base', 'HEAD', main).trim() : '';
224
+ const committed = base ? gitOut(ctx.worktree, 'diff', '--name-only', '-z', base, 'HEAD').split('\0').filter(Boolean) : [];
225
+ const touched = [...new Set([...modified.filter(Boolean), ...untracked, ...renameSources(status), ...committed])];
226
+ const unclaimed = touched.map(f => normFile(ctx, path.join(ctx.worktree, f))).filter(f => !mine.has(f.toLowerCase()));
227
+ const shown = unclaimed.slice(0, MAX_WARNED_FILES);
228
+ if (unclaimed.length)
229
+ 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
+ const cmd = typeof payload.tool_input?.command === 'string' ? payload.tool_input.command : '';
231
+ if (/\bgit\b[^|;&\n]*\bstash\b/.test(cmd))
232
+ 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
+ if (root && path.resolve(root) !== path.resolve(ctx.worktree)) {
234
+ const dirty = gitOut(root, 'status', '--porcelain', '--untracked-files=no').split('\n').filter(Boolean).map(l => l.slice(3));
235
+ if (dirty.length)
236
+ 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().`);
237
+ }
238
+ return out;
239
+ }
240
+ /** handleHook, but never lets an exception reach the tool: a broken hook must not fail open on a write. */
241
+ export function safeHandle(ctx, tool, event, payload) {
242
+ try {
243
+ return handleHook(ctx, tool, event, payload);
244
+ }
245
+ catch (e) {
246
+ const msg = e.message;
247
+ db.log(`hook ${event} failed: ${msg}`);
248
+ if (event === 'pre-tool-use' && filesTouched(tool, payload).length) {
249
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `chord could not check this write (${msg}); try again or report it to the user` } };
250
+ }
251
+ return undefined;
252
+ }
253
+ }
254
+ async function main() {
255
+ const started = Date.now();
256
+ const event = process.argv[2];
257
+ const agent = process.env.CHORD_AGENT_ID;
258
+ const file = process.env.CHORD_DB;
259
+ const tool = process.env.CHORD_TOOL;
260
+ if (!agent || !file || !tool) {
261
+ process.stderr.write('chord hook: CHORD_AGENT_ID, CHORD_DB and CHORD_TOOL must be set\n');
262
+ process.exit(0);
263
+ }
264
+ const chunks = [];
265
+ for await (const chunk of process.stdin)
266
+ chunks.push(Buffer.from(chunk));
267
+ const raw = Buffer.concat(chunks).toString('utf8'); // decode once: a multi-byte character may straddle chunks
268
+ let payload = {};
269
+ try {
270
+ payload = JSON.parse(raw);
271
+ }
272
+ catch { /* no payload */ }
273
+ // 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
274
+ const cwd = process.env.CHORD_WORKSPACE ?? (typeof payload.cwd === 'string' ? payload.cwd : process.cwd());
275
+ let ctx;
276
+ try {
277
+ ctx = { db: db.openDb(file), agent, cwd, worktree: process.env.CHORD_WORKTREE ?? cwd, project: process.env.CHORD_PROJECT };
278
+ }
279
+ catch (e) {
280
+ // the database would not open: a write must still not go through unchecked
281
+ const msg = e.message;
282
+ process.stderr.write(`chord hook: cannot open ${file}: ${msg}\n`);
283
+ if (event === 'pre-tool-use' && filesTouched(tool, payload).length) {
284
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `chord could not check this write (${msg}); try again or report it to the user` } }));
285
+ }
286
+ return;
287
+ }
288
+ if (process.env.CHORD_DEBUG_HOOKS)
289
+ 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 ?? '-'}`);
290
+ const out = safeHandle(ctx, tool, event, payload);
291
+ db.log(`hook ${event} (${tool}, tool_name=${payload.tool_name ?? '-'}): ${out === undefined ? 'no output' : `${typeof out === 'string' ? 'text' : 'json'} ${JSON.stringify(out).length} chars`} in ${Date.now() - started}ms`);
292
+ if (out !== undefined)
293
+ process.stdout.write(typeof out === 'string' ? out : JSON.stringify(out));
294
+ }
295
+ const isMain = (() => { try {
296
+ return fs.realpathSync.native(process.argv[1] ?? '') === fs.realpathSync.native(fileURLToPath(new URL(import.meta.url)));
297
+ }
298
+ catch {
299
+ return false;
300
+ } })();
301
+ if (isMain)
302
+ main();