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.
- package/.claude/hooks/permission_request.mjs +326 -59
- package/.claude/hooks/post_tool_use.mjs +90 -0
- package/.claude/hooks/pre_tool_use.mjs +212 -293
- package/.claude/hooks/session-register.mjs +89 -104
- package/.claude/hooks/session_end.mjs +41 -42
- package/.claude/hooks/session_start.mjs +45 -60
- package/.claude/hooks/stop.mjs +752 -99
- package/.claude/hooks/user_prompt_submit.mjs +26 -3
- package/lib/cli/daemon-commands.js +1 -1
- package/lib/cli/teleport-commands.js +469 -0
- package/lib/daemon/daemon-v2.js +104 -0
- package/lib/daemon/lifecycle.js +56 -171
- package/lib/daemon/services/index.js +3 -0
- package/lib/daemon/services/polling-service.js +173 -0
- package/lib/daemon/services/queue-service.js +318 -0
- package/lib/daemon/services/session-service.js +115 -0
- package/lib/daemon/state.js +35 -0
- package/lib/daemon/task-executor-v2.js +413 -0
- package/lib/daemon/task-executor.js +270 -96
- package/lib/daemon/teleportation-daemon.js +709 -126
- package/lib/daemon/timeline-analyzer.js +215 -0
- package/lib/daemon/transcript-ingestion.js +696 -0
- package/lib/daemon/utils.js +91 -0
- package/lib/install/installer.js +184 -20
- package/lib/install/uhr-installer.js +136 -0
- package/lib/remote/providers/base-provider.js +46 -0
- package/lib/remote/providers/daytona-provider.js +58 -0
- package/lib/remote/providers/provider-factory.js +90 -19
- package/lib/remote/providers/sprites-provider.js +711 -0
- package/lib/teleport/exporters/claude-exporter.js +302 -0
- package/lib/teleport/exporters/gemini-exporter.js +307 -0
- package/lib/teleport/exporters/index.js +93 -0
- package/lib/teleport/exporters/interface.js +153 -0
- package/lib/teleport/fork-tracker.js +415 -0
- package/lib/teleport/git-committer.js +337 -0
- package/lib/teleport/index.js +48 -0
- package/lib/teleport/manager.js +620 -0
- package/lib/teleport/session-capture.js +282 -0
- package/package.json +6 -2
- package/teleportation-cli.cjs +488 -453
- package/.claude/hooks/heartbeat.mjs +0 -396
- 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 };
|