teleportation-cli 1.1.4 → 1.2.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 +88 -34
- package/.claude/hooks/permission_request.mjs +392 -82
- package/.claude/hooks/post_tool_use.mjs +90 -0
- package/.claude/hooks/pre_tool_use.mjs +247 -305
- package/.claude/hooks/session-register.mjs +94 -105
- package/.claude/hooks/session_end.mjs +41 -42
- package/.claude/hooks/session_start.mjs +45 -60
- package/.claude/hooks/stop.mjs +752 -99
- package/.claude/hooks/user_prompt_submit.mjs +26 -3
- package/README.md +7 -0
- package/lib/auth/api-key.js +12 -0
- package/lib/auth/token-refresh.js +286 -0
- package/lib/cli/daemon-commands.js +1 -1
- package/lib/cli/teleport-commands.js +469 -0
- package/lib/daemon/daemon-v2.js +104 -0
- package/lib/daemon/lifecycle.js +56 -171
- package/lib/daemon/response-classifier.js +15 -1
- package/lib/daemon/services/index.js +3 -0
- package/lib/daemon/services/polling-service.js +173 -0
- package/lib/daemon/services/queue-service.js +318 -0
- package/lib/daemon/services/session-service.js +115 -0
- package/lib/daemon/state.js +35 -0
- package/lib/daemon/task-executor-v2.js +413 -0
- package/lib/daemon/task-executor.js +1235 -0
- package/lib/daemon/teleportation-daemon.js +770 -25
- package/lib/daemon/timeline-analyzer.js +215 -0
- package/lib/daemon/transcript-ingestion.js +696 -0
- package/lib/daemon/utils.js +91 -0
- package/lib/install/installer.js +184 -20
- package/lib/install/uhr-installer.js +136 -0
- package/lib/remote/providers/base-provider.js +46 -0
- package/lib/remote/providers/daytona-provider.js +58 -0
- package/lib/remote/providers/provider-factory.js +90 -19
- package/lib/remote/providers/sprites-provider.js +711 -0
- package/lib/teleport/exporters/claude-exporter.js +302 -0
- package/lib/teleport/exporters/gemini-exporter.js +307 -0
- package/lib/teleport/exporters/index.js +93 -0
- package/lib/teleport/exporters/interface.js +153 -0
- package/lib/teleport/fork-tracker.js +415 -0
- package/lib/teleport/git-committer.js +337 -0
- package/lib/teleport/index.js +48 -0
- package/lib/teleport/manager.js +620 -0
- package/lib/teleport/session-capture.js +282 -0
- package/package.json +11 -5
- package/teleportation-cli.cjs +632 -451
- package/.claude/hooks/heartbeat.mjs +0 -396
- package/lib/daemon/agentic-executor.js +0 -803
- package/lib/daemon/pid-manager.js +0 -160
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { stdin, stdout, exit, env } from 'node:process';
|
|
4
|
-
import { appendFileSync } from 'node:fs';
|
|
4
|
+
import fs, { appendFileSync } from 'node:fs';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
import { dirname, join } from 'path';
|
|
7
7
|
import { homedir, tmpdir } from 'os';
|
|
@@ -55,6 +55,24 @@ const fetchJson = async (url, opts) => {
|
|
|
55
55
|
return res.json();
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
const PRE_TOOL_NETWORK_TIMEOUT_MS = parseInt(env.TELEPORTATION_PRE_TOOL_NETWORK_TIMEOUT_MS || '1500', 10);
|
|
59
|
+
const PRE_TOOL_TOTAL_BUDGET_MS = parseInt(env.TELEPORTATION_PRE_TOOL_TOTAL_BUDGET_MS || '3500', 10);
|
|
60
|
+
|
|
61
|
+
const withTimeout = async (promiseFactory, timeoutMs, label) => {
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
64
|
+
try {
|
|
65
|
+
return await promiseFactory(controller.signal);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error?.name === 'AbortError') {
|
|
68
|
+
throw new Error(`${label} timed out after ${timeoutMs}ms`);
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timeoutId);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
58
76
|
(async () => {
|
|
59
77
|
// Debug: Log hook invocation
|
|
60
78
|
const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-hook.log';
|
|
@@ -68,19 +86,45 @@ const fetchJson = async (url, opts) => {
|
|
|
68
86
|
}
|
|
69
87
|
};
|
|
70
88
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
catch (e) {
|
|
77
|
-
log(`
|
|
78
|
-
return exit(0);
|
|
89
|
+
// Read and parse stdin to get hook input
|
|
90
|
+
let input = {};
|
|
91
|
+
try {
|
|
92
|
+
const raw = await readStdin();
|
|
93
|
+
input = JSON.parse(raw || '{}');
|
|
94
|
+
} catch (e) {
|
|
95
|
+
log(`[PreToolUse] Failed to parse stdin: ${e.message}`);
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
let { session_id, tool_name, tool_input } = input || {};
|
|
82
99
|
tool_input = tool_input && typeof tool_input === 'object' ? tool_input : {};
|
|
83
100
|
let claude_session_id = session_id; // Keep original ID
|
|
101
|
+
const hookStartMs = Date.now();
|
|
102
|
+
const remainingBudgetMs = () => PRE_TOOL_TOTAL_BUDGET_MS - (Date.now() - hookStartMs);
|
|
103
|
+
const hasBudget = (reserveMs = 150) => remainingBudgetMs() > reserveMs;
|
|
104
|
+
const getRequestTimeoutMs = () => {
|
|
105
|
+
const remaining = remainingBudgetMs();
|
|
106
|
+
if (remaining <= 200) return 0;
|
|
107
|
+
return Math.max(200, Math.min(PRE_TOOL_NETWORK_TIMEOUT_MS, remaining - 100));
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Detect message source
|
|
111
|
+
const isAutonomousTask = env.TELEPORTATION_TASK_MODE === 'true' || !!env.TELEPORTATION_PARENT_SESSION_ID;
|
|
112
|
+
const source = isAutonomousTask ? 'autonomous_task' : 'cli_interactive';
|
|
113
|
+
|
|
114
|
+
// 1. Recursion Guard (Critical Stability Fix)
|
|
115
|
+
// Prevent infinite hook-triggered tool loops
|
|
116
|
+
// Whitelist safe tools at higher depths to avoid breaking workflows
|
|
117
|
+
const SAFE_TOOLS = ['read', 'glob', 'grep', 'websearch', 'bashoutput', 'ls', 'pwd', 'git status'];
|
|
118
|
+
const RECURSION_DEPTH = parseInt(env.TELEPORTATION_HOOK_DEPTH || '0', 10) || 0;
|
|
119
|
+
|
|
120
|
+
if (RECURSION_DEPTH > 5 && !SAFE_TOOLS.includes(tool_name?.toLowerCase())) {
|
|
121
|
+
log(`[RECURSION] Depth limit reached (${RECURSION_DEPTH}) for tool "${tool_name}", auto-allowing to prevent infinite loop.`);
|
|
122
|
+
process.stdout.write(JSON.stringify({ decision: 'allow', reason: 'Recursion guard depth limit' }));
|
|
123
|
+
return exit(0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Update environment for child processes
|
|
127
|
+
process.env.TELEPORTATION_HOOK_DEPTH = (RECURSION_DEPTH + 1).toString();
|
|
84
128
|
|
|
85
129
|
log(`Session ID: ${session_id}, Tool: ${tool_name}, Input: ${JSON.stringify(tool_input).substring(0, 100)}`);
|
|
86
130
|
|
|
@@ -89,11 +133,18 @@ const fetchJson = async (url, opts) => {
|
|
|
89
133
|
const command = tool_input?.command || tool_input?.text || '';
|
|
90
134
|
if (typeof command === 'string') {
|
|
91
135
|
const trimmedCmd = command.trim().toLowerCase();
|
|
92
|
-
|
|
136
|
+
// Support multiple command formats: /away, teleportation away, teleport away, teleporation away (typo)
|
|
137
|
+
if (trimmedCmd === '/away' ||
|
|
138
|
+
trimmedCmd === 'teleportation away' ||
|
|
139
|
+
trimmedCmd === 'teleport away' ||
|
|
140
|
+
trimmedCmd === 'teleporation away') {
|
|
93
141
|
log('Detected /away command - setting away mode');
|
|
94
142
|
// Will set away mode after loading config
|
|
95
143
|
tool_input.__teleportation_away = true;
|
|
96
|
-
} else if (trimmedCmd === '/back' ||
|
|
144
|
+
} else if (trimmedCmd === '/back' ||
|
|
145
|
+
trimmedCmd === 'teleportation back' ||
|
|
146
|
+
trimmedCmd === 'teleport back' ||
|
|
147
|
+
trimmedCmd === 'teleporation back') {
|
|
97
148
|
log('Detected /back command - clearing away mode');
|
|
98
149
|
tool_input.__teleportation_back = true;
|
|
99
150
|
}
|
|
@@ -119,7 +170,6 @@ const fetchJson = async (url, opts) => {
|
|
|
119
170
|
const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
|
|
120
171
|
const DAEMON_PORT = config.daemonPort || env.TELEPORTATION_DAEMON_PORT || '3050';
|
|
121
172
|
const DAEMON_ENABLED = config.daemonEnabled !== false && env.TELEPORTATION_DAEMON_ENABLED !== 'false';
|
|
122
|
-
const CONTEXT_DELIVERY_ENABLED = config.contextDeliveryEnabled !== false && env.TELEPORTATION_CONTEXT_DELIVERY_ENABLED !== 'false';
|
|
123
173
|
|
|
124
174
|
// Fast polling timeout - how long to wait before handing off to daemon
|
|
125
175
|
// Default: 60 seconds - provides seamless experience before daemon handoff
|
|
@@ -143,171 +193,24 @@ const fetchJson = async (url, opts) => {
|
|
|
143
193
|
// All tool requests are sent to the remote approval system so the user
|
|
144
194
|
// can approve/deny from their mobile device. This enables true remote control.
|
|
145
195
|
|
|
146
|
-
// Helper: Format daemon work results into a human-readable message
|
|
147
|
-
// Enhanced for PRD-0013 Phase 1: Context Preservation
|
|
148
|
-
const formatDaemonUpdate = (results) => {
|
|
149
|
-
if (!results || results.length === 0) return '';
|
|
150
|
-
|
|
151
|
-
// Check if any browser tasks were completed
|
|
152
|
-
const hasBrowserTasks = results.some(r => {
|
|
153
|
-
const toolName = (r.tool_name || '').toLowerCase();
|
|
154
|
-
const command = (r.command || '').toLowerCase();
|
|
155
|
-
return toolName.includes('browser') || toolName.includes('mcp') ||
|
|
156
|
-
command.includes('browser') || command.includes('mcp');
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
const taskType = hasBrowserTasks ? 'browser/interactive task' : 'task';
|
|
160
|
-
let header = `🤖 **Daemon Work Update** (${results.length} ${taskType}${results.length > 1 ? 's' : ''} completed while you were away)\n\n`;
|
|
161
|
-
|
|
162
|
-
// PRD-0013 Phase 1: Show original context if available
|
|
163
|
-
// This helps Claude remember what the user originally requested
|
|
164
|
-
const firstResult = results[0];
|
|
165
|
-
if (firstResult?.original_context?.user_last_message) {
|
|
166
|
-
const context = firstResult.original_context;
|
|
167
|
-
const timeElapsed = context.timestamp
|
|
168
|
-
? Date.now() - context.timestamp
|
|
169
|
-
: null;
|
|
170
|
-
|
|
171
|
-
// Format time elapsed
|
|
172
|
-
let timeAgo = '';
|
|
173
|
-
if (timeElapsed) {
|
|
174
|
-
const seconds = Math.floor(timeElapsed / 1000);
|
|
175
|
-
const minutes = Math.floor(seconds / 60);
|
|
176
|
-
const hours = Math.floor(minutes / 60);
|
|
177
|
-
|
|
178
|
-
if (hours > 0) {
|
|
179
|
-
timeAgo = `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
|
180
|
-
} else if (minutes > 0) {
|
|
181
|
-
timeAgo = `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
|
182
|
-
} else {
|
|
183
|
-
timeAgo = `${seconds} second${seconds > 1 ? 's' : ''} ago`;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
header += `**Original Request**${timeAgo ? ` (${timeAgo})` : ''}:\n`;
|
|
188
|
-
header += `> ${context.user_last_message}\n\n`;
|
|
189
|
-
|
|
190
|
-
// Show original tool_use_id if available (helps with debugging/tracing)
|
|
191
|
-
if (firstResult.original_tool_use_id) {
|
|
192
|
-
header += `_Results for tool ID: ${firstResult.original_tool_use_id}_\n\n`;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// PRD-0013 Phase 2: Show task description and next step suggestion if available
|
|
196
|
-
// For Phase 1, task_description will be null, so this won't appear yet
|
|
197
|
-
if (context.task_description) {
|
|
198
|
-
header += `**Next Step:**\n`;
|
|
199
|
-
header += `${context.task_description}\n\n`;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const formatOutput = (output, toolName) => {
|
|
204
|
-
if (!output || output.trim() === '') return '(No output)';
|
|
205
|
-
|
|
206
|
-
// Try to detect and format JSON output
|
|
207
|
-
try {
|
|
208
|
-
const parsed = JSON.parse(output);
|
|
209
|
-
// For browser snapshots or large JSON, provide a summary
|
|
210
|
-
if (parsed.type === 'snapshot' || parsed.type === 'accessibility') {
|
|
211
|
-
const url = parsed.url || parsed.page?.url || '';
|
|
212
|
-
const title = parsed.title || parsed.page?.title || '';
|
|
213
|
-
const elements = parsed.elements?.length || parsed.children?.length || 0;
|
|
214
|
-
return `Browser snapshot captured:\n • URL: ${url}\n • Title: ${title}\n • Elements: ${elements}\n • Full snapshot available in output`;
|
|
215
|
-
}
|
|
216
|
-
// For other JSON, format nicely
|
|
217
|
-
return JSON.stringify(parsed, null, 2);
|
|
218
|
-
} catch {
|
|
219
|
-
// Not JSON, return as-is but format better
|
|
220
|
-
return output;
|
|
221
|
-
}
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
const formatToolName = (toolName, command) => {
|
|
225
|
-
if (toolName && toolName.toLowerCase().includes('browser')) return '🌐 Browser';
|
|
226
|
-
if (toolName && toolName.toLowerCase().includes('mcp')) return '🔌 MCP Tool';
|
|
227
|
-
if (command && command.toLowerCase().includes('browser')) return '🌐 Browser';
|
|
228
|
-
if (command && command.toLowerCase().includes('mcp')) return '🔌 MCP Tool';
|
|
229
|
-
return toolName || 'Command';
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
const details = results.map(r => {
|
|
233
|
-
// Determine success: exit_code 0 OR if there's meaningful output (browser tasks may not use exit codes)
|
|
234
|
-
const hasOutput = (r.stdout && r.stdout.trim()) || (r.stderr && r.stderr.trim());
|
|
235
|
-
const isSuccess = r.exit_code === 0 || r.exit_code === null || (hasOutput && !r.stderr);
|
|
236
|
-
const status = isSuccess ? '✅ Success' : `❌ Failed${r.exit_code !== null ? ` (Exit: ${r.exit_code})` : ''}`;
|
|
237
|
-
const time = new Date(r.executed_at).toLocaleTimeString();
|
|
238
|
-
|
|
239
|
-
const toolName = formatToolName(r.tool_name, r.command);
|
|
240
|
-
const output = r.stdout || r.stderr || '';
|
|
241
|
-
const formattedOutput = formatOutput(output, r.tool_name);
|
|
242
|
-
|
|
243
|
-
// For browser tasks or large outputs, provide a summary first
|
|
244
|
-
const isBrowserTask = toolName.includes('Browser') || toolName.includes('MCP');
|
|
245
|
-
const outputPreview = isBrowserTask && output.length > 1000
|
|
246
|
-
? formattedOutput.split('\n').slice(0, 20).join('\n') + '\n...(see full output below)...'
|
|
247
|
-
: formattedOutput.length > 2000
|
|
248
|
-
? formattedOutput.substring(0, 2000) + '\n...(truncated, see full output for details)...'
|
|
249
|
-
: formattedOutput;
|
|
250
|
-
|
|
251
|
-
let resultText = `**${toolName}:** ${r.command || '(task completed)'}\n`;
|
|
252
|
-
resultText += `**Status:** ${status} at ${time}\n`;
|
|
253
|
-
|
|
254
|
-
if (output.trim()) {
|
|
255
|
-
resultText += `\n**Result:**\n`;
|
|
256
|
-
// Use code blocks only for structured data, plain text otherwise
|
|
257
|
-
if (formattedOutput.includes('\n') || formattedOutput.length > 100) {
|
|
258
|
-
resultText += `\`\`\`\n${outputPreview}\n\`\`\`\n`;
|
|
259
|
-
} else {
|
|
260
|
-
resultText += `${outputPreview}\n`;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
return resultText;
|
|
265
|
-
}).join('\n---\n\n');
|
|
266
|
-
|
|
267
|
-
const successCount = results.filter(r => {
|
|
268
|
-
const hasOutput = (r.stdout && r.stdout.trim()) || (r.stderr && r.stderr.trim());
|
|
269
|
-
return r.exit_code === 0 || r.exit_code === null || (hasOutput && !r.stderr);
|
|
270
|
-
}).length;
|
|
271
|
-
const failCount = results.length - successCount;
|
|
272
|
-
const summary = `\n**Summary:** ${successCount} successful, ${failCount} failed.`;
|
|
273
|
-
|
|
274
|
-
// Add a prompt to ensure results are acknowledged
|
|
275
|
-
const browserTaskCount = results.filter(r => {
|
|
276
|
-
const toolName = (r.tool_name || '').toLowerCase();
|
|
277
|
-
const command = (r.command || '').toLowerCase();
|
|
278
|
-
return toolName.includes('browser') || toolName.includes('mcp') ||
|
|
279
|
-
command.includes('browser') || command.includes('mcp');
|
|
280
|
-
}).length;
|
|
281
|
-
|
|
282
|
-
const footer = browserTaskCount > 0
|
|
283
|
-
? `\n\n💡 **Note:** Browser task results are included above. Please review and summarize what was accomplished.`
|
|
284
|
-
: '';
|
|
285
|
-
|
|
286
|
-
let message = header + details + summary + footer;
|
|
287
|
-
|
|
288
|
-
// Increase limit for browser tasks (they need more space)
|
|
289
|
-
const maxLength = 10000; // Increased from 5000
|
|
290
|
-
if (message.length > maxLength) {
|
|
291
|
-
message = message.substring(0, maxLength) + '\n\n...(output truncated, check full results for complete details)...';
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
return message;
|
|
295
|
-
};
|
|
296
|
-
|
|
297
196
|
// Register session: relay first (source of truth), then daemon
|
|
298
197
|
const cwd = process.cwd();
|
|
299
198
|
const meta = await getSessionMetadata(cwd);
|
|
300
199
|
log(`Session metadata: project=${meta.project_name}, hostname=${meta.hostname}, branch=${meta.current_branch}, model=${meta.current_model || 'default'}`);
|
|
301
200
|
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
const
|
|
201
|
+
// Detect model change (PRD-0014)
|
|
202
|
+
// Store model in ~/.teleportation/sessions/ to persist across reboots
|
|
203
|
+
const sessionDir = join(homedir(), '.teleportation', 'sessions');
|
|
204
|
+
if (!fs.existsSync(sessionDir)) {
|
|
205
|
+
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
206
|
+
}
|
|
207
|
+
const modelFile = join(sessionDir, `model_${session_id}.txt`);
|
|
305
208
|
let modelChanged = false;
|
|
306
209
|
try {
|
|
307
210
|
const { readFile, writeFile } = await import('fs/promises');
|
|
308
211
|
let lastModel = null;
|
|
309
212
|
try {
|
|
310
|
-
lastModel = (await readFile(
|
|
213
|
+
lastModel = (await readFile(modelFile, 'utf8')).trim();
|
|
311
214
|
} catch (e) {
|
|
312
215
|
// File doesn't exist yet - first tool use
|
|
313
216
|
}
|
|
@@ -317,24 +220,33 @@ const fetchJson = async (url, opts) => {
|
|
|
317
220
|
log(`Model changed detected: ${lastModel} -> ${meta.current_model}`);
|
|
318
221
|
|
|
319
222
|
// Log model change to timeline
|
|
320
|
-
if (RELAY_API_URL && RELAY_API_KEY) {
|
|
223
|
+
if (RELAY_API_URL && RELAY_API_KEY && hasBudget()) {
|
|
321
224
|
try {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
225
|
+
const timeoutMs = getRequestTimeoutMs();
|
|
226
|
+
if (timeoutMs > 0) {
|
|
227
|
+
await withTimeout(
|
|
228
|
+
(signal) => fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: {
|
|
231
|
+
'Content-Type': 'application/json',
|
|
232
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
233
|
+
},
|
|
234
|
+
body: JSON.stringify({
|
|
235
|
+
session_id,
|
|
236
|
+
type: 'model_changed',
|
|
237
|
+
source,
|
|
238
|
+
data: {
|
|
239
|
+
previous_model: lastModel,
|
|
240
|
+
new_model: meta.current_model,
|
|
241
|
+
timestamp: Date.now()
|
|
242
|
+
}
|
|
243
|
+
}),
|
|
244
|
+
signal
|
|
245
|
+
}),
|
|
246
|
+
timeoutMs,
|
|
247
|
+
'model change timeline post'
|
|
248
|
+
);
|
|
249
|
+
}
|
|
338
250
|
log(`Model change logged to timeline`);
|
|
339
251
|
} catch (e) {
|
|
340
252
|
log(`Failed to log model change: ${e.message}`);
|
|
@@ -344,24 +256,47 @@ const fetchJson = async (url, opts) => {
|
|
|
344
256
|
|
|
345
257
|
// Update last known model
|
|
346
258
|
if (meta.current_model) {
|
|
347
|
-
await writeFile(
|
|
259
|
+
await writeFile(modelFile, meta.current_model, { mode: 0o600 });
|
|
348
260
|
}
|
|
349
261
|
} catch (e) {
|
|
350
262
|
log(`Model change detection error: ${e.message}`);
|
|
351
263
|
}
|
|
352
264
|
|
|
353
265
|
// 1. Register with relay first - makes session visible in mobile UI
|
|
354
|
-
if (session_id && RELAY_API_URL && RELAY_API_KEY) {
|
|
266
|
+
if (session_id && RELAY_API_URL && RELAY_API_KEY && hasBudget()) {
|
|
355
267
|
try {
|
|
356
268
|
log(`Registering session with relay: ${session_id}`);
|
|
357
269
|
const { ensureSessionRegistered } = await import('./session-register.mjs');
|
|
358
|
-
|
|
359
|
-
|
|
270
|
+
const timeoutMs = getRequestTimeoutMs();
|
|
271
|
+
if (timeoutMs <= 0) {
|
|
272
|
+
log('Skipping relay registration due to exhausted hook budget');
|
|
273
|
+
}
|
|
274
|
+
const regResult = timeoutMs > 0
|
|
275
|
+
? await withTimeout(
|
|
276
|
+
() => ensureSessionRegistered(session_id, cwd, config),
|
|
277
|
+
timeoutMs,
|
|
278
|
+
'relay session registration'
|
|
279
|
+
)
|
|
280
|
+
: false;
|
|
281
|
+
|
|
282
|
+
if (typeof regResult === 'object' && regResult.error === 'orphan_api_key') {
|
|
283
|
+
console.error(`\n⚠️ Teleportation: ${regResult.message || 'API key not linked to user.'}`);
|
|
284
|
+
console.error(' Visit https://app.teleportation.dev/api-keys to claim your key.\n');
|
|
285
|
+
} else {
|
|
286
|
+
log(`Session registered with relay successfully`);
|
|
287
|
+
}
|
|
360
288
|
|
|
361
289
|
// If model changed, update session metadata immediately
|
|
362
|
-
if (modelChanged) {
|
|
290
|
+
if (modelChanged && hasBudget() && (regResult === true || (typeof regResult === 'object' && regResult.success))) {
|
|
363
291
|
const { updateSessionMetadata } = await import('./session-register.mjs');
|
|
364
|
-
|
|
292
|
+
const timeoutMs = getRequestTimeoutMs();
|
|
293
|
+
if (timeoutMs > 0) {
|
|
294
|
+
await withTimeout(
|
|
295
|
+
() => updateSessionMetadata(session_id, cwd, config),
|
|
296
|
+
timeoutMs,
|
|
297
|
+
'session metadata update'
|
|
298
|
+
);
|
|
299
|
+
}
|
|
365
300
|
log(`Session metadata updated with new model`);
|
|
366
301
|
}
|
|
367
302
|
} catch (e) {
|
|
@@ -370,23 +305,33 @@ const fetchJson = async (url, opts) => {
|
|
|
370
305
|
}
|
|
371
306
|
|
|
372
307
|
// 2. Then register with daemon (local infrastructure for this session)
|
|
373
|
-
if (session_id && DAEMON_ENABLED) {
|
|
308
|
+
if (session_id && DAEMON_ENABLED && hasBudget()) {
|
|
374
309
|
try {
|
|
375
310
|
const daemonUrl = `http://127.0.0.1:${DAEMON_PORT}`;
|
|
376
311
|
log(`Registering session with daemon: ${session_id}`);
|
|
377
312
|
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
313
|
+
const timeoutMs = getRequestTimeoutMs();
|
|
314
|
+
const res = timeoutMs > 0
|
|
315
|
+
? await withTimeout(
|
|
316
|
+
(signal) => fetch(`${daemonUrl}/sessions/register`, {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers: { 'Content-Type': 'application/json' },
|
|
319
|
+
body: JSON.stringify({ session_id, claude_session_id, cwd, meta }),
|
|
320
|
+
signal
|
|
321
|
+
}),
|
|
322
|
+
timeoutMs,
|
|
323
|
+
'daemon session registration'
|
|
324
|
+
).catch(e => {
|
|
325
|
+
log(`Daemon registration fetch error: ${e.message}`);
|
|
326
|
+
return null;
|
|
327
|
+
})
|
|
328
|
+
: null;
|
|
386
329
|
if (res && res.ok) {
|
|
387
330
|
log(`Session registered with daemon successfully`);
|
|
388
331
|
} else if (res) {
|
|
389
332
|
log(`Daemon registration returned status ${res.status}`);
|
|
333
|
+
} else if (timeoutMs <= 0) {
|
|
334
|
+
log('Skipping daemon registration due to exhausted hook budget');
|
|
390
335
|
}
|
|
391
336
|
} catch (e) {
|
|
392
337
|
log(`Warning: Failed to register session with daemon: ${e.message}`);
|
|
@@ -395,93 +340,72 @@ const fetchJson = async (url, opts) => {
|
|
|
395
340
|
|
|
396
341
|
// 3. Log tool_use event to timeline (before execution)
|
|
397
342
|
// This shows what Claude is attempting to do
|
|
398
|
-
if (session_id && RELAY_API_URL && RELAY_API_KEY && tool_name) {
|
|
343
|
+
if (session_id && RELAY_API_URL && RELAY_API_KEY && tool_name && hasBudget()) {
|
|
399
344
|
try {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
345
|
+
const timeoutMs = getRequestTimeoutMs();
|
|
346
|
+
if (timeoutMs > 0) {
|
|
347
|
+
await withTimeout(
|
|
348
|
+
(signal) => fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
349
|
+
method: 'POST',
|
|
350
|
+
headers: {
|
|
351
|
+
'Content-Type': 'application/json',
|
|
352
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
353
|
+
},
|
|
354
|
+
body: JSON.stringify({
|
|
355
|
+
session_id,
|
|
356
|
+
type: 'tool_use',
|
|
357
|
+
source,
|
|
358
|
+
data: {
|
|
359
|
+
tool_name,
|
|
360
|
+
tool_input: tool_input || {},
|
|
361
|
+
timestamp: Date.now()
|
|
362
|
+
}
|
|
363
|
+
}),
|
|
364
|
+
signal
|
|
365
|
+
}),
|
|
366
|
+
timeoutMs,
|
|
367
|
+
'tool_use timeline post'
|
|
368
|
+
);
|
|
369
|
+
}
|
|
416
370
|
log(`Logged tool_use event for ${tool_name}`);
|
|
417
371
|
} catch (e) {
|
|
418
372
|
log(`Failed to log tool_use: ${e.message}`);
|
|
419
373
|
}
|
|
420
374
|
}
|
|
421
375
|
|
|
422
|
-
//
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const results = await fetchJson(`${RELAY_API_URL}/api/sessions/${session_id}/results/pending`, {
|
|
427
|
-
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
if (results && results.length > 0) {
|
|
431
|
-
log(`Found ${results.length} pending results. Formatting update and denying current request to deliver context.`);
|
|
432
|
-
|
|
433
|
-
// Mark results as delivered in parallel (best-effort, but wait before exiting)
|
|
434
|
-
try {
|
|
435
|
-
await Promise.allSettled(
|
|
436
|
-
results.map(r =>
|
|
437
|
-
fetch(`${RELAY_API_URL}/api/sessions/${session_id}/results/${r.result_id}/delivered`, {
|
|
438
|
-
method: 'POST',
|
|
439
|
-
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
|
|
440
|
-
}).catch(e => {
|
|
441
|
-
log(`Failed to mark result ${r.result_id} delivered: ${e.message}`);
|
|
442
|
-
})
|
|
443
|
-
)
|
|
444
|
-
);
|
|
445
|
-
} catch (markErr) {
|
|
446
|
-
log(`Warning: Error while marking results delivered: ${markErr.message}`);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const updateMessage = formatDaemonUpdate(results);
|
|
450
|
-
// Log the daemon update but allow the current tool to proceed
|
|
451
|
-
// This prevents blocking errors while still informing Claude of daemon work
|
|
452
|
-
log(`Daemon update delivered: ${updateMessage.substring(0, 200)}...`);
|
|
453
|
-
|
|
454
|
-
// Output the update message to Claude by denying the current request
|
|
455
|
-
// This forces Claude to read the update before retrying the tool
|
|
456
|
-
const out = {
|
|
457
|
-
hookSpecificOutput: {
|
|
458
|
-
hookEventName: 'PreToolUse',
|
|
459
|
-
permissionDecision: 'deny', // Deny to force reading the update
|
|
460
|
-
permissionDecisionReason: updateMessage
|
|
461
|
-
},
|
|
462
|
-
suppressOutput: true
|
|
463
|
-
};
|
|
464
|
-
stdout.write(JSON.stringify(out));
|
|
465
|
-
return exit(0);
|
|
466
|
-
}
|
|
467
|
-
} catch (e) {
|
|
468
|
-
log(`Warning: Failed to check pending results: ${e.message}`);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
376
|
+
// NOTE: Context delivery via notification has been removed.
|
|
377
|
+
// With parent session context resumption (PR #123), autonomous tasks now
|
|
378
|
+
// automatically resume the parent session's conversation context on turn 1.
|
|
379
|
+
// This eliminates the need for blocking notifications to deliver daemon work updates.
|
|
471
380
|
|
|
472
381
|
// Helper: Update session daemon state
|
|
473
382
|
const updateSessionState = async (updates) => {
|
|
474
383
|
if (!session_id || !RELAY_API_URL || !RELAY_API_KEY) return;
|
|
384
|
+
if (!hasBudget()) {
|
|
385
|
+
log('Skipping session state update due to exhausted hook budget');
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
475
388
|
try {
|
|
476
389
|
log(`Updating session state: ${JSON.stringify(updates)}`);
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
390
|
+
const timeoutMs = getRequestTimeoutMs();
|
|
391
|
+
if (timeoutMs <= 0) {
|
|
392
|
+
log('Skipping daemon-state PATCH due to exhausted hook budget');
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const res = await withTimeout(
|
|
396
|
+
(signal) => fetch(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
|
|
397
|
+
method: 'PATCH',
|
|
398
|
+
headers: {
|
|
399
|
+
'Content-Type': 'application/json',
|
|
400
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
401
|
+
},
|
|
402
|
+
body: JSON.stringify(updates),
|
|
403
|
+
signal
|
|
404
|
+
}),
|
|
405
|
+
timeoutMs,
|
|
406
|
+
'daemon-state patch'
|
|
407
|
+
);
|
|
408
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
485
409
|
} catch (e) {
|
|
486
410
|
log(`Warning: Failed to update session state: ${e.message}`);
|
|
487
411
|
}
|
|
@@ -490,23 +414,32 @@ const fetchJson = async (url, opts) => {
|
|
|
490
414
|
if (tool_input.__teleportation_away) {
|
|
491
415
|
await updateSessionState({ is_away: true });
|
|
492
416
|
// Log away_mode_changed event to timeline
|
|
493
|
-
if (RELAY_API_URL && RELAY_API_KEY) {
|
|
417
|
+
if (RELAY_API_URL && RELAY_API_KEY && hasBudget()) {
|
|
494
418
|
try {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
419
|
+
const timeoutMs = getRequestTimeoutMs();
|
|
420
|
+
if (timeoutMs > 0) {
|
|
421
|
+
await withTimeout(
|
|
422
|
+
(signal) => fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
423
|
+
method: 'POST',
|
|
424
|
+
headers: {
|
|
425
|
+
'Content-Type': 'application/json',
|
|
426
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
427
|
+
},
|
|
428
|
+
body: JSON.stringify({
|
|
429
|
+
session_id,
|
|
430
|
+
type: 'away_mode_changed',
|
|
431
|
+
source,
|
|
432
|
+
data: {
|
|
433
|
+
is_away: true,
|
|
434
|
+
timestamp: Date.now()
|
|
435
|
+
}
|
|
436
|
+
}),
|
|
437
|
+
signal
|
|
438
|
+
}),
|
|
439
|
+
timeoutMs,
|
|
440
|
+
'away-mode timeline post'
|
|
441
|
+
);
|
|
442
|
+
}
|
|
510
443
|
log(`Logged away_mode_changed (away=true) to timeline`);
|
|
511
444
|
} catch (e) {
|
|
512
445
|
log(`Failed to log away_mode_changed: ${e.message}`);
|
|
@@ -527,23 +460,32 @@ const fetchJson = async (url, opts) => {
|
|
|
527
460
|
if (tool_input.__teleportation_back) {
|
|
528
461
|
await updateSessionState({ is_away: false });
|
|
529
462
|
// Log away_mode_changed event to timeline
|
|
530
|
-
if (RELAY_API_URL && RELAY_API_KEY) {
|
|
463
|
+
if (RELAY_API_URL && RELAY_API_KEY && hasBudget()) {
|
|
531
464
|
try {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
465
|
+
const timeoutMs = getRequestTimeoutMs();
|
|
466
|
+
if (timeoutMs > 0) {
|
|
467
|
+
await withTimeout(
|
|
468
|
+
(signal) => fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
469
|
+
method: 'POST',
|
|
470
|
+
headers: {
|
|
471
|
+
'Content-Type': 'application/json',
|
|
472
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
473
|
+
},
|
|
474
|
+
body: JSON.stringify({
|
|
475
|
+
session_id,
|
|
476
|
+
type: 'away_mode_changed',
|
|
477
|
+
source,
|
|
478
|
+
data: {
|
|
479
|
+
is_away: false,
|
|
480
|
+
timestamp: Date.now()
|
|
481
|
+
}
|
|
482
|
+
}),
|
|
483
|
+
signal
|
|
484
|
+
}),
|
|
485
|
+
timeoutMs,
|
|
486
|
+
'away-mode timeline post'
|
|
487
|
+
);
|
|
488
|
+
}
|
|
547
489
|
log(`Logged away_mode_changed (away=false) to timeline`);
|
|
548
490
|
} catch (e) {
|
|
549
491
|
log(`Failed to log away_mode_changed: ${e.message}`);
|