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/src/cli.ts DELETED
@@ -1,234 +0,0 @@
1
- #!/usr/bin/env node
2
- // chord <claude|codex> [--name <agent>] [tool args...] launch an agent in its own workspace
3
- // chord ls [--all] agents and file claims, this project or all
4
- // chord messages [agent] [-n N] message history
5
- // chord events [agent] [-n N] claim, release, merge and conflict history
6
- // chord stop <agent> quit an agent once it is idle
7
- // chord clean [--force] [agent...] remove offline agents' workspaces, worktrees and branches
8
- import { execFileSync } from 'node:child_process';
9
- import fs from 'node:fs';
10
- import path from 'node:path';
11
- import { createInterface } from 'node:readline/promises';
12
- import { fileURLToPath } from 'node:url';
13
- import { clean } from './clean.ts';
14
- import * as db from './db.ts';
15
- import { mergeUserSettings, prepare } from './launch.ts';
16
- import { NeedsRestructure, knownProjects, resolveProject, restructure, restructureBlocker, validateRepoName } from './project.ts';
17
- import { resolveCommand, run } from './pty.ts';
18
- import { disabledCodexHooks, pretrustClaude } from './trust.ts';
19
- import type { Tool } from './hooks.ts';
20
-
21
- const USAGE = 'usage: chord <claude|codex> [--name <agent>] [tool args...]\n chord ls [--all]\n chord messages [agent] [-n N]\n chord events [agent] [-n N]\n chord stop <agent>\n chord clean [--force] [agent...]\n';
22
-
23
- const gitOut = (cwd: string, ...args: string[]): string | undefined => {
24
- try { return execFileSync('git', args, { cwd, encoding: 'utf8', env: db.gitEnv(), stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return undefined; }
25
- };
26
-
27
- /** "12s", "3m", "2h", "5d": how long ago. */
28
- export function ago(ms: number): string {
29
- const s = Math.max(0, Math.round(ms / 1000));
30
- return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`;
31
- }
32
-
33
- /** Local time of day, with the date in front when it is not today. */
34
- export function when(t: number, now = Date.now()): string {
35
- const d = new Date(t), pad = (n: number) => String(n).padStart(2, '0');
36
- const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
37
- return d.toDateString() === new Date(now).toDateString() ? time : `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`;
38
- }
39
-
40
- /** Agents (with unread messages, time since the last sign of life, commits on their branch not yet on main) and
41
- * claims. `repo` is the main checkout; without it the branch column shows "?". */
42
- export function ls(d: ReturnType<typeof db.openDb>, repo?: string, now = Date.now()): string {
43
- db.sweep(d);
44
- const agents = db.listAgents(d);
45
- const claims = db.allClaims(d);
46
- const main = repo ? gitOut(repo, 'rev-parse', '--abbrev-ref', 'HEAD') : undefined;
47
- const ahead = (name: string) => { if (!repo || !main) return '?'; const n = gitOut(repo, 'rev-list', '--count', `${main}..chord/${name}`); return n === undefined ? '-' : `+${n}`; };
48
- const unread = (name: string) => (d.prepare('select count(*) c from messages where recipient = ? and read_at is null').get(name) as { c: number }).c;
49
- const row = (a: db.Agent) => [a.name.padEnd(28), a.tool.padEnd(7), a.status.padEnd(8), String(unread(a.name)).padEnd(6), ago(now - (a.heartbeat_at ?? a.updated_at)).padEnd(7), ahead(a.name).padEnd(6), `${a.task ?? '-'}${a.scope ? ` [${a.scope}]` : ''}`].join(' ');
50
- const header = ['agent'.padEnd(28), 'tool'.padEnd(7), 'status'.padEnd(8), 'unread', 'active'.padEnd(7), 'branch', 'task'].join(' ');
51
- const out = agents.length ? [header, ...agents.map(row)] : ['no agents'];
52
- out.push('', ...(claims.length ? claims.map(c => `${c.file.padEnd(40)} ${c.agent.padEnd(28)} ${c.kind.padEnd(7)} ${c.intent}`) : ['no claims']));
53
- return out.join('\n') + '\n';
54
- }
55
- export function lsAll(): string {
56
- const projects = knownProjects();
57
- if (!projects.length) return 'no chord projects on this machine yet\n';
58
- const repoOf = (dir?: string) => { try { return dir ? resolveProject(dir).repo : undefined; } catch { return undefined; } };
59
- return projects.map(p => {
60
- try { return `== ${p.dir ?? p.stateDir}\n${ls(db.openDb(p.dbFile), repoOf(p.dir ?? undefined))}`; } catch (e) { return `== ${p.dir ?? p.stateDir}\n(unreadable: ${(e as Error).message})\n`; }
61
- }).join('\n');
62
- }
63
-
64
- /** chord messages [agent] [-n N]: the latest messages, oldest first, one line each. */
65
- export function messages(d: ReturnType<typeof db.openDb>, agent?: string, limit = 50, now = Date.now()): string {
66
- const ms = db.listMessages(d, agent, limit);
67
- if (!ms.length) return agent ? `no messages to or from ${agent}\n` : 'no messages\n';
68
- const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim();
69
- return ms.map(m => `${when(m.created_at, now)} ${m.sender} -> ${m.recipient}${m.kind !== 'message' ? ` [${m.kind}]` : ''}${m.read_at ? '' : ' (unread)'}: ${oneLine(m.body)}`).join('\n') + '\n';
70
- }
71
-
72
- /** chord events [agent] [-n N]: claims, releases, merges and conflicts, oldest first. */
73
- export function events(d: ReturnType<typeof db.openDb>, agent?: string, limit = 50, now = Date.now()): string {
74
- const es = db.listEvents(d, agent, limit);
75
- if (!es.length) return agent ? `no events for ${agent}\n` : 'no events\n';
76
- return es.map(e => `${when(e.at, now)} ${e.agent.padEnd(28)} ${e.kind.padEnd(8)} ${[e.file, e.detail].filter(Boolean).join(' ')}`).join('\n') + '\n';
77
- }
78
-
79
- /** chord stop <agent>: the wrapper quits the tool once the agent is idle and nothing is typed. */
80
- export function stop(d: ReturnType<typeof db.openDb>, name: string): string {
81
- if (db.requestStop(d, name)) return `stop requested: ${name} will quit when its current turn ends and its input box is empty\n`;
82
- throw new Error(db.getAgent(d, name) ? `${name} is already offline` : `no such agent: ${name}`);
83
- }
84
-
85
- /** [agent] and -n N for the history commands. */
86
- export function listArgs(rest: string[]): { agent?: string; limit: number } {
87
- let agent: string | undefined, limit = 50;
88
- for (let i = 0; i < rest.length; i++) {
89
- if (rest[i] === '-n') { limit = Number(rest[++i]); if (!Number.isInteger(limit) || limit <= 0) throw new Error('-n needs a positive number'); }
90
- else if (rest[i].startsWith('-')) throw new Error(`unknown option ${rest[i]}`);
91
- else if (agent) throw new Error('give at most one agent name');
92
- else agent = rest[i];
93
- }
94
- return { agent, limit };
95
- }
96
-
97
- /** `--name <x>` or `--name=<x>` anywhere before the tool's own arguments. */
98
- /** The user's own `--settings <json|file>` for Claude, removed from the tool arguments: passed through as is it would
99
- * replace chord's --settings wholesale and switch the hooks off, so chord folds it under its own instead. */
100
- export function takeSettings(rest: string[]): Record<string, any> | undefined {
101
- let raw: string | undefined;
102
- for (let i = 0; i < rest.length; i++) {
103
- const a = rest[i];
104
- if (a === '--settings') { raw = rest[i + 1]; if (raw === undefined || raw.startsWith('-')) throw new Error('--settings needs a value'); rest.splice(i, 2); break; }
105
- if (a.startsWith('--settings=')) { raw = a.slice(11); rest.splice(i, 1); break; }
106
- }
107
- if (raw === undefined) return undefined;
108
- const text = fs.existsSync(raw) ? fs.readFileSync(raw, 'utf8') : raw;
109
- let parsed: unknown;
110
- try { parsed = JSON.parse(text); } catch { throw new Error(`--settings must be a JSON object or a path to a JSON file: ${raw}`); }
111
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new Error('--settings must be a JSON object');
112
- return parsed as Record<string, any>;
113
- }
114
-
115
- /** Tool flags that would silently disable chord's hooks or MCP server: refused rather than launched half-working. */
116
- export function refuseHookKillers(tool: Tool, rest: string[]): void {
117
- if (tool === 'claude') {
118
- for (const flag of ['--bare', '--safe-mode', '--disable-all-hooks']) if (rest.includes(flag)) throw new Error(`${flag} switches off the hooks chord relies on; run plain claude for that`);
119
- } else {
120
- for (let i = 0; i < rest.length; i++) {
121
- const v = rest[i] === '-c' || rest[i] === '--config' ? rest[i + 1] ?? '' : rest[i].startsWith('--config=') ? rest[i].slice(9) : '';
122
- if (/^(hooks\b|mcp_servers\.chord\b)/.test(v)) throw new Error(`"-c ${v}" would replace chord's own hooks or MCP server; chord sets these itself`);
123
- // Codex's Windows sandbox runs commands as a restricted user that cannot start chord's hooks (nor reach the
124
- // state under the user's profile), so claims would go unchecked; refused rather than run half-working
125
- const sandbox = rest[i] === '-s' || rest[i] === '--sandbox' ? rest[i + 1] ?? '' : rest[i].startsWith('--sandbox=') ? rest[i].slice(10) : /^sandbox_mode\s*=\s*"?([\w-]+)/.exec(v)?.[1] ?? '';
126
- if (process.platform === 'win32' && sandbox && sandbox !== 'danger-full-access') throw new Error(`Codex's "${sandbox}" sandbox cannot run chord's hooks on Windows (the sandboxed user cannot start them), so file claims would not be checked; run without -s, or with -s danger-full-access`);
127
- }
128
- }
129
- }
130
-
131
- export function takeName(rest: string[]): string | undefined {
132
- for (let i = 0; i < rest.length; i++) {
133
- const a = rest[i];
134
- if (a === '--name') {
135
- const v = rest[i + 1];
136
- if (!v || v.startsWith('-')) throw new Error('--name needs a value');
137
- rest.splice(i, 2); return v;
138
- }
139
- if (a.startsWith('--name=')) { rest.splice(i, 1); const v = a.slice(7); if (!v) throw new Error('--name needs a value'); return v; }
140
- }
141
- return undefined;
142
- }
143
-
144
- /** The folder is a repository root: offer to move it into a child directory so it fits the project layout.
145
- * Throws with guidance when that is impossible or declined. */
146
- async function offerRestructure(e: NeedsRestructure): Promise<void> {
147
- const example = path.join(e.dir, e.suggestedName);
148
- const blocker = restructureBlocker(e.dir);
149
- if (blocker) throw new Error(`${e.message}; ${blocker}`);
150
- if (!process.stdin.isTTY) throw new Error(`${e.message}. Move it into a subfolder (e.g. ${example}) and run chord in ${e.dir} again`);
151
- const rl = createInterface({ input: process.stdin, output: process.stderr });
152
- try {
153
- process.stderr.write(`chord: ${e.dir} is itself a git repository.\n` +
154
- `A chord project is a folder that contains the repository as a subfolder, next to chord's own working directory.\n` +
155
- `Move everything in ${e.dir} into ${example}${path.sep} ? Open editors, other terminals' current directory and absolute paths in IDE settings will need updating.\n`);
156
- for (let attempt = 0; attempt < 3; attempt++) {
157
- const answer = (await rl.question(`[Y/n/other name] (${e.suggestedName}): `)).trim();
158
- if (/^n(o)?$/i.test(answer)) throw new Error('not moved; move the repository into a subfolder yourself and run chord in the parent folder');
159
- const name = answer === '' || /^y(es)?$/i.test(answer) ? e.suggestedName : answer;
160
- const invalid = validateRepoName(name) ?? (fs.existsSync(path.join(e.dir, name)) ? `${name} already exists` : null);
161
- if (invalid) { process.stderr.write(`chord: ${invalid}; try another name\n`); continue; }
162
- const target = restructure(e.dir, name);
163
- process.stderr.write(`chord: moved the repository to ${target}\n`);
164
- return;
165
- }
166
- throw new Error('no usable name given; nothing was moved');
167
- } finally { rl.close(); }
168
- }
169
-
170
- async function main(argv: string[]): Promise<number> {
171
- const [cmd, ...rest] = argv;
172
- if (cmd === 'ls') {
173
- if (rest.includes('--all')) { process.stdout.write(lsAll()); return 0; }
174
- let project;
175
- try { project = resolveProject(process.cwd()); } catch (e) {
176
- if (e instanceof NeedsRestructure) throw new Error(`${e.message}; run \`chord claude\` or \`chord codex\` here to set the project up, or \`chord ls --all\` for every project`);
177
- throw new Error(`${(e as Error).message}; use \`chord ls --all\` for every project on this machine`);
178
- }
179
- const file = db.dbPath(project.stateDir);
180
- if (!fs.existsSync(file)) { process.stdout.write(`no agents have run in ${project.dir} yet\n`); return 0; }
181
- process.stdout.write(ls(db.openDb(file), project.repo));
182
- return 0;
183
- }
184
- if (cmd === 'messages' || cmd === 'events' || cmd === 'stop') {
185
- const project = resolveProject(process.cwd());
186
- const file = db.dbPath(project.stateDir);
187
- if (!fs.existsSync(file)) { process.stdout.write(`no agents have run in ${project.dir} yet\n`); return 0; }
188
- const d = db.openDb(file);
189
- if (cmd === 'stop') { if (rest.length !== 1) { process.stderr.write(USAGE); return 2; } process.stdout.write(stop(d, rest[0])); return 0; }
190
- const { agent, limit } = listArgs(rest);
191
- process.stdout.write(cmd === 'messages' ? messages(d, agent, limit) : events(d, agent, limit));
192
- return 0;
193
- }
194
- if (cmd === 'clean') {
195
- const project = resolveProject(process.cwd());
196
- const file = db.dbPath(project.stateDir);
197
- if (!fs.existsSync(file)) { process.stdout.write(`no agents have run in ${project.dir} yet\n`); return 0; }
198
- const force = rest.includes('--force');
199
- const r = clean(project, db.openDb(file), rest.filter(a => a !== '--force'), force);
200
- for (const n of r.removed) process.stdout.write(`removed ${n}\n`);
201
- for (const k of r.kept) process.stdout.write(`kept ${k.name}: ${k.why}\n`);
202
- if (!r.removed.length && !r.kept.length) process.stdout.write('nothing to clean\n');
203
- return 0;
204
- }
205
- if (cmd !== 'claude' && cmd !== 'codex') { process.stderr.write(USAGE); return 2; }
206
- const name = takeName(rest);
207
- const userSettings = cmd === 'claude' ? takeSettings(rest) : undefined;
208
- refuseHookKillers(cmd as Tool, rest);
209
- resolveCommand(cmd); // fail before creating a workspace when the tool is not installed
210
- let launch;
211
- try {
212
- launch = prepare(cmd as Tool, process.cwd(), name);
213
- } catch (e) {
214
- if (!(e instanceof NeedsRestructure)) throw e;
215
- await offerRestructure(e);
216
- launch = prepare(cmd as Tool, process.cwd(), name);
217
- }
218
- process.stderr.write(`chord: agent ${launch.name} in ${launch.cwd}\n`);
219
- if (userSettings) launch.args = mergeUserSettings(launch.args, userSettings);
220
- if (cmd === 'claude') { // otherwise every new workspace asks "do you trust this folder?"; a failure here only brings that dialogue back
221
- try { pretrustClaude(launch.cwd); } catch (e) { process.stderr.write(`chord: could not record the workspace as trusted in ~/.claude.json (${(e as Error).message}); Claude may ask you to trust the folder\n`); }
222
- }
223
- if (cmd === 'codex') {
224
- const off = disabledCodexHooks();
225
- if (off.length) 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`);
226
- }
227
- return run(cmd as Tool, launch, rest);
228
- }
229
-
230
- // run only as the entry point (argv[1] may be a symlink such as an npm bin link)
231
- const isMain = (() => { try { return fs.realpathSync.native(process.argv[1] ?? '') === fs.realpathSync.native(fileURLToPath(import.meta.url)); } catch { return false; } })();
232
- if (isMain) {
233
- main(process.argv.slice(2)).then(code => process.exit(code), e => { process.stderr.write(`chord: ${(e as Error).message}\n`); process.exit(1); });
234
- }
package/src/db.ts DELETED
@@ -1,331 +0,0 @@
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
-
6
- export type Status = 'busy' | 'idle' | 'offline';
7
-
8
- export interface Agent {
9
- name: string;
10
- tool: string;
11
- pid: number;
12
- cwd: string; // the agent's working directory (its workspace W)
13
- worktree: string; // the repository worktree inside W, used by finish
14
- task: string | null;
15
- scope: string | null;
16
- status: Status;
17
- started_at: number;
18
- updated_at: number;
19
- heartbeat_at: number | null; // refreshed by the wrapper; null for agents without one (tests)
20
- stop_requested_at: number | null; // set by `chord stop`; the wrapper quits the tool once the agent is idle
21
- }
22
-
23
- export interface Claim {
24
- rid: number; // rowid: orders claims created in the same millisecond
25
- agent: string;
26
- file: string;
27
- intent: string;
28
- kind: string;
29
- symbols: string | null;
30
- created_at: number;
31
- asked_at: number | null;
32
- }
33
-
34
- export type MessageKind = 'message' | 'notice';
35
-
36
- export interface Message {
37
- id: number;
38
- sender: string;
39
- recipient: string;
40
- kind: MessageKind; // notices (e.g. "X merged into main") never count as replies
41
- body: string;
42
- created_at: number;
43
- read_at: number | null;
44
- }
45
-
46
- /** process.env without the GIT_* variables that would make git look at some other repository. */
47
- export function gitEnv(): NodeJS.ProcessEnv {
48
- const env: NodeJS.ProcessEnv = {};
49
- for (const [k, v] of Object.entries(process.env)) if (!/^GIT_(DIR|WORK_TREE|INDEX_FILE|COMMON_DIR|OBJECT_DIRECTORY|NAMESPACE|CEILING_DIRECTORIES|ALTERNATE_OBJECT_DIRECTORIES)$/.test(k)) env[k] = v;
50
- return env;
51
- }
52
-
53
- const commonDir = (cwd: string) => path.resolve(cwd, execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8', env: gitEnv() }).trim());
54
-
55
- /** Root of the main checkout, even when run from inside a linked worktree. */
56
- export const mainRoot = (cwd: string) => path.dirname(commonDir(cwd));
57
-
58
- /** chord.db lives in the project's state directory under ~/.chord; CHORD_DB carries its path to hooks and servers. */
59
- export const dbPath = (stateDir: string) => path.join(stateDir, 'chord.db');
60
-
61
- /** Lock file that serialises finish() across agents: git cannot run two merges in one checkout at once. */
62
- export const finishLockPath = (dbFile = process.env.CHORD_DB ?? '') => path.join(path.dirname(dbFile), 'finish.lock');
63
-
64
- /** Windows and macOS file systems are case-insensitive: Src/a.ts and src/a.ts are one file, so one claim key. */
65
- const FILE_COLLATION = process.platform === 'linux' ? '' : 'collate nocase';
66
-
67
- export function openDb(file: string): DatabaseSync {
68
- // the busy timeout must be in force before the first statement: switching to WAL below does not wait otherwise,
69
- // and several agents starting at once on a new database all hit it together
70
- const db = new DatabaseSync(file, { timeout: 5000 });
71
- db.exec(`
72
- pragma journal_mode = wal;
73
- pragma busy_timeout = 5000;
74
- pragma foreign_keys = on;
75
- create table if not exists agents (
76
- name text primary key,
77
- tool text not null,
78
- pid integer not null,
79
- cwd text not null,
80
- worktree text not null,
81
- task text,
82
- scope text,
83
- status text not null default 'busy',
84
- started_at integer not null,
85
- updated_at integer not null,
86
- heartbeat_at integer,
87
- stop_requested_at integer
88
- );
89
- create table if not exists claims (
90
- agent text not null references agents(name),
91
- file text not null ${FILE_COLLATION},
92
- intent text not null,
93
- kind text not null,
94
- symbols text,
95
- created_at integer not null,
96
- asked_at integer,
97
- primary key (agent, file)
98
- );
99
- create table if not exists messages (
100
- id integer primary key autoincrement,
101
- sender text not null,
102
- recipient text not null,
103
- kind text not null default 'message',
104
- body text not null,
105
- created_at integer not null,
106
- read_at integer
107
- );
108
- create table if not exists events (
109
- id integer primary key autoincrement,
110
- at integer not null,
111
- agent text not null,
112
- kind text not null,
113
- file text,
114
- detail text
115
- );
116
- `);
117
- migrate(db);
118
- return db;
119
- }
120
-
121
- /** Bump when the schema changes and add a step below; `pragma user_version` records what a database has. A database
122
- * from before versioning reads as 0 and gets every step, each of which checks what is actually there. */
123
- const SCHEMA_VERSION = 2;
124
-
125
- function migrate(db: DatabaseSync): void {
126
- const v = (db.prepare('pragma user_version').get() as { user_version: number }).user_version;
127
- if (v >= SCHEMA_VERSION) return;
128
- const agentCols = () => (db.prepare('pragma table_info(agents)').all() as { name: string }[]).map(c => c.name);
129
- if (v < 1 && !agentCols().includes('heartbeat_at')) db.exec('alter table agents add column heartbeat_at integer'); // 2026-10-17
130
- if (v < 2 && !agentCols().includes('stop_requested_at')) db.exec('alter table agents add column stop_requested_at integer'); // 2026-10-17; the events table is created above
131
- db.exec(`pragma user_version = ${SCHEMA_VERSION}`);
132
- }
133
-
134
- // agents
135
-
136
- export function addAgent(db: DatabaseSync, a: { name: string; tool: string; pid: number; cwd: string; worktree?: string; heartbeat?: boolean }): void {
137
- const now = Date.now();
138
- db.prepare(
139
- `insert into agents (name, tool, pid, cwd, worktree, status, started_at, updated_at, heartbeat_at)
140
- values (?, ?, ?, ?, ?, 'busy', ?, ?, ?)
141
- on conflict(name) do update set tool = excluded.tool, pid = excluded.pid, cwd = excluded.cwd, worktree = excluded.worktree,
142
- status = 'busy', task = null, scope = null, started_at = excluded.started_at, updated_at = excluded.updated_at, heartbeat_at = excluded.heartbeat_at,
143
- stop_requested_at = null`,
144
- ).run(a.name, a.tool, a.pid, a.cwd, a.worktree ?? a.cwd, now, now, a.heartbeat ? now : null);
145
- // a relaunch under the same name must not inherit messages meant for the previous session
146
- db.prepare('delete from messages where recipient = ? and read_at is null').run(a.name);
147
- }
148
-
149
- /** Forget an agent entirely: its claims and every message it sent or received. */
150
- export function removeAgent(db: DatabaseSync, name: string): void {
151
- db.prepare('delete from claims where agent = ?').run(name);
152
- db.prepare('delete from messages where sender = ? or recipient = ?').run(name, name);
153
- db.prepare('delete from agents where name = ?').run(name);
154
- }
155
-
156
- export function setTask(db: DatabaseSync, name: string, task: string, scope: string | null): void {
157
- db.prepare('update agents set task = ?, scope = ?, updated_at = ? where name = ?').run(task, scope, Date.now(), name);
158
- }
159
-
160
- /** Offline is sticky here: a hook that outlives its killed agent must not revive it. Only the wrapper does, through
161
- * addAgent (relaunch) or heartbeat. */
162
- export function setStatus(db: DatabaseSync, name: string, status: Status): void {
163
- db.prepare(`update agents set status = ?, updated_at = ? where name = ? and status != 'offline'`).run(status, Date.now(), name);
164
- }
165
-
166
- export function getAgent(db: DatabaseSync, name: string): Agent | undefined {
167
- return db.prepare('select * from agents where name = ?').get(name) as Agent | undefined;
168
- }
169
-
170
- export function listAgents(db: DatabaseSync): Agent[] {
171
- return db.prepare('select * from agents order by started_at').all() as unknown as Agent[];
172
- }
173
-
174
- function pidAlive(pid: number): boolean {
175
- try { process.kill(pid, 0); return true; } catch (e) {
176
- // EPERM: the process exists but we may not open it (e.g. from inside a sandbox). Only ESRCH means gone.
177
- const code = (e as NodeJS.ErrnoException).code;
178
- if (code !== 'EPERM') log(`pid ${pid} judged dead: ${code}`);
179
- return code === 'EPERM';
180
- }
181
- }
182
-
183
- /** Append a line to chord.db.log next to the database, when CHORD_DB is known. Silent otherwise. */
184
- export function log(line: string): void {
185
- if (!process.env.CHORD_DB) return;
186
- try { fs.appendFileSync(process.env.CHORD_DB + '.log', `${new Date().toISOString()} [${process.env.CHORD_AGENT_ID ?? '-'}/${process.pid}] ${line}
187
- `); } catch { /* best effort */ }
188
- }
189
-
190
- /** The wrapper refreshes this every second; an agent whose heartbeat is this old is dead even if its pid is taken again. */
191
- export const HEARTBEAT_STALE_MS = 30_000;
192
-
193
- /** The wrapper is the one process that knows the agent is alive, so its heartbeat also revives an agent a sweep
194
- * marked offline (a machine asleep for over 30 s looks dead to the first sweep after waking). Claims are gone by
195
- * then; the agent claims again when it next writes. */
196
- export function heartbeat(db: DatabaseSync, name: string): void {
197
- // revived as busy, the safe direction: a wake-up or a stop must not be typed into an agent that was in fact
198
- // working all along; the next Stop, idle report or idle_prompt makes it idle again
199
- db.prepare(`update agents set heartbeat_at = ?, status = case when status = 'offline' then 'busy' else status end where name = ?`).run(Date.now(), name);
200
- }
201
-
202
- /** Mark agents whose process is gone as offline and drop their claims. Returns the names swept. A missing
203
- * process or a stale heartbeat both count: Windows reuses pids quickly, so the pid alone can lie. */
204
- export function sweep(db: DatabaseSync, alive: (pid: number) => boolean = pidAlive, now = Date.now()): string[] {
205
- const online = listAgents(db).filter(a => a.status !== 'offline');
206
- // Every heartbeat stale at once means the machine was asleep, not that everyone died: judge by pid alone then.
207
- // ponytail: a lone dead agent whose pid was reused is therefore missed until a second agent comes online.
208
- const beating = online.filter(a => a.heartbeat_at !== null);
209
- const stale = (a: Agent) => a.heartbeat_at !== null && now - a.heartbeat_at > HEARTBEAT_STALE_MS;
210
- const slept = beating.length > 0 && beating.every(stale);
211
- if (slept) log('sweep: every heartbeat is stale, the machine was probably asleep; judging by pid only');
212
- const dead = online.filter(a => !alive(a.pid) || (!slept && stale(a))).map(a => a.name);
213
- for (const name of dead) {
214
- log(`sweep: ${name} marked offline, claims released`);
215
- setStatus(db, name, 'offline');
216
- releaseAll(db, name, 'swept');
217
- }
218
- return dead;
219
- }
220
-
221
- // claims
222
-
223
- export function addClaim(db: DatabaseSync, c: { agent: string; file: string; intent: string; kind: string; symbols?: string | null }): void {
224
- db.prepare(
225
- `insert into claims (agent, file, intent, kind, symbols, created_at) values (?, ?, ?, ?, ?, ?)
226
- on conflict(agent, file) do update set intent = excluded.intent, kind = excluded.kind, symbols = excluded.symbols`,
227
- ).run(c.agent, c.file, c.intent, c.kind, c.symbols ?? null, Date.now());
228
- addEvent(db, c.agent, 'claim', c.file, `${c.kind}: ${c.intent}`);
229
- }
230
-
231
- export function setAskedAt(db: DatabaseSync, agent: string, file: string, at: number): void {
232
- db.prepare('update claims set asked_at = ? where agent = ? and file = ?').run(at, agent, file);
233
- }
234
-
235
- export function removeClaim(db: DatabaseSync, agent: string, file: string): void {
236
- const gone = db.prepare('delete from claims where agent = ? and file = ?').run(agent, file).changes;
237
- if (gone) addEvent(db, agent, 'release', file, 'released');
238
- }
239
-
240
- /** `why` is what the events log shows: released (by the agent), finish, swept, offline, exit. */
241
- export function releaseAll(db: DatabaseSync, agent: string, why = 'released'): void {
242
- const files = (db.prepare('select file from claims where agent = ? order by file').all(agent) as { file: string }[]).map(r => r.file);
243
- db.prepare('delete from claims where agent = ?').run(agent);
244
- for (const f of files) addEvent(db, agent, 'release', f, why);
245
- }
246
-
247
- export type EventKind = 'claim' | 'release' | 'merge' | 'conflict' | 'merge-failed';
248
- export interface Event { id: number; at: number; agent: string; kind: EventKind; file: string | null; detail: string | null }
249
-
250
- /** Append-only history of claims, releases and merges, for `chord events`. */
251
- export function addEvent(db: DatabaseSync, agent: string, kind: EventKind, file: string | null, detail: string | null): void {
252
- db.prepare('insert into events (at, agent, kind, file, detail) values (?, ?, ?, ?, ?)').run(Date.now(), agent, kind, file, detail);
253
- }
254
-
255
- /** The latest `limit` events (oldest first), for one agent or all. */
256
- export function listEvents(db: DatabaseSync, agent?: string, limit = 50): Event[] {
257
- const rows = agent
258
- ? db.prepare('select * from events where agent = ? order by id desc limit ?').all(agent, limit)
259
- : db.prepare('select * from events order by id desc limit ?').all(limit);
260
- return (rows as unknown as Event[]).reverse();
261
- }
262
-
263
- /** The latest `limit` messages (oldest first), all or those sent or received by one agent. */
264
- export function listMessages(db: DatabaseSync, agent?: string, limit = 50): Message[] {
265
- const rows = agent
266
- ? db.prepare('select * from messages where sender = ? or recipient = ? order by id desc limit ?').all(agent, agent, limit)
267
- : db.prepare('select * from messages order by id desc limit ?').all(limit);
268
- return (rows as unknown as Message[]).reverse();
269
- }
270
-
271
- /** `chord stop`: ask the wrapper to quit the tool once the agent is idle. Returns false for an unknown or offline agent. */
272
- export function requestStop(db: DatabaseSync, name: string): boolean {
273
- return db.prepare(`update agents set stop_requested_at = ? where name = ? and status != 'offline'`).run(Date.now(), name).changes > 0;
274
- }
275
-
276
- export function claimsFor(db: DatabaseSync, file: string): Claim[] {
277
- return db.prepare('select rowid as rid, * from claims where file = ?').all(file) as unknown as Claim[];
278
- }
279
-
280
- export function claimsBy(db: DatabaseSync, agent: string): Claim[] {
281
- return db.prepare('select rowid as rid, * from claims where agent = ? order by file').all(agent) as unknown as Claim[];
282
- }
283
-
284
- export function allClaims(db: DatabaseSync): Claim[] {
285
- return db.prepare('select rowid as rid, * from claims order by file, agent').all() as unknown as Claim[];
286
- }
287
-
288
- // messages
289
-
290
- export function send(db: DatabaseSync, sender: string, recipients: string[], body: string, kind: MessageKind = 'message'): void {
291
- const now = Date.now();
292
- const ins = db.prepare('insert into messages (sender, recipient, kind, body, created_at) values (?, ?, ?, ?, ?)');
293
- for (const r of recipients) ins.run(sender, r, kind, body, now);
294
- }
295
-
296
- /** Roughly what a hook may attach without the tool truncating it (Codex caps additionalContext near 2500 tokens). */
297
- export const DELIVERY_BUDGET_CHARS = 6000;
298
-
299
- /** Unread messages for `recipient`, oldest first, marked read as they are returned. Stops after `budget`
300
- * characters (always at least one message) so a flood is delivered in several rounds instead of truncated. */
301
- export function takeUnread(db: DatabaseSync, recipient: string, budget = DELIVERY_BUDGET_CHARS): Message[] {
302
- // one transaction: parallel tool calls run several PostToolUse hooks at once, and each must get different messages
303
- db.exec('begin immediate');
304
- try {
305
- const rows = db.prepare('select * from messages where recipient = ? and read_at is null order by id').all(recipient) as unknown as Message[];
306
- const out: Message[] = [];
307
- let used = 0;
308
- for (const m of rows) { if (out.length && used + m.body.length > budget) break; out.push(m); used += m.body.length; }
309
- if (out.length) {
310
- 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);
311
- }
312
- db.exec('commit');
313
- return out;
314
- } catch (e) { db.exec('rollback'); throw e; }
315
- }
316
-
317
- /** Whether a message (not a notice) is waiting: only messages wake an idle agent; notices ride along with the next hook. */
318
- export function hasUnread(db: DatabaseSync, recipient: string): boolean {
319
- return !!db.prepare(`select 1 from messages where recipient = ? and read_at is null and kind = 'message' limit 1`).get(recipient);
320
- }
321
-
322
- /** Has `sender` sent `recipient` a real message (not a notice) at or after `since`? */
323
- export function repliedSince(db: DatabaseSync, sender: string, recipient: string, since: number): boolean {
324
- return firstMessageAt(db, sender, recipient, since) !== null;
325
- }
326
-
327
- /** When `sender` first messaged `recipient` at or after `since`; null if never. Notices do not count. */
328
- export function firstMessageAt(db: DatabaseSync, sender: string, recipient: string, since: number): number | null {
329
- 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) as { t: number | null };
330
- return row?.t ?? null;
331
- }