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.
- package/.claude/hooks/config-loader.mjs +93 -0
- package/.claude/hooks/heartbeat.mjs +331 -0
- package/.claude/hooks/notification.mjs +35 -0
- package/.claude/hooks/permission_request.mjs +307 -0
- package/.claude/hooks/post_tool_use.mjs +137 -0
- package/.claude/hooks/pre_tool_use.mjs +451 -0
- package/.claude/hooks/session-register.mjs +274 -0
- package/.claude/hooks/session_end.mjs +256 -0
- package/.claude/hooks/session_start.mjs +308 -0
- package/.claude/hooks/stop.mjs +277 -0
- package/.claude/hooks/user_prompt_submit.mjs +91 -0
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/lib/auth/api-key.js +110 -0
- package/lib/auth/credentials.js +341 -0
- package/lib/backup/manager.js +461 -0
- package/lib/cli/daemon-commands.js +299 -0
- package/lib/cli/index.js +303 -0
- package/lib/cli/session-commands.js +294 -0
- package/lib/cli/snapshot-commands.js +223 -0
- package/lib/cli/worktree-commands.js +291 -0
- package/lib/config/manager.js +306 -0
- package/lib/daemon/lifecycle.js +336 -0
- package/lib/daemon/pid-manager.js +160 -0
- package/lib/daemon/teleportation-daemon.js +2009 -0
- package/lib/handoff/config.js +102 -0
- package/lib/handoff/example.js +152 -0
- package/lib/handoff/git-handoff.js +351 -0
- package/lib/handoff/handoff.js +277 -0
- package/lib/handoff/index.js +25 -0
- package/lib/handoff/session-state.js +238 -0
- package/lib/install/installer.js +555 -0
- package/lib/machine-coders/claude-code-adapter.js +329 -0
- package/lib/machine-coders/example.js +239 -0
- package/lib/machine-coders/gemini-cli-adapter.js +406 -0
- package/lib/machine-coders/index.js +103 -0
- package/lib/machine-coders/interface.js +168 -0
- package/lib/router/classifier.js +251 -0
- package/lib/router/example.js +92 -0
- package/lib/router/index.js +69 -0
- package/lib/router/mech-llms-client.js +277 -0
- package/lib/router/models.js +188 -0
- package/lib/router/router.js +382 -0
- package/lib/session/cleanup.js +100 -0
- package/lib/session/metadata.js +258 -0
- package/lib/session/mute-checker.js +114 -0
- package/lib/session-registry/manager.js +302 -0
- package/lib/snapshot/manager.js +390 -0
- package/lib/utils/errors.js +166 -0
- package/lib/utils/logger.js +148 -0
- package/lib/utils/retry.js +155 -0
- package/lib/worktree/manager.js +301 -0
- package/package.json +66 -0
- 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.
|