teleportation-cli 1.1.5 → 1.2.1

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 (42) hide show
  1. package/.claude/hooks/permission_request.mjs +326 -59
  2. package/.claude/hooks/post_tool_use.mjs +90 -0
  3. package/.claude/hooks/pre_tool_use.mjs +212 -293
  4. package/.claude/hooks/session-register.mjs +89 -104
  5. package/.claude/hooks/session_end.mjs +41 -42
  6. package/.claude/hooks/session_start.mjs +45 -60
  7. package/.claude/hooks/stop.mjs +752 -99
  8. package/.claude/hooks/user_prompt_submit.mjs +26 -3
  9. package/lib/cli/daemon-commands.js +1 -1
  10. package/lib/cli/teleport-commands.js +469 -0
  11. package/lib/daemon/daemon-v2.js +104 -0
  12. package/lib/daemon/lifecycle.js +56 -171
  13. package/lib/daemon/services/index.js +3 -0
  14. package/lib/daemon/services/polling-service.js +173 -0
  15. package/lib/daemon/services/queue-service.js +318 -0
  16. package/lib/daemon/services/session-service.js +115 -0
  17. package/lib/daemon/state.js +35 -0
  18. package/lib/daemon/task-executor-v2.js +413 -0
  19. package/lib/daemon/task-executor.js +270 -96
  20. package/lib/daemon/teleportation-daemon.js +709 -126
  21. package/lib/daemon/timeline-analyzer.js +215 -0
  22. package/lib/daemon/transcript-ingestion.js +696 -0
  23. package/lib/daemon/utils.js +91 -0
  24. package/lib/install/installer.js +184 -20
  25. package/lib/install/uhr-installer.js +136 -0
  26. package/lib/remote/providers/base-provider.js +46 -0
  27. package/lib/remote/providers/daytona-provider.js +58 -0
  28. package/lib/remote/providers/provider-factory.js +90 -19
  29. package/lib/remote/providers/sprites-provider.js +711 -0
  30. package/lib/teleport/exporters/claude-exporter.js +302 -0
  31. package/lib/teleport/exporters/gemini-exporter.js +307 -0
  32. package/lib/teleport/exporters/index.js +93 -0
  33. package/lib/teleport/exporters/interface.js +153 -0
  34. package/lib/teleport/fork-tracker.js +415 -0
  35. package/lib/teleport/git-committer.js +337 -0
  36. package/lib/teleport/index.js +48 -0
  37. package/lib/teleport/manager.js +620 -0
  38. package/lib/teleport/session-capture.js +282 -0
  39. package/package.json +6 -2
  40. package/teleportation-cli.cjs +488 -453
  41. package/.claude/hooks/heartbeat.mjs +0 -396
  42. package/lib/daemon/pid-manager.js +0 -183
@@ -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}`);
@@ -254,11 +368,17 @@ const fetchJson = async (url, opts) => {
254
368
  log(`Warning: tool_use_id not provided, generated fallback: ${effective_tool_use_id}`);
255
369
  }
256
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
+
257
377
  // Fetch session to get owner_id (PRD-0018: Multi-tenancy)
258
378
  let owner_id = null;
259
379
  try {
260
- log(`Fetching session ${session_id} to get owner_id for approval`);
261
- const sessionResponse = await fetch(`${RELAY_API_URL}/api/sessions/${session_id}`, {
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}`, {
262
382
  headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
263
383
  });
264
384
 
