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,307 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * PermissionRequest Hook
4
+ *
5
+ * This hook fires when Claude Code is about to ask the user for permission.
6
+ * This is the RIGHT place to create remote approvals because:
7
+ * 1. We know Claude Code needs user permission (not auto-approved)
8
+ * 2. We can intercept and handle remotely if user is away
9
+ *
10
+ * Flow:
11
+ * - If user is PRESENT: Let Claude Code show its normal permission dialog
12
+ * - If user is AWAY: Create remote approval and wait for mobile response
13
+ */
14
+
15
+ import { stdin, stdout, exit, env } from 'node:process';
16
+ import { appendFileSync } from 'node:fs';
17
+ import { readFile } from 'node:fs/promises';
18
+ import { fileURLToPath } from 'url';
19
+ import { dirname, join } from 'path';
20
+ import { homedir } from 'os';
21
+
22
+ const __filename = fileURLToPath(import.meta.url);
23
+ const __dirname = dirname(__filename);
24
+
25
+ // Lazy-load metadata extraction
26
+ let extractSessionMetadata = null;
27
+ async function getSessionMetadata(cwd) {
28
+ if (!extractSessionMetadata) {
29
+ try {
30
+ // Try multiple paths for the metadata module
31
+ const possiblePaths = [
32
+ join(__dirname, '..', '..', 'lib', 'session', 'metadata.js'),
33
+ join(homedir(), '.teleportation', 'lib', 'session', 'metadata.js'),
34
+ ];
35
+
36
+ for (const path of possiblePaths) {
37
+ try {
38
+ const mod = await import('file://' + path);
39
+ extractSessionMetadata = mod.extractSessionMetadata;
40
+ break;
41
+ } catch (e) {
42
+ // Try next path
43
+ }
44
+ }
45
+ } catch (e) {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ if (!extractSessionMetadata) return {};
51
+
52
+ try {
53
+ return await extractSessionMetadata(cwd);
54
+ } catch (e) {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ // Load version info from ~/.teleportation/version.json
60
+ async function loadVersionInfo() {
61
+ try {
62
+ const versionFile = join(homedir(), '.teleportation', 'version.json');
63
+ const content = await readFile(versionFile, 'utf8');
64
+ return JSON.parse(content);
65
+ } catch (e) {
66
+ // Version file may not exist on first run - this is expected
67
+ return null;
68
+ }
69
+ }
70
+
71
+ const readStdin = () => new Promise((resolve, reject) => {
72
+ let data = '';
73
+ stdin.setEncoding('utf8');
74
+ stdin.on('data', chunk => data += chunk);
75
+ stdin.on('end', () => resolve(data));
76
+ stdin.on('error', reject);
77
+ });
78
+
79
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
80
+
81
+ const isValidSessionId = (id) => {
82
+ return id && /^[a-f0-9-]{36}$/i.test(id);
83
+ };
84
+
85
+ const fetchJson = async (url, opts) => {
86
+ const res = await fetch(url, opts);
87
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
88
+ return res.json();
89
+ };
90
+
91
+ (async () => {
92
+ const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-hook.log';
93
+ const log = (msg) => {
94
+ const timestamp = new Date().toISOString();
95
+ try {
96
+ appendFileSync(hookLogFile, `[${timestamp}] [PermissionRequest] ${msg}\n`);
97
+ } catch (e) {}
98
+ };
99
+
100
+ log('=== PermissionRequest Hook invoked ===');
101
+
102
+ const raw = await readStdin();
103
+ let input;
104
+ try {
105
+ input = JSON.parse(raw || '{}');
106
+ } catch (e) {
107
+ log(`ERROR: Invalid JSON: ${e.message}`);
108
+ return exit(0);
109
+ }
110
+
111
+ const { session_id, tool_name, tool_input, cwd } = input || {};
112
+ log(`Session: ${session_id}, Tool: ${tool_name}, CWD: ${cwd}`);
113
+
114
+ // Validate session_id
115
+ if (!isValidSessionId(session_id)) {
116
+ log(`ERROR: Invalid session_id format: ${session_id}`);
117
+ return exit(0);
118
+ }
119
+
120
+ // Load config
121
+ let config;
122
+ try {
123
+ const { loadConfig } = await import('./config-loader.mjs');
124
+ config = await loadConfig();
125
+ } catch (e) {
126
+ config = {
127
+ relayApiUrl: env.RELAY_API_URL || '',
128
+ relayApiKey: env.RELAY_API_KEY || '',
129
+ };
130
+ }
131
+
132
+ const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
133
+ const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
134
+ const POLLING_INTERVAL_MS = parseInt(env.APPROVAL_POLL_INTERVAL_MS || '2000', 10);
135
+ const APPROVAL_TIMEOUT_MS = parseInt(env.APPROVAL_TIMEOUT_MS || '300000', 10); // 5 min default
136
+
137
+ if (!RELAY_API_URL || !RELAY_API_KEY) {
138
+ log('No relay config - letting Claude Code handle permission locally');
139
+ return exit(0);
140
+ }
141
+
142
+ // Check if session is in "away" mode
143
+ let isAway = false;
144
+ try {
145
+ const state = await fetchJson(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
146
+ headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
147
+ });
148
+ isAway = !!state.is_away;
149
+ log(`Session away status: ${isAway}`);
150
+ } catch (e) {
151
+ // Fail-safe: if relay is down, assume user is present (safer default)
152
+ const failSafe = env.AWAY_CHECK_FAIL_SAFE || 'present';
153
+ isAway = failSafe === 'away';
154
+ log(`Could not check away status: ${e.message} - using fail-safe: ${failSafe}`);
155
+ }
156
+
157
+ // If user is NOT away, don't create an approval - let Claude Code handle it locally
158
+ // This prevents stale approvals from appearing in the mobile UI
159
+ if (!isAway) {
160
+ log('User is present - letting Claude Code show permission dialog (no remote approval created)');
161
+ return exit(0);
162
+ }
163
+
164
+ // User is AWAY - create remote approval and poll for decision
165
+ log(`Creating remote approval for ${tool_name}...`);
166
+
167
+ // Extract session metadata (project name, hostname, branch, etc.)
168
+ let meta = {};
169
+ try {
170
+ const workingDir = cwd || process.cwd();
171
+ meta = await getSessionMetadata(workingDir);
172
+ meta.session_id = session_id;
173
+
174
+ // Add version info
175
+ const versionInfo = await loadVersionInfo();
176
+ if (versionInfo) {
177
+ meta.teleportation_version = versionInfo.version;
178
+ meta.protocol_version = versionInfo.protocol_version;
179
+ } else {
180
+ log('Warning: Version info not found (version.json may not exist yet)');
181
+ }
182
+
183
+ // Log metadata status
184
+ if (!meta.project_name && !meta.hostname) {
185
+ log('Warning: Metadata extraction returned empty - metadata module may not be installed');
186
+ } else {
187
+ log(`Session metadata: project=${meta.project_name}, hostname=${meta.hostname}, branch=${meta.current_branch}`);
188
+ }
189
+ } catch (e) {
190
+ log(`Warning: Failed to extract metadata: ${e.message}`);
191
+ }
192
+
193
+ // Invalidate old pending approvals
194
+ try {
195
+ await fetchJson(`${RELAY_API_URL}/api/approvals/invalidate`, {
196
+ method: 'POST',
197
+ headers: {
198
+ 'Content-Type': 'application/json',
199
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
200
+ },
201
+ body: JSON.stringify({ session_id, reason: 'New permission request' })
202
+ });
203
+ } catch (e) {
204
+ log(`Warning: Failed to invalidate old approvals: ${e.message}`);
205
+ }
206
+
207
+ // Create approval request with metadata
208
+ let approvalId;
209
+ try {
210
+ const created = await fetchJson(`${RELAY_API_URL}/api/approvals`, {
211
+ method: 'POST',
212
+ headers: {
213
+ 'Content-Type': 'application/json',
214
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
215
+ },
216
+ body: JSON.stringify({ session_id, tool_name, tool_input, meta })
217
+ });
218
+ approvalId = created.id;
219
+ log(`Approval created: ${approvalId}`);
220
+ } catch (e) {
221
+ log(`ERROR creating approval: ${e.message}`);
222
+ return exit(0); // Let Claude Code handle it
223
+ }
224
+
225
+ // Poll for remote approval decision
226
+ log('Polling for remote approval decision...');
227
+ const AUTO_AWAY_TIMEOUT_MS = parseInt(env.AUTO_AWAY_TIMEOUT_MS || '300000', 10); // 5 min default
228
+ const startTime = Date.now();
229
+ let hasSetAutoAway = false;
230
+ let consecutiveFailures = 0;
231
+ const MAX_CONSECUTIVE_FAILURES = 5;
232
+
233
+ const deadline = Date.now() + APPROVAL_TIMEOUT_MS;
234
+ while (Date.now() < deadline) {
235
+ try {
236
+ const status = await fetchJson(`${RELAY_API_URL}/api/approvals/${approvalId}`, {
237
+ headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
238
+ });
239
+ consecutiveFailures = 0; // Reset on success
240
+
241
+ if (status.status === 'allowed') {
242
+ log('Remote approval: ALLOWED');
243
+ const out = {
244
+ hookSpecificOutput: {
245
+ hookEventName: 'PermissionRequest',
246
+ permissionDecision: 'allow',
247
+ permissionDecisionReason: 'Approved remotely via Teleportation'
248
+ },
249
+ suppressOutput: true
250
+ };
251
+ stdout.write(JSON.stringify(out));
252
+ return exit(0);
253
+ }
254
+
255
+ if (status.status === 'denied') {
256
+ log('Remote approval: DENIED');
257
+ const out = {
258
+ hookSpecificOutput: {
259
+ hookEventName: 'PermissionRequest',
260
+ permissionDecision: 'deny',
261
+ permissionDecisionReason: 'Denied remotely via Teleportation'
262
+ },
263
+ suppressOutput: true
264
+ };
265
+ stdout.write(JSON.stringify(out));
266
+ return exit(0);
267
+ }
268
+
269
+ if (status.status === 'invalidated') {
270
+ log('Approval was invalidated - letting Claude Code handle');
271
+ return exit(0);
272
+ }
273
+
274
+ // Auto-set away after timeout (if not already away)
275
+ if (!hasSetAutoAway && (Date.now() - startTime) > AUTO_AWAY_TIMEOUT_MS) {
276
+ const timeoutMinutes = Math.round(AUTO_AWAY_TIMEOUT_MS / 1000 / 60);
277
+ log(`Approval waiting >${timeoutMinutes} minutes - auto-setting away mode`);
278
+ try {
279
+ await fetchJson(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
280
+ method: 'PATCH',
281
+ headers: {
282
+ 'Content-Type': 'application/json',
283
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
284
+ },
285
+ body: JSON.stringify({ is_away: true })
286
+ });
287
+ hasSetAutoAway = true;
288
+ } catch (e) {
289
+ log(`Failed to auto-set away: ${e.message}`);
290
+ }
291
+ }
292
+ } catch (e) {
293
+ consecutiveFailures++;
294
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
295
+ log(`Too many consecutive failures (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}) - aborting poll`);
296
+ return exit(0);
297
+ }
298
+ log(`Poll error (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ${e.message}`);
299
+ }
300
+
301
+ await sleep(POLLING_INTERVAL_MS);
302
+ }
303
+
304
+ // Timeout - let Claude Code handle it
305
+ log('Approval timeout - letting Claude Code handle');
306
+ return exit(0);
307
+ })();
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * PostToolUse Hook
4
+ *
5
+ * This hook fires AFTER a tool has been executed.
6
+ * If we get here, the tool was approved (either auto or manually) and ran.
7
+ *
8
+ * Purpose: Record tool executions to the timeline for activity history.
9
+ */
10
+
11
+ import { stdin, stdout, exit, env } from 'node:process';
12
+ import { appendFileSync } from 'node:fs';
13
+
14
+ const readStdin = () => new Promise((resolve, reject) => {
15
+ let data = '';
16
+ stdin.setEncoding('utf8');
17
+ stdin.on('data', chunk => data += chunk);
18
+ stdin.on('end', () => resolve(data));
19
+ stdin.on('error', reject);
20
+ });
21
+
22
+ const isValidSessionId = (id) => {
23
+ return id && /^[a-f0-9-]{36}$/i.test(id);
24
+ };
25
+
26
+ const TIMELINE_OUTPUT_PREVIEW_MAX_LENGTH = 500;
27
+
28
+ const fetchJson = async (url, opts) => {
29
+ const res = await fetch(url, opts);
30
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
31
+ return res.json();
32
+ };
33
+
34
+ (async () => {
35
+ const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-hook.log';
36
+ const log = (msg) => {
37
+ const timestamp = new Date().toISOString();
38
+ try {
39
+ appendFileSync(hookLogFile, `[${timestamp}] [PostToolUse] ${msg}\n`);
40
+ } catch (e) {}
41
+ };
42
+
43
+ log('=== PostToolUse Hook invoked ===');
44
+
45
+ const raw = await readStdin();
46
+ let input;
47
+ try {
48
+ input = JSON.parse(raw || '{}');
49
+ } catch (e) {
50
+ log(`ERROR: Invalid JSON: ${e.message}`);
51
+ return exit(0);
52
+ }
53
+
54
+ const { session_id, tool_name, tool_input, tool_output } = input || {};
55
+ log(`Session: ${session_id}, Tool: ${tool_name}`);
56
+
57
+ // Validate session_id
58
+ if (!isValidSessionId(session_id)) {
59
+ log(`ERROR: Invalid session_id format: ${session_id}`);
60
+ return exit(0);
61
+ }
62
+
63
+ // Load config
64
+ let config;
65
+ try {
66
+ const { loadConfig } = await import('./config-loader.mjs');
67
+ config = await loadConfig();
68
+ } catch (e) {
69
+ config = {
70
+ relayApiUrl: env.RELAY_API_URL || '',
71
+ relayApiKey: env.RELAY_API_KEY || '',
72
+ };
73
+ }
74
+
75
+ const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
76
+ const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
77
+
78
+ if (!RELAY_API_URL || !RELAY_API_KEY || !session_id) {
79
+ log('No relay config or session - skipping timeline log');
80
+ return exit(0);
81
+ }
82
+
83
+ // Clear any pending approvals for this session since the tool executed successfully
84
+ // This handles the case where Claude Code auto-approved the tool
85
+ try {
86
+ await fetchJson(`${RELAY_API_URL}/api/approvals/invalidate`, {
87
+ method: 'POST',
88
+ headers: {
89
+ 'Content-Type': 'application/json',
90
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
91
+ },
92
+ body: JSON.stringify({
93
+ session_id,
94
+ reason: `Tool ${tool_name} executed (auto-approved by Claude Code)`
95
+ })
96
+ });
97
+ log(`Cleared pending approvals after tool execution: ${tool_name}`);
98
+ } catch (e) {
99
+ log(`Failed to clear pending approvals: ${e.message}`);
100
+ }
101
+
102
+ // Record tool execution to timeline
103
+ try {
104
+ await fetchJson(`${RELAY_API_URL}/api/timeline`, {
105
+ method: 'POST',
106
+ headers: {
107
+ 'Content-Type': 'application/json',
108
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
109
+ },
110
+ body: JSON.stringify({
111
+ session_id,
112
+ type: 'tool_executed',
113
+ data: {
114
+ tool_name,
115
+ tool_input,
116
+ // Include truncated output for context
117
+ tool_output_preview: (() => {
118
+ if (!tool_output) return null;
119
+ try {
120
+ const stringified = JSON.stringify(tool_output);
121
+ const truncated = stringified.slice(0, TIMELINE_OUTPUT_PREVIEW_MAX_LENGTH);
122
+ return stringified.length > TIMELINE_OUTPUT_PREVIEW_MAX_LENGTH ? truncated + '...' : truncated;
123
+ } catch (e) {
124
+ return '[Unserializable output]';
125
+ }
126
+ })()
127
+ }
128
+ })
129
+ });
130
+ log(`Recorded tool execution: ${tool_name}`);
131
+ } catch (e) {
132
+ log(`Failed to record to timeline: ${e.message}`);
133
+ }
134
+
135
+ // PostToolUse hooks don't need to output anything
136
+ return exit(0);
137
+ })();