chord-cli 0.1.3 → 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/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,16 +26,38 @@ 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
- export const isEditTool = (tool, name) => (tool === 'claude' ? EDIT_TOOLS.has(String(name)) : name === 'apply_patch');
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
- export function formatMessages(ms) {
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;
36
36
  const MAX_WARNED_FILES = 20;
37
37
  const MAX_INSTRUCTION_CHARS = 8000;
38
- /** The repository's own agent instructions. The agent's cwd is the workspace, so the tools do not load them themselves. */
38
+ /** The agent's worktree as a path from its cwd, the project folder: chord/agents/<name>/<repoName>. */
39
+ const repoDirOf = (ctx) => path.relative(ctx.project ?? ctx.cwd, ctx.worktree).split(path.sep).join('/') || '.';
40
+ /** The same file in the agent's own worktree, for a path that hit the main checkout or another agent's worktree:
41
+ * both are named after the repository, so what follows that directory is the file. */
42
+ function ownCopy(ctx, abs, tool) {
43
+ const parts = path.relative(ctx.project ?? ctx.cwd, abs).split(path.sep), repoName = path.basename(ctx.worktree);
44
+ // P/<repoName>/... is the main checkout; P/chord/agents/<other>/<repoName>/... another agent's worktree
45
+ const tail = parts[0] === repoName ? parts.slice(1) : parts[0] === 'chord' && parts[1] === 'agents' && parts[3] === repoName ? parts.slice(4) : [];
46
+ return tool === 'claude' ? path.join(ctx.worktree, ...tail) : `${repoDirOf(ctx)}/${tail.join('/')}`; // Claude's editor wants absolute paths
47
+ }
48
+ /** The 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
+ }
59
+ /** The repository's own agent instructions. The agent's cwd is the project folder, which is not a git repository, so
60
+ * Codex does not load the worktree's AGENTS.md and Claude only reads a nested CLAUDE.md once it opens a file there. */
39
61
  function projectInstructions(ctx, repoDir) {
40
62
  const out = [];
41
63
  for (const name of ['AGENTS.md', 'CLAUDE.md']) {
@@ -52,9 +74,9 @@ export function briefing(ctx) {
52
74
  const me = db.getAgent(ctx.db, ctx.agent);
53
75
  const peers = db.listAgents(ctx.db).filter(a => a.name !== ctx.agent && a.status !== 'offline');
54
76
  const claims = db.allClaims(ctx.db);
55
- const repoDir = path.relative(ctx.cwd, ctx.worktree).split(path.sep).join('/') || '.';
77
+ const repoDir = repoDirOf(ctx), repoName = path.basename(ctx.worktree);
56
78
  const lines = [
57
- `You are agent "${ctx.agent}" (${me?.tool ?? 'unknown'}), one of several coding agents working on the same project. Your working directory is your own workspace; the repository is the directory "${repoDir}" inside it, a git worktree on your own branch. Run git commands inside "${repoDir}". Name files as paths from your working directory, e.g. "${repoDir}/src/x.ts" (claim() also accepts repository-relative paths like "src/x.ts"). Other agents have their own worktrees of the same repository; never edit files outside your workspace. Coordination goes through the "chord" MCP tools.`,
79
+ `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
80
  '',
59
81
  'Rules:',
60
82
  '1. First thing: call register(task, scope) to say what you are doing.',
@@ -80,21 +102,27 @@ export function briefing(ctx) {
80
102
  /** Context for the model. Plain stdout also works for Claude Code and `codex exec`, but the Codex TUI
81
103
  * only takes the JSON form, so both events use it. */
82
104
  const context = (hookEventName, additionalContext) => ({ hookSpecificOutput: { hookEventName, additionalContext } });
105
+ /** Both tools put their conversation id in the payload as session_id; `chord resume` reopens that conversation. Taken on
106
+ * every prompt too, because Codex /new and a /resume start a new conversation without a SessionStart hook. */
107
+ const recordSession = (ctx, payload) => { if (typeof payload.session_id === 'string' && payload.session_id)
108
+ db.setSession(ctx.db, ctx.agent, payload.session_id); };
83
109
  export function handleHook(ctx, tool, event, payload) {
84
110
  db.sweep(ctx.db);
85
111
  switch (event) {
86
112
  case 'session-start':
113
+ recordSession(ctx, payload);
87
114
  // The agent now waits for its first prompt: idle, so messages that arrive before the user types still wake it.
88
115
  // Claude Code also fires this after compacting a long conversation, in the middle of a turn: not idle then.
89
116
  if (payload.source !== 'compact')
90
117
  db.setStatus(ctx.db, ctx.agent, 'idle');
91
118
  return context('SessionStart', briefing(ctx));
92
119
  case 'user-prompt-submit': {
120
+ recordSession(ctx, payload);
93
121
  db.setStatus(ctx.db, ctx.agent, 'busy');
94
122
  const ms = db.takeUnread(ctx.db, ctx.agent);
95
123
  // One line of identity on every prompt: a conversation that started without the briefing (Codex /new fires no
96
124
  // 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.`;
125
+ 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
126
  return context('UserPromptSubmit', ms.length
99
127
  ? `${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
128
  : reminder);
@@ -104,14 +132,16 @@ export function handleHook(ctx, tool, event, payload) {
104
132
  if (!files.length && isEditTool(tool, payload.tool_name)) {
105
133
  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
134
  }
107
- // Codex resolves a patch's relative paths against the call's workdir when it gives one; otherwise the workspace
108
- const workdir = tool === 'codex' && typeof payload.tool_input?.workdir === 'string' ? path.resolve(ctx.cwd, payload.tool_input.workdir) : ctx.cwd;
135
+ // Codex resolves a patch's relative paths against the call's workdir when it gives one, else against its own cwd,
136
+ // the project folder (the workspace when no project is known, as in the tests)
137
+ const base = ctx.project ?? ctx.cwd;
138
+ const workdir = tool === 'codex' && typeof payload.tool_input?.workdir === 'string' ? path.resolve(base, payload.tool_input.workdir) : base;
109
139
  for (const f of files) {
110
- const abs = path.resolve(workdir, f);
140
+ const abs = realPath(path.resolve(workdir, f));
111
141
  if (!insideDir(ctx.worktree, abs)) {
112
142
  // the main checkout and the other agents' workspaces are off limits; anything else is not ours to police
113
143
  if (ctx.project && insideDir(ctx.project, abs) && !insideDir(ctx.cwd, abs)) {
114
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `${f} is outside your workspace: edit files only inside your own worktree ${ctx.worktree}` } };
144
+ 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
145
  }
116
146
  continue;
117
147
  }
@@ -130,18 +160,21 @@ export function handleHook(ctx, tool, event, payload) {
130
160
  db.setStatus(ctx.db, ctx.agent, 'busy');
131
161
  const parts = [];
132
162
  if (isShell(payload.tool_name))
133
- parts.push(...shellWarnings(ctx, payload));
134
- const ms = db.takeUnread(ctx.db, ctx.agent);
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);
135
167
  if (ms.length)
136
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.`);
137
169
  return parts.length ? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: parts.join('\n\n') } } : undefined;
138
170
  }
139
171
  case 'stop': {
140
- const ms = db.takeUnread(ctx.db, ctx.agent);
141
- if (ms.length)
142
- return { decision: 'block', reason: formatMessages(ms) + '\n\nHandle these messages before stopping.' };
143
- db.setStatus(ctx.db, ctx.agent, 'idle');
144
- return undefined;
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.' };
145
178
  }
146
179
  case 'notification':
147
180
  // Claude Code: "idle_prompt" fires about a minute after Claude stopped and nobody typed. It is the only
@@ -157,30 +190,9 @@ export function handleHook(ctx, tool, event, payload) {
157
190
  return undefined;
158
191
  }
159
192
  }
160
- /** Paths changed according to `git status --porcelain=v1 -z -uall`: entries are NUL-separated, a rename
161
- * or copy is followed by the original path, and nothing is quoted or folded into directories. */
162
- export function changedPaths(statusZ) {
163
- const parts = statusZ.split('\0');
164
- const out = [];
165
- for (let i = 0; i < parts.length; i++) {
166
- const entry = parts[i];
167
- if (!entry)
168
- continue;
169
- out.push(entry.slice(3));
170
- const xy = entry.slice(0, 2);
171
- if (/R/.test(xy)) {
172
- i++;
173
- if (parts[i])
174
- out.push(parts[i]);
175
- } // a rename changes its "from" path too
176
- else if (/C/.test(xy))
177
- i++; // a copy leaves its source alone
178
- }
179
- return out;
180
- }
181
193
  /** The old names of renames in NUL-separated status output: a rename changes that path too, and diff --name-only
182
194
  * lists only the new one. */
183
- export function renameSources(statusZ) {
195
+ function renameSources(statusZ) {
184
196
  const parts = statusZ.split('\0');
185
197
  const out = [];
186
198
  for (let i = 0; i < parts.length; i++) {
@@ -206,37 +218,66 @@ const gitOut = (cwd, ...args) => {
206
218
  return '';
207
219
  }
208
220
  };
209
- /** What a shell command may have done behind chord's back: edits or commits to unclaimed files, git stash, and
210
- * changes to the main checkout. */
211
- export function shellWarnings(ctx, payload) {
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) {
212
224
  const out = [];
213
225
  const mine = new Set(db.claimsBy(ctx.db, ctx.agent).map(c => c.file.toLowerCase()));
214
- // uncommitted changes (-unormal folds an untracked directory into one entry) plus whatever the branch has
215
- // committed since it left main: a commit made from the shell is no more a claim than an edit is
216
- // tracked files: real changes only, so a repository whose files differ in line endings from the moment they are
217
- // 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.
218
230
  const status = gitOut(ctx.worktree, 'status', '--porcelain=v1', '-z', '-unormal');
219
231
  const untracked = status.split('\0').filter(e => e.startsWith('?? ')).map(e => e.slice(3));
220
- const modified = ['', '--cached'].flatMap(c => gitOut(ctx.worktree, 'diff', ...(c ? [c] : []), '--name-only', '-z', '--ignore-cr-at-eol').split('\0'));
221
- const root = db.mainRoot(ctx.worktree);
222
- const main = root ? gitOut(root, 'rev-parse', '--abbrev-ref', 'HEAD').trim() : '';
223
- const base = main && main !== 'HEAD' ? gitOut(ctx.worktree, 'merge-base', 'HEAD', main).trim() : '';
224
- const committed = base ? gitOut(ctx.worktree, 'diff', '--name-only', '-z', base, 'HEAD').split('\0').filter(Boolean) : [];
225
- const touched = [...new Set([...modified.filter(Boolean), ...untracked, ...renameSources(status), ...committed])];
226
- const unclaimed = touched.map(f => normFile(ctx, path.join(ctx.worktree, f))).filter(f => !mine.has(f.toLowerCase()));
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()));
227
239
  const shown = unclaimed.slice(0, MAX_WARNED_FILES);
228
240
  if (unclaimed.length)
229
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.`);
230
- const cmd = typeof payload.tool_input?.command === 'string' ? payload.tool_input.command : '';
231
- if (/\bgit\b[^|;&\n]*\bstash\b/.test(cmd))
232
- 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
- if (root && path.resolve(root) !== path.resolve(ctx.worktree)) {
234
- const dirty = gitOut(root, 'status', '--porcelain', '--untracked-files=no').split('\n').filter(Boolean).map(l => l.slice(3));
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
+ } })();
235
250
  if (dirty.length)
236
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().`);
237
252
  }
