teleportation-cli 1.0.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.
Files changed (54) hide show
  1. package/.claude/hooks/config-loader.mjs +93 -0
  2. package/.claude/hooks/heartbeat.mjs +331 -0
  3. package/.claude/hooks/notification.mjs +35 -0
  4. package/.claude/hooks/permission_request.mjs +307 -0
  5. package/.claude/hooks/post_tool_use.mjs +137 -0
  6. package/.claude/hooks/pre_tool_use.mjs +451 -0
  7. package/.claude/hooks/session-register.mjs +274 -0
  8. package/.claude/hooks/session_end.mjs +256 -0
  9. package/.claude/hooks/session_start.mjs +308 -0
  10. package/.claude/hooks/stop.mjs +277 -0
  11. package/.claude/hooks/user_prompt_submit.mjs +91 -0
  12. package/LICENSE +21 -0
  13. package/README.md +243 -0
  14. package/lib/auth/api-key.js +110 -0
  15. package/lib/auth/credentials.js +341 -0
  16. package/lib/backup/manager.js +461 -0
  17. package/lib/cli/daemon-commands.js +299 -0
  18. package/lib/cli/index.js +303 -0
  19. package/lib/cli/session-commands.js +294 -0
  20. package/lib/cli/snapshot-commands.js +223 -0
  21. package/lib/cli/worktree-commands.js +291 -0
  22. package/lib/config/manager.js +306 -0
  23. package/lib/daemon/lifecycle.js +336 -0
  24. package/lib/daemon/pid-manager.js +160 -0
  25. package/lib/daemon/teleportation-daemon.js +2009 -0
  26. package/lib/handoff/config.js +102 -0
  27. package/lib/handoff/example.js +152 -0
  28. package/lib/handoff/git-handoff.js +351 -0
  29. package/lib/handoff/handoff.js +277 -0
  30. package/lib/handoff/index.js +25 -0
  31. package/lib/handoff/session-state.js +238 -0
  32. package/lib/install/installer.js +555 -0
  33. package/lib/machine-coders/claude-code-adapter.js +329 -0
  34. package/lib/machine-coders/example.js +239 -0
  35. package/lib/machine-coders/gemini-cli-adapter.js +406 -0
  36. package/lib/machine-coders/index.js +103 -0
  37. package/lib/machine-coders/interface.js +168 -0
  38. package/lib/router/classifier.js +251 -0
  39. package/lib/router/example.js +92 -0
  40. package/lib/router/index.js +69 -0
  41. package/lib/router/mech-llms-client.js +277 -0
  42. package/lib/router/models.js +188 -0
  43. package/lib/router/router.js +382 -0
  44. package/lib/session/cleanup.js +100 -0
  45. package/lib/session/metadata.js +258 -0
  46. package/lib/session/mute-checker.js +114 -0
  47. package/lib/session-registry/manager.js +302 -0
  48. package/lib/snapshot/manager.js +390 -0
  49. package/lib/utils/errors.js +166 -0
  50. package/lib/utils/logger.js +148 -0
  51. package/lib/utils/retry.js +155 -0
  52. package/lib/worktree/manager.js +301 -0
  53. package/package.json +66 -0
  54. package/teleportation-cli.cjs +2987 -0
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { stdin, exit, env } from 'node:process';
4
+ import { spawn } from 'child_process';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname, join } from 'path';
7
+ import { homedir } from 'os';
8
+ import { existsSync, statSync, writeFileSync, mkdirSync } from 'fs';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+
13
+ // Import metadata extraction (lazy loaded to avoid slowing down startup)
14
+ let extractSessionMetadata = null;
15
+ async function getSessionMetadata(cwd) {
16
+ if (!extractSessionMetadata) {
17
+ // Try multiple possible paths for the metadata module
18
+ const possiblePaths = [
19
+ // Installed location (copied during `teleportation on`)
20
+ join(homedir(), '.teleportation', 'lib', 'session', 'metadata.js'),
21
+ // Development mode - relative to hooks directory
22
+ join(__dirname, '..', '..', 'lib', 'session', 'metadata.js'),
23
+ // If hook is still in project directory
24
+ join(process.cwd(), 'lib', 'session', 'metadata.js')
25
+ ];
26
+
27
+ // Check existsSync first to avoid expensive import failures
28
+ for (const metadataPath of possiblePaths) {
29
+ if (!existsSync(metadataPath)) continue;
30
+ try {
31
+ const metadataModule = await import(metadataPath);
32
+ extractSessionMetadata = metadataModule.extractSessionMetadata;
33
+ if (extractSessionMetadata) break;
34
+ } catch {
35
+ // Try next path
36
+ continue;
37
+ }
38
+ }
39
+
40
+ if (!extractSessionMetadata) {
41
+ if (env.DEBUG) console.error(`[SessionStart] Metadata module not found`);
42
+ return {};
43
+ }
44
+ }
45
+ try {
46
+ return await extractSessionMetadata(cwd);
47
+ } catch (e) {
48
+ if (env.DEBUG) console.error(`[SessionStart] Failed to extract metadata: ${e.message}`);
49
+ return {};
50
+ }
51
+ }
52
+
53
+ const readStdin = () => new Promise((resolve, reject) => {
54
+ let data='';
55
+ stdin.setEncoding('utf8');
56
+ stdin.on('data', c => data += c);
57
+ stdin.on('end', () => resolve(data));
58
+ stdin.on('error', reject);
59
+ });
60
+
61
+ const fetchWithTimeout = (url, opts, timeoutMs = 2000) => {
62
+ return Promise.race([
63
+ fetch(url, opts),
64
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs))
65
+ ]);
66
+ };
67
+
68
+ // Session marker file to track when this Claude Code session started
69
+ const TELEPORTATION_DIR = join(homedir(), '.teleportation');
70
+ const SESSION_MARKER_FILE = join(TELEPORTATION_DIR, '.session_marker');
71
+ const CREDENTIALS_FILE = join(TELEPORTATION_DIR, 'credentials');
72
+
73
+ /**
74
+ * Check if credentials were modified after the session started.
75
+ * If a session marker exists and credentials are newer, user needs to restart.
76
+ */
77
+ function checkRestartNeeded() {
78
+ try {
79
+ if (!existsSync(SESSION_MARKER_FILE) || !existsSync(CREDENTIALS_FILE)) {
80
+ return { needsRestart: false };
81
+ }
82
+
83
+ const markerMtime = statSync(SESSION_MARKER_FILE).mtimeMs;
84
+ const credsMtime = statSync(CREDENTIALS_FILE).mtimeMs;
85
+
86
+ if (credsMtime > markerMtime) {
87
+ return {
88
+ needsRestart: true,
89
+ reason: 'Credentials changed after session started',
90
+ markerTime: new Date(markerMtime).toISOString(),
91
+ credsTime: new Date(credsMtime).toISOString()
92
+ };
93
+ }
94
+
95
+ return { needsRestart: false };
96
+ } catch (e) {
97
+ if (env.DEBUG) console.error(`[SessionStart] Restart check error: ${e.message}`);
98
+ return { needsRestart: false };
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Update the session marker file with current timestamp.
104
+ * Called when a session starts to track when this Claude Code instance began.
105
+ */
106
+ function updateSessionMarker(sessionId) {
107
+ try {
108
+ if (!existsSync(TELEPORTATION_DIR)) {
109
+ mkdirSync(TELEPORTATION_DIR, { recursive: true, mode: 0o700 });
110
+ }
111
+ const markerData = JSON.stringify({
112
+ timestamp: Date.now(),
113
+ sessionId: sessionId,
114
+ startedAt: new Date().toISOString()
115
+ });
116
+ writeFileSync(SESSION_MARKER_FILE, markerData, { mode: 0o600 });
117
+ if (env.DEBUG) console.error(`[SessionStart] Session marker updated`);
118
+ } catch (e) {
119
+ if (env.DEBUG) console.error(`[SessionStart] Failed to update session marker: ${e.message}`);
120
+ }
121
+ }
122
+
123
+ (async () => {
124
+ let input = {};
125
+ try {
126
+ const raw = await readStdin();
127
+ input = JSON.parse(raw || '{}');
128
+ } catch {}
129
+
130
+ let { session_id, cwd } = input || {};
131
+ const claude_session_id = session_id;
132
+
133
+ // Check if credentials changed since last session start - user may need to restart
134
+ const restartCheck = checkRestartNeeded();
135
+ if (restartCheck.needsRestart) {
136
+ // Output a warning to stderr (shown to user)
137
+ console.error('\n⚠️ Teleportation credentials changed after this session started.');
138
+ console.error(' Restart Claude Code to apply the new credentials.\n');
139
+ if (env.DEBUG) {
140
+ console.error(` Session started: ${restartCheck.markerTime}`);
141
+ console.error(` Credentials updated: ${restartCheck.credsTime}`);
142
+ }
143
+ }
144
+
145
+ // Update session marker with current time (for future restart detection)
146
+ updateSessionMarker(session_id);
147
+
148
+ // Load config from encrypted credentials, legacy config file, or env vars
149
+ let config;
150
+ try {
151
+ const { loadConfig } = await import('./config-loader.mjs');
152
+ config = await loadConfig();
153
+ } catch (e) {
154
+ // Fallback to environment variables if config loader fails
155
+ config = {
156
+ relayApiUrl: env.RELAY_API_URL || '',
157
+ relayApiKey: env.RELAY_API_KEY || ''
158
+ };
159
+ }
160
+
161
+ const RELAY_API_URL = config.relayApiUrl || '';
162
+ const RELAY_API_KEY = config.relayApiKey || '';
163
+ const DAEMON_PORT = config.daemonPort || env.TELEPORTATION_DAEMON_PORT || '3050';
164
+ const DAEMON_ENABLED = config.daemonEnabled !== false && env.TELEPORTATION_DAEMON_ENABLED !== 'false';
165
+
166
+ // Auto-start daemon if enabled
167
+ if (DAEMON_ENABLED && session_id && RELAY_API_URL && RELAY_API_KEY) {
168
+ try {
169
+ // Check if daemon is already running
170
+ const daemonUrl = `http://127.0.0.1:${DAEMON_PORT}`;
171
+ let daemonRunning = false;
172
+
173
+ try {
174
+ const healthResponse = await fetchWithTimeout(`${daemonUrl}/health`, {}, 1000);
175
+ daemonRunning = healthResponse.ok;
176
+ } catch {}
177
+
178
+ // Start daemon if not running
179
+ if (!daemonRunning) {
180
+ // Try multiple locations to find daemon script
181
+ const possibleLocations = [
182
+ // 1. Installed location (copied during `teleportation on`)
183
+ join(homedir(), '.teleportation', 'daemon', 'teleportation-daemon.js'),
184
+ // 2. Development mode - relative to hooks directory
185
+ join(__dirname, '..', '..', 'lib', 'daemon', 'teleportation-daemon.js'),
186
+ // 3. Environment variable override
187
+ env.TELEPORTATION_DAEMON_SCRIPT
188
+ ].filter(Boolean);
189
+
190
+ // Import fs/promises once outside the loop
191
+ const { access } = await import('fs/promises');
192
+ let daemonScript = null;
193
+ for (const location of possibleLocations) {
194
+ try {
195
+ await access(location);
196
+ daemonScript = location;
197
+ break;
198
+ } catch {
199
+ // Try next location
200
+ }
201
+ }
202
+
203
+ if (!daemonScript) {
204
+ if (env.DEBUG) {
205
+ console.error('[SessionStart] Daemon script not found. Tried:', possibleLocations);
206
+ }
207
+ // Continue without daemon
208
+ try { process.stdout.write(JSON.stringify({ suppressOutput: true })); } catch {}
209
+ return exit(0);
210
+ }
211
+
212
+ // Retry daemon start with exponential backoff
213
+ let retries = 3;
214
+ let daemonStarted = false;
215
+
216
+ while (retries > 0 && !daemonStarted) {
217
+ try {
218
+ // Spawn daemon process
219
+ spawn(process.execPath, [daemonScript], {
220
+ detached: true,
221
+ stdio: 'ignore',
222
+ env: {
223
+ ...process.env,
224
+ TELEPORTATION_DAEMON: 'true',
225
+ RELAY_API_URL,
226
+ RELAY_API_KEY,
227
+ TELEPORTATION_DAEMON_PORT: DAEMON_PORT
228
+ }
229
+ }).unref();
230
+
231
+ // Wait for daemon to start with increasing delays
232
+ const waitTime = 500 * (4 - retries); // 500ms, 1000ms, 1500ms
233
+ await new Promise(r => setTimeout(r, waitTime));
234
+
235
+ // Verify daemon is actually running
236
+ try {
237
+ const healthCheck = await fetchWithTimeout(`${daemonUrl}/health`, {}, 2000);
238
+ if (healthCheck.ok) {
239
+ daemonStarted = true;
240
+ if (env.DEBUG) {
241
+ console.error('[SessionStart] Daemon started successfully');
242
+ }
243
+ break;
244
+ }
245
+ } catch (healthError) {
246
+ if (env.DEBUG) {
247
+ console.error(`[SessionStart] Health check failed: ${healthError.message}`);
248
+ }
249
+ }
250
+
251
+ retries--;
252
+ if (retries === 0 && !daemonStarted) {
253
+ if (env.DEBUG) {
254
+ console.error('[SessionStart] Failed to start daemon after 3 attempts');
255
+ }
256
+ // Set flag to disable daemon for this session
257
+ process.env.TELEPORTATION_DAEMON_DISABLED = 'true';
258
+ }
259
+ } catch (spawnError) {
260
+ retries--;
261
+ if (env.DEBUG) {
262
+ console.error(`[SessionStart] Daemon spawn error (${3 - retries}/3):`, spawnError.message);
263
+ }
264
+ if (retries > 0) {
265
+ // Exponential backoff between retries
266
+ await new Promise(r => setTimeout(r, 1000 * (4 - retries)));
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ // Register session with daemon (with metadata)
273
+ try {
274
+ // Extract session metadata (project, branch, hostname, current_model, etc.)
275
+ const meta = await getSessionMetadata(cwd || process.cwd());
276
+
277
+ if (env.DEBUG && meta.current_model) {
278
+ console.error(`[SessionStart] Captured model: ${meta.current_model}`);
279
+ }
280
+
281
+ await fetchWithTimeout(`${daemonUrl}/sessions/register`, {
282
+ method: 'POST',
283
+ headers: { 'Content-Type': 'application/json' },
284
+ body: JSON.stringify({ session_id, claude_session_id, cwd, meta })
285
+ }, 2000);
286
+
287
+ if (env.DEBUG) {
288
+ console.error(`[SessionStart] Session registered with daemon: ${session_id}`);
289
+ console.error(`[SessionStart] Metadata: ${JSON.stringify(meta)}`);
290
+ }
291
+ } catch (regError) {
292
+ if (env.DEBUG) {
293
+ console.error(`[SessionStart] Failed to register session with daemon:`, regError.message);
294
+ }
295
+ }
296
+ } catch (daemonError) {
297
+ // Don't fail session start if daemon fails
298
+ if (env.DEBUG) {
299
+ console.error('[SessionStart] Daemon error:', daemonError.message);
300
+ }
301
+ }
302
+ }
303
+
304
+ // Session registration with relay happens on first message (in pre_tool_use hook)
305
+ // This hook just starts the daemon infrastructure
306
+ try { process.stdout.write(JSON.stringify({ suppressOutput: true })); } catch {}
307
+ return exit(0);
308
+ })();
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Stop Hook
4
+ *
5
+ * This hook fires when Claude Code finishes responding.
6
+ *
7
+ * Purpose:
8
+ * 1. Check for pending messages from the mobile app (existing functionality)
9
+ * 2. Extract Claude's last response from the transcript and log it to timeline
10
+ */
11
+
12
+ import { stdin, stdout, stderr, exit, env } from 'node:process';
13
+ import { readFile } from 'node:fs/promises';
14
+ import { appendFileSync } from 'node:fs';
15
+
16
+ const readStdin = () => new Promise((resolve, reject) => {
17
+ let data = '';
18
+ stdin.setEncoding('utf8');
19
+ stdin.on('data', c => data += c);
20
+ stdin.on('end', () => resolve(data));
21
+ stdin.on('error', reject);
22
+ });
23
+
24
+ // More lenient session ID validation - accepts any alphanumeric string with hyphens
25
+ const isValidSessionId = (id) => {
26
+ return id && typeof id === 'string' && /^[a-zA-Z0-9-]+$/.test(id) && id.length >= 8;
27
+ };
28
+
29
+ // Max length for assistant response preview (characters)
30
+ const ASSISTANT_RESPONSE_MAX_LENGTH = 2000;
31
+
32
+ // Retry configuration
33
+ const MAX_RETRIES = 3;
34
+ const RETRY_DELAY_MS = 1000;
35
+
36
+ /**
37
+ * Fetch JSON with retry logic
38
+ */
39
+ const fetchJsonWithRetry = async (url, opts, log, retries = 0) => {
40
+ try {
41
+ const res = await fetch(url, opts);
42
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
43
+ return res.json();
44
+ } catch (error) {
45
+ if (retries < MAX_RETRIES) {
46
+ log(`Retry ${retries + 1}/${MAX_RETRIES} after error: ${error.message}`);
47
+ await new Promise(r => setTimeout(r, RETRY_DELAY_MS * (retries + 1)));
48
+ return fetchJsonWithRetry(url, opts, log, retries + 1);
49
+ }
50
+ throw error;
51
+ }
52
+ };
53
+
54
+ /**
55
+ * Extract the last assistant message from the transcript
56
+ * The transcript is a JSON file with conversation messages
57
+ * @returns {Object|null} - { text: string, model: string|null } or null
58
+ */
59
+ const extractLastAssistantMessage = async (transcriptPath, log) => {
60
+ try {
61
+ if (!transcriptPath) {
62
+ log('No transcript_path provided');
63
+ return null;
64
+ }
65
+
66
+ // Read file directly - handle errors instead of TOCTOU-vulnerable access() check
67
+ let content;
68
+ try {
69
+ content = await readFile(transcriptPath, 'utf8');
70
+ } catch (e) {
71
+ // Handle file access errors specifically
72
+ if (e.code === 'ENOENT') {
73
+ log(`Transcript file not found: ${transcriptPath}`);
74
+ return null;
75
+ }
76
+ if (e.code === 'EACCES' || e.code === 'EPERM') {
77
+ log(`Permission denied reading transcript: ${transcriptPath}`);
78
+ return null;
79
+ }
80
+ log(`Error reading transcript: ${e.code || e.message}`);
81
+ return null;
82
+ }
83
+ let transcript;
84
+
85
+ // Try parsing as JSON array first
86
+ try {
87
+ transcript = JSON.parse(content);
88
+ log('Parsed transcript as JSON array');
89
+ } catch (e) {
90
+ // Try parsing as JSONL (newline-delimited JSON)
91
+ log('JSON parse failed, trying JSONL format');
92
+ const lines = content.trim().split('\n').filter(l => l.trim());
93
+ transcript = lines.map(line => {
94
+ try {
95
+ return JSON.parse(line);
96
+ } catch {
97
+ return null;
98
+ }
99
+ }).filter(Boolean);
100
+ log(`Parsed transcript as JSONL (${transcript.length} messages)`);
101
+ }
102
+
103
+ if (!Array.isArray(transcript)) {
104
+ log(`Transcript is not an array: ${typeof transcript}`);
105
+ return null;
106
+ }
107
+
108
+ log(`Transcript has ${transcript.length} messages`);
109
+
110
+ // Find the last assistant message
111
+ // Messages typically have: { role: 'assistant' | 'user', content: string | array, model?: string }
112
+ for (let i = transcript.length - 1; i >= 0; i--) {
113
+ const msg = transcript[i];
114
+
115
+ // Check for assistant role (various possible formats)
116
+ const role = msg.role || msg.type || '';
117
+ if (role === 'assistant' || role === 'model' || msg.isAssistant) {
118
+ // Extract content (could be string or array of content blocks)
119
+ let text = '';
120
+
121
+ if (typeof msg.content === 'string') {
122
+ text = msg.content;
123
+ } else if (Array.isArray(msg.content)) {
124
+ // Content blocks format: [{ type: 'text', text: '...' }, ...]
125
+ text = msg.content
126
+ .filter(block => block.type === 'text' && block.text)
127
+ .map(block => block.text)
128
+ .join('\n\n'); // Use double newline for paragraph separation
129
+ } else if (msg.text) {
130
+ text = msg.text;
131
+ } else if (msg.message) {
132
+ // Don't stringify objects - only use if it's a string
133
+ text = typeof msg.message === 'string' ? msg.message : '';
134
+ }
135
+
136
+ if (text && text.trim()) {
137
+ // Extract model if available
138
+ const model = msg.model || null;
139
+ log(`Found assistant message (${text.length} chars, model: ${model || 'not specified'})`);
140
+ return { text: text.trim(), model };
141
+ }
142
+ }
143
+ }
144
+
145
+ log('No assistant message found in transcript');
146
+ return null;
147
+ } catch (e) {
148
+ log(`Error reading transcript: ${e.message}`);
149
+ return null;
150
+ }
151
+ };
152
+
153
+ (async () => {
154
+ const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-hook.log';
155
+ const log = (msg) => {
156
+ const timestamp = new Date().toISOString();
157
+ try {
158
+ // Use sync for logging to ensure messages are written even if hook exits quickly
159
+ appendFileSync(hookLogFile, `[${timestamp}] [Stop] ${msg}\n`);
160
+ } catch (e) {}
161
+ };
162
+
163
+ log('=== Stop Hook invoked ===');
164
+
165
+ const raw = await readStdin();
166
+ let input;
167
+ try {
168
+ input = JSON.parse(raw || '{}');
169
+ } catch (e) {
170
+ log(`ERROR: Invalid JSON: ${e.message}`);
171
+ return exit(0);
172
+ }
173
+
174
+ const { session_id, transcript_path, stop_hook_active } = input || {};
175
+ log(`Session: ${session_id}, Transcript: ${transcript_path}, stop_hook_active: ${stop_hook_active}`);
176
+
177
+ // Load config
178
+ let config;
179
+ try {
180
+ const { loadConfig } = await import('./config-loader.mjs');
181
+ config = await loadConfig();
182
+ } catch (e) {
183
+ config = {
184
+ relayApiUrl: env.RELAY_API_URL || '',
185
+ relayApiKey: env.RELAY_API_KEY || '',
186
+ };
187
+ }
188
+
189
+ const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
190
+ const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
191
+
192
+ if (!session_id || !RELAY_API_URL || !RELAY_API_KEY) {
193
+ log('Missing session_id or relay config - skipping');
194
+ return exit(0);
195
+ }
196
+
197
+ // Validate session_id
198
+ if (!isValidSessionId(session_id)) {
199
+ log(`ERROR: Invalid session_id format: ${session_id}`);
200
+ return exit(0);
201
+ }
202
+
203
+ // 1. Extract and log Claude's last response to timeline
204
+ // Skip if this is a continuation from a previous stop hook (stop_hook_active=true)
205
+ // to avoid logging duplicate responses
206
+ if (!stop_hook_active) {
207
+ try {
208
+ const result = await extractLastAssistantMessage(transcript_path, log);
209
+
210
+ if (result && result.text) {
211
+ const { text, model } = result;
212
+
213
+ // Truncate for timeline storage
214
+ const preview = text.length > ASSISTANT_RESPONSE_MAX_LENGTH
215
+ ? text.slice(0, ASSISTANT_RESPONSE_MAX_LENGTH) + '...'
216
+ : text;
217
+
218
+ await fetchJsonWithRetry(`${RELAY_API_URL}/api/timeline`, {
219
+ method: 'POST',
220
+ headers: {
221
+ 'Content-Type': 'application/json',
222
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
223
+ },
224
+ body: JSON.stringify({
225
+ session_id,
226
+ type: 'assistant_response',
227
+ data: {
228
+ message: preview,
229
+ model: model, // Include the model field
230
+ full_length: text.length,
231
+ truncated: text.length > ASSISTANT_RESPONSE_MAX_LENGTH
232
+ }
233
+ })
234
+ }, log);
235
+ log(`Logged assistant response to timeline (${preview.length} chars, model: ${model || 'not specified'})`);
236
+ }
237
+ } catch (e) {
238
+ log(`Failed to log assistant response after ${MAX_RETRIES} retries: ${e.message}`);
239
+ // Don't fail the hook - continue with other functionality
240
+ }
241
+ } else {
242
+ log('Skipping assistant response log (stop_hook_active=true)');
243
+ }
244
+
245
+ // 2. Check for pending messages from mobile app (existing functionality)
246
+ try {
247
+ const res = await fetch(`${RELAY_API_URL}/api/messages/pending?session_id=${encodeURIComponent(session_id)}`, {
248
+ headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
249
+ });
250
+ if (res.ok) {
251
+ const msg = await res.json();
252
+ if (msg && msg.id && msg.text) {
253
+ log(`Found pending message: ${String(msg.text).slice(0, 50)}...`);
254
+ try {
255
+ await fetch(`${RELAY_API_URL}/api/messages/${msg.id}/ack`, {
256
+ method: 'POST',
257
+ headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
258
+ });
259
+ } catch {}
260
+
261
+ const out = {
262
+ decision: 'block',
263
+ reason: msg.text,
264
+ hookSpecificOutput: { hookEventName: 'Stop' },
265
+ suppressOutput: true
266
+ };
267
+ stdout.write(JSON.stringify(out));
268
+ return exit(0);
269
+ }
270
+ }
271
+ } catch (e) {
272
+ log(`Error checking pending messages: ${e.message}`);
273
+ }
274
+
275
+ log('Stop hook completed');
276
+ return exit(0);
277
+ })();
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * UserPromptSubmit Hook
5
+ * Fires when the user submits a prompt to Claude Code
6
+ * Used to detect /model command and log it to timeline
7
+ */
8
+
9
+ import { stdin, stdout, exit, env } from 'node:process';
10
+ import { tmpdir } from 'os';
11
+ import { join } from 'path';
12
+
13
+ const readStdin = () => new Promise((resolve, reject) => {
14
+ let data = '';
15
+ stdin.setEncoding('utf8');
16
+ stdin.on('data', chunk => data += chunk);
17
+ stdin.on('end', () => resolve(data));
18
+ stdin.on('error', reject);
19
+ });
20
+
21
+ (async () => {
22
+ let input = {};
23
+ try {
24
+ const raw = await readStdin();
25
+ input = JSON.parse(raw || '{}');
26
+ } catch (e) {
27
+ // Invalid JSON - exit gracefully
28
+ try { stdout.write(JSON.stringify({ suppressOutput: true })); } catch {}
29
+ return exit(0);
30
+ }
31
+
32
+ const { session_id, prompt } = input;
33
+
34
+ // Check if user is running /model command
35
+ if (prompt && typeof prompt === 'string') {
36
+ const trimmed = prompt.trim().toLowerCase();
37
+
38
+ if (trimmed === '/model' || trimmed.startsWith('/model ')) {
39
+ // User is switching models - log this intent
40
+ // The actual model change will be detected in pre_tool_use hook
41
+
42
+ // Write a marker file to indicate model change is in progress
43
+ try {
44
+ const { writeFile } = await import('fs/promises');
45
+ const MODEL_CHANGE_MARKER = join(tmpdir(), `teleportation-model-changing-${session_id}.txt`);
46
+ await writeFile(MODEL_CHANGE_MARKER, Date.now().toString(), { mode: 0o600 });
47
+
48
+ if (env.DEBUG) {
49
+ console.error(`[UserPromptSubmit] Detected /model command for session ${session_id}`);
50
+ }
51
+ } catch (e) {
52
+ // Non-critical - just a marker file
53
+ }
54
+
55
+ // Load config and log to timeline
56
+ try {
57
+ const { loadConfig } = await import('./config-loader.mjs');
58
+ const config = await loadConfig();
59
+ const RELAY_API_URL = config.relayApiUrl;
60
+ const RELAY_API_KEY = config.relayApiKey;
61
+
62
+ if (RELAY_API_URL && RELAY_API_KEY) {
63
+ await fetch(`${RELAY_API_URL}/api/timeline/log`, {
64
+ method: 'POST',
65
+ headers: {
66
+ 'Content-Type': 'application/json',
67
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
68
+ },
69
+ body: JSON.stringify({
70
+ session_id,
71
+ event_type: 'model_change_requested',
72
+ data: {
73
+ command: prompt,
74
+ timestamp: Date.now()
75
+ }
76
+ })
77
+ });
78
+ }
79
+ } catch (e) {
80
+ // Non-critical - timeline logging is optional
81
+ if (env.DEBUG) {
82
+ console.error(`[UserPromptSubmit] Failed to log model change request: ${e.message}`);
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ // Always suppress output from this hook
89
+ try { stdout.write(JSON.stringify({ suppressOutput: true })); } catch {}
90
+ return exit(0);
91
+ })();
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.