teleportation-cli 1.0.0 → 1.0.2
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/heartbeat.mjs +67 -2
- package/.claude/hooks/permission_request.mjs +55 -26
- package/.claude/hooks/pre_tool_use.mjs +29 -2
- package/.claude/hooks/session-register.mjs +64 -5
- package/.claude/hooks/stop.mjs +205 -1
- package/.claude/hooks/user_prompt_submit.mjs +111 -0
- package/README.md +36 -12
- package/lib/auth/claude-key-extractor.js +196 -0
- package/lib/auth/credentials.js +7 -2
- package/lib/cli/remote-commands.js +649 -0
- package/lib/daemon/teleportation-daemon.js +131 -41
- package/lib/install/installer.js +22 -7
- package/lib/machine-coders/claude-code-adapter.js +191 -37
- package/lib/remote/code-sync.js +213 -0
- package/lib/remote/init-script-robust.js +187 -0
- package/lib/remote/liveport-client.js +417 -0
- package/lib/remote/orchestrator.js +480 -0
- package/lib/remote/pr-creator.js +382 -0
- package/lib/remote/providers/base-provider.js +407 -0
- package/lib/remote/providers/daytona-provider.js +506 -0
- package/lib/remote/providers/fly-provider.js +611 -0
- package/lib/remote/providers/provider-factory.js +228 -0
- package/lib/remote/results-delivery.js +333 -0
- package/lib/remote/session-manager.js +273 -0
- package/lib/remote/state-capture.js +324 -0
- package/lib/remote/vault-client.js +478 -0
- package/lib/session/metadata.js +80 -49
- package/lib/session/mute-checker.js +2 -1
- package/lib/utils/vault-errors.js +353 -0
- package/package.json +5 -5
- package/teleportation-cli.cjs +417 -7
|
@@ -46,11 +46,53 @@ let intervalHandle = null;
|
|
|
46
46
|
let heartbeatCount = 0;
|
|
47
47
|
let failureCount = 0;
|
|
48
48
|
let lastDaemonCheck = 0;
|
|
49
|
+
// Module cache for lifecycle and pid-manager (imported once, reused)
|
|
50
|
+
let lifecycleModule = null;
|
|
51
|
+
let pidManagerModule = null;
|
|
52
|
+
// Flag to prevent concurrent daemon start attempts
|
|
53
|
+
let daemonStartInProgress = false;
|
|
49
54
|
// Make interval configurable via environment variable (default 60 seconds)
|
|
50
55
|
const DAEMON_CHECK_INTERVAL = Math.max(10000, Math.min(300000,
|
|
51
56
|
parseInt(env.DAEMON_CHECK_INTERVAL || '60000', 10)
|
|
52
57
|
)); // Default 60 seconds, min 10s, max 5min
|
|
53
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Update daemon state on relay
|
|
61
|
+
* @param {'running' | 'stopped'} status - Daemon status
|
|
62
|
+
* @param {string} reason - Reason for state change
|
|
63
|
+
*/
|
|
64
|
+
async function updateRelayDaemonState(status, reason) {
|
|
65
|
+
try {
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
68
|
+
|
|
69
|
+
const response = await fetch(`${RELAY_API_URL}/api/sessions/${SESSION_ID}/daemon-state`, {
|
|
70
|
+
method: 'PATCH',
|
|
71
|
+
headers: {
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
status,
|
|
77
|
+
started_at: status === 'running' ? Date.now() : null,
|
|
78
|
+
started_reason: status === 'running' ? reason : null
|
|
79
|
+
}),
|
|
80
|
+
signal: controller.signal
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
clearTimeout(timeoutId);
|
|
84
|
+
|
|
85
|
+
if (!response.ok && DEBUG) {
|
|
86
|
+
console.log(`[Heartbeat] Failed to update relay daemon state: ${response.status}`);
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (DEBUG) {
|
|
90
|
+
console.log(`[Heartbeat] Error updating relay daemon state: ${error.message}`);
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
54
96
|
/**
|
|
55
97
|
* Check if daemon is running and start it if not
|
|
56
98
|
*/
|
|
@@ -67,8 +109,15 @@ async function ensureDaemonRunning() {
|
|
|
67
109
|
try {
|
|
68
110
|
// Try to import lifecycle module from installed location
|
|
69
111
|
const possibleLocations = [
|
|
112
|
+
// npm global install location
|
|
113
|
+
'/usr/local/lib/node_modules/teleportation-cli/lib/daemon/lifecycle.js',
|
|
114
|
+
// User's npm global install (common on macOS with nvm/fnm)
|
|
115
|
+
join(homedir(), '.local', 'lib', 'node_modules', 'teleportation-cli', 'lib', 'daemon', 'lifecycle.js'),
|
|
116
|
+
// Legacy location
|
|
70
117
|
join(homedir(), '.teleportation', 'lib', 'daemon', 'lifecycle.js'),
|
|
118
|
+
// Relative to hooks dir (development)
|
|
71
119
|
join(__dirname, '..', '..', 'lib', 'daemon', 'lifecycle.js'),
|
|
120
|
+
// Environment override
|
|
72
121
|
env.TELEPORTATION_LIFECYCLE_PATH
|
|
73
122
|
].filter(Boolean);
|
|
74
123
|
|
|
@@ -116,9 +165,11 @@ async function ensureDaemonRunning() {
|
|
|
116
165
|
}
|
|
117
166
|
|
|
118
167
|
try {
|
|
119
|
-
|
|
168
|
+
// Use startDaemonIfNeeded to both start the daemon AND update the relay
|
|
169
|
+
// This ensures the mobile UI knows the daemon is running
|
|
170
|
+
await lifecycle.startDaemonIfNeeded(SESSION_ID, 'heartbeat');
|
|
120
171
|
if (DEBUG) {
|
|
121
|
-
console.log(`[Heartbeat] Daemon started successfully`);
|
|
172
|
+
console.log(`[Heartbeat] Daemon started and relay updated successfully`);
|
|
122
173
|
}
|
|
123
174
|
} catch (error) {
|
|
124
175
|
// Don't fail heartbeat if daemon start fails - it might already be starting
|
|
@@ -129,6 +180,20 @@ async function ensureDaemonRunning() {
|
|
|
129
180
|
// Always reset flag, even if start fails
|
|
130
181
|
daemonStartInProgress = false;
|
|
131
182
|
}
|
|
183
|
+
} else if (status.running) {
|
|
184
|
+
// Daemon is running - ensure relay knows about it
|
|
185
|
+
// This handles the case where daemon was started manually or by another session
|
|
186
|
+
try {
|
|
187
|
+
await updateRelayDaemonState('running', 'heartbeat');
|
|
188
|
+
if (DEBUG) {
|
|
189
|
+
console.log(`[Heartbeat] Synced daemon running state to relay`);
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
// Ignore errors - just a sync, not critical
|
|
193
|
+
if (DEBUG) {
|
|
194
|
+
console.log(`[Heartbeat] Failed to sync daemon state: ${error.message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
132
197
|
}
|
|
133
198
|
} catch (error) {
|
|
134
199
|
// Silently fail - daemon check shouldn't break heartbeat
|
|
@@ -132,7 +132,10 @@ const fetchJson = async (url, opts) => {
|
|
|
132
132
|
const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
|
|
133
133
|
const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
|
|
134
134
|
const POLLING_INTERVAL_MS = parseInt(env.APPROVAL_POLL_INTERVAL_MS || '2000', 10);
|
|
135
|
-
const
|
|
135
|
+
const FAST_APPROVAL_TIMEOUT_MS = parseInt(
|
|
136
|
+
env.FAST_APPROVAL_TIMEOUT_MS || env.FAST_POLL_TIMEOUT_MS || '55000',
|
|
137
|
+
10
|
|
138
|
+
);
|
|
136
139
|
|
|
137
140
|
if (!RELAY_API_URL || !RELAY_API_KEY) {
|
|
138
141
|
log('No relay config - letting Claude Code handle permission locally');
|
|
@@ -224,13 +227,10 @@ const fetchJson = async (url, opts) => {
|
|
|
224
227
|
|
|
225
228
|
// Poll for remote approval decision
|
|
226
229
|
log('Polling for remote approval decision...');
|
|
227
|
-
const AUTO_AWAY_TIMEOUT_MS = parseInt(env.AUTO_AWAY_TIMEOUT_MS || '300000', 10); // 5 min default
|
|
228
|
-
const startTime = Date.now();
|
|
229
|
-
let hasSetAutoAway = false;
|
|
230
230
|
let consecutiveFailures = 0;
|
|
231
231
|
const MAX_CONSECUTIVE_FAILURES = 5;
|
|
232
232
|
|
|
233
|
-
const deadline = Date.now() +
|
|
233
|
+
const deadline = Date.now() + FAST_APPROVAL_TIMEOUT_MS;
|
|
234
234
|
while (Date.now() < deadline) {
|
|
235
235
|
try {
|
|
236
236
|
const status = await fetchJson(`${RELAY_API_URL}/api/approvals/${approvalId}`, {
|
|
@@ -240,6 +240,30 @@ const fetchJson = async (url, opts) => {
|
|
|
240
240
|
|
|
241
241
|
if (status.status === 'allowed') {
|
|
242
242
|
log('Remote approval: ALLOWED');
|
|
243
|
+
// Acknowledge on the fast-path to prevent duplicate daemon execution.
|
|
244
|
+
// CRITICAL: If ACK fails, we must NOT proceed with local execution to avoid duplicates.
|
|
245
|
+
try {
|
|
246
|
+
const ackRes = await fetch(`${RELAY_API_URL}/api/approvals/${approvalId}/ack`, {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
|
|
249
|
+
});
|
|
250
|
+
if (!ackRes.ok) {
|
|
251
|
+
throw new Error(`ACK returned ${ackRes.status}`);
|
|
252
|
+
}
|
|
253
|
+
} catch (e) {
|
|
254
|
+
log(`ERROR: Failed to ack approval ${approvalId}: ${e.message} - aborting to prevent duplicate execution`);
|
|
255
|
+
// Return early without allowing - let daemon handle it instead
|
|
256
|
+
const out = {
|
|
257
|
+
hookSpecificOutput: {
|
|
258
|
+
hookEventName: 'PermissionRequest',
|
|
259
|
+
permissionDecision: 'deny',
|
|
260
|
+
permissionDecisionReason: '⚠️ Teleportation: Approval received but acknowledgment failed. Request handed to daemon to prevent duplicate execution.'
|
|
261
|
+
},
|
|
262
|
+
suppressOutput: true
|
|
263
|
+
};
|
|
264
|
+
stdout.write(JSON.stringify(out));
|
|
265
|
+
return exit(0);
|
|
266
|
+
}
|
|
243
267
|
const out = {
|
|
244
268
|
hookSpecificOutput: {
|
|
245
269
|
hookEventName: 'PermissionRequest',
|
|
@@ -270,25 +294,6 @@ const fetchJson = async (url, opts) => {
|
|
|
270
294
|
log('Approval was invalidated - letting Claude Code handle');
|
|
271
295
|
return exit(0);
|
|
272
296
|
}
|
|
273
|
-
|
|
274
|
-
// Auto-set away after timeout (if not already away)
|
|
275
|
-
if (!hasSetAutoAway && (Date.now() - startTime) > AUTO_AWAY_TIMEOUT_MS) {
|
|
276
|
-
const timeoutMinutes = Math.round(AUTO_AWAY_TIMEOUT_MS / 1000 / 60);
|
|
277
|
-
log(`Approval waiting >${timeoutMinutes} minutes - auto-setting away mode`);
|
|
278
|
-
try {
|
|
279
|
-
await fetchJson(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
|
|
280
|
-
method: 'PATCH',
|
|
281
|
-
headers: {
|
|
282
|
-
'Content-Type': 'application/json',
|
|
283
|
-
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
284
|
-
},
|
|
285
|
-
body: JSON.stringify({ is_away: true })
|
|
286
|
-
});
|
|
287
|
-
hasSetAutoAway = true;
|
|
288
|
-
} catch (e) {
|
|
289
|
-
log(`Failed to auto-set away: ${e.message}`);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
297
|
} catch (e) {
|
|
293
298
|
consecutiveFailures++;
|
|
294
299
|
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
@@ -301,7 +306,31 @@ const fetchJson = async (url, opts) => {
|
|
|
301
306
|
await sleep(POLLING_INTERVAL_MS);
|
|
302
307
|
}
|
|
303
308
|
|
|
304
|
-
//
|
|
305
|
-
|
|
309
|
+
// Fast-path timeout: do NOT fall back to local permission prompts while user is away.
|
|
310
|
+
// Instead, hand off to daemon (background) and inform Claude.
|
|
311
|
+
log('Fast-path approval timeout - handing off to daemon');
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
await fetch(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
|
|
315
|
+
method: 'PATCH',
|
|
316
|
+
headers: {
|
|
317
|
+
'Content-Type': 'application/json',
|
|
318
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
319
|
+
},
|
|
320
|
+
body: JSON.stringify({ last_approval_location: 'daemon_handoff', is_away: true })
|
|
321
|
+
});
|
|
322
|
+
} catch (e) {
|
|
323
|
+
log(`Warning: Failed to update daemon-state for handoff: ${e.message}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const out = {
|
|
327
|
+
hookSpecificOutput: {
|
|
328
|
+
hookEventName: 'PermissionRequest',
|
|
329
|
+
permissionDecision: 'deny',
|
|
330
|
+
permissionDecisionReason: '⏳ Teleportation: waiting for mobile approval timed out. This request was handed off to the daemon and will run in the background once approved. You will see a Daemon Work Update here when it completes.'
|
|
331
|
+
},
|
|
332
|
+
suppressOutput: true
|
|
333
|
+
};
|
|
334
|
+
stdout.write(JSON.stringify(out));
|
|
306
335
|
return exit(0);
|
|
307
336
|
})();
|
|
@@ -79,6 +79,7 @@ const fetchJson = async (url, opts) => {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
let { session_id, tool_name, tool_input } = input || {};
|
|
82
|
+
tool_input = tool_input && typeof tool_input === 'object' ? tool_input : {};
|
|
82
83
|
let claude_session_id = session_id; // Keep original ID
|
|
83
84
|
|
|
84
85
|
log(`Session ID: ${session_id}, Tool: ${tool_name}, Input: ${JSON.stringify(tool_input).substring(0, 100)}`);
|
|
@@ -418,10 +419,36 @@ const fetchJson = async (url, opts) => {
|
|
|
418
419
|
}
|
|
419
420
|
};
|
|
420
421
|
|
|
422
|
+
if (tool_input.__teleportation_away) {
|
|
423
|
+
await updateSessionState({ is_away: true });
|
|
424
|
+
const out = {
|
|
425
|
+
hookSpecificOutput: {
|
|
426
|
+
hookEventName: 'PreToolUse',
|
|
427
|
+
permissionDecision: 'deny',
|
|
428
|
+
permissionDecisionReason: '✅ Teleportation: Away mode enabled.'
|
|
429
|
+
},
|
|
430
|
+
suppressOutput: true
|
|
431
|
+
};
|
|
432
|
+
stdout.write(JSON.stringify(out));
|
|
433
|
+
return exit(0);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (tool_input.__teleportation_back) {
|
|
437
|
+
await updateSessionState({ is_away: false });
|
|
438
|
+
const out = {
|
|
439
|
+
hookSpecificOutput: {
|
|
440
|
+
hookEventName: 'PreToolUse',
|
|
441
|
+
permissionDecision: 'deny',
|
|
442
|
+
permissionDecisionReason: '✅ Teleportation: Away mode disabled.'
|
|
443
|
+
},
|
|
444
|
+
suppressOutput: true
|
|
445
|
+
};
|
|
446
|
+
stdout.write(JSON.stringify(out));
|
|
447
|
+
return exit(0);
|
|
448
|
+
}
|
|
449
|
+
|
|
421
450
|
// SMART AWAY MODE: Auto-mark as present ("back") on any activity
|
|
422
451
|
// If the user is typing commands locally, they are clearly not away.
|
|
423
|
-
await updateSessionState({ is_away: false });
|
|
424
|
-
|
|
425
452
|
// Note: Approval invalidation is handled by PermissionRequest hook
|
|
426
453
|
// to avoid race conditions and duplicate API calls
|
|
427
454
|
|
|
@@ -145,19 +145,78 @@ export async function ensureSessionRegistered(session_id, cwd, config) {
|
|
|
145
145
|
|
|
146
146
|
// Check if heartbeat is already running for this session
|
|
147
147
|
const { tmpdir } = await import('os');
|
|
148
|
-
const { readFile } = await import('fs/promises');
|
|
148
|
+
const { readFile, unlink } = await import('fs/promises');
|
|
149
149
|
const pidFile = join(tmpdir(), `teleportation-heartbeat-${session_id}.pid`);
|
|
150
|
-
|
|
150
|
+
|
|
151
151
|
let shouldStartHeartbeat = true;
|
|
152
152
|
try {
|
|
153
|
-
await readFile(pidFile);
|
|
154
|
-
// PID file
|
|
155
|
-
|
|
153
|
+
const pidContent = await readFile(pidFile, 'utf8');
|
|
154
|
+
// Parse PID file (JSON format with session_id validation)
|
|
155
|
+
let pidData;
|
|
156
|
+
try {
|
|
157
|
+
pidData = JSON.parse(pidContent);
|
|
158
|
+
} catch {
|
|
159
|
+
// Fallback for old format (plain PID number)
|
|
160
|
+
pidData = { pid: parseInt(pidContent.trim(), 10) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const pid = pidData.pid;
|
|
164
|
+
if (pid && !isNaN(pid)) {
|
|
165
|
+
// Check if the process is actually running
|
|
166
|
+
try {
|
|
167
|
+
process.kill(pid, 0); // Signal 0 checks existence without killing
|
|
168
|
+
// Process is running - don't start another heartbeat
|
|
169
|
+
shouldStartHeartbeat = false;
|
|
170
|
+
if (env.DEBUG) {
|
|
171
|
+
console.error(`[SessionRegister] Heartbeat already running (PID: ${pid})`);
|
|
172
|
+
}
|
|
173
|
+
} catch (killError) {
|
|
174
|
+
if (killError.code === 'ESRCH') {
|
|
175
|
+
// Process is dead - clean up stale PID file
|
|
176
|
+
try {
|
|
177
|
+
await unlink(pidFile);
|
|
178
|
+
if (env.DEBUG) {
|
|
179
|
+
console.error(`[SessionRegister] Cleaned up stale heartbeat PID file (PID: ${pid})`);
|
|
180
|
+
}
|
|
181
|
+
} catch (unlinkError) {
|
|
182
|
+
if (env.DEBUG) {
|
|
183
|
+
console.error(`[SessionRegister] Failed to unlink stale PID file: ${unlinkError.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Will start new heartbeat
|
|
187
|
+
} else {
|
|
188
|
+
// Permission error - assume process exists, don't start duplicate
|
|
189
|
+
shouldStartHeartbeat = false;
|
|
190
|
+
if (env.DEBUG) {
|
|
191
|
+
console.error(`[SessionRegister] Cannot check heartbeat (permission denied), assuming running (PID: ${pid})`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
156
196
|
} catch (e) {
|
|
157
197
|
// PID file doesn't exist, start heartbeat
|
|
158
198
|
}
|
|
159
199
|
|
|
160
200
|
if (shouldStartHeartbeat) {
|
|
201
|
+
// Kill any orphan heartbeat processes for this session before starting a new one
|
|
202
|
+
// This handles race conditions where multiple processes pass the check simultaneously
|
|
203
|
+
try {
|
|
204
|
+
const { execSync } = await import('child_process');
|
|
205
|
+
// Use pkill with -f flag to match full command line (session_id argument)
|
|
206
|
+
// Returns 1 if no processes matched, so we ignore the exit code
|
|
207
|
+
execSync(`pkill -f "heartbeat.mjs ${session_id}" 2>/dev/null || true`, { stdio: 'ignore' });
|
|
208
|
+
if (env.DEBUG) {
|
|
209
|
+
console.error(`[SessionRegister] Cleaned up any orphan heartbeats for ${session_id}`);
|
|
210
|
+
}
|
|
211
|
+
// Small delay to let orphan processes terminate
|
|
212
|
+
await new Promise(r => setTimeout(r, 100));
|
|
213
|
+
} catch (pkillError) {
|
|
214
|
+
// Ignore pkill errors - process might not exist
|
|
215
|
+
if (env.DEBUG) {
|
|
216
|
+
console.error(`[SessionRegister] pkill cleanup failed: ${pkillError.message}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
161
220
|
const heartbeat = spawn('node', [heartbeatPath, session_id], {
|
|
162
221
|
detached: true,
|
|
163
222
|
stdio: 'ignore',
|
package/.claude/hooks/stop.mjs
CHANGED
|
@@ -29,6 +29,9 @@ const isValidSessionId = (id) => {
|
|
|
29
29
|
// Max length for assistant response preview (characters)
|
|
30
30
|
const ASSISTANT_RESPONSE_MAX_LENGTH = 2000;
|
|
31
31
|
|
|
32
|
+
// Max size for full transcript (bytes) - 500KB
|
|
33
|
+
const MAX_TRANSCRIPT_SIZE = 500 * 1024;
|
|
34
|
+
|
|
32
35
|
// Retry configuration
|
|
33
36
|
const MAX_RETRIES = 3;
|
|
34
37
|
const RETRY_DELAY_MS = 1000;
|
|
@@ -150,6 +153,178 @@ const extractLastAssistantMessage = async (transcriptPath, log) => {
|
|
|
150
153
|
}
|
|
151
154
|
};
|
|
152
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Extract the full conversation transcript from the transcript file
|
|
158
|
+
* Returns all user and assistant messages with turn_index
|
|
159
|
+
* @returns {Object|null} - { messages: [{ role, content, turn_index }], total_turns: number, truncated: boolean, original_size: number } or null
|
|
160
|
+
*/
|
|
161
|
+
const extractFullTranscript = async (transcriptPath, log) => {
|
|
162
|
+
try {
|
|
163
|
+
if (!transcriptPath) {
|
|
164
|
+
log('No transcript_path provided for full extraction');
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Read file directly
|
|
169
|
+
let content;
|
|
170
|
+
try {
|
|
171
|
+
content = await readFile(transcriptPath, 'utf8');
|
|
172
|
+
} catch (e) {
|
|
173
|
+
if (e.code === 'ENOENT') {
|
|
174
|
+
log(`Transcript file not found: ${transcriptPath}`);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
if (e.code === 'EACCES' || e.code === 'EPERM') {
|
|
178
|
+
log(`Permission denied reading transcript: ${transcriptPath}`);
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
log(`Error reading transcript for full extraction: ${e.code || e.message}`);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let transcript;
|
|
186
|
+
|
|
187
|
+
// Try parsing as JSON array first
|
|
188
|
+
try {
|
|
189
|
+
transcript = JSON.parse(content);
|
|
190
|
+
log('Parsed transcript as JSON array for full extraction');
|
|
191
|
+
} catch (e) {
|
|
192
|
+
// Try parsing as JSONL (newline-delimited JSON)
|
|
193
|
+
log('JSON parse failed for full extraction, trying JSONL format');
|
|
194
|
+
const lines = content.trim().split('\n').filter(l => l.trim());
|
|
195
|
+
transcript = lines.map(line => {
|
|
196
|
+
try {
|
|
197
|
+
return JSON.parse(line);
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}).filter(Boolean);
|
|
202
|
+
log(`Parsed transcript as JSONL (${transcript.length} messages) for full extraction`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!Array.isArray(transcript)) {
|
|
206
|
+
log(`Transcript is not an array for full extraction: ${typeof transcript}`);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
log(`Full transcript has ${transcript.length} raw messages`);
|
|
211
|
+
|
|
212
|
+
// Extract all user and assistant messages
|
|
213
|
+
const messages = [];
|
|
214
|
+
let turnIndex = 0;
|
|
215
|
+
|
|
216
|
+
for (const msg of transcript) {
|
|
217
|
+
const role = msg.role || msg.type || '';
|
|
218
|
+
const isAssistant = role === 'assistant' || role === 'model' || msg.isAssistant;
|
|
219
|
+
const isUser = role === 'user' || role === 'human' || msg.isUser;
|
|
220
|
+
|
|
221
|
+
if (!isAssistant && !isUser) continue;
|
|
222
|
+
|
|
223
|
+
// Extract content
|
|
224
|
+
let text = '';
|
|
225
|
+
if (typeof msg.content === 'string') {
|
|
226
|
+
text = msg.content;
|
|
227
|
+
} else if (Array.isArray(msg.content)) {
|
|
228
|
+
// Content blocks format: [{ type: 'text', text: '...' }, ...]
|
|
229
|
+
text = msg.content
|
|
230
|
+
.filter(block => block.type === 'text' && block.text)
|
|
231
|
+
.map(block => block.text)
|
|
232
|
+
.join('\n\n');
|
|
233
|
+
} else if (msg.text) {
|
|
234
|
+
text = msg.text;
|
|
235
|
+
} else if (msg.message && typeof msg.message === 'string') {
|
|
236
|
+
text = msg.message;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (text && text.trim()) {
|
|
240
|
+
messages.push({
|
|
241
|
+
role: isAssistant ? 'assistant' : 'user',
|
|
242
|
+
content: text.trim(),
|
|
243
|
+
turn_index: turnIndex
|
|
244
|
+
});
|
|
245
|
+
turnIndex++;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
log(`Extracted ${messages.length} messages from transcript`);
|
|
250
|
+
|
|
251
|
+
if (messages.length === 0) {
|
|
252
|
+
log('No valid messages found in transcript');
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Check size and truncate if needed (reliability requirement: 500KB limit)
|
|
257
|
+
let result = {
|
|
258
|
+
messages,
|
|
259
|
+
total_turns: messages.length,
|
|
260
|
+
truncated: false,
|
|
261
|
+
original_size: 0
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const jsonSize = JSON.stringify(result).length;
|
|
265
|
+
result.original_size = jsonSize;
|
|
266
|
+
|
|
267
|
+
if (jsonSize > MAX_TRANSCRIPT_SIZE) {
|
|
268
|
+
log(`Transcript size ${jsonSize} exceeds limit ${MAX_TRANSCRIPT_SIZE}, truncating...`);
|
|
269
|
+
|
|
270
|
+
// Use size estimation to avoid O(n²) repeated JSON.stringify calls
|
|
271
|
+
// Estimate average message size and calculate how many to keep
|
|
272
|
+
const metadataOverhead = 100; // Approximate overhead for result wrapper
|
|
273
|
+
const avgMsgSize = (jsonSize - metadataOverhead) / messages.length;
|
|
274
|
+
const targetCount = Math.max(1, Math.floor((MAX_TRANSCRIPT_SIZE - metadataOverhead) / avgMsgSize));
|
|
275
|
+
|
|
276
|
+
// Keep the most recent messages (slice from end)
|
|
277
|
+
let truncatedMessages = messages.slice(-targetCount);
|
|
278
|
+
|
|
279
|
+
// Verify size with a single stringify check
|
|
280
|
+
let testResult = {
|
|
281
|
+
messages: truncatedMessages,
|
|
282
|
+
total_turns: messages.length,
|
|
283
|
+
truncated: true,
|
|
284
|
+
original_size: jsonSize
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// If still too large (estimation was off), remove a few more messages
|
|
288
|
+
while (truncatedMessages.length > 1 && JSON.stringify(testResult).length > MAX_TRANSCRIPT_SIZE) {
|
|
289
|
+
truncatedMessages = truncatedMessages.slice(1); // Remove oldest of remaining
|
|
290
|
+
testResult = {
|
|
291
|
+
messages: truncatedMessages,
|
|
292
|
+
total_turns: messages.length,
|
|
293
|
+
truncated: true,
|
|
294
|
+
original_size: jsonSize
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
result = testResult;
|
|
299
|
+
|
|
300
|
+
// If still too large with just one message, truncate the message content
|
|
301
|
+
if (truncatedMessages.length === 0) {
|
|
302
|
+
log('All messages truncated - transcript too large for any message');
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (JSON.stringify(result).length > MAX_TRANSCRIPT_SIZE) {
|
|
307
|
+
const lastMsg = { ...truncatedMessages[truncatedMessages.length - 1] }; // Clone to avoid mutation
|
|
308
|
+
const maxContentLen = Math.floor(MAX_TRANSCRIPT_SIZE * 0.8); // Leave room for metadata
|
|
309
|
+
lastMsg.content = lastMsg.content.substring(0, maxContentLen) + '... [truncated]';
|
|
310
|
+
result = {
|
|
311
|
+
messages: [lastMsg],
|
|
312
|
+
total_turns: messages.length,
|
|
313
|
+
truncated: true,
|
|
314
|
+
original_size: jsonSize
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
log(`Truncated transcript to ${result.messages.length} messages`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return result;
|
|
322
|
+
} catch (e) {
|
|
323
|
+
log(`Error extracting full transcript: ${e.message}`);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
153
328
|
(async () => {
|
|
154
329
|
const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-hook.log';
|
|
155
330
|
const log = (msg) => {
|
|
@@ -242,7 +417,36 @@ const extractLastAssistantMessage = async (transcriptPath, log) => {
|
|
|
242
417
|
log('Skipping assistant response log (stop_hook_active=true)');
|
|
243
418
|
}
|
|
244
419
|
|
|
245
|
-
// 2.
|
|
420
|
+
// 2. Store full transcript for session (new feature: PRD-0011 Phase 2)
|
|
421
|
+
// Only store if not in a recursive stop hook call
|
|
422
|
+
if (!stop_hook_active) {
|
|
423
|
+
try {
|
|
424
|
+
const transcriptData = await extractFullTranscript(transcript_path, log);
|
|
425
|
+
|
|
426
|
+
if (transcriptData && transcriptData.messages && transcriptData.messages.length > 0) {
|
|
427
|
+
await fetchJsonWithRetry(`${RELAY_API_URL}/api/sessions/${session_id}/transcript`, {
|
|
428
|
+
method: 'POST',
|
|
429
|
+
headers: {
|
|
430
|
+
'Content-Type': 'application/json',
|
|
431
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
432
|
+
},
|
|
433
|
+
body: JSON.stringify({
|
|
434
|
+
messages: transcriptData.messages,
|
|
435
|
+
total_turns: transcriptData.total_turns,
|
|
436
|
+
truncated: transcriptData.truncated,
|
|
437
|
+
original_size: transcriptData.original_size,
|
|
438
|
+
stored_at: Date.now()
|
|
439
|
+
})
|
|
440
|
+
}, log);
|
|
441
|
+
log(`Stored full transcript (${transcriptData.messages.length} messages, ${transcriptData.total_turns} total turns, truncated: ${transcriptData.truncated})`);
|
|
442
|
+
}
|
|
443
|
+
} catch (e) {
|
|
444
|
+
// Reliability: Log but don't fail hook if transcript storage fails
|
|
445
|
+
log(`Failed to store full transcript: ${e.message}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// 3. Check for pending messages from mobile app (existing functionality)
|
|
246
450
|
try {
|
|
247
451
|
const res = await fetch(`${RELAY_API_URL}/api/messages/pending?session_id=${encodeURIComponent(session_id)}`, {
|
|
248
452
|
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
|