chord-cli 0.1.0 → 0.1.2
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 +4 -2
- package/dist/clean.js +93 -0
- package/dist/cli.js +329 -0
- package/dist/db.js +286 -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/src/hooks.ts
DELETED
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
// Hook entry point for both Claude Code and Codex: `node hooks.ts <event>`, payload on stdin.
|
|
2
|
-
// Event names are chord's own; launch.ts maps each tool's hook config onto them.
|
|
3
|
-
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import fs from 'node:fs';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import * as db from './db.ts';
|
|
8
|
-
import { patchFiles } from './patch.ts';
|
|
9
|
-
import { canWrite, insideDir, normFile, type Ctx } from './tools.ts';
|
|
10
|
-
|
|
11
|
-
export type Event = 'session-start' | 'user-prompt-submit' | 'pre-tool-use' | 'post-tool-use' | 'stop' | 'session-end' | 'notification';
|
|
12
|
-
export type Tool = 'claude' | 'codex';
|
|
13
|
-
|
|
14
|
-
/** What the hook prints: plain text (context) or a JSON object; `undefined` prints nothing. */
|
|
15
|
-
export type HookOut = string | object | undefined;
|
|
16
|
-
|
|
17
|
-
const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
18
|
-
|
|
19
|
-
export function filesTouched(tool: Tool, payload: Record<string, any>): string[] {
|
|
20
|
-
const name = String(payload.tool_name ?? '');
|
|
21
|
-
const input = payload.tool_input ?? {};
|
|
22
|
-
if (tool === 'claude' && EDIT_TOOLS.has(name)) {
|
|
23
|
-
const p = input.file_path ?? input.notebook_path;
|
|
24
|
-
return typeof p === 'string' ? [p] : [];
|
|
25
|
-
}
|
|
26
|
-
if (tool === 'codex') {
|
|
27
|
-
// the patch has been seen under "command"; "input", "patch" and a bare string cost nothing to accept as well
|
|
28
|
-
const text = typeof input === 'string' ? input : [input.command, input.input, input.patch].find(v => typeof v === 'string');
|
|
29
|
-
if (name === 'apply_patch' && typeof text === 'string') return patchFiles(text);
|
|
30
|
-
if (isShell(name) && typeof text === 'string' && text.includes('*** Begin Patch')) return patchFiles(text); // apply_patch run from the shell
|
|
31
|
-
}
|
|
32
|
-
return [];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Tools that write files and must therefore name them: a call that names none is refused rather than let through. */
|
|
36
|
-
export const isEditTool = (tool: Tool, name: unknown) => (tool === 'claude' ? EDIT_TOOLS.has(String(name)) : name === 'apply_patch');
|
|
37
|
-
|
|
38
|
-
const isShell = (name: unknown) => /^(bash|powershell|shell|exec|exec_command|local_shell)$/i.test(String(name ?? ''));
|
|
39
|
-
|
|
40
|
-
const hhmm = (t: number) => new Date(t).toTimeString().slice(0, 5); // local time
|
|
41
|
-
|
|
42
|
-
export function formatMessages(ms: db.Message[]): string {
|
|
43
|
-
return ms.map(m => `[chord] message from ${m.sender} at ${hhmm(m.created_at)}:\n${m.body}`).join('\n\n');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const MAX_CLAIM_LINES = 60;
|
|
47
|
-
const MAX_WARNED_FILES = 20;
|
|
48
|
-
const MAX_INSTRUCTION_CHARS = 8000;
|
|
49
|
-
|
|
50
|
-
/** The repository's own agent instructions. The agent's cwd is the workspace, so the tools do not load them themselves. */
|
|
51
|
-
function projectInstructions(ctx: Ctx, repoDir: string): string[] {
|
|
52
|
-
const out: string[] = [];
|
|
53
|
-
for (const name of ['AGENTS.md', 'CLAUDE.md']) {
|
|
54
|
-
try {
|
|
55
|
-
const text = fs.readFileSync(path.join(ctx.worktree, name), 'utf8');
|
|
56
|
-
const body = text.length > MAX_INSTRUCTION_CHARS ? text.slice(0, MAX_INSTRUCTION_CHARS) + `\n[... truncated, read ${repoDir}/${name} for the rest]` : text;
|
|
57
|
-
out.push('', `Project instructions from ${repoDir}/${name}:`, body.trimEnd());
|
|
58
|
-
} catch { /* not present */ }
|
|
59
|
-
}
|
|
60
|
-
return out;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function briefing(ctx: Ctx): string {
|
|
64
|
-
const me = db.getAgent(ctx.db, ctx.agent);
|
|
65
|
-
const peers = db.listAgents(ctx.db).filter(a => a.name !== ctx.agent && a.status !== 'offline');
|
|
66
|
-
const claims = db.allClaims(ctx.db);
|
|
67
|
-
const repoDir = path.relative(ctx.cwd, ctx.worktree).split(path.sep).join('/') || '.';
|
|
68
|
-
const lines = [
|
|
69
|
-
`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.`,
|
|
70
|
-
'',
|
|
71
|
-
'Rules:',
|
|
72
|
-
'1. First thing: call register(task, scope) to say what you are doing.',
|
|
73
|
-
'2. Before creating or changing any file, call claim(file, intent, kind). Writes to unclaimed files are refused.',
|
|
74
|
-
'3. If claim() lists agents under must_talk_to, read their intent and send() each of them a message saying what you will change. You may write once each has replied, or after 10 minutes. Do other work meanwhile; never wait idle.',
|
|
75
|
-
'4. Do not modify files through shell commands (sed, echo >, etc.). Use the editor tools so claims are checked.',
|
|
76
|
-
'5. To talk to other agents use only chord send()/inbox(); do not use any other agent-messaging feature. Messages are asynchronous.',
|
|
77
|
-
'6. To learn about the project, read the agent list and claims below and ask a specific agent with send(); do not ask the user.',
|
|
78
|
-
'7. When your change is complete and committed on your branch, call finish() to rebase onto main and fast-forward it. If finish() reports a conflict, stop and ask the user.',
|
|
79
|
-
'8. Never merge, rebase onto, push or otherwise update the main branch yourself, and never touch the main checkout: finish() is the only way changes reach main. This holds even if the project\'s own instructions say otherwise.',
|
|
80
|
-
'9. Do not use git stash: every worktree of this repository shares one stash list, so a stash pop can hand you another agent\'s changes. Make a work-in-progress commit instead.',
|
|
81
|
-
'',
|
|
82
|
-
peers.length ? 'Agents online:' : 'No other agents are online yet.',
|
|
83
|
-
...peers.map(a => `- ${a.name} (${a.tool}, ${a.status}): ${a.task ?? 'not registered yet'}${a.scope ? ` [${a.scope}]` : ''}`),
|
|
84
|
-
'',
|
|
85
|
-
claims.length ? 'Current file claims:' : 'No files are claimed yet.',
|
|
86
|
-
...claims.slice(0, MAX_CLAIM_LINES).map(c => `- ${c.file} <- ${c.agent}: ${c.kind}, ${c.intent}${c.symbols ? ` (${c.symbols})` : ''}`),
|
|
87
|
-
...(claims.length > MAX_CLAIM_LINES ? [`- ... and ${claims.length - MAX_CLAIM_LINES} more (the claim tool reports holders of any file you claim)`] : []),
|
|
88
|
-
...projectInstructions(ctx, repoDir),
|
|
89
|
-
];
|
|
90
|
-
return lines.join('\n');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Context for the model. Plain stdout also works for Claude Code and `codex exec`, but the Codex TUI
|
|
94
|
-
* only takes the JSON form, so both events use it. */
|
|
95
|
-
const context = (hookEventName: string, additionalContext: string) => ({ hookSpecificOutput: { hookEventName, additionalContext } });
|
|
96
|
-
|
|
97
|
-
export function handleHook(ctx: Ctx, tool: Tool, event: Event, payload: Record<string, any>): HookOut {
|
|
98
|
-
db.sweep(ctx.db);
|
|
99
|
-
switch (event) {
|
|
100
|
-
case 'session-start':
|
|
101
|
-
// The agent now waits for its first prompt: idle, so messages that arrive before the user types still wake it.
|
|
102
|
-
// Claude Code also fires this after compacting a long conversation, in the middle of a turn: not idle then.
|
|
103
|
-
if (payload.source !== 'compact') db.setStatus(ctx.db, ctx.agent, 'idle');
|
|
104
|
-
return context('SessionStart', briefing(ctx));
|
|
105
|
-
|
|
106
|
-
case 'user-prompt-submit': {
|
|
107
|
-
db.setStatus(ctx.db, ctx.agent, 'busy');
|
|
108
|
-
const ms = db.takeUnread(ctx.db, ctx.agent);
|
|
109
|
-
// One line of identity on every prompt: a conversation that started without the briefing (Codex /new fires no
|
|
110
|
-
// session hooks; a resumed conversation may predate chord) still learns what it is and what the rules are.
|
|
111
|
-
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.`;
|
|
112
|
-
return context('UserPromptSubmit', ms.length
|
|
113
|
-
? `${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}`
|
|
114
|
-
: reminder);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
case 'pre-tool-use': {
|
|
118
|
-
const files = filesTouched(tool, payload);
|
|
119
|
-
if (!files.length && isEditTool(tool, payload.tool_name)) {
|
|
120
|
-
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' } };
|
|
121
|
-
}
|
|
122
|
-
// Codex resolves a patch's relative paths against the call's workdir when it gives one; otherwise the workspace
|
|
123
|
-
const workdir = tool === 'codex' && typeof payload.tool_input?.workdir === 'string' ? path.resolve(ctx.cwd, payload.tool_input.workdir) : ctx.cwd;
|
|
124
|
-
for (const f of files) {
|
|
125
|
-
const abs = path.resolve(workdir, f);
|
|
126
|
-
if (!insideDir(ctx.worktree, abs)) {
|
|
127
|
-
// the main checkout and the other agents' workspaces are off limits; anything else is not ours to police
|
|
128
|
-
if (ctx.project && insideDir(ctx.project, abs) && !insideDir(ctx.cwd, abs)) {
|
|
129
|
-
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `${f} is outside your workspace: edit files only inside your own worktree ${ctx.worktree}` } };
|
|
130
|
-
}
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
const check = canWrite(ctx, abs);
|
|
134
|
-
if (!check.ok) {
|
|
135
|
-
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: check.reason } };
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
return undefined;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
case 'post-tool-use': {
|
|
142
|
-
// Runs after every tool call. Any tool call means the agent is busy (another plugin's Stop hook may have
|
|
143
|
-
// blocked the end of the turn, or compaction may have run). Shell commands are checked for what they did
|
|
144
|
-
// behind chord's back, and messages that arrived mid-turn are handed over so a busy agent hears from its
|
|
145
|
-
// peers at the next tool call, not at turn end.
|
|
146
|
-
db.setStatus(ctx.db, ctx.agent, 'busy');
|
|
147
|
-
const parts: string[] = [];
|
|
148
|
-
if (isShell(payload.tool_name)) parts.push(...shellWarnings(ctx, payload));
|
|
149
|
-
const ms = db.takeUnread(ctx.db, ctx.agent);
|
|
150
|
-
if (ms.length) 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.`);
|
|
151
|
-
return parts.length ? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: parts.join('\n\n') } } : undefined;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
case 'stop': {
|
|
155
|
-
const ms = db.takeUnread(ctx.db, ctx.agent);
|
|
156
|
-
if (ms.length) return { decision: 'block', reason: formatMessages(ms) + '\n\nHandle these messages before stopping.' };
|
|
157
|
-
db.setStatus(ctx.db, ctx.agent, 'idle');
|
|
158
|
-
return undefined;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
case 'notification':
|
|
162
|
-
// Claude Code: "idle_prompt" fires about a minute after Claude stopped and nobody typed. It is the only
|
|
163
|
-
// signal after an Esc interrupt, which fires no Stop hook, so it is how a cut-off turn becomes idle.
|
|
164
|
-
if (payload.notification_type === 'idle_prompt') db.setStatus(ctx.db, ctx.agent, 'idle');
|
|
165
|
-
return undefined;
|
|
166
|
-
|
|
167
|
-
case 'session-end':
|
|
168
|
-
// A session ending is not the agent leaving: /clear, /resume and /compact end one and start another in the same
|
|
169
|
-
// process, and the reason strings differ between tools and versions. Going offline and releasing claims is the
|
|
170
|
-
// wrapper's job when the process exits (and the sweep's if it crashes); here the agent only becomes idle.
|
|
171
|
-
db.setStatus(ctx.db, ctx.agent, 'idle');
|
|
172
|
-
return undefined;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** Paths changed according to `git status --porcelain=v1 -z -uall`: entries are NUL-separated, a rename
|
|
177
|
-
* or copy is followed by the original path, and nothing is quoted or folded into directories. */
|
|
178
|
-
export function changedPaths(statusZ: string): string[] {
|
|
179
|
-
const parts = statusZ.split('\0');
|
|
180
|
-
const out: string[] = [];
|
|
181
|
-
for (let i = 0; i < parts.length; i++) {
|
|
182
|
-
const entry = parts[i];
|
|
183
|
-
if (!entry) continue;
|
|
184
|
-
out.push(entry.slice(3));
|
|
185
|
-
const xy = entry.slice(0, 2);
|
|
186
|
-
if (/R/.test(xy)) { i++; if (parts[i]) out.push(parts[i]); } // a rename changes its "from" path too
|
|
187
|
-
else if (/C/.test(xy)) i++; // a copy leaves its source alone
|
|
188
|
-
}
|
|
189
|
-
return out;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** The old names of renames in NUL-separated status output: a rename changes that path too, and diff --name-only
|
|
193
|
-
* lists only the new one. */
|
|
194
|
-
export function renameSources(statusZ: string): string[] {
|
|
195
|
-
const parts = statusZ.split('\0');
|
|
196
|
-
const out: string[] = [];
|
|
197
|
-
for (let i = 0; i < parts.length; i++) {
|
|
198
|
-
const entry = parts[i];
|
|
199
|
-
if (!entry) continue;
|
|
200
|
-
if (/R/.test(entry.slice(0, 2))) { i++; if (parts[i]) out.push(parts[i]); }
|
|
201
|
-
else if (/C/.test(entry.slice(0, 2))) i++;
|
|
202
|
-
}
|
|
203
|
-
return out;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** git output, or '' when the command fails; optional locks off so this read-only look never fights the agent's own git. */
|
|
207
|
-
const gitOut = (cwd: string, ...args: string[]): string => {
|
|
208
|
-
try { return execFileSync('git', ['--no-optional-locks', ...args], { cwd, encoding: 'utf8', env: db.gitEnv(), maxBuffer: 64 * 1024 * 1024 }); } catch { return ''; }
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
/** What a shell command may have done behind chord's back: edits or commits to unclaimed files, git stash, and
|
|
212
|
-
* changes to the main checkout. */
|
|
213
|
-
export function shellWarnings(ctx: Ctx, payload: Record<string, any>): string[] {
|
|
214
|
-
const out: string[] = [];
|
|
215
|
-
const mine = new Set(db.claimsBy(ctx.db, ctx.agent).map(c => c.file.toLowerCase()));
|
|
216
|
-
// uncommitted changes (-unormal folds an untracked directory into one entry) plus whatever the branch has
|
|
217
|
-
// committed since it left main: a commit made from the shell is no more a claim than an edit is
|
|
218
|
-
// tracked files: real changes only, so a repository whose files differ in line endings from the moment they are
|
|
219
|
-
// checked out does not make every shell command cry wolf; untracked files from status, directories folded
|
|
220
|
-
const status = gitOut(ctx.worktree, 'status', '--porcelain=v1', '-z', '-unormal');
|
|
221
|
-
const untracked = status.split('\0').filter(e => e.startsWith('?? ')).map(e => e.slice(3));
|
|
222
|
-
const modified = ['', '--cached'].flatMap(c => gitOut(ctx.worktree, 'diff', ...(c ? [c] : []), '--name-only', '-z', '--ignore-cr-at-eol').split('\0'));
|
|
223
|
-
const root = db.mainRoot(ctx.worktree);
|
|
224
|
-
const main = root ? gitOut(root, 'rev-parse', '--abbrev-ref', 'HEAD').trim() : '';
|
|
225
|
-
const base = main && main !== 'HEAD' ? gitOut(ctx.worktree, 'merge-base', 'HEAD', main).trim() : '';
|
|
226
|
-
const committed = base ? gitOut(ctx.worktree, 'diff', '--name-only', '-z', base, 'HEAD').split('\0').filter(Boolean) : [];
|
|
227
|
-
const touched = [...new Set([...modified.filter(Boolean), ...untracked, ...renameSources(status), ...committed])];
|
|
228
|
-
const unclaimed = touched.map(f => normFile(ctx, path.join(ctx.worktree, f))).filter(f => !mine.has(f.toLowerCase()));
|
|
229
|
-
const shown = unclaimed.slice(0, MAX_WARNED_FILES);
|
|
230
|
-
if (unclaimed.length) 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.`);
|
|
231
|
-
const cmd = typeof payload.tool_input?.command === 'string' ? payload.tool_input.command : '';
|
|
232
|
-
if (/\bgit\b[^|;&\n]*\bstash\b/.test(cmd)) 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));
|
|
235
|
-
if (dirty.length) 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().`);
|
|
236
|
-
}
|
|
237
|
-
return out;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** handleHook, but never lets an exception reach the tool: a broken hook must not fail open on a write. */
|
|
241
|
-
export function safeHandle(ctx: Ctx, tool: Tool, event: Event, payload: Record<string, any>): HookOut {
|
|
242
|
-
try {
|
|
243
|
-
return handleHook(ctx, tool, event, payload);
|
|
244
|
-
} catch (e) {
|
|
245
|
-
const msg = (e as Error).message;
|
|
246
|
-
db.log(`hook ${event} failed: ${msg}`);
|
|
247
|
-
if (event === 'pre-tool-use' && filesTouched(tool, payload).length) {
|
|
248
|
-
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `chord could not check this write (${msg}); try again or report it to the user` } };
|
|
249
|
-
}
|
|
250
|
-
return undefined;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
async function main(): Promise<void> {
|
|
255
|
-
const started = Date.now();
|
|
256
|
-
const event = process.argv[2] as Event;
|
|
257
|
-
const agent = process.env.CHORD_AGENT_ID;
|
|
258
|
-
const file = process.env.CHORD_DB;
|
|
259
|
-
const tool = process.env.CHORD_TOOL as Tool;
|
|
260
|
-
if (!agent || !file || !tool) { process.stderr.write('chord hook: CHORD_AGENT_ID, CHORD_DB and CHORD_TOOL must be set\n'); process.exit(0); }
|
|
261
|
-
const chunks: Buffer[] = [];
|
|
262
|
-
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
263
|
-
const raw = Buffer.concat(chunks).toString('utf8'); // decode once: a multi-byte character may straddle chunks
|
|
264
|
-
let payload: Record<string, any> = {};
|
|
265
|
-
try { payload = JSON.parse(raw); } catch { /* no payload */ }
|
|
266
|
-
// 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
|
|
267
|
-
const cwd = process.env.CHORD_WORKSPACE ?? (typeof payload.cwd === 'string' ? payload.cwd : process.cwd());
|
|
268
|
-
let ctx: Ctx;
|
|
269
|
-
try {
|
|
270
|
-
ctx = { db: db.openDb(file), agent, cwd, worktree: process.env.CHORD_WORKTREE ?? cwd, project: process.env.CHORD_PROJECT };
|
|
271
|
-
} catch (e) {
|
|
272
|
-
// the database would not open: a write must still not go through unchecked
|
|
273
|
-
const msg = (e as Error).message;
|
|
274
|
-
process.stderr.write(`chord hook: cannot open ${file}: ${msg}\n`);
|
|
275
|
-
if (event === 'pre-tool-use' && filesTouched(tool, payload).length) {
|
|
276
|
-
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `chord could not check this write (${msg}); try again or report it to the user` } }));
|
|
277
|
-
}
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
if (process.env.CHORD_DEBUG_HOOKS) 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 ?? '-'}`);
|
|
281
|
-
const out = safeHandle(ctx, tool, event, payload);
|
|
282
|
-
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`);
|
|
283
|
-
if (out !== undefined) process.stdout.write(typeof out === 'string' ? out : JSON.stringify(out));
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const isMain = (() => { try { return fs.realpathSync.native(process.argv[1] ?? '') === fs.realpathSync.native(fileURLToPath(new URL(import.meta.url))); } catch { return false; } })();
|
|
287
|
-
if (isMain) main();
|
package/src/launch.ts
DELETED
|
@@ -1,356 +0,0 @@
|
|
|
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 type { DatabaseSync } from 'node:sqlite';
|
|
11
|
-
import * as db from './db.ts';
|
|
12
|
-
import { ensureStateDir, resolveProject, type Project } from './project.ts';
|
|
13
|
-
import { claudeMcpChoices, claudeTrusts } from './trust.ts';
|
|
14
|
-
import type { Event, Tool } from './hooks.ts';
|
|
15
|
-
|
|
16
|
-
const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, encoding: 'utf8', env: db.gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
17
|
-
|
|
18
|
-
// realpath gives one canonical spelling (drive letter and directory case): Codex persists hook trust by hashing the command string
|
|
19
|
-
const here = (f: string) => fs.realpathSync.native(fileURLToPath(new URL(f, import.meta.url))).split(path.sep).join('/');
|
|
20
|
-
export const HOOKS_TS = here('./hooks.ts');
|
|
21
|
-
export const SERVE_TS = here('./serve.ts');
|
|
22
|
-
|
|
23
|
-
const sleepSync = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
24
|
-
|
|
25
|
-
/** `staleWaitMs`: how long to watch a stale heartbeat before calling its agent dead (tests shorten it). */
|
|
26
|
-
export function pickName(d: DatabaseSync, tool: Tool, project: string, override?: string, staleWaitMs = 2500): string {
|
|
27
|
-
if (override) {
|
|
28
|
-
if (!/^[A-Za-z0-9._-]+$/.test(override)) throw new Error('agent names may only contain letters, digits, ".", "_" and "-"');
|
|
29
|
-
// the name becomes a directory and a branch: ".." would escape agents/, git refuses components starting with "."
|
|
30
|
-
// or ending in "." or ".lock", and Windows reserves CON, NUL, COM1 and friends
|
|
31
|
-
if (/^[.-]|\.$|\.\.|\.lock$/i.test(override) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(override)) 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...)`);
|
|
32
|
-
// workspace paths are case-insensitive on Windows and macOS, so names differing only in case would share one
|
|
33
|
-
const clash = db.listAgents(d).find(a => a.name.toLowerCase() === override.toLowerCase());
|
|
34
|
-
if (clash && clash.name !== override) throw new Error(`agent "${clash.name}" already exists; names must differ in more than case`);
|
|
35
|
-
if (clash && clash.status !== 'offline') {
|
|
36
|
-
// A lone agent killed outright, whose pid Windows has since handed to another process, is never swept: nobody
|
|
37
|
-
// else's heartbeat is fresh to compare with. A heartbeat that stays stale while we watch it is a dead wrapper
|
|
38
|
-
// (a live one writes every second), and this launch itself proves the machine is awake.
|
|
39
|
-
const stale = clash.heartbeat_at !== null && Date.now() - clash.heartbeat_at > db.HEARTBEAT_STALE_MS;
|
|
40
|
-
if (stale) sleepSync(staleWaitMs);
|
|
41
|
-
const again = stale ? db.getAgent(d, clash.name) : clash;
|
|
42
|
-
if (!stale || again?.heartbeat_at !== clash.heartbeat_at) 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)) return name;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** The agent's workspace: <project>/chord/agents/<name>. Its repository worktree sits inside at <repoName>. */
|
|
58
|
-
export const workspacePath = (p: Project, name: string) => path.join(p.dir, 'chord', 'agents', name);
|
|
59
|
-
|
|
60
|
-
/** Create (or reuse) a worktree of `repo` at `wt` on branch chord/<name>, forked from the current main HEAD. */
|
|
61
|
-
export function ensureWorktree(repo: string, wt: string, name: string): string {
|
|
62
|
-
const branch = `chord/${name}`;
|
|
63
|
-
if (fs.existsSync(path.join(wt, '.git'))) {
|
|
64
|
-
try { git(wt, 'rev-parse', '--git-dir'); } catch { // the link is broken, e.g. the project was moved
|
|
65
|
-
git(repo, 'worktree', 'repair', wt);
|
|
66
|
-
git(wt, 'rev-parse', '--git-dir');
|
|
67
|
-
}
|
|
68
|
-
return onBranch(wt, branch);
|
|
69
|
-
}
|
|
70
|
-
if (fs.existsSync(wt) && fs.readdirSync(wt).length) throw new Error(`${wt} exists but is not a git worktree; remove it or pick another --name`);
|
|
71
|
-
fs.mkdirSync(path.dirname(wt), { recursive: true });
|
|
72
|
-
// Every worktree directory is named after the repository, so git gives them ids repo, repo1, repo2... After the
|
|
73
|
-
// project moves, the registrations point at the old place: pruning first would drop them all and let this new
|
|
74
|
-
// worktree take the id "repo" that belonged to another agent, which a later repair of that agent would then share.
|
|
75
|
-
// Repair the siblings first; prune only removes what the user really deleted.
|
|
76
|
-
const siblings = siblingWorktrees(wt);
|
|
77
|
-
if (siblings.length) { try { git(repo, 'worktree', 'repair', ...siblings); } catch { /* a sibling beyond repair is pruned below */ } }
|
|
78
|
-
git(repo, 'worktree', 'prune');
|
|
79
|
-
const exists = git(repo, 'branch', '--list', branch) !== '';
|
|
80
|
-
try {
|
|
81
|
-
// core.longpaths: a worktree path is some forty characters longer than the main checkout's, which on Windows is
|
|
82
|
-
// enough to push a deep repository past MAX_PATH
|
|
83
|
-
if (exists) git(repo, '-c', 'core.longpaths=true', 'worktree', 'add', wt, branch);
|
|
84
|
-
else git(repo, '-c', 'core.longpaths=true', 'worktree', 'add', '-b', branch, wt);
|
|
85
|
-
} catch (e) {
|
|
86
|
-
// nothing half-made stays behind: a stale branch or an empty directory would trip the next launch
|
|
87
|
-
try { git(repo, 'worktree', 'prune'); } catch { /* best effort */ }
|
|
88
|
-
if (!exists) { try { git(repo, 'branch', '-D', branch); } catch { /* never created */ } }
|
|
89
|
-
try { fs.rmSync(wt, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
90
|
-
const msg = ((e as { stderr?: string }).stderr ?? (e as Error).message).trim();
|
|
91
|
-
if (/too long/i.test(msg)) 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`);
|
|
92
|
-
throw new Error(`could not create the worktree ${wt}: ${msg}`);
|
|
93
|
-
}
|
|
94
|
-
return onBranch(wt, branch);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** The other agents' worktrees beside `wt` (chord/agents/<other>/<repoName>) that still exist on disk. */
|
|
98
|
-
function siblingWorktrees(wt: string): string[] {
|
|
99
|
-
const agents = path.dirname(path.dirname(wt));
|
|
100
|
-
try {
|
|
101
|
-
return fs.readdirSync(agents)
|
|
102
|
-
.map(n => path.join(agents, n, path.basename(wt)))
|
|
103
|
-
.filter(p => p !== wt && fs.existsSync(path.join(p, '.git')));
|
|
104
|
-
} catch { return []; }
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** A worktree that is not on its own branch is sharing a registration with another agent: refuse rather than let two
|
|
108
|
-
* agents work on one index. */
|
|
109
|
-
function onBranch(wt: string, branch: string): string {
|
|
110
|
-
const head = git(wt, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
111
|
-
if (head !== branch) 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`);
|
|
112
|
-
return wt;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// ctxLimit: Codex caps a hook's additionalContext (default about 2,500 tokens); the briefing and message batches need more
|
|
116
|
-
const EVENTS: { event: Event; claude: string; codex?: string; matcher?: { claude: string; codex?: string }; timeout?: number; ctxLimit?: number }[] = [
|
|
117
|
-
{ event: 'session-start', claude: 'SessionStart', codex: 'SessionStart', ctxLimit: 16000 },
|
|
118
|
-
{ event: 'notification', claude: 'Notification', matcher: { claude: 'idle_prompt' } }, // Claude only: Codex reports idle through OSC 7501
|
|
119
|
-
{ event: 'user-prompt-submit', claude: 'UserPromptSubmit', codex: 'UserPromptSubmit', ctxLimit: 16000 },
|
|
120
|
-
{ 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
|
|
121
|
-
{ event: 'post-tool-use', claude: 'PostToolUse', codex: 'PostToolUse', ctxLimit: 16000 }, // every tool: shell-edit warnings and mid-turn message delivery
|
|
122
|
-
{ event: 'stop', claude: 'Stop', codex: 'Stop' },
|
|
123
|
-
{ event: 'session-end', claude: 'SessionEnd', codex: 'SessionEnd', timeout: 10 },
|
|
124
|
-
];
|
|
125
|
-
|
|
126
|
-
/** The node that runs chord, by absolute path, for the MCP server (spawned from an argument list, so spaces in the
|
|
127
|
-
* path are no problem). The hook commands say plain `node`: Codex cannot run a command whose executable is quoted
|
|
128
|
-
* ("C:/Program Files/nodejs/node.exe" fails silently, a quoted argument is fine), so instead the wrapper puts this
|
|
129
|
-
* node's directory at the front of the agent's PATH (see withNodeOnPath), which also keeps an older node on the
|
|
130
|
-
* user's PATH (nvm, a project .nvmrc) from being the one that cannot run .ts files. */
|
|
131
|
-
export const NODE = fs.realpathSync.native(process.execPath).split(path.sep).join('/');
|
|
132
|
-
export const hookCommand = (event: Event) => `node "${HOOKS_TS}" ${event}`;
|
|
133
|
-
|
|
134
|
-
/** The environment with the directory of the node running chord first on PATH (whatever the variable's case). */
|
|
135
|
-
export function withNodeOnPath(env: Record<string, string>): Record<string, string> {
|
|
136
|
-
const key = Object.keys(env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH';
|
|
137
|
-
const dir = path.dirname(fs.realpathSync.native(process.execPath));
|
|
138
|
-
const current = env[key] ?? '';
|
|
139
|
-
if (current.split(path.delimiter)[0] === dir) return env;
|
|
140
|
-
return { ...env, [key]: current ? `${dir}${path.delimiter}${current}` : dir };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** CLI arguments that inject chord's hooks and MCP server into Claude Code. */
|
|
144
|
-
/** `projectConfig`: also load the repository's own .claude settings and .mcp.json. Only when the user has trusted the
|
|
145
|
-
* repository in Claude Code, because Claude's trust dialogue is what normally stands between a cloned repository's
|
|
146
|
-
* hooks and MCP servers and running them. */
|
|
147
|
-
export interface ProjectConfigSource { repo: string; claudeConfig?: string }
|
|
148
|
-
|
|
149
|
-
/** `$CLAUDE_PROJECT_DIR` in a repository's hook commands and MCP definitions means the repository, which for an agent
|
|
150
|
-
* is its worktree; Claude would point it at the workspace. */
|
|
151
|
-
export function substituteProjectDir<T>(value: T, worktree: string): T {
|
|
152
|
-
const dir = worktree.split(path.sep).join('/');
|
|
153
|
-
const walk = (v: unknown): unknown => {
|
|
154
|
-
if (typeof v === 'string') return v.replace(/\$\{CLAUDE_PROJECT_DIR\}|\$CLAUDE_PROJECT_DIR/g, dir);
|
|
155
|
-
if (Array.isArray(v)) return v.map(walk);
|
|
156
|
-
if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
|
|
157
|
-
return v;
|
|
158
|
-
};
|
|
159
|
-
return walk(value) as T;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** The repository's .mcp.json as an --mcp-config value, reduced to the servers the user approved in Claude Code, or
|
|
163
|
-
* null when there is nothing to pass. */
|
|
164
|
-
export function projectMcpConfig(worktree: string, source: ProjectConfigSource): string | null {
|
|
165
|
-
const file = path.join(worktree, '.mcp.json');
|
|
166
|
-
if (!fs.existsSync(file)) return null;
|
|
167
|
-
let parsed: { mcpServers?: Record<string, unknown> };
|
|
168
|
-
try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { process.stderr.write(`chord: ${file} is not valid JSON (${(e as Error).message}); its servers are not loaded\n`); return null; }
|
|
169
|
-
const servers = parsed?.mcpServers ?? {};
|
|
170
|
-
const choice = claudeMcpChoices(source.repo, source.claudeConfig);
|
|
171
|
-
const keep = Object.fromEntries(Object.entries(servers).filter(([name]) => !choice.disabled.includes(name) && (choice.all || choice.enabled.includes(name))));
|
|
172
|
-
const skipped = Object.keys(servers).filter(n => !(n in keep));
|
|
173
|
-
if (skipped.length) 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`);
|
|
174
|
-
return Object.keys(keep).length ? JSON.stringify({ mcpServers: substituteProjectDir(keep, worktree) }) : null;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export function claudeArgs(env: Record<string, string>, projectConfig: boolean | ProjectConfigSource = false): string[] {
|
|
178
|
-
const hooks: Record<string, unknown[]> = {};
|
|
179
|
-
for (const e of EVENTS) {
|
|
180
|
-
hooks[e.claude] = [{
|
|
181
|
-
...(e.matcher ? { matcher: e.matcher.claude } : {}),
|
|
182
|
-
hooks: [{ type: 'command', command: hookCommand(e.event), ...(e.timeout ? { timeout: e.timeout } : {}) }],
|
|
183
|
-
}];
|
|
184
|
-
}
|
|
185
|
-
const mcp = { mcpServers: { chord: { command: NODE, args: [SERVE_TS], env } } };
|
|
186
|
-
// chord's own tools are pre-approved: in the default permission mode every claim, send and finish would otherwise
|
|
187
|
-
// stop at a prompt, and an unattended agent would never get past the first one
|
|
188
|
-
const ours = { hooks, permissions: { allow: ['mcp__chord'] } };
|
|
189
|
-
// The agent's cwd is its workspace, not the repository, so Claude Code would not pick up the repository's
|
|
190
|
-
// own CLAUDE.md, .claude/settings*.json or .mcp.json. --add-dir covers CLAUDE.md; the others are passed along.
|
|
191
|
-
const wt = env.CHORD_WORKTREE;
|
|
192
|
-
const addDir = wt ? ['--add-dir', wt] : [];
|
|
193
|
-
const source: ProjectConfigSource | null = projectConfig === true ? { repo: wt ?? '' } : projectConfig || null;
|
|
194
|
-
const projectMcp = source && wt ? projectMcpConfig(wt, source) : null;
|
|
195
|
-
const mcpFiles = projectMcp ? [projectMcp] : [];
|
|
196
|
-
const settings = source ? mergeProjectSettings(ours, wt) : ours;
|
|
197
|
-
// --add-dir and --mcp-config take any number of values, so they must not come last: the user's own arguments
|
|
198
|
-
// follow these, and a prompt such as `chord claude "fix the tests"` would be read as another directory.
|
|
199
|
-
// --settings takes exactly one value and closes the list.
|
|
200
|
-
return [...addDir, '--mcp-config', JSON.stringify(mcp), ...mcpFiles, '--settings', JSON.stringify(settings)];
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** The user's own --settings folded under chord's (chord's hooks stay, theirs are added), in place in `args`. */
|
|
204
|
-
export function mergeUserSettings(args: string[], user: Record<string, any>): string[] {
|
|
205
|
-
const i = args.indexOf('--settings');
|
|
206
|
-
if (i < 0 || i + 1 >= args.length) return args;
|
|
207
|
-
const out = [...args];
|
|
208
|
-
out[i + 1] = JSON.stringify(mergeSettings(JSON.parse(args[i + 1]), user));
|
|
209
|
-
return out;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const PROJECT_CONFIG_FILES = ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json'];
|
|
213
|
-
export const hasProjectConfig = (worktree: string) => PROJECT_CONFIG_FILES.some(f => fs.existsSync(path.join(worktree, f)));
|
|
214
|
-
|
|
215
|
-
/** chord's settings plus the repository's .claude/settings.json and settings.local.json (later files win,
|
|
216
|
-
* hook lists are concatenated so both chord's and the project's hooks run). */
|
|
217
|
-
/** Hook lists of `b` appended to those of `a`, so both sets run. */
|
|
218
|
-
function concatHooks(a: Record<string, any>, b: Record<string, any>): Record<string, unknown[]> {
|
|
219
|
-
const hooks: Record<string, unknown[]> = { ...(a.hooks ?? {}) };
|
|
220
|
-
for (const [event, list] of Object.entries(b.hooks ?? {})) hooks[event] = [...(hooks[event] ?? []), ...(Array.isArray(list) ? list : [])];
|
|
221
|
-
return hooks;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** Somebody else's settings underneath chord's: their keys fill in, chord's keys win, hook lists are concatenated,
|
|
225
|
-
* and disableAllHooks is dropped because it would switch chord itself off. */
|
|
226
|
-
export function mergeSettings(ours: Record<string, any>, theirs: Record<string, any>): Record<string, any> {
|
|
227
|
-
const out: Record<string, any> = { ...theirs, ...ours, hooks: concatHooks(ours, theirs) };
|
|
228
|
-
if (ours.permissions || theirs.permissions) {
|
|
229
|
-
// permission lists add up, as across Claude's own settings levels; other permission keys follow the usual precedence
|
|
230
|
-
const lists = (k: 'allow' | 'deny' | 'ask') => [...new Set([...(ours.permissions?.[k] ?? []), ...(theirs.permissions?.[k] ?? [])])];
|
|
231
|
-
out.permissions = { ...theirs.permissions, ...ours.permissions };
|
|
232
|
-
for (const k of ['allow', 'deny', 'ask'] as const) if (lists(k).length) out.permissions[k] = lists(k);
|
|
233
|
-
}
|
|
234
|
-
delete out.disableAllHooks;
|
|
235
|
-
return out;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
export function mergeProjectSettings(ours: Record<string, any>, worktree?: string): Record<string, any> {
|
|
239
|
-
if (!worktree) return ours;
|
|
240
|
-
let theirs: Record<string, any> = {};
|
|
241
|
-
for (const name of ['settings.json', 'settings.local.json']) { // settings.local.json wins, as in Claude Code itself
|
|
242
|
-
let one: Record<string, any>;
|
|
243
|
-
try { one = JSON.parse(fs.readFileSync(path.join(worktree, '.claude', name), 'utf8')); } catch { continue; }
|
|
244
|
-
if (typeof one !== 'object' || one === null) continue;
|
|
245
|
-
one = substituteProjectDir(one, worktree);
|
|
246
|
-
theirs = { ...theirs, ...one, hooks: concatHooks(theirs, one) };
|
|
247
|
-
}
|
|
248
|
-
return mergeSettings(ours, theirs);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** A JSON string literal is also a valid TOML basic string, quotes, backslashes and non-ASCII included. */
|
|
252
|
-
const toml = (s: string) => JSON.stringify(s);
|
|
253
|
-
|
|
254
|
-
/** CLI arguments that inject chord's hooks and MCP server into Codex. Values are TOML. */
|
|
255
|
-
/** `writableRoots`: directories Codex's workspace-write sandbox must also be allowed to write. A linked worktree keeps
|
|
256
|
-
* its index and refs under the main checkout's .git, outside the agent's workspace, so without this every git commit
|
|
257
|
-
* fails inside the sandbox. */
|
|
258
|
-
export function codexArgs(env: Record<string, string>, writableRoots: string[] = []): string[] {
|
|
259
|
-
const args: string[] = [];
|
|
260
|
-
if (writableRoots.length) args.push('-c', `sandbox_workspace_write.writable_roots=[${writableRoots.map(toml).join(',')}]`);
|
|
261
|
-
for (const e of EVENTS) {
|
|
262
|
-
if (!e.codex) continue;
|
|
263
|
-
const matcher = e.matcher?.codex ? `matcher=${toml(e.matcher.codex)},` : '';
|
|
264
|
-
const limit = e.ctxLimit ? `,additionalContextLimit=${e.ctxLimit}` : '';
|
|
265
|
-
args.push('-c', `hooks.${e.codex}=[{${matcher}hooks=[{type="command",command=${toml(hookCommand(e.event))}${limit}}]}]`);
|
|
266
|
-
}
|
|
267
|
-
const envToml = Object.entries(env).map(([k, v]) => `${k}=${toml(v)}`).join(',');
|
|
268
|
-
// finish may wait minutes for the merge lock; Codex's default tool timeout is 60s
|
|
269
|
-
args.push('-c', `mcp_servers.chord={command=${toml(NODE)},args=[${toml(SERVE_TS)}],env={${envToml}},tool_timeout_sec=600}`);
|
|
270
|
-
return args;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
export interface Launch {
|
|
274
|
-
name: string;
|
|
275
|
-
project: Project;
|
|
276
|
-
cwd: string; // the agent's workspace W
|
|
277
|
-
worktree: string; // W/<repoName>, the repository worktree
|
|
278
|
-
dbFile: string;
|
|
279
|
-
env: Record<string, string>;
|
|
280
|
-
args: string[];
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const fwd = (p: string) => p.split(path.sep).join('/');
|
|
284
|
-
|
|
285
|
-
/** Resolve everything for `chord <tool> [--name x] [tool args...]` without spawning. Throws NeedsRestructure. */
|
|
286
|
-
export function prepare(tool: Tool, cwd: string, override?: string): Launch {
|
|
287
|
-
const project = resolveProject(cwd);
|
|
288
|
-
try { git(project.repo, 'rev-parse', '--verify', '-q', 'HEAD'); } catch { throw new Error(`${project.repo} has no commits yet; make an initial commit first`); }
|
|
289
|
-
ensureStateDir(project);
|
|
290
|
-
const dbFile = db.dbPath(project.stateDir);
|
|
291
|
-
const d = db.openDb(dbFile);
|
|
292
|
-
db.sweep(d);
|
|
293
|
-
const name = pickName(d, tool, project.dir, override);
|
|
294
|
-
const ws = workspacePath(project, name);
|
|
295
|
-
// Take the name now, before the worktree and setup take their time: a second launch under the same name a moment
|
|
296
|
-
// later must find it online. The wrapper re-registers with the tool's own pid when it starts.
|
|
297
|
-
db.addAgent(d, { name, tool, pid: process.pid, cwd: ws, worktree: path.join(ws, project.repoName), heartbeat: true });
|
|
298
|
-
try {
|
|
299
|
-
const config = readConfig(project);
|
|
300
|
-
const worktree = ensureWorktree(project.repo, path.join(ws, project.repoName), name);
|
|
301
|
-
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) };
|
|
302
|
-
// Setup is owed until it has finished once: a marker, not the worktree's existence, says so, because a setup
|
|
303
|
-
// interrupted half-way leaves the worktree behind and a relaunch must not take a half-prepared checkout for ready.
|
|
304
|
-
const marker = setupMarker(project, name);
|
|
305
|
-
if (config.setup && !fs.existsSync(marker)) {
|
|
306
|
-
runSetup(project, name, worktree, config.setup, env);
|
|
307
|
-
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
308
|
-
fs.writeFileSync(marker, `${Date.now()}\n`);
|
|
309
|
-
}
|
|
310
|
-
let projectConfig: boolean | ProjectConfigSource = false;
|
|
311
|
-
if (tool === 'claude' && hasProjectConfig(worktree)) {
|
|
312
|
-
projectConfig = claudeTrusts(project.repo) || claudeTrusts(project.dir) ? { repo: project.repo } : false;
|
|
313
|
-
if (!projectConfig) 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`);
|
|
314
|
-
}
|
|
315
|
-
return { name, project, cwd: ws, worktree, dbFile, env, args: tool === 'claude' ? claudeArgs(env, projectConfig) : codexArgs(env, [path.join(project.repo, '.git')]) };
|
|
316
|
-
} catch (e) {
|
|
317
|
-
db.setStatus(d, name, 'offline'); // the launch failed: the name is free again
|
|
318
|
-
throw e;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** <project>/chord/config.json. `setup` is a shell command run in every new worktree before the agent starts,
|
|
323
|
-
* for example "npm install". */
|
|
324
|
-
export function readConfig(project: Project): { setup?: string } {
|
|
325
|
-
const file = path.join(project.dir, 'chord', 'config.json');
|
|
326
|
-
let raw: string;
|
|
327
|
-
try { raw = fs.readFileSync(file, 'utf8'); } catch { return {}; }
|
|
328
|
-
let cfg: unknown;
|
|
329
|
-
try { cfg = JSON.parse(raw); } catch (e) { throw new Error(`${file} is not valid JSON: ${(e as Error).message}`); }
|
|
330
|
-
if (typeof cfg !== 'object' || cfg === null) throw new Error(`${file} must hold a JSON object`);
|
|
331
|
-
const setup = (cfg as { setup?: unknown }).setup;
|
|
332
|
-
if (setup !== undefined && typeof setup !== 'string') throw new Error(`${file}: "setup" must be a string (a shell command)`);
|
|
333
|
-
return setup ? { setup } : {};
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/** Where `chord clean` and a relaunch look to see whether the setup command has run for this agent. */
|
|
337
|
-
export const setupMarker = (project: Project, name: string) => path.join(project.stateDir, 'setup-done', name);
|
|
338
|
-
|
|
339
|
-
/** A failed setup aborts the launch, and the half-prepared worktree and branch go with it so the next launch starts clean. */
|
|
340
|
-
function runSetup(project: Project, name: string, worktree: string, setup: string, env: Record<string, string>): void {
|
|
341
|
-
process.stderr.write(`chord: running setup in ${worktree}: ${setup}\n`);
|
|
342
|
-
// Ctrl+C goes to the setup command, which then fails and is cleaned up below; without a listener it would kill chord
|
|
343
|
-
// itself first and leave the worktree and branch behind
|
|
344
|
-
const ignoreSigint = () => {};
|
|
345
|
-
process.on('SIGINT', ignoreSigint);
|
|
346
|
-
try {
|
|
347
|
-
execSync(setup, { cwd: worktree, stdio: 'inherit', env: { ...process.env, ...env } });
|
|
348
|
-
} catch (e) {
|
|
349
|
-
try { git(project.repo, 'worktree', 'remove', '--force', worktree); } catch { /* best effort */ }
|
|
350
|
-
try { git(project.repo, 'branch', '-D', `chord/${name}`); } catch { /* best effort */ }
|
|
351
|
-
const status = (e as { status?: number | null }).status;
|
|
352
|
-
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`);
|
|
353
|
-
} finally {
|
|
354
|
-
process.off('SIGINT', ignoreSigint);
|
|
355
|
-
}
|
|
356
|
-
}
|