teleportation-cli 1.1.4 → 1.2.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.
Files changed (48) hide show
  1. package/.claude/hooks/config-loader.mjs +88 -34
  2. package/.claude/hooks/permission_request.mjs +392 -82
  3. package/.claude/hooks/post_tool_use.mjs +90 -0
  4. package/.claude/hooks/pre_tool_use.mjs +247 -305
  5. package/.claude/hooks/session-register.mjs +94 -105
  6. package/.claude/hooks/session_end.mjs +41 -42
  7. package/.claude/hooks/session_start.mjs +45 -60
  8. package/.claude/hooks/stop.mjs +752 -99
  9. package/.claude/hooks/user_prompt_submit.mjs +26 -3
  10. package/README.md +7 -0
  11. package/lib/auth/api-key.js +12 -0
  12. package/lib/auth/token-refresh.js +286 -0
  13. package/lib/cli/daemon-commands.js +1 -1
  14. package/lib/cli/teleport-commands.js +469 -0
  15. package/lib/daemon/daemon-v2.js +104 -0
  16. package/lib/daemon/lifecycle.js +56 -171
  17. package/lib/daemon/response-classifier.js +15 -1
  18. package/lib/daemon/services/index.js +3 -0
  19. package/lib/daemon/services/polling-service.js +173 -0
  20. package/lib/daemon/services/queue-service.js +318 -0
  21. package/lib/daemon/services/session-service.js +115 -0
  22. package/lib/daemon/state.js +35 -0
  23. package/lib/daemon/task-executor-v2.js +413 -0
  24. package/lib/daemon/task-executor.js +1235 -0
  25. package/lib/daemon/teleportation-daemon.js +770 -25
  26. package/lib/daemon/timeline-analyzer.js +215 -0
  27. package/lib/daemon/transcript-ingestion.js +696 -0
  28. package/lib/daemon/utils.js +91 -0
  29. package/lib/install/installer.js +184 -20
  30. package/lib/install/uhr-installer.js +136 -0
  31. package/lib/remote/providers/base-provider.js +46 -0
  32. package/lib/remote/providers/daytona-provider.js +58 -0
  33. package/lib/remote/providers/provider-factory.js +90 -19
  34. package/lib/remote/providers/sprites-provider.js +711 -0
  35. package/lib/teleport/exporters/claude-exporter.js +302 -0
  36. package/lib/teleport/exporters/gemini-exporter.js +307 -0
  37. package/lib/teleport/exporters/index.js +93 -0
  38. package/lib/teleport/exporters/interface.js +153 -0
  39. package/lib/teleport/fork-tracker.js +415 -0
  40. package/lib/teleport/git-committer.js +337 -0
  41. package/lib/teleport/index.js +48 -0
  42. package/lib/teleport/manager.js +620 -0
  43. package/lib/teleport/session-capture.js +282 -0
  44. package/package.json +11 -5
  45. package/teleportation-cli.cjs +632 -451
  46. package/.claude/hooks/heartbeat.mjs +0 -396
  47. package/lib/daemon/agentic-executor.js +0 -803
  48. package/lib/daemon/pid-manager.js +0 -160
