teleportation-cli 1.4.0 → 1.4.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.
- package/.claude/hooks/pre_tool_use.mjs +47 -10
- package/.claude/hooks/session-register.mjs +12 -5
- package/.claude/hooks/session_end.mjs +28 -0
- package/.claude/hooks/session_start.mjs +57 -1
- package/.claude/hooks/stop.mjs +30 -18
- package/lib/config/manager.js +45 -1
- package/lib/daemon/session-file-registry.js +207 -0
- package/lib/daemon/task-executor-v2.js +38 -9
- package/lib/daemon/teleportation-daemon.js +287 -17
- package/lib/daemon/transcript-ingestion.js +160 -9
- package/lib/daemon/utils.js +0 -9
- package/lib/install/installer.js +126 -3
- package/lib/install/uhr-installer.js +32 -18
- package/lib/intelligence/benchmark.js +240 -0
- package/lib/intelligence/index.js +29 -0
- package/lib/intelligence/rebuild-policies.js +169 -0
- package/lib/intelligence/schema.js +259 -0
- package/lib/intelligence/transcript-mine.js +339 -0
- package/lib/session/metadata.js +23 -5
- package/lib/transcript-sync/lifecycle.js +88 -0
- package/lib/transcript-sync/repo-context.js +45 -0
- package/lib/transcript-sync/worker.js +233 -0
- package/lib/utils/log-sanitizer.js +65 -0
- package/package.json +2 -1
- package/scripts/sync-transcripts.sh +272 -0
- package/teleportation-cli.cjs +295 -4
|
@@ -19,7 +19,7 @@ const readStdin = () => new Promise((resolve, reject) => {
|
|
|
19
19
|
|
|
20
20
|
// Lazy-load metadata extraction (only used on slow path)
|
|
21
21
|
let extractSessionMetadata = null;
|
|
22
|
-
async function getSessionMetadata(cwd) {
|
|
22
|
+
async function getSessionMetadata(cwd, hookInput = null) {
|
|
23
23
|
if (!extractSessionMetadata) {
|
|
24
24
|
const possiblePaths = [
|
|
25
25
|
join(__dirname, '..', '..', 'lib', 'session', 'metadata.js'),
|
|
@@ -40,12 +40,41 @@ async function getSessionMetadata(cwd) {
|
|
|
40
40
|
if (!extractSessionMetadata) return {};
|
|
41
41
|
|
|
42
42
|
try {
|
|
43
|
-
return await extractSessionMetadata(cwd);
|
|
43
|
+
return await extractSessionMetadata(cwd, hookInput);
|
|
44
44
|
} catch (e) {
|
|
45
45
|
return {};
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// Lazy-load event data sanitizer (redact API keys, tokens, passwords from timeline events)
|
|
50
|
+
let _sanitizeEventData = null;
|
|
51
|
+
async function loadEventSanitizer() {
|
|
52
|
+
if (_sanitizeEventData) return _sanitizeEventData;
|
|
53
|
+
|
|
54
|
+
const possiblePaths = [
|
|
55
|
+
join(__dirname, '..', '..', 'lib', 'utils', 'log-sanitizer.js'),
|
|
56
|
+
join(homedir(), '.teleportation', 'lib', 'utils', 'log-sanitizer.js'),
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
for (const path of possiblePaths) {
|
|
60
|
+
try {
|
|
61
|
+
const mod = await import(path);
|
|
62
|
+
if (mod.sanitizeEventData) {
|
|
63
|
+
_sanitizeEventData = mod.sanitizeEventData;
|
|
64
|
+
return _sanitizeEventData;
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Fallback: identity function (don't block tool execution if sanitizer unavailable)
|
|
72
|
+
// Server-side sanitization in relay provides defense-in-depth
|
|
73
|
+
log('Warning: sanitizeEventData not found, using identity fallback (relay-side sanitization still active)');
|
|
74
|
+
_sanitizeEventData = (data) => data;
|
|
75
|
+
return _sanitizeEventData;
|
|
76
|
+
}
|
|
77
|
+
|
|
49
78
|
const fetchJson = async (url, opts) => {
|
|
50
79
|
const res = await fetch(url, opts);
|
|
51
80
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
@@ -105,6 +134,8 @@ function postTimeline(relayUrl, apiKey, body) {
|
|
|
105
134
|
}
|
|
106
135
|
|
|
107
136
|
let { session_id, tool_name, tool_input } = input || {};
|
|
137
|
+
// Cursor sends conversation_id in preToolUse instead of session_id
|
|
138
|
+
session_id = session_id || input?.conversation_id;
|
|
108
139
|
tool_input = tool_input && typeof tool_input === 'object' ? tool_input : {};
|
|
109
140
|
let claude_session_id = session_id; // Keep original ID
|
|
110
141
|
const hookStartMs = Date.now();
|
|
@@ -116,7 +147,9 @@ function postTimeline(relayUrl, apiKey, body) {
|
|
|
116
147
|
return Math.max(200, Math.min(PRE_TOOL_NETWORK_TIMEOUT_MS, remaining - 100));
|
|
117
148
|
};
|
|
118
149
|
|
|
119
|
-
|
|
150
|
+
// Detect client: Cursor injects cursor_version in all hook inputs
|
|
151
|
+
const client = input?.cursor_version ? 'cursor' : 'claude-code';
|
|
152
|
+
const source = client === 'cursor' ? 'cursor_agent' : 'cli_interactive';
|
|
120
153
|
|
|
121
154
|
// 1. Recursion Guard
|
|
122
155
|
const SAFE_TOOLS = ['read', 'glob', 'grep', 'websearch', 'bashoutput', 'ls', 'pwd', 'git status'];
|
|
@@ -330,12 +363,13 @@ function postTimeline(relayUrl, apiKey, body) {
|
|
|
330
363
|
log(`[FastPath] Model check error: ${e.message}`);
|
|
331
364
|
}
|
|
332
365
|
|
|
333
|
-
// Log tool_use to timeline
|
|
366
|
+
// Log tool_use to timeline (sanitize to redact secrets from tool_input)
|
|
334
367
|
if (session_id && RELAY_API_URL && RELAY_API_KEY && tool_name) {
|
|
335
368
|
try {
|
|
369
|
+
const sanitize = await loadEventSanitizer();
|
|
336
370
|
await postTimeline(RELAY_API_URL, RELAY_API_KEY, {
|
|
337
371
|
session_id, type: 'tool_use', source,
|
|
338
|
-
data: { tool_name, tool_input: tool_input || {}, timestamp: Date.now() }
|
|
372
|
+
data: sanitize({ tool_name, tool_input: tool_input || {}, timestamp: Date.now() })
|
|
339
373
|
});
|
|
340
374
|
} catch (e) { log(`Failed to log tool_use: ${e.message}`); }
|
|
341
375
|
}
|
|
@@ -350,8 +384,10 @@ function postTimeline(relayUrl, apiKey, body) {
|
|
|
350
384
|
// ═══════════════════════════════════════════════════════════════════════
|
|
351
385
|
log(`[SlowPath] First tool call, registering session ${session_id}`);
|
|
352
386
|
|
|
353
|
-
|
|
354
|
-
|
|
387
|
+
// Cursor provides workspace_roots; Claude Code provides cwd.
|
|
388
|
+
// Fall back to process.cwd() when hook payload doesn't include either.
|
|
389
|
+
const cwd = input?.cwd || (input?.workspace_roots?.[0]) || process.cwd();
|
|
390
|
+
const meta = await getSessionMetadata(cwd, input);
|
|
355
391
|
log(`Session metadata: project=${meta.project_name}, hostname=${meta.hostname}, branch=${meta.current_branch}, model=${meta.current_model || 'default'}`);
|
|
356
392
|
|
|
357
393
|
// Model change detection (full version via metadata)
|
|
@@ -422,7 +458,7 @@ function postTimeline(relayUrl, apiKey, body) {
|
|
|
422
458
|
const res = await fetch(`${daemonUrl}/sessions/register`, {
|
|
423
459
|
method: 'POST',
|
|
424
460
|
headers: { 'Content-Type': 'application/json' },
|
|
425
|
-
body: JSON.stringify({ session_id, claude_session_id, cwd, meta })
|
|
461
|
+
body: JSON.stringify({ session_id, claude_session_id, cwd, meta: { ...meta, client } })
|
|
426
462
|
}).catch(e => {
|
|
427
463
|
log(`Daemon registration fetch error: ${e.message}`);
|
|
428
464
|
return null;
|
|
@@ -437,12 +473,13 @@ function postTimeline(relayUrl, apiKey, body) {
|
|
|
437
473
|
}
|
|
438
474
|
}
|
|
439
475
|
|
|
440
|
-
// Log tool_use event to timeline
|
|
476
|
+
// Log tool_use event to timeline (sanitize to redact secrets from tool_input)
|
|
441
477
|
if (session_id && RELAY_API_URL && RELAY_API_KEY && tool_name) {
|
|
442
478
|
try {
|
|
479
|
+
const sanitize = await loadEventSanitizer();
|
|
443
480
|
await postTimeline(RELAY_API_URL, RELAY_API_KEY, {
|
|
444
481
|
session_id, type: 'tool_use', source,
|
|
445
|
-
data: { tool_name, tool_input: tool_input || {}, timestamp: Date.now() }
|
|
482
|
+
data: sanitize({ tool_name, tool_input: tool_input || {}, timestamp: Date.now() })
|
|
446
483
|
});
|
|
447
484
|
log(`Logged tool_use event for ${tool_name}`);
|
|
448
485
|
} catch (e) {
|
|
@@ -69,13 +69,17 @@ export async function ensureSessionRegistered(session_id, cwd, config, preExtrac
|
|
|
69
69
|
// Use pre-extracted metadata if provided (avoids duplicate git subprocess calls)
|
|
70
70
|
let metadata;
|
|
71
71
|
if (preExtractedMetadata && Object.keys(preExtractedMetadata).length > 0) {
|
|
72
|
-
|
|
72
|
+
const client = preExtractedMetadata.client || 'claude-code';
|
|
73
|
+
const clientSessionField = client === 'cursor'
|
|
74
|
+
? { cursor_conversation_id: session_id }
|
|
75
|
+
: { claude_session_id: session_id };
|
|
76
|
+
metadata = { ...preExtractedMetadata, session_id, ...clientSessionField };
|
|
73
77
|
if (env.DEBUG) {
|
|
74
78
|
console.error('[SessionRegister] Using pre-extracted metadata (skipping re-extraction)');
|
|
75
79
|
}
|
|
76
80
|
} else {
|
|
77
81
|
// Fallback: extract metadata (for callers that don't provide it)
|
|
78
|
-
metadata = { cwd, claude_session_id: session_id };
|
|
82
|
+
metadata = { cwd, claude_session_id: session_id, client: 'claude-code' };
|
|
79
83
|
try {
|
|
80
84
|
const possiblePaths = [
|
|
81
85
|
join(__dirname, '..', '..', 'lib', 'session', 'metadata.js'),
|
|
@@ -96,8 +100,11 @@ export async function ensureSessionRegistered(session_id, cwd, config, preExtrac
|
|
|
96
100
|
if (metadataModule && metadataModule.extractSessionMetadata && cwd) {
|
|
97
101
|
const extracted = await metadataModule.extractSessionMetadata(cwd);
|
|
98
102
|
extracted.session_id = session_id;
|
|
99
|
-
extracted.
|
|
100
|
-
|
|
103
|
+
extracted.client = extracted.client || 'claude-code';
|
|
104
|
+
const clientSessionField = extracted.client === 'cursor'
|
|
105
|
+
? { cursor_conversation_id: session_id }
|
|
106
|
+
: { claude_session_id: session_id };
|
|
107
|
+
metadata = { ...extracted, ...clientSessionField };
|
|
101
108
|
}
|
|
102
109
|
} catch (e) {
|
|
103
110
|
if (env.DEBUG) {
|
|
@@ -157,7 +164,7 @@ export async function ensureSessionRegistered(session_id, cwd, config, preExtrac
|
|
|
157
164
|
'Content-Type': 'application/json',
|
|
158
165
|
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
159
166
|
},
|
|
160
|
-
body: JSON.stringify({ session_id, meta: metadata })
|
|
167
|
+
body: JSON.stringify({ session_id, meta: metadata, cwd: metadata.working_directory || cwd })
|
|
161
168
|
});
|
|
162
169
|
|
|
163
170
|
if (response.ok || response.status === 200) {
|
|
@@ -90,6 +90,34 @@ const readStdin = () => new Promise((resolve, reject) => {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// Mark PID-based session file as ended so daemon stops heartbeating immediately
|
|
94
|
+
try {
|
|
95
|
+
const { homedir } = await import('node:os');
|
|
96
|
+
const { fileURLToPath } = await import('node:url');
|
|
97
|
+
const { dirname } = await import('node:path');
|
|
98
|
+
const __hookDir = dirname(fileURLToPath(import.meta.url));
|
|
99
|
+
const registryPaths = [
|
|
100
|
+
join(homedir(), '.teleportation', 'lib', 'daemon', 'session-file-registry.js'),
|
|
101
|
+
// Relative path — consistent with session_start.mjs (__dirname-based, not URL-based)
|
|
102
|
+
join(__hookDir, '..', '..', 'lib', 'daemon', 'session-file-registry.js'),
|
|
103
|
+
];
|
|
104
|
+
let marked = false;
|
|
105
|
+
for (const p of registryPaths) {
|
|
106
|
+
try {
|
|
107
|
+
const { markSessionEnded } = await import(p);
|
|
108
|
+
await markSessionEnded(session_id);
|
|
109
|
+
if (env.DEBUG) console.error(`[SessionEnd] Marked session file ended: ${session_id}`);
|
|
110
|
+
marked = true;
|
|
111
|
+
break;
|
|
112
|
+
} catch { /* try next */ }
|
|
113
|
+
}
|
|
114
|
+
if (!marked) {
|
|
115
|
+
console.error(`[SessionEnd] WARN: Could not mark session file ended — session-file-registry not found at any path`);
|
|
116
|
+
}
|
|
117
|
+
} catch (e) {
|
|
118
|
+
if (env.DEBUG) console.error(`[SessionEnd] Failed to mark session ended: ${e.message}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
93
121
|
// Delete registration marker file
|
|
94
122
|
try {
|
|
95
123
|
const registrationMarker = join(tmpdir(), `teleportation-session-${session_id}.registered`);
|
|
@@ -128,6 +128,17 @@ function updateSessionMarker(sessionId) {
|
|
|
128
128
|
} catch {}
|
|
129
129
|
|
|
130
130
|
let { session_id, cwd } = input || {};
|
|
131
|
+
// Cursor's sessionStart provides session_id (same as conversation_id) but cwd
|
|
132
|
+
// comes from workspace_roots, not a top-level cwd field
|
|
133
|
+
cwd = cwd || input?.workspace_roots?.[0];
|
|
134
|
+
// Detect client and capture Cursor-specific session context
|
|
135
|
+
const client = input?.cursor_version ? 'cursor' : 'claude-code';
|
|
136
|
+
const cursorMeta = client === 'cursor' ? {
|
|
137
|
+
client,
|
|
138
|
+
is_background_agent: input?.is_background_agent ?? false,
|
|
139
|
+
composer_mode: input?.composer_mode || null,
|
|
140
|
+
cursor_version: input?.cursor_version || null,
|
|
141
|
+
} : { client };
|
|
131
142
|
const claude_session_id = session_id;
|
|
132
143
|
|
|
133
144
|
// Check if credentials changed since last session start - user may need to restart
|
|
@@ -277,10 +288,55 @@ function updateSessionMarker(sessionId) {
|
|
|
277
288
|
console.error(`[SessionStart] Captured model: ${meta.current_model}`);
|
|
278
289
|
}
|
|
279
290
|
|
|
291
|
+
// If this session was spawned by the task executor as a child of a parent session,
|
|
292
|
+
// record the parent_session_id so the UI can hide child sessions from the list.
|
|
293
|
+
const parentSessionId = env.TELEPORTATION_PARENT_SESSION_ID;
|
|
294
|
+
const taskMeta = parentSessionId ? { parent_session_id: parentSessionId } : {};
|
|
295
|
+
|
|
296
|
+
const fullMeta = { ...meta, ...cursorMeta, ...taskMeta };
|
|
297
|
+
|
|
298
|
+
// PRIMARY: Write session file for daemon to discover via PID-based polling.
|
|
299
|
+
// This is fire-and-forget — no timeout, no network, survives daemon restarts.
|
|
300
|
+
// Cursor sessions use conversation_id not a real claude PID, so skip for them.
|
|
301
|
+
if (client === 'claude-code' && process.ppid) {
|
|
302
|
+
try {
|
|
303
|
+
const registryPaths = [
|
|
304
|
+
join(homedir(), '.teleportation', 'lib', 'daemon', 'session-file-registry.js'),
|
|
305
|
+
join(__dirname, '..', '..', 'lib', 'daemon', 'session-file-registry.js'),
|
|
306
|
+
];
|
|
307
|
+
let writeSessionFile = null;
|
|
308
|
+
for (const p of registryPaths) {
|
|
309
|
+
try {
|
|
310
|
+
const mod = await import(p);
|
|
311
|
+
writeSessionFile = mod.writeSessionFile;
|
|
312
|
+
break;
|
|
313
|
+
} catch { /* try next */ }
|
|
314
|
+
}
|
|
315
|
+
if (writeSessionFile) {
|
|
316
|
+
await writeSessionFile({ session_id, claude_pid: process.ppid, cwd: cwd || process.cwd(), meta: fullMeta });
|
|
317
|
+
if (env.DEBUG) {
|
|
318
|
+
// Log the parent process name so we can verify ppid points to `claude`
|
|
319
|
+
// and not an intermediate shell (sh -c "bun hook") in some environments.
|
|
320
|
+
try {
|
|
321
|
+
const { execFile } = await import('node:child_process');
|
|
322
|
+
const { promisify: pify } = await import('node:util');
|
|
323
|
+
const { stdout } = await pify(execFile)('ps', ['-p', String(process.ppid), '-o', 'comm='], { timeout: 1000 });
|
|
324
|
+
console.error(`[SessionStart] Session file written (PID: ${process.ppid}, comm: ${stdout.trim()})`);
|
|
325
|
+
} catch {
|
|
326
|
+
console.error(`[SessionStart] Session file written (PID: ${process.ppid})`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} catch (fileErr) {
|
|
331
|
+
if (env.DEBUG) console.error(`[SessionStart] Session file write failed: ${fileErr.message}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// SECONDARY: HTTP registration with daemon (kept for backward compat with older daemons).
|
|
280
336
|
await fetchWithTimeout(`${daemonUrl}/sessions/register`, {
|
|
281
337
|
method: 'POST',
|
|
282
338
|
headers: { 'Content-Type': 'application/json' },
|
|
283
|
-
body: JSON.stringify({ session_id, claude_session_id, cwd, meta })
|
|
339
|
+
body: JSON.stringify({ session_id, claude_session_id, cwd, meta: fullMeta })
|
|
284
340
|
}, 2000);
|
|
285
341
|
|
|
286
342
|
if (env.DEBUG) {
|
package/.claude/hooks/stop.mjs
CHANGED
|
@@ -75,6 +75,9 @@ function basicSanitizer(text) {
|
|
|
75
75
|
* @param {Function} [log] - Optional logging function for warnings
|
|
76
76
|
* @returns {Promise<Function>} - The sanitizer function
|
|
77
77
|
*/
|
|
78
|
+
// Shared sanitizeEventData from lib/utils/log-sanitizer.js (loaded alongside sanitizeForLog)
|
|
79
|
+
let sanitizeEventDataFn = null;
|
|
80
|
+
|
|
78
81
|
async function getSanitizer(log = () => {}) {
|
|
79
82
|
if (sanitizeForLog) return sanitizeForLog;
|
|
80
83
|
|
|
@@ -90,6 +93,7 @@ async function getSanitizer(log = () => {}) {
|
|
|
90
93
|
try {
|
|
91
94
|
const mod = await import(path);
|
|
92
95
|
sanitizeForLog = mod.sanitizeForLog;
|
|
96
|
+
sanitizeEventDataFn = mod.sanitizeEventData || null;
|
|
93
97
|
if (sanitizeForLog) return sanitizeForLog;
|
|
94
98
|
} catch (err) {
|
|
95
99
|
// Log import failures for debugging (only in DEBUG mode to avoid noise)
|
|
@@ -127,9 +131,10 @@ const isValidSessionId = (id) => {
|
|
|
127
131
|
/**
|
|
128
132
|
* Truncation length constants - intentionally different for different message types
|
|
129
133
|
*
|
|
130
|
-
* ASSISTANT_RESPONSE_MAX_LENGTH (
|
|
134
|
+
* ASSISTANT_RESPONSE_MAX_LENGTH (50000 chars / ~50KB):
|
|
131
135
|
* - Used for assistant conversational responses
|
|
132
|
-
* -
|
|
136
|
+
* - Matches the relay API's MAX_EVENT_DATA_SIZE limit per field
|
|
137
|
+
* - Responses are primary output users want to read in full
|
|
133
138
|
* - Less frequent (typically 1 per user turn)
|
|
134
139
|
*
|
|
135
140
|
* MAX_SYSTEM_MESSAGE_LENGTH (1000 chars):
|
|
@@ -138,7 +143,7 @@ const isValidSessionId = (id) => {
|
|
|
138
143
|
* - Thinking blocks can occur many times per response
|
|
139
144
|
* - Optimizes storage while maintaining useful context
|
|
140
145
|
*/
|
|
141
|
-
const ASSISTANT_RESPONSE_MAX_LENGTH =
|
|
146
|
+
const ASSISTANT_RESPONSE_MAX_LENGTH = 50000;
|
|
142
147
|
const MAX_SYSTEM_MESSAGE_LENGTH = 1000;
|
|
143
148
|
|
|
144
149
|
// Max size for full transcript (bytes) - 500KB
|
|
@@ -268,42 +273,49 @@ const extractMessageContent = (msg) => {
|
|
|
268
273
|
|
|
269
274
|
/**
|
|
270
275
|
* Sanitize event data to remove sensitive information before storing in timeline.
|
|
271
|
-
*
|
|
276
|
+
* Delegates to the shared sanitizeEventData from lib/utils/log-sanitizer.js when
|
|
277
|
+
* available, otherwise falls back to a local implementation.
|
|
272
278
|
*
|
|
273
279
|
* @param {Object} eventData - The event data object
|
|
274
280
|
* @param {Function} sanitizer - The sanitization function
|
|
275
281
|
* @returns {Object} - Sanitized event data
|
|
276
282
|
*/
|
|
277
283
|
const sanitizeEventData = (eventData, sanitizer) => {
|
|
284
|
+
// Use shared module if loaded by getSanitizer()
|
|
285
|
+
if (sanitizeEventDataFn) {
|
|
286
|
+
return sanitizeEventDataFn(eventData, sanitizer);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Fallback: local implementation (kept as safety net if shared module unavailable)
|
|
278
290
|
if (!eventData || typeof eventData !== 'object') return eventData;
|
|
279
291
|
if (!sanitizer) return eventData;
|
|
280
292
|
|
|
281
293
|
const sanitized = { ...eventData };
|
|
282
|
-
|
|
283
|
-
// Sanitize known text fields
|
|
284
|
-
const textFields = ['message', 'summary', 'thinking', 'result', 'error'];
|
|
294
|
+
const textFields = ['message', 'summary', 'thinking', 'result', 'error', 'stdout', 'stderr', 'content'];
|
|
285
295
|
for (const field of textFields) {
|
|
286
296
|
if (typeof sanitized[field] === 'string') {
|
|
287
297
|
sanitized[field] = sanitizer(sanitized[field]);
|
|
288
298
|
}
|
|
289
299
|
}
|
|
290
300
|
|
|
291
|
-
// Sanitize tool_input if it's a string or contains sensitive data
|
|
292
301
|
if (sanitized.tool_input) {
|
|
293
302
|
if (typeof sanitized.tool_input === 'string') {
|
|
294
303
|
sanitized.tool_input = sanitizer(sanitized.tool_input);
|
|
295
304
|
} else if (typeof sanitized.tool_input === 'object') {
|
|
296
|
-
//
|
|
297
|
-
const
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
} catch {
|
|
303
|
-
// If parsing fails, use sanitized string
|
|
304
|
-
sanitized.tool_input = sanitizedStr;
|
|
305
|
+
// SYNC_WITH: lib/utils/log-sanitizer.js SENSITIVE_KEY_RE
|
|
306
|
+
const sensitiveKeyRe = /^(password|passwd|pwd|secret|token|api[_-]?key|authorization|credentials?)$/i;
|
|
307
|
+
const sanitizeValues = (obj, parentKey) => {
|
|
308
|
+
if (typeof obj === 'string') {
|
|
309
|
+
if (parentKey && sensitiveKeyRe.test(parentKey)) return '***';
|
|
310
|
+
return sanitizer(obj);
|
|
305
311
|
}
|
|
306
|
-
|
|
312
|
+
if (typeof obj !== 'object' || obj === null) return obj;
|
|
313
|
+
if (Array.isArray(obj)) return obj.map(item => sanitizeValues(item));
|
|
314
|
+
const result = {};
|
|
315
|
+
for (const [k, v] of Object.entries(obj)) result[k] = sanitizeValues(v, k);
|
|
316
|
+
return result;
|
|
317
|
+
};
|
|
318
|
+
sanitized.tool_input = sanitizeValues(sanitized.tool_input);
|
|
307
319
|
}
|
|
308
320
|
}
|
|
309
321
|
|
package/lib/config/manager.js
CHANGED
|
@@ -49,6 +49,15 @@ const DEFAULT_CONFIG = {
|
|
|
49
49
|
enabled: true,
|
|
50
50
|
sound: false
|
|
51
51
|
},
|
|
52
|
+
transcriptSync: {
|
|
53
|
+
enabled: false,
|
|
54
|
+
intervalMs: 300000, // 5 minutes
|
|
55
|
+
machineId: null,
|
|
56
|
+
peer: null,
|
|
57
|
+
peerMirrorDir: null,
|
|
58
|
+
mode: 'local', // local|push|pull|bidirectional
|
|
59
|
+
mirrorDir: join(homedir(), '.teleportation', 'transcript-mirror')
|
|
60
|
+
},
|
|
52
61
|
installation: {
|
|
53
62
|
scope: null, // 'global' | 'project' | null (not installed)
|
|
54
63
|
hooksPath: null, // Path where hooks are installed
|
|
@@ -99,7 +108,8 @@ function validateConfig(config) {
|
|
|
99
108
|
const booleanFields = [
|
|
100
109
|
'hooks.autoUpdate',
|
|
101
110
|
'notifications.enabled',
|
|
102
|
-
'notifications.sound'
|
|
111
|
+
'notifications.sound',
|
|
112
|
+
'transcriptSync.enabled'
|
|
103
113
|
];
|
|
104
114
|
|
|
105
115
|
booleanFields.forEach(field => {
|
|
@@ -109,6 +119,40 @@ function validateConfig(config) {
|
|
|
109
119
|
}
|
|
110
120
|
});
|
|
111
121
|
|
|
122
|
+
// Validate transcript sync block if present
|
|
123
|
+
if (config.transcriptSync) {
|
|
124
|
+
const ts = config.transcriptSync;
|
|
125
|
+
const validModes = ['local', 'push', 'pull', 'bidirectional'];
|
|
126
|
+
|
|
127
|
+
if (ts.intervalMs !== undefined) {
|
|
128
|
+
if (typeof ts.intervalMs !== 'number' || ts.intervalMs < 10000 || ts.intervalMs > 86400000) {
|
|
129
|
+
errors.push('transcriptSync.intervalMs must be a number between 10000 and 86400000');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (ts.mode !== undefined && !validModes.includes(ts.mode)) {
|
|
134
|
+
errors.push('transcriptSync.mode must be one of: local, push, pull, bidirectional');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
['mirrorDir', 'peerMirrorDir'].forEach(field => {
|
|
138
|
+
const value = ts[field];
|
|
139
|
+
if (value !== null && value !== undefined && typeof value !== 'string') {
|
|
140
|
+
errors.push(`transcriptSync.${field} must be a string path or null`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
['machineId', 'peer'].forEach(field => {
|
|
145
|
+
const value = ts[field];
|
|
146
|
+
if (value !== null && value !== undefined && typeof value !== 'string') {
|
|
147
|
+
errors.push(`transcriptSync.${field} must be a string or null`);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (['push', 'pull', 'bidirectional'].includes(ts.mode)) {
|
|
152
|
+
warnings.push('Non-local transcript sync modes require repo peer auto-discovery (set via git config teleportation.transcriptSyncPeer)');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
112
156
|
// Validate installation block if present
|
|
113
157
|
if (config.installation) {
|
|
114
158
|
const installation = config.installation;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based session registry.
|
|
3
|
+
*
|
|
4
|
+
* Hooks write a session file once on session_start. The daemon scans this
|
|
5
|
+
* directory to discover sessions, checks PID liveness, and heartbeats the
|
|
6
|
+
* relay on their behalf. This decouples registration from the daemon's HTTP
|
|
7
|
+
* server — a file write never times out and survives daemon restarts.
|
|
8
|
+
*
|
|
9
|
+
* File location: /tmp/teleportation-sessions/<session_id>.json
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readdir, readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
import { promisify } from 'node:util';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
|
|
20
|
+
export const SESSIONS_DIR = join(tmpdir(), 'teleportation-sessions');
|
|
21
|
+
|
|
22
|
+
/** Guard against path traversal: session_ids must be standard lowercase hex UUIDs */
|
|
23
|
+
function assertValidSessionId(session_id) {
|
|
24
|
+
if (typeof session_id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(session_id)) {
|
|
25
|
+
throw new Error(`Invalid session_id: ${String(session_id)}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Ensure the sessions directory exists (mode 0700 — only owner can read/list) */
|
|
30
|
+
export async function ensureSessionsDir(dir = SESSIONS_DIR) {
|
|
31
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Write a session registration file.
|
|
36
|
+
* Called ONCE by session_start.mjs. Never updated by the hook after this.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} opts
|
|
39
|
+
* @param {string} opts.session_id
|
|
40
|
+
* @param {number} opts.claude_pid - process.ppid inside the hook (the parent claude process)
|
|
41
|
+
* @param {string} opts.cwd
|
|
42
|
+
* @param {object} opts.meta
|
|
43
|
+
* @param {string} [opts._dir] - override directory (for testing)
|
|
44
|
+
*/
|
|
45
|
+
export async function writeSessionFile({ session_id, claude_pid, cwd, meta, _dir }) {
|
|
46
|
+
assertValidSessionId(session_id);
|
|
47
|
+
const dir = _dir || SESSIONS_DIR;
|
|
48
|
+
await ensureSessionsDir(dir);
|
|
49
|
+
const path = join(dir, `${session_id}.json`);
|
|
50
|
+
const record = {
|
|
51
|
+
session_id,
|
|
52
|
+
claude_pid,
|
|
53
|
+
cwd,
|
|
54
|
+
meta: meta || {},
|
|
55
|
+
registered_at: Date.now(),
|
|
56
|
+
acked: false,
|
|
57
|
+
ended: false,
|
|
58
|
+
};
|
|
59
|
+
await writeFile(path, JSON.stringify(record, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
60
|
+
return record;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Mark a session file as ended (written by session_end.mjs).
|
|
65
|
+
* Daemon will stop heartbeating on its next poll cycle.
|
|
66
|
+
*
|
|
67
|
+
* Note: read-modify-write is not atomic. If session_end.mjs and the daemon
|
|
68
|
+
* call this simultaneously (e.g., daemon cleanup races with hook), one write
|
|
69
|
+
* may clobber the other. The window is ~1ms and the worst outcome is a
|
|
70
|
+
* missed ended=true flag — the daemon will catch the dead PID within 60s.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} session_id
|
|
73
|
+
* @param {string} [_dir] - override directory (for testing)
|
|
74
|
+
*/
|
|
75
|
+
export async function markSessionEnded(session_id, _dir) {
|
|
76
|
+
assertValidSessionId(session_id);
|
|
77
|
+
const path = join(_dir || SESSIONS_DIR, `${session_id}.json`);
|
|
78
|
+
try {
|
|
79
|
+
const raw = await readFile(path, 'utf8');
|
|
80
|
+
const record = JSON.parse(raw);
|
|
81
|
+
record.ended = true;
|
|
82
|
+
record.ended_at = Date.now();
|
|
83
|
+
await writeFile(path, JSON.stringify(record, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
84
|
+
} catch {
|
|
85
|
+
// File may not exist if daemon already cleaned it up — that's fine
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Ack a session file (written by daemon after picking it up).
|
|
91
|
+
*
|
|
92
|
+
* @param {string} session_id
|
|
93
|
+
* @param {number} daemon_pid
|
|
94
|
+
* @param {string} [_dir] - override directory (for testing)
|
|
95
|
+
*/
|
|
96
|
+
export async function ackSessionFile(session_id, daemon_pid, _dir) {
|
|
97
|
+
assertValidSessionId(session_id);
|
|
98
|
+
const path = join(_dir || SESSIONS_DIR, `${session_id}.json`);
|
|
99
|
+
try {
|
|
100
|
+
const raw = await readFile(path, 'utf8');
|
|
101
|
+
const record = JSON.parse(raw);
|
|
102
|
+
record.acked = true;
|
|
103
|
+
record.daemon_pid = daemon_pid;
|
|
104
|
+
record.acked_at = Date.now();
|
|
105
|
+
await writeFile(path, JSON.stringify(record, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
106
|
+
} catch {
|
|
107
|
+
// File may have been deleted concurrently — ignore
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Delete a session file (daemon cleanup after session ends or PID dies).
|
|
113
|
+
*
|
|
114
|
+
* @param {string} session_id
|
|
115
|
+
* @param {string} [_dir] - override directory (for testing)
|
|
116
|
+
*/
|
|
117
|
+
export async function deleteSessionFile(session_id, _dir) {
|
|
118
|
+
assertValidSessionId(session_id);
|
|
119
|
+
try {
|
|
120
|
+
await unlink(join(_dir || SESSIONS_DIR, `${session_id}.json`));
|
|
121
|
+
} catch {
|
|
122
|
+
// Already gone — fine
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Read all session files from the sessions directory.
|
|
128
|
+
* Returns an array of parsed records, skipping malformed files.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} [_dir] - override directory (for testing)
|
|
131
|
+
* @returns {Promise<Array>}
|
|
132
|
+
*/
|
|
133
|
+
export async function readAllSessionFiles(_dir) {
|
|
134
|
+
const dir = _dir || SESSIONS_DIR;
|
|
135
|
+
await ensureSessionsDir(dir);
|
|
136
|
+
let files;
|
|
137
|
+
try {
|
|
138
|
+
files = await readdir(dir);
|
|
139
|
+
} catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const records = [];
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
if (!file.endsWith('.json')) continue;
|
|
146
|
+
const expectedId = file.slice(0, -5); // strip .json
|
|
147
|
+
try {
|
|
148
|
+
const raw = await readFile(join(dir, file), 'utf8');
|
|
149
|
+
const record = JSON.parse(raw);
|
|
150
|
+
// Validate session_id is a proper UUID and matches filename to prevent path traversal
|
|
151
|
+
if (
|
|
152
|
+
record.session_id &&
|
|
153
|
+
record.claude_pid &&
|
|
154
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(record.session_id) &&
|
|
155
|
+
record.session_id === expectedId
|
|
156
|
+
) {
|
|
157
|
+
records.push(record);
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// Malformed or mid-write — skip
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return records;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Check whether a PID belongs to a live `claude` process.
|
|
168
|
+
*
|
|
169
|
+
* Uses two signals:
|
|
170
|
+
* 1. kill -0 (signal 0): tests if the process exists without sending a signal
|
|
171
|
+
* 2. ps comm=: verifies the process name is "claude" to guard against PID reuse
|
|
172
|
+
*
|
|
173
|
+
* Async to avoid blocking the event loop — execSync would stall the daemon's
|
|
174
|
+
* heartbeat and relay polling for up to 1s per call × N sessions.
|
|
175
|
+
*
|
|
176
|
+
* Known edge case: if a new `claude` process reuses the exact PID of a
|
|
177
|
+
* just-exited session, this returns true until the 60s grace period expires.
|
|
178
|
+
* Probability is extremely low in practice given PID space on modern systems.
|
|
179
|
+
*
|
|
180
|
+
* @param {number} pid
|
|
181
|
+
* @returns {Promise<boolean>}
|
|
182
|
+
*/
|
|
183
|
+
export async function isClaudePidAlive(pid) {
|
|
184
|
+
if (!pid || typeof pid !== 'number') return false;
|
|
185
|
+
try {
|
|
186
|
+
// kill(pid, 0) throws ESRCH if process doesn't exist, EPERM if exists but no permission
|
|
187
|
+
process.kill(pid, 0);
|
|
188
|
+
} catch (e) {
|
|
189
|
+
// EPERM means process exists but we can't signal it — treat as alive
|
|
190
|
+
if (e.code === 'EPERM') return true;
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Verify process name to guard against PID reuse by non-claude processes.
|
|
195
|
+
// Uses async execFile to avoid blocking the event loop.
|
|
196
|
+
try {
|
|
197
|
+
const { stdout } = await execFileAsync('ps', ['-p', String(pid), '-o', 'comm='], { timeout: 1000 });
|
|
198
|
+
const comm = stdout.trim();
|
|
199
|
+
// Accept "claude" or paths ending in "/claude"
|
|
200
|
+
return comm === 'claude' || comm.endsWith('/claude');
|
|
201
|
+
} catch {
|
|
202
|
+
// ps failed (process just died, or ps unavailable in container). When kill -0
|
|
203
|
+
// already confirmed the process exists (we get here only if kill -0 succeeded),
|
|
204
|
+
// optimistically treat it as alive — kill -0 is the stronger liveness signal.
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
}
|