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.
@@ -16,6 +16,51 @@ import { MachineCoder, CODER_NAMES } from './interface.js';
16
16
 
17
17
  const execAsync = promisify(exec);
18
18
 
19
+ // Grace period before killing process after receiving result (allows hooks to finish)
20
+ // Configurable via environment variable for production tuning
21
+ const PROCESS_KILL_GRACE_MS = parseInt(process.env.TELEPORTATION_PROCESS_GRACE_MS || '50', 10);
22
+
23
+ // Environment variables to pass to Claude Code process (allowlist for security)
24
+ // Only pass essential env vars instead of spreading all of process.env
25
+ const ALLOWED_ENV_VARS = [
26
+ 'HOME',
27
+ 'PATH',
28
+ 'SHELL',
29
+ 'USER',
30
+ 'TERM',
31
+ 'LANG',
32
+ 'LC_ALL',
33
+ 'TMPDIR',
34
+ 'XDG_CONFIG_HOME',
35
+ 'XDG_DATA_HOME',
36
+ 'XDG_CACHE_HOME',
37
+ // Claude Code specific
38
+ 'ANTHROPIC_API_KEY',
39
+ 'CLAUDE_CODE_USE_BEDROCK',
40
+ 'CLAUDE_CODE_USE_VERTEX',
41
+ // Node.js
42
+ 'NODE_ENV',
43
+ 'NODE_OPTIONS',
44
+ ];
45
+
46
+ /**
47
+ * Build a filtered environment object for spawning Claude Code
48
+ * Uses allowlist approach for security - only passes essential env vars
49
+ * @param {string} sessionId - Teleportation session ID to pass
50
+ * @returns {Object} Environment object
51
+ */
52
+ function buildSecureEnv(sessionId) {
53
+ const env = {};
54
+ for (const key of ALLOWED_ENV_VARS) {
55
+ if (process.env[key] !== undefined) {
56
+ env[key] = process.env[key];
57
+ }
58
+ }
59
+ // Always pass session ID to hooks
60
+ env.TELEPORTATION_SESSION_ID = sessionId;
61
+ return env;
62
+ }
63
+
19
64
  /**
20
65
  * Check if a command exists on the system
21
66
  * @param {string} command
@@ -36,9 +81,25 @@ async function commandExists(command) {
36
81
  export class ClaudeCodeAdapter extends MachineCoder {
37
82
  name = CODER_NAMES.CLAUDE_CODE;
38
83
  displayName = 'Claude Code';
39
-
84
+
40
85
  // Track running processes for stop()
41
86
  #runningProcesses = new Map();
87
+
88
+ /**
89
+ * Close stdin immediately to prevent Claude Code from hanging
90
+ * Claude Code waits for stdin to close when spawned - without this,
91
+ * the process hangs indefinitely waiting for possible input.
92
+ * @param {ChildProcess} proc - The spawned process
93
+ * @private
94
+ */
95
+ #closeStdinSafely(proc) {
96
+ try {
97
+ proc.stdin.end();
98
+ } catch (err) {
99
+ // Log but don't throw - stdin might already be closed or in error state
100
+ console.warn('[claude-adapter] Failed to close stdin:', err.message);
101
+ }
102
+ }
42
103
 