238
253
  return out;
239
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 = [];
259
+ const cmd = typeof payload.tool_input?.command === 'string' ? payload.tool_input.command : '';
260
+ // From the project folder, "<repo>/" is the main checkout: a command that ran there (Codex's workdir), cd'd there or
261
+ // named a path there did not touch the agent's own copy.
262
+ if (ctx.project) {
263
+ const repoName = path.basename(ctx.worktree);
264
+ const workdir = typeof payload.tool_input?.workdir === 'string' ? path.resolve(ctx.project, payload.tool_input.workdir) : null;
265
+ const inMain = workdir !== null && insideDir(ctx.project, workdir) && !insideDir(ctx.cwd, workdir);
266
+ const words = cmd.split(/\s+/);
267
+ const namesMain = words.some((w, i) => {
268
+ if (/chord[\\/]agents[\\/]/.test(w))
269
+ return false;
270
+ const stripped = w.replace(/^["'(]+/, '').replace(/^\.[\\/]/, '');
271
+ const head = stripped.split(/[\\/]/)[0].replace(/["';&|)]+$/, '');
272
+ return head === repoName && (/[\\/]/.test(stripped) || words[i - 1] === 'cd');
273
+ });
274
+ if (inMain || namesMain)
275
+ 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.`);
276
+ }
277
+ if (/\bgit\b[^|;&\n]*\bstash\b/.test(cmd))
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.");
279
+ return out;
280
+ }
240
281
  /** handleHook, but never lets an exception reach the tool: a broken hook must not fail open on a write. */
241
282
  export function safeHandle(ctx, tool, event, payload) {
242
283
  try {
@@ -257,8 +298,11 @@ async function main() {
257
298
  const agent = process.env.CHORD_AGENT_ID;
258
299
  const file = process.env.CHORD_DB;
259
300
  const tool = process.env.CHORD_TOOL;
260
- if (!agent || !file || !tool) {
261
- process.stderr.write('chord hook: CHORD_AGENT_ID, CHORD_DB and CHORD_TOOL must be set\n');
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');
262
306
  process.exit(0);
263
307
  }
264
308
  const chunks = [];
@@ -270,11 +314,9 @@ async function main() {
270
314
  payload = JSON.parse(raw);
271
315
  }
272
316
  catch { /* no payload */ }
273
- // 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
274
- const cwd = process.env.CHORD_WORKSPACE ?? (typeof payload.cwd === 'string' ? payload.cwd : process.cwd());
275
317
  let ctx;
276
318
  try {
277
- ctx = { db: db.openDb(file), agent, cwd, worktree: process.env.CHORD_WORKTREE ?? cwd, project: process.env.CHORD_PROJECT };
319
+ ctx = { db: db.openDb(file), agent, cwd, worktree, project: process.env.CHORD_PROJECT };
278
320
  }
279
321
  catch (e) {
280
322
  // the database would not open: a write must still not go through unchecked
@@ -288,9 +330,9 @@ async function main() {
288
330
  if (process.env.CHORD_DEBUG_HOOKS)
289
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 ?? '-'}`);
290
332
  const out = safeHandle(ctx, tool, event, payload);
291
- db.log(`hook ${event} (${tool}, tool_name=${payload.tool_name ?? '-'}): ${out === undefined ? 'no output' : `${typeof out === 'string' ? 'text' : 'json'} ${JSON.stringify(out).length} chars`} in ${Date.now() - started}ms`);
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`);
292
334
  if (out !== undefined)
293
- process.stdout.write(typeof out === 'string' ? out : JSON.stringify(out));
335
+ process.stdout.write(JSON.stringify(out));
294
336
  }
295
337
  const isMain = (() => { try {
296
338
  return fs.realpathSync.native(process.argv[1] ?? '') === fs.realpathSync.native(fileURLToPath(new URL(import.meta.url)));
package/dist/launch.js CHANGED
@@ -2,21 +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 { execFileSync, execSync } from 'node:child_process';
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 git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', env: db.gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
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
- 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);
18
+ const HOOKS_TS = here('./hooks');
19
+ const SERVE_TS = here('./serve');
20
20
  /** `staleWaitMs`: how long to watch a stale heartbeat before calling its agent dead (tests shorten it). */
21
21
  export function pickName(d, tool, project, override, staleWaitMs = 2500) {
22
22
  if (override) {
@@ -36,7 +36,7 @@ export function pickName(d, tool, project, override, staleWaitMs = 2500) {
36
36
  // (a live one writes every second), and this launch itself proves the machine is awake.
37
37
  const stale = clash.heartbeat_at !== null && Date.now() - clash.heartbeat_at > db.HEARTBEAT_STALE_MS;
38
38
  if (stale)
39
- sleepSync(staleWaitMs);
39
+ db.sleepSync(staleWaitMs);
40
40
  const again = stale ? db.getAgent(d, clash.name) : clash;
41
41
  if (!stale || again?.heartbeat_at !== clash.heartbeat_at)
42
42
  throw new Error(`agent "${override}" is already online`);
@@ -163,8 +163,8 @@ export function withNodeOnPath(env) {
163
163
  return { ...env, [key]: current ? `${dir}${path.delimiter}${current}` : dir };
164
164
  }
165
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) {
166
+ * is its worktree; Claude would point it at the project folder. */
167
+ function substituteProjectDir(value, worktree) {
168
168
  const dir = worktree.split(path.sep).join('/');
169
169
  const walk = (v) => {
170
170
  if (typeof v === 'string')
@@ -179,7 +179,7 @@ export function substituteProjectDir(value, worktree) {
179
179
  }
180
180
  /** The repository's .mcp.json as an --mcp-config value, reduced to the servers the user approved in Claude Code, or
181
181
  * null when there is nothing to pass. */
182
- export function projectMcpConfig(worktree, source) {
182
+ function projectMcpConfig(worktree, source) {
183
183
  const file = path.join(worktree, '.mcp.json');
184
184
  if (!fs.existsSync(file))
185
185
  return null;
@@ -199,6 +199,10 @@ export function projectMcpConfig(worktree, source) {
199
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
200
  return Object.keys(keep).length ? JSON.stringify({ mcpServers: substituteProjectDir(keep, worktree) }) : null;
201
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. */
202
206
  export function claudeArgs(env, projectConfig = false) {
203
207
  const hooks = {};
204
208
  for (const e of EVENTS) {
@@ -211,14 +215,14 @@ export function claudeArgs(env, projectConfig = false) {
211
215
  // chord's own tools are pre-approved: in the default permission mode every claim, send and finish would otherwise
212
216
  // stop at a prompt, and an unattended agent would never get past the first one
213
217
  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.
218
+ // The agent's cwd is the project folder, not the repository, so Claude Code would not pick up the repository's
219
+ // skills, commands and agents under .claude/, nor its settings*.json or .mcp.json. --add-dir covers the first group
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 source = projectConfig === true ? { repo: wt ?? '' } : projectConfig || null;
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 = source ? mergeProjectSettings(ours, wt) : ours;
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
- 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). */
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
- export function mergeSettings(ours, theirs) {
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
- /** `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. */
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. 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
+ // 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: '0.0.0' } });
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 { gitEnv } from "./db.js";
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
- 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);
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 = process.platform === 'win32' ? dir.toLowerCase() : dir;
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
- export const stateDirFor = (dir) => path.join(process.env.CHORD_HOME ?? path.join(os.homedir(), '.chord'), 'projects', encodeProjectPath(dir));
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 {