teleportation-cli 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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 +182 -12
- package/lib/daemon/timeline-analyzer.js +19 -13
- package/lib/daemon/transcript-ingestion.js +152 -44
- package/package.json +1 -1
|
@@ -22,6 +22,37 @@ const MAX_TURNS = 100;
|
|
|
22
22
|
// Track running processes for stop functionality (only active processes)
|
|
23
23
|
const runningProcesses = new Map();
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Extract Claude's text response from stream-json output
|
|
27
|
+
* Stream-json format: one JSON object per line, assistant text is in
|
|
28
|
+
* {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
|
|
29
|
+
* @param {string} output - Raw stream-json stdout
|
|
30
|
+
* @returns {string} Extracted text or empty string
|
|
31
|
+
*/
|
|
32
|
+
function extractAssistantText(output) {
|
|
33
|
+
if (!output) return '';
|
|
34
|
+
const lines = output.split('\n');
|
|
35
|
+
// Search from end — the last assistant message is most relevant for display
|
|
36
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
37
|
+
const line = lines[i];
|
|
38
|
+
if (!line.trim()) continue;
|
|
39
|
+
try {
|
|
40
|
+
const event = JSON.parse(line);
|
|
41
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
42
|
+
const textBlock = Array.isArray(event.message.content)
|
|
43
|
+
? event.message.content.find(c => c.type === 'text')
|
|
44
|
+
: null;
|
|
45
|
+
if (textBlock?.text) {
|
|
46
|
+
return textBlock.text.trim().substring(0, 2000);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Not JSON, skip
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
|
|
25
56
|
/**
|
|
26
57
|
* Execute a single turn for a task
|
|
27
58
|
* Stateless - queries timeline to determine what to do
|
|
@@ -71,42 +102,58 @@ export async function executeTaskTurn(options) {
|
|
|
71
102
|
}
|
|
72
103
|
|
|
73
104
|
// 7. Determine which session to resume
|
|
74
|
-
// First turn: resume parent
|
|
105
|
+
// First turn: start fresh (don't resume parent — it's actively in use)
|
|
75
106
|
// Subsequent turns: resume child session from previous turn
|
|
76
|
-
const resumeSessionId = state.claude_session_id ||
|
|
77
|
-
|
|
78
|
-
if (!resumeSessionId) {
|
|
79
|
-
console.error(`[task-v2] No session ID to resume for task ${task_id}`);
|
|
80
|
-
return { success: false, error: 'No session ID to resume' };
|
|
81
|
-
}
|
|
107
|
+
const resumeSessionId = state.claude_session_id || null;
|
|
82
108
|
|
|
83
109
|
console.log(`[task-v2] Executing turn ${state.turn_count + 1} for task ${task_id.slice(0, 20)}...`);
|
|
84
|
-
|
|
110
|
+
if (resumeSessionId) {
|
|
111
|
+
console.log(`[task-v2] Resuming child session: ${resumeSessionId}`);
|
|
112
|
+
} else {
|
|
113
|
+
console.log(`[task-v2] Starting fresh session (first turn)`);
|
|
114
|
+
}
|
|
85
115
|
console.log(`[task-v2] Prompt: ${prompt.slice(0, 100)}...`);
|
|
86
116
|
|
|
117
|
+
// 7.5 Check remaining budget before executing
|
|
118
|
+
const remainingBudget = (task.budget_usd || 0) - (task.cost_usd || 0);
|
|
119
|
+
if (remainingBudget <= 0) {
|
|
120
|
+
console.log(`[task-v2] Budget exhausted: $${task.cost_usd} of $${task.budget_usd} used`);
|
|
121
|
+
await updateTaskStatus(task_id, session_id, 'stopped', config);
|
|
122
|
+
return { success: true, stopped: true, reason: 'Budget exhausted' };
|
|
123
|
+
}
|
|
124
|
+
|
|
87
125
|
// 8. Execute Claude headless
|
|
88
126
|
try {
|
|
89
127
|
const result = await executeClaudeHeadless({
|
|
90
128
|
prompt,
|
|
91
129
|
cwd: task.cwd || process.cwd(),
|
|
92
130
|
resumeSessionId,
|
|
93
|
-
budgetUsd:
|
|
131
|
+
budgetUsd: remainingBudget,
|
|
94
132
|
taskId: task_id,
|
|
95
133
|
parentSessionId: task.session_id,
|
|
134
|
+
bypassPermissions: task.bypass_permissions,
|
|
96
135
|
});
|
|
97
136
|
|
|
98
137
|
// 9. Ingest transcript to timeline
|
|
99
138
|
// This ensures all events (especially assistant responses) are captured
|
|
100
139
|
// even if hooks failed to log them during execution
|
|
140
|
+
let transcriptIngested = false;
|
|
101
141
|
if (result.session_id) {
|
|
102
142
|
try {
|
|
103
|
-
await ingestTranscriptToTimeline({
|
|
143
|
+
const ingestionResult = await ingestTranscriptToTimeline({
|
|
104
144
|
claude_session_id: result.session_id,
|
|
105
145
|
parent_session_id: session_id,
|
|
106
146
|
task_id: task_id,
|
|
107
147
|
cwd: task.cwd || process.cwd(), // Pass cwd for project slug derivation
|
|
108
148
|
config,
|
|
109
149
|
});
|
|
150
|
+
// Only consider ingested if events were actually pushed to timeline
|
|
151
|
+
// When transcript isn't found (child session in different project dir),
|
|
152
|
+
// events_pushed will be 0 and we must fall back to manual POST
|
|
153
|
+
transcriptIngested = (ingestionResult?.events_pushed || 0) > 0;
|
|
154
|
+
if (!transcriptIngested) {
|
|
155
|
+
console.log(`[task-v2] Transcript ingestion returned 0 events — will use fallback POST`);
|
|
156
|
+
}
|
|
110
157
|
} catch (error) {
|
|
111
158
|
// Don't fail the whole turn if transcript ingestion fails
|
|
112
159
|
console.error(`[task-v2] Failed to ingest transcript:`, error.message);
|
|
@@ -116,6 +163,65 @@ export async function executeTaskTurn(options) {
|
|
|
116
163
|
// 10. Update task in Redis with results
|
|
117
164
|
await updateTaskAfterTurn(task_id, session_id, result, task, config);
|
|
118
165
|
|
|
166
|
+
// 11. Log task turn completion to timeline so analyzeTaskState() can track progress
|
|
167
|
+
// Only emit this fallback marker if transcript ingestion didn't already emit events.
|
|
168
|
+
// Both paths produce assistant_response events with task_id, so emitting both
|
|
169
|
+
// would double-count turns in analyzeTaskState().
|
|
170
|
+
if (!transcriptIngested) {
|
|
171
|
+
try {
|
|
172
|
+
const resp = await fetch(`${relayApiUrl}/api/timeline`, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: {
|
|
175
|
+
'Content-Type': 'application/json',
|
|
176
|
+
'Authorization': `Bearer ${apiKey}`
|
|
177
|
+
},
|
|
178
|
+
body: JSON.stringify({
|
|
179
|
+
session_id,
|
|
180
|
+
type: 'assistant_response',
|
|
181
|
+
data: {
|
|
182
|
+
task_id,
|
|
183
|
+
source: 'cli_interactive',
|
|
184
|
+
claude_session_id: result.session_id,
|
|
185
|
+
turn: state.turn_count + 1,
|
|
186
|
+
cost_usd: result.cost_usd,
|
|
187
|
+
timestamp: Date.now(),
|
|
188
|
+
message: extractAssistantText(result.output) || 'Turn completed',
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
});
|
|
192
|
+
if (!resp.ok) {
|
|
193
|
+
console.error(`[task-v2] Turn completion POST failed: ${resp.status}`);
|
|
194
|
+
} else {
|
|
195
|
+
console.log(`[task-v2] Logged turn ${state.turn_count + 1} completion to timeline (fallback)`);
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
console.error(`[task-v2] Failed to log turn completion:`, e.message);
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
console.log(`[task-v2] Turn ${state.turn_count + 1} events already ingested via transcript`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 12. Auto-pause: unless auto_continue is set, pause after each turn
|
|
205
|
+
// This is the default behavior — user must send a message to continue
|
|
206
|
+
// CRITICAL: If this fails, the task stays 'running' and re-executes on the next
|
|
207
|
+
// poll cycle, causing the same infinite loop this PR fixes. Retry once on failure.
|
|
208
|
+
if (!task.auto_continue) {
|
|
209
|
+
console.log(`[task-v2] Pausing task after turn ${state.turn_count + 1} (auto_continue not set)`);
|
|
210
|
+
try {
|
|
211
|
+
await updateTaskStatus(task_id, session_id, 'paused', config);
|
|
212
|
+
} catch (pauseError) {
|
|
213
|
+
console.error(`[task-v2] Failed to pause task (retrying once): ${pauseError.message}`);
|
|
214
|
+
try {
|
|
215
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
216
|
+
await updateTaskStatus(task_id, session_id, 'paused', config);
|
|
217
|
+
} catch (retryError) {
|
|
218
|
+
console.error(`[task-v2] CRITICAL: Failed to pause task after retry: ${retryError.message}`);
|
|
219
|
+
// Return error so daemon doesn't re-execute this turn
|
|
220
|
+
return { success: false, error: 'Failed to pause task — cannot guarantee no re-execution' };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
119
225
|
return {
|
|
120
226
|
success: true,
|
|
121
227
|
executed: true,
|
|
@@ -187,11 +293,19 @@ async function updateTaskAfterTurn(task_id, session_id, result, currentTask, con
|
|
|
187
293
|
const totalCost = (currentTask.cost_usd || 0) + (result.cost_usd || 0);
|
|
188
294
|
|
|
189
295
|
const updates = {
|
|
190
|
-
claude_session_id: result.session_id || currentTask.claude_session_id,
|
|
191
296
|
cost_usd: totalCost,
|
|
192
297
|
turn_count: (currentTask.turn_count || 0) + 1,
|
|
298
|
+
// Clear consumed prompts to prevent re-use on next turn
|
|
299
|
+
pending_answer: null,
|
|
300
|
+
pending_redirect: null,
|
|
193
301
|
};
|
|
194
302
|
|
|
303
|
+
// Only include claude_session_id if we have a valid one (relay rejects null)
|
|
304
|
+
const sessionId = result.session_id || currentTask.claude_session_id;
|
|
305
|
+
if (sessionId) {
|
|
306
|
+
updates.claude_session_id = sessionId;
|
|
307
|
+
}
|
|
308
|
+
|
|
195
309
|
const response = await fetch(
|
|
196
310
|
`${relayApiUrl}/api/sessions/${session_id}/tasks/${task_id}`,
|
|
197
311
|
{
|
|
@@ -205,7 +319,8 @@ async function updateTaskAfterTurn(task_id, session_id, result, currentTask, con
|
|
|
205
319
|
);
|
|
206
320
|
|
|
207
321
|
if (!response.ok) {
|
|
208
|
-
|
|
322
|
+
const body = await response.text().catch(() => '');
|
|
323
|
+
throw new Error(`Failed to update task: ${response.status} ${body}`);
|
|
209
324
|
}
|
|
210
325
|
|
|
211
326
|
return await response.json();
|
|
@@ -241,6 +356,13 @@ async function executeClaudeHeadless(options) {
|
|
|
241
356
|
args.push('--output-format', 'stream-json');
|
|
242
357
|
args.push('--verbose');
|
|
243
358
|
|
|
359
|
+
// Permission mode: configurable per-task or via config
|
|
360
|
+
// Default: no override (uses hooks → approvals show on mobile UI)
|
|
361
|
+
// User can set bypass to skip approvals for trusted tasks
|
|
362
|
+
if (options.bypassPermissions) {
|
|
363
|
+
args.push('--permission-mode', 'bypassPermissions');
|
|
364
|
+
}
|
|
365
|
+
|
|
244
366
|
// Budget control
|
|
245
367
|
if (budgetUsd > 0) {
|
|
246
368
|
args.push('--max-budget-usd', budgetUsd.toFixed(2));
|
|
@@ -248,21 +370,25 @@ async function executeClaudeHeadless(options) {
|
|
|
248
370
|
|
|
249
371
|
console.log(`[task-v2] Executing: ${CLAUDE_CLI} ${args.join(' ')}`);
|
|
250
372
|
|
|
373
|
+
// Build child env: unset CLAUDECODE to allow nested Claude launches
|
|
374
|
+
const childEnv = {
|
|
375
|
+
...process.env,
|
|
376
|
+
CI: 'true',
|
|
377
|
+
TELEPORTATION_TASK_MODE: 'true',
|
|
378
|
+
// Route approvals to parent session timeline
|
|
379
|
+
...(parentSessionId && { TELEPORTATION_PARENT_SESSION_ID: parentSessionId }),
|
|
380
|
+
};
|
|
381
|
+
delete childEnv.CLAUDECODE;
|
|
382
|
+
|
|
251
383
|
const proc = spawn(CLAUDE_CLI, args, {
|
|
252
384
|
cwd,
|
|
253
385
|
stdio: 'pipe',
|
|
254
|
-
env:
|
|
255
|
-
...process.env,
|
|
256
|
-
CI: 'true',
|
|
257
|
-
TELEPORTATION_TASK_MODE: 'true',
|
|
258
|
-
// Route approvals to parent session timeline
|
|
259
|
-
...(parentSessionId && { TELEPORTATION_PARENT_SESSION_ID: parentSessionId }),
|
|
260
|
-
},
|
|
386
|
+
env: childEnv,
|
|
261
387
|
});
|
|
262
388
|
|
|
263
|
-
// Track process for stop functionality
|
|
389
|
+
// Track process for stop functionality (includes session_id for per-session filtering)
|
|
264
390
|
if (taskId) {
|
|
265
|
-
runningProcesses.set(taskId, proc);
|
|
391
|
+
runningProcesses.set(taskId, { proc, session_id: parentSessionId });
|
|
266
392
|
}
|
|
267
393
|
|
|
268
394
|
// Close stdin
|
|
@@ -279,6 +405,7 @@ async function executeClaudeHeadless(options) {
|
|
|
279
405
|
exit_code: 0,
|
|
280
406
|
session_id: null,
|
|
281
407
|
cost_usd: 0,
|
|
408
|
+
_costExplicitlySet: false, // Track if total_cost_usd was found
|
|
282
409
|
usage: {
|
|
283
410
|
input_tokens: 0,
|
|
284
411
|
output_tokens: 0,
|
|
@@ -311,8 +438,18 @@ async function executeClaudeHeadless(options) {
|
|
|
311
438
|
}
|
|
312
439
|
|
|
313
440
|
// Extract cost and usage
|
|
441
|
+
// stream-json 'result' event has total_cost_usd at top level
|
|
442
|
+
// e.g. {"type":"result","total_cost_usd":0.72,"usage":{...}}
|
|
443
|
+
if (event.total_cost_usd != null) {
|
|
444
|
+
result.cost_usd = event.total_cost_usd;
|
|
445
|
+
result._costExplicitlySet = true;
|
|
446
|
+
}
|
|
314
447
|
if (event.usage) {
|
|
315
|
-
|
|
448
|
+
// Fallback: older format had usage.total_cost
|
|
449
|
+
// Only use fallback if total_cost_usd was never found (not just zero)
|
|
450
|
+
if (!result._costExplicitlySet && event.usage.total_cost) {
|
|
451
|
+
result.cost_usd = event.usage.total_cost;
|
|
452
|
+
}
|
|
316
453
|
result.usage = event.usage;
|
|
317
454
|
}
|
|
318
455
|
} catch (e) {
|
|
@@ -337,6 +474,10 @@ async function executeClaudeHeadless(options) {
|
|
|
337
474
|
result.error = stderr;
|
|
338
475
|
result.success = code === 0;
|
|
339
476
|
|
|
477
|
+
if (!result._costExplicitlySet && result.cost_usd === 0 && code === 0) {
|
|
478
|
+
console.warn(`[task-v2] No cost data found in stream-json output`);
|
|
479
|
+
}
|
|
480
|
+
|
|
340
481
|
resolve(result);
|
|
341
482
|
});
|
|
342
483
|
|
|
@@ -356,19 +497,57 @@ async function executeClaudeHeadless(options) {
|
|
|
356
497
|
* Stop a running task
|
|
357
498
|
*/
|
|
358
499
|
export function stopTask(task_id) {
|
|
359
|
-
const
|
|
360
|
-
if (
|
|
500
|
+
const entry = runningProcesses.get(task_id);
|
|
501
|
+
if (entry) {
|
|
502
|
+
// All entries are {proc, session_id} objects (set in executeClaudeHeadless)
|
|
503
|
+
const proc = entry.proc;
|
|
504
|
+
// If process already exited, no need to kill or set timer
|
|
505
|
+
if (proc.exitCode !== null) {
|
|
506
|
+
runningProcesses.delete(task_id);
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
361
509
|
proc.kill('SIGTERM');
|
|
362
|
-
setTimeout(() => {
|
|
510
|
+
const killTimer = setTimeout(() => {
|
|
363
511
|
if (runningProcesses.has(task_id)) {
|
|
364
|
-
proc.kill('SIGKILL');
|
|
512
|
+
try { proc.kill('SIGKILL'); } catch {}
|
|
365
513
|
}
|
|
366
514
|
}, 2000);
|
|
515
|
+
proc.once('exit', () => clearTimeout(killTimer));
|
|
367
516
|
return true;
|
|
368
517
|
}
|
|
369
518
|
return false;
|
|
370
519
|
}
|
|
371
520
|
|
|
521
|
+
/**
|
|
522
|
+
* Stop all running tasks for a specific session
|
|
523
|
+
* Returns number of processes killed
|
|
524
|
+
*/
|
|
525
|
+
export function stopTasksForSession(session_id) {
|
|
526
|
+
let killed = 0;
|
|
527
|
+
for (const [task_id, entry] of runningProcesses) {
|
|
528
|
+
if (entry.session_id !== session_id) continue;
|
|
529
|
+
const proc = entry.proc;
|
|
530
|
+
if (proc.exitCode !== null) {
|
|
531
|
+
runningProcesses.delete(task_id);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
proc.kill('SIGTERM');
|
|
536
|
+
const killTimer = setTimeout(() => {
|
|
537
|
+
try { proc.kill('SIGKILL'); } catch {}
|
|
538
|
+
}, 2000);
|
|
539
|
+
proc.once('exit', () => {
|
|
540
|
+
clearTimeout(killTimer);
|
|
541
|
+
runningProcesses.delete(task_id);
|
|
542
|
+
});
|
|
543
|
+
killed++;
|
|
544
|
+
} catch (err) {
|
|
545
|
+
// Process may already be dead
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return killed;
|
|
549
|
+
}
|
|
550
|
+
|
|
372
551
|
/**
|
|
373
552
|
* Stop all running tasks
|
|
374
553
|
* Used during daemon shutdown to ensure clean exit
|
|
@@ -376,8 +555,10 @@ export function stopTask(task_id) {
|
|
|
376
555
|
export function stopAllTasks() {
|
|
377
556
|
console.log(`[task-v2] Stopping all tasks (${runningProcesses.size} processes active)`);
|
|
378
557
|
|
|
379
|
-
for (const [task_id,
|
|
558
|
+
for (const [task_id, entry] of runningProcesses) {
|
|
380
559
|
try {
|
|
560
|
+
// All entries are {proc, session_id} objects (set in executeClaudeHeadless)
|
|
561
|
+
const proc = entry.proc;
|
|
381
562
|
let killTimer = null;
|
|
382
563
|
|
|
383
564
|
// Setup exit handler to clear timer
|
|
@@ -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
|
|
@@ -1144,6 +1145,67 @@ async function handleInboxMessage(session_id, message) {
|
|
|
1144
1145
|
return;
|
|
1145
1146
|
}
|
|
1146
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
|
+
|
|
1147
1209
|
// Invalidate pending approvals BEFORE executing new command
|
|
1148
1210
|
// This prevents race conditions where stale approvals could be acted upon
|
|
1149
1211
|
try {
|
|
@@ -1494,6 +1556,77 @@ async function pollRelayAPI() {
|
|
|
1494
1556
|
// Update activity timestamp for cleanup tracking
|
|
1495
1557
|
sessionActivity.set(session_id, Date.now());
|
|
1496
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
|
+
|
|
1497
1630
|
// 1) Approvals polling (existing behavior)
|
|
1498
1631
|
try {
|
|
1499
1632
|
const response = await fetch(
|
|
@@ -1603,8 +1736,8 @@ async function pollRelayAPI() {
|
|
|
1603
1736
|
|
|
1604
1737
|
// Process each task (stateless - queries timeline each time)
|
|
1605
1738
|
for (const task of tasks) {
|
|
1606
|
-
// Skip
|
|
1607
|
-
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') {
|
|
1608
1741
|
continue;
|
|
1609
1742
|
}
|
|
1610
1743
|
|
|
@@ -1643,11 +1776,21 @@ async function pollRelayAPI() {
|
|
|
1643
1776
|
const claude_session_id = sessionData.claude_session_id || session_id;
|
|
1644
1777
|
const cwd = sessionData.cwd || process.cwd();
|
|
1645
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
|
|
1646
1789
|
// Throttling: Check if ingestion is already in progress for this session
|
|
1647
1790
|
// Prevents concurrent ingestion runs that could cause race conditions
|
|
1648
1791
|
if (ingestionInProgress.has(session_id)) {
|
|
1649
1792
|
debugLog('daemon-transcript-debug.log', `Ingestion already in progress for ${session_id}, skipping`);
|
|
1650
|
-
// Skip this
|
|
1793
|
+
// Skip ingestion this cycle (heartbeat already sent above)
|
|
1651
1794
|
continue;
|
|
1652
1795
|
}
|
|
1653
1796
|
|
|
@@ -1680,15 +1823,6 @@ async function pollRelayAPI() {
|
|
|
1680
1823
|
|
|
1681
1824
|
// Track the promise (but don't await - fire-and-forget)
|
|
1682
1825
|
ingestionInProgress.set(session_id, ingestionPromise);
|
|
1683
|
-
|
|
1684
|
-
// 5) Heartbeat - send periodically to keep session alive
|
|
1685
|
-
// Only send heartbeat if enough time has passed since last one (throttled per session)
|
|
1686
|
-
const now = Date.now();
|
|
1687
|
-
const sessionHeartbeat = heartbeatState.get(session_id);
|
|
1688
|
-
const lastSent = sessionHeartbeat?.lastSent || 0;
|
|
1689
|
-
if (now - lastSent >= SESSION_HEARTBEAT_INTERVAL_MS) {
|
|
1690
|
-
await sendHeartbeat(session_id);
|
|
1691
|
-
}
|
|
1692
1826
|
}
|
|
1693
1827
|
|
|
1694
1828
|
// Process approval queue
|
|
@@ -1823,6 +1957,7 @@ async function processQueue() {
|
|
|
1823
1957
|
// Mark as executing (child_process will be set when spawnClaudeProcess is called)
|
|
1824
1958
|
executions.set(approval_id, {
|
|
1825
1959
|
approval_id,
|
|
1960
|
+
session_id,
|
|
1826
1961
|
status: 'executing',
|
|
1827
1962
|
started_at: Date.now(),
|
|
1828
1963
|
completed_at: null,
|
|
@@ -2890,6 +3025,41 @@ async function main() {
|
|
|
2890
3025
|
});
|
|
2891
3026
|
if (!hbResponse.ok) {
|
|
2892
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
|
+
|
|
2893
3063
|
if (!heartbeatFailureLogged.has(sessionId)) {
|
|
2894
3064
|
heartbeatFailureLogged.add(sessionId);
|
|
2895
3065
|
console.warn(`[daemon] Heartbeat rejected for ${sessionId}: ${errMsg} (further failures for this session suppressed unless DEBUG is set)`);
|