teleportation-cli 1.0.0 → 1.0.2

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.
@@ -46,11 +46,53 @@ let intervalHandle = null;
46
46
  let heartbeatCount = 0;
47
47
  let failureCount = 0;
48
48
  let lastDaemonCheck = 0;
49
+ // Module cache for lifecycle and pid-manager (imported once, reused)
50
+ let lifecycleModule = null;
51
+ let pidManagerModule = null;
52
+ // Flag to prevent concurrent daemon start attempts
53
+ let daemonStartInProgress = false;
49
54
  // Make interval configurable via environment variable (default 60 seconds)
50
55
  const DAEMON_CHECK_INTERVAL = Math.max(10000, Math.min(300000,
51
56
  parseInt(env.DAEMON_CHECK_INTERVAL || '60000', 10)
52
57
  )); // Default 60 seconds, min 10s, max 5min
53
58
 
59
+ /**
60
+ * Update daemon state on relay
61
+ * @param {'running' | 'stopped'} status - Daemon status
62
+ * @param {string} reason - Reason for state change
63
+ */
64
+ async function updateRelayDaemonState(status, reason) {
65
+ try {
66
+ const controller = new AbortController();
67
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
68
+
69
+ const response = await fetch(`${RELAY_API_URL}/api/sessions/${SESSION_ID}/daemon-state`, {
70
+ method: 'PATCH',
71
+ headers: {
72
+ 'Content-Type': 'application/json',
73
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
74
+ },
75
+ body: JSON.stringify({
76
+ status,
77
+ started_at: status === 'running' ? Date.now() : null,
78
+ started_reason: status === 'running' ? reason : null
79
+ }),
80
+ signal: controller.signal
81
+ });
82
+
83
+ clearTimeout(timeoutId);
84
+
85
+ if (!response.ok && DEBUG) {
86
+ console.log(`[Heartbeat] Failed to update relay daemon state: ${response.status}`);
87
+ }
88
+ } catch (error) {
89
+ if (DEBUG) {
90
+ console.log(`[Heartbeat] Error updating relay daemon state: ${error.message}`);
91
+ }
92
+ throw error;
93
+ }
94
+ }
95
+
54
96
  /**
55
97
  * Check if daemon is running and start it if not
56
98
  */
