teleportation-cli 1.4.5 → 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
  })();
File without changes
@@ -40,7 +40,7 @@ import { spawn, exec } from 'child_process';
40
40
  import { promisify } from 'util';
41
41
  import { homedir, tmpdir } from 'os';
42
42
  import { existsSync, appendFileSync, readFileSync, unlinkSync } from 'fs';
43
- import { join, dirname } from 'path';
43
+ import { join, dirname, resolve } from 'path';
44
44
  // NOTE: PID locking is handled by agent-process at the platform level (launchd/systemd/pm2).
45
45
  // Signal handling and heartbeat management are handled inline below.
46
46
 
@@ -677,6 +677,13 @@ function validateSessionId(session_id) {
677
677
  return session_id;
678
678
  }
679
679
 
680
+ // Strict UUID v4 validation — used for externally-supplied IDs to prevent
681
+ // accidental relay requests to non-existent sessions from misconfigured env vars.
682
+ function isValidUUID(id) {
683
+ return id && typeof id === 'string' &&
684
+ /^[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);
685
+ }
686
+
680
687
  function validateApprovalId(approval_id) {
681
688
  if (!approval_id || typeof approval_id !== 'string') {
682
689
  throw new Error('approval_id must be a non-empty string');
@@ -895,6 +902,91 @@ async function handleRequest(req, res) {
895
902
  return;
896
903
  }
897
904
 
905
+ // Stop-hook delegate: ingest transcript asynchronously so stop.mjs can exit immediately
906
+ if (method === 'POST' && pathname === '/sessions/stop') {
907
+ const body = await parseJSONBody(req);
908
+ const { session_id, transcript_path, parent_session_id } = body;
909
+
910
+ try {
911
+ validateSessionId(session_id);
912
+ } catch (validationError) {
913
+ sendJSON(res, 400, { error: validationError.message });
914
+ return;
915
+ }
916
+
917
+ if (!transcript_path || typeof transcript_path !== 'string') {
918
+ sendJSON(res, 400, { error: 'transcript_path must be a non-empty string' });
919
+ return;
920
+ }
921
+
922
+ // Path traversal guard: transcript_path must resolve inside ~/.claude/projects/.
923
+ // The daemon has no auth on localhost — any local process can POST to it, so we
924
+ // must not allow arbitrary filesystem reads via a crafted transcript_path value.
925
+ const resolvedTranscriptPath = resolve(transcript_path);
926
+ const allowedTranscriptRoot = join(homedir(), '.claude', 'projects');
927
+ if (!resolvedTranscriptPath.startsWith(allowedTranscriptRoot + '/')) {
928
+ sendJSON(res, 400, { error: 'transcript_path must be within ~/.claude/projects' });
929
+ return;
930
+ }
931
+
932
+ // Validate optional parent_session_id (UUID v4 only, must differ from session_id)
933
+ const validParentId = parent_session_id &&
934
+ isValidUUID(parent_session_id) &&
935
+ parent_session_id !== session_id
936
+ ? parent_session_id
937
+ : null;
938
+
939
+ // Respond immediately — processing is async (AC3)
940
+ sendJSON(res, 200, { queued: true });
941
+
942
+ // Fire-and-forget ingestion (AC3, AC5)
943
+ setImmediate(async () => {
944
+ const sessionEntry = sessions.get(session_id);
945
+ const claude_session_id = sessionEntry?.claude_session_id || session_id;
946
+ const cwd = sessionEntry?.cwd || process.cwd();
947
+
948
+ console.log(`[daemon] /sessions/stop: ingesting transcript for ${session_id} (transcript_path=${transcript_path})`);
949
+
950
+ try {
951
+ await ingestTranscriptToTimeline({
952
+ claude_session_id,
953
+ parent_session_id: session_id,
954
+ task_id: null,
955
+ cwd,
956
+ transcript_path, // AC4: use authoritative path from stop hook
957
+ config: {
958
+ relayApiUrl: RELAY_API_URL,
959
+ apiKey: RELAY_API_KEY
960
+ },
961
+ maxEvents: Infinity // AC5: no cap for triggered ingestion
962
+ });
963
+ } catch (err) {
964
+ console.error(`[daemon] /sessions/stop: child ingestion error for ${session_id}: ${err.message}`);
965
+ }
966
+
967
+ // AC6: Best-effort fan-out to parent session timeline
968
+ if (validParentId) {
969
+ try {
970
+ await ingestTranscriptToTimeline({
971
+ claude_session_id,
972
+ parent_session_id: validParentId,
973
+ task_id: null,
974
+ cwd,
975
+ transcript_path,
976
+ config: {
977
+ relayApiUrl: RELAY_API_URL,
978
+ apiKey: RELAY_API_KEY
979
+ },
980
+ maxEvents: Infinity
981
+ });
982
+ } catch (err) {
983
+ console.warn(`[daemon] ⚠️ Parent fan-out failed for session ${validParentId}: ${err.message} — missed events are NOT retried (best-effort)`);
984
+ }
985
+ }
986
+ });
987
+
988
+ return;
989
+ }
898
990
 
899
991
  // Queue approval for daemon handling
900
992
  if (method === 'POST' && pathname === '/approvals/handoff') {
@@ -665,7 +665,7 @@ async function updateLastIngestedIndex(task_id, session_id, lastIndex, config) {
665
665
  * @returns {Promise<Object>} Result { events_pushed: number }
666
666
  */
667
667
  export async function ingestTranscriptToTimeline(options) {
668
- const { claude_session_id, parent_session_id, task_id, cwd, config, onNormalizedEvents, realTimeMode = false, maxEvents } = options;
668
+ const { claude_session_id, parent_session_id, task_id, cwd, config, onNormalizedEvents, realTimeMode = false, maxEvents, transcript_path } = options;
669
669
  const { relayApiUrl, apiKey } = config;
670
670
 
671
671
  // Determine max events based on mode
@@ -689,8 +689,25 @@ export async function ingestTranscriptToTimeline(options) {
689
689
  console.log(`[transcript] ===== INGESTION START =====`);
690
690
  console.log(`[transcript] claude_session: ${claude_session_id}, parent: ${parent_session_id}, task: ${task_id}`);
691
691
 
692
- // 1. Read transcript with cwd for project slug derivation
693
- const transcript = await readTranscript(claude_session_id, cwd);
692
+ // 1. Read transcript use explicit path when provided (AC4), otherwise derive via cwd/project-slug.
693
+ // When transcript_path is supplied by the stop-hook delegate, the daemon skips path derivation
694
+ // entirely. The daemon's cursor (in /tmp/teleportation-cursors/) becomes the sole writer;
695
+ // any stale stop-hook cursors (~/.teleportation/.stop_hook_cursor_v2/) are silently superseded.
696
+ // ON CONFLICT DO NOTHING on the relay prevents duplicates on first-run reprocessing.
697
+ let transcript;
698
+ if (transcript_path) {
699
+ try {
700
+ const content = await readFile(transcript_path, 'utf8');
701
+ const lines = content.trim().split('\n').filter(Boolean);
702
+ transcript = lines.map(line => JSON.parse(line));
703
+ console.log(`[transcript] Loaded ${transcript.length} messages from explicit path: ${transcript_path}`);
704
+ } catch (e) {
705
+ console.error(`[transcript] Failed to read transcript at ${transcript_path}: ${e.message}`);
706
+ transcript = [];
707
+ }
708
+ } else {
709
+ transcript = await readTranscript(claude_session_id, cwd);
710
+ }
694
711
  log(`Read transcript: ${transcript.length} messages`);
695
712
  if (transcript.length === 0) {
696
713
  log(`No transcript found or empty - returning`);
@@ -717,6 +734,12 @@ export async function ingestTranscriptToTimeline(options) {
717
734
  // For regular sessions: Use local cursor as primary deduplication,
718
735
  // with timeline query as validation
719
736
  const cursorMessageCount = await readCursor(parent_session_id);
737
+ if (cursorMessageCount === 0 && transcript_path) {
738
+ // AC7: First daemon-triggered ingestion for this session. The stop-hook cursor
739
+ // (~/.teleportation/.stop_hook_cursor_v2/) is now stale. The daemon reprocesses
740
+ // from the start; server-side ON CONFLICT DO NOTHING prevents duplicates.
741
+ console.log(`[stop-delegate] First daemon ingestion for session ${parent_session_id} — reprocessing from start (dedup handles any duplicates)`);
742
+ }
720
743
  console.log(`[transcript] Local cursor: ${cursorMessageCount} messages previously processed`);
721
744
  console.log(`[transcript] Querying timeline for session ${parent_session_id} to find recent events...`);
722
745
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teleportation-cli",
3
- "version": "1.4.5",
3
+ "version": "1.5.0",
4
4
  "description": "Remote approval system for Claude Code - approve AI coding changes from your phone",
5
5
  "type": "module",
6
6
  "main": "teleportation-cli.cjs",
@@ -56,6 +56,8 @@
56
56
  ".claude/hooks/*.mjs",
57
57
  "!.claude/hooks/*.test.mjs",
58
58
  ".gemini/hooks/*.mjs",
59
+ "!.gemini/hooks/*.test.mjs",
60
+ "!.gemini/hooks/test-hooks.mjs",
59
61
  ".gemini/hooks/shared/*.mjs",
60
62
  "teleportation.uhr.json",
61
63
  "scripts/sync-transcripts.sh",
@@ -335,6 +335,21 @@ async function commandOn() {
335
335
  if (uhrResult.warnings.length > 0) {
336
336
  uhrResult.warnings.forEach(w => console.log(c.yellow(` ⚠️ ${w}`)));
337
337
  }
338
+
339
+ // UHR handles Claude Code + Gemini CLI via settings files, but Cursor IDE
340
+ // hooks require a separate write to ~/.cursor/hooks.json via the legacy installer
341
+ try {
342
+ const installerPath = path.join(TELEPORTATION_DIR, 'lib', 'install', 'installer.js');
343
+ const { checkCursorIde, installCursorHooks } = await import('file://' + installerPath);
344
+ if (checkCursorIde().valid) {
345
+ const sourceHooksDir = path.join(TELEPORTATION_DIR, '.claude', 'hooks');
346
+ await installCursorHooks(sourceHooksDir);
347
+ console.log(c.green(' ✅ Cursor IDE hooks installed'));
348
+ console.log(c.dim(' Config: ~/.cursor/hooks.json'));
349
+ }
350
+ } catch (e) {
351
+ // Cursor not installed or hooks install failed — non-fatal
352
+ }
338
353
  } else {
339
354
  if (uhrResult.success && !fs.existsSync(globalSettings)) {
340
355
  console.log(c.yellow(' ⚠️ UHR reported success but did not write ~/.claude/settings.json'));
@@ -3467,7 +3482,8 @@ async function commandInstallHooks() {
3467
3482
  }
3468
3483
  installed = true;
3469
3484
 
3470
- // UHR only handles Claude Code also install Cursor hooks via legacy installer
3485
+ // UHR handles Claude Code + Gemini CLI via settings files, but Cursor IDE
3486
+ // hooks require a separate write to ~/.cursor/hooks.json via the legacy installer
3471
3487
  try {
3472
3488
  const installerPath = path.join(TELEPORTATION_DIR, 'lib', 'install', 'installer.js');
3473
3489
  const { checkCursorIde, installCursorHooks } = await import('file://' + installerPath);
@@ -1,121 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Tests for Gemini CLI AfterAgent hook
4
- */
5
-
6
- import { describe, it, expect, beforeEach, afterEach, vi } from 'bun:test';
7
- import * as fs from 'node:fs';
8
- import * as fsPromises from 'node:fs/promises';
9
- import { join } from 'node:path';
10
- import { homedir } from 'node:os';
11
-
12
- // Helper to simulate running the hook script
13
- // We'll extract the core logic into a testable function or just mock the dependencies
14
- // and require the script. Since it's an IIFE, we might need to refactor it slightly
15
- // for better testability, but for now we'll mock the global environment.
16
-
17
- describe('AfterAgent Hook Logic', () => {
18
- let originalFetch = globalThis.fetch;
19
- let existsSyncSpy;
20
- let readFileSpy;
21
- let fetchSpy;
22
-
23
- beforeEach(() => {
24
- existsSyncSpy = vi.spyOn(fs, 'existsSync');
25
- readFileSpy = vi.spyOn(fsPromises, 'readFile');
26
- fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true }) });
27
- globalThis.fetch = fetchSpy;
28
- });
29
-
30
- afterEach(() => {
31
- vi.restoreAllMocks();
32
- globalThis.fetch = originalFetch;
33
- });
34
-
35
- it('should calculate the correct transcript path', () => {
36
- const { createHash } = require('node:crypto');
37
- const cwd = '/Users/test/project';
38
- const hash = createHash('md5').update(cwd).digest('hex').substring(0, 16);
39
- const expectedPath = join(homedir(), '.gemini', 'tmp', hash, 'logs.json');
40
-
41
- // This just verifies our understanding of the path calculation
42
- expect(expectedPath).toContain('.gemini/tmp/');
43
- expect(expectedPath).toContain('/logs.json');
44
- });
45
-
46
- // Since we can't easily run the IIFE in the script without refactoring,
47
- // let's at least test the parsing logic by re-implementing it here
48
- // and ensuring it works as expected. This verifies the "Dialectical Autocoder"
49
- // requirement for robust parsing.
50
-
51
- const parseTranscript = (content) => {
52
- try {
53
- const transcript = JSON.parse(content);
54
- if (!Array.isArray(transcript)) return null;
55
-
56
- for (let i = transcript.length - 1; i >= 0; i--) {
57
- const msg = transcript[i];
58
- if (msg.role === 'model' || msg.role === 'assistant') {
59
- if (Array.isArray(msg.parts)) {
60
- return msg.parts
61
- .filter(p => p.text)
62
- .map(p => p.text)
63
- .join('\n\n');
64
- } else if (typeof msg.content === 'string') {
65
- return msg.content;
66
- }
67
- }
68
- }
69
- } catch (e) {
70
- return null;
71
- }
72
- return null;
73
- };
74
-
75
- it('should parse assistant response from JSON array transcript', () => {
76
- const mockTranscript = JSON.stringify([
77
- { role: 'user', parts: [{ text: 'hello' }] },
78
- { role: 'model', parts: [{ text: 'hi there' }] }
79
- ]);
80
-
81
- const result = parseTranscript(mockTranscript);
82
- expect(result).toBe('hi there');
83
- });
84
-
85
- it('should parse assistant response with multiple parts', () => {
86
- const mockTranscript = JSON.stringify([
87
- { role: 'model', parts: [{ text: 'Part 1' }, { text: 'Part 2' }] }
88
- ]);
89
-
90
- const result = parseTranscript(mockTranscript);
91
- expect(result).toBe('Part 1\n\nPart 2');
92
- });
93
-
94
- it('should parse assistant response from content field (alternative format)', () => {
95
- const mockTranscript = JSON.stringify([
96
- { role: 'assistant', content: 'Legacy format response' }
97
- ]);
98
-
99
- const result = parseTranscript(mockTranscript);
100
- expect(result).toBe('Legacy format response');
101
- });
102
-
103
- it('should return null for transcript without assistant messages', () => {
104
- const mockTranscript = JSON.stringify([
105
- { role: 'user', parts: [{ text: 'hello' }] }
106
- ]);
107
-
108
- const result = parseTranscript(mockTranscript);
109
- expect(result).toBeNull();
110
- });
111
-
112
- it('should handle malformed JSON gracefully', () => {
113
- const result = parseTranscript('not json');
114
- expect(result).toBeNull();
115
- });
116
-
117
- it('should handle empty transcript', () => {
118
- const result = parseTranscript('[]');
119
- expect(result).toBeNull();
120
- });
121
- });
@@ -1,254 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Test script for Gemini CLI hooks
4
- *
5
- * Tests each hook individually by simulating the input they receive
6
- * from Gemini CLI and verifying the output.
7
- *
8
- * Usage:
9
- * node .gemini/hooks/test-hooks.mjs
10
- *
11
- * Or with relay configured:
12
- * RELAY_API_URL=http://localhost:3030 RELAY_API_KEY=your-key node .gemini/hooks/test-hooks.mjs
13
- */
14
-
15
- import { spawn } from 'child_process';
16
- import { fileURLToPath } from 'url';
17
- import { dirname, join } from 'path';
18
-
19
- const __filename = fileURLToPath(import.meta.url);
20
- const __dirname = dirname(__filename);
21
-
22
- // Helper to run a hook with input and capture output
23
- async function runHook(hookName, input, timeoutMs = 10000) {
24
- return new Promise((resolve, reject) => {
25
- const hookPath = join(__dirname, `${hookName}.mjs`);
26
- const proc = spawn('node', [hookPath], {
27
- stdio: ['pipe', 'pipe', 'pipe'],
28
- env: {
29
- ...process.env,
30
- TELEPORTATION_SESSION_ID: 'test-session-' + Date.now(),
31
- },
32
- });
33
-
34
- let stdout = '';
35
- let stderr = '';
36
-
37
- proc.stdout.on('data', (data) => {
38
- stdout += data.toString();
39
- });
40
-
41
- proc.stderr.on('data', (data) => {
42
- stderr += data.toString();
43
- });
44
-
45
- const timeout = setTimeout(() => {
46
- proc.kill('SIGTERM');
47
- reject(new Error(`Hook ${hookName} timed out after ${timeoutMs}ms`));
48
- }, timeoutMs);
49
-
50
- proc.on('close', (code) => {
51
- clearTimeout(timeout);
52
- resolve({ code, stdout, stderr });
53
- });
54
-
55
- proc.on('error', (err) => {
56
- clearTimeout(timeout);
57
- reject(err);
58
- });
59
-
60
- // Send input
61
- proc.stdin.write(JSON.stringify(input));
62
- proc.stdin.end();
63
- });
64
- }
65
-
66
- // Test cases
67
- async function testBeforeTool() {
68
- console.log('\n=== Testing before_tool.mjs ===\n');
69
-
70
- // Test 1: Safe tool (should auto-approve)
71
- console.log('1. Testing safe tool (read_file)...');
72
- try {
73
- const result = await runHook('before_tool', {
74
- tool_name: 'read_file',
75
- tool_input: { path: '/tmp/test.txt' },
76
- session_id: 'test-session',
77
- });
78
-
79
- console.log(` Exit code: ${result.code}`);
80
- console.log(` Output: ${result.stdout}`);
81
-
82
- const output = JSON.parse(result.stdout || '{}');
83
- if (output.action === 'ALLOW') {
84
- console.log(' ✓ Safe tool auto-approved');
85
- } else {
86
- console.log(` ✗ Expected ALLOW, got ${output.action}`);
87
- }
88
- } catch (e) {
89
- console.log(` ✗ Error: ${e.message}`);
90
- }
91
-
92
- // Test 2: Safe shell command (git status)
93
- console.log('\n2. Testing safe shell command (git status)...');
94
- try {
95
- const result = await runHook('before_tool', {
96
- tool_name: 'run_shell_command',
97
- tool_input: { command: 'git status' },
98
- session_id: 'test-session',
99
- });
100
-
101
- console.log(` Exit code: ${result.code}`);
102
- console.log(` Output: ${result.stdout}`);
103
-
104
- const output = JSON.parse(result.stdout || '{}');
105
- if (output.action === 'ALLOW') {
106
- console.log(' ✓ Safe shell command auto-approved');
107
- } else {
108
- console.log(` ✗ Expected ALLOW, got ${output.action}`);
109
- }
110
- } catch (e) {
111
- console.log(` ✗ Error: ${e.message}`);
112
- }
113
-
114
- // Test 3: Potentially dangerous tool (should ask or check relay)
115
- console.log('\n3. Testing potentially dangerous tool (write_file)...');
116
- try {
117
- const result = await runHook('before_tool', {
118
- tool_name: 'write_file',
119
- tool_input: { path: '/tmp/test.txt', content: 'hello' },
120
- session_id: 'test-session',
121
- });
122
-
123
- console.log(` Exit code: ${result.code}`);
124
- console.log(` Output: ${result.stdout}`);
125
-
126
- const output = JSON.parse(result.stdout || '{}');
127
- console.log(` Action: ${output.action}, Reason: ${output.reason}`);
128
- // Without relay, should either ALLOW (no relay) or ASK_USER (user present)
129
- if (output.action === 'ALLOW' || output.action === 'ASK_USER') {
130
- console.log(' ✓ Handled correctly (no relay or user present)');
131
- } else {
132
- console.log(` Note: Got ${output.action}`);
133
- }
134
- } catch (e) {
135
- console.log(` ✗ Error: ${e.message}`);
136
- }
137
- }
138
-
139
- async function testAfterTool() {
140
- console.log('\n=== Testing after_tool.mjs ===\n');
141
-
142
- console.log('1. Testing tool result logging...');
143
- try {
144
- const result = await runHook('after_tool', {
145
- tool_name: 'read_file',
146
- tool_input: { path: '/tmp/test.txt' },
147
- tool_output: 'file contents here',
148
- success: true,
149
- duration_ms: 50,
150
- session_id: 'test-session',
151
- });
152
-
153
- console.log(` Exit code: ${result.code}`);
154
- console.log(` Output: ${result.stdout}`);
155
-
156
- if (result.code === 0) {
157
- console.log(' ✓ After tool hook completed');
158
- } else {
159
- console.log(` ✗ Hook failed with code ${result.code}`);
160
- }
161
- } catch (e) {
162
- console.log(` ✗ Error: ${e.message}`);
163
- }
164
- }
165
-
166
- async function testSessionStart() {
167
- console.log('\n=== Testing session_start.mjs ===\n');
168
-
169
- console.log('1. Testing session registration...');
170
- try {
171
- const result = await runHook('session_start', {
172
- session_id: 'gemini-test-' + Date.now(),
173
- model: 'gemini-2.5-flash',
174
- cwd: process.cwd(),
175
- });
176
-
177
- console.log(` Exit code: ${result.code}`);
178
- console.log(` Output: ${result.stdout}`);
179
-
180
- const output = JSON.parse(result.stdout || '{}');
181
- if (output.session_id) {
182
- console.log(` ✓ Session registered: ${output.session_id}`);
183
- } else {
184
- console.log(' ✗ No session_id in output');
185
- }
186
- } catch (e) {
187
- console.log(` ✗ Error: ${e.message}`);
188
- }
189
- }
190
-
191
- async function testSessionEnd() {
192
- console.log('\n=== Testing session_end.mjs ===\n');
193
-
194
- console.log('1. Testing session cleanup...');
195
- try {
196
- const result = await runHook('session_end', {
197
- session_id: 'test-session',
198
- reason: 'user_exit',
199
- stats: {
200
- total_tokens: 1000,
201
- duration_ms: 60000,
202
- },
203
- });
204
-
205
- console.log(` Exit code: ${result.code}`);
206
- console.log(` Output: ${result.stdout}`);
207
-
208
- if (result.code === 0) {
209
- console.log(' ✓ Session end hook completed');
210
- } else {
211
- console.log(` ✗ Hook failed with code ${result.code}`);
212
- }
213
- } catch (e) {
214
- console.log(` ✗ Error: ${e.message}`);
215
- }
216
- }
217
-
218
- // Check hook log
219
- async function showHookLog() {
220
- console.log('\n=== Hook Log ===\n');
221
- try {
222
- const { readFileSync } = await import('fs');
223
- const log = readFileSync('/tmp/teleportation-gemini-hook.log', 'utf8');
224
- const lines = log.split('\n').slice(-30); // Last 30 lines
225
- console.log(lines.join('\n'));
226
- } catch (e) {
227
- console.log('No hook log found (this is normal for first run)');
228
- }
229
- }
230
-
231
- // Main
232
- async function main() {
233
- console.log('╔════════════════════════════════════════════════════════════╗');
234
- console.log('║ Gemini CLI Hooks Test Suite ║');
235
- console.log('╚════════════════════════════════════════════════════════════╝');
236
-
237
- console.log('\nEnvironment:');
238
- console.log(` RELAY_API_URL: ${process.env.RELAY_API_URL || '(not set)'}`);
239
- console.log(` RELAY_API_KEY: ${process.env.RELAY_API_KEY ? '***' : '(not set)'}`);
240
- console.log(` CWD: ${process.cwd()}`);
241
-
242
- await testBeforeTool();
243
- await testAfterTool();
244
- await testSessionStart();
245
- await testSessionEnd();
246
-
247
- await showHookLog();
248
-
249
- console.log('\n╔════════════════════════════════════════════════════════════╗');
250
- console.log('║ Test Complete ║');
251
- console.log('╚════════════════════════════════════════════════════════════╝\n');
252
- }
253
-
254
- main().catch(console.error);