forkoff 1.0.0

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.
Files changed (56) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +173 -0
  3. package/dist/api.d.ts +44 -0
  4. package/dist/api.d.ts.map +1 -0
  5. package/dist/api.js +76 -0
  6. package/dist/api.js.map +1 -0
  7. package/dist/approval.d.ts +46 -0
  8. package/dist/approval.d.ts.map +1 -0
  9. package/dist/approval.js +119 -0
  10. package/dist/approval.js.map +1 -0
  11. package/dist/config.d.ts +36 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +209 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/index.d.ts +3 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +868 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/integration.d.ts +30 -0
  20. package/dist/integration.d.ts.map +1 -0
  21. package/dist/integration.js +84 -0
  22. package/dist/integration.js.map +1 -0
  23. package/dist/terminal.d.ts +25 -0
  24. package/dist/terminal.d.ts.map +1 -0
  25. package/dist/terminal.js +171 -0
  26. package/dist/terminal.js.map +1 -0
  27. package/dist/tools/claude-hooks.d.ts +97 -0
  28. package/dist/tools/claude-hooks.d.ts.map +1 -0
  29. package/dist/tools/claude-hooks.js +348 -0
  30. package/dist/tools/claude-hooks.js.map +1 -0
  31. package/dist/tools/claude-process.d.ts +271 -0
  32. package/dist/tools/claude-process.d.ts.map +1 -0
  33. package/dist/tools/claude-process.js +931 -0
  34. package/dist/tools/claude-process.js.map +1 -0
  35. package/dist/tools/claude-sessions.d.ts +60 -0
  36. package/dist/tools/claude-sessions.d.ts.map +1 -0
  37. package/dist/tools/claude-sessions.js +285 -0
  38. package/dist/tools/claude-sessions.js.map +1 -0
  39. package/dist/tools/detector.d.ts +64 -0
  40. package/dist/tools/detector.d.ts.map +1 -0
  41. package/dist/tools/detector.js +383 -0
  42. package/dist/tools/detector.js.map +1 -0
  43. package/dist/tools/index.d.ts +8 -0
  44. package/dist/tools/index.d.ts.map +1 -0
  45. package/dist/tools/index.js +15 -0
  46. package/dist/tools/index.js.map +1 -0
  47. package/dist/transcript-streamer.d.ts +68 -0
  48. package/dist/transcript-streamer.d.ts.map +1 -0
  49. package/dist/transcript-streamer.js +459 -0
  50. package/dist/transcript-streamer.js.map +1 -0
  51. package/dist/websocket.d.ts +133 -0
  52. package/dist/websocket.d.ts.map +1 -0
  53. package/dist/websocket.js +247 -0
  54. package/dist/websocket.js.map +1 -0
  55. package/nul +0 -0
  56. package/package.json +54 -0
