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.
- package/.claude/hooks/permission_request.mjs +326 -59
- package/.claude/hooks/post_tool_use.mjs +90 -0
- package/.claude/hooks/pre_tool_use.mjs +212 -293
- package/.claude/hooks/session-register.mjs +89 -104
- package/.claude/hooks/session_end.mjs +41 -42
- package/.claude/hooks/session_start.mjs +45 -60
- package/.claude/hooks/stop.mjs +752 -99
- package/.claude/hooks/user_prompt_submit.mjs +26 -3
- package/lib/cli/daemon-commands.js +1 -1
- package/lib/cli/teleport-commands.js +469 -0
- package/lib/daemon/daemon-v2.js +104 -0
- package/lib/daemon/lifecycle.js +56 -171
- package/lib/daemon/services/index.js +3 -0
- package/lib/daemon/services/polling-service.js +173 -0
- package/lib/daemon/services/queue-service.js +318 -0
- package/lib/daemon/services/session-service.js +115 -0
- package/lib/daemon/state.js +35 -0
- package/lib/daemon/task-executor-v2.js +413 -0
- package/lib/daemon/task-executor.js +270 -96
- package/lib/daemon/teleportation-daemon.js +709 -126
- package/lib/daemon/timeline-analyzer.js +215 -0
- package/lib/daemon/transcript-ingestion.js +696 -0
- package/lib/daemon/utils.js +91 -0
- package/lib/install/installer.js +184 -20
- package/lib/install/uhr-installer.js +136 -0
- package/lib/remote/providers/base-provider.js +46 -0
- package/lib/remote/providers/daytona-provider.js +58 -0
- package/lib/remote/providers/provider-factory.js +90 -19
- package/lib/remote/providers/sprites-provider.js +711 -0
- package/lib/teleport/exporters/claude-exporter.js +302 -0
- package/lib/teleport/exporters/gemini-exporter.js +307 -0
- package/lib/teleport/exporters/index.js +93 -0
- package/lib/teleport/exporters/interface.js +153 -0
- package/lib/teleport/fork-tracker.js +415 -0
- package/lib/teleport/git-committer.js +337 -0
- package/lib/teleport/index.js +48 -0
- package/lib/teleport/manager.js +620 -0
- package/lib/teleport/session-capture.js +282 -0
- package/package.json +6 -2
- package/teleportation-cli.cjs +488 -453
- package/.claude/hooks/heartbeat.mjs +0 -396
- 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
|
-
//
|
|
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/${
|
|
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
|
-
|
|
161
|
-
|
|
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 ${
|
|
261
|
-
const sessionResponse = await fetch(`${RELAY_API_URL}/api/sessions/${
|
|
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
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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(`
|
|
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
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
//
|
|
447
|
-
log('
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
})();
|