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/dist/pty.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Run the agent under a pseudo-terminal: pass the user's keyboard through, and when the
|
|
2
|
+
// agent is idle with unread messages, type a nudge in as if the user had.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
6
|
+
import pty from 'node-pty';
|
|
7
|
+
import * as db from "./db.js";
|
|
8
|
+
import { withNodeOnPath } from "./launch.js";
|
|
9
|
+
/** First PATH entry holding `<tool>.exe` or `<tool>.cmd`. Pure Node: `where.exe` output is in the OEM code page. */
|
|
10
|
+
export function whereIs(tool, pathVar = process.env.PATH ?? '', exists = p => fs.existsSync(p)) {
|
|
11
|
+
for (const dir of pathVar.split(path.delimiter).filter(Boolean)) {
|
|
12
|
+
for (const ext of ['.exe', '.cmd']) {
|
|
13
|
+
const p = path.join(dir, tool + ext);
|
|
14
|
+
if (exists(p))
|
|
15
|
+
return p;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
/** Executable and leading args for a tool. On Windows an npm .cmd shim is unwrapped to
|
|
21
|
+
* `node <script>` so JSON arguments reach the process intact instead of going through cmd.exe. */
|
|
22
|
+
export function resolveCommand(tool, readFile = (f) => fs.readFileSync(f, 'utf8'), lookup = whereIs) {
|
|
23
|
+
if (process.platform !== 'win32')
|
|
24
|
+
return { file: tool, args: [] };
|
|
25
|
+
const hit = lookup(tool);
|
|
26
|
+
if (!hit)
|
|
27
|
+
throw new Error(`${tool} not found on PATH`);
|
|
28
|
+
if (!/\.cmd$/i.test(hit))
|
|
29
|
+
return { file: hit, args: [] };
|
|
30
|
+
const m = /"%~?dp0%?\\([^"]+\.(?:js|mjs|cjs))"/.exec(readFile(hit)); // npm writes %dp0%\, pnpm and yarn %~dp0\
|
|
31
|
+
if (!m)
|
|
32
|
+
throw new Error(`cannot read the script path from ${hit}`);
|
|
33
|
+
return { file: process.execPath, args: [path.join(path.dirname(hit), m[1])] };
|
|
34
|
+
}
|
|
35
|
+
const ESC_KEY = ''; // a decoded Esc key, kept apart from escape sequences while they are stripped
|
|
36
|
+
const SOFT_NL = ''; // Shift+Enter: a newline inside the draft, not a submit
|
|
37
|
+
/**
|
|
38
|
+
* Characters the user typed, out of a raw stdin chunk. Under ConPTY keys arrive in win32-input-mode,
|
|
39
|
+
* `ESC [ Vk ; Sc ; Uc ; KeyDown ; Ctrl ; Repeat _`, one event per key press and release, and a chunk may
|
|
40
|
+
* end mid-sequence; `hold` receives such a tail to prepend to the next chunk. Other escape sequences
|
|
41
|
+
* (arrows, mouse reports, terminal replies to queries) are dropped: they are not a draft.
|
|
42
|
+
*/
|
|
43
|
+
export function decodeKeys(data, hold) {
|
|
44
|
+
if (data === '\x1b') {
|
|
45
|
+
hold('');
|
|
46
|
+
return '\x1b';
|
|
47
|
+
} // a bare Esc key outside win32-input-mode
|
|
48
|
+
const lastEsc = data.lastIndexOf('\x1b');
|
|
49
|
+
if (lastEsc >= 0) {
|
|
50
|
+
const tail = data.slice(lastEsc);
|
|
51
|
+
const complete = /^\x1b(\[[0-9;?<>=]*[ -/]*[@-~]|\][^\x07\x1b]*(\x07|\x1b\\)|P[^\x1b]*\x1b\\|O.|[^[\]PO\x1b])/.test(tail) || tail === '\x1b\x1b';
|
|
52
|
+
if (!complete) {
|
|
53
|
+
hold(tail);
|
|
54
|
+
data = data.slice(0, lastEsc);
|
|
55
|
+
}
|
|
56
|
+
else
|
|
57
|
+
hold('');
|
|
58
|
+
}
|
|
59
|
+
else
|
|
60
|
+
hold('');
|
|
61
|
+
return data
|
|
62
|
+
.replace(/\x1b\[(\d+);(\d+);(\d+);(\d+);(\d+);(\d+)_/g, (_, _vk, _sc, uc, down, ctrl) => {
|
|
63
|
+
if (down !== '1' || Number(uc) === 0)
|
|
64
|
+
return '';
|
|
65
|
+
const code = Number(uc);
|
|
66
|
+
if (code === 27)
|
|
67
|
+
return ESC_KEY;
|
|
68
|
+
if (code === 13 && (Number(ctrl) & 0x10))
|
|
69
|
+
return SOFT_NL; // SHIFT_PRESSED
|
|
70
|
+
return String.fromCharCode(code);
|
|
71
|
+
})
|
|
72
|
+
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') // OSC (terminal replies, titles)
|
|
73
|
+
.replace(/\x1bP[^\x1b]*\x1b\\/g, '') // DCS (e.g. XTVERSION replies)
|
|
74
|
+
.replace(/\x1b\[[0-9;?<>=]*[ -/]*[@-~]/g, '') // CSI (arrows, mouse, replies)
|
|
75
|
+
.replace(/\x1bO./g, '') // SS3 (application-mode arrows)
|
|
76
|
+
.replace(/\x1b\x1b/g, ESC_KEY) // Esc pressed twice outside win32 mode
|
|
77
|
+
.replace(/\x1b[^\x1b]/g, '') // Alt+key and other two-byte sequences
|
|
78
|
+
.replaceAll(ESC_KEY, '\x1b');
|
|
79
|
+
}
|
|
80
|
+
/** Decide whether chord may type into the terminal right now, by tracking what the user has typed but not sent.
|
|
81
|
+
* The user's interaction always wins: while anything they typed is still in the input box, chord stays out,
|
|
82
|
+
* however long they take. Messages wait in the inbox until they submit or clear it. */
|
|
83
|
+
export class InputGate {
|
|
84
|
+
draftLen = 0;
|
|
85
|
+
pending = '';
|
|
86
|
+
inPaste = false;
|
|
87
|
+
lastKeyAt = -Infinity; // never
|
|
88
|
+
lastEscAt = -Infinity;
|
|
89
|
+
onUserInput(data, now = Date.now()) {
|
|
90
|
+
// bracketed paste: its newlines are content, not submits, and a long paste arrives in several chunks
|
|
91
|
+
const pasted = this.inPaste || data.includes('\x1b[200~');
|
|
92
|
+
const open = data.lastIndexOf('\x1b[200~'), close = data.lastIndexOf('\x1b[201~');
|
|
93
|
+
if (open >= 0 || close >= 0)
|
|
94
|
+
this.inPaste = open > close;
|
|
95
|
+
const text = decodeKeys(this.pending + data, rest => { this.pending = rest; });
|
|
96
|
+
if (!text)
|
|
97
|
+
return;
|
|
98
|
+
this.lastKeyAt = now;
|
|
99
|
+
if (text.includes('\x1b'))
|
|
100
|
+
this.lastEscAt = now;
|
|
101
|
+
for (const ch of text) {
|
|
102
|
+
if (ch === '\x15' || ch === '\x03')
|
|
103
|
+
this.draftLen = 0; // Ctrl+U, Ctrl+C clear the line
|
|
104
|
+
else if (ch === '\x7f' || ch === '\x08')
|
|
105
|
+
this.draftLen = Math.max(0, this.draftLen - 1); // Backspace
|
|
106
|
+
else if ((ch === '\r' || ch === '\n') && !pasted)
|
|
107
|
+
this.draftLen = 0; // submitted
|
|
108
|
+
else if (ch !== '\x1b')
|
|
109
|
+
this.draftLen++;
|
|
110
|
+
// Esc is neither: in Claude Code one press closes a menu or interrupts, in Codex it never empties the box, so the
|
|
111
|
+
// draft is assumed to be still there (erring towards leaving the user alone)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** True when the input box is believed empty. */
|
|
115
|
+
clear() {
|
|
116
|
+
// ponytail: cannot see the real input box; count keystrokes since the last submit as the draft
|
|
117
|
+
return this.draftLen === 0;
|
|
118
|
+
}
|
|
119
|
+
/** True when the user has not touched the keyboard for a while: three seconds after any key, ten after Esc. A
|
|
120
|
+
* submit takes the hook up to a second to show as busy, and after an interrupt the user is about to type a
|
|
121
|
+
* correction; chord must not slip its text in between. */
|
|
122
|
+
settled(now = Date.now()) {
|
|
123
|
+
return now - this.lastKeyAt >= 3000 && now - this.lastEscAt >= 10_000;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export const NUDGE = '[chord] You have new messages from other agents; they are attached to this prompt. Handle them, replying with the chord send tool where needed.';
|
|
127
|
+
/** Terminal output with escape sequences removed (OSC, CSI, DCS, charset selection, other two-byte sequences). */
|
|
128
|
+
export function stripAnsi(s) {
|
|
129
|
+
return s
|
|
130
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
131
|
+
.replace(/\x1bP[^\x1b]*\x1b\\/g, '')
|
|
132
|
+
.replace(/\x1b\[[0-9;?<>=]*[ -\/]*[@-~]/g, '')
|
|
133
|
+
.replace(/\x1b[()][0-9A-Za-z]/g, '')
|
|
134
|
+
.replace(/\x1b./g, '');
|
|
135
|
+
}
|
|
136
|
+
/** What `chord stop` types to quit the tool, once the agent is idle and the input box is empty. */
|
|
137
|
+
export const EXIT_COMMAND = { claude: '/exit', codex: '/quit' };
|
|
138
|
+
/** Did the TUI echo the typed text into its input box? A dialog, picker or any other mode that swallows keystrokes
|
|
139
|
+
* does not echo them, so this is the test before Enter is pressed: Enter in a dialog picks the default answer. Only
|
|
140
|
+
* the opening characters are checked, with whitespace ignored, so wrapping and re-rendering do not matter. */
|
|
141
|
+
export function typedEchoed(output, text) {
|
|
142
|
+
const squash = (s) => stripAnsi(s).replace(/\s+/g, '');
|
|
143
|
+
return squash(output).includes(squash(text).slice(0, 25));
|
|
144
|
+
}
|
|
145
|
+
/** The terminal title is chord's: "chord <agent> · <task>", so a window or tab can be matched to a `chord ls` row.
|
|
146
|
+
* The tools set their own titles (Claude Code its session summary, Codex the folder name), so their title
|
|
147
|
+
* sequences (OSC 0, 1, 2) are removed from the forwarded output; everything else, including Codex's OSC 7501
|
|
148
|
+
* state reports, passes through. A sequence split across two chunks is held until it completes. */
|
|
149
|
+
export class TitleFilter {
|
|
150
|
+
pending = '';
|
|
151
|
+
push(chunk) {
|
|
152
|
+
let s = this.pending + chunk;
|
|
153
|
+
this.pending = '';
|
|
154
|
+
s = s.replace(/\x1b\][012];[^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
|
|
155
|
+
const i = s.lastIndexOf('\x1b]');
|
|
156
|
+
if (i !== -1 && /^\x1b\](?:[012](?:;[^\x07\x1b]*\x1b?)?)?$/.test(s.slice(i))) {
|
|
157
|
+
this.pending = s.slice(i);
|
|
158
|
+
s = s.slice(0, i);
|
|
159
|
+
}
|
|
160
|
+
return s;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export function titleFor(name, task) {
|
|
164
|
+
const t = (task ?? '').replace(/\s+/g, ' ').trim();
|
|
165
|
+
return `chord ${name}${t ? ` · ${t.length > 80 ? `${t.slice(0, 79)}…` : t}` : ''}`;
|
|
166
|
+
}
|
|
167
|
+
export const titleSequence = (title) => `\x1b]0;${title.replace(/[\x00-\x1f\x7f]/g, ' ')}\x07`;
|
|
168
|
+
/** What the idle-wake loop should do this tick: type the nudge (unread messages), type the exit command (`chord
|
|
169
|
+
* stop`), press Enter once more, or nothing. Messages stay in the inbox until a hook takes them, so a lost
|
|
170
|
+
* keystroke is retried, never dropped. `typed`/`typedAt` say what was last typed and when; `echoed` whether the TUI
|
|
171
|
+
* showed it; `enters` how many times Enter was pressed for it and `enteredAt` when last. Enter is pressed at most
|
|
172
|
+
* twice per typed text, never when the text was not echoed, and nothing new is typed while the last text is fresh. */
|
|
173
|
+
export function nextAction(s, now) {
|
|
174
|
+
if (s.status !== 'idle' || !s.gateClear)
|
|
175
|
+
return null;
|
|
176
|
+
const want = s.stop ? 'stop' : s.unread ? 'nudge' : null;
|
|
177
|
+
if (!want)
|
|
178
|
+
return null;
|
|
179
|
+
if (!s.typedAt || now - s.typedAt > 30_000)
|
|
180
|
+
return want; // first try, or the typed text was lost or swallowed
|
|
181
|
+
if (s.typed !== want)
|
|
182
|
+
return null; // something else is still in the input box; wait for it to go through or expire
|
|
183
|
+
if (!s.echoed || s.enters !== 1)
|
|
184
|
+
return null; // swallowed: hands off; never pressed: the user got there first; pressed twice: enough
|
|
185
|
+
if (now - s.enteredAt > 5_000)
|
|
186
|
+
return 'enter'; // typed and echoed but not submitted: press Enter once more
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
/** Codex reports its state to the terminal as OSC 7501 ("state=idle" / "state=working"); Claude Code does not. */
|
|
190
|
+
export function oscState(chunk) {
|
|
191
|
+
const m = /\x1b\]7501;state=(\w+)/g;
|
|
192
|
+
let last = null;
|
|
193
|
+
for (const x of chunk.matchAll(m))
|
|
194
|
+
last = x[1] === 'idle' ? 'idle' : 'busy';
|
|
195
|
+
return last;
|
|
196
|
+
}
|
|
197
|
+
export function run(tool, launch, extraArgs) {
|
|
198
|
+
const { file, args } = resolveCommand(tool);
|
|
199
|
+
const env = withNodeOnPath({ ...process.env, ...launch.env });
|
|
200
|
+
for (const k of Object.keys(env))
|
|
201
|
+
if (/^CLAUDECODE/.test(k))
|
|
202
|
+
delete env[k]; // allow launching from inside a Claude session
|
|
203
|
+
const term = pty.spawn(file, [...args, ...launch.args, ...extraArgs], {
|
|
204
|
+
name: 'xterm-256color', cols: process.stdout.columns || 120, rows: process.stdout.rows || 40, cwd: launch.cwd, env,
|
|
205
|
+
});
|
|
206
|
+
const d = db.openDb(launch.dbFile);
|
|
207
|
+
db.addAgent(d, { name: launch.name, tool, pid: term.pid, cwd: launch.cwd, worktree: launch.worktree, heartbeat: true });
|
|
208
|
+
let outTail = '', echoBuf = '', awaitingEcho = false;
|
|
209
|
+
const titles = new TitleFilter();
|
|
210
|
+
let title = titleFor(launch.name, null);
|
|
211
|
+
process.stdout.write(titleSequence(title));
|
|
212
|
+
term.onData(x => {
|
|
213
|
+
process.stdout.write(titles.push(x));
|
|
214
|
+
if (awaitingEcho)
|
|
215
|
+
echoBuf = (echoBuf + x).slice(-16000);
|
|
216
|
+
if (tool === 'codex') { // the hooks set status too; the terminal report catches turns the hooks miss (Esc, untrusted hooks)
|
|
217
|
+
const state = oscState(outTail + x);
|
|
218
|
+
if (state)
|
|
219
|
+
db.setStatus(d, launch.name, state);
|
|
220
|
+
outTail = x.slice(-64);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
const gate = new InputGate();
|
|
224
|
+
const decoder = new StringDecoder('utf8'); // a multi-byte character may straddle two stdin chunks
|
|
225
|
+
const onKey = (x) => { const s = decoder.write(x); if (!s)
|
|
226
|
+
return; if (process.env.CHORD_DEBUG_STDIN)
|
|
227
|
+
db.log(`stdin ${JSON.stringify(s)}`); gate.onUserInput(s); term.write(s); };
|
|
228
|
+
if (process.stdin.isTTY)
|
|
229
|
+
process.stdin.setRawMode(true);
|
|
230
|
+
process.stdin.resume();
|
|
231
|
+
process.stdin.on('data', onKey);
|
|
232
|
+
const onResize = () => term.resize(process.stdout.columns || 120, process.stdout.rows || 40);
|
|
233
|
+
process.stdout.on('resize', onResize);
|
|
234
|
+
Object.assign(process.env, launch.env); // so the wrapper writes to the same chord.db.log as the hooks
|
|
235
|
+
let typed = null, typedAt = 0, enteredAt = 0, enters = 0, echoed = false, lastState = '';
|
|
236
|
+
const timer = setInterval(() => {
|
|
237
|
+
db.heartbeat(d, launch.name);
|
|
238
|
+
const me = db.getAgent(d, launch.name);
|
|
239
|
+
const wanted = titleFor(launch.name, me?.task);
|
|
240
|
+
if (wanted !== title) {
|
|
241
|
+
title = wanted;
|
|
242
|
+
process.stdout.write(titleSequence(title));
|
|
243
|
+
}
|
|
244
|
+
const status = me?.status;
|
|
245
|
+
if (status !== 'idle') {
|
|
246
|
+
typed = null;
|
|
247
|
+
typedAt = enteredAt = enters = 0;
|
|
248
|
+
echoed = false;
|
|
249
|
+
}
|
|
250
|
+
const s = { status, unread: db.hasUnread(d, launch.name), stop: !!me?.stop_requested_at, gateClear: gate.clear() && gate.settled(), typed, typedAt, echoed, enters, enteredAt };
|
|
251
|
+
const action = nextAction(s, Date.now());
|
|
252
|
+
const state = `${status}/${s.unread}/${s.stop}/${s.gateClear}/${action}`;
|
|
253
|
+
if (state !== lastState) {
|
|
254
|
+
db.log(`wake loop: status=${status} unread=${s.unread} stop=${s.stop} gateClear=${s.gateClear} action=${action}`);
|
|
255
|
+
lastState = state;
|
|
256
|
+
}
|
|
257
|
+
if (!action)
|
|
258
|
+
return;
|
|
259
|
+
if (action === 'enter') {
|
|
260
|
+
enteredAt = Date.now();
|
|
261
|
+
enters++;
|
|
262
|
+
term.write('\r');
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const text = action === 'stop' ? EXIT_COMMAND[tool] : NUDGE;
|
|
266
|
+
typed = action;
|
|
267
|
+
typedAt = Date.now();
|
|
268
|
+
enteredAt = enters = 0;
|
|
269
|
+
echoed = false;
|
|
270
|
+
echoBuf = '';
|
|
271
|
+
awaitingEcho = true;
|
|
272
|
+
term.write(text);
|
|
273
|
+
// Enter must arrive separately or the TUI treats it as pasted text. It is only pressed once the TUI has echoed
|
|
274
|
+
// the text (so a dialog or picker, which swallows keystrokes, never gets an Enter) and the user has not typed.
|
|
275
|
+
setTimeout(() => {
|
|
276
|
+
awaitingEcho = false;
|
|
277
|
+
echoed = typedEchoed(echoBuf, text);
|
|
278
|
+
if (!echoed) {
|
|
279
|
+
db.log(`wake loop: ${action} text was not echoed, something other than the prompt has the keyboard; not pressing Enter`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (!gate.clear() || !gate.settled()) {
|
|
283
|
+
db.log('wake loop: the user typed meanwhile; not pressing Enter');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
term.write('\r');
|
|
287
|
+
enteredAt = Date.now();
|
|
288
|
+
enters = 1;
|
|
289
|
+
}, 1500);
|
|
290
|
+
}, 1000);
|
|
291
|
+
return new Promise(resolve => {
|
|
292
|
+
term.onExit(({ exitCode }) => {
|
|
293
|
+
clearInterval(timer);
|
|
294
|
+
process.stdin.off('data', onKey);
|
|
295
|
+
process.stdout.off('resize', onResize);
|
|
296
|
+
if (process.stdin.isTTY)
|
|
297
|
+
process.stdin.setRawMode(false);
|
|
298
|
+
process.stdin.pause();
|
|
299
|
+
db.setStatus(d, launch.name, 'offline');
|
|
300
|
+
db.releaseAll(d, launch.name, 'exit');
|
|
301
|
+
resolve(exitCode);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// Entry point for the per-agent MCP server. Spawned by the coding agent over stdio, in the agent's workspace.
|
|
2
|
-
import { openDb } from
|
|
3
|
-
import { serve } from
|
|
4
|
-
import { tools } from
|
|
5
|
-
|
|
2
|
+
import { openDb } from "./db.js";
|
|
3
|
+
import { serve } from "./mcp.js";
|
|
4
|
+
import { tools } from "./tools.js";
|
|
6
5
|
const agent = process.env.CHORD_AGENT_ID;
|
|
7
6
|
const file = process.env.CHORD_DB;
|
|
8
7
|
if (!agent || !file) {
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
process.stderr.write('chord: CHORD_AGENT_ID and CHORD_DB must be set\n');
|
|
9
|
+
process.exit(2);
|
|
11
10
|
}
|
|
12
11
|
const cwd = process.env.CHORD_WORKSPACE ?? process.cwd();
|
|
13
12
|
serve(tools({ db: openDb(file), agent, cwd, worktree: process.env.CHORD_WORKTREE ?? cwd, project: process.env.CHORD_PROJECT }));
|