teleportation-cli 1.1.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.claude/hooks/config-loader.mjs +88 -34
  2. package/.claude/hooks/permission_request.mjs +392 -82
  3. package/.claude/hooks/post_tool_use.mjs +90 -0
  4. package/.claude/hooks/pre_tool_use.mjs +247 -305
  5. package/.claude/hooks/session-register.mjs +94 -105
  6. package/.claude/hooks/session_end.mjs +41 -42
  7. package/.claude/hooks/session_start.mjs +45 -60
  8. package/.claude/hooks/stop.mjs +752 -99
  9. package/.claude/hooks/user_prompt_submit.mjs +26 -3
  10. package/README.md +7 -0
  11. package/lib/auth/api-key.js +12 -0
  12. package/lib/auth/token-refresh.js +286 -0
  13. package/lib/cli/daemon-commands.js +1 -1
  14. package/lib/cli/teleport-commands.js +469 -0
  15. package/lib/daemon/daemon-v2.js +104 -0
  16. package/lib/daemon/lifecycle.js +56 -171
  17. package/lib/daemon/response-classifier.js +15 -1
  18. package/lib/daemon/services/index.js +3 -0
  19. package/lib/daemon/services/polling-service.js +173 -0
  20. package/lib/daemon/services/queue-service.js +318 -0
  21. package/lib/daemon/services/session-service.js +115 -0
  22. package/lib/daemon/state.js +35 -0
  23. package/lib/daemon/task-executor-v2.js +413 -0
  24. package/lib/daemon/task-executor.js +1235 -0
  25. package/lib/daemon/teleportation-daemon.js +770 -25
  26. package/lib/daemon/timeline-analyzer.js +215 -0
  27. package/lib/daemon/transcript-ingestion.js +696 -0
  28. package/lib/daemon/utils.js +91 -0
  29. package/lib/install/installer.js +184 -20
  30. package/lib/install/uhr-installer.js +136 -0
  31. package/lib/remote/providers/base-provider.js +46 -0
  32. package/lib/remote/providers/daytona-provider.js +58 -0
  33. package/lib/remote/providers/provider-factory.js +90 -19
  34. package/lib/remote/providers/sprites-provider.js +711 -0
  35. package/lib/teleport/exporters/claude-exporter.js +302 -0
  36. package/lib/teleport/exporters/gemini-exporter.js +307 -0
  37. package/lib/teleport/exporters/index.js +93 -0
  38. package/lib/teleport/exporters/interface.js +153 -0
  39. package/lib/teleport/fork-tracker.js +415 -0
  40. package/lib/teleport/git-committer.js +337 -0
  41. package/lib/teleport/index.js +48 -0
  42. package/lib/teleport/manager.js +620 -0
  43. package/lib/teleport/session-capture.js +282 -0
  44. package/package.json +11 -5
  45. package/teleportation-cli.cjs +632 -451
  46. package/.claude/hooks/heartbeat.mjs +0 -396
  47. package/lib/daemon/agentic-executor.js +0 -803
  48. package/lib/daemon/pid-manager.js +0 -160
