teleportation-cli 1.3.0 → 1.4.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.
@@ -185,10 +185,7 @@ const fetchJson = async (url, opts) => {
185
185
  log(`Using parent session ${parent_session_id} for approvals (child session: ${session_id})`);
186
186
  }
187
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}`);
188
+ const source = 'cli_interactive';
192
189
 
193
190
  // Load config
194
191
  let config;
@@ -232,6 +229,16 @@ const fetchJson = async (url, opts) => {
232
229
  headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
233
230
  });
234
231
 
232
+ // Bypass permissions: auto-approve everything when enabled from mobile.
233
+ // NOTE: This reads from the daemon-state fetched above (already needed for away-mode).
234
+ // Eventual consistency: there's a brief delay (~5s poll cycle) between the mobile toggle
235
+ // and the daemon syncing this state. Acceptable for MVP — no extra network call needed.
236
+ if (state.bypass_permissions) {
237
+ log(`[PermissionRequest] Bypass permissions enabled — auto-approving ${tool_name}`);
238
+ stdout.write(JSON.stringify({ decision: 'allow', reason: 'Auto-approve enabled from mobile' }));
239
+ return exit(0);
240
+ }
241
+
235
242
  // Skip auto-toggle if task mode is forcing away
236
243
  if (isTaskMode) {
237
244
  log(`Task mode: skipping auto-detection, using forced away mode`);
@@ -257,9 +257,7 @@ function buildErrorMessage(tool_output) {
257
257
  const { session_id, tool_name, tool_input, tool_output, tool_use_id } = input || {};
258
258
  log(`Session: ${session_id}, Tool: ${tool_name}, tool_use_id: ${tool_use_id || 'none'}`);
259
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';
260
+ const source = 'cli_interactive';
263
261
 
264
262
  // Validate session_id
265
263
  if (!isValidSessionId(session_id)) {
@@ -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
22
  async function getSessionMetadata(cwd) {
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'),
@@ -73,20 +70,32 @@ const withTimeout = async (promiseFactory, timeoutMs, label) => {
73
70
  }
74
71
  };
75
72
 
73
+ // Helper: POST a timeline event (reused in fast and slow paths)
74
+ function postTimeline(relayUrl, apiKey, body) {
75
+ return fetch(`${relayUrl}/api/timeline`, {
76
+ method: 'POST',
77
+ headers: {
78
+ 'Content-Type': 'application/json',
79
+ 'Authorization': `Bearer ${apiKey}`
80
+ },
81
+ body: JSON.stringify(body)
82
+ });
83
+ }
84
+
76
85
  (async () => {
77
- // Debug: Log hook invocation
86
+ const hookStart = Date.now();
87
+
78
88
  const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-hook.log';
79
89
  const log = (msg) => {
80
90
  const timestamp = new Date().toISOString();
81
- const logMsg = `[${timestamp}] ${msg}\n`;
82
91
  try {
83
- appendFileSync(hookLogFile, logMsg);
92
+ appendFileSync(hookLogFile, `[${timestamp}] ${msg}\n`);
84
93
  } catch (e) {
85
- // Silently ignore log failures - don't write to stderr as it shows in UI
94
+ // Silently ignore log failures
86
95
  }
87
96
  };
88
97
 
89
- // Read and parse stdin to get hook input
98
+ // Read and parse stdin
90
99
  let input = {};
91
100
  try {
92
101
  const raw = await readStdin();
@@ -107,56 +116,47 @@ const withTimeout = async (promiseFactory, timeoutMs, label) => {
107
116
  return Math.max(200, Math.min(PRE_TOOL_NETWORK_TIMEOUT_MS, remaining - 100));
108
117
  };
109
118
 
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';
119
+ const source = 'cli_interactive';
113
120
 
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
121
+ // 1. Recursion Guard
117
122
  const SAFE_TOOLS = ['read', 'glob', 'grep', 'websearch', 'bashoutput', 'ls', 'pwd', 'git status'];
118
123
  const RECURSION_DEPTH = parseInt(env.TELEPORTATION_HOOK_DEPTH || '0', 10) || 0;
119
-
124
+
120
125
  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.`);
