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.
@@ -54,6 +54,7 @@ import {
54
54
  executeTaskTurn,
55
55
  stopTask,
56
56
  stopAllTasks,
57
+ stopTasksForSession,
57
58
  } from './task-executor-v2.js';
58
59
 
59
60
  // Transcript ingestion for timeline completeness
@@ -229,6 +230,10 @@ const OUTPUT_PREVIEW_LONG = 1000; // Full output displays
229
230
  const heartbeatState = new Map();
230
231
  let lastHeartbeatTime = 0;
231
232
 
233
+ // Track which sessions have had their first heartbeat failure logged.
234
+ // Prevents log spam while ensuring operators see at least one failure per session.
235
+ const heartbeatFailureLogged = new Set();
236
+
232
237
  // In-memory registry of active teleportation sessions handled by this daemon
233
238
  const sessions = new Map();
234
239
 
@@ -265,6 +270,7 @@ setInterval(() => {
265
270
  sessions.delete(sessionId);
266
271
  sessionActivity.delete(sessionId);
267
272
  heartbeatState.delete(sessionId);
273
+ heartbeatFailureLogged.delete(sessionId);
268
274
  sessionCleanedCount++;
269
275
 
270
276
  if (process.env.DEBUG) {
@@ -1037,7 +1043,8 @@ async function ackAndLogCancellation(session_id, message, triggeredBy = {}) {
1037
1043
  try {
1038
1044
  await fetch(`${RELAY_API_URL}/api/messages/${encodeURIComponent(message.id)}/ack`, {
1039
1045
  method: 'POST',
1040
- headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
1046
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${RELAY_API_KEY}` },
1047
+ body: JSON.stringify({ session_id })
1041
1048
  });
1042
1049
  logDebug(`[daemon] Acknowledged message ${message.id}`);
1043
1050
  } catch (ackError) {
@@ -1098,7 +1105,8 @@ async function handleInboxMessage(session_id, message) {
1098
1105
  logWarn(`[daemon] Failed to check is_away state, skipping auto-continue for safety`);
1099
1106
  await fetch(`${RELAY_API_URL}/api/messages/${encodeURIComponent(message.id)}/ack`, {
1100
1107
  method: 'POST',
1101
- headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
1108
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${RELAY_API_KEY}` },
1109
+ body: JSON.stringify({ session_id })
1102
1110
  }).catch(() => {});
1103
1111
  return;
1104
1112
  }
@@ -1131,11 +1139,73 @@ async function handleInboxMessage(session_id, message) {
1131
1139
  // Still acknowledge the message to prevent re-delivery
1132
1140
  await fetch(`${RELAY_API_URL}/api/messages/${encodeURIComponent(message.id)}/ack`, {
1133
1141
  method: 'POST',
1134
- headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
1142
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${RELAY_API_KEY}` },
1143
+ body: JSON.stringify({ session_id })
1135
1144
  });
1136
1145
  return;
1137
1146
  }
1138
1147
 
1148
+ // Check for paused tasks — route message to task instead of spawning new process.
1149
+ // This adds one relay round-trip per inbox message, but only fires for 'command' type
1150
+ // messages (user-initiated from mobile), not for auto-continue or approval messages.
1151
+ // Future optimization: include paused task info in the session polling response.
1152
+ try {
1153
+ const tasksResp = await fetch(
1154
+ `${RELAY_API_URL}/api/sessions/${encodeURIComponent(session_id)}/tasks`,
1155
+ { headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }, signal: AbortSignal.timeout(5000) }
1156
+ );
1157
+ if (tasksResp.ok) {
1158
+ const tasks = await tasksResp.json();
1159
+ const pausedTasks = tasks.filter(t => t.status === 'paused' || t.status === 'waiting_input');
1160
+ if (pausedTasks.length > 1) {
1161
+ logWarn(`[daemon] ⚠️ Multiple paused tasks (${pausedTasks.length}) for session ${session_id} — routing to first`);
1162
+ }
1163
+ const pausedTask = pausedTasks[0];
1164
+ if (pausedTask) {
1165
+ logInfo(`[daemon] 📨 Routing message to paused task ${pausedTask.id.slice(0, 20)}... (status: ${pausedTask.status})`);
1166
+
1167
+ let routeResp;
1168
+ if (pausedTask.status === 'waiting_input') {
1169
+ // Task is waiting for user input — use the answer endpoint
1170
+ routeResp = await fetch(
1171
+ `${RELAY_API_URL}/api/sessions/${encodeURIComponent(session_id)}/tasks/${encodeURIComponent(pausedTask.id)}/answer`,
1172
+ {
1173
+ method: 'POST',
1174
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${RELAY_API_KEY}` },
1175
+ body: JSON.stringify({ answer: commandText })
1176
+ }
1177
+ );
1178
+ } else {
1179
+ // Task is paused — use redirect to set new instructions and resume
1180
+ routeResp = await fetch(
1181
+ `${RELAY_API_URL}/api/sessions/${encodeURIComponent(session_id)}/tasks/${encodeURIComponent(pausedTask.id)}/redirect`,
1182
+ {
1183
+ method: 'POST',
1184
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${RELAY_API_KEY}` },
1185
+ body: JSON.stringify({ instruction: commandText })
1186
+ }
1187
+ );
1188
+ }
1189
+
1190
+ if (routeResp.ok) {
1191
+ logInfo(`[daemon] ✅ Message routed to task, will resume on next poll cycle`);
1192
+ // Acknowledge the inbox message
1193
+ await fetch(`${RELAY_API_URL}/api/messages/${encodeURIComponent(message.id)}/ack`, {
1194
+ method: 'POST',
1195
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${RELAY_API_KEY}` },
1196
+ body: JSON.stringify({ session_id })
1197
+ }).catch(() => {});
1198
+ return; // Don't spawn a new process
1199
+ }
1200
+ // If route failed (e.g. wrong status), fall through to normal execution
1201
+ logWarn(`[daemon] Failed to route message to task (${routeResp.status}), falling back to normal execution`);
1202
+ }
1203
+ }
1204
+ } catch (taskCheckError) {
1205
+ // Non-critical — fall through to normal execution
1206
+ logWarn(`[daemon] Task check failed: ${taskCheckError.message}`);
1207
+ }
1208
+
1139
1209
  // Invalidate pending approvals BEFORE executing new command
