chord-cli 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
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">
8
+ <img alt="Windows, macOS and Linux" src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue">
9
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
 
@@ -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. The gpt-6-luna figures cover the pairs where both sides finished.
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; anything else stops where it is and waits for you.
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 runs with full access. On Windows chord refuses a sandboxed launch, because that sandbox cannot run the hooks; on Linux the hooks run inside the sandbox, but Codex then asks you to approve each chord tool call.
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/README.zh-CN.md CHANGED
@@ -5,7 +5,7 @@
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">
8
+ <img alt="Windows, macOS and Linux" src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue">
9
9
  <a href="README.md"><img alt="English README" src="https://img.shields.io/badge/README-English-2F7CF6"></a>
10
10
  </p>
11
11
 
@@ -21,7 +21,7 @@
21
21
  ## ✨ 特点
22
22
 
23
23
  - 一个仓库、N 个 agent、每人一个 worktree:并行干活,互不踩脚
24
- - 文件级的占用看板:改之前先登记,先占的人先说话,没登记过的写入被 hook 挡回去
24
+ - 文件级的占用看板:改之前先登记,想改别人占着的文件先找占着的人说,没登记过的写入被 hook 挡回去
25
25
  - agent 之间的异步消息:空闲的被叫醒,忙着的在下一次工具调用时听到
26
26
  - `finish` 一步到位:rebase、快进 main、释放占用、通知其他人;遇到冲突什么都不动,让 agent 自己 rebase、解决、再 finish,解决不了才来问你
27
27
  - 规则由 chord 自己教给 agent,启动时讲一遍,每条 prompt 再提醒一次;你只派任务,不用解释 chord
@@ -30,7 +30,7 @@
30
30
 
31
31
  ## 📊 基准测试
32
32
 
