teleportation-cli 1.2.2 → 1.4.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/permission_request.mjs +11 -4
- package/.claude/hooks/post_tool_use.mjs +1 -3
- package/.claude/hooks/pre_tool_use.mjs +216 -287
- package/.claude/hooks/session-register.mjs +36 -28
- package/.claude/hooks/session_end.mjs +1 -3
- package/.claude/hooks/session_start.mjs +15 -1
- package/.claude/hooks/stop.mjs +215 -224
- package/.claude/hooks/user_prompt_submit.mjs +1 -3
- package/lib/daemon/task-executor-v2.js +208 -27
- package/lib/daemon/teleportation-daemon.js +215 -19
- package/lib/daemon/timeline-analyzer.js +19 -13
- package/lib/daemon/transcript-ingestion.js +152 -44
- package/lib/install/installer.js +43 -13
- package/package.json +1 -1
- package/teleportation-cli.cjs +57 -1
|
@@ -34,9 +34,10 @@ async function loadVersionInfo() {
|
|
|
34
34
|
* @param {string} session_id - Session ID (also Claude session ID)
|
|
35
35
|
* @param {string} cwd - Current working directory
|
|
36
36
|
* @param {object} config - Config object with relayApiUrl and relayApiKey
|
|
37
|
+
* @param {object} [preExtractedMetadata] - Pre-extracted metadata to avoid duplicate extraction
|
|
37
38
|
* @returns {Promise<boolean|object>} - True if registered successfully, or error object if orphan
|
|
38
39
|
*/
|
|
39
|
-
export async function ensureSessionRegistered(session_id, cwd, config) {
|
|
40
|
+
export async function ensureSessionRegistered(session_id, cwd, config, preExtractedMetadata = null) {
|
|
40
41
|
const RELAY_API_URL = config.relayApiUrl || '';
|
|
41
42
|
const RELAY_API_KEY = config.relayApiKey || '';
|
|
42
43
|
|
|
@@ -65,36 +66,43 @@ export async function ensureSessionRegistered(session_id, cwd, config) {
|
|
|
65
66
|
console.error(`[SessionRegister] Registering session ${session_id}`);
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
//
|
|
69
|
-
let metadata
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
// Use pre-extracted metadata if provided (avoids duplicate git subprocess calls)
|
|
70
|
+
let metadata;
|
|
71
|
+
if (preExtractedMetadata && Object.keys(preExtractedMetadata).length > 0) {
|
|
72
|
+
metadata = { ...preExtractedMetadata, session_id, claude_session_id: session_id };
|
|
73
|
+
if (env.DEBUG) {
|
|
74
|
+
console.error('[SessionRegister] Using pre-extracted metadata (skipping re-extraction)');
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
// Fallback: extract metadata (for callers that don't provide it)
|
|
78
|
+
metadata = { cwd, claude_session_id: session_id };
|
|
79
|
+
try {
|
|
80
|
+
const possiblePaths = [
|
|
81
|
+
join(__dirname, '..', '..', 'lib', 'session', 'metadata.js'),
|
|
82
|
+
join(process.env.HOME || process.env.USERPROFILE || '', '.teleportation', 'lib', 'session', 'metadata.js'),
|
|
83
|
+
'./lib/session/metadata.js'
|
|
84
|
+
];
|
|
77
85
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
let metadataModule = null;
|
|
87
|
+
for (const path of possiblePaths) {
|
|
88
|
+
try {
|
|
89
|
+
metadataModule = await import('file://' + path);
|
|
90
|
+
break;
|
|
91
|
+
} catch (e) {
|
|
92
|
+
// Try next path
|
|
93
|
+
}
|
|
85
94
|
}
|
|
86
|
-
}
|
|
87
95
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
if (metadataModule && metadataModule.extractSessionMetadata && cwd) {
|
|
97
|
+
const extracted = await metadataModule.extractSessionMetadata(cwd);
|
|
98
|
+
extracted.session_id = session_id;
|
|
99
|
+
extracted.claude_session_id = session_id;
|
|
100
|
+
metadata = extracted;
|
|
101
|
+
}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (env.DEBUG) {
|
|
104
|
+
console.error('[SessionRegister] Failed to extract metadata:', e.message);
|
|
105
|
+
}
|
|
98
106
|
}
|
|
99
107
|
}
|
|
100
108
|
|
|
@@ -40,9 +40,7 @@ const readStdin = () => new Promise((resolve, reject) => {
|
|
|
40
40
|
const DAEMON_PORT = config.daemonPort || env.TELEPORTATION_DAEMON_PORT || '3050';
|
|
41
41
|
const DAEMON_ENABLED = config.daemonEnabled !== false && env.TELEPORTATION_DAEMON_ENABLED !== 'false';
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
const isAutonomousTask = env.TELEPORTATION_TASK_MODE === 'true' || !!env.TELEPORTATION_PARENT_SESSION_ID;
|
|
45
|
-
const source = isAutonomousTask ? 'autonomous_task' : 'cli_interactive';
|
|
43
|
+
const source = 'cli_interactive';
|
|
46
44
|
|
|
47
45
|
const updateSessionDaemonState = async (updates) => {
|
|
48
46
|
if (!session_id || !RELAY_API_URL || !RELAY_API_KEY) return;
|
|
@@ -196,7 +196,7 @@ function updateSessionMarker(sessionId) {
|
|
|
196
196
|
].filter(Boolean);
|
|
197
197
|
|
|
198
198
|
// Import fs/promises once outside the loop
|
|
199
|
-
const { access } = await import('fs/promises');
|
|
199
|
+
const { access, readFile } = await import('fs/promises');
|
|
200
200
|
let daemonScript = null;
|
|
201
201
|
for (const location of possibleLocations) {
|
|
202
202
|
try {
|
|
@@ -217,6 +217,20 @@ function updateSessionMarker(sessionId) {
|
|
|
217
217
|
return exit(0);
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
// Staleness check: detect outdated daemon that imports removed heartbeat-manager module
|
|
221
|
+
try {
|
|
222
|
+
const daemonSource = await readFile(daemonScript, 'utf-8');
|
|
223
|
+
if (/require\s*\(\s*['"]\.\/heartbeat-manager(?:\.js)?['"]\s*\)|from\s+['"]\.\/heartbeat-manager(?:\.js)?['"]/.test(daemonSource)) {
|
|
224
|
+
console.error('[SessionStart] ⚠️ Installed daemon is stale (imports removed heartbeat-manager module).');
|
|
225
|
+
console.error('[SessionStart] Run: teleportation install-hooks');
|
|
226
|
+
// Continue without daemon rather than crash
|
|
227
|
+
try { process.stdout.write(JSON.stringify({ suppressOutput: true })); } catch {}
|
|
228
|
+
return exit(0);
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// If we can't read the file, let agentStart handle the error
|
|
232
|
+
}
|
|
233
|
+
|
|
220
234
|
// Start daemon using agent-runtime
|
|
221
235
|
// This handles retries, health checks, and platform-native service installation
|
|
222
236
|
try {
|
package/.claude/hooks/stop.mjs
CHANGED
|
@@ -479,58 +479,65 @@ const extractLastAssistantMessage = async (transcriptPath, log) => {
|
|
|
479
479
|
};
|
|
480
480
|
|
|
481
481
|
/**
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
482
|
+
* Read and parse transcript file once for sharing across multiple consumers.
|
|
483
|
+
* Handles JSON array and JSONL formats.
|
|
484
|
+
*
|
|
485
|
+
* @param {string} transcriptPath - Path to transcript file
|
|
486
|
+
* @param {Function} log - Logging function
|
|
487
|
+
* @returns {Promise<Array|null>} - Parsed transcript array, or null on failure
|
|
485
488
|
*/
|
|
486
|
-
const
|
|
489
|
+
const readAndParseTranscript = async (transcriptPath, log) => {
|
|
490
|
+
if (!transcriptPath) {
|
|
491
|
+
log('No transcript_path provided');
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let content;
|
|
487
496
|
try {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
497
|
+
content = await readFile(transcriptPath, 'utf8');
|
|
498
|
+
} catch (e) {
|
|
499
|
+
if (e.code === 'ENOENT') {
|
|
500
|
+
log(`Transcript file not found: ${transcriptPath}`);
|
|
501
|
+
} else if (e.code === 'EACCES' || e.code === 'EPERM') {
|
|
502
|
+
log(`Permission denied reading transcript: ${transcriptPath}`);
|
|
503
|
+
} else {
|
|
504
|
+
log(`Error reading transcript: ${e.code || e.message}`);
|
|
491
505
|
}
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
492
508
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
return null;
|
|
505
|
-
}
|
|
506
|
-
log(`Error reading transcript for full extraction: ${e.code || e.message}`);
|
|
507
|
-
return null;
|
|
508
|
-
}
|
|
509
|
+
let transcript;
|
|
510
|
+
try {
|
|
511
|
+
transcript = JSON.parse(content);
|
|
512
|
+
log('Parsed transcript as JSON array');
|
|
513
|
+
} catch (e) {
|
|
514
|
+
const lines = content.trim().split('\n').filter(l => l.trim());
|
|
515
|
+
transcript = lines.map(line => {
|
|
516
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
517
|
+
}).filter(Boolean);
|
|
518
|
+
log(`Parsed transcript as JSONL (${transcript.length} messages)`);
|
|
519
|
+
}
|
|
509
520
|
|
|
510
|
-
|
|
521
|
+
if (!Array.isArray(transcript)) {
|
|
522
|
+
log(`Transcript is not an array: ${typeof transcript}`);
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
511
525
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
transcript = JSON.parse(content);
|
|
515
|
-
log('Parsed transcript as JSON array for full extraction');
|
|
516
|
-
} catch (e) {
|
|
517
|
-
// Try parsing as JSONL (newline-delimited JSON)
|
|
518
|
-
log('JSON parse failed for full extraction, trying JSONL format');
|
|
519
|
-
const lines = content.trim().split('\n').filter(l => l.trim());
|
|
520
|
-
transcript = lines.map(line => {
|
|
521
|
-
try {
|
|
522
|
-
return JSON.parse(line);
|
|
523
|
-
} catch {
|
|
524
|
-
return null;
|
|
525
|
-
}
|
|
526
|
-
}).filter(Boolean);
|
|
527
|
-
log(`Parsed transcript as JSONL (${transcript.length} messages) for full extraction`);
|
|
528
|
-
}
|
|
526
|
+
return transcript;
|
|
527
|
+
};
|
|
529
528
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
529
|
+
/**
|
|
530
|
+
* Extract the full conversation transcript from the transcript file
|
|
531
|
+
* Returns all user and assistant messages with turn_index
|
|
532
|
+
* @param {string} transcriptPath - Path to transcript file
|
|
533
|
+
* @param {Function} log - Logging function
|
|
534
|
+
* @param {Array|null} preReadTranscript - Pre-parsed transcript array (avoids re-reading file)
|
|
535
|
+
* @returns {Object|null} - { messages: [{ role, content, turn_index }], total_turns: number, truncated: boolean, original_size: number } or null
|
|
536
|
+
*/
|
|
537
|
+
const extractFullTranscript = async (transcriptPath, log, preReadTranscript = null) => {
|
|
538
|
+
try {
|
|
539
|
+
const transcript = preReadTranscript || await readAndParseTranscript(transcriptPath, log);
|
|
540
|
+
if (!transcript) return null;
|
|
534
541
|
|
|
535
542
|
log(`Full transcript has ${transcript.length} raw messages`);
|
|
536
543
|
|
|
@@ -657,12 +664,13 @@ const extractFullTranscript = async (transcriptPath, log) => {
|
|
|
657
664
|
* @param {string} relayApiUrl - Relay API URL
|
|
658
665
|
* @param {string} relayApiKey - Relay API key
|
|
659
666
|
* @param {Function} log - Logging function
|
|
667
|
+
* @param {Array|null} preReadTranscript - Pre-parsed transcript array (avoids re-reading file)
|
|
660
668
|
* @returns {Promise<{events: Array<{type: string, data: object}>, cursor: object|null}>}
|
|
661
669
|
*/
|
|
662
|
-
const parseTranscriptToEvents = async (transcriptPath, sessionId, relayApiUrl, relayApiKey, log) => {
|
|
670
|
+
const parseTranscriptToEvents = async (transcriptPath, sessionId, relayApiUrl, relayApiKey, log, preReadTranscript = null) => {
|
|
663
671
|
try {
|
|
664
|
-
if (!transcriptPath) {
|
|
665
|
-
log('No transcript_path provided for parsing');
|
|
672
|
+
if (!transcriptPath && !preReadTranscript) {
|
|
673
|
+
log('No transcript_path or pre-read transcript provided for parsing');
|
|
666
674
|
return { events: [], cursor: null };
|
|
667
675
|
}
|
|
668
676
|
|
|
@@ -721,36 +729,9 @@ const parseTranscriptToEvents = async (transcriptPath, sessionId, relayApiUrl, r
|
|
|
721
729
|
log(`Timeline query failed: ${e.message} - will process all messages`);
|
|
722
730
|
}
|
|
723
731
|
|
|
724
|
-
// 2.
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
content = await readFile(transcriptPath, 'utf8');
|
|
728
|
-
} catch (e) {
|
|
729
|
-
log(`Error reading transcript: ${e.code || e.message}`);
|
|
730
|
-
return { events: [], cursor: null };
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
let transcript;
|
|
734
|
-
try {
|
|
735
|
-
transcript = JSON.parse(content);
|
|
736
|
-
log('Parsed transcript as JSON array');
|
|
737
|
-
} catch (e) {
|
|
738
|
-
// Try JSONL format
|
|
739
|
-
const lines = content.trim().split('\n').filter(l => l.trim());
|
|
740
|
-
transcript = lines.map(line => {
|
|
741
|
-
try {
|
|
742
|
-
return JSON.parse(line);
|
|
743
|
-
} catch {
|
|
744
|
-
return null;
|
|
745
|
-
}
|
|
746
|
-
}).filter(Boolean);
|
|
747
|
-
log(`Parsed transcript as JSONL (${transcript.length} total messages)`);
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
if (!Array.isArray(transcript)) {
|
|
751
|
-
log(`Transcript is not an array: ${typeof transcript}`);
|
|
752
|
-
return { events: [], cursor: null };
|
|
753
|
-
}
|
|
732
|
+
// 2. Use pre-read transcript or read fresh
|
|
733
|
+
const transcript = preReadTranscript || await readAndParseTranscript(transcriptPath, log);
|
|
734
|
+
if (!transcript) return { events: [], cursor: null };
|
|
754
735
|
|
|
755
736
|
// 3. Filter using local cursor first, then timeline timestamp
|
|
756
737
|
const localCursor = await loadStopCursor(sessionId, log);
|
|
@@ -1150,10 +1131,22 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
|
|
|
1150
1131
|
|
|
1151
1132
|
log('=== Stop Hook invoked ===');
|
|
1152
1133
|
|
|
1153
|
-
|
|
1134
|
+
// PARALLEL: Read stdin + load config simultaneously (independent operations)
|
|
1135
|
+
const [raw, config] = await Promise.all([
|
|
1136
|
+
readStdin(),
|
|
1137
|
+
(async () => {
|
|
1138
|
+
try {
|
|
1139
|
+
const { loadConfig } = await import('./config-loader.mjs');
|
|
1140
|
+
return await loadConfig();
|
|
1141
|
+
} catch (e) {
|
|
1142
|
+
return { relayApiUrl: env.RELAY_API_URL || '', relayApiKey: env.RELAY_API_KEY || '' };
|
|
1143
|
+
}
|
|
1144
|
+
})()
|
|
1145
|
+
]);
|
|
1146
|
+
|
|
1154
1147
|
let input;
|
|
1155
|
-
try {
|
|
1156
|
-
input = JSON.parse(raw || '{}');
|
|
1148
|
+
try {
|
|
1149
|
+
input = JSON.parse(raw || '{}');
|
|
1157
1150
|
} catch (e) {
|
|
1158
1151
|
log(`ERROR: Invalid JSON: ${e.message}`);
|
|
1159
1152
|
return exit(0);
|
|
@@ -1162,22 +1155,7 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
|
|
|
1162
1155
|
const { session_id, transcript_path, stop_hook_active } = input || {};
|
|
1163
1156
|
log(`Session: ${session_id}, Transcript: ${transcript_path}, stop_hook_active: ${stop_hook_active}`);
|
|
1164
1157
|
|
|
1165
|
-
|
|
1166
|
-
const isAutonomousTask = env.TELEPORTATION_TASK_MODE === 'true' || !!env.TELEPORTATION_PARENT_SESSION_ID;
|
|
1167
|
-
const source = isAutonomousTask ? 'autonomous_task' : 'cli_interactive';
|
|
1168
|
-
log(`Message source: ${source}`);
|
|
1169
|
-
|
|
1170
|
-
// Load config
|
|
1171
|
-
let config;
|
|
1172
|
-
try {
|
|
1173
|
-
const { loadConfig } = await import('./config-loader.mjs');
|
|
1174
|
-
config = await loadConfig();
|
|
1175
|
-
} catch (e) {
|
|
1176
|
-
config = {
|
|
1177
|
-
relayApiUrl: env.RELAY_API_URL || '',
|
|
1178
|
-
relayApiKey: env.RELAY_API_KEY || '',
|
|
1179
|
-
};
|
|
1180
|
-
}
|
|
1158
|
+
const source = 'cli_interactive';
|
|
1181
1159
|
|
|
1182
1160
|
const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
|
|
1183
1161
|
const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
|
|
@@ -1207,150 +1185,163 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
|
|
|
1207
1185
|
}
|
|
1208
1186
|
}
|
|
1209
1187
|
|
|
1210
|
-
//
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
try {
|
|
1234
|
-
// Use longer timeout for batch uploads (60s) to handle large payloads
|
|
1235
|
-
// Don't retry on timeout to prevent duplicate events (see fetchJsonWithRetry)
|
|
1236
|
-
const response = await fetchJsonWithRetry(`${RELAY_API_URL}/api/sessions/${session_id}/events/batch`, {
|
|
1237
|
-
method: 'POST',
|
|
1238
|
-
headers: {
|
|
1239
|
-
'Content-Type': 'application/json',
|
|
1240
|
-
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
1241
|
-
},
|
|
1242
|
-
body: JSON.stringify({ events: chunk })
|
|
1243
|
-
}, log, 0, BATCH_FETCH_TIMEOUT_MS);
|
|
1244
|
-
|
|
1245
|
-
const success = response.success || 0;
|
|
1246
|
-
const failed = response.failed || 0;
|
|
1247
|
-
|
|
1248
|
-
totalSuccess += success;
|
|
1249
|
-
totalFailed += failed;
|
|
1188
|
+
// Read and parse transcript ONCE (shared across all consumers)
|
|
1189
|
+
const transcript = (!stop_hook_active && !skipHeavyUpload && transcript_path)
|
|
1190
|
+
? await readAndParseTranscript(transcript_path, log)
|
|
1191
|
+
: null;
|
|
1192
|
+
|
|
1193
|
+
// PARALLEL: Parse events + prepare full transcript simultaneously
|
|
1194
|
+
// Both use the shared pre-read transcript to avoid duplicate file reads
|
|
1195
|
+
let parsedResult = { events: [], cursor: null };
|
|
1196
|
+
let transcriptData = null;
|
|
1197
|
+
let cursor = null;
|
|
1198
|
+
|
|
1199
|
+
if (!stop_hook_active && !skipHeavyUpload && transcript) {
|
|
1200
|
+
[parsedResult, transcriptData] = await Promise.all([
|
|
1201
|
+
parseTranscriptToEvents(transcript_path, session_id, RELAY_API_URL, RELAY_API_KEY, log, transcript),
|
|
1202
|
+
extractFullTranscript(transcript_path, log, transcript)
|
|
1203
|
+
]);
|
|
1204
|
+
} else if (stop_hook_active) {
|
|
1205
|
+
log('Skipping batch event log (stop_hook_active=true)');
|
|
1206
|
+
} else if (!STOP_FULL_INGEST_ENABLED) {
|
|
1207
|
+
log('Skipping batch event log (full ingest disabled)');
|
|
1208
|
+
} else if (skipHeavyUpload) {
|
|
1209
|
+
log('Skipping batch event log (timeline probe unhealthy)');
|
|
1210
|
+
}
|
|
1250
1211
|
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
log(`❌ Batch ${i + 1}/${chunks.length} failed: ${e.message}`);
|
|
1254
|
-
totalFailed += chunk.length;
|
|
1255
|
-
}
|
|
1256
|
-
}
|
|
1212
|
+
const events = parsedResult.events || [];
|
|
1213
|
+
cursor = parsedResult.cursor || null;
|
|
1257
1214
|
|
|
1258
|
-
|
|
1215
|
+
// PARALLEL: Fire all HTTP calls together using Promise.allSettled
|
|
1216
|
+
// This avoids sequential waiting for: batch events, transcript storage, pending messages
|
|
1217
|
+
const httpTasks = [];
|
|
1259
1218
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
}
|
|
1267
|
-
} else {
|
|
1268
|
-
log('No events to log from transcript');
|
|
1269
|
-
// No events but cursor advanced (all filtered) — safe to save
|
|
1270
|
-
if (cursor) {
|
|
1271
|
-
await saveStopCursor(session_id, cursor, log);
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
} catch (e) {
|
|
1275
|
-
log(`Failed to batch log events after ${MAX_RETRIES} retries: ${e.message}`);
|
|
1276
|
-
// Don't fail the hook - continue with other functionality
|
|
1219
|
+
// 1. Batch log events
|
|
1220
|
+
if (events.length > 0) {
|
|
1221
|
+
const MAX_BATCH_SIZE = 1000;
|
|
1222
|
+
const chunks = [];
|
|
1223
|
+
for (let i = 0; i < events.length; i += MAX_BATCH_SIZE) {
|
|
1224
|
+
chunks.push(events.slice(i, i + MAX_BATCH_SIZE));
|
|
1277
1225
|
}
|
|
1278
|
-
|
|
1279
|
-
const reason = stop_hook_active
|
|
1280
|
-
? 'stop_hook_active=true'
|
|
1281
|
-
: !STOP_FULL_INGEST_ENABLED
|
|
1282
|
-
? 'full ingest disabled'
|
|
1283
|
-
: 'timeline probe unhealthy';
|
|
1284
|
-
log(`Skipping batch event log (${reason})`);
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
// 2. Store full transcript for session (new feature: PRD-0011 Phase 2)
|
|
1288
|
-
// Only store if not in a recursive stop hook call
|
|
1289
|
-
if (!stop_hook_active && !skipHeavyUpload) {
|
|
1290
|
-
try {
|
|
1291
|
-
const transcriptData = await extractFullTranscript(transcript_path, log);
|
|
1226
|
+
log(`📦 Sending ${events.length} events in ${chunks.length} batch(es)`);
|
|
1292
1227
|
|
|
1293
|
-
|
|
1294
|
-
|
|
1228
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
1229
|
+
const chunk = chunks[i];
|
|
1230
|
+
const batchIndex = i;
|
|
1231
|
+
httpTasks.push(
|
|
1232
|
+
fetchJsonWithRetry(`${RELAY_API_URL}/api/sessions/${session_id}/events/batch`, {
|
|
1295
1233
|
method: 'POST',
|
|
1296
1234
|
headers: {
|
|
1297
1235
|
'Content-Type': 'application/json',
|
|
1298
1236
|
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
1299
1237
|
},
|
|
1300
|
-
body: JSON.stringify({
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
stored_at: Date.now()
|
|
1238
|
+
body: JSON.stringify({ events: chunk })
|
|
1239
|
+
}, log, 0, BATCH_FETCH_TIMEOUT_MS)
|
|
1240
|
+
.then(response => {
|
|
1241
|
+
log(`✅ Batch ${batchIndex + 1}/${chunks.length}: ${response.success || 0} success, ${response.failed || 0} failed`);
|
|
1242
|
+
return { type: 'batch', success: response.success || 0, failed: response.failed || 0 };
|
|
1306
1243
|
})
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1244
|
+
.catch(e => {
|
|
1245
|
+
log(`❌ Batch ${batchIndex + 1}/${chunks.length} failed: ${e.message}`);
|
|
1246
|
+
return { type: 'batch', success: 0, failed: chunk.length };
|
|
1247
|
+
})
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
} else if (!stop_hook_active && !skipHeavyUpload) {
|
|
1251
|
+
log('No events to log from transcript');
|
|
1252
|
+
// No events but cursor may have advanced (all filtered) — safe to save
|
|
1253
|
+
if (cursor) {
|
|
1254
|
+
await saveStopCursor(session_id, cursor, log);
|
|
1313
1255
|
}
|
|
1314
|
-
} else {
|
|
1315
|
-
const reason = !STOP_FULL_INGEST_ENABLED ? 'full ingest disabled' : 'timeline probe unhealthy';
|
|
1316
|
-
log(`Skipping transcript storage because ${reason}`);
|
|
1317
1256
|
}
|
|
1318
1257
|
|
|
1319
|
-
//
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1258
|
+
// 2. Store full transcript
|
|
1259
|
+
if (transcriptData && transcriptData.messages && transcriptData.messages.length > 0) {
|
|
1260
|
+
httpTasks.push(
|
|
1261
|
+
fetchJsonWithRetry(`${RELAY_API_URL}/api/sessions/${session_id}/transcript`, {
|
|
1262
|
+
method: 'POST',
|
|
1263
|
+
headers: {
|
|
1264
|
+
'Content-Type': 'application/json',
|
|
1265
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
1266
|
+
},
|
|
1267
|
+
body: JSON.stringify({
|
|
1268
|
+
messages: transcriptData.messages,
|
|
1269
|
+
total_turns: transcriptData.total_turns,
|
|
1270
|
+
truncated: transcriptData.truncated,
|
|
1271
|
+
original_size: transcriptData.original_size,
|
|
1272
|
+
stored_at: Date.now()
|
|
1273
|
+
})
|
|
1274
|
+
}, log)
|
|
1275
|
+
.then(() => {
|
|
1276
|
+
log(`Stored full transcript (${transcriptData.messages.length} messages, ${transcriptData.total_turns} total turns, truncated: ${transcriptData.truncated})`);
|
|
1277
|
+
return { type: 'transcript', success: true };
|
|
1278
|
+
})
|
|
1279
|
+
.catch(e => {
|
|
1280
|
+
log(`Failed to store full transcript: ${e.message}`);
|
|
1281
|
+
return { type: 'transcript', success: false };
|
|
1282
|
+
})
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// 3. Check for pending messages from mobile app
|
|
1287
|
+
httpTasks.push(
|
|
1288
|
+
fetchWithTimeout(`${RELAY_API_URL}/api/messages/pending?session_id=${encodeURIComponent(session_id)}`, {
|
|
1332
1289
|
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
|
|
1333
|
-
})
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1290
|
+
})
|
|
1291
|
+
.then(async res => {
|
|
1292
|
+
if (res.ok) {
|
|
1293
|
+
const msg = await res.json();
|
|
1294
|
+
if (msg && msg.id && msg.text) {
|
|
1295
|
+
log(`Found pending message: ${String(msg.text).slice(0, 50)}...`);
|
|
1296
|
+
try {
|
|
1297
|
+
await fetchWithTimeout(`${RELAY_API_URL}/api/messages/${msg.id}/ack`, {
|
|
1298
|
+
method: 'POST',
|
|
1299
|
+
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
|
|
1300
|
+
});
|
|
1301
|
+
} catch {}
|
|
1302
|
+
log(`Acknowledged pending message (mobile message injection not supported in stop hook)`);
|
|
1303
|
+
return { type: 'messages', found: true };
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
return { type: 'messages', found: false };
|
|
1307
|
+
})
|
|
1308
|
+
.catch(e => {
|
|
1309
|
+
log(`Error checking pending messages: ${e.message}`);
|
|
1310
|
+
return { type: 'messages', found: false };
|
|
1311
|
+
})
|
|
1312
|
+
);
|
|
1313
|
+
|
|
1314
|
+
// Wait for all HTTP calls to complete
|
|
1315
|
+
const results = await Promise.allSettled(httpTasks);
|
|
1316
|
+
|
|
1317
|
+
// Surface any unexpected rejections (each task has .catch(), but defense-in-depth)
|
|
1318
|
+
const rejected = results.filter(r => r.status === 'rejected');
|
|
1319
|
+
if (rejected.length > 0) {
|
|
1320
|
+
log(`⚠️ ${rejected.length} HTTP task(s) rejected unexpectedly: ${rejected.map(r => r.reason?.message || r.reason).join(', ')}`);
|
|
1321
|
+
}
|
|
1344
1322
|
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1323
|
+
// Log batch totals
|
|
1324
|
+
const batchResults = results
|
|
1325
|
+
.filter(r => r.status === 'fulfilled' && r.value?.type === 'batch')
|
|
1326
|
+
.map(r => r.value);
|
|
1327
|
+
if (batchResults.length > 0) {
|
|
1328
|
+
const totalSuccess = batchResults.reduce((sum, r) => sum + r.success, 0);
|
|
1329
|
+
const totalFailed = batchResults.reduce((sum, r) => sum + r.failed, 0);
|
|
1330
|
+
log(`✅ Total: ${totalSuccess} success, ${totalFailed} failed (${batchResults.length} batch(es))`);
|
|
1331
|
+
|
|
1332
|
+
// Save cursor AFTER successful upload — prevents reprocessing on next stop
|
|
1333
|
+
// Only save if all events succeeded (partial failures should retry next time)
|
|
1334
|
+
if (cursor && totalFailed === 0) {
|
|
1335
|
+
await saveStopCursor(session_id, cursor, log);
|
|
1336
|
+
} else if (cursor && totalFailed > 0) {
|
|
1337
|
+
log(`⚠️ Skipping cursor save due to ${totalFailed} failed events (will retry next stop)`);
|
|
1351
1338
|
}
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Log transcript storage failure for visibility
|
|
1342
|
+
const transcriptResult = results.find(r => r.status === 'fulfilled' && r.value?.type === 'transcript');
|
|
1343
|
+
if (transcriptResult && !transcriptResult.value.success) {
|
|
1344
|
+
log(`⚠️ Transcript storage failed - timeline events were still logged separately`);
|
|
1354
1345
|
}
|
|
1355
1346
|
|
|
1356
1347
|
log('Stop hook completed');
|
|
@@ -46,9 +46,7 @@ const fetchJson = async (url, opts) => {
|
|
|
46
46
|
|
|
47
47
|
const { session_id, prompt } = input;
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
const isAutonomousTask = env.TELEPORTATION_TASK_MODE === 'true' || !!env.TELEPORTATION_PARENT_SESSION_ID;
|
|
51
|
-
const source = isAutonomousTask ? 'autonomous_task' : 'cli_interactive';
|
|
49
|
+
const source = 'cli_interactive';
|
|
52
50
|
|
|
53
51
|
// Clear away mode only on actual user activity (prompt submit), not on tool attempts.
|
|
54
52
|
// Also support /away and /back here in case they are handled as prompts.
|