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/src/mcp.ts DELETED
@@ -1,54 +0,0 @@
1
- // Minimal MCP server over stdio: newline-delimited JSON-RPC 2.0, tools only.
2
- import { createInterface } from 'node:readline';
3
-
4
- export interface Tool {
5
- name: string;
6
- description: string;
7
- inputSchema: Record<string, unknown>;
8
- handler: (args: Record<string, unknown>) => unknown;
9
- }
10
-
11
- interface Request { jsonrpc: '2.0'; id?: number | string; method: string; params?: Record<string, unknown> }
12
-
13
- const PROTOCOL = '2025-06-18';
14
-
15
- /** Handle one JSON-RPC message. Returns the response, or undefined for notifications. */
16
- export function handle(tools: Tool[], req: Request): object | undefined {
17
- const { id, method, params = {} } = req;
18
- if (id === undefined) return undefined; // notification, e.g. notifications/initialized
19
- const ok = (result: unknown) => ({ jsonrpc: '2.0', id, result });
20
- const err = (code: number, message: string) => ({ jsonrpc: '2.0', id, error: { code, message } });
21
-
22
- switch (method) {
23
- case 'initialize':
24
- return ok({ protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: 'chord', version: '0.0.0' } });
25
- case 'ping':
26
- return ok({});
27
- case 'tools/list':
28
- return ok({ tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
29
- case 'tools/call': {
30
- const tool = tools.find(t => t.name === params.name);
31
- if (!tool) return err(-32602, `unknown tool: ${String(params.name)}`);
32
- try {
33
- const out = tool.handler((params.arguments as Record<string, unknown>) ?? {});
34
- const text = typeof out === 'string' ? out : JSON.stringify(out, null, 2);
35
- return ok({ content: [{ type: 'text', text }] });
36
- } catch (e) {
37
- return ok({ content: [{ type: 'text', text: (e as Error).message }], isError: true });
38
- }
39
- }
40
- default:
41
- return err(-32601, `method not found: ${method}`);
42
- }
43
- }
44
-
45
- export function serve(tools: Tool[]): void {
46
- const rl = createInterface({ input: process.stdin });
47
- rl.on('line', line => {
48
- if (!line.trim()) return;
49
- let req: Request;
50
- try { req = JSON.parse(line); } catch { return; }
51
- const res = handle(tools, req);
52
- if (res) process.stdout.write(JSON.stringify(res) + '\n');
53
- });
54
- }
package/src/patch.ts DELETED
@@ -1,9 +0,0 @@
1
- /** File paths touched by a Codex apply_patch payload, taken from its header lines. */
2
- export function patchFiles(patch: string): string[] {
3
- const out = new Set<string>();
4
- for (const line of patch.split('\n')) {
5
- const m = /^\s*\*\*\* (?:Add File|Update File|Delete File|Move to): (.+?)\s*$/.exec(line); // a heredoc may indent the headers
6
- if (m) out.add(m[1]);
7
- }
8
- return [...out];
9
- }
package/src/project.ts DELETED
@@ -1,152 +0,0 @@
1
- // A chord project is the folder `chord` is run in. It contains the git repository as a child
2
- // directory and a `chord/` directory for agent workspaces; chord's own state lives under ~/.chord.
3
- import { createHash } from 'node:crypto';
4
- import { execFileSync } from 'node:child_process';
5
- import fs from 'node:fs';
6
- import os from 'node:os';
7
- import path from 'node:path';
8
- import { gitEnv } from './db.ts';
9
-
10
- export interface Project {
11
- dir: string; // the project folder, absolute
12
- repo: string; // the repository's main checkout, a child of dir
13
- repoName: string; // basename of repo
14
- stateDir: string; // ~/.chord/projects/<encoded dir>
15
- }
16
-
17
- /** Thrown when the folder is a repository root: chord offers to move it into a child directory. */
18
- export class NeedsRestructure extends Error {
19
- dir: string;
20
- suggestedName: string;
21
- constructor(dir: string, suggestedName: string) {
22
- super(`${dir} is itself a git repository; chord needs the repository in a child directory of the project`);
23
- this.dir = dir;
24
- this.suggestedName = suggestedName;
25
- }
26
- }
27
-
28
- const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, encoding: 'utf8', env: gitEnv(), stdio: ['ignore', 'pipe', 'ignore'] }).trim();
29
-
30
- const same = (a: string, b: string) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
31
-
32
- function repoRootOf(dir: string): string | null {
33
- try { return fs.realpathSync.native(git(dir, 'rev-parse', '--show-toplevel')); } catch { return null; }
34
- }
35
-
36
- /** Only a .git *directory* marks a project repository; a .git file is a linked worktree or a submodule. */
37
- const isRepoDir = (dir: string) => { try { return fs.statSync(path.join(dir, '.git')).isDirectory(); } catch { return false; } };
38
-
39
- /** Readable like Claude Code's projects directory (separators and colons become dashes), plus a short hash so
40
- * that D:\a-b and D:\a\b do not share one state directory. */
41
- export function encodeProjectPath(dir: string): string {
42
- const key = process.platform === 'win32' ? dir.toLowerCase() : dir;
43
- return `${dir.replace(/[\\/:]/g, '-')}-${createHash('sha1').update(key).digest('hex').slice(0, 6)}`;
44
- }
45
-
46
- export const stateDirFor = (dir: string) => path.join(process.env.CHORD_HOME ?? path.join(os.homedir(), '.chord'), 'projects', encodeProjectPath(dir));
47
-
48
- /** A repository directory name chord can work with: a single path segment that is not chord's own directory. */
49
- export function validateRepoName(name: string): string | null {
50
- if (!name.trim()) return 'the name is empty';
51
- if (/[\\/]/.test(name) || name === '.' || name === '..') return 'the name must be a single directory name';
52
- if (name.toLowerCase() === 'chord') return '"chord" is reserved for chord\'s own directory';
53
- return null;
54
- }
55
-
56
- /** Default name for the repository directory: the origin remote's name, else the folder's own name. */
57
- export function suggestedRepoName(repo: string): string {
58
- try {
59
- const url = git(repo, 'remote', 'get-url', 'origin');
60
- const last = url.split(/[\\/:]/).filter(Boolean).pop() ?? '';
61
- const name = last.replace(/\.git$/i, '');
62
- if (name && !validateRepoName(name)) return name;
63
- } catch { /* no origin */ }
64
- const own = path.basename(repo);
65
- return validateRepoName(own) ? 'repo' : own;
66
- }
67
-
68
- /** The nearest ancestor that already is a chord project, if any. */
69
- /** Child directories of `dir` that are repository roots. Looked up with stat, so a junction or symlink to a repository
70
- * counts (a Dirent reports those as links, not directories). */
71
- const repoChildren = (dir: string) => fs.readdirSync(dir).filter(n => { const p = path.join(dir, n); try { return fs.statSync(p).isDirectory() && isRepoDir(p); } catch { return false; } });
72
-
73
- /** The project `dir` belongs to, when it lies inside one: under the project's chord/ directory (a workspace or a
74
- * worktree) or inside its repository. A folder merely beside those, such as a new project created next to an old
75
- * one, is not claimed by the ancestor. */
76
- function enclosingProject(dir: string): string | null {
77
- for (let a = path.dirname(dir); a !== path.dirname(a); a = path.dirname(a)) {
78
- if (!fs.existsSync(stateDirFor(a)) && !fs.existsSync(path.join(a, 'chord', 'agents'))) continue;
79
- const inside = (sub: string) => { const rel = path.relative(path.join(a, sub), dir); return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); };
80
- if (inside('chord') || repoChildren(a).some(inside)) return a;
81
- }
82
- return null;
83
- }
84
-
85
- export function resolveProject(cwd: string): Project {
86
- const dir = fs.realpathSync.native(cwd);
87
- // inside an existing project (its main checkout, a workspace or a worktree): that project is meant
88
- const parent = enclosingProject(dir);
89
- if (parent) return resolveProject(parent);
90
- const enclosing = repoRootOf(dir);
91
- if (enclosing) {
92
- if (same(enclosing, dir)) throw new NeedsRestructure(dir, suggestedRepoName(dir));
93
- throw new Error(`${dir} is inside the repository ${enclosing}; a chord project is the folder that contains the repository, not a folder inside it`);
94
- }
95
- const children = repoChildren(dir);
96
- if (children.some(c => c.toLowerCase() === 'chord')) throw new Error(`${dir}/chord is a git repository, but chord/ is reserved for chord's own directory; rename it`);
97
- if (children.length === 0) throw new Error(`${dir} contains no git repository; a chord project is a folder with the repository in a child directory`);
98
- if (children.length > 1) throw new Error(`${dir} contains several repositories (${children.join(', ')}); chord supports one per project for now`);
99
- const repo = path.join(dir, children[0]);
100
- return { dir, repo, repoName: children[0], stateDir: stateDirFor(dir) };
101
- }
102
-
103
- /** Why `dir` (a repository root) must not be moved into a child directory, or null when it is safe. */
104
- export function restructureBlocker(dir: string): string | null {
105
- if (same(fs.realpathSync.native(dir), fs.realpathSync.native(os.homedir()))) return 'refusing to move your home directory';
106
- let worktrees = 1;
107
- try { worktrees = git(dir, 'worktree', 'list', '--porcelain').split('\n').filter(l => l.startsWith('worktree ')).length; } catch { /* not a repo */ }
108
- if (worktrees > 1) return `${dir} has linked worktrees (git worktree list); remove them first, moving the repository would break them`;
109
- try { if (git(dir, 'ls-files', '--', 'chord')) return 'the repository tracks a top-level chord/ directory, which chord needs for itself; rename it first'; } catch { /* ignore */ }
110
- return null;
111
- }
112
-
113
- /** Move everything in `dir` (except chord/) into `dir/name`, so the repository becomes a child. Rolls back on failure. */
114
- export function restructure(dir: string, name: string, rename: (from: string, to: string) => void = fs.renameSync): string {
115
- const invalid = validateRepoName(name);
116
- if (invalid) throw new Error(invalid);
117
- const blocker = restructureBlocker(dir);
118
- if (blocker) throw new Error(blocker);
119
- const target = path.join(dir, name);
120
- if (fs.existsSync(target)) throw new Error(`${target} already exists`);
121
- const entries = fs.readdirSync(dir).filter(e => e !== 'chord');
122
- fs.mkdirSync(target);
123
- const moved: string[] = [];
124
- try {
125
- for (const e of entries) { rename(path.join(dir, e), path.join(target, e)); moved.push(e); }
126
- } catch (e) {
127
- const stuck: string[] = [];
128
- for (const m of moved) { try { rename(path.join(target, m), path.join(dir, m)); } catch { stuck.push(m); } }
129
- if (!stuck.length) { try { fs.rmdirSync(target); } catch { /* leave it */ } }
130
- const note = stuck.length ? ` Rolling back failed too: ${stuck.join(', ')} are still under ${target}.` : ' Everything was moved back.';
131
- throw new Error(`could not move ${dir} into ${target}: ${(e as Error).message}.${note} Close programs that have files open there and try again.`);
132
- }
133
- return target;
134
- }
135
-
136
- /** Every project that has state under ~/.chord/projects, for `chord ls --all`. */
137
- export function knownProjects(): { stateDir: string; dbFile: string; dir: string | null }[] {
138
- const root = path.dirname(stateDirFor('x'));
139
- if (!fs.existsSync(root)) return [];
140
- return fs.readdirSync(root).map(name => {
141
- const stateDir = path.join(root, name);
142
- let dir: string | null = null;
143
- try { dir = fs.readFileSync(path.join(stateDir, 'path'), 'utf8').trim(); } catch { /* unknown */ }
144
- return { stateDir, dbFile: path.join(stateDir, 'chord.db'), dir };
145
- }).filter(p => fs.existsSync(p.dbFile));
146
- }
147
-
148
- /** Create the state directory and remember the project's real path (the encoding is not reversible). */
149
- export function ensureStateDir(p: Project): void {
150
- fs.mkdirSync(p.stateDir, { recursive: true });
151
- fs.writeFileSync(path.join(p.stateDir, 'path'), p.dir);
152
- }
package/src/pty.ts DELETED
@@ -1,251 +0,0 @@
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.ts';
8
- import type { Tool } from './hooks.ts';
9
- import { withNodeOnPath, type Launch } from './launch.ts';
10
-
11
- /** First PATH entry holding `<tool>.exe` or `<tool>.cmd`. Pure Node: `where.exe` output is in the OEM code page. */
12
- export function whereIs(tool: string, pathVar = process.env.PATH ?? '', exists: (p: string) => boolean = p => fs.existsSync(p)): string | undefined {
13
- for (const dir of pathVar.split(path.delimiter).filter(Boolean)) {
14
- for (const ext of ['.exe', '.cmd']) { const p = path.join(dir, tool + ext); if (exists(p)) return p; }
15
- }
16
- return undefined;
17
- }
18
-
19
- /** Executable and leading args for a tool. On Windows an npm .cmd shim is unwrapped to
20
- * `node <script>` so JSON arguments reach the process intact instead of going through cmd.exe. */
21
- export function resolveCommand(tool: string, readFile = (f: string) => fs.readFileSync(f, 'utf8'), lookup = whereIs): { file: string; args: string[] } {
22
- if (process.platform !== 'win32') return { file: tool, args: [] };
23
- const hit = lookup(tool);
24
- if (!hit) throw new Error(`${tool} not found on PATH`);
25
- if (!/\.cmd$/i.test(hit)) return { file: hit, args: [] };
26
- const m = /"%~?dp0%?\\([^"]+\.(?:js|mjs|cjs))"/.exec(readFile(hit)); // npm writes %dp0%\, pnpm and yarn %~dp0\
27
- if (!m) throw new Error(`cannot read the script path from ${hit}`);
28
- return { file: process.execPath, args: [path.join(path.dirname(hit), m[1])] };
29
- }
30
-
31
- const ESC_KEY = ''; // a decoded Esc key, kept apart from escape sequences while they are stripped
32
- const SOFT_NL = ''; // Shift+Enter: a newline inside the draft, not a submit
33
-
34
- /**
35
- * Characters the user typed, out of a raw stdin chunk. Under ConPTY keys arrive in win32-input-mode,
36
- * `ESC [ Vk ; Sc ; Uc ; KeyDown ; Ctrl ; Repeat _`, one event per key press and release, and a chunk may
37
- * end mid-sequence; `hold` receives such a tail to prepend to the next chunk. Other escape sequences
38
- * (arrows, mouse reports, terminal replies to queries) are dropped: they are not a draft.
39
- */
40
- export function decodeKeys(data: string, hold: (rest: string) => void): string {
41
- if (data === '\x1b') { hold(''); return '\x1b'; } // a bare Esc key outside win32-input-mode
42
- const lastEsc = data.lastIndexOf('\x1b');
43
- if (lastEsc >= 0) {
44
- const tail = data.slice(lastEsc);
45
- const complete = /^\x1b(\[[0-9;?<>=]*[ -/]*[@-~]|\][^\x07\x1b]*(\x07|\x1b\\)|P[^\x1b]*\x1b\\|O.|[^[\]PO\x1b])/.test(tail) || tail === '\x1b\x1b';
46
- if (!complete) { hold(tail); data = data.slice(0, lastEsc); } else hold('');
47
- } else hold('');
48
- return data
49
- .replace(/\x1b\[(\d+);(\d+);(\d+);(\d+);(\d+);(\d+)_/g, (_, _vk, _sc, uc, down, ctrl) => {
50
- if (down !== '1' || Number(uc) === 0) return '';
51
- const code = Number(uc);
52
- if (code === 27) return ESC_KEY;
53
- if (code === 13 && (Number(ctrl) & 0x10)) return SOFT_NL; // SHIFT_PRESSED
54
- return String.fromCharCode(code);
55
- })
56
- .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') // OSC (terminal replies, titles)
57
- .replace(/\x1bP[^\x1b]*\x1b\\/g, '') // DCS (e.g. XTVERSION replies)
58
- .replace(/\x1b\[[0-9;?<>=]*[ -/]*[@-~]/g, '') // CSI (arrows, mouse, replies)
59
- .replace(/\x1bO./g, '') // SS3 (application-mode arrows)
60
- .replace(/\x1b\x1b/g, ESC_KEY) // Esc pressed twice outside win32 mode
61
- .replace(/\x1b[^\x1b]/g, '') // Alt+key and other two-byte sequences
62
- .replaceAll(ESC_KEY, '\x1b');
63
- }
64
-
65
- /** Decide whether chord may type into the terminal right now, by tracking what the user has typed but not sent.
66
- * The user's interaction always wins: while anything they typed is still in the input box, chord stays out,
67
- * however long they take. Messages wait in the inbox until they submit or clear it. */
68
- export class InputGate {
69
- private draftLen = 0;
70
- private pending = '';
71
- private inPaste = false;
72
- private lastKeyAt = -Infinity; // never
73
- private lastEscAt = -Infinity;
74
- onUserInput(data: string, now = Date.now()): void {
75
- // bracketed paste: its newlines are content, not submits, and a long paste arrives in several chunks
76
- const pasted = this.inPaste || data.includes('\x1b[200~');
77
- const open = data.lastIndexOf('\x1b[200~'), close = data.lastIndexOf('\x1b[201~');
78
- if (open >= 0 || close >= 0) this.inPaste = open > close;
79
- const text = decodeKeys(this.pending + data, rest => { this.pending = rest; });
80
- if (!text) return;
81
- this.lastKeyAt = now;
82
- if (text.includes('\x1b')) this.lastEscAt = now;
83
- for (const ch of text) {
84
- if (ch === '\x15' || ch === '\x03') this.draftLen = 0; // Ctrl+U, Ctrl+C clear the line
85
- else if (ch === '\x7f' || ch === '\x08') this.draftLen = Math.max(0, this.draftLen - 1); // Backspace
86
- else if ((ch === '\r' || ch === '\n') && !pasted) this.draftLen = 0; // submitted
87
- else if (ch !== '\x1b') this.draftLen++;
88
- // Esc is neither: in Claude Code one press closes a menu or interrupts, in Codex it never empties the box, so the
89
- // draft is assumed to be still there (erring towards leaving the user alone)
90
- }
91
- }
92
- /** True when the input box is believed empty. */
93
- clear(): boolean {
94
- // ponytail: cannot see the real input box; count keystrokes since the last submit as the draft
95
- return this.draftLen === 0;
96
- }
97
- /** True when the user has not touched the keyboard for a while: three seconds after any key, ten after Esc. A
98
- * submit takes the hook up to a second to show as busy, and after an interrupt the user is about to type a
99
- * correction; chord must not slip its text in between. */
100
- settled(now = Date.now()): boolean {
101
- return now - this.lastKeyAt >= 3000 && now - this.lastEscAt >= 10_000;
102
- }
103
- }
104
-
105
- 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.';
106
-
107
- /** Terminal output with escape sequences removed (OSC, CSI, DCS, charset selection, other two-byte sequences). */
108
- export function stripAnsi(s: string): string {
109
- return s
110
- .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
111
- .replace(/\x1bP[^\x1b]*\x1b\\/g, '')
112
- .replace(/\x1b\[[0-9;?<>=]*[ -\/]*[@-~]/g, '')
113
- .replace(/\x1b[()][0-9A-Za-z]/g, '')
114
- .replace(/\x1b./g, '');
115
- }
116
-
117
- /** What `chord stop` types to quit the tool, once the agent is idle and the input box is empty. */
118
- export const EXIT_COMMAND: Record<Tool, string> = { claude: '/exit', codex: '/quit' };
119
-
120
- /** Did the TUI echo the typed text into its input box? A dialog, picker or any other mode that swallows keystrokes
121
- * does not echo them, so this is the test before Enter is pressed: Enter in a dialog picks the default answer. Only
122
- * the opening characters are checked, with whitespace ignored, so wrapping and re-rendering do not matter. */
123
- export function typedEchoed(output: string, text: string): boolean {
124
- const squash = (s: string) => stripAnsi(s).replace(/\s+/g, '');
125
- return squash(output).includes(squash(text).slice(0, 25));
126
- }
127
-
128
- /** The terminal title is chord's: "chord <agent> · <task>", so a window or tab can be matched to a `chord ls` row.
129
- * The tools set their own titles (Claude Code its session summary, Codex the folder name), so their title
130
- * sequences (OSC 0, 1, 2) are removed from the forwarded output; everything else, including Codex's OSC 7501
131
- * state reports, passes through. A sequence split across two chunks is held until it completes. */
132
- export class TitleFilter {
133
- private pending = '';
134
- push(chunk: string): string {
135
- let s = this.pending + chunk;
136
- this.pending = '';
137
- s = s.replace(/\x1b\][012];[^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
138
- const i = s.lastIndexOf('\x1b]');
139
- if (i !== -1 && /^\x1b\](?:[012](?:;[^\x07\x1b]*\x1b?)?)?$/.test(s.slice(i))) { this.pending = s.slice(i); s = s.slice(0, i); }
140
- return s;
141
- }
142
- }
143
-
144
- export function titleFor(name: string, task: string | null | undefined): string {
145
- const t = (task ?? '').replace(/\s+/g, ' ').trim();
146
- return `chord ${name}${t ? ` · ${t.length > 80 ? `${t.slice(0, 79)}…` : t}` : ''}`;
147
- }
148
- export const titleSequence = (title: string) => `\x1b]0;${title.replace(/[\x00-\x1f\x7f]/g, ' ')}\x07`;
149
-
150
- export type Typed = 'nudge' | 'stop';
151
- export type Action = Typed | 'enter' | null;
152
-
153
- /** What the idle-wake loop should do this tick: type the nudge (unread messages), type the exit command (`chord
154
- * stop`), press Enter once more, or nothing. Messages stay in the inbox until a hook takes them, so a lost
155
- * keystroke is retried, never dropped. `typed`/`typedAt` say what was last typed and when; `echoed` whether the TUI
156
- * showed it; `enters` how many times Enter was pressed for it and `enteredAt` when last. Enter is pressed at most
157
- * twice per typed text, never when the text was not echoed, and nothing new is typed while the last text is fresh. */
158
- export function nextAction(s: { status: string | undefined; unread: boolean; stop: boolean; gateClear: boolean; typed: Typed | null; typedAt: number; echoed: boolean; enters: number; enteredAt: number }, now: number): Action {
159
- if (s.status !== 'idle' || !s.gateClear) return null;
160
- const want: Typed | null = s.stop ? 'stop' : s.unread ? 'nudge' : null;
161
- if (!want) return null;
162
- if (!s.typedAt || now - s.typedAt > 30_000) return want; // first try, or the typed text was lost or swallowed
163
- if (s.typed !== want) return null; // something else is still in the input box; wait for it to go through or expire
164
- if (!s.echoed || s.enters !== 1) return null; // swallowed: hands off; never pressed: the user got there first; pressed twice: enough
165
- if (now - s.enteredAt > 5_000) return 'enter'; // typed and echoed but not submitted: press Enter once more
166
- return null;
167
- }
168
-
169
- /** Codex reports its state to the terminal as OSC 7501 ("state=idle" / "state=working"); Claude Code does not. */
170
- export function oscState(chunk: string): 'idle' | 'busy' | null {
171
- const m = /\x1b\]7501;state=(\w+)/g;
172
- let last: 'idle' | 'busy' | null = null;
173
- for (const x of chunk.matchAll(m)) last = x[1] === 'idle' ? 'idle' : 'busy';
174
- return last;
175
- }
176
-
177
- export function run(tool: Tool, launch: Launch, extraArgs: string[]): Promise<number> {
178
- const { file, args } = resolveCommand(tool);
179
- const env: Record<string, string> = withNodeOnPath({ ...process.env as Record<string, string>, ...launch.env });
180
- for (const k of Object.keys(env)) if (/^CLAUDECODE/.test(k)) delete env[k]; // allow launching from inside a Claude session
181
- const term = pty.spawn(file, [...args, ...launch.args, ...extraArgs], {
182
- name: 'xterm-256color', cols: process.stdout.columns || 120, rows: process.stdout.rows || 40, cwd: launch.cwd, env,
183
- });
184
- const d = db.openDb(launch.dbFile);
185
- db.addAgent(d, { name: launch.name, tool, pid: term.pid, cwd: launch.cwd, worktree: launch.worktree, heartbeat: true });
186
-
187
- let outTail = '', echoBuf = '', awaitingEcho = false;
188
- const titles = new TitleFilter();
189
- let title = titleFor(launch.name, null);
190
- process.stdout.write(titleSequence(title));
191
- term.onData(x => {
192
- process.stdout.write(titles.push(x));
193
- if (awaitingEcho) echoBuf = (echoBuf + x).slice(-16000);
194
- if (tool === 'codex') { // the hooks set status too; the terminal report catches turns the hooks miss (Esc, untrusted hooks)
195
- const state = oscState(outTail + x);
196
- if (state) db.setStatus(d, launch.name, state);
197
- outTail = x.slice(-64);
198
- }
199
- });
200
- const gate = new InputGate();
201
- const decoder = new StringDecoder('utf8'); // a multi-byte character may straddle two stdin chunks
202
- const onKey = (x: Buffer) => { const s = decoder.write(x); if (!s) return; if (process.env.CHORD_DEBUG_STDIN) db.log(`stdin ${JSON.stringify(s)}`); gate.onUserInput(s); term.write(s); };
203
- if (process.stdin.isTTY) process.stdin.setRawMode(true);
204
- process.stdin.resume();
205
- process.stdin.on('data', onKey);
206
- const onResize = () => term.resize(process.stdout.columns || 120, process.stdout.rows || 40);
207
- process.stdout.on('resize', onResize);
208
-
209
- Object.assign(process.env, launch.env); // so the wrapper writes to the same chord.db.log as the hooks
210
- let typed: Typed | null = null, typedAt = 0, enteredAt = 0, enters = 0, echoed = false, lastState = '';
211
- const timer = setInterval(() => {
212
- db.heartbeat(d, launch.name);
213
- const me = db.getAgent(d, launch.name);
214
- const wanted = titleFor(launch.name, me?.task);
215
- if (wanted !== title) { title = wanted; process.stdout.write(titleSequence(title)); }
216
- const status = me?.status;
217
- if (status !== 'idle') { typed = null; typedAt = enteredAt = enters = 0; echoed = false; }
218
- const s = { status, unread: db.hasUnread(d, launch.name), stop: !!me?.stop_requested_at, gateClear: gate.clear() && gate.settled(), typed, typedAt, echoed, enters, enteredAt };
219
- const action = nextAction(s, Date.now());
220
- const state = `${status}/${s.unread}/${s.stop}/${s.gateClear}/${action}`;
221
- if (state !== lastState) { db.log(`wake loop: status=${status} unread=${s.unread} stop=${s.stop} gateClear=${s.gateClear} action=${action}`); lastState = state; }
222
- if (!action) return;
223
- if (action === 'enter') { enteredAt = Date.now(); enters++; term.write('\r'); return; }
224
- const text = action === 'stop' ? EXIT_COMMAND[tool] : NUDGE;
225
- typed = action; typedAt = Date.now(); enteredAt = enters = 0; echoed = false;
226
- echoBuf = ''; awaitingEcho = true;
227
- term.write(text);
228
- // Enter must arrive separately or the TUI treats it as pasted text. It is only pressed once the TUI has echoed
229
- // the text (so a dialog or picker, which swallows keystrokes, never gets an Enter) and the user has not typed.
230
- setTimeout(() => {
231
- awaitingEcho = false;
232
- echoed = typedEchoed(echoBuf, text);
233
- if (!echoed) { db.log(`wake loop: ${action} text was not echoed, something other than the prompt has the keyboard; not pressing Enter`); return; }
234
- if (!gate.clear() || !gate.settled()) { db.log('wake loop: the user typed meanwhile; not pressing Enter'); return; }
235
- term.write('\r'); enteredAt = Date.now(); enters = 1;
236
- }, 1500);
237
- }, 1000);
238
-
239
- return new Promise(resolve => {
240
- term.onExit(({ exitCode }) => {
241
- clearInterval(timer);
242
- process.stdin.off('data', onKey);
243
- process.stdout.off('resize', onResize);
244
- if (process.stdin.isTTY) process.stdin.setRawMode(false);
245
- process.stdin.pause();
246
- db.setStatus(d, launch.name, 'offline');
247
- db.releaseAll(d, launch.name, 'exit');
248
- resolve(exitCode);
249
- });
250
- });
251
- }