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.
@@ -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. Check for pending messages from mobile app (existing functionality)
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
- * - Spawns child Claude Code processes via `claude --resume <session_id> -p "<prompt>"`
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
- * This daemon executes shell commands via spawn('sh', ['-c', command]) which bypasses
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. COMMAND WHITELIST: Only pre-approved command prefixes are allowed (see ALLOWED_COMMAND_PREFIXES)
20
- * 2. SHELL INJECTION BLOCKING: Commands containing metacharacters (;|&`$() etc.) are rejected
21
- * 3. APPROVAL FLOW: All commands must be explicitly approved via the relay API
22
- * 4. DEVELOPMENT BYPASS: ALLOW_ALL_COMMANDS requires TELEPORTATION_DANGER_ZONE confirmation
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
- * For production deployments requiring Claude CLI integration, consider:
25
- * - Using the CLAUDE_CLI_PATH environment variable to specify a custom Claude CLI wrapper
26
- * - Implementing additional command validation in a proxy layer
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
- // Command whitelist for inbox execution (security: prevents arbitrary command execution)
188
- // Only commands starting with these prefixes are allowed
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
- // Hybrid Execution Logic:
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 = 'shell';
791
-
792
- if (validation.allowed) {
793
- // Fast path: Execute shell command directly
794
- console.log(`[daemon] Executing direct shell command: ${commandText}`);
795
- executionResult = await executeCommand(session_id, commandText);
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
- // Fallback: Natural language prompt via machine coder (Claude/Gemini/etc.)
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
- // Stream output callback for inbox message execution
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
- executionResult = await executeWithMachineCoder(session_id, commandText, {
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 header = executionType === 'agent' ? 'Claude executed your request:\n\n' : 'Command executed successfully:\n\n';
844
- resultText = `${header}${executionResult.stdout}`;
915
+ const resultOutput = executionResult.output ?? executionResult.stdout ?? '';
916
+ resultText = `Claude completed your request:\n\n${resultOutput}`;
845
917
  } else {
846
- const header = executionType === 'agent' ? 'Claude failed to execute request:\n\n' : `Command failed with exit code ${executionResult.exit_code}:\n\n`;
847
- resultText = `${header}Error: ${executionResult.error}\n\nStderr:\n${executionResult.stderr}`;
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
- // For Claude Code with resume support, use resume if we have a claude_session_id
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
- if (coder.name === 'claude-code' && session.claude_session_id) {
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
- stdout += data.toString();
265
- onProgress?.({
266
- type: 'output',
267
- timestamp: Date.now(),
268
- data: { text: data.toString() },
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teleportation-cli",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Remote approval system for Claude Code - approve AI coding changes from your phone",
5
5
  "type": "module",
6
6
  "main": "teleportation-cli.cjs",