teleportation-cli 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.claude/hooks/stop.mjs
CHANGED
|
@@ -29,6 +29,9 @@ const isValidSessionId = (id) => {
|
|
|
29
29
|
// Max length for assistant response preview (characters)
|
|
30
30
|
const ASSISTANT_RESPONSE_MAX_LENGTH = 2000;
|
|
31
31
|
|
|
32
|
+
// Max size for full transcript (bytes) - 500KB
|
|
33
|
+
const MAX_TRANSCRIPT_SIZE = 500 * 1024;
|
|
34
|
+
|
|
32
35
|
// Retry configuration
|
|
33
36
|
const MAX_RETRIES = 3;
|
|
34
37
|
const RETRY_DELAY_MS = 1000;
|
|
@@ -150,6 +153,178 @@ const extractLastAssistantMessage = async (transcriptPath, log) => {
|
|
|
150
153
|
}
|
|
151
154
|
};
|
|
152
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Extract the full conversation transcript from the transcript file
|
|
158
|
+
* Returns all user and assistant messages with turn_index
|
|
159
|
+
* @returns {Object|null} - { messages: [{ role, content, turn_index }], total_turns: number, truncated: boolean, original_size: number } or null
|
|
160
|
+
*/
|
|
161
|
+
const extractFullTranscript = async (transcriptPath, log) => {
|
|
162
|
+
try {
|
|
163
|
+
if (!transcriptPath) {
|
|
164
|
+
log('No transcript_path provided for full extraction');
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Read file directly
|
|
169
|
+
let content;
|
|
170
|
+
try {
|
|
171
|
+
content = await readFile(transcriptPath, 'utf8');
|
|
172
|
+
} catch (e) {
|
|
173
|
+
if (e.code === 'ENOENT') {
|
|
174
|
+
log(`Transcript file not found: ${transcriptPath}`);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
if (e.code === 'EACCES' || e.code === 'EPERM') {
|
|
178
|
+
log(`Permission denied reading transcript: ${transcriptPath}`);
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
log(`Error reading transcript for full extraction: ${e.code || e.message}`);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let transcript;
|
|
186
|
+
|
|
187
|
+
// Try parsing as JSON array first
|
|
188
|
+
try {
|
|
189
|
+
transcript = JSON.parse(content);
|
|
190
|
+
log('Parsed transcript as JSON array for full extraction');
|
|
191
|
+
} catch (e) {
|
|
192
|
+
// Try parsing as JSONL (newline-delimited JSON)
|
|
193
|
+
log('JSON parse failed for full extraction, trying JSONL format');
|
|
194
|
+
const lines = content.trim().split('\n').filter(l => l.trim());
|
|
195
|
+
transcript = lines.map(line => {
|
|
196
|
+
try {
|
|
197
|
+
return JSON.parse(line);
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}).filter(Boolean);
|
|
202
|
+
log(`Parsed transcript as JSONL (${transcript.length} messages) for full extraction`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!Array.isArray(transcript)) {
|
|
206
|
+
log(`Transcript is not an array for full extraction: ${typeof transcript}`);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
log(`Full transcript has ${transcript.length} raw messages`);
|
|
211
|
+
|
|
212
|
+
// Extract all user and assistant messages
|
|
213
|
+
const messages = [];
|
|
214
|
+
let turnIndex = 0;
|
|
215
|
+
|
|
216
|
+
for (const msg of transcript) {
|
|
217
|
+
const role = msg.role || msg.type || '';
|
|
218
|
+
const isAssistant = role === 'assistant' || role === 'model' || msg.isAssistant;
|
|
219
|
+
const isUser = role === 'user' || role === 'human' || msg.isUser;
|
|
220
|
+
|
|
221
|
+
if (!isAssistant && !isUser) continue;
|
|
222
|
+
|
|
223
|
+
// Extract content
|
|
224
|
+
let text = '';
|
|
225
|
+
if (typeof msg.content === 'string') {
|
|
226
|
+
text = msg.content;
|
|
227
|
+
} else if (Array.isArray(msg.content)) {
|
|
228
|
+
// Content blocks format: [{ type: 'text', text: '...' }, ...]
|
|
229
|
+
text = msg.content
|
|
230
|
+
.filter(block => block.type === 'text' && block.text)
|
|
231
|
+
.map(block => block.text)
|
|
232
|
+
.join('\n\n');
|
|
233
|
+
} else if (msg.text) {
|
|
234
|
+
text = msg.text;
|
|
235
|
+
} else if (msg.message && typeof msg.message === 'string') {
|
|
236
|
+
text = msg.message;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (text && text.trim()) {
|
|
240
|
+
messages.push({
|
|
241
|
+
role: isAssistant ? 'assistant' : 'user',
|
|
242
|
+
content: text.trim(),
|
|
243
|
+
turn_index: turnIndex
|
|
244
|
+
});
|
|
245
|
+
turnIndex++;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
log(`Extracted ${messages.length} messages from transcript`);
|
|
250
|
+
|
|
251
|
+
if (messages.length === 0) {
|
|
252
|
+
log('No valid messages found in transcript');
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Check size and truncate if needed (reliability requirement: 500KB limit)
|
|
257
|
+
let result = {
|
|
258
|
+
messages,
|
|
259
|
+
total_turns: messages.length,
|
|
260
|
+
truncated: false,
|
|
261
|
+
original_size: 0
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const jsonSize = JSON.stringify(result).length;
|
|
265
|
+
result.original_size = jsonSize;
|
|
266
|
+
|
|
267
|
+
if (jsonSize > MAX_TRANSCRIPT_SIZE) {
|
|
268
|
+
log(`Transcript size ${jsonSize} exceeds limit ${MAX_TRANSCRIPT_SIZE}, truncating...`);
|
|
269
|
+
|
|
270
|
+
// Use size estimation to avoid O(n²) repeated JSON.stringify calls
|
|
271
|
+
// Estimate average message size and calculate how many to keep
|
|
272
|
+
const metadataOverhead = 100; // Approximate overhead for result wrapper
|
|
273
|
+
const avgMsgSize = (jsonSize - metadataOverhead) / messages.length;
|
|
274
|
+
const targetCount = Math.max(1, Math.floor((MAX_TRANSCRIPT_SIZE - metadataOverhead) / avgMsgSize));
|
|
275
|
+
|
|
276
|
+
// Keep the most recent messages (slice from end)
|
|
277
|
+
let truncatedMessages = messages.slice(-targetCount);
|
|
278
|
+
|
|
279
|
+
// Verify size with a single stringify check
|
|
280
|
+
let testResult = {
|
|
281
|
+
messages: truncatedMessages,
|
|
282
|
+
total_turns: messages.length,
|
|
283
|
+
truncated: true,
|
|
284
|
+
original_size: jsonSize
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// If still too large (estimation was off), remove a few more messages
|
|
288
|
+
while (truncatedMessages.length > 1 && JSON.stringify(testResult).length > MAX_TRANSCRIPT_SIZE) {
|
|
289
|
+
truncatedMessages = truncatedMessages.slice(1); // Remove oldest of remaining
|
|
290
|
+
testResult = {
|
|
291
|
+
messages: truncatedMessages,
|
|
292
|
+
total_turns: messages.length,
|
|
293
|
+
truncated: true,
|
|
294
|
+
original_size: jsonSize
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
result = testResult;
|
|
299
|
+
|
|
300
|
+
// If still too large with just one message, truncate the message content
|
|
301
|
+
if (truncatedMessages.length === 0) {
|
|
302
|
+
log('All messages truncated - transcript too large for any message');
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (JSON.stringify(result).length > MAX_TRANSCRIPT_SIZE) {
|
|
307
|
+
const lastMsg = { ...truncatedMessages[truncatedMessages.length - 1] }; // Clone to avoid mutation
|
|
308
|
+
const maxContentLen = Math.floor(MAX_TRANSCRIPT_SIZE * 0.8); // Leave room for metadata
|
|
309
|
+
lastMsg.content = lastMsg.content.substring(0, maxContentLen) + '... [truncated]';
|
|
310
|
+
result = {
|
|
311
|
+
messages: [lastMsg],
|
|
312
|
+
total_turns: messages.length,
|
|
313
|
+
truncated: true,
|
|
314
|
+
original_size: jsonSize
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
log(`Truncated transcript to ${result.messages.length} messages`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return result;
|
|
322
|
+
} catch (e) {
|
|
323
|
+
log(`Error extracting full transcript: ${e.message}`);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
153
328
|
(async () => {
|
|
154
329
|
const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-hook.log';
|
|
155
330
|
const log = (msg) => {
|
|
@@ -242,7 +417,36 @@ const extractLastAssistantMessage = async (transcriptPath, log) => {
|
|
|
242
417
|
log('Skipping assistant response log (stop_hook_active=true)');
|
|
243
418
|
}
|
|
244
419
|
|
|
245
|
-
// 2.
|
|
420
|
+
// 2. Store full transcript for session (new feature: PRD-0011 Phase 2)
|
|
421
|
+
// Only store if not in a recursive stop hook call
|
|
422
|
+
if (!stop_hook_active) {
|
|
423
|
+
try {
|
|
424
|
+
const transcriptData = await extractFullTranscript(transcript_path, log);
|
|
425
|
+
|
|
426
|
+
if (transcriptData && transcriptData.messages && transcriptData.messages.length > 0) {
|
|
427
|
+
await fetchJsonWithRetry(`${RELAY_API_URL}/api/sessions/${session_id}/transcript`, {
|
|
428
|
+
method: 'POST',
|
|
429
|
+
headers: {
|
|
430
|
+
'Content-Type': 'application/json',
|
|
431
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
432
|
+
},
|
|
433
|
+
body: JSON.stringify({
|
|
434
|
+
messages: transcriptData.messages,
|
|
435
|
+
total_turns: transcriptData.total_turns,
|
|
436
|
+
truncated: transcriptData.truncated,
|
|
437
|
+
original_size: transcriptData.original_size,
|
|
438
|
+
stored_at: Date.now()
|
|
439
|
+
})
|
|
440
|
+
}, log);
|
|
441
|
+
log(`Stored full transcript (${transcriptData.messages.length} messages, ${transcriptData.total_turns} total turns, truncated: ${transcriptData.truncated})`);
|
|
442
|
+
}
|
|
443
|
+
} catch (e) {
|
|
444
|
+
// Reliability: Log but don't fail hook if transcript storage fails
|
|
445
|
+
log(`Failed to store full transcript: ${e.message}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// 3. Check for pending messages from mobile app (existing functionality)
|
|
246
450
|
try {
|
|
247
451
|
const res = await fetch(`${RELAY_API_URL}/api/messages/pending?session_id=${encodeURIComponent(session_id)}`, {
|
|
248
452
|
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
|
|
@@ -139,6 +139,63 @@ const fetchJson = async (url, opts) => {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// Log user message to timeline (skip special commands that are already logged differently)
|
|
143
|
+
if (session_id && prompt && typeof prompt === 'string') {
|
|
144
|
+
const trimmed = prompt.trim();
|
|
145
|
+
const lowered = trimmed.toLowerCase();
|
|
146
|
+
|
|
147
|
+
// Skip special commands - they are handled above or have their own logging
|
|
148
|
+
const isSpecialCommand =
|
|
149
|
+
lowered === '/away' || lowered === 'teleportation away' ||
|
|
150
|
+
lowered === '/back' || lowered === 'teleportation back' ||
|
|
151
|
+
lowered === '/model' || lowered.startsWith('/model ');
|
|
152
|
+
|
|
153
|
+
if (!isSpecialCommand && trimmed.length > 0) {
|
|
154
|
+
try {
|
|
155
|
+
const { loadConfig } = await import('./config-loader.mjs');
|
|
156
|
+
const config = await loadConfig();
|
|
157
|
+
const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
|
|
158
|
+
const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
|
|
159
|
+
|
|
160
|
+
if (RELAY_API_URL && RELAY_API_KEY) {
|
|
161
|
+
// Truncate prompt to 2000 chars for storage efficiency
|
|
162
|
+
const MAX_PROMPT_LENGTH = 2000;
|
|
163
|
+
const truncatedPrompt = trimmed.length > MAX_PROMPT_LENGTH
|
|
164
|
+
? trimmed.substring(0, MAX_PROMPT_LENGTH)
|
|
165
|
+
: trimmed;
|
|
166
|
+
|
|
167
|
+
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: {
|
|
170
|
+
'Content-Type': 'application/json',
|
|
171
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
172
|
+
},
|
|
173
|
+
body: JSON.stringify({
|
|
174
|
+
session_id,
|
|
175
|
+
type: 'user_message',
|
|
176
|
+
data: {
|
|
177
|
+
prompt: truncatedPrompt,
|
|
178
|
+
full_length: trimmed.length,
|
|
179
|
+
truncated: trimmed.length > MAX_PROMPT_LENGTH,
|
|
180
|
+
timestamp: Date.now()
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (env.DEBUG) {
|
|
186
|
+
log(`Logged user_message to timeline for session ${session_id}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch (e) {
|
|
190
|
+
// Non-critical - log error but don't block prompt submission
|
|
191
|
+
log(`Failed to log user message to timeline: ${e.message}`);
|
|
192
|
+
if (env.DEBUG) {
|
|
193
|
+
console.error(`[UserPromptSubmit] Failed to log user message: ${e.message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
142
199
|
// Always suppress output from this hook
|
|
143
200
|
try { stdout.write(JSON.stringify({ suppressOutput: true })); } catch {}
|
|
144
201
|
return exit(0);
|
|
@@ -4,26 +4,33 @@
|
|
|
4
4
|
* Teleportation Daemon
|
|
5
5
|
*
|
|
6
6
|
* Persistent background service that:
|
|
7
|
-
* - Polls relay API for approved tool requests
|
|
8
|
-
* -
|
|
7
|
+
* - Polls relay API for approved tool requests and inbox messages
|
|
8
|
+
* - Routes all remote messages to Claude Code via the machine coder interface
|
|
9
9
|
* - Executes approved tools asynchronously when user is away
|
|
10
10
|
* - Maintains session registry and approval queue
|
|
11
11
|
* - Provides HTTP server for hook communication
|
|
12
12
|
*
|
|
13
13
|
* SECURITY ARCHITECTURE:
|
|
14
14
|
* ----------------------
|
|
15
|
-
*
|
|
16
|
-
* Claude CLI's built-in security controls. This is an intentional architectural decision
|
|
17
|
-
* to enable remote approval/execution, but requires defense-in-depth measures:
|
|
15
|
+
* Remote message execution is delegated to Claude Code's security model:
|
|
18
16
|
*
|
|
19
|
-
* 1.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
17
|
+
* 1. CLAUDE CODE PERMISSIONS: All tool calls go through Claude Code's permission system
|
|
18
|
+
* - When hooks are active, tool approvals are routed via Teleportation Relay
|
|
19
|
+
* - When using --dangerously-skip-permissions, Claude auto-approves tool calls
|
|
20
|
+
* 2. SESSION VALIDATION: Each execution validates the session is still active via Relay API
|
|
21
|
+
* 3. APPROVAL CONTEXT: All executions require an approvalContext (inbox_message, approval_queue)
|
|
22
|
+
* for audit trail
|
|
23
|
+
* 4. MACHINE CODER INTERFACE: Unified interface supports Claude Code, Gemini CLI, and future backends
|
|
23
24
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* LEGACY SHELL EXECUTION:
|
|
26
|
+
* The daemon also supports direct shell command execution via executeCommand() for specific
|
|
27
|
+
* use cases like approval queue processing. This path uses:
|
|
28
|
+
* - COMMAND WHITELIST: Only pre-approved command prefixes (see ALLOWED_COMMAND_PREFIXES)
|
|
29
|
+
* - SHELL INJECTION BLOCKING: Commands with metacharacters (;|&`$() etc.) are rejected
|
|
30
|
+
*
|
|
31
|
+
* For production deployments, consider:
|
|
32
|
+
* - Using --dangerously-skip-permissions only in sandboxed environments
|
|
33
|
+
* - Implementing rate limiting on the relay API
|
|
27
34
|
* - Enabling audit logging by setting DEBUG=1
|
|
28
35
|
*/
|
|
29
36
|
|
|
@@ -54,6 +61,11 @@ const CLAUDE_CLI = process.env.CLAUDE_CLI_PATH || 'claude'; // Configurable Clau
|
|
|
54
61
|
const ALLOW_ALL_COMMANDS = process.env.TELEPORTATION_DAEMON_ALLOW_ALL_COMMANDS === 'true';
|
|
55
62
|
const HEARTBEAT_INTERVAL_MS = parseInt(process.env.DAEMON_HEARTBEAT_INTERVAL_MS || '30000', 10); // 30 sec default
|
|
56
63
|
|
|
64
|
+
// Message routing configuration
|
|
65
|
+
// REQUIRE_COMMAND_WHITELIST: If true, use legacy shell execution with command whitelist
|
|
66
|
+
// Default: false (all messages route to Claude Code)
|
|
67
|
+
const REQUIRE_COMMAND_WHITELIST = process.env.TELEPORTATION_REQUIRE_COMMAND_WHITELIST === 'true';
|
|
68
|
+
|
|
57
69
|
// Machine coder configuration
|
|
58
70
|
// PREFERRED_CODER: 'claude-code' | 'gemini-cli' | 'auto' (default: auto)
|
|
59
71
|
// 'auto' will use Claude Code if available, otherwise Gemini CLI
|
|
@@ -68,6 +80,10 @@ const ROUTER_ENABLED = process.env.TELEPORTATION_ROUTER_ENABLED !== 'false' && !
|
|
|
68
80
|
const ROUTER_VERBOSE = process.env.TELEPORTATION_ROUTER_VERBOSE === 'true';
|
|
69
81
|
const ROUTER_MAX_ESCALATIONS = parseInt(process.env.TELEPORTATION_ROUTER_MAX_ESCALATIONS || '2', 10);
|
|
70
82
|
|
|
83
|
+
// Test helper: allows mocking executeWithMachineCoder in tests
|
|
84
|
+
// In production, this just holds null and the real function is called
|
|
85
|
+
const _executeWithMachineCoderRef = { fn: null };
|
|
86
|
+
|
|
71
87
|
// Lazy-initialized router instance
|
|
72
88
|
let _router = null;
|
|
73
89
|
function getRouter() {
|
|
@@ -184,8 +200,17 @@ const MAX_EXECUTIONS = 1000; // Maximum executions to keep in memory (LRU cache)
|
|
|
184
200
|
// Maximum output size to prevent memory issues
|
|
185
201
|
const MAX_OUTPUT_SIZE = 100_000; // 100KB
|
|
186
202
|
|
|
187
|
-
//
|
|
188
|
-
//
|
|
203
|
+
// LEGACY SHELL EXECUTION:
|
|
204
|
+
// These constants and functions are kept for:
|
|
205
|
+
// 1. Whitelist fallback mode (TELEPORTATION_REQUIRE_COMMAND_WHITELIST=true)
|
|
206
|
+
// 2. Direct shell execution via approval queue processing
|
|
207
|
+
// 3. Backward compatibility with existing tests
|
|
208
|
+
//
|
|
209
|
+
// For inbox messages, all prompts are now routed to Claude Code by default.
|
|
210
|
+
// See handleInboxMessage() for the current flow.
|
|
211
|
+
|
|
212
|
+
// Command whitelist for legacy shell execution (security: prevents arbitrary command execution)
|
|
213
|
+
// Only commands starting with these prefixes are allowed when TELEPORTATION_REQUIRE_COMMAND_WHITELIST=true
|
|
189
214
|
const ALLOWED_COMMAND_PREFIXES = [
|
|
190
215
|
'git ', // Git operations
|
|
191
216
|
'npm ', // NPM package management
|
|
@@ -253,7 +278,15 @@ function sanitizeCommand(command) {
|
|
|
253
278
|
}
|
|
254
279
|
|
|
255
280
|
/**
|
|
256
|
-
* Check if a command is allowed based on the whitelist
|
|
281
|
+
* LEGACY: Check if a command is allowed based on the whitelist
|
|
282
|
+
*
|
|
283
|
+
* This function is kept for:
|
|
284
|
+
* - Whitelist fallback mode (TELEPORTATION_REQUIRE_COMMAND_WHITELIST=true)
|
|
285
|
+
* - executeCommand() which is used by approval queue processing
|
|
286
|
+
* - Backward compatibility with existing tests
|
|
287
|
+
*
|
|
288
|
+
* For inbox messages, all prompts are now routed to Claude Code by default.
|
|
289
|
+
*
|
|
257
290
|
* @param {string} command - The command to validate
|
|
258
291
|
* @returns {{ allowed: boolean, reason?: string }}
|
|
259
292
|
*/
|
|
@@ -685,9 +718,17 @@ async function checkIdleTimeout() {
|
|
|
685
718
|
}
|
|
686
719
|
|
|
687
720
|
/**
|
|
688
|
-
* Execute a shell command in the session's working directory
|
|
721
|
+
* LEGACY: Execute a shell command in the session's working directory
|
|
689
722
|
* Returns { success, stdout, stderr, exit_code, error }
|
|
690
723
|
*
|
|
724
|
+
* This function is kept for:
|
|
725
|
+
* - Approval queue processing (executing approved tool calls)
|
|
726
|
+
* - Whitelist fallback mode (TELEPORTATION_REQUIRE_COMMAND_WHITELIST=true)
|
|
727
|
+
* - Backward compatibility with existing tests
|
|
728
|
+
*
|
|
729
|
+
* For inbox messages, all prompts are now routed to Claude Code by default.
|
|
730
|
+
* See handleInboxMessage() and executeWithMachineCoder() for the current flow.
|
|
731
|
+
*
|
|
691
732
|
* Security: Commands must be in the ALLOWED_COMMAND_PREFIXES whitelist
|
|
692
733
|
*/
|
|
693
734
|
async function executeCommand(session_id, command) {
|
|
@@ -755,7 +796,18 @@ async function handleInboxMessage(session_id, message) {
|
|
|
755
796
|
// For command messages, execute the command and post result back to the main agent inbox
|
|
756
797
|
if (meta.type === 'command') {
|
|
757
798
|
const replyAgentId = meta.reply_agent_id || 'main';
|
|
758
|
-
const commandText = message.text || '';
|
|
799
|
+
const commandText = (message.text || '').trim();
|
|
800
|
+
|
|
801
|
+
// Validate non-empty message before processing
|
|
802
|
+
if (!commandText) {
|
|
803
|
+
console.warn(`[daemon] Received empty message ${message.id}, skipping execution`);
|
|
804
|
+
// Still acknowledge the message to prevent re-delivery
|
|
805
|
+
await fetch(`${RELAY_API_URL}/api/messages/${encodeURIComponent(message.id)}/ack`, {
|
|
806
|
+
method: 'POST',
|
|
807
|
+
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
|
|
808
|
+
});
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
759
811
|
|
|
760
812
|
// Invalidate pending approvals BEFORE executing new command
|
|
761
813
|
// This prevents race conditions where stale approvals could be acted upon
|
|
@@ -783,39 +835,57 @@ async function handleInboxMessage(session_id, message) {
|
|
|
783
835
|
// Continue with execution - this is not critical
|
|
784
836
|
}
|
|
785
837
|
|
|
786
|
-
//
|
|
787
|
-
// 1. Check if it's a valid whitelisted shell command
|
|
788
|
-
const validation = isCommandAllowed(commandText);
|
|
838
|
+
// Route message to Claude Code (default) or use legacy shell execution (with feature flag)
|
|
789
839
|
let executionResult;
|
|
790
|
-
let executionType
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
840
|
+
let executionType;
|
|
841
|
+
|
|
842
|
+
// Feature flag: TELEPORTATION_REQUIRE_COMMAND_WHITELIST enables legacy whitelist-based execution
|
|
843
|
+
if (REQUIRE_COMMAND_WHITELIST) {
|
|
844
|
+
// Legacy mode: check if command is in whitelist, execute via shell if allowed
|
|
845
|
+
const validation = isCommandAllowed(commandText);
|
|
846
|
+
if (validation.allowed) {
|
|
847
|
+
executionType = 'shell';
|
|
848
|
+
console.log(`[daemon] Legacy shell execution: ${commandText.substring(0, 100)}`);
|
|
849
|
+
executionResult = await executeCommand(session_id, commandText);
|
|
850
|
+
} else {
|
|
851
|
+
// Command not in whitelist - reject in legacy mode
|
|
852
|
+
console.log(`[daemon] Command rejected by whitelist: ${commandText.substring(0, 100)}`);
|
|
853
|
+
executionResult = {
|
|
854
|
+
success: false,
|
|
855
|
+
exit_code: -1,
|
|
856
|
+
stdout: '',
|
|
857
|
+
stderr: '',
|
|
858
|
+
error: validation.reason
|
|
859
|
+
};
|
|
860
|
+
executionType = 'rejected';
|
|
861
|
+
}
|
|
796
862
|
} else {
|
|
797
|
-
//
|
|
798
|
-
console.log(`[daemon] Command not in whitelist, handing off to machine coder: ${commandText}`);
|
|
863
|
+
// Default mode: route all messages to Claude Code
|
|
799
864
|
executionType = 'agent';
|
|
800
|
-
|
|
801
|
-
|
|
865
|
+
console.log(`[daemon] Routing message to Claude Code: ${commandText.substring(0, 100)}`);
|
|
866
|
+
|
|
867
|
+
// Stream output callback for message execution
|
|
802
868
|
const onOutput = createStreamingCallback(session_id, message.id, {
|
|
803
869
|
message_id: message.id
|
|
804
870
|
});
|
|
805
|
-
|
|
871
|
+
|
|
806
872
|
// Use the unified machine coder interface
|
|
807
873
|
// This supports Claude Code, Gemini CLI, and future backends
|
|
874
|
+
// Note: _executeWithMachineCoderRef.fn is used for test mocking
|
|
808
875
|
try {
|
|
809
|
-
|
|
876
|
+
const executeFn = _executeWithMachineCoderRef.fn || executeWithMachineCoder;
|
|
877
|
+
executionResult = await executeFn(session_id, commandText, {
|
|
810
878
|
onOutput,
|
|
811
879
|
approvalContext: { type: 'inbox_message', id: message.id },
|
|
812
880
|
});
|
|
813
|
-
|
|
881
|
+
|
|
814
882
|
// Track which coder was used
|
|
815
883
|
if (executionResult.coder_used) {
|
|
816
884
|
console.log(`[daemon] Executed via ${executionResult.coder_used}`);
|
|
817
885
|
}
|
|
818
886
|
} catch (error) {
|
|
887
|
+
const preview = commandText.substring(0, 50);
|
|
888
|
+
console.error(`[daemon] Machine coder execution failed for "${preview}...":`, error.message);
|
|
819
889
|
executionResult = {
|
|
820
890
|
success: false,
|
|
821
891
|
exit_code: -1,
|
|
@@ -838,13 +908,17 @@ async function handleInboxMessage(session_id, message) {
|
|
|
838
908
|
);
|
|
839
909
|
|
|
840
910
|
// Build result message with execution details
|
|
911
|
+
// Note: executeWithMachineCoder returns 'output', legacy executeCommand returns 'stdout'
|
|
912
|
+
// Using ?? to handle empty string correctly (|| would skip empty string)
|
|
841
913
|
let resultText = '';
|
|
842
914
|
if (executionResult.success) {
|
|
843
|
-
const
|
|
844
|
-
resultText =
|
|
915
|
+
const resultOutput = executionResult.output ?? executionResult.stdout ?? '';
|
|
916
|
+
resultText = `Claude completed your request:\n\n${resultOutput}`;
|
|
845
917
|
} else {
|
|
846
|
-
|
|
847
|
-
|
|
918
|
+
resultText = `Claude failed to complete request:\n\nError: ${executionResult.error}`;
|
|
919
|
+
if (executionResult.stderr) {
|
|
920
|
+
resultText += `\n\nDetails:\n${executionResult.stderr}`;
|
|
921
|
+
}
|
|
848
922
|
}
|
|
849
923
|
|
|
850
924
|
try {
|
|
@@ -1525,11 +1599,19 @@ async function executeWithMachineCoder(session_id, prompt, options = {}) {
|
|
|
1525
1599
|
|
|
1526
1600
|
// Execute via the coder
|
|
1527
1601
|
const startedAt = Date.now();
|
|
1528
|
-
|
|
1602
|
+
|
|
1529
1603
|
try {
|
|
1530
|
-
//
|
|
1604
|
+
// Always use execute() for remote inbox messages since our claude_session_id
|
|
1605
|
+
// is actually the teleportation UUID, not a real Claude Code session ID.
|
|
1606
|
+
// Passing UUID to claude --resume causes it to fail.
|
|
1607
|
+
// Use UUID v4 regex pattern for robust detection instead of fragile hyphen check.
|
|
1608
|
+
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1531
1609
|
let result;
|
|
1532
|
-
|
|
1610
|
+
const isValidClaudeSession = session.claude_session_id &&
|
|
1611
|
+
session.claude_session_id !== session.session_id &&
|
|
1612
|
+
!UUID_V4_PATTERN.test(session.claude_session_id);
|
|
1613
|
+
|
|
1614
|
+
if (coder.name === 'claude-code' && isValidClaudeSession) {
|
|
1533
1615
|
result = await coder.resume(session.claude_session_id, prompt, execOptions);
|
|
1534
1616
|
} else {
|
|
1535
1617
|
result = await coder.execute(execOptions);
|
|
@@ -1975,7 +2057,14 @@ const __test = {
|
|
|
1975
2057
|
_setLastSessionActivityAt: (value) => {
|
|
1976
2058
|
lastSessionActivityAt = value;
|
|
1977
2059
|
},
|
|
1978
|
-
_getSessionsMap: () => sessions
|
|
2060
|
+
_getSessionsMap: () => sessions,
|
|
2061
|
+
// Test helper: inject mock for executeWithMachineCoder
|
|
2062
|
+
_setExecuteWithMachineCoder: (mockFn) => {
|
|
2063
|
+
_executeWithMachineCoderRef.fn = mockFn;
|
|
2064
|
+
},
|
|
2065
|
+
_resetExecuteWithMachineCoder: () => {
|
|
2066
|
+
_executeWithMachineCoderRef.fn = null;
|
|
2067
|
+
}
|
|
1979
2068
|
};
|
|
1980
2069
|
|
|
1981
2070
|
export {
|
|
@@ -2000,6 +2089,7 @@ export {
|
|
|
2000
2089
|
sendStreamingMessage,
|
|
2001
2090
|
MAX_EXECUTIONS,
|
|
2002
2091
|
PREFERRED_CODER, // New: machine coder preference
|
|
2092
|
+
REQUIRE_COMMAND_WHITELIST, // Feature flag for legacy whitelist mode
|
|
2003
2093
|
// Router integration for cost-aware LLM calls
|
|
2004
2094
|
routedCompletion,
|
|
2005
2095
|
classifyTaskTier,
|
|
@@ -16,6 +16,51 @@ import { MachineCoder, CODER_NAMES } from './interface.js';
|
|
|
16
16
|
|
|
17
17
|
const execAsync = promisify(exec);
|
|
18
18
|
|
|
19
|
+
// Grace period before killing process after receiving result (allows hooks to finish)
|
|
20
|
+
// Configurable via environment variable for production tuning
|
|
21
|
+
const PROCESS_KILL_GRACE_MS = parseInt(process.env.TELEPORTATION_PROCESS_GRACE_MS || '50', 10);
|
|
22
|
+
|
|
23
|
+
// Environment variables to pass to Claude Code process (allowlist for security)
|
|
24
|
+
// Only pass essential env vars instead of spreading all of process.env
|
|
25
|
+
const ALLOWED_ENV_VARS = [
|
|
26
|
+
'HOME',
|
|
27
|
+
'PATH',
|
|
28
|
+
'SHELL',
|
|
29
|
+
'USER',
|
|
30
|
+
'TERM',
|
|
31
|
+
'LANG',
|
|
32
|
+
'LC_ALL',
|
|
33
|
+
'TMPDIR',
|
|
34
|
+
'XDG_CONFIG_HOME',
|
|
35
|
+
'XDG_DATA_HOME',
|
|
36
|
+
'XDG_CACHE_HOME',
|
|
37
|
+
// Claude Code specific
|
|
38
|
+
'ANTHROPIC_API_KEY',
|
|
39
|
+
'CLAUDE_CODE_USE_BEDROCK',
|
|
40
|
+
'CLAUDE_CODE_USE_VERTEX',
|
|
41
|
+
// Node.js
|
|
42
|
+
'NODE_ENV',
|
|
43
|
+
'NODE_OPTIONS',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build a filtered environment object for spawning Claude Code
|
|
48
|
+
* Uses allowlist approach for security - only passes essential env vars
|
|
49
|
+
* @param {string} sessionId - Teleportation session ID to pass
|
|
50
|
+
* @returns {Object} Environment object
|
|
51
|
+
*/
|
|
52
|
+
function buildSecureEnv(sessionId) {
|
|
53
|
+
const env = {};
|
|
54
|
+
for (const key of ALLOWED_ENV_VARS) {
|
|
55
|
+
if (process.env[key] !== undefined) {
|
|
56
|
+
env[key] = process.env[key];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Always pass session ID to hooks
|
|
60
|
+
env.TELEPORTATION_SESSION_ID = sessionId;
|
|
61
|
+
return env;
|
|
62
|
+
}
|
|
63
|
+
|
|
19
64
|
/**
|
|
20
65
|
* Check if a command exists on the system
|
|
21
66
|
* @param {string} command
|
|
@@ -36,9 +81,25 @@ async function commandExists(command) {
|
|
|
36
81
|
export class ClaudeCodeAdapter extends MachineCoder {
|
|
37
82
|
name = CODER_NAMES.CLAUDE_CODE;
|
|
38
83
|
displayName = 'Claude Code';
|
|
39
|
-
|
|
84
|
+
|
|
40
85
|
// Track running processes for stop()
|
|
41
86
|
#runningProcesses = new Map();
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Close stdin immediately to prevent Claude Code from hanging
|
|
90
|
+
* Claude Code waits for stdin to close when spawned - without this,
|
|
91
|
+
* the process hangs indefinitely waiting for possible input.
|
|
92
|
+
* @param {ChildProcess} proc - The spawned process
|
|
93
|
+
* @private
|
|
94
|
+
*/
|
|
95
|
+
#closeStdinSafely(proc) {
|
|
96
|
+
try {
|
|
97
|
+
proc.stdin.end();
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// Log but don't throw - stdin might already be closed or in error state
|
|
100
|
+
console.warn('[claude-adapter] Failed to close stdin:', err.message);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
42
103
|
|
|
43
104
|
/**
|
|
44
105
|
* Check if Claude Code CLI is available
|
|
@@ -102,40 +163,39 @@ export class ClaudeCodeAdapter extends MachineCoder {
|
|
|
102
163
|
|
|
103
164
|
// Output format for parsing
|
|
104
165
|
args.push('--output-format', 'json');
|
|
105
|
-
|
|
166
|
+
|
|
106
167
|
return new Promise((resolve, reject) => {
|
|
107
168
|
const startTime = Date.now();
|
|
108
169
|
let stdout = '';
|
|
109
170
|
let stderr = '';
|
|
110
171
|
const toolCalls = [];
|
|
111
|
-
|
|
172
|
+
let resolved = false; // Prevent double-resolution race condition
|
|
173
|
+
let killTimeout = null; // Track kill timeout for cleanup
|
|
174
|
+
|
|
112
175
|
const proc = spawn('claude', args, {
|
|
113
176
|
cwd: projectPath,
|
|
114
|
-
env:
|
|
115
|
-
...process.env,
|
|
116
|
-
// Pass session ID to hooks
|
|
117
|
-
TELEPORTATION_SESSION_ID: sessionId,
|
|
118
|
-
},
|
|
177
|
+
env: buildSecureEnv(sessionId),
|
|
119
178
|
});
|
|
120
|
-
|
|
179
|
+
|
|
121
180
|
this.#runningProcesses.set(executionId, proc);
|
|
122
|
-
|
|
181
|
+
this.#closeStdinSafely(proc);
|
|
182
|
+
|
|
123
183
|
// Timeout handling
|
|
124
184
|
const timeout = setTimeout(() => {
|
|
125
185
|
proc.kill('SIGTERM');
|
|
126
186
|
reject(new Error(`Execution timed out after ${timeoutMs}ms`));
|
|
127
187
|
}, timeoutMs);
|
|
128
|
-
|
|
188
|
+
|
|
129
189
|
proc.stdout.on('data', (data) => {
|
|
130
190
|
const chunk = data.toString();
|
|
131
191
|
stdout += chunk;
|
|
132
|
-
|
|
192
|
+
|
|
133
193
|
// Try to parse JSON events for progress
|
|
134
194
|
try {
|
|
135
195
|
const lines = chunk.split('\n').filter(l => l.trim());
|
|
136
196
|
for (const line of lines) {
|
|
137
197
|
const event = JSON.parse(line);
|
|
138
|
-
|
|
198
|
+
|
|
139
199
|
// Track tool calls
|
|
140
200
|
if (event.type === 'tool_use') {
|
|
141
201
|
toolCalls.push({
|
|
@@ -145,12 +205,49 @@ export class ClaudeCodeAdapter extends MachineCoder {
|
|
|
145
205
|
timestamp: Date.now(),
|
|
146
206
|
});
|
|
147
207
|
}
|
|
148
|
-
|
|
208
|
+
|
|
149
209
|
onProgress?.({
|
|
150
210
|
type: event.type,
|
|
151
211
|
timestamp: Date.now(),
|
|
152
212
|
data: event,
|
|
153
213
|
});
|
|
214
|
+
|
|
215
|
+
// When we receive a final result, resolve early and kill the process
|
|
216
|
+
// This handles cases where hooks keep the process alive after completion
|
|
217
|
+
if (event.type === 'result') {
|
|
218
|
+
if (resolved) return; // Prevent double-resolution
|
|
219
|
+
resolved = true;
|
|
220
|
+
|
|
221
|
+
clearTimeout(timeout);
|
|
222
|
+
this.#runningProcesses.delete(executionId);
|
|
223
|
+
|
|
224
|
+
const durationMs = Date.now() - startTime;
|
|
225
|
+
const output = event.result || event.response || event.output || '';
|
|
226
|
+
const stats = { durationMs };
|
|
227
|
+
|
|
228
|
+
if (event.usage) {
|
|
229
|
+
stats.tokensUsed = event.usage.total_tokens;
|
|
230
|
+
}
|
|
231
|
+
if (event.cost || event.total_cost_usd) {
|
|
232
|
+
stats.cost = event.cost || event.total_cost_usd;
|
|
233
|
+
}
|
|
234
|
+
if (event.model) {
|
|
235
|
+
stats.model = event.model;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Kill the process since we have the result (small delay for hooks to finish)
|
|
239
|
+
killTimeout = setTimeout(() => proc.kill('SIGTERM'), PROCESS_KILL_GRACE_MS);
|
|
240
|
+
|
|
241
|
+
resolve({
|
|
242
|
+
success: !event.is_error,
|
|
243
|
+
output,
|
|
244
|
+
error: event.is_error ? (event.error || 'Unknown error') : undefined,
|
|
245
|
+
toolCalls,
|
|
246
|
+
stats,
|
|
247
|
+
executionId,
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
154
251
|
}
|
|
155
252
|
} catch {
|
|
156
253
|
// Not JSON, just raw output
|
|
@@ -172,15 +269,19 @@ export class ClaudeCodeAdapter extends MachineCoder {
|
|
|
172
269
|
});
|
|
173
270
|
|
|
174
271
|
proc.on('close', (code) => {
|
|
272
|
+
if (resolved) return; // Prevent double-resolution
|
|
273
|
+
resolved = true;
|
|
274
|
+
|
|
175
275
|
clearTimeout(timeout);
|
|
276
|
+
if (killTimeout) clearTimeout(killTimeout); // Clean up kill timeout if process exits naturally
|
|
176
277
|
this.#runningProcesses.delete(executionId);
|
|
177
|
-
|
|
278
|
+
|
|
178
279
|
const durationMs = Date.now() - startTime;
|
|
179
|
-
|
|
280
|
+
|
|
180
281
|
// Try to parse final JSON output
|
|
181
282
|
let output = stdout;
|
|
182
283
|
let stats = { durationMs };
|
|
183
|
-
|
|
284
|
+
|
|
184
285
|
try {
|
|
185
286
|
const result = JSON.parse(stdout);
|
|
186
287
|
output = result.result || result.response || result.output || stdout;
|
|
@@ -196,7 +297,7 @@ export class ClaudeCodeAdapter extends MachineCoder {
|
|
|
196
297
|
} catch {
|
|
197
298
|
// Not JSON, use raw output
|
|
198
299
|
}
|
|
199
|
-
|
|
300
|
+
|
|
200
301
|
resolve({
|
|
201
302
|
success: code === 0,
|
|
202
303
|
output,
|
|
@@ -238,45 +339,98 @@ export class ClaudeCodeAdapter extends MachineCoder {
|
|
|
238
339
|
}
|
|
239
340
|
|
|
240
341
|
args.push('--output-format', 'json');
|
|
241
|
-
|
|
342
|
+
|
|
242
343
|
return new Promise((resolve, reject) => {
|
|
243
344
|
const startTime = Date.now();
|
|
244
345
|
let stdout = '';
|
|
245
346
|
let stderr = '';
|
|
246
347
|
const toolCalls = [];
|
|
247
|
-
|
|
348
|
+
let resolved = false; // Prevent double-resolution race condition
|
|
349
|
+
let killTimeout = null; // Track kill timeout for cleanup
|
|
350
|
+
|
|
248
351
|
const proc = spawn('claude', args, {
|
|
249
352
|
cwd: projectPath,
|
|
250
|
-
env:
|
|
251
|
-
...process.env,
|
|
252
|
-
TELEPORTATION_SESSION_ID: sessionId,
|
|
253
|
-
},
|
|
353
|
+
env: buildSecureEnv(sessionId),
|
|
254
354
|
});
|
|
255
|
-
|
|
355
|
+
|
|
256
356
|
this.#runningProcesses.set(executionId, proc);
|
|
257
|
-
|
|
357
|
+
this.#closeStdinSafely(proc);
|
|
358
|
+
|
|
258
359
|
const timeout = setTimeout(() => {
|
|
259
360
|
proc.kill('SIGTERM');
|
|
260
361
|
reject(new Error(`Resume timed out after ${timeoutMs}ms`));
|
|
261
362
|
}, timeoutMs);
|
|
262
|
-
|
|
363
|
+
|
|
263
364
|
proc.stdout.on('data', (data) => {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
365
|
+
const chunk = data.toString();
|
|
366
|
+
stdout += chunk;
|
|
367
|
+
|
|
368
|
+
// Try to parse JSON result
|
|
369
|
+
try {
|
|
370
|
+
const lines = chunk.split('\n').filter(l => l.trim());
|
|
371
|
+
for (const line of lines) {
|
|
372
|
+
const event = JSON.parse(line);
|
|
373
|
+
|
|
374
|
+
onProgress?.({
|
|
375
|
+
type: event.type || 'output',
|
|
376
|
+
timestamp: Date.now(),
|
|
377
|
+
data: event,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// When we receive a final result, resolve early and kill the process
|
|
381
|
+
if (event.type === 'result') {
|
|
382
|
+
if (resolved) return; // Prevent double-resolution
|
|
383
|
+
resolved = true;
|
|
384
|
+
|
|
385
|
+
clearTimeout(timeout);
|
|
386
|
+
this.#runningProcesses.delete(executionId);
|
|
387
|
+
|
|
388
|
+
const output = event.result || event.response || event.output || '';
|
|
389
|
+
const stats = { durationMs: Date.now() - startTime };
|
|
390
|
+
|
|
391
|
+
if (event.usage) {
|
|
392
|
+
stats.tokensUsed = event.usage.total_tokens;
|
|
393
|
+
}
|
|
394
|
+
if (event.cost || event.total_cost_usd) {
|
|
395
|
+
stats.cost = event.cost || event.total_cost_usd;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Kill the process since we have the result (small delay for hooks to finish)
|
|
399
|
+
killTimeout = setTimeout(() => proc.kill('SIGTERM'), PROCESS_KILL_GRACE_MS);
|
|
400
|
+
|
|
401
|
+
resolve({
|
|
402
|
+
success: !event.is_error,
|
|
403
|
+
output,
|
|
404
|
+
error: event.is_error ? (event.error || 'Unknown error') : undefined,
|
|
405
|
+
toolCalls,
|
|
406
|
+
stats,
|
|
407
|
+
executionId,
|
|
408
|
+
});
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
} catch {
|
|
413
|
+
// Not JSON, just raw output
|
|
414
|
+
onProgress?.({
|
|
415
|
+
type: 'output',
|
|
416
|
+
timestamp: Date.now(),
|
|
417
|
+
data: { text: chunk },
|
|
418
|
+
});
|
|
419
|
+
}
|
|
270
420
|
});
|
|
271
|
-
|
|
421
|
+
|
|
272
422
|
proc.stderr.on('data', (data) => {
|
|
273
423
|
stderr += data.toString();
|
|
274
424
|
});
|
|
275
|
-
|
|
425
|
+
|
|
276
426
|
proc.on('close', (code) => {
|
|
427
|
+
if (resolved) return; // Prevent double-resolution
|
|
428
|
+
resolved = true;
|
|
429
|
+
|
|
277
430
|
clearTimeout(timeout);
|
|
431
|
+
if (killTimeout) clearTimeout(killTimeout); // Clean up kill timeout if process exits naturally
|
|
278
432
|
this.#runningProcesses.delete(executionId);
|
|
279
|
-
|
|
433
|
+
|
|
280
434
|
resolve({
|
|
281
435
|
success: code === 0,
|
|
282
436
|
output: stdout,
|
|
@@ -286,7 +440,7 @@ export class ClaudeCodeAdapter extends MachineCoder {
|
|
|
286
440
|
executionId,
|
|
287
441
|
});
|
|
288
442
|
});
|
|
289
|
-
|
|
443
|
+
|
|
290
444
|
proc.on('error', (err) => {
|
|
291
445
|
clearTimeout(timeout);
|
|
292
446
|
this.#runningProcesses.delete(executionId);
|