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.
- package/.claude/hooks/permission_request.mjs +11 -4
- package/.claude/hooks/post_tool_use.mjs +1 -3
- package/.claude/hooks/pre_tool_use.mjs +255 -289
- package/.claude/hooks/session-register.mjs +44 -29
- package/.claude/hooks/session_end.mjs +29 -3
- package/.claude/hooks/session_start.mjs +57 -1
- package/.claude/hooks/stop.mjs +245 -242
- package/.claude/hooks/user_prompt_submit.mjs +1 -3
- package/lib/config/manager.js +45 -1
- package/lib/daemon/session-file-registry.js +207 -0
- package/lib/daemon/task-executor-v2.js +239 -29
- package/lib/daemon/teleportation-daemon.js +469 -29
- package/lib/daemon/timeline-analyzer.js +19 -13
- package/lib/daemon/transcript-ingestion.js +310 -51
- package/lib/daemon/utils.js +0 -9
- package/lib/install/installer.js +126 -3
- package/lib/install/uhr-installer.js +32 -18
- package/lib/intelligence/benchmark.js +240 -0
- package/lib/intelligence/index.js +29 -0
- package/lib/intelligence/rebuild-policies.js +169 -0
- package/lib/intelligence/schema.js +259 -0
- package/lib/intelligence/transcript-mine.js +339 -0
- package/lib/session/metadata.js +23 -5
- package/lib/transcript-sync/lifecycle.js +88 -0
- package/lib/transcript-sync/repo-context.js +45 -0
- package/lib/transcript-sync/worker.js +233 -0
- package/lib/utils/log-sanitizer.js +65 -0
- package/package.json +2 -1
- package/scripts/sync-transcripts.sh +272 -0
- package/teleportation-cli.cjs +295 -4
package/lib/config/manager.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
360
|
-
if (
|
|
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,
|
|
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
|