126
+ log(`[RECURSION] Depth limit reached (${RECURSION_DEPTH}) for tool "${tool_name}", auto-allowing.`);
122
127
  process.stdout.write(JSON.stringify({ decision: 'allow', reason: 'Recursion guard depth limit' }));
123
128
  return exit(0);
124
129
  }
125
130
 
126
- // Update environment for child processes
127
131
  process.env.TELEPORTATION_HOOK_DEPTH = (RECURSION_DEPTH + 1).toString();
128
132
 
129
133
  log(`Session ID: ${session_id}, Tool: ${tool_name}, Input: ${JSON.stringify(tool_input).substring(0, 100)}`);
130
134
 
131
- // Check for /away and /back commands (user typing in Claude Code)
132
- // These are special commands to toggle away mode
135
+ // 2. Detect /away and /back commands
133
136
  const command = tool_input?.command || tool_input?.text || '';
134
137
  if (typeof command === 'string') {
135
138
  const trimmedCmd = command.trim().toLowerCase();
136
- // Support multiple command formats: /away, teleportation away, teleport away, teleporation away (typo)
137
139
  if (trimmedCmd === '/away' ||
138
140
  trimmedCmd === 'teleportation away' ||
139
141
  trimmedCmd === 'teleport away' ||
140
142
  trimmedCmd === 'teleporation away') {
141
- log('Detected /away command - setting away mode');
142
- // Will set away mode after loading config
143
+ log('Detected /away command');
143
144
  tool_input.__teleportation_away = true;
144
145
  } else if (trimmedCmd === '/back' ||
145
146
  trimmedCmd === 'teleportation back' ||
146
147
  trimmedCmd === 'teleport back' ||
147
148
  trimmedCmd === 'teleporation back') {
148
- log('Detected /back command - clearing away mode');
149
+ log('Detected /back command');
149
150
  tool_input.__teleportation_back = true;
150
151
  }
151
152
  }
152
153
 
153
- // Load config from encrypted credentials, legacy config file, or env vars
154
+ // 3. Load config (always needed for API URLs)
154
155
  let config;
155
156
  try {
156
157
  const { loadConfig } = await import('./config-loader.mjs');
157
158
  config = await loadConfig();
158
159
  } catch (e) {
159
- // Fallback to environment variables if config loader fails
160
160
  config = {
161
161
  relayApiUrl: env.RELAY_API_URL || '',
162
162
  relayApiKey: env.RELAY_API_KEY || '',
@@ -164,8 +164,7 @@ const withTimeout = async (promiseFactory, timeoutMs, label) => {
164
164
  };
165
165
  }
166
166
 
167
- // Prioritize environment variables over config file (for testing)
168
- const SLACK_WEBHOOK_URL = env.SLACK_WEBHOOK_URL || config.slackWebhookUrl || '';
167
+ // Derive constants
169
168
  const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
170
169
  const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
171
170
  const DAEMON_PORT = config.daemonPort || env.TELEPORTATION_DAEMON_PORT || '3050';
@@ -193,186 +192,6 @@ const withTimeout = async (promiseFactory, timeoutMs, label) => {
193
192
  // All tool requests are sent to the remote approval system so the user
194
193
  // can approve/deny from their mobile device. This enables true remote control.
195
194
 
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
195
  // NOTE: Context delivery via notification has been removed.
377
196
  // With parent session context resumption (PR #123), autonomous tasks now
378
197
  // automatically resume the parent session's conversation context on turn 1.
@@ -411,49 +230,27 @@ const withTimeout = async (promiseFactory, timeoutMs, label) => {
411
230
  }
412
231
  };
413
232
 
233
+ // 4. Handle /away and /back (early exit, needs config only)
414
234
  if (tool_input.__teleportation_away) {
415
235
  await updateSessionState({ is_away: true });
416
236
  // Log away_mode_changed event to timeline
417
237
  if (RELAY_API_URL && RELAY_API_KEY && hasBudget()) {
418
238
  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
- }
239
+ await postTimeline(RELAY_API_URL, RELAY_API_KEY, {
240
+ session_id, type: 'away_mode_changed', source,
241
+ data: { is_away: true, timestamp: Date.now() }
242
+ });
443
243
  log(`Logged away_mode_changed (away=true) to timeline`);
444
- } catch (e) {
445
- log(`Failed to log away_mode_changed: ${e.message}`);
446
- }
244
+ } catch (e) { log(`Failed to log away_mode_changed: ${e.message}`); }
447
245
  }
