teleportation-cli 1.1.5 → 1.2.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.
Files changed (42) hide show
  1. package/.claude/hooks/permission_request.mjs +326 -59
  2. package/.claude/hooks/post_tool_use.mjs +90 -0
  3. package/.claude/hooks/pre_tool_use.mjs +212 -293
  4. package/.claude/hooks/session-register.mjs +89 -104
  5. package/.claude/hooks/session_end.mjs +41 -42
  6. package/.claude/hooks/session_start.mjs +45 -60
  7. package/.claude/hooks/stop.mjs +752 -99
  8. package/.claude/hooks/user_prompt_submit.mjs +26 -3
  9. package/lib/cli/daemon-commands.js +1 -1
  10. package/lib/cli/teleport-commands.js +469 -0
  11. package/lib/daemon/daemon-v2.js +104 -0
  12. package/lib/daemon/lifecycle.js +56 -171
  13. package/lib/daemon/services/index.js +3 -0
  14. package/lib/daemon/services/polling-service.js +173 -0
  15. package/lib/daemon/services/queue-service.js +318 -0
  16. package/lib/daemon/services/session-service.js +115 -0
  17. package/lib/daemon/state.js +35 -0
  18. package/lib/daemon/task-executor-v2.js +413 -0
  19. package/lib/daemon/task-executor.js +270 -96
  20. package/lib/daemon/teleportation-daemon.js +709 -126
  21. package/lib/daemon/timeline-analyzer.js +215 -0
  22. package/lib/daemon/transcript-ingestion.js +696 -0
  23. package/lib/daemon/utils.js +91 -0
  24. package/lib/install/installer.js +184 -20
  25. package/lib/install/uhr-installer.js +136 -0
  26. package/lib/remote/providers/base-provider.js +46 -0
  27. package/lib/remote/providers/daytona-provider.js +58 -0
  28. package/lib/remote/providers/provider-factory.js +90 -19
  29. package/lib/remote/providers/sprites-provider.js +711 -0
  30. package/lib/teleport/exporters/claude-exporter.js +302 -0
  31. package/lib/teleport/exporters/gemini-exporter.js +307 -0
  32. package/lib/teleport/exporters/index.js +93 -0
  33. package/lib/teleport/exporters/interface.js +153 -0
  34. package/lib/teleport/fork-tracker.js +415 -0
  35. package/lib/teleport/git-committer.js +337 -0
  36. package/lib/teleport/index.js +48 -0
  37. package/lib/teleport/manager.js +620 -0
  38. package/lib/teleport/session-capture.js +282 -0
  39. package/package.json +6 -2
  40. package/teleportation-cli.cjs +488 -453
  41. package/.claude/hooks/heartbeat.mjs +0 -396
  42. package/lib/daemon/pid-manager.js +0 -183
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Task Executor V2 - Timeline-Driven Architecture
3
+ *
4
+ * Stateless task execution that uses timeline events as source of truth.
5
+ * Daemon can crash and restart without losing state.
6
+ *
7
+ * @module lib/daemon/task-executor-v2
8
+ */
9
+
10
+ import { spawn } from 'child_process';
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import os from 'os';
14
+ import { fetchTimeline, analyzeTaskState, getNextPrompt, shouldStopTask } from './timeline-analyzer.js';
15
+ import { ingestTranscriptToTimeline } from './transcript-ingestion.js';
16
+
17
+ // Configuration
18
+ const CLAUDE_CLI = process.env.CLAUDE_CLI_PATH || 'claude';
19
+ const DEFAULT_TIMEOUT_MS = 600000; // 10 minutes per turn
20
+ const MAX_TURNS = 100;
21
+
22
+ // Track running processes for stop functionality (only active processes)
23
+ const runningProcesses = new Map();
24
+
25
+ /**
26
+ * Execute a single turn for a task
27
+ * Stateless - queries timeline to determine what to do
28
+ *
29
+ * @param {Object} options
30
+ * @param {string} options.task_id - Task ID
31
+ * @param {string} options.session_id - Teleportation session ID
32
+ * @param {Object} options.config - Config with relayApiUrl, apiKey
33
+ * @returns {Promise<Object>} Execution result
34
+ */
35
+ export async function executeTaskTurn(options) {
36
+ const { task_id, session_id, config } = options;
37
+ const { relayApiUrl, apiKey } = config;
38
+
39
+ // 1. Fetch task from Redis
40
+ const task = await fetchTask(task_id, session_id, config);
41
+ if (!task) {
42
+ return { success: false, error: 'Task not found' };
43
+ }
44
+
45
+ // 2. Fetch timeline for parent session
46
+ const timeline = await fetchTimeline(session_id, config);
47
+
48
+ // 3. Analyze timeline to determine current state
49
+ const state = analyzeTaskState(timeline, task_id);
50
+
51
+ console.log(`[task-v2] Task ${task_id.slice(0, 20)}... state: ${state.state}, turn: ${state.turn_count}`);
52
+
53
+ // 4. Check if should stop
54
+ const stopCheck = shouldStopTask(state, MAX_TURNS);
55
+ if (stopCheck) {
56
+ console.log(`[task-v2] Task should stop: ${stopCheck.reason}`);
57
+ await updateTaskStatus(task_id, session_id, 'stopped', config);
58
+ return { success: true, stopped: true, reason: stopCheck.reason };
59
+ }
60
+
61
+ // 5. Check if ready for execution
62
+ if (!state.ready_for_execution) {
63
+ console.log(`[task-v2] Task not ready: ${state.reason || state.state}`);
64
+ return { success: true, waiting: true, reason: state.reason || state.state };
65
+ }
66
+
67
+ // 6. Determine prompt
68
+ const prompt = getNextPrompt(state, task);
69
+ if (!prompt) {
70
+ return { success: false, error: 'No prompt available' };
71
+ }
72
+
73
+ // 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
+ }
82
+
83
+ 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}`);
85
+ console.log(`[task-v2] Prompt: ${prompt.slice(0, 100)}...`);
86
+
87
+ // 8. Execute Claude headless
88
+ try {
89
+ const result = await executeClaudeHeadless({
90
+ prompt,
91
+ cwd: task.cwd || process.cwd(),
92
+ resumeSessionId,
93
+ budgetUsd: task.budget_usd - task.cost_usd,
94
+ taskId: task_id,
95
+ parentSessionId: task.session_id,
96
+ });
97
+
98
+ // 9. Ingest transcript to timeline
99
+ // This ensures all events (especially assistant responses) are captured
100
+ // even if hooks failed to log them during execution
101
+ if (result.session_id) {
102
+ try {
103
+ await ingestTranscriptToTimeline({
104
+ claude_session_id: result.session_id,
105
+ parent_session_id: session_id,
106
+ task_id: task_id,
107
+ cwd: task.cwd || process.cwd(), // Pass cwd for project slug derivation
108
+ config,
109
+ });
110
+ } catch (error) {
111
+ // Don't fail the whole turn if transcript ingestion fails
112
+ console.error(`[task-v2] Failed to ingest transcript:`, error.message);
113
+ }
114
+ }
115
+
116
+ // 10. Update task in Redis with results
117
+ await updateTaskAfterTurn(task_id, session_id, result, task, config);
118
+
119
+ return {
120
+ success: true,
121
+ executed: true,
122
+ turn_count: state.turn_count + 1,
123
+ cost_usd: result.cost_usd,
124
+ claude_session_id: result.session_id,
125
+ };
126
+ } catch (error) {
127
+ console.error(`[task-v2] Execution error:`, error);
128
+ return { success: false, error: error.message };
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Fetch task from relay API
134
+ */
135
+ async function fetchTask(task_id, session_id, config) {
136
+ const { relayApiUrl, apiKey } = config;
137
+
138
+ const response = await fetch(
139
+ `${relayApiUrl}/api/sessions/${session_id}/tasks/${task_id}/status`,
140
+ {
141
+ headers: { 'Authorization': `Bearer ${apiKey}` }
142
+ }
143
+ );
144
+
145
+ if (!response.ok) {
146
+ if (response.status === 404) {
147
+ return null;
148
+ }
149
+ throw new Error(`Failed to fetch task: ${response.status}`);
150
+ }
151
+
152
+ return await response.json();
153
+ }
154
+
155
+ /**
156
+ * Update task status in Redis via relay API
157
+ */
158
+ async function updateTaskStatus(task_id, session_id, status, config) {
159
+ const { relayApiUrl, apiKey } = config;
160
+
161
+ const response = await fetch(
162
+ `${relayApiUrl}/api/sessions/${session_id}/tasks/${task_id}`,
163
+ {
164
+ method: 'PATCH',
165
+ headers: {
166
+ 'Content-Type': 'application/json',
167
+ 'Authorization': `Bearer ${apiKey}`
168
+ },
169
+ body: JSON.stringify({ status })
170
+ }
171
+ );
172
+
173
+ if (!response.ok) {
174
+ throw new Error(`Failed to update task status: ${response.status}`);
175
+ }
176
+
177
+ return await response.json();
178
+ }
179
+
180
+ /**
181
+ * Update task after turn execution
182
+ */
183
+ async function updateTaskAfterTurn(task_id, session_id, result, currentTask, config) {
184
+ const { relayApiUrl, apiKey } = config;
185
+
186
+ // Accumulate cost
187
+ const totalCost = (currentTask.cost_usd || 0) + (result.cost_usd || 0);
188
+
189
+ const updates = {
190
+ claude_session_id: result.session_id || currentTask.claude_session_id,
191
+ cost_usd: totalCost,
192
+ turn_count: (currentTask.turn_count || 0) + 1,
193
+ };
194
+
195
+ const response = await fetch(
196
+ `${relayApiUrl}/api/sessions/${session_id}/tasks/${task_id}`,
197
+ {
198
+ method: 'PATCH',
199
+ headers: {
200
+ 'Content-Type': 'application/json',
201
+ 'Authorization': `Bearer ${apiKey}`
202
+ },
203
+ body: JSON.stringify(updates)
204
+ }
205
+ );
206
+
207
+ if (!response.ok) {
208
+ throw new Error(`Failed to update task: ${response.status}`);
209
+ }
210
+
211
+ return await response.json();
212
+ }
213
+
214
+ /**
215
+ * Execute Claude Code in headless mode
216
+ * (Same as original implementation)
217
+ */
218
+ async function executeClaudeHeadless(options) {
219
+ const {
220
+ prompt,
221
+ cwd,
222
+ resumeSessionId,
223
+ budgetUsd,
224
+ timeoutMs = DEFAULT_TIMEOUT_MS,
225
+ taskId,
226
+ parentSessionId,
227
+ } = options;
228
+
229
+ return new Promise((resolve, reject) => {
230
+ const args = [];
231
+
232
+ // Resume existing session
233
+ if (resumeSessionId) {
234
+ args.push('--resume', resumeSessionId);
235
+ }
236
+
237
+ // Add prompt
238
+ args.push('-p', prompt);
239
+
240
+ // Output format for structured parsing
241
+ args.push('--output-format', 'stream-json');
242
+ args.push('--verbose');
243
+
244
+ // Budget control
245
+ if (budgetUsd > 0) {
246
+ args.push('--max-budget-usd', budgetUsd.toFixed(2));
247
+ }
248
+
249
+ console.log(`[task-v2] Executing: ${CLAUDE_CLI} ${args.join(' ')}`);
250
+
251
+ const proc = spawn(CLAUDE_CLI, args, {
252
+ cwd,
253
+ 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
+ },
261
+ });
262
+
263
+ // Track process for stop functionality
264
+ if (taskId) {
265
+ runningProcesses.set(taskId, proc);
266
+ }
267
+
268
+ // Close stdin
269
+ proc.stdin.end();
270
+
271
+ let stdout = '';
272
+ let stderr = '';
273
+ let buffer = '';
274
+
275
+ const result = {
276
+ success: false,
277
+ output: '',
278
+ error: null,
279
+ exit_code: 0,
280
+ session_id: null,
281
+ cost_usd: 0,
282
+ usage: {
283
+ input_tokens: 0,
284
+ output_tokens: 0,
285
+ },
286
+ };
287
+
288
+ // Timeout handler
289
+ const timeout = setTimeout(() => {
290
+ proc.kill('SIGTERM');
291
+ setTimeout(() => proc.kill('SIGKILL'), 2000);
292
+ }, timeoutMs);
293
+
294
+ proc.stdout.on('data', (data) => {
295
+ const chunk = data.toString();
296
+ stdout += chunk;
297
+ buffer += chunk;
298
+
299
+ // Process stream-json lines
300
+ const lines = buffer.split('\n');
301
+ buffer = lines.pop();
302
+
303
+ for (const line of lines) {
304
+ if (!line.trim()) continue;
305
+ try {
306
+ const event = JSON.parse(line);
307
+
308
+ // Extract session_id
309
+ if (event.session_id) {
310
+ result.session_id = event.session_id;
311
+ }
312
+
313
+ // Extract cost and usage
314
+ if (event.usage) {
315
+ result.cost_usd = event.usage.total_cost || 0;
316
+ result.usage = event.usage;
317
+ }
318
+ } catch (e) {
319
+ // Not JSON, skip
320
+ }
321
+ }
322
+ });
323
+
324
+ proc.stderr.on('data', (data) => {
325
+ stderr += data.toString();
326
+ });
327
+
328
+ proc.on('close', (code) => {
329
+ clearTimeout(timeout);
330
+
331
+ if (taskId) {
332
+ runningProcesses.delete(taskId);
333
+ }
334
+
335
+ result.exit_code = code;
336
+ result.output = stdout;
337
+ result.error = stderr;
338
+ result.success = code === 0;
339
+
340
+ resolve(result);
341
+ });
342
+
343
+ proc.on('error', (err) => {
344
+ clearTimeout(timeout);
345
+
346
+ if (taskId) {
347
+ runningProcesses.delete(taskId);
348
+ }
349
+
350
+ reject(err);
351
+ });
352
+ });
353
+ }
354
+
355
+ /**
356
+ * Stop a running task
357
+ */
358
+ export function stopTask(task_id) {
359
+ const proc = runningProcesses.get(task_id);
360
+ if (proc) {
361
+ proc.kill('SIGTERM');
362
+ setTimeout(() => {
363
+ if (runningProcesses.has(task_id)) {
364
+ proc.kill('SIGKILL');
365
+ }
366
+ }, 2000);
367
+ return true;
368
+ }
369
+ return false;
370
+ }
371
+
372
+ /**
373
+ * Stop all running tasks
374
+ * Used during daemon shutdown to ensure clean exit
375
+ */
376
+ export function stopAllTasks() {
377
+ console.log(`[task-v2] Stopping all tasks (${runningProcesses.size} processes active)`);
378
+
379
+ for (const [task_id, proc] of runningProcesses) {
380
+ try {
381
+ let killTimer = null;
382
+
383
+ // Setup exit handler to clear timer
384
+ proc.once('exit', () => {
385
+ if (killTimer) {
386
+ clearTimeout(killTimer);
387
+ killTimer = null;
388
+ }
389
+ });
390
+
391
+ // Try graceful SIGTERM
392
+ proc.kill('SIGTERM');
393
+
394
+ // Setup watchdog timer for SIGKILL if needed
395
+ killTimer = setTimeout(() => {
396
+ try {
397
+ if (runningProcesses.has(task_id)) {
398
+ proc.kill('SIGKILL');
399
+ console.log(`[task-v2] Force killed task ${task_id} (SIGTERM timeout)`);
400
+ }
401
+ } catch (killError) {
402
+ // Process might already be dead
403
+ }
404
+ }, 2000);
405
+
406
+ runningProcesses.delete(task_id);
407
+ } catch (error) {
408
+ console.error(`[task-v2] Error stopping task ${task_id}:`, error.message);
409
+ }
410
+ }
411
+ }
412
+
413
+ export { runningProcesses };