teleportation-cli 1.1.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.claude/hooks/config-loader.mjs +88 -34
  2. package/.claude/hooks/permission_request.mjs +392 -82
  3. package/.claude/hooks/post_tool_use.mjs +90 -0
  4. package/.claude/hooks/pre_tool_use.mjs +247 -305
  5. package/.claude/hooks/session-register.mjs +94 -105
  6. package/.claude/hooks/session_end.mjs +41 -42
  7. package/.claude/hooks/session_start.mjs +45 -60
  8. package/.claude/hooks/stop.mjs +752 -99
  9. package/.claude/hooks/user_prompt_submit.mjs +26 -3
  10. package/README.md +7 -0
  11. package/lib/auth/api-key.js +12 -0
  12. package/lib/auth/token-refresh.js +286 -0
  13. package/lib/cli/daemon-commands.js +1 -1
  14. package/lib/cli/teleport-commands.js +469 -0
  15. package/lib/daemon/daemon-v2.js +104 -0
  16. package/lib/daemon/lifecycle.js +56 -171
  17. package/lib/daemon/response-classifier.js +15 -1
  18. package/lib/daemon/services/index.js +3 -0
  19. package/lib/daemon/services/polling-service.js +173 -0
  20. package/lib/daemon/services/queue-service.js +318 -0
  21. package/lib/daemon/services/session-service.js +115 -0
  22. package/lib/daemon/state.js +35 -0
  23. package/lib/daemon/task-executor-v2.js +413 -0
  24. package/lib/daemon/task-executor.js +1235 -0
  25. package/lib/daemon/teleportation-daemon.js +770 -25
  26. package/lib/daemon/timeline-analyzer.js +215 -0
  27. package/lib/daemon/transcript-ingestion.js +696 -0
  28. package/lib/daemon/utils.js +91 -0
  29. package/lib/install/installer.js +184 -20
  30. package/lib/install/uhr-installer.js +136 -0
  31. package/lib/remote/providers/base-provider.js +46 -0
  32. package/lib/remote/providers/daytona-provider.js +58 -0
  33. package/lib/remote/providers/provider-factory.js +90 -19
  34. package/lib/remote/providers/sprites-provider.js +711 -0
  35. package/lib/teleport/exporters/claude-exporter.js +302 -0
  36. package/lib/teleport/exporters/gemini-exporter.js +307 -0
  37. package/lib/teleport/exporters/index.js +93 -0
  38. package/lib/teleport/exporters/interface.js +153 -0
  39. package/lib/teleport/fork-tracker.js +415 -0
  40. package/lib/teleport/git-committer.js +337 -0
  41. package/lib/teleport/index.js +48 -0
  42. package/lib/teleport/manager.js +620 -0
  43. package/lib/teleport/session-capture.js +282 -0
  44. package/package.json +11 -5
  45. package/teleportation-cli.cjs +632 -451
  46. package/.claude/hooks/heartbeat.mjs +0 -396
  47. package/lib/daemon/agentic-executor.js +0 -803
  48. package/lib/daemon/pid-manager.js +0 -160
@@ -82,6 +82,45 @@ const isValidSessionId = (id) => {
82
82
  return id && /^[a-f0-9-]{36}$/i.test(id);
83
83
  };
84
84
 
