teleportation-cli 1.3.0 → 1.4.1

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.
@@ -75,6 +75,9 @@ function basicSanitizer(text) {
75
75
  * @param {Function} [log] - Optional logging function for warnings
76
76
  * @returns {Promise<Function>} - The sanitizer function
77
77
  */
78
+ // Shared sanitizeEventData from lib/utils/log-sanitizer.js (loaded alongside sanitizeForLog)
79
+ let sanitizeEventDataFn = null;
80
+
78
81
  async function getSanitizer(log = () => {}) {
79
82
  if (sanitizeForLog) return sanitizeForLog;
80
83
 
@@ -90,6 +93,7 @@ async function getSanitizer(log = () => {}) {
90
93
  try {
91
94
  const mod = await import(path);
92
95
  sanitizeForLog = mod.sanitizeForLog;
96
+ sanitizeEventDataFn = mod.sanitizeEventData || null;
93
97
  if (sanitizeForLog) return sanitizeForLog;
94
98
  } catch (err) {
95
99
  // Log import failures for debugging (only in DEBUG mode to avoid noise)
@@ -127,9 +131,10 @@ const isValidSessionId = (id) => {
127
131
  /**
128
132
  * Truncation length constants - intentionally different for different message types
129
133
  *
130
- * ASSISTANT_RESPONSE_MAX_LENGTH (2000 chars):
134
+ * ASSISTANT_RESPONSE_MAX_LENGTH (50000 chars / ~50KB):
131
135
  * - Used for assistant conversational responses
132
- * - Longer because these are the primary output users want to see
136
+ * - Matches the relay API's MAX_EVENT_DATA_SIZE limit per field
137
+ * - Responses are primary output users want to read in full
133
138
  * - Less frequent (typically 1 per user turn)
134
139
  *
135
140
  * MAX_SYSTEM_MESSAGE_LENGTH (1000 chars):
@@ -138,7 +143,7 @@ const isValidSessionId = (id) => {
138
143
  * - Thinking blocks can occur many times per response
139
144
  * - Optimizes storage while maintaining useful context
140
145
  */
141
- const ASSISTANT_RESPONSE_MAX_LENGTH = 2000;
146
+ const ASSISTANT_RESPONSE_MAX_LENGTH = 50000;
142
147
  const MAX_SYSTEM_MESSAGE_LENGTH = 1000;
143
148
 
144
149
  // Max size for full transcript (bytes) - 500KB
@@ -268,42 +273,49 @@ const extractMessageContent = (msg) => {
268
273
 
269
274
  /**
270
275
  * Sanitize event data to remove sensitive information before storing in timeline.
271
- * Applies sanitization to string fields that may contain secrets.
276
+ * Delegates to the shared sanitizeEventData from lib/utils/log-sanitizer.js when
277
+ * available, otherwise falls back to a local implementation.
272
278
  *
273
279
  * @param {Object} eventData - The event data object
274
280
  * @param {Function} sanitizer - The sanitization function
275
281
  * @returns {Object} - Sanitized event data
276
282
  */
277
283
  const sanitizeEventData = (eventData, sanitizer) => {
284
+ // Use shared module if loaded by getSanitizer()
285
+ if (sanitizeEventDataFn) {
286
+ return sanitizeEventDataFn(eventData, sanitizer);
287
+ }
288
+
289
+ // Fallback: local implementation (kept as safety net if shared module unavailable)
278
290
  if (!eventData || typeof eventData !== 'object') return eventData;
279
291
  if (!sanitizer) return eventData;
280
292
 
281
293
  const sanitized = { ...eventData };
282
-
283
- // Sanitize known text fields
284
- const textFields = ['message', 'summary', 'thinking', 'result', 'error'];
294
+ const textFields = ['message', 'summary', 'thinking', 'result', 'error', 'stdout', 'stderr', 'content'];
285
295
  for (const field of textFields) {
286
296
  if (typeof sanitized[field] === 'string') {
287
297
  sanitized[field] = sanitizer(sanitized[field]);
288
298
  }
289
299
  }
290
300
 
291
- // Sanitize tool_input if it's a string or contains sensitive data
292
301
  if (sanitized.tool_input) {
293
302
  if (typeof sanitized.tool_input === 'string') {
294
303
  sanitized.tool_input = sanitizer(sanitized.tool_input);
295
304
  } else if (typeof sanitized.tool_input === 'object') {
296
- // Sanitize stringified version of object fields that might contain secrets
297
- const inputStr = JSON.stringify(sanitized.tool_input);
298
- const sanitizedStr = sanitizer(inputStr);
299
- if (inputStr !== sanitizedStr) {
300
- try {
301
- sanitized.tool_input = JSON.parse(sanitizedStr);
302
- } catch {
303
- // If parsing fails, use sanitized string
304
- sanitized.tool_input = sanitizedStr;
305
+ // SYNC_WITH: lib/utils/log-sanitizer.js SENSITIVE_KEY_RE
306
+ const sensitiveKeyRe = /^(password|passwd|pwd|secret|token|api[_-]?key|authorization|credentials?)$/i;
307
+ const sanitizeValues = (obj, parentKey) => {
308
+ if (typeof obj === 'string') {
309
+ if (parentKey && sensitiveKeyRe.test(parentKey)) return '***';
310
+ return sanitizer(obj);
305
311
  }
306
- }
312
+ if (typeof obj !== 'object' || obj === null) return obj;
313
+ if (Array.isArray(obj)) return obj.map(item => sanitizeValues(item));
314
+ const result = {};
315
+ for (const [k, v] of Object.entries(obj)) result[k] = sanitizeValues(v, k);
316
+ return result;
317
+ };
318
+ sanitized.tool_input = sanitizeValues(sanitized.tool_input);
307
319
  }
308
320
  }
309
321
 
@@ -479,58 +491,65 @@ const extractLastAssistantMessage = async (transcriptPath, log) => {
479
491
  };
480
492
 
481
493
  /**
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
494
+ * Read and parse transcript file once for sharing across multiple consumers.
495
+ * Handles JSON array and JSONL formats.
496
+ *
497
+ * @param {string} transcriptPath - Path to transcript file
498
+ * @param {Function} log - Logging function
499
+ * @returns {Promise<Array|null>} - Parsed transcript array, or null on failure
485
500
  */
486
- const extractFullTranscript = async (transcriptPath, log) => {
501
+ const readAndParseTranscript = async (transcriptPath, log) => {
502
+ if (!transcriptPath) {
503
+ log('No transcript_path provided');
504
+ return null;
505
+ }
506
+
507
+ let content;
487
508
  try {
488
- if (!transcriptPath) {
489
- log('No transcript_path provided for full extraction');
490
- return null;
509
+ content = await readFile(transcriptPath, 'utf8');
510
+ } catch (e) {
511
+ if (e.code === 'ENOENT') {
512
+ log(`Transcript file not found: ${transcriptPath}`);
513
+ } else if (e.code === 'EACCES' || e.code === 'EPERM') {
514
+ log(`Permission denied reading transcript: ${transcriptPath}`);
515
+ } else {
516
+ log(`Error reading transcript: ${e.code || e.message}`);
491
517
  }
518
+ return null;
519
+ }
492
520
 
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
- }
521
+ let transcript;
522
+ try {
523
+ transcript = JSON.parse(content);
524
+ log('Parsed transcript as JSON array');
525
+ } catch (e) {
526
+ const lines = content.trim().split('\n').filter(l => l.trim());
527
+ transcript = lines.map(line => {
528
+ try { return JSON.parse(line); } catch { return null; }
529
+ }).filter(Boolean);
530
+ log(`Parsed transcript as JSONL (${transcript.length} messages)`);
531
+ }
509
532
 
510
- let transcript;
533
+ if (!Array.isArray(transcript)) {
534
+ log(`Transcript is not an array: ${typeof transcript}`);
535
+ return null;
536
+ }
511
537
 
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
- }
538
+ return transcript;
539
+ };
529
540
 
530
- if (!Array.isArray(transcript)) {
531
- log(`Transcript is not an array for full extraction: ${typeof transcript}`);
532
- return null;
533
- }
541
+ /**
542
+ * Extract the full conversation transcript from the transcript file
543
+ * Returns all user and assistant messages with turn_index
544
+ * @param {string} transcriptPath - Path to transcript file
545
+ * @param {Function} log - Logging function
546
+ * @param {Array|null} preReadTranscript - Pre-parsed transcript array (avoids re-reading file)
547
+ * @returns {Object|null} - { messages: [{ role, content, turn_index }], total_turns: number, truncated: boolean, original_size: number } or null
548
+ */
549
+ const extractFullTranscript = async (transcriptPath, log, preReadTranscript = null) => {
550
+ try {
551
+ const transcript = preReadTranscript || await readAndParseTranscript(transcriptPath, log);
552
+ if (!transcript) return null;
534
553
 
535
554
  log(`Full transcript has ${transcript.length} raw messages`);
536
555
 
@@ -657,12 +676,13 @@ const extractFullTranscript = async (transcriptPath, log) => {
657
676
  * @param {string} relayApiUrl - Relay API URL
658
677
  * @param {string} relayApiKey - Relay API key
659
678
  * @param {Function} log - Logging function
679
+ * @param {Array|null} preReadTranscript - Pre-parsed transcript array (avoids re-reading file)
660
680
  * @returns {Promise<{events: Array<{type: string, data: object}>, cursor: object|null}>}
661
681
  */
662
- const parseTranscriptToEvents = async (transcriptPath, sessionId, relayApiUrl, relayApiKey, log) => {
682
+ const parseTranscriptToEvents = async (transcriptPath, sessionId, relayApiUrl, relayApiKey, log, preReadTranscript = null) => {
663
683
  try {
664
- if (!transcriptPath) {
665
- log('No transcript_path provided for parsing');
684
+ if (!transcriptPath && !preReadTranscript) {
685
+ log('No transcript_path or pre-read transcript provided for parsing');
666
686
  return { events: [], cursor: null };
667
687
  }
668
688
 
@@ -721,36 +741,9 @@ const parseTranscriptToEvents = async (transcriptPath, sessionId, relayApiUrl, r
721
741
  log(`Timeline query failed: ${e.message} - will process all messages`);
722
742
  }
723
743
 
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
- }
744
+ // 2. Use pre-read transcript or read fresh
745
+ const transcript = preReadTranscript || await readAndParseTranscript(transcriptPath, log);
746
+ if (!transcript) return { events: [], cursor: null };
754
747
 
755
748
  // 3. Filter using local cursor first, then timeline timestamp
756
749
  const localCursor = await loadStopCursor(sessionId, log);
@@ -1150,10 +1143,22 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
1150
1143
 
1151
1144
  log('=== Stop Hook invoked ===');
1152
1145
 
1153
- const raw = await readStdin();
1146
+ // PARALLEL: Read stdin + load config simultaneously (independent operations)
1147
+ const [raw, config] = await Promise.all([
1148
+ readStdin(),
1149
+ (async () => {
1150
+ try {
1151
+ const { loadConfig } = await import('./config-loader.mjs');
1152
+ return await loadConfig();
1153
+ } catch (e) {
1154
+ return { relayApiUrl: env.RELAY_API_URL || '', relayApiKey: env.RELAY_API_KEY || '' };
1155
+ }
1156
+ })()
1157
+ ]);
1158
+
1154
1159
  let input;
1155
- try {
1156
- input = JSON.parse(raw || '{}');
1160
+ try {
1161
+ input = JSON.parse(raw || '{}');
1157
1162
  } catch (e) {
1158
1163
  log(`ERROR: Invalid JSON: ${e.message}`);
1159
1164
  return exit(0);
@@ -1162,22 +1167,7 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
1162
1167
  const { session_id, transcript_path, stop_hook_active } = input || {};
1163
1168
  log(`Session: ${session_id}, Transcript: ${transcript_path}, stop_hook_active: ${stop_hook_active}`);
1164
1169
 
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
- }
1170
+ const source = 'cli_interactive';
1181
1171
 
1182
1172
  const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
1183
1173
  const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
@@ -1207,150 +1197,163 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
1207
1197
  }
1208
1198
  }
1209
1199
 
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;
1200
+ // Read and parse transcript ONCE (shared across all consumers)
1201
+ const transcript = (!stop_hook_active && !skipHeavyUpload && transcript_path)
1202
+ ? await readAndParseTranscript(transcript_path, log)
1203
+ : null;
1204
+
1205
+ // PARALLEL: Parse events + prepare full transcript simultaneously
1206
+ // Both use the shared pre-read transcript to avoid duplicate file reads
1207
+ let parsedResult = { events: [], cursor: null };
1208
+ let transcriptData = null;
1209
+ let cursor = null;
1210
+
1211
+ if (!stop_hook_active && !skipHeavyUpload && transcript) {
1212
+ [parsedResult, transcriptData] = await Promise.all([
1213
+ parseTranscriptToEvents(transcript_path, session_id, RELAY_API_URL, RELAY_API_KEY, log, transcript),
1214
+ extractFullTranscript(transcript_path, log, transcript)
1215
+ ]);
1216
+ } else if (stop_hook_active) {
1217
+ log('Skipping batch event log (stop_hook_active=true)');
1218
+ } else if (!STOP_FULL_INGEST_ENABLED) {
1219
+ log('Skipping batch event log (full ingest disabled)');
1220
+ } else if (skipHeavyUpload) {
1221
+ log('Skipping batch event log (timeline probe unhealthy)');
1222
+ }
1250
1223
 
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
- }
1224
+ const events = parsedResult.events || [];
1225
+ cursor = parsedResult.cursor || null;
1257
1226
 
1258
- log(`✅ Total: ${totalSuccess} success, ${totalFailed} failed (${chunks.length} batch(es))`);
1227
+ // PARALLEL: Fire all HTTP calls together using Promise.allSettled
1228
+ // This avoids sequential waiting for: batch events, transcript storage, pending messages
1229
+ const httpTasks = [];
1259
1230
 
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
1231
+ // 1. Batch log events
1232
+ if (events.length > 0) {
1233
+ const MAX_BATCH_SIZE = 1000;
1234
+ const chunks = [];
1235
+ for (let i = 0; i < events.length; i += MAX_BATCH_SIZE) {
1236
+ chunks.push(events.slice(i, i + MAX_BATCH_SIZE));
1277
1237
  }
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);
1238
+ log(`📦 Sending ${events.length} events in ${chunks.length} batch(es)`);
1292
1239
 
1293
- if (transcriptData && transcriptData.messages && transcriptData.messages.length > 0) {
1294
- await fetchJsonWithRetry(`${RELAY_API_URL}/api/sessions/${session_id}/transcript`, {
1240
+ for (let i = 0; i < chunks.length; i++) {
1241
+ const chunk = chunks[i];
1242
+ const batchIndex = i;
1243
+ httpTasks.push(
1244
+ fetchJsonWithRetry(`${RELAY_API_URL}/api/sessions/${session_id}/events/batch`, {
1295
1245
  method: 'POST',
1296
1246
  headers: {
1297
1247
  'Content-Type': 'application/json',
1298
1248
  'Authorization': `Bearer ${RELAY_API_KEY}`
1299
1249
  },
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()
1250
+ body: JSON.stringify({ events: chunk })
1251
+ }, log, 0, BATCH_FETCH_TIMEOUT_MS)
1252
+ .then(response => {
1253
+ log(`✅ Batch ${batchIndex + 1}/${chunks.length}: ${response.success || 0} success, ${response.failed || 0} failed`);
1254
+ return { type: 'batch', success: response.success || 0, failed: response.failed || 0 };
1306
1255
  })
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}`);
1256
+ .catch(e => {
1257
+ log(`❌ Batch ${batchIndex + 1}/${chunks.length} failed: ${e.message}`);
1258
+ return { type: 'batch', success: 0, failed: chunk.length };
1259
+ })
1260
+ );
1261
+ }
1262
+ } else if (!stop_hook_active && !skipHeavyUpload) {
1263
+ log('No events to log from transcript');
1264
+ // No events but cursor may have advanced (all filtered) — safe to save
1265
+ if (cursor) {
1266
+ await saveStopCursor(session_id, cursor, log);
1313
1267
  }
1314
- } else {
1315
- const reason = !STOP_FULL_INGEST_ENABLED ? 'full ingest disabled' : 'timeline probe unhealthy';
1316
- log(`Skipping transcript storage because ${reason}`);
1317
1268
  }
1318
1269
 
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)}`, {
1270
+ // 2. Store full transcript
1271
+ if (transcriptData && transcriptData.messages && transcriptData.messages.length > 0) {
1272
+ httpTasks.push(
1273
+ fetchJsonWithRetry(`${RELAY_API_URL}/api/sessions/${session_id}/transcript`, {
1274
+ method: 'POST',
1275
+ headers: {
1276
+ 'Content-Type': 'application/json',
1277
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
1278
+ },
1279
+ body: JSON.stringify({
1280
+ messages: transcriptData.messages,
1281
+ total_turns: transcriptData.total_turns,
1282
+ truncated: transcriptData.truncated,
1283
+ original_size: transcriptData.original_size,
1284
+ stored_at: Date.now()
1285
+ })
1286
+ }, log)
1287
+ .then(() => {
1288
+ log(`Stored full transcript (${transcriptData.messages.length} messages, ${transcriptData.total_turns} total turns, truncated: ${transcriptData.truncated})`);
1289
+ return { type: 'transcript', success: true };
1290
+ })
1291
+ .catch(e => {
1292
+ log(`Failed to store full transcript: ${e.message}`);
1293
+ return { type: 'transcript', success: false };
1294
+ })
1295
+ );
1296
+ }
1297
+
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)}`, {
1332
1301
  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 {}
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
+ // Wait for all HTTP calls to complete
1327
+ const results = await Promise.allSettled(httpTasks);
1328
+
1329
+ // Surface any unexpected rejections (each task has .catch(), but defense-in-depth)
1330
+ const rejected = results.filter(r => r.status === 'rejected');
1331
+ if (rejected.length > 0) {
1332
+ log(`⚠️ ${rejected.length} HTTP task(s) rejected unexpectedly: ${rejected.map(r => r.reason?.message || r.reason).join(', ')}`);
1333
+ }
1344
1334
 
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
- }
1335
+ // Log batch totals
1336
+ const batchResults = results
1337
+ .filter(r => r.status === 'fulfilled' && r.value?.type === 'batch')
1338
+ .map(r => r.value);
1339
+ if (batchResults.length > 0) {
1340
+ const totalSuccess = batchResults.reduce((sum, r) => sum + r.success, 0);
1341
+ const totalFailed = batchResults.reduce((sum, r) => sum + r.failed, 0);
1342
+ log(`✅ Total: ${totalSuccess} success, ${totalFailed} failed (${batchResults.length} batch(es))`);
1343
+
1344
+ // Save cursor AFTER successful upload — prevents reprocessing on next stop
1345
+ // Only save if all events succeeded (partial failures should retry next time)
1346
+ if (cursor && totalFailed === 0) {
1347
+ await saveStopCursor(session_id, cursor, log);
1348
+ } else if (cursor && totalFailed > 0) {
1349
+ log(`⚠️ Skipping cursor save due to ${totalFailed} failed events (will retry next stop)`);
1351
1350
  }
1352
- } catch (e) {
1353
- log(`Error checking pending messages: ${e.message}`);
1351
+ }
1352
+
1353
+ // Log transcript storage failure for visibility
1354
+ const transcriptResult = results.find(r => r.status === 'fulfilled' && r.value?.type === 'transcript');
1355
+ if (transcriptResult && !transcriptResult.value.success) {
1356
+ log(`⚠️ Transcript storage failed - timeline events were still logged separately`);
1354
1357
  }
1355
1358
 
1356
1359
  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.