teleportation-cli 1.1.3 → 1.1.4

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.
@@ -84,7 +84,16 @@ const isValidSessionId = (id) => {
84
84
 
85
85
  const fetchJson = async (url, opts) => {
86
86
  const res = await fetch(url, opts);
87
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
87
+ if (!res.ok) {
88
+ let errorBody = '';
89
+ try {
90
+ const errorData = await res.json();
91
+ errorBody = JSON.stringify(errorData);
92
+ } catch (e) {
93
+ errorBody = await res.text();
94
+ }
95
+ throw new Error(`HTTP ${res.status}: ${errorBody}`);
96
+ }
88
97
  return res.json();
89
98
  };
90
99
 
@@ -246,27 +255,36 @@ const fetchJson = async (url, opts) => {
246
255
  // Create approval request with metadata and context (PRD-0013)
247
256
  let approvalId;
248
257
  try {
258
+ const payload = {
259
+ session_id,
260
+ tool_name,
261
+ tool_input,
262
+ meta,
263
+ tool_use_id: effective_tool_use_id,
264
+ };
265
+ // Only include conversation_context if not null
266
+ // API validation expects it to be an object, not null
267
+ if (conversation_context !== null) {
268
+ payload.conversation_context = conversation_context;
269
+ }
270
+ // Phase 2 fields - not implemented yet, so don't include them
271
+ // if (task_description !== null) payload.task_description = task_description;
272
+ // if (transcript_excerpt !== null) payload.transcript_excerpt = transcript_excerpt;
273
+
274
+ log(`Creating approval with payload: ${JSON.stringify(payload).substring(0, 500)}`);
249
275
  const created = await fetchJson(`${RELAY_API_URL}/api/approvals`, {
250
276
  method: 'POST',
251
277
  headers: {
252
278
  'Content-Type': 'application/json',
253
279
  'Authorization': `Bearer ${RELAY_API_KEY}`
254
280
  },
255
- body: JSON.stringify({
256
- session_id,
257
- tool_name,
258
- tool_input,
259
- meta,
260
- tool_use_id: effective_tool_use_id,
261
- conversation_context,
262
- task_description: null, // Phase 2: infer multi-step tasks
263
- transcript_excerpt: null // Phase 2: include recent messages
264
- })
281
+ body: JSON.stringify(payload)
265
282
  });
266
283
  approvalId = created.id;
267
284
  log(`Approval created: ${approvalId} (tool_use_id: ${effective_tool_use_id})`);
268
285
  } catch (e) {
269
286
  log(`ERROR creating approval: ${e.message}`);
287
+ log(`Error stack: ${e.stack}`);
270
288
  return exit(0); // Let Claude Code handle it
271
289
  }
272
290
 
@@ -1,11 +1,16 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
3
  * PostToolUse Hook
4
- *
4
+ *
5
5
  * This hook fires AFTER a tool has been executed.
6
6
  * If we get here, the tool was approved (either auto or manually) and ran.
7
- *
7
+ *
8
8
  * Purpose: Record tool executions to the timeline for activity history.
9
+ *
10
+ * PRD-0015: Approval Execution Feedback Loop
11
+ * - Logs tool_completed events for successful executions
12
+ * - Logs tool_failed events for failed executions
13
+ * - Includes exit_code, duration_ms, stdout, stderr, and approval_id linking
9
14
  */
10
15
 
11
16
  import { stdin, stdout, exit, env } from 'node:process';
@@ -24,6 +29,7 @@ const isValidSessionId = (id) => {
24
29
  };
25
30
 
26
31
  const TIMELINE_OUTPUT_PREVIEW_MAX_LENGTH = 500;
32
+ const COMPLETION_EVENT_OUTPUT_MAX_LENGTH = 500;
27
33
 
28
34
  const fetchJson = async (url, opts) => {
29
35
  const res = await fetch(url, opts);
@@ -31,7 +37,154 @@ const fetchJson = async (url, opts) => {
31
37
  return res.json();
32
38
  };
33
39
 
40
+ // ============================================================================
41
+ // PRD-0015: Utility Functions for Completion Events
42
+ // ============================================================================
43
+
44
+ /**
45
+ * Truncate output to a maximum length, adding ellipsis if truncated.
46
+ * Handles null/undefined gracefully.
47
+ *
48
+ * @param {string|null|undefined} text - The text to truncate
49
+ * @param {number} maxLength - Maximum length (default 500)
50
+ * @returns {string} Truncated text or empty string
51
+ */
52
+ export function truncateOutput(text, maxLength = COMPLETION_EVENT_OUTPUT_MAX_LENGTH) {
53
+ if (text == null || text === '') return '';
54
+ const str = String(text);
55
+ if (str.length <= maxLength) return str;
56
+ return str.slice(0, maxLength) + '...';
57
+ }
58
+
59
+ /**
60
+ * Extract exit code from tool_output.
61
+ * - For Bash: uses exit_code property
62
+ * - For other tools: derives from is_error, error, or output patterns
63
+ *
64
+ * @param {object|string|null|undefined} tool_output - The tool output
65
+ * @returns {number} Exit code (0 for success, 1+ for failure)
66
+ */
67
+ export function extractExitCode(tool_output) {
68
+ if (tool_output == null) return 0;
69
+
70
+ // Direct exit_code property (Bash commands)
71
+ if (typeof tool_output === 'object' && typeof tool_output.exit_code === 'number') {
72
+ return tool_output.exit_code;
73
+ }
74
+
75
+ // is_error flag
76
+ if (tool_output?.is_error === true) return 1;
77
+
78
+ // error property exists
79
+ if (tool_output?.error) return 1;
80
+
81
+ // Check for non-empty stderr (indicates error)
82
+ if (tool_output?.stderr && typeof tool_output.stderr === 'string' && tool_output.stderr.trim()) {
83
+ // Only treat as error if exit_code is explicitly non-zero
84
+ // Note: undefined/null exit_code with stderr is NOT an error (e.g., git push SSH warnings)
85
+ if (tool_output.exit_code !== undefined && tool_output.exit_code !== null && tool_output.exit_code !== 0) {
86
+ return 1;
87
+ }
88
+ }
89
+
90
+ // String output with error patterns
91
+ if (typeof tool_output === 'string') {
92
+ const lower = tool_output.toLowerCase();
93
+ if (lower.includes('error:') || lower.includes('exception:') || lower.includes('failed:')) {
94
+ return 1;
95
+ }
96
+ }
97
+
98
+ return 0;
99
+ }
100
+
101
+ /**
102
+ * Calculate duration in milliseconds from start time.
103
+ * Can use tool_output.duration_ms if available.
104
+ *
105
+ * @param {number|null} startTime - Start timestamp (Date.now())
106
+ * @param {number|null} endTime - End timestamp (default: now)
107
+ * @param {object|null} tool_output - Tool output (may contain duration_ms)
108
+ * @returns {number|null} Duration in ms or null if cannot be determined
109
+ */
110
+ export function extractDurationMs(startTime, endTime = null, tool_output = null) {
111
+ // Check if tool_output provides duration
112
+ if (tool_output && typeof tool_output.duration_ms === 'number') {
113
+ return tool_output.duration_ms;
114
+ }
115
+
116
+ // Calculate from timestamps
117
+ if (startTime == null) return null;
118
+ const end = endTime ?? Date.now();
119
+ return end - startTime;
120
+ }
121
+
122
+ /**
123
+ * Extract stdout from tool_output, handling various formats.
124
+ *
125
+ * @param {object|string|null} tool_output - The tool output
126
+ * @returns {string} The stdout content or empty string
127
+ */
128
+ function extractStdout(tool_output) {
129
+ if (tool_output == null) return '';
130
+ if (typeof tool_output === 'string') return tool_output;
131
+ if (typeof tool_output.stdout === 'string') return tool_output.stdout;
132
+ // For non-Bash tools, try to get readable content
133
+ if (tool_output.content) return String(tool_output.content);
134
+ if (tool_output.result) return String(tool_output.result);
135
+ return '';
136
+ }
137
+
138
+ /**
139
+ * Extract stderr from tool_output.
140
+ *
141
+ * @param {object|string|null} tool_output - The tool output
142
+ * @returns {string} The stderr content or empty string
143
+ */
144
+ function extractStderr(tool_output) {
145
+ if (tool_output == null) return '';
146
+ if (typeof tool_output.stderr === 'string') return tool_output.stderr;
147
+ if (typeof tool_output.error === 'string') return tool_output.error;
148
+ return '';
149
+ }
150
+
151
+ /**
152
+ * Build error message for tool_failed events.
153
+ * Prioritizes stderr content.
154
+ *
155
+ * @param {object|string|null} tool_output - The tool output
156
+ * @returns {string} Error message
157
+ */
158
+ function buildErrorMessage(tool_output) {
159
+ if (tool_output == null) return 'Tool execution failed';
160
+
161
+ // Prioritize stderr
162
+ const stderr = extractStderr(tool_output);
163
+ if (stderr) return stderr;
164
+
165
+ // Check for error property
166
+ if (tool_output?.error) {
167
+ return typeof tool_output.error === 'string'
168
+ ? tool_output.error
169
+ : JSON.stringify(tool_output.error);
170
+ }
171
+
172
+ // Check for string output with error patterns
173
+ if (typeof tool_output === 'string') {
174
+ return tool_output;
175
+ }
176
+
177
+ return 'Tool execution failed';
178
+ }
179
+
180
+ // ============================================================================
181
+ // Main Hook Logic
182
+ // ============================================================================
183
+
34
184
  (async () => {
185
+ // Track hook start time for duration calculation
186
+ const hookStartTime = Date.now();
187
+
35
188
  const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-hook.log';
36
189
  const log = (msg) => {
37
190
  const timestamp = new Date().toISOString();
@@ -44,15 +197,15 @@ const fetchJson = async (url, opts) => {
44
197
 
45
198
  const raw = await readStdin();
46
199
  let input;
47
- try {
48
- input = JSON.parse(raw || '{}');
200
+ try {
201
+ input = JSON.parse(raw || '{}');
49
202
  } catch (e) {
50
203
  log(`ERROR: Invalid JSON: ${e.message}`);
51
204
  return exit(0);
52
205
  }
53
206
 
54
- const { session_id, tool_name, tool_input, tool_output } = input || {};
55
- log(`Session: ${session_id}, Tool: ${tool_name}`);
207
+ const { session_id, tool_name, tool_input, tool_output, tool_use_id } = input || {};
208
+ log(`Session: ${session_id}, Tool: ${tool_name}, tool_use_id: ${tool_use_id || 'none'}`);
56
209
 
57
210
  // Validate session_id
58
211
  if (!isValidSessionId(session_id)) {
@@ -102,23 +255,41 @@ const fetchJson = async (url, opts) => {
102
255
  log(`Failed to clear pending approvals: ${e.message}`);
103
256
  }
104
257
 
105
- // Detect if this tool execution resulted in an error
106
- const hasError = (() => {
107
- if (!tool_output) return false;
108
- // Check for is_error flag
109
- if (tool_output?.is_error) return true;
110
- // Check for error property
111
- if (tool_output?.error) return true;
112
- // Check for common error patterns in string output
113
- if (typeof tool_output === 'string') {
114
- const lower = tool_output.toLowerCase();
115
- return lower.includes('error:') || lower.includes('exception:') || lower.includes('failed:');
258
+ // =========================================================================
259
+ // PRD-0015: Extract completion event data
260
+ // =========================================================================
261
+
262
+ const exitCode = extractExitCode(tool_output);
263
+ const hasError = exitCode !== 0;
264
+ const durationMs = extractDurationMs(hookStartTime, Date.now(), tool_output);
265
+ const rawStdout = extractStdout(tool_output);
266
+ const rawStderr = extractStderr(tool_output);
267
+
268
+ log(`Exit code: ${exitCode}, Has error: ${hasError}, Duration: ${durationMs}ms`);
269
+
270
+ // Lookup approval_id if tool_use_id is available
271
+ // The approval is linked via tool_use_id (set during PermissionRequest hook)
272
+ let approvalId = null;
273
+ if (tool_use_id && RELAY_API_URL && RELAY_API_KEY) {
274
+ try {
275
+ // Query for the most recent approval with this tool_use_id
276
+ const approvalResponse = await fetchJson(
277
+ `${RELAY_API_URL}/api/approvals?session_id=${session_id}&tool_use_id=${tool_use_id}&limit=1`,
278
+ {
279
+ headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
280
+ }
281
+ );
282
+ if (approvalResponse && approvalResponse.length > 0) {
283
+ approvalId = approvalResponse[0].id;
284
+ log(`Found approval_id: ${approvalId} for tool_use_id: ${tool_use_id}`);
285
+ }
286
+ } catch (e) {
287
+ log(`Failed to lookup approval_id: ${e.message}`);
288
+ // Continue without approval_id - not a fatal error
116
289
  }
117
- // Check for stderr content
118
- if (tool_output?.stderr && tool_output.stderr.trim()) return true;
119
- return false;
120
- })();
290
+ }
121
291
 
292
+ // Backward compatibility: legacy outputPreview for tool_executed event
122
293
  const outputPreview = (() => {
123
294
  if (!tool_output) return null;
124
295
  try {
@@ -130,10 +301,57 @@ const fetchJson = async (url, opts) => {
130
301
  }
131
302
  })();
132
303
 
133
- // If there's an error, log a separate tool_error event for visibility
304
+ // =========================================================================
305
+ // PRD-0015: Log tool_completed or tool_failed event
306
+ // =========================================================================
307
+
308
+ const completionEventType = hasError ? 'tool_failed' : 'tool_completed';
309
+ const completionEventData = {
310
+ tool_name,
311
+ tool_input,
312
+ exit_code: exitCode,
313
+ duration_ms: durationMs,
314
+ stdout: truncateOutput(rawStdout, COMPLETION_EVENT_OUTPUT_MAX_LENGTH),
315
+ stderr: truncateOutput(rawStderr, COMPLETION_EVENT_OUTPUT_MAX_LENGTH),
316
+ timestamp: Date.now()
317
+ };
318
+
319
+ // Add optional fields
320
+ if (approvalId) {
321
+ completionEventData.approval_id = approvalId;
322
+ }
323
+ if (tool_use_id) {
324
+ completionEventData.tool_use_id = tool_use_id;
325
+ }
326
+
327
+ // Add error_message for tool_failed events
328
+ if (hasError) {
329
+ completionEventData.error_message = truncateOutput(buildErrorMessage(tool_output), 1000);
330
+ }
331
+
332
+ try {
333
+ await fetchJson(`${RELAY_API_URL}/api/timeline`, {
334
+ method: 'POST',
335
+ headers: {
336
+ 'Content-Type': 'application/json',
337
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
338
+ },
339
+ body: JSON.stringify({
340
+ session_id,
341
+ type: completionEventType,
342
+ data: completionEventData
343
+ })
344
+ });
345
+ log(`Logged ${completionEventType} event for ${tool_name} (exit_code=${exitCode}, duration=${durationMs}ms)`);
346
+ } catch (e) {
347
+ log(`Failed to log ${completionEventType}: ${e.message}`);
348
+ }
349
+
350
+ // Legacy: Log tool_error event for backward compatibility
351
+ // (Some UI components may still expect this event type)
134
352
  if (hasError) {
135
353
  try {
136
- const errorMessage = tool_output?.error || tool_output?.stderr ||
354
+ const errorMessage = tool_output?.error || tool_output?.stderr ||
137
355
  (typeof tool_output === 'string' ? tool_output.slice(0, 500) : 'Tool execution failed');
138
356
  await fetchJson(`${RELAY_API_URL}/api/timeline`, {
139
357
  method: 'POST',
@@ -152,13 +370,13 @@ const fetchJson = async (url, opts) => {
152
370
  }
153
371
  })
154
372
  });
155
- log(`Logged tool_error event for ${tool_name}`);
373
+ log(`Logged legacy tool_error event for ${tool_name}`);
156
374
  } catch (e) {
157
- log(`Failed to log tool_error: ${e.message}`);
375
+ log(`Failed to log legacy tool_error: ${e.message}`);
158
376
  }
159
377
  }
160
378
 
161
- // Record tool execution to timeline
379
+ // Legacy: Record tool_executed event for backward compatibility
162
380
  try {
163
381
  await fetchJson(`${RELAY_API_URL}/api/timeline`, {
164
382
  method: 'POST',
@@ -178,9 +396,9 @@ const fetchJson = async (url, opts) => {
178
396
  }
179
397
  })
180
398
  });
181
- log(`Recorded tool execution: ${tool_name}${hasError ? ' (with error)' : ''}`);
399
+ log(`Recorded legacy tool_executed: ${tool_name}${hasError ? ' (with error)' : ''}`);
182
400
  } catch (e) {
183
- log(`Failed to record to timeline: ${e.message}`);
401
+ log(`Failed to record legacy tool_executed: ${e.message}`);
184
402
  }
185
403
 
186
404
  // PostToolUse hooks don't need to output anything
@@ -134,12 +134,12 @@ export async function ensureSessionRegistered(session_id, cwd, config) {
134
134
 
135
135
  // Start heartbeat if not already running
136
136
  try {
137
- const heartbeatEnabled = config.session?.heartbeat?.enabled !== false;
138
137
  const heartbeatInterval = config.session?.heartbeat?.interval || 120000;
139
138
  const startDelay = config.session?.heartbeat?.startDelay || 5000;
140
139
  const maxFailures = config.session?.heartbeat?.maxFailures || 3;
141
140
 
142
- if (heartbeatEnabled && RELAY_API_URL && RELAY_API_KEY) {
141
+ // Heartbeats are mandatory for session liveness tracking
142
+ if (RELAY_API_URL && RELAY_API_KEY) {
143
143
  const { spawn } = await import('child_process');
144
144
  const heartbeatPath = join(__dirname, 'heartbeat.mjs');
145
145
 
@@ -39,7 +39,6 @@ const DEFAULT_CONFIG = {
39
39
  timeout: 3600000, // 1 hour
40
40
  muteTimeout: 300000, // 5 minutes
41
41
  heartbeat: {
42
- enabled: true,
43
42
  interval: 120000, // 2 minutes between heartbeats
44
43
  timeout: 300000, // 5 minutes without heartbeat = session dead
45
44
  startDelay: 5000, // Wait 5 seconds after session start before first heartbeat