@proletariat/cli 0.3.44 → 0.3.46

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 (73) hide show
  1. package/dist/commands/agent/list.js +2 -3
  2. package/dist/commands/agent/login.js +2 -2
  3. package/dist/commands/agent/rebuild.js +2 -3
  4. package/dist/commands/agent/shell.js +2 -2
  5. package/dist/commands/agent/status.js +3 -3
  6. package/dist/commands/agent/visit.js +2 -2
  7. package/dist/commands/config/index.js +39 -1
  8. package/dist/commands/linear/auth.d.ts +14 -0
  9. package/dist/commands/linear/auth.js +211 -0
  10. package/dist/commands/linear/import.d.ts +21 -0
  11. package/dist/commands/linear/import.js +260 -0
  12. package/dist/commands/linear/status.d.ts +11 -0
  13. package/dist/commands/linear/status.js +88 -0
  14. package/dist/commands/linear/sync.d.ts +15 -0
  15. package/dist/commands/linear/sync.js +233 -0
  16. package/dist/commands/orchestrator/attach.d.ts +9 -1
  17. package/dist/commands/orchestrator/attach.js +67 -13
  18. package/dist/commands/orchestrator/index.js +22 -7
  19. package/dist/commands/staff/list.js +2 -3
  20. package/dist/commands/ticket/link/duplicates.d.ts +15 -0
  21. package/dist/commands/ticket/link/duplicates.js +95 -0
  22. package/dist/commands/ticket/link/index.js +14 -0
  23. package/dist/commands/ticket/link/relates.d.ts +15 -0
  24. package/dist/commands/ticket/link/relates.js +95 -0
  25. package/dist/commands/work/revise.js +7 -6
  26. package/dist/commands/work/spawn.d.ts +5 -0
  27. package/dist/commands/work/spawn.js +195 -14
  28. package/dist/commands/work/start.js +79 -23
  29. package/dist/commands/work/watch.js +2 -2
  30. package/dist/lib/agents/commands.d.ts +11 -0
  31. package/dist/lib/agents/commands.js +40 -10
  32. package/dist/lib/execution/config.d.ts +15 -0
  33. package/dist/lib/execution/config.js +54 -0
  34. package/dist/lib/execution/devcontainer.d.ts +6 -3
  35. package/dist/lib/execution/devcontainer.js +39 -12
  36. package/dist/lib/execution/runners.d.ts +28 -32
  37. package/dist/lib/execution/runners.js +345 -271
  38. package/dist/lib/execution/spawner.js +65 -7
  39. package/dist/lib/execution/types.d.ts +4 -0
  40. package/dist/lib/execution/types.js +3 -0
  41. package/dist/lib/external-issues/adapters.d.ts +26 -0
  42. package/dist/lib/external-issues/adapters.js +251 -0
  43. package/dist/lib/external-issues/index.d.ts +10 -0
  44. package/dist/lib/external-issues/index.js +14 -0
  45. package/dist/lib/external-issues/mapper.d.ts +21 -0
  46. package/dist/lib/external-issues/mapper.js +86 -0
  47. package/dist/lib/external-issues/types.d.ts +144 -0
  48. package/dist/lib/external-issues/types.js +26 -0
  49. package/dist/lib/external-issues/validation.d.ts +34 -0
  50. package/dist/lib/external-issues/validation.js +219 -0
  51. package/dist/lib/linear/client.d.ts +55 -0
  52. package/dist/lib/linear/client.js +254 -0
  53. package/dist/lib/linear/config.d.ts +37 -0
  54. package/dist/lib/linear/config.js +100 -0
  55. package/dist/lib/linear/index.d.ts +11 -0
  56. package/dist/lib/linear/index.js +10 -0
  57. package/dist/lib/linear/mapper.d.ts +67 -0
  58. package/dist/lib/linear/mapper.js +219 -0
  59. package/dist/lib/linear/sync.d.ts +37 -0
  60. package/dist/lib/linear/sync.js +89 -0
  61. package/dist/lib/linear/types.d.ts +139 -0
  62. package/dist/lib/linear/types.js +34 -0
  63. package/dist/lib/mcp/helpers.d.ts +8 -0
  64. package/dist/lib/mcp/helpers.js +10 -0
  65. package/dist/lib/mcp/tools/board.js +63 -11
  66. package/dist/lib/pmo/schema.d.ts +2 -0
  67. package/dist/lib/pmo/schema.js +20 -0
  68. package/dist/lib/pmo/storage/base.js +92 -13
  69. package/dist/lib/pmo/storage/dependencies.js +15 -0
  70. package/dist/lib/prompt-json.d.ts +4 -0
  71. package/dist/lib/themes.js +32 -16
  72. package/oclif.manifest.json +2823 -2336
  73. package/package.json +2 -1
@@ -10,6 +10,7 @@ import * as path from 'node:path';
10
10
  import * as os from 'node:os';
11
11
  import { DEFAULT_EXECUTION_CONFIG, } from './types.js';
12
12
  import { getSetTitleCommands } from '../terminal.js';
13
+ import { readDevcontainerJson } from './devcontainer.js';
13
14
  // =============================================================================
14
15
  // Terminal Title Helpers
15
16
  // =============================================================================
@@ -87,47 +88,6 @@ export function configureITermTmuxWindowMode(mode) {
87
88
  configureITermTmuxPreferences(mode);
88
89
  }
89
90
  // =============================================================================
90
- // Background Mode Cleanup Helpers (TKT-988)
91
- // =============================================================================
92
- /**
93
- * Build the tmux script that runs inside the container.
94
- * In background mode: kills PID 1 (sleep infinity) after Claude exits to stop/remove container.
95
- * In terminal/foreground mode: drops into exec bash for user inspection.
96
- */
97
- export function buildTmuxScript(sessionName, claudeCmd, displayMode) {
98
- if (displayMode === 'background') {
99
- return `#!/bin/bash
100
- export TERM=xterm-256color
101
- export COLORTERM=truecolor
102
- unset CI
103
- echo "🚀 Starting: ${sessionName}"
104
- echo ""
105
- ${claudeCmd}
106
- echo ""
107
- echo "✅ Agent work complete. Cleaning up container..."
108
- kill 1
109
- `;
110
- }
111
- return `#!/bin/bash
112
- export TERM=xterm-256color
113
- export COLORTERM=truecolor
114
- unset CI
115
- echo "🚀 Starting: ${sessionName}"
116
- echo ""
117
- ${claudeCmd}
118
- echo ""
119
- echo "✅ Agent work complete. Press Enter to close or run more commands."
120
- exec bash
121
- `;
122
- }
123
- /**
124
- * Get the auto-remove flags for docker run based on display mode.
125
- * Background mode containers get --rm so Docker removes them when they stop.
126
- */
127
- export function getDockerAutoRemoveFlags(displayMode) {
128
- return displayMode === 'background' ? ['--rm'] : [];
129
- }
130
- // =============================================================================
131
91
  // Docker Credential Helpers
