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,451 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { stdin, stdout, exit, env } from 'node:process';
4
+ import { appendFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname, join } from 'path';
7
+ import { homedir, tmpdir } from 'os';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+
12
+ const readStdin = () => new Promise((resolve, reject) => {
13
+ let data = '';
14
+ stdin.setEncoding('utf8');
15
+ stdin.on('data', chunk => data += chunk);
16
+ stdin.on('end', () => resolve(data));
17
+ stdin.on('error', reject);
18
+ });
19
+
20
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
21
+
22
+ // Lazy-load metadata extraction
23
+ let extractSessionMetadata = null;
24
+ async function getSessionMetadata(cwd) {
25
+ if (!extractSessionMetadata) {
26
+ // Try multiple paths for the metadata module
27
+ const possiblePaths = [
28
+ join(__dirname, '..', '..', 'lib', 'session', 'metadata.js'),
29
+ join(homedir(), '.teleportation', 'lib', 'session', 'metadata.js'),
30
+ ];
31
+
32
+ for (const path of possiblePaths) {
33
+ try {
34
+ const mod = await import('file://' + path);
35
+ extractSessionMetadata = mod.extractSessionMetadata;
36
+ break;
37
+ } catch (e) {
38
+ // Try next path
39
+ }
40
+ }
41
+ }
42
+
43
+ if (!extractSessionMetadata) return {};
44
+
45
+ try {
46
+ return await extractSessionMetadata(cwd);
47
+ } catch (e) {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ const fetchJson = async (url, opts) => {
53
+ const res = await fetch(url, opts);
54
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
55
+ return res.json();
56
+ };
57
+
58
+ (async () => {
59
+ // Debug: Log hook invocation
60
+ const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-hook.log';
61
+ const log = (msg) => {
62
+ const timestamp = new Date().toISOString();
63
+ const logMsg = `[${timestamp}] ${msg}\n`;
64
+ try {
65
+ appendFileSync(hookLogFile, logMsg);
66
+ } catch (e) {
67
+ // Silently ignore log failures - don't write to stderr as it shows in UI
68
+ }
69
+ };
70
+
71
+ log('=== Hook invoked ===');
72
+
73
+ const raw = await readStdin();
74
+ let input;
75
+ try { input = JSON.parse(raw || '{}'); }
76
+ catch (e) {
77
+ log(`ERROR: Invalid JSON: ${e.message}`);
78
+ return exit(0);
79
+ }
80
+
81
+ let { session_id, tool_name, tool_input } = input || {};
82
+ let claude_session_id = session_id; // Keep original ID
83
+
84
+ log(`Session ID: ${session_id}, Tool: ${tool_name}, Input: ${JSON.stringify(tool_input).substring(0, 100)}`);
85
+
86
+ // Check for /away and /back commands (user typing in Claude Code)
87
+ // These are special commands to toggle away mode
88
+ const command = tool_input?.command || tool_input?.text || '';
89
+ if (typeof command === 'string') {
90
+ const trimmedCmd = command.trim().toLowerCase();
91
+ if (trimmedCmd === '/away' || trimmedCmd === 'teleportation away') {
92
+ log('Detected /away command - setting away mode');
93
+ // Will set away mode after loading config
94
+ tool_input.__teleportation_away = true;
95
+ } else if (trimmedCmd === '/back' || trimmedCmd === 'teleportation back') {
96
+ log('Detected /back command - clearing away mode');
97
+ tool_input.__teleportation_back = true;
98
+ }
99
+ }
100
+
101
+ // Load config from encrypted credentials, legacy config file, or env vars
102
+ let config;
103
+ try {
104
+ const { loadConfig } = await import('./config-loader.mjs');
105
+ config = await loadConfig();
106
+ } catch (e) {
107
+ // Fallback to environment variables if config loader fails
108
+ config = {
109
+ relayApiUrl: env.RELAY_API_URL || '',
110
+ relayApiKey: env.RELAY_API_KEY || '',
111
+ slackWebhookUrl: env.SLACK_WEBHOOK_URL || ''
112
+ };
113
+ }
114
+
115
+ // Prioritize environment variables over config file (for testing)
116
+ const SLACK_WEBHOOK_URL = env.SLACK_WEBHOOK_URL || config.slackWebhookUrl || '';
117
+ const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
118
+ const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
119
+ const DAEMON_PORT = config.daemonPort || env.TELEPORTATION_DAEMON_PORT || '3050';
120
+ const DAEMON_ENABLED = config.daemonEnabled !== false && env.TELEPORTATION_DAEMON_ENABLED !== 'false';
121
+ const CONTEXT_DELIVERY_ENABLED = config.contextDeliveryEnabled !== false && env.TELEPORTATION_CONTEXT_DELIVERY_ENABLED !== 'false';
122
+
123
+ // Fast polling timeout - how long to wait before handing off to daemon
124
+ // Default: 60 seconds - provides seamless experience before daemon handoff
125
+ // If daemon is disabled, falls back to 2-hour timeout (old behavior)
126
+ const FAST_POLL_TIMEOUT_MS = env.FAST_POLL_TIMEOUT_MS
127
+ ? parseInt(env.FAST_POLL_TIMEOUT_MS, 10)
128
+ : 60_000; // 60 seconds (increased from 10s for better UX)
129
+ const APPROVAL_TIMEOUT_MS = DAEMON_ENABLED ? FAST_POLL_TIMEOUT_MS :
130
+ (config.approvalTimeout !== undefined ? config.approvalTimeout :
131
+ (env.APPROVAL_TIMEOUT_MS ? parseInt(env.APPROVAL_TIMEOUT_MS, 10) : 7_200_000));
132
+
133
+ // Polling interval - how often to check for approval decision
134
+ // Default: 5 seconds - reduces API load for long waits
135
+ const POLLING_INTERVAL_MS = config.pollingInterval ||
136
+ (env.POLLING_INTERVAL_MS ? parseInt(env.POLLING_INTERVAL_MS, 10) : 5_000);
137
+
138
+ // Whether to wait indefinitely (until approval or session ends)
139
+ const WAIT_INDEFINITELY = APPROVAL_TIMEOUT_MS === 0 || APPROVAL_TIMEOUT_MS === -1;
140
+
141
+ // NOTE: We do NOT auto-approve any tools locally.
142
+ // All tool requests are sent to the remote approval system so the user
143
+ // can approve/deny from their mobile device. This enables true remote control.
144
+
145
+ // Helper: Format daemon work results into a human-readable message
146
+ const formatDaemonUpdate = (results) => {
147
+ if (!results || results.length === 0) return '';
148
+
149
+ // Check if any browser tasks were completed
150
+ const hasBrowserTasks = results.some(r => {
151
+ const toolName = (r.tool_name || '').toLowerCase();
152
+ const command = (r.command || '').toLowerCase();
153
+ return toolName.includes('browser') || toolName.includes('mcp') ||
154
+ command.includes('browser') || command.includes('mcp');
155
+ });
156
+
157
+ const taskType = hasBrowserTasks ? 'browser/interactive task' : 'task';
158
+ const header = `🤖 **Daemon Work Update** (${results.length} ${taskType}${results.length > 1 ? 's' : ''} completed while you were away)\n\n`;
159
+
160
+ const formatOutput = (output, toolName) => {
161
+ if (!output || output.trim() === '') return '(No output)';
162
+
163
+ // Try to detect and format JSON output
164
+ try {
165
+ const parsed = JSON.parse(output);
166
+ // For browser snapshots or large JSON, provide a summary
167
+ if (parsed.type === 'snapshot' || parsed.type === 'accessibility') {
168
+ const url = parsed.url || parsed.page?.url || '';
169
+ const title = parsed.title || parsed.page?.title || '';
170
+ const elements = parsed.elements?.length || parsed.children?.length || 0;
171
+ return `Browser snapshot captured:\n • URL: ${url}\n • Title: ${title}\n • Elements: ${elements}\n • Full snapshot available in output`;
172
+ }
173
+ // For other JSON, format nicely
174
+ return JSON.stringify(parsed, null, 2);
175
+ } catch {
176
+ // Not JSON, return as-is but format better
177
+ return output;
178
+ }
179
+ };
180
+
181
+ const formatToolName = (toolName, command) => {
182
+ if (toolName && toolName.toLowerCase().includes('browser')) return '🌐 Browser';
183
+ if (toolName && toolName.toLowerCase().includes('mcp')) return '🔌 MCP Tool';
184
+ if (command && command.toLowerCase().includes('browser')) return '🌐 Browser';
185
+ if (command && command.toLowerCase().includes('mcp')) return '🔌 MCP Tool';
186
+ return toolName || 'Command';
187
+ };
188
+
189
+ const details = results.map(r => {
190
+ // Determine success: exit_code 0 OR if there's meaningful output (browser tasks may not use exit codes)
191
+ const hasOutput = (r.stdout && r.stdout.trim()) || (r.stderr && r.stderr.trim());
192
+ const isSuccess = r.exit_code === 0 || r.exit_code === null || (hasOutput && !r.stderr);
193
+ const status = isSuccess ? '✅ Success' : `❌ Failed${r.exit_code !== null ? ` (Exit: ${r.exit_code})` : ''}`;
194
+ const time = new Date(r.executed_at).toLocaleTimeString();
195
+
196
+ const toolName = formatToolName(r.tool_name, r.command);
197
+ const output = r.stdout || r.stderr || '';
198
+ const formattedOutput = formatOutput(output, r.tool_name);
199
+
200
+ // For browser tasks or large outputs, provide a summary first
201
+ const isBrowserTask = toolName.includes('Browser') || toolName.includes('MCP');
202
+ const outputPreview = isBrowserTask && output.length > 1000
203
+ ? formattedOutput.split('\n').slice(0, 20).join('\n') + '\n...(see full output below)...'
204
+ : formattedOutput.length > 2000
205
+ ? formattedOutput.substring(0, 2000) + '\n...(truncated, see full output for details)...'
206
+ : formattedOutput;
207
+
208
+ let resultText = `**${toolName}:** ${r.command || '(task completed)'}\n`;
209
+ resultText += `**Status:** ${status} at ${time}\n`;
210
+
211
+ if (output.trim()) {
212
+ resultText += `\n**Result:**\n`;
213
+ // Use code blocks only for structured data, plain text otherwise
214
+ if (formattedOutput.includes('\n') || formattedOutput.length > 100) {
215
+ resultText += `\`\`\`\n${outputPreview}\n\`\`\`\n`;
216
+ } else {
217
+ resultText += `${outputPreview}\n`;
218
+ }
219
+ }
220
+
221
+ return resultText;
222
+ }).join('\n---\n\n');
223
+
224
+ const successCount = results.filter(r => {
225
+ const hasOutput = (r.stdout && r.stdout.trim()) || (r.stderr && r.stderr.trim());
226
+ return r.exit_code === 0 || r.exit_code === null || (hasOutput && !r.stderr);
227
+ }).length;
228
+ const failCount = results.length - successCount;
229
+ const summary = `\n**Summary:** ${successCount} successful, ${failCount} failed.`;
230
+
231
+ // Add a prompt to ensure results are acknowledged
232
+ const browserTaskCount = results.filter(r => {
233
+ const toolName = (r.tool_name || '').toLowerCase();
234
+ const command = (r.command || '').toLowerCase();
235
+ return toolName.includes('browser') || toolName.includes('mcp') ||
236
+ command.includes('browser') || command.includes('mcp');
237
+ }).length;
238
+
239
+ const footer = browserTaskCount > 0
240
+ ? `\n\n💡 **Note:** Browser task results are included above. Please review and summarize what was accomplished.`
241
+ : '';
242
+
243
+ let message = header + details + summary + footer;
244
+
245
+ // Increase limit for browser tasks (they need more space)
246
+ const maxLength = 10000; // Increased from 5000
247
+ if (message.length > maxLength) {
248
+ message = message.substring(0, maxLength) + '\n\n...(output truncated, check full results for complete details)...';
249
+ }
250
+
251
+ return message;
252
+ };
253
+
254
+ // Register session: relay first (source of truth), then daemon
255
+ const cwd = process.cwd();
256
+ const meta = await getSessionMetadata(cwd);
257
+ log(`Session metadata: project=${meta.project_name}, hostname=${meta.hostname}, branch=${meta.current_branch}, model=${meta.current_model || 'default'}`);
258
+
259
+ // Check if model has changed since last tool use
260
+ // This detects when user runs /model to switch models mid-session
261
+ const LAST_MODEL_FILE = join(tmpdir(), `teleportation-last-model-${session_id}.txt`);
262
+ let modelChanged = false;
263
+ try {
264
+ const { readFile, writeFile } = await import('fs/promises');
265
+ let lastModel = null;
266
+ try {
267
+ lastModel = (await readFile(LAST_MODEL_FILE, 'utf8')).trim();
268
+ } catch (e) {
269
+ // File doesn't exist yet - first tool use
270
+ }
271
+
272
+ if (lastModel && meta.current_model && lastModel !== meta.current_model) {
273
+ modelChanged = true;
274
+ log(`Model changed detected: ${lastModel} -> ${meta.current_model}`);
275
+
276
+ // Log model change to timeline
277
+ if (RELAY_API_URL && RELAY_API_KEY) {
278
+ try {
279
+ await fetch(`${RELAY_API_URL}/api/timeline/log`, {
280
+ method: 'POST',
281
+ headers: {
282
+ 'Content-Type': 'application/json',
283
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
284
+ },
285
+ body: JSON.stringify({
286
+ session_id,
287
+ event_type: 'model_changed',
288
+ data: {
289
+ previous_model: lastModel,
290
+ new_model: meta.current_model,
291
+ timestamp: Date.now()
292
+ }
293
+ })
294
+ });
295
+ log(`Model change logged to timeline`);
296
+ } catch (e) {
297
+ log(`Failed to log model change: ${e.message}`);
298
+ }
299
+ }
300
+ }
301
+
302
+ // Update last known model
303
+ if (meta.current_model) {
304
+ await writeFile(LAST_MODEL_FILE, meta.current_model, { mode: 0o600 });
305
+ }
306
+ } catch (e) {
307
+ log(`Model change detection error: ${e.message}`);
308
+ }
309
+
310
+ // 1. Register with relay first - makes session visible in mobile UI
311
+ if (session_id && RELAY_API_URL && RELAY_API_KEY) {
312
+ try {
313
+ log(`Registering session with relay: ${session_id}`);
314
+ const { ensureSessionRegistered } = await import('./session-register.mjs');
315
+ await ensureSessionRegistered(session_id, cwd, config);
316
+ log(`Session registered with relay successfully`);
317
+
318
+ // If model changed, update session metadata immediately
319
+ if (modelChanged) {
320
+ const { updateSessionMetadata } = await import('./session-register.mjs');
321
+ await updateSessionMetadata(session_id, cwd, config);
322
+ log(`Session metadata updated with new model`);
323
+ }
324
+ } catch (e) {
325
+ log(`Warning: Failed to register session with relay: ${e.message}`);
326
+ }
327
+ }
328
+
329
+ // 2. Then register with daemon (local infrastructure for this session)
330
+ if (session_id && DAEMON_ENABLED) {
331
+ try {
332
+ const daemonUrl = `http://127.0.0.1:${DAEMON_PORT}`;
333
+ log(`Registering session with daemon: ${session_id}`);
334
+
335
+ const res = await fetch(`${daemonUrl}/sessions/register`, {
336
+ method: 'POST',
337
+ headers: { 'Content-Type': 'application/json' },
338
+ body: JSON.stringify({ session_id, claude_session_id, cwd, meta })
339
+ }).catch(e => {
340
+ log(`Daemon registration fetch error: ${e.message}`);
341
+ return null;
342
+ });
343
+ if (res && res.ok) {
344
+ log(`Session registered with daemon successfully`);
345
+ } else if (res) {
346
+ log(`Daemon registration returned status ${res.status}`);
347
+ }
348
+ } catch (e) {
349
+ log(`Warning: Failed to register session with daemon: ${e.message}`);
350
+ }
351
+ }
352
+
353
+ // Check for pending results from daemon execution
354
+ if (session_id && RELAY_API_URL && RELAY_API_KEY && CONTEXT_DELIVERY_ENABLED) {
355
+ try {
356
+ log(`Checking for pending results for session: ${session_id}`);
357
+ const results = await fetchJson(`${RELAY_API_URL}/api/sessions/${session_id}/results/pending`, {
358
+ headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
359
+ });
360
+
361
+ if (results && results.length > 0) {
362
+ log(`Found ${results.length} pending results. Formatting update and denying current request to deliver context.`);
363
+
364
+ // Mark results as delivered in parallel (best-effort, but wait before exiting)
365
+ try {
366
+ await Promise.allSettled(
367
+ results.map(r =>
368
+ fetch(`${RELAY_API_URL}/api/sessions/${session_id}/results/${r.result_id}/delivered`, {
369
+ method: 'POST',
370
+ headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
371
+ }).catch(e => {
372
+ log(`Failed to mark result ${r.result_id} delivered: ${e.message}`);
373
+ })
374
+ )
375
+ );
376
+ } catch (markErr) {
377
+ log(`Warning: Error while marking results delivered: ${markErr.message}`);
378
+ }
379
+
380
+ const updateMessage = formatDaemonUpdate(results);
381
+ // Log the daemon update but allow the current tool to proceed
382
+ // This prevents blocking errors while still informing Claude of daemon work
383
+ log(`Daemon update delivered: ${updateMessage.substring(0, 200)}...`);
384
+
385
+ // Output the update message to Claude by denying the current request
386
+ // This forces Claude to read the update before retrying the tool
387
+ const out = {
388
+ hookSpecificOutput: {
389
+ hookEventName: 'PreToolUse',
390
+ permissionDecision: 'deny', // Deny to force reading the update
391
+ permissionDecisionReason: updateMessage
392
+ },
393
+ suppressOutput: true
394
+ };
395
+ stdout.write(JSON.stringify(out));
396
+ return exit(0);
397
+ }
398
+ } catch (e) {
399
+ log(`Warning: Failed to check pending results: ${e.message}`);
400
+ }
401
+ }
402
+
403
+ // Helper: Update session daemon state
404
+ const updateSessionState = async (updates) => {
405
+ if (!session_id || !RELAY_API_URL || !RELAY_API_KEY) return;
406
+ try {
407
+ log(`Updating session state: ${JSON.stringify(updates)}`);
408
+ await fetchJson(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
409
+ method: 'PATCH',
410
+ headers: {
411
+ 'Content-Type': 'application/json',
412
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
413
+ },
414
+ body: JSON.stringify(updates)
415
+ });
416
+ } catch (e) {
417
+ log(`Warning: Failed to update session state: ${e.message}`);
418
+ }
419
+ };
420
+
421
+ // SMART AWAY MODE: Auto-mark as present ("back") on any activity
422
+ // If the user is typing commands locally, they are clearly not away.
423
+ await updateSessionState({ is_away: false });
424
+
425
+ // Note: Approval invalidation is handled by PermissionRequest hook
426
+ // to avoid race conditions and duplicate API calls
427
+
428
+ // PreToolUse now only handles:
429
+ // 1. Session registration with daemon
430
+ // 2. Checking for pending daemon results
431
+ // 3. Marking user as present (not away)
432
+ //
433
+ // Remote approvals are handled by PermissionRequest hook
434
+ // Tool execution logging is handled by PostToolUse hook
435
+
436
+ log(`PreToolUse complete for ${tool_name} - letting Claude Code proceed`);
437
+
438
+ // Don't output anything - let Claude Code handle permissions with its own system
439
+ // The PermissionRequest hook will handle remote approvals if user is away
440
+ // The PostToolUse hook will record tool executions to the timeline
441
+ return exit(0);
442
+ })().catch(err => {
443
+ // Log to file but don't write to stderr - stderr shows in UI as "hook error"
444
+ try {
445
+ const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-hook.log';
446
+ appendFileSync(hookLogFile, `[${new Date().toISOString()}] FATAL: ${err.message}\n${err.stack}\n`);
447
+ } catch (e) {
448
+ // Silently ignore
449
+ }
450
+ exit(0);
451
+ });