85
+ // Timeout for daemon-state updates (extracted from magic number per code review)
86
+ const DAEMON_STATE_UPDATE_TIMEOUT_MS = 3000;
87
+
88
+ /**
89
+ * Update daemon state with timeout and proper resource cleanup.
90
+ * Returns true if update succeeded, false otherwise.
91
+ * @param {string} relayApiUrl - Relay API base URL
92
+ * @param {string} sessionId - Session ID to update
93
+ * @param {object} state - State object to patch (e.g., { is_away: true })
94
+ * @param {string} apiKey - API key for authorization
95
+ * @param {function} log - Logging function
96
+ * @returns {Promise<boolean>} - True if update succeeded
97
+ */
98
+ const updateDaemonState = async (relayApiUrl, sessionId, state, apiKey, log) => {
99
+ const controller = new AbortController();
100
+ const timeoutId = setTimeout(() => controller.abort(), DAEMON_STATE_UPDATE_TIMEOUT_MS);
101
+ try {
102
+ const res = await fetch(`${relayApiUrl}/api/sessions/${sessionId}/daemon-state`, {
103
+ method: 'PATCH',
104
+ headers: {
105
+ 'Content-Type': 'application/json',
106
+ 'Authorization': `Bearer ${apiKey}`
107
+ },
108
+ body: JSON.stringify(state),
109
+ signal: controller.signal
110
+ });
111
+ if (!res.ok) {
112
+ log(`[TRACE] daemon-state update returned ${res.status}`);
113
+ return false;
114
+ }
115
+ return true;
116
+ } catch (e) {
117
+ log(`[TRACE] daemon-state update failed: ${e.message}`);
118
+ return false;
119
+ } finally {
120
+ clearTimeout(timeoutId);
121
+ }
122
+ };
123
+
85
124
  const fetchJson = async (url, opts) => {
86
125
  const res = await fetch(url, opts);
87
126
  if (!res.ok) {
@@ -126,6 +165,31 @@ const fetchJson = async (url, opts) => {
126
165
  return exit(0);
127
166
  }
128
167
 
168
+ // For autonomous tasks, use parent session ID for approvals
169
+ // This ensures approvals appear in the timeline the user is viewing
170
+ const raw_parent_session_id = env.TELEPORTATION_PARENT_SESSION_ID;
171
+
172
+ // Validate parent_session_id if provided to prevent injection
173
+ // Only use if it passes validation, otherwise fall back to session_id
174
+ const parent_session_id = (raw_parent_session_id && isValidSessionId(raw_parent_session_id))
175
+ ? raw_parent_session_id
176
+ : null;
177
+
178
+ if (raw_parent_session_id && !parent_session_id) {
179
+ log(`Warning: Invalid TELEPORTATION_PARENT_SESSION_ID format, ignoring: ${raw_parent_session_id}`);
180
+ }
181
+ const approval_session_id = parent_session_id || session_id;
182
+ const child_session_id = parent_session_id ? session_id : null;
183
+
184
+ if (parent_session_id) {
185
+ log(`Using parent session ${parent_session_id} for approvals (child session: ${session_id})`);
186
+ }
187
+
188
+ // Detect message source
189
+ const isAutonomousTask = env.TELEPORTATION_TASK_MODE === 'true' || !!parent_session_id;
190
+ const source = isAutonomousTask ? 'autonomous_task' : 'cli_interactive';
191
+ log(`Message source: ${source}`);
192
+
129
193
  // Load config
130
194
  let config;
131
195
  try {
@@ -151,14 +215,64 @@ const fetchJson = async (url, opts) => {
151
215
  return exit(0);
152
216
  }
153
217
 
154
- // Check if session is in "away" mode
218
+ // Auto-detect away mode based on last approval location
155
219
  let isAway = false;
220
+ let autoDetected = false;
221
+
222
+ // Force away mode if running as a daemon-spawned task
223
+ // TELEPORTATION_TASK_MODE is set by task-executor.js when spawning Claude for task execution
224
+ const isTaskMode = env.TELEPORTATION_TASK_MODE === 'true';
225
+ if (isTaskMode) {
226
+ isAway = true;
227
+ log(`[PermissionRequest] Task mode detected - forcing away mode for remote approval`);
228
+ }
229
+
156
230
  try {
157
- const state = await fetchJson(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
231
+ const state = await fetchJson(`${RELAY_API_URL}/api/sessions/${approval_session_id}/daemon-state`, {
158
232
  headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
159
233
  });
160
- isAway = !!state.is_away;
161
- log(`Session away status: ${isAway}`);
234
+
235
+ // Skip auto-toggle if task mode is forcing away
236
+ if (isTaskMode) {
237
+ log(`Task mode: skipping auto-detection, using forced away mode`);
238
+ } else {
239
+ // Auto-toggle based on last interaction source
240
+ const lastLocation = state.last_approval_location;
241
+ if (lastLocation === 'mobile') {
242
+ // Last approval was from mobile → user is away
243
+ isAway = true;
244
+ autoDetected = true;
245
+ log(`Auto-detected: user away (last approval from mobile)`);
246
+ } else if (lastLocation === 'local') {
247
+ // Last approval was local → user is present
248
+ isAway = false;
249
+ autoDetected = true;
250
+ log(`Auto-detected: user present (last approval from local)`);
251
+ } else {
252
+ // Use existing is_away flag if no approval history
253
+ isAway = !!state.is_away;
254
+ log(`Session away status: ${isAway} (no auto-detection)`);
255
+ }
256
+ }
257
+
258
+ // Update daemon state if auto-detected different from current
259
+ // Use approval_session_id for consistency with session registration
260
+ if (autoDetected && isAway !== !!state.is_away) {
261
+ log(`Updating away status: ${state.is_away} → ${isAway}`);
262
+ try {
263
+ await fetchJson(`${RELAY_API_URL}/api/sessions/${approval_session_id}/daemon-state`, {
264
+ method: 'PATCH',
265
+ headers: {
266
+ 'Content-Type': 'application/json',
267
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
268
+ },
269
+ body: JSON.stringify({ is_away: isAway })
270
+ });
271
+ log(`Updated session away status to: ${isAway}`);
272
+ } catch (updateErr) {
273
+ log(`Warning: Failed to update away status: ${updateErr.message}`);
274
+ }
275
+ }
162
276
  } catch (e) {
163
277
  // Fail-safe: if relay is down, assume user is present (safer default)
164
278
  const failSafe = env.AWAY_CHECK_FAIL_SAFE || 'present';
@@ -205,7 +319,7 @@ const fetchJson = async (url, opts) => {
205
319
  'Content-Type': 'application/json',
206
320
  'Authorization': `Bearer ${RELAY_API_KEY}`
207
321
  },
208
- body: JSON.stringify({ session_id, reason: 'New permission request' })
322
+ body: JSON.stringify({ session_id: approval_session_id, reason: 'New permission request' })
209
323
  });
210
324
  } catch (e) {
211
325
  log(`Warning: Failed to invalidate old approvals: ${e.message}`);
@@ -234,12 +348,14 @@ const fetchJson = async (url, opts) => {
234
348
  }
235
349
 
236
350
  if (lastUserMessage) {
351
+ const CONTEXT_LIMIT = 2000;
352
+ const truncated = lastUserMessage.length > CONTEXT_LIMIT;
237
353
  conversation_context = {
238
- user_last_message: lastUserMessage.slice(0, 500), // Truncate to 500 chars
354
+ user_last_message: truncated ? lastUserMessage.slice(0, CONTEXT_LIMIT) + '...' : lastUserMessage,
239
355
  claude_reasoning: null, // Phase 2: extract Claude's reasoning
240
356
  timestamp: Date.now()
241
357
  };
242
- log(`Extracted conversation context: user_message="${lastUserMessage.slice(0, 50)}..."`);
358
+ log(`Extracted conversation context (truncated: ${truncated}): user_message="${conversation_context.user_last_message.slice(0, 50)}..."`);
243
359
  }
244
360
  } catch (e) {
245
361
  log(`Warning: Failed to extract conversation context: ${e.message}`);
@@ -247,20 +363,47 @@ const fetchJson = async (url, opts) => {
247
363
  }
248
364
 
249
365
  // Fallback: Generate tool_use_id if not provided (defensive programming)
250
- const effective_tool_use_id = tool_use_id || `tool_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
366
+ const effective_tool_use_id = tool_use_id || `tool_${crypto.randomUUID()}`;
251
367
  if (!tool_use_id) {
252
368
  log(`Warning: tool_use_id not provided, generated fallback: ${effective_tool_use_id}`);
253
369
  }
254
370
 
371
+ // Add child session ID to metadata if this is an autonomous task
372
+ if (child_session_id) {
373
+ meta.child_session_id = child_session_id;
374
+ meta.is_autonomous_task = true;
375
+ }
376
+
377
+ // Fetch session to get owner_id (PRD-0018: Multi-tenancy)
378
+ let owner_id = null;
379
+ try {
380
+ log(`Fetching session ${approval_session_id} to get owner_id for approval`);
381
+ const sessionResponse = await fetch(`${RELAY_API_URL}/api/sessions/${approval_session_id}`, {
382
+ headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
383
+ });
384
+
385
+ if (sessionResponse.ok) {
386
+ const session = await sessionResponse.json();
387
+ owner_id = session.owner_id;
388
+ log(`Retrieved owner_id: ${owner_id} from session`);
389
+ } else {
390
+ log(`Warning: Failed to fetch session (HTTP ${sessionResponse.status}). Creating orphan approval.`);
391
+ }
392
+ } catch (e) {
393
+ log(`Warning: Failed to fetch session owner_id: ${e.message}`);
394
+ // Continue without owner_id - will create orphan approval
395
+ }
396
+
255
397
  // Create approval request with metadata and context (PRD-0013)
256
398
  let approvalId;
257
399
  try {
258
400
  const payload = {
259
- session_id,
401
+ session_id: approval_session_id, // Use parent session for autonomous tasks
260
402
  tool_name,
261
403
  tool_input,
262
404
  meta,
263
405
  tool_use_id: effective_tool_use_id,
406
+ owner_id, // PRD-0018: Associate approval with user
264
407
  };
265
408
  // Only include conversation_context if not null
266
409
  // API validation expects it to be an object, not null
@@ -272,7 +415,7 @@ const fetchJson = async (url, opts) => {
272
415
  // if (transcript_excerpt !== null) payload.transcript_excerpt = transcript_excerpt;
273
416
 
274
417
  log(`Creating approval with payload: ${JSON.stringify(payload).substring(0, 500)}`);
275
- const created = await fetchJson(`${RELAY_API_URL}/api/approvals`, {
418
+ const approvalRes = await fetch(`${RELAY_API_URL}/api/approvals`, {
276
419
  method: 'POST',
277
420
  headers: {
278
421
  'Content-Type': 'application/json',
@@ -280,30 +423,83 @@ const fetchJson = async (url, opts) => {
280
423
  },
281
424
  body: JSON.stringify(payload)
282
425
  });
426
+
427
+ const created = await approvalRes.json();
428
+ if (!approvalRes.ok) {
429
+ throw new Error(created.error || `HTTP ${approvalRes.status}`);
430
+ }
431
+
432
+ if (created.warning === 'orphan_approval') {
433
+ process.stderr.write('⚠️ Warning: Approval created without owner context. It may not be visible in all devices.\n');
434
+ }
435
+
283
436
  approvalId = created.id;
284
- log(`Approval created: ${approvalId} (tool_use_id: ${effective_tool_use_id})`);
437
+ log(`Created approval: ${approvalId}`);
285
438
  } catch (e) {
286
- log(`ERROR creating approval: ${e.message}`);
287
- log(`Error stack: ${e.stack}`);
288
- return exit(0); // Let Claude Code handle it
439
+ log(`ERROR: Failed to create approval: ${e.message}`);
440
+ process.stderr.write(`[teleportation] Error: Could not request remote approval: ${e.message}\n`);
441
+ // Fall back to local prompt if approval creation fails
442
+ return process.stdout.write(JSON.stringify({ decision: 'ask', reason: `Remote approval request failed: ${e.message}` }));
289
443
  }
290
444
 
291
- // If user is PRESENT: return immediately and let Claude Code show its native prompt
292
- // The approval is now visible in mobile UI. If user approves locally:
293
- // - PostToolUse will invalidate the approval
294
- // - Mobile UI will filter it out (any timeline event after approval = stale)
295
- if (!isAway) {
296
- log(`User is present - approval ${approvalId} created for mobile visibility, letting Claude Code handle locally`);
297
- return exit(0);
298
- }
445
+ // DESIGN RATIONALE: Hybrid Approval Flow
446
+ // ========================================
447
+ // This polling loop supports "hybrid approval" where users can approve from
448
+ // EITHER their local CLI OR mobile app, regardless of away/present mode:
449
+ //
450
+ // - User PRESENT: Claude Code shows native prompt AND we poll for mobile approval.
451
+ // If user approves locally, PostToolUse hook invalidates the remote approval.
452
+ // If user approves via mobile, we detect it here and proceed.
453
+ //
454
+ // - User AWAY: Claude Code prompt is suppressed, we poll for mobile approval.
455
+ // User must approve via mobile to continue.
456
+ //
457
+ // WHY NO MODE-AWARE TIMEOUTS:
458
+ // Old design had 5s timeout for present, 60s for away. This caused issues:
459
+ // 1. User at computer who wants to approve via mobile (e.g., testing) couldn't
460
+ // 2. Quick timeouts caused unnecessary daemon handoffs
461
+ // 3. Users stepping away briefly got their approvals handed to daemon
462
+ //
463
+ // The 1-hour timeout (configurable) allows flexible approval from either location.
464
+ // Circuit breaker prevents indefinite polling. Local approvals invalidate remote.
465
+ //
466
+ log(`${isAway ? 'User is away' : 'User is present'} - polling for approval...`);
299
467
 
300
- // User is AWAY - poll for remote approval decision
301
- log(`User is away - polling for remote approval decision (timeout=${FAST_APPROVAL_TIMEOUT_MS}ms)...`);
302
468
  let consecutiveFailures = 0;
303
469
  const MAX_CONSECUTIVE_FAILURES = 5;
304
470
 
305
- const deadline = Date.now() + FAST_APPROVAL_TIMEOUT_MS;
306
- while (Date.now() < deadline) {
471
+ // Circuit breaker: max 1 hour (1800 polls at 2s interval) or configurable via env
472
+ // Bounds: iterations 10-10000, timeout 60s-4hrs to prevent DoS and misconfiguration
473
+ const rawIterations = parseInt(env.APPROVAL_MAX_POLL_ITERATIONS || '1800', 10);
474
+ const rawTimeout = parseInt(env.APPROVAL_MAX_TIMEOUT_MS || '3600000', 10);
475
+ const MAX_POLL_ITERATIONS = Math.max(10, Math.min(10000, isNaN(rawIterations) ? 1800 : rawIterations));
476
+ const MAX_POLL_TIMEOUT_MS = Math.max(60000, Math.min(14400000, isNaN(rawTimeout) ? 3600000 : rawTimeout));
477
+ const pollStartTime = Date.now();
478
+ let pollIterations = 0;
479
+
480
+ // Poll with circuit breaker limits
481
+ while (true) {
482
+ pollIterations++;
483
+
484
+ // Circuit breaker: check iteration limit
485
+ if (pollIterations > MAX_POLL_ITERATIONS) {
486
+ log(`Circuit breaker: exceeded max poll iterations (${MAX_POLL_ITERATIONS})`);
487
+ // Fall back to daemon handoff
488
+ return process.stdout.write(JSON.stringify({
489
+ decision: 'deny',
490
+ reason: 'Approval polling exceeded maximum iterations - handed off to daemon'
491
+ }));
492
+ }
493
+
494
+ // Circuit breaker: check timeout
495
+ if (Date.now() - pollStartTime > MAX_POLL_TIMEOUT_MS) {
496
+ log(`Circuit breaker: exceeded max poll timeout (${MAX_POLL_TIMEOUT_MS}ms)`);
497
+ // Fall back to daemon handoff
498
+ return process.stdout.write(JSON.stringify({
499
+ decision: 'deny',
500
+ reason: 'Approval polling exceeded maximum timeout - handed off to daemon'
501
+ }));
502
+ }
307
503
  try {
308
504
  const status = await fetchJson(`${RELAY_API_URL}/api/approvals/${approvalId}`, {
309
505
  headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
@@ -312,37 +508,168 @@ const fetchJson = async (url, opts) => {
312
508
 
313
509
  if (status.status === 'allowed') {
314
510
  log('Remote approval: ALLOWED');
511
+
512
+ // Auto-detect away mode: if approval came from mobile, switch to away mode
513
+ // This enables seamless handoff when user approves from mobile
514
+ if (status.decision_location === 'mobile') {
515
+ // Timeline: Mobile approval detected
516
+ log('[TIMELINE-DEBUG] About to log approval_processing event');
517
+ try {
518
+ await fetchJson(`${RELAY_API_URL}/api/timeline`, {
519
+ method: 'POST',
520
+ headers: {
521
+ 'Content-Type': 'application/json',
522
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
523
+ },
524
+ body: JSON.stringify({
525
+ session_id: approval_session_id,
526
+ type: 'approval_processing',
527
+ source,
528
+ data: {
529
+ tool_name,
530
+ approval_id: approvalId,
531
+ decision_location: 'mobile',
532
+ message: 'Mobile approval detected - processing handoff'
533
+ }
534
+ })
535
+ });
536
+ log('[TIMELINE-DEBUG] Successfully logged approval_processing event');
537
+ } catch (e) {
538
+ log(`[TIMELINE-DEBUG] Failed to log approval_processing: ${e.message}`);
539
+ }
540
+
541
+ {
542
+ log('[TRACE] Approval came from mobile - starting auto-switch to away mode');
543
+ // Use helper function with proper timeout and resource cleanup
544
+ const updateSuccess = await updateDaemonState(
545
+ RELAY_API_URL,
546
+ approval_session_id,
547
+ { is_away: true, last_approval_location: 'mobile' },
548
+ RELAY_API_KEY,
549
+ log
550
+ );
551
+
552
+ // Timeline: Mode switched to away - ONLY log if daemon-state update succeeded
553
+ // This prevents logging a mode switch that didn't actually happen
554
+ if (updateSuccess) {
555
+ log('[TRACE] daemon-state update completed successfully');
556
+ try {
557
+ await fetchJson(`${RELAY_API_URL}/api/timeline`, {
558
+ method: 'POST',
559
+ headers: {
560
+ 'Content-Type': 'application/json',
561
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
562
+ },
563
+ body: JSON.stringify({
564
+ session_id: approval_session_id,
565
+ type: 'mode_switched',
566
+ source,
567
+ data: {
568
+ from: 'present',
569
+ to: 'away',
570
+ trigger: 'mobile_approval',
571
+ approval_id: approvalId
572
+ }
573
+ })
574
+ });
575
+ } catch (e) {
576
+ log(`Warning: Failed to log timeline event: ${e.message}`);
577
+ }
578
+ } else {
579
+ log('[TRACE] Skipping mode_switched timeline event - daemon-state update failed');
580
+ }
581
+ }
582
+ } else if (status.decision_location === 'local') {
583
+ // Auto-switch to present mode when user approves locally
584
+ log('Approval came from CLI - auto-switching to present mode');
585
+ // Use helper function with proper timeout and resource cleanup
586
+ const updateSuccess = await updateDaemonState(
587
+ RELAY_API_URL,
588
+ approval_session_id,
589
+ { is_away: false, last_approval_location: 'local' },
590
+ RELAY_API_KEY,
591
+ log
592
+ );
593
+ if (!updateSuccess) {
594
+ log('Warning: Failed to auto-switch to present mode');
595
+ }
596
+ }
597
+
315
598
  // Acknowledge on the fast-path to prevent duplicate daemon execution.
316
- try {
317
- const ackRes = await fetch(`${RELAY_API_URL}/api/approvals/${approvalId}/ack`, {
318
- method: 'POST',
319
- headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
320
- });
321
- if (!ackRes.ok) {
322
- throw new Error(`ACK returned ${ackRes.status}`);
599
+ // PRD-0016: Implement 3-attempt exponential backoff for ACK
600
+ log('[TRACE] Starting ACK process');
601
+ let ackSuccess = false;
602
+ let ackAttempts = 0;
603
+ const MAX_ACK_ATTEMPTS = 3;
604
+
605
+ while (!ackSuccess && ackAttempts < MAX_ACK_ATTEMPTS) {
606
+ ackAttempts++;
607
+ log(`[TRACE] ACK attempt ${ackAttempts}/${MAX_ACK_ATTEMPTS}`);
608
+ try {
609
+ const ackRes = await fetch(`${RELAY_API_URL}/api/approvals/${approvalId}/ack`, {
610
+ method: 'POST',
611
+ headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
612
+ });
613
+ if (ackRes.ok) {
614
+ ackSuccess = true;
615
+ log('[TRACE] ACK succeeded');
616
+ } else {
617
+ throw new Error(`ACK returned ${ackRes.status}`);
618
+ }
619
+ } catch (e) {
620
+ const delay = Math.pow(2, ackAttempts) * 100;
621
+ log(`[TRACE] ACK attempt ${ackAttempts} failed: ${e.message}. Retrying in ${delay}ms...`);
622
+ if (ackAttempts < MAX_ACK_ATTEMPTS) {
623
+ await sleep(delay);
624
+ }
323
625
  }
324
- } catch (e) {
325
- log(`ERROR: Failed to ack approval ${approvalId}: ${e.message} - aborting to prevent duplicate execution`);
326
- const out = {
327
- hookSpecificOutput: {
328
- hookEventName: 'PermissionRequest',
329
- permissionDecision: 'deny',
330
- permissionDecisionReason: '⚠️ Teleportation: Approval received but acknowledgment failed. Request handed to daemon to prevent duplicate execution.'
331
- },
332
- suppressOutput: true
333
- };
334
- stdout.write(JSON.stringify(out));
335
- return exit(0);
336
626
  }
627
+
628
+ if (!ackSuccess) {
629
+ log(`[TRACE] ERROR: All ${MAX_ACK_ATTEMPTS} ACK attempts failed for ${approvalId}. Allowing locally anyway.`);
630
+ }
631
+
632
+ // Timeline: ACK completed (mobile approvals only - more detail for mobile)
633
+ if (status.decision_location === 'mobile') {
634
+ try {
635
+ await fetchJson(`${RELAY_API_URL}/api/timeline`, {
636
+ method: 'POST',
637
+ headers: {
638
+ 'Content-Type': 'application/json',
639
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
640
+ },
641
+ body: JSON.stringify({
642
+ session_id: approval_session_id,
643
+ type: 'approval_acknowledged',
644
+ source,
645
+ data: {
646
+ tool_name,
647
+ approval_id: approvalId,
648
+ ack_success: ackSuccess,
649
+ ack_attempts: ackAttempts,
650
+ message: ackSuccess
651
+ ? 'Approval acknowledged - executing tool locally'
652
+ : 'ACK failed but proceeding anyway'
653
+ }
654
+ })
655
+ });
656
+ } catch (e) {
657
+ log(`Warning: Failed to log timeline event: ${e.message}`);
658
+ }
659
+ }
660
+
661
+ log('[TRACE] Returning allow decision to Claude Code');
337
662
  const out = {
338
663
  hookSpecificOutput: {
339
664
  hookEventName: 'PermissionRequest',
340
- permissionDecision: 'allow',
341
- permissionDecisionReason: 'Approved remotely via Teleportation'
342
- },
343
- suppressOutput: true
665
+ decision: {
666
+ behavior: 'allow'
667
+ }
668
+ }
344
669
  };
345
- stdout.write(JSON.stringify(out));
670
+ stdout.write(JSON.stringify(out) + '\n');
671
+ log('[TRACE] Wrote allow decision to stdout with newline');
672
+ log('[TRACE] Hook exiting with exit(0)');
346
673
  return exit(0);
347
674
  }
348
675
 
@@ -357,8 +684,9 @@ const fetchJson = async (url, opts) => {
357
684
  'Authorization': `Bearer ${RELAY_API_KEY}`
358
685
  },
359
686
  body: JSON.stringify({
360
- session_id,
687
+ session_id: approval_session_id,
361
688
  type: 'tool_denied',
689
+ source,
362
690
  data: {
363
691
  tool_name,
364
692
  tool_input: tool_input || {},
@@ -375,12 +703,14 @@ const fetchJson = async (url, opts) => {
375
703
  const out = {
376
704
  hookSpecificOutput: {
377
705
  hookEventName: 'PermissionRequest',
378
- permissionDecision: 'deny',
379
- permissionDecisionReason: 'Denied remotely via Teleportation'
380
- },
381
- suppressOutput: true
706
+ decision: {
707
+ behavior: 'deny',
708
+ message: status.decision_reason || 'Denied remotely via Teleportation'
709
+ }
710
+ }
382
711
  };
383
- stdout.write(JSON.stringify(out));
712
+ stdout.write(JSON.stringify(out) + '\n');
713
+ log('[TRACE] Wrote deny decision to stdout with newline');
384
714
  return exit(0);
385
715
  }
386
716
 
@@ -400,30 +730,10 @@ const fetchJson = async (url, opts) => {
400
730
  await sleep(POLLING_INTERVAL_MS);
401
731
  }
402
732
 
403
- // Timeout while user is away - hand off to daemon
404
- log('Fast-path approval timeout - handing off to daemon');
405
-
406
- try {
407
- await fetch(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
408
- method: 'PATCH',
409
- headers: {
410
- 'Content-Type': 'application/json',
411
- 'Authorization': `Bearer ${RELAY_API_KEY}`
412
- },
413
- body: JSON.stringify({ last_approval_location: 'daemon_handoff', is_away: true })
414
- });
415
- } catch (e) {
416
- log(`Warning: Failed to update daemon-state for handoff: ${e.message}`);
417
- }
418
-
419
- const out = {
420
- hookSpecificOutput: {
421
- hookEventName: 'PermissionRequest',
422
- permissionDecision: 'deny',
423
- permissionDecisionReason: '⏳ Teleportation: waiting for mobile approval timed out. This request was handed off to the daemon and will run in the background once approved.'
424
- },
425
- suppressOutput: true
426
- };
427
- stdout.write(JSON.stringify(out));
428
- return exit(0);
733
+ // Safety fallback: should never reach here, but if loop exits unexpectedly, deny
734
+ log('ERROR: Polling loop exited unexpectedly - this should not happen');
735
+ return process.stdout.write(JSON.stringify({
736
+ decision: 'deny',
737
+ reason: 'Internal error: polling loop exited unexpectedly'
738
+ }));
429
739
  })();