33
- 在 [CooperBench](https://github.com/cooperbench/CooperBench) 上,两个 agent 在同一个仓库里各做一个功能,合并后的结果通过两个功能的测试才算这一对通过。gpt-6-luna 的数字只算两边都做完的那些对。
33
+ 在 [CooperBench](https://github.com/cooperbench/CooperBench) 上,两个 agent 在同一个仓库里各做一个功能,合并后的结果通过两个功能的测试才算这一对通过。
34
34
 
35
35
  | chord 对比 | gpt-6.1-sol | gpt-6-luna |
36
36
  |---|---|---|
@@ -67,7 +67,7 @@
67
67
 
68
68
  1. **隔离。** 每个 agent 在自己的分支、自己的 worktree 里干活。主 checkout 只通过 `finish` 变化。
69
69
  2. **协调。** 一块共享看板存着占用、消息和事件。agent 用 chord 给的工具登记、占用、对话;hook 在每次写入前查占用,并在它干活时把新消息递过去。
70
- 3. **合并。** `finish` 把分支 rebase 到 main 再快进。rebase 干净就合进去;其他任何情况都停在原地等你。
70
+ 3. **合并。** `finish` 把分支 rebase 到 main 再快进。rebase 干净、项目的 test 命令通过就合进去;遇到冲突什么都不动,agent 自己解决再 finish。
71
71
 
72
72
  ## 📦 安装
73
73
 
@@ -77,7 +77,7 @@
77
77
  npm install -g chord-cli
78
78
  ```
79
79
 
80
- Linux 上加 `--allow-scripts=node-pty`,并装好 python3、make 和 g++:node-pty 不带 Linux 的二进制,安装时现场编译。
80
+ Linux 上加 `--allow-scripts=node-pty`,并装好 python3、make 和 g++:node-pty 不带 Linux 的二进制,安装时现场编译。macOS 不需要额外步骤:node-pty 自带 macOS 的二进制。
81
81
 
82
82
  或者从源码:
83
83
 
@@ -129,6 +129,7 @@ agent 在项目文件夹里启动,也就是你平时直接敲 `claude` 或 `co
129
129
  | `chord events [agent] [-n N]` | 占用、释放、合并、冲突的记录 |
130
130
  | `chord stop <agent>` | 等 agent 这一轮结束、输入框为空时退出它 |
131
131
  | `chord clean [--force] [agent...]` | 删掉离线 agent 的工作区、worktree 和分支;还没到 main 上的工作留着,除非 `--force` |
132
+ | `chord --version` | 安装的版本 |
132
133
 
133
134
  ## ⚙️ 配置
134
135
 
@@ -142,20 +143,20 @@ agent 在项目文件夹里启动,也就是你平时直接敲 `claude` 或 `co
142
143
 
143
144
  `test` 设置了的话,每次 `finish` 把分支 rebase 到 main 之后、main 移动之前,在 agent 的 worktree 里跑一遍。失败就什么都不合,agent 拿到输出的末尾:rebase 干净也可能弄坏别人刚合进去的东西。它跑的时候其他 agent 的 `finish` 等着。
144
145
 
145
- 仓库自带的 Claude 配置(`.claude/settings*.json`、`.mcp.json`)在你自己在 Claude Code 里信任过这个仓库之后才会到达 agent;你在那里批准过的 MCP server 才会启动。
146
+ 仓库自带的 Claude 配置(`.claude/settings*.json`、`.mcp.json`)在你自己在 Claude Code 里信任过这个仓库或项目文件夹之后才会到达 agent;你在那里批准过的 MCP server 才会启动。
146
147
 
147
148
  ## 🔌 chord 怎么和你的工具相处
148
149
 
149
150
  - **叫醒 agent** 就是往它的终端里敲一句短话。chord 只在输入框为空、键盘安静了几秒、终端回显了这句话之后才这么做;对话框永远收不到那个回车。
150
151
  - **信任**:agent 在项目文件夹里跑,所以 Claude 像平时敲 `claude` 一样只问一次;chord 不往 `~/.claude.json` 里写任何东西。Codex 会让你审核一次 chord 的 hook,hook 定义变了会再问一次;选 "trust all"。有 hook 被关掉时 chord 会在启动时告诉你。
151
152
  - **分支和 worktree。** 每个 agent 添一个 `chord/<名字>` 分支和一个 worktree;`chord clean` 在 agent 离开后删掉它们,还没到 main 上的东西留着。
152
- - **权限。** chord 自己的工具对 Claude 预先放行,所以独自干活的 agent 不会卡在它们的确认框上;你平时的 shell 和编辑审批照旧。Codex 以完全访问运行。Windows 上 chord 拒绝带沙箱启动,因为那个沙箱起不来 hook;Linux 上 hook 在沙箱里运行,但 Codex 会让你逐个批准 chord 的工具调用。
153
+ - **权限。** chord 自己的工具对 Claude 预先放行,所以独自干活的 agent 不会卡在它们的确认框上;你平时的 shell 和编辑审批照旧。Codex 沿用你自己配置里的沙箱和审批模式。Windows 上 chord 拒绝命令行里给出的沙箱,因为那个沙箱起不来 hook;Linux 和 macOS 上沙箱里 hook 照常运行,但 Codex 会让你逐个批准 chord 的工具调用。
153
154
  - **终端标题**是 `chord <agent> · <任务>`,每个窗口都说得出自己是谁。
154
155
  - **hook 把关的是编辑器工具。** 从 shell 改的文件在每条命令之后按名字报出来,并附上该做的占用。
155
156
 
156
157
  ## 🗺️ 范围
157
158
 
158
- - 在 Windows 11 和 Linux 上运行。
159
+ - 在 Windows 11、macOS 和 Linux 上运行。
159
160
  - 每个项目一个仓库。
160
161
  - 项目路径最长约 190 个字符。
161
162
  - 仓库里有 CRLF 文件和一条从未重新规范化过的 `* text` 属性时,每个 worktree 都会出现假改动;chord 会报出文件名和把仓库修好的命令。
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 git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', env: db.gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
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 { /* a broken worktree holds nothing to keep */ }
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 ${path.relative(project.dir, wt).split(path.sep).join('/')}`);
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
- try {
32
- return execFileSync('git', args, { cwd, encoding: 'utf8', env: db.gitEnv(), stdio: ['ignore', 'pipe', 'ignore'] }).trim();
33
- }
34
- catch {
35
- return undefined;
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
- const commonDir = (cwd) => path.resolve(cwd, execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8', env: gitEnv() }).trim());
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
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
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 = 3;
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, worktree, status, started_at, updated_at, heartbeat_at)
120
- values (?, ?, ?, ?, ?, 'busy', ?, ?, ?)
121
- on conflict(name) do update set tool = excluded.tool, pid = excluded.pid, cwd = excluded.cwd, worktree = excluded.worktree,
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, a.worktree ?? a.cwd, now, now, a.heartbeat ? now : null);
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 entirely: its claims and every message it sent or received. */
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 sender = ? or recipient = ?').run(name, name);
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
- fs.appendFileSync(process.env.CHORD_DB + '.log', `${new Date().toISOString()} [${process.env.CHORD_AGENT_ID ?? '-'}/${process.pid}] ${line}
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
- export const DELIVERY_BUDGET_CHARS = 6000;
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);