chord-cli 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/clean.js +93 -0
- package/dist/cli.js +327 -0
- package/dist/db.js +271 -0
- package/dist/hooks.js +302 -0
- package/dist/launch.js +399 -0
- package/dist/mcp.js +51 -0
- package/dist/patch.js +10 -0
- package/dist/project.js +188 -0
- package/dist/pty.js +304 -0
- package/{src/serve.ts → dist/serve.js} +5 -6
- package/dist/tools.js +376 -0
- package/dist/trust.js +90 -0
- package/package.json +5 -3
- package/src/clean.ts +0 -64
- package/src/cli.ts +0 -234
- package/src/db.ts +0 -331
- package/src/hooks.ts +0 -287
- package/src/launch.ts +0 -356
- package/src/mcp.ts +0 -54
- package/src/patch.ts +0 -9
- package/src/project.ts +0 -152
- package/src/pty.ts +0 -251
- package/src/tools.ts +0 -331
- package/src/trust.ts +0 -71
package/dist/launch.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// Everything `chord <tool>` needs before spawning: the project, an agent name, the agent's
|
|
2
|
+
// workspace with its repository worktree, and the per-launch hook/MCP configuration for each
|
|
3
|
+
// tool. Hook command strings must stay identical across launches (Codex persists trust by
|
|
4
|
+
// hashing them), so per-session values go in env.
|
|
5
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import * as db from "./db.js";
|
|
11
|
+
import { ensureStateDir, resolveProject } from "./project.js";
|
|
12
|
+
import { claudeMcpChoices, claudeTrusts } from "./trust.js";
|
|
13
|
+
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', env: db.gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
14
|
+
// realpath gives one canonical spelling (drive letter and directory case): Codex persists hook trust by hashing the command string
|
|
15
|
+
// sibling modules by absolute path: .ts when run from src, .js from the built dist
|
|
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
|
+
export const HOOKS_TS = here('./hooks');
|
|
18
|
+
export const SERVE_TS = here('./serve');
|
|
19
|
+
const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
20
|
+
/** `staleWaitMs`: how long to watch a stale heartbeat before calling its agent dead (tests shorten it). */
|
|
21
|
+
export function pickName(d, tool, project, override, staleWaitMs = 2500) {
|
|
22
|
+
if (override) {
|
|
23
|
+
if (!/^[A-Za-z0-9._-]+$/.test(override))
|
|
24
|
+
throw new Error('agent names may only contain letters, digits, ".", "_" and "-"');
|
|
25
|
+
// the name becomes a directory and a branch: ".." would escape agents/, git refuses components starting with "."
|
|
26
|
+
// or ending in "." or ".lock", and Windows reserves CON, NUL, COM1 and friends
|
|
27
|
+
if (/^[.-]|\.$|\.\.|\.lock$/i.test(override) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(override))
|
|
28
|
+
throw new Error(`"${override}" cannot be an agent name: no leading "." or "-", no trailing "." or ".lock", no "..", and no Windows reserved names (CON, NUL, COM1...)`);
|
|
29
|
+
// workspace paths are case-insensitive on Windows and macOS, so names differing only in case would share one
|
|
30
|
+
const clash = db.listAgents(d).find(a => a.name.toLowerCase() === override.toLowerCase());
|
|
31
|
+
if (clash && clash.name !== override)
|
|
32
|
+
throw new Error(`agent "${clash.name}" already exists; names must differ in more than case`);
|
|
33
|
+
if (clash && clash.status !== 'offline') {
|
|
34
|
+
// A lone agent killed outright, whose pid Windows has since handed to another process, is never swept: nobody
|
|
35
|
+
// else's heartbeat is fresh to compare with. A heartbeat that stays stale while we watch it is a dead wrapper
|
|
36
|
+
// (a live one writes every second), and this launch itself proves the machine is awake.
|
|
37
|
+
const stale = clash.heartbeat_at !== null && Date.now() - clash.heartbeat_at > db.HEARTBEAT_STALE_MS;
|
|
38
|
+
if (stale)
|
|
39
|
+
sleepSync(staleWaitMs);
|
|
40
|
+
const again = stale ? db.getAgent(d, clash.name) : clash;
|
|
41
|
+
if (!stale || again?.heartbeat_at !== clash.heartbeat_at)
|
|
42
|
+
throw new Error(`agent "${override}" is already online`);
|
|
43
|
+
db.log(`launch: ${override} has not had a heartbeat for ${Math.round((Date.now() - clash.heartbeat_at) / 1000)}s; taking it for dead`);
|
|
44
|
+
db.setStatus(d, clash.name, 'offline');
|
|
45
|
+
db.releaseAll(d, clash.name, 'swept');
|
|
46
|
+
}
|
|
47
|
+
return override;
|
|
48
|
+
}
|
|
49
|
+
// the project name makes the agent recognisable in `chord ls --all`; kept short so the worktree path stays short
|
|
50
|
+
const base = path.basename(project).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 12).replace(/^-+|-+$/g, '') || 'project';
|
|
51
|
+
for (;;) {
|
|
52
|
+
const name = `${tool}-${base}-${randomBytes(2).toString('hex')}`;
|
|
53
|
+
if (!db.getAgent(d, name))
|
|
54
|
+
return name;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** The agent's workspace: <project>/chord/agents/<name>. Its repository worktree sits inside at <repoName>. */
|
|
58
|
+
export const workspacePath = (p, name) => path.join(p.dir, 'chord', 'agents', name);
|
|
59
|
+
/** Create (or reuse) a worktree of `repo` at `wt` on branch chord/<name>, forked from the current main HEAD. */
|
|
60
|
+
export function ensureWorktree(repo, wt, name) {
|
|
61
|
+
const branch = `chord/${name}`;
|
|
62
|
+
if (fs.existsSync(path.join(wt, '.git'))) {
|
|
63
|
+
try {
|
|
64
|
+
git(wt, 'rev-parse', '--git-dir');
|
|
65
|
+
}
|
|
66
|
+
catch { // the link is broken, e.g. the project was moved
|
|
67
|
+
git(repo, 'worktree', 'repair', wt);
|
|
68
|
+
git(wt, 'rev-parse', '--git-dir');
|
|
69
|
+
}
|
|
70
|
+
return onBranch(wt, branch);
|
|
71
|
+
}
|
|
72
|
+
if (fs.existsSync(wt) && fs.readdirSync(wt).length)
|
|
73
|
+
throw new Error(`${wt} exists but is not a git worktree; remove it or pick another --name`);
|
|
74
|
+
fs.mkdirSync(path.dirname(wt), { recursive: true });
|
|
75
|
+
// Every worktree directory is named after the repository, so git gives them ids repo, repo1, repo2... After the
|
|
76
|
+
// project moves, the registrations point at the old place: pruning first would drop them all and let this new
|
|
77
|
+
// worktree take the id "repo" that belonged to another agent, which a later repair of that agent would then share.
|
|
78
|
+
// Repair the siblings first; prune only removes what the user really deleted.
|
|
79
|
+
const siblings = siblingWorktrees(wt);
|
|
80
|
+
if (siblings.length) {
|
|
81
|
+
try {
|
|
82
|
+
git(repo, 'worktree', 'repair', ...siblings);
|
|
83
|
+
}
|
|
84
|
+
catch { /* a sibling beyond repair is pruned below */ }
|
|
85
|
+
}
|
|
86
|
+
git(repo, 'worktree', 'prune');
|
|
87
|
+
const exists = git(repo, 'branch', '--list', branch) !== '';
|
|
88
|
+
try {
|
|
89
|
+
// core.longpaths: a worktree path is some forty characters longer than the main checkout's, which on Windows is
|
|
90
|
+
// enough to push a deep repository past MAX_PATH
|
|
91
|
+
if (exists)
|
|
92
|
+
git(repo, '-c', 'core.longpaths=true', 'worktree', 'add', wt, branch);
|
|
93
|
+
else
|
|
94
|
+
git(repo, '-c', 'core.longpaths=true', 'worktree', 'add', '-b', branch, wt);
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
// nothing half-made stays behind: a stale branch or an empty directory would trip the next launch
|
|
98
|
+
try {
|
|
99
|
+
git(repo, 'worktree', 'prune');
|
|
100
|
+
}
|
|
101
|
+
catch { /* best effort */ }
|
|
102
|
+
if (!exists) {
|
|
103
|
+
try {
|
|
104
|
+
git(repo, 'branch', '-D', branch);
|
|
105
|
+
}
|
|
106
|
+
catch { /* never created */ }
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
fs.rmSync(wt, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
catch { /* best effort */ }
|
|
112
|
+
const msg = (e.stderr ?? e.message).trim();
|
|
113
|
+
if (/too long/i.test(msg))
|
|
114
|
+
throw new Error(`${msg}\n${wt} is too long a path for git here; run "git config --global core.longpaths true", or move the project to a shorter path`);
|
|
115
|
+
throw new Error(`could not create the worktree ${wt}: ${msg}`);
|
|
116
|
+
}
|
|
117
|
+
return onBranch(wt, branch);
|
|
118
|
+
}
|
|
119
|
+
/** The other agents' worktrees beside `wt` (chord/agents/<other>/<repoName>) that still exist on disk. */
|
|
120
|
+
function siblingWorktrees(wt) {
|
|
121
|
+
const agents = path.dirname(path.dirname(wt));
|
|
122
|
+
try {
|
|
123
|
+
return fs.readdirSync(agents)
|
|
124
|
+
.map(n => path.join(agents, n, path.basename(wt)))
|
|
125
|
+
.filter(p => p !== wt && fs.existsSync(path.join(p, '.git')));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** A worktree that is not on its own branch is sharing a registration with another agent: refuse rather than let two
|
|
132
|
+
* agents work on one index. */
|
|
133
|
+
function onBranch(wt, branch) {
|
|
134
|
+
const head = git(wt, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
135
|
+
if (head !== branch)
|
|
136
|
+
throw new Error(`${wt} is on ${head} instead of ${branch}; its git registration seems to be shared with another agent. Remove the directory and launch again`);
|
|
137
|
+
return wt;
|
|
138
|
+
}
|
|
139
|
+
// ctxLimit: Codex caps a hook's additionalContext (default about 2,500 tokens); the briefing and message batches need more
|
|
140
|
+
const EVENTS = [
|
|
141
|
+
{ event: 'session-start', claude: 'SessionStart', codex: 'SessionStart', ctxLimit: 16000 },
|
|
142
|
+
{ event: 'notification', claude: 'Notification', matcher: { claude: 'idle_prompt' } }, // Claude only: Codex reports idle through OSC 7501
|
|
143
|
+
{ event: 'user-prompt-submit', claude: 'UserPromptSubmit', codex: 'UserPromptSubmit', ctxLimit: 16000 },
|
|
144
|
+
{ event: 'pre-tool-use', claude: 'PreToolUse', codex: 'PreToolUse', matcher: { claude: 'Edit|Write|MultiEdit|NotebookEdit' } }, // Codex: every tool, so apply_patch run from the shell is seen too
|
|
145
|
+
{ event: 'post-tool-use', claude: 'PostToolUse', codex: 'PostToolUse', ctxLimit: 16000 }, // every tool: shell-edit warnings and mid-turn message delivery
|
|
146
|
+
{ event: 'stop', claude: 'Stop', codex: 'Stop' },
|
|
147
|
+
{ event: 'session-end', claude: 'SessionEnd', codex: 'SessionEnd', timeout: 10 },
|
|
148
|
+
];
|
|
149
|
+
/** The node that runs chord, by absolute path, for the MCP server (spawned from an argument list, so spaces in the
|
|
150
|
+
* path are no problem). The hook commands say plain `node`: Codex cannot run a command whose executable is quoted
|
|
151
|
+
* ("C:/Program Files/nodejs/node.exe" fails silently, a quoted argument is fine), so instead the wrapper puts this
|
|
152
|
+
* node's directory at the front of the agent's PATH (see withNodeOnPath), which also keeps an older node on the
|
|
153
|
+
* user's PATH (nvm, a project .nvmrc) from being the one that cannot run .ts files. */
|
|
154
|
+
export const NODE = fs.realpathSync.native(process.execPath).split(path.sep).join('/');
|
|
155
|
+
export const hookCommand = (event) => `node "${HOOKS_TS}" ${event}`;
|
|
156
|
+
/** The environment with the directory of the node running chord first on PATH (whatever the variable's case). */
|
|
157
|
+
export function withNodeOnPath(env) {
|
|
158
|
+
const key = Object.keys(env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH';
|
|
159
|
+
const dir = path.dirname(fs.realpathSync.native(process.execPath));
|
|
160
|
+
const current = env[key] ?? '';
|
|
161
|
+
if (current.split(path.delimiter)[0] === dir)
|
|
162
|
+
return env;
|
|
163
|
+
return { ...env, [key]: current ? `${dir}${path.delimiter}${current}` : dir };
|
|
164
|
+
}
|
|
165
|
+
/** `$CLAUDE_PROJECT_DIR` in a repository's hook commands and MCP definitions means the repository, which for an agent
|
|
166
|
+
* is its worktree; Claude would point it at the workspace. */
|
|
167
|
+
export function substituteProjectDir(value, worktree) {
|
|
168
|
+
const dir = worktree.split(path.sep).join('/');
|
|
169
|
+
const walk = (v) => {
|
|
170
|
+
if (typeof v === 'string')
|
|
171
|
+
return v.replace(/\$\{CLAUDE_PROJECT_DIR\}|\$CLAUDE_PROJECT_DIR/g, dir);
|
|
172
|
+
if (Array.isArray(v))
|
|
173
|
+
return v.map(walk);
|
|
174
|
+
if (v && typeof v === 'object')
|
|
175
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
|
|
176
|
+
return v;
|
|
177
|
+
};
|
|
178
|
+
return walk(value);
|
|
179
|
+
}
|
|
180
|
+
/** The repository's .mcp.json as an --mcp-config value, reduced to the servers the user approved in Claude Code, or
|
|
181
|
+
* null when there is nothing to pass. */
|
|
182
|
+
export function projectMcpConfig(worktree, source) {
|
|
183
|
+
const file = path.join(worktree, '.mcp.json');
|
|
184
|
+
if (!fs.existsSync(file))
|
|
185
|
+
return null;
|
|
186
|
+
let parsed;
|
|
187
|
+
try {
|
|
188
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
process.stderr.write(`chord: ${file} is not valid JSON (${e.message}); its servers are not loaded\n`);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const servers = parsed?.mcpServers ?? {};
|
|
195
|
+
const choice = claudeMcpChoices(source.repo, source.claudeConfig);
|
|
196
|
+
const keep = Object.fromEntries(Object.entries(servers).filter(([name]) => !choice.disabled.includes(name) && (choice.all || choice.enabled.includes(name))));
|
|
197
|
+
const skipped = Object.keys(servers).filter(n => !(n in keep));
|
|
198
|
+
if (skipped.length)
|
|
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`);
|
|
200
|
+
return Object.keys(keep).length ? JSON.stringify({ mcpServers: substituteProjectDir(keep, worktree) }) : null;
|
|
201
|
+
}
|
|
202
|
+
export function claudeArgs(env, projectConfig = false) {
|
|
203
|
+
const hooks = {};
|
|
204
|
+
for (const e of EVENTS) {
|
|
205
|
+
hooks[e.claude] = [{
|
|
206
|
+
...(e.matcher ? { matcher: e.matcher.claude } : {}),
|
|
207
|
+
hooks: [{ type: 'command', command: hookCommand(e.event), ...(e.timeout ? { timeout: e.timeout } : {}) }],
|
|
208
|
+
}];
|
|
209
|
+
}
|
|
210
|
+
const mcp = { mcpServers: { chord: { command: NODE, args: [SERVE_TS], env } } };
|
|
211
|
+
// chord's own tools are pre-approved: in the default permission mode every claim, send and finish would otherwise
|
|
212
|
+
// stop at a prompt, and an unattended agent would never get past the first one
|
|
213
|
+
const ours = { hooks, permissions: { allow: ['mcp__chord'] } };
|
|
214
|
+
// The agent's cwd is its workspace, not the repository, so Claude Code would not pick up the repository's
|
|
215
|
+
// own CLAUDE.md, .claude/settings*.json or .mcp.json. --add-dir covers CLAUDE.md; the others are passed along.
|
|
216
|
+
const wt = env.CHORD_WORKTREE;
|
|
217
|
+
const addDir = wt ? ['--add-dir', wt] : [];
|
|
218
|
+
const source = projectConfig === true ? { repo: wt ?? '' } : projectConfig || null;
|
|
219
|
+
const projectMcp = source && wt ? projectMcpConfig(wt, source) : null;
|
|
220
|
+
const mcpFiles = projectMcp ? [projectMcp] : [];
|
|
221
|
+
const settings = source ? mergeProjectSettings(ours, wt) : ours;
|
|
222
|
+
// --add-dir and --mcp-config take any number of values, so they must not come last: the user's own arguments
|
|
223
|
+
// follow these, and a prompt such as `chord claude "fix the tests"` would be read as another directory.
|
|
224
|
+
// --settings takes exactly one value and closes the list.
|
|
225
|
+
return [...addDir, '--mcp-config', JSON.stringify(mcp), ...mcpFiles, '--settings', JSON.stringify(settings)];
|
|
226
|
+
}
|
|
227
|
+
/** The user's own --settings folded under chord's (chord's hooks stay, theirs are added), in place in `args`. */
|
|
228
|
+
export function mergeUserSettings(args, user) {
|
|
229
|
+
const i = args.indexOf('--settings');
|
|
230
|
+
if (i < 0 || i + 1 >= args.length)
|
|
231
|
+
return args;
|
|
232
|
+
const out = [...args];
|
|
233
|
+
out[i + 1] = JSON.stringify(mergeSettings(JSON.parse(args[i + 1]), user));
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
const PROJECT_CONFIG_FILES = ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json'];
|
|
237
|
+
export const hasProjectConfig = (worktree) => PROJECT_CONFIG_FILES.some(f => fs.existsSync(path.join(worktree, f)));
|
|
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). */
|
|
240
|
+
/** Hook lists of `b` appended to those of `a`, so both sets run. */
|
|
241
|
+
function concatHooks(a, b) {
|
|
242
|
+
const hooks = { ...(a.hooks ?? {}) };
|
|
243
|
+
for (const [event, list] of Object.entries(b.hooks ?? {}))
|
|
244
|
+
hooks[event] = [...(hooks[event] ?? []), ...(Array.isArray(list) ? list : [])];
|
|
245
|
+
return hooks;
|
|
246
|
+
}
|
|
247
|
+
/** Somebody else's settings underneath chord's: their keys fill in, chord's keys win, hook lists are concatenated,
|
|
248
|
+
* and disableAllHooks is dropped because it would switch chord itself off. */
|
|
249
|
+
export function mergeSettings(ours, theirs) {
|
|
250
|
+
const out = { ...theirs, ...ours, hooks: concatHooks(ours, theirs) };
|
|
251
|
+
if (ours.permissions || theirs.permissions) {
|
|
252
|
+
// permission lists add up, as across Claude's own settings levels; other permission keys follow the usual precedence
|
|
253
|
+
const lists = (k) => [...new Set([...(ours.permissions?.[k] ?? []), ...(theirs.permissions?.[k] ?? [])])];
|
|
254
|
+
out.permissions = { ...theirs.permissions, ...ours.permissions };
|
|
255
|
+
for (const k of ['allow', 'deny', 'ask'])
|
|
256
|
+
if (lists(k).length)
|
|
257
|
+
out.permissions[k] = lists(k);
|
|
258
|
+
}
|
|
259
|
+
delete out.disableAllHooks;
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
export function mergeProjectSettings(ours, worktree) {
|
|
263
|
+
if (!worktree)
|
|
264
|
+
return ours;
|
|
265
|
+
let theirs = {};
|
|
266
|
+
for (const name of ['settings.json', 'settings.local.json']) { // settings.local.json wins, as in Claude Code itself
|
|
267
|
+
let one;
|
|
268
|
+
try {
|
|
269
|
+
one = JSON.parse(fs.readFileSync(path.join(worktree, '.claude', name), 'utf8'));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (typeof one !== 'object' || one === null)
|
|
275
|
+
continue;
|
|
276
|
+
one = substituteProjectDir(one, worktree);
|
|
277
|
+
theirs = { ...theirs, ...one, hooks: concatHooks(theirs, one) };
|
|
278
|
+
}
|
|
279
|
+
return mergeSettings(ours, theirs);
|
|
280
|
+
}
|
|
281
|
+
/** A JSON string literal is also a valid TOML basic string, quotes, backslashes and non-ASCII included. */
|
|
282
|
+
const toml = (s) => JSON.stringify(s);
|
|
283
|
+
/** CLI arguments that inject chord's hooks and MCP server into Codex. Values are TOML. */
|
|
284
|
+
/** `writableRoots`: directories Codex's workspace-write sandbox must also be allowed to write. A linked worktree keeps
|
|
285
|
+
* its index and refs under the main checkout's .git, outside the agent's workspace, so without this every git commit
|
|
286
|
+
* fails inside the sandbox. */
|
|
287
|
+
export function codexArgs(env, writableRoots = []) {
|
|
288
|
+
const args = [];
|
|
289
|
+
if (writableRoots.length)
|
|
290
|
+
args.push('-c', `sandbox_workspace_write.writable_roots=[${writableRoots.map(toml).join(',')}]`);
|
|
291
|
+
for (const e of EVENTS) {
|
|
292
|
+
if (!e.codex)
|
|
293
|
+
continue;
|
|
294
|
+
const matcher = e.matcher?.codex ? `matcher=${toml(e.matcher.codex)},` : '';
|
|
295
|
+
const limit = e.ctxLimit ? `,additionalContextLimit=${e.ctxLimit}` : '';
|
|
296
|
+
args.push('-c', `hooks.${e.codex}=[{${matcher}hooks=[{type="command",command=${toml(hookCommand(e.event))}${limit}}]}]`);
|
|
297
|
+
}
|
|
298
|
+
const envToml = Object.entries(env).map(([k, v]) => `${k}=${toml(v)}`).join(',');
|
|
299
|
+
// finish may wait minutes for the merge lock; Codex's default tool timeout is 60s
|
|
300
|
+
args.push('-c', `mcp_servers.chord={command=${toml(NODE)},args=[${toml(SERVE_TS)}],env={${envToml}},tool_timeout_sec=600}`);
|
|
301
|
+
return args;
|
|
302
|
+
}
|
|
303
|
+
const fwd = (p) => p.split(path.sep).join('/');
|
|
304
|
+
/** Resolve everything for `chord <tool> [--name x] [tool args...]` without spawning. Throws NeedsRestructure. */
|
|
305
|
+
export function prepare(tool, cwd, override) {
|
|
306
|
+
const project = resolveProject(cwd);
|
|
307
|
+
try {
|
|
308
|
+
git(project.repo, 'rev-parse', '--verify', '-q', 'HEAD');
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
throw new Error(`${project.repo} has no commits yet; make an initial commit first`);
|
|
312
|
+
}
|
|
313
|
+
ensureStateDir(project);
|
|
314
|
+
const dbFile = db.dbPath(project.stateDir);
|
|
315
|
+
const d = db.openDb(dbFile);
|
|
316
|
+
db.sweep(d);
|
|
317
|
+
const name = pickName(d, tool, project.dir, override);
|
|
318
|
+
const ws = workspacePath(project, name);
|
|
319
|
+
// 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. The wrapper re-registers with the tool's own pid when it starts.
|
|
321
|
+
db.addAgent(d, { name, tool, pid: process.pid, cwd: ws, worktree: path.join(ws, project.repoName), heartbeat: true });
|
|
322
|
+
try {
|
|
323
|
+
const config = readConfig(project);
|
|
324
|
+
const worktree = ensureWorktree(project.repo, path.join(ws, project.repoName), name);
|
|
325
|
+
const env = { CHORD_AGENT_ID: name, CHORD_DB: fwd(dbFile), CHORD_TOOL: tool, CHORD_PROJECT: fwd(project.dir), CHORD_WORKSPACE: fwd(ws), CHORD_WORKTREE: fwd(worktree) };
|
|
326
|
+
// Setup is owed until it has finished once: a marker, not the worktree's existence, says so, because a setup
|
|
327
|
+
// interrupted half-way leaves the worktree behind and a relaunch must not take a half-prepared checkout for ready.
|
|
328
|
+
const marker = setupMarker(project, name);
|
|
329
|
+
if (config.setup && !fs.existsSync(marker)) {
|
|
330
|
+
runSetup(project, name, worktree, config.setup, env);
|
|
331
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
332
|
+
fs.writeFileSync(marker, `${Date.now()}\n`);
|
|
333
|
+
}
|
|
334
|
+
let projectConfig = false;
|
|
335
|
+
if (tool === 'claude' && hasProjectConfig(worktree)) {
|
|
336
|
+
projectConfig = claudeTrusts(project.repo) || claudeTrusts(project.dir) ? { repo: project.repo } : false;
|
|
337
|
+
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`);
|
|
339
|
+
}
|
|
340
|
+
return { name, project, cwd: ws, worktree, dbFile, env, args: tool === 'claude' ? claudeArgs(env, projectConfig) : codexArgs(env, [path.join(project.repo, '.git')]) };
|
|
341
|
+
}
|
|
342
|
+
catch (e) {
|
|
343
|
+
db.setStatus(d, name, 'offline'); // the launch failed: the name is free again
|
|
344
|
+
throw e;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** <project>/chord/config.json. `setup` is a shell command run in every new worktree before the agent starts,
|
|
348
|
+
* for example "npm install". */
|
|
349
|
+
export function readConfig(project) {
|
|
350
|
+
const file = path.join(project.dir, 'chord', 'config.json');
|
|
351
|
+
let raw;
|
|
352
|
+
try {
|
|
353
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return {};
|
|
357
|
+
}
|
|
358
|
+
let cfg;
|
|
359
|
+
try {
|
|
360
|
+
cfg = JSON.parse(raw);
|
|
361
|
+
}
|
|
362
|
+
catch (e) {
|
|
363
|
+
throw new Error(`${file} is not valid JSON: ${e.message}`);
|
|
364
|
+
}
|
|
365
|
+
if (typeof cfg !== 'object' || cfg === null)
|
|
366
|
+
throw new Error(`${file} must hold a JSON object`);
|
|
367
|
+
const setup = cfg.setup;
|
|
368
|
+
if (setup !== undefined && typeof setup !== 'string')
|
|
369
|
+
throw new Error(`${file}: "setup" must be a string (a shell command)`);
|
|
370
|
+
return setup ? { setup } : {};
|
|
371
|
+
}
|
|
372
|
+
/** Where `chord clean` and a relaunch look to see whether the setup command has run for this agent. */
|
|
373
|
+
export const setupMarker = (project, name) => path.join(project.stateDir, 'setup-done', name);
|
|
374
|
+
/** A failed setup aborts the launch, and the half-prepared worktree and branch go with it so the next launch starts clean. */
|
|
375
|
+
function runSetup(project, name, worktree, setup, env) {
|
|
376
|
+
process.stderr.write(`chord: running setup in ${worktree}: ${setup}\n`);
|
|
377
|
+
// Ctrl+C goes to the setup command, which then fails and is cleaned up below; without a listener it would kill chord
|
|
378
|
+
// itself first and leave the worktree and branch behind
|
|
379
|
+
const ignoreSigint = () => { };
|
|
380
|
+
process.on('SIGINT', ignoreSigint);
|
|
381
|
+
try {
|
|
382
|
+
execSync(setup, { cwd: worktree, stdio: 'inherit', env: { ...process.env, ...env } });
|
|
383
|
+
}
|
|
384
|
+
catch (e) {
|
|
385
|
+
try {
|
|
386
|
+
git(project.repo, 'worktree', 'remove', '--force', worktree);
|
|
387
|
+
}
|
|
388
|
+
catch { /* best effort */ }
|
|
389
|
+
try {
|
|
390
|
+
git(project.repo, 'branch', '-D', `chord/${name}`);
|
|
391
|
+
}
|
|
392
|
+
catch { /* best effort */ }
|
|
393
|
+
const status = e.status;
|
|
394
|
+
throw new Error(`setup command failed${status != null ? ` with exit code ${status}` : ''}: ${setup}\nthe worktree was removed; fix the command in ${path.join(project.dir, 'chord', 'config.json')} or the repository and launch again`);
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
process.off('SIGINT', ignoreSigint);
|
|
398
|
+
}
|
|
399
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Minimal MCP server over stdio: newline-delimited JSON-RPC 2.0, tools only.
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
const PROTOCOL = '2025-06-18';
|
|
4
|
+
/** Handle one JSON-RPC message. Returns the response, or undefined for notifications. */
|
|
5
|
+
export function handle(tools, req) {
|
|
6
|
+
const { id, method, params = {} } = req;
|
|
7
|
+
if (id === undefined)
|
|
8
|
+
return undefined; // notification, e.g. notifications/initialized
|
|
9
|
+
const ok = (result) => ({ jsonrpc: '2.0', id, result });
|
|
10
|
+
const err = (code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
11
|
+
switch (method) {
|
|
12
|
+
case 'initialize':
|
|
13
|
+
return ok({ protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: 'chord', version: '0.0.0' } });
|
|
14
|
+
case 'ping':
|
|
15
|
+
return ok({});
|
|
16
|
+
case 'tools/list':
|
|
17
|
+
return ok({ tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
|
|
18
|
+
case 'tools/call': {
|
|
19
|
+
const tool = tools.find(t => t.name === params.name);
|
|
20
|
+
if (!tool)
|
|
21
|
+
return err(-32602, `unknown tool: ${String(params.name)}`);
|
|
22
|
+
try {
|
|
23
|
+
const out = tool.handler(params.arguments ?? {});
|
|
24
|
+
const text = typeof out === 'string' ? out : JSON.stringify(out, null, 2);
|
|
25
|
+
return ok({ content: [{ type: 'text', text }] });
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
return ok({ content: [{ type: 'text', text: e.message }], isError: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
default:
|
|
32
|
+
return err(-32601, `method not found: ${method}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function serve(tools) {
|
|
36
|
+
const rl = createInterface({ input: process.stdin });
|
|
37
|
+
rl.on('line', line => {
|
|
38
|
+
if (!line.trim())
|
|
39
|
+
return;
|
|
40
|
+
let req;
|
|
41
|
+
try {
|
|
42
|
+
req = JSON.parse(line);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const res = handle(tools, req);
|
|
48
|
+
if (res)
|
|
49
|
+
process.stdout.write(JSON.stringify(res) + '\n');
|
|
50
|
+
});
|
|
51
|
+
}
|
package/dist/patch.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** File paths touched by a Codex apply_patch payload, taken from its header lines. */
|
|
2
|
+
export function patchFiles(patch) {
|
|
3
|
+
const out = new Set();
|
|
4
|
+
for (const line of patch.split('\n')) {
|
|
5
|
+
const m = /^\s*\*\*\* (?:Add File|Update File|Delete File|Move to): (.+?)\s*$/.exec(line); // a heredoc may indent the headers
|
|
6
|
+
if (m)
|
|
7
|
+
out.add(m[1]);
|
|
8
|
+
}
|
|
9
|
+
return [...out];
|
|
10
|
+
}
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// A chord project is the folder `chord` is run in. It contains the git repository as a child
|
|
2
|
+
// directory and a `chord/` directory for agent workspaces; chord's own state lives under ~/.chord.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { gitEnv } from "./db.js";
|
|
9
|
+
/** Thrown when the folder is a repository root: chord offers to move it into a child directory. */
|
|
10
|
+
export class NeedsRestructure extends Error {
|
|
11
|
+
dir;
|
|
12
|
+
suggestedName;
|
|
13
|
+
constructor(dir, suggestedName) {
|
|
14
|
+
super(`${dir} is itself a git repository; chord needs the repository in a child directory of the project`);
|
|
15
|
+
this.dir = dir;
|
|
16
|
+
this.suggestedName = suggestedName;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', env: gitEnv(), stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
20
|
+
const same = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
21
|
+
function repoRootOf(dir) {
|
|
22
|
+
try {
|
|
23
|
+
return fs.realpathSync.native(git(dir, 'rev-parse', '--show-toplevel'));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Only a .git *directory* marks a project repository; a .git file is a linked worktree or a submodule. */
|
|
30
|
+
const isRepoDir = (dir) => { try {
|
|
31
|
+
return fs.statSync(path.join(dir, '.git')).isDirectory();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
} };
|
|
36
|
+
/** Readable like Claude Code's projects directory (separators and colons become dashes), plus a short hash so
|
|
37
|
+
* that D:\a-b and D:\a\b do not share one state directory. */
|
|
38
|
+
export function encodeProjectPath(dir) {
|
|
39
|
+
const key = process.platform === 'win32' ? dir.toLowerCase() : dir;
|
|
40
|
+
return `${dir.replace(/[\\/:]/g, '-')}-${createHash('sha1').update(key).digest('hex').slice(0, 6)}`;
|
|
41
|
+
}
|
|
42
|
+
export const stateDirFor = (dir) => path.join(process.env.CHORD_HOME ?? path.join(os.homedir(), '.chord'), 'projects', encodeProjectPath(dir));
|
|
43
|
+
/** A repository directory name chord can work with: a single path segment that is not chord's own directory. */
|
|
44
|
+
export function validateRepoName(name) {
|
|
45
|
+
if (!name.trim())
|
|
46
|
+
return 'the name is empty';
|
|
47
|
+
if (/[\\/]/.test(name) || name === '.' || name === '..')
|
|
48
|
+
return 'the name must be a single directory name';
|
|
49
|
+
if (name.toLowerCase() === 'chord')
|
|
50
|
+
return '"chord" is reserved for chord\'s own directory';
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
/** Default name for the repository directory: the origin remote's name, else the folder's own name. */
|
|
54
|
+
export function suggestedRepoName(repo) {
|
|
55
|
+
try {
|
|
56
|
+
const url = git(repo, 'remote', 'get-url', 'origin');
|
|
57
|
+
const last = url.split(/[\\/:]/).filter(Boolean).pop() ?? '';
|
|
58
|
+
const name = last.replace(/\.git$/i, '');
|
|
59
|
+
if (name && !validateRepoName(name))
|
|
60
|
+
return name;
|
|
61
|
+
}
|
|
62
|
+
catch { /* no origin */ }
|
|
63
|
+
const own = path.basename(repo);
|
|
64
|
+
return validateRepoName(own) ? 'repo' : own;
|
|
65
|
+
}
|
|
66
|
+
/** The nearest ancestor that already is a chord project, if any. */
|
|
67
|
+
/** Child directories of `dir` that are repository roots. Looked up with stat, so a junction or symlink to a repository
|
|
68
|
+
* counts (a Dirent reports those as links, not directories). */
|
|
69
|
+
const repoChildren = (dir) => fs.readdirSync(dir).filter(n => { const p = path.join(dir, n); try {
|
|
70
|
+
return fs.statSync(p).isDirectory() && isRepoDir(p);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
} });
|
|
75
|
+
/** The project `dir` belongs to, when it lies inside one: under the project's chord/ directory (a workspace or a
|
|
76
|
+
* worktree) or inside its repository. A folder merely beside those, such as a new project created next to an old
|
|
77
|
+
* one, is not claimed by the ancestor. */
|
|
78
|
+
function enclosingProject(dir) {
|
|
79
|
+
for (let a = path.dirname(dir); a !== path.dirname(a); a = path.dirname(a)) {
|
|
80
|
+
if (!fs.existsSync(stateDirFor(a)) && !fs.existsSync(path.join(a, 'chord', 'agents')))
|
|
81
|
+
continue;
|
|
82
|
+
const inside = (sub) => { const rel = path.relative(path.join(a, sub), dir); return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); };
|
|
83
|
+
if (inside('chord') || repoChildren(a).some(inside))
|
|
84
|
+
return a;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
export function resolveProject(cwd) {
|
|
89
|
+
const dir = fs.realpathSync.native(cwd);
|
|
90
|
+
// inside an existing project (its main checkout, a workspace or a worktree): that project is meant
|
|
91
|
+
const parent = enclosingProject(dir);
|
|
92
|
+
if (parent)
|
|
93
|
+
return resolveProject(parent);
|
|
94
|
+
const enclosing = repoRootOf(dir);
|
|
95
|
+
if (enclosing) {
|
|
96
|
+
if (same(enclosing, dir))
|
|
97
|
+
throw new NeedsRestructure(dir, suggestedRepoName(dir));
|
|
98
|
+
throw new Error(`${dir} is inside the repository ${enclosing}; a chord project is the folder that contains the repository, not a folder inside it`);
|
|
99
|
+
}
|
|
100
|
+
const children = repoChildren(dir);
|
|
101
|
+
if (children.some(c => c.toLowerCase() === 'chord'))
|
|
102
|
+
throw new Error(`${dir}/chord is a git repository, but chord/ is reserved for chord's own directory; rename it`);
|
|
103
|
+
if (children.length === 0)
|
|
104
|
+
throw new Error(`${dir} contains no git repository; a chord project is a folder with the repository in a child directory`);
|
|
105
|
+
if (children.length > 1)
|
|
106
|
+
throw new Error(`${dir} contains several repositories (${children.join(', ')}); chord supports one per project for now`);
|
|
107
|
+
const repo = path.join(dir, children[0]);
|
|
108
|
+
return { dir, repo, repoName: children[0], stateDir: stateDirFor(dir) };
|
|
109
|
+
}
|
|
110
|
+
/** Why `dir` (a repository root) must not be moved into a child directory, or null when it is safe. */
|
|
111
|
+
export function restructureBlocker(dir) {
|
|
112
|
+
if (same(fs.realpathSync.native(dir), fs.realpathSync.native(os.homedir())))
|
|
113
|
+
return 'refusing to move your home directory';
|
|
114
|
+
let worktrees = 1;
|
|
115
|
+
try {
|
|
116
|
+
worktrees = git(dir, 'worktree', 'list', '--porcelain').split('\n').filter(l => l.startsWith('worktree ')).length;
|
|
117
|
+
}
|
|
118
|
+
catch { /* not a repo */ }
|
|
119
|
+
if (worktrees > 1)
|
|
120
|
+
return `${dir} has linked worktrees (git worktree list); remove them first, moving the repository would break them`;
|
|
121
|
+
try {
|
|
122
|
+
if (git(dir, 'ls-files', '--', 'chord'))
|
|
123
|
+
return 'the repository tracks a top-level chord/ directory, which chord needs for itself; rename it first';
|
|
124
|
+
}
|
|
125
|
+
catch { /* ignore */ }
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
/** Move everything in `dir` (except chord/) into `dir/name`, so the repository becomes a child. Rolls back on failure. */
|
|
129
|
+
export function restructure(dir, name, rename = fs.renameSync) {
|
|
130
|
+
const invalid = validateRepoName(name);
|
|
131
|
+
if (invalid)
|
|
132
|
+
throw new Error(invalid);
|
|
133
|
+
const blocker = restructureBlocker(dir);
|
|
134
|
+
if (blocker)
|
|
135
|
+
throw new Error(blocker);
|
|
136
|
+
const target = path.join(dir, name);
|
|
137
|
+
if (fs.existsSync(target))
|
|
138
|
+
throw new Error(`${target} already exists`);
|
|
139
|
+
const entries = fs.readdirSync(dir).filter(e => e !== 'chord');
|
|
140
|
+
fs.mkdirSync(target);
|
|
141
|
+
const moved = [];
|
|
142
|
+
try {
|
|
143
|
+
for (const e of entries) {
|
|
144
|
+
rename(path.join(dir, e), path.join(target, e));
|
|
145
|
+
moved.push(e);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
const stuck = [];
|
|
150
|
+
for (const m of moved) {
|
|
151
|
+
try {
|
|
152
|
+
rename(path.join(target, m), path.join(dir, m));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
stuck.push(m);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!stuck.length) {
|
|
159
|
+
try {
|
|
160
|
+
fs.rmdirSync(target);
|
|
161
|
+
}
|
|
162
|
+
catch { /* leave it */ }
|
|
163
|
+
}
|
|
164
|
+
const note = stuck.length ? ` Rolling back failed too: ${stuck.join(', ')} are still under ${target}.` : ' Everything was moved back.';
|
|
165
|
+
throw new Error(`could not move ${dir} into ${target}: ${e.message}.${note} Close programs that have files open there and try again.`);
|
|
166
|
+
}
|
|
167
|
+
return target;
|
|
168
|
+
}
|
|
169
|
+
/** Every project that has state under ~/.chord/projects, for `chord ls --all`. */
|
|
170
|
+
export function knownProjects() {
|
|
171
|
+
const root = path.dirname(stateDirFor('x'));
|
|
172
|
+
if (!fs.existsSync(root))
|
|
173
|
+
return [];
|
|
174
|
+
return fs.readdirSync(root).map(name => {
|
|
175
|
+
const stateDir = path.join(root, name);
|
|
176
|
+
let dir = null;
|
|
177
|
+
try {
|
|
178
|
+
dir = fs.readFileSync(path.join(stateDir, 'path'), 'utf8').trim();
|
|
179
|
+
}
|
|
180
|
+
catch { /* unknown */ }
|
|
181
|
+
return { stateDir, dbFile: path.join(stateDir, 'chord.db'), dir };
|
|
182
|
+
}).filter(p => fs.existsSync(p.dbFile));
|
|
183
|
+
}
|
|
184
|
+
/** Create the state directory and remember the project's real path (the encoding is not reversible). */
|
|
185
|
+
export function ensureStateDir(p) {
|
|
186
|
+
fs.mkdirSync(p.stateDir, { recursive: true });
|
|
187
|
+
fs.writeFileSync(path.join(p.stateDir, 'path'), p.dir);
|
|
188
|
+
}
|