@proletariat/cli 0.3.45 → 0.3.47

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