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.
- package/.claude/hooks/permission_request.mjs +11 -4
- package/.claude/hooks/post_tool_use.mjs +1 -3
- package/.claude/hooks/pre_tool_use.mjs +255 -289
- package/.claude/hooks/session-register.mjs +44 -29
- package/.claude/hooks/session_end.mjs +29 -3
- package/.claude/hooks/session_start.mjs +57 -1
- package/.claude/hooks/stop.mjs +245 -242
- package/.claude/hooks/user_prompt_submit.mjs +1 -3
- package/lib/config/manager.js +45 -1
- package/lib/daemon/session-file-registry.js +207 -0
- package/lib/daemon/task-executor-v2.js +239 -29
- package/lib/daemon/teleportation-daemon.js +469 -29
- package/lib/daemon/timeline-analyzer.js +19 -13
- package/lib/daemon/transcript-ingestion.js +310 -51
- package/lib/daemon/utils.js +0 -9
- package/lib/install/installer.js +126 -3
- package/lib/install/uhr-installer.js +32 -18
- package/lib/intelligence/benchmark.js +240 -0
- package/lib/intelligence/index.js +29 -0
- package/lib/intelligence/rebuild-policies.js +169 -0
- package/lib/intelligence/schema.js +259 -0
- package/lib/intelligence/transcript-mine.js +339 -0
- package/lib/session/metadata.js +23 -5
- package/lib/transcript-sync/lifecycle.js +88 -0
- package/lib/transcript-sync/repo-context.js +45 -0
- package/lib/transcript-sync/worker.js +233 -0
- package/lib/utils/log-sanitizer.js +65 -0
- package/package.json +2 -1
- package/scripts/sync-transcripts.sh +272 -0
- package/teleportation-cli.cjs +295 -4
|
@@ -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
|
-
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
|
-
|
|
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,
|
|
121
|
+
appendFileSync(hookLogFile, `[${timestamp}] ${msg}\n`);
|
|
84
122
|
} catch (e) {
|
|
85
|
-
// Silently ignore log failures
|
|
123
|
+
// Silently ignore log failures
|
|
86
124
|
}
|
|
87
125
|
};
|
|
88
126
|
|
|
89
|
-
// Read and parse stdin
|
|
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
|
|
111
|
-
const
|
|
112
|
-
const source =
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
182
|
+
log('Detected /back command');
|
|
149
183
|
tool_input.__teleportation_back = true;
|
|
150
184
|
}
|
|
151
185
|
}
|
|
152
186
|
|
|
153
|
-
// Load config
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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`);
|