chord-cli 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -7
- package/README.zh-CN.md +9 -8
- package/dist/clean.js +8 -4
- package/dist/cli.js +10 -11
- package/dist/db.js +48 -24
- package/dist/hooks.js +66 -60
- package/dist/launch.js +26 -54
- package/dist/mcp.js +3 -1
- package/dist/project.js +6 -7
- package/dist/pty.js +42 -8
- package/dist/serve.js +5 -7
- package/dist/tools.js +89 -40
- package/dist/trust.js +2 -2
- package/package.json +3 -2
package/dist/hooks.js
CHANGED
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import * as db from "./db.js";
|
|
8
8
|
import { patchFiles } from "./patch.js";
|
|
9
|
-
import { canWrite, insideDir, normFile } from "./tools.js";
|
|
9
|
+
import { canWrite, dirtyTracked, insideDir, normFile } from "./tools.js";
|
|
10
10
|
const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
11
11
|
export function filesTouched(tool, payload) {
|
|
12
12
|
const name = String(payload.tool_name ?? '');
|
|
@@ -26,10 +26,10 @@ export function filesTouched(tool, payload) {
|
|
|
26
26
|
return [];
|
|
27
27
|
}
|
|
28
28
|
/** Tools that write files and must therefore name them: a call that names none is refused rather than let through. */
|
|
29
|
-
|
|
29
|
+
const isEditTool = (tool, name) => (tool === 'claude' ? EDIT_TOOLS.has(String(name)) : name === 'apply_patch');
|
|
30
30
|
const isShell = (name) => /^(bash|powershell|shell|exec|exec_command|local_shell)$/i.test(String(name ?? ''));
|
|
31
31
|
const hhmm = (t) => new Date(t).toTimeString().slice(0, 5); // local time
|
|
32
|
-
|
|
32
|
+
function formatMessages(ms) {
|
|
33
33
|
return ms.map(m => `[chord] message from ${m.sender} at ${hhmm(m.created_at)}:\n${m.body}`).join('\n\n');
|
|
34
34
|
}
|
|
35
35
|
const MAX_CLAIM_LINES = 60;
|
|
@@ -45,6 +45,17 @@ function ownCopy(ctx, abs, tool) {
|
|
|
45
45
|
const tail = parts[0] === repoName ? parts.slice(1) : parts[0] === 'chord' && parts[1] === 'agents' && parts[3] === repoName ? parts.slice(4) : [];
|
|
46
46
|
return tool === 'claude' ? path.join(ctx.worktree, ...tail) : `${repoDirOf(ctx)}/${tail.join('/')}`; // Claude's editor wants absolute paths
|
|
47
47
|
}
|
|
48
|
+
/** The path with links resolved, so a write through a junction or symlink is judged by where it lands. The file may
|
|
49
|
+
* not exist yet: the nearest existing ancestor is resolved and the rest appended. */
|
|
50
|
+
function realPath(p) {
|
|
51
|
+
try {
|
|
52
|
+
return fs.realpathSync.native(p);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
const parent = path.dirname(p);
|
|
56
|
+
return parent === p ? p : path.join(realPath(parent), path.basename(p));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
48
59
|
/** The repository's own agent instructions. The agent's cwd is the project folder, which is not a git repository, so
|
|
49
60
|
* Codex does not load the worktree's AGENTS.md and Claude only reads a nested CLAUDE.md once it opens a file there. */
|
|
50
61
|
function projectInstructions(ctx, repoDir) {
|
|
@@ -126,7 +137,7 @@ export function handleHook(ctx, tool, event, payload) {
|
|
|
126
137
|
const base = ctx.project ?? ctx.cwd;
|
|
127
138
|
const workdir = tool === 'codex' && typeof payload.tool_input?.workdir === 'string' ? path.resolve(base, payload.tool_input.workdir) : base;
|
|
128
139
|
for (const f of files) {
|
|
129
|
-
const abs = path.resolve(workdir, f);
|
|
140
|
+
const abs = realPath(path.resolve(workdir, f));
|
|
130
141
|
if (!insideDir(ctx.worktree, abs)) {
|
|
131
142
|
// the main checkout and the other agents' workspaces are off limits; anything else is not ours to police
|
|
132
143
|
if (ctx.project && insideDir(ctx.project, abs) && !insideDir(ctx.cwd, abs)) {
|
|
@@ -149,18 +160,21 @@ export function handleHook(ctx, tool, event, payload) {
|
|
|
149
160
|
db.setStatus(ctx.db, ctx.agent, 'busy');
|
|
150
161
|
const parts = [];
|
|
151
162
|
if (isShell(payload.tool_name))
|
|
152
|
-
parts.push(...shellWarnings(ctx, payload));
|
|
153
|
-
|
|
163
|
+
parts.push(...shellWarnings(ctx, payload), ...commandWarnings(ctx, payload));
|
|
164
|
+
// a Claude Code subagent's tool calls fire this hook too (with its agent_id); context given to it never reaches
|
|
165
|
+
// the main conversation, so the messages wait for a call of the agent's own
|
|
166
|
+
const ms = payload.agent_id ? [] : db.takeUnread(ctx.db, ctx.agent);
|
|
154
167
|
if (ms.length)
|
|
155
168
|
parts.push(`${formatMessages(ms)}\n\n[chord] ${ms.length} message(s) above arrived from other agents while you were working. Deal with them when convenient (reply with the chord send tool where a reply is asked for), then carry on with your task.`);
|
|
156
169
|
return parts.length ? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: parts.join('\n\n') } } : undefined;
|
|
157
170
|
}
|
|
158
171
|
case 'stop': {
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
172
|
+
// only a message is worth another turn; a notice (a merge broadcast) waits for the next hook, as it does for the wake-up
|
|
173
|
+
if (!db.hasUnread(ctx.db, ctx.agent)) {
|
|
174
|
+
db.setStatus(ctx.db, ctx.agent, 'idle');
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
return { decision: 'block', reason: formatMessages(db.takeUnread(ctx.db, ctx.agent)) + '\n\nHandle these messages before stopping.' };
|
|
164
178
|
}
|
|
165
179
|
case 'notification':
|
|
166
180
|
// Claude Code: "idle_prompt" fires about a minute after Claude stopped and nobody typed. It is the only
|
|
@@ -176,30 +190,9 @@ export function handleHook(ctx, tool, event, payload) {
|
|
|
176
190
|
return undefined;
|
|
177
191
|
}
|
|
178
192
|
}
|
|
179
|
-
/** Paths changed according to `git status --porcelain=v1 -z -uall`: entries are NUL-separated, a rename
|
|
180
|
-
* or copy is followed by the original path, and nothing is quoted or folded into directories. */
|
|
181
|
-
export function changedPaths(statusZ) {
|
|
182
|
-
const parts = statusZ.split('\0');
|
|
183
|
-
const out = [];
|
|
184
|
-
for (let i = 0; i < parts.length; i++) {
|
|
185
|
-
const entry = parts[i];
|
|
186
|
-
if (!entry)
|
|
187
|
-
continue;
|
|
188
|
-
out.push(entry.slice(3));
|
|
189
|
-
const xy = entry.slice(0, 2);
|
|
190
|
-
if (/R/.test(xy)) {
|
|
191
|
-
i++;
|
|
192
|
-
if (parts[i])
|
|
193
|
-
out.push(parts[i]);
|
|
194
|
-
} // a rename changes its "from" path too
|
|
195
|
-
else if (/C/.test(xy))
|
|
196
|
-
i++; // a copy leaves its source alone
|
|
197
|
-
}
|
|
198
|
-
return out;
|
|
199
|
-
}
|
|
200
193
|
/** The old names of renames in NUL-separated status output: a rename changes that path too, and diff --name-only
|
|
201
194
|
* lists only the new one. */
|
|
202
|
-
|
|
195
|
+
function renameSources(statusZ) {
|
|
203
196
|
const parts = statusZ.split('\0');
|
|
204
197
|
const out = [];
|
|
205
198
|
for (let i = 0; i < parts.length; i++) {
|
|
@@ -225,30 +218,47 @@ const gitOut = (cwd, ...args) => {
|
|
|
225
218
|
return '';
|
|
226
219
|
}
|
|
227
220
|
};
|
|
228
|
-
/** What a shell command may have done behind chord's back: edits or commits to unclaimed files,
|
|
229
|
-
* changes to the main checkout. */
|
|
230
|
-
|
|
221
|
+
/** What a shell command may have done behind chord's back, according to git: edits or commits to unclaimed files,
|
|
222
|
+
* and changes to the main checkout. */
|
|
223
|
+
function shellWarnings(ctx, payload) {
|
|
231
224
|
const out = [];
|
|
232
225
|
const mine = new Set(db.claimsBy(ctx.db, ctx.agent).map(c => c.file.toLowerCase()));
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
// checked out does not make every shell command cry wolf; untracked files from status, directories folded
|
|
226
|
+
// Everything the worktree differs in from where its branch left main, committed or not: a commit made from the
|
|
227
|
+
// shell is no more a claim than an edit is. Tracked files by one diff against the merge base with the main
|
|
228
|
+
// checkout's HEAD (real changes only, so a repository whose files differ in line endings from the moment they are
|
|
229
|
+
// checked out does not make every shell command cry wolf); untracked files from status, directories folded.
|
|
237
230
|
const status = gitOut(ctx.worktree, 'status', '--porcelain=v1', '-z', '-unormal');
|
|
238
231
|
const untracked = status.split('\0').filter(e => e.startsWith('?? ')).map(e => e.slice(3));
|
|
239
|
-
const modified =
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
const unclaimed = touched.map(f => normFile(ctx, path.join(ctx.worktree, f))).filter(f => !
|
|
232
|
+
const modified = gitOut(ctx.worktree, 'diff', '--name-only', '-z', '--ignore-cr-at-eol', '--merge-base', 'main-worktree/HEAD').split('\0');
|
|
233
|
+
// the main checkout is <project>/<repoName>; asking git costs a subprocess on every shell command
|
|
234
|
+
const root = ctx.project ? path.join(ctx.project, path.basename(ctx.worktree)) : db.mainRoot(ctx.worktree);
|
|
235
|
+
const touched = [...new Set([...modified.filter(Boolean), ...untracked, ...renameSources(status)])];
|
|
236
|
+
// a folded untracked directory counts as claimed when a claim names a file inside it
|
|
237
|
+
const claimed = (f) => mine.has(f) || [...mine].some(m => m.startsWith(f + '/'));
|
|
238
|
+
const unclaimed = touched.map(f => normFile(ctx, path.join(ctx.worktree, f))).filter(f => !claimed(f.toLowerCase()));
|
|
246
239
|
const shown = unclaimed.slice(0, MAX_WARNED_FILES);
|
|
247
240
|
if (unclaimed.length)
|
|
248
241
|
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.`);
|
|
242
|
+
if (path.resolve(root) !== path.resolve(ctx.worktree)) {
|
|
243
|
+
// the same rule finish applies, so what finish will not mind (line endings, submodule insides) is not reported
|
|
244
|
+
const dirty = (() => { try {
|
|
245
|
+
return dirtyTracked(root).split('\n').filter(Boolean);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return [];
|
|
249
|
+
} })();
|
|
250
|
+
if (dirty.length)
|
|
251
|
+
out.push(`[chord] The main checkout (${root}) has uncommitted changes, which blocks everyone's finish(): ${dirty.slice(0, 10).join(', ')}. If your command did this, undo it there; the main branch is only ever changed through finish().`);
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
/** What the command's text alone gives away: it ran in or named the main checkout, or used git stash. Heuristics,
|
|
256
|
+
* so warnings, not gates; no git involved, unlike shellWarnings. */
|
|
257
|
+
export function commandWarnings(ctx, payload) {
|
|
258
|
+
const out = [];
|
|
249
259
|
const cmd = typeof payload.tool_input?.command === 'string' ? payload.tool_input.command : '';
|
|
250
260
|
// 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.
|
|
261
|
+
// named a path there did not touch the agent's own copy.
|
|
252
262
|
if (ctx.project) {
|
|
253
263
|
const repoName = path.basename(ctx.worktree);
|
|
254
264
|
const workdir = typeof payload.tool_input?.workdir === 'string' ? path.resolve(ctx.project, payload.tool_input.workdir) : null;
|
|
@@ -266,11 +276,6 @@ export function shellWarnings(ctx, payload) {
|
|
|
266
276
|
}
|
|
267
277
|
if (/\bgit\b[^|;&\n]*\bstash\b/.test(cmd))
|
|
268
278
|
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.");
|
|
269
|
-
if (root && path.resolve(root) !== path.resolve(ctx.worktree)) {
|
|
270
|
-
const dirty = gitOut(root, 'status', '--porcelain', '--untracked-files=no').split('\n').filter(Boolean).map(l => l.slice(3));
|
|
271
|
-
if (dirty.length)
|
|
272
|
-
out.push(`[chord] The main checkout (${root}) has uncommitted changes, which blocks everyone's finish(): ${dirty.slice(0, 10).join(', ')}. If your command did this, undo it there; the main branch is only ever changed through finish().`);
|
|
273
|
-
}
|
|
274
279
|
return out;
|
|
275
280
|
}
|
|
276
281
|
/** handleHook, but never lets an exception reach the tool: a broken hook must not fail open on a write. */
|
|
@@ -293,8 +298,11 @@ async function main() {
|
|
|
293
298
|
const agent = process.env.CHORD_AGENT_ID;
|
|
294
299
|
const file = process.env.CHORD_DB;
|
|
295
300
|
const tool = process.env.CHORD_TOOL;
|
|
296
|
-
|
|
297
|
-
|
|
301
|
+
// the workspace is fixed per agent and comes from the launch; the tool's own cwd is the project folder, which would
|
|
302
|
+
// make every claim path wrong, so it is never used as a stand-in
|
|
303
|
+
const { CHORD_WORKSPACE: cwd, CHORD_WORKTREE: worktree } = process.env;
|
|
304
|
+
if (!agent || !file || !tool || !cwd || !worktree) {
|
|
305
|
+
process.stderr.write('chord hook: CHORD_AGENT_ID, CHORD_DB, CHORD_TOOL, CHORD_WORKSPACE and CHORD_WORKTREE must be set\n');
|
|
298
306
|
process.exit(0);
|
|
299
307
|
}
|
|
300
308
|
const chunks = [];
|
|
@@ -306,11 +314,9 @@ async function main() {
|
|
|
306
314
|
payload = JSON.parse(raw);
|
|
307
315
|
}
|
|
308
316
|
catch { /* no payload */ }
|
|
309
|
-
// the workspace is fixed per agent; the tool's own cwd moves when the agent runs `cd`, so it is not used as the base
|
|
310
|
-
const cwd = process.env.CHORD_WORKSPACE ?? (typeof payload.cwd === 'string' ? payload.cwd : process.cwd());
|
|
311
317
|
let ctx;
|
|
312
318
|
try {
|
|
313
|
-
ctx = { db: db.openDb(file), agent, cwd, worktree
|
|
319
|
+
ctx = { db: db.openDb(file), agent, cwd, worktree, project: process.env.CHORD_PROJECT };
|
|
314
320
|
}
|
|
315
321
|
catch (e) {
|
|
316
322
|
// the database would not open: a write must still not go through unchecked
|
|
@@ -324,9 +330,9 @@ async function main() {
|
|
|
324
330
|
if (process.env.CHORD_DEBUG_HOOKS)
|
|
325
331
|
db.log(`hook payload ${event}: keys=${Object.keys(payload).join(',')} agent_id=${payload.agent_id ?? '-'} agent_type=${payload.agent_type ?? '-'} source=${payload.source ?? '-'} reason=${payload.reason ?? '-'}`);
|
|
326
332
|
const out = safeHandle(ctx, tool, event, payload);
|
|
327
|
-
db.log(`hook ${event} (${tool}, tool_name=${payload.tool_name ?? '-'}): ${out === undefined ? 'no output' :
|
|
333
|
+
db.log(`hook ${event} (${tool}, tool_name=${payload.tool_name ?? '-'}): ${out === undefined ? 'no output' : `json ${JSON.stringify(out).length} chars`} in ${Date.now() - started}ms`);
|
|
328
334
|
if (out !== undefined)
|
|
329
|
-
process.stdout.write(
|
|
335
|
+
process.stdout.write(JSON.stringify(out));
|
|
330
336
|
}
|
|
331
337
|
const isMain = (() => { try {
|
|
332
338
|
return fs.realpathSync.native(process.argv[1] ?? '') === fs.realpathSync.native(fileURLToPath(new URL(import.meta.url)));
|
package/dist/launch.js
CHANGED
|
@@ -2,20 +2,21 @@
|
|
|
2
2
|
// workspace with its repository worktree, and the per-launch hook/MCP configuration for each
|
|
3
3
|
// tool. Hook command strings must stay identical across launches (Codex persists trust by
|
|
4
4
|
// hashing them), so per-session values go in env.
|
|
5
|
-
import {
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
6
|
import { randomBytes } from 'node:crypto';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import * as db from "./db.js";
|
|
11
11
|
import { ensureStateDir, resolveProject } from "./project.js";
|
|
12
|
+
import { readConfig } from "./tools.js";
|
|
12
13
|
import { claudeMcpChoices, claudeTrusts } from "./trust.js";
|
|
13
|
-
const
|
|
14
|
+
const { git } = db;
|
|
14
15
|
// realpath gives one canonical spelling (drive letter and directory case): Codex persists hook trust by hashing the command string
|
|
15
16
|
// sibling modules by absolute path: .ts when run from src, .js from the built dist
|
|
16
17
|
const here = (f) => fs.realpathSync.native(fileURLToPath(new URL(f + path.extname(fileURLToPath(import.meta.url)), import.meta.url))).split(path.sep).join('/');
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
const HOOKS_TS = here('./hooks');
|
|
19
|
+
const SERVE_TS = here('./serve');
|
|
19
20
|
/** `staleWaitMs`: how long to watch a stale heartbeat before calling its agent dead (tests shorten it). */
|
|
20
21
|
export function pickName(d, tool, project, override, staleWaitMs = 2500) {
|
|
21
22
|
if (override) {
|
|
@@ -162,8 +163,8 @@ export function withNodeOnPath(env) {
|
|
|
162
163
|
return { ...env, [key]: current ? `${dir}${path.delimiter}${current}` : dir };
|
|
163
164
|
}
|
|
164
165
|
/** `$CLAUDE_PROJECT_DIR` in a repository's hook commands and MCP definitions means the repository, which for an agent
|
|
165
|
-
* is its worktree; Claude would point it at the
|
|
166
|
-
|
|
166
|
+
* is its worktree; Claude would point it at the project folder. */
|
|
167
|
+
function substituteProjectDir(value, worktree) {
|
|
167
168
|
const dir = worktree.split(path.sep).join('/');
|
|
168
169
|
const walk = (v) => {
|
|
169
170
|
if (typeof v === 'string')
|
|
@@ -178,7 +179,7 @@ export function substituteProjectDir(value, worktree) {
|
|
|
178
179
|
}
|
|
179
180
|
/** The repository's .mcp.json as an --mcp-config value, reduced to the servers the user approved in Claude Code, or
|
|
180
181
|
* null when there is nothing to pass. */
|
|
181
|
-
|
|
182
|
+
function projectMcpConfig(worktree, source) {
|
|
182
183
|
const file = path.join(worktree, '.mcp.json');
|
|
183
184
|
if (!fs.existsSync(file))
|
|
184
185
|
return null;
|
|
@@ -198,6 +199,10 @@ export function projectMcpConfig(worktree, source) {
|
|
|
198
199
|
process.stderr.write(`chord: not starting the repository's MCP server(s) ${skipped.join(', ')}: run claude in ${source.repo} once and approve them there first\n`);
|
|
199
200
|
return Object.keys(keep).length ? JSON.stringify({ mcpServers: substituteProjectDir(keep, worktree) }) : null;
|
|
200
201
|
}
|
|
202
|
+
/** CLI arguments that inject chord's hooks and MCP server into Claude Code. `projectConfig`: also load the repository's
|
|
203
|
+
* own .claude settings and .mcp.json, only when the user has trusted the repository (or the project folder) in Claude
|
|
204
|
+
* Code, because Claude's trust dialogue is what normally stands between a cloned repository's hooks and MCP servers
|
|
205
|
+
* and running them. */
|
|
201
206
|
export function claudeArgs(env, projectConfig = false) {
|
|
202
207
|
const hooks = {};
|
|
203
208
|
for (const e of EVENTS) {
|
|
@@ -215,10 +220,9 @@ export function claudeArgs(env, projectConfig = false) {
|
|
|
215
220
|
// (the briefing carries CLAUDE.md itself); the others are passed along.
|
|
216
221
|
const wt = env.CHORD_WORKTREE;
|
|
217
222
|
const addDir = wt ? ['--add-dir', wt] : [];
|
|
218
|
-
const
|
|
219
|
-
const projectMcp = source && wt ? projectMcpConfig(wt, source) : null;
|
|
223
|
+
const projectMcp = projectConfig && wt ? projectMcpConfig(wt, projectConfig) : null;
|
|
220
224
|
const mcpFiles = projectMcp ? [projectMcp] : [];
|
|
221
|
-
const settings =
|
|
225
|
+
const settings = projectConfig && wt ? mergeProjectSettings(ours, wt) : ours;
|
|
222
226
|
// --add-dir and --mcp-config take any number of values, so they must not come last: the user's own arguments
|
|
223
227
|
// follow these, and a prompt such as `chord claude "fix the tests"` would be read as another directory.
|
|
224
228
|
// --settings takes exactly one value and closes the list.
|
|
@@ -234,9 +238,7 @@ export function mergeUserSettings(args, user) {
|
|
|
234
238
|
return out;
|
|
235
239
|
}
|
|
236
240
|
const PROJECT_CONFIG_FILES = ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json'];
|
|
237
|
-
|
|
238
|
-
/** chord's settings plus the repository's .claude/settings.json and settings.local.json (later files win,
|
|
239
|
-
* hook lists are concatenated so both chord's and the project's hooks run). */
|
|
241
|
+
const hasProjectConfig = (worktree) => PROJECT_CONFIG_FILES.some(f => fs.existsSync(path.join(worktree, f)));
|
|
240
242
|
/** Hook lists of `b` appended to those of `a`, so both sets run. */
|
|
241
243
|
function concatHooks(a, b) {
|
|
242
244
|
const hooks = { ...(a.hooks ?? {}) };
|
|
@@ -246,7 +248,7 @@ function concatHooks(a, b) {
|
|
|
246
248
|
}
|
|
247
249
|
/** Somebody else's settings underneath chord's: their keys fill in, chord's keys win, hook lists are concatenated,
|
|
248
250
|
* and disableAllHooks is dropped because it would switch chord itself off. */
|
|
249
|
-
|
|
251
|
+
function mergeSettings(ours, theirs) {
|
|
250
252
|
const out = { ...theirs, ...ours, hooks: concatHooks(ours, theirs) };
|
|
251
253
|
if (ours.permissions || theirs.permissions) {
|
|
252
254
|
// permission lists add up, as across Claude's own settings levels; other permission keys follow the usual precedence
|
|
@@ -259,9 +261,9 @@ export function mergeSettings(ours, theirs) {
|
|
|
259
261
|
delete out.disableAllHooks;
|
|
260
262
|
return out;
|
|
261
263
|
}
|
|
264
|
+
/** chord's settings plus the repository's .claude/settings.json and settings.local.json (later files win, hook lists
|
|
265
|
+
* are concatenated so both chord's and the project's hooks run). */
|
|
262
266
|
export function mergeProjectSettings(ours, worktree) {
|
|
263
|
-
if (!worktree)
|
|
264
|
-
return ours;
|
|
265
267
|
let theirs = {};
|
|
266
268
|
for (const name of ['settings.json', 'settings.local.json']) { // settings.local.json wins, as in Claude Code itself
|
|
267
269
|
let one;
|
|
@@ -280,10 +282,10 @@ export function mergeProjectSettings(ours, worktree) {
|
|
|
280
282
|
}
|
|
281
283
|
/** A JSON string literal is also a valid TOML basic string, quotes, backslashes and non-ASCII included. */
|
|
282
284
|
const toml = (s) => JSON.stringify(s);
|
|
283
|
-
/** CLI arguments that inject chord's hooks and MCP server into Codex. Values are TOML.
|
|
284
|
-
|
|
285
|
-
*
|
|
286
|
-
*
|
|
285
|
+
/** CLI arguments that inject chord's hooks and MCP server into Codex. Values are TOML. `writableRoots`: directories
|
|
286
|
+
* Codex's workspace-write sandbox must also be allowed to write: a linked worktree keeps its index and refs under the
|
|
287
|
+
* main checkout's .git. shortcut: the tool now runs in the project folder, which holds that .git, so this may be
|
|
288
|
+
* redundant; drop it once a sandboxed Codex has committed from a worktree without it on Linux or macOS. */
|
|
287
289
|
export function codexArgs(env, writableRoots = []) {
|
|
288
290
|
const args = [];
|
|
289
291
|
if (writableRoots.length)
|
|
@@ -317,8 +319,9 @@ export function prepare(tool, cwd, override) {
|
|
|
317
319
|
const name = pickName(d, tool, project.dir, override);
|
|
318
320
|
const ws = workspacePath(project, name);
|
|
319
321
|
// Take the name now, before the worktree and setup take their time: a second launch under the same name a moment
|
|
320
|
-
// later must find it online.
|
|
321
|
-
|
|
322
|
+
// later must find it online. No heartbeat yet: nobody writes one until the wrapper runs, and setup or Codex's first
|
|
323
|
+
// start can take minutes, so a sweep meanwhile must judge this agent by its (live) pid alone.
|
|
324
|
+
db.addAgent(d, { name, tool, pid: process.pid, cwd: ws });
|
|
322
325
|
try {
|
|
323
326
|
const config = readConfig(project);
|
|
324
327
|
const worktree = ensureWorktree(project.repo, path.join(ws, project.repoName), name);
|
|
@@ -335,7 +338,7 @@ export function prepare(tool, cwd, override) {
|
|
|
335
338
|
if (tool === 'claude' && hasProjectConfig(worktree)) {
|
|
336
339
|
projectConfig = claudeTrusts(project.repo) || claudeTrusts(project.dir) ? { repo: project.repo } : false;
|
|
337
340
|
if (!projectConfig)
|
|
338
|
-
process.stderr.write(`chord: ${project.repo} has its own .claude settings or .mcp.json, but you have not trusted it in Claude Code, so they are not loaded for this agent. Run claude in ${project.repo} once and accept the trust prompt to change that.\n`);
|
|
341
|
+
process.stderr.write(`chord: ${project.repo} has its own .claude settings or .mcp.json, but you have not trusted it or the project folder in Claude Code, so they are not loaded for this agent. Run claude in ${project.repo} once and accept the trust prompt to change that.\n`);
|
|
339
342
|
}
|
|
340
343
|
return { name, project, cwd: ws, worktree, dbFile, env, args: tool === 'claude' ? claudeArgs(env, projectConfig) : codexArgs(env, [path.join(project.repo, '.git')]) };
|
|
341
344
|
}
|
|
@@ -344,37 +347,6 @@ export function prepare(tool, cwd, override) {
|
|
|
344
347
|
throw e;
|
|
345
348
|
}
|
|
346
349
|
}
|
|
347
|
-
/** <project>/chord/config.json. `setup` is a shell command run in every new worktree before the agent starts,
|
|
348
|
-
* for example "npm install". `test` is a shell command finish runs in the worktree after rebasing onto main and
|
|
349
|
-
* before fast-forwarding it, for example "npm test"; when it fails nothing is merged. */
|
|
350
|
-
export function readConfig(project) {
|
|
351
|
-
const file = path.join(project.dir, 'chord', 'config.json');
|
|
352
|
-
let raw;
|
|
353
|
-
try {
|
|
354
|
-
raw = fs.readFileSync(file, 'utf8');
|
|
355
|
-
}
|
|
356
|
-
catch {
|
|
357
|
-
return {};
|
|
358
|
-
}
|
|
359
|
-
let cfg;
|
|
360
|
-
try {
|
|
361
|
-
cfg = JSON.parse(raw);
|
|
362
|
-
}
|
|
363
|
-
catch (e) {
|
|
364
|
-
throw new Error(`${file} is not valid JSON: ${e.message}`);
|
|
365
|
-
}
|
|
366
|
-
if (typeof cfg !== 'object' || cfg === null)
|
|
367
|
-
throw new Error(`${file} must hold a JSON object`);
|
|
368
|
-
const out = {};
|
|
369
|
-
for (const key of ['setup', 'test']) {
|
|
370
|
-
const v = cfg[key];
|
|
371
|
-
if (v !== undefined && typeof v !== 'string')
|
|
372
|
-
throw new Error(`${file}: "${key}" must be a string (a shell command)`);
|
|
373
|
-
if (v)
|
|
374
|
-
out[key] = v;
|
|
375
|
-
}
|
|
376
|
-
return out;
|
|
377
|
-
}
|
|
378
350
|
/** Where `chord clean` and a relaunch look to see whether the setup command has run for this agent. */
|
|
379
351
|
export const setupMarker = (project, name) => path.join(project.stateDir, 'setup-done', name);
|
|
380
352
|
/** A failed setup aborts the launch, and the half-prepared worktree and branch go with it so the next launch starts clean. */
|
package/dist/mcp.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Minimal MCP server over stdio: newline-delimited JSON-RPC 2.0, tools only.
|
|
2
|
+
import fs from 'node:fs';
|
|
2
3
|
import { createInterface } from 'node:readline';
|
|
3
4
|
const PROTOCOL = '2025-06-18';
|
|
5
|
+
const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; // src/ and dist/ both sit under the package root
|
|
4
6
|
/** Handle one JSON-RPC message. Returns the response, or undefined for notifications. */
|
|
5
7
|
export function handle(tools, req) {
|
|
6
8
|
const { id, method, params = {} } = req;
|
|
@@ -10,7 +12,7 @@ export function handle(tools, req) {
|
|
|
10
12
|
const err = (code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
11
13
|
switch (method) {
|
|
12
14
|
case 'initialize':
|
|
13
|
-
return ok({ protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: 'chord', version:
|
|
15
|
+
return ok({ protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: 'chord', version: VERSION } });
|
|
14
16
|
case 'ping':
|
|
15
17
|
return ok({});
|
|
16
18
|
case 'tools/list':
|
package/dist/project.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
// A chord project is the folder `chord` is run in. It contains the git repository as a child
|
|
2
2
|
// directory and a `chord/` directory for agent workspaces; chord's own state lives under ~/.chord.
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { execFileSync } from 'node:child_process';
|
|
5
4
|
import fs from 'node:fs';
|
|
6
5
|
import os from 'node:os';
|
|
7
6
|
import path from 'node:path';
|
|
8
|
-
import {
|
|
7
|
+
import { git } from "./db.js";
|
|
9
8
|
/** Thrown when the folder is a repository root: chord offers to move it into a child directory. */
|
|
10
9
|
export class NeedsRestructure extends Error {
|
|
11
10
|
dir;
|
|
@@ -16,8 +15,9 @@ export class NeedsRestructure extends Error {
|
|
|
16
15
|
this.suggestedName = suggestedName;
|
|
17
16
|
}
|
|
18
17
|
}
|
|
19
|
-
|
|
20
|
-
const
|
|
18
|
+
// Windows and macOS file systems ignore case: two spellings are one folder
|
|
19
|
+
const CASE_INSENSITIVE = process.platform !== 'linux';
|
|
20
|
+
const same = (a, b) => (CASE_INSENSITIVE ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
21
21
|
function repoRootOf(dir) {
|
|
22
22
|
try {
|
|
23
23
|
return fs.realpathSync.native(git(dir, 'rev-parse', '--show-toplevel'));
|
|
@@ -36,10 +36,10 @@ catch {
|
|
|
36
36
|
/** Readable like Claude Code's projects directory (separators and colons become dashes), plus a short hash so
|
|
37
37
|
* that D:\a-b and D:\a\b do not share one state directory. */
|
|
38
38
|
export function encodeProjectPath(dir) {
|
|
39
|
-
const key =
|
|
39
|
+
const key = CASE_INSENSITIVE ? dir.toLowerCase() : dir;
|
|
40
40
|
return `${dir.replace(/[\\/:]/g, '-')}-${createHash('sha1').update(key).digest('hex').slice(0, 6)}`;
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
const stateDirFor = (dir) => path.join(process.env.CHORD_HOME ?? path.join(os.homedir(), '.chord'), 'projects', encodeProjectPath(dir));
|
|
43
43
|
/** A repository directory name chord can work with: a single path segment that is not chord's own directory. */
|
|
44
44
|
export function validateRepoName(name) {
|
|
45
45
|
if (!name.trim())
|
|
@@ -63,7 +63,6 @@ export function suggestedRepoName(repo) {
|
|
|
63
63
|
const own = path.basename(repo);
|
|
64
64
|
return validateRepoName(own) ? 'repo' : own;
|
|
65
65
|
}
|
|
66
|
-
/** The nearest ancestor that already is a chord project, if any. */
|
|
67
66
|
/** Child directories of `dir` that are repository roots. Looked up with stat, so a junction or symlink to a repository
|
|
68
67
|
* counts (a Dirent reports those as links, not directories). */
|
|
69
68
|
const repoChildren = (dir) => fs.readdirSync(dir).filter(n => { const p = path.join(dir, n); try {
|
package/dist/pty.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Run the agent under a pseudo-terminal: pass the user's keyboard through, and when the
|
|
2
2
|
// agent is idle with unread messages, type a nudge in as if the user had.
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { StringDecoder } from 'node:string_decoder';
|
|
6
7
|
import pty from 'node-pty';
|
|
@@ -32,6 +33,27 @@ export function resolveCommand(tool, readFile = (f) => fs.readFileSync(f, 'utf8'
|
|
|
32
33
|
throw new Error(`cannot read the script path from ${hit}`);
|
|
33
34
|
return { file: process.execPath, args: [path.join(path.dirname(hit), m[1])] };
|
|
34
35
|
}
|
|
36
|
+
/** On macOS node-pty starts the tool through its `spawn-helper` binary, which the prebuilt package ships without the
|
|
37
|
+
* execute bit (and npm no longer runs install scripts that might set it), so every launch would fail with
|
|
38
|
+
* "posix_spawnp failed". The bit is set here, or the error says how when the install directory is not writable. */
|
|
39
|
+
export function ensureSpawnHelper(root = path.dirname(createRequire(import.meta.url).resolve('node-pty/package.json'))) {
|
|
40
|
+
for (const dir of ['build/Release', 'build/Debug', `prebuilds/${process.platform}-${process.arch}`]) { // where node-pty looks
|
|
41
|
+
const helper = path.join(root, dir, 'spawn-helper');
|
|
42
|
+
if (!fs.existsSync(helper))
|
|
43
|
+
continue;
|
|
44
|
+
try {
|
|
45
|
+
fs.accessSync(helper, fs.constants.X_OK);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
catch { /* not executable */ }
|
|
49
|
+
try {
|
|
50
|
+
fs.chmodSync(helper, 0o755);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
throw new Error(`${helper} is not executable, so no agent can start (${e.message}); run: chmod +x "${helper}"`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
35
57
|
const ESC_KEY = ''; // a decoded Esc key, kept apart from escape sequences while they are stripped
|
|
36
58
|
const SOFT_NL = ''; // Shift+Enter: a newline inside the draft, not a submit
|
|
37
59
|
/**
|
|
@@ -144,8 +166,8 @@ export function typedEchoed(output, text) {
|
|
|
144
166
|
}
|
|
145
167
|
/** The Esc key as the tool reads it: in win32-input-mode (ConPTY turns a bare ESC byte into nothing, waiting for a
|
|
146
168
|
* 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.
|
|
148
|
-
|
|
169
|
+
* reader ignores harmlessly. Verified against Claude Code on macOS; on Linux not yet. */
|
|
170
|
+
const ESC_INPUT = process.platform === 'win32' ? '\x1b[27;1;27;1;0;1_\x1b[27;1;27;0;0;1_' : '\x1b[27u';
|
|
149
171
|
/** Did the tool answer the exit command with its "background work is running, exit and stop it or stay?" dialogue?
|
|
150
172
|
* Claude Code asks this while a shell the agent started (a monitor it is waiting on, say) still runs. */
|
|
151
173
|
export function exitDeclined(output) {
|
|
@@ -200,14 +222,18 @@ export function nextAction(s, now) {
|
|
|
200
222
|
return 'enter'; // typed and echoed but not submitted: press Enter once more
|
|
201
223
|
return null;
|
|
202
224
|
}
|
|
203
|
-
/** Codex reports its state to the terminal as OSC 7501 ("state=idle" / "state=working"
|
|
225
|
+
/** Codex reports its state to the terminal as OSC 7501 ("state=idle" / "state=working", and "state=done" when a turn
|
|
226
|
+
* ends, as of 0.164); Claude Code does not. */
|
|
204
227
|
export function oscState(chunk) {
|
|
205
|
-
const m = /\x1b\]7501;state=(\w+)/g;
|
|
228
|
+
const m = /\x1b\]7501;state=(\w+)[^\x07\x1b]*(?:\x07|\x1b\\)/g; // complete reports only: "state=id" cut off by a chunk is not "busy"
|
|
206
229
|
let last = null;
|
|
207
230
|
for (const x of chunk.matchAll(m))
|
|
208
|
-
last = x[1] === 'idle' ? 'idle' : 'busy';
|
|
231
|
+
last = x[1] === 'idle' || x[1] === 'done' ? 'idle' : 'busy';
|
|
209
232
|
return last;
|
|
210
233
|
}
|
|
234
|
+
/** An OSC sequence a chunk ends in the middle of, to prepend to the next chunk; '' when the chunk ends cleanly, so a
|
|
235
|
+
* complete report is never read twice. */
|
|
236
|
+
export const oscTail = (chunk) => /\x1b\][^\x07\x1b]*$/.exec(chunk)?.[0] ?? '';
|
|
211
237
|
/** A database call from the wrapper's own callbacks. One that fails (a lock held past busy_timeout while the disk
|
|
212
238
|
* stalls) is logged and dropped: thrown from a timer or a PTY callback it would end the wrapper and the agent in
|
|
213
239
|
* it, and the next tick or terminal report puts the state right anyway. */
|
|
@@ -229,7 +255,10 @@ export function run(tool, launch, extraArgs) {
|
|
|
229
255
|
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)
|
|
230
256
|
});
|
|
231
257
|
const d = db.openDb(launch.dbFile);
|
|
232
|
-
|
|
258
|
+
// prepare registered the agent; the wrapper only takes it over (a fresh registration would drop messages that
|
|
259
|
+
// arrived during setup and a stop requested meanwhile)
|
|
260
|
+
db.setPid(d, launch.name, term.pid);
|
|
261
|
+
db.heartbeat(d, launch.name);
|
|
233
262
|
let outTail = '', echoBuf = '', awaitingEcho = false, lastOutputAt = Date.now();
|
|
234
263
|
const titles = new TitleFilter();
|
|
235
264
|
let title = titleFor(launch.name, null);
|
|
@@ -243,7 +272,7 @@ export function run(tool, launch, extraArgs) {
|
|
|
243
272
|
const state = oscState(outTail + x);
|
|
244
273
|
if (state)
|
|
245
274
|
tolerant('terminal state report', () => db.setStatus(d, launch.name, state));
|
|
246
|
-
outTail = x
|
|
275
|
+
outTail = oscTail(x);
|
|
247
276
|
}
|
|
248
277
|
});
|
|
249
278
|
const gate = new InputGate();
|
|
@@ -258,8 +287,13 @@ export function run(tool, launch, extraArgs) {
|
|
|
258
287
|
const onResize = () => term.resize(process.stdout.columns || 120, process.stdout.rows || 40);
|
|
259
288
|
process.stdout.on('resize', onResize);
|
|
260
289
|
Object.assign(process.env, launch.env); // so the wrapper writes to the same chord.db.log as the hooks
|
|
261
|
-
let typed = null, typedAt = 0, enteredAt = 0, enters = 0, echoed = false, lastState = '';
|
|
290
|
+
let typed = null, typedAt = 0, enteredAt = 0, enters = 0, echoed = false, lastState = '', lastTick = Date.now();
|
|
262
291
|
const timer = setInterval(() => tolerant('wake loop', () => {
|
|
292
|
+
// a tick long overdue means the machine slept: vouch for every wrapper before a hook sweeps the ones not yet awake
|
|
293
|
+
const now = Date.now();
|
|
294
|
+
if (now - lastTick > db.HEARTBEAT_STALE_MS)
|
|
295
|
+
db.heartbeatAll(d);
|
|
296
|
+
lastTick = now;
|
|
263
297
|
db.heartbeat(d, launch.name);
|
|
264
298
|
const me = db.getAgent(d, launch.name);
|
|
265
299
|
const wanted = titleFor(launch.name, me?.task);
|
package/dist/serve.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
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,
|
|
2
|
+
// agent's workspace comes from the CHORD_* environment, never from cwd, which would make every claim path wrong.
|
|
3
3
|
import { openDb } from "./db.js";
|
|
4
4
|
import { serve } from "./mcp.js";
|
|
5
5
|
import { tools } from "./tools.js";
|
|
6
|
-
const agent = process.env
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
process.stderr.write('chord: CHORD_AGENT_ID and CHORD_DB must be set\n');
|
|
6
|
+
const { CHORD_AGENT_ID: agent, CHORD_DB: file, CHORD_WORKSPACE: cwd, CHORD_WORKTREE: worktree } = process.env;
|
|
7
|
+
if (!agent || !file || !cwd || !worktree) {
|
|
8
|
+
process.stderr.write('chord: CHORD_AGENT_ID, CHORD_DB, CHORD_WORKSPACE and CHORD_WORKTREE must be set\n');
|
|
10
9
|
process.exit(2);
|
|
11
10
|
}
|
|
12
|
-
|
|
13
|
-
serve(tools({ db: openDb(file), agent, cwd, worktree: process.env.CHORD_WORKTREE ?? cwd, project: process.env.CHORD_PROJECT }));
|
|
11
|
+
serve(tools({ db: openDb(file), agent, cwd, worktree, project: process.env.CHORD_PROJECT }));
|