@kelceyp/caw-server 1.0.195 → 1.0.198

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,375 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PTY Wrapper — spawns interactive claude via node-pty, streams transcript, emits result.
4
+ *
5
+ * Spawned by JobRunner as a normal child process. Internally uses node-pty to run
6
+ * interactive `claude` (no -p flag), uses a Stop hook for completion detection via
7
+ * a sentinel file, and tails Claude's transcript JSONL for live streaming.
8
+ *
9
+ * Args:
10
+ * --session-id <uuid> Session ID (first run)
11
+ * --resume <uuid> Resume session (continuation)
12
+ * --model <model> Model override
13
+ * --skip-permissions Enable --dangerously-skip-permissions
14
+ *
15
+ * Stdin: The prompt text (piped by JobRunner before spawn)
16
+ * Stdout: JSONL: {type:"transcript",entry:{...}} per line, then {type:"result",...}
17
+ * Exit: 0 on success (or SIGINT with valid sentinel), non-zero on failure
18
+ */
19
+
20
+ import * as pty from 'node-pty';
21
+ import { mkdtempSync, readFileSync, existsSync, writeFileSync, rmSync, realpathSync } from 'fs';
22
+ import { join } from 'path';
23
+ import { tmpdir, homedir } from 'os';
24
+ import { execSync } from 'child_process';
25
+
26
+ // --- Arg parsing ---
27
+
28
+ const args = process.argv.slice(2);
29
+ const getArg = (name) => {
30
+ const idx = args.indexOf(name);
31
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
32
+ };
33
+ const hasFlag = (name) => args.includes(name);
34
+
35
+ const sessionId = getArg('--session-id');
36
+ const resumeId = getArg('--resume');
37
+ const model = getArg('--model');
38
+ const skipPermissions = hasFlag('--skip-permissions');
39
+
40
+ if (!sessionId && !resumeId) {
41
+ process.stderr.write('Error: Either --session-id or --resume must be provided\n');
42
+ process.exit(1);
43
+ }
44
+
45
+ const effectiveSessionId = sessionId || resumeId;
46
+
47
+ // --- Logging (stderr only — stdout is reserved for streaming output) ---
48
+
49
+ const log = (msg) => process.stderr.write(`[pty-wrapper] ${msg}\n`);
50
+
51
+ // --- Read prompt from stdin ---
52
+
53
+ const promptChunks = [];
54
+ for await (const chunk of process.stdin) {
55
+ promptChunks.push(chunk);
56
+ }
57
+ const prompt = Buffer.concat(promptChunks).toString('utf-8').trim();
58
+
59
+ if (!prompt) {
60
+ process.stderr.write('Error: No prompt provided on stdin\n');
61
+ process.exit(1);
62
+ }
63
+
64
+ // --- Resolve claude binary ---
65
+
66
+ let claudePath;
67
+ try {
68
+ claudePath = execSync('which claude', { encoding: 'utf-8' }).trim();
69
+ if (!claudePath) throw new Error('empty result');
70
+ } catch {
71
+ process.stderr.write('Error: claude binary not found in PATH\n');
72
+ process.exit(1);
73
+ }
74
+
75
+ // --- Temp directory for settings, hook script, and sentinel file ---
76
+
77
+ const tempDir = mkdtempSync(join(tmpdir(), 'caw-pty-'));
78
+ const sentinelPath = join(tempDir, 'result.json');
79
+ const hookScriptPath = join(tempDir, 'stop-hook.sh');
80
+ const settingsPath = join(tempDir, 'settings.json');
81
+ const claudePidPath = join(tempDir, 'claude.pid');
82
+
83
+ // --- Write Stop hook script ---
84
+ // The hook: (1) cats Stop hook JSON (on stdin) to sentinel file, then
85
+ // (2) SIGINTs the specific claude PID recorded in claude.pid.
86
+ // Using a PID file avoids a fuzzy *claude* tree walk that could accidentally
87
+ // SIGINT the calling agent process (e.g. when running under a Claude agent).
88
+
89
+ const hookScript = [
90
+ '#!/bin/bash',
91
+ `cat > '${sentinelPath}'`,
92
+ `kill -INT $(cat '${claudePidPath}') 2>/dev/null`,
93
+ ''
94
+ ].join('\n');
95
+ writeFileSync(hookScriptPath, hookScript, { mode: 0o755 });
96
+
97
+ // --- Write settings file with Stop hook config ---
98
+
99
+ const settings = {
100
+ hooks: {
101
+ Stop: [
102
+ {
103
+ matcher: '',
104
+ hooks: [
105
+ {
106
+ type: 'command',
107
+ command: hookScriptPath
108
+ }
109
+ ]
110
+ }
111
+ ]
112
+ }
113
+ };
114
+ writeFileSync(settingsPath, JSON.stringify(settings));
115
+
116
+ // --- Pre-seed ~/.claude.json to avoid interactive dialogs ---
117
+ // Sets hasCompletedOnboarding and trusts the working directory.
118
+ // Uses realpathSync to handle macOS /tmp → /private/tmp symlinks.
119
+
120
+ const ensureNonInteractiveSetup = (cwd) => {
121
+ const claudeJsonPath = join(homedir(), '.claude.json');
122
+ try {
123
+ let data = {};
124
+ try { data = JSON.parse(readFileSync(claudeJsonPath, 'utf-8')); } catch { /* file may not exist */ }
125
+
126
+ if (!data.hasCompletedOnboarding) {
127
+ data.hasCompletedOnboarding = true;
128
+ data.lastOnboardingVersion = '9.9.9';
129
+ }
130
+
131
+ // Trust both the raw and real cwd paths (handles macOS /tmp symlink)
132
+ const paths = new Set([cwd]);
133
+ try { paths.add(realpathSync(cwd)); } catch { /* ignore */ }
134
+
135
+ data.projects = data.projects || {};
136
+ for (const p of paths) {
137
+ if (!data.projects[p]?.hasTrustDialogAccepted) {
138
+ data.projects[p] = {
139
+ ...(data.projects[p] || {}),
140
+ hasTrustDialogAccepted: true,
141
+ projectOnboardingSeenCount: 10
142
+ };
143
+ }
144
+ }
145
+
146
+ writeFileSync(claudeJsonPath, JSON.stringify(data));
147
+ log(`Non-interactive setup complete for: ${cwd}`);
148
+ } catch (err) {
149
+ log(`Warning: non-interactive setup failed: ${err.message}`);
150
+ }
151
+ };
152
+
153
+ ensureNonInteractiveSetup(process.cwd());
154
+
155
+ // --- Build claude args ---
156
+
157
+ const claudeArgs = [];
158
+ if (resumeId) {
159
+ claudeArgs.push('--resume', resumeId);
160
+ } else {
161
+ claudeArgs.push('--session-id', sessionId);
162
+ }
163
+ if (skipPermissions) {
164
+ claudeArgs.push('--dangerously-skip-permissions');
165
+ }
166
+ if (model) {
167
+ claudeArgs.push('--model', model);
168
+ }
169
+ claudeArgs.push('--settings', settingsPath);
170
+ claudeArgs.push(prompt);
171
+
172
+ log(`Session: ${effectiveSessionId}`);
173
+ log(`Prompt length: ${prompt.length} chars`);
174
+
175
+ // --- Spawn claude via PTY ---
176
+
177
+ const ptyProcess = pty.spawn(claudePath, claudeArgs, {
178
+ name: 'xterm-256color',
179
+ cols: 120,
180
+ rows: 40,
181
+ cwd: process.cwd(),
182
+ env: { ...process.env, CLAUBBIT: '1' }
183
+ });
184
+
185
+ log(`PID: ${ptyProcess.pid}`);
186
+ writeFileSync(claudePidPath, String(ptyProcess.pid));
187
+
188
+ // --- Auto-confirm --dangerously-skip-permissions dialog ---
189
+ // Claude shows a "Yes/No" dialog when --dangerously-skip-permissions is used.
190
+ // Detect it in PTY output and send down-arrow (select "Yes") + Enter.
191
+
192
+ let ttyBuffer = '';
193
+ let permissionConfirmed = false;
194
+ ptyProcess.onData((data) => {
195
+ ttyBuffer += data;
196
+ if (!permissionConfirmed && ttyBuffer.includes('Yes') && ttyBuffer.includes('No')) {
197
+ log('Auto-confirming bypass permissions dialog');
198
+ ptyProcess.write('\x1b[B'); // down arrow to select "Yes"
199
+ setTimeout(() => ptyProcess.write('\r'), 200); // Enter
200
+ permissionConfirmed = true;
201
+ }
202
+ // Keep buffer bounded
203
+ if (ttyBuffer.length > 10000) ttyBuffer = ttyBuffer.slice(-5000);
204
+ });
205
+
206
+ // --- Transcript tailing ---
207
+ // Claude writes session transcript to ~/.claude/projects/<cwd-hash>/<session-id>.jsonl.
208
+ // We poll it every 1 second and forward new entries to stdout as streaming events.
209
+
210
+ let transcriptPath = null;
211
+ let transcriptLinesRead = 0;
212
+
213
+ // Resolve transcript path, handling macOS /tmp → /private/tmp symlinks.
214
+ // Returns null if not yet found (retried on next poll).
215
+ const resolveTranscriptPath = () => {
216
+ if (transcriptPath) return transcriptPath;
217
+
218
+ const claudeProjectsDir = join(homedir(), '.claude', 'projects');
219
+ const cwd = process.cwd();
220
+ const paths = new Set([cwd]);
221
+ try { paths.add(realpathSync(cwd)); } catch { /* ignore */ }
222
+
223
+ for (const dir of paths) {
224
+ const hash = dir.replace(/\//g, '-');
225
+ const candidate = join(claudeProjectsDir, hash, `${effectiveSessionId}.jsonl`);
226
+ if (existsSync(candidate)) {
227
+ transcriptPath = candidate;
228
+ log(`Transcript found: ${transcriptPath}`);
229
+ // On resume: snapshot existing line count so only new content streams
230
+ if (resumeId) {
231
+ try {
232
+ const content = readFileSync(candidate, 'utf-8');
233
+ transcriptLinesRead = content.trim().split('\n').filter(Boolean).length;
234
+ log(`Resume: skipping ${transcriptLinesRead} existing transcript lines`);
235
+ } catch { /* ignore */ }
236
+ }
237
+ return transcriptPath;
238
+ }
239
+ }
240
+ return null;
241
+ };
242
+
243
+ // Read new transcript entries and forward to stdout. Returns count of new lines emitted.
244
+ const tailTranscript = () => {
245
+ const path = resolveTranscriptPath();
246
+ if (!path) return 0;
247
+
248
+ let newLines = 0;
249
+ try {
250
+ const content = readFileSync(path, 'utf-8');
251
+ const lines = content.trim().split('\n').filter(Boolean);
252
+ for (let i = transcriptLinesRead; i < lines.length; i++) {
253
+ try {
254
+ const entry = JSON.parse(lines[i]);
255
+ // Skip hook progress notifications (internal noise)
256
+ if (entry.type === 'progress' && entry.data?.type === 'hook_progress') continue;
257
+ process.stdout.write(JSON.stringify({ type: 'transcript', entry }) + '\n');
258
+ newLines++;
259
+ } catch {
260
+ // Skip unparseable lines
261
+ }
262
+ }
263
+ transcriptLinesRead = lines.length;
264
+ } catch {
265
+ // File may not exist yet or be mid-write — retry on next poll
266
+ }
267
+ return newLines;
268
+ };
269
+
270
+ // --- Sentinel / result emission ---
271
+ // Called from onExit after polling stops. Reads sentinel written by Stop hook.
272
+
273
+ let resultEmitted = false;
274
+
275
+ const emitResult = () => {
276
+ if (resultEmitted) return;
277
+ if (!existsSync(sentinelPath)) return;
278
+
279
+ try {
280
+ const hookData = JSON.parse(readFileSync(sentinelPath, 'utf-8'));
281
+ const result = {
282
+ type: 'result',
283
+ result: hookData.last_assistant_message || '',
284
+ metadata: {
285
+ sessionId: hookData.session_id || effectiveSessionId,
286
+ transcriptPath: hookData.transcript_path || transcriptPath || null,
287
+ permissionMode: hookData.permission_mode || null
288
+ }
289
+ };
290
+ process.stdout.write(JSON.stringify(result) + '\n');
291
+ resultEmitted = true;
292
+ } catch (err) {
293
+ log(`Error reading sentinel: ${err.message}`);
294
+ }
295
+ };
296
+
297
+ // --- Inactivity timeout ---
298
+ // Resets on every new transcript entry. Only fires after 5 continuous minutes
299
+ // with NO transcript activity AND no process exit — indicating a genuine hang.
300
+
301
+ const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
302
+ let inactivityTimer = null;
303
+
304
+ const resetInactivityTimer = () => {
305
+ if (inactivityTimer) clearTimeout(inactivityTimer);
306
+ inactivityTimer = setTimeout(() => {
307
+ log('Inactivity timeout: no transcript activity for 5 minutes, killing claude');
308
+ stopPolling();
309
+ ptyProcess.kill();
310
+ // Clean up temp dir before exiting — process.exit() is synchronous so
311
+ // onExit cannot fire after this point.
312
+ try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
313
+ process.exit(1);
314
+ }, INACTIVITY_TIMEOUT_MS);
315
+ };
316
+
317
+ // --- Transcript poll interval ---
318
+
319
+ let transcriptPollInterval;
320
+
321
+ const stopPolling = () => {
322
+ if (inactivityTimer) { clearTimeout(inactivityTimer); inactivityTimer = null; }
323
+ if (transcriptPollInterval) { clearInterval(transcriptPollInterval); transcriptPollInterval = null; }
324
+ };
325
+
326
+ transcriptPollInterval = setInterval(() => {
327
+ const newLines = tailTranscript();
328
+ if (newLines > 0) resetInactivityTimer();
329
+ }, 1000);
330
+
331
+ // Start inactivity timer now that claude is spawned
332
+ resetInactivityTimer();
333
+
334
+ // --- Signal forwarding ---
335
+ // Forward SIGTERM/SIGINT to the claude process inside the PTY.
336
+ // onExit fires when ptyProcess exits, handling cleanup.
337
+
338
+ process.on('SIGTERM', () => {
339
+ log('Received SIGTERM, forwarding to claude');
340
+ ptyProcess.kill('SIGTERM');
341
+ });
342
+
343
+ process.on('SIGINT', () => {
344
+ log('Received SIGINT, forwarding to claude');
345
+ ptyProcess.kill('SIGINT');
346
+ });
347
+
348
+ // --- Process exit handler ---
349
+
350
+ let exited = false;
351
+ ptyProcess.onExit(({ exitCode }) => {
352
+ if (exited) return;
353
+ exited = true;
354
+
355
+ // Stop polling (sentinelPath still exists on disk at this point)
356
+ stopPolling();
357
+
358
+ // Do a final transcript tail to capture any last entries
359
+ tailTranscript();
360
+
361
+ // Read sentinel and emit result (tempDir still present — delete after)
362
+ emitResult();
363
+
364
+ // Clean up temp directory
365
+ try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
366
+
367
+ log(`Claude exited (code: ${exitCode})`);
368
+
369
+ // Exit 0 if result was successfully emitted (includes SIGINT 130 remap),
370
+ // or if claude itself exited cleanly. Otherwise propagate exit code.
371
+ if (resultEmitted || exitCode === 0) {
372
+ process.exit(0);
373
+ }
374
+ process.exit(exitCode ?? 1);
375
+ });