@@ -46,6 +46,10 @@ const fetchJson = async (url, opts) => {
46
46
 
47
47
  const { session_id, prompt } = input;
48
48
 
49
+ // Detect message source
50
+ const isAutonomousTask = env.TELEPORTATION_TASK_MODE === 'true' || !!env.TELEPORTATION_PARENT_SESSION_ID;
51
+ const source = isAutonomousTask ? 'autonomous_task' : 'cli_interactive';
52
+
49
53
  // Clear away mode only on actual user activity (prompt submit), not on tool attempts.
50
54
  // Also support /away and /back here in case they are handled as prompts.
51
55
  if (session_id && prompt && typeof prompt === 'string') {
@@ -53,8 +57,15 @@ const fetchJson = async (url, opts) => {
53
57
  const lowered = trimmed.toLowerCase();
54
58
 
55
59
  let desiredAway = null;
56
- if (lowered === '/away' || lowered === 'teleportation away') desiredAway = true;
57
- else if (lowered === '/back' || lowered === 'teleportation back') desiredAway = false;
60
+ // Support multiple command formats: /away, teleportation away, teleport away, teleporation away (typo)
61
+ if (lowered === '/away' ||
62
+ lowered === 'teleportation away' ||
63
+ lowered === 'teleport away' ||
64
+ lowered === 'teleporation away') desiredAway = true;
65
+ else if (lowered === '/back' ||
66
+ lowered === 'teleportation back' ||
67
+ lowered === 'teleport back' ||
68
+ lowered === 'teleporation back') desiredAway = false;
58
69
  else if (trimmed.length > 0) desiredAway = false;
59
70
 
60
71
  if (desiredAway !== null) {
@@ -66,13 +77,21 @@ const fetchJson = async (url, opts) => {
66
77
  const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
67
78
 
68
79
  if (RELAY_API_URL && RELAY_API_KEY) {
80
+ // When user types locally, signal they're back and set location to 'local'
81
+ // This helps the daemon know to stop auto-continuing after approvals
82
+ const patchBody = { is_away: desiredAway };
83
+ if (desiredAway === false) {
84
+ // User is active locally - mark location as 'local'
85
+ patchBody.last_approval_location = 'local';
86
+ }
87
+
69
88
  await fetchJson(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
70
89
  method: 'PATCH',
71
90
  headers: {
72
91
  'Content-Type': 'application/json',
73
92
  'Authorization': `Bearer ${RELAY_API_KEY}`
74
93
  },
75
- body: JSON.stringify({ is_away: desiredAway })
94
+ body: JSON.stringify(patchBody)
76
95
  });
77
96
 
78
97
  // Log away_mode_changed event to timeline (only for explicit /away or /back commands)
@@ -89,6 +108,7 @@ const fetchJson = async (url, opts) => {
89
108
  body: JSON.stringify({
90
109
  session_id,
91
110
  type: 'away_mode_changed',
111
+ source,
92
112
  data: {
93
113
  is_away: desiredAway,
94
114
  source: 'user_prompt',
@@ -150,6 +170,7 @@ const fetchJson = async (url, opts) => {
150
170
  body: JSON.stringify({
151
171
  session_id,
152
172
  type: 'model_change_requested',
173
+ source,
153
174
  data: {
154
175
  command: prompt,
155
176
  timestamp: Date.now()
@@ -200,10 +221,12 @@ const fetchJson = async (url, opts) => {
200
221
  body: JSON.stringify({
201
222
  session_id,
202
223
  type: 'user_message',
224
+ source,
203
225
  data: {
204
226
  prompt: truncatedPrompt,
205
227
  full_length: trimmed.length,
206
228
  truncated: trimmed.length > MAX_PROMPT_LENGTH,
229
+ source: 'cli',
207
230
  timestamp: Date.now()
208
231
  }
209
232
  })
package/README.md CHANGED
@@ -199,6 +199,13 @@ Configuration is stored in `~/.teleportation/`:
199
199
  └── teleportation # CLI symlink
200
200
  ```
201
201
 
202
+ ## Documentation
203
+
204
+ - `RUNBOOK.md` - What must be running (local/prod) + smoke checks
205
+ - `docs/E2E_TEST_PLAN.md` - End-to-end test plan and minimum regression matrix
206
+ - `DEPLOYMENT_GUIDE.md` - Fly.io / Cloudflare deployment steps
207
+ - `CHANGELOG.md` - Versioned change history
208
+
202
209
  Environment variables:
203
210
  - `TELEPORTATION_RELAY_URL` - Custom relay server URL
204
211
  - `TELEPORTATION_API_KEY` - API key for authentication
@@ -70,6 +70,18 @@ export async function testApiKey(apiKey, relayApiUrl, retryOptions = {}) {
70
70
  return { valid: true, data };
71
71
  } else if (response.status === 401) {
72
72
  return { valid: false, error: 'Invalid API key - authentication failed. Please check your API key and try again.' };
73
+ } else if (response.status === 403) {
74
+ // PRD-0018: Handle orphan API keys
75
+ const data = await response.json().catch(() => ({}));
76
+ if (data.error === 'orphan_api_key') {
77
+ return {
78
+ valid: false,
79
+ error: 'Orphan API key - not linked to an account.',
80
+ isOrphan: true,
81
+ message: data.message
82
+ };
83
+ }
84
+ return { valid: false, error: `Forbidden: ${data.message || 'You do not have permission to access this resource.'}` };
73
85
  } else if (response.status >= 500) {
74
86
  return { valid: false, error: `Relay API server error (${response.status}). The server may be temporarily unavailable. Please try again later.` };
75
87
  } else {
@@ -0,0 +1,286 @@
1
+ /**
2
+ * JWT Token Auto-Refresh Module (PRD-0019)
3
+ *
4
+ * Provides automatic access token refresh functionality for CLI and hooks.
5
+ * Access tokens are short-lived (15 min default), this module handles
6
+ * transparent refresh using the stored refresh token.
7
+ */
8
+
9
+ import { CredentialManager } from './credentials.js';
10
+
11
+ // Refresh access token if it expires within this time (ms)
12
+ const REFRESH_BUFFER_MS = 60000; // 1 minute before expiry
13
+
14
+ // Timeout for refresh API calls (ms)
15
+ const REFRESH_TIMEOUT_MS = 10000; // 10 seconds
16
+
17
+ // Retry on 401 (handles race condition when multiple hooks refresh concurrently)
18
+ const MAX_REFRESH_RETRIES = 1;
19
+
20
+ // Valid range for expiresIn (seconds) - prevents malicious/buggy server values
21
+ const MIN_EXPIRES_IN = 60; // 1 minute minimum
22
+ const MAX_EXPIRES_IN = 86400; // 24 hours maximum
23
+ const DEFAULT_EXPIRES_IN = 900; // 15 minutes default
24
+
25
+ /**
26
+ * Get a valid access token, refreshing if necessary.
27
+ *
28
+ * This is the main function used by hooks and CLI to get an authentication token.
29
+ * It handles:
30
+ * 1. Loading credentials from encrypted storage
31
+ * 2. Checking if access token is still valid
32
+ * 3. Refreshing the token if expired or expiring soon
33
+ * 4. Updating stored credentials with new tokens
34
+ *
35
+ * @param {object} options - Optional configuration
36
+ * @param {string} options.credentialsPath - Custom credentials path (for testing)
37
+ * @param {string} options.keyPath - Custom key path (for testing)
38
+ * @returns {Promise<string>} Valid access token
39
+ * @throws {Error} If no credentials, refresh fails, or session expired
40
+ *
41
+ * @example
42
+ * ```javascript
43
+ * import { getValidAccessToken } from './token-refresh.js';
44
+ *
45
+ * const token = await getValidAccessToken();
46
+ * const response = await fetch(url, {
47
+ * headers: { Authorization: `Bearer ${token}` }
48
+ * });
49
+ * ```
50
+ */
51
+ export async function getValidAccessToken(options = {}) {
52
+ const manager = new CredentialManager(options.credentialsPath, options.keyPath);
53
+
54
+ // Load current credentials (use let to allow reassignment on retry)
55
+ let credentials = await manager.load();
56
+
57
+ if (!credentials) {
58
+ throw new Error('No credentials found. Run: teleportation login');
59
+ }
60
+
61
+ // Check for JWT credentials
62
+ if (!credentials.refreshToken) {
63
+ // Fall back to legacy API key if available
64
+ if (credentials.apiKey) {
65
+ return credentials.apiKey;
66
+ }
67
+ throw new Error('No JWT or API key found. Run: teleportation login');
68
+ }
69
+
70
+ // Check if access token is still valid
71
+ const now = Date.now();
72
+ const expiresAt = credentials.accessTokenExpiresAt || 0;
73
+ const needsRefresh = expiresAt < (now + REFRESH_BUFFER_MS);
74
+
75
+ if (!needsRefresh && credentials.accessToken) {
76
+ // Access token is still valid
77
+ return credentials.accessToken;
78
+ }
79
+
80
+ // Need to refresh - call the relay API
81
+ const relayUrl = credentials.relayApiUrl || 'https://api.teleportation.dev';
82
+
83
+ // Retry loop handles race condition when multiple hooks refresh concurrently
84
+ // (one hook rotates the refresh token, making the other's token invalid)
85
+ let lastError = null;
86
+ for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
87
+ try {
88
+ // Reload credentials on retry (another process may have updated them)
89
+ if (attempt > 0) {
90
+ const freshCredentials = await manager.load();
91
+ if (freshCredentials && freshCredentials.refreshToken) {
92
+ credentials = freshCredentials;
93
+ // Check if fresh credentials have a valid access token
94
+ const freshExpiresAt = freshCredentials.accessTokenExpiresAt || 0;
95
+ if (freshExpiresAt > (Date.now() + REFRESH_BUFFER_MS) && freshCredentials.accessToken) {
96
+ // Another process already refreshed, use the new token
97
+ // Debug: race condition resolved by using fresh credentials
98
+ if (process.env.DEBUG) {
99
+ console.error('[token-refresh] Race condition resolved: using token refreshed by another process');
100
+ }
101
+ return freshCredentials.accessToken;
102
+ }
103
+ }
104
+ }
105
+
106
+ // Create abort controller for timeout
107
+ const controller = new AbortController();
108
+ const timeoutId = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS);
109
+
110
+ let response;
111
+ try {
112
+ response = await fetch(`${relayUrl}/api/auth/refresh`, {
113
+ method: 'POST',
114
+ headers: {
115
+ 'Content-Type': 'application/json',
116
+ },
117
+ body: JSON.stringify({ refreshToken: credentials.refreshToken }),
118
+ signal: controller.signal,
119
+ });
120
+ } finally {
121
+ clearTimeout(timeoutId);
122
+ }
123
+
124
+ if (!response.ok) {
125
+ const errorData = await response.json().catch(() => ({}));
126
+
127
+ if (response.status === 401) {
128
+ // Refresh token is invalid - could be race condition, retry once
129
+ if (attempt < MAX_REFRESH_RETRIES) {
130
+ lastError = new Error('Refresh token invalid, retrying...');
131
+ continue; // Retry with fresh credentials
132
+ }
133
+ // Final attempt failed
134
+ throw new Error(
135
+ 'Session expired. Run: teleportation login\n' +
136
+ 'Your refresh token is no longer valid.'
137
+ );
138
+ }
139
+
140
+ if (response.status === 503) {
141
+ throw new Error(
142
+ 'JWT authentication is not enabled on this relay.\n' +
143
+ 'Use API key authentication: teleportation login --api-key YOUR_KEY'
144
+ );
145
+ }
146
+
147
+ throw new Error(
148
+ `Failed to refresh token: ${errorData.message || response.statusText}`
149
+ );
150
+ }
151
+
152
+ const tokenData = await response.json();
153
+
154
+ // Validate required fields in response
155
+ if (!tokenData.accessToken || !tokenData.refreshToken) {
156
+ throw new Error(
157
+ 'Invalid token response from relay: missing accessToken or refreshToken'
158
+ );
159
+ }
160
+
161
+ // Validate and clamp expiresIn to prevent malicious/buggy server values
162
+ let expiresIn = DEFAULT_EXPIRES_IN;
163
+ if (typeof tokenData.expiresIn === 'number') {
164
+ // Clamp to valid range (1 minute to 24 hours)
165
+ expiresIn = Math.max(MIN_EXPIRES_IN, Math.min(MAX_EXPIRES_IN, tokenData.expiresIn));
166
+ if (expiresIn !== tokenData.expiresIn && process.env.DEBUG) {
167
+ console.error(`[token-refresh] expiresIn clamped from ${tokenData.expiresIn}s to ${expiresIn}s`);
168
+ }
169
+ }
170
+
171
+ // Calculate new expiry time
172
+ const newExpiresAt = Date.now() + (expiresIn * 1000);
173
+
174
+ // Update stored credentials
175
+ await manager.update({
176
+ accessToken: tokenData.accessToken,
177
+ refreshToken: tokenData.refreshToken, // Refresh token is rotated
178
+ accessTokenExpiresAt: newExpiresAt,
179
+ refreshTokenId: tokenData.refreshTokenId,
180
+ });
181
+
182
+ return tokenData.accessToken;
183
+ } catch (error) {
184
+ // Handle abort (timeout)
185
+ if (error.name === 'AbortError') {
186
+ throw new Error(
187
+ `Token refresh timed out after ${REFRESH_TIMEOUT_MS / 1000} seconds.\n` +
188
+ 'Check your network connection or relay URL.'
189
+ );
190
+ }
191
+
192
+ // Handle network errors
193
+ if (error.message.includes('fetch failed') || error.code === 'ECONNREFUSED') {
194
+ throw new Error(
195
+ `Cannot reach relay at ${relayUrl}.\n` +
196
+ 'Check your network connection or relay URL.'
197
+ );
198
+ }
199
+
200
+ lastError = error;
201
+
202
+ // Don't retry on non-401 errors
203
+ if (!error.message.includes('retrying')) {
204
+ throw error;
205
+ }
206
+ }
207
+ }
208
+
209
+ // If we get here, all retries failed
210
+ throw lastError || new Error('Token refresh failed after retries');
211
+ }
212
+
213
+ /**
214
+ * Check if credentials have a valid JWT configuration.
215
+ *
216
+ * @param {object} options - Optional configuration
217
+ * @param {string} options.credentialsPath - Custom credentials path
218
+ * @param {string} options.keyPath - Custom key path
219
+ * @returns {Promise<boolean>} True if JWT credentials are present
220
+ */
221
+ export async function hasJwtCredentials(options = {}) {
222
+ try {
223
+ const manager = new CredentialManager(options.credentialsPath, options.keyPath);
224
+ const credentials = await manager.load();
225
+ return !!(credentials && credentials.refreshToken);
226
+ } catch {
227
+ return false;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Check if credentials have any valid authentication method.
233
+ *
234
+ * @param {object} options - Optional configuration
235
+ * @returns {Promise<{hasJwt: boolean, hasApiKey: boolean, method: string|null}>}
236
+ */
237
+ export async function getAuthMethod(options = {}) {
238
+ try {
239
+ const manager = new CredentialManager(options.credentialsPath, options.keyPath);
240
+ const credentials = await manager.load();
241
+
242
+ if (!credentials) {
243
+ return { hasJwt: false, hasApiKey: false, method: null };
244
+ }
245
+
246
+ const hasJwt = !!credentials.refreshToken;
247
+ const hasApiKey = !!credentials.apiKey;
248
+
249
+ let method = null;
250
+ if (hasJwt) {
251
+ method = 'jwt';
252
+ } else if (hasApiKey) {
253
+ method = 'api-key';
254
+ }
255
+
256
+ return { hasJwt, hasApiKey, method };
257
+ } catch {
258
+ return { hasJwt: false, hasApiKey: false, method: null };
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Get token expiry information.
264
+ *
265
+ * @param {object} options - Optional configuration
266
+ * @returns {Promise<{expiresAt: number|null, expiresIn: number|null, isExpired: boolean}>}
267
+ */
268
+ export async function getTokenExpiry(options = {}) {
269
+ try {
270
+ const manager = new CredentialManager(options.credentialsPath, options.keyPath);
271
+ const credentials = await manager.load();
272
+
273
+ if (!credentials || !credentials.accessTokenExpiresAt) {
274
+ return { expiresAt: null, expiresIn: null, isExpired: true };
275
+ }
276
+
277
+ const now = Date.now();
278
+ const expiresAt = credentials.accessTokenExpiresAt;
279
+ const expiresIn = Math.max(0, expiresAt - now);
280
+ const isExpired = expiresAt < now;
281
+
282
+ return { expiresAt, expiresIn, isExpired };
283
+ } catch {
284
+ return { expiresAt: null, expiresIn: null, isExpired: true };
285
+ }
286
+ }
@@ -3,7 +3,7 @@
3
3
  * Handles away mode, back mode, and daemon status commands
4
4
  */
5
5
 
6
- import { checkDaemonStatus, startDaemon, stopDaemon } from '../daemon/pid-manager.js';
6
+ import { checkDaemonStatus, startDaemon, stopDaemon } from '../daemon/lifecycle.js';
7
7
 
8
8
  // Color helpers
9
9
  const c = {