132
92
  // =============================================================================
133
93
  const CLAUDE_CREDENTIALS_VOLUME = 'claude-credentials';
@@ -188,150 +148,116 @@ export function getDockerCredentialInfo() {
188
148
  return null;
189
149
  }
190
150
  }
191
- /**
192
- * Get the binary name for an executor type.
193
- */
194
- function getExecutorBinary(executor) {
151
+ // =============================================================================
152
+ // Executor Commands
153
+ // =============================================================================
154
+ export function getExecutorCommand(executor, prompt, skipPermissions = true) {
195
155
  switch (executor) {
196
156
  case 'claude-code':
197
- return 'claude';
157
+ if (skipPermissions) {
158
+ // Skip permissions - agent runs autonomously without prompting
159
+ // Note: NO -p flag - we want interactive mode for streaming output in terminal
160
+ // --permission-mode bypassPermissions: skips the "trust this folder" dialog
161
+ // --dangerously-skip-permissions: skips tool permission checks
162
+ // --effort high: skips the effort level prompt (TKT-1134)
163
+ return { cmd: 'claude', args: ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', '--effort', 'high', prompt] };
164
+ }
165
+ // Manual mode - will prompt for each action (still interactive, no -p)
166
+ return { cmd: 'claude', args: [prompt] };
198
167
  case 'codex':
199
- return 'codex';
168
+ return { cmd: 'codex', args: ['--prompt', prompt] };
200
169
  case 'aider':
201
- return 'aider';
170
+ return { cmd: 'aider', args: ['--message', prompt] };
202
171
  case 'custom':
203
- return 'echo'; // placeholder
172
+ // Custom executor should be configured
173
+ return { cmd: 'echo', args: ['Custom executor not configured'] };
204
174
  default:
205
- return 'claude';
175
+ if (skipPermissions) {
176
+ // Note: NO -p flag - we want interactive mode for streaming output
177
+ // --effort high: skips the effort level prompt (TKT-1134)
178
+ return { cmd: 'claude', args: ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', '--effort', 'high', prompt] };
179
+ }
180
+ return { cmd: 'claude', args: [prompt] };
206
181
  }
207
182
  }
208
183
  /**
209
- * Get install/setup hints for a missing executor binary.
184
+ * Check if an executor is Claude Code.
185
+ * Used to gate Claude-specific flags and configuration.
210
186
  */
211
- function getExecutorRemediationHint(executor) {
187
+ export function isClaudeExecutor(executor) {
188
+ return executor === 'claude-code';
189
+ }
190
+ /**
191
+ * Get the display name for an executor type.
192
+ */
193
+ export function getExecutorDisplayName(executor) {
212
194
  switch (executor) {
213
- case 'claude-code':
214
- return 'Install Claude Code: npm install -g @anthropic-ai/claude-code\n' +
215
- ' Then authenticate: claude login';
216
- case 'codex':
217
- return 'Install Codex: npm install -g @openai/codex\n' +
218
- ' Then authenticate: set OPENAI_API_KEY or run codex --login';
219
- case 'aider':
220
- return 'Install aider: pip install aider-chat\n' +
221
- ' Then authenticate: set OPENAI_API_KEY or ANTHROPIC_API_KEY';
222
- case 'custom':
223
- return 'Configure a custom executor command in your execution settings.';
224
- default:
225
- return 'Install the required executor and ensure it is on your PATH.';
195
+ case 'claude-code': return 'Claude Code';
196
+ case 'codex': return 'Codex';
197
+ case 'aider': return 'Aider';
198
+ case 'custom': return 'Custom';
199
+ default: return 'Claude Code';
226
200
  }
227
201
  }
228
202
  /**
229
- * Check if an executor binary is available on the host.
230
- * Returns a PreflightResult with ok=true if the binary is found,
231
- * or ok=false with a descriptive error and remediation hint.
203
+ * Get the npm package name for an executor (for container installation).
204
+ */
205
+ export function getExecutorPackage(executor) {
206
+ switch (executor) {
207
+ case 'claude-code': return '@anthropic-ai/claude-code';
208
+ case 'codex': return '@openai/codex';
209
+ case 'aider': return null; // aider is Python-based, installed via pip
210
+ case 'custom': return null;
211
+ default: return '@anthropic-ai/claude-code';
212
+ }
213
+ }
214
+ /**
215
+ * Check executor binary availability on host.
232
216
  */
233
217
  export function checkExecutorOnHost(executor) {
234
- const binary = getExecutorBinary(executor);
218
+ const { cmd } = getExecutorCommand(executor, 'preflight');
235
219
  try {
236
- execSync(`${binary} --version`, {
237
- stdio: ['pipe', 'pipe', 'pipe'],
238
- timeout: 10000,
239
- });
220
+ execSync(`command -v ${cmd}`, { stdio: 'pipe' });
240
221
  return { ok: true };
241
222
  }
242
223
  catch {
224
+ const pkg = getExecutorPackage(executor);
225
+ const installHint = pkg ? `Install it with: npm install -g ${pkg}` : 'Install and configure the executor binary.';
243
226
  return {
244
227
  ok: false,
245
- error: `Executor "${executor}" not found: "${binary}" is not installed or not on PATH.\n\n` +
246
- `Remediation:\n ${getExecutorRemediationHint(executor)}`,
228
+ error: `${getExecutorDisplayName(executor)} CLI not found on host (missing "${cmd}"). ${installHint}`,
247
229
  };
248
230
  }
249
231
  }
250
232
  /**
251
- * Check if an executor binary is available inside a Docker container.
252
- * Returns a PreflightResult with ok=true if the binary is found,
253
- * or ok=false with a descriptive error and remediation hint.
233
+ * Check executor binary availability inside a container.
254
234
  */
255
235
  export function checkExecutorInContainer(executor, containerId) {
256
- const binary = getExecutorBinary(executor);
236
+ const { cmd } = getExecutorCommand(executor, 'preflight');
257
237
  try {
258
- execSync(`docker exec ${containerId} which ${binary}`, {
259
- stdio: 'pipe',
260
- timeout: 10000,
261
- });
238
+ execSync(`docker exec ${containerId} sh -lc 'command -v ${cmd}'`, { stdio: 'pipe' });
262
239
  return { ok: true };
263
240
  }
264
241
  catch {
242
+ const pkg = getExecutorPackage(executor);
243
+ const installHint = pkg ? `Container image is missing ${pkg}.` : `Container image is missing "${cmd}".`;
265
244
  return {
266
245
  ok: false,
267
- error: `Executor "${executor}" not found in container ${containerId}: "${binary}" is not installed.\n\n` +
268
- `Remediation:\n Ensure "${binary}" is installed in the devcontainer image.\n ` +
269
- getExecutorRemediationHint(executor),
246
+ error: `${getExecutorDisplayName(executor)} CLI not found in container (missing "${cmd}"). ${installHint}`,
270
247
  };
271
248
  }
272
249
  }
273
250
  /**
274
- * Run preflight checks for a given execution environment and executor.
275
- * Validates that the executor binary is available before spawning.
276
- *
277
- * Checks performed per environment:
278
- * - host: Verify binary on PATH
279
- * - devcontainer: Verify binary inside container (if container running)
280
- * - docker: Verify binary on host (used in docker run command)
281
- * - vm: Verify binary on host (will be checked on remote separately)
251
+ * Run executor preflight checks for the target environment.
282
252
  */
283
- export function runExecutorPreflight(executor, environment, containerId) {
284
- switch (environment) {
285
- case 'host':
286
- return checkExecutorOnHost(executor);
287
- case 'devcontainer':
288
- // For devcontainer, check inside the container if it's already running
289
- if (containerId) {
290
- return checkExecutorInContainer(executor, containerId);
291
- }
292
- // Container not yet running - will be checked after container start
293
- return { ok: true };
294
- case 'docker':
295
- // Docker runner builds the command on host, executor runs inside container
296
- // Can't check until container is created, so skip for now
297
- return { ok: true };
298
- case 'vm':
299
- // VM executor runs remotely - can't check from host
300
- // Could add SSH-based check in the future
301
- return { ok: true };
302
- default:
303
- return { ok: true };
253
+ export function runExecutorPreflight(environment, executor, options) {
254
+ if (environment === 'host') {
255
+ return checkExecutorOnHost(executor);
304
256
  }
305
- }
306
- // =============================================================================
307
- // Executor Commands
308
- // =============================================================================
309
- export function getExecutorCommand(executor, prompt, skipPermissions = true) {
310
- switch (executor) {
311
- case 'claude-code':
312
- if (skipPermissions) {
313
- // Skip permissions - agent runs autonomously without prompting
314
- // Note: NO -p flag - we want interactive mode for streaming output in terminal
315
- // --permission-mode bypassPermissions: skips the "trust this folder" dialog
316
- // --dangerously-skip-permissions: skips tool permission checks
317
- return { cmd: 'claude', args: ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', prompt] };
318
- }
319
- // Manual mode - will prompt for each action (still interactive, no -p)
320
- return { cmd: 'claude', args: [prompt] };
321
- case 'codex':
322
- return { cmd: 'codex', args: ['--prompt', prompt] };
323
- case 'aider':
324
- return { cmd: 'aider', args: ['--message', prompt] };
325
- case 'custom':
326
- // Custom executor should be configured
327
- return { cmd: 'echo', args: ['Custom executor not configured'] };
328
- default:
329
- if (skipPermissions) {
330
- // Note: NO -p flag - we want interactive mode for streaming output
331
- return { cmd: 'claude', args: ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', prompt] };
332
- }
333
- return { cmd: 'claude', args: [prompt] };
257
+ if (environment === 'devcontainer' && options?.containerId) {
258
+ return checkExecutorInContainer(executor, options.containerId);
334
259
  }
260
+ return { ok: true };
335
261
  }
336
262
  function buildPrompt(context) {
337
263
  let prompt = '';
@@ -450,7 +376,7 @@ export async function runHost(context, executor, config, displayMode = 'terminal
450
376
  const prompt = buildPrompt(context);
451
377
  // Terminal - use sandboxed setting
452
378
  const skipPermissions = !config.sandboxed;
453
- const { cmd } = getExecutorCommand(executor, prompt, skipPermissions);
379
+ const { cmd, args } = getExecutorCommand(executor, prompt, skipPermissions);
454
380
  // Write command to temp script to avoid shell escaping issues
455
381
  // Use HQ .proletariat/scripts if available, otherwise fallback to home dir
456
382
  const baseDir = context.hqPath
@@ -462,11 +388,26 @@ export async function runHost(context, executor, config, displayMode = 'terminal
462
388
  const promptPath = path.join(baseDir, `prompt-${context.ticketId}-${timestamp}.txt`);
463
389
  // Write prompt to separate file to avoid any shell escaping issues
464
390
  fs.writeFileSync(promptPath, prompt, { mode: 0o644 });
465
- // Build flags based on config
466
- const permissionsFlag = skipPermissions ? '--dangerously-skip-permissions ' : '';
467
- // outputMode: 'print' adds -p flag (final result only), 'interactive' shows streaming UI
468
- const printFlag = config.outputMode === 'print' ? '-p ' : '';
469
- // Build script that runs claude and keeps shell open after completion
391
+ // Build the executor command using getExecutorCommand() output
392
+ // For Claude Code, we also support outputMode and additional flags
393
+ // For non-Claude executors, we use the command as-is from getExecutorCommand()
394
+ let executorInvocation;
395
+ if (isClaudeExecutor(executor)) {
396
+ // Build flags based on config - Claude-specific flags
397
+ const permissionsFlag = skipPermissions ? '--dangerously-skip-permissions ' : '';
398
+ // outputMode: 'print' adds -p flag (final result only), 'interactive' shows streaming UI
399
+ const printFlag = config.outputMode === 'print' ? '-p ' : '';
400
+ // --effort high: skips the effort level prompt for automated agents (TKT-1134)
401
+ const effortFlag = skipPermissions ? '--effort high ' : '';
402
+ executorInvocation = `${cmd} ${permissionsFlag}${effortFlag}${printFlag}"$(cat "$PROMPT_PATH")"`;
403
+ }
404
+ else {
405
+ // Non-Claude executors: build command from getExecutorCommand() args
406
+ // Replace the prompt in args with a file read to avoid shell escaping
407
+ const argsWithFile = args.map(a => a === prompt ? '"$(cat "$PROMPT_PATH")"' : `"${a}"`);
408
+ executorInvocation = `${cmd} ${argsWithFile.join(' ')}`;
409
+ }
410
+ // Build script that runs executor and keeps shell open after completion
470
411
  const setTitleCmds = getSetTitleCommands(windowTitle);
471
412
  const scriptContent = `#!/bin/bash
472
413
  # Auto-generated script for ticket ${context.ticketId}
@@ -476,7 +417,7 @@ ${setTitleCmds}
476
417
  echo "🚀 Starting: ${sessionName}"
477
418
  echo ""
478
419
  cd "${context.worktreePath}"
479
- ${cmd} ${permissionsFlag}${printFlag}"$(cat "$PROMPT_PATH")"
420
+ ${executorInvocation}
480
421
 
481
422
  # Clean up script and prompt files
482
423
  rm -f "$SCRIPT_PATH" "$PROMPT_PATH"
@@ -801,6 +742,24 @@ export function isDevcontainerCliInstalled() {
801
742
  // =============================================================================
802
743
  // Docker Container Management (Raw Docker, no devcontainer CLI)
803
744
  // =============================================================================
745
+ /**
746
+ * Get the host's installed prlt CLI version.
747
+ * Returns the semver version string (e.g., "0.3.35") or null if not available.
748
+ * Used to ensure containers run the same prlt version as the host (TKT-1029).
749
+ */
750
+ function getHostPrltVersion() {
751
+ try {
752
+ const output = execSync('prlt --version', {
753
+ encoding: 'utf-8',
754
+ stdio: ['pipe', 'pipe', 'pipe'],
755
+ }).trim();
756
+ const match = output.match(/(\d+\.\d+\.\d+)/);
757
+ return match ? match[1] : null;
758
+ }
759
+ catch {
760
+ return null;
761
+ }
762
+ }
804
763
  /**
805
764
  * Get the container name for an agent.
806
765
  * Format: prlt-agent-{agentName}
@@ -896,7 +855,7 @@ function imageExists(imageName) {
896
855
  * Create and start a Docker container for an agent.
897
856
  * Uses raw Docker commands instead of devcontainer CLI.
898
857
  */
899
- function createDockerContainer(context, containerName, imageName, config, displayMode = 'terminal') {
858
+ function createDockerContainer(context, containerName, imageName, config, executor = 'claude-code', prltInfo) {
900
859
  // Build mount flags
901
860
  // KEY: Use a named Docker volume for Claude credentials - this is how devcontainer.json
902
861
  // was handling it. The volume persists across containers, so login once = logged in everywhere.
@@ -919,10 +878,14 @@ function createDockerContainer(context, containerName, imageName, config, displa
919
878
  // These mounts make those paths accessible inside the container at /hq/repos/{repoName}
920
879
  ...(context.repoWorktrees || []).map(repoName => `-v "${context.hqPath}/repos/${repoName}:/hq/repos/${repoName}:cached"`),
921
880
  // Claude credentials - shared named volume (login once, all containers share)
922
- `-v "claude-credentials:/home/node/.claude"`,
881
+ // Only needed for Claude Code executor
882
+ ...(isClaudeExecutor(executor) ? [`-v "claude-credentials:/home/node/.claude"`] : []),
923
883
  ];
924
884
  // Build environment flags
925
885
  const hasWorktrees = context.repoWorktrees && context.repoWorktrees.length > 0;
886
+ const firewallAllowlistDomains = [...new Set((config.firewall?.allowlistDomains || [])
887
+ .map(domain => domain.trim().toLowerCase())
888
+ .filter(domain => /^[a-z0-9.-]+$/.test(domain)))];
926
889
  const envVars = [
927
890
  `-e DEVCONTAINER=true`,
928
891
  `-e PRLT_HQ_PATH=/hq`,
@@ -934,10 +897,16 @@ function createDockerContainer(context, containerName, imageName, config, displa
934
897
  ...(context.useApiKey && process.env.ANTHROPIC_API_KEY ? [`-e ANTHROPIC_API_KEY="${process.env.ANTHROPIC_API_KEY}"`] : []),
935
898
  ...(process.env.GITHUB_TOKEN ? [`-e GITHUB_TOKEN="${process.env.GITHUB_TOKEN}"`] : []),
936
899
  ...(process.env.GH_TOKEN ? [`-e GH_TOKEN="${process.env.GH_TOKEN}"`] : []),
900
+ ...(firewallAllowlistDomains.length > 0 ? [`-e PRLT_EXTRA_ALLOWLIST_DOMAINS="${firewallAllowlistDomains.join(',')}"`] : []),
937
901
  // NOTE: Do NOT pass CLAUDE_CODE_OAUTH_TOKEN - it overrides credentials file
938
902
  // and setup-token generates invalid tokens. Use "prlt agent auth" instead.
939
903
  // Set mount mode to worktree if we have repo worktrees - triggers git wrapper setup
940
904
  ...(hasWorktrees ? [`-e PRLT_MOUNT_MODE=worktree`] : []),
905
+ // Pass prlt version info for setup-prlt.sh to verify/update at container start (TKT-1029)
906
+ ...(prltInfo ? [
907
+ `-e PRLT_REGISTRY="${prltInfo.registry}"`,
908
+ `-e PRLT_VERSION="${prltInfo.version}"`,
909
+ ] : []),
941
910
  ];
942
911
  // Resource limits
943
912
  const resourceFlags = [
@@ -950,16 +919,12 @@ function createDockerContainer(context, containerName, imageName, config, displa
950
919
  '--cap-add=NET_RAW', // For firewall setup
951
920
  // Note: After firewall is set up, the container is network-restricted
952
921
  ];
953
- // Auto-remove container on stop for background mode (R5)
954
- // Background containers should be cleaned up after work completes — nobody will attach to inspect
955
- const autoRemoveFlags = getDockerAutoRemoveFlags(displayMode);
956
922
  try {
957
923
  const createCmd = [
958
924
  'docker run -d',
959
925
  `--name ${containerName}`,
960
926
  '--user node',
961
927
  '-w /workspace',
962
- ...autoRemoveFlags,
963
928
  ...mounts,
964
929
  ...envVars,
965
930
  ...resourceFlags,
@@ -981,8 +946,9 @@ function createDockerContainer(context, containerName, imageName, config, displa
981
946
  * This includes firewall initialization, prlt setup, and Claude settings.
982
947
  * @param containerId - Docker container ID
983
948
  * @param sandboxed - Whether running in safe mode (true) or danger mode (false)
949
+ * @param executor - Which executor is being used (determines Claude-specific setup)
984
950
  */
985
- function runContainerSetup(containerId, sandboxed = true) {
951
+ function runContainerSetup(containerId, sandboxed = true, executor = 'claude-code') {
986
952
  try {
987
953
  // Run firewall init (requires sudo since we're running as node user)
988
954
  execSync(`docker exec ${containerId} sudo /usr/local/bin/init-firewall.sh`, { stdio: 'pipe' });
@@ -1005,67 +971,109 @@ function runContainerSetup(containerId, sandboxed = true) {
1005
971
  // Non-fatal - pnpm may not be installed in all containers
1006
972
  }
1007
973
  // Copy Claude settings file (.claude.json) from host to container
1008
- // This is needed for Claude Code to recognize settings and bypass prompts
1009
- // Note: Auth tokens are in the claude-credentials volume at /home/node/.claude/.credentials.json
1010
- // But settings (.claude.json) need to be at /home/node/.claude.json (outside the .claude dir)
1011
- try {
1012
- const hostClaudeJson = path.join(os.homedir(), '.claude.json');
1013
- let settings = {};
1014
- if (fs.existsSync(hostClaudeJson)) {
1015
- // Read host file content as base
1016
- const content = fs.readFileSync(hostClaudeJson, 'utf-8');
1017
- try {
1018
- settings = JSON.parse(content);
974
+ // Only needed for Claude Code executor - other executors have their own config
975
+ if (isClaudeExecutor(executor)) {
976
+ // This is needed for Claude Code to recognize settings and bypass prompts
977
+ // Note: Auth tokens are in the claude-credentials volume at /home/node/.claude/.credentials.json
978
+ // But settings (.claude.json) need to be at /home/node/.claude.json (outside the .claude dir)
979
+ try {
980
+ const hostClaudeJson = path.join(os.homedir(), '.claude.json');
981
+ let settings = {};
982
+ if (fs.existsSync(hostClaudeJson)) {
983
+ // Read host file content as base
984
+ const content = fs.readFileSync(hostClaudeJson, 'utf-8');
985
+ try {
986
+ settings = JSON.parse(content);
987
+ }
988
+ catch {
989
+ console.debug('[runners:docker] Failed to parse host .claude.json, using empty settings');
990
+ }
1019
991
  }
1020
- catch {
1021
- console.debug('[runners:docker] Failed to parse host .claude.json, using empty settings');
992
+ // Only set bypassPermissionsModeAccepted when user chose danger mode (!sandboxed)
993
+ // This doesn't modify the host file - only the container copy
994
+ if (!sandboxed) {
995
+ settings.bypassPermissionsModeAccepted = true;
1022
996
  }
997
+ // Skip first-run onboarding (theme picker, tips, etc.) for automated agents
998
+ // These flags indicate Claude Code has been run before
999
+ settings.numStartups = settings.numStartups || 1;
1000
+ settings.hasCompletedOnboarding = true;
1001
+ settings.theme = settings.theme || 'dark';
1002
+ // Ensure tipsHistory exists to prevent tip prompts
1003
+ if (!settings.tipsHistory || typeof settings.tipsHistory !== 'object') {
1004
+ settings.tipsHistory = {};
1005
+ }
1006
+ const tips = settings.tipsHistory;
1007
+ tips['new-user-warmup'] = tips['new-user-warmup'] || 1;
1008
+ // Dismiss the effort level callout so agents aren't prompted (TKT-1134)
1009
+ settings.effortCalloutDismissed = true;
1010
+ // Pre-accept the "trust this folder" dialog for /workspace (TKT-1134)
1011
+ // Claude Code stores trust per-project under projects[path].hasTrustDialogAccepted
1012
+ // Without this, agents get stuck on the workspace safety prompt
1013
+ if (!settings.projects || typeof settings.projects !== 'object') {
1014
+ settings.projects = {};
1015
+ }
1016
+ const projects = settings.projects;
1017
+ // Accept trust for /workspace and root / to cover all container working directories
1018
+ for (const projectPath of ['/workspace', '/']) {
1019
+ if (!projects[projectPath]) {
1020
+ projects[projectPath] = {};
1021
+ }
1022
+ projects[projectPath].hasTrustDialogAccepted = true;
1023
+ projects[projectPath].hasCompletedProjectOnboarding = true;
1024
+ }
1025
+ // Pipe settings via stdin to avoid ARG_MAX limits with large .claude.json files
1026
+ const settingsJson = JSON.stringify(settings);
1027
+ // Write to container at /home/node/.claude.json using stdin piping
1028
+ execSync(`docker exec -i ${containerId} bash -c 'cat > /home/node/.claude.json'`, { input: settingsJson, stdio: ['pipe', 'pipe', 'pipe'] });
1029
+ console.debug(`[runners:docker] Copied .claude.json settings to container (bypassPermissionsModeAccepted=${!sandboxed})`);
1030
+ // Write ~/.claude/settings.json to skip the dangerous mode permission prompt (TKT-1134)
1031
+ // This prevents Claude Code from prompting about permission mode on first run
1032
+ const claudeSettings = JSON.stringify({ skipDangerousModePermissionPrompt: true });
1033
+ execSync(`docker exec -i ${containerId} bash -c 'mkdir -p /home/node/.claude && cat > /home/node/.claude/settings.json'`, { input: claudeSettings, stdio: ['pipe', 'pipe', 'pipe'] });
1034
+ console.debug(`[runners:docker] Wrote ~/.claude/settings.json to container`);
1023
1035
  }
1024
- // Only set bypassPermissionsModeAccepted when user chose danger mode (!sandboxed)
1025
- // This doesn't modify the host file - only the container copy
1026
- if (!sandboxed) {
1027
- settings.bypassPermissionsModeAccepted = true;
1028
- }
1029
- // Skip first-run onboarding (theme picker, tips, etc.) for automated agents
1030
- // These flags indicate Claude Code has been run before
1031
- settings.numStartups = settings.numStartups || 1;
1032
- settings.hasCompletedOnboarding = true;
1033
- settings.theme = settings.theme || 'dark';
1034
- // Ensure tipsHistory exists to prevent tip prompts
1035
- if (!settings.tipsHistory || typeof settings.tipsHistory !== 'object') {
1036
- settings.tipsHistory = {};
1036
+ catch (error) {
1037
+ console.debug('[runners:docker] Failed to copy Claude settings to container:', error);
1038
+ // Non-fatal - Claude will just prompt for settings
1037
1039
  }
1038
- const tips = settings.tipsHistory;
1039
- tips['new-user-warmup'] = tips['new-user-warmup'] || 1;
1040
- // Pipe settings via stdin to avoid ARG_MAX limits with large .claude.json files
1041
- const settingsJson = JSON.stringify(settings);
1042
- // Write to container at /home/node/.claude.json using stdin piping
1043
- execSync(`docker exec -i ${containerId} bash -c 'cat > /home/node/.claude.json'`, { input: settingsJson, stdio: ['pipe', 'pipe', 'pipe'] });
1044
- console.debug(`[runners:docker] Copied .claude.json settings to container (bypassPermissionsModeAccepted=${!sandboxed})`);
1040
+ // NOTE: Auth credentials come from the claude-credentials volume.
1041
+ // Run "prlt agent auth" to set up authentication (one-time).
1042
+ // Do NOT sync CLAUDE_CODE_OAUTH_TOKEN env var - it causes issues
1043
+ // (setup-token generates invalid tokens, and env var overrides valid credentials file).
1045
1044
  }
1046
- catch (error) {
1047
- console.debug('[runners:docker] Failed to copy .claude.json to container:', error);
1048
- // Non-fatal - Claude will just prompt for settings
1045
+ else {
1046
+ console.debug(`[runners:docker] Skipping .claude.json settings injection for ${executor} executor`);
1049
1047
  }
1050
- // NOTE: Auth credentials come from the claude-credentials volume.
1051
- // Run "prlt agent auth" to set up authentication (one-time).
1052
- // Do NOT sync CLAUDE_CODE_OAUTH_TOKEN env var - it causes issues
1053
- // (setup-token generates invalid tokens, and env var overrides valid credentials file).
1054
1048
  return true;
1055
1049
  }
1056
1050
  /**
1057
1051
  * Ensure a Docker container is running for the agent.
1052
+ * Reuses running containers to preserve in-progress work (TKT-1028).
1053
+ * Only destroys and recreates stopped containers.
1058
1054
  * Builds image and creates container if needed.
1059
1055
  * Returns the container ID if successful, null otherwise.
1060
1056
  */
1061
- function ensureDockerContainer(context, config, displayMode = 'terminal') {
1057
+ function ensureDockerContainer(context, config, executor = 'claude-code') {
1062
1058
  const containerName = getContainerName(context.agentName);
1063
1059
  const imageName = getImageName(context.agentName);
1064
- // Always create fresh container to ensure mounts are up-to-date
1065
- // TODO: Revisit container reuse strategy - for now, fresh containers ensure
1066
- // correct volume mounts (especially claude-credentials) are applied
1060
+ // TKT-1028: Reuse running containers instead of destroying them.
1061
+ // This preserves in-progress tmux sessions and avoids killing running agents.
1062
+ // Only destroy stopped containers (which have stale mounts anyway).
1067
1063
  if (containerExists(containerName)) {
1068
- console.debug(`[runners:docker] Removing existing container ${containerName} to create fresh one`);
1064
+ if (isContainerRunning(containerName)) {
1065
+ // Container is running - reuse it to preserve any in-progress work.
1066
+ // Note: runContainerSetup is skipped for reused containers since they
1067
+ // were already set up when first created. GitHub token and credentials
1068
+ // are refreshed by the caller (runDevcontainer).
1069
+ const containerId = getContainerId(containerName);
1070
+ if (containerId) {
1071
+ console.debug(`[runners:docker] Reusing running container ${containerName} (${containerId}), skipping setup`);
1072
+ return containerId;
1073
+ }
1074
+ }
1075
+ // Container exists but is stopped - remove and recreate for fresh mounts
1076
+ console.debug(`[runners:docker] Removing stopped container ${containerName} to create fresh one`);
1069
1077
  try {
1070
1078
  execSync(`docker rm -f ${containerName}`, { stdio: 'pipe' });
1071
1079
  }
@@ -1073,21 +1081,50 @@ function ensureDockerContainer(context, config, displayMode = 'terminal') {
1073
1081
  // Ignore removal errors
1074
1082
  }
1075
1083
  }
1076
- // Build image if it doesn't exist
1077
- if (!imageExists(imageName)) {
1078
- console.debug(`[runners:docker] Building image ${imageName}`);
1079
- const buildArgs = {
1080
- TZ: 'America/Los_Angeles',
1081
- PRLT_REGISTRY: 'npm',
1082
- PRLT_VERSION: 'latest',
1083
- };
1084
- if (!buildDockerImage(context.agentDir, imageName, buildArgs)) {
1085
- return null;
1086
- }
1084
+ // Build image with version-aware cache busting (TKT-1029)
1085
+ // Read build args from devcontainer.json instead of hardcoding
1086
+ const devcontainerJson = readDevcontainerJson(context.agentDir);
1087
+ const buildArgs = {
1088
+ TZ: devcontainerJson?.build?.args?.TZ || 'America/Los_Angeles',
1089
+ PRLT_REGISTRY: devcontainerJson?.build?.args?.PRLT_REGISTRY || 'npm',
1090
+ };
1091
+ // Resolve the specific prlt version to install (TKT-1029)
1092
+ // When the configured version is a tag like "latest", resolve it to the host's
1093
+ // actual prlt version. This serves two purposes:
1094
+ // 1. Ensures the container runs the same version as the host
1095
+ // 2. Enables Docker layer cache busting when the host version changes
1096
+ // (Docker caches "latest" as a static string, so the layer never rebuilds)
1097
+ const configuredVersion = devcontainerJson?.build?.args?.PRLT_VERSION || 'latest';
1098
+ const isTagVersion = ['latest', 'dev', 'next'].includes(configuredVersion);
1099
+ const hostPrltVersion = isTagVersion ? getHostPrltVersion() : null;
1100
+ if (hostPrltVersion) {
1101
+ buildArgs.PRLT_VERSION = hostPrltVersion;
1102
+ console.debug(`[runners:docker] Using host prlt version ${hostPrltVersion} for image build`);
1087
1103
  }
1104
+ else {
1105
+ buildArgs.PRLT_VERSION = configuredVersion;
1106
+ }
1107
+ // Always run docker build - Docker layer caching makes this efficient when
1108
+ // nothing has changed. When PRLT_VERSION changes (e.g., "0.3.29" -> "0.3.35"),
1109
+ // the changed build arg invalidates the cache from that layer forward,
1110
+ // ensuring the new version gets installed.
1111
+ console.debug(`[runners:docker] Building image ${imageName} (PRLT_VERSION=${buildArgs.PRLT_VERSION})`);
1112
+ if (!buildDockerImage(context.agentDir, imageName, buildArgs)) {
1113
+ if (!imageExists(imageName)) {
1114
+ return null; // No image at all, can't proceed
1115
+ }
1116
+ // Build failed but old image exists - continue with setup-prlt.sh as fallback
1117
+ console.debug(`[runners:docker] Build failed but existing image found, continuing with runtime update`);
1118
+ }
1119
+ // Pass resolved prlt version info to the container environment (TKT-1029)
1120
+ // This allows setup-prlt.sh to verify/update prlt without querying npm registry
1121
+ const prltInfo = {
1122
+ registry: buildArgs.PRLT_REGISTRY,
1123
+ version: buildArgs.PRLT_VERSION,
1124
+ };
1088
1125
  // Create and start container
1089
1126
  console.debug(`[runners:docker] Creating container ${containerName}`);
1090
- if (!createDockerContainer(context, containerName, imageName, config, displayMode)) {
1127
+ if (!createDockerContainer(context, containerName, imageName, config, executor, prltInfo)) {
1091
1128
  return null;
1092
1129
  }
1093
1130
  const containerId = getContainerId(containerName);
@@ -1096,8 +1133,9 @@ function ensureDockerContainer(context, config, displayMode = 'terminal') {
1096
1133
  }
1097
1134
  // Run post-start setup (firewall, prlt, Claude settings)
1098
1135
  // Pass sandboxed config to determine whether to set bypassPermissionsModeAccepted
1099
- console.debug(`[runners:docker] Running container setup (sandboxed=${config.sandboxed})`);
1100
- if (!runContainerSetup(containerId, config.sandboxed)) {
1136
+ // Pass executor to skip Claude-specific setup for non-Claude executors
1137
+ console.debug(`[runners:docker] Running container setup (sandboxed=${config.sandboxed}, executor=${executor})`);
1138
+ if (!runContainerSetup(containerId, config.sandboxed, executor)) {
1101
1139
  console.debug(`[runners:docker] Setup failed, but continuing...`);
1102
1140
  // Don't fail completely - setup might partially work
1103
1141
  }
@@ -1181,42 +1219,42 @@ function writePromptFile(context) {
1181
1219
  * Uses docker exec for direct container access.
1182
1220
  * Uses a prompt file to avoid shell escaping issues.
1183
1221
  */
1184
- function buildDevcontainerCommand(context, executor, promptFile, containerId, outputMode = 'interactive', sandboxed = true, displayMode = 'terminal') {
1185
- // Get base command (just 'claude' for claude-code)
1186
- let baseCmd;
1187
- switch (executor) {
1188
- case 'claude-code':
1189
- baseCmd = 'claude';
1190
- break;
1191
- case 'codex':
1192
- baseCmd = 'codex';
1193
- break;
1194
- case 'aider':
1195
- baseCmd = 'aider';
1196
- break;
1197
- default:
1198
- baseCmd = 'claude';
1199
- }
1222
+ export function buildDevcontainerCommand(context, executor, promptFile, containerId, outputMode = 'interactive', sandboxed = true, displayMode = 'terminal') {
1200
1223
  // Calculate the relative path from agentDir to worktreePath for cd
1201
1224
  const relativePath = path.relative(context.agentDir, context.worktreePath);
1202
1225
  const cdCmd = relativePath ? `cd /workspace/${relativePath} && ` : '';
1203
- // Build Claude flags based on output mode and sandboxed setting
1204
- // - interactive: No -p flag, shows streaming UI (watch Claude work in real-time)
1205
- // - print: Uses -p flag, outputs final result only (better for logs/automation)
1206
- const printFlag = outputMode === 'print' ? '-p ' : '';
1207
- // sandboxed=true means safe mode (no --dangerously-skip-permissions)
1208
- // sandboxed=false means danger mode (use --dangerously-skip-permissions)
1209
- // --permission-mode bypassPermissions: skips the "trust this folder" dialog
1210
- const bypassTrustFlag = '--permission-mode bypassPermissions ';
1211
- const permissionsFlag = !sandboxed ? '--dangerously-skip-permissions ' : '';
1212
- // Build the claude command
1213
- const claudeCmd = `${cdCmd}${baseCmd} ${bypassTrustFlag}${permissionsFlag}${printFlag}"$(cat ${promptFile})" && rm -f ${promptFile}`;
1226
+ // Build executor command using the centralized getExecutorCommand()
1227
+ // This ensures all runners use consistent executor invocation
1228
+ let executorCmd;
1229
+ if (isClaudeExecutor(executor)) {
1230
+ // Claude-specific flags based on output mode and sandboxed setting
1231
+ // - interactive: No -p flag, shows streaming UI (watch Claude work in real-time)
1232
+ // - print: Uses -p flag, outputs final result only (better for logs/automation)
1233
+ const printFlag = outputMode === 'print' ? '-p ' : '';
1234
+ // sandboxed=true means safe mode (no --dangerously-skip-permissions)
1235
+ // sandboxed=false means danger mode (use --dangerously-skip-permissions)
1236
+ // --permission-mode bypassPermissions: skips the "trust this folder" dialog
1237
+ const bypassTrustFlag = '--permission-mode bypassPermissions ';
1238
+ const permissionsFlag = !sandboxed ? '--dangerously-skip-permissions ' : '';
1239
+ // --effort high: skips the effort level prompt for automated agents (TKT-1134)
1240
+ const effortFlag = '--effort high ';
1241
+ executorCmd = `claude ${bypassTrustFlag}${permissionsFlag}${effortFlag}${printFlag}"$(cat ${promptFile})"`;
1242
+ }
1243
+ else {
1244
+ // Non-Claude executors: use getExecutorCommand() to get correct command and args
1245
+ const { cmd, args } = getExecutorCommand(executor, `PLACEHOLDER`, false);
1246
+ // Replace the placeholder prompt with a file read for shell safety
1247
+ const argsStr = args.map(a => a === 'PLACEHOLDER' ? `"$(cat ${promptFile})"` : a).join(' ');
1248
+ executorCmd = `${cmd} ${argsStr}`;
1249
+ }
1250
+ // Build the full command with cd, executor invocation, and cleanup
1251
+ const fullCmd = `${cdCmd}${executorCmd} && rm -f ${promptFile}`;
1214
1252
  // Use docker exec for running commands in the container
1215
1253
  // Use -it flags only for terminal/foreground modes where a TTY is available
1216
1254
  // Background mode runs without a TTY, so -it flags would cause "not a TTY" error
1217
1255
  const ttyFlags = displayMode === 'background' ? '' : '-it ';
1218
- // Direct mode - run claude directly (tmux setup is handled by runDevcontainerInTmux)
1219
- return `docker exec ${ttyFlags}${containerId} bash -c '${claudeCmd}'`;
1256
+ // Direct mode - run executor directly (tmux setup is handled by runDevcontainerInTmux)
1257
+ return `docker exec ${ttyFlags}${containerId} bash -c '${fullCmd}'`;
1220
1258
  }
1221
1259
  /**
1222
1260
  * Run command inside a Docker container.
@@ -1261,25 +1299,20 @@ export async function runDevcontainer(context, executor, config, displayMode = '
1261
1299
  }
1262
1300
  }
1263
1301
  // Copy Claude credentials into agent directory so container can access them
1264
- // This was the original working approach - credentials at /workspace/.claude.json
1265
- copyClaudeCredentials(context.agentDir);
1302
+ // Only needed for Claude Code executor
1303
+ if (isClaudeExecutor(executor)) {
1304
+ // This was the original working approach - credentials at /workspace/.claude.json
1305
+ copyClaudeCredentials(context.agentDir);
1306
+ }
1266
1307
  // Start or reuse container using raw Docker commands
1267
1308
  // No devcontainer CLI required!
1268
- const containerId = ensureDockerContainer(context, config, displayMode);
1309
+ const containerId = ensureDockerContainer(context, config, executor);
1269
1310
  if (!containerId) {
1270
1311
  return {
1271
1312
  success: false,
1272
1313
  error: 'Failed to start Docker container. Check Docker logs for details.',
1273
1314
  };
1274
1315
  }
1275
- // Executor preflight check (TKT-1082): verify executor binary is available inside container
1276
- const preflight = checkExecutorInContainer(executor, containerId);
1277
- if (!preflight.ok) {
1278
- return {
1279
- success: false,
1280
- error: preflight.error,
1281
- };
1282
- }
1283
1316
  // Write prompt to file in worktree (accessible by container)
1284
1317
  const { hostPath: promptHostPath, containerPath: promptFile } = writePromptFile(context);
1285
1318
  // Inject fresh GitHub token into container (containers may be reused with stale/empty tokens)
@@ -1624,10 +1657,21 @@ async function runDevcontainerInTmux(context, devcontainerCmd, config, displayMo
1624
1657
  // Extract the claude command from the devcontainer command
1625
1658
  const cmdMatch = devcontainerCmd.match(/bash -c '(.+)'$/);
1626
1659
  const claudeCmd = cmdMatch ? cmdMatch[1] : devcontainerCmd;
1627
- // Create a script inside the container that runs claude
1628
- // Background mode (R1): kills PID 1 to stop container after completion
1629
- // Terminal/foreground mode (R2): drops into exec bash for user inspection
1630
- const tmuxScript = buildTmuxScript(sessionName, claudeCmd, displayMode);
1660
+ // Create a script inside the container that runs claude and keeps shell open
1661
+ // TERM must be set for Claude's TUI to render properly
1662
+ // Unset CI to prevent Claude from detecting CI environment which suppresses TUI output
1663
+ // Note: We keep DEVCONTAINER set so prlt workspace detection works correctly
1664
+ const tmuxScript = `#!/bin/bash
1665
+ export TERM=xterm-256color
1666
+ export COLORTERM=truecolor
1667
+ unset CI
1668
+ echo "🚀 Starting: ${sessionName}"
1669
+ echo ""
1670
+ ${claudeCmd}
1671
+ echo ""
1672
+ echo "✅ Agent work complete. Press Enter to close or run more commands."
1673
+ exec bash
1674
+ `;
1631
1675
  const scriptPath = `/tmp/prlt-${sessionName}.sh`;
1632
1676
  // Write script and start tmux session inside container
1633
1677
  // IMPORTANT: We create the session with bash first, then send keys to run the script.
@@ -1651,6 +1695,22 @@ async function runDevcontainerInTmux(context, devcontainerCmd, config, displayMo
1651
1695
  error: `Failed to write script to container: ${error instanceof Error ? error.message : error}`,
1652
1696
  };
1653
1697
  }
1698
+ // TKT-1028: If a tmux session with the same name already exists (e.g., same
1699
+ // ticket+action spawned again in a reused container), kill the old session first.
1700
+ try {
1701
+ execSync(`docker exec ${actualContainerId} tmux has-session -t "${sessionName}" 2>&1`, { stdio: 'pipe' });
1702
+ // Session exists - kill it before creating a new one
1703
+ console.debug(`[runners:tmux] Killing existing tmux session "${sessionName}" in container`);
1704
+ try {
1705
+ execSync(`docker exec ${actualContainerId} tmux kill-session -t "${sessionName}"`, { stdio: 'pipe' });
1706
+ }
1707
+ catch {
1708
+ // Ignore kill errors
1709
+ }
1710
+ }
1711
+ catch {
1712
+ // Session doesn't exist - that's the normal case
1713
+ }
1654
1714
  // Step 2: Create tmux session running the script directly
1655
1715
  // Pass the script as the session command (like host runner does) instead of using send-keys.
1656
1716
  // The send-keys approach had a race condition where keys could be lost if bash hadn't
@@ -1897,8 +1957,6 @@ exec $SHELL
1897
1957
  export async function runDocker(context, executor, config) {
1898
1958
  const prompt = buildPrompt(context);
1899
1959
  const containerName = `work-${context.ticketId}-${Date.now()}`;
1900
- // Get the correct executor command (claude, codex, aider, etc.)
1901
- const { cmd, args } = getExecutorCommand(executor, prompt, !config.sandboxed);
1902
1960
  try {
1903
1961
  // Check if docker is available
1904
1962
  execSync('which docker', { stdio: 'pipe' });
@@ -1923,10 +1981,19 @@ export async function runDocker(context, executor, config) {
1923
1981
  if (config.docker.cpus) {
1924
1982
  dockerCmd += ` --cpus ${config.docker.cpus}`;
1925
1983
  }
1926
- // Build executor command with properly escaped args
1927
- const escapedArgs = args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ');
1984
+ // Build executor command using getExecutorCommand() for correct invocation
1985
+ const escapedPrompt = prompt.replace(/'/g, "'\\''");
1986
+ const { cmd, args } = getExecutorCommand(executor, escapedPrompt, !config.sandboxed);
1987
+ // For Claude Code in Docker, use --print for non-interactive output
1988
+ // Non-Claude executors use their native command format from getExecutorCommand()
1928
1989
  dockerCmd += ` ${config.docker.image}`;
1929
- dockerCmd += ` ${cmd} ${escapedArgs}`;
1990
+ if (isClaudeExecutor(executor)) {
1991
+ dockerCmd += ` ${cmd} --print '${escapedPrompt}'`;
1992
+ }
1993
+ else {
1994
+ const argsStr = args.map(a => a === escapedPrompt ? `'${escapedPrompt}'` : a).join(' ');
1995
+ dockerCmd += ` ${cmd} ${argsStr}`;
1996
+ }
1930
1997
  const containerId = execSync(dockerCmd, { encoding: 'utf-8' }).trim();
1931
1998
  return {
1932
1999
  success: true,
@@ -1955,8 +2022,6 @@ export async function runVm(context, executor, config, host) {
1955
2022
  const user = config.vm.user;
1956
2023
  const keyPath = config.vm.keyPath;
1957
2024
  const remoteWorkspace = `/workspace/${context.agentName}`;
1958
- // Get the correct executor command (claude, codex, aider, etc.)
1959
- const { cmd, args } = getExecutorCommand(executor, prompt, !config.sandboxed);
1960
2025
  try {
1961
2026
  // Build SSH options
1962
2027
  let sshOpts = '';
@@ -1978,9 +2043,18 @@ export async function runVm(context, executor, config, host) {
1978
2043
  const gitPullCmd = `cd ${remoteWorkspace} && git fetch && git checkout ${context.branch}`;
1979
2044
  execSync(`ssh ${sshOpts} ${user}@${targetHost} "${gitPullCmd}"`, { stdio: 'pipe' });
1980
2045
  }
1981
- // Execute on remote using the correct executor
1982
- const escapedArgs = args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ');
1983
- const remoteCmd = `cd ${remoteWorkspace} && ${cmd} ${escapedArgs}`;
2046
+ // Execute on remote using executor-appropriate command
2047
+ const escapedPrompt = prompt.replace(/'/g, "'\\''");
2048
+ const { cmd: executorCmd, args: executorArgs } = getExecutorCommand(executor, escapedPrompt, !config.sandboxed);
2049
+ // Build the remote command based on executor type
2050
+ let remoteCmd;
2051
+ if (isClaudeExecutor(executor)) {
2052
+ remoteCmd = `cd ${remoteWorkspace} && ${executorCmd} --print '${escapedPrompt}'`;
2053
+ }
2054
+ else {
2055
+ const argsStr = executorArgs.map(a => a === escapedPrompt ? `'${escapedPrompt}'` : a).join(' ');
2056
+ remoteCmd = `cd ${remoteWorkspace} && ${executorCmd} ${argsStr}`;
2057
+ }
1984
2058
  const sshCmd = `ssh ${sshOpts} ${user}@${targetHost} "nohup ${remoteCmd} > /tmp/work-${context.ticketId}.log 2>&1 &"`;
1985
2059
  execSync(sshCmd, { stdio: 'pipe' });
1986
2060
  return {