43
104
  /**
44
105
  * Check if Claude Code CLI is available
@@ -102,40 +163,39 @@ export class ClaudeCodeAdapter extends MachineCoder {
102
163
 
103
164
  // Output format for parsing
104
165
  args.push('--output-format', 'json');
105
-
166
+
106
167
  return new Promise((resolve, reject) => {
107
168
  const startTime = Date.now();
108
169
  let stdout = '';
109
170
  let stderr = '';
110
171
  const toolCalls = [];
111
-
172
+ let resolved = false; // Prevent double-resolution race condition
173
+ let killTimeout = null; // Track kill timeout for cleanup
174
+
112
175
  const proc = spawn('claude', args, {
113
176
  cwd: projectPath,
114
- env: {
115
- ...process.env,
116
- // Pass session ID to hooks
117
- TELEPORTATION_SESSION_ID: sessionId,
118
- },
177
+ env: buildSecureEnv(sessionId),
119
178
  });
120
-
179
+
121
180
  this.#runningProcesses.set(executionId, proc);
122
-
181
+ this.#closeStdinSafely(proc);
182
+
123
183
  // Timeout handling
124
184
  const timeout = setTimeout(() => {
125
185
  proc.kill('SIGTERM');
126
186
  reject(new Error(`Execution timed out after ${timeoutMs}ms`));
127
187
  }, timeoutMs);
128
-
188
+
129
189
  proc.stdout.on('data', (data) => {
130
190
  const chunk = data.toString();
131
191
  stdout += chunk;
132
-
192
+
133
193
  // Try to parse JSON events for progress
134
194
  try {
135
195
  const lines = chunk.split('\n').filter(l => l.trim());
136
196
  for (const line of lines) {
137
197
  const event = JSON.parse(line);
138
-
198
+
139
199
  // Track tool calls
140
200
  if (event.type === 'tool_use') {
141
201
  toolCalls.push({
@@ -145,12 +205,49 @@ export class ClaudeCodeAdapter extends MachineCoder {
145
205
  timestamp: Date.now(),
146
206
  });
147
207
  }
148
-
208
+
149
209
  onProgress?.({
150
210
  type: event.type,
151
211
  timestamp: Date.now(),
152
212
  data: event,
153
213
  });
214
+
215
+ // When we receive a final result, resolve early and kill the process
216
+ // This handles cases where hooks keep the process alive after completion
217
+ if (event.type === 'result') {
218
+ if (resolved) return; // Prevent double-resolution
219
+ resolved = true;
220
+
221
+ clearTimeout(timeout);
222
+ this.#runningProcesses.delete(executionId);
223
+
224
+ const durationMs = Date.now() - startTime;
225
+ const output = event.result || event.response || event.output || '';
226
+ const stats = { durationMs };
227
+
228
+ if (event.usage) {
229
+ stats.tokensUsed = event.usage.total_tokens;
230
+ }
231
+ if (event.cost || event.total_cost_usd) {
232
+ stats.cost = event.cost || event.total_cost_usd;
233
+ }
234
+ if (event.model) {
235
+ stats.model = event.model;
236
+ }
237
+
238
+ // Kill the process since we have the result (small delay for hooks to finish)
239
+ killTimeout = setTimeout(() => proc.kill('SIGTERM'), PROCESS_KILL_GRACE_MS);
240
+
241
+ resolve({
242
+ success: !event.is_error,
243
+ output,
244
+ error: event.is_error ? (event.error || 'Unknown error') : undefined,
245
+ toolCalls,
246
+ stats,
247
+ executionId,
248
+ });
249
+ return;
250
+ }
154
251
  }
155
252
  } catch {
156
253
  // Not JSON, just raw output
@@ -172,15 +269,19 @@ export class ClaudeCodeAdapter extends MachineCoder {
172
269
  });
173
270
 
174
271
  proc.on('close', (code) => {
272
+ if (resolved) return; // Prevent double-resolution
273
+ resolved = true;
274
+
175
275
  clearTimeout(timeout);
276
+ if (killTimeout) clearTimeout(killTimeout); // Clean up kill timeout if process exits naturally
176
277
  this.#runningProcesses.delete(executionId);
177
-
278
+
178
279
  const durationMs = Date.now() - startTime;
179
-
280
+
180
281
  // Try to parse final JSON output
181
282
  let output = stdout;
182
283
  let stats = { durationMs };
183
-
284
+
184
285
  try {
185
286
  const result = JSON.parse(stdout);
186
287
  output = result.result || result.response || result.output || stdout;
@@ -196,7 +297,7 @@ export class ClaudeCodeAdapter extends MachineCoder {
196
297
  } catch {
197
298
  // Not JSON, use raw output
198
299
  }
199
-
300
+
200
301
  resolve({
201
302
  success: code === 0,
202
303
  output,
@@ -238,45 +339,98 @@ export class ClaudeCodeAdapter extends MachineCoder {
238
339
  }
239
340
 
240
341
  args.push('--output-format', 'json');
241
-
342
+
242
343
  return new Promise((resolve, reject) => {
243
344
  const startTime = Date.now();
244
345
  let stdout = '';
245
346
  let stderr = '';
246
347
  const toolCalls = [];
247
-
348
+ let resolved = false; // Prevent double-resolution race condition
349
+ let killTimeout = null; // Track kill timeout for cleanup
350
+
248
351
  const proc = spawn('claude', args, {
249
352
  cwd: projectPath,
250
- env: {
251
- ...process.env,
252
- TELEPORTATION_SESSION_ID: sessionId,
253
- },
353
+ env: buildSecureEnv(sessionId),
254
354
  });
255
-
355
+
256
356
  this.#runningProcesses.set(executionId, proc);
257
-
357
+ this.#closeStdinSafely(proc);
358
+
258
359
  const timeout = setTimeout(() => {
259
360
  proc.kill('SIGTERM');
260
361
  reject(new Error(`Resume timed out after ${timeoutMs}ms`));
261
362
  }, timeoutMs);
262
-
363
+
263
364
  proc.stdout.on('data', (data) => {
264
- stdout += data.toString();
265
- onProgress?.({
266
- type: 'output',
267
- timestamp: Date.now(),
268
- data: { text: data.toString() },
269
- });
365
+ const chunk = data.toString();
366
+ stdout += chunk;
367
+
368
+ // Try to parse JSON result
369
+ try {
370
+ const lines = chunk.split('\n').filter(l => l.trim());
371
+ for (const line of lines) {
372
+ const event = JSON.parse(line);
373
+
374
+ onProgress?.({
375
+ type: event.type || 'output',
376
+ timestamp: Date.now(),
377
+ data: event,
378
+ });
379
+
380
+ // When we receive a final result, resolve early and kill the process
381
+ if (event.type === 'result') {
382
+ if (resolved) return; // Prevent double-resolution
383
+ resolved = true;
384
+
385
+ clearTimeout(timeout);
386
+ this.#runningProcesses.delete(executionId);
387
+
388
+ const output = event.result || event.response || event.output || '';
389
+ const stats = { durationMs: Date.now() - startTime };
390
+
391
+ if (event.usage) {
392
+ stats.tokensUsed = event.usage.total_tokens;
393
+ }
394
+ if (event.cost || event.total_cost_usd) {
395
+ stats.cost = event.cost || event.total_cost_usd;
396
+ }
397
+
398
+ // Kill the process since we have the result (small delay for hooks to finish)
399
+ killTimeout = setTimeout(() => proc.kill('SIGTERM'), PROCESS_KILL_GRACE_MS);
400
+
401
+ resolve({
402
+ success: !event.is_error,
403
+ output,
404
+ error: event.is_error ? (event.error || 'Unknown error') : undefined,
405
+ toolCalls,
406
+ stats,
407
+ executionId,
408
+ });
409
+ return;
410
+ }
411
+ }
412
+ } catch {
413
+ // Not JSON, just raw output
414
+ onProgress?.({
415
+ type: 'output',
416
+ timestamp: Date.now(),
417
+ data: { text: chunk },
418
+ });
419
+ }
270
420
  });
271
-
421
+
272
422
  proc.stderr.on('data', (data) => {
273
423
  stderr += data.toString();
274
424
  });
275
-
425
+
276
426
  proc.on('close', (code) => {
427
+ if (resolved) return; // Prevent double-resolution
428
+ resolved = true;
429
+
277
430
  clearTimeout(timeout);
431
+ if (killTimeout) clearTimeout(killTimeout); // Clean up kill timeout if process exits naturally
278
432
  this.#runningProcesses.delete(executionId);
279
-
433
+
280
434
  resolve({
281
435
  success: code === 0,
282
436
  output: stdout,
@@ -286,7 +440,7 @@ export class ClaudeCodeAdapter extends MachineCoder {
286
440
  executionId,
287
441
  });
288
442
  });
289
-
443
+
290
444
  proc.on('error', (err) => {
291
445
  clearTimeout(timeout);
292
446
  this.#runningProcesses.delete(executionId);
@@ -0,0 +1,213 @@
1
+ /**
2
+ * CodeSync
3
+ *
4
+ * Manages git state synchronization between local and remote machines:
5
+ * - Capture local state (branch, commit, uncommitted changes)
6
+ * - Sync code to remote machine
7
+ * - Pull changes from remote
8
+ * - Conflict detection
9
+ *
10
+ * Integrates with existing GitHandoff system from lib/handoff/git-handoff.js
11
+ */
12
+
13
+ import { GitHandoff } from '../handoff/git-handoff.js';
14
+
15
+ /**
16
+ * Local state structure
17
+ * @typedef {Object} LocalState
18
+ * @property {string} branch - Current branch name
19
+ * @property {string} commit - Current commit hash
20
+ * @property {string} remoteUrl - Remote repository URL
21
+ * @property {Array<string>} uncommittedChanges - List of uncommitted files
22
+ * @property {boolean} hasUncommitted - Whether there are uncommitted changes
23
+ * @property {number} timestamp - Capture timestamp
24
+ */
25
+
26
+ /**
27
+ * CodeSync class
28
+ */
29
+ export class CodeSync {
30
+ /**
31
+ * @param {Object} options
32
+ * @param {string} options.repoPath - Path to git repository
33
+ * @param {string} options.sessionId - Session identifier
34
+ * @param {Object} [options.gitHandoff] - GitHandoff instance (optional, will create if not provided)
35
+ */
36
+ constructor(options) {
37
+ const { repoPath, sessionId, gitHandoff } = options;
38
+
39
+ if (!repoPath) {
40
+ throw new Error('repoPath is required');
41
+ }
42
+
43
+ if (!sessionId) {
44
+ throw new Error('sessionId is required');
45
+ }
46
+
47
+ this.repoPath = repoPath;
48
+ this.sessionId = sessionId;
49
+
50
+ // Use provided GitHandoff or create new one
51
+ this.gitHandoff = gitHandoff || new GitHandoff({
52
+ repoPath,
53
+ sessionId,
54
+ branchPrefix: 'teleport/wip-'
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Capture current local git state
60
+ * @returns {Promise<LocalState>}
61
+ */
62
+ async captureLocalState() {
63
+ // Verify git repository
64
+ const isRepo = await this.gitHandoff.isGitRepo();
65
+ if (!isRepo) {
66
+ throw new Error(`Not a git repository: ${this.repoPath}`);
67
+ }
68
+
69
+ // Get current state
70
+ const branch = await this.gitHandoff.getCurrentBranch();
71
+ const commit = await this.gitHandoff.getCurrentCommit();
72
+ const remoteUrl = await this.gitHandoff.getRemoteUrl();
73
+
74
+ if (!remoteUrl) {
75
+ throw new Error('No remote URL configured');
76
+ }
77
+
78
+ const hasUncommitted = await this.gitHandoff.hasUncommittedChanges();
79
+ const uncommittedChanges = hasUncommitted
80
+ ? await this.gitHandoff.getChangedFiles()
81
+ : [];
82
+
83
+ return {
84
+ branch,
85
+ commit,
86
+ remoteUrl,
87
+ uncommittedChanges,
88
+ hasUncommitted,
89
+ timestamp: Date.now()
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Sync code to remote machine
95
+ * @param {Object} options
96
+ * @param {string} options.machineId - Remote machine ID
97
+ * @param {LocalState} [options.localState] - Pre-captured local state (optional)
98
+ * @returns {Promise<Object>}
99
+ */
100
+ async syncToRemote(options) {
101
+ const { machineId, localState: providedState } = options;
102
+
103
+ // Use provided state or capture fresh
104
+ const localState = providedState || await this.captureLocalState();
105
+
106
+ // If there are uncommitted changes, commit them as WIP
107
+ let wipCommit = null;
108
+ if (localState.hasUncommitted) {
109
+ const hasChanges = await this.gitHandoff.hasUncommittedChanges();
110
+ if (hasChanges) {
111
+ const result = await this.gitHandoff.commitWip(
112
+ `Remote sync for session ${this.sessionId}`
113
+ );
114
+ wipCommit = result.commit;
115
+ }
116
+ }
117
+
118
+ // Push to WIP branch
119
+ await this.gitHandoff.pushToWipBranch(true); // force push
120
+
121
+ return {
122
+ success: true,
123
+ sessionId: this.sessionId,
124
+ machineId,
125
+ branch: localState.branch,
126
+ commit: localState.commit,
127
+ wipCommit,
128
+ syncedAt: Date.now()
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Pull changes from remote machine
134
+ * @param {Object} options
135
+ * @param {string} [options.strategy='merge'] - Sync strategy ('merge' or 'rebase')
136
+ * @param {boolean} [options.stashLocal=true] - Stash local changes before pulling
137
+ * @returns {Promise<Object>}
138
+ */
139
+ async pullFromRemote(options = {}) {
140
+ const { strategy = 'merge', stashLocal = true } = options;
141
+
142
+ // Fetch WIP branch
143
+ await this.gitHandoff.fetchWipBranch();
144
+
145
+ // Check if there are remote changes
146
+ const remoteCheck = await this.gitHandoff.checkRemoteChanges();
147
+ if (!remoteCheck.hasChanges) {
148
+ return {
149
+ success: true,
150
+ hasChanges: false,
151
+ commit: remoteCheck.localCommit
152
+ };
153
+ }
154
+
155
+ // Sync from remote
156
+ const result = await this.gitHandoff.syncFromRemote({
157
+ strategy,
158
+ stashLocal
159
+ });
160
+
161
+ return {
162
+ success: result.success,
163
+ hasChanges: true,
164
+ commit: result.commit,
165
+ conflicts: result.conflicts
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Get diff summary between local and remote
171
+ * @returns {Promise<Object>}
172
+ */
173
+ async getDiffSummary() {
174
+ await this.gitHandoff.fetchWipBranch();
175
+ return this.gitHandoff.getDiffSummary();
176
+ }
177
+
178
+ /**
179
+ * Detect potential conflicts between local and remote
180
+ * @returns {Promise<Object>}
181
+ */
182
+ async detectConflicts() {
183
+ // Check remote changes
184
+ const remoteCheck = await this.gitHandoff.checkRemoteChanges();
185
+
186
+ // Check local uncommitted changes
187
+ const hasUncommitted = await this.gitHandoff.hasUncommittedChanges();
188
+ const localChanges = hasUncommitted
189
+ ? await this.gitHandoff.getChangedFiles()
190
+ : [];
191
+
192
+ // Get diff summary
193
+ const diff = await this.getDiffSummary();
194
+
195
+ // Determine if branches diverged
196
+ const diverged = diff.ahead > 0 && diff.behind > 0;
197
+
198
+ // Find conflicting files (overlap between remote changes and local uncommitted)
199
+ const conflictingFiles = localChanges.filter((file) =>
200
+ diff.files.includes(file)
201
+ );
202
+
203
+ return {
204
+ hasConflicts: conflictingFiles.length > 0 || (remoteCheck.hasChanges && hasUncommitted),
205
+ diverged,
206
+ conflictingFiles,
207
+ remoteChanges: diff.files,
208
+ localChanges
209
+ };
210
+ }
211
+ }
212
+
213
+ export default CodeSync;