teleportation-cli 1.1.5 → 1.2.1

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 (42) hide show
  1. package/.claude/hooks/permission_request.mjs +326 -59
  2. package/.claude/hooks/post_tool_use.mjs +90 -0
  3. package/.claude/hooks/pre_tool_use.mjs +212 -293
  4. package/.claude/hooks/session-register.mjs +89 -104
  5. package/.claude/hooks/session_end.mjs +41 -42
  6. package/.claude/hooks/session_start.mjs +45 -60
  7. package/.claude/hooks/stop.mjs +752 -99
  8. package/.claude/hooks/user_prompt_submit.mjs +26 -3
  9. package/lib/cli/daemon-commands.js +1 -1
  10. package/lib/cli/teleport-commands.js +469 -0
  11. package/lib/daemon/daemon-v2.js +104 -0
  12. package/lib/daemon/lifecycle.js +56 -171
  13. package/lib/daemon/services/index.js +3 -0
  14. package/lib/daemon/services/polling-service.js +173 -0
  15. package/lib/daemon/services/queue-service.js +318 -0
  16. package/lib/daemon/services/session-service.js +115 -0
  17. package/lib/daemon/state.js +35 -0
  18. package/lib/daemon/task-executor-v2.js +413 -0
  19. package/lib/daemon/task-executor.js +270 -96
  20. package/lib/daemon/teleportation-daemon.js +709 -126
  21. package/lib/daemon/timeline-analyzer.js +215 -0
  22. package/lib/daemon/transcript-ingestion.js +696 -0
  23. package/lib/daemon/utils.js +91 -0
  24. package/lib/install/installer.js +184 -20
  25. package/lib/install/uhr-installer.js +136 -0
  26. package/lib/remote/providers/base-provider.js +46 -0
  27. package/lib/remote/providers/daytona-provider.js +58 -0
  28. package/lib/remote/providers/provider-factory.js +90 -19
  29. package/lib/remote/providers/sprites-provider.js +711 -0
  30. package/lib/teleport/exporters/claude-exporter.js +302 -0
  31. package/lib/teleport/exporters/gemini-exporter.js +307 -0
  32. package/lib/teleport/exporters/index.js +93 -0
  33. package/lib/teleport/exporters/interface.js +153 -0
  34. package/lib/teleport/fork-tracker.js +415 -0
  35. package/lib/teleport/git-committer.js +337 -0
  36. package/lib/teleport/index.js +48 -0
  37. package/lib/teleport/manager.js +620 -0
  38. package/lib/teleport/session-capture.js +282 -0
  39. package/package.json +6 -2
  40. package/teleportation-cli.cjs +488 -453
  41. package/.claude/hooks/heartbeat.mjs +0 -396
  42. package/lib/daemon/pid-manager.js +0 -183
@@ -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,17 +86,30 @@ const fetchJson = async (url, opts) => {
68
86
  }
69
87
  };
70
88
 
71
- const rawInput = await readStdin();
89
+ // Read and parse stdin to get hook input
72
90
  let input = {};
73
91
  try {
74
- input = JSON.parse(rawInput || '{}');
92
+ const raw = await readStdin();
93
+ input = JSON.parse(raw || '{}');
75
94
  } catch (e) {
76
- log(`Warning: Failed to parse input JSON: ${e.message}`);
95
+ log(`[PreToolUse] Failed to parse stdin: ${e.message}`);
77
96
  }
78
97
 
79
98
  let { session_id, tool_name, tool_input } = input || {};
80
99
  tool_input = tool_input && typeof tool_input === 'object' ? tool_input : {};
81
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';
82
113
 
83
114
  // 1. Recursion Guard (Critical Stability Fix)
84
115
  // Prevent infinite hook-triggered tool loops
