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.
- package/LICENSE +17 -0
- package/README.md +173 -0
- package/dist/api.d.ts +44 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +76 -0
- package/dist/api.js.map +1 -0
- package/dist/approval.d.ts +46 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +119 -0
- package/dist/approval.js.map +1 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +209 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +868 -0
- package/dist/index.js.map +1 -0
- package/dist/integration.d.ts +30 -0
- package/dist/integration.d.ts.map +1 -0
- package/dist/integration.js +84 -0
- package/dist/integration.js.map +1 -0
- package/dist/terminal.d.ts +25 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +171 -0
- package/dist/terminal.js.map +1 -0
- package/dist/tools/claude-hooks.d.ts +97 -0
- package/dist/tools/claude-hooks.d.ts.map +1 -0
- package/dist/tools/claude-hooks.js +348 -0
- package/dist/tools/claude-hooks.js.map +1 -0
- package/dist/tools/claude-process.d.ts +271 -0
- package/dist/tools/claude-process.d.ts.map +1 -0
- package/dist/tools/claude-process.js +931 -0
- package/dist/tools/claude-process.js.map +1 -0
- package/dist/tools/claude-sessions.d.ts +60 -0
- package/dist/tools/claude-sessions.d.ts.map +1 -0
- package/dist/tools/claude-sessions.js +285 -0
- package/dist/tools/claude-sessions.js.map +1 -0
- package/dist/tools/detector.d.ts +64 -0
- package/dist/tools/detector.d.ts.map +1 -0
- package/dist/tools/detector.js +383 -0
- package/dist/tools/detector.js.map +1 -0
- package/dist/tools/index.d.ts +8 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +15 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/transcript-streamer.d.ts +68 -0
- package/dist/transcript-streamer.d.ts.map +1 -0
- package/dist/transcript-streamer.js +459 -0
- package/dist/transcript-streamer.js.map +1 -0
- package/dist/websocket.d.ts +133 -0
- package/dist/websocket.d.ts.map +1 -0
- package/dist/websocket.js +247 -0
- package/dist/websocket.js.map +1 -0
- package/nul +0 -0
- 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
|