@proletariat/cli 0.3.68 → 0.3.70

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 (53) hide show
  1. package/dist/commands/session/health.d.ts +11 -0
  2. package/dist/commands/session/health.js +1 -1
  3. package/dist/commands/session/health.js.map +1 -1
  4. package/dist/lib/execution/runners/cloud.d.ts +16 -0
  5. package/dist/lib/execution/runners/cloud.js +88 -0
  6. package/dist/lib/execution/runners/cloud.js.map +1 -0
  7. package/dist/lib/execution/runners/devcontainer-terminal.d.ts +13 -0
  8. package/dist/lib/execution/runners/devcontainer-terminal.js +184 -0
  9. package/dist/lib/execution/runners/devcontainer-terminal.js.map +1 -0
  10. package/dist/lib/execution/runners/devcontainer-tmux.d.ts +16 -0
  11. package/dist/lib/execution/runners/devcontainer-tmux.js +270 -0
  12. package/dist/lib/execution/runners/devcontainer-tmux.js.map +1 -0
  13. package/dist/lib/execution/runners/devcontainer.d.ts +19 -0
  14. package/dist/lib/execution/runners/devcontainer.js +261 -0
  15. package/dist/lib/execution/runners/devcontainer.js.map +1 -0
  16. package/dist/lib/execution/runners/docker-credentials.d.ts +51 -0
  17. package/dist/lib/execution/runners/docker-credentials.js +175 -0
  18. package/dist/lib/execution/runners/docker-credentials.js.map +1 -0
  19. package/dist/lib/execution/runners/docker-management.d.ts +49 -0
  20. package/dist/lib/execution/runners/docker-management.js +300 -0
  21. package/dist/lib/execution/runners/docker-management.js.map +1 -0
  22. package/dist/lib/execution/runners/docker.d.ts +13 -0
  23. package/dist/lib/execution/runners/docker.js +75 -0
  24. package/dist/lib/execution/runners/docker.js.map +1 -0
  25. package/dist/lib/execution/runners/executor.d.ts +41 -0
  26. package/dist/lib/execution/runners/executor.js +108 -0
  27. package/dist/lib/execution/runners/executor.js.map +1 -0
  28. package/dist/lib/execution/runners/host.d.ts +14 -0
  29. package/dist/lib/execution/runners/host.js +437 -0
  30. package/dist/lib/execution/runners/host.js.map +1 -0
  31. package/dist/lib/execution/runners/index.d.ts +29 -0
  32. package/dist/lib/execution/runners/index.js +79 -0
  33. package/dist/lib/execution/runners/index.js.map +1 -0
  34. package/dist/lib/execution/runners/orchestrator.d.ts +30 -0
  35. package/dist/lib/execution/runners/orchestrator.js +332 -0
  36. package/dist/lib/execution/runners/orchestrator.js.map +1 -0
  37. package/dist/lib/execution/runners/prompt-builder.d.ts +12 -0
  38. package/dist/lib/execution/runners/prompt-builder.js +337 -0
  39. package/dist/lib/execution/runners/prompt-builder.js.map +1 -0
  40. package/dist/lib/execution/runners/sandbox.d.ts +34 -0
  41. package/dist/lib/execution/runners/sandbox.js +108 -0
  42. package/dist/lib/execution/runners/sandbox.js.map +1 -0
  43. package/dist/lib/execution/runners/shared.d.ts +62 -0
  44. package/dist/lib/execution/runners/shared.js +141 -0
  45. package/dist/lib/execution/runners/shared.js.map +1 -0
  46. package/dist/lib/execution/runners.d.ts +12 -272
  47. package/dist/lib/execution/runners.js +12 -3200
  48. package/dist/lib/execution/runners.js.map +1 -1
  49. package/dist/lib/external-issues/outbound-sync.d.ts +15 -0
  50. package/dist/lib/external-issues/outbound-sync.js +11 -1
  51. package/dist/lib/external-issues/outbound-sync.js.map +1 -1
  52. package/oclif.manifest.json +2334 -2334
  53. package/package.json +1 -1