@@ -67,8 +109,15 @@ async function ensureDaemonRunning() {
67
109
  try {
68
110
  // Try to import lifecycle module from installed location
69
111
  const possibleLocations = [
112
+ // npm global install location
113
+ '/usr/local/lib/node_modules/teleportation-cli/lib/daemon/lifecycle.js',
114
+ // User's npm global install (common on macOS with nvm/fnm)
115
+ join(homedir(), '.local', 'lib', 'node_modules', 'teleportation-cli', 'lib', 'daemon', 'lifecycle.js'),
116
+ // Legacy location
70
117
  join(homedir(), '.teleportation', 'lib', 'daemon', 'lifecycle.js'),
118
+ // Relative to hooks dir (development)
71
119
  join(__dirname, '..', '..', 'lib', 'daemon', 'lifecycle.js'),
120
+ // Environment override
72
121
  env.TELEPORTATION_LIFECYCLE_PATH
73
122
  ].filter(Boolean);
74
123
 
@@ -116,9 +165,11 @@ async function ensureDaemonRunning() {
116
165
  }
117
166
 
118
167
  try {
119
- await lifecycle.startDaemon({ silent: true });
168
+ // Use startDaemonIfNeeded to both start the daemon AND update the relay
169
+ // This ensures the mobile UI knows the daemon is running
170
+ await lifecycle.startDaemonIfNeeded(SESSION_ID, 'heartbeat');
120
171
  if (DEBUG) {
121
- console.log(`[Heartbeat] Daemon started successfully`);
172
+ console.log(`[Heartbeat] Daemon started and relay updated successfully`);
122
173
  }
123
174
  } catch (error) {
124
175
  // Don't fail heartbeat if daemon start fails - it might already be starting
@@ -129,6 +180,20 @@ async function ensureDaemonRunning() {
129
180
  // Always reset flag, even if start fails
130
181
  daemonStartInProgress = false;
131
182
  }
183
+ } else if (status.running) {
184
+ // Daemon is running - ensure relay knows about it
185
+ // This handles the case where daemon was started manually or by another session
186
+ try {
187
+ await updateRelayDaemonState('running', 'heartbeat');
188
+ if (DEBUG) {
189
+ console.log(`[Heartbeat] Synced daemon running state to relay`);
190
+ }
191
+ } catch (error) {
192
+ // Ignore errors - just a sync, not critical
193
+ if (DEBUG) {
194
+ console.log(`[Heartbeat] Failed to sync daemon state: ${error.message}`);
195
+ }
196
+ }
132
197
  }
133
198
  } catch (error) {
134
199
  // Silently fail - daemon check shouldn't break heartbeat
@@ -132,7 +132,10 @@ const fetchJson = async (url, opts) => {
132
132
  const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
133
133
  const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
134
134
  const POLLING_INTERVAL_MS = parseInt(env.APPROVAL_POLL_INTERVAL_MS || '2000', 10);
135
- const APPROVAL_TIMEOUT_MS = parseInt(env.APPROVAL_TIMEOUT_MS || '300000', 10); // 5 min default
135
+ const FAST_APPROVAL_TIMEOUT_MS = parseInt(
136
+ env.FAST_APPROVAL_TIMEOUT_MS || env.FAST_POLL_TIMEOUT_MS || '55000',
137
+ 10
138
+ );
136
139
 
137
140
  if (!RELAY_API_URL || !RELAY_API_KEY) {
138
141
  log('No relay config - letting Claude Code handle permission locally');
@@ -224,13 +227,10 @@ const fetchJson = async (url, opts) => {
224
227
 
225
228
  // Poll for remote approval decision
226
229
  log('Polling for remote approval decision...');
227
- const AUTO_AWAY_TIMEOUT_MS = parseInt(env.AUTO_AWAY_TIMEOUT_MS || '300000', 10); // 5 min default
228
- const startTime = Date.now();
229
- let hasSetAutoAway = false;
230
230
  let consecutiveFailures = 0;
231
231
  const MAX_CONSECUTIVE_FAILURES = 5;
232
232
 
233
- const deadline = Date.now() + APPROVAL_TIMEOUT_MS;
233
+ const deadline = Date.now() + FAST_APPROVAL_TIMEOUT_MS;
234
234
  while (Date.now() < deadline) {
235
235
  try {
236
236
  const status = await fetchJson(`${RELAY_API_URL}/api/approvals/${approvalId}`, {
@@ -240,6 +240,30 @@ const fetchJson = async (url, opts) => {
240
240
 
241
241
  if (status.status === 'allowed') {
242
242
  log('Remote approval: ALLOWED');
243
+ // Acknowledge on the fast-path to prevent duplicate daemon execution.
244
+ // CRITICAL: If ACK fails, we must NOT proceed with local execution to avoid duplicates.
245
+ try {
246
+ const ackRes = await fetch(`${RELAY_API_URL}/api/approvals/${approvalId}/ack`, {
247
+ method: 'POST',
248
+ headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
249
+ });
250
+ if (!ackRes.ok) {
251
+ throw new Error(`ACK returned ${ackRes.status}`);
252
+ }
253
+ } catch (e) {
254
+ log(`ERROR: Failed to ack approval ${approvalId}: ${e.message} - aborting to prevent duplicate execution`);
255
+ // Return early without allowing - let daemon handle it instead
256
+ const out = {
257
+ hookSpecificOutput: {
258
+ hookEventName: 'PermissionRequest',
259
+ permissionDecision: 'deny',
260
+ permissionDecisionReason: '⚠️ Teleportation: Approval received but acknowledgment failed. Request handed to daemon to prevent duplicate execution.'
261
+ },
262
+ suppressOutput: true
263
+ };
264
+ stdout.write(JSON.stringify(out));
265
+ return exit(0);
266
+ }
243
267
  const out = {
244
268
  hookSpecificOutput: {
245
269
  hookEventName: 'PermissionRequest',
@@ -270,25 +294,6 @@ const fetchJson = async (url, opts) => {
270
294
  log('Approval was invalidated - letting Claude Code handle');
271
295
  return exit(0);
272
296
  }
273
-
274
- // Auto-set away after timeout (if not already away)
275
- if (!hasSetAutoAway && (Date.now() - startTime) > AUTO_AWAY_TIMEOUT_MS) {
276
- const timeoutMinutes = Math.round(AUTO_AWAY_TIMEOUT_MS / 1000 / 60);
277
- log(`Approval waiting >${timeoutMinutes} minutes - auto-setting away mode`);
278
- try {
279
- await fetchJson(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
280
- method: 'PATCH',
281
- headers: {
282
- 'Content-Type': 'application/json',
283
- 'Authorization': `Bearer ${RELAY_API_KEY}`
284
- },
285
- body: JSON.stringify({ is_away: true })
286
- });
287
- hasSetAutoAway = true;
288
- } catch (e) {
289
- log(`Failed to auto-set away: ${e.message}`);
290
- }
291
- }
292
297
  } catch (e) {
293
298
  consecutiveFailures++;
294
299
  if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
@@ -301,7 +306,31 @@ const fetchJson = async (url, opts) => {
301
306
  await sleep(POLLING_INTERVAL_MS);
302
307
  }
303
308
 
304
- // Timeout - let Claude Code handle it
305
- log('Approval timeout - letting Claude Code handle');
309
+ // Fast-path timeout: do NOT fall back to local permission prompts while user is away.
310
+ // Instead, hand off to daemon (background) and inform Claude.
311
+ log('Fast-path approval timeout - handing off to daemon');
312
+
313
+ try {
314
+ await fetch(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
315
+ method: 'PATCH',
316
+ headers: {
317
+ 'Content-Type': 'application/json',
318
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
319
+ },
320
+ body: JSON.stringify({ last_approval_location: 'daemon_handoff', is_away: true })
321
+ });
322
+ } catch (e) {
323
+ log(`Warning: Failed to update daemon-state for handoff: ${e.message}`);
324
+ }
325
+
326
+ const out = {
327
+ hookSpecificOutput: {
328
+ hookEventName: 'PermissionRequest',
329
+ permissionDecision: 'deny',
330
+ permissionDecisionReason: '⏳ Teleportation: waiting for mobile approval timed out. This request was handed off to the daemon and will run in the background once approved. You will see a Daemon Work Update here when it completes.'
331
+ },
332
+ suppressOutput: true
333
+ };
334
+ stdout.write(JSON.stringify(out));
306
335
  return exit(0);
307
336
  })();
@@ -79,6 +79,7 @@ const fetchJson = async (url, opts) => {
79
79
  }
80
80
 
81
81
  let { session_id, tool_name, tool_input } = input || {};
82
+ tool_input = tool_input && typeof tool_input === 'object' ? tool_input : {};
82
83
  let claude_session_id = session_id; // Keep original ID
83
84
 
84
85
  log(`Session ID: ${session_id}, Tool: ${tool_name}, Input: ${JSON.stringify(tool_input).substring(0, 100)}`);
@@ -418,10 +419,36 @@ const fetchJson = async (url, opts) => {
418
419
  }
419
420
  };
420
421
 
422
+ if (tool_input.__teleportation_away) {
423
+ await updateSessionState({ is_away: true });
424
+ const out = {
425
+ hookSpecificOutput: {
426
+ hookEventName: 'PreToolUse',
427
+ permissionDecision: 'deny',
428
+ permissionDecisionReason: '✅ Teleportation: Away mode enabled.'
429
+ },
430
+ suppressOutput: true
431
+ };
432
+ stdout.write(JSON.stringify(out));
433
+ return exit(0);
434
+ }
435
+
436
+ if (tool_input.__teleportation_back) {
437
+ await updateSessionState({ is_away: false });
438
+ const out = {
439
+ hookSpecificOutput: {
440
+ hookEventName: 'PreToolUse',
441
+ permissionDecision: 'deny',
442
+ permissionDecisionReason: '✅ Teleportation: Away mode disabled.'
443
+ },
444
+ suppressOutput: true
445
+ };
446
+ stdout.write(JSON.stringify(out));
447
+ return exit(0);
448
+ }
449
+
421
450
  // SMART AWAY MODE: Auto-mark as present ("back") on any activity
422
451
  // If the user is typing commands locally, they are clearly not away.
423
- await updateSessionState({ is_away: false });
424
-
425
452
  // Note: Approval invalidation is handled by PermissionRequest hook
426
453
  // to avoid race conditions and duplicate API calls
427
454
 
@@ -145,19 +145,78 @@ export async function ensureSessionRegistered(session_id, cwd, config) {
145
145
 
146
146
  // Check if heartbeat is already running for this session
147
147
  const { tmpdir } = await import('os');
148
- const { readFile } = await import('fs/promises');
148
+ const { readFile, unlink } = await import('fs/promises');
149
149
  const pidFile = join(tmpdir(), `teleportation-heartbeat-${session_id}.pid`);
150
-
150
+
151
151
  let shouldStartHeartbeat = true;
152
152
  try {
153
- await readFile(pidFile);
154
- // PID file exists, heartbeat might be running
155
- shouldStartHeartbeat = false;
153
+ const pidContent = await readFile(pidFile, 'utf8');
154
+ // Parse PID file (JSON format with session_id validation)
155
+ let pidData;
156
+ try {
157
+ pidData = JSON.parse(pidContent);
158
+ } catch {
159
+ // Fallback for old format (plain PID number)
160
+ pidData = { pid: parseInt(pidContent.trim(), 10) };
161
+ }
162
+
163
+ const pid = pidData.pid;
164
+ if (pid && !isNaN(pid)) {
165
+ // Check if the process is actually running
166
+ try {
167
+ process.kill(pid, 0); // Signal 0 checks existence without killing
168
+ // Process is running - don't start another heartbeat
169
+ shouldStartHeartbeat = false;
170
+ if (env.DEBUG) {
171
+ console.error(`[SessionRegister] Heartbeat already running (PID: ${pid})`);
172
+ }
173
+ } catch (killError) {
174
+ if (killError.code === 'ESRCH') {
175
+ // Process is dead - clean up stale PID file
176
+ try {
177
+ await unlink(pidFile);
178
+ if (env.DEBUG) {
179
+ console.error(`[SessionRegister] Cleaned up stale heartbeat PID file (PID: ${pid})`);
180
+ }
181
+ } catch (unlinkError) {
182
+ if (env.DEBUG) {
183
+ console.error(`[SessionRegister] Failed to unlink stale PID file: ${unlinkError.message}`);
184
+ }
185
+ }
186
+ // Will start new heartbeat
187
+ } else {
188
+ // Permission error - assume process exists, don't start duplicate
189
+ shouldStartHeartbeat = false;
190
+ if (env.DEBUG) {
191
+ console.error(`[SessionRegister] Cannot check heartbeat (permission denied), assuming running (PID: ${pid})`);
192
+ }
193
+ }
194
+ }
195
+ }
156
196
  } catch (e) {
157
197
  // PID file doesn't exist, start heartbeat
158
198
  }
159
199
 
160
200
  if (shouldStartHeartbeat) {
201
+ // Kill any orphan heartbeat processes for this session before starting a new one
202
+ // This handles race conditions where multiple processes pass the check simultaneously
203
+ try {
204
+ const { execSync } = await import('child_process');
205
+ // Use pkill with -f flag to match full command line (session_id argument)
206
+ // Returns 1 if no processes matched, so we ignore the exit code
207
+ execSync(`pkill -f "heartbeat.mjs ${session_id}" 2>/dev/null || true`, { stdio: 'ignore' });
208
+ if (env.DEBUG) {
209
+ console.error(`[SessionRegister] Cleaned up any orphan heartbeats for ${session_id}`);
210
+ }
211
+ // Small delay to let orphan processes terminate
212
+ await new Promise(r => setTimeout(r, 100));
213
+ } catch (pkillError) {
214
+ // Ignore pkill errors - process might not exist
215
+ if (env.DEBUG) {
216
+ console.error(`[SessionRegister] pkill cleanup failed: ${pkillError.message}`);
217
+ }
218
+ }
219
+
161
220
  const heartbeat = spawn('node', [heartbeatPath, session_id], {
162
221
  detached: true,
163
222
  stdio: 'ignore',
@@ -29,6 +29,9 @@ const isValidSessionId = (id) => {
29
29
  // Max length for assistant response preview (characters)
30
30
  const ASSISTANT_RESPONSE_MAX_LENGTH = 2000;
31
31
 
32
+ // Max size for full transcript (bytes) - 500KB
33
+ const MAX_TRANSCRIPT_SIZE = 500 * 1024;
34
+
32
35
  // Retry configuration
33
36
  const MAX_RETRIES = 3;
34
37
  const RETRY_DELAY_MS = 1000;
@@ -150,6 +153,178 @@ const extractLastAssistantMessage = async (transcriptPath, log) => {
150
153
  }
151
154
  };
152
155
 
156
+ /**
157
+ * Extract the full conversation transcript from the transcript file
158
+ * Returns all user and assistant messages with turn_index
159
+ * @returns {Object|null} - { messages: [{ role, content, turn_index }], total_turns: number, truncated: boolean, original_size: number } or null
160
+ */
161
+ const extractFullTranscript = async (transcriptPath, log) => {
162
+ try {
163
+ if (!transcriptPath) {
164
+ log('No transcript_path provided for full extraction');
165
+ return null;
166
+ }
167
+
168
+ // Read file directly
169
+ let content;
170
+ try {
171
+ content = await readFile(transcriptPath, 'utf8');
172
+ } catch (e) {
173
+ if (e.code === 'ENOENT') {
174
+ log(`Transcript file not found: ${transcriptPath}`);
175
+ return null;
176
+ }
177
+ if (e.code === 'EACCES' || e.code === 'EPERM') {
178
+ log(`Permission denied reading transcript: ${transcriptPath}`);
179
+ return null;
180
+ }
181
+ log(`Error reading transcript for full extraction: ${e.code || e.message}`);
182
+ return null;
183
+ }
184
+
185
+ let transcript;
186
+
187
+ // Try parsing as JSON array first
188
+ try {
189
+ transcript = JSON.parse(content);
190
+ log('Parsed transcript as JSON array for full extraction');
191
+ } catch (e) {
192
+ // Try parsing as JSONL (newline-delimited JSON)
193
+ log('JSON parse failed for full extraction, trying JSONL format');
194
+ const lines = content.trim().split('\n').filter(l => l.trim());
195
+ transcript = lines.map(line => {
196
+ try {
197
+ return JSON.parse(line);
198
+ } catch {
199
+ return null;
200
+ }
201
+ }).filter(Boolean);
202
+ log(`Parsed transcript as JSONL (${transcript.length} messages) for full extraction`);
203
+ }
204
+
205
+ if (!Array.isArray(transcript)) {
206
+ log(`Transcript is not an array for full extraction: ${typeof transcript}`);
207
+ return null;
208
+ }
209
+
210
+ log(`Full transcript has ${transcript.length} raw messages`);
211
+
212
+ // Extract all user and assistant messages
213
+ const messages = [];
214
+ let turnIndex = 0;
215
+
216
+ for (const msg of transcript) {
217
+ const role = msg.role || msg.type || '';
218
+ const isAssistant = role === 'assistant' || role === 'model' || msg.isAssistant;
219
+ const isUser = role === 'user' || role === 'human' || msg.isUser;
220
+
221
+ if (!isAssistant && !isUser) continue;
222
+
223
+ // Extract content
224
+ let text = '';
225
+ if (typeof msg.content === 'string') {
226
+ text = msg.content;
227
+ } else if (Array.isArray(msg.content)) {
228
+ // Content blocks format: [{ type: 'text', text: '...' }, ...]
229
+ text = msg.content
230
+ .filter(block => block.type === 'text' && block.text)
231
+ .map(block => block.text)
232
+ .join('\n\n');
233
+ } else if (msg.text) {
234
+ text = msg.text;
235
+ } else if (msg.message && typeof msg.message === 'string') {
236
+ text = msg.message;
237
+ }
238
+
239
+ if (text && text.trim()) {
240
+ messages.push({
241
+ role: isAssistant ? 'assistant' : 'user',
242
+ content: text.trim(),
243
+ turn_index: turnIndex
244
+ });
245
+ turnIndex++;
246
+ }
247
+ }
248
+
249
+ log(`Extracted ${messages.length} messages from transcript`);
250
+
251
+ if (messages.length === 0) {
252
+ log('No valid messages found in transcript');
253
+ return null;
254
+ }
255
+
256
+ // Check size and truncate if needed (reliability requirement: 500KB limit)
257
+ let result = {
258
+ messages,
259
+ total_turns: messages.length,
260
+ truncated: false,
261
+ original_size: 0
262
+ };
263
+
264
+ const jsonSize = JSON.stringify(result).length;
265
+ result.original_size = jsonSize;
266
+
267
+ if (jsonSize > MAX_TRANSCRIPT_SIZE) {
268
+ log(`Transcript size ${jsonSize} exceeds limit ${MAX_TRANSCRIPT_SIZE}, truncating...`);
269
+
270
+ // Use size estimation to avoid O(n²) repeated JSON.stringify calls
271
+ // Estimate average message size and calculate how many to keep
272
+ const metadataOverhead = 100; // Approximate overhead for result wrapper
273
+ const avgMsgSize = (jsonSize - metadataOverhead) / messages.length;
274
+ const targetCount = Math.max(1, Math.floor((MAX_TRANSCRIPT_SIZE - metadataOverhead) / avgMsgSize));
275
+
276
+ // Keep the most recent messages (slice from end)
277
+ let truncatedMessages = messages.slice(-targetCount);
278
+
279
+ // Verify size with a single stringify check
280
+ let testResult = {
281
+ messages: truncatedMessages,
282
+ total_turns: messages.length,
283
+ truncated: true,
284
+ original_size: jsonSize
285
+ };
286
+
287
+ // If still too large (estimation was off), remove a few more messages
288
+ while (truncatedMessages.length > 1 && JSON.stringify(testResult).length > MAX_TRANSCRIPT_SIZE) {
289
+ truncatedMessages = truncatedMessages.slice(1); // Remove oldest of remaining
290
+ testResult = {
291
+ messages: truncatedMessages,
292
+ total_turns: messages.length,
293
+ truncated: true,
294
+ original_size: jsonSize
295
+ };
296
+ }
297
+
298
+ result = testResult;
299
+
300
+ // If still too large with just one message, truncate the message content
301
+ if (truncatedMessages.length === 0) {
302
+ log('All messages truncated - transcript too large for any message');
303
+ return null;
304
+ }
305
+
306
+ if (JSON.stringify(result).length > MAX_TRANSCRIPT_SIZE) {
307
+ const lastMsg = { ...truncatedMessages[truncatedMessages.length - 1] }; // Clone to avoid mutation
308
+ const maxContentLen = Math.floor(MAX_TRANSCRIPT_SIZE * 0.8); // Leave room for metadata
309
+ lastMsg.content = lastMsg.content.substring(0, maxContentLen) + '... [truncated]';
310
+ result = {
311
+ messages: [lastMsg],
312
+ total_turns: messages.length,
313
+ truncated: true,
314
+ original_size: jsonSize
315
+ };
316
+ }
317
+
318
+ log(`Truncated transcript to ${result.messages.length} messages`);
319
+ }
320
+
321
+ return result;
322
+ } catch (e) {
323
+ log(`Error extracting full transcript: ${e.message}`);
324
+ return null;
325
+ }
326
+ };
327
+
153
328
  (async () => {
154
329
  const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-hook.log';
155
330
  const log = (msg) => {
@@ -242,7 +417,36 @@ const extractLastAssistantMessage = async (transcriptPath, log) => {
242
417
  log('Skipping assistant response log (stop_hook_active=true)');
243
418
  }
244
419
 
245
- // 2. Check for pending messages from mobile app (existing functionality)
420
+ // 2. Store full transcript for session (new feature: PRD-0011 Phase 2)
421
+ // Only store if not in a recursive stop hook call
422
+ if (!stop_hook_active) {
423
+ try {
424
+ const transcriptData = await extractFullTranscript(transcript_path, log);
425
+
426
+ if (transcriptData && transcriptData.messages && transcriptData.messages.length > 0) {
427
+ await fetchJsonWithRetry(`${RELAY_API_URL}/api/sessions/${session_id}/transcript`, {
428
+ method: 'POST',
429
+ headers: {
430
+ 'Content-Type': 'application/json',
431
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
432
+ },
433
+ body: JSON.stringify({
434
+ messages: transcriptData.messages,
435
+ total_turns: transcriptData.total_turns,
436
+ truncated: transcriptData.truncated,
437
+ original_size: transcriptData.original_size,
438
+ stored_at: Date.now()
439
+ })
440
+ }, log);
441
+ log(`Stored full transcript (${transcriptData.messages.length} messages, ${transcriptData.total_turns} total turns, truncated: ${transcriptData.truncated})`);
442
+ }
443
+ } catch (e) {
444
+ // Reliability: Log but don't fail hook if transcript storage fails
445
+ log(`Failed to store full transcript: ${e.message}`);
446
+ }
447
+ }
448
+
449
+ // 3. Check for pending messages from mobile app (existing functionality)
246
450
  try {
247
451
  const res = await fetch(`${RELAY_API_URL}/api/messages/pending?session_id=${encodeURIComponent(session_id)}`, {
248
452
  headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }