teleportation-cli 1.3.0 → 1.4.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.
@@ -17,13 +17,10 @@ const readStdin = () => new Promise((resolve, reject) => {
17
17
  stdin.on('error', reject);
18
18
  });
19
19
 
20
- const sleep = (ms) => new Promise(r => setTimeout(r, ms));
21
-
22
- // Lazy-load metadata extraction
20
+ // Lazy-load metadata extraction (only used on slow path)
23
21
  let extractSessionMetadata = null;
24
- async function getSessionMetadata(cwd) {
22
+ async function getSessionMetadata(cwd, hookInput = null) {
25
23
  if (!extractSessionMetadata) {
26
- // Try multiple paths for the metadata module
27
24
  const possiblePaths = [
28
25
  join(__dirname, '..', '..', 'lib', 'session', 'metadata.js'),
29
26
  join(homedir(), '.teleportation', 'lib', 'session', 'metadata.js'),
@@ -43,12 +40,41 @@ async function getSessionMetadata(cwd) {
43
40
  if (!extractSessionMetadata) return {};
44
41
 
45
42
  try {
46
- return await extractSessionMetadata(cwd);
43
+ return await extractSessionMetadata(cwd, hookInput);
47
44
  } catch (e) {
48
45
  return {};
49
46
  }
50
47
  }
51
48
 
49
+ // Lazy-load event data sanitizer (redact API keys, tokens, passwords from timeline events)
50
+ let _sanitizeEventData = null;
51
+ async function loadEventSanitizer() {
52
+ if (_sanitizeEventData) return _sanitizeEventData;
53
+
54
+ const possiblePaths = [
55
+ join(__dirname, '..', '..', 'lib', 'utils', 'log-sanitizer.js'),
56
+ join(homedir(), '.teleportation', 'lib', 'utils', 'log-sanitizer.js'),
57
+ ];
58
+
59
+ for (const path of possiblePaths) {
60
+ try {
61
+ const mod = await import(path);
62
+ if (mod.sanitizeEventData) {
63
+ _sanitizeEventData = mod.sanitizeEventData;
64
+ return _sanitizeEventData;
65
+ }
66
+ } catch {
67
+ continue;
68
+ }
69
+ }
70
+
71
+ // Fallback: identity function (don't block tool execution if sanitizer unavailable)
72
+ // Server-side sanitization in relay provides defense-in-depth
73
+ log('Warning: sanitizeEventData not found, using identity fallback (relay-side sanitization still active)');
74
+ _sanitizeEventData = (data) => data;
75
+ return _sanitizeEventData;
76
+ }
77
+
52
78
  const fetchJson = async (url, opts) => {
53
79
  const res = await fetch(url, opts);
54
80
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -73,20 +99,32 @@ const withTimeout = async (promiseFactory, timeoutMs, label) => {
73
99
  }
74
100
  };
75
101
 
102
+ // Helper: POST a timeline event (reused in fast and slow paths)
103
+ function postTimeline(relayUrl, apiKey, body) {
104
+ return fetch(`${relayUrl}/api/timeline`, {
105
+ method: 'POST',
106
+ headers: {
107
+ 'Content-Type': 'application/json',
108
+ 'Authorization': `Bearer ${apiKey}`
109
+ },
110
+ body: JSON.stringify(body)
111
+ });
112
+ }
113
+
76
114
  (async () => {
77
- // Debug: Log hook invocation
115
+ const hookStart = Date.now();
116
+
78
117
  const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-hook.log';
79
118
  const log = (msg) => {
80
119
  const timestamp = new Date().toISOString();
81
- const logMsg = `[${timestamp}] ${msg}\n`;
82
120
  try {
83
- appendFileSync(hookLogFile, logMsg);
121
+ appendFileSync(hookLogFile, `[${timestamp}] ${msg}\n`);
84
122
  } catch (e) {
85
- // Silently ignore log failures - don't write to stderr as it shows in UI
123
+ // Silently ignore log failures
86
124
  }
87
125
  };
88
126
 
89
- // Read and parse stdin to get hook input
127
+ // Read and parse stdin
90
128
  let input = {};
91
129
  try {
92
130
  const raw = await readStdin();
@@ -96,6 +134,8 @@ const withTimeout = async (promiseFactory, timeoutMs, label) => {
96
134
  }
97
135
 
98
136
  let { session_id, tool_name, tool_input } = input || {};
137
+ // Cursor sends conversation_id in preToolUse instead of session_id
138
+ session_id = session_id || input?.conversation_id;
99
139
  tool_input = tool_input && typeof tool_input === 'object' ? tool_input : {};
100
140
  let claude_session_id = session_id; // Keep original ID
101
141
  const hookStartMs = Date.now();
@@ -107,56 +147,49 @@ const withTimeout = async (promiseFactory, timeoutMs, label) => {
107
147
  return Math.max(200, Math.min(PRE_TOOL_NETWORK_TIMEOUT_MS, remaining - 100));
108
148
  };
109
149
 
110
- // Detect message source
111
- const isAutonomousTask = env.TELEPORTATION_TASK_MODE === 'true' || !!env.TELEPORTATION_PARENT_SESSION_ID;
112
- const source = isAutonomousTask ? 'autonomous_task' : 'cli_interactive';
150
+ // Detect client: Cursor injects cursor_version in all hook inputs
151
+ const client = input?.cursor_version ? 'cursor' : 'claude-code';
152
+ const source = client === 'cursor' ? 'cursor_agent' : 'cli_interactive';
113
153
 
114
- // 1. Recursion Guard (Critical Stability Fix)
115
- // Prevent infinite hook-triggered tool loops
116
- // Whitelist safe tools at higher depths to avoid breaking workflows
154
+ // 1. Recursion Guard
117
155
  const SAFE_TOOLS = ['read', 'glob', 'grep', 'websearch', 'bashoutput', 'ls', 'pwd', 'git status'];
118
156
  const RECURSION_DEPTH = parseInt(env.TELEPORTATION_HOOK_DEPTH || '0', 10) || 0;
119
-
157
+
120
158
  if (RECURSION_DEPTH > 5 && !SAFE_TOOLS.includes(tool_name?.toLowerCase())) {
121
- log(`[RECURSION] Depth limit reached (${RECURSION_DEPTH}) for tool "${tool_name}", auto-allowing to prevent infinite loop.`);
159
+ log(`[RECURSION] Depth limit reached (${RECURSION_DEPTH}) for tool "${tool_name}", auto-allowing.`);
122
160
  process.stdout.write(JSON.stringify({ decision: 'allow', reason: 'Recursion guard depth limit' }));
123
161
  return exit(0);
124
162
  }
125
163
 
126
- // Update environment for child processes
127
164
  process.env.TELEPORTATION_HOOK_DEPTH = (RECURSION_DEPTH + 1).toString();
128
165
 
129
166
  log(`Session ID: ${session_id}, Tool: ${tool_name}, Input: ${JSON.stringify(tool_input).substring(0, 100)}`);
130
167
 
131
- // Check for /away and /back commands (user typing in Claude Code)
132
- // These are special commands to toggle away mode
168
+ // 2. Detect /away and /back commands
133
169
  const command = tool_input?.command || tool_input?.text || '';
134
170
  if (typeof command === 'string') {
135
171
  const trimmedCmd = command.trim().toLowerCase();
136
- // Support multiple command formats: /away, teleportation away, teleport away, teleporation away (typo)
137
172
  if (trimmedCmd === '/away' ||
138
173
  trimmedCmd === 'teleportation away' ||
139
174
  trimmedCmd === 'teleport away' ||
140
175
  trimmedCmd === 'teleporation away') {
141
- log('Detected /away command - setting away mode');
142
- // Will set away mode after loading config
176
+ log('Detected /away command');
143
177
  tool_input.__teleportation_away = true;
144
178
  } else if (trimmedCmd === '/back' ||
145
179
  trimmedCmd === 'teleportation back' ||
146
180
  trimmedCmd === 'teleport back' ||
147
181
  trimmedCmd === 'teleporation back') {
148
- log('Detected /back command - clearing away mode');
182
+ log('Detected /back command');
149
183
  tool_input.__teleportation_back = true;
150
184
  }
151
185
  }
152
186
 
153
- // Load config from encrypted credentials, legacy config file, or env vars
187
+ // 3. Load config (always needed for API URLs)
154
188
  let config;
155
189
  try {
156
190
  const { loadConfig } = await import('./config-loader.mjs');
157
191
  config = await loadConfig();
158
192
  } catch (e) {
159
- // Fallback to environment variables if config loader fails
160
193
  config = {
161
194
  relayApiUrl: env.RELAY_API_URL || '',
162
195
  relayApiKey: env.RELAY_API_KEY || '',
@@ -164,8 +197,7 @@ const withTimeout = async (promiseFactory, timeoutMs, label) => {
164
197
  };
165
198
  }
166
199
 
167
- // Prioritize environment variables over config file (for testing)
168
- const SLACK_WEBHOOK_URL = env.SLACK_WEBHOOK_URL || config.slackWebhookUrl || '';
200
+ // Derive constants
169
201
  const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
170
202
  const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
171
203
  const DAEMON_PORT = config.daemonPort || env.TELEPORTATION_DAEMON_PORT || '3050';
@@ -193,186 +225,6 @@ const withTimeout = async (promiseFactory, timeoutMs, label) => {
193
225
  // All tool requests are sent to the remote approval system so the user
194
226
  // can approve/deny from their mobile device. This enables true remote control.
195
227
 
196
- // Register session: relay first (source of truth), then daemon
197
- const cwd = process.cwd();
198
- const meta = await getSessionMetadata(cwd);
199
- log(`Session metadata: project=${meta.project_name}, hostname=${meta.hostname}, branch=${meta.current_branch}, model=${meta.current_model || 'default'}`);
200
-
201
- // Detect model change (PRD-0014)
202
- // Store model in ~/.teleportation/sessions/ to persist across reboots
203
- const sessionDir = join(homedir(), '.teleportation', 'sessions');
204
- if (!fs.existsSync(sessionDir)) {
205
- fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
206
- }
207
- const modelFile = join(sessionDir, `model_${session_id}.txt`);
208
- let modelChanged = false;
209
- try {
210
- const { readFile, writeFile } = await import('fs/promises');
211
- let lastModel = null;
212
- try {
213
- lastModel = (await readFile(modelFile, 'utf8')).trim();
214
- } catch (e) {
215
- // File doesn't exist yet - first tool use
216
- }
217
-
218
- if (lastModel && meta.current_model && lastModel !== meta.current_model) {
219
- modelChanged = true;
220
- log(`Model changed detected: ${lastModel} -> ${meta.current_model}`);
221
-
222
- // Log model change to timeline
223
- if (RELAY_API_URL && RELAY_API_KEY && hasBudget()) {
224
- try {
225
- const timeoutMs = getRequestTimeoutMs();
226
- if (timeoutMs > 0) {
227
- await withTimeout(
228
- (signal) => fetch(`${RELAY_API_URL}/api/timeline`, {
229
- method: 'POST',
230
- headers: {
231
- 'Content-Type': 'application/json',
232
- 'Authorization': `Bearer ${RELAY_API_KEY}`
233
- },
234
- body: JSON.stringify({
235
- session_id,
236
- type: 'model_changed',
237
- source,
238
- data: {
239
- previous_model: lastModel,
240
- new_model: meta.current_model,
241
- timestamp: Date.now()
242
- }
243
- }),
244
- signal
245
- }),
246
- timeoutMs,
247
- 'model change timeline post'
248
- );
249
- }
250
- log(`Model change logged to timeline`);
251
- } catch (e) {
252
- log(`Failed to log model change: ${e.message}`);
253
- }
254
- }
255
- }
256
-
257
- // Update last known model
258
- if (meta.current_model) {
259
- await writeFile(modelFile, meta.current_model, { mode: 0o600 });
260
- }
261
- } catch (e) {
262
- log(`Model change detection error: ${e.message}`);
263
- }
264
-
265
- // 1. Register with relay first - makes session visible in mobile UI
266
- if (session_id && RELAY_API_URL && RELAY_API_KEY && hasBudget()) {
267
- try {
268
- log(`Registering session with relay: ${session_id}`);
269
- const { ensureSessionRegistered } = await import('./session-register.mjs');
270
- const timeoutMs = getRequestTimeoutMs();
271
- if (timeoutMs <= 0) {
272
- log('Skipping relay registration due to exhausted hook budget');
273
- }
274
- const regResult = timeoutMs > 0
275
- ? await withTimeout(
276
- () => ensureSessionRegistered(session_id, cwd, config),
277
- timeoutMs,
278
- 'relay session registration'
279
- )
280
- : false;
281
-
282
- if (typeof regResult === 'object' && regResult.error === 'orphan_api_key') {
283
- console.error(`\n⚠️ Teleportation: ${regResult.message || 'API key not linked to user.'}`);
284
- console.error(' Visit https://app.teleportation.dev/api-keys to claim your key.\n');
285
- } else {
286
- log(`Session registered with relay successfully`);
287
- }
288
-
289
- // If model changed, update session metadata immediately
290
- if (modelChanged && hasBudget() && (regResult === true || (typeof regResult === 'object' && regResult.success))) {
291
- const { updateSessionMetadata } = await import('./session-register.mjs');
292
- const timeoutMs = getRequestTimeoutMs();
293
- if (timeoutMs > 0) {
294
- await withTimeout(
295
- () => updateSessionMetadata(session_id, cwd, config),
296
- timeoutMs,
297
- 'session metadata update'
298
- );
299
- }
300
- log(`Session metadata updated with new model`);
301
- }
302
- } catch (e) {
303
- log(`Warning: Failed to register session with relay: ${e.message}`);
304
- }
305
- }
306
-
307
- // 2. Then register with daemon (local infrastructure for this session)
308
- if (session_id && DAEMON_ENABLED && hasBudget()) {
309
- try {
310
- const daemonUrl = `http://127.0.0.1:${DAEMON_PORT}`;
311
- log(`Registering session with daemon: ${session_id}`);
312
-
313
- const timeoutMs = getRequestTimeoutMs();
314
- const res = timeoutMs > 0
315
- ? await withTimeout(
316
- (signal) => fetch(`${daemonUrl}/sessions/register`, {
317
- method: 'POST',
318
- headers: { 'Content-Type': 'application/json' },
319
- body: JSON.stringify({ session_id, claude_session_id, cwd, meta }),
320
- signal
321
- }),
322
- timeoutMs,
323
- 'daemon session registration'
324
- ).catch(e => {
325
- log(`Daemon registration fetch error: ${e.message}`);
326
- return null;
327
- })
328
- : null;
329
- if (res && res.ok) {
330
- log(`Session registered with daemon successfully`);
331
- } else if (res) {
332
- log(`Daemon registration returned status ${res.status}`);
333
- } else if (timeoutMs <= 0) {
334
- log('Skipping daemon registration due to exhausted hook budget');
335
- }
336
- } catch (e) {
337
- log(`Warning: Failed to register session with daemon: ${e.message}`);
338
- }
339
- }
340
-
341
- // 3. Log tool_use event to timeline (before execution)
342
- // This shows what Claude is attempting to do
343
- if (session_id && RELAY_API_URL && RELAY_API_KEY && tool_name && hasBudget()) {
344
- try {
345
- const timeoutMs = getRequestTimeoutMs();
346
- if (timeoutMs > 0) {
347
- await withTimeout(
348
- (signal) => fetch(`${RELAY_API_URL}/api/timeline`, {
349
- method: 'POST',
350
- headers: {
351
- 'Content-Type': 'application/json',
352
- 'Authorization': `Bearer ${RELAY_API_KEY}`
353
- },
354
- body: JSON.stringify({
355
- session_id,
356
- type: 'tool_use',
357
- source,
358
- data: {
359
- tool_name,
360
- tool_input: tool_input || {},
361
- timestamp: Date.now()
362
- }
363
- }),
364
- signal
365
- }),
366
- timeoutMs,
367
- 'tool_use timeline post'
368
- );
369
- }
370
- log(`Logged tool_use event for ${tool_name}`);
371
- } catch (e) {
372
- log(`Failed to log tool_use: ${e.message}`);
373
- }
374
- }
375
-
376
228
  // NOTE: Context delivery via notification has been removed.
377
229
  // With parent session context resumption (PR #123), autonomous tasks now
378
230
  // automatically resume the parent session's conversation context on turn 1.
@@ -411,49 +263,27 @@ const withTimeout = async (promiseFactory, timeoutMs, label) => {
411
263
  }
412
264
  };
413
265
 
266
+ // 4. Handle /away and /back (early exit, needs config only)
414
267
  if (tool_input.__teleportation_away) {
415
268
  await updateSessionState({ is_away: true });
416
269
  // Log away_mode_changed event to timeline
417
270
  if (RELAY_API_URL && RELAY_API_KEY && hasBudget()) {
418
271
  try {
419
- const timeoutMs = getRequestTimeoutMs();
420
- if (timeoutMs > 0) {
421
- await withTimeout(
422
- (signal) => fetch(`${RELAY_API_URL}/api/timeline`, {
423
- method: 'POST',
424
- headers: {
425
- 'Content-Type': 'application/json',
426
- 'Authorization': `Bearer ${RELAY_API_KEY}`
427
- },
428
- body: JSON.stringify({
429
- session_id,
430
- type: 'away_mode_changed',
431
- source,
432
- data: {
433
- is_away: true,
434
- timestamp: Date.now()
435
- }
436
- }),
437
- signal
438
- }),
439
- timeoutMs,
440
- 'away-mode timeline post'
441
- );
442
- }
272
+ await postTimeline(RELAY_API_URL, RELAY_API_KEY, {
273
+ session_id, type: 'away_mode_changed', source,
274
+ data: { is_away: true, timestamp: Date.now() }
275
+ });
443
276
  log(`Logged away_mode_changed (away=true) to timeline`);
444
- } catch (e) {
445
- log(`Failed to log away_mode_changed: ${e.message}`);
446
- }
277
+ } catch (e) { log(`Failed to log away_mode_changed: ${e.message}`); }
447
278
  }
448
- const out = {
279
+ stdout.write(JSON.stringify({
449
280
  hookSpecificOutput: {
450
281
  hookEventName: 'PreToolUse',
451
282
  permissionDecision: 'deny',
452
283
  permissionDecisionReason: '✅ Teleportation: Away mode enabled.'
453
284
  },
454
285
  suppressOutput: true
455
- };
456
- stdout.write(JSON.stringify(out));
286
+ }));
457
287
  return exit(0);
458
288
  }
459
289
 
@@ -462,68 +292,204 @@ const withTimeout = async (promiseFactory, timeoutMs, label) => {
462
292
  // Log away_mode_changed event to timeline
463
293
  if (RELAY_API_URL && RELAY_API_KEY && hasBudget()) {
464
294
  try {
465
- const timeoutMs = getRequestTimeoutMs();
466
- if (timeoutMs > 0) {
467
- await withTimeout(
468
- (signal) => fetch(`${RELAY_API_URL}/api/timeline`, {
469
- method: 'POST',
470
- headers: {
471
- 'Content-Type': 'application/json',
472
- 'Authorization': `Bearer ${RELAY_API_KEY}`
473
- },
474
- body: JSON.stringify({
475
- session_id,
476
- type: 'away_mode_changed',
477
- source,
478
- data: {
479
- is_away: false,
480
- timestamp: Date.now()
481
- }
482
- }),
483
- signal
484
- }),
485
- timeoutMs,
486
- 'away-mode timeline post'
487
- );
488
- }
295
+ await postTimeline(RELAY_API_URL, RELAY_API_KEY, {
296
+ session_id, type: 'away_mode_changed', source,
297
+ data: { is_away: false, timestamp: Date.now() }
298
+ });
489
299
  log(`Logged away_mode_changed (away=false) to timeline`);
490
- } catch (e) {
491
- log(`Failed to log away_mode_changed: ${e.message}`);
492
- }
300
+ } catch (e) { log(`Failed to log away_mode_changed: ${e.message}`); }
493
301
  }
494
- const out = {
302
+ stdout.write(JSON.stringify({
495
303
  hookSpecificOutput: {
496
304
  hookEventName: 'PreToolUse',
497
305
  permissionDecision: 'deny',
498
306
  permissionDecisionReason: '✅ Teleportation: Away mode disabled.'
499
307
  },
500
308
  suppressOutput: true
501
- };
502
- stdout.write(JSON.stringify(out));
309
+ }));
503
310
  return exit(0);
504
311
  }
505
312
 
506
- // SMART AWAY MODE: Auto-mark as present ("back") on any activity
507
- // If the user is typing commands locally, they are clearly not away.
508
- // Note: Approval invalidation is handled by PermissionRequest hook
509
- // to avoid race conditions and duplicate API calls
510
-
511
- // PreToolUse now only handles:
512
- // 1. Session registration with daemon
513
- // 2. Checking for pending daemon results
514
- // 3. Marking user as present (not away)
515
- //
516
- // Remote approvals are handled by PermissionRequest hook
517
- // Tool execution logging is handled by PostToolUse hook
518
-
519
- log(`PreToolUse complete for ${tool_name} - letting Claude Code proceed`);
520
-
521
- // Don't output anything - let Claude Code handle permissions with its own system
522
- // The PermissionRequest hook will handle remote approvals if user is away
523
- // The PostToolUse hook will record tool executions to the timeline
313
+ // 5. Check if session is already registered (marker file from first call)
314
+ // This is the key optimization: skip metadata extraction, registration,
315
+ // and daemon calls on subsequent tool uses.
316
+ // The marker file is created by session-register.mjs after successful relay
317
+ // registration. If registration fails, the marker won't exist and subsequent
318
+ // calls will continue taking the slow path until registration succeeds.
319
+ let sessionAlreadyRegistered = false;
320
+ if (session_id) {
321
+ const registrationMarker = join(tmpdir(), `teleportation-session-${session_id}.registered`);
322
+ try {
323
+ fs.accessSync(registrationMarker);
324
+ sessionAlreadyRegistered = true;
325
+ } catch {}
326
+ }
327
+
328
+ if (sessionAlreadyRegistered) {
329
+ // ═══════════════════════════════════════════════════════════════════
330
+ // FAST PATH: Session registered. Skip metadata extraction (6+ git
331
+ // subprocesses), skip relay/daemon registration, skip model file I/O.
332
+ // Only do: lightweight model check + timeline POST.
333
+ // ═══════════════════════════════════════════════════════════════════
334
+
335
+ // Lightweight model check (env vars + settings.json, no git calls)
336
+ try {
337
+ const sessionDir = join(homedir(), '.teleportation', 'sessions');
338
+ const modelFile = join(sessionDir, `model_${session_id}.txt`);
339
+
340
+ let lastModel = null;
341
+ try { lastModel = fs.readFileSync(modelFile, 'utf8').trim(); } catch {}
342
+
343
+ // Read current model from env vars, then settings.json (no git needed)
344
+ let currentModel = env.ANTHROPIC_MODEL || env.CLAUDE_MODEL || null;
345
+ if (!currentModel) {
346
+ try {
347
+ const settings = JSON.parse(fs.readFileSync(join(homedir(), '.claude', 'settings.json'), 'utf8'));
348
+ currentModel = settings.model || null;
349
+ } catch {}
350
+ }
351
+
352
+ if (lastModel && currentModel && lastModel !== currentModel) {
353
+ log(`[FastPath] Model changed: ${lastModel} -> ${currentModel}`);
354
+ if (RELAY_API_URL && RELAY_API_KEY) {
355
+ await postTimeline(RELAY_API_URL, RELAY_API_KEY, {
356
+ session_id, type: 'model_changed', source,
357
+ data: { previous_model: lastModel, new_model: currentModel, timestamp: Date.now() }
358
+ }).catch(e => log(`Failed to log model change: ${e.message}`));
359
+ }
360
+ try { fs.writeFileSync(modelFile, currentModel, { mode: 0o600 }); } catch {}
361
+ }
362
+ } catch (e) {
363
+ log(`[FastPath] Model check error: ${e.message}`);
364
+ }
365
+
366
+ // Log tool_use to timeline (sanitize to redact secrets from tool_input)
367
+ if (session_id && RELAY_API_URL && RELAY_API_KEY && tool_name) {
368
+ try {
369
+ const sanitize = await loadEventSanitizer();
370
+ await postTimeline(RELAY_API_URL, RELAY_API_KEY, {
371
+ session_id, type: 'tool_use', source,
372
+ data: sanitize({ tool_name, tool_input: tool_input || {}, timestamp: Date.now() })
373
+ });
374
+ } catch (e) { log(`Failed to log tool_use: ${e.message}`); }
375
+ }
376
+
377
+ log(`PreToolUse complete [fast path, ${Date.now() - hookStart}ms] for ${tool_name}`);
378
+ return exit(0);
379
+ }
380
+
381
+ // ═══════════════════════════════════════════════════════════════════════
382
+ // SLOW PATH: First tool call in session. Extract metadata, register
383
+ // session with relay and daemon, detect model changes.
384
+ // ═══════════════════════════════════════════════════════════════════════
385
+ log(`[SlowPath] First tool call, registering session ${session_id}`);
386
+
387
+ // Cursor provides workspace_roots; Claude Code provides cwd.
388
+ // Fall back to process.cwd() when hook payload doesn't include either.
389
+ const cwd = input?.cwd || (input?.workspace_roots?.[0]) || process.cwd();
390
+ const meta = await getSessionMetadata(cwd, input);
391
+ log(`Session metadata: project=${meta.project_name}, hostname=${meta.hostname}, branch=${meta.current_branch}, model=${meta.current_model || 'default'}`);
392
+
393
+ // Model change detection (full version via metadata)
394
+ const sessionDir = join(homedir(), '.teleportation', 'sessions');
395
+ if (!fs.existsSync(sessionDir)) {
396
+ fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
397
+ }
398
+ const modelFile = join(sessionDir, `model_${session_id}.txt`);
399
+ let modelChanged = false;
400
+ try {
401
+ const { readFile, writeFile } = await import('fs/promises');
402
+ let lastModel = null;
403
+ try {
404
+ lastModel = (await readFile(modelFile, 'utf8')).trim();
405
+ } catch (e) {
406
+ // File doesn't exist yet - first tool use
407
+ }
408
+
409
+ if (lastModel && meta.current_model && lastModel !== meta.current_model) {
410
+ modelChanged = true;
411
+ log(`Model changed detected: ${lastModel} -> ${meta.current_model}`);
412
+ if (RELAY_API_URL && RELAY_API_KEY) {
413
+ try {
414
+ await postTimeline(RELAY_API_URL, RELAY_API_KEY, {
415
+ session_id, type: 'model_changed', source,
416
+ data: { previous_model: lastModel, new_model: meta.current_model, timestamp: Date.now() }
417
+ });
418
+ } catch (e) { log(`Failed to log model change: ${e.message}`); }
419
+ }
420
+ }
421
+
422
+ if (meta.current_model) {
423
+ await writeFile(modelFile, meta.current_model, { mode: 0o600 });
424
+ }
425
+ } catch (e) {
426
+ log(`Model change detection error: ${e.message}`);
427
+ }
428
+
429
+ // Register with relay (pass pre-extracted metadata to avoid double extraction)
430
+ if (session_id && RELAY_API_URL && RELAY_API_KEY) {
431
+ try {
432
+ log(`Registering session with relay: ${session_id}`);
433
+ const { ensureSessionRegistered, updateSessionMetadata } = await import('./session-register.mjs');
434
+ const regResult = await ensureSessionRegistered(session_id, cwd, config, meta);
435
+
436
+ if (typeof regResult === 'object' && regResult.error === 'orphan_api_key') {
437
+ console.error(`\n⚠️ Teleportation: ${regResult.message || 'API key not linked to user.'}`);
438
+ console.error(' Visit https://app.teleportation.dev/api-keys to claim your key.\n');
439
+ } else {
440
+ log(`Session registered with relay successfully`);
441
+ }
442
+
443
+ // If model changed, update session metadata
444
+ if (modelChanged && (regResult === true || (typeof regResult === 'object' && regResult.success))) {
445
+ await updateSessionMetadata(session_id, cwd, config);
446
+ log(`Session metadata updated with new model`);
447
+ }
448
+ } catch (e) {
449
+ log(`Warning: Failed to register session with relay: ${e.message}`);
450
+ }
451
+ }
452
+
453
+ // Register with daemon
454
+ if (session_id && DAEMON_ENABLED) {
455
+ try {
456
+ const daemonUrl = `http://127.0.0.1:${DAEMON_PORT}`;
457
+ log(`Registering session with daemon: ${session_id}`);
458
+ const res = await fetch(`${daemonUrl}/sessions/register`, {
459
+ method: 'POST',
460
+ headers: { 'Content-Type': 'application/json' },
461
+ body: JSON.stringify({ session_id, claude_session_id, cwd, meta: { ...meta, client } })
462
+ }).catch(e => {
463
+ log(`Daemon registration fetch error: ${e.message}`);
464
+ return null;
465
+ });
466
+ if (res && res.ok) {
467
+ log(`Session registered with daemon successfully`);
468
+ } else if (res) {
469
+ log(`Daemon registration returned status ${res.status}`);
470
+ }
471
+ } catch (e) {
472
+ log(`Warning: Failed to register session with daemon: ${e.message}`);
473
+ }
474
+ }
475
+
476
+ // Log tool_use event to timeline (sanitize to redact secrets from tool_input)
477
+ if (session_id && RELAY_API_URL && RELAY_API_KEY && tool_name) {
478
+ try {
479
+ const sanitize = await loadEventSanitizer();
480
+ await postTimeline(RELAY_API_URL, RELAY_API_KEY, {
481
+ session_id, type: 'tool_use', source,
482
+ data: sanitize({ tool_name, tool_input: tool_input || {}, timestamp: Date.now() })
483
+ });
484
+ log(`Logged tool_use event for ${tool_name}`);
485
+ } catch (e) {
486
+ log(`Failed to log tool_use: ${e.message}`);
487
+ }
488
+ }
489
+
490
+ log(`PreToolUse complete [slow path, ${Date.now() - hookStart}ms] for ${tool_name}`);
524
491
  return exit(0);
525
492
  })().catch(err => {
526
- // Log to file but don't write to stderr - stderr shows in UI as "hook error"
527
493
  try {
528
494
  const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-hook.log';
529
495
  appendFileSync(hookLogFile, `[${new Date().toISOString()}] FATAL: ${err.message}\n${err.stack}\n`);