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
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
|
})();
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Gemini CLI AfterAgent Hook
|
|
4
|
+
*
|
|
5
|
+
* This hook fires when the Gemini agent completes its planning/execution loop.
|
|
6
|
+
* It mirrors the Claude Code stop hook for Teleportation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { stdin, stdout, exit, env } from 'node:process';
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { appendFileSync, existsSync } from 'node:fs';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { loadConfig } from './shared/config.mjs';
|
|
17
|
+
|
|
18
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
19
|
+
const __dirname = dirname(__filename);
|
|
20
|
+
|
|
21
|
+
// Security: Max input size to prevent memory exhaustion
|
|
22
|
+
const MAX_INPUT_SIZE = 10 * 1024 * 1024; // 10MB
|
|
23
|
+
|
|
24
|
+
// Read all stdin
|
|
25
|
+
const readStdin = () => new Promise((resolve, reject) => {
|
|
26
|
+
let data = '';
|
|
27
|
+
stdin.setEncoding('utf8');
|
|
28
|
+
stdin.on('data', chunk => {
|
|
29
|
+
data += chunk;
|
|
30
|
+
if (data.length > MAX_INPUT_SIZE) {
|
|
31
|
+
reject(new Error('Input too large'));
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
stdin.on('end', () => resolve(data));
|
|
35
|
+
stdin.on('error', reject);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Logging
|
|
39
|
+
const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-gemini-hook.log';
|
|
40
|
+
const log = (msg) => {
|
|
41
|
+
const timestamp = new Date().toISOString();
|
|
42
|
+
const logMsg = `[${timestamp}] [gemini:after_agent] ${msg}\n`;
|
|
43
|
+
try {
|
|
44
|
+
appendFileSync(hookLogFile, logMsg);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// Silently ignore
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generate MD5 hash of project slug for Gemini's directory naming.
|
|
52
|
+
*/
|
|
53
|
+
function hashSlug(slug) {
|
|
54
|
+
if (!slug) return createHash('md5').update('default').digest('hex').substring(0, 16);
|
|
55
|
+
return createHash('md5').update(slug).digest('hex').substring(0, 16);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Find and parse the Gemini transcript
|
|
60
|
+
*/
|
|
61
|
+
async function getTranscript(cwd, log) {
|
|
62
|
+
const hash = hashSlug(cwd);
|
|
63
|
+
const transcriptPath = join(homedir(), '.gemini', 'tmp', hash, 'logs.json');
|
|
64
|
+
|
|
65
|
+
if (!existsSync(transcriptPath)) {
|
|
66
|
+
log(`Transcript not found at ${transcriptPath}`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const content = await readFile(transcriptPath, 'utf8');
|
|
72
|
+
return JSON.parse(content);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
log(`Failed to parse transcript: ${e.message}`);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
(async () => {
|
|
80
|
+
log('=== AfterAgent hook invoked ===');
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const raw = await readStdin();
|
|
84
|
+
let input;
|
|
85
|
+
try {
|
|
86
|
+
input = JSON.parse(raw || '{}');
|
|
87
|
+
} catch (e) {
|
|
88
|
+
log(`Invalid JSON input: ${e.message}`);
|
|
89
|
+
return exit(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { session_id, model, response, cwd } = input;
|
|
93
|
+
const workingDir = cwd || process.cwd();
|
|
94
|
+
const teleportSessionId = env.TELEPORTATION_SESSION_ID || session_id || 'unknown';
|
|
95
|
+
|
|
96
|
+
log(`Session: ${teleportSessionId}, Model: ${model}, CWD: ${workingDir}`);
|
|
97
|
+
|
|
98
|
+
// Load config from shared module
|
|
99
|
+
const config = await loadConfig(log);
|
|
100
|
+
const RELAY_API_URL = config.relayApiUrl;
|
|
101
|
+
const RELAY_API_KEY = config.relayApiKey;
|
|
102
|
+
|
|
103
|
+
log(`Using Relay: ${RELAY_API_URL}`);
|
|
104
|
+
|
|
105
|
+
if (!RELAY_API_URL || !RELAY_API_KEY) {
|
|
106
|
+
log('No relay configured, skipping transcript ingestion');
|
|
107
|
+
return exit(0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let assistantText = '';
|
|
111
|
+
|
|
112
|
+
// 1. Try to use response from input if provided
|
|
113
|
+
if (response && response.content) {
|
|
114
|
+
log('Using response from hook input');
|
|
115
|
+
assistantText = typeof response.content === 'string'
|
|
116
|
+
? response.content
|
|
117
|
+
: JSON.stringify(response.content);
|
|
118
|
+
}
|
|
119
|
+
// 2. Otherwise, parse the transcript file
|
|
120
|
+
else {
|
|
121
|
+
log('Parsing transcript file from disk');
|
|
122
|
+
const transcript = await getTranscript(workingDir, log);
|
|
123
|
+
if (transcript && Array.isArray(transcript)) {
|
|
124
|
+
// Find last model message
|
|
125
|
+
for (let i = transcript.length - 1; i >= 0; i--) {
|
|
126
|
+
const msg = transcript[i];
|
|
127
|
+
if (msg.role === 'model' || msg.role === 'assistant') {
|
|
128
|
+
if (Array.isArray(msg.parts)) {
|
|
129
|
+
assistantText = msg.parts
|
|
130
|
+
.filter(p => p.text)
|
|
131
|
+
.map(p => p.text)
|
|
132
|
+
.join('\n\n');
|
|
133
|
+
} else if (typeof msg.content === 'string') {
|
|
134
|
+
assistantText = msg.content;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (assistantText) break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!assistantText) {
|
|
144
|
+
log('No assistant message found to log');
|
|
145
|
+
return exit(0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Truncate if too long (parity with Claude)
|
|
149
|
+
const ASSISTANT_RESPONSE_MAX_LENGTH = 2000;
|
|
150
|
+
const preview = assistantText.length > ASSISTANT_RESPONSE_MAX_LENGTH
|
|
151
|
+
? assistantText.slice(0, ASSISTANT_RESPONSE_MAX_LENGTH) + '...'
|
|
152
|
+
: assistantText;
|
|
153
|
+
|
|
154
|
+
// Log to timeline
|
|
155
|
+
const timelineEvent = {
|
|
156
|
+
session_id: teleportSessionId,
|
|
157
|
+
type: 'assistant_response',
|
|
158
|
+
data: {
|
|
159
|
+
message: preview,
|
|
160
|
+
model: model || null,
|
|
161
|
+
full_length: assistantText.length,
|
|
162
|
+
truncated: assistantText.length > ASSISTANT_RESPONSE_MAX_LENGTH,
|
|
163
|
+
timestamp: Date.now()
|
|
164
|
+
},
|
|
165
|
+
source: 'gemini-cli',
|
|
166
|
+
timestamp: Date.now()
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: {
|
|
173
|
+
'Content-Type': 'application/json',
|
|
174
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
175
|
+
},
|
|
176
|
+
body: JSON.stringify(timelineEvent),
|
|
177
|
+
signal: AbortSignal.timeout(5000)
|
|
178
|
+
});
|
|
179
|
+
log(`Logged assistant_response (${assistantText.length} chars)`);
|
|
180
|
+
} catch (e) {
|
|
181
|
+
log(`Failed to log timeline event: ${e.message}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return exit(0);
|
|
185
|
+
|
|
186
|
+
} catch (e) {
|
|
187
|
+
log(`Hook error: ${e.message}`);
|
|
188
|
+
return exit(0);
|
|
189
|
+
}
|
|
190
|
+
})();
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Gemini CLI AfterTool Hook
|
|
4
|
+
*
|
|
5
|
+
* This hook executes AFTER any tool runs in Gemini CLI.
|
|
6
|
+
* It mirrors the Claude Code post_tool_use hook for Teleportation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { stdin, stdout, exit, env } from 'node:process';
|
|
10
|
+
import { appendFileSync } from 'node:fs';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { dirname } from 'path';
|
|
13
|
+
import { loadConfig } from './shared/config.mjs';
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = dirname(__filename);
|
|
17
|
+
|
|
18
|
+
// Security: Max input size to prevent memory exhaustion
|
|
19
|
+
const MAX_INPUT_SIZE = 10 * 1024 * 1024; // 10MB
|
|
20
|
+
|
|
21
|
+
// Read all stdin
|
|
22
|
+
const readStdin = () => new Promise((resolve, reject) => {
|
|
23
|
+
let data = '';
|
|
24
|
+
stdin.setEncoding('utf8');
|
|
25
|
+
stdin.on('data', chunk => {
|
|
26
|
+
data += chunk;
|
|
27
|
+
if (data.length > MAX_INPUT_SIZE) {
|
|
28
|
+
reject(new Error('Input too large'));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
stdin.on('end', () => resolve(data));
|
|
32
|
+
stdin.on('error', reject);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Logging
|
|
36
|
+
const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-gemini-hook.log';
|
|
37
|
+
const log = (msg) => {
|
|
38
|
+
const timestamp = new Date().toISOString();
|
|
39
|
+
const logMsg = `[${timestamp}] [gemini:after_tool] ${msg}\n`;
|
|
40
|
+
try {
|
|
41
|
+
appendFileSync(hookLogFile, logMsg);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
// Silently ignore
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Fetch JSON helper
|
|
48
|
+
const fetchJson = async (url, opts) => {
|
|
49
|
+
const res = await fetch(url, opts);
|
|
50
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
51
|
+
return res.json();
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
(async () => {
|
|
55
|
+
log('=== AfterTool hook invoked ===');
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const raw = await readStdin();
|
|
59
|
+
let input;
|
|
60
|
+
try {
|
|
61
|
+
input = JSON.parse(raw || '{}');
|
|
62
|
+
} catch (e) {
|
|
63
|
+
log(`Invalid JSON input: ${e.message}`);
|
|
64
|
+
return exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { tool_name, tool_input, tool_output, success, error, session_id, duration_ms } = input;
|
|
68
|
+
const teleportSessionId = env.TELEPORTATION_SESSION_ID || session_id || 'unknown';
|
|
69
|
+
log(`Tool: ${tool_name}, Success: ${success}, Duration: ${duration_ms}ms, TeleportSession: ${teleportSessionId}`);
|
|
70
|
+
|
|
71
|
+
// Load config from shared module
|
|
72
|
+
const config = await loadConfig(log);
|
|
73
|
+
const RELAY_API_URL = config.relayApiUrl;
|
|
74
|
+
const RELAY_API_KEY = config.relayApiKey;
|
|
75
|
+
|
|
76
|
+
log(`Using Relay: ${RELAY_API_URL}`);
|
|
77
|
+
|
|
78
|
+
// If no relay configured, just log locally
|
|
79
|
+
if (!RELAY_API_URL || !RELAY_API_KEY) {
|
|
80
|
+
log('No relay configured, skipping timeline event');
|
|
81
|
+
return exit(0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Log to timeline
|
|
85
|
+
const timelineEvent = {
|
|
86
|
+
session_id: teleportSessionId,
|
|
87
|
+
type: 'tool_executed',
|
|
88
|
+
data: {
|
|
89
|
+
tool_name,
|
|
90
|
+
tool_input: JSON.stringify(tool_input).slice(0, 1000),
|
|
91
|
+
tool_output: typeof tool_output === 'string'
|
|
92
|
+
? tool_output.slice(0, 5000)
|
|
93
|
+
: JSON.stringify(tool_output).slice(0, 5000),
|
|
94
|
+
success,
|
|
95
|
+
error: error || null,
|
|
96
|
+
duration_ms,
|
|
97
|
+
timestamp: Date.now()
|
|
98
|
+
},
|
|
99
|
+
source: 'gemini-cli',
|
|
100
|
+
timestamp: Date.now(),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {
|
|
107
|
+
'Content-Type': 'application/json',
|
|
108
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`,
|
|
109
|
+
},
|
|
110
|
+
body: JSON.stringify(timelineEvent),
|
|
111
|
+
signal: AbortSignal.timeout(5000),
|
|
112
|
+
});
|
|
113
|
+
log(`Timeline event logged for ${tool_name}`);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
log(`Failed to log timeline event: ${e.message}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Output empty response (hook completed successfully)
|
|
119
|
+
stdout.write('{}');
|
|
120
|
+
return exit(0);
|
|
121
|
+
|
|
122
|
+
} catch (e) {
|
|
123
|
+
log(`Hook error: ${e.message}`);
|
|
124
|
+
return exit(0);
|
|
125
|
+
}
|
|
126
|
+
})();
|