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,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
|
+
});
|