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.
- package/.claude/hooks/permission_request.mjs +29 -11
- package/.claude/hooks/post_tool_use.mjs +246 -28
- package/.claude/hooks/session-register.mjs +2 -2
- package/lib/config/manager.js +0 -1
- package/lib/daemon/agentic-executor.js +803 -0
- package/lib/daemon/response-classifier.js +312 -0
- package/lib/daemon/teleportation-daemon.js +53 -10
- package/lib/utils/log-sanitizer.js +111 -0
- package/lib/utils/logger.js +74 -127
- package/package.json +4 -3
- package/teleportation-cli.cjs +18 -0
|
@@ -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)
|
|
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
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
399
|
+
log(`Recorded legacy tool_executed: ${tool_name}${hasError ? ' (with error)' : ''}`);
|
|
182
400
|
} catch (e) {
|
|
183
|
-
log(`Failed to record
|
|
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
|
-
|
|
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
|
|
package/lib/config/manager.js
CHANGED
|
@@ -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
|