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 +19 -2
- package/README.zh-CN.md +169 -0
- package/dist/clean.js +14 -12
- package/dist/cli.js +156 -58
- package/dist/db.js +56 -22
- package/dist/hooks.js +43 -7
- package/dist/launch.js +4 -4
- package/dist/pty.js +36 -8
- package/dist/serve.js +2 -1
- package/dist/tools.js +9 -5
- package/dist/trust.js +2 -34
- package/package.json +5 -2
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
|
|
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**:
|
|
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.
|
package/README.zh-CN.md
ADDED
|
@@ -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,
|
|
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(
|
|
42
|
+
if (!force && fs.existsSync(path.join(wt, '.git'))) {
|
|
40
43
|
let dirty = '';
|
|
41
44
|
try {
|
|
42
|
-
dirty = git(
|
|
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,
|
|
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(
|
|
55
|
+
if (fs.existsSync(wt)) {
|
|
53
56
|
try {
|
|
54
|
-
git(project.repo, 'worktree', 'remove', '--force',
|
|
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(
|
|
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(
|
|
64
|
-
kept(a.name, `${
|
|
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'
|
|
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
|
|
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
|
|
19
|
-
const USAGE =
|
|
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
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
273
|
-
|
|
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}
|
|
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
|
-
|
|
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 =
|
|
93
|
+
const SCHEMA_VERSION = 3;
|
|
93
94
|
function migrate(db) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
108
|
-
|
|
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
|
|
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
|
|
215
|
-
//
|
|
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
|
|
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.
|
|
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
|
|
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;
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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')}\
|
|
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 (
|
|
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
|
|
2
|
-
//
|
|
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.
|
|
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": [
|
|
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",
|