@@ -278,7 +398,7 @@ const fetchJson = async (url, opts) => {
278
398
  let approvalId;
279
399
  try {
280
400
  const payload = {
281
- session_id,
401
+ session_id: approval_session_id, // Use parent session for autonomous tasks
282
402
  tool_name,
283
403
  tool_input,
284
404
  meta,
@@ -322,22 +442,64 @@ const fetchJson = async (url, opts) => {
322
442
  return process.stdout.write(JSON.stringify({ decision: 'ask', reason: `Remote approval request failed: ${e.message}` }));
323
443
  }
324
444
 
325
- // If user is PRESENT: return immediately and let Claude Code show its native prompt
326
- // The approval is now visible in mobile UI. If user approves locally:
327
- // - PostToolUse will invalidate the approval
328
- // - Mobile UI will filter it out (any timeline event after approval = stale)
329
- if (!isAway) {
330
- log(`User is present - approval ${approvalId} created for mobile visibility, letting Claude Code handle locally`);
331
- return exit(0);
332
- }
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...`);
333
467
 
334
- // User is AWAY - poll for remote approval decision
335
- log(`User is away - polling for remote approval decision (timeout=${FAST_APPROVAL_TIMEOUT_MS}ms)...`);
336
468
  let consecutiveFailures = 0;
337
469
  const MAX_CONSECUTIVE_FAILURES = 5;
338
470
 
339
- const deadline = Date.now() + FAST_APPROVAL_TIMEOUT_MS;
340
- 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
+ }
341
503
  try {
342
504
  const status = await fetchJson(`${RELAY_API_URL}/api/approvals/${approvalId}`, {
343
505
  headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
@@ -346,14 +508,103 @@ const fetchJson = async (url, opts) => {
346
508
 
347
509
  if (status.status === 'allowed') {
348
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
+
349
598
  // Acknowledge on the fast-path to prevent duplicate daemon execution.
350
599
  // PRD-0016: Implement 3-attempt exponential backoff for ACK
600
+ log('[TRACE] Starting ACK process');
351
601
  let ackSuccess = false;
352
602
  let ackAttempts = 0;
353
603
  const MAX_ACK_ATTEMPTS = 3;
354
604
 
355
605
  while (!ackSuccess && ackAttempts < MAX_ACK_ATTEMPTS) {
356
606
  ackAttempts++;
607
+ log(`[TRACE] ACK attempt ${ackAttempts}/${MAX_ACK_ATTEMPTS}`);
357
608
  try {
358
609
  const ackRes = await fetch(`${RELAY_API_URL}/api/approvals/${approvalId}/ack`, {
359
610
  method: 'POST',
@@ -361,12 +612,13 @@ const fetchJson = async (url, opts) => {
361
612
  });
362
613
  if (ackRes.ok) {
363
614
  ackSuccess = true;
615
+ log('[TRACE] ACK succeeded');
364
616
  } else {
365
617
  throw new Error(`ACK returned ${ackRes.status}`);
366
618
  }
367
619
  } catch (e) {
368
620
  const delay = Math.pow(2, ackAttempts) * 100;
369
- log(`Warning: ACK attempt ${ackAttempts} failed: ${e.message}. Retrying in ${delay}ms...`);
621
+ log(`[TRACE] ACK attempt ${ackAttempts} failed: ${e.message}. Retrying in ${delay}ms...`);
370
622
  if (ackAttempts < MAX_ACK_ATTEMPTS) {
371
623
  await sleep(delay);
372
624
  }
@@ -374,18 +626,50 @@ const fetchJson = async (url, opts) => {
374
626
  }
375
627
 
376
628
  if (!ackSuccess) {
377
- log(`ERROR: All ${MAX_ACK_ATTEMPTS} ACK attempts failed for ${approvalId}. Allowing locally anyway to prevent total stall, though duplicate execution risk exists.`);
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
+ }
378
659
  }
379
660
 
661
+ log('[TRACE] Returning allow decision to Claude Code');
380
662
  const out = {
381
663
  hookSpecificOutput: {
382
664
  hookEventName: 'PermissionRequest',
383
- permissionDecision: 'allow',
384
- permissionDecisionReason: 'Approved remotely via Teleportation'
385
- },
386
- suppressOutput: true
665
+ decision: {
666
+ behavior: 'allow'
667
+ }
668
+ }
387
669
  };
388
- 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)');
389
673
  return exit(0);
390
674
  }
391
675
 
@@ -400,8 +684,9 @@ const fetchJson = async (url, opts) => {
400
684
  'Authorization': `Bearer ${RELAY_API_KEY}`
401
685
  },
402
686
  body: JSON.stringify({
403
- session_id,
687
+ session_id: approval_session_id,
404
688
  type: 'tool_denied',
689
+ source,
405
690
  data: {
406
691
  tool_name,
407
692
  tool_input: tool_input || {},
@@ -418,12 +703,14 @@ const fetchJson = async (url, opts) => {
418
703
  const out = {
419
704
  hookSpecificOutput: {
420
705
  hookEventName: 'PermissionRequest',
421
- permissionDecision: 'deny',
422
- permissionDecisionReason: 'Denied remotely via Teleportation'
423
- },
424
- suppressOutput: true
706
+ decision: {
707
+ behavior: 'deny',
708
+ message: status.decision_reason || 'Denied remotely via Teleportation'
709
+ }
710
+ }
425
711
  };
426
- stdout.write(JSON.stringify(out));
712
+ stdout.write(JSON.stringify(out) + '\n');
713
+ log('[TRACE] Wrote deny decision to stdout with newline');
427
714
  return exit(0);
428
715
  }
429
716
 
@@ -443,30 +730,10 @@ const fetchJson = async (url, opts) => {
443
730
  await sleep(POLLING_INTERVAL_MS);
444
731
  }
445
732
 
446
- // Timeout while user is away - hand off to daemon
447
- log('Fast-path approval timeout - handing off to daemon');
448
-
449
- try {
450
- await fetch(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
451
- method: 'PATCH',
452
- headers: {
453
- 'Content-Type': 'application/json',
454
- 'Authorization': `Bearer ${RELAY_API_KEY}`
455
- },
456
- body: JSON.stringify({ last_approval_location: 'daemon_handoff', is_away: true })
457
- });
458
- } catch (e) {
459
- log(`Warning: Failed to update daemon-state for handoff: ${e.message}`);
460
- }
461
-
462
- const out = {
463
- hookSpecificOutput: {
464
- hookEventName: 'PermissionRequest',
465
- permissionDecision: 'deny',
466
- permissionDecisionReason: '⏳ Teleportation: waiting for mobile approval timed out. This request was handed off to the daemon and will run in the background once approved.'
467
- },
468
- suppressOutput: true
469
- };
470
- stdout.write(JSON.stringify(out));
471
- 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
+ }));
472
739
  })();
@@ -15,6 +15,56 @@
15
15
 
16
16
  import { stdin, stdout, exit, env } from 'node:process';
17
17
  import { appendFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { join, dirname } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ // Get __dirname equivalent for ES modules
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = dirname(__filename);
25
+
26
+ /**
27
+ * Try to import transcript-ingestion from multiple possible paths
28
+ * This makes the hook work in both development and installed contexts
29
+ *
30
+ * IMPORTANT: Only catches module-not-found errors. Syntax errors, runtime
31
+ * errors, and other exceptions are re-thrown immediately for debugging.
32
+ */
33
+ async function tryImportTranscriptIngestion() {
34
+ const possiblePaths = [
35
+ // If installed globally to ~/.claude/hooks/, lib is in ~/.teleportation/lib/daemon/
36
+ () => join(homedir(), '.teleportation', 'lib', 'daemon', 'transcript-ingestion.js'),
37
+ // If hook is still in project directory
38
+ () => join(process.cwd(), 'lib', 'daemon', 'transcript-ingestion.js'),
39
+ // Try relative to hook location (development)
40
+ () => join(__dirname, '..', '..', 'lib', 'daemon', 'transcript-ingestion.js')
41
+ ];
42
+
43
+ let lastError;
44
+
45
+ for (const getPath of possiblePaths) {
46
+ const modulePath = getPath();
47
+ try {
48
+ const module = await import(modulePath);
49
+ return module;
50
+ } catch (e) {
51
+ lastError = e;
52
+
53
+ // Only skip module-not-found errors - try next path
54
+ // Re-throw syntax errors, runtime errors, reference errors immediately
55
+ if (e.code === 'ERR_MODULE_NOT_FOUND' || e.code === 'ENOENT') {
56
+ continue; // Module doesn't exist at this path, try next
57
+ }
58
+
59
+ // Syntax error, runtime error, etc. - throw immediately with context
60
+ console.error(`[post_tool_use] Failed to import transcript-ingestion from ${modulePath}:`, e.message);
61
+ throw e;
62
+ }
63
+ }
64
+
65
+ // If we get here, module wasn't found in any location
66
+ throw new Error(`Could not find transcript-ingestion module in any expected location. Last error: ${lastError?.message || 'unknown'}`);
67
+ }
18
68
 
19
69
  const readStdin = () => new Promise((resolve, reject) => {
20
70
  let data = '';
@@ -207,6 +257,10 @@ function buildErrorMessage(tool_output) {
207
257
  const { session_id, tool_name, tool_input, tool_output, tool_use_id } = input || {};
208
258
  log(`Session: ${session_id}, Tool: ${tool_name}, tool_use_id: ${tool_use_id || 'none'}`);
209
259
 
260
+ // Detect message source
261
+ const isAutonomousTask = env.TELEPORTATION_TASK_MODE === 'true' || !!env.TELEPORTATION_PARENT_SESSION_ID;
262
+ const source = isAutonomousTask ? 'autonomous_task' : 'cli_interactive';
263
+
210
264
  // Validate session_id
211
265
  if (!isValidSessionId(session_id)) {
212
266
  log(`ERROR: Invalid session_id format: ${session_id}`);
@@ -339,6 +393,7 @@ function buildErrorMessage(tool_output) {
339
393
  body: JSON.stringify({
340
394
  session_id,
341
395
  type: completionEventType,
396
+ source,
342
397
  data: completionEventData
343
398
  })
344
399
  });
@@ -362,6 +417,7 @@ function buildErrorMessage(tool_output) {
362
417
  body: JSON.stringify({
363
418
  session_id,
364
419
  type: 'tool_error',
420
+ source,
365
421
  data: {
366
422
  tool_name,
367
423
  tool_input,
@@ -387,6 +443,7 @@ function buildErrorMessage(tool_output) {
387
443
  body: JSON.stringify({
388
444
  session_id,
389
445
  type: 'tool_executed',
446
+ source,
390
447
  data: {
391
448
  tool_name,
392
449
  tool_input,
@@ -401,6 +458,39 @@ function buildErrorMessage(tool_output) {
401
458
  log(`Failed to record legacy tool_executed: ${e.message}`);
402
459
  }
403
460
 
461
+ // =========================================================================
462
+ // Real-time transcript ingestion after each tool execution
463
+ // Ensures timeline stays in sync with assistant responses
464
+ // =========================================================================
465
+
466
+ try {
467
+ // Import transcript ingestion from multiple possible locations (works in dev + installed contexts)
468
+ const transcriptModule = await tryImportTranscriptIngestion();
469
+ const { ingestTranscriptToTimeline } = transcriptModule;
470
+
471
+ const claude_session_id = env.CLAUDE_SESSION_ID || session_id;
472
+ const cwd = env.TELEPORTATION_CWD || process.cwd();
473
+
474
+ // Use real-time mode flag to only process recent events (max 10)
475
+ await ingestTranscriptToTimeline({
476
+ claude_session_id,
477
+ parent_session_id: session_id,
478
+ task_id: null,
479
+ cwd,
480
+ config: {
481
+ relayApiUrl: RELAY_API_URL,
482
+ apiKey: RELAY_API_KEY
483
+ },
484
+ realTimeMode: true, // Fast mode - only recent events
485
+ maxEvents: 10 // Limit to 10 most recent events (prevents blocking hook)
486
+ });
487
+
488
+ log(`Real-time transcript ingestion completed`);
489
+ } catch (e) {
490
+ log(`Real-time transcript ingestion failed (non-fatal): ${e.message}`);
491
+ // Don't fail the hook if ingestion fails - this is a best-effort feature
492
+ }
493
+
404
494
  // PostToolUse hooks don't need to output anything
405
495
  return exit(0);
406
496
  })();