teleportation-cli 1.3.0 → 1.4.1

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.
@@ -49,6 +49,15 @@ const DEFAULT_CONFIG = {
49
49
  enabled: true,
50
50
  sound: false
51
51
  },
52
+ transcriptSync: {
53
+ enabled: false,
54
+ intervalMs: 300000, // 5 minutes
55
+ machineId: null,
56
+ peer: null,
57
+ peerMirrorDir: null,
58
+ mode: 'local', // local|push|pull|bidirectional
59
+ mirrorDir: join(homedir(), '.teleportation', 'transcript-mirror')
60
+ },
52
61
  installation: {
53
62
  scope: null, // 'global' | 'project' | null (not installed)
54
63
  hooksPath: null, // Path where hooks are installed
@@ -99,7 +108,8 @@ function validateConfig(config) {
99
108
  const booleanFields = [
100
109
  'hooks.autoUpdate',
101
110
  'notifications.enabled',
102
- 'notifications.sound'
111
+ 'notifications.sound',
112
+ 'transcriptSync.enabled'
103
113
  ];
104
114
 
105
115
  booleanFields.forEach(field => {
@@ -109,6 +119,40 @@ function validateConfig(config) {
109
119
  }
110
120
  });
111
121
 
122
+ // Validate transcript sync block if present
123
+ if (config.transcriptSync) {
124
+ const ts = config.transcriptSync;
125
+ const validModes = ['local', 'push', 'pull', 'bidirectional'];
126
+
127
+ if (ts.intervalMs !== undefined) {
128
+ if (typeof ts.intervalMs !== 'number' || ts.intervalMs < 10000 || ts.intervalMs > 86400000) {
129
+ errors.push('transcriptSync.intervalMs must be a number between 10000 and 86400000');
130
+ }
131
+ }
132
+
133
+ if (ts.mode !== undefined && !validModes.includes(ts.mode)) {
134
+ errors.push('transcriptSync.mode must be one of: local, push, pull, bidirectional');
135
+ }
136
+
137
+ ['mirrorDir', 'peerMirrorDir'].forEach(field => {
138
+ const value = ts[field];
139
+ if (value !== null && value !== undefined && typeof value !== 'string') {
140
+ errors.push(`transcriptSync.${field} must be a string path or null`);
141
+ }
142
+ });
143
+
144
+ ['machineId', 'peer'].forEach(field => {
145
+ const value = ts[field];
146
+ if (value !== null && value !== undefined && typeof value !== 'string') {
147
+ errors.push(`transcriptSync.${field} must be a string or null`);
148
+ }
149
+ });
150
+
151
+ if (['push', 'pull', 'bidirectional'].includes(ts.mode)) {
152
+ warnings.push('Non-local transcript sync modes require repo peer auto-discovery (set via git config teleportation.transcriptSyncPeer)');
153
+ }
154
+ }
155
+
112
156
  // Validate installation block if present
113
157
  if (config.installation) {
114
158
  const installation = config.installation;
@@ -0,0 +1,207 @@
1
+ /**
2
+ * File-based session registry.
3
+ *
4
+ * Hooks write a session file once on session_start. The daemon scans this
5
+ * directory to discover sessions, checks PID liveness, and heartbeats the
6
+ * relay on their behalf. This decouples registration from the daemon's HTTP
7
+ * server — a file write never times out and survives daemon restarts.
8
+ *
9
+ * File location: /tmp/teleportation-sessions/<session_id>.json
10
+ */
11
+
12
+ import { readdir, readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { execFile } from 'node:child_process';
15
+ import { promisify } from 'node:util';
16
+ import { tmpdir } from 'node:os';
17
+
18
+ const execFileAsync = promisify(execFile);
19
+
20
+ export const SESSIONS_DIR = join(tmpdir(), 'teleportation-sessions');
21
+
22
+ /** Guard against path traversal: session_ids must be standard lowercase hex UUIDs */
23
+ function assertValidSessionId(session_id) {
24
+ if (typeof session_id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(session_id)) {
25
+ throw new Error(`Invalid session_id: ${String(session_id)}`);
26
+ }
27
+ }
28
+
29
+ /** Ensure the sessions directory exists (mode 0700 — only owner can read/list) */
30
+ export async function ensureSessionsDir(dir = SESSIONS_DIR) {
31
+ await mkdir(dir, { recursive: true, mode: 0o700 });
32
+ }
33
+
34
+ /**
35
+ * Write a session registration file.
36
+ * Called ONCE by session_start.mjs. Never updated by the hook after this.
37
+ *
38
+ * @param {object} opts
39
+ * @param {string} opts.session_id
40
+ * @param {number} opts.claude_pid - process.ppid inside the hook (the parent claude process)
41
+ * @param {string} opts.cwd
42
+ * @param {object} opts.meta
43
+ * @param {string} [opts._dir] - override directory (for testing)
44
+ */
45
+ export async function writeSessionFile({ session_id, claude_pid, cwd, meta, _dir }) {
46
+ assertValidSessionId(session_id);
47
+ const dir = _dir || SESSIONS_DIR;
48
+ await ensureSessionsDir(dir);
49
+ const path = join(dir, `${session_id}.json`);
50
+ const record = {
51
+ session_id,
52
+ claude_pid,
53
+ cwd,
54
+ meta: meta || {},
55
+ registered_at: Date.now(),
56
+ acked: false,
57
+ ended: false,
58
+ };
59
+ await writeFile(path, JSON.stringify(record, null, 2), { encoding: 'utf8', mode: 0o600 });
60
+ return record;
61
+ }
62
+
63
+ /**
64
+ * Mark a session file as ended (written by session_end.mjs).
65
+ * Daemon will stop heartbeating on its next poll cycle.
66
+ *
67
+ * Note: read-modify-write is not atomic. If session_end.mjs and the daemon
68
+ * call this simultaneously (e.g., daemon cleanup races with hook), one write
69
+ * may clobber the other. The window is ~1ms and the worst outcome is a
70
+ * missed ended=true flag — the daemon will catch the dead PID within 60s.
71
+ *
72
+ * @param {string} session_id
73
+ * @param {string} [_dir] - override directory (for testing)
74
+ */
75
+ export async function markSessionEnded(session_id, _dir) {
76
+ assertValidSessionId(session_id);
77
+ const path = join(_dir || SESSIONS_DIR, `${session_id}.json`);
78
+ try {
79
+ const raw = await readFile(path, 'utf8');
80
+ const record = JSON.parse(raw);
81
+ record.ended = true;
82
+ record.ended_at = Date.now();
83
+ await writeFile(path, JSON.stringify(record, null, 2), { encoding: 'utf8', mode: 0o600 });
84
+ } catch {
85
+ // File may not exist if daemon already cleaned it up — that's fine
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Ack a session file (written by daemon after picking it up).
91
+ *
92
+ * @param {string} session_id
93
+ * @param {number} daemon_pid
94
+ * @param {string} [_dir] - override directory (for testing)
95
+ */
96
+ export async function ackSessionFile(session_id, daemon_pid, _dir) {
97
+ assertValidSessionId(session_id);
98
+ const path = join(_dir || SESSIONS_DIR, `${session_id}.json`);
99
+ try {
100
+ const raw = await readFile(path, 'utf8');
101
+ const record = JSON.parse(raw);
102
+ record.acked = true;
103
+ record.daemon_pid = daemon_pid;
104
+ record.acked_at = Date.now();
105
+ await writeFile(path, JSON.stringify(record, null, 2), { encoding: 'utf8', mode: 0o600 });
106
+ } catch {
107
+ // File may have been deleted concurrently — ignore
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Delete a session file (daemon cleanup after session ends or PID dies).
113
+ *
114
+ * @param {string} session_id
115
+ * @param {string} [_dir] - override directory (for testing)
116
+ */
117
+ export async function deleteSessionFile(session_id, _dir) {
118
+ assertValidSessionId(session_id);
119
+ try {
120
+ await unlink(join(_dir || SESSIONS_DIR, `${session_id}.json`));
121
+ } catch {
122
+ // Already gone — fine
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Read all session files from the sessions directory.
128
+ * Returns an array of parsed records, skipping malformed files.
129
+ *
130
+ * @param {string} [_dir] - override directory (for testing)
131
+ * @returns {Promise<Array>}
132
+ */
133
+ export async function readAllSessionFiles(_dir) {
134
+ const dir = _dir || SESSIONS_DIR;
135
+ await ensureSessionsDir(dir);
136
+ let files;
137
+ try {
138
+ files = await readdir(dir);
139
+ } catch {
140
+ return [];
141
+ }
142
+
143
+ const records = [];
144
+ for (const file of files) {
145
+ if (!file.endsWith('.json')) continue;
146
+ const expectedId = file.slice(0, -5); // strip .json
147
+ try {
148
+ const raw = await readFile(join(dir, file), 'utf8');
149
+ const record = JSON.parse(raw);
150
+ // Validate session_id is a proper UUID and matches filename to prevent path traversal
151
+ if (
152
+ record.session_id &&
153
+ record.claude_pid &&
154
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(record.session_id) &&
155
+ record.session_id === expectedId
156
+ ) {
157
+ records.push(record);
158
+ }
159
+ } catch {
160
+ // Malformed or mid-write — skip
161
+ }
162
+ }
163
+ return records;
164
+ }
165
+
166
+ /**
167
+ * Check whether a PID belongs to a live `claude` process.
168
+ *
169
+ * Uses two signals:
170
+ * 1. kill -0 (signal 0): tests if the process exists without sending a signal
171
+ * 2. ps comm=: verifies the process name is "claude" to guard against PID reuse
172
+ *
173
+ * Async to avoid blocking the event loop — execSync would stall the daemon's
174
+ * heartbeat and relay polling for up to 1s per call × N sessions.
175
+ *
176
+ * Known edge case: if a new `claude` process reuses the exact PID of a
177
+ * just-exited session, this returns true until the 60s grace period expires.
178
+ * Probability is extremely low in practice given PID space on modern systems.
179
+ *
180
+ * @param {number} pid
181
+ * @returns {Promise<boolean>}
182
+ */
183
+ export async function isClaudePidAlive(pid) {
184
+ if (!pid || typeof pid !== 'number') return false;
185
+ try {
186
+ // kill(pid, 0) throws ESRCH if process doesn't exist, EPERM if exists but no permission
187
+ process.kill(pid, 0);
188
+ } catch (e) {
189
+ // EPERM means process exists but we can't signal it — treat as alive
190
+ if (e.code === 'EPERM') return true;
191
+ return false;
192
+ }
193
+
194
+ // Verify process name to guard against PID reuse by non-claude processes.
195
+ // Uses async execFile to avoid blocking the event loop.
196
+ try {
197
+ const { stdout } = await execFileAsync('ps', ['-p', String(pid), '-o', 'comm='], { timeout: 1000 });
198
+ const comm = stdout.trim();
199
+ // Accept "claude" or paths ending in "/claude"
200
+ return comm === 'claude' || comm.endsWith('/claude');
201
+ } catch {
202
+ // ps failed (process just died, or ps unavailable in container). When kill -0
203
+ // already confirmed the process exists (we get here only if kill -0 succeeded),
204
+ // optimistically treat it as alive — kill -0 is the stronger liveness signal.
205
+ return true;
206
+ }
207
+ }
@@ -18,10 +18,42 @@ import { ingestTranscriptToTimeline } from './transcript-ingestion.js';
18
18
  const CLAUDE_CLI = process.env.CLAUDE_CLI_PATH || 'claude';
19
19
  const DEFAULT_TIMEOUT_MS = 600000; // 10 minutes per turn
20
20
  const MAX_TURNS = 100;
21
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
22
 
22
23
  // Track running processes for stop functionality (only active processes)
23
24
  const runningProcesses = new Map();
24
25
 
26
+ /**
27
+ * Extract Claude's text response from stream-json output
28
+ * Stream-json format: one JSON object per line, assistant text is in
29
+ * {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
30
+ * @param {string} output - Raw stream-json stdout
31
+ * @returns {string} Extracted text or empty string
32
+ */
33
+ function extractAssistantText(output) {
34
+ if (!output) return '';
35
+ const lines = output.split('\n');
36
+ // Search from end — the last assistant message is most relevant for display
37
+ for (let i = lines.length - 1; i >= 0; i--) {
38
+ const line = lines[i];
39
+ if (!line.trim()) continue;
40
+ try {
41
+ const event = JSON.parse(line);
42
+ if (event.type === 'assistant' && event.message?.content) {
43
+ const textBlock = Array.isArray(event.message.content)
44
+ ? event.message.content.find(c => c.type === 'text')
45
+ : null;
46
+ if (textBlock?.text) {
47
+ return textBlock.text.trim().substring(0, 2000);
48
+ }
49
+ }
50
+ } catch {
51
+ // Not JSON, skip
52
+ }
53
+ }
54
+ return '';
55
+ }
56
+
25
57
  /**
26
58
  * Execute a single turn for a task
27
59
  * Stateless - queries timeline to determine what to do
@@ -65,48 +97,91 @@ export async function executeTaskTurn(options) {
65
97
  }
66
98
 
67
99
  // 6. Determine prompt
68
- const prompt = getNextPrompt(state, task);
100
+ let prompt = getNextPrompt(state, task);
69
101
  if (!prompt) {
70
102
  return { success: false, error: 'No prompt available' };
71
103
  }
72
104
 
73
105
  // 7. Determine which session to resume
74
- // First turn: resume parent session for context
75
- // 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
- }
106
+ // Turn 1: resume parent session so Claude has full conversation context
107
+ // Turn 2+: resume child session from previous turn for continuity
108
+ // Safety: relay guarantees parent session is idle when this task was created (mobile Send
109
+ // flow only fires when the user is waiting for a response). Claude Code branches on --resume
110
+ // so the parent transcript is never mutated — only the child session ID is written back.
111
+ const validParentSessionId = UUID_PATTERN.test(task.parent_claude_session_id || '')
112
+ ? task.parent_claude_session_id
113
+ : null;
114
+ const resumeSessionId = state.claude_session_id // child session from previous turns
115
+ || validParentSessionId // parent session for turn 1 context
116
+ || null; // fallback: fresh session (tests / legacy tasks without relay validation)
82
117
 
83
118
  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}`);
119
+ if (state.claude_session_id) {
120
+ console.log(`[task-v2] Resuming child session: ${state.claude_session_id}`);
121
+ } else if (validParentSessionId) {
122
+ console.log(`[task-v2] Turn 1: resuming parent session for context: ${validParentSessionId}`);
123
+ } else {
124
+ if (task.parent_claude_session_id && !validParentSessionId) {
125
+ console.warn(`[task-v2] parent_claude_session_id is not a valid UUID, starting fresh: ${task.parent_claude_session_id}`);
126
+ }
127
+ // Fresh session: inject caller context so Claude is never completely blind
128
+ const contextLines = [];
129
+ if (task.caller) contextLines.push(`Triggered by: ${task.caller}`);
130
+ if (task.cwd) contextLines.push(`Working directory: ${task.cwd}`);
131
+ if (task.project_name) contextLines.push(`Project: ${task.project_name}`);
132
+ if (task.branch) contextLines.push(`Branch: ${task.branch}`);
133
+ if (task.hostname) contextLines.push(`Host: ${task.hostname}`);
134
+
135
+ if (contextLines.length > 0) {
136
+ const preamble = `[Context]\n${contextLines.join('\n')}\n\n`;
137
+ prompt = preamble + prompt;
138
+ console.log(`[task-v2] Starting fresh session with context preamble (${contextLines.length} fields)`);
139
+ } else {
140
+ console.log(`[task-v2] Starting fresh session (no context metadata available)`);
141
+ }
142
+ }
85
143
  console.log(`[task-v2] Prompt: ${prompt.slice(0, 100)}...`);
86
144
 
145
+ // 7.5 Check remaining budget before executing
146
+ const remainingBudget = (task.budget_usd || 0) - (task.cost_usd || 0);
147
+ if (remainingBudget <= 0) {
148
+ console.log(`[task-v2] Budget exhausted: $${task.cost_usd} of $${task.budget_usd} used`);
149
+ await updateTaskStatus(task_id, session_id, 'stopped', config);
150
+ return { success: true, stopped: true, reason: 'Budget exhausted' };
151
+ }
152
+
87
153
  // 8. Execute Claude headless
88
154
  try {
89
155
  const result = await executeClaudeHeadless({
90
156
  prompt,
91
157
  cwd: task.cwd || process.cwd(),
92
158
  resumeSessionId,
93
- budgetUsd: task.budget_usd - task.cost_usd,
159
+ budgetUsd: remainingBudget,
94
160
  taskId: task_id,
95
161
  parentSessionId: task.session_id,
162
+ bypassPermissions: task.bypass_permissions,
96
163
  });
97
164
 
98
165
  // 9. Ingest transcript to timeline
99
166
  // This ensures all events (especially assistant responses) are captured
100
167
  // even if hooks failed to log them during execution
168
+ let transcriptIngested = false;
101
169
  if (result.session_id) {
102
170
  try {
103
- await ingestTranscriptToTimeline({
171
+ const ingestionResult = await ingestTranscriptToTimeline({
104
172
  claude_session_id: result.session_id,
105
173
  parent_session_id: session_id,
106
174
  task_id: task_id,
107
175
  cwd: task.cwd || process.cwd(), // Pass cwd for project slug derivation
108
176
  config,
109
177
  });
178
+ // Only consider ingested if events were actually pushed to timeline
179
+ // When transcript isn't found (child session in different project dir),
180
+ // events_pushed will be 0 and we must fall back to manual POST
181
+ transcriptIngested = (ingestionResult?.events_pushed || 0) > 0;
182
+ if (!transcriptIngested) {
183
+ console.log(`[task-v2] Transcript ingestion returned 0 events — will use fallback POST`);
184
+ }
110
185
  } catch (error) {
111
186
  // Don't fail the whole turn if transcript ingestion fails
112
187
  console.error(`[task-v2] Failed to ingest transcript:`, error.message);
@@ -116,6 +191,66 @@ export async function executeTaskTurn(options) {
116
191
  // 10. Update task in Redis with results
117
192
  await updateTaskAfterTurn(task_id, session_id, result, task, config);
118
193
 
194
+ // 11. Log task turn completion to timeline so analyzeTaskState() can track progress
195
+ // Only emit this fallback marker if transcript ingestion didn't already emit events.
196
+ // Both paths produce assistant_response events with task_id, so emitting both
197
+ // would double-count turns in analyzeTaskState().
198
+ if (!transcriptIngested) {
199
+ try {
200
+ const resp = await fetch(`${relayApiUrl}/api/timeline`, {
201
+ method: 'POST',
202
+ headers: {
203
+ 'Content-Type': 'application/json',
204
+ 'Authorization': `Bearer ${apiKey}`
205
+ },
206
+ body: JSON.stringify({
207
+ session_id,
208
+ type: 'task_update',
209
+ data: {
210
+ task_id,
211
+ source: 'cli_interactive',
212
+ claude_session_id: result.session_id,
213
+ turn_number: state.turn_count + 1,
214
+ status: 'turn_complete',
215
+ cost_usd: result.cost_usd,
216
+ timestamp: Date.now(),
217
+ message: extractAssistantText(result.output) || 'Turn completed',
218
+ }
219
+ })
220
+ });
221
+ if (!resp.ok) {
222
+ console.error(`[task-v2] Turn completion POST failed: ${resp.status}`);
223
+ } else {
224
+ console.log(`[task-v2] Logged turn ${state.turn_count + 1} completion to timeline (fallback)`);
225
+ }
226
+ } catch (e) {
227
+ console.error(`[task-v2] Failed to log turn completion:`, e.message);
228
+ }
229
+ } else {
230
+ console.log(`[task-v2] Turn ${state.turn_count + 1} events already ingested via transcript`);
231
+ }
232
+
233
+ // 12. Auto-pause: unless auto_continue is set, pause after each turn
234
+ // This is the default behavior — user must send a message to continue
235
+ // CRITICAL: If this fails, the task stays 'running' and re-executes on the next
236
+ // poll cycle, causing the same infinite loop this PR fixes. Retry once on failure.
237
+ if (!task.auto_continue) {
238
+ console.log(`[task-v2] Pausing task after turn ${state.turn_count + 1} (auto_continue not set)`);
239
+ try {
240
+ await updateTaskStatus(task_id, session_id, 'paused', config);
241
+ } catch (pauseError) {
242
+ console.error(`[task-v2] Failed to pause task (retrying once): ${pauseError.message}`);
243
+ try {
244
+ await new Promise(r => setTimeout(r, 1000));
245
+ await updateTaskStatus(task_id, session_id, 'paused', config);
246
+ } catch (retryError) {
247
+ console.error(`[task-v2] CRITICAL: Failed to pause task after retry: ${retryError.message}`);
248
+ // Return error so daemon doesn't re-execute this turn
249
+ return { success: false, error: 'Failed to pause task — cannot guarantee no re-execution' };
250
+ }
251
+ }
252
+ }
253
+
119
254
  return {
120
255
  success: true,
121
256
  executed: true,
@@ -187,11 +322,19 @@ async function updateTaskAfterTurn(task_id, session_id, result, currentTask, con
187
322
  const totalCost = (currentTask.cost_usd || 0) + (result.cost_usd || 0);
188
323
 
189
324
  const updates = {
190
- claude_session_id: result.session_id || currentTask.claude_session_id,
191
325
  cost_usd: totalCost,
192
326
  turn_count: (currentTask.turn_count || 0) + 1,
327
+ // Clear consumed prompts to prevent re-use on next turn
328
+ pending_answer: null,
329
+ pending_redirect: null,
193
330
  };
194
331
 
332
+ // Only include claude_session_id if we have a valid one (relay rejects null)
333
+ const sessionId = result.session_id || currentTask.claude_session_id;
334
+ if (sessionId) {
335
+ updates.claude_session_id = sessionId;
336
+ }
337
+
195
338
  const response = await fetch(
196
339
  `${relayApiUrl}/api/sessions/${session_id}/tasks/${task_id}`,
197
340
  {
@@ -205,7 +348,8 @@ async function updateTaskAfterTurn(task_id, session_id, result, currentTask, con
205
348
  );
206
349
 
207
350
  if (!response.ok) {
208
- throw new Error(`Failed to update task: ${response.status}`);
351
+ const body = await response.text().catch(() => '');
352
+ throw new Error(`Failed to update task: ${response.status} ${body}`);
209
353
  }
210
354
 
211
355
  return await response.json();
@@ -241,6 +385,13 @@ async function executeClaudeHeadless(options) {
241
385
  args.push('--output-format', 'stream-json');
242
386
  args.push('--verbose');
243
387
 
388
+ // Permission mode: configurable per-task or via config
389
+ // Default: no override (uses hooks → approvals show on mobile UI)
390
+ // User can set bypass to skip approvals for trusted tasks
391
+ if (options.bypassPermissions) {
392
+ args.push('--permission-mode', 'bypassPermissions');
393
+ }
394
+
244
395
  // Budget control
245
396
  if (budgetUsd > 0) {
246
397
  args.push('--max-budget-usd', budgetUsd.toFixed(2));
@@ -248,21 +399,25 @@ async function executeClaudeHeadless(options) {
248
399
 
249
400
  console.log(`[task-v2] Executing: ${CLAUDE_CLI} ${args.join(' ')}`);
250
401
 
402
+ // Build child env: unset CLAUDECODE to allow nested Claude launches
403
+ const childEnv = {
404
+ ...process.env,
405
+ CI: 'true',
406
+ TELEPORTATION_TASK_MODE: 'true',
407
+ // Route approvals to parent session timeline
408
+ ...(parentSessionId && { TELEPORTATION_PARENT_SESSION_ID: parentSessionId }),
409
+ };
410
+ delete childEnv.CLAUDECODE;
411
+
251
412
  const proc = spawn(CLAUDE_CLI, args, {
252
413
  cwd,
253
414
  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
- },
415
+ env: childEnv,
261
416
  });
262
417
 
263
- // Track process for stop functionality
418
+ // Track process for stop functionality (includes session_id for per-session filtering)
264
419
  if (taskId) {
265
- runningProcesses.set(taskId, proc);
420
+ runningProcesses.set(taskId, { proc, session_id: parentSessionId });
266
421
  }
267
422
 
268
423
  // Close stdin
@@ -279,6 +434,7 @@ async function executeClaudeHeadless(options) {
279
434
  exit_code: 0,
280
435
  session_id: null,
281
436
  cost_usd: 0,
437
+ _costExplicitlySet: false, // Track if total_cost_usd was found
282
438
  usage: {
283
439
  input_tokens: 0,
284
440
  output_tokens: 0,
@@ -311,8 +467,18 @@ async function executeClaudeHeadless(options) {
311
467
  }
312
468
 
313
469
  // Extract cost and usage
470
+ // stream-json 'result' event has total_cost_usd at top level
471
+ // e.g. {"type":"result","total_cost_usd":0.72,"usage":{...}}
472
+ if (event.total_cost_usd != null) {
473
+ result.cost_usd = event.total_cost_usd;
474
+ result._costExplicitlySet = true;
475
+ }
314
476
  if (event.usage) {
315
- result.cost_usd = event.usage.total_cost || 0;
477
+ // Fallback: older format had usage.total_cost
478
+ // Only use fallback if total_cost_usd was never found (not just zero)
479
+ if (!result._costExplicitlySet && event.usage.total_cost) {
480
+ result.cost_usd = event.usage.total_cost;
481
+ }
316
482
  result.usage = event.usage;
317
483
  }
318
484
  } catch (e) {
@@ -337,6 +503,10 @@ async function executeClaudeHeadless(options) {
337
503
  result.error = stderr;
338
504
  result.success = code === 0;
339
505
 
506
+ if (!result._costExplicitlySet && result.cost_usd === 0 && code === 0) {
507
+ console.warn(`[task-v2] No cost data found in stream-json output`);
508
+ }
509
+
340
510
  resolve(result);
341
511
  });
342
512
 
@@ -356,19 +526,57 @@ async function executeClaudeHeadless(options) {
356
526
  * Stop a running task
357
527
  */
358
528
  export function stopTask(task_id) {
359
- const proc = runningProcesses.get(task_id);
360
- if (proc) {
529
+ const entry = runningProcesses.get(task_id);
530
+ if (entry) {
531
+ // All entries are {proc, session_id} objects (set in executeClaudeHeadless)
532
+ const proc = entry.proc;
533
+ // If process already exited, no need to kill or set timer
534
+ if (proc.exitCode !== null) {
535
+ runningProcesses.delete(task_id);
536
+ return true;
537
+ }
361
538
  proc.kill('SIGTERM');
362
- setTimeout(() => {
539
+ const killTimer = setTimeout(() => {
363
540
  if (runningProcesses.has(task_id)) {
364
- proc.kill('SIGKILL');
541
+ try { proc.kill('SIGKILL'); } catch {}
365
542
  }
366
543
  }, 2000);
544
+ proc.once('exit', () => clearTimeout(killTimer));
367
545
  return true;
368
546
  }
369
547
  return false;
370
548
  }
371
549
 
550
+ /**
551
+ * Stop all running tasks for a specific session
552
+ * Returns number of processes killed
553
+ */
554
+ export function stopTasksForSession(session_id) {
555
+ let killed = 0;
556
+ for (const [task_id, entry] of runningProcesses) {
557
+ if (entry.session_id !== session_id) continue;
558
+ const proc = entry.proc;
559
+ if (proc.exitCode !== null) {
560
+ runningProcesses.delete(task_id);
561
+ continue;
562
+ }
563
+ try {
564
+ proc.kill('SIGTERM');
565
+ const killTimer = setTimeout(() => {
566
+ try { proc.kill('SIGKILL'); } catch {}
567
+ }, 2000);
568
+ proc.once('exit', () => {
569
+ clearTimeout(killTimer);
570
+ runningProcesses.delete(task_id);
571
+ });
572
+ killed++;
573
+ } catch (err) {
574
+ // Process may already be dead
575
+ }
576
+ }
577
+ return killed;
578
+ }
579
+
372
580
  /**
373
581
  * Stop all running tasks
374
582
  * Used during daemon shutdown to ensure clean exit
@@ -376,8 +584,10 @@ export function stopTask(task_id) {
376
584
  export function stopAllTasks() {
377
585
  console.log(`[task-v2] Stopping all tasks (${runningProcesses.size} processes active)`);
378
586
 
379
- for (const [task_id, proc] of runningProcesses) {
587
+ for (const [task_id, entry] of runningProcesses) {
380
588
  try {
589
+ // All entries are {proc, session_id} objects (set in executeClaudeHeadless)
590
+ const proc = entry.proc;
381
591
  let killTimer = null;
382
592
 
383
593
  // Setup exit handler to clear timer