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.
@@ -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 session for context
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 || task.parent_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
- console.log(`[task-v2] Resuming session: ${resumeSessionId}`);
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: task.budget_usd - task.cost_usd,
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
- throw new Error(`Failed to update task: ${response.status}`);
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
- result.cost_usd = event.usage.total_cost || 0;
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 proc = runningProcesses.get(task_id);
360
- if (proc) {
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, proc] of runningProcesses) {
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