@idl3/claude-control 1.3.0 → 1.4.3

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.
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env node
2
+ // Bridge process for Claude print-mode sessions.
3
+ //
4
+ // This runs inside the tmux pane so the pane remains a real session pin. It owns
5
+ // `claude -p` subprocesses and streams their NDJSON events back to server.js over
6
+ // a local Unix socket. It never shells out with prompt text.
7
+
8
+ import net from 'node:net';
9
+ import { spawn } from 'node:child_process';
10
+
11
+ function parseArgs(argv) {
12
+ const out = {};
13
+ for (let i = 0; i < argv.length; i += 1) {
14
+ const key = argv[i];
15
+ if (!key.startsWith('--')) continue;
16
+ out[key.slice(2)] = argv[i + 1] ?? '';
17
+ i += 1;
18
+ }
19
+ return out;
20
+ }
21
+
22
+ function writeLine(socket, obj) {
23
+ if (!socket || socket.destroyed) return;
24
+ socket.write(`${JSON.stringify(obj)}\n`);
25
+ }
26
+
27
+ function userInputEvent(text) {
28
+ return {
29
+ type: 'user',
30
+ message: {
31
+ role: 'user',
32
+ content: [{ type: 'text', text: String(text ?? '') }],
33
+ },
34
+ parent_tool_use_id: null,
35
+ };
36
+ }
37
+
38
+ function sleep(ms) {
39
+ return new Promise((resolve) => setTimeout(resolve, ms));
40
+ }
41
+
42
+ const args = parseArgs(process.argv.slice(2));
43
+ const socketPath = args.socket;
44
+ const cwd = args.cwd || process.cwd();
45
+ const claudeBin = args.bin || 'claude';
46
+ const permissionMode = args['permission-mode'] || 'acceptEdits';
47
+ const sessionName = args.name || '';
48
+
49
+ if (!socketPath) {
50
+ console.error('claude-print-bridge: --socket is required');
51
+ process.exit(2);
52
+ }
53
+
54
+ let socket = null;
55
+ let buffer = '';
56
+ let connected = false;
57
+ let busy = false;
58
+ let sessionId = null;
59
+ let currentChild = null;
60
+ const queue = [];
61
+
62
+ console.log('Claude Print mode');
63
+ console.log(`cwd: ${cwd}`);
64
+ console.log('waiting for claude-control...');
65
+
66
+ function connect() {
67
+ const next = net.createConnection(socketPath);
68
+ next.setEncoding('utf8');
69
+ next.on('connect', () => {
70
+ socket = next;
71
+ connected = true;
72
+ buffer = '';
73
+ console.log('connected to claude-control');
74
+ writeLine(socket, { type: 'ready', pid: process.pid });
75
+ });
76
+ next.on('data', onData);
77
+ next.on('error', () => {
78
+ connected = false;
79
+ });
80
+ next.on('close', async () => {
81
+ connected = false;
82
+ socket = null;
83
+ await sleep(1000);
84
+ connect();
85
+ });
86
+ }
87
+
88
+ function onData(chunk) {
89
+ buffer += chunk;
90
+ let idx;
91
+ while ((idx = buffer.indexOf('\n')) >= 0) {
92
+ const line = buffer.slice(0, idx);
93
+ buffer = buffer.slice(idx + 1);
94
+ if (!line.trim()) continue;
95
+ let msg;
96
+ try {
97
+ msg = JSON.parse(line);
98
+ } catch {
99
+ continue;
100
+ }
101
+ if (msg.type === 'submit') {
102
+ queue.push(String(msg.text ?? ''));
103
+ drainQueue().catch((err) => writeLine(socket, { type: 'error', error: String(err?.message || err) }));
104
+ } else if (msg.type === 'cancel') {
105
+ if (currentChild && !currentChild.killed) {
106
+ currentChild.kill('SIGINT');
107
+ setTimeout(() => {
108
+ if (currentChild && !currentChild.killed) currentChild.kill('SIGTERM');
109
+ }, 1500).unref?.();
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ async function drainQueue() {
116
+ if (busy) return;
117
+ busy = true;
118
+ try {
119
+ while (queue.length > 0) {
120
+ const text = queue.shift();
121
+ if (!text.trim()) continue;
122
+ await runClaudeTurn(text);
123
+ }
124
+ } finally {
125
+ busy = false;
126
+ }
127
+ }
128
+
129
+ async function runClaudeTurn(text) {
130
+ writeLine(socket, { type: 'status', status: 'active' });
131
+ console.log(`running claude -p stream-json turn${sessionId ? ` resume=${sessionId}` : ''}`);
132
+ const streamResult = await runClaudeChild(text, true, { reportExitError: false });
133
+ if (streamResult.code !== 0 && !streamResult.sawResponse) {
134
+ writeLine(socket, {
135
+ type: 'event',
136
+ event: {
137
+ type: 'stderr',
138
+ text: 'stream-json input was rejected; falling back to argv prompt mode for this turn',
139
+ },
140
+ });
141
+ console.log('stream-json input rejected; falling back to argv prompt mode');
142
+ await runClaudeChild(text, false);
143
+ } else if (streamResult.code !== 0) {
144
+ writeLine(socket, { type: 'error', error: `claude exited ${streamResult.code}` });
145
+ }
146
+ writeLine(socket, { type: 'status', status: 'idle' });
147
+ }
148
+
149
+ function runClaudeChild(text, useStreamInput, { reportExitError = true } = {}) {
150
+ return new Promise((resolve) => {
151
+ const turnArgs = useStreamInput
152
+ ? [
153
+ '-p',
154
+ '--input-format', 'stream-json',
155
+ '--output-format', 'stream-json',
156
+ '--verbose',
157
+ '--replay-user-messages',
158
+ '--permission-mode', permissionMode,
159
+ ]
160
+ : [
161
+ '-p', text,
162
+ '--output-format', 'stream-json',
163
+ '--verbose',
164
+ '--permission-mode', permissionMode,
165
+ ];
166
+ if (sessionId) {
167
+ turnArgs.push('--resume', sessionId);
168
+ } else if (sessionName) {
169
+ turnArgs.push('--name', sessionName);
170
+ }
171
+
172
+ const child = spawn(claudeBin, turnArgs, {
173
+ cwd,
174
+ stdio: [useStreamInput ? 'pipe' : 'ignore', 'pipe', 'pipe'],
175
+ env: {
176
+ ...process.env,
177
+ CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: '1',
178
+ },
179
+ });
180
+ currentChild = child;
181
+
182
+ let out = '';
183
+ let err = '';
184
+ let sawResponse = false;
185
+
186
+ child.stdout.setEncoding('utf8');
187
+ child.stdout.on('data', (chunk) => {
188
+ out += chunk;
189
+ let idx;
190
+ while ((idx = out.indexOf('\n')) >= 0) {
191
+ const line = out.slice(0, idx);
192
+ out = out.slice(idx + 1);
193
+ if (!line.trim()) continue;
194
+ try {
195
+ const event = JSON.parse(line);
196
+ if (typeof event.session_id === 'string') sessionId = event.session_id;
197
+ if (event.type === 'assistant' || event.type === 'result') sawResponse = true;
198
+ writeLine(socket, { type: 'event', event });
199
+ } catch {
200
+ writeLine(socket, { type: 'event', event: { type: 'stdout', text: line } });
201
+ }
202
+ }
203
+ });
204
+
205
+ child.stderr.setEncoding('utf8');
206
+ child.stderr.on('data', (chunk) => {
207
+ err += chunk;
208
+ const lines = err.split('\n');
209
+ err = lines.pop() ?? '';
210
+ for (const line of lines) {
211
+ if (line.trim()) writeLine(socket, { type: 'event', event: { type: 'stderr', text: line } });
212
+ }
213
+ });
214
+
215
+ if (useStreamInput) {
216
+ child.stdin.write(`${JSON.stringify(userInputEvent(text))}\n`);
217
+ child.stdin.end();
218
+ }
219
+
220
+ child.on('error', (error) => {
221
+ writeLine(socket, { type: 'error', error: String(error?.message || error) });
222
+ });
223
+
224
+ child.on('close', (code) => {
225
+ if (currentChild === child) currentChild = null;
226
+ if (out.trim()) {
227
+ try {
228
+ const event = JSON.parse(out.trim());
229
+ if (typeof event.session_id === 'string') sessionId = event.session_id;
230
+ if (event.type === 'assistant' || event.type === 'result') sawResponse = true;
231
+ writeLine(socket, { type: 'event', event });
232
+ } catch {
233
+ writeLine(socket, { type: 'event', event: { type: 'stdout', text: out.trim() } });
234
+ }
235
+ }
236
+ if (err.trim()) {
237
+ writeLine(socket, { type: 'event', event: { type: 'stderr', text: err.trim() } });
238
+ }
239
+ if (code !== 0 && reportExitError) {
240
+ writeLine(socket, { type: 'error', error: `claude exited ${code}` });
241
+ }
242
+ resolve({ code, sawResponse });
243
+ });
244
+ });
245
+ }
246
+
247
+ connect();
@@ -0,0 +1,352 @@
1
+ // lib/claude-print.js — Claude Code print-mode bridge transport.
2
+ //
3
+ // Keeps tmux as the visible session/pane pin while moving composer submission
4
+ // over a structured local socket to a bridge process that owns `claude -p`.
5
+
6
+ import { EventEmitter } from 'node:events';
7
+ import fs from 'node:fs/promises';
8
+ import net from 'node:net';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ import { parseRecord } from './transcript.js';
13
+
14
+ const CONNECT_TIMEOUT_MS = 10_000;
15
+ const MAX_MESSAGES = 4000;
16
+
17
+ function safeTargetName(target) {
18
+ return String(target || 'session').replace(/[^a-zA-Z0-9_.-]+/g, '_');
19
+ }
20
+
21
+ function lineOf(obj) {
22
+ return `${JSON.stringify(obj)}\n`;
23
+ }
24
+
25
+ function textBlock(text) {
26
+ return { kind: 'text', text: String(text ?? '') };
27
+ }
28
+
29
+ function normalizeClaudePrintEvent(event) {
30
+ if (!event || typeof event !== 'object') return null;
31
+ if (event.type === 'user' || event.type === 'assistant') {
32
+ return parseRecord(JSON.stringify(event));
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function eventSessionInfo(event) {
38
+ if (!event || typeof event !== 'object') return null;
39
+ const sessionId =
40
+ typeof event.session_id === 'string' ? event.session_id :
41
+ typeof event.sessionId === 'string' ? event.sessionId :
42
+ null;
43
+ const transcriptPath =
44
+ typeof event.transcript_path === 'string' ? event.transcript_path :
45
+ typeof event.transcriptPath === 'string' ? event.transcriptPath :
46
+ typeof event.cwd_transcript_path === 'string' ? event.cwd_transcript_path :
47
+ null;
48
+ const model =
49
+ typeof event.model === 'string' ? event.model :
50
+ typeof event.message?.model === 'string' ? event.message.model :
51
+ typeof event.data?.model === 'string' ? event.data.model :
52
+ null;
53
+ return sessionId || transcriptPath || model
54
+ ? { sessionId, transcriptPath, model }
55
+ : null;
56
+ }
57
+
58
+ export function buildBridgeCommand({
59
+ nodeBin = process.execPath,
60
+ bridgePath,
61
+ socketPath,
62
+ cwd,
63
+ claudeBin,
64
+ name,
65
+ permissionMode = 'acceptEdits',
66
+ quote,
67
+ } = {}) {
68
+ if (!bridgePath) throw new Error('bridgePath is required');
69
+ if (!socketPath) throw new Error('socketPath is required');
70
+ if (!cwd) throw new Error('cwd is required');
71
+ if (!claudeBin) throw new Error('claudeBin is required');
72
+ const q = quote ?? ((s) => `'${String(s).replace(/'/g, `'\\''`)}'`);
73
+ const args = [
74
+ q(nodeBin),
75
+ q(bridgePath),
76
+ '--socket', q(socketPath),
77
+ '--cwd', q(cwd),
78
+ '--bin', q(claudeBin),
79
+ '--permission-mode', q(permissionMode),
80
+ ];
81
+ if (name) args.push('--name', q(name));
82
+ return args.join(' ');
83
+ }
84
+
85
+ export class ClaudePrintClient extends EventEmitter {
86
+ constructor({ target, socketPath, cwd }) {
87
+ super();
88
+ this.target = target;
89
+ this.socketPath = socketPath;
90
+ this.cwd = cwd;
91
+ this.server = null;
92
+ this.socket = null;
93
+ this.buffer = '';
94
+ this.messages = [];
95
+ this.sessionId = null;
96
+ this.transcriptPath = null;
97
+ this.model = null;
98
+ this.ready = false;
99
+ this.active = false;
100
+ this.createdAt = Date.now();
101
+ this._pendingConnect = null;
102
+ this._resolveConnect = null;
103
+ this._rejectConnect = null;
104
+ this._lastAssistantDuringTurn = false;
105
+ }
106
+
107
+ async listen() {
108
+ await fs.mkdir(path.dirname(this.socketPath), { recursive: true, mode: 0o700 });
109
+ await fs.rm(this.socketPath, { force: true }).catch(() => {});
110
+ this.server = net.createServer((socket) => this._attach(socket));
111
+ await new Promise((resolve, reject) => {
112
+ const onError = (err) => {
113
+ this.server?.off('listening', onListening);
114
+ reject(err);
115
+ };
116
+ const onListening = () => {
117
+ this.server?.off('error', onError);
118
+ resolve();
119
+ };
120
+ this.server.once('error', onError);
121
+ this.server.once('listening', onListening);
122
+ this.server.listen(this.socketPath);
123
+ });
124
+ this.server.unref?.();
125
+ await fs.chmod(this.socketPath, 0o600).catch(() => {});
126
+ }
127
+
128
+ async waitForBridge(timeoutMs = CONNECT_TIMEOUT_MS) {
129
+ if (this.ready && this.socket) return this;
130
+ if (!this._pendingConnect) {
131
+ this._pendingConnect = new Promise((resolve, reject) => {
132
+ this._resolveConnect = resolve;
133
+ this._rejectConnect = reject;
134
+ });
135
+ }
136
+ let timer = null;
137
+ const timeout = new Promise((_, reject) => {
138
+ timer = setTimeout(
139
+ () => reject(new Error(`timed out waiting for Claude print bridge ${this.socketPath}`)),
140
+ timeoutMs,
141
+ );
142
+ timer.unref?.();
143
+ });
144
+ try {
145
+ return await Promise.race([this._pendingConnect, timeout]);
146
+ } finally {
147
+ if (timer) clearTimeout(timer);
148
+ }
149
+ }
150
+
151
+ submit(text) {
152
+ if (!this.socket || this.socket.destroyed) throw new Error('Claude print bridge is not connected');
153
+ this._lastAssistantDuringTurn = false;
154
+ this.socket.write(lineOf({ type: 'submit', text: String(text ?? '') }));
155
+ }
156
+
157
+ cancel() {
158
+ if (!this.socket || this.socket.destroyed) throw new Error('Claude print bridge is not connected');
159
+ this.socket.write(lineOf({ type: 'cancel' }));
160
+ }
161
+
162
+ close() {
163
+ try { this.socket?.destroy(); } catch {}
164
+ try { this.server?.close(); } catch {}
165
+ this.socket = null;
166
+ this.server = null;
167
+ fs.rm(this.socketPath, { force: true }).catch(() => {});
168
+ }
169
+
170
+ threadInfo() {
171
+ return {
172
+ sessionId: this.sessionId,
173
+ transcriptPath: this.transcriptPath,
174
+ socketPath: this.socketPath,
175
+ cwd: this.cwd,
176
+ model: this.model,
177
+ };
178
+ }
179
+
180
+ _attach(socket) {
181
+ if (this.socket && !this.socket.destroyed) this.socket.destroy();
182
+ this.socket = socket;
183
+ socket.unref?.();
184
+ socket.setEncoding('utf8');
185
+ socket.on('data', (chunk) => this._onData(chunk));
186
+ socket.on('error', (err) => this.emit('error', err));
187
+ socket.on('close', () => {
188
+ this.ready = false;
189
+ this.active = false;
190
+ this.emit('status', 'idle');
191
+ this.emit('close');
192
+ });
193
+ }
194
+
195
+ _onData(chunk) {
196
+ this.buffer += chunk;
197
+ let idx;
198
+ while ((idx = this.buffer.indexOf('\n')) >= 0) {
199
+ const line = this.buffer.slice(0, idx);
200
+ this.buffer = this.buffer.slice(idx + 1);
201
+ if (!line.trim()) continue;
202
+ let msg;
203
+ try {
204
+ msg = JSON.parse(line);
205
+ } catch (err) {
206
+ this.emit('error', new Error(`invalid Claude print bridge JSON: ${err.message}`));
207
+ continue;
208
+ }
209
+ this._onMessage(msg);
210
+ }
211
+ }
212
+
213
+ _onMessage(msg) {
214
+ if (msg.type === 'ready') {
215
+ this.ready = true;
216
+ if (this._resolveConnect) this._resolveConnect(this);
217
+ this._pendingConnect = null;
218
+ this._resolveConnect = null;
219
+ this._rejectConnect = null;
220
+ return;
221
+ }
222
+
223
+ if (msg.type === 'status') {
224
+ this.active = msg.status === 'active';
225
+ this.emit('status', this.active ? 'active' : 'idle');
226
+ return;
227
+ }
228
+
229
+ if (msg.type === 'error') {
230
+ this.emit('error', new Error(String(msg.error || 'Claude print bridge error')));
231
+ return;
232
+ }
233
+
234
+ if (msg.type === 'event') {
235
+ this._onClaudeEvent(msg.event);
236
+ }
237
+ }
238
+
239
+ _onClaudeEvent(event) {
240
+ const info = eventSessionInfo(event);
241
+ if (info) {
242
+ if (info.sessionId) this.sessionId = info.sessionId;
243
+ if (info.transcriptPath) this.transcriptPath = info.transcriptPath;
244
+ if (info.model) this.model = info.model;
245
+ this.emit('thread', this.threadInfo());
246
+ }
247
+
248
+ const normalized = normalizeClaudePrintEvent(event);
249
+ if (normalized) {
250
+ if (normalized.role === 'assistant') this._lastAssistantDuringTurn = true;
251
+ this._appendMessage(normalized);
252
+ return;
253
+ }
254
+
255
+ if (event?.type === 'result') {
256
+ if (event.session_id && !this.sessionId) this.sessionId = event.session_id;
257
+ if (event.transcript_path && !this.transcriptPath) this.transcriptPath = event.transcript_path;
258
+ this.emit('thread', this.threadInfo());
259
+ const result = typeof event.result === 'string' ? event.result : '';
260
+ if (result.trim() && !this._lastAssistantDuringTurn) {
261
+ this._appendMessage({
262
+ uuid: event.uuid || `claude-print-result-${Date.now()}`,
263
+ role: 'assistant',
264
+ ts: event.timestamp || Date.now(),
265
+ blocks: [textBlock(result)],
266
+ rawType: 'claude_print_result',
267
+ });
268
+ }
269
+ }
270
+ }
271
+
272
+ _appendMessage(msg) {
273
+ this.messages.push(msg);
274
+ if (this.messages.length > MAX_MESSAGES) {
275
+ this.messages.splice(0, this.messages.length - MAX_MESSAGES);
276
+ }
277
+ this.emit('messages', [msg]);
278
+ }
279
+ }
280
+
281
+ export class ClaudePrintManager extends EventEmitter {
282
+ constructor({ socketDir = path.join(os.homedir(), '.claude-control', 'claude-print') } = {}) {
283
+ super();
284
+ this.socketDir = socketDir;
285
+ this.clients = new Map();
286
+ }
287
+
288
+ endpointFor(target) {
289
+ return path.join(this.socketDir, `${safeTargetName(target)}.sock`);
290
+ }
291
+
292
+ async attach({ target, socketPath = this.endpointFor(target), cwd }) {
293
+ const existing = this.clients.get(target);
294
+ if (existing) existing.close();
295
+ const client = new ClaudePrintClient({ target, socketPath, cwd });
296
+ this._bind(client);
297
+ await client.listen();
298
+ this.clients.set(target, client);
299
+ return client;
300
+ }
301
+
302
+ get(target) {
303
+ return this.clients.get(target) || null;
304
+ }
305
+
306
+ has(target) {
307
+ return this.clients.has(target);
308
+ }
309
+
310
+ messages(target) {
311
+ return this.get(target)?.messages ?? [];
312
+ }
313
+
314
+ prompt() {
315
+ return null;
316
+ }
317
+
318
+ threadInfo(target) {
319
+ return this.get(target)?.threadInfo() ?? null;
320
+ }
321
+
322
+ submit(target, text) {
323
+ const client = this.get(target);
324
+ if (!client) throw new Error('Claude print bridge is not attached');
325
+ return client.submit(text);
326
+ }
327
+
328
+ cancel(target) {
329
+ const client = this.get(target);
330
+ if (!client) throw new Error('Claude print bridge is not attached');
331
+ return client.cancel();
332
+ }
333
+
334
+ sweep(validTargets, { graceMs = 30_000 } = {}) {
335
+ const valid = new Set(validTargets || []);
336
+ const now = Date.now();
337
+ for (const [target, client] of this.clients) {
338
+ if (valid.has(target)) continue;
339
+ if (now - client.createdAt < graceMs) continue;
340
+ client.close();
341
+ this.clients.delete(target);
342
+ }
343
+ }
344
+
345
+ _bind(client) {
346
+ client.on('messages', (messages) => this.emit('messages', client.target, messages));
347
+ client.on('thread', (thread) => this.emit('thread', client.target, thread));
348
+ client.on('status', (status) => this.emit('status', client.target, status));
349
+ client.on('error', (err) => this.emit('error', client.target, err));
350
+ client.on('close', () => this.emit('close', client.target));
351
+ }
352
+ }