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.
- package/.claude/hooks/stop.mjs +114 -33
- package/.gemini/hooks/after_agent.mjs +0 -0
- package/lib/daemon/teleportation-daemon.js +93 -1
- package/lib/daemon/transcript-ingestion.js +26 -3
- package/package.json +3 -1
- package/teleportation-cli.cjs +17 -1
- package/.gemini/hooks/after_agent.test.mjs +0 -121
- package/.gemini/hooks/test-hooks.mjs +0 -254
package/.claude/hooks/stop.mjs
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* This hook fires when Claude Code finishes responding.
|
|
6
6
|
*
|
|
7
7
|
* Purpose:
|
|
8
|
-
* 1.
|
|
9
|
-
* 2.
|
|
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
|
|
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
|
|
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",
|
|
@@ -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",
|
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);
|
|
@@ -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);
|