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.
Files changed (48) hide show
  1. package/.claude/hooks/config-loader.mjs +88 -34
  2. package/.claude/hooks/permission_request.mjs +392 -82
  3. package/.claude/hooks/post_tool_use.mjs +90 -0
  4. package/.claude/hooks/pre_tool_use.mjs +247 -305
  5. package/.claude/hooks/session-register.mjs +94 -105
  6. package/.claude/hooks/session_end.mjs +41 -42
  7. package/.claude/hooks/session_start.mjs +45 -60
  8. package/.claude/hooks/stop.mjs +752 -99
  9. package/.claude/hooks/user_prompt_submit.mjs +26 -3
  10. package/README.md +7 -0
  11. package/lib/auth/api-key.js +12 -0
  12. package/lib/auth/token-refresh.js +286 -0
  13. package/lib/cli/daemon-commands.js +1 -1
  14. package/lib/cli/teleport-commands.js +469 -0
  15. package/lib/daemon/daemon-v2.js +104 -0
  16. package/lib/daemon/lifecycle.js +56 -171
  17. package/lib/daemon/response-classifier.js +15 -1
  18. package/lib/daemon/services/index.js +3 -0
  19. package/lib/daemon/services/polling-service.js +173 -0
  20. package/lib/daemon/services/queue-service.js +318 -0
  21. package/lib/daemon/services/session-service.js +115 -0
  22. package/lib/daemon/state.js +35 -0
  23. package/lib/daemon/task-executor-v2.js +413 -0
  24. package/lib/daemon/task-executor.js +1235 -0
  25. package/lib/daemon/teleportation-daemon.js +770 -25
  26. package/lib/daemon/timeline-analyzer.js +215 -0
  27. package/lib/daemon/transcript-ingestion.js +696 -0
  28. package/lib/daemon/utils.js +91 -0
  29. package/lib/install/installer.js +184 -20
  30. package/lib/install/uhr-installer.js +136 -0
  31. package/lib/remote/providers/base-provider.js +46 -0
  32. package/lib/remote/providers/daytona-provider.js +58 -0
  33. package/lib/remote/providers/provider-factory.js +90 -19
  34. package/lib/remote/providers/sprites-provider.js +711 -0
  35. package/lib/teleport/exporters/claude-exporter.js +302 -0
  36. package/lib/teleport/exporters/gemini-exporter.js +307 -0
  37. package/lib/teleport/exporters/index.js +93 -0
  38. package/lib/teleport/exporters/interface.js +153 -0
  39. package/lib/teleport/fork-tracker.js +415 -0
  40. package/lib/teleport/git-committer.js +337 -0
  41. package/lib/teleport/index.js +48 -0
  42. package/lib/teleport/manager.js +620 -0
  43. package/lib/teleport/session-capture.js +282 -0
  44. package/package.json +11 -5
  45. package/teleportation-cli.cjs +632 -451
  46. package/.claude/hooks/heartbeat.mjs +0 -396
  47. package/lib/daemon/agentic-executor.js +0 -803
  48. 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
- 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);
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
- if (trimmedCmd === '/away' || trimmedCmd === 'teleportation away') {
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' || trimmedCmd === 'teleportation 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
- // Check if model has changed since last tool use
303
- // This detects when user runs /model to switch models mid-session
304
- const LAST_MODEL_FILE = join(tmpdir(), `teleportation-last-model-${session_id}.txt`);
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(LAST_MODEL_FILE, 'utf8')).trim();
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
- await fetch(`${RELAY_API_URL}/api/timeline`, {
323
- method: 'POST',
324
- headers: {
325
- 'Content-Type': 'application/json',
326
- 'Authorization': `Bearer ${RELAY_API_KEY}`
327
- },
328
- body: JSON.stringify({
329
- session_id,
330
- type: 'model_changed',
331
- data: {
332
- previous_model: lastModel,
333
- new_model: meta.current_model,
334
- timestamp: Date.now()
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(LAST_MODEL_FILE, meta.current_model, { mode: 0o600 });
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
- await ensureSessionRegistered(session_id, cwd, config);
359
- log(`Session registered with relay successfully`);
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
- await updateSessionMetadata(session_id, cwd, config);
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 res = await fetch(`${daemonUrl}/sessions/register`, {
379
- method: 'POST',
380
- headers: { 'Content-Type': 'application/json' },
381
- body: JSON.stringify({ session_id, claude_session_id, cwd, meta })
382
- }).catch(e => {
383
- log(`Daemon registration fetch error: ${e.message}`);
384
- return null;
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
- await fetch(`${RELAY_API_URL}/api/timeline`, {
401
- method: 'POST',
402
- headers: {
403
- 'Content-Type': 'application/json',
404
- 'Authorization': `Bearer ${RELAY_API_KEY}`
405
- },
406
- body: JSON.stringify({
407
- session_id,
408
- type: 'tool_use',
409
- data: {
410
- tool_name,
411
- tool_input: tool_input || {},
412
- timestamp: Date.now()
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
- // Check for pending results from daemon execution
423
- if (session_id && RELAY_API_URL && RELAY_API_KEY && CONTEXT_DELIVERY_ENABLED) {
424
- try {
425
- log(`Checking for pending results for session: ${session_id}`);
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
- await fetchJson(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
478
- method: 'PATCH',
479
- headers: {
480
- 'Content-Type': 'application/json',
481
- 'Authorization': `Bearer ${RELAY_API_KEY}`
482
- },
483
- body: JSON.stringify(updates)
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
- await fetch(`${RELAY_API_URL}/api/timeline`, {
496
- method: 'POST',
497
- headers: {
498
- 'Content-Type': 'application/json',
499
- 'Authorization': `Bearer ${RELAY_API_KEY}`
500
- },
501
- body: JSON.stringify({
502
- session_id,
503
- type: 'away_mode_changed',
504
- data: {
505
- is_away: true,
506
- timestamp: Date.now()
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
- await fetch(`${RELAY_API_URL}/api/timeline`, {
533
- method: 'POST',
534
- headers: {
535
- 'Content-Type': 'application/json',
536
- 'Authorization': `Bearer ${RELAY_API_KEY}`
537
- },
538
- body: JSON.stringify({
539
- session_id,
540
- type: 'away_mode_changed',
541
- data: {
542
- is_away: false,
543
- timestamp: Date.now()
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}`);