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.
@@ -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
- const source = 'cli_interactive';
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
- const cwd = process.cwd();
354
- const meta = await getSessionMetadata(cwd);
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
- metadata = { ...preExtractedMetadata, session_id, claude_session_id: session_id };
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.claude_session_id = session_id;
100
- metadata = extracted;
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) {
@@ -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 (2000 chars):
134
+ * ASSISTANT_RESPONSE_MAX_LENGTH (50000 chars / ~50KB):
131
135
  * - Used for assistant conversational responses
132
- * - Longer because these are the primary output users want to see
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 = 2000;
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
- * Applies sanitization to string fields that may contain secrets.
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
- // Sanitize stringified version of object fields that might contain secrets
297
- const inputStr = JSON.stringify(sanitized.tool_input);
298
- const sanitizedStr = sanitizer(inputStr);
299
- if (inputStr !== sanitizedStr) {
300
- try {
301
- sanitized.tool_input = JSON.parse(sanitizedStr);
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
 
@@ -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
+ }