chord-cli 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,7 @@
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
8
  <img alt="Windows and Linux" src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux-blue">
9
+ <a href="README.zh-CN.md"><img alt="中文说明" src="https://img.shields.io/badge/README-%E4%B8%AD%E6%96%87-2F7CF6"></a>
9
10
  </p>
10
11
 
11
12
  One repository, as many agents as you care to start. Each agent works in its own worktree; a shared board records who holds which file; an agent that wants a file another agent holds asks first and waits for the answer; when the work is committed, one call rebases the branch and fast-forwards main. Idle agents are woken the moment a message arrives, so the agents negotiate among themselves while you keep handing out work in plain language.
@@ -27,6 +28,19 @@ One repository, as many agents as you care to start. Each agent works in its own
27
28
  - Agent-agnostic by design: adding a tool means one launch recipe, not a new coordinator
28
29
  - `chord ls` for the whole picture, `messages` and `events` to replay what happened, `stop` and `clean` to wind down
29
30
 
31
+ ## 📊 Benchmark
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.
34
+
35
+ | chord vs | gpt-6.1-sol | gpt-6-luna |
36
+ |---|---|---|
37
+ | solo (one agent, both features) | 76% vs 74% | 63% vs 51% |
38
+ | coop + git (messages and a shared git remote) | 76% vs 73% | 63% vs 47% |
39
+ | team (a lead hands out the work) | 76% vs 69% | **63% vs 34%** |
40
+ | coop (messages only) | 76% vs 11% | 63% vs 11% |
41
+
42
+ On the stronger model chord keeps pace with the best baselines. On the weaker one it is the only two-agent setup that does not fall below a single agent, and it beats the team setting by 20 points. chord's agents got only the feature text, while the coop + git and team prompts spell out how to split and merge the work.
43
+
30
44
  ## 🤖 Supported agents
31
45
 
32
46
  | agent | status |
@@ -100,13 +114,16 @@ chord stop api # let it finish its turn, then quit
100
114
  chord clean # remove the workspaces and branches of agents that have left
