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.
@@ -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.4",
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",
@@ -55,6 +55,11 @@
55
55
  "!lib/**/*.log",
56
56
  ".claude/hooks/*.mjs",
57
57
  "!.claude/hooks/*.test.mjs",
58
+ ".gemini/hooks/*.mjs",
59
+ "!.gemini/hooks/*.test.mjs",
60
+ "!.gemini/hooks/test-hooks.mjs",
61
+ ".gemini/hooks/shared/*.mjs",
62
+ "teleportation.uhr.json",
58
63
  "scripts/sync-transcripts.sh",
59
64
  "teleportation-cli.cjs",
60
65
  "README.md",
@@ -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);
@@ -3506,6 +3522,11 @@ async function commandInstallHooks() {
3506
3522
  console.log(c.dim(' Directory: ~/.gemini/hooks/'));
3507
3523
  }
3508
3524
 
3525
+ if (result.cursorHooksInstalled > 0) {
3526
+ console.log(c.green(` ✅ Cursor IDE hooks installed`));
3527
+ console.log(c.dim(' Config: ~/.cursor/hooks.json'));
3528
+ }
3529
+
3509
3530
  if (result.libFilesInstalled > 0) {
3510
3531
  console.log(c.green(` ✅ ${result.libFilesInstalled} shared library files installed`));
3511
3532
  }
@@ -0,0 +1,76 @@
1
+ {
2
+ "$schema": "https://uhr.dev/schema/manifest.v1.json",
3
+ "name": "teleportation",
4
+ "version": "1.2.0",
5
+ "description": "Remote approval system for Claude Code — approve AI coding changes from any device",
6
+ "homepage": "https://github.com/dundas/teleportation-private",
7
+ "hooks": [
8
+ {
9
+ "id": "pre-tool-use",
10
+ "on": "beforeToolExecution",
11
+ "command": "bun __HOOKS_DIR__/pre_tool_use.mjs",
12
+ "tools": ["*"],
13
+ "blocking": true,
14
+ "timeout": 300000,
15
+ "platforms": ["claude-code", "gemini-cli", "cursor"]
16
+ },
17
+ {
18
+ "id": "session-start",
19
+ "on": "sessionStart",
20
+ "command": "bun __HOOKS_DIR__/session_start.mjs",
21
+ "platforms": ["claude-code", "gemini-cli", "cursor"]
22
+ },
23
+ {
24
+ "id": "session-end",
25
+ "on": "sessionEnd",
26
+ "command": "bun __HOOKS_DIR__/session_end.mjs",
27
+ "platforms": ["claude-code", "gemini-cli", "cursor"]
28
+ },
29
+ {
30
+ "id": "stop",
31
+ "on": "stop",
32
+ "command": "bun __HOOKS_DIR__/stop.mjs",
33
+ "platforms": ["claude-code", "cursor"]
34
+ },
35
+ {
36
+ "id": "notification",
37
+ "on": "notification",
38
+ "command": "bun __HOOKS_DIR__/notification.mjs",
39
+ "platforms": ["claude-code"]
40
+ },
41
+ {
42
+ "id": "user-prompt-submit",
43
+ "on": "beforePromptSubmit",
44
+ "command": "bun __HOOKS_DIR__/user_prompt_submit.mjs",
45
+ "platforms": ["claude-code", "cursor"]
46
+ },
47
+ {
48
+ "id": "permission-request",
49
+ "on": "permissionRequest",
50
+ "command": "bun __HOOKS_DIR__/permission_request.mjs",
51
+ "blocking": true,
52
+ "timeout": 300000,
53
+ "platforms": ["claude-code"]
54
+ },
55
+ {
56
+ "id": "post-tool-use",
57
+ "on": "afterToolExecution",
58
+ "command": "bun __HOOKS_DIR__/post_tool_use.mjs",
59
+ "tools": ["*"],
60
+ "platforms": ["claude-code", "cursor"]
61
+ }
62
+ ],
63
+ "permissions": {
64
+ "allow": ["read", "grep", "fetch"],
65
+ "deny": []
66
+ },
67
+ "ordering": {
68
+ "pre-tool-use": {
69
+ "runBefore": ["*"]
70
+ }
71
+ },
72
+ "meta": {
73
+ "minUhrVersion": "0.1.0",
74
+ "license": "MIT"
75
+ }
76
+ }