teleportation-cli 1.2.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/hooks/permission_request.mjs +11 -4
- package/.claude/hooks/post_tool_use.mjs +1 -3
- package/.claude/hooks/pre_tool_use.mjs +216 -287
- package/.claude/hooks/session-register.mjs +36 -28
- package/.claude/hooks/session_end.mjs +1 -3
- package/.claude/hooks/session_start.mjs +15 -1
- package/.claude/hooks/stop.mjs +215 -224
- package/.claude/hooks/user_prompt_submit.mjs +1 -3
- package/lib/daemon/task-executor-v2.js +208 -27
- package/lib/daemon/teleportation-daemon.js +215 -19
- package/lib/daemon/timeline-analyzer.js +19 -13
- package/lib/daemon/transcript-ingestion.js +152 -44
- package/lib/install/installer.js +43 -13
- package/package.json +1 -1
- package/teleportation-cli.cjs +57 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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/
|
|
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.
|
|
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'
|
|
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].
|
|
65
|
-
claude_session_id = taskEvents[i].
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
187
|
-
if (task.
|
|
188
|
-
return task.
|
|
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
|