teleportation-cli 1.4.4 → 1.5.0

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.
@@ -5,8 +5,8 @@
5
5
  * This hook fires when Claude Code finishes responding.
6
6
  *
7
7
  * Purpose:
8
- * 1. Check for pending messages from the mobile app (existing functionality)
9
- * 2. Extract Claude's last response from the transcript and log it to timeline
8
+ * 1. Delegate to daemon (lean path) POST /sessions/stop with 1.5s timeout, exit immediately on 2xx.
9
+ * 2. Fallback (daemon unreachable): extract transcript events and batch-log to relay inline.
10
10
  */
11
11
 
12
12
  import { stdin, stdout, stderr, exit, env } from 'node:process';
@@ -128,6 +128,13 @@ const isValidSessionId = (id) => {
128
128
  return id && typeof id === 'string' && /^[a-zA-Z0-9-]+$/.test(id) && id.length >= 8;
129
129
  };
130
130
 
131
+ // Strict UUID v4 validation — used for externally-supplied IDs like TELEPORTATION_PARENT_SESSION_ID
132
+ // to prevent accidental relay requests to non-existent sessions from misconfigured env vars.
133
+ const isValidUUID = (id) => {
134
+ return id && typeof id === 'string' &&
135
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id);
136
+ };
137
+
131
138
  /**
132
139
  * Truncation length constants - intentionally different for different message types
133
140
  *
@@ -1167,8 +1174,28 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
1167
1174
  const { session_id, transcript_path, stop_hook_active } = input || {};
1168
1175
  log(`Session: ${session_id}, Transcript: ${transcript_path}, stop_hook_active: ${stop_hook_active}`);
1169
1176
 
1177
+ if (stop_hook_active) {
1178
+ log('stop_hook_active=true — skipping all processing to prevent recursive hook calls');
1179
+ return exit(0);
1180
+ }
1181
+
1170
1182
  const source = 'cli_interactive';
1171
1183
 
1184
+ // When this session was spawned by a mobile Send (task executor), fan events to the parent timeline.
1185
+ // Use strict UUID v4 validation — env var could be a typo, test value, or stale config.
1186
+ const PARENT_SESSION_ID = env.TELEPORTATION_PARENT_SESSION_ID || '';
1187
+ let parentSessionId = null;
1188
+ if (PARENT_SESSION_ID) {
1189
+ if (!isValidUUID(PARENT_SESSION_ID)) {
1190
+ log(`WARN: TELEPORTATION_PARENT_SESSION_ID="${PARENT_SESSION_ID}" is not a valid UUID v4 — ignoring`);
1191
+ } else if (PARENT_SESSION_ID === session_id) {
1192
+ log(`WARN: TELEPORTATION_PARENT_SESSION_ID is the same as session_id — self-loop prevented`);
1193
+ } else {
1194
+ parentSessionId = PARENT_SESSION_ID;
1195
+ log(`Parent session detected: ${parentSessionId} — will fan-out events to parent timeline`);
1196
+ }
1197
+ }
1198
+
1172
1199
  const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
1173
1200
  const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
1174
1201
 
@@ -1183,6 +1210,31 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
1183
1210
  return exit(0);
1184
1211
  }
1185
1212
 
1213
+ // AC1/AC2: Lean path — delegate to daemon if reachable (1.5s timeout).
1214
+ // On success (2xx), exit immediately. On any failure, fall through to inline logic.
1215
+ const DAEMON_PORT = config.daemonPort || env.TELEPORTATION_DAEMON_PORT || '3050';
1216
+ const daemonBody = { session_id, transcript_path };
1217
+ if (parentSessionId) daemonBody.parent_session_id = parentSessionId;
1218
+
1219
+ try {
1220
+ const daemonRes = await fetchWithTimeout(
1221
+ `http://127.0.0.1:${DAEMON_PORT}/sessions/stop`,
1222
+ {
1223
+ method: 'POST',
1224
+ headers: { 'Content-Type': 'application/json' },
1225
+ body: JSON.stringify(daemonBody),
1226
+ },
1227
+ 1500
1228
+ );
1229
+ if (daemonRes.ok) {
1230
+ log(`[stop-delegate] Daemon accepted stop signal — exiting lean path`);
1231
+ return exit(0);
1232
+ }
1233
+ log(`[stop-delegate] Daemon returned HTTP ${daemonRes.status} — falling back to inline logic`);
1234
+ } catch (e) {
1235
+ log(`[stop-delegate] Daemon unreachable (${e.message}) — falling back to inline logic`);
1236
+ }
1237
+
1186
1238
  if (!STOP_FULL_INGEST_ENABLED) {
1187
1239
  log('Stop full ingest disabled (TELEPORTATION_STOP_FULL_INGEST!=true) - running lightweight stop path');
1188
1240
  }
@@ -1213,8 +1265,6 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
1213
1265
  parseTranscriptToEvents(transcript_path, session_id, RELAY_API_URL, RELAY_API_KEY, log, transcript),
1214
1266
  extractFullTranscript(transcript_path, log, transcript)
1215
1267
  ]);
1216
- } else if (stop_hook_active) {
1217
- log('Skipping batch event log (stop_hook_active=true)');
1218
1268
  } else if (!STOP_FULL_INGEST_ENABLED) {
1219
1269
  log('Skipping batch event log (full ingest disabled)');
1220
1270
  } else if (skipHeavyUpload) {
@@ -1225,7 +1275,7 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
1225
1275
  cursor = parsedResult.cursor || null;
1226
1276
 
1227
1277
  // PARALLEL: Fire all HTTP calls together using Promise.allSettled
1228
- // This avoids sequential waiting for: batch events, transcript storage, pending messages
1278
+ // This avoids sequential waiting for: batch events, transcript storage
1229
1279
  const httpTasks = [];
1230
1280
 
1231
1281
  // 1. Batch log events
@@ -1267,6 +1317,45 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
1267
1317
  }
1268
1318
  }
1269
1319
 
1320
+ // 1b. Fan-out: send same events to parent session timeline so mobile-sent tasks
1321
+ // appear inline in the parent timeline (not only in the child session).
1322
+ if (events.length > 0 && parentSessionId) {
1323
+ const MAX_BATCH_SIZE = 1000;
1324
+ const parentChunks = [];
1325
+ for (let i = 0; i < events.length; i += MAX_BATCH_SIZE) {
1326
+ parentChunks.push(events.slice(i, i + MAX_BATCH_SIZE));
1327
+ }
1328
+ log(`📤 Fanning out ${events.length} events to parent session ${parentSessionId} in ${parentChunks.length} batch(es)`);
1329
+
1330
+ for (let i = 0; i < parentChunks.length; i++) {
1331
+ const chunk = parentChunks[i];
1332
+ const batchIndex = i;
1333
+ // Tag each event with child_session_id for provenance (non-breaking extra field)
1334
+ const taggedChunk = chunk.map(evt => ({
1335
+ ...evt,
1336
+ data: { ...evt.data, child_session_id: session_id }
1337
+ }));
1338
+ httpTasks.push(
1339
+ fetchJsonWithRetry(`${RELAY_API_URL}/api/sessions/${parentSessionId}/events/batch`, {
1340
+ method: 'POST',
1341
+ headers: {
1342
+ 'Content-Type': 'application/json',
1343
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
1344
+ },
1345
+ body: JSON.stringify({ events: taggedChunk })
1346
+ }, log, 0, BATCH_FETCH_TIMEOUT_MS)
1347
+ .then(response => {
1348
+ log(`✅ Parent fan-out batch ${batchIndex + 1}/${parentChunks.length}: ${response.success || 0} success, ${response.failed || 0} failed`);
1349
+ return { type: 'parent_fanout', success: response.success || 0, failed: response.failed || 0 };
1350
+ })
1351
+ .catch(e => {
1352
+ log(`❌ Parent fan-out batch ${batchIndex + 1}/${parentChunks.length} failed: ${e.message}`);
1353
+ return { type: 'parent_fanout', success: 0, failed: chunk.length };
1354
+ })
1355
+ );
1356
+ }
1357
+ }
1358
+
1270
1359
  // 2. Store full transcript
1271
1360
  if (transcriptData && transcriptData.messages && transcriptData.messages.length > 0) {
1272
1361
  httpTasks.push(
@@ -1295,34 +1384,6 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
1295
1384
  );
1296
1385
  }
1297
1386
 
1298
- // 3. Check for pending messages from mobile app
1299
- httpTasks.push(
1300
- fetchWithTimeout(`${RELAY_API_URL}/api/messages/pending?session_id=${encodeURIComponent(session_id)}`, {
1301
- headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
1302
- })
1303
- .then(async res => {
1304
- if (res.ok) {
1305
- const msg = await res.json();
1306
- if (msg && msg.id && msg.text) {
1307
- log(`Found pending message: ${String(msg.text).slice(0, 50)}...`);
1308
- try {
1309
- await fetchWithTimeout(`${RELAY_API_URL}/api/messages/${msg.id}/ack`, {
1310
- method: 'POST',
1311
- headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
1312
- });
1313
- } catch {}
1314
- log(`Acknowledged pending message (mobile message injection not supported in stop hook)`);
1315
- return { type: 'messages', found: true };
1316
- }
1317
- }
1318
- return { type: 'messages', found: false };
1319
- })
1320
- .catch(e => {
1321
- log(`Error checking pending messages: ${e.message}`);
1322
- return { type: 'messages', found: false };
1323
- })
1324
- );
1325
-
1326
1387
  // Wait for all HTTP calls to complete
1327
1388
  const results = await Promise.allSettled(httpTasks);
1328
1389
 
@@ -1356,6 +1417,26 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
1356
1417
  log(`⚠️ Transcript storage failed - timeline events were still logged separately`);
1357
1418
  }
1358
1419
 
1420
+ // Log parent fan-out results. Fan-out is best-effort: failures do NOT block cursor advancement
1421
+ // for the child session (cursor is only gated on primary batch success above). This means if
1422
+ // the parent session has been garbage-collected or the relay rejects the fan-out, those events
1423
+ // are permanently unrecoverable for the parent timeline. Acceptable tradeoff — the child
1424
+ // session retains the authoritative record.
1425
+ if (parentSessionId) {
1426
+ const fanoutResults = results
1427
+ .filter(r => r.status === 'fulfilled' && r.value?.type === 'parent_fanout')
1428
+ .map(r => r.value);
1429
+ if (fanoutResults.length > 0) {
1430
+ const fanoutSuccess = fanoutResults.reduce((sum, r) => sum + r.success, 0);
1431
+ const fanoutFailed = fanoutResults.reduce((sum, r) => sum + r.failed, 0);
1432
+ if (fanoutFailed > 0) {
1433
+ log(`⚠️ Parent fan-out: ${fanoutSuccess} success, ${fanoutFailed} failed — missed events are NOT retried (best-effort)`);
1434
+ } else {
1435
+ log(`✅ Parent fan-out: ${fanoutSuccess} events delivered to ${parentSessionId}`);
1436
+ }
1437
+ }
1438
+ }
1439
+
1359
1440
  log('Stop hook completed');
1360
1441
  return exit(0);
1361
1442
  })();
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gemini CLI AfterAgent Hook
4
+ *
5
+ * This hook fires when the Gemini agent completes its planning/execution loop.
6
+ * It mirrors the Claude Code stop hook for Teleportation.
7
+ */
8
+
9
+ import { stdin, stdout, exit, env } from 'node:process';
10
+ import { readFile } from 'node:fs/promises';
11
+ import { appendFileSync, existsSync } from 'node:fs';
12
+ import { createHash } from 'node:crypto';
13
+ import { homedir } from 'node:os';
14
+ import { join, dirname } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { loadConfig } from './shared/config.mjs';
17
+
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = dirname(__filename);
20
+
21
+ // Security: Max input size to prevent memory exhaustion
22
+ const MAX_INPUT_SIZE = 10 * 1024 * 1024; // 10MB
23
+
24
+ // Read all stdin
25
+ const readStdin = () => new Promise((resolve, reject) => {
26
+ let data = '';
27
+ stdin.setEncoding('utf8');
28
+ stdin.on('data', chunk => {
29
+ data += chunk;
30
+ if (data.length > MAX_INPUT_SIZE) {
31
+ reject(new Error('Input too large'));
32
+ }
33
+ });
34
+ stdin.on('end', () => resolve(data));
35
+ stdin.on('error', reject);
36
+ });
37
+
38
+ // Logging
39
+ const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-gemini-hook.log';
40
+ const log = (msg) => {
41
+ const timestamp = new Date().toISOString();
42
+ const logMsg = `[${timestamp}] [gemini:after_agent] ${msg}\n`;
43
+ try {
44
+ appendFileSync(hookLogFile, logMsg);
45
+ } catch (e) {
46
+ // Silently ignore
47
+ }
48
+ };
49
+
50
+ /**
51
+ * Generate MD5 hash of project slug for Gemini's directory naming.
52
+ */
53
+ function hashSlug(slug) {
54
+ if (!slug) return createHash('md5').update('default').digest('hex').substring(0, 16);
55
+ return createHash('md5').update(slug).digest('hex').substring(0, 16);
56
+ }
57
+
58
+ /**
59
+ * Find and parse the Gemini transcript
60
+ */
61
+ async function getTranscript(cwd, log) {
62
+ const hash = hashSlug(cwd);
63
+ const transcriptPath = join(homedir(), '.gemini', 'tmp', hash, 'logs.json');
64
+
65
+ if (!existsSync(transcriptPath)) {
66
+ log(`Transcript not found at ${transcriptPath}`);
67
+ return null;
68
+ }
69
+
70
+ try {
71
+ const content = await readFile(transcriptPath, 'utf8');
72
+ return JSON.parse(content);
73
+ } catch (e) {
74
+ log(`Failed to parse transcript: ${e.message}`);
75
+ return null;
76
+ }
77
+ }
78
+
79
+ (async () => {
80
+ log('=== AfterAgent hook invoked ===');
81
+
82
+ try {
83
+ const raw = await readStdin();
84
+ let input;
85
+ try {
86
+ input = JSON.parse(raw || '{}');
87
+ } catch (e) {
88
+ log(`Invalid JSON input: ${e.message}`);
89
+ return exit(0);
90
+ }
91
+
92
+ const { session_id, model, response, cwd } = input;
93
+ const workingDir = cwd || process.cwd();
94
+ const teleportSessionId = env.TELEPORTATION_SESSION_ID || session_id || 'unknown';
95
+
96
+ log(`Session: ${teleportSessionId}, Model: ${model}, CWD: ${workingDir}`);
97
+
98
+ // Load config from shared module
99
+ const config = await loadConfig(log);
100
+ const RELAY_API_URL = config.relayApiUrl;
101
+ const RELAY_API_KEY = config.relayApiKey;
102
+
103
+ log(`Using Relay: ${RELAY_API_URL}`);
104
+
105
+ if (!RELAY_API_URL || !RELAY_API_KEY) {
106
+ log('No relay configured, skipping transcript ingestion');
107
+ return exit(0);
108
+ }
109
+
110
+ let assistantText = '';
111
+
112
+ // 1. Try to use response from input if provided
113
+ if (response && response.content) {
114
+ log('Using response from hook input');
115
+ assistantText = typeof response.content === 'string'
116
+ ? response.content
117
+ : JSON.stringify(response.content);
118
+ }
119
+ // 2. Otherwise, parse the transcript file
120
+ else {
121
+ log('Parsing transcript file from disk');
122
+ const transcript = await getTranscript(workingDir, log);
123
+ if (transcript && Array.isArray(transcript)) {
124
+ // Find last model message
125
+ for (let i = transcript.length - 1; i >= 0; i--) {
126
+ const msg = transcript[i];
127
+ if (msg.role === 'model' || msg.role === 'assistant') {
128
+ if (Array.isArray(msg.parts)) {
129
+ assistantText = msg.parts
130
+ .filter(p => p.text)
131
+ .map(p => p.text)
132
+ .join('\n\n');
133
+ } else if (typeof msg.content === 'string') {
134
+ assistantText = msg.content;
135
+ }
136
+
137
+ if (assistantText) break;
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ if (!assistantText) {
144
+ log('No assistant message found to log');
145
+ return exit(0);
146
+ }
147
+
148
+ // Truncate if too long (parity with Claude)
149
+ const ASSISTANT_RESPONSE_MAX_LENGTH = 2000;
150
+ const preview = assistantText.length > ASSISTANT_RESPONSE_MAX_LENGTH
151
+ ? assistantText.slice(0, ASSISTANT_RESPONSE_MAX_LENGTH) + '...'
152
+ : assistantText;
153
+
154
+ // Log to timeline
155
+ const timelineEvent = {
156
+ session_id: teleportSessionId,
157
+ type: 'assistant_response',
158
+ data: {
159
+ message: preview,
160
+ model: model || null,
161
+ full_length: assistantText.length,
162
+ truncated: assistantText.length > ASSISTANT_RESPONSE_MAX_LENGTH,
163
+ timestamp: Date.now()
164
+ },
165
+ source: 'gemini-cli',
166
+ timestamp: Date.now()
167
+ };
168
+
169
+ try {
170
+ await fetch(`${RELAY_API_URL}/api/timeline`, {
171
+ method: 'POST',
172
+ headers: {
173
+ 'Content-Type': 'application/json',
174
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
175
+ },
176
+ body: JSON.stringify(timelineEvent),
177
+ signal: AbortSignal.timeout(5000)
178
+ });
179
+ log(`Logged assistant_response (${assistantText.length} chars)`);
180
+ } catch (e) {
181
+ log(`Failed to log timeline event: ${e.message}`);
182
+ }
183
+
184
+ return exit(0);
185
+
186
+ } catch (e) {
187
+ log(`Hook error: ${e.message}`);
188
+ return exit(0);
189
+ }
190
+ })();
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gemini CLI AfterTool Hook
4
+ *
5
+ * This hook executes AFTER any tool runs in Gemini CLI.
6
+ * It mirrors the Claude Code post_tool_use hook for Teleportation.
7
+ */
8
+
9
+ import { stdin, stdout, exit, env } from 'node:process';
10
+ import { appendFileSync } from 'node:fs';
11
+ import { fileURLToPath } from 'url';
12
+ import { dirname } from 'path';
13
+ import { loadConfig } from './shared/config.mjs';
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+
18
+ // Security: Max input size to prevent memory exhaustion
19
+ const MAX_INPUT_SIZE = 10 * 1024 * 1024; // 10MB
20
+
21
+ // Read all stdin
22
+ const readStdin = () => new Promise((resolve, reject) => {
23
+ let data = '';
24
+ stdin.setEncoding('utf8');
25
+ stdin.on('data', chunk => {
26
+ data += chunk;
27
+ if (data.length > MAX_INPUT_SIZE) {
28
+ reject(new Error('Input too large'));
29
+ }
30
+ });
31
+ stdin.on('end', () => resolve(data));
32
+ stdin.on('error', reject);
33
+ });
34
+
35
+ // Logging
36
+ const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-gemini-hook.log';
37
+ const log = (msg) => {
38
+ const timestamp = new Date().toISOString();
39
+ const logMsg = `[${timestamp}] [gemini:after_tool] ${msg}\n`;
40
+ try {
41
+ appendFileSync(hookLogFile, logMsg);
42
+ } catch (e) {
43
+ // Silently ignore
44
+ }
45
+ };
46
+
47
+ // Fetch JSON helper
48
+ const fetchJson = async (url, opts) => {
49
+ const res = await fetch(url, opts);
50
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
51
+ return res.json();
52
+ };
53
+
54
+ (async () => {
55
+ log('=== AfterTool hook invoked ===');
56
+
57
+ try {
58
+ const raw = await readStdin();
59
+ let input;
60
+ try {
61
+ input = JSON.parse(raw || '{}');
62
+ } catch (e) {
63
+ log(`Invalid JSON input: ${e.message}`);
64
+ return exit(0);
65
+ }
66
+
67
+ const { tool_name, tool_input, tool_output, success, error, session_id, duration_ms } = input;
68
+ const teleportSessionId = env.TELEPORTATION_SESSION_ID || session_id || 'unknown';
69
+ log(`Tool: ${tool_name}, Success: ${success}, Duration: ${duration_ms}ms, TeleportSession: ${teleportSessionId}`);
70
+
71
+ // Load config from shared module
72
+ const config = await loadConfig(log);
73
+ const RELAY_API_URL = config.relayApiUrl;
74
+ const RELAY_API_KEY = config.relayApiKey;
75
+
76
+ log(`Using Relay: ${RELAY_API_URL}`);
77
+
78
+ // If no relay configured, just log locally
79
+ if (!RELAY_API_URL || !RELAY_API_KEY) {
80
+ log('No relay configured, skipping timeline event');
81
+ return exit(0);
82
+ }
83
+
84
+ // Log to timeline
85
+ const timelineEvent = {
86
+ session_id: teleportSessionId,
87
+ type: 'tool_executed',
88
+ data: {
89
+ tool_name,
90
+ tool_input: JSON.stringify(tool_input).slice(0, 1000),
91
+ tool_output: typeof tool_output === 'string'
92
+ ? tool_output.slice(0, 5000)
93
+ : JSON.stringify(tool_output).slice(0, 5000),
94
+ success,
95
+ error: error || null,
96
+ duration_ms,
97
+ timestamp: Date.now()
98
+ },
99
+ source: 'gemini-cli',
100
+ timestamp: Date.now(),
101
+ };
102
+
103
+ try {
104
+ await fetch(`${RELAY_API_URL}/api/timeline`, {
105
+ method: 'POST',
106
+ headers: {
107
+ 'Content-Type': 'application/json',
108
+ 'Authorization': `Bearer ${RELAY_API_KEY}`,
109
+ },
110
+ body: JSON.stringify(timelineEvent),
111
+ signal: AbortSignal.timeout(5000),
112
+ });
113
+ log(`Timeline event logged for ${tool_name}`);
114
+ } catch (e) {
115
+ log(`Failed to log timeline event: ${e.message}`);
116
+ }
117
+
118
+ // Output empty response (hook completed successfully)
119
+ stdout.write('{}');
120
+ return exit(0);
121
+
122
+ } catch (e) {
123
+ log(`Hook error: ${e.message}`);
124
+ return exit(0);
125
+ }
126
+ })();