@@ -1,3205 +1,17 @@
1
- /* eslint-disable max-lines -- runner implementations require cohesive logic */
2
1
  /**
3
- * Execution Runners
2
+ * Execution Runners — Re-export barrel
4
3
  *
5
- * Implementations for each execution environment (host, sandbox, devcontainer, docker, cloud).
6
- */
7
- import { spawn, execSync } from 'node:child_process';
8
- import * as fs from 'node:fs';
9
- import * as path from 'node:path';
10
- import * as os from 'node:os';
11
- import { fileURLToPath } from 'node:url';
12
- import { DEFAULT_EXECUTION_CONFIG, normalizeEnvironment, } from './types.js';
13
- import { getSetTitleCommands } from '../terminal.js';
14
- import { readDevcontainerJson, generateOrchestratorDockerfile } from './devcontainer.js';
15
- import { getCodexCommand, resolveCodexExecutionContext, validateCodexMode } from './codex-adapter.js';
16
- import { resolveToolsForSpawn } from '../tool-registry/index.js';
17
- // =============================================================================
18
- // Terminal Title Helpers
19
- // =============================================================================
20
- /**
21
- * Build a unified name for tmux sessions, window names, and tab titles.
22
- * Format: "{ticketId}-{action}-{agentName}"
23
- * Example: "TKT-347-implement-altman"
24
- */
25
- export function buildSessionName(context) {
26
- // Sanitize action name: strip non-alphanumeric chars for shell/tmux safety (& breaks paths)
27
- const action = (context.actionName || 'work')
28
- .replace(/[^a-zA-Z0-9._-]/g, '-')
29
- .replace(/-+/g, '-')
30
- .replace(/^-|-$/g, '');
31
- const agent = context.agentName || 'agent';
32
- return `${context.ticketId}-${action}-${agent}`;
33
- }
34
- // Legacy aliases for backwards compatibility
35
- function buildWindowTitle(context) {
36
- return buildSessionName(context);
37
- }
38
- function buildTmuxWindowName(context) {
39
- return buildSessionName(context);
40
- }
41
- /**
42
- * Check if tmux control mode (-CC) should be used.
43
- * Control mode is only used with iTerm when controlMode is enabled in config.
44
- *
45
- * When control mode is active:
46
- * - iTerm handles scrolling, selection, and gestures natively
47
- * - tmux mouse mode should be disabled to avoid conflicts
48
- */
49
- export function shouldUseControlMode(terminalApp, controlModeEnabled) {
50
- return terminalApp === 'iTerm' && controlModeEnabled;
51
- }
52
- /**
53
- * Build the tmux mouse option string for session creation.
54
- * Enables mouse mode for scroll support in tmux.
55
- * To select text or switch tabs, hold Shift or Option to bypass tmux.
56
- */
57
- export function buildTmuxMouseOption(_useControlMode) {
58
- return ' \\; set-option -g mouse on';
59
- }
60
- /**
61
- * Build the tmux attach command based on control mode.
62
- * Uses -u -CC flags for iTerm control mode (native scrolling/selection).
63
- * -u forces UTF-8 mode which is required for proper iTerm integration.
64
- * Uses regular attach otherwise.
65
- */
66
- export function buildTmuxAttachCommand(useControlMode, includeUnicodeFlag = false) {
67
- const unicodeFlag = includeUnicodeFlag ? '-u ' : '';
68
- if (useControlMode) {
69
- // Always use -u with -CC for proper iTerm integration
70
- // -d detaches other clients to prevent multi-attach lockups
71
- return `tmux -u -CC attach -d`;
72
- }
73
- // -d detaches other clients to prevent multi-attach lockups
74
- return `tmux ${unicodeFlag}attach -d`;
75
- }
76
- /**
77
- * Configure iTerm tmux preferences for control mode.
78
- * - windowMode: whether tmux -CC opens windows as tabs or new windows
79
- * - autoHide: automatically bury/hide the control session (the terminal where -CC was run)
80
- * @param mode - 'tab' for tabs in current window, 'window' for new windows
81
- */
82
- export function configureITermTmuxPreferences(mode) {
83
- try {
84
- // OpenTmuxWindowsIn: 0=native windows, 1=new window, 2=tabs in existing window
85
- const windowModeValue = mode === 'tab' ? 2 : 1;
86
- execSync(`defaults write com.googlecode.iterm2 OpenTmuxWindowsIn -int ${windowModeValue}`, { stdio: 'pipe' });
87
- // AutoHideTmuxClientSession: hide the control channel terminal so it doesn't clutter
88
- execSync(`defaults write com.googlecode.iterm2 AutoHideTmuxClientSession -bool true`, { stdio: 'pipe' });
89
- }
90
- catch {
91
- // Non-fatal - preference setting failed but execution can continue
92
- }
93
- }
94
- // Legacy alias for backwards compatibility
95
- export function configureITermTmuxWindowMode(mode) {
96
- configureITermTmuxPreferences(mode);
97
- }
98
- // =============================================================================
99
- // Docker Credential Helpers
100
- // =============================================================================
101
- const CLAUDE_CREDENTIALS_VOLUME = 'claude-credentials';
102
- /**
103
- * Check if the claude-credentials Docker volume exists.
104
- */
105
- export function credentialsVolumeExists() {
106
- try {
107
- execSync(`docker volume inspect ${CLAUDE_CREDENTIALS_VOLUME}`, { stdio: 'pipe' });
108
- return true;
109
- }
110
- catch {
111
- return false;
112
- }
113
- }
114
- /**
115
- * Check if valid Claude OAuth credentials exist in the Docker volume.
116
- * Returns true if OAuth credentials are stored (even if access token is expired,
117
- * since Claude Code handles refresh internally using stored refresh tokens).
118
- *
119
- * NOTE: This intentionally does NOT check for ANTHROPIC_API_KEY. If the user
120
- * has an API key but no OAuth credentials, we want to prompt them to set up
121
- * OAuth (which uses their Max subscription) rather than silently burning API credits.
122
- */
123
- export function dockerCredentialsExist() {
124
- try {
125
- const result = execSync(`docker run --rm -v ${CLAUDE_CREDENTIALS_VOLUME}:/data alpine cat /data/.credentials.json 2>/dev/null`, { stdio: 'pipe', encoding: 'utf-8' });
126
- const creds = JSON.parse(result);
127
- // Check if OAuth credentials exist. Don't check expiration because
128
- // access tokens are short-lived but Claude Code handles token refresh
129
- // internally using stored refresh tokens.
130
- if (creds.claudeAiOauth?.accessToken) {
131
- return true;
132
- }
133
- return false;
134
- }
135
- catch {
136
- return false;
137
- }
138
- }
139
- /**
140
- * Get Docker credential info for display.
141
- * Returns expiration date and subscription type if available.
142
- */
143
- export function getDockerCredentialInfo() {
144
- try {
145
- const result = execSync(`docker run --rm -v ${CLAUDE_CREDENTIALS_VOLUME}:/data alpine cat /data/.credentials.json 2>/dev/null`, { stdio: 'pipe', encoding: 'utf-8' });
146
- const creds = JSON.parse(result);
147
- if (creds.claudeAiOauth?.expiresAt) {
148
- return {
149
- expiresAt: new Date(creds.claudeAiOauth.expiresAt),
150
- subscriptionType: creds.claudeAiOauth.subscriptionType,
151
- };
152
- }
153
- return null;
154
- }
155
- catch {
156
- return null;
157
- }
158
- }
159
- /**
160
- * Check if Claude Code authentication is available on the host system.
161
- * Returns true if any of:
162
- * 1. ANTHROPIC_API_KEY environment variable is set
163
- * 2. OAuth credentials exist in ~/.claude/.credentials.json (Claude Code 1.x)
164
- * 3. OAuth credentials exist in macOS keychain (Claude Code 2.x)
165
- *
166
- * This is used to validate auth before spawning host sessions (e.g., orchestrator)
167
- * to avoid creating stuck sessions when the keychain is locked (SSH contexts).
168
- */
169
- export function hostCredentialsExist() {
170
- // Check for ANTHROPIC_API_KEY first (works in all contexts, including SSH)
171
- if (process.env.ANTHROPIC_API_KEY) {
172
- return true;
173
- }
174
- // Check for OAuth credentials in ~/.claude/.credentials.json (Claude Code 1.x)
175
- try {
176
- const homeDir = process.env.HOME || os.homedir();
177
- const credPath = path.join(homeDir, '.claude', '.credentials.json');
178
- if (fs.existsSync(credPath)) {
179
- const credData = fs.readFileSync(credPath, 'utf-8');
180
- const creds = JSON.parse(credData);
181
- // Check if OAuth credentials exist (similar to Docker check)
182
- // Don't check expiration - Claude Code handles token refresh internally
183
- if (creds.claudeAiOauth?.accessToken) {
184
- return true;
185
- }
186
- }
187
- }
188
- catch {
189
- // Fall through to keychain check
190
- }
191
- // Check for Claude Code 2.x keychain-based auth (macOS)
192
- // Claude Code 2.x stores OAuth tokens in the macOS keychain under service
193
- // "Claude Code-credentials". If the keychain is locked (e.g., SSH sessions),
194
- // this check will fail, which is the desired behavior — we want to surface
195
- // the error early rather than create stuck sessions.
196
- if (process.platform === 'darwin') {
197
- try {
198
- execSync('security find-generic-password -s "Claude Code-credentials" 2>/dev/null', {
199
- stdio: 'pipe',
200
- timeout: 5000,
201
- });
202
- return true;
203
- }
204
- catch {
205
- // Keychain entry not found or keychain is locked
206
- }
207
- }
208
- return false;
209
- }
210
- /**
211
- * Ensure tmux server has keychain access for Claude Code OAuth.
212
- *
213
- * On macOS, tmux sessions can lose access to the keychain if the tmux server
214
- * was started in a context without keychain access (e.g., from a background
215
- * process, SSH session, or parent process with restricted keychain access).
216
- *
217
- * This function:
218
- * 1. Checks if a tmux server is running
219
- * 2. Tests if it can access Claude Code OAuth credentials
220
- * 3. If not, restarts the tmux server to restore keychain access
221
- *
222
- * This runs transparently before spawning agent sessions, ensuring OAuth
223
- * authentication works without manual intervention.
224
- */
225
- async function ensureTmuxServerHasKeychainAccess() {
226
- // Skip if no tmux server is running (will be started fresh with keychain access)
227
- try {
228
- const serverRunning = execSync('tmux list-sessions 2>/dev/null || echo ""', {
229
- encoding: 'utf-8',
230
- stdio: 'pipe'
231
- });
232
- if (!serverRunning.trim()) {
233
- return; // No server running, will start fresh
234
- }
235
- }
236
- catch {
237
- return; // tmux not installed or no server running
238
- }
239
- // Test if tmux server can access Claude Code credentials
240
- // We spawn a test session and check if Claude Code can authenticate
241
- const testSession = `prlt-keychain-test-${Date.now()}`;
242
- try {
243
- // Create test session
244
- execSync(`tmux new-session -d -s "${testSession}"`, { stdio: 'pipe' });
245
- // Send command to check Claude Code auth
246
- // Use 'unset CLAUDECODE' to avoid nested session error
247
- execSync(`tmux send-keys -t "${testSession}" "unset CLAUDECODE && claude -p 'test' 2>&1 | head -1" Enter`, { stdio: 'pipe' });
248
- // Wait for response (Claude Code startup + auth check)
249
- await new Promise(resolve => setTimeout(resolve, 3000));
250
- // Capture output
251
- const output = execSync(`tmux capture-pane -t "${testSession}" -p`, {
252
- encoding: 'utf-8',
253
- stdio: 'pipe'
254
- });
255
- // Clean up test session
256
- execSync(`tmux kill-session -t "${testSession}"`, { stdio: 'pipe' });
257
- // Check if auth failed
258
- if (output.includes('Not logged in') || output.includes('Please run /login')) {
259
- // Keychain access is broken - restart tmux server
260
- // This happens silently - the next tmux session will have keychain access
261
- execSync('tmux kill-server', { stdio: 'pipe' });
262
- // TKT-099: Wait for the tmux server to fully stop before returning.
263
- // The old 500ms fixed delay was insufficient under load, causing the subsequent
264
- // `tmux new-session` to occasionally create a session on the dying server.
265
- // Poll for server shutdown with a reasonable timeout instead.
266
- for (let i = 0; i < 10; i++) {
267
- await new Promise(resolve => setTimeout(resolve, 300));
268
- try {
269
- execSync('tmux list-sessions 2>/dev/null', { stdio: 'pipe' });
270
- // Server still alive, keep waiting
271
- }
272
- catch {
273
- // Server is gone — ready to proceed
274
- break;
275
- }
276
- }
277
- }
278
- }
279
- catch (_error) {
280
- // Test session failed - clean up if it exists
281
- try {
282
- execSync(`tmux kill-session -t "${testSession}"`, { stdio: 'pipe' });
283
- }
284
- catch {
285
- // Ignore cleanup errors
286
- }
287
- // Continue - worst case, spawn will fail with clear error message
288
- }
289
- }
290
- // =============================================================================
291
- // Executor Commands
292
- // =============================================================================
293
- export function getExecutorCommand(executor, prompt, skipPermissions = true) {
294
- switch (executor) {
295
- case 'claude-code':
296
- if (skipPermissions) {
297
- // Skip permissions - agent runs autonomously without prompting
298
- // Note: NO -p flag - we want interactive mode for streaming output in terminal
299
- // --permission-mode bypassPermissions: skips the "trust this folder" dialog
300
- // --dangerously-skip-permissions: skips tool permission checks
301
- // --effort high: skips the effort level prompt (TKT-1134)
302
- return { cmd: 'claude', args: ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', '--effort', 'high', prompt] };
303
- }
304
- // Manual mode - will prompt for each action (still interactive, no -p)
305
- return { cmd: 'claude', args: [prompt] };
306
- case 'codex': {
307
- // Delegate to Codex adapter for deterministic mode mapping.
308
- // getExecutorCommand is called without display/output context, so we use
309
- // 'interactive' as default context (safe for validation — all permission modes
310
- // are valid with interactive). Runners that need stricter validation should
311
- // call the adapter directly with the actual execution context.
312
- const codexPermission = skipPermissions ? 'danger' : 'safe';
313
- const codexResult = getCodexCommand(prompt, codexPermission, 'interactive');
314
- return { cmd: codexResult.cmd, args: codexResult.args };
315
- }
316
- case 'custom':
317
- // Custom executor should be configured
318
- return { cmd: 'echo', args: ['Custom executor not configured'] };
319
- default:
320
- if (skipPermissions) {
321
- // Note: NO -p flag - we want interactive mode for streaming output
322
- // --effort high: skips the effort level prompt (TKT-1134)
323
- return { cmd: 'claude', args: ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', '--effort', 'high', prompt] };
324
- }
325
- return { cmd: 'claude', args: [prompt] };
326
- }
327
- }
328
- /**
329
- * Check if an executor is Claude Code.
330
- * Used to gate Claude-specific flags and configuration.
331
- */
332
- export function isClaudeExecutor(executor) {
333
- return executor === 'claude-code';
334
- }
335
- /**
336
- * Get the display name for an executor type.
337
- */
338
- export function getExecutorDisplayName(executor) {
339
- switch (executor) {
340
- case 'claude-code': return 'Claude Code';
341
- case 'codex': return 'Codex';
342
- case 'custom': return 'Custom';
343
- default: return 'Claude Code';
344
- }
345
- }
346
- /**
347
- * Get the npm package name for an executor (for container installation).
348
- */
349
- export function getExecutorPackage(executor) {
350
- switch (executor) {
351
- case 'claude-code': return '@anthropic-ai/claude-code';
352
- case 'codex': return '@openai/codex';
353
- case 'custom': return null;
354
- default: return '@anthropic-ai/claude-code';
355
- }
356
- }
357
- /**
358
- * Check executor binary availability on host.
359
- */
360
- export function checkExecutorOnHost(executor) {
361
- const { cmd } = getExecutorCommand(executor, 'preflight');
362
- try {
363
- execSync(`command -v ${cmd}`, { stdio: 'pipe' });
364
- return { ok: true };
365
- }
366
- catch {
367
- const pkg = getExecutorPackage(executor);
368
- const installHint = pkg ? `Install it with: npm install -g ${pkg}` : 'Install and configure the executor binary.';
369
- return {
370
- ok: false,
371
- error: `${getExecutorDisplayName(executor)} CLI not found on host (missing "${cmd}"). ${installHint}`,
372
- };
373
- }
374
- }
375
- /**
376
- * Check executor binary availability inside a container.
377
- */
378
- export function checkExecutorInContainer(executor, containerId) {
379
- const { cmd } = getExecutorCommand(executor, 'preflight');
380
- try {
381
- execSync(`docker exec ${containerId} sh -lc 'command -v ${cmd}'`, { stdio: 'pipe' });
382
- return { ok: true };
383
- }
384
- catch {
385
- const pkg = getExecutorPackage(executor);
386
- const installHint = pkg ? `Container image is missing ${pkg}.` : `Container image is missing "${cmd}".`;
387
- return {
388
- ok: false,
389
- error: `${getExecutorDisplayName(executor)} CLI not found in container (missing "${cmd}"). ${installHint}`,
390
- };
391
- }
392
- }
393
- /**
394
- * Run executor preflight checks for the target environment.
395
- */
396
- export function runExecutorPreflight(environment, executor, options) {
397
- const env = normalizeEnvironment(environment);
398
- if (env === 'host' || env === 'sandbox') {
399
- return checkExecutorOnHost(executor);
400
- }
401
- if (env === 'devcontainer' && options?.containerId) {
402
- return checkExecutorInContainer(executor, options.containerId);
403
- }
404
- return { ok: true };
405
- }
406
- const INTEGRATION_COMMANDS = [
407
- {
408
- provider: 'asana',
409
- displayName: 'Asana',
410
- commands: [
411
- 'prlt asana connect — authenticate with Asana',
412
- 'prlt asana sync --ticket TKT-XXX --create-missing --project <gid> — sync a PMO ticket to Asana',
413
- 'prlt asana import — import Asana tasks into PMO',
414
- ],
415
- },
416
- {
417
- provider: 'linear',
418
- displayName: 'Linear',
419
- commands: [
420
- 'prlt linear connect — authenticate with Linear',
421
- 'prlt linear sync --ticket TKT-XXX --create-missing — sync a PMO ticket to Linear',
422
- 'prlt linear import — import Linear issues into PMO',
423
- ],
424
- },
425
- {
426
- provider: 'jira',
427
- displayName: 'Jira',
428
- commands: [
429
- 'prlt jira connect — authenticate with Jira',
430
- 'prlt jira sync --ticket TKT-XXX --create-missing — sync a PMO ticket to Jira',
431
- 'prlt jira import — import Jira issues into PMO',
432
- ],
433
- },
434
- {
435
- provider: 'shortcut',
436
- displayName: 'Shortcut',
437
- commands: [
438
- 'prlt shortcut connect — authenticate with Shortcut',
439
- 'prlt shortcut sync --ticket TKT-XXX --create-missing — sync a PMO ticket to Shortcut',
440
- 'prlt shortcut import — import Shortcut stories into PMO',
441
- ],
442
- },
443
- {
444
- provider: 'monday',
445
- displayName: 'Monday.com',
446
- commands: [
447
- 'prlt monday connect — authenticate with Monday.com',
448
- 'prlt monday sync --ticket TKT-XXX --create-missing — sync a PMO ticket to Monday.com',
449
- ],
450
- },
451
- ];
452
- /**
453
- * Build the integration commands section for agent prompts.
454
- * Only includes integrations that are actually connected/configured.
455
- * Returns empty string if no integrations are connected.
456
- */
457
- function buildIntegrationCommandsSection(connectedIntegrations) {
458
- if (!connectedIntegrations || connectedIntegrations.length === 0)
459
- return '';
460
- const connected = INTEGRATION_COMMANDS.filter(ic => connectedIntegrations.includes(ic.provider));
461
- if (connected.length === 0)
462
- return '';
463
- let section = `## Integration Commands\n\n`;
464
- section += `The following external integrations are connected. Use these prlt commands to interact with them.\n\n`;
465
- for (const integration of connected) {
466
- section += `### ${integration.displayName}\n`;
467
- for (const cmd of integration.commands) {
468
- section += `- \`${cmd.split(' — ')[0]}\` — ${cmd.split(' — ')[1] || ''}\n`;
469
- }
470
- section += '\n';
471
- }
472
- section += `**ANTI-PATTERN:** Never use curl, raw API calls, or shell scripts to interact with external services (Asana, Linear, Jira, Shortcut, Monday.com, etc.). Always use the corresponding \`prlt\` commands.\n\n`;
473
- return section;
474
- }
475
- const ORCHESTRATOR_COMMAND_REGISTRY = [
476
- {
477
- title: 'Agent Lifecycle',
478
- commands: [
479
- { cmd: 'prlt work start <ticket> --ephemeral --skip-permissions --create-pr --display background --action implement --run-on-host --yes', desc: 'Spawn an agent for a ticket', checkPath: 'work/start' },
480
- { cmd: 'prlt session list', desc: 'List running sessions', checkPath: 'session/list' },
481
- { cmd: 'prlt session inspect <agent>', desc: 'Inspect session details', checkPath: 'session/inspect' },
482
- { cmd: 'prlt session poke <agent> \'message\'', desc: 'Send message to agent', checkPath: 'session/poke' },
483
- { cmd: 'prlt session peek <agent> --lines 200', desc: 'Read agent output', checkPath: 'session/peek' },
484
- { cmd: 'prlt session health', desc: 'Check health of all sessions', checkPath: 'session/health' },
485
- { cmd: 'prlt session restart <agent>', desc: 'Restart a stuck agent', checkPath: 'session/restart' },
486
- { cmd: 'prlt session exec <agent> -- git status', desc: 'Run command in agent context', checkPath: 'session/exec' },
487
- { cmd: 'prlt session prune', desc: 'Clean up dead sessions', checkPath: 'session/prune' },
488
- ],
489
- },
490
- {
491
- title: 'Board Management',
492
- commands: [
493
- { cmd: 'prlt board view', desc: 'View the board', checkPath: 'board/view' },
494
- { cmd: 'prlt ticket list', desc: 'List tickets', checkPath: 'ticket/list' },
495
- { cmd: 'prlt ticket show <id>', desc: 'Show ticket details', checkPath: 'ticket/show' },
496
- { cmd: 'prlt ticket create --title \'x\' --description \'y\'', desc: 'Create a ticket', checkPath: 'ticket/create' },
497
- { cmd: 'prlt ticket edit <id> --title \'...\' --add-ac \'...\'', desc: 'Edit ticket fields', checkPath: 'ticket/edit' },
498
- ],
499
- },
500
- {
501
- title: 'PR Workflow',
502
- commands: [
503
- { cmd: 'gh pr list', desc: 'List open PRs' },
504
- { cmd: 'gh pr view <num>', desc: 'View PR details' },
505
- { cmd: 'gh pr checks <num>', desc: 'Check CI status' },
506
- { cmd: 'gh pr merge <num> --squash', desc: 'Merge PR (squash only)' },
507
- ],
508
- },
509
- ];
510
- const ORCHESTRATOR_ANTI_PATTERNS = [
511
- { bad: 'docker exec <container> ...', good: 'prlt session exec', checkPath: 'session/exec' },
512
- { bad: 'tmux send-keys ...', good: 'prlt session poke', checkPath: 'session/poke' },
513
- { bad: 'tmux capture-pane ...', good: 'prlt session peek', checkPath: 'session/peek' },
514
- { bad: 'Direct git operations on agent worktrees', good: 'prlt session exec', checkPath: 'session/exec' },
515
- ];
516
- /**
517
- * Resolve the commands directory for dynamic command availability checks.
518
- * Looks for compiled command files under dist/commands/.
519
- */
520
- let _commandsDir = null;
521
- function getCommandsDir() {
522
- if (_commandsDir === null) {
523
- const currentFile = fileURLToPath(import.meta.url);
524
- // From dist/lib/execution/runners.js → dist/commands/
525
- _commandsDir = path.resolve(path.dirname(currentFile), '..', '..', 'commands');
526
- }
527
- return _commandsDir;
528
- }
529
- function isCommandAvailable(checkPath) {
530
- const dir = getCommandsDir();
531
- // Check for compiled .js file or directory (which would contain index.js)
532
- return fs.existsSync(path.join(dir, `${checkPath}.js`)) || fs.existsSync(path.join(dir, checkPath));
533
- }
534
- /**
535
- * Build the dynamic command reference section for the orchestrator prompt.
536
- * Only includes commands that are actually available in this build.
537
- */
538
- function buildOrchestratorCommandReference() {
539
- let ref = '';
540
- for (const category of ORCHESTRATOR_COMMAND_REGISTRY) {
541
- const available = category.commands.filter(c => !c.checkPath || isCommandAvailable(c.checkPath));
542
- if (available.length === 0)
543
- continue;
544
- ref += `### ${category.title}\n`;
545
- for (const cmd of available) {
546
- ref += `- \`${cmd.cmd}\` — ${cmd.desc}\n`;
547
- }
548
- ref += '\n';
549
- }
550
- return ref;
551
- }
552
- /**
553
- * Build the anti-patterns section for the orchestrator prompt.
554
- * Only includes anti-patterns where the prlt replacement is available.
555
- */
556
- function buildOrchestratorAntiPatterns() {
557
- const available = ORCHESTRATOR_ANTI_PATTERNS.filter(ap => !ap.checkPath || isCommandAvailable(ap.checkPath));
558
- if (available.length === 0)
559
- return '';
560
- let section = `## Anti-Patterns — NEVER DO\n\n`;
561
- for (const ap of available) {
562
- section += `- \`${ap.bad}\` → use \`${ap.good}\` instead\n`;
563
- }
564
- section += `\n`;
565
- return section;
566
- }
567
- /**
568
- * Build the shared orchestrator prompt body (role, runtime, commands, anti-patterns).
569
- * Used by both buildOrchestratorSystemPrompt and buildOrchestratorPrompt.
570
- */
571
- function buildOrchestratorBody(hqName, context) {
572
- let prompt = '';
573
- // Dynamic workspace context
574
- const prltVersion = getHostPrltVersion();
575
- prompt += `## Environment\n`;
576
- if (prltVersion) {
577
- prompt += `- **prlt version**: ${prltVersion}\n`;
578
- }
579
- prompt += `- **Available executors**: claude-code, codex\n`;
580
- prompt += `- **Agent worktrees**: \`agents/temp/<agent-name>/<repo>\` — each agent gets an isolated git worktree\n`;
581
- if (context.hqPath) {
582
- prompt += `- **HQ path**: \`${context.hqPath}\`\n`;
583
- }
584
- prompt += `\n`;
585
- // Runtime declaration
586
- prompt += `## prlt Is Your Orchestration Runtime\n\n`;
587
- prompt += `prlt is your orchestration runtime. NEVER use raw docker exec, tmux send-keys, or direct container access. `;
588
- prompt += `All orchestration goes through prlt. Every agent interaction, session management, and board operation `;
589
- prompt += `has a dedicated prlt command. Using raw infrastructure commands bypasses session tracking, breaks `;
590
- prompt += `health monitoring, and creates orphaned processes.\n\n`;
591
- // Role
592
- prompt += `## Your Role\n`;
593
- prompt += `- Assess the current state of the board, running agents, and open PRs\n`;
594
- prompt += `- Plan and prioritize work — decide what to tackle next and in what order\n`;
595
- prompt += `- Delegate implementation to agents via \`prlt work start\`\n`;
596
- prompt += `- Monitor agent progress via sessions and review completed work\n`;
597
- prompt += `- Review and merge completed PRs via \`gh pr merge --squash\`\n`;
598
- prompt += `- Coordinate parallel agents — handle rebases after merges\n`;
599
- prompt += `- Never write code or make changes to source files yourself\n\n`;
600
- // Command reference (dynamically generated)
601
- prompt += `## Command Reference\n\n`;
602
- prompt += buildOrchestratorCommandReference();
603
- // Spawning agents (detailed example)
604
- prompt += `## Spawning Agents\n`;
605
- prompt += `\`\`\`\n`;
606
- prompt += `script -q /dev/null prlt work start TKT-XXXX --ephemeral --skip-permissions --create-pr --display background --action implement --run-on-host --yes\n`;
607
- prompt += `\`\`\`\n`;
608
- prompt += `- Review: \`--action review-comment\`\n`;
609
- prompt += `- Fix: \`--action review-fix\`\n\n`;
610
- // Anti-patterns (dynamically generated)
611
- prompt += buildOrchestratorAntiPatterns();
612
- // Integration commands (only for connected integrations)
613
- prompt += buildIntegrationCommandsSection(context.connectedIntegrations);
614
- // Workflow
615
- prompt += `## Workflow\n`;
616
- prompt += `- Squash merge only: \`gh pr merge --squash\`\n`;
617
- prompt += `- After merging: subsequent PRs from parallel agents will need rebase\n`;
618
- prompt += `- Kill stale sessions after their PRs are merged\n\n`;
619
- // Tool registry (TKT-083): inject available tools into orchestrator prompt
620
- if (context.hqPath) {
621
- const toolsResult = resolveToolsForSpawn(context.hqPath, context.toolPolicy, path.join(context.hqPath, '.proletariat', 'scripts'));
622
- if (toolsResult.promptSection) {
623
- prompt += toolsResult.promptSection;
624
- }
625
- }
626
- // Load .orchestrator-context.md from HQ root if it exists
627
- if (context.hqPath) {
628
- const contextFilePath = path.join(context.hqPath, '.orchestrator-context.md');
629
- if (fs.existsSync(contextFilePath)) {
630
- try {
631
- const contextContent = fs.readFileSync(contextFilePath, 'utf-8').trim();
632
- if (contextContent) {
633
- prompt += `## Workspace Context\n\n${contextContent}\n\n`;
634
- }
635
- }
636
- catch {
637
- // Ignore read errors
638
- }
639
- }
640
- }
641
- return prompt;
642
- }
643
- /**
644
- * Build the system prompt for orchestrator sessions.
645
- * This is injected via Claude Code's --system-prompt flag so the orchestrator
646
- * knows its role immediately without relying on CLAUDE.md.
647
- */
648
- export function buildOrchestratorSystemPrompt(context) {
649
- const hqName = context.hqName || 'workspace';
650
- let prompt = `# Orchestrator: ${hqName}\n\n`;
651
- prompt += `You are the orchestrator for the **${hqName}** headquarters — a technical project manager driving software delivery through delegated AI agents.\n\n`;
652
- prompt += `**prlt** is an AI agent orchestration CLI. It manages software development by coordinating autonomous coding agents that work in isolated git worktrees. `;
653
- prompt += `Your workspace (HQ) contains a PMO board for tracking tickets, agent worktrees under \`agents/temp/\`, and repo connections. `;
654
- prompt += `Agents are spawned to implement, review, and fix code — you never write code yourself. `;
655
- prompt += `Your job is to assess the state of the project, plan and prioritize work, delegate to agents, monitor their progress, review results, and merge completed PRs.\n\n`;
656
- prompt += buildOrchestratorBody(hqName, context);
657
- return prompt;
658
- }
659
- function buildOrchestratorPrompt(context) {
660
- // Full prompt including role context — used for non-Claude executors that
661
- // don't support --system-prompt. For Claude Code, runHost() splits this into
662
- // a system prompt (role/tools) + a shorter user message.
663
- const hqName = context.hqName || 'workspace';
664
- let prompt = `# Orchestrator: ${hqName}\n\n`;
665
- prompt += `You are the orchestrator for the **${hqName}** headquarters — a technical project manager driving software delivery through delegated AI agents.\n\n`;
666
- prompt += `**prlt** is an AI agent orchestration CLI. It manages software development by coordinating autonomous coding agents that work in isolated git worktrees. `;
667
- prompt += `Your workspace (HQ) contains a PMO board for tracking tickets, agent worktrees under \`agents/temp/\`, and repo connections. `;
668
- prompt += `Agents are spawned to implement, review, and fix code — you never write code yourself.\n\n`;
669
- prompt += buildOrchestratorBody(hqName, context);
670
- // Include user's custom prompt or action content
671
- if (context.actionPrompt) {
672
- prompt += `## Instructions\n\n${context.actionPrompt}\n`;
673
- }
674
- return prompt;
675
- }
676
- function buildPrompt(context) {
677
- // Orchestrator sessions get a role-specific prompt instead of the generic ticket format
678
- if (context.isOrchestrator) {
679
- return buildOrchestratorPrompt(context);
680
- }
681
- let prompt = '';
682
- // For revisions, lead with the PR feedback
683
- if (context.isRevision && context.prFeedback) {
684
- prompt += `# Revision: Address PR Feedback\n\n`;
685
- prompt += context.prFeedback;
686
- prompt += `\n\n---\n\n`;
687
- prompt += `## Original Ticket Context\n\n`;
688
- }
689
- // Action instruction (what the agent should do) - START HOOK
690
- if (context.actionPrompt) {
691
- prompt += `# Action: ${context.actionName || 'Work'}\n\n`;
692
- prompt += context.actionPrompt;
693
- prompt += `\n\n---\n\n`;
694
- }
695
- // TICKET CONTENT
696
- prompt += `# Ticket: ${context.ticketId}\n\n`;
697
- prompt += `**Title:** ${context.ticketTitle}\n\n`;
698
- if (context.ticketPriority) {
699
- prompt += `**Priority:** ${context.ticketPriority}\n`;
700
- }
701
- if (context.ticketCategory) {
702
- prompt += `**Category:** ${context.ticketCategory}\n`;
703
- }
704
- if (context.epicTitle) {
705
- prompt += `**Epic:** ${context.epicTitle}\n`;
706
- }
707
- if (context.ticketDescription) {
708
- prompt += `\n## Description\n\n${context.ticketDescription}\n`;
709
- }
710
- if (context.ticketSubtasks && context.ticketSubtasks.length > 0) {
711
- prompt += `\n## Subtasks\n\n`;
712
- for (const subtask of context.ticketSubtasks) {
713
- const checkbox = subtask.done ? '[x]' : '[ ]';
714
- prompt += `- ${checkbox} ${subtask.title}\n`;
715
- }
716
- }
717
- // Note: Branch setup (fetch + checkout/create) is now handled programmatically
718
- // in work/start.ts before the agent spawns, so no prompt instructions needed
719
- // Integration commands (only for connected integrations)
720
- const integrationSection = buildIntegrationCommandsSection(context.connectedIntegrations);
721
- if (integrationSection) {
722
- prompt += `\n${integrationSection}`;
723
- }
724
- // Additional instructions from --message flag (appended to any action)
725
- if (context.customMessage) {
726
- prompt += `\n## Additional Instructions\n\n${context.customMessage}\n`;
727
- }
728
- // Tool registry (TKT-083): inject available tools into agent prompt
729
- if (context.hqPath) {
730
- const toolsResult = resolveToolsForSpawn(context.hqPath, context.toolPolicy, path.join(context.hqPath, '.proletariat', 'scripts'));
731
- if (toolsResult.promptSection) {
732
- prompt += `\n${toolsResult.promptSection}`;
733
- }
734
- }
735
- // END HOOK - Action-specific completion instructions
736
- prompt += `\n---\n\n## When Complete\n\n`;
737
- // For revisions, use the revision-specific end prompt
738
- if (context.isRevision) {
739
- prompt += `After addressing the feedback:\n`;
740
- prompt += `1. Commit your changes using \`prlt commit "your message"\`\n`;
741
- prompt += `2. Push your changes: \`git push\`\n`;
742
- prompt += `\nThe PR will be updated automatically.`;
743
- }
744
- else if (context.actionEndPrompt) {
745
- // Use action-specific end prompt, replacing {{TICKET_ID}} placeholder
746
- let endPrompt = context.actionEndPrompt.replace(/\{\{TICKET_ID\}\}/g, context.ticketId);
747
- // Also handle the PR flag placeholder if present
748
- if (endPrompt.includes('--pr')) {
749
- // Replace --pr with appropriate flag based on createPR setting
750
- if (!context.createPR) {
751
- endPrompt = endPrompt.replace(/--pr/g, '--no-pr');
752
- }
753
- }
754
- prompt += endPrompt;
755
- }
756
- else {
757
- // Fallback to default completion instructions (for custom actions without end_prompt)
758
- if (context.modifiesCode) {
759
- prompt += `1. **Commit your work** in each repository directory you modified:\n`;
760
- prompt += ` \`\`\`bash\n`;
761
- prompt += ` cd /workspace/<repo-name>\n`;
762
- prompt += ` git add -A\n`;
763
- prompt += ` prlt commit "describe your change"\n`;
764
- prompt += ` git push\n`;
765
- prompt += ` \`\`\`\n`;
766
- prompt += ` This formats your commit as a conventional commit with the ticket ID.\n`;
767
- prompt += `\n2. **Mark work as ready** by running:\n`;
768
- const prFlag = context.createPR ? ' --pr' : ' --no-pr';
769
- prompt += ` \`\`\`bash\n prlt work ready ${context.ticketId}${prFlag}\n \`\`\`\n`;
770
- if (context.createPR) {
771
- prompt += ` This moves the ticket to review and creates a pull request.\n`;
772
- }
773
- else {
774
- prompt += ` This moves the ticket to review.\n`;
775
- }
776
- prompt += `\n**IMPORTANT:** Use the global \`prlt\` command (just type \`prlt\`). Do NOT use \`./bin/run.js\` or any local path.`;
777
- }
778
- else {
779
- // Non-code-modifying action without custom end_prompt
780
- prompt += `When you have completed the task, provide a summary of what you did.`;
781
- }
782
- }
783
- // Universal stop instruction - prevents Claude Code from making additional API calls after task completion
784
- prompt += `\n\n---\n\n**STOP:** After providing your final summary, your task is complete. Do not take any further actions, do not verify your work again, and do not continue the conversation. Simply output your summary and stop.`;
785
- return prompt;
786
- }
787
- // =============================================================================
788
- // Host Runner - Host execution with tmux session persistence
789
- // =============================================================================
790
- /**
791
- * Run command on the host machine with tmux session for persistence.
792
- * Supports multiple terminal emulators on macOS.
793
- *
794
- * Architecture (same as devcontainer):
795
- * - Always creates a host tmux session for session persistence
796
- * - displayMode controls whether to open a terminal tab attached to the session
797
- * - User can reattach with `prlt session attach` if tab is closed
798
- */
799
- export async function runHost(context, executor, config, displayMode = 'terminal') {
800
- // Session name: {ticketId}-{action} (e.g., TKT-347-implement)
801
- const sessionName = buildTmuxWindowName(context);
802
- const windowTitle = buildWindowTitle(context);
803
- const prompt = buildPrompt(context);
804
- // Terminal - use permission mode setting
805
- const skipPermissions = config.permissionMode === 'danger';
806
- // Validate Codex mode combination before proceeding
807
- if (executor === 'codex') {
808
- const codexPermission = config.permissionMode;
809
- const codexContext = resolveCodexExecutionContext(displayMode, config.outputMode);
810
- const modeError = validateCodexMode(codexPermission, codexContext);
811
- if (modeError) {
812
- return { success: false, error: modeError.message };
813
- }
814
- }
815
- const { cmd, args } = getExecutorCommand(executor, prompt, skipPermissions);
816
- // Write command to temp script to avoid shell escaping issues
817
- // Use HQ .proletariat/scripts if available, otherwise fallback to home dir
818
- const baseDir = context.hqPath
819
- ? path.join(context.hqPath, '.proletariat', 'scripts')
820
- : path.join(os.homedir(), '.proletariat', 'scripts');
821
- fs.mkdirSync(baseDir, { recursive: true });
822
- const timestamp = Date.now();
823
- const scriptPath = path.join(baseDir, `exec-${context.ticketId}-${timestamp}.sh`);
824
- const promptPath = path.join(baseDir, `prompt-${context.ticketId}-${timestamp}.txt`);
825
- // For orchestrator sessions with Claude Code, split the prompt:
826
- // - System prompt (role/tools/context) → injected via --system-prompt flag
827
- // - User message (action instructions or default) → passed as the initial message
828
- // Non-Claude executors get the full combined prompt as the user message.
829
- let systemPromptPath = null;
830
- if (context.isOrchestrator && isClaudeExecutor(executor)) {
831
- const systemPrompt = buildOrchestratorSystemPrompt(context);
832
- systemPromptPath = path.join(baseDir, `system-prompt-${context.ticketId}-${timestamp}.txt`);
833
- fs.writeFileSync(systemPromptPath, systemPrompt, { mode: 0o644 });
834
- // Override user message: just action instructions or a default startup message
835
- const userMessage = context.actionPrompt
836
- || 'Assess the current state of the project:\n'
837
- + '1. Check the board: `prlt board view` — what tickets are in progress, blocked, or ready?\n'
838
- + '2. List running agents: `prlt session list` — who is working on what? Any stale sessions?\n'
839
- + '3. Check open PRs: `gh pr list` — any PRs ready for review or merge?\n'
840
- + '4. Summarize what needs attention and recommend next actions.';
841
- fs.writeFileSync(promptPath, userMessage, { mode: 0o644 });
842
- }
843
- else {
844
- // Write full prompt (includes role context for non-Claude executors)
845
- fs.writeFileSync(promptPath, prompt, { mode: 0o644 });
846
- }
847
- // Tool registry (TKT-083): generate MCP config for Claude Code
848
- let mcpConfigPath = null;
849
- if (context.hqPath && isClaudeExecutor(executor)) {
850
- const toolsResult = resolveToolsForSpawn(context.hqPath, context.toolPolicy, baseDir);
851
- mcpConfigPath = toolsResult.mcpConfigPath;
852
- }
853
- // Build the executor command using getExecutorCommand() output
854
- // For Claude Code, we also support outputMode and additional flags
855
- // For Codex, we use the codex adapter for deterministic command building (TKT-080)
856
- // For other executors, we use the command as-is from getExecutorCommand()
857
- let executorInvocation;
858
- if (isClaudeExecutor(executor)) {
859
- // Build flags based on config - Claude-specific flags
860
- // PRLT-948: --permission-mode bypassPermissions skips the "trust this folder" dialog.
861
- // Without it, Claude Code shows a workspace trust prompt in new worktrees and the
862
- // agent sits idle waiting for user input that never comes in automated tmux sessions.
863
- const bypassTrustFlag = skipPermissions ? '--permission-mode bypassPermissions ' : '';
864
- const permissionsFlag = skipPermissions ? '--dangerously-skip-permissions ' : '';
865
- // outputMode: 'print' adds -p flag (final result only), 'interactive' shows streaming UI
866
- const printFlag = config.outputMode === 'print' ? '-p ' : '';
867
- // --effort high: skips the effort level prompt for automated agents (TKT-1134)
868
- const effortFlag = skipPermissions ? '--effort high ' : '';
869
- // Orchestrator sessions inject their role via --system-prompt
870
- const systemPromptFlag = systemPromptPath ? '--system-prompt "$(cat "$SYSTEM_PROMPT_PATH")" ' : '';
871
- // TKT-053: Disable plan mode for background agents — prevents silent stalls
872
- // when there's no user to approve the plan mode transition
873
- const disallowPlanFlag = displayMode === 'background' ? '--disallowedTools EnterPlanMode ' : '';
874
- // Tool registry (TKT-083): pass MCP config to Claude Code via --mcp-config flag
875
- const mcpConfigFlag = mcpConfigPath ? `--mcp-config "${mcpConfigPath}" ` : '';
876
- // PRLT-950: Use -- to separate flags from positional prompt argument.
877
- // --disallowedTools is variadic and will consume the prompt as its second arg without --.
878
- executorInvocation = `${cmd} ${bypassTrustFlag}${permissionsFlag}${effortFlag}${printFlag}${disallowPlanFlag}${systemPromptFlag}${mcpConfigFlag}-- "$(cat "$PROMPT_PATH")"`;
879
- }
880
- else if (executor === 'codex') {
881
- // TKT-080: Use Codex adapter for deterministic command building.
882
- // Uses PLACEHOLDER pattern for reliable prompt replacement (same as devcontainer runner).
883
- const codexPermission = config.permissionMode;
884
- const codexContext = resolveCodexExecutionContext(displayMode, config.outputMode);
885
- const codexResult = getCodexCommand('PLACEHOLDER', codexPermission, codexContext);
886
- const argsStr = codexResult.args.map(a => a === 'PLACEHOLDER' ? '"$(cat "$PROMPT_PATH")"' : a).join(' ');
887
- executorInvocation = `${codexResult.cmd} ${argsStr}`;
888
- }
889
- else {
890
- // Non-Claude, non-Codex executors: build command from getExecutorCommand() args
891
- // Use PLACEHOLDER for reliable prompt replacement instead of fragile string comparison
892
- const { cmd: execCmd, args: execArgs } = getExecutorCommand(executor, 'PLACEHOLDER', skipPermissions);
893
- const argsWithFile = execArgs.map(a => a === 'PLACEHOLDER' ? '"$(cat "$PROMPT_PATH")"' : `"${a}"`);
894
- executorInvocation = `${execCmd} ${argsWithFile.join(' ')}`;
895
- }
896
- // Build script that runs executor and keeps shell open after completion
897
- const setTitleCmds = getSetTitleCommands(windowTitle);
898
- // TKT-941: Export SYSTEM_PROMPT_PATH so it's available inside srt sandbox child processes.
899
- // Without export, `bash -c '...'` inside srt can't access the variable.
900
- const systemPromptVar = systemPromptPath ? `\nexport SYSTEM_PROMPT_PATH="${systemPromptPath}"` : '';
901
- // Ephemeral agents auto-close after completion instead of dropping to interactive shell
902
- const postExecBlock = context.isEphemeral
903
- ? `
904
- echo ""
905
- echo "✅ Ephemeral agent work complete. Session will auto-close in 5s..."
906
- sleep 5
907
- exit 0
908
- `
909
- : `
910
- echo ""
911
- echo "✅ Agent work complete. Press Enter to close or run more commands."
912
- exec $SHELL
913
- `;
914
- // Wrap with srt sandbox if running in sandbox environment
915
- let finalInvocation = executorInvocation;
916
- if (context.executionEnvironment === 'sandbox') {
917
- // Build the srt wrapper command
918
- // The inner command is the executor invocation that reads from PROMPT_PATH
919
- const srtCmd = buildSrtCommand(`bash -c '${executorInvocation.replace(/'/g, "'\\''")}'`, context, config);
920
- finalInvocation = srtCmd;
921
- }
922
- // TKT-099: Build a fallback invocation WITHOUT the prompt argument.
923
- // Used when prompt file is missing/empty — starts Claude in interactive mode
924
- // so the agent at least gets a working session instead of silently failing.
925
- let fallbackInvocation;
926
- if (isClaudeExecutor(executor)) {
927
- const fbBypassTrust = skipPermissions ? '--permission-mode bypassPermissions ' : '';
928
- const fbPermissions = skipPermissions ? '--dangerously-skip-permissions ' : '';
929
- const fbEffort = skipPermissions ? '--effort high ' : '';
930
- const fbPrint = config.outputMode === 'print' ? '-p ' : '';
931
- const fbDisallowPlan = displayMode === 'background' ? '--disallowedTools EnterPlanMode ' : '';
932
- const fbSystemPrompt = systemPromptPath ? '--system-prompt "$(cat "$SYSTEM_PROMPT_PATH")" ' : '';
933
- const fbMcpConfig = mcpConfigPath ? `--mcp-config "${mcpConfigPath}" ` : '';
934
- fallbackInvocation = `${cmd} ${fbBypassTrust}${fbPermissions}${fbEffort}${fbPrint}${fbDisallowPlan}${fbSystemPrompt}${fbMcpConfig}`.trim();
935
- }
936
- else {
937
- fallbackInvocation = cmd;
938
- }
939
- const scriptContent = `#!/bin/bash
940
- # Auto-generated script for ticket ${context.ticketId}
941
- SCRIPT_PATH="${scriptPath}"
942
- # TKT-941: Export PROMPT_PATH so it's available inside srt sandbox child processes.
943
- # When running in sandbox mode, the executor is wrapped with:
944
- # srt ... -- bash -c 'claude ... "$(cat "$PROMPT_PATH")"'
945
- # Without export, the inner bash started by srt cannot access PROMPT_PATH,
946
- # causing $(cat "$PROMPT_PATH") to expand to empty and the agent to start idle.
947
- export PROMPT_PATH="${promptPath}"${systemPromptVar}
948
- ${setTitleCmds}
949
- echo "🚀 Starting: ${sessionName}"
950
- ${context.executionEnvironment === 'sandbox' ? 'echo "🔒 Running in srt sandbox (filesystem + network isolation)"' : ''}
951
- echo ""
952
- cd "${context.worktreePath}"
953
-
954
- # TKT-099: Robust prompt loading — wait for file and verify content before passing to executor.
955
- # Prevents race where the prompt file isn't flushed/synced yet (e.g., Docker file-sharing
956
- # delay, tmux server restart, or transient filesystem latency).
957
- PROMPT_WAIT=0
958
- while [ ! -s "$PROMPT_PATH" ] && [ $PROMPT_WAIT -lt 30 ]; do
959
- sleep 0.5
960
- PROMPT_WAIT=$((PROMPT_WAIT + 1))
961
- done
962
-
963
- if [ ! -s "$PROMPT_PATH" ]; then
964
- echo "⚠️ Warning: Prompt file not available after 15s. Starting in interactive mode."
965
- echo " Expected: $PROMPT_PATH"
966
- # Fallback: launch executor without prompt so the session isn't lost
967
- (unset CLAUDECODE CLAUDE_CODE_ENTRYPOINT; ${fallbackInvocation})
968
- else
969
- # Run executor in subshell with CLAUDECODE unset (prevents nested session error)
970
- (unset CLAUDECODE CLAUDE_CODE_ENTRYPOINT; ${finalInvocation})
971
- fi
972
-
973
- # Clean up script and prompt files
974
- rm -f "$SCRIPT_PATH" "$PROMPT_PATH"${systemPromptPath ? ' "$SYSTEM_PROMPT_PATH"' : ''}
975
- ${postExecBlock}`;
976
- fs.writeFileSync(scriptPath, scriptContent, { mode: 0o755 });
977
- try {
978
- // Check if tmux is available
979
- execSync('which tmux', { stdio: 'pipe' });
980
- const terminalApp = config.terminal.app;
981
- // Check if we should use iTerm control mode (-CC)
982
- // When using -CC, iTerm handles scrolling/selection natively, so we DON'T set mouse on
983
- // Without -CC, we need mouse on for tmux to handle scrolling
984
- const useControlMode = shouldUseControlMode(terminalApp, config.tmux.controlMode);
985
- // Step 1: Create host tmux session (detached)
986
- // Only enable mouse mode if NOT using control mode (control mode lets iTerm handle mouse natively)
987
- const mouseOption = buildTmuxMouseOption(useControlMode);
988
- const tmuxCmd = `tmux new-session -d -s "${sessionName}" -n "${sessionName}" "${scriptPath}"${mouseOption} \\; set-option -g set-titles on \\; set-option -g set-titles-string "#{window_name}"`;
989
- try {
990
- execSync(tmuxCmd, { stdio: 'pipe' });
991
- }
992
- catch (error) {
993
- return {
994
- success: false,
995
- error: `Failed to create tmux session: ${error instanceof Error ? error.message : error}`,
996
- };
997
- }
998
- // Step 2: Open terminal tab attached to tmux session (unless background or foreground mode)
999
- if (displayMode === 'background') {
1000
- return {
1001
- success: true,
1002
- sessionId: sessionName,
1003
- };
1004
- }
1005
- // Foreground mode: attach to tmux session in current terminal (blocking)
1006
- if (displayMode === 'foreground') {
1007
- try {
1008
- // Clear screen and attach - this blocks until user detaches or claude exits
1009
- // Never use -CC in foreground mode: control mode sends raw tmux protocol
1010
- // sequences (%begin, %output, %end) that render as garbled text unless
1011
- // iTerm's native CC handler is active (only happens in new tabs opened via AppleScript)
1012
- const fgTmuxAttach = buildTmuxAttachCommand(false);
1013
- execSync(`clear && ${fgTmuxAttach} -t "${sessionName}"`, { stdio: 'inherit' });
1014
- return {
1015
- success: true,
1016
- sessionId: sessionName,
1017
- };
1018
- }
1019
- catch (error) {
1020
- return {
1021
- success: false,
1022
- error: `Failed to attach to tmux session: ${error instanceof Error ? error.message : error}`,
1023
- };
1024
- }
1025
- }
1026
- // Use tmux -CC (control mode) for iTerm when enabled in config
1027
- // -CC gives native iTerm scrolling, selection, and gesture support
1028
- // Without -CC, use regular attach (relies on mouse mode for scrolling)
1029
- const tmuxAttach = buildTmuxAttachCommand(useControlMode);
1030
- const attachCmd = `clear && ${tmuxAttach} -t \\"${sessionName}\\"`;
1031
- // For iTerm with control mode, create a new tab and run -CC attach there
1032
- // This avoids interfering with the terminal where prlt is running
1033
- if (terminalApp === 'iTerm' && useControlMode) {
1034
- // Configure iTerm to open tmux windows as tabs or windows based on user preference
1035
- configureITermTmuxWindowMode(config.tmux.windowMode);
1036
- const openInBackground = config.terminal.openInBackground ?? true;
1037
- if (openInBackground) {
1038
- // Open tab without stealing focus - save frontmost app and restore after
1039
- execSync(`osascript -e '
1040
- set frontApp to path to frontmost application as text
1041
- tell application "iTerm"
1042
- tell current window
1043
- set newTab to (create tab with default profile)
1044
- tell current session of newTab
1045
- write text "tmux -u -CC attach -d -t \\"${sessionName}\\""
1046
- end tell
1047
- end tell
1048
- end tell
1049
- tell application frontApp to activate
1050
- '`);
1051
- }
1052
- else {
1053
- execSync(`osascript -e '
1054
- tell application "iTerm"
1055
- activate
1056
- tell current window
1057
- set newTab to (create tab with default profile)
1058
- tell current session of newTab
1059
- write text "tmux -u -CC attach -d -t \\"${sessionName}\\""
1060
- end tell
1061
- end tell
1062
- end tell
1063
- '`);
1064
- }
1065
- return {
1066
- success: true,
1067
- sessionId: sessionName,
1068
- };
1069
- }
1070
- // Check if we should open in background (don't steal focus)
1071
- const openInBackground = config.terminal.openInBackground ?? true;
1072
- switch (terminalApp) {
1073
- case 'iTerm':
1074
- // Without control mode, create a new tab and attach normally
1075
- // When openInBackground is true, save frontmost app and restore after
1076
- if (openInBackground) {
1077
- execSync(`osascript -e '
1078
- -- Save the currently active application and window
1079
- tell application "System Events"
1080
- set frontApp to name of first application process whose frontmost is true
1081
- set frontAppBundle to bundle identifier of first application process whose frontmost is true
1082
- end tell
1083
-
1084
- tell application "iTerm"
1085
- if (count of windows) = 0 then
1086
- create window with default profile
1087
- delay 0.3
1088
- tell current session of current window
1089
- set name to "${windowTitle}"
1090
- write text "${attachCmd}"
1091
- end tell
1092
- else
1093
- tell current window
1094
- set newTab to (create tab with default profile)
1095
- delay 0.3
1096
- tell current session of newTab
1097
- set name to "${windowTitle}"
1098
- write text "${attachCmd}"
1099
- end tell
1100
- end tell
1101
- end if
1102
- end tell
1103
-
1104
- -- Restore focus to the original application
1105
- delay 0.2
1106
- tell application "System Events"
1107
- set frontmost of process frontApp to true
1108
- end tell
1109
- delay 0.1
1110
- do shell script "open -b " & quoted form of frontAppBundle
1111
- '`);
1112
- }
1113
- else {
1114
- execSync(`osascript -e '
1115
- tell application "iTerm"
1116
- activate
1117
- if (count of windows) = 0 then
1118
- create window with default profile
1119
- delay 0.3
1120
- tell current session of current window
1121
- set name to "${windowTitle}"
1122
- write text "${attachCmd}"
1123
- end tell
1124
- else
1125
- tell current window
1126
- set newTab to (create tab with default profile)
1127
- delay 0.3
1128
- tell current session of newTab
1129
- set name to "${windowTitle}"
1130
- write text "${attachCmd}"
1131
- end tell
1132
- end tell
1133
- end if
1134
- end tell
1135
- '`);
1136
- }
1137
- break;
1138
- case 'Ghostty':
1139
- // Ghostty - use osascript to open new tab and run command
1140
- execSync(`osascript -e '
1141
- tell application "Ghostty"
1142
- activate
1143
- end tell
1144
- tell application "System Events"
1145
- tell process "Ghostty"
1146
- keystroke "t" using command down
1147
- delay 0.3
1148
- keystroke "${attachCmd}"
1149
- keystroke return
1150
- end tell
1151
- end tell
1152
- '`);
1153
- break;
1154
- case 'WezTerm':
1155
- // WezTerm - use wezterm cli to spawn new tab
1156
- execSync(`wezterm cli spawn --new-window -- bash -c '${attachCmd}'`);
1157
- break;
1158
- case 'Kitty':
1159
- // Kitty - use kitten to open new tab
1160
- execSync(`kitty @ launch --type=tab -- bash -c '${attachCmd}'`);
1161
- break;
1162
- case 'Alacritty':
1163
- // Alacritty doesn't have native tab support, opens new window
1164
- execSync(`osascript -e '
1165
- tell application "Alacritty"
1166
- activate
1167
- end tell
1168
- tell application "System Events"
1169
- tell process "Alacritty"
1170
- keystroke "n" using command down
1171
- delay 0.3
1172
- keystroke "${attachCmd}"
1173
- keystroke return
1174
- end tell
1175
- end tell
1176
- '`);
1177
- break;
1178
- case 'Terminal':
1179
- default:
1180
- // macOS Terminal.app - new tab
1181
- // Note: Terminal.app with System Events keystrokes requires activation for Cmd+T
1182
- // But we can use 'do script' which opens a new window without activation if needed
1183
- if (openInBackground) {
1184
- // Open in background: use 'do script' which creates a new window without activating
1185
- execSync(`osascript -e '
1186
- tell application "Terminal"
1187
- do script "${attachCmd}"
1188
- set custom title of front window to "${windowTitle}"
1189
- end tell
1190
- '`);
1191
- }
1192
- else {
1193
- // Bring to front: use traditional Cmd+T for new tab
1194
- execSync(`osascript -e '
1195
- tell application "Terminal"
1196
- activate
1197
- tell application "System Events"
1198
- tell process "Terminal"
1199
- keystroke "t" using command down
1200
- end tell
1201
- end tell
1202
- delay 0.3
1203
- do script "${attachCmd}" in front window
1204
- end tell
1205
- '`);
1206
- }
1207
- break;
1208
- }
1209
- return {
1210
- success: true,
1211
- sessionId: sessionName,
1212
- };
1213
- }
1214
- catch (error) {
1215
- return {
1216
- success: false,
1217
- error: error instanceof Error ? error.message : `Failed to start host tmux session`,
1218
- };
1219
- }
1220
- }
1221
- // =============================================================================
1222
- // GitHub Token Check
1223
- // =============================================================================
1224
- /**
1225
- * Check if GitHub token is available for git push operations.
1226
- * Checks environment variables first, then tries gh auth token.
1227
- * Returns the token if available, null otherwise.
1228
- */
1229
- export function getGitHubToken() {
1230
- // Check environment variables first
1231
- if (process.env.GITHUB_TOKEN) {
1232
- return process.env.GITHUB_TOKEN;
1233
- }
1234
- if (process.env.GH_TOKEN) {
1235
- return process.env.GH_TOKEN;
1236
- }
1237
- // Try to get token from gh CLI
1238
- try {
1239
- const token = execSync('gh auth token', { encoding: 'utf-8', stdio: 'pipe' }).trim();
1240
- if (token) {
1241
- return token;
1242
- }
1243
- }
1244
- catch {
1245
- // gh auth token failed - user not logged in
1246
- }
1247
- return null;
1248
- }
1249
- /**
1250
- * Check if GitHub token is available.
1251
- * Returns true if token is available via env vars or gh CLI.
1252
- */
1253
- export function isGitHubTokenAvailable() {
1254
- return getGitHubToken() !== null;
1255
- }
1256
- /**
1257
- * Check Docker daemon health with fast detection (TKT-081).
1258
- *
1259
- * Uses `docker ps` with a 5-second timeout to quickly detect:
1260
- * - Docker not installed
1261
- * - Docker installed but daemon unresponsive (stuck on license, initializing, 500 errors)
1262
- * - Docker ready
1263
- *
1264
- * Total worst-case time: ~5 seconds (single attempt with timeout).
1265
- */
1266
- export function checkDockerDaemon() {
1267
- // First: is docker even installed?
1268
- try {
1269
- execSync('which docker', { stdio: 'pipe', timeout: 3000 });
1270
- }
1271
- catch {
1272
- return {
1273
- available: false,
1274
- reason: 'not-installed',
1275
- message: 'Docker is not installed.',
1276
- };
1277
- }
1278
- // Second: is the daemon responsive? Use `docker ps` — it's lightweight and
1279
- // fails fast when the daemon returns 500s or hangs on GUI prompts.
1280
- const timeout = 5000; // 5 seconds — enough for a healthy daemon, fast fail otherwise
1281
- try {
1282
- execSync('docker ps -q --no-trunc', { stdio: 'pipe', timeout });
1283
- return {
1284
- available: true,
1285
- reason: 'ready',
1286
- message: 'Docker daemon is ready.',
1287
- };
1288
- }
1289
- catch (error) {
1290
- // Parse the error to give actionable feedback
1291
- const stderr = error?.stderr?.toString() || '';
1292
- const isTimeout = error?.killed === true;
1293
- let message;
1294
- if (isTimeout) {
1295
- message = 'Docker daemon is not responding (timed out after 5s). Docker Desktop may be initializing or stuck — check for license/login prompts.';
1296
- }
1297
- else if (stderr.includes('500') || stderr.includes('Internal Server Error')) {
1298
- message = 'Docker daemon is returning errors (500). Docker Desktop needs attention — check for license/login prompts.';
1299
- }
1300
- else if (stderr.includes('connect') || stderr.includes('Cannot connect') || stderr.includes('Is the docker daemon running')) {
1301
- message = 'Docker daemon is not running. Start Docker Desktop and try again.';
1302
- }
1303
- else {
1304
- message = `Docker daemon is not ready: ${stderr.trim() || 'unknown error'}. Check Docker Desktop status.`;
1305
- }
1306
- return {
1307
- available: false,
1308
- reason: 'daemon-not-ready',
1309
- message,
1310
- };
1311
- }
1312
- }
1313
- /**
1314
- * Check if Docker daemon is running.
1315
- * Returns true if Docker is available and responsive.
4
+ * This file has been refactored into separate modules under ./runners/.
5
+ * All exports are preserved for backwards compatibility.
1316
6
  *
1317
- * For detailed diagnostics, use checkDockerDaemon() instead.
1318
- */
1319
- export function isDockerRunning() {
1320
- return checkDockerDaemon().available;
1321
- }
1322
- /**
1323
- * Check if the devcontainer CLI is installed.
1324
- * Returns true if the CLI is available, false otherwise.
1325
- * @deprecated No longer required - we use raw Docker commands now
1326
- */
1327
- export function isDevcontainerCliInstalled() {
1328
- // Always return true since we no longer require devcontainer CLI
1329
- // We use raw Docker commands instead
1330
- return true;
1331
- }
1332
- // =============================================================================
1333
- // Docker Container Management (Raw Docker, no devcontainer CLI)
1334
- // =============================================================================
1335
- /**
1336
- * Get the host's installed prlt CLI version.
1337
- * Returns the semver version string (e.g., "0.3.35") or null if not available.
1338
- * Used to ensure containers run the same prlt version as the host (TKT-1029).
1339
- */
1340
- function getHostPrltVersion() {
1341
- try {
1342
- const output = execSync('prlt --version', {
1343
- encoding: 'utf-8',
1344
- stdio: ['pipe', 'pipe', 'pipe'],
1345
- }).trim();
1346
- const match = output.match(/(\d+\.\d+\.\d+)/);
1347
- return match ? match[1] : null;
1348
- }
1349
- catch {
1350
- return null;
1351
- }
1352
- }
1353
- /**
1354
- * Get the container name for an agent.
1355
- * Format: prlt-agent-{agentName}
1356
- */
1357
- export function getAgentContainerName(agentName) {
1358
- // Sanitize agent name for Docker container naming (alphanumeric, dash, underscore only)
1359
- const sanitized = agentName.replace(/[^a-zA-Z0-9_-]/g, '-');
1360
- return `prlt-agent-${sanitized}`;
1361
- }
1362
- // Alias for internal use
1363
- const getContainerName = getAgentContainerName;
1364
- /**
1365
- * Get the image name for an agent.
1366
- * Format: prlt-agent-{agentName}:latest
1367
- */
1368
- function getImageName(agentName) {
1369
- const sanitized = agentName.replace(/[^a-zA-Z0-9_-]/g, '-');
1370
- return `prlt-agent-${sanitized}:latest`;
1371
- }
1372
- /**
1373
- * Check if a Docker container exists (running or stopped).
1374
- */
1375
- export function containerExists(containerName) {
1376
- try {
1377
- execSync(`docker container inspect ${containerName}`, { stdio: 'pipe', timeout: 5000 });
1378
- return true;
1379
- }
1380
- catch {
1381
- return false;
1382
- }
1383
- }
1384
- /**
1385
- * Check if a Docker container is running.
1386
- */
1387
- export function isContainerRunning(containerName) {
1388
- try {
1389
- const status = execSync(`docker container inspect -f '{{.State.Running}}' ${containerName}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }).trim();
1390
- return status === 'true';
1391
- }
1392
- catch {
1393
- return false;
1394
- }
1395
- }
1396
- /**
1397
- * Get the container ID for a running container.
1398
- */
1399
- export function getContainerId(containerName) {
1400
- try {
1401
- const containerId = execSync(`docker container inspect -f '{{.Id}}' ${containerName}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }).trim();
1402
- return containerId ? containerId.substring(0, 12) : null;
1403
- }
1404
- catch {
1405
- return null;
1406
- }
1407
- }
1408
- /**
1409
- * Build Docker image for an agent from its Dockerfile.
1410
- */
1411
- function buildDockerImage(agentDir, imageName, buildArgs = {}) {
1412
- const dockerfilePath = path.join(agentDir, '.devcontainer', 'Dockerfile');
1413
- if (!fs.existsSync(dockerfilePath)) {
1414
- console.debug(`[runners:docker] Dockerfile not found at ${dockerfilePath}`);
1415
- return false;
1416
- }
1417
- try {
1418
- // Build --build-arg flags
1419
- const buildArgFlags = Object.entries(buildArgs)
1420
- .map(([key, value]) => `--build-arg ${key}="${value}"`)
1421
- .join(' ');
1422
- const buildCmd = `docker build -t ${imageName} -f "${dockerfilePath}" ${buildArgFlags} "${path.join(agentDir, '.devcontainer')}"`;
1423
- console.debug(`[runners:docker] Building image: ${buildCmd}`);
1424
- execSync(buildCmd, { stdio: 'pipe' });
1425
- return true;
1426
- }
1427
- catch (error) {
1428
- console.debug(`[runners:docker] Failed to build image:`, error);
1429
- return false;
1430
- }
1431
- }
1432
- /**
1433
- * Check if a Docker image exists.
1434
- */
1435
- function imageExists(imageName) {
1436
- try {
1437
- execSync(`docker image inspect ${imageName}`, { stdio: 'pipe', timeout: 5000 });
1438
- return true;
1439
- }
1440
- catch {
1441
- return false;
1442
- }
1443
- }
1444
- /**
1445
- * Create and start a Docker container for an agent.
1446
- * Uses raw Docker commands instead of devcontainer CLI.
1447
- */
1448
- function createDockerContainer(context, containerName, imageName, config, executor = 'claude-code', prltInfo) {
1449
- // Build mount flags
1450
- // KEY: Use a named Docker volume for Claude credentials - this is how devcontainer.json
1451
- // was handling it. The volume persists across containers, so login once = logged in everywhere.
1452
- // This avoids corruption from concurrent writes to host filesystem.
1453
- //
1454
- // TKT-801: Use :cached mount option to reduce grpcfuse contention on Docker Desktop.
1455
- // This improves performance and helps prevent kernel panics when multiple containers
1456
- // mount the same paths concurrently.
1457
- const mounts = [
1458
- // Agent workspace
1459
- `-v "${context.agentDir}:/workspace:cached"`,
1460
- // HQ .proletariat directory (for database access) - use :cached to reduce contention
1461
- ...(context.hqPath ? [`-v "${context.hqPath}/.proletariat:/hq/.proletariat:cached"`] : []),
1462
- // PMO path - use :cached to reduce contention
1463
- ...(context.pmoPath ? [`-v "${context.pmoPath}:/hq/pmo:cached"`] : []),
1464
- // Mount parent repos for git worktree resolution - use :cached to reduce contention
1465
- // NOTE: Cannot use :ro because git worktrees share the object store with parent repo.
1466
- // Commits write to parent's .git/objects/ and refs update in .git/worktrees/<name>/
1467
- // Worktree .git files reference paths like /Users/.../repos/{repoName}/.git/worktrees/name
1468
- // These mounts make those paths accessible inside the container at /hq/repos/{repoName}
1469
- ...(context.repoWorktrees || []).map(repoName => `-v "${context.hqPath}/repos/${repoName}:/hq/repos/${repoName}:cached"`),
1470
- // Claude credentials - shared named volume (login once, all containers share)
1471
- // Only needed for Claude Code executor
1472
- ...(isClaudeExecutor(executor) ? [`-v "claude-credentials:/home/node/.claude"`] : []),
1473
- ];
1474
- // Build environment flags
1475
- const hasWorktrees = context.repoWorktrees && context.repoWorktrees.length > 0;
1476
- const firewallAllowlistDomains = [...new Set((config.firewall?.allowlistDomains || [])
1477
- .map(domain => domain.trim().toLowerCase())
1478
- .filter(domain => /^[a-z0-9.-]+$/.test(domain)))];
1479
- const envVars = [
1480
- `-e DEVCONTAINER=true`,
1481
- `-e PRLT_HQ_PATH=/hq`,
1482
- `-e PRLT_AGENT_NAME="${context.agentName}"`,
1483
- `-e PRLT_HOST_PATH="${context.agentDir}"`,
1484
- // Only pass ANTHROPIC_API_KEY if the user explicitly chose to use it (no OAuth creds).
1485
- // Claude Code prefers API key over OAuth, so passing it would cause agents to burn
1486
- // API credits instead of using Max subscription.
1487
- ...(context.useApiKey && process.env.ANTHROPIC_API_KEY ? [`-e ANTHROPIC_API_KEY="${process.env.ANTHROPIC_API_KEY}"`] : []),
1488
- ...(process.env.GITHUB_TOKEN ? [`-e GITHUB_TOKEN="${process.env.GITHUB_TOKEN}"`] : []),
1489
- ...(process.env.GH_TOKEN ? [`-e GH_TOKEN="${process.env.GH_TOKEN}"`] : []),
1490
- ...(firewallAllowlistDomains.length > 0 ? [`-e PRLT_EXTRA_ALLOWLIST_DOMAINS="${firewallAllowlistDomains.join(',')}"`] : []),
1491
- // NOTE: Do NOT pass CLAUDE_CODE_OAUTH_TOKEN - it overrides credentials file
1492
- // and setup-token generates invalid tokens. Use "prlt agent auth" instead.
1493
- // Set mount mode to worktree if we have repo worktrees - triggers git wrapper setup
1494
- ...(hasWorktrees ? [`-e PRLT_MOUNT_MODE=worktree`] : []),
1495
- // Pass prlt version info for setup-prlt.sh to verify/update at container start (TKT-1029)
1496
- ...(prltInfo ? [
1497
- `-e PRLT_REGISTRY="${prltInfo.registry}"`,
1498
- `-e PRLT_VERSION="${prltInfo.version}"`,
1499
- ] : []),
1500
- ];
1501
- // Resource limits
1502
- const resourceFlags = [
1503
- `--memory=${config.devcontainer.memory}`,
1504
- `--cpus=${config.devcontainer.cpus}`,
1505
- ];
1506
- // Security flags - these provide the isolation
1507
- const securityFlags = [
1508
- '--cap-add=NET_ADMIN', // For firewall setup
1509
- '--cap-add=NET_RAW', // For firewall setup
1510
- // Note: After firewall is set up, the container is network-restricted
1511
- ];
1512
- try {
1513
- const createCmd = [
1514
- 'docker run -d',
1515
- `--name ${containerName}`,
1516
- '--user node',
1517
- '-w /workspace',
1518
- ...mounts,
1519
- ...envVars,
1520
- ...resourceFlags,
1521
- ...securityFlags,
1522
- imageName,
1523
- 'sleep infinity', // Keep container running
1524
- ].join(' ');
1525
- console.debug(`[runners:docker] Creating container: ${createCmd}`);
1526
- execSync(createCmd, { stdio: 'pipe' });
1527
- return true;
1528
- }
1529
- catch (error) {
1530
- console.debug(`[runners:docker] Failed to create container:`, error);
1531
- return false;
1532
- }
1533
- }
1534
- /**
1535
- * Run the post-start setup commands in a container.
1536
- * This includes firewall initialization, prlt setup, and Claude settings.
1537
- * @param containerId - Docker container ID
1538
- * @param permissionMode - Permission mode: 'safe' requires approval, 'danger' skips checks
1539
- * @param executor - Which executor is being used (determines Claude-specific setup)
1540
- */
1541
- function runContainerSetup(containerId, permissionMode = 'safe', executor = 'claude-code') {
1542
- try {
1543
- // Run firewall init (requires sudo since we're running as node user)
1544
- execSync(`docker exec ${containerId} sudo /usr/local/bin/init-firewall.sh`, { stdio: 'pipe' });
1545
- // Run prlt setup
1546
- execSync(`docker exec ${containerId} /usr/local/bin/setup-prlt.sh`, { stdio: 'pipe' });
1547
- }
1548
- catch (error) {
1549
- console.debug(`[runners:docker] Container setup scripts failed:`, error);
1550
- // Continue - setup might partially work
1551
- }
1552
- // Configure pnpm to use container-local store to prevent contention
1553
- // Multiple agents sharing the same pnpm store causes hangs and ERR_PNPM errors (TKT-718)
1554
- // Each container gets its own store at /tmp/pnpm-store for reliability
1555
- try {
1556
- execSync(`docker exec ${containerId} pnpm config set store-dir /tmp/pnpm-store`, { stdio: 'pipe' });
1557
- console.debug(`[runners:docker] Configured pnpm store-dir to /tmp/pnpm-store`);
1558
- }
1559
- catch (error) {
1560
- console.debug(`[runners:docker] Failed to configure pnpm store (pnpm may not be installed):`, error);
1561
- // Non-fatal - pnpm may not be installed in all containers
1562
- }
1563
- // Copy Claude settings file (.claude.json) from host to container
1564
- // Only needed for Claude Code executor - other executors have their own config
1565
- if (isClaudeExecutor(executor)) {
1566
- // This is needed for Claude Code to recognize settings and bypass prompts
1567
- // Note: Auth tokens are in the claude-credentials volume at /home/node/.claude/.credentials.json
1568
- // But settings (.claude.json) need to be at /home/node/.claude.json (outside the .claude dir)
1569
- try {
1570
- const hostClaudeJson = path.join(os.homedir(), '.claude.json');
1571
- let settings = {};
1572
- if (fs.existsSync(hostClaudeJson)) {
1573
- // Read host file content as base
1574
- const content = fs.readFileSync(hostClaudeJson, 'utf-8');
1575
- try {
1576
- settings = JSON.parse(content);
1577
- }
1578
- catch {
1579
- console.debug('[runners:docker] Failed to parse host .claude.json, using empty settings');
1580
- }
1581
- }
1582
- // Only set bypassPermissionsModeAccepted when user chose danger mode
1583
- // This doesn't modify the host file - only the container copy
1584
- if (permissionMode === 'danger') {
1585
- settings.bypassPermissionsModeAccepted = true;
1586
- }
1587
- // Skip first-run onboarding (theme picker, tips, etc.) for automated agents
1588
- // These flags indicate Claude Code has been run before
1589
- settings.numStartups = settings.numStartups || 1;
1590
- settings.hasCompletedOnboarding = true;
1591
- settings.theme = settings.theme || 'dark';
1592
- // Ensure tipsHistory exists to prevent tip prompts
1593
- if (!settings.tipsHistory || typeof settings.tipsHistory !== 'object') {
1594
- settings.tipsHistory = {};
1595
- }
1596
- const tips = settings.tipsHistory;
1597
- tips['new-user-warmup'] = tips['new-user-warmup'] || 1;
1598
- // Dismiss the effort level callout so agents aren't prompted (TKT-1134)
1599
- settings.effortCalloutDismissed = true;
1600
- // Pre-accept the "trust this folder" dialog for /workspace (TKT-1134)
1601
- // Claude Code stores trust per-project under projects[path].hasTrustDialogAccepted
1602
- // Without this, agents get stuck on the workspace safety prompt
1603
- if (!settings.projects || typeof settings.projects !== 'object') {
1604
- settings.projects = {};
1605
- }
1606
- const projects = settings.projects;
1607
- // Accept trust for /workspace and root / to cover all container working directories
1608
- for (const projectPath of ['/workspace', '/']) {
1609
- if (!projects[projectPath]) {
1610
- projects[projectPath] = {};
1611
- }
1612
- projects[projectPath].hasTrustDialogAccepted = true;
1613
- projects[projectPath].hasCompletedProjectOnboarding = true;
1614
- }
1615
- // Pipe settings via stdin to avoid ARG_MAX limits with large .claude.json files
1616
- const settingsJson = JSON.stringify(settings);
1617
- // Write to container at /home/node/.claude.json using stdin piping
1618
- execSync(`docker exec -i ${containerId} bash -c 'cat > /home/node/.claude.json'`, { input: settingsJson, stdio: ['pipe', 'pipe', 'pipe'] });
1619
- console.debug(`[runners:docker] Copied .claude.json settings to container (bypassPermissionsModeAccepted=${permissionMode === 'danger'})`);
1620
- // Write ~/.claude/settings.json to skip the dangerous mode permission prompt (TKT-1134)
1621
- // This prevents Claude Code from prompting about permission mode on first run
1622
- const claudeSettings = JSON.stringify({ skipDangerousModePermissionPrompt: true });
1623
- execSync(`docker exec -i ${containerId} bash -c 'mkdir -p /home/node/.claude && cat > /home/node/.claude/settings.json'`, { input: claudeSettings, stdio: ['pipe', 'pipe', 'pipe'] });
1624
- console.debug(`[runners:docker] Wrote ~/.claude/settings.json to container`);
1625
- }
1626
- catch (error) {
1627
- console.debug('[runners:docker] Failed to copy Claude settings to container:', error);
1628
- // Non-fatal - Claude will just prompt for settings
1629
- }
1630
- // NOTE: Auth credentials come from the claude-credentials volume.
1631
- // Run "prlt agent auth" to set up authentication (one-time).
1632
- // Do NOT sync CLAUDE_CODE_OAUTH_TOKEN env var - it causes issues
1633
- // (setup-token generates invalid tokens, and env var overrides valid credentials file).
1634
- }
1635
- else {
1636
- console.debug(`[runners:docker] Skipping .claude.json settings injection for ${executor} executor`);
1637
- }
1638
- return true;
1639
- }
1640
- /**
1641
- * Ensure a Docker container is running for the agent.
1642
- * Reuses running containers to preserve in-progress work (TKT-1028).
1643
- * Only destroys and recreates stopped containers.
1644
- * Builds image and creates container if needed.
1645
- * Returns the container ID if successful, null otherwise.
1646
- */
1647
- function ensureDockerContainer(context, config, executor = 'claude-code') {
1648
- const containerName = getContainerName(context.agentName);
1649
- const imageName = getImageName(context.agentName);
1650
- // TKT-1028: Reuse running containers instead of destroying them.
1651
- // This preserves in-progress tmux sessions and avoids killing running agents.
1652
- // Only destroy stopped containers (which have stale mounts anyway).
1653
- if (containerExists(containerName)) {
1654
- if (isContainerRunning(containerName)) {
1655
- // Container is running - reuse it to preserve any in-progress work.
1656
- // Note: runContainerSetup is skipped for reused containers since they
1657
- // were already set up when first created. GitHub token and credentials
1658
- // are refreshed by the caller (runDevcontainer).
1659
- const containerId = getContainerId(containerName);
1660
- if (containerId) {
1661
- console.debug(`[runners:docker] Reusing running container ${containerName} (${containerId}), skipping setup`);
1662
- return containerId;
1663
- }
1664
- }
1665
- // Container exists but is stopped - remove and recreate for fresh mounts
1666
- console.debug(`[runners:docker] Removing stopped container ${containerName} to create fresh one`);
1667
- try {
1668
- execSync(`docker rm -f ${containerName}`, { stdio: 'pipe', timeout: 10000 });
1669
- }
1670
- catch {
1671
- // Ignore removal errors
1672
- }
1673
- }
1674
- // Build image with version-aware cache busting (TKT-1029)
1675
- // Read build args from devcontainer.json instead of hardcoding
1676
- const devcontainerJson = readDevcontainerJson(context.agentDir);
1677
- const buildArgs = {
1678
- TZ: devcontainerJson?.build?.args?.TZ || 'America/Los_Angeles',
1679
- PRLT_REGISTRY: devcontainerJson?.build?.args?.PRLT_REGISTRY || 'npm',
1680
- };
1681
- // Resolve the specific prlt version to install (TKT-1029)
1682
- // When the configured version is a tag like "latest", resolve it to the host's
1683
- // actual prlt version. This serves two purposes:
1684
- // 1. Ensures the container runs the same version as the host
1685
- // 2. Enables Docker layer cache busting when the host version changes
1686
- // (Docker caches "latest" as a static string, so the layer never rebuilds)
1687
- const configuredVersion = devcontainerJson?.build?.args?.PRLT_VERSION || 'latest';
1688
- const isTagVersion = ['latest', 'dev', 'next'].includes(configuredVersion);
1689
- const hostPrltVersion = isTagVersion ? getHostPrltVersion() : null;
1690
- if (hostPrltVersion) {
1691
- buildArgs.PRLT_VERSION = hostPrltVersion;
1692
- console.debug(`[runners:docker] Using host prlt version ${hostPrltVersion} for image build`);
1693
- }
1694
- else {
1695
- buildArgs.PRLT_VERSION = configuredVersion;
1696
- }
1697
- // Always run docker build - Docker layer caching makes this efficient when
1698
- // nothing has changed. When PRLT_VERSION changes (e.g., "0.3.29" -> "0.3.35"),
1699
- // the changed build arg invalidates the cache from that layer forward,
1700
- // ensuring the new version gets installed.
1701
- console.debug(`[runners:docker] Building image ${imageName} (PRLT_VERSION=${buildArgs.PRLT_VERSION})`);
1702
- if (!buildDockerImage(context.agentDir, imageName, buildArgs)) {
1703
- if (!imageExists(imageName)) {
1704
- return null; // No image at all, can't proceed
1705
- }
1706
- // Build failed but old image exists - continue with setup-prlt.sh as fallback
1707
- console.debug(`[runners:docker] Build failed but existing image found, continuing with runtime update`);
1708
- }
1709
- // Pass resolved prlt version info to the container environment (TKT-1029)
1710
- // This allows setup-prlt.sh to verify/update prlt without querying npm registry
1711
- const prltInfo = {
1712
- registry: buildArgs.PRLT_REGISTRY,
1713
- version: buildArgs.PRLT_VERSION,
1714
- };
1715
- // Create and start container
1716
- console.debug(`[runners:docker] Creating container ${containerName}`);
1717
- if (!createDockerContainer(context, containerName, imageName, config, executor, prltInfo)) {
1718
- return null;
1719
- }
1720
- const containerId = getContainerId(containerName);
1721
- if (!containerId) {
1722
- return null;
1723
- }
1724
- // Run post-start setup (firewall, prlt, Claude settings)
1725
- // Pass permission mode to determine whether to set bypassPermissionsModeAccepted
1726
- // Pass executor to skip Claude-specific setup for non-Claude executors
1727
- console.debug(`[runners:docker] Running container setup (permissionMode=${config.permissionMode}, executor=${executor})`);
1728
- if (!runContainerSetup(containerId, config.permissionMode, executor)) {
1729
- console.debug(`[runners:docker] Setup failed, but continuing...`);
1730
- // Don't fail completely - setup might partially work
1731
- }
1732
- // NOTE: Claude credentials are copied to workspace before container creation
1733
- // (see copyClaudeCredentials call in runDevcontainer)
1734
- return containerId;
1735
- }
1736
- /**
1737
- * Copy Claude Code credentials (~/.claude.json) into the agent directory.
1738
- * This makes the subscription credentials available inside the devcontainer
1739
- * since the agent directory is mounted at /workspace.
1740
- *
1741
- * This was the original working approach before the raw Docker refactor.
1742
- */
1743
- function copyClaudeCredentials(agentDir) {
1744
- const sourceFile = path.join(os.homedir(), '.claude.json');
1745
- const destFile = path.join(agentDir, '.claude.json');
1746
- if (fs.existsSync(sourceFile)) {
1747
- try {
1748
- fs.copyFileSync(sourceFile, destFile);
1749
- console.debug('[runners:credentials] Copied .claude.json to workspace');
1750
- }
1751
- catch (err) {
1752
- console.debug('[runners:credentials] Failed to copy .claude.json:', err);
1753
- }
1754
- }
1755
- }
1756
- // =============================================================================
1757
- // Devcontainer Runner (now uses raw Docker)
1758
- // =============================================================================
1759
- /**
1760
- * Clean up old prompt files from the worktree.
1761
- * This is called before writing a new prompt file to prevent accumulation
1762
- * of stale prompt files from failed or interrupted executions.
1763
- */
1764
- function cleanupOldPromptFiles(worktreePath, ticketId) {
1765
- try {
1766
- const files = fs.readdirSync(worktreePath);
1767
- const pattern = ticketId
1768
- ? new RegExp(`^\\.prlt-prompt-${ticketId}-\\d+\\.txt$`)
1769
- : /^\.prlt-prompt-.*\.txt$/;
1770
- for (const file of files) {
1771
- if (pattern.test(file)) {
1772
- try {
1773
- fs.unlinkSync(path.join(worktreePath, file));
1774
- }
1775
- catch (err) {
1776
- console.debug(`[runners:cleanup] Failed to delete ${file}:`, err);
1777
- }
1778
- }
1779
- }
1780
- }
1781
- catch (err) {
1782
- console.debug(`[runners:cleanup] Failed to read directory ${worktreePath}:`, err);
1783
- }
1784
- }
1785
- /**
1786
- * Write prompt to a file inside the worktree so the container can access it.
1787
- * Returns the path to the prompt file (relative to worktree for container access).
1788
- * Cleans up old prompt files for the same ticket before writing.
1789
- */
1790
- function writePromptFile(context) {
1791
- // Clean up old prompt files for this ticket before creating a new one
1792
- cleanupOldPromptFiles(context.worktreePath, context.ticketId);
1793
- const prompt = buildPrompt(context);
1794
- const filename = `.prlt-prompt-${context.ticketId}-${Date.now()}.txt`;
1795
- const hostPath = path.join(context.worktreePath, filename);
1796
- fs.writeFileSync(hostPath, prompt, { mode: 0o644 });
1797
- // Container mounts agentDir at /workspace
1798
- // If worktreePath is a subdirectory of agentDir, we need the relative path
1799
- // e.g., agentDir=/agents/altman, worktreePath=/agents/altman/textdeck
1800
- // -> containerPath=/workspace/textdeck/.prlt-prompt-....txt
1801
- const relativePath = path.relative(context.agentDir, context.worktreePath);
1802
- const containerPath = relativePath
1803
- ? `/workspace/${relativePath}/${filename}`
1804
- : `/workspace/${filename}`;
1805
- return { hostPath, containerPath };
1806
- }
1807
- /**
1808
- * Build the command to run Claude inside the container.
1809
- * Uses docker exec for direct container access.
1810
- * Uses a prompt file to avoid shell escaping issues.
1811
- */
1812
- export function buildDevcontainerCommand(context, executor, promptFile, containerId, outputMode = 'interactive', permissionMode = 'safe', displayMode = 'terminal', mcpConfigFile) {
1813
- // Calculate the relative path from agentDir to worktreePath for cd
1814
- const relativePath = path.relative(context.agentDir, context.worktreePath);
1815
- const cdCmd = relativePath ? `cd /workspace/${relativePath} && ` : '';
1816
- // Build executor command using the centralized getExecutorCommand()
1817
- // This ensures all runners use consistent executor invocation
1818
- let executorCmd;
1819
- const skipPermissions = permissionMode === 'danger';
1820
- if (isClaudeExecutor(executor)) {
1821
- // Claude-specific flags based on output mode and permission mode
1822
- // - interactive: No -p flag, shows streaming UI (watch Claude work in real-time)
1823
- // - print: Uses -p flag, outputs final result only (better for logs/automation)
1824
- const printFlag = outputMode === 'print' ? '-p ' : '';
1825
- // --permission-mode bypassPermissions: skips the "trust this folder" dialog
1826
- const bypassTrustFlag = '--permission-mode bypassPermissions ';
1827
- const permissionsFlag = skipPermissions ? '--dangerously-skip-permissions ' : '';
1828
- // --effort high: skips the effort level prompt for automated agents (TKT-1134)
1829
- const effortFlag = '--effort high ';
1830
- // TKT-053: Disable plan mode for background agents — prevents silent stalls
1831
- const disallowPlanFlag = displayMode === 'background' ? '--disallowedTools EnterPlanMode ' : '';
1832
- // Tool registry (TKT-083): pass MCP config to Claude Code via --mcp-config flag
1833
- const mcpConfigFlag = mcpConfigFile ? `--mcp-config ${mcpConfigFile} ` : '';
1834
- // PRLT-950: Use -- to separate flags from positional prompt argument.
1835
- // --disallowedTools is variadic and will consume the prompt as its second arg without --.
1836
- executorCmd = `claude ${bypassTrustFlag}${permissionsFlag}${effortFlag}${printFlag}${disallowPlanFlag}${mcpConfigFlag}-- "$(cat ${promptFile})"`;
1837
- }
1838
- else if (executor === 'codex') {
1839
- // Use Codex adapter for mode validation and deterministic command building.
1840
- // Validates that the permission/display combination is supported before building.
1841
- const codexPermission = permissionMode;
1842
- const codexContext = resolveCodexExecutionContext(displayMode, outputMode);
1843
- const modeError = validateCodexMode(codexPermission, codexContext);
1844
- if (modeError) {
1845
- throw modeError;
1846
- }
1847
- const codexResult = getCodexCommand('PLACEHOLDER', codexPermission, codexContext);
1848
- const argsStr = codexResult.args.map(a => a === 'PLACEHOLDER' ? `"$(cat ${promptFile})"` : a).join(' ');
1849
- executorCmd = `${codexResult.cmd} ${argsStr}`;
1850
- }
1851
- else {
1852
- // Non-Claude, non-Codex executors: use getExecutorCommand() to get correct command and args
1853
- const { cmd, args } = getExecutorCommand(executor, `PLACEHOLDER`, skipPermissions);
1854
- // Replace the placeholder prompt with a file read for shell safety
1855
- const argsStr = args.map(a => a === 'PLACEHOLDER' ? `"$(cat ${promptFile})"` : a).join(' ');
1856
- executorCmd = `${cmd} ${argsStr}`;
1857
- }
1858
- // Build the full command with cd, executor invocation, and cleanup
1859
- const fullCmd = `${cdCmd}${executorCmd} && rm -f ${promptFile}`;
1860
- // Use docker exec for running commands in the container
1861
- // Use -it flags only for terminal/foreground modes where a TTY is available
1862
- // Background mode runs without a TTY, so -it flags would cause "not a TTY" error
1863
- const ttyFlags = displayMode === 'background' ? '' : '-it ';
1864
- // Direct mode - run executor directly (tmux setup is handled by runDevcontainerInTmux)
1865
- return `docker exec ${ttyFlags}${containerId} bash -c '${fullCmd}'`;
1866
- }
1867
- /**
1868
- * Run command inside a Docker container.
1869
- * Uses raw Docker commands for filesystem isolation - no devcontainer CLI required.
1870
- * Agent can only access mounted worktrees and configured paths.
1871
- *
1872
- * @param displayMode - How to display output (terminal, foreground, background, tmux)
1873
- * @param sessionManager - How to manage the session inside the container (tmux, direct)
1874
- */
1875
- export async function runDevcontainer(context, executor, config, displayMode = 'terminal', sessionManager = 'tmux' // Default to tmux for session persistence
1876
- ) {
1877
- // Docker config is in the agent directory (still uses .devcontainer for Dockerfile)
1878
- const devcontainerPath = path.join(context.agentDir, '.devcontainer');
1879
- const dockerfile = path.join(devcontainerPath, 'Dockerfile');
1880
- // Check if Dockerfile exists
1881
- if (!fs.existsSync(dockerfile)) {
1882
- return {
1883
- success: false,
1884
- error: `No Dockerfile found at ${devcontainerPath}. Run 'prlt agent add' to set up the agent with Docker config.`,
1885
- };
1886
- }
1887
- try {
1888
- // Check if Docker is running (TKT-081: fast detection with diagnostic info)
1889
- const dockerStatus = checkDockerDaemon();
1890
- if (!dockerStatus.available) {
1891
- return {
1892
- success: false,
1893
- error: `Docker daemon is not available. ${dockerStatus.message}`,
1894
- };
1895
- }
1896
- // Ensure GitHub token is available for git push operations
1897
- // Try to get token from gh CLI if not already in environment
1898
- if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
1899
- try {
1900
- const token = execSync('gh auth token', { encoding: 'utf-8', stdio: 'pipe' }).trim();
1901
- if (token) {
1902
- process.env.GITHUB_TOKEN = token;
1903
- process.env.GH_TOKEN = token;
1904
- }
1905
- }
1906
- catch (err) {
1907
- console.debug('[runners:docker] gh auth token failed:', err);
1908
- }
1909
- }
1910
- // Copy Claude credentials into agent directory so container can access them
1911
- // Only needed for Claude Code executor
1912
- if (isClaudeExecutor(executor)) {
1913
- // This was the original working approach - credentials at /workspace/.claude.json
1914
- copyClaudeCredentials(context.agentDir);
1915
- }
1916
- // Start or reuse container using raw Docker commands
1917
- // No devcontainer CLI required!
1918
- const containerId = ensureDockerContainer(context, config, executor);
1919
- if (!containerId) {
1920
- return {
1921
- success: false,
1922
- error: 'Failed to start Docker container. Check Docker logs for details.',
1923
- };
1924
- }
1925
- // Write prompt to file in worktree (accessible by container)
1926
- const { hostPath: promptHostPath, containerPath: promptFile } = writePromptFile(context);
1927
- // Tool registry (TKT-083): generate MCP config file for container
1928
- let mcpConfigContainerPath;
1929
- if (context.hqPath && isClaudeExecutor(executor)) {
1930
- const toolsResult = resolveToolsForSpawn(context.hqPath, context.toolPolicy, context.worktreePath);
1931
- if (toolsResult.mcpConfigPath) {
1932
- // Map host path to container path
1933
- const relativeMcp = path.relative(context.agentDir, toolsResult.mcpConfigPath);
1934
- mcpConfigContainerPath = `/workspace/${relativeMcp}`;
1935
- }
1936
- }
1937
- // Inject fresh GitHub token into container (containers may be reused with stale/empty tokens)
1938
- // This ensures git push works even if the container was created before token was available
1939
- const githubToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
1940
- if (containerId && githubToken) {
1941
- try {
1942
- // Write token to file and configure git credential helper
1943
- execSync(`docker exec ${containerId} bash -c 'echo "${githubToken}" > /home/node/.github-token && chmod 600 /home/node/.github-token && git config --global credential.helper "!f() { echo \\"username=x-access-token\\"; echo \\"password=\\$(cat /home/node/.github-token)\\"; }; f" && git config --global url."https://github.com/".insteadOf "git@github.com:"'`, {
1944
- stdio: 'pipe',
1945
- });
1946
- }
1947
- catch {
1948
- // Non-fatal - token injection failed but execution can continue
1949
- }
1950
- }
1951
- // Build the docker exec command (just runs claude directly)
1952
- // tmux session setup is handled by runDevcontainerInTmux, not buildDevcontainerCommand
1953
- const devcontainerCmd = buildDevcontainerCommand(context, executor, promptFile, containerId, config.outputMode, config.permissionMode, displayMode, mcpConfigContainerPath);
1954
- // Execute based on display mode
1955
- // When sessionManager is 'tmux', always use tmux inside container for session persistence
1956
- // (allows reattach via `prlt session attach` even for background mode)
1957
- let result;
1958
- if (sessionManager === 'tmux') {
1959
- // Use tmux inside container - pass displayMode to control whether to open terminal tab
1960
- // Pass containerId directly to avoid regex extraction issues with devcontainer exec commands
1961
- result = await runDevcontainerInTmux(context, devcontainerCmd, config, displayMode, containerId || undefined, promptFile);
1962
- }
1963
- else {
1964
- switch (displayMode) {
1965
- case 'background':
1966
- result = await runDevcontainerInBackground(context, devcontainerCmd);
1967
- break;
1968
- case 'terminal':
1969
- default:
1970
- result = await runDevcontainerInTerminal(context, devcontainerCmd, config);
1971
- break;
1972
- }
1973
- }
1974
- // Clean up prompt file if execution failed to start
1975
- // (successful executions clean up the file themselves via the command)
1976
- if (!result.success && fs.existsSync(promptHostPath)) {
1977
- try {
1978
- fs.unlinkSync(promptHostPath);
1979
- }
1980
- catch (err) {
1981
- console.debug('[runners:devcontainer] Failed to cleanup prompt file:', err);
1982
- }
1983
- }
1984
- // Override containerId with the real Docker container ID (not the placeholder)
1985
- if (result.success && containerId) {
1986
- result.containerId = containerId;
1987
- }
1988
- // Set sessionId when using tmux inside the container
1989
- // Use buildSessionName to match the actual tmux session name format: {ticketId}-{action}-{agentName}
1990
- if (result.success && sessionManager === 'tmux') {
1991
- const sessionId = buildSessionName(context);
1992
- result.sessionId = sessionId;
1993
- // For terminal display mode, verify the tmux session was actually created
1994
- // (terminal spawns asynchronously, so we need to wait and check)
1995
- if (displayMode === 'terminal' && containerId) {
1996
- // Wait for the terminal to execute the script
1997
- await new Promise(resolve => setTimeout(resolve, 3000));
1998
- // Check if tmux session exists inside the container
1999
- try {
2000
- execSync(`docker exec ${containerId} tmux has-session -t "${sessionId}" 2>&1`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
2001
- // Session exists - success
2002
- }
2003
- catch (err) {
2004
- console.debug(`[runners:devcontainer] tmux session ${sessionId} not found in container:`, err);
2005
- result.success = false;
2006
- result.error = `Failed to create tmux session "${sessionId}" inside container. Check terminal for errors.`;
2007
- }
2008
- }
2009
- }
2010
- return result;
2011
- }
2012
- catch (error) {
2013
- // Clean up any orphaned prompt files on error
2014
- cleanupOldPromptFiles(context.worktreePath, context.ticketId);
2015
- return {
2016
- success: false,
2017
- error: error instanceof Error ? error.message : 'Failed to run in devcontainer',
2018
- };
2019
- }
2020
- }
2021
- /**
2022
- * Run devcontainer command in a new terminal window.
2023
- * Uses a temp script file to avoid shell escaping issues with complex prompts.
2024
- */
2025
- async function runDevcontainerInTerminal(context, devcontainerCmd, config) {
2026
- if (process.platform !== 'darwin') {
2027
- return {
2028
- success: false,
2029
- error: 'Terminal mode is only supported on macOS. Use background mode instead.',
2030
- };
2031
- }
2032
- const terminalApp = config.terminal.app;
2033
- // Write command to temp script to avoid shell escaping issues
2034
- // Use HQ .proletariat/scripts if available, otherwise fallback to home dir
2035
- const baseDir = context.hqPath
2036
- ? path.join(context.hqPath, '.proletariat', 'scripts')
2037
- : path.join(os.homedir(), '.proletariat', 'scripts');
2038
- fs.mkdirSync(baseDir, { recursive: true });
2039
- const scriptPath = path.join(baseDir, `exec-${context.ticketId}-${Date.now()}.sh`);
2040
- // Build window title for terminal tab
2041
- const windowTitle = buildWindowTitle(context);
2042
- const setTitleCmds = getSetTitleCommands(windowTitle);
2043
- // Write script - run the command directly
2044
- // No auth check needed - if auth is required, Claude will show "Invalid API key"
2045
- // and user can run /login from there
2046
- // Ephemeral agents auto-close after completion
2047
- const postExecBlock = context.isEphemeral
2048
- ? `echo ""
2049
- echo "✅ Ephemeral agent work complete. Session will auto-close in 5s..."
2050
- sleep 5
2051
- exit 0`
2052
- : `# Keep shell open after completion
2053
- exec $SHELL`;
2054
- const scriptContent = `#!/bin/bash
2055
- # Auto-generated script for ticket ${context.ticketId}
2056
- ${setTitleCmds}
2057
- echo "🚀 Starting ticket execution: ${context.ticketId}"
2058
- echo ""
2059
-
2060
- # Run the ticket
2061
- ${devcontainerCmd}
2062
-
2063
- # Clean up script file
2064
- rm -f "${scriptPath}"
2065
-
2066
- ${postExecBlock}
2067
- `;
2068
- fs.writeFileSync(scriptPath, scriptContent, { mode: 0o755 });
2069
- // Check if we should open in background (don't steal focus)
2070
- const openInBackground = config.terminal.openInBackground ?? true;
2071
- try {
2072
- switch (terminalApp) {
2073
- case 'iTerm':
2074
- // Run script file directly - iTerm will execute it with proper TTY
2075
- // When openInBackground is true, save frontmost app and restore after
2076
- if (openInBackground) {
2077
- execSync(`osascript -e '
2078
- -- Save the currently active application and window
2079
- tell application "System Events"
2080
- set frontApp to name of first application process whose frontmost is true
2081
- set frontAppBundle to bundle identifier of first application process whose frontmost is true
2082
- end tell
2083
-
2084
- tell application "iTerm"
2085
- if (count of windows) = 0 then
2086
- create window with default profile
2087
- tell current session of current window
2088
- write text "${scriptPath}"
2089
- end tell
2090
- else
2091
- tell current window
2092
- set newTab to (create tab with default profile)
2093
- tell current session of newTab
2094
- write text "${scriptPath}"
2095
- end tell
2096
- end tell
2097
- end if
2098
- end tell
2099
-
2100
- -- Restore focus to the original application
2101
- delay 0.2
2102
- tell application "System Events"
2103
- set frontmost of process frontApp to true
2104
- end tell
2105
- delay 0.1
2106
- do shell script "open -b " & quoted form of frontAppBundle
2107
- '`);
2108
- }
2109
- else {
2110
- execSync(`osascript -e '
2111
- tell application "iTerm"
2112
- activate
2113
- if (count of windows) = 0 then
2114
- create window with default profile
2115
- tell current session of current window
2116
- write text "${scriptPath}"
2117
- end tell
2118
- else
2119
- tell current window
2120
- set newTab to (create tab with default profile)
2121
- tell current session of newTab
2122
- write text "${scriptPath}"
2123
- end tell
2124
- end tell
2125
- end if
2126
- end tell
2127
- '`);
2128
- }
2129
- break;
2130
- case 'Ghostty':
2131
- // Use source to preserve TTY for docker exec
2132
- execSync(`osascript -e '
2133
- tell application "Ghostty"
2134
- activate
2135
- end tell
2136
- tell application "System Events"
2137
- tell process "Ghostty"
2138
- keystroke "t" using command down
2139
- delay 0.3
2140
- keystroke "source ${scriptPath}"
2141
- keystroke return
2142
- end tell
2143
- end tell
2144
- '`);
2145
- break;
2146
- case 'WezTerm':
2147
- // Use bash -c source to preserve TTY
2148
- execSync(`wezterm cli spawn --new-window -- bash -c 'source ${scriptPath}'`);
2149
- break;
2150
- case 'Kitty':
2151
- // Use bash -c source to preserve TTY
2152
- execSync(`kitty @ launch --type=tab -- bash -c 'source ${scriptPath}'`);
2153
- break;
2154
- case 'Alacritty':
2155
- // Use source to preserve TTY for docker exec
2156
- execSync(`osascript -e '
2157
- tell application "Alacritty"
2158
- activate
2159
- end tell
2160
- tell application "System Events"
2161
- tell process "Alacritty"
2162
- keystroke "n" using command down
2163
- delay 0.3
2164
- keystroke "source ${scriptPath}"
2165
- keystroke return
2166
- end tell
2167
- end tell
2168
- '`);
2169
- break;
2170
- case 'Terminal':
2171
- default:
2172
- // Use source to preserve TTY for docker exec
2173
- if (openInBackground) {
2174
- // Open in background: use 'do script' which creates a new window without activating
2175
- execSync(`osascript -e '
2176
- tell application "Terminal"
2177
- do script "source ${scriptPath}"
2178
- end tell
2179
- '`);
2180
- }
2181
- else {
2182
- // Bring to front: use traditional Cmd+T for new tab
2183
- execSync(`osascript -e '
2184
- tell application "Terminal"
2185
- activate
2186
- tell application "System Events"
2187
- tell process "Terminal"
2188
- keystroke "t" using command down
2189
- end tell
2190
- end tell
2191
- delay 0.3
2192
- do script "source ${scriptPath}" in front window
2193
- end tell
2194
- '`);
2195
- }
2196
- break;
2197
- }
2198
- return {
2199
- success: true,
2200
- containerId: `devcontainer-${context.agentName}`,
2201
- sessionId: `terminal-${context.ticketId}`,
2202
- };
2203
- }
2204
- catch (error) {
2205
- return {
2206
- success: false,
2207
- error: error instanceof Error ? error.message : `Failed to open ${terminalApp}`,
2208
- };
2209
- }
2210
- }
2211
- /**
2212
- * Run devcontainer command in background, logging to file
2213
- */
2214
- async function runDevcontainerInBackground(context, devcontainerCmd) {
2215
- // Create logs directory
2216
- const logsDir = path.join(os.homedir(), '.proletariat', 'logs');
2217
- fs.mkdirSync(logsDir, { recursive: true });
2218
- const logPath = path.join(logsDir, `work-${context.ticketId}-${Date.now()}.log`);
2219
- const logStream = fs.openSync(logPath, 'w');
2220
- const child = spawn('sh', ['-c', devcontainerCmd], {
2221
- detached: true,
2222
- stdio: ['ignore', logStream, logStream],
2223
- });
2224
- child.unref();
2225
- return {
2226
- success: true,
2227
- pid: child.pid?.toString(),
2228
- containerId: `devcontainer-${context.agentName}`,
2229
- logPath,
2230
- };
2231
- }
2232
- /**
2233
- * Run devcontainer command in tmux session INSIDE the container.
2234
- *
2235
- * Architecture: Container tmux only (simple, no nesting)
2236
- * 1. Start tmux session INSIDE the container (detached) - runs claude
2237
- * 2. Open iTerm tab that attaches directly to the container's tmux
2238
- *
2239
- * Benefits:
2240
- * - Session persists even if you close iTerm tab
2241
- * - No nested tmux = proper scrolling
2242
- * - Can reattach anytime via `prlt session attach`
2243
- * - Sessions tracked in workspace.db
2244
- */
2245
- async function runDevcontainerInTmux(context, devcontainerCmd, config, displayMode = 'terminal', containerId, promptContainerPath) {
2246
- // Session name: {ticketId}-{action} (e.g., TKT-347-implement)
2247
- const sessionName = buildTmuxWindowName(context);
2248
- const windowTitle = buildWindowTitle(context);
2249
- // Check if we should use iTerm control mode (-CC)
2250
- // When using -CC, iTerm handles scrolling/selection natively, so we DON'T set mouse on
2251
- const terminalApp = config.terminal.app;
2252
- const useControlMode = shouldUseControlMode(terminalApp, config.tmux.controlMode);
2253
- try {
2254
- // Get container ID - prefer passed value, fallback to extracting from command
2255
- // The devcontainerCmd is like: docker exec [-it] <containerId> bash -c '...'
2256
- // Note: -it flags are optional (not present in background mode)
2257
- let actualContainerId = containerId;
2258
- if (!actualContainerId) {
2259
- const containerIdMatch = devcontainerCmd.match(/docker exec\s+(?:-it\s+)?(\S+)/);
2260
- if (containerIdMatch) {
2261
- actualContainerId = containerIdMatch[1];
2262
- }
2263
- }
2264
- if (!actualContainerId) {
2265
- return {
2266
- success: false,
2267
- error: 'Could not determine container ID for tmux session',
2268
- };
2269
- }
2270
- // Check if tmux is available inside the container
2271
- try {
2272
- execSync(`docker exec ${actualContainerId} which tmux`, { stdio: 'pipe' });
2273
- }
2274
- catch {
2275
- return {
2276
- success: false,
2277
- error: `tmux is not installed in the devcontainer. ` +
2278
- `Add 'tmux' to your devcontainer's Dockerfile (e.g., apt-get install -y tmux) ` +
2279
- `or use the default prlt devcontainer template which includes tmux.`,
2280
- };
2281
- }
2282
- // Step 1: Start tmux session INSIDE the container (detached)
2283
- // Extract the claude command from the devcontainer command
2284
- const cmdMatch = devcontainerCmd.match(/bash -c '(.+)'$/);
2285
- const claudeCmd = cmdMatch ? cmdMatch[1] : devcontainerCmd;
2286
- // Create a script inside the container that runs claude and keeps shell open
2287
- // TERM must be set for Claude's TUI to render properly
2288
- // Unset CI to prevent Claude from detecting CI environment which suppresses TUI output
2289
- // Unset CLAUDECODE to allow Claude Code to run (prevents nested session error)
2290
- // Note: We keep DEVCONTAINER set so prlt workspace detection works correctly
2291
- // Ephemeral agents auto-close after completion
2292
- const containerPostExec = context.isEphemeral
2293
- ? `echo ""
2294
- echo "✅ Ephemeral agent work complete. Session will auto-close in 5s..."
2295
- sleep 5
2296
- exit 0`
2297
- : `echo ""
2298
- echo "✅ Agent work complete. Press Enter to close or run more commands."
2299
- exec bash`;
2300
- // TKT-099: Build a wait guard for the prompt file inside the container.
2301
- // Docker Desktop's file-sharing layer (grpcfuse/virtiofs) can lag behind host writes,
2302
- // so the prompt file may not be visible in the container the instant it was written on the host.
2303
- const promptWaitBlock = promptContainerPath
2304
- ? `# TKT-099: Wait for prompt file to sync from host into container
2305
- PROMPT_WAIT=0
2306
- while [ ! -s "${promptContainerPath}" ] && [ $PROMPT_WAIT -lt 30 ]; do
2307
- sleep 0.5
2308
- PROMPT_WAIT=$((PROMPT_WAIT + 1))
2309
- done
2310
- if [ ! -s "${promptContainerPath}" ]; then
2311
- echo "⚠️ Warning: Prompt file not available after 15s: ${promptContainerPath}"
2312
- fi
2313
- `
2314
- : '';
2315
- const tmuxScript = `#!/bin/bash
2316
- export TERM=xterm-256color
2317
- export COLORTERM=truecolor
2318
- unset CI
2319
- unset CLAUDECODE
2320
- echo "🚀 Starting: ${sessionName}"
2321
- echo ""
2322
- ${promptWaitBlock}${claudeCmd}
2323
- ${containerPostExec}
2324
- `;
2325
- const scriptPath = `/tmp/prlt-${sessionName}.sh`;
2326
- // Write script and start tmux session inside container
2327
- // -n sets the window name (shows in iTerm tab title with -CC mode)
2328
- // sessionName is already ticket-action-agent format
2329
- // Only enable mouse mode if NOT using control mode (control mode lets iTerm handle mouse natively)
2330
- // set-titles on + set-titles-string: makes tmux set terminal title to window name
2331
- const mouseOption = buildTmuxMouseOption(useControlMode);
2332
- // Step 1: Write the script to the container via stdin piping to avoid ARG_MAX limits
2333
- try {
2334
- execSync(`docker exec -i ${actualContainerId} bash -c 'cat > ${scriptPath} && chmod +x ${scriptPath}'`, {
2335
- input: tmuxScript,
2336
- stdio: ['pipe', 'pipe', 'pipe'],
2337
- });
2338
- }
2339
- catch (error) {
2340
- return {
2341
- success: false,
2342
- error: `Failed to write script to container: ${error instanceof Error ? error.message : error}`,
2343
- };
2344
- }
2345
- // TKT-1028: If a tmux session with the same name already exists (e.g., same
2346
- // ticket+action spawned again in a reused container), kill the old session first.
2347
- try {
2348
- execSync(`docker exec ${actualContainerId} tmux has-session -t "${sessionName}" 2>&1`, { stdio: 'pipe' });
2349
- // Session exists - kill it before creating a new one
2350
- console.debug(`[runners:tmux] Killing existing tmux session "${sessionName}" in container`);
2351
- try {
2352
- execSync(`docker exec ${actualContainerId} tmux kill-session -t "${sessionName}"`, { stdio: 'pipe' });
2353
- }
2354
- catch {
2355
- // Ignore kill errors
2356
- }
2357
- }
2358
- catch {
2359
- // Session doesn't exist - that's the normal case
2360
- }
2361
- // Step 2: Create tmux session running the script directly
2362
- // Pass the script as the session command (like host runner does) instead of using send-keys.
2363
- // The send-keys approach had a race condition where keys could be lost if bash hadn't
2364
- // fully initialized, causing background mode to create empty sessions without running claude.
2365
- const createSessionCmd = `tmux new-session -d -s "${sessionName}" -n "${sessionName}" "bash ${scriptPath}"${mouseOption} \\; set-option -g set-titles on \\; set-option -g set-titles-string "#{window_name}"`;
2366
- try {
2367
- execSync(`docker exec ${actualContainerId} bash -c '${createSessionCmd}'`, { stdio: 'pipe' });
2368
- }
2369
- catch (error) {
2370
- return {
2371
- success: false,
2372
- error: `Failed to create tmux session inside container: ${error instanceof Error ? error.message : error}`,
2373
- };
2374
- }
2375
- // Step 3: Handle display mode
2376
- // For background mode, return success after tmux session is created
2377
- // User can reattach later with `prlt session attach`
2378
- if (displayMode === 'background') {
2379
- // Verify the tmux session was actually created (brief delay to let tmux start)
2380
- await new Promise(resolve => setTimeout(resolve, 500));
2381
- try {
2382
- execSync(`docker exec ${actualContainerId} tmux has-session -t "${sessionName}" 2>&1`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
2383
- }
2384
- catch {
2385
- return {
2386
- success: false,
2387
- error: `Failed to verify tmux session "${sessionName}" inside container. The session may not have started correctly.`,
2388
- };
2389
- }
2390
- return {
2391
- success: true,
2392
- containerId: actualContainerId,
2393
- sessionId: sessionName, // Container tmux session name for tracking
2394
- };
2395
- }
2396
- // For foreground mode: attach to container's tmux session in current terminal (blocking)
2397
- if (displayMode === 'foreground') {
2398
- try {
2399
- // Clear screen and attach - this blocks until user detaches or claude exits
2400
- // Never use -CC in foreground mode: control mode sends raw tmux protocol
2401
- // sequences (%begin, %output, %end) that render as garbled text unless
2402
- // iTerm's native CC handler is active (only happens in new tabs opened via AppleScript)
2403
- const fgTmuxAttach = buildTmuxAttachCommand(false, true);
2404
- execSync(`clear && docker exec -it ${actualContainerId} ${fgTmuxAttach} -t "${sessionName}"`, { stdio: 'inherit' });
2405
- return {
2406
- success: true,
2407
- containerId: actualContainerId,
2408
- sessionId: sessionName,
2409
- };
2410
- }
2411
- catch (error) {
2412
- return {
2413
- success: false,
2414
- error: `Failed to attach to container tmux session: ${error instanceof Error ? error.message : error}`,
2415
- };
2416
- }
2417
- }
2418
- // Use tmux -CC (control mode) for iTerm when enabled in config
2419
- // -CC gives native iTerm scrolling, selection, and gesture support
2420
- // Without -CC, use regular attach (relies on mouse mode for scrolling)
2421
- const tmuxAttach = buildTmuxAttachCommand(useControlMode, true);
2422
- const attachCmd = `docker exec -it ${actualContainerId} ${tmuxAttach} -t "${sessionName}"`;
2423
- // Open terminal and run the attach command
2424
- const terminalApp = config.terminal.app;
2425
- // For iTerm with control mode, create a new tab and run -CC attach there
2426
- // This avoids interfering with the terminal where prlt is running
2427
- if (terminalApp === 'iTerm' && useControlMode) {
2428
- // Configure iTerm to open tmux windows as tabs or windows based on user preference
2429
- configureITermTmuxWindowMode(config.tmux.windowMode);
2430
- const openInBackground = config.terminal.openInBackground ?? true;
2431
- if (openInBackground) {
2432
- // Open tab without stealing focus - save frontmost app and restore after
2433
- execSync(`osascript -e '
2434
- set frontApp to path to frontmost application as text
2435
- tell application "iTerm"
2436
- tell current window
2437
- set newTab to (create tab with default profile)
2438
- tell current session of newTab
2439
- write text "docker exec -it ${actualContainerId} tmux -u -CC attach -d -t \\"${sessionName}\\""
2440
- end tell
2441
- end tell
2442
- end tell
2443
- tell application frontApp to activate
2444
- '`);
2445
- }
2446
- else {
2447
- execSync(`osascript -e '
2448
- tell application "iTerm"
2449
- activate
2450
- tell current window
2451
- set newTab to (create tab with default profile)
2452
- tell current session of newTab
2453
- write text "docker exec -it ${actualContainerId} tmux -u -CC attach -d -t \\"${sessionName}\\""
2454
- end tell
2455
- end tell
2456
- end tell
2457
- '`);
2458
- }
2459
- return {
2460
- success: true,
2461
- containerId: actualContainerId,
2462
- sessionId: sessionName,
2463
- };
2464
- }
2465
- // For all other cases, create a script file and open in a new tab
2466
- const baseDir = context.hqPath
2467
- ? path.join(context.hqPath, '.proletariat', 'scripts')
2468
- : path.join(os.homedir(), '.proletariat', 'scripts');
2469
- fs.mkdirSync(baseDir, { recursive: true });
2470
- const hostScriptPath = path.join(baseDir, `attach-${sessionName}-${Date.now()}.sh`);
2471
- const setTitleCmds = getSetTitleCommands(windowTitle);
2472
- const hostScript = `#!/bin/bash
2473
- ${setTitleCmds}
2474
- # Attach to container tmux session
2475
- # Session: ${sessionName}
2476
- # Container: ${actualContainerId}
2477
- ${attachCmd}
2478
-
2479
- # Clean up
2480
- rm -f "${hostScriptPath}"
2481
- exec $SHELL
2482
- `;
2483
- fs.writeFileSync(hostScriptPath, hostScript, { mode: 0o755 });
2484
- // Check if we should open in background (don't steal focus)
2485
- const openInBackground = config.terminal.openInBackground ?? true;
2486
- switch (terminalApp) {
2487
- case 'iTerm':
2488
- // Without control mode, create a new tab and attach normally
2489
- // When openInBackground is true, save frontmost app and restore after
2490
- if (openInBackground) {
2491
- execSync(`osascript -e '
2492
- -- Save the currently active application and window
2493
- tell application "System Events"
2494
- set frontApp to name of first application process whose frontmost is true
2495
- set frontAppBundle to bundle identifier of first application process whose frontmost is true
2496
- end tell
2497
-
2498
- tell application "iTerm"
2499
- if (count of windows) = 0 then
2500
- create window with default profile
2501
- tell current session of current window
2502
- set name to "${windowTitle}"
2503
- write text "${hostScriptPath}"
2504
- end tell
2505
- else
2506
- tell current window
2507
- create tab with default profile
2508
- tell current session
2509
- set name to "${windowTitle}"
2510
- write text "${hostScriptPath}"
2511
- end tell
2512
- end tell
2513
- end if
2514
- end tell
2515
-
2516
- -- Restore focus to the original application
2517
- delay 0.2
2518
- tell application "System Events"
2519
- set frontmost of process frontApp to true
2520
- end tell
2521
- delay 0.1
2522
- do shell script "open -b " & quoted form of frontAppBundle
2523
- '`);
2524
- }
2525
- else {
2526
- execSync(`osascript -e '
2527
- tell application "iTerm"
2528
- activate
2529
- if (count of windows) = 0 then
2530
- create window with default profile
2531
- tell current session of current window
2532
- set name to "${windowTitle}"
2533
- write text "${hostScriptPath}"
2534
- end tell
2535
- else
2536
- tell current window
2537
- create tab with default profile
2538
- tell current session
2539
- set name to "${windowTitle}"
2540
- write text "${hostScriptPath}"
2541
- end tell
2542
- end tell
2543
- end if
2544
- end tell
2545
- '`);
2546
- }
2547
- break;
2548
- case 'Ghostty':
2549
- execSync(`osascript -e '
2550
- tell application "Ghostty"
2551
- activate
2552
- end tell
2553
- tell application "System Events"
2554
- tell process "Ghostty"
2555
- keystroke "t" using command down
2556
- delay 0.3
2557
- keystroke "${hostScriptPath}"
2558
- keystroke return
2559
- end tell
2560
- end tell
2561
- '`);
2562
- break;
2563
- case 'Terminal':
2564
- default:
2565
- if (openInBackground) {
2566
- // Open in background: use 'do script' which creates a new window without activating
2567
- execSync(`osascript -e '
2568
- tell application "Terminal"
2569
- do script "${hostScriptPath}"
2570
- end tell
2571
- '`);
2572
- }
2573
- else {
2574
- // Bring to front: use traditional Cmd+T for new tab
2575
- execSync(`osascript -e '
2576
- tell application "Terminal"
2577
- activate
2578
- tell application "System Events"
2579
- tell process "Terminal"
2580
- keystroke "t" using command down
2581
- end tell
2582
- end tell
2583
- delay 0.3
2584
- do script "${hostScriptPath}" in front window
2585
- end tell
2586
- '`);
2587
- }
2588
- break;
2589
- }
2590
- return {
2591
- success: true,
2592
- containerId: actualContainerId,
2593
- sessionId: sessionName, // Container tmux session name for tracking
2594
- };
2595
- }
2596
- catch (error) {
2597
- return {
2598
- success: false,
2599
- error: error instanceof Error ? error.message : 'Failed to start tmux session in container',
2600
- };
2601
- }
2602
- }
2603
- // =============================================================================
2604
- // Docker Runner
2605
- // =============================================================================
2606
- export async function runDocker(context, executor, config) {
2607
- const prompt = buildPrompt(context);
2608
- const containerName = `work-${context.ticketId}-${Date.now()}`;
2609
- try {
2610
- // Check if docker is available and daemon is responsive (TKT-081)
2611
- const dockerStatus = checkDockerDaemon();
2612
- if (!dockerStatus.available) {
2613
- return {
2614
- success: false,
2615
- error: `Docker daemon is not available. ${dockerStatus.message}`,
2616
- };
2617
- }
2618
- // Build docker run command
2619
- let dockerCmd = `docker run -d --name ${containerName}`;
2620
- dockerCmd += ` -v "${context.worktreePath}:/workspace"`;
2621
- dockerCmd += ` -w /workspace`;
2622
- dockerCmd += ` -e TICKET_ID="${context.ticketId}"`;
2623
- if (config.docker.network) {
2624
- dockerCmd += ` --network ${config.docker.network}`;
2625
- }
2626
- if (config.docker.memory) {
2627
- dockerCmd += ` --memory ${config.docker.memory}`;
2628
- }
2629
- if (config.docker.cpus) {
2630
- dockerCmd += ` --cpus ${config.docker.cpus}`;
2631
- }
2632
- // Validate Codex mode: Docker runner is always non-tty (detached with -d)
2633
- if (executor === 'codex') {
2634
- const codexPermission = config.permissionMode;
2635
- const modeError = validateCodexMode(codexPermission, 'non-tty');
2636
- if (modeError) {
2637
- return { success: false, error: modeError.message };
2638
- }
2639
- }
2640
- // Build executor command using getExecutorCommand() for correct invocation
2641
- const escapedPrompt = prompt.replace(/'/g, "'\\''");
2642
- const { cmd, args } = getExecutorCommand(executor, escapedPrompt, config.permissionMode === 'danger');
2643
- // For Claude Code in Docker, use --print for non-interactive output
2644
- // Non-Claude executors use their native command format from getExecutorCommand()
2645
- dockerCmd += ` ${config.docker.image}`;
2646
- if (isClaudeExecutor(executor)) {
2647
- // TKT-053: Disable plan mode — Docker runner is always detached (no user to approve)
2648
- // PRLT-950: Use -- to separate flags from positional prompt argument.
2649
- dockerCmd += ` ${cmd} --print --disallowedTools EnterPlanMode -- '${escapedPrompt}'`;
2650
- }
2651
- else {
2652
- const argsStr = args.map(a => a === escapedPrompt ? `'${escapedPrompt}'` : a).join(' ');
2653
- dockerCmd += ` ${cmd} ${argsStr}`;
2654
- }
2655
- const containerId = execSync(dockerCmd, { encoding: 'utf-8' }).trim();
2656
- return {
2657
- success: true,
2658
- containerId: containerId.substring(0, 12),
2659
- };
2660
- }
2661
- catch (error) {
2662
- return {
2663
- success: false,
2664
- error: error instanceof Error ? error.message : 'Failed to start docker container',
2665
- };
2666
- }
2667
- }
2668
- // =============================================================================
2669
- // Orchestrator Docker Runner (Sibling Container Pattern)
2670
- // =============================================================================
2671
- /**
2672
- * Run orchestrator in a Docker container using the sibling container pattern.
2673
- *
2674
- * Architecture:
2675
- * ```
2676
- * Host Docker daemon
2677
- * ├── orchestrator container (has /var/run/docker.sock mounted)
2678
- * ├── agent-1 container (spawned by orchestrator, sibling)
2679
- * ├── agent-2 container (spawned by orchestrator, sibling)
2680
- * ```
2681
- *
2682
- * The orchestrator container needs:
2683
- * - HQ directory mounted (proletariat-hq)
2684
- * - Docker socket mounted (/var/run/docker.sock) — so it can spawn agent containers as siblings
2685
- * - prlt CLI installed in the container
2686
- * - OAuth credentials for Claude Code (via Docker volume)
2687
- * - tmux for session persistence inside the container
2688
- */
2689
- export async function runOrchestratorInDocker(context, executor, config, options) {
2690
- const displayMode = options?.displayMode || 'background';
2691
- const hqPath = context.hqPath || context.worktreePath;
2692
- const hqName = context.hqName || 'default';
2693
- const orchestratorName = context.agentName || 'main';
2694
- // Container name matches tmux session name for consistency
2695
- const containerName = `prlt-orchestrator-${(hqName).replace(/[^a-zA-Z0-9._-]/g, '-')}-${(orchestratorName).replace(/[^a-zA-Z0-9._-]/g, '-')}`;
2696
- const imageName = `prlt-orchestrator-${(hqName).replace(/[^a-zA-Z0-9._-]/g, '-')}:latest`;
2697
- try {
2698
- // Check Docker is running (TKT-081: fast detection with diagnostic info)
2699
- const dockerStatus = checkDockerDaemon();
2700
- if (!dockerStatus.available) {
2701
- return {
2702
- success: false,
2703
- error: `Docker daemon is not available. ${dockerStatus.message}`,
2704
- };
2705
- }
2706
- // Check if container already exists and is running
2707
- if (containerExists(containerName)) {
2708
- if (isContainerRunning(containerName)) {
2709
- return {
2710
- success: false,
2711
- error: `Orchestrator container "${containerName}" is already running. Use "prlt orchestrator attach" to reattach.`,
2712
- };
2713
- }
2714
- // Remove stopped container
2715
- try {
2716
- execSync(`docker rm -f ${containerName}`, { stdio: 'pipe' });
2717
- }
2718
- catch {
2719
- // Ignore removal errors
2720
- }
2721
- }
2722
- // Generate Dockerfile
2723
- const orchestratorDockerOptions = {
2724
- orchestratorName,
2725
- hqPath,
2726
- executor,
2727
- };
2728
- const dockerfileContent = generateOrchestratorDockerfile(orchestratorDockerOptions);
2729
- // Write Dockerfile to temp directory
2730
- const buildDir = path.join(hqPath, '.proletariat', 'orchestrator-docker');
2731
- fs.mkdirSync(buildDir, { recursive: true });
2732
- const dockerfilePath = path.join(buildDir, 'Dockerfile');
2733
- fs.writeFileSync(dockerfilePath, dockerfileContent);
2734
- // Build the image
2735
- const hostPrltVersion = getHostPrltVersion();
2736
- const buildArgs = {
2737
- PRLT_VERSION: hostPrltVersion || 'latest',
2738
- };
2739
- const buildArgFlags = Object.entries(buildArgs)
2740
- .map(([key, value]) => `--build-arg ${key}="${value}"`)
2741
- .join(' ');
2742
- console.debug(`[runners:orchestrator-docker] Building image: ${imageName}`);
2743
- try {
2744
- execSync(`docker build -t ${imageName} -f "${dockerfilePath}" ${buildArgFlags} "${buildDir}"`, { stdio: 'pipe' });
2745
- }
2746
- catch (buildError) {
2747
- return {
2748
- success: false,
2749
- error: `Failed to build orchestrator Docker image: ${buildError instanceof Error ? buildError.message : buildError}`,
2750
- };
2751
- }
2752
- // Build mount flags for docker run
2753
- const mounts = [
2754
- // Mount HQ directory
2755
- `-v "${hqPath}:/hq:cached"`,
2756
- // Docker socket for sibling container pattern
2757
- `-v /var/run/docker.sock:/var/run/docker.sock`,
2758
- // Claude credentials volume (shared with agent containers)
2759
- ...(executor === 'claude-code' ? ['-v "claude-credentials:/home/node/.claude"'] : []),
2760
- // Persistent bash history
2761
- '-v "claude-bash-history:/commandhistory"',
2762
- ];
2763
- // Build environment variables
2764
- const envVars = [
2765
- `-e PRLT_HQ_PATH=/hq`,
2766
- `-e PRLT_AGENT_NAME="orchestrator-${orchestratorName}"`,
2767
- `-e PRLT_HOST_PATH="${hqPath}"`,
2768
- // Pass through GitHub tokens for agent spawning
2769
- ...(process.env.GITHUB_TOKEN ? [`-e GITHUB_TOKEN="${process.env.GITHUB_TOKEN}"`] : []),
2770
- ...(process.env.GH_TOKEN ? [`-e GH_TOKEN="${process.env.GH_TOKEN}"`] : []),
2771
- // Pass ANTHROPIC_API_KEY if available (for cases where OAuth is not set up)
2772
- ...(process.env.ANTHROPIC_API_KEY ? [`-e ANTHROPIC_API_KEY="${process.env.ANTHROPIC_API_KEY}"`] : []),
2773
- ];
2774
- // Create and start container
2775
- const createCmd = [
2776
- 'docker run -d',
2777
- `--name ${containerName}`,
2778
- '--user node',
2779
- '-w /hq',
2780
- ...mounts,
2781
- ...envVars,
2782
- `--memory=${config.devcontainer.memory}`,
2783
- `--cpus=${config.devcontainer.cpus}`,
2784
- imageName,
2785
- 'sleep infinity', // Keep container running
2786
- ].join(' ');
2787
- console.debug(`[runners:orchestrator-docker] Creating container: ${createCmd}`);
2788
- execSync(createCmd, { stdio: 'pipe' });
2789
- const containerId = getContainerId(containerName);
2790
- if (!containerId) {
2791
- return {
2792
- success: false,
2793
- error: 'Failed to get container ID after creation',
2794
- };
2795
- }
2796
- // Fix Docker socket permissions inside the container
2797
- // The socket is owned by root on the host; we need the node user to access it
2798
- try {
2799
- execSync(`docker exec --user root ${containerId} chmod 666 /var/run/docker.sock`, { stdio: 'pipe' });
2800
- }
2801
- catch {
2802
- console.debug('[runners:orchestrator-docker] Failed to fix Docker socket permissions (may already be accessible)');
2803
- }
2804
- // Copy Claude Code settings to container (for bypassing prompts)
2805
- if (executor === 'claude-code') {
2806
- try {
2807
- const hostClaudeJson = path.join(os.homedir(), '.claude.json');
2808
- let settings = {};
2809
- if (fs.existsSync(hostClaudeJson)) {
2810
- try {
2811
- settings = JSON.parse(fs.readFileSync(hostClaudeJson, 'utf-8'));
2812
- }
2813
- catch {
2814
- // Use empty settings
2815
- }
2816
- }
2817
- if (config.permissionMode === 'danger') {
2818
- settings.bypassPermissionsModeAccepted = true;
2819
- }
2820
- settings.numStartups = settings.numStartups || 1;
2821
- settings.hasCompletedOnboarding = true;
2822
- settings.theme = settings.theme || 'dark';
2823
- if (!settings.tipsHistory || typeof settings.tipsHistory !== 'object') {
2824
- settings.tipsHistory = {};
2825
- }
2826
- const tips = settings.tipsHistory;
2827
- tips['new-user-warmup'] = tips['new-user-warmup'] || 1;
2828
- settings.effortCalloutDismissed = true;
2829
- if (!settings.projects || typeof settings.projects !== 'object') {
2830
- settings.projects = {};
2831
- }
2832
- const projects = settings.projects;
2833
- for (const projectPath of ['/hq', '/']) {
2834
- if (!projects[projectPath])
2835
- projects[projectPath] = {};
2836
- projects[projectPath].hasTrustDialogAccepted = true;
2837
- projects[projectPath].hasCompletedProjectOnboarding = true;
2838
- }
2839
- execSync(`docker exec -i ${containerId} bash -c 'cat > /home/node/.claude.json'`, { input: JSON.stringify(settings), stdio: ['pipe', 'pipe', 'pipe'] });
2840
- const claudeSettings = JSON.stringify({ skipDangerousModePermissionPrompt: true });
2841
- execSync(`docker exec -i ${containerId} bash -c 'mkdir -p /home/node/.claude && cat > /home/node/.claude/settings.json'`, { input: claudeSettings, stdio: ['pipe', 'pipe', 'pipe'] });
2842
- }
2843
- catch (error) {
2844
- console.debug('[runners:orchestrator-docker] Failed to copy Claude settings:', error);
2845
- }
2846
- }
2847
- // Build the prompt and write to temp file inside container
2848
- const prompt = buildPrompt(context);
2849
- const promptPath = `/tmp/orchestrator-prompt-${Date.now()}.txt`;
2850
- try {
2851
- execSync(`docker exec -i ${containerId} bash -c 'cat > ${promptPath}'`, { input: prompt, stdio: ['pipe', 'pipe', 'pipe'] });
2852
- }
2853
- catch {
2854
- return {
2855
- success: false,
2856
- error: 'Failed to write prompt to container',
2857
- };
2858
- }
2859
- // Build executor command
2860
- const skipPermissions = config.permissionMode === 'danger';
2861
- const permissionsFlag = skipPermissions ? '--dangerously-skip-permissions ' : '';
2862
- const effortFlag = skipPermissions ? '--effort high ' : '';
2863
- // TKT-053: Disable plan mode for background agents — prevents silent stalls
2864
- const disallowPlanFlag = displayMode === 'background' ? '--disallowedTools EnterPlanMode ' : '';
2865
- // PRLT-950: Use -- to separate flags from positional prompt argument.
2866
- // --disallowedTools is variadic and will consume the prompt as its second arg without --.
2867
- const executorCmd = executor === 'claude-code'
2868
- ? `claude ${permissionsFlag}${effortFlag}${disallowPlanFlag}-- "$(cat ${promptPath})"`
2869
- : `claude ${permissionsFlag}${effortFlag}-- "$(cat ${promptPath})"`;
2870
- // Build tmux session name (reuses the same name as host tmux for consistency)
2871
- const tmuxSessionName = options?.sessionName || containerName;
2872
- // Create tmux session inside container with the executor command
2873
- const tmuxCmd = `tmux new-session -d -s "${tmuxSessionName}" -n "${tmuxSessionName}" bash -c '(unset CLAUDECODE CLAUDE_CODE_ENTRYPOINT; cd /hq && ${executorCmd}); echo ""; echo "Orchestrator complete. Press Enter to close."; exec bash'`;
2874
- try {
2875
- execSync(`docker exec ${containerId} bash -c '${tmuxCmd.replace(/'/g, "'\\''")}'`, { stdio: 'pipe' });
2876
- }
2877
- catch (tmuxError) {
2878
- // Fallback: try simpler command without subshell
2879
- console.debug('[runners:orchestrator-docker] tmux creation failed, trying simpler approach:', tmuxError);
2880
- try {
2881
- // Write a script inside the container
2882
- const scriptContent = `#!/bin/bash
2883
- cd /hq
2884
- unset CLAUDECODE CLAUDE_CODE_ENTRYPOINT
2885
- ${executor === 'claude-code' ? `claude ${permissionsFlag}${effortFlag}${disallowPlanFlag}"$(cat ${promptPath})"` : `claude "$(cat ${promptPath})"`}
2886
- echo ""
2887
- echo "Orchestrator complete. Press Enter to close."
2888
- exec bash
2889
- `;
2890
- execSync(`docker exec -i ${containerId} bash -c 'cat > /tmp/orchestrator-start.sh && chmod +x /tmp/orchestrator-start.sh'`, { input: scriptContent, stdio: ['pipe', 'pipe', 'pipe'] });
2891
- execSync(`docker exec ${containerId} tmux new-session -d -s "${tmuxSessionName}" /tmp/orchestrator-start.sh`, { stdio: 'pipe' });
2892
- }
2893
- catch (fallbackError) {
2894
- return {
2895
- success: false,
2896
- error: `Failed to create tmux session in container: ${fallbackError instanceof Error ? fallbackError.message : fallbackError}`,
2897
- };
2898
- }
2899
- }
2900
- // Handle display mode
2901
- if (displayMode === 'foreground') {
2902
- // Attach to tmux inside the container in current terminal
2903
- try {
2904
- const child = spawn('docker', ['exec', '-it', containerId, 'tmux', 'attach', '-t', tmuxSessionName], {
2905
- stdio: 'inherit',
2906
- });
2907
- await new Promise((resolve) => {
2908
- child.on('close', () => resolve());
2909
- });
2910
- }
2911
- catch {
2912
- // User detached - that's fine
2913
- }
2914
- }
2915
- else if (displayMode === 'terminal' && process.platform === 'darwin') {
2916
- // Open a new terminal tab that attaches to the container's tmux
2917
- const baseDir = path.join(hqPath, '.proletariat', 'scripts');
2918
- fs.mkdirSync(baseDir, { recursive: true });
2919
- const scriptPath = path.join(baseDir, `orch-docker-attach-${Date.now()}.sh`);
2920
- const scriptContent = `#!/bin/bash
2921
- echo -ne "\\033]0;Orchestrator (Docker)\\007"
2922
- echo -ne "\\033]1;Orchestrator (Docker)\\007"
2923
- docker exec -it ${containerId} tmux attach -t "${tmuxSessionName}"
2924
- rm -f "${scriptPath}"
2925
- exec $SHELL
2926
- `;
2927
- fs.writeFileSync(scriptPath, scriptContent, { mode: 0o755 });
2928
- const terminalApp = config.terminal.app;
2929
- try {
2930
- switch (terminalApp) {
2931
- case 'iTerm':
2932
- execSync(`osascript -e '
2933
- tell application "iTerm"
2934
- activate
2935
- tell current window
2936
- set newTab to (create tab with default profile)
2937
- tell current session of newTab
2938
- set name to "Orchestrator (Docker)"
2939
- write text "${scriptPath}"
2940
- end tell
2941
- end tell
2942
- end tell
2943
- '`);
2944
- break;
2945
- case 'Ghostty':
2946
- execSync(`osascript -e '
2947
- tell application "Ghostty"
2948
- activate
2949
- end tell
2950
- tell application "System Events"
2951
- tell process "Ghostty"
2952
- keystroke "t" using command down
2953
- delay 0.3
2954
- keystroke "${scriptPath}"
2955
- keystroke return
2956
- end tell
2957
- end tell
2958
- '`);
2959
- break;
2960
- default:
2961
- execSync(`osascript -e '
2962
- tell application "Terminal"
2963
- activate
2964
- tell application "System Events"
2965
- tell process "Terminal"
2966
- keystroke "t" using command down
2967
- end tell
2968
- end tell
2969
- delay 0.3
2970
- do script "${scriptPath}" in front window
2971
- end tell
2972
- '`);
2973
- break;
2974
- }
2975
- }
2976
- catch {
2977
- console.debug('[runners:orchestrator-docker] Failed to open terminal tab, running in background');
2978
- }
2979
- }
2980
- // 'background' display mode: container is already running, nothing more to do
2981
- return {
2982
- success: true,
2983
- containerId,
2984
- sessionId: tmuxSessionName,
2985
- };
2986
- }
2987
- catch (error) {
2988
- return {
2989
- success: false,
2990
- error: error instanceof Error ? error.message : 'Failed to start orchestrator in Docker',
2991
- };
2992
- }
2993
- }
2994
- // =============================================================================
2995
- // Sandbox Utilities
2996
- // =============================================================================
2997
- /**
2998
- * Check if srt (sandbox-runtime) is installed on the host.
2999
- */
3000
- export function isSrtInstalled() {
3001
- try {
3002
- execSync('which srt', { stdio: 'pipe' });
3003
- return true;
3004
- }
3005
- catch {
3006
- return false;
3007
- }
3008
- }
3009
- /**
3010
- * Build the srt command with filesystem and network restrictions.
3011
- *
3012
- * Filesystem policy (read-restriction philosophy from claude-code-sandbox):
3013
- * - Read/write: agent worktree directory
3014
- * - Read-only: repo source (if different from worktree)
3015
- * - Read-only: additional configured read paths
3016
- * - Deny: home directory, system paths, other repos
3017
- *
3018
- * Network policy:
3019
- * - Allow: configured domains (GitHub, Anthropic API, npm registries, etc.)
3020
- * - Deny: everything else
3021
- */
3022
- export function buildSrtCommand(innerCommand, context, config) {
3023
- const args = ['srt'];
3024
- // Filesystem: always allow read/write to agent worktree
3025
- args.push(`--fs-write=${context.worktreePath}`);
3026
- // Allow read/write to the agent directory (parent of worktree, contains .devcontainer etc.)
3027
- if (context.agentDir && context.agentDir !== context.worktreePath) {
3028
- args.push(`--fs-write=${context.agentDir}`);
3029
- }
3030
- // Allow read/write to HQ scripts directory (for temp script files)
3031
- if (context.hqPath) {
3032
- const scriptsDir = path.join(context.hqPath, '.proletariat', 'scripts');
3033
- args.push(`--fs-write=${scriptsDir}`);
3034
- }
3035
- // Allow read access to additional configured paths
3036
- for (const readPath of config.sandbox.allowReadPaths) {
3037
- args.push(`--fs-read=${readPath}`);
3038
- }
3039
- // Allow write access to additional configured paths
3040
- for (const writePath of config.sandbox.allowWritePaths) {
3041
- args.push(`--fs-write=${writePath}`);
3042
- }
3043
- // Allow read to temp directory (needed for script execution)
3044
- args.push(`--fs-write=${os.tmpdir()}`);
3045
- // Network: merge sandbox domains with firewall allowlist
3046
- const allDomains = new Set([
3047
- ...config.sandbox.networkDomains,
3048
- ...config.firewall.allowlistDomains,
3049
- ]);
3050
- for (const domain of allDomains) {
3051
- args.push(`--net-allow=${domain}`);
3052
- }
3053
- // The inner command to execute inside the sandbox
3054
- args.push('--');
3055
- args.push(innerCommand);
3056
- return args.join(' ');
3057
- }
3058
- // =============================================================================
3059
- // Sandbox Runner - srt-based sandbox on host
3060
- // =============================================================================
3061
- /**
3062
- * Run command in an srt sandbox on the host machine.
3063
- * Uses the same tmux session approach as the host runner, but wraps the
3064
- * executor command with srt for filesystem and network isolation.
3065
- *
3066
- * Falls back to host runner with warning if srt is not installed.
3067
- */
3068
- export async function runSandbox(context, executor, config, displayMode = 'terminal') {
3069
- // Check if srt is installed
3070
- if (!isSrtInstalled()) {
3071
- if (config.sandbox.fallbackToHost) {
3072
- // Log warning via stderr (will be visible in terminal)
3073
- process.stderr.write('\x1b[33m⚠️ srt (sandbox-runtime) not installed. Falling back to host execution.\n' +
3074
- ' Install srt for filesystem + network isolation: https://github.com/anthropic-experimental/sandbox-runtime\x1b[0m\n');
3075
- // Fall back to host runner
3076
- return runHost(context, executor, config, displayMode);
3077
- }
3078
- return {
3079
- success: false,
3080
- error: 'srt (sandbox-runtime) is not installed.\n\n' +
3081
- 'Install it from: https://github.com/anthropic-experimental/sandbox-runtime\n' +
3082
- 'Or set sandbox.fallbackToHost to true in execution config to fall back to host.',
3083
- };
3084
- }
3085
- // Delegate to host runner — the sandbox wrapping happens at the script level
3086
- // We set a flag on context so the host runner knows to wrap with srt
3087
- const sandboxContext = {
3088
- ...context,
3089
- executionEnvironment: 'sandbox',
3090
- };
3091
- return runHost(sandboxContext, executor, config, displayMode);
3092
- }
3093
- // =============================================================================
3094
- // Cloud Runner (was VM Runner)
3095
- // =============================================================================
3096
- /**
3097
- * Run command on a remote machine (cloud) via SSH.
3098
- * Formerly 'runVm' — renamed to reflect the simplified environment hierarchy.
3099
- * Uses cloud config with fallback to legacy vm config for backwards compatibility.
7
+ * @see ./runners/index.ts Dispatcher and re-exports
8
+ * @see ./runners/shared.ts — Shared utilities
9
+ * @see ./runners/host.ts — Host runner
10
+ * @see ./runners/devcontainer.ts — Devcontainer runner
11
+ * @see ./runners/docker.ts — Docker runner
12
+ * @see ./runners/orchestrator.ts — Orchestrator-in-Docker runner
13
+ * @see ./runners/sandbox.ts Sandbox runner
14
+ * @see ./runners/cloud.ts Cloud/VM runner
3100
15
  */
3101
- export async function runCloud(context, executor, config, host) {
3102
- // Use cloud config, fall back to vm config for backwards compatibility
3103
- const cloudConfig = config.cloud?.defaultHost ? config.cloud : config.vm;
3104
- const targetHost = host || cloudConfig.defaultHost;
3105
- if (!targetHost) {
3106
- return {
3107
- success: false,
3108
- error: 'No cloud host specified. Use --host or configure execution.cloud.default_host',
3109
- };
3110
- }
3111
- const prompt = buildPrompt(context);
3112
- const user = cloudConfig.user;
3113
- const keyPath = cloudConfig.keyPath;
3114
- const remoteWorkspace = `/workspace/${context.agentName}`;
3115
- try {
3116
- // Build SSH options
3117
- let sshOpts = '';
3118
- if (keyPath) {
3119
- sshOpts = `-i "${keyPath}"`;
3120
- }
3121
- // Sync worktree to remote
3122
- if (cloudConfig.syncMethod === 'rsync') {
3123
- let rsyncCmd = `rsync -avz`;
3124
- if (keyPath) {
3125
- rsyncCmd += ` -e "ssh -i ${keyPath}"`;
3126
- }
3127
- rsyncCmd += ` "${context.worktreePath}/" ${user}@${targetHost}:${remoteWorkspace}/`;
3128
- execSync(rsyncCmd, { stdio: 'pipe' });
3129
- }
3130
- else {
3131
- // Git-based sync: push branch and pull on remote
3132
- execSync(`git push origin ${context.branch}`, { cwd: context.worktreePath, stdio: 'pipe' });
3133
- const gitPullCmd = `cd ${remoteWorkspace} && git fetch && git checkout ${context.branch}`;
3134
- execSync(`ssh ${sshOpts} ${user}@${targetHost} "${gitPullCmd}"`, { stdio: 'pipe' });
3135
- }
3136
- // Validate Codex mode: Cloud runner is always non-tty (SSH + nohup)
3137
- if (executor === 'codex') {
3138
- const codexPermission = config.permissionMode;
3139
- const modeError = validateCodexMode(codexPermission, 'non-tty');
3140
- if (modeError) {
3141
- return { success: false, error: modeError.message };
3142
- }
3143
- }
3144
- // Execute on remote using executor-appropriate command
3145
- const escapedPrompt = prompt.replace(/'/g, "'\\''");
3146
- const { cmd: executorCmd, args: executorArgs } = getExecutorCommand(executor, escapedPrompt, config.permissionMode === 'danger');
3147
- // Build the remote command based on executor type
3148
- let remoteCmd;
3149
- if (isClaudeExecutor(executor)) {
3150
- // TKT-053: Disable plan mode — VM runner is always nohup (no user to approve)
3151
- // PRLT-950: Use -- to separate flags from positional prompt argument.
3152
- remoteCmd = `cd ${remoteWorkspace} && ${executorCmd} --print --disallowedTools EnterPlanMode -- '${escapedPrompt}'`;
3153
- }
3154
- else {
3155
- const argsStr = executorArgs.map(a => a === escapedPrompt ? `'${escapedPrompt}'` : a).join(' ');
3156
- remoteCmd = `cd ${remoteWorkspace} && ${executorCmd} ${argsStr}`;
3157
- }
3158
- const sshCmd = `ssh ${sshOpts} ${user}@${targetHost} "nohup ${remoteCmd} > /tmp/work-${context.ticketId}.log 2>&1 &"`;
3159
- execSync(sshCmd, { stdio: 'pipe' });
3160
- return {
3161
- success: true,
3162
- sessionId: `${targetHost}:${context.ticketId}`,
3163
- logPath: `/tmp/work-${context.ticketId}.log`,
3164
- };
3165
- }
3166
- catch (error) {
3167
- return {
3168
- success: false,
3169
- error: error instanceof Error ? error.message : 'Failed to execute on cloud',
3170
- };
3171
- }
3172
- }
3173
- /** @deprecated Use runCloud instead */
3174
- export const runVm = runCloud;
3175
- // =============================================================================
3176
- // Runner Dispatcher
3177
- // =============================================================================
3178
- export async function runExecution(environment, context, executor, config = DEFAULT_EXECUTION_CONFIG, options) {
3179
- // Ensure context knows its execution environment
3180
- if (!context.executionEnvironment) {
3181
- context.executionEnvironment = environment;
3182
- }
3183
- // Normalize environment (maps 'vm' -> 'cloud')
3184
- const normalizedEnv = normalizeEnvironment(environment);
3185
- // Ensure tmux server has keychain access for OAuth (host/sandbox only)
3186
- // Docker uses claude-credentials volume, devcontainer runs inside container
3187
- if (normalizedEnv === 'host' || normalizedEnv === 'sandbox') {
3188
- await ensureTmuxServerHasKeychainAccess();
3189
- }
3190
- switch (normalizedEnv) {
3191
- case 'devcontainer':
3192
- return runDevcontainer(context, executor, config, options?.displayMode, options?.sessionManager);
3193
- case 'host':
3194
- return runHost(context, executor, config, options?.displayMode);
3195
- case 'sandbox':
3196
- return runSandbox(context, executor, config, options?.displayMode);
3197
- case 'docker':
3198
- return runDocker(context, executor, config);
3199
- case 'cloud':
3200
- return runCloud(context, executor, config, options?.host);
3201
- default:
3202
- return { success: false, error: `Unknown execution environment: ${environment}` };
3203
- }
3204
- }
16
+ export * from './runners/index.js';
3205
17
  //# sourceMappingURL=runners.js.map