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.
- package/.claude/hooks/config-loader.mjs +88 -34
- package/.claude/hooks/permission_request.mjs +392 -82
- package/.claude/hooks/post_tool_use.mjs +90 -0
- package/.claude/hooks/pre_tool_use.mjs +247 -305
- package/.claude/hooks/session-register.mjs +94 -105
- 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/README.md +7 -0
- package/lib/auth/api-key.js +12 -0
- package/lib/auth/token-refresh.js +286 -0
- 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/response-classifier.js +15 -1
- 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 +1235 -0
- package/lib/daemon/teleportation-daemon.js +770 -25
- 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 +11 -5
- package/teleportation-cli.cjs +632 -451
- package/.claude/hooks/heartbeat.mjs +0 -396
- package/lib/daemon/agentic-executor.js +0 -803
- 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
|
-
//
|
|
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}`);
|
|
@@ -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,
|
|
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="${
|
|
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_${
|
|
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
|
|
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(`
|
|
437
|
+
log(`Created approval: ${approvalId}`);
|
|
285
438
|
} catch (e) {
|
|
286
|
-
log(`ERROR
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
//
|
|
404
|
-
log('
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
})();
|