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
|
@@ -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
|