448
- const out = {
246
+ stdout.write(JSON.stringify({
449
247
  hookSpecificOutput: {
450
248
  hookEventName: 'PreToolUse',
451
249
  permissionDecision: 'deny',
452
250
  permissionDecisionReason: '✅ Teleportation: Away mode enabled.'
453
251
  },
454
252
  suppressOutput: true
455
- };
456
- stdout.write(JSON.stringify(out));
253
+ }));
457
254
  return exit(0);
458
255
  }
459
256
 
@@ -462,68 +259,200 @@ const withTimeout = async (promiseFactory, timeoutMs, label) => {
462
259
  // Log away_mode_changed event to timeline
463
260
  if (RELAY_API_URL && RELAY_API_KEY && hasBudget()) {
464
261
  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
- }
262
+ await postTimeline(RELAY_API_URL, RELAY_API_KEY, {
263
+ session_id, type: 'away_mode_changed', source,
264
+ data: { is_away: false, timestamp: Date.now() }
265
+ });
489
266
  log(`Logged away_mode_changed (away=false) to timeline`);
490
- } catch (e) {
491
- log(`Failed to log away_mode_changed: ${e.message}`);
492
- }
267
+ } catch (e) { log(`Failed to log away_mode_changed: ${e.message}`); }
493
268
  }
494
- const out = {
269
+ stdout.write(JSON.stringify({
495
270
  hookSpecificOutput: {
496
271
  hookEventName: 'PreToolUse',
497
272
  permissionDecision: 'deny',
498
273
  permissionDecisionReason: '✅ Teleportation: Away mode disabled.'
499
274
  },
500
275
  suppressOutput: true
501
- };
502
- stdout.write(JSON.stringify(out));
276
+ }));
503
277
  return exit(0);
504
278
  }
