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.
- package/.claude/hooks/stop.mjs +114 -33
- package/.gemini/hooks/after_agent.mjs +190 -0
- package/.gemini/hooks/after_tool.mjs +126 -0
- package/.gemini/hooks/before_tool.mjs +276 -0
- package/.gemini/hooks/session_end.mjs +158 -0
- package/.gemini/hooks/session_start.mjs +193 -0
- package/.gemini/hooks/shared/config.mjs +67 -0
- package/lib/daemon/teleportation-daemon.js +93 -1
- package/lib/daemon/transcript-ingestion.js +26 -3
- package/package.json +6 -1
- package/teleportation-cli.cjs +22 -1
- package/teleportation.uhr.json +76 -0
|
@@ -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
|
|
693
|
-
|
|
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.
|
|
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",
|
package/teleportation-cli.cjs
CHANGED
|
@@ -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
|
|
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
|
+
}
|