@@ -0,0 +1,1235 @@
1
+ /**
2
+ * Task Executor
3
+ *
4
+ * Executes task tasks using Claude Code's headless mode.
5
+ * Provides an autonomous execution loop that continues until:
6
+ * - Task is complete
7
+ * - Claude asks a question requiring user input
8
+ * - User stops the task
9
+ * - Budget is exhausted
10
+ *
11
+ * Key features:
12
+ * - Uses `claude -p` with `--output-format json` for structured results
13
+ * - Captures `session_id` from JSON result for conversation continuity
14
+ * - Uses `--resume <session-id>` for subsequent turns
15
+ * - Budget control via `--max-budget-usd`
16
+ * - Hooks fire normally (approvals go to mobile app)
17
+ *
18
+ * @module lib/daemon/task-executor
19
+ */
20
+
21
+ import { spawn } from 'child_process';
22
+ import { randomUUID } from 'crypto';
23
+ import fs from 'fs';
24
+ import path from 'path';
25
+ import os from 'os';
26
+ import { classifyResponse, getConfidenceThreshold } from './response-classifier.js';
27
+
28
+ // Paths
29
+ const TELEPORTATION_DIR = path.join(os.homedir(), '.teleportation');
30
+ const TASKS_LOCK_DIR = path.join(TELEPORTATION_DIR, 'task_locks');
31
+
32
+ // Ensure lock directory exists with strict permissions (owner only)
33
+ if (!fs.existsSync(TASKS_LOCK_DIR)) {
34
+ fs.mkdirSync(TASKS_LOCK_DIR, { recursive: true, mode: 0o700 });
35
+ } else {
36
+ // Ensure permissions are correct if it already exists
37
+ try { fs.chmodSync(TASKS_LOCK_DIR, 0o700); } catch (e) {}
38
+ }
39
+
40
+ // Default configuration
41
+ const DEFAULT_BUDGET_USD = 10.0;
42
+ const DEFAULT_TIMEOUT_MS = 600000; // 10 minutes per turn
43
+ const CLAUDE_CLI = process.env.CLAUDE_CLI_PATH || 'claude';
44
+ const SIGTERM_TIMEOUT_MS = 2000; // 2 seconds before SIGKILL
45
+
46
+ // Safety limits to prevent runaway execution
47
+ const MAX_TURNS = 100; // Maximum execution turns before forced stop
48
+ const WAIT_FOR_RESUME_TIMEOUT_MS = 1800000; // 30 minutes max wait for resume
49
+ const POLLING_INTERVAL_MS = 1000; // Polling interval for waitForResume
50
+ const SESSION_CLEANUP_INTERVAL_MS = 300000; // 5 minutes cleanup interval
51
+ const SESSION_MAX_AGE_MS = 3600000; // 1 hour max session age
52
+ const MAX_CONSECUTIVE_FAILURES = 3; // Stop after this many consecutive failures
53
+ const MAX_HISTORY_SIZE = 20; // Maximum history entries to keep per session
54
+
55
+ /**
56
+ * Helper: Remove task lock file safely
57
+ * @param {string} taskId
58
+ */
59
+ export function removeTaskLock(taskId) {
60
+ const lockPath = path.join(TASKS_LOCK_DIR, `${taskId}.pid`);
61
+ try {
62
+ fs.unlinkSync(lockPath);
63
+ } catch (err) {
64
+ if (err.code !== 'ENOENT') {
65
+ console.debug(`[task] Non-fatal: Failed to remove lock for ${taskId}:`, err.message);
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Task session state
72
+ * @typedef {Object} TaskSession
73
+ * @property {string} id - Unique session ID
74
+ * @property {string} task - Original task description
75
+ * @property {'running' | 'paused' | 'waiting_input' | 'completed' | 'stopped' | 'budget_paused'} status
76
+ * @property {string|null} claude_session_id - Claude Code's session ID for resume
77
+ * @property {boolean} auto_continue - Whether to automatically continue after each turn
78
+ * @property {number} budget_usd - Total budget allocated
79
+ * @property {number} cost_usd - Total cost incurred so far
80
+ * @property {number} started_at - Timestamp when task started
81
+ * @property {number} updated_at - Timestamp of last update
82
+ * @property {number|null} completed_at - Timestamp when task completed
83
+ * @property {string|null} pending_question - Question waiting for user answer
84
+ * @property {number} turn_count - Number of execution turns
85
+ * @property {Array} history - Execution history
86
+ */
87
+
88
+ // In-memory session store (will be replaced with Redis in relay endpoints)
89
+ const taskSessions = new Map();
90
+
91
+ // Track running processes for stop functionality
92
+ const runningProcesses = new Map();
93
+
94
+ /**
95
+ * Cleanup orphaned process entries on startup
96
+ * Removes Map entries for processes that are no longer running
97
+ */
98
+ export function cleanupOrphanedProcesses() {
99
+ let cleanedCount = 0;
100
+ for (const [id, proc] of runningProcesses) {
101
+ if (proc.pid && !isProcessRunning(proc.pid)) {
102
+ runningProcesses.delete(id);
103
+ cleanedCount++;
104
+ }
105
+ }
106
+ if (cleanedCount > 0) {
107
+ console.log(`[task] Cleaned up ${cleanedCount} orphaned process entries`);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Check if a process is running
113
+ * @param {number} pid
114
+ * @returns {boolean}
115
+ */
116
+ function isProcessRunning(pid) {
117
+ try {
118
+ process.kill(pid, 0);
119
+ return true;
120
+ } catch (e) {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Cleanup old/stale task locks on startup
127
+ * Removes lock files for processes that are no longer running
128
+ */
129
+ export function cleanupStaleLocks() {
130
+ if (!fs.existsSync(TASKS_LOCK_DIR)) return;
131
+
132
+ try {
133
+ const files = fs.readdirSync(TASKS_LOCK_DIR);
134
+ let cleanedCount = 0;
135
+
136
+ for (const file of files) {
137
+ if (!file.endsWith('.pid')) continue;
138
+
139
+ const lockPath = path.join(TASKS_LOCK_DIR, file);
140
+ try {
141
+ const lockContent = fs.readFileSync(lockPath, 'utf8');
142
+ const { pid } = JSON.parse(lockContent);
143
+
144
+ // Check if process exists
145
+ process.kill(pid, 0);
146
+ } catch (e) {
147
+ // Process is dead, stale lock, or invalid JSON
148
+ // Only clean up if it's actually dead (ESRCH) or corrupted (SyntaxError)
149
+ if (e.code === 'ESRCH' || e instanceof SyntaxError || e.name === 'SyntaxError') {
150
+ try {
151
+ fs.unlinkSync(lockPath);
152
+ cleanedCount++;
153
+ } catch (unlinkErr) {}
154
+ }
155
+ }
156
+ }
157
+
158
+ if (cleanedCount > 0) {
159
+ console.log(`[task] Cleaned up ${cleanedCount} stale task lock(s)`);
160
+ }
161
+ } catch (err) {
162
+ console.error('[task] Failed to cleanup stale locks:', err.message);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Cleanup old/stale sessions to prevent memory leaks
168
+ * Removes sessions that:
169
+ * - Are completed/stopped and older than SESSION_MAX_AGE_MS
170
+ * - Have no activity for SESSION_MAX_AGE_MS
171
+ */
172
+ function cleanupStaleSessions() {
173
+ const now = Date.now();
174
+ let cleanedCount = 0;
175
+
176
+ for (const [id, session] of taskSessions) {
177
+ const age = now - session.started_at;
178
+ const lastActivity = now - session.updated_at;
179
+
180
+ // Remove finished sessions older than max age
181
+ const isFinished = session.status === 'completed' || session.status === 'stopped';
182
+ if (isFinished && age > SESSION_MAX_AGE_MS) {
183
+ taskSessions.delete(id);
184
+ runningProcesses.delete(id);
185
+ cleanedCount++;
186
+ continue;
187
+ }
188
+
189
+ // Remove sessions with no activity for max age
190
+ if (lastActivity > SESSION_MAX_AGE_MS) {
191
+ // Kill any running process
192
+ const proc = runningProcesses.get(id);
193
+ if (proc) {
194
+ proc.kill('SIGTERM');
195
+ runningProcesses.delete(id);
196
+ }
197
+ taskSessions.delete(id);
198
+ cleanedCount++;
199
+ }
200
+ }
201
+
202
+ if (cleanedCount > 0) {
203
+ console.log(`[task] Cleaned up ${cleanedCount} stale sessions`);
204
+ }
205
+ }
206
+
207
+ // Start periodic cleanup (only in non-test environments)
208
+ if (process.env.NODE_ENV !== 'test') {
209
+ setInterval(cleanupStaleSessions, SESSION_CLEANUP_INTERVAL_MS);
210
+ }
211
+
212
+ /**
213
+ * Generate a unique task session ID using cryptographically secure random UUID
214
+ * @returns {string}
215
+ */
216
+ function generateTaskId() {
217
+ return `task-${Date.now()}-${randomUUID()}`;
218
+ }
219
+
220
+ /**
221
+ * Execute Claude Code in headless mode
222
+ *
223
+ * @param {Object} options
224
+ * @param {string} options.prompt - The prompt to send to Claude
225
+ * @param {string} options.cwd - Working directory
226
+ * @param {string|null} options.resumeSessionId - Claude session ID to resume (null for new)
227
+ * @param {number} options.budgetUsd - Max budget in USD
228
+ * @param {number} options.timeoutMs - Timeout in milliseconds
229
+ * @param {string} options.taskId - Task session ID for tracking
230
+ * @param {Function} [options.onUpdate] - Optional callback for streaming updates
231
+ * @returns {Promise<Object>} Execution result with session_id, output, cost, etc.
232
+ */
233
+ async function executeClaudeHeadless(options) {
234
+ const {
235
+ prompt,
236
+ cwd,
237
+ resumeSessionId,
238
+ budgetUsd,
239
+ timeoutMs = DEFAULT_TIMEOUT_MS,
240
+ taskId,
241
+ parentSessionId,
242
+ onUpdate,
243
+ } = options;
244
+
245
+ return new Promise((resolve, reject) => {
246
+ const args = [];
247
+
248
+ // Resume existing session or start new
249
+ if (resumeSessionId) {
250
+ args.push('--resume', resumeSessionId);
251
+ }
252
+
253
+ // Add prompt
254
+ args.push('-p', prompt);
255
+
256
+ // Output format for structured parsing - using stream-json for real-time metadata
257
+ args.push('--output-format', 'stream-json');
258
+ args.push('--verbose');
259
+
260
+ // Budget control (Claude Code native feature)
261
+ if (budgetUsd > 0) {
262
+ args.push('--max-budget-usd', budgetUsd.toFixed(2));
263
+ }
264
+
265
+ // Hooks enabled: approvals route to mobile app
266
+ // Session must be in "away" mode for hooks to wait for remote approval
267
+ // (Daemon sets away mode before calling startTask)
268
+
269
+ console.log(`[task] Executing: ${CLAUDE_CLI} ${args.join(' ')}`);
270
+
271
+ const proc = spawn(CLAUDE_CLI, args, {
272
+ cwd,
273
+ stdio: 'pipe',
274
+ env: {
275
+ ...process.env,
276
+ CI: 'true', // Non-interactive mode
277
+ TELEPORTATION_TASK_MODE: 'true',
278
+ // Pass parent session ID so hooks can log approvals to correct timeline
279
+ ...(parentSessionId && { TELEPORTATION_PARENT_SESSION_ID: parentSessionId }),
280
+ },
281
+ });
282
+
283
+ // Track process for stop functionality
284
+ runningProcesses.set(taskId, proc);
285
+
286
+ // Close stdin to prevent hanging
287
+ proc.stdin.end();
288
+
289
+ let stdout = '';
290
+ let stderr = '';
291
+ let timedOut = false;
292
+ let buffer = '';
293
+
294
+ // Final result structure
295
+ let result = {
296
+ success: false,
297
+ output: '',
298
+ error: null,
299
+ exit_code: 0,
300
+ session_id: null,
301
+ cost_usd: 0,
302
+ duration_ms: 0,
303
+ model: null,
304
+ usage: {
305
+ tools_used: 0,
306
+ input_tokens: 0,
307
+ output_tokens: 0,
308
+ cache_creation_input_tokens: 0,
309
+ cache_read_input_tokens: 0
310
+ },
311
+ tool_calls: [],
312
+ };
313
+
314
+ // Timeout handler
315
+ const timeout = setTimeout(() => {
316
+ timedOut = true;
317
+ proc.kill('SIGTERM');
318
+ }, timeoutMs);
319
+
320
+ proc.stdout.on('data', (data) => {
321
+ const chunk = data.toString();
322
+ stdout += chunk;
323
+ buffer += chunk;
324
+
325
+ // Process stream-json lines
326
+ const lines = buffer.split('\n');
327
+ buffer = lines.pop(); // Keep last incomplete line
328
+
329
+ for (const line of lines) {
330
+ if (!line.trim()) continue;
331
+ try {
332
+ // Debug: console.debug(`[stream] Line: ${line.slice(0, 100)}`);
333
+ const msg = JSON.parse(line);
334
+
335
+ // Accumulate metadata from stream
336
+ if (msg.type === 'system' && msg.subtype === 'init' && msg.session_id) {
337
+ result.session_id = msg.session_id;
338
+ }
339
+
340
+ if (msg.type === 'assistant' && msg.message?.content) {
341
+ const content = msg.message.content;
342
+ for (const block of content) {
343
+ if (block.type === 'text') {
344
+ result.output += block.text;
345
+ if (onUpdate) onUpdate({ type: 'text', text: block.text });
346
+ } else if (block.type === 'tool_use') {
347
+ result.usage.tools_used++;
348
+ result.tool_calls.push(block);
349
+ if (onUpdate) onUpdate({ type: 'tool_use', tool: block.name });
350
+ }
351
+ }
352
+ }
353
+
354
+ if (msg.type === 'usage' || (msg.type === 'assistant' && msg.message?.usage)) {
355
+ const usage = msg.usage || msg.message.usage;
356
+ result.usage.input_tokens += usage.input_tokens || 0;
357
+ result.usage.output_tokens += usage.output_tokens || 0;
358
+ result.usage.cache_creation_input_tokens += usage.cache_creation_input_tokens || 0;
359
+ result.usage.cache_read_input_tokens += usage.cache_read_input_tokens || 0;
360
+ }
361
+
362
+ if (msg.type === 'result') {
363
+ result.success = !msg.is_error;
364
+ if (msg.is_error) result.error = msg.error;
365
+ if (msg.total_cost_usd) result.cost_usd = msg.total_cost_usd;
366
+ }
367
+ } catch (e) {
368
+ // Non-JSON line, ignore or log
369
+ }
370
+ }
371
+ });
372
+
373
+ proc.stderr.on('data', (data) => {
374
+ stderr += data.toString();
375
+ });
376
+
377
+ proc.on('close', (code) => {
378
+ clearTimeout(timeout);
379
+ runningProcesses.delete(taskId);
380
+
381
+ result.exit_code = code;
382
+ result.duration_ms = Date.now() - (result.start_time || Date.now());
383
+
384
+ if (timedOut) {
385
+ result.success = false;
386
+ result.error = 'Execution timed out';
387
+ result.exit_code = -1;
388
+ } else if (code !== 0 && !result.error) {
389
+ result.success = false;
390
+ result.error = stderr || `Exit code: ${code}`;
391
+ } else if (code === 0) {
392
+ result.success = true;
393
+ }
394
+
395
+ // If output is still empty but we have stdout that wasn't stream-json,
396
+ // fall back to raw stdout
397
+ if (!result.output && stdout) {
398
+ try {
399
+ const parsed = JSON.parse(stdout);
400
+ result.output = parsed.result || parsed.response || parsed.output || stdout;
401
+ result.session_id = result.session_id || parsed.session_id;
402
+ result.cost_usd = result.cost_usd || parsed.cost || 0;
403
+ } catch {
404
+ result.output = stdout;
405
+ }
406
+ }
407
+
408
+ resolve(result);
409
+ });
410
+
411
+ proc.on('error', (err) => {
412
+ clearTimeout(timeout);
413
+ runningProcesses.delete(taskId);
414
+ reject(err);
415
+ });
416
+ });
417
+ }
418
+
419
+ /**
420
+ * Start a new task task
421
+ *
422
+ * @param {Object} options
423
+ * @param {string} options.task - Task description
424
+ * @param {string} options.session_id - Teleportation session ID
425
+ * @param {string} options.cwd - Working directory
426
+ * @param {number} [options.budget_usd] - Max budget in USD (default: 10)
427
+ * @param {boolean} [options.auto_continue=false] - Auto-continue after each turn (default: false). When disabled, task pauses after each turn and waits for user message.
428
+ * @param {Function} [options.onEvent] - Callback for task events
429
+ * @returns {Promise<TaskSession>}
430
+ */
431
+ export async function startTask(options) {
432
+ const {
433
+ task,
434
+ session_id,
435
+ cwd,
436
+ budget_usd = DEFAULT_BUDGET_USD,
437
+ parent_claude_session_id = null,
438
+ auto_continue: rawAutoContinue = false,
439
+ onEvent,
440
+ } = options;
441
+
442
+ // Validate auto_continue parameter - ensure it's a boolean
443
+ const auto_continue = Boolean(rawAutoContinue ?? false);
444
+
445
+ const taskId = options.task_id || generateTaskId();
446
+ const now = Date.now();
447
+ const lockPath = path.join(TASKS_LOCK_DIR, `${taskId}.pid`);
448
+
449
+ // Atomic lock acquisition using 'wx' (exclusive write)
450
+ // This prevents multiple daemon instances from claiming the same task simultaneously
451
+ let acquired = false;
452
+ let attempts = 0;
453
+
454
+ while (!acquired && attempts < 2) {
455
+ attempts++;
456
+ try {
457
+ const lockData = JSON.stringify({
458
+ pid: process.pid,
459
+ taskId,
460
+ startTime: now,
461
+ hostname: os.hostname()
462
+ });
463
+ fs.writeFileSync(lockPath, lockData, { flag: 'wx', mode: 0o600 });
464
+ acquired = true;
465
+ } catch (err) {
466
+ if (err.code === 'EEXIST') {
467
+ // Lock already exists - verify if it's stale or our own
468
+ try {
469
+ const lockContent = fs.readFileSync(lockPath, 'utf8');
470
+ const existing = JSON.parse(lockContent);
471
+
472
+ // 1. Self-recovery check
473
+ if (existing.pid === process.pid && existing.hostname === os.hostname()) {
474
+ console.log(`[task] Task ${taskId} already owned by this process, continuing.`);
475
+ acquired = true;
476
+ break;
477
+ } else if (existing.hostname === os.hostname()) {
478
+ // 2. Same-machine staleness check
479
+ process.kill(existing.pid, 0); // Throws if process is dead
480
+ console.log(`[task] Task ${taskId} is already being handled by process ${existing.pid} (started ${new Date(existing.startTime).toISOString()}), skipping start.`);
481
+ return { id: taskId, status: 'already_running' };
482
+ } else {
483
+ // 3. Different machine - assume task is alive if lock is relatively fresh (< 1 hour)
484
+ const lockAge = now - existing.startTime;
485
+ if (lockAge < 3600000) {
486
+ console.log(`[task] Task ${taskId} is owned by another host (${existing.hostname}), skipping start.`);
487
+ return { id: taskId, status: 'already_running' };
488
+ }
489
+ // Lock is old - potentially stale cross-host, remove and retry
490
+ console.log(`[task] Stale cross-host lock found for task ${taskId}, cleaning up and retrying acquisition.`);
491
+ removeTaskLock(taskId);
492
+ // Allow while loop to retry acquisition immediately
493
+ continue;
494
+ }
495
+ } catch (e) {
496
+ // Process dead or invalid JSON - lock is stale
497
+ console.log(`[task] Stale lock found for task ${taskId}, cleaning up and retrying acquisition.`);
498
+ removeTaskLock(taskId);
499
+ // Allow while loop to retry acquisition immediately
500
+ continue;
501
+ }
502
+ } else {
503
+ throw err;
504
+ }
505
+ }
506
+ }
507
+
508
+ if (!acquired) {
509
+ console.log(`[task] Failed to acquire lock for ${taskId} after retrying stale cleanup.`);
510
+ return { id: taskId, status: 'already_running' };
511
+ }
512
+
513
+ // Create task session
514
+ const session = {
515
+ id: taskId,
516
+ teleportation_session_id: session_id,
517
+ task,
518
+ status: 'running',
519
+ claude_session_id: null, // Will be set to child session on first execution
520
+ parent_claude_session_id, // Parent session to resume for context
521
+ auto_continue, // Whether to automatically continue or wait for user messages
522
+ budget_usd,
523
+ cost_usd: 0,
524
+ started_at: now,
525
+ updated_at: now,
526
+ completed_at: null,
527
+ pending_question: null,
528
+ turn_count: 0,
529
+ history: [],
530
+ cwd,
531
+ };
532
+
533
+ taskSessions.set(taskId, session);
534
+
535
+ // Emit started event
536
+ if (onEvent) {
537
+ onEvent({
538
+ type: 'task_started',
539
+ source: 'autonomous_task',
540
+ task_id: taskId,
541
+ session_id,
542
+ task,
543
+ budget_usd,
544
+ timestamp: now,
545
+ });
546
+ }
547
+
548
+ // Start execution loop - ensure we don't start the same task twice
549
+ // (Race condition protection at the executor level)
550
+ if (runningProcesses.has(taskId)) {
551
+ console.log(`[task] Task ${taskId} is already running in this process, skipping loop start`);
552
+ return session;
553
+ }
554
+
555
+ executeTaskLoop(taskId, onEvent).catch(err => {
556
+ console.error(`[task] Loop error for ${taskId}:`, err.message);
557
+ const s = taskSessions.get(taskId);
558
+ if (s) {
559
+ s.status = 'stopped';
560
+ s.updated_at = Date.now();
561
+ // Remove lock on error
562
+ removeTaskLock(taskId);
563
+
564
+ if (onEvent) {
565
+ onEvent({
566
+ type: 'task_stopped',
567
+ source: 'autonomous_task',
568
+ task_id: taskId,
569
+ reason: `Error: ${err.message}`,
570
+ timestamp: Date.now(),
571
+ });
572
+ }
573
+ }
574
+ });
575
+
576
+ return session;
577
+ }
578
+
579
+ /**
580
+ * Main task execution loop
581
+ * Continues until complete, question, stopped, or budget exhausted
582
+ *
583
+ * @param {string} taskId - Task session ID
584
+ * @param {Function} onEvent - Event callback
585
+ */
586
+ async function executeTaskLoop(taskId, onEvent) {
587
+ const session = taskSessions.get(taskId);
588
+ if (!session) {
589
+ throw new Error(`Task session not found: ${taskId}`);
590
+ }
591
+
592
+ let continueExecution = true;
593
+ let consecutiveFailures = 0;
594
+
595
+ while (continueExecution) {
596
+ // Check for stop signal
597
+ if (session.status === 'stopped') {
598
+ console.log(`[task] Session ${taskId} stopped by user`);
599
+ break;
600
+ }
601
+
602
+ // Safety: Check max turns limit to prevent infinite loops
603
+ if (session.turn_count >= MAX_TURNS) {
604
+ session.status = 'stopped';
605
+ session.updated_at = Date.now();
606
+ console.log(`[task] Session ${taskId} reached max turns limit (${MAX_TURNS})`);
607
+ if (onEvent) {
608
+ onEvent({
609
+ type: 'task_stopped',
610
+ source: 'autonomous_task',
611
+ task_id: taskId,
612
+ reason: `Max turns limit reached (${MAX_TURNS})`,
613
+ cost_usd: session.cost_usd,
614
+ turn_count: session.turn_count,
615
+ timestamp: Date.now(),
616
+ });
617
+ }
618
+ break;
619
+ }
620
+
621
+ // Check for pause
622
+ if (session.status === 'paused') {
623
+ console.log(`[task] Session ${taskId} paused, waiting for resume`);
624
+ try {
625
+ await waitForResume(taskId);
626
+ } catch (timeoutError) {
627
+ // Timeout occurred - session already marked as stopped in waitForResume
628
+ console.log(`[task] Session ${taskId} pause timeout: ${timeoutError.message}`);
629
+ if (onEvent) {
630
+ onEvent({
631
+ type: 'task_stopped',
632
+ source: 'autonomous_task',
633
+ task_id: taskId,
634
+ reason: 'Pause timeout - no resume received',
635
+ timestamp: Date.now(),
636
+ });
637
+ }
638
+ break;
639
+ }
640
+ continue;
641
+ }
642
+
643
+ // Check budget
644
+ const remainingBudget = session.budget_usd - session.cost_usd;
645
+ if (remainingBudget <= 0) {
646
+ session.status = 'budget_paused';
647
+ session.updated_at = Date.now();
648
+ console.log(`[task] Session ${taskId} budget exhausted`);
649
+ if (onEvent) {
650
+ onEvent({
651
+ type: 'task_budget_hit',
652
+ source: 'autonomous_task',
653
+ task_id: taskId,
654
+ cost_usd: session.cost_usd,
655
+ budget_usd: session.budget_usd,
656
+ timestamp: Date.now(),
657
+ });
658
+ }
659
+ break;
660
+ }
661
+
662
+ // Build prompt for this turn
663
+ let prompt;
664
+ if (session.turn_count === 0) {
665
+ // First turn: use the original task description
666
+ prompt = session.task;
667
+ } else if (session.auto_continue) {
668
+ // Auto-continuation enabled: use generic continuation
669
+ prompt = 'Continue working on the task.';
670
+ } else {
671
+ // Auto-continuation disabled: pause and wait for user message
672
+ session.status = 'paused';
673
+ session.updated_at = Date.now();
674
+ console.log(`[task] Turn ${session.turn_count + 1}: Auto-continue disabled, pausing for user input`);
675
+ if (onEvent) {
676
+ onEvent({
677
+ type: 'task_paused',
678
+ source: 'autonomous_task',
679
+ task_id: taskId,
680
+ reason: 'Waiting for user message (auto_continue disabled)',
681
+ turn_count: session.turn_count,
682
+ timestamp: Date.now(),
683
+ });
684
+ }
685
+ // Wait for resume with user message
686
+ try {
687
+ await waitForResume(taskId);
688
+ // Atomically get and clear pending_question to prevent race conditions
689
+ const userMessage = atomicGetAndClearMessage(taskId);
690
+ if (!userMessage) {
691
+ console.log(`[task] Resumed without user message, stopping task ${taskId}`);
692
+ session.status = 'stopped';
693
+ session.updated_at = Date.now();
694
+ break;
695
+ }
696
+ // Use the user's message as the prompt
697
+ prompt = userMessage;
698
+ } catch (timeoutError) {
699
+ console.log(`[task] Session ${taskId} pause timeout: ${timeoutError.message}`);
700
+ if (onEvent) {
701
+ onEvent({
702
+ type: 'task_stopped',
703
+ source: 'autonomous_task',
704
+ task_id: taskId,
705
+ reason: 'Pause timeout - no user message received',
706
+ timestamp: Date.now(),
707
+ });
708
+ }
709
+ break;
710
+ }
711
+ }
712
+
713
+ // Execute Claude
714
+ session.turn_count++;
715
+ console.log(`[task] Turn ${session.turn_count} for ${taskId}`);
716
+
717
+ // Re-check status immediately before expensive operation (race condition fix)
718
+ const currentSession = taskSessions.get(taskId);
719
+ if (!currentSession || currentSession.status === 'stopped') {
720
+ console.log(`[task] Session ${taskId} stopped before execution`);
721
+ break;
722
+ }
723
+
724
+ try {
725
+ // Determine which session to resume:
726
+ // - Turn 1: Resume parent session for context (if available)
727
+ // - Turn 2+: Resume child session from previous turn
728
+ const resumeSessionId = session.claude_session_id
729
+ ? session.claude_session_id // Use child session from previous turns
730
+ : session.parent_claude_session_id; // Use parent session on first turn
731
+
732
+ if (resumeSessionId && session.turn_count === 1) {
733
+ console.log(`[task] Turn 1: Resuming parent session ${resumeSessionId} for context`);
734
+ } else if (resumeSessionId) {
735
+ console.log(`[task] Turn ${session.turn_count}: Resuming child session ${resumeSessionId}`);
736
+ }
737
+
738
+ const result = await executeClaudeHeadless({
739
+ prompt,
740
+ cwd: session.cwd,
741
+ resumeSessionId,
742
+ budgetUsd: remainingBudget,
743
+ taskId,
744
+ parentSessionId: session.teleportation_session_id,
745
+ onUpdate: (update) => {
746
+ if (onEvent) {
747
+ onEvent({
748
+ type: 'task_progress',
749
+ source: 'autonomous_task',
750
+ task_id: taskId,
751
+ update,
752
+ timestamp: Date.now(),
753
+ });
754
+ }
755
+ }
756
+ });
757
+
758
+ // Update session with result
759
+ session.claude_session_id = result.session_id || session.claude_session_id;
760
+ session.cost_usd += result.cost_usd || 0;
761
+ session.updated_at = Date.now();
762
+
763
+ // Add to history
764
+ session.history.push({
765
+ turn: session.turn_count,
766
+ prompt,
767
+ output: result.output?.slice(0, 2000), // Truncate for storage
768
+ cost_usd: result.cost_usd,
769
+ success: result.success,
770
+ usage: result.usage,
771
+ timestamp: Date.now(),
772
+ });
773
+
774
+ // Trim history to prevent unbounded growth
775
+ if (session.history.length > MAX_HISTORY_SIZE) {
776
+ session.history = session.history.slice(-MAX_HISTORY_SIZE);
777
+ }
778
+
779
+ // Track consecutive failures for error recovery
780
+ if (!result.success) {
781
+ consecutiveFailures++;
782
+ console.error(`[task] Turn ${session.turn_count} failed (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}):`, result.error);
783
+
784
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
785
+ session.status = 'stopped';
786
+ session.updated_at = Date.now();
787
+ console.log(`[task] Session ${taskId} stopped due to ${MAX_CONSECUTIVE_FAILURES} consecutive failures`);
788
+ if (onEvent) {
789
+ onEvent({
790
+ type: 'task_stopped',
791
+ source: 'autonomous_task',
792
+ task_id: taskId,
793
+ reason: `${MAX_CONSECUTIVE_FAILURES} consecutive failures`,
794
+ cost_usd: session.cost_usd,
795
+ turn_count: session.turn_count,
796
+ timestamp: Date.now(),
797
+ });
798
+ }
799
+ break;
800
+ }
801
+ } else {
802
+ // Reset failure counter on success
803
+ consecutiveFailures = 0;
804
+ }
805
+
806
+ // Mic-Handover Logic (Parity with Local CLI)
807
+ // 1. If tools were called, we MUST continue automatically (Task loop)
808
+ // 2. If no tools were called, Claude is "handing the mic" to the user.
809
+
810
+ const hasToolsMetadata = result.usage?.tools_used !== undefined || Array.isArray(result.tool_calls);
811
+ const toolsCalled = result.usage?.tools_used > 0 || (Array.isArray(result.tool_calls) && result.tool_calls.length > 0);
812
+
813
+ // If we have tool metadata and tools were used, auto-continue.
814
+ // If we DON'T have metadata (e.g. basic coder), fall back to text classification.
815
+ if (hasToolsMetadata && toolsCalled && result.success) {
816
+ console.log(`[task] Tools executed in turn ${session.turn_count}. Auto-continuing...`);
817
+ // Continue working - loop will iterate automatically
818
+ continue;
819
+ }
820
+
821
+ // No tools called (or no metadata available) - Claude is done or has a question
822
+ console.log(`[task] ${hasToolsMetadata ? 'No tools executed' : 'No tool metadata available'}. Classifying response...`);
823
+ const classification = classifyResponse(result);
824
+ console.log(`[task] Classification: ${classification.action} (confidence: ${classification.confidence})`);
825
+
826
+ // Determine next action based on classification
827
+ switch (classification.action) {
828
+ case 'complete':
829
+ session.status = 'completed';
830
+ session.completed_at = Date.now();
831
+ session.updated_at = Date.now();
832
+ continueExecution = false;
833
+ console.log(`[task] Task completed for ${taskId}`);
834
+
835
+ // Remove lock file on completion
836
+ removeTaskLock(taskId);
837
+
838
+ if (onEvent) {
839
+ onEvent({
840
+ type: 'task_completed',
841
+ source: 'autonomous_task',
842
+ task_id: taskId,
843
+ output: result.output,
844
+ cost_usd: session.cost_usd,
845
+ turn_count: session.turn_count,
846
+ timestamp: Date.now(),
847
+ });
848
+ }
849
+ break;
850
+
851
+ case 'question':
852
+ session.status = 'waiting_input';
853
+ session.pending_question = classification.questionText;
854
+ session.updated_at = Date.now();
855
+ continueExecution = false;
856
+ console.log(`[task] Question detected for ${taskId}: ${classification.questionText?.slice(0, 100)}`);
857
+ if (onEvent) {
858
+ onEvent({
859
+ type: 'task_question',
860
+ source: 'autonomous_task',
861
+ task_id: taskId,
862
+ question: classification.questionText,
863
+ confidence: classification.confidence,
864
+ timestamp: Date.now(),
865
+ });
866
+ }
867
+ break;
868
+
869
+ case 'continue':
870
+ default:
871
+ // Continue working - loop will iterate
872
+ console.log(`[task] Continuing execution for ${taskId}`);
873
+ break;
874
+ }
875
+ } catch (error) {
876
+ console.error(`[task] Execution error for ${taskId}:`, error.message);
877
+ session.status = 'stopped';
878
+ session.updated_at = Date.now();
879
+ continueExecution = false;
880
+
881
+ // Remove lock file on error
882
+ removeTaskLock(taskId);
883
+
884
+ if (onEvent) {
885
+ onEvent({
886
+ type: 'task_stopped',
887
+ source: 'autonomous_task',
888
+ task_id: taskId,
889
+ reason: `Error: ${error.message}`,
890
+ timestamp: Date.now(),
891
+ });
892
+ }
893
+ }
894
+ }
895
+ }
896
+
897
+ /**
898
+ * Wait for an task session to be resumed
899
+ * @param {string} taskId
900
+ * @returns {Promise<void>}
901
+ */
902
+ /**
903
+ * Wait for an task session to be resumed with timeout protection
904
+ * @param {string} taskId
905
+ * @param {number} timeoutMs - Maximum wait time (default: 30 minutes)
906
+ * @returns {Promise<void>}
907
+ * @throws {Error} If timeout is reached
908
+ */
909
+ async function waitForResume(taskId, timeoutMs = WAIT_FOR_RESUME_TIMEOUT_MS) {
910
+ return new Promise((resolve, reject) => {
911
+ const startTime = Date.now();
912
+
913
+ const checkInterval = setInterval(() => {
914
+ const session = taskSessions.get(taskId);
915
+
916
+ // Check for timeout
917
+ if (Date.now() - startTime > timeoutMs) {
918
+ clearInterval(checkInterval);
919
+ console.log(`[task] Session ${taskId} resume timeout after ${timeoutMs}ms`);
920
+
921
+ // Mark session as stopped due to timeout
922
+ if (session) {
923
+ session.status = 'stopped';
924
+ session.updated_at = Date.now();
925
+
926
+ // Remove lock file on timeout
927
+ removeTaskLock(taskId);
928
+ }
929
+
930
+ reject(new Error(`Resume timeout after ${timeoutMs}ms`));
931
+ return;
932
+ }
933
+
934
+ // Check if session no longer paused
935
+ if (!session || session.status !== 'paused') {
936
+ clearInterval(checkInterval);
937
+ resolve();
938
+ }
939
+ }, POLLING_INTERVAL_MS);
940
+ });
941
+ }
942
+
943
+ /**
944
+ * Atomically get and clear pending_question from a session
945
+ * Prevents race conditions where the message could be modified between read and clear
946
+ * @param {string} taskId
947
+ * @returns {string|null} The pending question, or null if none exists
948
+ */
949
+ function atomicGetAndClearMessage(taskId) {
950
+ const session = taskSessions.get(taskId);
951
+ if (!session) {
952
+ return null;
953
+ }
954
+
955
+ const message = session.pending_question;
956
+ session.pending_question = null;
957
+ return message;
958
+ }
959
+
960
+ /**
961
+ * Stop an task task
962
+ *
963
+ * @param {string} taskId - Task session ID
964
+ * @returns {{ success: boolean, reason?: string }}
965
+ */
966
+ export function stopTask(taskId) {
967
+ const session = taskSessions.get(taskId);
968
+ if (!session) {
969
+ return { success: false, reason: 'Session not found' };
970
+ }
971
+
972
+ // Kill running process if any
973
+ const proc = runningProcesses.get(taskId);
974
+ if (proc) {
975
+ proc.kill('SIGTERM');
976
+ runningProcesses.delete(taskId);
977
+ console.log(`[task] Killed process for ${taskId}`);
978
+ }
979
+
980
+ session.status = 'stopped';
981
+ session.updated_at = Date.now();
982
+
983
+ // Remove lock file
984
+ removeTaskLock(taskId);
985
+
986
+ return { success: true };
987
+ }
988
+
989
+ /**
990
+ * Stop all active task tasks and kill their processes
991
+ */
992
+ export function stopAllTasks() {
993
+ console.log(`[task] Stopping all tasks (${runningProcesses.size} processes active)`);
994
+
995
+ for (const [id, proc] of runningProcesses) {
996
+ try {
997
+ let killTimer = null;
998
+
999
+ // 1. Setup exit handler FIRST to ensure we catch early exits
1000
+ proc.once('exit', () => {
1001
+ if (killTimer) {
1002
+ clearTimeout(killTimer);
1003
+ killTimer = null;
1004
+ }
1005
+ });
1006
+
1007
+ // 2. Try graceful SIGTERM
1008
+ proc.kill('SIGTERM');
1009
+
1010
+ // 3. Setup watchdog timer
1011
+ killTimer = setTimeout(() => {
1012
+ try {
1013
+ if (proc.exitCode === null) {
1014
+ proc.kill('SIGKILL');
1015
+ console.log(`[task] Force killed task ${id} (no SIGTERM response)`);
1016
+ }
1017
+ } catch (e) {}
1018
+ }, SIGTERM_TIMEOUT_MS);
1019
+
1020
+ console.log(`[task] Signal sent to task ${id}`);
1021
+
1022
+ // Remove lock file
1023
+ removeTaskLock(id);
1024
+ } catch (err) {
1025
+ console.error(`[task] Failed to send signal to task ${id}:`, err.message);
1026
+ }
1027
+ }
1028
+
1029
+ // Clear the map after signals are sent
1030
+ runningProcesses.clear();
1031
+
1032
+ for (const session of taskSessions.values()) {
1033
+ if (session.status === 'running' || session.status === 'paused' || session.status === 'budget_paused') {
1034
+ session.status = 'stopped';
1035
+ session.updated_at = Date.now();
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ /**
1041
+ * Pause an task task
1042
+ *
1043
+ * @param {string} taskId - Task session ID
1044
+ * @returns {{ success: boolean, reason?: string }}
1045
+ */
1046
+ export function pauseTask(taskId) {
1047
+ const session = taskSessions.get(taskId);
1048
+ if (!session) {
1049
+ return { success: false, reason: 'Session not found' };
1050
+ }
1051
+
1052
+ if (session.status !== 'running') {
1053
+ return { success: false, reason: `Cannot pause session in ${session.status} status` };
1054
+ }
1055
+
1056
+ session.status = 'paused';
1057
+ session.updated_at = Date.now();
1058
+
1059
+ return { success: true };
1060
+ }
1061
+
1062
+ /**
1063
+ * Resume a paused task task
1064
+ *
1065
+ * @param {string} taskId - Task session ID
1066
+ * @param {Function} [onEvent] - Event callback
1067
+ * @returns {{ success: boolean, reason?: string }}
1068
+ */
1069
+ export function resumeTask(taskId, onEvent) {
1070
+ const session = taskSessions.get(taskId);
1071
+ if (!session) {
1072
+ return { success: false, reason: 'Session not found' };
1073
+ }
1074
+
1075
+ if (session.status !== 'paused' && session.status !== 'budget_paused') {
1076
+ return { success: false, reason: `Cannot resume session in ${session.status} status` };
1077
+ }
1078
+
1079
+ session.status = 'running';
1080
+ session.updated_at = Date.now();
1081
+
1082
+ // Restart execution loop
1083
+ executeTaskLoop(taskId, onEvent).catch(err => {
1084
+ console.error(`[task] Resume loop error for ${taskId}:`, err.message);
1085
+ });
1086
+
1087
+ return { success: true };
1088
+ }
1089
+
1090
+ /**
1091
+ * Answer a question for an task task
1092
+ *
1093
+ * @param {string} taskId - Task session ID
1094
+ * @param {string} answer - User's answer to the question
1095
+ * @param {Function} [onEvent] - Event callback
1096
+ * @returns {{ success: boolean, reason?: string }}
1097
+ */
1098
+ export async function answerTaskQuestion(taskId, answer, onEvent) {
1099
+ const session = taskSessions.get(taskId);
1100
+ if (!session) {
1101
+ return { success: false, reason: 'Session not found' };
1102
+ }
1103
+
1104
+ if (session.status !== 'waiting_input' && session.status !== 'paused') {
1105
+ return { success: false, reason: `Cannot send message in ${session.status} status` };
1106
+ }
1107
+
1108
+ // Emit user input event
1109
+ if (onEvent) {
1110
+ onEvent({
1111
+ type: 'task_user_input',
1112
+ source: 'autonomous_task',
1113
+ task_id: taskId,
1114
+ question: session.pending_question,
1115
+ answer,
1116
+ timestamp: Date.now(),
1117
+ });
1118
+ }
1119
+
1120
+ // Handle based on current status
1121
+ if (session.status === 'paused') {
1122
+ // Task is paused waiting for user message (auto_continue disabled)
1123
+ // Set the message as pending_question and resume the execution loop
1124
+ session.pending_question = answer;
1125
+ session.status = 'running';
1126
+ session.updated_at = Date.now();
1127
+
1128
+ // The execution loop will pick up the pending_question and use it as the prompt
1129
+ return { success: true };
1130
+ } else {
1131
+ // Task is waiting_input (Claude asked a question)
1132
+ // Execute immediately with the user's answer
1133
+ session.pending_question = null;
1134
+ session.status = 'running';
1135
+ session.updated_at = Date.now();
1136
+
1137
+ const remainingBudget = session.budget_usd - session.cost_usd;
1138
+
1139
+ try {
1140
+ session.turn_count++;
1141
+ const result = await executeClaudeHeadless({
1142
+ prompt: answer,
1143
+ cwd: session.cwd,
1144
+ resumeSessionId: session.claude_session_id,
1145
+ budgetUsd: remainingBudget,
1146
+ taskId,
1147
+ parentSessionId: session.teleportation_session_id,
1148
+ });
1149
+
1150
+ // Update session
1151
+ session.claude_session_id = result.session_id || session.claude_session_id;
1152
+ session.cost_usd += result.cost_usd || 0;
1153
+ session.updated_at = Date.now();
1154
+
1155
+ // Add to history
1156
+ session.history.push({
1157
+ turn: session.turn_count,
1158
+ prompt: `[User Answer] ${answer}`,
1159
+ output: result.output?.slice(0, 2000),
1160
+ cost_usd: result.cost_usd,
1161
+ success: result.success,
1162
+ timestamp: Date.now(),
1163
+ });
1164
+
1165
+ // Resume the execution loop
1166
+ executeTaskLoop(taskId, onEvent).catch(err => {
1167
+ console.error(`[task] Answer loop error for ${taskId}:`, err.message);
1168
+ });
1169
+
1170
+ return { success: true };
1171
+ } catch (error) {
1172
+ session.status = 'stopped';
1173
+ session.updated_at = Date.now();
1174
+ return { success: false, reason: error.message };
1175
+ }
1176
+ }
1177
+ }
1178
+
1179
+ /**
1180
+ * Get task session status
1181
+ *
1182
+ * @param {string} taskId - Task session ID
1183
+ * @returns {TaskSession|null}
1184
+ */
1185
+ export function getTaskSession(taskId) {
1186
+ return taskSessions.get(taskId) || null;
1187
+ }
1188
+
1189
+ /**
1190
+ * List all task sessions for a teleportation session
1191
+ *
1192
+ * @param {string} session_id - Teleportation session ID
1193
+ * @returns {TaskSession[]}
1194
+ */
1195
+ export function listTaskSessions(session_id) {
1196
+ const sessions = [];
1197
+ for (const [, session] of taskSessions) {
1198
+ if (session.teleportation_session_id === session_id) {
1199
+ sessions.push(session);
1200
+ }
1201
+ }
1202
+ return sessions;
1203
+ }
1204
+
1205
+ /**
1206
+ * Increase budget for an task task
1207
+ *
1208
+ * @param {string} taskId - Task session ID
1209
+ * @param {number} additionalBudget - Additional budget in USD
1210
+ * @returns {{ success: boolean, new_budget?: number, reason?: string }}
1211
+ */
1212
+ export function increaseBudget(taskId, additionalBudget) {
1213
+ const session = taskSessions.get(taskId);
1214
+ if (!session) {
1215
+ return { success: false, reason: 'Session not found' };
1216
+ }
1217
+
1218
+ if (additionalBudget <= 0) {
1219
+ return { success: false, reason: 'Additional budget must be positive' };
1220
+ }
1221
+
1222
+ session.budget_usd += additionalBudget;
1223
+ session.updated_at = Date.now();
1224
+
1225
+ return { success: true, new_budget: session.budget_usd };
1226
+ }
1227
+
1228
+ // Export for testing
1229
+ export const __test = {
1230
+ taskSessions,
1231
+ runningProcesses,
1232
+ executeClaudeHeadless,
1233
+ executeTaskLoop,
1234
+ generateTaskId,
1235
+ };