505
279
 
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
280
+ // 5. Check if session is already registered (marker file from first call)
281
+ // This is the key optimization: skip metadata extraction, registration,
282
+ // and daemon calls on subsequent tool uses.
283
+ // The marker file is created by session-register.mjs after successful relay
284
+ // registration. If registration fails, the marker won't exist and subsequent
285
+ // calls will continue taking the slow path until registration succeeds.
286
+ let sessionAlreadyRegistered = false;
287
+ if (session_id) {
288
+ const registrationMarker = join(tmpdir(), `teleportation-session-${session_id}.registered`);
289
+ try {
290
+ fs.accessSync(registrationMarker);
291
+ sessionAlreadyRegistered = true;
292
+ } catch {}
293
+ }
294
+
295
+ if (sessionAlreadyRegistered) {
296
+ // ═══════════════════════════════════════════════════════════════════
297
+ // FAST PATH: Session registered. Skip metadata extraction (6+ git
298
+ // subprocesses), skip relay/daemon registration, skip model file I/O.
299
+ // Only do: lightweight model check + timeline POST.
300
+ // ═══════════════════════════════════════════════════════════════════
301
+
302
+ // Lightweight model check (env vars + settings.json, no git calls)
303
+ try {
304
+ const sessionDir = join(homedir(), '.teleportation', 'sessions');
305
+ const modelFile = join(sessionDir, `model_${session_id}.txt`);
306
+
307
+ let lastModel = null;
308
+ try { lastModel = fs.readFileSync(modelFile, 'utf8').trim(); } catch {}
309
+
310
+ // Read current model from env vars, then settings.json (no git needed)
311
+ let currentModel = env.ANTHROPIC_MODEL || env.CLAUDE_MODEL || null;
312
+ if (!currentModel) {
313
+ try {
314
+ const settings = JSON.parse(fs.readFileSync(join(homedir(), '.claude', 'settings.json'), 'utf8'));
315
+ currentModel = settings.model || null;
316
+ } catch {}
317
+ }
318
+
319
+ if (lastModel && currentModel && lastModel !== currentModel) {
320
+ log(`[FastPath] Model changed: ${lastModel} -> ${currentModel}`);
321
+ if (RELAY_API_URL && RELAY_API_KEY) {
322
+ await postTimeline(RELAY_API_URL, RELAY_API_KEY, {
323
+ session_id, type: 'model_changed', source,
324
+ data: { previous_model: lastModel, new_model: currentModel, timestamp: Date.now() }
325
+ }).catch(e => log(`Failed to log model change: ${e.message}`));
326
+ }
327
+ try { fs.writeFileSync(modelFile, currentModel, { mode: 0o600 }); } catch {}
328
+ }
329
+ } catch (e) {
330
+ log(`[FastPath] Model check error: ${e.message}`);
331
+ }
332
+
333
+ // Log tool_use to timeline
334
+ if (session_id && RELAY_API_URL && RELAY_API_KEY && tool_name) {
335
+ try {
336
+ await postTimeline(RELAY_API_URL, RELAY_API_KEY, {
337
+ session_id, type: 'tool_use', source,
338
+ data: { tool_name, tool_input: tool_input || {}, timestamp: Date.now() }
339
+ });
340
+ } catch (e) { log(`Failed to log tool_use: ${e.message}`); }
341
+ }
342
+
343
+ log(`PreToolUse complete [fast path, ${Date.now() - hookStart}ms] for ${tool_name}`);
344
+ return exit(0);
345
+ }
346
+
347
+ // ═══════════════════════════════════════════════════════════════════════
348
+ // SLOW PATH: First tool call in session. Extract metadata, register
349
+ // session with relay and daemon, detect model changes.
350
+ // ═══════════════════════════════════════════════════════════════════════
351
+ log(`[SlowPath] First tool call, registering session ${session_id}`);
352
+
353
+ const cwd = process.cwd();
354
+ const meta = await getSessionMetadata(cwd);
355
+ log(`Session metadata: project=${meta.project_name}, hostname=${meta.hostname}, branch=${meta.current_branch}, model=${meta.current_model || 'default'}`);
356
+
357
+ // Model change detection (full version via metadata)
358
+ const sessionDir = join(homedir(), '.teleportation', 'sessions');
359
+ if (!fs.existsSync(sessionDir)) {
360
+ fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
361
+ }
362
+ const modelFile = join(sessionDir, `model_${session_id}.txt`);
363
+ let modelChanged = false;
364
+ try {
365
+ const { readFile, writeFile } = await import('fs/promises');
366
+ let lastModel = null;
367
+ try {
368
+ lastModel = (await readFile(modelFile, 'utf8')).trim();
369
+ } catch (e) {
370
+ // File doesn't exist yet - first tool use
371
+ }
372
+
373
+ if (lastModel && meta.current_model && lastModel !== meta.current_model) {
374
+ modelChanged = true;
375
+ log(`Model changed detected: ${lastModel} -> ${meta.current_model}`);
376
+ if (RELAY_API_URL && RELAY_API_KEY) {
377
+ try {
378
+ await postTimeline(RELAY_API_URL, RELAY_API_KEY, {
379
+ session_id, type: 'model_changed', source,
380
+ data: { previous_model: lastModel, new_model: meta.current_model, timestamp: Date.now() }
381
+ });
382
+ } catch (e) { log(`Failed to log model change: ${e.message}`); }
383
+ }
384
+ }
385
+
386
+ if (meta.current_model) {
387
+ await writeFile(modelFile, meta.current_model, { mode: 0o600 });
388
+ }
389
+ } catch (e) {
390
+ log(`Model change detection error: ${e.message}`);
391
+ }
392
+
393
+ // Register with relay (pass pre-extracted metadata to avoid double extraction)
394
+ if (session_id && RELAY_API_URL && RELAY_API_KEY) {
395
+ try {
396
+ log(`Registering session with relay: ${session_id}`);
397
+ const { ensureSessionRegistered, updateSessionMetadata } = await import('./session-register.mjs');
398
+ const regResult = await ensureSessionRegistered(session_id, cwd, config, meta);
399
+
400
+ if (typeof regResult === 'object' && regResult.error === 'orphan_api_key') {
401
+ console.error(`\n⚠️ Teleportation: ${regResult.message || 'API key not linked to user.'}`);
402
+ console.error(' Visit https://app.teleportation.dev/api-keys to claim your key.\n');
403
+ } else {
404
+ log(`Session registered with relay successfully`);
405
+ }
406
+
407
+ // If model changed, update session metadata
408
+ if (modelChanged && (regResult === true || (typeof regResult === 'object' && regResult.success))) {
409
+ await updateSessionMetadata(session_id, cwd, config);
410
+ log(`Session metadata updated with new model`);
411
+ }
412
+ } catch (e) {
413
+ log(`Warning: Failed to register session with relay: ${e.message}`);
414
+ }
415
+ }
416
+
417
+ // Register with daemon
418
+ if (session_id && DAEMON_ENABLED) {
419
+ try {
420
+ const daemonUrl = `http://127.0.0.1:${DAEMON_PORT}`;
421
+ log(`Registering session with daemon: ${session_id}`);
422
+ const res = await fetch(`${daemonUrl}/sessions/register`, {
423
+ method: 'POST',
424
+ headers: { 'Content-Type': 'application/json' },
425
+ body: JSON.stringify({ session_id, claude_session_id, cwd, meta })
426
+ }).catch(e => {
427
+ log(`Daemon registration fetch error: ${e.message}`);
428
+ return null;
429
+ });
430
+ if (res && res.ok) {
431
+ log(`Session registered with daemon successfully`);
432
+ } else if (res) {
433
+ log(`Daemon registration returned status ${res.status}`);
434
+ }
435
+ } catch (e) {
436
+ log(`Warning: Failed to register session with daemon: ${e.message}`);
437
+ }
438
+ }
439
+
440
+ // Log tool_use event to timeline
441
+ if (session_id && RELAY_API_URL && RELAY_API_KEY && tool_name) {
442
+ try {
443
+ await postTimeline(RELAY_API_URL, RELAY_API_KEY, {
444
+ session_id, type: 'tool_use', source,
445
+ data: { tool_name, tool_input: tool_input || {}, timestamp: Date.now() }
446
+ });
447
+ log(`Logged tool_use event for ${tool_name}`);
448
+ } catch (e) {
449
+ log(`Failed to log tool_use: ${e.message}`);
450
+ }
451
+ }
452
+
453
+ log(`PreToolUse complete [slow path, ${Date.now() - hookStart}ms] for ${tool_name}`);
524
454
  return exit(0);
525
455
  })().catch(err => {
526
- // Log to file but don't write to stderr - stderr shows in UI as "hook error"
527
456
  try {
528
457
  const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-hook.log';
529
458
  appendFileSync(hookLogFile, `[${new Date().toISOString()}] FATAL: ${err.message}\n${err.stack}\n`);