teleportation-cli 1.0.0 → 1.0.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/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/user_prompt_submit.mjs +54 -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/install/installer.js +22 -7
- 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',
|
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
import { stdin, stdout, exit, env } from 'node:process';
|
|
10
10
|
import { tmpdir } from 'os';
|
|
11
11
|
import { join } from 'path';
|
|
12
|
+
import { appendFile } from 'fs/promises';
|
|
13
|
+
|
|
14
|
+
const HOOK_LOG = '/tmp/teleportation-hook.log';
|
|
15
|
+
const log = async (msg) => {
|
|
16
|
+
const ts = new Date().toISOString();
|
|
17
|
+
try {
|
|
18
|
+
await appendFile(HOOK_LOG, `[${ts}] [UserPromptSubmit] ${msg}\n`);
|
|
19
|
+
} catch {}
|
|
20
|
+
};
|
|
12
21
|
|
|
13
22
|
const readStdin = () => new Promise((resolve, reject) => {
|
|
14
23
|
let data = '';
|
|
@@ -18,6 +27,12 @@ const readStdin = () => new Promise((resolve, reject) => {
|
|
|
18
27
|
stdin.on('error', reject);
|
|
19
28
|
});
|
|
20
29
|
|
|
30
|
+
const fetchJson = async (url, opts) => {
|
|
31
|
+
const res = await fetch(url, opts);
|
|
32
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
33
|
+
return res.json();
|
|
34
|
+
};
|
|
35
|
+
|
|
21
36
|
(async () => {
|
|
22
37
|
let input = {};
|
|
23
38
|
try {
|
|
@@ -31,6 +46,45 @@ const readStdin = () => new Promise((resolve, reject) => {
|
|
|
31
46
|
|
|
32
47
|
const { session_id, prompt } = input;
|
|
33
48
|
|
|
49
|
+
// Clear away mode only on actual user activity (prompt submit), not on tool attempts.
|
|
50
|
+
// Also support /away and /back here in case they are handled as prompts.
|
|
51
|
+
if (session_id && prompt && typeof prompt === 'string') {
|
|
52
|
+
const trimmed = prompt.trim();
|
|
53
|
+
const lowered = trimmed.toLowerCase();
|
|
54
|
+
|
|
55
|
+
let desiredAway = null;
|
|
56
|
+
if (lowered === '/away' || lowered === 'teleportation away') desiredAway = true;
|
|
57
|
+
else if (lowered === '/back' || lowered === 'teleportation back') desiredAway = false;
|
|
58
|
+
else if (trimmed.length > 0) desiredAway = false;
|
|
59
|
+
|
|
60
|
+
if (desiredAway !== null) {
|
|
61
|
+
try {
|
|
62
|
+
const { loadConfig } = await import('./config-loader.mjs');
|
|
63
|
+
const config = await loadConfig();
|
|
64
|
+
|
|
65
|
+
const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
|
|
66
|
+
const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
|
|
67
|
+
|
|
68
|
+
if (RELAY_API_URL && RELAY_API_KEY) {
|
|
69
|
+
await fetchJson(`${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({ is_away: desiredAway })
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
// Always log failures to hook log file for debugging (not just in DEBUG mode)
|
|
80
|
+
log(`Failed to update away state: ${e.message}`);
|
|
81
|
+
if (env.DEBUG) {
|
|
82
|
+
console.error(`[UserPromptSubmit] Failed to update away state: ${e.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
34
88
|
// Check if user is running /model command
|
|
35
89
|
if (prompt && typeof prompt === 'string') {
|
|
36
90
|
const trimmed = prompt.trim().toLowerCase();
|
package/README.md
CHANGED
|
@@ -10,13 +10,29 @@ Teleportation enables developers to approve Claude Code actions remotely from an
|
|
|
10
10
|
|
|
11
11
|
## Installation
|
|
12
12
|
|
|
13
|
-
### Method 1:
|
|
13
|
+
### Method 1: npm (Recommended)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Install globally via npm
|
|
17
|
+
npm install -g teleportation-cli
|
|
18
|
+
|
|
19
|
+
# Verify installation
|
|
20
|
+
teleportation --version
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Requirements:**
|
|
24
|
+
- **Bun >=1.3.0** - [Install Bun](https://bun.sh/docs/installation)
|
|
25
|
+
- **Claude Code CLI** - [Install Claude Code](https://claude.ai/code)
|
|
26
|
+
|
|
27
|
+
> **Note:** While the package uses npm for distribution, it requires Bun as the runtime. Make sure to install Bun first.
|
|
28
|
+
|
|
29
|
+
### Method 2: Quick Install (curl)
|
|
14
30
|
|
|
15
31
|
```bash
|
|
16
32
|
curl -fsSL https://raw.githubusercontent.com/dundas/teleportation-private/main/scripts/install.sh | bash
|
|
17
33
|
```
|
|
18
34
|
|
|
19
|
-
### Method
|
|
35
|
+
### Method 3: From Source
|
|
20
36
|
|
|
21
37
|
**Requirements:** Bun >=1.3.0
|
|
22
38
|
|
|
@@ -31,15 +47,10 @@ bun install
|
|
|
31
47
|
bun link # Makes 'teleportation' command available globally
|
|
32
48
|
```
|
|
33
49
|
|
|
34
|
-
### Method
|
|
50
|
+
### Method 4: GitHub Releases
|
|
35
51
|
|
|
36
52
|
Download the latest release from [GitHub Releases](https://github.com/dundas/teleportation-private/releases/latest) and extract to `~/.teleportation/`.
|
|
37
53
|
|
|
38
|
-
## Requirements
|
|
39
|
-
|
|
40
|
-
- **Bun >=1.3.0** (JavaScript runtime)
|
|
41
|
-
- **Claude Code CLI** installed
|
|
42
|
-
|
|
43
54
|
### Installing Bun
|
|
44
55
|
|
|
45
56
|
```bash
|
|
@@ -77,16 +88,19 @@ bun install
|
|
|
77
88
|
## Quick Start
|
|
78
89
|
|
|
79
90
|
```bash
|
|
80
|
-
# 1.
|
|
91
|
+
# 1. Install via npm
|
|
92
|
+
npm install -g teleportation-cli
|
|
93
|
+
|
|
94
|
+
# 2. Enable hooks
|
|
81
95
|
teleportation on
|
|
82
96
|
|
|
83
|
-
#
|
|
97
|
+
# 3. Authenticate with your account
|
|
84
98
|
teleportation login
|
|
85
99
|
|
|
86
|
-
#
|
|
100
|
+
# 4. Check status
|
|
87
101
|
teleportation status
|
|
88
102
|
|
|
89
|
-
#
|
|
103
|
+
# 5. Start Claude Code - approvals will be routed to your phone!
|
|
90
104
|
```
|
|
91
105
|
|
|
92
106
|
## Usage
|
|
@@ -229,6 +243,16 @@ bun run dev:all
|
|
|
229
243
|
- **Multi-tenant isolation**: Your data is isolated from other users
|
|
230
244
|
- **Privacy-preserving**: Session existence not revealed to unauthorized users
|
|
231
245
|
|
|
246
|
+
## Known Limitations
|
|
247
|
+
|
|
248
|
+
### Session Registry Race Condition
|
|
249
|
+
|
|
250
|
+
The remote session registry uses file-based storage (`~/.teleportation/remote-sessions/sessions.json`). There is a theoretical race condition if multiple processes attempt to register the same session ID simultaneously. The check for existing sessions and the save operation are not atomic.
|
|
251
|
+
|
|
252
|
+
**Impact**: This is an acceptable limitation for the current single-user CLI use case.
|
|
253
|
+
|
|
254
|
+
**Future mitigation**: Future versions may implement file locking (e.g., using `proper-lockfile` package) or migrate to a database (e.g., SQLite) for multi-user or concurrent scenarios.
|
|
255
|
+
|
|
232
256
|
## License
|
|
233
257
|
|
|
234
258
|
MIT
|