@@ -102,11 +133,18 @@ const fetchJson = async (url, opts) => {
102
133
  const command = tool_input?.command || tool_input?.text || '';
103
134
  if (typeof command === 'string') {
104
135
  const trimmedCmd = command.trim().toLowerCase();
105
- 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') {
106
141
  log('Detected /away command - setting away mode');
107
142
  // Will set away mode after loading config
108
143
  tool_input.__teleportation_away = true;
109
- } else if (trimmedCmd === '/back' || trimmedCmd === 'teleportation back') {
144
+ } else if (trimmedCmd === '/back' ||
145
+ trimmedCmd === 'teleportation back' ||
146
+ trimmedCmd === 'teleport back' ||
147
+ trimmedCmd === 'teleporation back') {
110
148
  log('Detected /back command - clearing away mode');
111
149
  tool_input.__teleportation_back = true;
112
150
  }
@@ -132,7 +170,6 @@ const fetchJson = async (url, opts) => {
132
170
  const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
133
171
  const DAEMON_PORT = config.daemonPort || env.TELEPORTATION_DAEMON_PORT || '3050';
134
172
  const DAEMON_ENABLED = config.daemonEnabled !== false && env.TELEPORTATION_DAEMON_ENABLED !== 'false';
135
- const CONTEXT_DELIVERY_ENABLED = config.contextDeliveryEnabled !== false && env.TELEPORTATION_CONTEXT_DELIVERY_ENABLED !== 'false';
136
173
 
137
174
  // Fast polling timeout - how long to wait before handing off to daemon
138
175
  // Default: 60 seconds - provides seamless experience before daemon handoff
@@ -156,157 +193,6 @@ const fetchJson = async (url, opts) => {
156
193
  // All tool requests are sent to the remote approval system so the user
157
194
  // can approve/deny from their mobile device. This enables true remote control.
158
195
 
159
- // Helper: Format daemon work results into a human-readable message
160
- // Enhanced for PRD-0013 Phase 1: Context Preservation
161
- const formatDaemonUpdate = (results) => {
162
- if (!results || results.length === 0) return '';
163
-
164
- // Check if any browser tasks were completed
165
- const hasBrowserTasks = results.some(r => {
166
- const toolName = (r.tool_name || '').toLowerCase();
167
- const command = (r.command || '').toLowerCase();
168
- return toolName.includes('browser') || toolName.includes('mcp') ||
169
- command.includes('browser') || command.includes('mcp');
170
- });
171
-
172
- const taskType = hasBrowserTasks ? 'browser/interactive task' : 'task';
173
- let header = `🤖 **Daemon Work Update** (${results.length} ${taskType}${results.length > 1 ? 's' : ''} completed while you were away)\n\n`;
174
-
175
- // PRD-0013 Phase 1: Show original context if available
176
- // This helps Claude remember what the user originally requested
177
- const firstResult = results[0];
178
- if (firstResult?.original_context?.user_last_message) {
179
- const context = firstResult.original_context;
180
- const timeElapsed = context.timestamp
181
- ? Date.now() - context.timestamp
182
- : null;
183
-
184
- // Format time elapsed
185
- let timeAgo = '';
186
- if (timeElapsed) {
187
- const seconds = Math.floor(timeElapsed / 1000);
188
- const minutes = Math.floor(seconds / 60);
189
- const hours = Math.floor(minutes / 60);
190
-
191
- if (hours > 0) {
192
- timeAgo = `${hours} hour${hours > 1 ? 's' : ''} ago`;
193
- } else if (minutes > 0) {
194
- timeAgo = `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
195
- } else {
196
- timeAgo = `${seconds} second${seconds > 1 ? 's' : ''} ago`;
197
- }
198
- }
199
-
200
- header += `**Original Request**${timeAgo ? ` (${timeAgo})` : ''}:\n`;
201
- header += `> ${context.user_last_message}\n\n`;
202
-
203
- // Show original tool_use_id if available (helps with debugging/tracing)
204
- if (firstResult.original_tool_use_id) {
205
- header += `_Results for tool ID: ${firstResult.original_tool_use_id}_\n\n`;
206
- }
207
-
208
- // PRD-0013 Phase 2: Show task description and next step suggestion if available
209
- // For Phase 1, task_description will be null, so this won't appear yet
210
- if (context.task_description) {
211
- header += `**Next Step:**\n`;
212
- header += `${context.task_description}\n\n`;
213
- }
214
- }
215
-
216
- const formatOutput = (output, toolName) => {
217
- if (!output || output.trim() === '') return '(No output)';
218
-
219
- // Try to detect and format JSON output
220
- try {
221
- const parsed = JSON.parse(output);
222
- // For browser snapshots or large JSON, provide a summary
223
- if (parsed.type === 'snapshot' || parsed.type === 'accessibility') {
224
- const url = parsed.url || parsed.page?.url || '';
225
- const title = parsed.title || parsed.page?.title || '';
226
- const elements = parsed.elements?.length || parsed.children?.length || 0;
227
- return `Browser snapshot captured:\n • URL: ${url}\n • Title: ${title}\n • Elements: ${elements}\n • Full snapshot available in output`;
228
- }
229
- // For other JSON, format nicely
230
- return JSON.stringify(parsed, null, 2);
231
- } catch {
232
- // Not JSON, return as-is but format better
233
- return output;
234
- }
235
- };
236
-
237
- const formatToolName = (toolName, command) => {
238
- if (toolName && toolName.toLowerCase().includes('browser')) return '🌐 Browser';
239
- if (toolName && toolName.toLowerCase().includes('mcp')) return '🔌 MCP Tool';
240
- if (command && command.toLowerCase().includes('browser')) return '🌐 Browser';
241
- if (command && command.toLowerCase().includes('mcp')) return '🔌 MCP Tool';
242
- return toolName || 'Command';
243
- };
244
-
245
- const details = results.map(r => {
246
- // Determine success: exit_code 0 OR if there's meaningful output (browser tasks may not use exit codes)
247
- const hasOutput = (r.stdout && r.stdout.trim()) || (r.stderr && r.stderr.trim());
248
- const isSuccess = r.exit_code === 0 || r.exit_code === null || (hasOutput && !r.stderr);
249
- const status = isSuccess ? '✅ Success' : `❌ Failed${r.exit_code !== null ? ` (Exit: ${r.exit_code})` : ''}`;
250
- const time = new Date(r.executed_at).toLocaleTimeString();
251
-
252
- const toolName = formatToolName(r.tool_name, r.command);
253
- const output = r.stdout || r.stderr || '';
254
- const formattedOutput = formatOutput(output, r.tool_name);
255
-
256
- // For browser tasks or large outputs, provide a summary first
257
- const isBrowserTask = toolName.includes('Browser') || toolName.includes('MCP');
258
- const outputPreview = isBrowserTask && output.length > 1000
259
- ? formattedOutput.split('\n').slice(0, 20).join('\n') + '\n...(see full output below)...'
260
- : formattedOutput.length > 2000
261
- ? formattedOutput.substring(0, 2000) + '\n...(truncated, see full output for details)...'
262
- : formattedOutput;
263
-
264
- let resultText = `**${toolName}:** ${r.command || '(task completed)'}\n`;
265
- resultText += `**Status:** ${status} at ${time}\n`;
266
-
267
- if (output.trim()) {
268
- resultText += `\n**Result:**\n`;
269
- // Use code blocks only for structured data, plain text otherwise
270
- if (formattedOutput.includes('\n') || formattedOutput.length > 100) {
271
- resultText += `\`\`\`\n${outputPreview}\n\`\`\`\n`;
272
- } else {
273
- resultText += `${outputPreview}\n`;
274
- }
275
- }
276
-
277
- return resultText;
278
- }).join('\n---\n\n');
279
-
280
- const successCount = results.filter(r => {
281
- const hasOutput = (r.stdout && r.stdout.trim()) || (r.stderr && r.stderr.trim());
282
- return r.exit_code === 0 || r.exit_code === null || (hasOutput && !r.stderr);
283
- }).length;
284
- const failCount = results.length - successCount;
285
- const summary = `\n**Summary:** ${successCount} successful, ${failCount} failed.`;
286
-
287
- // Add a prompt to ensure results are acknowledged
288
- const browserTaskCount = results.filter(r => {
289
- const toolName = (r.tool_name || '').toLowerCase();
290
- const command = (r.command || '').toLowerCase();
291
- return toolName.includes('browser') || toolName.includes('mcp') ||
292
- command.includes('browser') || command.includes('mcp');
293
- }).length;
294
-
295
- const footer = browserTaskCount > 0
296
- ? `\n\n💡 **Note:** Browser task results are included above. Please review and summarize what was accomplished.`
297
- : '';
298
-
299
- let message = header + details + summary + footer;
300
-
301
- // Increase limit for browser tasks (they need more space)
302
- const maxLength = 10000; // Increased from 5000
303
- if (message.length > maxLength) {
304
- message = message.substring(0, maxLength) + '\n\n...(output truncated, check full results for complete details)...';
305
- }
306
-
307
- return message;
308
- };
309
-
310
196
  // Register session: relay first (source of truth), then daemon
311
197
  const cwd = process.cwd();
312
198
  const meta = await getSessionMetadata(cwd);
@@ -334,24 +220,33 @@ const fetchJson = async (url, opts) => {
334
220
  log(`Model changed detected: ${lastModel} -> ${meta.current_model}`);
335
221
 
336
222
  // Log model change to timeline
337
- if (RELAY_API_URL && RELAY_API_KEY) {
223
+ if (RELAY_API_URL && RELAY_API_KEY && hasBudget()) {
338
224
  try {
339
- await fetch(`${RELAY_API_URL}/api/timeline`, {
340
- method: 'POST',
341
- headers: {
342
- 'Content-Type': 'application/json',
343
- 'Authorization': `Bearer ${RELAY_API_KEY}`
344
- },
345
- body: JSON.stringify({
346
- session_id,
347
- type: 'model_changed',
348
- data: {
349
- previous_model: lastModel,
350
- new_model: meta.current_model,
351
- timestamp: Date.now()
352
- }
353
- })
354
- });
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
+ }
355
250
  log(`Model change logged to timeline`);
356
251
  } catch (e) {
357
252
  log(`Failed to log model change: ${e.message}`);
@@ -368,11 +263,21 @@ const fetchJson = async (url, opts) => {
368
263
  }
369
264
 
370
265
  // 1. Register with relay first - makes session visible in mobile UI
371
- if (session_id && RELAY_API_URL && RELAY_API_KEY) {
266
+ if (session_id && RELAY_API_URL && RELAY_API_KEY && hasBudget()) {
372
267
  try {
373
268
  log(`Registering session with relay: ${session_id}`);
374
269
  const { ensureSessionRegistered } = await import('./session-register.mjs');
375
- const regResult = await ensureSessionRegistered(session_id, cwd, config);
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;
376
281
 
377
282
  if (typeof regResult === 'object' && regResult.error === 'orphan_api_key') {
378
283
  console.error(`\n⚠️ Teleportation: ${regResult.message || 'API key not linked to user.'}`);
@@ -382,9 +287,16 @@ const fetchJson = async (url, opts) => {
382
287
  }
383
288
 
384
289
  // If model changed, update session metadata immediately
385
- if (modelChanged && (regResult === true || (typeof regResult === 'object' && regResult.success))) {
290
+ if (modelChanged && hasBudget() && (regResult === true || (typeof regResult === 'object' && regResult.success))) {
386
291
  const { updateSessionMetadata } = await import('./session-register.mjs');
387
- 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
+ }
388
300
  log(`Session metadata updated with new model`);
389
301
  }
390
302
  } catch (e) {
@@ -393,23 +305,33 @@ const fetchJson = async (url, opts) => {
393
305
  }
394
306
 
395
307
  // 2. Then register with daemon (local infrastructure for this session)
396
- if (session_id && DAEMON_ENABLED) {
308
+ if (session_id && DAEMON_ENABLED && hasBudget()) {
397
309
  try {
398
310
  const daemonUrl = `http://127.0.0.1:${DAEMON_PORT}`;
399
311
  log(`Registering session with daemon: ${session_id}`);
400
312
 
401
- const res = await fetch(`${daemonUrl}/sessions/register`, {
402
- method: 'POST',
403
- headers: { 'Content-Type': 'application/json' },
404
- body: JSON.stringify({ session_id, claude_session_id, cwd, meta })
405
- }).catch(e => {
406
- log(`Daemon registration fetch error: ${e.message}`);
407
- return null;
408
- });
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;
409
329
  if (res && res.ok) {
410
330
  log(`Session registered with daemon successfully`);
411
331
  } else if (res) {
412
332
  log(`Daemon registration returned status ${res.status}`);
333
+ } else if (timeoutMs <= 0) {
334
+ log('Skipping daemon registration due to exhausted hook budget');
413
335
  }
414
336
  } catch (e) {
415
337
  log(`Warning: Failed to register session with daemon: ${e.message}`);
@@ -418,93 +340,72 @@ const fetchJson = async (url, opts) => {
418
340
 
419
341
  // 3. Log tool_use event to timeline (before execution)
420
342
  // This shows what Claude is attempting to do
421
- if (session_id && RELAY_API_URL && RELAY_API_KEY && tool_name) {
343
+ if (session_id && RELAY_API_URL && RELAY_API_KEY && tool_name && hasBudget()) {
422
344
  try {
423
- await fetch(`${RELAY_API_URL}/api/timeline`, {
424
- method: 'POST',
425
- headers: {
426
- 'Content-Type': 'application/json',
427
- 'Authorization': `Bearer ${RELAY_API_KEY}`
428
- },
429
- body: JSON.stringify({
430
- session_id,
431
- type: 'tool_use',
432
- data: {
433
- tool_name,
434
- tool_input: tool_input || {},
435
- timestamp: Date.now()
436
- }
437
- })
438
- });
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
+ }
439
370
  log(`Logged tool_use event for ${tool_name}`);
440
371
  } catch (e) {
441
372
  log(`Failed to log tool_use: ${e.message}`);
442
373
  }
443
374
  }
444
375
 
445
- // Check for pending results from daemon execution
446
- if (session_id && RELAY_API_URL && RELAY_API_KEY && CONTEXT_DELIVERY_ENABLED) {
447
- try {
448
- log(`Checking for pending results for session: ${session_id}`);
449
- const results = await fetchJson(`${RELAY_API_URL}/api/sessions/${session_id}/results/pending`, {
450
- headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
451
- });
452
-
453
- if (results && results.length > 0) {
454
- log(`Found ${results.length} pending results. Formatting update and denying current request to deliver context.`);
455
-
456
- // Mark results as delivered in parallel (best-effort, but wait before exiting)
457
- try {
458
- await Promise.allSettled(
459
- results.map(r =>
460
- fetch(`${RELAY_API_URL}/api/sessions/${session_id}/results/${r.result_id}/delivered`, {
461
- method: 'POST',
462
- headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
463
- }).catch(e => {
464
- log(`Failed to mark result ${r.result_id} delivered: ${e.message}`);
465
- })
466
- )
467
- );
468
- } catch (markErr) {
469
- log(`Warning: Error while marking results delivered: ${markErr.message}`);
470
- }
471
-
472
- const updateMessage = formatDaemonUpdate(results);
473
- // Log the daemon update but allow the current tool to proceed
474
- // This prevents blocking errors while still informing Claude of daemon work
475
- log(`Daemon update delivered: ${updateMessage.substring(0, 200)}...`);
476
-
477
- // Output the update message to Claude by denying the current request
478
- // This forces Claude to read the update before retrying the tool
479
- const out = {
480
- hookSpecificOutput: {
481
- hookEventName: 'PreToolUse',
482
- permissionDecision: 'deny', // Deny to force reading the update
483
- permissionDecisionReason: updateMessage
484
- },
485
- suppressOutput: true
486
- };
487
- stdout.write(JSON.stringify(out));
488
- return exit(0);
489
- }
490
- } catch (e) {
491
- log(`Warning: Failed to check pending results: ${e.message}`);
492
- }
493
- }
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.
494
380
 
495
381
  // Helper: Update session daemon state
496
382
  const updateSessionState = async (updates) => {
497
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
+ }
498
388
  try {
499
389
  log(`Updating session state: ${JSON.stringify(updates)}`);
500
- await fetchJson(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
501
- method: 'PATCH',
502
- headers: {
503
- 'Content-Type': 'application/json',
504
- 'Authorization': `Bearer ${RELAY_API_KEY}`
505
- },
506
- body: JSON.stringify(updates)
507
- });
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}`);
508
409
  } catch (e) {
509
410
  log(`Warning: Failed to update session state: ${e.message}`);
510
411
  }
@@ -513,23 +414,32 @@ const fetchJson = async (url, opts) => {
513
414
  if (tool_input.__teleportation_away) {
514
415
  await updateSessionState({ is_away: true });
515
416
  // Log away_mode_changed event to timeline
516
- if (RELAY_API_URL && RELAY_API_KEY) {
417
+ if (RELAY_API_URL && RELAY_API_KEY && hasBudget()) {
517
418
  try {
518
- await fetch(`${RELAY_API_URL}/api/timeline`, {
519
- method: 'POST',
520
- headers: {
521
- 'Content-Type': 'application/json',
522
- 'Authorization': `Bearer ${RELAY_API_KEY}`
523
- },
524
- body: JSON.stringify({
525
- session_id,
526
- type: 'away_mode_changed',
527
- data: {
528
- is_away: true,
529
- timestamp: Date.now()
530
- }
531
- })
532
- });
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
+ }
533
443
  log(`Logged away_mode_changed (away=true) to timeline`);
534
444
  } catch (e) {
535
445
  log(`Failed to log away_mode_changed: ${e.message}`);
@@ -550,23 +460,32 @@ const fetchJson = async (url, opts) => {
550
460
  if (tool_input.__teleportation_back) {
551
461
  await updateSessionState({ is_away: false });
552
462
  // Log away_mode_changed event to timeline
553
- if (RELAY_API_URL && RELAY_API_KEY) {
463
+ if (RELAY_API_URL && RELAY_API_KEY && hasBudget()) {
554
464
  try {
555
- await fetch(`${RELAY_API_URL}/api/timeline`, {
556
- method: 'POST',
557
- headers: {
558
- 'Content-Type': 'application/json',
559
- 'Authorization': `Bearer ${RELAY_API_KEY}`
560
- },
561
- body: JSON.stringify({
562
- session_id,
563
- type: 'away_mode_changed',
564
- data: {
565
- is_away: false,
566
- timestamp: Date.now()
567
- }
568
- })
569
- });
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
+ }
570
489
  log(`Logged away_mode_changed (away=false) to timeline`);
571
490
  } catch (e) {
572
491
  log(`Failed to log away_mode_changed: ${e.message}`);