101
115
  ```
102
116
 
103
- `--name` fixes an agent's name, workspace and branch, so launching the same name later returns to the same worktree (add `--resume` or `-c` to pick up the conversation too). Run chord in a repository root and it offers to move the repository into a child directory first.
117
+ `--name` fixes an agent's name, workspace and branch, so launching the same name later returns to the same worktree. `chord resume <agent>` does that and also reopens the agent's own conversation, for Claude and Codex alike. Run chord in a repository root and it offers to move the repository into a child directory first.
118
+
119
+ Agents start in the project folder, the same directory you run plain `claude` or `codex` in, so that stays their project too: one `/resume` list, one CLAUDE.md, one trust answer. Each agent edits only its own worktree under `chord/agents/<name>/`; chord refuses edits (editor tools, apply_patch) to the main checkout and to other agents' worktrees, and tells the agent off after a shell command that ran there.
104
120
 
105
121
  ## 🧰 Commands
106
122
 
107
123
  | command | what it does |
108
124
  |---|---|
109
125
  | `chord <agent> [--name x] [tool args...]` | start an agent: `claude` or `codex` |
126
+ | `chord resume <agent> [tool args...]` | start an agent that has left again, in its own worktree, reopening its own conversation |
110
127
  | `chord ls [--all]` | agents and claims of this project, or of every project |
111
128
  | `chord messages [agent] [-n N]` | message history |
112
129
  | `chord events [agent] [-n N]` | claim, release, merge and conflict history |
@@ -130,7 +147,7 @@ A repository's own Claude configuration (`.claude/settings*.json`, `.mcp.json`)
130
147
  ## 🔌 How chord works with your tools
131
148
 
132
149
  - **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.
133
- - **Trust**: chord records the project's agents folder as trusted in `~/.claude.json` once, so Claude does not ask about each agent's workspace. 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.
150
+ - **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.
134
151
  - **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.
135
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.
136
153
  - **The terminal title** reads `chord <agent> · <task>`, so each window says which agent it is.
@@ -0,0 +1,169 @@
1
+ # chord
2
+
3
+ **把编码 agent 当成一个集群来跑。**
4
+
5
+ <p align="center">
6
+ <a href="LICENSE"><img alt="MIT licence" src="https://img.shields.io/badge/licence-MIT-blue"></a>
7
+ <img alt="Node 24 or later" src="https://img.shields.io/badge/node-%E2%89%A5%2024-339933?logo=nodedotjs&logoColor=white">
8
+ <img alt="Windows and Linux" src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux-blue">
9
+ <a href="README.md"><img alt="English README" src="https://img.shields.io/badge/README-English-2F7CF6"></a>
10
+ </p>
11
+
12
+ 一个仓库,想开几个 agent 就开几个。每个 agent 在自己的 worktree 里干活;一块共享的看板记着谁占着哪个文件;想改别人占着的文件,先问,等对方回话;活干完提交了,一个调用把分支 rebase 到 main 并快进。空闲的 agent 一有消息就被叫醒,所以 agent 之间自己协商,你只管用大白话继续派活。
13
+
14
+ <p align="center">
15
+ <picture>
16
+ <source media="(prefers-color-scheme: dark)" srcset="docs/demo-dark.svg">
17
+ <img alt="两个 agent 各在自己的终端里占用文件,通过消息商量一个共用文件,然后 finish 合进 main;chord ls 显示它们的状态" src="docs/demo-light.svg" width="100%">
18
+ </picture>
19
+ </p>
20
+
21
+ ## ✨ 特点
22
+
23
+ - 一个仓库、N 个 agent、每人一个 worktree:并行干活,互不踩脚
24
+ - 文件级的占用看板:改之前先登记,先占的人先说话,没登记过的写入被 hook 挡回去
25
+ - agent 之间的异步消息:空闲的被叫醒,忙着的在下一次工具调用时听到
26
+ - `finish` 一步到位:rebase、快进 main、释放占用、通知其他人;遇到冲突什么都不动,让 agent 自己 rebase、解决、再 finish,解决不了才来问你
27
+ - 规则由 chord 自己教给 agent,启动时讲一遍,每条 prompt 再提醒一次;你只派任务,不用解释 chord
28
+ - 设计上不绑定某一家 agent:接一个新工具只是加一条启动配方,不用新写协调器
29
+ - `chord ls` 看全局,`messages` 和 `events` 回放发生过什么,`stop` 和 `clean` 收尾
30
+
31
+ ## 📊 基准测试
32
+
33
+ 在 [CooperBench](https://github.com/cooperbench/CooperBench) 上,两个 agent 在同一个仓库里各做一个功能,合并后的结果通过两个功能的测试才算这一对通过。gpt-6-luna 的数字只算两边都做完的那些对。
34
+
35
+ | chord 对比 | gpt-6.1-sol | gpt-6-luna |
36
+ |---|---|---|
37
+ | solo(一个 agent 做两个功能) | 76% vs 74% | 63% vs 51% |
38
+ | coop + git(消息加一个共享的 git 远端) | 76% vs 73% | 63% vs 47% |
39
+ | team(一个主管派活) | 76% vs 69% | **63% vs 34%** |
40
+ | coop(只有消息) | 76% vs 11% | 63% vs 11% |
41
+
42
+ 在强模型上 chord 和最好的基线持平。在弱模型上它是唯一不掉到单 agent 以下的双 agent 方案,比 team 高 20 个点。chord 的 agent 只拿到功能描述,而 coop + git 和 team 的 prompt 里把怎么分工、怎么合并都写明了。
43
+
44
+ ## 🤖 支持的 agent
45
+
46
+ | agent | 状态 |
47
+ |---|---|
48
+ | Claude Code | 支持 |
49
+ | Codex CLI | 支持 |
50
+ | OpenCode | 计划中 |
51
+ | Gemini CLI | 计划中 |
52
+ | Cursor CLI | 计划中 |
53
+ | GitHub Copilot CLI | 计划中 |
54
+ | Qwen Code | 计划中 |
55
+ | DeepSeek Harness | 计划中 |
56
+
57
+ 接一个新 agent 需要它的工具提供三样东西:启动时注入 hook 和 MCP server 的办法、hook 事件的名字和载荷、一个空闲/忙碌信号。
58
+
59
+ ## 🧩 工作原理
60
+
61
+ <p align="center">
62
+ <picture>
63
+ <source media="(prefers-color-scheme: dark)" srcset="docs/architecture-dark.svg">
64
+ <img alt="你把任务交给一群 agent,每人一个 worktree;agent 共享一块占用和消息的看板,做完 finish 进 main" src="docs/architecture-light.svg" width="100%">
65
+ </picture>
66
+ </p>
67
+
68
+ 1. **隔离。** 每个 agent 在自己的分支、自己的 worktree 里干活。主 checkout 只通过 `finish` 变化。
69
+ 2. **协调。** 一块共享看板存着占用、消息和事件。agent 用 chord 给的工具登记、占用、对话;hook 在每次写入前查占用,并在它干活时把新消息递过去。
70
+ 3. **合并。** `finish` 把分支 rebase 到 main 再快进。rebase 干净就合进去;其他任何情况都停在原地等你。
71
+
72
+ ## 📦 安装
73
+
74
+ 机器上有 Node 24 和 git:
75
+
76
+ ```
77
+ npm install -g chord-cli
78
+ ```
79
+
80
+ Linux 上加 `--allow-scripts=node-pty`,并装好 python3、make 和 g++:node-pty 不带 Linux 的二进制,安装时现场编译。
81
+
82
+ 或者从源码:
83
+
84
+ ```
85
+ git clone https://github.com/Cedr1cSun/chord-cli && cd chord-cli
86
+ npm install && npm link
87
+ ```
88
+
89
+ ## 🚀 快速开始
90
+
91
+ 一个项目是一个文件夹,仓库放在它的子目录里:
92
+
93
+ ```
94
+ my-project/
95
+ my-repo/ 主 checkout
96
+ chord/ chord 自己的文件,在仓库之外
97
+ ```
98
+
99
+ 在项目文件夹里,一个终端开一个 agent:
100
+
101
+ ```
102
+ chord claude # 开一个 agent
103
+ chord codex --name api # 再开一个,起个名字
104
+ chord claude "fix the flaky test in tests/auth" # 工具名后面的参数原样交给工具
105
+ ```
106
+
107
+ 在这些终端里像平时一样派任务。另开一个终端:
108
+
109
+ ```
110
+ chord ls # agent、各自在做什么、未读消息、分支状态、文件占用
111
+ chord messages api # 某个 agent 说过什么、收到过什么
112
+ chord events # 占用、释放、合并、冲突
113
+ chord stop api # 让它做完这一轮再退出
114
+ chord clean # 删掉已经离开的 agent 的工作区和分支
115
+ ```
116
+
117
+ `--name` 固定 agent 的名字、工作区和分支,以后用同一个名字启动就回到同一个 worktree。`chord resume <agent>` 也是这样,还会接着打开这个 agent 自己的对话,Claude 和 Codex 都一样。在仓库根目录里跑 chord,它会先提议把仓库移进一个子目录。
118
+
119
+ agent 在项目文件夹里启动,也就是你平时直接敲 `claude` 或 `codex` 的那个目录,所以那仍然是它们的项目:同一份 `/resume` 列表,同一个 CLAUDE.md,同一次信任确认。每个 agent 只改 `chord/agents/<名字>/` 下自己的 worktree;chord 拒绝对主 checkout 和其他 agent 的 worktree 的编辑(编辑器工具、apply_patch),shell 命令在那里跑过之后也会说一声。
120
+
121
+ ## 🧰 命令
122
+
123
+ | 命令 | 作用 |
124
+ |---|---|
125
+ | `chord <agent> [--name x] [tool args...]` | 开一个 agent:`claude` 或 `codex` |
126
+ | `chord resume <agent> [tool args...]` | 把离开的 agent 再开起来,回到它的 worktree,接着它自己的对话 |
127
+ | `chord ls [--all]` | 本项目的 agent 和占用,或所有项目的 |
128
+ | `chord messages [agent] [-n N]` | 消息记录 |
129
+ | `chord events [agent] [-n N]` | 占用、释放、合并、冲突的记录 |
130
+ | `chord stop <agent>` | 等 agent 这一轮结束、输入框为空时退出它 |
131
+ | `chord clean [--force] [agent...]` | 删掉离线 agent 的工作区、worktree 和分支;还没到 main 上的工作留着,除非 `--force` |
132
+
133
+ ## ⚙️ 配置
134
+
135
+ `<项目>/chord/config.json`:
136
+
137
+ ```json
138
+ { "setup": "npm ci", "test": "npm test" }
139
+ ```
140
+
141
+ `setup` 在每个新建的 worktree 里、agent 启动前跑一次,环境里带着 `CHORD_*` 变量,这样新 worktree 一来就装好了依赖。setup 失败则放弃这次启动并删掉 worktree。
142
+
143
+ `test` 设置了的话,每次 `finish` 把分支 rebase 到 main 之后、main 移动之前,在 agent 的 worktree 里跑一遍。失败就什么都不合,agent 拿到输出的末尾:rebase 干净也可能弄坏别人刚合进去的东西。它跑的时候其他 agent 的 `finish` 等着。
144
+
145
+ 仓库自带的 Claude 配置(`.claude/settings*.json`、`.mcp.json`)在你自己在 Claude Code 里信任过这个仓库之后才会到达 agent;你在那里批准过的 MCP server 才会启动。
146
+
147
+ ## 🔌 chord 怎么和你的工具相处
148
+
149
+ - **叫醒 agent** 就是往它的终端里敲一句短话。chord 只在输入框为空、键盘安静了几秒、终端回显了这句话之后才这么做;对话框永远收不到那个回车。
150
+ - **信任**:agent 在项目文件夹里跑,所以 Claude 像平时敲 `claude` 一样只问一次;chord 不往 `~/.claude.json` 里写任何东西。Codex 会让你审核一次 chord 的 hook,hook 定义变了会再问一次;选 "trust all"。有 hook 被关掉时 chord 会在启动时告诉你。
151
+ - **分支和 worktree。** 每个 agent 添一个 `chord/<名字>` 分支和一个 worktree;`chord clean` 在 agent 离开后删掉它们,还没到 main 上的东西留着。
152
+ - **权限。** chord 自己的工具对 Claude 预先放行,所以独自干活的 agent 不会卡在它们的确认框上;你平时的 shell 和编辑审批照旧。Codex 以完全访问运行。Windows 上 chord 拒绝带沙箱启动,因为那个沙箱起不来 hook;Linux 上 hook 在沙箱里运行,但 Codex 会让你逐个批准 chord 的工具调用。
153
+ - **终端标题**是 `chord <agent> · <任务>`,每个窗口都说得出自己是谁。
154
+ - **hook 把关的是编辑器工具。** 从 shell 改的文件在每条命令之后按名字报出来,并附上该做的占用。
155
+
156
+ ## 🗺️ 范围
157
+
158
+ - 在 Windows 11 和 Linux 上运行。
159
+ - 每个项目一个仓库。
160
+ - 项目路径最长约 190 个字符。
161
+ - 仓库里有 CRLF 文件和一条从未重新规范化过的 `* text` 属性时,每个 worktree 都会出现假改动;chord 会报出文件名和把仓库修好的命令。
162
+
163
+ ## 🧪 测试
164
+
165
+ `npm test` 在临时文件夹里的真实 git 仓库上跑单元测试。协调本身用真实工具做过端到端验证,从两个 agent 商量一个文件到二十个 agent 合进同一个 main,还有 Python、Rust 和 JavaScript 的开源仓库。
166
+
167
+ ## 📄 许可
168
+
169
+ [MIT](LICENSE)
package/dist/clean.js CHANGED
@@ -9,12 +9,15 @@ const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8',
9
9
  * main, or whose worktree has uncommitted work, is kept unless `force`; an online agent is always kept. Directories
10
10
  * under chord/agents/ with no database row (a launch that died before the tool started) are treated the same way. */
11
11
  export function clean(project, d, names = [], force = false) {
12
- db.sweep(d);
12
+ db.sweep(d, undefined, undefined, 2500); // as in ls: a dead wrapper with a reused pid must not count as online
13
13
  const main = git(project.repo, 'rev-parse', '--abbrev-ref', 'HEAD');
14
14
  const agents = db.listAgents(d).filter(a => (names.length ? names.includes(a.name) : a.status === 'offline'));
15
15
  const result = { removed: [], kept: [] };
16
16
  const kept = (name, why) => result.kept.push({ name, why });
17
+ const agentsDir = path.join(project.dir, 'chord', 'agents');
17
18
  const remove = (a, hasRow) => {
19
+ // by name, never by the stored paths: a row pointing elsewhere must not take that folder or worktree with it
20
+ const dir = path.join(agentsDir, a.name), wt = path.join(dir, project.repoName);
18
21
  try {
19
22
  if (a.status !== 'offline') {
20
23
  kept(a.name, 'still online');
@@ -31,37 +34,37 @@ export function clean(project, d, names = [], force = false) {
31
34
  merged = false;
32
35
  }
33
36
  if (!merged) {
34
- kept(a.name, `${branch} has commits that are not on ${main}; use --force to discard them`);
37
+ kept(a.name, `unmerged commits on ${branch}`);
35
38
  return;
36
39
  }
37
40
  }
38
41
  // work that was never committed (an agent stopped half-way) is just as much work as an unmerged commit
39
- if (!force && fs.existsSync(path.join(a.worktree, '.git'))) {
42
+ if (!force && fs.existsSync(path.join(wt, '.git'))) {
40
43
  let dirty = '';
41
44
  try {
42
- dirty = git(a.worktree, 'status', '--porcelain');
45
+ dirty = git(wt, 'status', '--porcelain');
43
46
  }
44
47
  catch { /* a broken worktree holds nothing to keep */ }
45
48
  if (dirty) {
46
- kept(a.name, `${a.worktree} has uncommitted changes; use --force to discard them`);
49
+ kept(a.name, `uncommitted changes in ${path.relative(project.dir, wt).split(path.sep).join('/')}`);
47
50
  return;
48
51
  }
49
52
  }
50
53
  // The directory goes first. On Windows a directory some process is using (a shell left open in it) cannot be
51
54
  // removed; then the branch and the database row stay too, so the next clean sees the agent whole.
52
- if (fs.existsSync(a.worktree)) {
55
+ if (fs.existsSync(wt)) {
53
56
  try {
54
- git(project.repo, 'worktree', 'remove', '--force', a.worktree);
57
+ git(project.repo, 'worktree', 'remove', '--force', wt);
55
58
  }
56
59
  catch { /* fall through to rm */ }
57
60
  }
58
61
  try {
59
- fs.rmSync(a.cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
62
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
60
63
  }
61
64
  catch { /* checked below */ }
62
65
  git(project.repo, 'worktree', 'prune');
63
- if (fs.existsSync(a.cwd)) {
64
- kept(a.name, `${a.cwd} could not be removed, probably because a process is using it; close it and run clean again`);
66
+ if (fs.existsSync(dir)) {
67
+ kept(a.name, `${dir} could not be removed, probably because a process is using it; close it and run clean again`);
65
68
  return;
66
69
  }
67
70
  if (hasBranch)
@@ -80,7 +83,6 @@ export function clean(project, d, names = [], force = false) {
80
83
  for (const a of agents)
81
84
  remove(a, true);
82
85
  // directories with no row: a launch that died before the tool registered, or a database that was deleted
83
- const agentsDir = path.join(project.dir, 'chord', 'agents');
84
86
  const known = new Set(db.listAgents(d).map(a => a.name.toLowerCase()));
85
87
  let orphans = [];
86
88
  try {
@@ -88,6 +90,6 @@ export function clean(project, d, names = [], force = false) {
88
90
  }
89
91
  catch { /* no agents directory yet */ }
90
92
  for (const n of orphans)
91
- remove({ name: n, status: 'offline', cwd: path.join(agentsDir, n), worktree: path.join(agentsDir, n, project.repoName) }, false);
93
+ remove({ name: n, status: 'offline' }, false);
92
94
  return result;
93
95
  }
package/dist/cli.js CHANGED
@@ -1,10 +1,5 @@
1
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
2
+ // The chord command line; see USAGE.
8
3
  import { execFileSync } from 'node:child_process';
9
4
  import fs from 'node:fs';
10
5
  import path from 'node:path';
@@ -15,8 +10,23 @@ import * as db from "./db.js";
15
10
  import { mergeUserSettings, prepare } from "./launch.js";
16
11
  import { NeedsRestructure, knownProjects, resolveProject, restructure, restructureBlocker, validateRepoName } from "./project.js";
17
12
  import { resolveCommand, run } from "./pty.js";
18
- import { codexFirstStart, disabledCodexHooks, pretrustClaude } from "./trust.js";
19
- 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';
13
+ import { codexFirstStart, disabledCodexHooks } from "./trust.js";
14
+ const USAGE = [
15
+ 'usage: chord <command> [arguments]',
16
+ '',
17
+ ' chord claude [--name <agent>] ... start a Claude Code agent here',
18
+ ' chord codex [--name <agent>] ... start a Codex agent here',
19
+ " chord resume <agent> ... reopen an agent's conversation",
20
+ ' chord ls [--all] agents and claims (--all: every project)',
21
+ ' chord messages [agent] [-n N] message history, latest N',
22
+ ' chord events [agent] [-n N] claims, releases, merges, conflicts',
23
+ ' chord stop <agent> quit an agent once it is idle',
24
+ " chord clean [--force] [agent...] remove offline agents' worktrees",
25
+ ' chord --version',
26
+ '',
27
+ ' ... stands for arguments passed on to the tool.',
28
+ '',
29
+ ].join('\n');
20
30
  const gitOut = (cwd, ...args) => {
21
31
  try {
22
32
  return execFileSync('git', args, { cwd, encoding: 'utf8', env: db.gitEnv(), stdio: ['ignore', 'pipe', 'ignore'] }).trim();
@@ -30,29 +40,65 @@ export function ago(ms) {
30
40
  const s = Math.max(0, Math.round(ms / 1000));
31
41
  return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`;
