teleportation-cli 1.2.2 → 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.
- package/.claude/hooks/permission_request.mjs +11 -4
- package/.claude/hooks/post_tool_use.mjs +1 -3
- package/.claude/hooks/pre_tool_use.mjs +216 -287
- package/.claude/hooks/session-register.mjs +36 -28
- package/.claude/hooks/session_end.mjs +1 -3
- package/.claude/hooks/session_start.mjs +15 -1
- package/.claude/hooks/stop.mjs +215 -224
- package/.claude/hooks/user_prompt_submit.mjs +1 -3
- package/lib/daemon/task-executor-v2.js +208 -27
- package/lib/daemon/teleportation-daemon.js +215 -19
- package/lib/daemon/timeline-analyzer.js +19 -13
- package/lib/daemon/transcript-ingestion.js +152 -44
- package/lib/install/installer.js +43 -13
- package/package.json +1 -1
- package/teleportation-cli.cjs +57 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
92
|
+
appendFileSync(hookLogFile, `[${timestamp}] ${msg}\n`);
|
|
84
93
|
} catch (e) {
|
|
85
|
-
// Silently ignore log failures
|
|
94
|
+
// Silently ignore log failures
|
|
86
95
|
}
|
|
87
96
|
};
|
|
88
97
|
|
|
89
|
-
// Read and parse stdin
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
149
|
+
log('Detected /back command');
|
|
149
150
|
tool_input.__teleportation_back = true;
|
|
150
151
|
}
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
// Load config
|
|
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
|
-
//
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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`);
|