@@ -0,0 +1,931 @@
1
+ "use strict";
2
+ /**
3
+ * Claude Process Manager
4
+ * Spawns and manages Claude CLI processes for terminal sessions
5
+ */
6
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
+ if (k2 === undefined) k2 = k;
8
+ var desc = Object.getOwnPropertyDescriptor(m, k);
9
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
+ desc = { enumerable: true, get: function() { return m[k]; } };
11
+ }
12
+ Object.defineProperty(o, k2, desc);
13
+ }) : (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ o[k2] = m[k];
16
+ }));
17
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
19
+ }) : function(o, v) {
20
+ o["default"] = v;
21
+ });
22
+ var __importStar = (this && this.__importStar) || (function () {
23
+ var ownKeys = function(o) {
24
+ ownKeys = Object.getOwnPropertyNames || function (o) {
25
+ var ar = [];
26
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
+ return ar;
28
+ };
29
+ return ownKeys(o);
30
+ };
31
+ return function (mod) {
32
+ if (mod && mod.__esModule) return mod;
33
+ var result = {};
34
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
+ __setModuleDefault(result, mod);
36
+ return result;
37
+ };
38
+ })();
39
+ var __importDefault = (this && this.__importDefault) || function (mod) {
40
+ return (mod && mod.__esModule) ? mod : { "default": mod };
41
+ };
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.claudeProcessManager = void 0;
44
+ const cross_spawn_1 = __importDefault(require("cross-spawn"));
45
+ const events_1 = require("events");
46
+ const os = __importStar(require("os"));
47
+ const path = __importStar(require("path"));
48
+ /**
49
+ * Regular expression patterns used to detect approval prompts in Claude CLI output.
50
+ * When any of these patterns match the output, an approval request is triggered
51
+ * and sent to the mobile app for user confirmation.
52
+ *
53
+ * Supported patterns include:
54
+ * - [y]es, [n]o, [p]lan format (bracketed option letters)
55
+ * - (y/n) format (parenthetical yes/no)
56
+ * - Various question phrases like "Do you want to proceed?", "Allow this action?", etc.
57
+ *
58
+ * @constant {RegExp[]}
59
+ */
60
+ const APPROVAL_PATTERNS = [
61
+ /\[y\]es.*\[n\]o/i, // [y]es, [n]o, [p]lan format
62
+ /\(y\/n\)/i, // (y/n) format
63
+ /do you want to proceed/i, // Do you want to proceed?
64
+ /allow this action/i, // Allow this action?
65
+ /continue\?/i, // Continue?
66
+ /approve this/i, // Approve this?
67
+ ];
68
+ /**
69
+ * Extracts available approval options from a prompt text.
70
+ *
71
+ * Parses the approval prompt to identify available response options.
72
+ * For bracketed format prompts like "[y]es, [n]o, [p]lan", it extracts
73
+ * each option as "key:label" pairs (e.g., "y:yes", "n:no", "p:plan").
74
+ *
75
+ * @param {string} text - The prompt text to parse for options
76
+ * @returns {string[]} Array of option strings in "key:label" format.
77
+ * Returns ['y:yes', 'n:no'] as default if no specific options are found.
78
+ *
79
+ * @example
80
+ * // Bracketed format
81
+ * extractApprovalOptions("[y]es, [n]o, [p]lan");
82
+ * // Returns: ['y:yes', 'n:no', 'p:plan']
83
+ *
84
+ * @example
85
+ * // Default fallback
86
+ * extractApprovalOptions("Continue? (y/n)");
87
+ * // Returns: ['y:yes', 'n:no']
88
+ */
89
+ function extractApprovalOptions(text) {
90
+ // Skip JSON content - only parse plain text prompts
91
+ if (text.trim().startsWith('{') || text.trim().startsWith('[')) {
92
+ // For SDK JSON output, default to standard options
93
+ return ['y:yes', 'n:no', 'p:plan'];
94
+ }
95
+ // Check for [y]es, [n]o, [p]lan format in plain text
96
+ const bracketMatch = text.match(/\[([ynpae])\][a-z]+/gi);
97
+ if (bracketMatch && bracketMatch.length >= 2 && bracketMatch.length <= 4) {
98
+ return bracketMatch.map(m => {
99
+ const key = m.match(/\[([a-z])\]/i)?.[1]?.toLowerCase() || '';
100
+ const full = m.replace(/\[|\]/g, '');
101
+ return `${key}:${full}`;
102
+ });
103
+ }
104
+ // Default yes/no/plan
105
+ return ['y:yes', 'n:no', 'p:plan'];
106
+ }
107
+ class ClaudeProcessManager extends events_1.EventEmitter {
108
+ constructor() {
109
+ super(...arguments);
110
+ this.processes = new Map();
111
+ this.pendingApprovals = new Map();
112
+ this.APPROVAL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
113
+ this.MAX_OUTPUT_BUFFER_LINES = 20;
114
+ /** Track closed sessions for auto-restart */
115
+ this.closedSessions = new Map();
116
+ /** Maximum number of auto-restarts per session to prevent chaos */
117
+ this.MAX_AUTO_RESTARTS = 3;
118
+ }
119
+ /** Type-safe emit for known events */
120
+ emit(event, ...args) {
121
+ return super.emit(event, ...args);
122
+ }
123
+ /** Type-safe on for known events */
124
+ on(event, listener) {
125
+ return super.on(event, listener);
126
+ }
127
+ /**
128
+ * Start a new Claude session in the specified directory
129
+ */
130
+ async startSession(directory, terminalSessionId) {
131
+ const resolvedDir = this.resolvePath(directory);
132
+ // SDK flags for structured JSON communication
133
+ const args = [
134
+ '--output-format', 'stream-json', // JSONL output from Claude
135
+ '--input-format', 'stream-json', // JSONL input to Claude
136
+ '--verbose', // Complete messages
137
+ // '--permission-mode' removed - using default mode for tool execution
138
+ ];
139
+ // SECURITY: Using cross-spawn instead of shell: true to prevent command injection
140
+ const proc = (0, cross_spawn_1.default)('claude', args, {
141
+ cwd: resolvedDir,
142
+ env: { ...process.env, TERM: 'xterm-256color' },
143
+ stdio: ['pipe', 'pipe', 'pipe'],
144
+ });
145
+ this.setupProcessHandlers(terminalSessionId, proc, resolvedDir);
146
+ this.processes.set(terminalSessionId, { terminalSessionId, process: proc, directory: resolvedDir, outputBuffer: [], wasAutoRestarted: false });
147
+ return { cwd: resolvedDir };
148
+ }
149
+ /**
150
+ * Resume an existing Claude session
151
+ */
152
+ async resumeSession(sessionKey, directory, terminalSessionId) {
153
+ const resolvedDir = this.resolvePath(directory);
154
+ // SDK flags for structured JSON communication
155
+ // When resuming from mobile, use acceptEdits to auto-approve file operations
156
+ // This is necessary because SDK JSON streaming mode interprets raw 'y' input
157
+ // as a user message rather than a permission approval
158
+ const args = [
159
+ '--resume', sessionKey, // Pass session key to --resume!
160
+ '--output-format', 'stream-json', // JSONL output from Claude
161
+ '--input-format', 'stream-json', // JSONL input to Claude
162
+ '--verbose', // Complete messages
163
+ '--permission-mode', 'acceptEdits', // Auto-approve edits when controlled from mobile
164
+ ];
165
+ console.log(`[Claude Process] Spawning: claude ${args.join(' ')}`);
166
+ // SECURITY: Using cross-spawn instead of shell: true to prevent command injection
167
+ const proc = (0, cross_spawn_1.default)('claude', args, {
168
+ cwd: resolvedDir,
169
+ env: { ...process.env, TERM: 'xterm-256color' },
170
+ stdio: ['pipe', 'pipe', 'pipe'],
171
+ });
172
+ this.setupProcessHandlers(terminalSessionId, proc, resolvedDir, sessionKey);
173
+ this.processes.set(terminalSessionId, { terminalSessionId, process: proc, directory: resolvedDir, sessionKey, outputBuffer: [], wasAutoRestarted: false });
174
+ // Store session info for future message sends (needed since we spawn fresh process per message)
175
+ this.closedSessions.set(terminalSessionId, {
176
+ sessionKey,
177
+ directory: resolvedDir,
178
+ lastExitCode: 0,
179
+ lastExitTime: Date.now(),
180
+ restartCount: 0,
181
+ });
182
+ return { cwd: resolvedDir };
183
+ }
184
+ /**
185
+ * Send input to a Claude process in JSONL format
186
+ * Format: {"type":"user","message":{"role":"user","content":"..."}}
187
+ *
188
+ * IMPORTANT: Claude SDK with --resume and streaming JSON only supports ONE turn per process.
189
+ * So we kill any existing process and spawn a fresh one for each message.
190
+ * Since we use --resume, the conversation history is preserved.
191
+ */
192
+ async sendInput(terminalSessionId, input) {
193
+ let info = this.processes.get(terminalSessionId);
194
+ const restartInfo = this.closedSessions.get(terminalSessionId);
195
+ // If there's an existing process, kill it first (Claude SDK only supports 1 turn per process)
196
+ if (info?.process && info.process.exitCode === null) {
197
+ console.log(`[Claude Process] Killing existing process for new message (SDK limitation: 1 turn per process)`);
198
+ info.process.kill('SIGTERM');
199
+ // Wait for process to die
200
+ await new Promise(resolve => setTimeout(resolve, 200));
201
+ this.processes.delete(terminalSessionId);
202
+ info = undefined;
203
+ }
204
+ // Get session info from either current process or closed sessions
205
+ const sessionKey = info?.sessionKey || restartInfo?.sessionKey;
206
+ const directory = info?.directory || restartInfo?.directory;
207
+ if (!sessionKey || !directory) {
208
+ console.log(`[Claude Process] No session info found for ${terminalSessionId}`);
209
+ return false;
210
+ }
211
+ // Spawn a fresh Claude process for this message
212
+ // IMPORTANT: We must write to stdin immediately after spawn - Claude CLI with
213
+ // stream-json input format exits if it doesn't receive input quickly
214
+ console.log(`[Claude Process] Spawning fresh process for message (--resume preserves history)`);
215
+ // Format as JSONL user message (SDK format)
216
+ const message = {
217
+ type: 'user',
218
+ message: {
219
+ role: 'user',
220
+ content: input.replace(/\n$/, ''), // Remove trailing newline from input
221
+ },
222
+ };
223
+ const jsonLine = JSON.stringify(message) + '\n';
224
+ try {
225
+ await this.resumeSession(sessionKey, directory, terminalSessionId);
226
+ info = this.processes.get(terminalSessionId);
227
+ }
228
+ catch (err) {
229
+ console.error(`[Claude Process] Failed to spawn process:`, err.message);
230
+ return false;
231
+ }
232
+ if (!info?.process) {
233
+ console.log(`[Claude Process] Failed to get process after spawn for ${terminalSessionId}`);
234
+ return false;
235
+ }
236
+ if (!info.process.stdin || info.process.stdin.destroyed) {
237
+ console.log(`[Claude Process] stdin is closed or destroyed for ${terminalSessionId}`);
238
+ return false;
239
+ }
240
+ // Write message to stdin IMMEDIATELY - no waiting
241
+ console.log(`[Claude Process] Sending JSONL immediately: ${jsonLine.substring(0, 100)}...`);
242
+ return new Promise((resolve) => {
243
+ try {
244
+ info.process.stdin.write(jsonLine, (err) => {
245
+ if (err) {
246
+ console.error(`[Claude Process] Error writing to stdin for ${terminalSessionId}:`, err.message);
247
+ resolve(false);
248
+ }
249
+ else {
250
+ console.log(`[Claude Process] Message written to stdin successfully`);
251
+ resolve(true);
252
+ }
253
+ });
254
+ }
255
+ catch (err) {
256
+ console.error(`[Claude Process] Exception writing to stdin for ${terminalSessionId}:`, err.message);
257
+ resolve(false);
258
+ }
259
+ });
260
+ }
261
+ /**
262
+ * Check if a session is a Claude session (active or restartable)
263
+ */
264
+ isClaudeSession(terminalSessionId) {
265
+ return this.processes.has(terminalSessionId) || this.closedSessions.has(terminalSessionId);
266
+ }
267
+ /**
268
+ * Register session info without spawning a process.
269
+ * Used when mobile opens a session view - we store the info so we can spawn later on first message.
270
+ */
271
+ registerSession(sessionKey, directory, terminalSessionId) {
272
+ console.log(`[Claude Process] Registering session: ${sessionKey} in ${directory}`);
273
+ this.closedSessions.set(terminalSessionId, {
274
+ sessionKey,
275
+ directory,
276
+ lastExitCode: 0,
277
+ lastExitTime: Date.now(),
278
+ restartCount: 0,
279
+ });
280
+ }
281
+ /**
282
+ * Set up event handlers for the spawned process
283
+ */
284
+ setupProcessHandlers(terminalSessionId, proc, directory, sessionKey) {
285
+ // Buffer for incomplete JSONL lines
286
+ let jsonLineBuffer = '';
287
+ proc.stdout?.on('data', (data) => {
288
+ const rawOutput = data.toString();
289
+ jsonLineBuffer += rawOutput;
290
+ // Update output buffer for approval context
291
+ const processInfo = this.processes.get(terminalSessionId);
292
+ if (processInfo) {
293
+ // Add new lines to buffer, keeping last N lines
294
+ const newLines = rawOutput.split('\n').filter(l => l.trim());
295
+ processInfo.outputBuffer.push(...newLines);
296
+ if (processInfo.outputBuffer.length > this.MAX_OUTPUT_BUFFER_LINES) {
297
+ processInfo.outputBuffer = processInfo.outputBuffer.slice(-this.MAX_OUTPUT_BUFFER_LINES);
298
+ }
299
+ // Check for approval patterns in the raw output
300
+ this.checkForApprovalPattern(terminalSessionId, rawOutput, processInfo);
301
+ }
302
+ // Process complete JSONL lines
303
+ const lines = jsonLineBuffer.split('\n');
304
+ jsonLineBuffer = lines.pop() || ''; // Keep incomplete line in buffer
305
+ for (const line of lines) {
306
+ if (line.trim()) {
307
+ try {
308
+ const message = JSON.parse(line);
309
+ // Emit parsed SDK message for status tracking
310
+ this.emit('sdk_message', { terminalSessionId, message });
311
+ // Log SDK message type for debugging
312
+ if (message.type) {
313
+ console.log(`[Claude Process] SDK message: ${message.type}${message.subtype ? '/' + message.subtype : ''}`);
314
+ // Log when we receive a result message (end of turn)
315
+ if (message.type === 'result') {
316
+ console.log(`[Claude Process] Received result message - turn complete. Subtype: ${message.subtype}, Cost: $${message.cost_usd || 'unknown'}`);
317
+ if (message.is_error) {
318
+ console.log(`[Claude Process] Result indicates error: ${JSON.stringify(message.error || 'unknown')}`);
319
+ }
320
+ }
321
+ }
322
+ // Detect tool_use in SDK messages and emit approval request
323
+ if (processInfo) {
324
+ this.checkForToolUseInSdkMessage(terminalSessionId, message, processInfo);
325
+ }
326
+ // Parse thinking content from content_block_delta with thinking type
327
+ this.parseThinkingContent(terminalSessionId, message, sessionKey);
328
+ // Parse token usage from message_delta
329
+ this.parseTokenUsage(terminalSessionId, message, sessionKey);
330
+ // Parse task progress from TaskCreate/TaskUpdate/TaskList tool_use
331
+ this.parseTaskProgress(terminalSessionId, message, sessionKey);
332
+ }
333
+ catch (e) {
334
+ // Non-JSON output (shouldn't happen with SDK flags, but log it)
335
+ console.log(`[Claude Process] Non-JSON stdout: ${line.substring(0, 50)}...`);
336
+ }
337
+ }
338
+ }
339
+ // Keep raw output emission for terminal display
340
+ const output = {
341
+ terminalSessionId,
342
+ output: rawOutput,
343
+ type: 'stdout',
344
+ };
345
+ this.emit('output', output);
346
+ });
347
+ proc.stderr?.on('data', (data) => {
348
+ const output = {
349
+ terminalSessionId,
350
+ output: data.toString(),
351
+ type: 'stderr',
352
+ };
353
+ this.emit('output', output);
354
+ });
355
+ proc.on('close', (code) => {
356
+ const exitCode = code ?? 0;
357
+ console.log(`[Claude Process] Process closed for ${terminalSessionId}, exit code: ${exitCode}`);
358
+ // Store session info for potential restart
359
+ // Get process info before we delete it
360
+ const processInfo = this.processes.get(terminalSessionId);
361
+ const existingInfo = this.closedSessions.get(terminalSessionId);
362
+ // Only preserve restart count if this was an auto-restarted session
363
+ // Otherwise reset to 0 (user explicitly started a new session)
364
+ const restartCount = processInfo?.wasAutoRestarted
365
+ ? (existingInfo?.restartCount ?? 0)
366
+ : 0;
367
+ this.closedSessions.set(terminalSessionId, {
368
+ sessionKey,
369
+ directory,
370
+ lastExitCode: exitCode,
371
+ lastExitTime: Date.now(),
372
+ restartCount,
373
+ });
374
+ // Emit exit event
375
+ const exitOutput = {
376
+ terminalSessionId,
377
+ output: '',
378
+ type: 'exit',
379
+ exitCode,
380
+ };
381
+ this.emit('output', exitOutput);
382
+ // Emit session ended event
383
+ const endedEvent = {
384
+ terminalSessionId,
385
+ directory,
386
+ sessionKey,
387
+ exitCode,
388
+ };
389
+ this.emit('session_ended', endedEvent);
390
+ // Clean up
391
+ this.processes.delete(terminalSessionId);
392
+ });
393
+ proc.on('error', (error) => {
394
+ console.error(`[Claude Process] Error for ${terminalSessionId}:`, error.message);
395
+ const output = {
396
+ terminalSessionId,
397
+ output: `Error: ${error.message}\n`,
398
+ type: 'stderr',
399
+ };
400
+ this.emit('output', output);
401
+ });
402
+ }
403
+ /**
404
+ * Resolve path (handle ~ for home directory)
405
+ * SECURITY: Validates path doesn't contain dangerous characters
406
+ */
407
+ resolvePath(dir) {
408
+ // SECURITY: Reject paths with shell metacharacters that could be dangerous
409
+ if (/[;&|`$()<>]/.test(dir)) {
410
+ throw new Error('Invalid directory path: contains disallowed characters');
411
+ }
412
+ if (dir === '~' || dir.startsWith('~/')) {
413
+ return dir === '~' ? os.homedir() : dir.replace('~', os.homedir());
414
+ }
415
+ return path.resolve(dir);
416
+ }
417
+ /**
418
+ * Kill a Claude process
419
+ */
420
+ killProcess(terminalSessionId) {
421
+ const info = this.processes.get(terminalSessionId);
422
+ if (info?.process) {
423
+ info.process.kill('SIGTERM');
424
+ }
425
+ }
426
+ /**
427
+ * Get all active process IDs
428
+ */
429
+ getActiveProcessIds() {
430
+ return Array.from(this.processes.keys());
431
+ }
432
+ /**
433
+ * Get all active sessions with their details
434
+ */
435
+ getActiveSessions() {
436
+ return Array.from(this.processes.values()).map(info => ({
437
+ terminalSessionId: info.terminalSessionId,
438
+ sessionKey: info.sessionKey,
439
+ directory: info.directory,
440
+ }));
441
+ }
442
+ /**
443
+ * Clean up old closed session entries to prevent memory leaks.
444
+ * Sessions older than 1 hour are removed.
445
+ */
446
+ cleanupOldClosedSessions() {
447
+ const ONE_HOUR_MS = 60 * 60 * 1000;
448
+ const now = Date.now();
449
+ let cleanedCount = 0;
450
+ for (const [sessionId, info] of this.closedSessions.entries()) {
451
+ if (now - info.lastExitTime > ONE_HOUR_MS) {
452
+ this.closedSessions.delete(sessionId);
453
+ cleanedCount++;
454
+ }
455
+ }
456
+ if (cleanedCount > 0) {
457
+ console.log(`[Claude Process] Cleaned up ${cleanedCount} old closed session(s)`);
458
+ }
459
+ }
460
+ /**
461
+ * Clear restart counter for a session, allowing fresh restarts.
462
+ * Useful when user explicitly wants to reset.
463
+ */
464
+ clearRestartCounter(terminalSessionId) {
465
+ const info = this.closedSessions.get(terminalSessionId);
466
+ if (info) {
467
+ info.restartCount = 0;
468
+ console.log(`[Claude Process] Restart counter cleared for ${terminalSessionId}`);
469
+ }
470
+ }
471
+ /**
472
+ * Checks Claude CLI output for approval patterns and emits approval request events.
473
+ *
474
+ * Scans the output against all patterns in APPROVAL_PATTERNS. When a match is found,
475
+ * creates a unique approval ID, sets up a timeout for auto-denial, and emits a
476
+ * 'claude_approval_request' event with the approval details.
477
+ *
478
+ * Prevents duplicate approvals for the same terminal session.
479
+ *
480
+ * @param {string} terminalSessionId - The terminal session ID producing the output
481
+ * @param {string} output - Raw output text from the Claude CLI process
482
+ * @param {ClaudeProcessInfo} processInfo - Process information including output buffer
483
+ * @fires ClaudeProcessManager#claude_approval_request
484
+ * @private
485
+ */
486
+ checkForApprovalPattern(terminalSessionId, output, processInfo) {
487
+ // Check if output matches any approval pattern
488
+ const matchedPattern = APPROVAL_PATTERNS.find(pattern => pattern.test(output));
489
+ if (!matchedPattern)
490
+ return;
491
+ // Don't create duplicate approvals
492
+ for (const pending of this.pendingApprovals.values()) {
493
+ if (pending.terminalSessionId === terminalSessionId) {
494
+ console.log(`[Claude Process] Approval already pending for ${terminalSessionId}`);
495
+ return;
496
+ }
497
+ }
498
+ const approvalId = `approval-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
499
+ const options = extractApprovalOptions(output);
500
+ console.log(`[Claude Process] Approval pattern detected: ${matchedPattern.toString()}`);
501
+ console.log(`[Claude Process] Options: ${options.join(', ')}`);
502
+ // Set up timeout for auto-deny
503
+ const timeoutId = setTimeout(() => {
504
+ this.handleApprovalTimeout(approvalId);
505
+ }, this.APPROVAL_TIMEOUT_MS);
506
+ // Track pending approval
507
+ this.pendingApprovals.set(approvalId, {
508
+ approvalId,
509
+ terminalSessionId,
510
+ createdAt: Date.now(),
511
+ timeoutId,
512
+ });
513
+ // Extract human-readable prompt from SDK JSON output
514
+ let promptText = output.trim();
515
+ let toolName = '';
516
+ let toolInput = '';
517
+ // Try to parse as JSON and extract meaningful content
518
+ try {
519
+ const lines = output.split('\n').filter(l => l.trim());
520
+ for (const line of lines) {
521
+ try {
522
+ const json = JSON.parse(line);
523
+ // Check for tool_use in message content
524
+ if (json.message?.content) {
525
+ const content = Array.isArray(json.message.content) ? json.message.content : [json.message.content];
526
+ for (const block of content) {
527
+ if (block.type === 'tool_use') {
528
+ toolName = block.name || 'Unknown tool';
529
+ toolInput = typeof block.input === 'string' ? block.input : JSON.stringify(block.input, null, 2);
530
+ promptText = `Claude wants to use: ${toolName}`;
531
+ break;
532
+ }
533
+ else if (typeof block === 'string' && block.includes('[y]es')) {
534
+ promptText = block;
535
+ break;
536
+ }
537
+ else if (block.text && block.text.includes('[y]es')) {
538
+ promptText = block.text;
539
+ break;
540
+ }
541
+ }
542
+ }
543
+ }
544
+ catch (e) {
545
+ // Not JSON, might be text prompt
546
+ if (line.includes('[y]es') || line.includes('(y/n)')) {
547
+ promptText = line;
548
+ break;
549
+ }
550
+ }
551
+ }
552
+ }
553
+ catch (e) {
554
+ // Keep original output
555
+ }
556
+ // Build context with tool details if available
557
+ const context = [...processInfo.outputBuffer];
558
+ if (toolInput && toolInput.length > 0) {
559
+ context.push(`Tool: ${toolName}`);
560
+ context.push(`Input: ${toolInput.substring(0, 500)}${toolInput.length > 500 ? '...' : ''}`);
561
+ }
562
+ // Emit approval request event
563
+ const approvalRequest = {
564
+ approvalId,
565
+ terminalSessionId,
566
+ sessionKey: processInfo.sessionKey,
567
+ context,
568
+ options,
569
+ promptText,
570
+ };
571
+ this.emit('claude_approval_request', approvalRequest);
572
+ console.log(`[Claude Process] Emitted claude_approval_request: ${approvalId}, promptText: ${promptText.substring(0, 50)}...`);
573
+ }
574
+ /**
575
+ * Checks SDK messages for tool_use content and emits approval notifications.
576
+ *
577
+ * In SDK mode, Claude doesn't emit text approval prompts. Instead, we detect
578
+ * tool_use in the SDK messages and emit approval notifications so the mobile
579
+ * app can display what Claude is doing. Note: This is a notification, not
580
+ * a blocking approval - the tool may already be executed by the time the
581
+ * user sees this.
582
+ *
583
+ * @param {string} terminalSessionId - The terminal session ID
584
+ * @param {any} message - The parsed SDK JSON message
585
+ * @param {ClaudeProcessInfo} processInfo - Process info for context
586
+ * @private
587
+ */
588
+ checkForToolUseInSdkMessage(terminalSessionId, message, processInfo) {
589
+ // Only check assistant messages with content
590
+ if (message.type !== 'assistant') {
591
+ return;
592
+ }
593
+ // Debug: log the message structure
594
+ console.log(`[Claude Process] Checking assistant message for tool_use...`);
595
+ if (!message.message?.content) {
596
+ console.log(`[Claude Process] No message.content found in assistant message`);
597
+ return;
598
+ }
599
+ const content = Array.isArray(message.message.content)
600
+ ? message.message.content
601
+ : [message.message.content];
602
+ // Find tool_use blocks
603
+ for (const block of content) {
604
+ if (block.type === 'tool_use') {
605
+ const toolName = block.name || 'Unknown tool';
606
+ const toolId = block.id || '';
607
+ const toolInput = block.input || {};
608
+ // Check if we already have a pending approval for this terminal
609
+ let alreadyPending = false;
610
+ for (const pending of this.pendingApprovals.values()) {
611
+ if (pending.terminalSessionId === terminalSessionId) {
612
+ alreadyPending = true;
613
+ break;
614
+ }
615
+ }
616
+ if (alreadyPending) {
617
+ console.log(`[Claude Process] Tool use detected but approval already pending for ${terminalSessionId}`);
618
+ continue;
619
+ }
620
+ // Create approval notification
621
+ const approvalId = `tool-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
622
+ // Format input for display
623
+ let inputSummary = '';
624
+ if (typeof toolInput === 'object') {
625
+ if (toolInput.file_path) {
626
+ inputSummary = `File: ${toolInput.file_path}`;
627
+ }
628
+ else if (toolInput.command) {
629
+ inputSummary = `Command: ${toolInput.command.substring(0, 100)}`;
630
+ }
631
+ else if (toolInput.pattern) {
632
+ inputSummary = `Pattern: ${toolInput.pattern}`;
633
+ }
634
+ else {
635
+ inputSummary = JSON.stringify(toolInput).substring(0, 200);
636
+ }
637
+ }
638
+ const promptText = `Claude is using: ${toolName}`;
639
+ const context = inputSummary ? [inputSummary, ...processInfo.outputBuffer.slice(-5)] : processInfo.outputBuffer.slice(-10);
640
+ console.log(`[Claude Process] Tool use detected: ${toolName} (${toolId})`);
641
+ console.log(`[Claude Process] Input summary: ${inputSummary.substring(0, 100)}`);
642
+ // Set up timeout (5 minutes to match original plan)
643
+ const timeoutId = setTimeout(() => {
644
+ console.log(`[Claude Process] Tool approval timeout: ${approvalId}`);
645
+ this.pendingApprovals.delete(approvalId);
646
+ }, 300000); // 5 minute timeout for tool notifications
647
+ // Track this notification
648
+ this.pendingApprovals.set(approvalId, {
649
+ approvalId,
650
+ terminalSessionId,
651
+ createdAt: Date.now(),
652
+ timeoutId,
653
+ });
654
+ // Emit notification to mobile
655
+ const approvalRequest = {
656
+ approvalId,
657
+ terminalSessionId,
658
+ sessionKey: processInfo.sessionKey,
659
+ context,
660
+ options: ['y:yes', 'n:no', 'p:plan'], // Standard options
661
+ promptText,
662
+ };
663
+ this.emit('claude_approval_request', approvalRequest);
664
+ console.log(`[Claude Process] Emitted tool_use notification: ${approvalId}, tool: ${toolName}`);
665
+ }
666
+ }
667
+ }
668
+ /**
669
+ * Handles approval request timeout by automatically denying the request.
670
+ *
671
+ * Called when the approval timeout (APPROVAL_TIMEOUT_MS) expires without
672
+ * receiving a user response. Automatically sends 'n' (no/deny) to the
673
+ * Claude CLI process to prevent indefinite blocking.
674
+ *
675
+ * @param {string} approvalId - The unique identifier of the timed-out approval
676
+ * @private
677
+ */
678
+ handleApprovalTimeout(approvalId) {
679
+ const pending = this.pendingApprovals.get(approvalId);
680
+ if (!pending)
681
+ return;
682
+ console.log(`[Claude Process] Approval timeout for ${approvalId}, auto-denying`);
683
+ this.handleApprovalResponse(approvalId, 'n'); // Auto-deny with 'n'
684
+ }
685
+ /**
686
+ * Handles an approval response received from the mobile app.
687
+ *
688
+ * Processes the user's response to an approval request by:
689
+ * 1. Looking up the pending approval by ID
690
+ * 2. Clearing the auto-deny timeout
691
+ * 3. Writing the response character (e.g., 'y', 'n', 'p') to the Claude CLI stdin
692
+ *
693
+ * @param {string} approvalId - The unique identifier of the approval being responded to
694
+ * @param {string} response - The user's response (first character will be sent to stdin)
695
+ * @public
696
+ */
697
+ handleApprovalResponse(approvalId, response) {
698
+ const pending = this.pendingApprovals.get(approvalId);
699
+ if (!pending) {
700
+ console.log(`[Claude Process] No pending approval found for ${approvalId}`);
701
+ return;
702
+ }
703
+ // Clear timeout
704
+ clearTimeout(pending.timeoutId);
705
+ this.pendingApprovals.delete(approvalId);
706
+ // Get process and write response to stdin
707
+ const processInfo = this.processes.get(pending.terminalSessionId);
708
+ if (!processInfo?.process) {
709
+ console.log(`[Claude Process] No process for ${pending.terminalSessionId}`);
710
+ return;
711
+ }
712
+ // Check if process has exited
713
+ if (processInfo.process.exitCode !== null) {
714
+ console.log(`[Claude Process] Process already exited for ${pending.terminalSessionId} (exit code: ${processInfo.process.exitCode}), cannot send approval response`);
715
+ return;
716
+ }
717
+ if (!processInfo.process.stdin || processInfo.process.stdin.destroyed) {
718
+ console.log(`[Claude Process] stdin is closed or destroyed for ${pending.terminalSessionId}, cannot send approval response`);
719
+ return;
720
+ }
721
+ // Write the response character to stdin (e.g., 'y', 'n', 'p')
722
+ const char = response.charAt(0).toLowerCase();
723
+ console.log(`[Claude Process] Writing response '${char}' to stdin for ${pending.terminalSessionId}`);
724
+ try {
725
+ processInfo.process.stdin.write(char, (err) => {
726
+ if (err) {
727
+ console.error(`[Claude Process] Error writing approval response to stdin for ${pending.terminalSessionId}:`, err.message);
728
+ }
729
+ });
730
+ }
731
+ catch (err) {
732
+ console.error(`[Claude Process] Exception writing approval response to stdin for ${pending.terminalSessionId}:`, err.message);
733
+ }
734
+ }
735
+ /**
736
+ * Retrieves a pending approval request for a specific terminal session.
737
+ *
738
+ * Searches through all pending approvals to find one matching the given
739
+ * terminal session ID. Useful for checking if there's an active approval
740
+ * request for a session before creating a new one.
741
+ *
742
+ * @param {string} terminalSessionId - The terminal session ID to search for
743
+ * @returns {PendingApproval | undefined} The pending approval if found, undefined otherwise
744
+ * @public
745
+ */
746
+ getPendingApproval(terminalSessionId) {
747
+ for (const pending of this.pendingApprovals.values()) {
748
+ if (pending.terminalSessionId === terminalSessionId) {
749
+ return pending;
750
+ }
751
+ }
752
+ return undefined;
753
+ }
754
+ /**
755
+ * Parse thinking content from SDK messages.
756
+ * Claude SDK emits content_block_delta with type 'thinking' for extended thinking.
757
+ */
758
+ parseThinkingContent(terminalSessionId, message, sessionKey) {
759
+ // Check for content_block_delta with thinking type
760
+ if (message.type === 'content_block_delta' && message.delta?.type === 'thinking_delta') {
761
+ const thinkingId = message.index?.toString() || `thinking-${Date.now()}`;
762
+ const content = message.delta?.thinking || '';
763
+ this.emit('thinking_content', {
764
+ terminalSessionId,
765
+ sessionKey,
766
+ thinkingId,
767
+ content,
768
+ partial: true,
769
+ });
770
+ return;
771
+ }
772
+ // Check for content_block_stop to mark thinking complete
773
+ if (message.type === 'content_block_stop') {
774
+ const processInfo = this.processes.get(terminalSessionId);
775
+ // Check if this was a thinking block by looking at recent messages
776
+ // The SDK sends content_block_start before deltas, so we track by index
777
+ const thinkingId = message.index?.toString() || '';
778
+ if (thinkingId) {
779
+ this.emit('thinking_content', {
780
+ terminalSessionId,
781
+ sessionKey,
782
+ thinkingId,
783
+ content: '',
784
+ partial: false,
785
+ });
786
+ }
787
+ }
788
+ // Also check for thinking in assistant message content array
789
+ if (message.type === 'assistant' && message.message?.content) {
790
+ const content = Array.isArray(message.message.content)
791
+ ? message.message.content
792
+ : [message.message.content];
793
+ for (const block of content) {
794
+ if (block.type === 'thinking' && block.thinking) {
795
+ this.emit('thinking_content', {
796
+ terminalSessionId,
797
+ sessionKey,
798
+ thinkingId: `msg-${message.message?.id || Date.now()}`,
799
+ content: block.thinking,
800
+ partial: false,
801
+ });
802
+ }
803
+ }
804
+ }
805
+ }
806
+ /**
807
+ * Parse token usage from SDK messages.
808
+ * Claude SDK emits message_delta with usage field containing token counts.
809
+ */
810
+ parseTokenUsage(terminalSessionId, message, sessionKey) {
811
+ // Check for message_delta with usage
812
+ if (message.type === 'message_delta' && message.usage) {
813
+ const usage = message.usage;
814
+ if (usage.input_tokens !== undefined || usage.output_tokens !== undefined) {
815
+ this.emit('token_usage', {
816
+ terminalSessionId,
817
+ sessionKey,
818
+ usage: {
819
+ inputTokens: usage.input_tokens || 0,
820
+ outputTokens: usage.output_tokens || 0,
821
+ },
822
+ });
823
+ }
824
+ return;
825
+ }
826
+ // Also check for usage in result messages (end of conversation turn)
827
+ if (message.type === 'result' && message.usage) {
828
+ const usage = message.usage;
829
+ this.emit('token_usage', {
830
+ terminalSessionId,
831
+ sessionKey,
832
+ usage: {
833
+ inputTokens: usage.input_tokens || 0,
834
+ outputTokens: usage.output_tokens || 0,
835
+ },
836
+ });
837
+ }
838
+ }
839
+ /**
840
+ * Parse task progress from SDK messages.
841
+ * Detects TaskCreate, TaskUpdate, TaskList tool uses and extracts task data.
842
+ */
843
+ parseTaskProgress(terminalSessionId, message, sessionKey) {
844
+ // Only process assistant messages with tool_use
845
+ if (message.type !== 'assistant')
846
+ return;
847
+ const content = message.message?.content;
848
+ if (!content)
849
+ return;
850
+ const contentArray = Array.isArray(content) ? content : [content];
851
+ for (const block of contentArray) {
852
+ if (block.type !== 'tool_use')
853
+ continue;
854
+ const toolName = block.name?.toLowerCase();
855
+ if (!toolName)
856
+ continue;
857
+ // Handle TaskCreate
858
+ if (toolName === 'taskcreate') {
859
+ const input = block.input || {};
860
+ const task = {
861
+ id: block.id || `task-${Date.now()}`,
862
+ subject: input.subject || 'New Task',
863
+ status: 'pending',
864
+ activeForm: input.activeForm,
865
+ };
866
+ console.log(`[Claude Process] Task created: ${task.subject}`);
867
+ this.emit('task_progress', {
868
+ terminalSessionId,
869
+ sessionKey,
870
+ type: 'created',
871
+ task,
872
+ });
873
+ }
874
+ // Handle TaskUpdate
875
+ if (toolName === 'taskupdate') {
876
+ const input = block.input || {};
877
+ const task = {
878
+ id: input.taskId || block.id || '',
879
+ subject: input.subject || '',
880
+ status: input.status || 'pending',
881
+ activeForm: input.activeForm,
882
+ };
883
+ const eventType = task.status === 'completed' ? 'completed' : 'updated';
884
+ console.log(`[Claude Process] Task ${eventType}: ${task.id} -> ${task.status}`);
885
+ this.emit('task_progress', {
886
+ terminalSessionId,
887
+ sessionKey,
888
+ type: eventType,
889
+ task,
890
+ });
891
+ }
892
+ // Handle TaskList (tool result contains the list)
893
+ if (toolName === 'tasklist') {
894
+ console.log(`[Claude Process] Task list requested`);
895
+ // TaskList doesn't have task data in tool_use, only in tool_result
896
+ // We'll emit a list event when we see the result
897
+ }
898
+ }
899
+ // Also check for tool_result from TaskList
900
+ if (message.type === 'tool_result') {
901
+ const toolName = message.tool_name?.toLowerCase();
902
+ if (toolName === 'tasklist' && message.content) {
903
+ try {
904
+ // TaskList result might be JSON array of tasks
905
+ const tasks = typeof message.content === 'string'
906
+ ? JSON.parse(message.content)
907
+ : message.content;
908
+ if (Array.isArray(tasks)) {
909
+ this.emit('task_progress', {
910
+ terminalSessionId,
911
+ sessionKey,
912
+ type: 'list',
913
+ tasks: tasks.map((t) => ({
914
+ id: t.id || '',
915
+ subject: t.subject || '',
916
+ status: t.status || 'pending',
917
+ activeForm: t.activeForm,
918
+ })),
919
+ });
920
+ }
921
+ }
922
+ catch (e) {
923
+ // Not JSON, ignore
924
+ }
925
+ }
926
+ }
927
+ }
928
+ }
929
+ exports.claudeProcessManager = new ClaudeProcessManager();
930
+ exports.default = exports.claudeProcessManager;
931
+ //# sourceMappingURL=claude-process.js.map