32
42
  }
33
- /** Local time of day, with the date in front when it is not today. */
43
+ /** Local time of day; another day reads "Oct 17 23:39" (a numeric date differs between the UK and the US), another
44
+ * year adds the year. */
34
45
  export function when(t, now = Date.now()) {
35
- const d = new Date(t), pad = (n) => String(n).padStart(2, '0');
46
+ const d = new Date(t), n = new Date(now), pad = (x) => String(x).padStart(2, '0');
36
47
  const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
37
- return d.toDateString() === new Date(now).toDateString() ? time : `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`;
48
+ if (d.toDateString() === n.toDateString())
49
+ return time;
50
+ return `${d.toDateString().slice(4, 10)}${d.getFullYear() === n.getFullYear() ? '' : ` ${d.getFullYear()}`} ${time.slice(0, 5)}`;
38
51
  }
39
- /** Agents (with unread messages, time since the last sign of life, commits on their branch not yet on main) and
40
- * claims. `repo` is the main checkout; without it the branch column shows "?". */
41
- export function ls(d, repo, now = Date.now()) {
42
- db.sweep(d);
52
+ // East Asian wide and fullwidth ranges, plus emoji: two terminal cells each
53
+ const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u;
54
+ /** Terminal cells `s` takes. */
55
+ export function width(s) {
56
+ let n = 0;
57
+ for (const ch of s)
58
+ n += WIDE.test(ch) ? 2 : 1;
59
+ return n;
60
+ }
61
+ /** `s` cut to `max` cells with an ASCII "..." (the ellipsis character is ambiguous-width). */
62
+ export function fit(s, max) {
63
+ if (width(s) <= max)
64
+ return s;
65
+ let out = '', n = 0;
66
+ for (const ch of s) {
67
+ const w = WIDE.test(ch) ? 2 : 1;
68
+ if (n + w > max - 3)
69
+ break;
70
+ out += ch;
71
+ n += w;
72
+ }
73
+ return out + '...';
74
+ }
75
+ /** Agents (status, unread messages, commits on their branch not yet on main, task), each followed by its claims. `repo`
76
+ * is the main checkout; without it the branch column shows "?". `cols` is the terminal width: lines are cut to fit. */
77
+ export function ls(d, repo, now = Date.now(), cols = Infinity) {
78
+ db.sweep(d, undefined, undefined, 2500); // a short watch, so a killed wrapper whose pid was reused is not shown online for ever
43
79
  const agents = db.listAgents(d);
44
- const claims = db.allClaims(d);
45
80
  const main = repo ? gitOut(repo, 'rev-parse', '--abbrev-ref', 'HEAD') : undefined;
46
81
  const ahead = (name) => { if (!repo || !main)
47
82
  return '?'; const n = gitOut(repo, 'rev-list', '--count', `${main}..chord/${name}`); return n === undefined ? '-' : `+${n}`; };
48
- const unread = (name) => d.prepare('select count(*) c from messages where recipient = ? and read_at is null').get(name).c;
49
- const row = (a) => [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';
83
+ // messages only: a merge notice never wakes an agent, so it is not waiting on anyone
84
+ const unread = (name) => d.prepare("select count(*) c from messages where recipient = ? and read_at is null and kind = 'message'").get(name).c;
85
+ const label = (a) => (a.name.startsWith(`${a.tool}-`) ? a.name : `${a.name} (${a.tool})`);
86
+ const nameW = Math.max(5, ...agents.map(a => label(a).length));
87
+ // a live agent's heartbeat is always seconds old, so the age only means something once it is offline
88
+ const state = (a) => (a.status === 'offline' ? `off ${ago(now - (a.heartbeat_at ?? a.updated_at))}` : a.status);
89
+ const row = (a) => {
90
+ const head = [label(a).padEnd(nameW), state(a).padEnd(8), String(unread(a.name)).padEnd(6), ahead(a.name).padEnd(6), ''].join(' ');
91
+ return head + fit(`${a.task ?? '-'}${a.scope ? ` [${a.scope}]` : ''}`, cols - head.length);
92
+ };
93
+ const header = ['agent'.padEnd(nameW), 'status'.padEnd(8), 'unread', 'branch', 'task'].join(' ');
94
+ // each agent's claims under its row; a file held by several agents says so on each of their lines
95
+ const also = (c) => { const o = db.claimsFor(d, c.file).filter(x => x.agent !== c.agent).map(x => x.agent); return o.length ? ` (also ${o.join(', ')})` : ''; };
96
+ const claimLines = (a) => db.claimsBy(d, a.name).map(c => fit(` ${c.kind.padEnd(6)} ${c.file} ${c.intent}${also(c)}`, cols));
97
+ if (!agents.length)
98
+ return 'no agents\n';
99
+ return [header, ...agents.flatMap(a => [row(a), ...claimLines(a)])].join('\n') + '\n';
54
100
  }
55
- export function lsAll() {
101
+ export function lsAll(cols = Infinity) {
56
102
  const projects = knownProjects();
57
103
  if (!projects.length)
58
104
  return 'no chord projects on this machine yet\n';
@@ -64,34 +110,66 @@ export function lsAll() {
64
110
  } };
65
111
  return projects.map(p => {
66
112
  try {
67
- return `== ${p.dir ?? p.stateDir}\n${ls(db.openDb(p.dbFile), repoOf(p.dir ?? undefined))}`;
113
+ return `== ${p.dir ?? p.stateDir}\n${ls(db.openDb(p.dbFile), repoOf(p.dir ?? undefined), Date.now(), cols)}`;
68
114
  }
69
115
  catch (e) {
70
116
  return `== ${p.dir ?? p.stateDir}\n(unreadable: ${e.message})\n`;
71
117
  }
72
118
  }).join('\n');
73
119
  }
74
- /** chord messages [agent] [-n N]: the latest messages, oldest first, one line each. */
120
+ /** chord messages [agent] [-n N]: the latest sends, oldest first, one line each. */
75
121
  export function messages(d, agent, limit = 50, now = Date.now()) {
76
122
  const ms = db.listMessages(d, agent, limit);
77
123
  if (!ms.length)
78
124
  return agent ? `no messages to or from ${agent}\n` : 'no messages\n';
79
125
  const oneLine = (s) => s.replace(/\s+/g, ' ').trim();
80
- 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';
126
+ const body = (m) => (m.kind === 'notice' && m.body.endsWith('\n' + db.NOTICE_TAIL) ? m.body.slice(0, -db.NOTICE_TAIL.length - 1) : m.body);
127
+ const to = (m) => (m.n > 3 ? `${m.n} agents` : m.recipient);
128
+ const flag = (m) => (m.unread === 0 ? '' : m.unread === m.n ? ' (unread)' : ` (${m.unread}/${m.n} unread)`);
129
+ return ms.map(m => `${when(m.created_at, now)} ${m.sender} -> ${to(m)}${m.kind !== 'message' ? ` [${m.kind}]` : ''}${flag(m)}: ${oneLine(body(m))}`).join('\n') + '\n';
81
130
  }
82
131
  /** chord events [agent] [-n N]: claims, releases, merges and conflicts, oldest first. */
83
132
  export function events(d, agent, limit = 50, now = Date.now()) {
84
133
  const es = db.listEvents(d, agent, limit);
85
134
  if (!es.length)
86
135
  return agent ? `no events for ${agent}\n` : 'no events\n';
87
- 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';
136
+ const w = Math.max(...es.map(e => e.agent.length));
137
+ return es.map(e => `${when(e.at, now)} ${e.agent.padEnd(w)} ${e.kind.padEnd(8)} ${[e.file, e.detail].filter(Boolean).join(' ')}`).join('\n') + '\n';
138
+ }
139
+ /** What chord says when the tool has exited: how to carry on, or why resume cannot yet. */
140
+ export function quitHint(d, name) {
141
+ const a = db.getAgent(d, name);
142
+ return a?.session_id
143
+ ? `chord: ${name} quit; \`chord resume ${name}\` continues it\n`
144
+ : `chord: ${name} quit before its conversation was recorded; start it again with chord ${a?.tool ?? 'claude'} --name ${name}\n`;
145
+ }
146
+ /** chord clean's report: what went, what stayed and why, and the exact --force that would discard the held work. */
147
+ export function cleanReport(r) {
148
+ const lines = [...r.removed.map(n => `removed ${n}`), ...r.kept.map(k => `kept ${k.name}: ${k.why}`)];
149
+ // named, so the hint never reads as a bare "chord clean --force", which would discard every offline agent's work
150
+ const held = r.kept.filter(k => /^(unmerged|uncommitted)/.test(k.why)).map(k => k.name);
151
+ if (held.length)
152
+ lines.push(`to discard their work: chord clean --force ${held.join(' ')}`);
153
+ return (lines.length ? lines : ['nothing to clean']).join('\n') + '\n';
88
154
  }
89
- /** chord stop <agent>: the wrapper quits the tool once the agent is idle and nothing is typed. */
90
- export function stop(d, name) {
155
+ /** chord stop <agent>: the wrapper quits the tool once the agent is idle and nothing is typed. `watchMs`: see db.sweep;
156
+ * a wrapper that died with its pid reused must be found out here, or the quit is promised and never comes. */
157
+ export function stop(d, name, watchMs = 2500) {
158
+ db.sweep(d, undefined, undefined, watchMs);
91
159
  if (db.requestStop(d, name))
92
160
  return `stop requested: ${name} will quit when its current turn ends and its input box is empty\n`;
93
161
  throw new Error(db.getAgent(d, name) ? `${name} is already offline` : `no such agent: ${name}`);
94
162
  }
163
+ /** chord resume <agent>: the launch of that agent again under its own tool, reopening the conversation its hooks
164
+ * recorded. Every agent runs in the project folder, so the tools' "latest conversation here" could be anyone's. */
165
+ export function resumeArgv(d, name, more) {
166
+ const a = db.getAgent(d, name);
167
+ if (!a)
168
+ throw new Error(`no such agent: ${name}`);
169
+ if (!a.session_id)
170
+ throw new Error(`no recorded conversation for ${name}; start it again with chord ${a.tool} --name ${name}`);
171
+ return [a.tool, '--name', a.name, ...(a.tool === 'codex' ? ['resume', a.session_id] : ['--resume', a.session_id]), ...more];
172
+ }
95
173
  /** [agent] and -n N for the history commands. */
96
174
  export function listArgs(rest) {
97
175
  let agent, limit = 50;
@@ -164,6 +242,20 @@ export function refuseHookKillers(tool, rest) {
164
242
  }
165
243
  }
166
244
  }
245
+ /** `[--force|-f] [agent...]` for chord clean. */
246
+ export function cleanArgs(rest) {
247
+ const names = [];
248
+ let force = false;
249
+ for (const a of rest) {
250
+ if (a === '--force' || a === '-f')
251
+ force = true;
252
+ else if (a.startsWith('-'))
253
+ throw new Error(`unknown option ${a}`);
254
+ else
255
+ names.push(a);
256
+ }
257
+ return { names, force };
258
+ }
167
259
  export function takeName(rest) {
168
260
  for (let i = 0; i < rest.length; i++) {
169
261
  const a = rest[i];
@@ -220,9 +312,30 @@ async function offerRestructure(e) {
220
312
  }
221
313
  async function main(argv) {
222
314
  const [cmd, ...rest] = argv;
315
+ if (cmd === undefined || cmd === 'help' || cmd === '-h' || cmd === '--help') {
316
+ process.stdout.write(USAGE);
317
+ return 0;
318
+ }
319
+ if (cmd === '--version' || cmd === '-V') {
320
+ process.stdout.write(JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version + '\n');
321
+ return 0;
322
+ }
323
+ // argument shapes are checked before the project is looked up, so a typo gets one line, not a project error
324
+ if (cmd === 'stop' && rest.length !== 1) {
325
+ process.stderr.write('usage: chord stop <agent>\n');
326
+ return 2;
327
+ }
328
+ if (cmd === 'resume' && (!rest[0] || rest[0].startsWith('-'))) {
329
+ process.stderr.write('usage: chord resume <agent> [tool args...]\n');
330
+ return 2;
331
+ }
223
332
  if (cmd === 'ls') {
333
+ const bad = rest.find(a => a !== '--all');
334
+ if (bad)
335
+ throw new Error(`unknown argument ${bad}; usage: chord ls [--all]`);
336
+ const cols = process.stdout.isTTY ? process.stdout.columns ?? Infinity : Infinity; // piped output is never cut
224
337
  if (rest.includes('--all')) {
225
- process.stdout.write(lsAll());
338
+ process.stdout.write(lsAll(cols));
226
339
  return 0;
227
340
  }
228
341
  let project;
@@ -239,10 +352,10 @@ async function main(argv) {
239
352
  process.stdout.write(`no agents have run in ${project.dir} yet\n`);
240
353
  return 0;
241
354
  }
242
- process.stdout.write(ls(db.openDb(file), project.repo));
355
+ process.stdout.write(ls(db.openDb(file), project.repo, Date.now(), cols));
243
356
  return 0;
244
357
  }
245
- if (cmd === 'messages' || cmd === 'events' || cmd === 'stop') {
358
+ if (cmd === 'messages' || cmd === 'events' || cmd === 'stop' || cmd === 'resume') {
246
359
  const project = resolveProject(process.cwd());
247
360
  const file = db.dbPath(project.stateDir);
248
361
  if (!fs.existsSync(file)) {
@@ -251,13 +364,11 @@ async function main(argv) {
251
364
  }
252
365
  const d = db.openDb(file);
253
366
  if (cmd === 'stop') {
254
- if (rest.length !== 1) {
255
- process.stderr.write(USAGE);
256
- return 2;
257
- }
258
367
  process.stdout.write(stop(d, rest[0]));
259
368
  return 0;
260
369
  }
370
+ if (cmd === 'resume')
371
+ return main(resumeArgv(d, rest[0], rest.slice(1)));
261
372
  const { agent, limit } = listArgs(rest);
262
373
  process.stdout.write(cmd === 'messages' ? messages(d, agent, limit) : events(d, agent, limit));
263
374
  return 0;
@@ -269,18 +380,12 @@ async function main(argv) {
269
380
  process.stdout.write(`no agents have run in ${project.dir} yet\n`);
270
381
  return 0;
271
382
  }
272
- const force = rest.includes('--force');
273
- const r = clean(project, db.openDb(file), rest.filter(a => a !== '--force'), force);
274
- for (const n of r.removed)
275
- process.stdout.write(`removed ${n}\n`);
276
- for (const k of r.kept)
277
- process.stdout.write(`kept ${k.name}: ${k.why}\n`);
278
- if (!r.removed.length && !r.kept.length)
279
- process.stdout.write('nothing to clean\n');
383
+ const { names, force } = cleanArgs(rest);
384
+ process.stdout.write(cleanReport(clean(project, db.openDb(file), names, force)));
280
385
  return 0;
281
386
  }
282
387
  if (cmd !== 'claude' && cmd !== 'codex') {
283
- process.stderr.write(USAGE);
388
+ process.stderr.write(`chord: unknown command "${cmd}"\n${USAGE}`);
284
389
  return 2;
285
390
  }
286
391
  const name = takeName(rest);
@@ -297,19 +402,9 @@ async function main(argv) {
297
402
  await offerRestructure(e);
298
403
  launch = prepare(cmd, process.cwd(), name);
299
404
  }
300
- process.stderr.write(`chord: agent ${launch.name} in ${launch.cwd}\n`);
405
+ process.stderr.write(`chord: agent ${launch.name}; worktree ${launch.worktree}\n`);
301
406
  if (userSettings)
302
407
  launch.args = mergeUserSettings(launch.args, userSettings);
303
- if (cmd === 'claude') { // otherwise every new workspace asks "do you trust this folder?"; a failure here only brings that dialogue back
304
- // Claude inherits trust from a parent folder, so the project's agents folder is trusted once. One entry per
305
- // workspace was lost whenever two agents started together: each launch, and every running Claude, rewrites the file.
306
- try {
307
- pretrustClaude(path.dirname(launch.cwd));
308
- }
309
- catch (e) {
310
- process.stderr.write(`chord: could not record the agents folder as trusted in ~/.claude.json (${e.message}); Claude may ask you to trust the folder\n`);
311
- }
312
- }
313
408
  if (cmd === 'codex') {
314
409
  const off = disabledCodexHooks();
315
410
  if (off.length)
@@ -317,8 +412,11 @@ async function main(argv) {
317
412
  }
318
413
  const spawned = cmd === 'codex' ? await codexFirstStart() : () => { };
319
414
  const exit = run(cmd, launch, rest);
320
- spawned();
321
- return exit;
415
+ spawned(); // right after the spawn: it releases the Codex first-start lock
416
+ const code = await exit;
417
+ // the tool's own parting line (Codex prints "codex resume <id>") would start it outside chord
418
+ process.stderr.write(quitHint(db.openDb(launch.dbFile), launch.name));
419
+ return code;
322
420
  }
323
421
  // run only as the entry point (argv[1] may be a symlink such as an npm bin link)
324
422
  const isMain = (() => { try {
package/dist/db.js CHANGED
@@ -55,7 +55,8 @@ function setup(db) {
55
55
  started_at integer not null,
56
56
  updated_at integer not null,
57
57
  heartbeat_at integer,
58
- stop_requested_at integer
58
+ stop_requested_at integer,
59
+ session_id text
59
60
  );
60
61
  create table if not exists claims (
61
62
  agent text not null references agents(name),
@@ -89,17 +90,28 @@ function setup(db) {
89
90
  }
90
91
  /** Bump when the schema changes and add a step below; `pragma user_version` records what a database has. A database
91
92
  * from before versioning reads as 0 and gets every step, each of which checks what is actually there. */
92
- const SCHEMA_VERSION = 2;
93
+ const SCHEMA_VERSION = 3;
93
94
  function migrate(db) {
94
- const v = db.prepare('pragma user_version').get().user_version;
95
- if (v >= SCHEMA_VERSION)
96
- return;
97
- const agentCols = () => db.prepare('pragma table_info(agents)').all().map(c => c.name);
98
- if (v < 1 && !agentCols().includes('heartbeat_at'))
99
- db.exec('alter table agents add column heartbeat_at integer'); // 2026-10-17
100
- if (v < 2 && !agentCols().includes('stop_requested_at'))
101
- db.exec('alter table agents add column stop_requested_at integer'); // 2026-10-17; the events table is created above
102
- db.exec(`pragma user_version = ${SCHEMA_VERSION}`);
95
+ // under a write lock: several processes opening an old database together must not each add the same column
96
+ db.exec('begin immediate');
97
+ try {
98
+ const v = db.prepare('pragma user_version').get().user_version;
99
+ if (v < SCHEMA_VERSION) {
100
+ const agentCols = () => db.prepare('pragma table_info(agents)').all().map(c => c.name);
101
+ if (v < 1 && !agentCols().includes('heartbeat_at'))
102
+ db.exec('alter table agents add column heartbeat_at integer'); // 2026-10-17
103
+ if (v < 2 && !agentCols().includes('stop_requested_at'))
104
+ db.exec('alter table agents add column stop_requested_at integer'); // 2026-10-17; the events table is created above
105
+ if (v < 3 && !agentCols().includes('session_id'))
106
+ db.exec('alter table agents add column session_id text'); // 2026-10-25
107
+ db.exec(`pragma user_version = ${SCHEMA_VERSION}`);
108
+ }
109
+ db.exec('commit');
110
+ }
111
+ catch (e) {
112
+ db.exec('rollback');
113
+ throw e;
114
+ }
103
115
  }
104
116
  // agents
105
117
  export function addAgent(db, a) {
@@ -108,7 +120,7 @@ export function addAgent(db, a) {
108
120
  values (?, ?, ?, ?, ?, 'busy', ?, ?, ?)
109
121
  on conflict(name) do update set tool = excluded.tool, pid = excluded.pid, cwd = excluded.cwd, worktree = excluded.worktree,
110
122
  status = 'busy', task = null, scope = null, started_at = excluded.started_at, updated_at = excluded.updated_at, heartbeat_at = excluded.heartbeat_at,
111
- stop_requested_at = null`).run(a.name, a.tool, a.pid, a.cwd, a.worktree ?? a.cwd, now, now, a.heartbeat ? now : null);
123
+ stop_requested_at = null, session_id = case when tool = excluded.tool then session_id end`).run(a.name, a.tool, a.pid, a.cwd, a.worktree ?? a.cwd, now, now, a.heartbeat ? now : null);
112
124
  // a relaunch under the same name must not inherit messages meant for the previous session
113
125
  db.prepare('delete from messages where recipient = ? and read_at is null').run(a.name);
114
126
  }
@@ -118,6 +130,10 @@ export function removeAgent(db, name) {
118
130
  db.prepare('delete from messages where sender = ? or recipient = ?').run(name, name);
119
131
  db.prepare('delete from agents where name = ?').run(name);
120
132
  }
133
+ /** The tool's conversation id, from the hook payloads; a relaunch keeps the old one until the new session reports its own. */
134
+ export function setSession(db, name, id) {
135
+ db.prepare('update agents set session_id = ? where name = ?').run(id, name);
136
+ }
121
137
  export function setTask(db, name, task, scope) {
122
138
  db.prepare('update agents set task = ?, scope = ?, updated_at = ? where name = ?').run(task, scope, Date.now(), name);
123
139
  }
@@ -165,18 +181,33 @@ export function heartbeat(db, name) {
165
181
  // working all along; the next Stop, idle report or idle_prompt makes it idle again
166
182
  db.prepare(`update agents set heartbeat_at = ?, status = case when status = 'offline' then 'busy' else status end where name = ?`).run(Date.now(), name);
167
183
  }
184
+ /** How long after launch an agent may go without a heartbeat before the watch in sweep calls it dead. */
185
+ const STARTUP_GRACE_MS = 10 * 60_000;
186
+ export const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
168
187
  /** Mark agents whose process is gone as offline and drop their claims. Returns the names swept. A missing
169
- * process or a stale heartbeat both count: Windows reuses pids quickly, so the pid alone can lie. */
170
- export function sweep(db, alive = pidAlive, now = Date.now()) {
188
+ * process or a stale heartbeat both count: Windows reuses pids quickly, so the pid alone can lie.
189
+ * `watchMs`: when every heartbeat is stale, wait that long and see whose heartbeat moves (a live wrapper writes every
190
+ * second); the rest are dead. Zero, the default for hooks and tools, judges by pid alone in that case. */
191
+ export function sweep(db, alive = pidAlive, now = Date.now(), watchMs = 0, sleep = sleepSync) {
171
192
  const online = listAgents(db).filter(a => a.status !== 'offline');
172
193
  // Every heartbeat stale at once means the machine was asleep, not that everyone died: judge by pid alone then.
173
- // ponytail: a lone dead agent whose pid was reused is therefore missed until a second agent comes online.
194
+ // ponytail: a lone dead agent whose pid was reused is therefore missed by the hooks until a second agent comes online.
174
195
  const beating = online.filter(a => a.heartbeat_at !== null);
175
196
  const stale = (a) => a.heartbeat_at !== null && now - a.heartbeat_at > HEARTBEAT_STALE_MS;
176
197
  const slept = beating.length > 0 && beating.every(stale);
177
- if (slept)
178
- log('sweep: every heartbeat is stale, the machine was probably asleep; judging by pid only');
179
- const dead = online.filter(a => !alive(a.pid) || (!slept && stale(a))).map(a => a.name);
198
+ let dead;
199
+ if (slept && watchMs > 0) {
200
+ sleep(watchMs);
201
+ const moved = new Set(listAgents(db).filter(a => beating.some(b => b.name === a.name && b.heartbeat_at !== a.heartbeat_at)).map(a => a.name));
202
+ // no heartbeat yet while the setup command or Codex's first-start wait runs: a live pid that started lately is starting
203
+ const starting = (a) => now - a.started_at < STARTUP_GRACE_MS;
204
+ dead = online.filter(a => !alive(a.pid) || (a.heartbeat_at !== null && !moved.has(a.name) && !starting(a))).map(a => a.name);
205
+ }
206
+ else {
207
+ if (slept)
208
+ log('sweep: every heartbeat is stale, the machine was probably asleep; judging by pid only');
209
+ dead = online.filter(a => !alive(a.pid) || (!slept && stale(a))).map(a => a.name);
210
+ }
180
211
  for (const name of dead) {
181
212
  log(`sweep: ${name} marked offline, claims released`);
182
213
  setStatus(db, name, 'offline');
@@ -216,11 +247,14 @@ export function listEvents(db, agent, limit = 50) {
216
247
  : db.prepare('select * from events order by id desc limit ?').all(limit);
217
248
  return rows.reverse();
218
249
  }
219
- /** The latest `limit` messages (oldest first), all or those sent or received by one agent. */
250
+ /** The closing sentence of a merge notice: for the agents, not for `chord messages`. */
251
+ export const NOTICE_TAIL = 'No action is needed: finish() rebases your branch for you when you call it. This is a notification; do not reply.';
252
+ /** The latest `limit` sends (oldest first), all or those sent or received by one agent. A message to several agents
253
+ * is one row per recipient in the table and one send here. */
220
254
  export function listMessages(db, agent, limit = 50) {
221
- const rows = agent
222
- ? db.prepare('select * from messages where sender = ? or recipient = ? order by id desc limit ?').all(agent, agent, limit)
223
- : db.prepare('select * from messages order by id desc limit ?').all(limit);
255
+ const sql = (where) => `select min(id) id, sender, group_concat(recipient, ', ') recipient, kind, body, created_at, max(read_at) read_at,
256
+ count(*) n, count(*) - count(read_at) unread from messages ${where} group by sender, kind, body, created_at order by max(id) desc limit ?`;
257
+ const rows = agent ? db.prepare(sql('where sender = ? or recipient = ?')).all(agent, agent, limit) : db.prepare(sql('')).all(limit);
224
258
  return rows.reverse();
225
259
  }
226
260
  /** `chord stop`: ask the wrapper to quit the tool once the agent is idle. Returns false for an unknown or offline agent. */
package/dist/hooks.js CHANGED
@@ -35,7 +35,18 @@ export function formatMessages(ms) {
35
35
  const MAX_CLAIM_LINES = 60;
36
36
  const MAX_WARNED_FILES = 20;
37
37
  const MAX_INSTRUCTION_CHARS = 8000;
38
- /** The repository's own agent instructions. The agent's cwd is the workspace, so the tools do not load them themselves. */
38
+ /** The agent's worktree as a path from its cwd, the project folder: chord/agents/<name>/<repoName>. */
39
+ const repoDirOf = (ctx) => path.relative(ctx.project ?? ctx.cwd, ctx.worktree).split(path.sep).join('/') || '.';
40
+ /** The same file in the agent's own worktree, for a path that hit the main checkout or another agent's worktree:
41
+ * both are named after the repository, so what follows that directory is the file. */
42
+ function ownCopy(ctx, abs, tool) {
43
+ const parts = path.relative(ctx.project ?? ctx.cwd, abs).split(path.sep), repoName = path.basename(ctx.worktree);
44
+ // P/<repoName>/... is the main checkout; P/chord/agents/<other>/<repoName>/... another agent's worktree
45
+ const tail = parts[0] === repoName ? parts.slice(1) : parts[0] === 'chord' && parts[1] === 'agents' && parts[3] === repoName ? parts.slice(4) : [];
46
+ return tool === 'claude' ? path.join(ctx.worktree, ...tail) : `${repoDirOf(ctx)}/${tail.join('/')}`; // Claude's editor wants absolute paths
47
+ }
48
+ /** The repository's own agent instructions. The agent's cwd is the project folder, which is not a git repository, so
49
+ * Codex does not load the worktree's AGENTS.md and Claude only reads a nested CLAUDE.md once it opens a file there. */
39
50
  function projectInstructions(ctx, repoDir) {
40
51
  const out = [];
41
52
  for (const name of ['AGENTS.md', 'CLAUDE.md']) {
@@ -52,9 +63,9 @@ export function briefing(ctx) {
52
63
  const me = db.getAgent(ctx.db, ctx.agent);
53
64
  const peers = db.listAgents(ctx.db).filter(a => a.name !== ctx.agent && a.status !== 'offline');
54
65
  const claims = db.allClaims(ctx.db);
55
- const repoDir = path.relative(ctx.cwd, ctx.worktree).split(path.sep).join('/') || '.';
66
+ const repoDir = repoDirOf(ctx), repoName = path.basename(ctx.worktree);
56
67
  const lines = [
57
- `You are agent "${ctx.agent}" (${me?.tool ?? 'unknown'}), one of several coding agents working on the same project. Your working directory is 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.`,
68
+ `You are agent "${ctx.agent}" (${me?.tool ?? 'unknown'}), one of several coding agents working on the same project. Your working directory is the project folder. Your own copy of the repository is "${repoDir}", a git worktree on your own branch: edit, build and run git only there. "${repoName}/" in the project folder is the main checkout and other agents have their copies under chord/agents/; never touch those. Name files as "${repoDir}/src/x.ts" or repository-relative "src/x.ts" (claim() accepts both). Coordination goes through the "chord" MCP tools.`,
58
69
  '',
59
70
  'Rules:',
60
71
  '1. First thing: call register(task, scope) to say what you are doing.',
@@ -80,21 +91,27 @@ export function briefing(ctx) {
80
91
  /** Context for the model. Plain stdout also works for Claude Code and `codex exec`, but the Codex TUI
81
92
  * only takes the JSON form, so both events use it. */
82
93
  const context = (hookEventName, additionalContext) => ({ hookSpecificOutput: { hookEventName, additionalContext } });
94
+ /** Both tools put their conversation id in the payload as session_id; `chord resume` reopens that conversation. Taken on
95
+ * every prompt too, because Codex /new and a /resume start a new conversation without a SessionStart hook. */
96
+ const recordSession = (ctx, payload) => { if (typeof payload.session_id === 'string' && payload.session_id)
97
+ db.setSession(ctx.db, ctx.agent, payload.session_id); };
83
98
  export function handleHook(ctx, tool, event, payload) {
84
99
  db.sweep(ctx.db);
85
100
  switch (event) {
86
101
  case 'session-start':
102
+ recordSession(ctx, payload);
87
103
  // The agent now waits for its first prompt: idle, so messages that arrive before the user types still wake it.
88
104
  // Claude Code also fires this after compacting a long conversation, in the middle of a turn: not idle then.
89
105
  if (payload.source !== 'compact')
90
106
  db.setStatus(ctx.db, ctx.agent, 'idle');
91
107
  return context('SessionStart', briefing(ctx));
92
108
  case 'user-prompt-submit': {
109
+ recordSession(ctx, payload);
93
110
  db.setStatus(ctx.db, ctx.agent, 'busy');
94
111
  const ms = db.takeUnread(ctx.db, ctx.agent);
95
112
  // One line of identity on every prompt: a conversation that started without the briefing (Codex /new fires no
96
113
  // session hooks; a resumed conversation may predate chord) still learns what it is and what the rules are.
97
- const reminder = `[chord] You are agent "${ctx.agent}" in a chord project: claim a file with the chord claim tool before editing it, talk to the other agents with chord send, merge with chord finish.`;
114
+ const reminder = `[chord] You are agent "${ctx.agent}" in a chord project: claim a file with the chord claim tool before editing it, talk to the other agents with chord send, merge with chord finish. Your worktree is "${repoDirOf(ctx)}"; edit and run git only there.`;
98
115
  return context('UserPromptSubmit', ms.length
99
116
  ? `${formatMessages(ms)}\n\n[chord] ${ms.length} message(s) above arrived from other agents. Act on them in this turn as well as on the user's request: reply with the chord send tool where a reply is asked for.\n${reminder}`
100
117
  : reminder);
@@ -104,14 +121,16 @@ export function handleHook(ctx, tool, event, payload) {
104
121
  if (!files.length && isEditTool(tool, payload.tool_name)) {
105
122
  return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'chord could not tell which file this call writes, so it is refused; use the editor tool with a plain file path' } };
106
123
  }
107
- // Codex resolves a patch's relative paths against the call's workdir when it gives one; otherwise the workspace
108
- const workdir = tool === 'codex' && typeof payload.tool_input?.workdir === 'string' ? path.resolve(ctx.cwd, payload.tool_input.workdir) : ctx.cwd;
124
+ // Codex resolves a patch's relative paths against the call's workdir when it gives one, else against its own cwd,
125
+ // the project folder (the workspace when no project is known, as in the tests)
126
+ const base = ctx.project ?? ctx.cwd;
127
+ const workdir = tool === 'codex' && typeof payload.tool_input?.workdir === 'string' ? path.resolve(base, payload.tool_input.workdir) : base;
109
128
  for (const f of files) {
110
129
  const abs = path.resolve(workdir, f);
111
130
  if (!insideDir(ctx.worktree, abs)) {
112
131
  // the main checkout and the other agents' workspaces are off limits; anything else is not ours to police
113
132
  if (ctx.project && insideDir(ctx.project, abs) && !insideDir(ctx.cwd, abs)) {
114
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `${f} is outside your workspace: edit files only inside your own worktree ${ctx.worktree}` } };
133
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `${f} is outside your workspace (the main checkout or another agent's worktree); edit your own copy ${ownCopy(ctx, abs, tool)} instead` } };
115
134
  }
116
135
  continue;
117
136
  }
@@ -228,6 +247,23 @@ export function shellWarnings(ctx, payload) {
228
247
  if (unclaimed.length)
229
248
  out.push(`[chord] The shell command changed files you have not claimed: ${shown.join(', ')}${unclaimed.length > shown.length ? ` and ${unclaimed.length - shown.length} more` : ''}. Claim them now with claim(), or revert the change.`);
230
249
  const cmd = typeof payload.tool_input?.command === 'string' ? payload.tool_input.command : '';
250
+ // From the project folder, "<repo>/" is the main checkout: a command that ran there (Codex's workdir), cd'd there or
251
+ // named a path there did not touch the agent's own copy. A heuristic on the command text, so a warning, not a gate.
252
+ if (ctx.project) {
253
+ const repoName = path.basename(ctx.worktree);
254
+ const workdir = typeof payload.tool_input?.workdir === 'string' ? path.resolve(ctx.project, payload.tool_input.workdir) : null;
255
+ const inMain = workdir !== null && insideDir(ctx.project, workdir) && !insideDir(ctx.cwd, workdir);
256
+ const words = cmd.split(/\s+/);
257
+ const namesMain = words.some((w, i) => {
258
+ if (/chord[\\/]agents[\\/]/.test(w))
259
+ return false;
260
+ const stripped = w.replace(/^["'(]+/, '').replace(/^\.[\\/]/, '');
261
+ const head = stripped.split(/[\\/]/)[0].replace(/["';&|)]+$/, '');
262
+ return head === repoName && (/[\\/]/.test(stripped) || words[i - 1] === 'cd');
263
+ });
264
+ if (inMain || namesMain)
265
+ out.push(`[chord] That command ran in the main checkout (${repoName}/) or named a path there; your copy is ${repoDirOf(ctx)}. Build, test and run git only there.`);
266
+ }
231
267
  if (/\bgit\b[^|;&\n]*\bstash\b/.test(cmd))
232
268
  out.push("[chord] git stash is shared by every worktree of this repository: a stash pop can hand you another agent's changes, and yours to them. Make a work-in-progress commit instead.");
233
269
  if (root && path.resolve(root) !== path.resolve(ctx.worktree)) {
package/dist/launch.js CHANGED
@@ -16,7 +16,6 @@ const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8',
16
16
  const here = (f) => fs.realpathSync.native(fileURLToPath(new URL(f + path.extname(fileURLToPath(import.meta.url)), import.meta.url))).split(path.sep).join('/');
17
17
  export const HOOKS_TS = here('./hooks');
18
18
  export const SERVE_TS = here('./serve');
19
- const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
20
19
  /** `staleWaitMs`: how long to watch a stale heartbeat before calling its agent dead (tests shorten it). */
21
20
  export function pickName(d, tool, project, override, staleWaitMs = 2500) {
22
21
  if (override) {
@@ -36,7 +35,7 @@ export function pickName(d, tool, project, override, staleWaitMs = 2500) {
36
35
  // (a live one writes every second), and this launch itself proves the machine is awake.
37
36
  const stale = clash.heartbeat_at !== null && Date.now() - clash.heartbeat_at > db.HEARTBEAT_STALE_MS;
38
37
  if (stale)
39
- sleepSync(staleWaitMs);
38
+ db.sleepSync(staleWaitMs);
40
39
  const again = stale ? db.getAgent(d, clash.name) : clash;
41
40
  if (!stale || again?.heartbeat_at !== clash.heartbeat_at)
42
41
  throw new Error(`agent "${override}" is already online`);
@@ -211,8 +210,9 @@ export function claudeArgs(env, projectConfig = false) {
211
210
  // chord's own tools are pre-approved: in the default permission mode every claim, send and finish would otherwise
212
211
  // stop at a prompt, and an unattended agent would never get past the first one
213
212
  const ours = { hooks, permissions: { allow: ['mcp__chord'] } };
214
- // The agent's cwd is its workspace, not the repository, so Claude Code would not pick up the repository's
215
- // own CLAUDE.md, .claude/settings*.json or .mcp.json. --add-dir covers CLAUDE.md; the others are passed along.
213
+ // The agent's cwd is the project folder, not the repository, so Claude Code would not pick up the repository's
214
+ // skills, commands and agents under .claude/, nor its settings*.json or .mcp.json. --add-dir covers the first group
215
+ // (the briefing carries CLAUDE.md itself); the others are passed along.
216
216
  const wt = env.CHORD_WORKTREE;
217
217
  const addDir = wt ? ['--add-dir', wt] : [];
218
218
  const source = projectConfig === true ? { repo: wt ?? '' } : projectConfig || null;
package/dist/pty.js CHANGED
@@ -142,6 +142,15 @@ export function typedEchoed(output, text) {
142
142
  const squash = (s) => stripAnsi(s).replace(/\s+/g, '');
143
143
  return squash(output).includes(squash(text).slice(0, 25));
144
144
  }
145
+ /** The Esc key as the tool reads it: in win32-input-mode (ConPTY turns a bare ESC byte into nothing, waiting for a
146
+ * sequence) a key-down and key-up record; elsewhere the CSI u form the tools ask for (`ESC [ > 5 u`), which a legacy
147
+ * reader ignores harmlessly. Linux form not yet verified against the real tools. */
148
+ export const ESC_INPUT = process.platform === 'win32' ? '\x1b[27;1;27;1;0;1_\x1b[27;1;27;0;0;1_' : '\x1b[27u';
149
+ /** Did the tool answer the exit command with its "background work is running, exit and stop it or stay?" dialogue?
150
+ * Claude Code asks this while a shell the agent started (a monitor it is waiting on, say) still runs. */
151
+ export function exitDeclined(output) {
152
+ return /Backgroundworkisrunning|willstopwhenyouexit/i.test(stripAnsi(output).replace(/\s+/g, ''));
153
+ }
145
154
  /** The terminal title is chord's: "chord <agent> · <task>", so a window or tab can be matched to a `chord ls` row.
146
155
  * The tools set their own titles (Claude Code its session summary, Codex the folder name), so their title
147
156
  * sequences (OSC 0, 1, 2) are removed from the forwarded output; everything else, including Codex's OSC 7501
@@ -169,9 +178,14 @@ export const titleSequence = (title) => `\x1b]0;${title.replace(/[\x00-\x1f\x7f]
169
178
  * stop`), press Enter once more, or nothing. Messages stay in the inbox until a hook takes them, so a lost
170
179
  * keystroke is retried, never dropped. `typed`/`typedAt` say what was last typed and when; `echoed` whether the TUI
171
180
  * showed it; `enters` how many times Enter was pressed for it and `enteredAt` when last. Enter is pressed at most
172
- * twice per typed text, never when the text was not echoed, and nothing new is typed while the last text is fresh. */
181
+ * twice per nudge, never when the text was not echoed, and nothing new is typed while the last text is fresh.
182
+ * `quietFor`: milliseconds since the tool last wrote to the terminal. A Stop hook does not mean the turn is over: a
183
+ * goal (/goal) or another plugin's Stop hook keeps the tool working after it, with the spinner still redrawing, so
184
+ * nothing is typed until the screen has been still for a few seconds.
185
+ * The exit command gets no second Enter: the first one quits the tool, and anything that then appears is a dialogue
186
+ * (Claude Code asks about background tasks it would stop), where Enter would pick the default for the user. */
173
187
  export function nextAction(s, now) {
174
- if (s.status !== 'idle' || !s.gateClear)
188
+ if (s.status !== 'idle' || !s.gateClear || s.quietFor < 3000)
175
189
  return null;
176
190
  const want = s.stop ? 'stop' : s.unread ? 'nudge' : null;
177
191
  if (!want)
@@ -180,7 +194,7 @@ export function nextAction(s, now) {
180
194
  return want; // first try, or the typed text was lost or swallowed
181
195
  if (s.typed !== want)
182
196
  return null; // something else is still in the input box; wait for it to go through or expire
183
- if (!s.echoed || s.enters !== 1)
197
+ if (s.typed === 'stop' || !s.echoed || s.enters !== 1)
184
198
  return null; // swallowed: hands off; never pressed: the user got there first; pressed twice: enough
185
199
  if (now - s.enteredAt > 5_000)
186
200
  return 'enter'; // typed and echoed but not submitted: press Enter once more
@@ -212,15 +226,16 @@ export function run(tool, launch, extraArgs) {
212
226
  if (/^CLAUDECODE/.test(k))
213
227
  delete env[k]; // allow launching from inside a Claude session
214
228
  const term = pty.spawn(file, [...args, ...launch.args, ...extraArgs], {
215
- name: 'xterm-256color', cols: process.stdout.columns || 120, rows: process.stdout.rows || 40, cwd: launch.cwd, env,
229
+ name: 'xterm-256color', cols: process.stdout.columns || 120, rows: process.stdout.rows || 40, cwd: launch.project.dir, env, // the project folder: the tool then treats it as the project (same /resume list, CLAUDE.md, trust)
216
230
  });
217
231
  const d = db.openDb(launch.dbFile);
218
232
  db.addAgent(d, { name: launch.name, tool, pid: term.pid, cwd: launch.cwd, worktree: launch.worktree, heartbeat: true });
219
- let outTail = '', echoBuf = '', awaitingEcho = false;
233
+ let outTail = '', echoBuf = '', awaitingEcho = false, lastOutputAt = Date.now();
220
234
  const titles = new TitleFilter();
221
235
  let title = titleFor(launch.name, null);
222
236
  process.stdout.write(titleSequence(title));
223
237
  term.onData(x => {
238
+ lastOutputAt = Date.now();
224
239
  process.stdout.write(titles.push(x));
225
240
  if (awaitingEcho)
226
241
  echoBuf = (echoBuf + x).slice(-16000);
@@ -258,11 +273,11 @@ export function run(tool, launch, extraArgs) {
258
273
  typedAt = enteredAt = enters = 0;
259
274
  echoed = false;
260
275
  }
261
- const s = { status, unread: db.hasUnread(d, launch.name), stop: !!me?.stop_requested_at, gateClear: gate.clear() && gate.settled(), typed, typedAt, echoed, enters, enteredAt };
276
+ const s = { status, unread: db.hasUnread(d, launch.name), stop: !!me?.stop_requested_at, gateClear: gate.clear() && gate.settled(), quietFor: Date.now() - lastOutputAt, typed, typedAt, echoed, enters, enteredAt };
262
277
  const action = nextAction(s, Date.now());
263
- const state = `${status}/${s.unread}/${s.stop}/${s.gateClear}/${action}`;
278
+ const state = `${status}/${s.unread}/${s.stop}/${s.gateClear}/${s.quietFor >= 3000}/${action}`;
264
279
  if (state !== lastState) {
265
- db.log(`wake loop: status=${status} unread=${s.unread} stop=${s.stop} gateClear=${s.gateClear} action=${action}`);
280
+ db.log(`wake loop: status=${status} unread=${s.unread} stop=${s.stop} gateClear=${s.gateClear} quiet=${s.quietFor >= 3000} action=${action}`);
266
281
  lastState = state;
267
282
  }
268
283
  if (!action)
@@ -297,6 +312,19 @@ export function run(tool, launch, extraArgs) {
297
312
  term.write('\r');
298
313
  enteredAt = Date.now();
299
314
  enters = 1;
315
+ if (action !== 'stop')
316
+ return;
317
+ // Quitting over the agent's background work would kill it, so chord stays (Esc) and tries again once the exit
318
+ // command has expired, by which time the work may be done.
319
+ echoBuf = '';
320
+ awaitingEcho = true;
321
+ setTimeout(() => {
322
+ awaitingEcho = false;
323
+ if (!exitDeclined(echoBuf))
324
+ return;
325
+ db.log('wake loop: the tool asked before quitting because the agent has background work running; staying, will try again later');
326
+ term.write(ESC_INPUT);
327
+ }, 2000);
300
328
  }, 1500);
301
329
  }), 1000);
302
330
  return new Promise(resolve => {
package/dist/serve.js CHANGED
@@ -1,4 +1,5 @@
1
- // Entry point for the per-agent MCP server. Spawned by the coding agent over stdio, in the agent's workspace.
1
+ // Entry point for the per-agent MCP server. Spawned by the coding agent over stdio, in the project folder; the
2
+ // agent's workspace comes from the CHORD_* environment, not from cwd.
2
3
  import { openDb } from "./db.js";
3
4
  import { serve } from "./mcp.js";
4
5
  import { tools } from "./tools.js";
package/dist/tools.js CHANGED
@@ -10,16 +10,20 @@ export function insideDir(dir, file) {
10
10
  }
11
11
  /**
12
12
  * The claim key for a path an agent names: workspace-relative with forward slashes. Absolute paths and
13
- * paths starting with the repository directory are taken as given; any other relative path is read as
14
- * repository-relative (what the schema used to say, and what agents working inside the repo naturally
15
- * write). Paths outside the workspace are refused: chord cannot coordinate them.
13
+ * paths starting with the repository directory are taken as given; a path from the project folder
14
+ * (chord/agents/<name>/<repo>/...) is resolved there; any other relative path is read as
15
+ * repository-relative (what agents working inside the repo naturally write). Paths outside the
16
+ * workspace are refused: chord cannot coordinate them.
16
17
  */
17
18
  export function claimPath(ctx, file) {
18
19
  const repoDir = path.relative(ctx.cwd, ctx.worktree).split(path.sep).join('/');
19
20
  const asGiven = normFile(ctx, file);
20
21
  let abs;
22
+ const fromProject = 'chord/agents/'; // the spelling from the tool's cwd, the project folder; another agent's folder then fails the check below
21
23
  if (path.isAbsolute(file) || !repoDir || repoDir === '.' || /^\.\.(\/|$)/.test(asGiven))
22
24
  abs = path.resolve(ctx.cwd, file);
25
+ else if (ctx.project && asGiven.startsWith(fromProject))
26
+ abs = path.resolve(ctx.project, file);
23
27
  else if (asGiven === repoDir || asGiven.startsWith(repoDir + '/')) {
24
28
  // "rich/console.py" is the workspace path of the repository's console.py, or the repository path of a package
25
29
  // named like the repository (rich/rich/console.py); whichever exists is meant, and both existing is ambiguous
@@ -306,7 +310,7 @@ function finishLocked(ctx) {
306
310
  db.releaseAll(ctx.db, ctx.agent, 'finish');
307
311
  const online = db.listAgents(ctx.db).filter(a => a.name !== ctx.agent && a.status !== 'offline').map(a => a.name);
308
312
  if (online.length)
309
- db.send(ctx.db, ctx.agent, online, `${ctx.agent} merged into ${main}:\n${subjects.map(s => `- ${s}`).join('\n')}\nNo action is needed: finish() rebases your branch for you when you call it. This is a notification; do not reply.`, 'notice');
313
+ db.send(ctx.db, ctx.agent, online, `${ctx.agent} merged into ${main}:\n${subjects.map(s => `- ${s}`).join('\n')}\n${db.NOTICE_TAIL}`, 'notice');
310
314
  return { merged: subjects, into: main, notified: online };
311
315
  }
312
316
  export function tools(ctx) {
@@ -336,7 +340,7 @@ export function tools(ctx) {
336
340
  inputSchema: {
337
341
  type: 'object',
338
342
  properties: {
339
- file: str('The file, as a path from your working directory (e.g. <repo>/src/x.ts) or relative to the repository root (src/x.ts)'),
343
+ file: str('The file, as a path from your working directory (chord/agents/<you>/<repo>/src/x.ts) or relative to the repository root (src/x.ts)'),
340
344
  intent: str('One sentence: what you will change and why'),
341
345
  kind: { type: 'string', enum: ['add', 'modify', 'delete', 'rename'] },
342
346
  symbols: str('Optional: the functions or blocks you will touch, comma-separated. When you and an earlier holder of the file both name them and none are shared, you need not talk first.'),
package/dist/trust.js CHANGED
@@ -1,6 +1,5 @@
1
- // Claude Code asks "do you trust this folder?" once per working directory and records the answer in
2
- // ~/.claude.json. Every agent workspace is a new directory, so without this each launch asks again.
3
- // The workspace only holds a worktree of a repository the user already chose to run chord in.
1
+ // What the user has told Claude Code and Codex to trust, read from their own config files. Agents run in the
2
+ // project folder, which the user trusts (or not) in Claude Code themselves; chord writes no trust of its own.
4
3
  import fs from 'node:fs';
5
4
  import os from 'node:os';
6
5
  import path from 'node:path';
@@ -18,37 +17,6 @@ export function claudeTrusts(dir, file = claudeConfigPath()) {
18
17
  return false;
19
18
  }
20
19
  }
21
- /** Mark `workspace` as trusted for Claude Code. Returns false when Claude Code has no config yet (nothing to do)
22
- * or the entry was already there. Writes atomically; other fields of the file are left untouched. */
23
- export function pretrustClaude(workspace, file = claudeConfigPath()) {
24
- let raw;
25
- try {
26
- raw = fs.readFileSync(file, 'utf8');
27
- }
28
- catch {
29
- return false;
30
- }
31
- let cfg;
32
- try {
33
- cfg = JSON.parse(raw);
34
- }
35
- catch {
36
- return false;
37
- } // not ours to repair
38
- if (typeof cfg !== 'object' || cfg === null)
39
- return false;
40
- const projects = (cfg.projects ??= {});
41
- const key = Object.keys(projects).find(k => norm(k) === norm(workspace)) ?? workspace.replace(/\\/g, '/');
42
- if (projects[key]?.hasTrustDialogAccepted === true)
43
- return false;
44
- projects[key] = projects[key]
45
- ? { ...projects[key], hasTrustDialogAccepted: true } // existing entry: flip the flag, keep everything else
46
- : { allowedTools: [], mcpContextUris: [], mcpServers: {}, enabledMcpjsonServers: [], disabledMcpjsonServers: [], hasTrustDialogAccepted: true, hasClaudeMdExternalIncludesApproved: false, hasClaudeMdExternalIncludesWarningShown: false };
47
- const tmp = `${file}.chord-${process.pid}.tmp`;
48
- fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2));
49
- fs.renameSync(tmp, file);
50
- return true;
51
- }
52
20
  /** Which of a repository's .mcp.json servers the user has approved or rejected in Claude Code, and whether they
53
21
  * approved them all at once. Claude asks per server when it runs in the repository; chord has no prompt, so it only
54
22
  * starts what the user has already approved there. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chord-cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Run several coding agents in one git project, each in its own worktree, with shared file claims and messaging",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -31,7 +31,10 @@
31
31
  "engines": {
32
32
  "node": ">=24"
33
33
  },
34
- "os": ["win32", "linux"],
34
+ "os": [
35
+ "win32",
36
+ "linux"
37
+ ],
35
38
  "scripts": {
36
39
  "build": "tsc -p tsconfig.build.json",
37
40
  "prepare": "tsc -p tsconfig.build.json",