1140
1210
  // This prevents race conditions where stale approvals could be acted upon
1141
1211
  try {
@@ -1355,8 +1425,10 @@ async function handleInboxMessage(session_id, message) {
1355
1425
  const ackResponse = await fetch(`${RELAY_API_URL}/api/messages/${encodeURIComponent(message.id)}/ack`, {
1356
1426
  method: 'POST',
1357
1427
  headers: {
1428
+ 'Content-Type': 'application/json',
1358
1429
  'Authorization': `Bearer ${RELAY_API_KEY}`
1359
- }
1430
+ },
1431
+ body: JSON.stringify({ session_id, is_user_prompt: true })
1360
1432
  });
1361
1433
 
1362
1434
  if (ackResponse.ok) {
@@ -1484,6 +1556,77 @@ async function pollRelayAPI() {
1484
1556
  // Update activity timestamp for cleanup tracking
1485
1557
  sessionActivity.set(session_id, Date.now());
1486
1558
 
1559
+ // 0) Check for stop_requested flag (mobile stop button)
1560
+ // Uses daemon-state endpoint (lightweight) instead of full session fetch
1561
+ try {
1562
+ const stopCheckResponse = await fetch(
1563
+ `${RELAY_API_URL}/api/sessions/${encodeURIComponent(session_id)}/daemon-state`,
1564
+ {
1565
+ headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` },
1566
+ signal: AbortSignal.timeout(5000)
1567
+ }
1568
+ );
1569
+
1570
+ if (stopCheckResponse.ok) {
1571
+ const daemonState = await stopCheckResponse.json();
1572
+
1573
+ if (daemonState.stop_requested) {
1574
+ logInfo(`[daemon] 🛑 Stop requested for session ${session_id} — killing running processes`);
1575
+
1576
+ // Kill any running approval execution processes for this session
1577
+ let killedExecution = false;
1578
+ for (const [approval_id, exec] of executions) {
1579
+ if (exec.session_id === session_id && exec.status === 'executing' && exec.child_process) {
1580
+ try {
1581
+ const child = exec.child_process;
1582
+ child.kill('SIGTERM');
1583
+ // Track SIGKILL timer so it can be cancelled if process exits cleanly
1584
+ const killTimer = setTimeout(() => {
1585
+ try { child.kill('SIGKILL'); } catch {}
1586
+ }, 2000);
1587
+ child.once('exit', () => clearTimeout(killTimer));
1588
+ killedExecution = true;
1589
+ logInfo(`[daemon] Killed execution process for approval ${approval_id}`);
1590
+ } catch (killErr) {
1591
+ logWarn(`[daemon] Failed to kill execution ${approval_id}: ${killErr.message}`);
1592
+ }
1593
+ }
1594
+ }
1595
+
1596
+ // Kill any running task processes for this session
1597
+ const killedTaskCount = stopTasksForSession(session_id);
1598
+ const killedTask = killedTaskCount > 0;
1599
+ if (killedTask) {
1600
+ logInfo(`[daemon] Killed ${killedTaskCount} task process(es) for session ${session_id}`);
1601
+ }
1602
+
1603
+ // Clear stop_requested flag
1604
+ try {
1605
+ await fetch(`${RELAY_API_URL}/api/sessions/${encodeURIComponent(session_id)}/daemon-state`, {
1606
+ method: 'PATCH',
1607
+ headers: {
1608
+ 'Content-Type': 'application/json',
1609
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
1610
+ },
1611
+ body: JSON.stringify({ stop_requested: false })
1612
+ });
1613
+ logInfo(`[daemon] Cleared stop_requested flag for session ${session_id}`);
1614
+ } catch (clearErr) {
1615
+ logWarn(`[daemon] Failed to clear stop_requested: ${clearErr.message}`);
1616
+ }
1617
+
1618
+ if (!killedExecution && !killedTask) {
1619
+ logInfo(`[daemon] No running processes found to stop for session ${session_id}`);
1620
+ }
1621
+ }
1622
+ }
1623
+ } catch (stopCheckError) {
1624
+ // Don't block polling if stop check fails
1625
+ if (stopCheckError.name !== 'AbortError') {
1626
+ logWarn(`[daemon] Stop check error for ${session_id}: ${stopCheckError.message}`);
1627
+ }
1628
+ }
1629
+
1487
1630
  // 1) Approvals polling (existing behavior)
1488
1631
  try {
1489
1632
  const response = await fetch(
@@ -1593,8 +1736,8 @@ async function pollRelayAPI() {
1593
1736
 
1594
1737
  // Process each task (stateless - queries timeline each time)
1595
1738
  for (const task of tasks) {
1596
- // Skip stopped/completed tasks
1597
- if (task.status === 'stopped' || task.status === 'completed') {
1739
+ // Skip non-runnable tasks (paused tasks wait for user message to resume)
1740
+ if (task.status === 'stopped' || task.status === 'completed' || task.status === 'paused') {
1598
1741
  continue;
1599
1742
  }
1600
1743
 
@@ -1633,11 +1776,21 @@ async function pollRelayAPI() {
1633
1776
  const claude_session_id = sessionData.claude_session_id || session_id;
1634
1777
  const cwd = sessionData.cwd || process.cwd();
1635
1778
 
1779
+ // 4) Heartbeat - send periodically to keep session alive
1780
+ // Must run before ingestion throttle check — ingestion `continue` must not skip heartbeats
1781
+ const now = Date.now();
1782
+ const sessionHeartbeat = heartbeatState.get(session_id);
1783
+ const lastSent = sessionHeartbeat?.lastSent || 0;
1784
+ if (now - lastSent >= SESSION_HEARTBEAT_INTERVAL_MS) {
1785
+ await sendHeartbeat(session_id);
1786
+ }
1787
+
1788
+ // 5) Transcript ingestion - backup to stop hook for timeline completeness
1636
1789
  // Throttling: Check if ingestion is already in progress for this session
1637
1790
  // Prevents concurrent ingestion runs that could cause race conditions
1638
1791
  if (ingestionInProgress.has(session_id)) {
1639
1792
  debugLog('daemon-transcript-debug.log', `Ingestion already in progress for ${session_id}, skipping`);
1640
- // Skip this polling cycle for this session
1793
+ // Skip ingestion this cycle (heartbeat already sent above)
1641
1794
  continue;
1642
1795
  }
1643
1796
 
@@ -1670,15 +1823,6 @@ async function pollRelayAPI() {
1670
1823
 
1671
1824
  // Track the promise (but don't await - fire-and-forget)
1672
1825
  ingestionInProgress.set(session_id, ingestionPromise);
1673
-
1674
- // 5) Heartbeat - send periodically to keep session alive
1675
- // Only send heartbeat if enough time has passed since last one (throttled per session)
1676
- const now = Date.now();
1677
- const sessionHeartbeat = heartbeatState.get(session_id);
1678
- const lastSent = sessionHeartbeat?.lastSent || 0;
1679
- if (now - lastSent >= SESSION_HEARTBEAT_INTERVAL_MS) {
1680
- await sendHeartbeat(session_id);
1681
- }
1682
1826
  }
1683
1827
 
1684
1828
  // Process approval queue
@@ -1715,11 +1859,12 @@ function cleanupOldExecutions() {
1715
1859
  console.log(`[daemon] Cleaned up ${removed} old execution(s) from cache`);
1716
1860
  }
1717
1861
 
1718
- // Clean up heartbeatState for sessions that no longer exist
1862
+ // Clean up heartbeatState and heartbeatFailureLogged for sessions that no longer exist
1719
1863
  let heartbeatRemoved = 0;
1720
1864
  for (const sessionId of heartbeatState.keys()) {
1721
1865
  if (!sessions.has(sessionId)) {
1722
1866
  heartbeatState.delete(sessionId);
1867
+ heartbeatFailureLogged.delete(sessionId);
1723
1868
  heartbeatRemoved++;
1724
1869
  }
1725
1870
  }
@@ -1812,6 +1957,7 @@ async function processQueue() {
1812
1957
  // Mark as executing (child_process will be set when spawnClaudeProcess is called)
1813
1958
  executions.set(approval_id, {
1814
1959
  approval_id,
1960
+ session_id,
1815
1961
  status: 'executing',
1816
1962
  started_at: Date.now(),
1817
1963
  completed_at: null,
@@ -2868,7 +3014,7 @@ async function main() {
2868
3014
  if (isShuttingDown || sessions.size === 0) return;
2869
3015
  for (const [sessionId] of sessions) {
2870
3016
  try {
2871
- await fetch(`${RELAY_API_URL}/api/sessions/${encodeURIComponent(sessionId)}/heartbeat`, {
3017
+ const hbResponse = await fetch(`${RELAY_API_URL}/api/sessions/${encodeURIComponent(sessionId)}/heartbeat`, {
2872
3018
  method: 'POST',
2873
3019
  headers: {
2874
3020
  'Content-Type': 'application/json',
@@ -2877,8 +3023,58 @@ async function main() {
2877
3023
  body: JSON.stringify({ timestamp: Date.now() }),
2878
3024
  signal: AbortSignal.timeout(5000)
2879
3025
  });
3026
+ if (!hbResponse.ok) {
3027
+ const errMsg = `HTTP ${hbResponse.status}`;
3028
+
3029
+ // 404 means session expired from Redis — try to re-register it.
3030
+ // The relay heartbeat endpoint also attempts recovery from mech-storage,
3031
+ // but if that fails (e.g., session never persisted), daemon-side re-registration
3032
+ // ensures the session is recreated with correct metadata.
3033
+ if (hbResponse.status === 404) {
3034
+ const sessionData = sessions.get(sessionId);
3035
+ try {
3036
+ const regResponse = await fetch(`${RELAY_API_URL}/api/sessions/register`, {
3037
+ method: 'POST',
3038
+ headers: {
3039
+ 'Content-Type': 'application/json',
3040
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
3041
+ },
3042
+ body: JSON.stringify({
3043
+ session_id: sessionId,
3044
+ claude_session_id: sessionData?.claude_session_id || undefined,
3045
+ cwd: sessionData?.cwd || process.cwd(),
3046
+ meta: sessionData?.meta || {}
3047
+ }),
3048
+ signal: AbortSignal.timeout(5000)
3049
+ });
3050
+ if (regResponse.ok) {
3051
+ console.log(`[daemon] Re-registered expired session ${sessionId} after heartbeat 404`);
3052
+ // Clear failure tracking so next heartbeat is treated fresh
3053
+ heartbeatFailureLogged.delete(sessionId);
3054
+ continue; // Skip failure logging — session recovered
3055
+ } else {
3056
+ console.warn(`[daemon] Failed to re-register session ${sessionId}: HTTP ${regResponse.status}`);
3057
+ }
3058
+ } catch (regErr) {
3059
+ console.warn(`[daemon] Re-registration attempt failed for ${sessionId}: ${regErr.message}`);
3060
+ }
3061
+ }
3062
+
3063
+ if (!heartbeatFailureLogged.has(sessionId)) {
3064
+ heartbeatFailureLogged.add(sessionId);
3065
+ console.warn(`[daemon] Heartbeat rejected for ${sessionId}: ${errMsg} (further failures for this session suppressed unless DEBUG is set)`);
3066
+ } else if (process.env.DEBUG) {
3067
+ console.error(`[daemon] Heartbeat rejected for ${sessionId}: ${errMsg}`);
3068
+ }
3069
+ }
2880
3070
  } catch (err) {
2881
- if (process.env.DEBUG) {
3071
+ // Always log the first heartbeat failure per session so operators
3072
+ // know heartbeats are not reaching the relay. Subsequent failures
3073
+ // for the same session are only logged in DEBUG mode to avoid spam.
3074
+ if (!heartbeatFailureLogged.has(sessionId)) {
3075
+ heartbeatFailureLogged.add(sessionId);
3076
+ console.warn(`[daemon] Heartbeat failed for ${sessionId}: ${err.message} (further failures for this session suppressed unless DEBUG is set)`);
3077
+ } else if (process.env.DEBUG) {
2882
3078
  console.error(`[daemon] Heartbeat failed for ${sessionId}: ${err.message}`);
2883
3079
  }
2884
3080
  }
@@ -16,7 +16,7 @@
16
16
  export async function fetchTimeline(session_id, config) {
17
17
  const { relayApiUrl, apiKey } = config;
18
18
 
19
- const response = await fetch(`${relayApiUrl}/api/timeline/${session_id}`, {
19
+ const response = await fetch(`${relayApiUrl}/api/sessions/${session_id}/timeline`, {
20
20
  headers: { 'Authorization': `Bearer ${apiKey}` }
21
21
  });
22
22
 
@@ -36,7 +36,7 @@ export async function fetchTimeline(session_id, config) {
36
36
  */
37
37
  export function analyzeTaskState(events, task_id) {
38
38
  // Filter events for this specific task
39
- const taskEvents = events.filter(e => e.meta?.task_id === task_id);
39
+ const taskEvents = events.filter(e => e.data?.task_id === task_id);
40
40
 
41
41
  if (taskEvents.length === 0) {
42
42
  return {
@@ -53,16 +53,17 @@ export function analyzeTaskState(events, task_id) {
53
53
 
54
54
  const lastEvent = taskEvents[taskEvents.length - 1];
55
55
 
56
- // Count turns (each assistant_response indicates a completed turn)
56
+ // Count turns (each assistant_response with matching task_id indicates a completed turn)
57
+ // Note: relay stores source inside data object, not at event top level
57
58
  const turn_count = taskEvents.filter(e =>
58
- e.type === 'assistant_response' && e.source === 'autonomous_task'
59
+ e.type === 'assistant_response'
59
60
  ).length;
60
61
 
61
62
  // Find the latest claude_session_id from task execution
62
63
  let claude_session_id = null;
63
64
  for (let i = taskEvents.length - 1; i >= 0; i--) {
64
- if (taskEvents[i].meta?.claude_session_id) {
65
- claude_session_id = taskEvents[i].meta.claude_session_id;
65
+ if (taskEvents[i].data?.claude_session_id) {
66
+ claude_session_id = taskEvents[i].data.claude_session_id;
66
67
  break;
67
68
  }
68
69
  }
@@ -72,7 +73,7 @@ export function analyzeTaskState(events, task_id) {
72
73
  e.type === 'approval_requested' &&
73
74
  !taskEvents.some(later =>
74
75
  later.type === 'approval_decided' &&
75
- later.meta?.approval_id === e.meta?.approval_id
76
+ later.data?.approval_id === e.data?.approval_id
76
77
  )
77
78
  );
78
79
 
@@ -93,7 +94,7 @@ export function analyzeTaskState(events, task_id) {
93
94
  state: 'paused',
94
95
  turn_count,
95
96
  ready_for_execution: false,
96
- reason: lastEvent.meta?.reason || 'Task paused',
97
+ reason: lastEvent.data?.reason || 'Task paused',
97
98
  claude_session_id,
98
99
  waiting_for_user_message: true,
99
100
  };
@@ -105,7 +106,7 @@ export function analyzeTaskState(events, task_id) {
105
106
  state: 'stopped',
106
107
  turn_count,
107
108
  ready_for_execution: false,
108
- reason: lastEvent.meta?.reason || 'Task stopped',
109
+ reason: lastEvent.data?.reason || 'Task stopped',
109
110
  claude_session_id,
110
111
  };
111
112
  }
@@ -134,7 +135,7 @@ export function analyzeTaskState(events, task_id) {
134
135
  // If last event is assistant_response, ready for next turn
135
136
  // Use stop_reason to determine if Claude is done (works like CLI)
136
137
  if (lastEvent.type === 'assistant_response') {
137
- const stopReason = lastEvent.meta?.stop_reason;
138
+ const stopReason = lastEvent.data?.stop_reason;
138
139
 
139
140
  // Claude uses "end_turn" when it's done with the current turn and waiting for input
140
141
  // This is the natural stopping point, just like in the CLI
@@ -183,9 +184,14 @@ export function getNextPrompt(state, task) {
183
184
  return task.task;
184
185
  }
185
186
 
186
- // If paused and resumed with user message, use that message
187
- if (task.pending_question) {
188
- return task.pending_question;
187
+ // If resumed with a user answer/message, use that as the prompt
188
+ if (task.pending_answer) {
189
+ return task.pending_answer;
190
+ }
191
+
192
+ // If redirected with new instructions, use those
193
+ if (task.pending_redirect) {
194
+ return task.pending_redirect;
189
195
  }
190
196
 
191
197
  // Default continuation prompt