teleportation-cli 1.3.0 → 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.
@@ -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
- // Extract enhanced session metadata
69
- let metadata = { cwd, claude_session_id: session_id };
70
- try {
71
- // Try to load metadata extraction module
72
- const possiblePaths = [
73
- join(__dirname, '..', '..', 'lib', 'session', 'metadata.js'),
74
- join(process.env.HOME || process.env.USERPROFILE || '', '.teleportation', 'lib', 'session', 'metadata.js'),
75
- './lib/session/metadata.js'
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
- let metadataModule = null;
79
- for (const path of possiblePaths) {
80
- try {
81
- metadataModule = await import('file://' + path);
82
- break;
83
- } catch (e) {
84
- // Try next path
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
- if (metadataModule && metadataModule.extractSessionMetadata && cwd) {
89
- const extracted = await metadataModule.extractSessionMetadata(cwd);
90
- extracted.session_id = session_id;
91
- extracted.claude_session_id = session_id; // Include Claude session ID for autonomous task resumption
92
- metadata = extracted;
93
- }
94
- } catch (e) {
95
- // If metadata extraction fails, fall back to basic metadata
96
- if (env.DEBUG) {
97
- console.error('[SessionRegister] Failed to extract metadata:', e.message);
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
- // Detect message source
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;
@@ -479,58 +479,65 @@ const extractLastAssistantMessage = async (transcriptPath, log) => {
479
479
  };
480
480
 
481
481
  /**
482
- * Extract the full conversation transcript from the transcript file
483
- * Returns all user and assistant messages with turn_index
484
- * @returns {Object|null} - { messages: [{ role, content, turn_index }], total_turns: number, truncated: boolean, original_size: number } or null
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 extractFullTranscript = async (transcriptPath, log) => {
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
- if (!transcriptPath) {
489
- log('No transcript_path provided for full extraction');
490
- return null;
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
- // Read file directly
494
- let content;
495
- try {
496
- content = await readFile(transcriptPath, 'utf8');
497
- } catch (e) {
498
- if (e.code === 'ENOENT') {
499
- log(`Transcript file not found: ${transcriptPath}`);
500
- return null;
501
- }
502
- if (e.code === 'EACCES' || e.code === 'EPERM') {
503
- log(`Permission denied reading transcript: ${transcriptPath}`);
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
- let transcript;
521
+ if (!Array.isArray(transcript)) {
522
+ log(`Transcript is not an array: ${typeof transcript}`);
523
+ return null;
524
+ }
511
525
 
512
- // Try parsing as JSON array first
513
- try {
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
- if (!Array.isArray(transcript)) {
531
- log(`Transcript is not an array for full extraction: ${typeof transcript}`);
532
- return null;
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. Read and parse transcript (JSONL format)
725
- let content;
726
- try {
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
- const raw = await readStdin();
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
- // Detect message source
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
- // 1. Parse transcript and batch log all events (NEW APPROACH)
1211
- // Skip if this is a continuation from a previous stop hook (stop_hook_active=true)
1212
- if (!stop_hook_active && !skipHeavyUpload) {
1213
- try {
1214
- const { events, cursor } = await parseTranscriptToEvents(transcript_path, session_id, RELAY_API_URL, RELAY_API_KEY, log);
1215
-
1216
- if (events.length > 0) {
1217
- // Split into chunks of MAX_BATCH_SIZE to handle large batches
1218
- const MAX_BATCH_SIZE = 1000;
1219
- const chunks = [];
1220
-
1221
- for (let i = 0; i < events.length; i += MAX_BATCH_SIZE) {
1222
- chunks.push(events.slice(i, i + MAX_BATCH_SIZE));
1223
- }
1224
-
1225
- log(`📦 Sending ${events.length} events in ${chunks.length} batch(es)`);
1226
-
1227
- let totalSuccess = 0;
1228
- let totalFailed = 0;
1229
-
1230
- for (let i = 0; i < chunks.length; i++) {
1231
- const chunk = chunks[i];
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
- log(`✅ Batch ${i + 1}/${chunks.length}: ${success} success, ${failed} failed`);
1252
- } catch (e) {
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
- log(`✅ Total: ${totalSuccess} success, ${totalFailed} failed (${chunks.length} batch(es))`);
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
- // Save cursor AFTER successful upload — prevents reprocessing on next stop
1261
- // Only save if all events succeeded (partial failures should retry next time)
1262
- if (cursor && totalFailed === 0) {
1263
- await saveStopCursor(session_id, cursor, log);
1264
- } else if (cursor && totalFailed > 0) {
1265
- log(`⚠️ Skipping cursor save due to ${totalFailed} failed events (will retry next stop)`);
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
- } else {
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
- if (transcriptData && transcriptData.messages && transcriptData.messages.length > 0) {
1294
- await fetchJsonWithRetry(`${RELAY_API_URL}/api/sessions/${session_id}/transcript`, {
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
- messages: transcriptData.messages,
1302
- total_turns: transcriptData.total_turns,
1303
- truncated: transcriptData.truncated,
1304
- original_size: transcriptData.original_size,
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
- }, log);
1308
- log(`Stored full transcript (${transcriptData.messages.length} messages, ${transcriptData.total_turns} total turns, truncated: ${transcriptData.truncated})`);
1309
- }
1310
- } catch (e) {
1311
- // Reliability: Log but don't fail hook if transcript storage fails
1312
- log(`Failed to store full transcript: ${e.message}`);
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
- // 3. Individual system message logging REMOVED
1320
- // Now handled by batch event ingestion in step 1 above
1321
- // Previously logged thinking blocks, tool results, etc. individually which caused:
1322
- // - 27K+ tool_result events
1323
- // - 21K+ thinking events
1324
- // - 99.75% data loss due to mech-storage issues
1325
- //
1326
- // New approach: Single batch request with all events parsed from transcript
1327
- // See: parseTranscriptToEvents() function above
1328
-
1329
- // 4. Check for pending messages from mobile app (existing functionality)
1330
- try {
1331
- const res = await fetchWithTimeout(`${RELAY_API_URL}/api/messages/pending?session_id=${encodeURIComponent(session_id)}`, {
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
- if (res.ok) {
1335
- const msg = await res.json();
1336
- if (msg && msg.id && msg.text) {
1337
- log(`Found pending message: ${String(msg.text).slice(0, 50)}...`);
1338
- try {
1339
- await fetchWithTimeout(`${RELAY_API_URL}/api/messages/${msg.id}/ack`, {
1340
- method: 'POST',
1341
- headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
1342
- });
1343
- } catch {}
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
- // Stop hook cannot inject messages via decision/reason - that's not supported
1346
- // Mobile messages should be handled by a polling mechanism or different hook
1347
- log(`Acknowledged pending message (mobile message injection not supported in stop hook)`);
1348
- // Just exit cleanly without JSON output
1349
- return exit(0);
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
- } catch (e) {
1353
- log(`Error checking pending messages: ${e.message}`);
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
- // Detect message source
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.