@proletariat/cli 0.3.9 → 0.3.11

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 (152) hide show
  1. package/README.md +25 -0
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/action/index.js +1 -1
  4. package/dist/commands/action/run.js +8 -12
  5. package/dist/commands/agent/auth.d.ts +30 -0
  6. package/dist/commands/agent/auth.js +172 -0
  7. package/dist/commands/agent/discover.d.ts +9 -0
  8. package/dist/commands/agent/discover.js +67 -0
  9. package/dist/commands/agent/index.js +47 -12
  10. package/dist/commands/agent/list.d.ts +4 -1
  11. package/dist/commands/agent/list.js +78 -16
  12. package/dist/commands/agent/login.js +35 -31
  13. package/dist/commands/agent/restart.js +2 -0
  14. package/dist/commands/agent/shell.js +78 -19
  15. package/dist/commands/agent/staff/add.js +1 -12
  16. package/dist/commands/agent/staff/remove.js +9 -7
  17. package/dist/commands/agent/status.js +17 -4
  18. package/dist/commands/agent/temp/cleanup.js +7 -3
  19. package/dist/commands/agent/themes/index.js +4 -5
  20. package/dist/commands/agent/themes/list.js +5 -5
  21. package/dist/commands/agent/visit.js +17 -4
  22. package/dist/commands/branch/create.d.ts +4 -0
  23. package/dist/commands/branch/create.js +16 -8
  24. package/dist/commands/branch/index.js +1 -1
  25. package/dist/commands/branch/where.js +1 -0
  26. package/dist/commands/claude.d.ts +38 -0
  27. package/dist/commands/claude.js +899 -0
  28. package/dist/commands/commit.js +1 -1
  29. package/dist/commands/config/index.d.ts +12 -0
  30. package/dist/commands/config/index.js +271 -0
  31. package/dist/commands/docker/clean.js +2 -2
  32. package/dist/commands/docker/index.js +2 -2
  33. package/dist/commands/docker/list.js +3 -8
  34. package/dist/commands/docker/logs.js +2 -2
  35. package/dist/commands/docker/prune.js +1 -1
  36. package/dist/commands/docker/restart.js +2 -2
  37. package/dist/commands/docker/shell.js +2 -2
  38. package/dist/commands/docker/start.js +2 -2
  39. package/dist/commands/docker/status.js +1 -1
  40. package/dist/commands/docker/stop.js +2 -2
  41. package/dist/commands/docker/sync.js +2 -2
  42. package/dist/commands/epic/index.js +1 -1
  43. package/dist/commands/epic/link/index.js +25 -14
  44. package/dist/commands/epic/link/remove.js +2 -0
  45. package/dist/commands/epic/list.js +5 -5
  46. package/dist/commands/epic/progress.js +10 -4
  47. package/dist/commands/epic/spec.js +2 -0
  48. package/dist/commands/epic/ticket.js +3 -0
  49. package/dist/commands/execution/stop.js +1 -0
  50. package/dist/commands/init.js +4 -4
  51. package/dist/commands/project/index.js +1 -1
  52. package/dist/commands/project/spec.js +7 -0
  53. package/dist/commands/repo/add.js +1 -0
  54. package/dist/commands/repo/remove.js +1 -0
  55. package/dist/commands/roadmap/add-project.d.ts +18 -0
  56. package/dist/commands/roadmap/add-project.js +135 -0
  57. package/dist/commands/roadmap/create.d.ts +22 -0
  58. package/dist/commands/roadmap/create.js +156 -0
  59. package/dist/commands/roadmap/delete.d.ts +17 -0
  60. package/dist/commands/roadmap/delete.js +104 -0
  61. package/dist/commands/roadmap/generate.d.ts +22 -0
  62. package/dist/commands/roadmap/generate.js +201 -0
  63. package/dist/commands/roadmap/index.d.ts +13 -0
  64. package/dist/commands/roadmap/index.js +61 -0
  65. package/dist/commands/roadmap/list.d.ts +12 -0
  66. package/dist/commands/roadmap/list.js +42 -0
  67. package/dist/commands/roadmap/remove-project.d.ts +18 -0
  68. package/dist/commands/roadmap/remove-project.js +147 -0
  69. package/dist/commands/roadmap/reorder.d.ts +17 -0
  70. package/dist/commands/roadmap/reorder.js +157 -0
  71. package/dist/commands/roadmap/update.d.ts +19 -0
  72. package/dist/commands/roadmap/update.js +136 -0
  73. package/dist/commands/roadmap/view.d.ts +16 -0
  74. package/dist/commands/roadmap/view.js +103 -0
  75. package/dist/commands/spec/index.js +1 -1
  76. package/dist/commands/spec/link/index.js +24 -13
  77. package/dist/commands/spec/link/remove.js +2 -0
  78. package/dist/commands/status/index.js +1 -1
  79. package/dist/commands/status/list.js +0 -8
  80. package/dist/commands/template/delete.js +2 -0
  81. package/dist/commands/terminal/title.d.ts +12 -0
  82. package/dist/commands/terminal/title.js +48 -0
  83. package/dist/commands/ticket/complete.js +2 -0
  84. package/dist/commands/ticket/create.js +4 -2
  85. package/dist/commands/ticket/delete.js +2 -0
  86. package/dist/commands/ticket/edit.js +8 -2
  87. package/dist/commands/ticket/link/index.js +17 -3
  88. package/dist/commands/ticket/link/remove.js +2 -0
  89. package/dist/commands/ticket/list.js +1 -2
  90. package/dist/commands/ticket/move.js +2 -0
  91. package/dist/commands/ticket/project.js +3 -1
  92. package/dist/commands/ticket/reassign.js +2 -0
  93. package/dist/commands/ticket/spec.js +4 -2
  94. package/dist/commands/ticket/template/apply.js +4 -3
  95. package/dist/commands/ticket/template/create.js +2 -0
  96. package/dist/commands/ticket/template/index.js +1 -1
  97. package/dist/commands/ticket/update.js +2 -0
  98. package/dist/commands/work/index.js +1 -1
  99. package/dist/commands/work/revise.js +7 -1
  100. package/dist/commands/work/spawn.d.ts +2 -1
  101. package/dist/commands/work/spawn.js +131 -36
  102. package/dist/commands/work/start.d.ts +2 -1
  103. package/dist/commands/work/start.js +349 -69
  104. package/dist/commands/work/watch.js +10 -2
  105. package/dist/commands/workflow/create.js +3 -3
  106. package/dist/commands/workflow/switch.js +2 -1
  107. package/dist/commands/workspace/remove.js +0 -8
  108. package/dist/commands/workspace/use.js +1 -9
  109. package/dist/lib/agents/commands.js +18 -13
  110. package/dist/lib/database/index.d.ts +19 -12
  111. package/dist/lib/database/index.js +158 -42
  112. package/dist/lib/docker/resolve.js +1 -1
  113. package/dist/lib/execution/config.d.ts +6 -0
  114. package/dist/lib/execution/config.js +15 -2
  115. package/dist/lib/execution/devcontainer.d.ts +2 -0
  116. package/dist/lib/execution/devcontainer.js +41 -9
  117. package/dist/lib/execution/runners.d.ts +85 -3
  118. package/dist/lib/execution/runners.js +925 -228
  119. package/dist/lib/execution/spawner.d.ts +2 -2
  120. package/dist/lib/execution/spawner.js +4 -3
  121. package/dist/lib/execution/storage.d.ts +2 -1
  122. package/dist/lib/execution/storage.js +9 -13
  123. package/dist/lib/execution/types.d.ts +10 -1
  124. package/dist/lib/execution/types.js +3 -1
  125. package/dist/lib/init/index.js +1 -0
  126. package/dist/lib/machine-config.js +1 -1
  127. package/dist/lib/pmo/base-command.js +5 -9
  128. package/dist/lib/pmo/index.js +2 -0
  129. package/dist/lib/pmo/schema.d.ts +6 -0
  130. package/dist/lib/pmo/schema.js +36 -0
  131. package/dist/lib/pmo/storage/base.js +3 -3
  132. package/dist/lib/pmo/storage/index.d.ts +16 -1
  133. package/dist/lib/pmo/storage/index.js +45 -0
  134. package/dist/lib/pmo/storage/roadmaps.d.ts +62 -0
  135. package/dist/lib/pmo/storage/roadmaps.js +301 -0
  136. package/dist/lib/pmo/storage/specs.js +2 -0
  137. package/dist/lib/pmo/storage/types.d.ts +14 -0
  138. package/dist/lib/pmo/sync-manager.d.ts +1 -1
  139. package/dist/lib/pmo/sync-manager.js +1 -1
  140. package/dist/lib/pmo/types.d.ts +41 -0
  141. package/dist/lib/pmo/utils.d.ts +2 -0
  142. package/dist/lib/pmo/utils.js +22 -1
  143. package/dist/lib/repos/index.js +7 -1
  144. package/dist/lib/terminal.d.ts +31 -0
  145. package/dist/lib/terminal.js +48 -0
  146. package/dist/lib/themes.d.ts +21 -3
  147. package/dist/lib/themes.js +80 -23
  148. package/dist/lib/workspace-config.d.ts +80 -0
  149. package/dist/lib/workspace-config.js +100 -0
  150. package/oclif.manifest.json +4065 -3225
  151. package/package.json +10 -6
  152. package/LICENSE +0 -21
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
9
  import * as os from 'node:os';
10
10
  import { DEFAULT_EXECUTION_CONFIG, } from './types.js';
11
+ import { getSetTitleCommands } from '../terminal.js';
11
12
  // =============================================================================
12
13
  // Terminal Title Helpers
13
14
  // =============================================================================
@@ -30,21 +31,116 @@ function buildTmuxWindowName(context) {
30
31
  return buildSessionName(context);
31
32
  }
32
33
  /**
33
- * Generate shell commands to set the terminal tab/window title.
34
- * Uses ANSI escape sequences that work across most terminal emulators.
34
+ * Check if tmux control mode (-CC) should be used.
35
+ * Control mode is only used with iTerm when controlMode is enabled in config.
35
36
  *
36
- * \033]0;Title\007 - Sets both window and tab title (most compatible)
37
- * \033]1;Title\007 - Sets tab title only (iTerm2, some others)
38
- * \033]2;Title\007 - Sets window title only
37
+ * When control mode is active:
38
+ * - iTerm handles scrolling, selection, and gestures natively
39
+ * - tmux mouse mode should be disabled to avoid conflicts
39
40
  */
40
- function getSetTitleCommands(title) {
41
- // Escape any special characters in the title
42
- const safeTitle = title.replace(/[\\'"]/g, '');
43
- return `
44
- # Set terminal tab/window title
45
- echo -ne "\\033]0;${safeTitle}\\007"
46
- echo -ne "\\033]1;${safeTitle}\\007"
47
- `;
41
+ export function shouldUseControlMode(terminalApp, controlModeEnabled) {
42
+ return terminalApp === 'iTerm' && controlModeEnabled;
43
+ }
44
+ /**
45
+ * Build the tmux mouse option string for session creation.
46
+ * Enables mouse mode for scroll support in tmux.
47
+ * To select text or switch tabs, hold Shift or Option to bypass tmux.
48
+ */
49
+ export function buildTmuxMouseOption(_useControlMode) {
50
+ return ' \\; set-option -g mouse on';
51
+ }
52
+ /**
53
+ * Build the tmux attach command based on control mode.
54
+ * Uses -u -CC flags for iTerm control mode (native scrolling/selection).
55
+ * -u forces UTF-8 mode which is required for proper iTerm integration.
56
+ * Uses regular attach otherwise.
57
+ */
58
+ export function buildTmuxAttachCommand(useControlMode, includeUnicodeFlag = false) {
59
+ const unicodeFlag = includeUnicodeFlag ? '-u ' : '';
60
+ if (useControlMode) {
61
+ // Always use -u with -CC for proper iTerm integration
62
+ return `tmux -u -CC attach`;
63
+ }
64
+ return `tmux ${unicodeFlag}attach`;
65
+ }
66
+ /**
67
+ * Configure iTerm tmux preferences for control mode.
68
+ * - windowMode: whether tmux -CC opens windows as tabs or new windows
69
+ * - autoHide: automatically bury/hide the control session (the terminal where -CC was run)
70
+ * @param mode - 'tab' for tabs in current window, 'window' for new windows
71
+ */
72
+ export function configureITermTmuxPreferences(mode) {
73
+ try {
74
+ // OpenTmuxWindowsIn: 0=native windows, 1=new window, 2=tabs in existing window
75
+ const windowModeValue = mode === 'tab' ? 2 : 1;
76
+ execSync(`defaults write com.googlecode.iterm2 OpenTmuxWindowsIn -int ${windowModeValue}`, { stdio: 'pipe' });
77
+ // AutoHideTmuxClientSession: hide the control channel terminal so it doesn't clutter
78
+ execSync(`defaults write com.googlecode.iterm2 AutoHideTmuxClientSession -bool true`, { stdio: 'pipe' });
79
+ }
80
+ catch {
81
+ // Non-fatal - preference setting failed but execution can continue
82
+ }
83
+ }
84
+ // Legacy alias for backwards compatibility
85
+ export function configureITermTmuxWindowMode(mode) {
86
+ configureITermTmuxPreferences(mode);
87
+ }
88
+ // =============================================================================
89
+ // Docker Credential Helpers
90
+ // =============================================================================
91
+ const CLAUDE_CREDENTIALS_VOLUME = 'claude-credentials';
92
+ /**
93
+ * Check if the claude-credentials Docker volume exists.
94
+ */
95
+ export function credentialsVolumeExists() {
96
+ try {
97
+ execSync(`docker volume inspect ${CLAUDE_CREDENTIALS_VOLUME}`, { stdio: 'pipe' });
98
+ return true;
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
104
+ /**
105
+ * Check if valid Claude credentials exist in the Docker volume.
106
+ * Returns true if credentials exist and are not expired.
107
+ */
108
+ export function dockerCredentialsExist() {
109
+ try {
110
+ const result = execSync(`docker run --rm -v ${CLAUDE_CREDENTIALS_VOLUME}:/data alpine cat /data/.credentials.json 2>/dev/null`, { stdio: 'pipe', encoding: 'utf-8' });
111
+ const creds = JSON.parse(result);
112
+ if (creds.claudeAiOauth?.accessToken && creds.claudeAiOauth?.expiresAt) {
113
+ // Check if expired
114
+ const expiresAt = creds.claudeAiOauth.expiresAt;
115
+ if (expiresAt > Date.now()) {
116
+ return true;
117
+ }
118
+ }
119
+ return false;
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ }
125
+ /**
126
+ * Get Docker credential info for display.
127
+ * Returns expiration date and subscription type if available.
128
+ */
129
+ export function getDockerCredentialInfo() {
130
+ try {
131
+ const result = execSync(`docker run --rm -v ${CLAUDE_CREDENTIALS_VOLUME}:/data alpine cat /data/.credentials.json 2>/dev/null`, { stdio: 'pipe', encoding: 'utf-8' });
132
+ const creds = JSON.parse(result);
133
+ if (creds.claudeAiOauth?.expiresAt) {
134
+ return {
135
+ expiresAt: new Date(creds.claudeAiOauth.expiresAt),
136
+ subscriptionType: creds.claudeAiOauth.subscriptionType,
137
+ };
138
+ }
139
+ return null;
140
+ }
141
+ catch {
142
+ return null;
143
+ }
48
144
  }
49
145
  // =============================================================================
50
146
  // Executor Commands
@@ -55,7 +151,9 @@ function getExecutorCommand(executor, prompt, skipPermissions = true) {
55
151
  if (skipPermissions) {
56
152
  // Skip permissions - agent runs autonomously without prompting
57
153
  // Note: NO -p flag - we want interactive mode for streaming output in terminal
58
- return { cmd: 'claude', args: ['--dangerously-skip-permissions', prompt] };
154
+ // --permission-mode bypassPermissions: skips the "trust this folder" dialog
155
+ // --dangerously-skip-permissions: skips tool permission checks
156
+ return { cmd: 'claude', args: ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', prompt] };
59
157
  }
60
158
  // Manual mode - will prompt for each action (still interactive, no -p)
61
159
  return { cmd: 'claude', args: [prompt] };
@@ -69,7 +167,7 @@ function getExecutorCommand(executor, prompt, skipPermissions = true) {
69
167
  default:
70
168
  if (skipPermissions) {
71
169
  // Note: NO -p flag - we want interactive mode for streaming output
72
- return { cmd: 'claude', args: ['--dangerously-skip-permissions', prompt] };
170
+ return { cmd: 'claude', args: ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', prompt] };
73
171
  }
74
172
  return { cmd: 'claude', args: [prompt] };
75
173
  }
@@ -225,9 +323,14 @@ exec $SHELL
225
323
  // Check if tmux is available
226
324
  execSync('which tmux', { stdio: 'pipe' });
227
325
  const terminalApp = config.terminal.app;
326
+ // Check if we should use iTerm control mode (-CC)
327
+ // When using -CC, iTerm handles scrolling/selection natively, so we DON'T set mouse on
328
+ // Without -CC, we need mouse on for tmux to handle scrolling
329
+ const useControlMode = shouldUseControlMode(terminalApp, config.tmux.controlMode);
228
330
  // Step 1: Create host tmux session (detached)
229
- // Enable mouse mode for native scrolling
230
- const tmuxCmd = `tmux new-session -d -s "${sessionName}" -n "${sessionName}" "${scriptPath}" \\; set-option -g mouse on \\; set-option -g set-titles on \\; set-option -g set-titles-string "#{window_name}"`;
331
+ // Only enable mouse mode if NOT using control mode (control mode lets iTerm handle mouse natively)
332
+ const mouseOption = buildTmuxMouseOption(useControlMode);
333
+ const tmuxCmd = `tmux new-session -d -s "${sessionName}" -n "${sessionName}" "${scriptPath}"${mouseOption} \\; set-option -g set-titles on \\; set-option -g set-titles-string "#{window_name}"`;
231
334
  try {
232
335
  execSync(tmuxCmd, { stdio: 'pipe' });
233
336
  }
@@ -237,45 +340,143 @@ exec $SHELL
237
340
  error: `Failed to create tmux session: ${error instanceof Error ? error.message : error}`,
238
341
  };
239
342
  }
240
- // Step 2: Open terminal tab attached to tmux session (unless background mode)
343
+ // Step 2: Open terminal tab attached to tmux session (unless background or foreground mode)
241
344
  if (displayMode === 'background') {
242
345
  return {
243
346
  success: true,
244
347
  sessionId: sessionName,
245
348
  };
246
349
  }
247
- // NOTE: Don't use tmux -CC here. While -CC gives native iTerm scrolling,
248
- // it also causes iTerm to create new windows for tmux sessions.
249
- // Regular tmux attach inside an iTerm tab works well with mouse mode enabled.
250
- // User can reattach with `prlt session attach` which offers -CC option.
251
- // Use clear before attach to ensure clean display
252
- const attachCmd = `clear && tmux attach -t \\"${sessionName}\\"`;
253
- switch (terminalApp) {
254
- case 'iTerm':
255
- // iTerm2 - new tab in current window
256
- // Write the tmux attach command directly (no script file needed)
350
+ // Foreground mode: attach to tmux session in current terminal (blocking)
351
+ if (displayMode === 'foreground') {
352
+ try {
353
+ // Clear screen and attach - this blocks until user detaches or claude exits
354
+ // Use -CC for iTerm when control mode is enabled
355
+ const fgTmuxAttach = buildTmuxAttachCommand(useControlMode);
356
+ execSync(`clear && ${fgTmuxAttach} -t "${sessionName}"`, { stdio: 'inherit' });
357
+ return {
358
+ success: true,
359
+ sessionId: sessionName,
360
+ };
361
+ }
362
+ catch (error) {
363
+ return {
364
+ success: false,
365
+ error: `Failed to attach to tmux session: ${error instanceof Error ? error.message : error}`,
366
+ };
367
+ }
368
+ }
369
+ // Use tmux -CC (control mode) for iTerm when enabled in config
370
+ // -CC gives native iTerm scrolling, selection, and gesture support
371
+ // Without -CC, use regular attach (relies on mouse mode for scrolling)
372
+ const tmuxAttach = buildTmuxAttachCommand(useControlMode);
373
+ const attachCmd = `clear && ${tmuxAttach} -t \\"${sessionName}\\"`;
374
+ // For iTerm with control mode, create a new tab and run -CC attach there
375
+ // This avoids interfering with the terminal where prlt is running
376
+ if (terminalApp === 'iTerm' && useControlMode) {
377
+ // Configure iTerm to open tmux windows as tabs or windows based on user preference
378
+ configureITermTmuxWindowMode(config.tmux.windowMode);
379
+ const openInBackground = config.terminal.openInBackground ?? true;
380
+ if (openInBackground) {
381
+ // Open tab without stealing focus - save frontmost app and restore after
382
+ execSync(`osascript -e '
383
+ set frontApp to path to frontmost application as text
384
+ tell application "iTerm"
385
+ tell current window
386
+ set newTab to (create tab with default profile)
387
+ tell current session of newTab
388
+ write text "tmux -u -CC attach -t \\"${sessionName}\\""
389
+ end tell
390
+ end tell
391
+ end tell
392
+ tell application frontApp to activate
393
+ '`);
394
+ }
395
+ else {
257
396
  execSync(`osascript -e '
258
397
  tell application "iTerm"
259
398
  activate
260
- if (count of windows) = 0 then
261
- create window with default profile
262
- delay 0.3
263
- tell current session of current window
264
- set name to "${windowTitle}"
265
- write text "${attachCmd}"
399
+ tell current window
400
+ set newTab to (create tab with default profile)
401
+ tell current session of newTab
402
+ write text "tmux -u -CC attach -t \\"${sessionName}\\""
266
403
  end tell
267
- else
268
- tell current window
269
- set newTab to (create tab with default profile)
404
+ end tell
405
+ end tell
406
+ '`);
407
+ }
408
+ return {
409
+ success: true,
410
+ sessionId: sessionName,
411
+ };
412
+ }
413
+ // Check if we should open in background (don't steal focus)
414
+ const openInBackground = config.terminal.openInBackground ?? true;
415
+ switch (terminalApp) {
416
+ case 'iTerm':
417
+ // Without control mode, create a new tab and attach normally
418
+ // When openInBackground is true, save frontmost app and restore after
419
+ if (openInBackground) {
420
+ execSync(`osascript -e '
421
+ -- Save the currently active application and window
422
+ tell application "System Events"
423
+ set frontApp to name of first application process whose frontmost is true
424
+ set frontAppBundle to bundle identifier of first application process whose frontmost is true
425
+ end tell
426
+
427
+ tell application "iTerm"
428
+ if (count of windows) = 0 then
429
+ create window with default profile
270
430
  delay 0.3
271
- tell current session of newTab
431
+ tell current session of current window
272
432
  set name to "${windowTitle}"
273
433
  write text "${attachCmd}"
274
434
  end tell
275
- end tell
276
- end if
277
- end tell
278
- '`);
435
+ else
436
+ tell current window
437
+ set newTab to (create tab with default profile)
438
+ delay 0.3
439
+ tell current session of newTab
440
+ set name to "${windowTitle}"
441
+ write text "${attachCmd}"
442
+ end tell
443
+ end tell
444
+ end if
445
+ end tell
446
+
447
+ -- Restore focus to the original application
448
+ delay 0.2
449
+ tell application "System Events"
450
+ set frontmost of process frontApp to true
451
+ end tell
452
+ delay 0.1
453
+ do shell script "open -b " & quoted form of frontAppBundle
454
+ '`);
455
+ }
456
+ else {
457
+ execSync(`osascript -e '
458
+ tell application "iTerm"
459
+ activate
460
+ if (count of windows) = 0 then
461
+ create window with default profile
462
+ delay 0.3
463
+ tell current session of current window
464
+ set name to "${windowTitle}"
465
+ write text "${attachCmd}"
466
+ end tell
467
+ else
468
+ tell current window
469
+ set newTab to (create tab with default profile)
470
+ delay 0.3
471
+ tell current session of newTab
472
+ set name to "${windowTitle}"
473
+ write text "${attachCmd}"
474
+ end tell
475
+ end tell
476
+ end if
477
+ end tell
478
+ '`);
479
+ }
279
480
  break;
280
481
  case 'Ghostty':
281
482
  // Ghostty - use osascript to open new tab and run command
@@ -320,18 +521,32 @@ exec $SHELL
320
521
  case 'Terminal':
321
522
  default:
322
523
  // macOS Terminal.app - new tab
323
- execSync(`osascript -e '
324
- tell application "Terminal"
325
- activate
326
- tell application "System Events"
327
- tell process "Terminal"
328
- keystroke "t" using command down
524
+ // Note: Terminal.app with System Events keystrokes requires activation for Cmd+T
525
+ // But we can use 'do script' which opens a new window without activation if needed
526
+ if (openInBackground) {
527
+ // Open in background: use 'do script' which creates a new window without activating
528
+ execSync(`osascript -e '
529
+ tell application "Terminal"
530
+ do script "${attachCmd}"
531
+ set custom title of front window to "${windowTitle}"
532
+ end tell
533
+ '`);
534
+ }
535
+ else {
536
+ // Bring to front: use traditional Cmd+T for new tab
537
+ execSync(`osascript -e '
538
+ tell application "Terminal"
539
+ activate
540
+ tell application "System Events"
541
+ tell process "Terminal"
542
+ keystroke "t" using command down
543
+ end tell
329
544
  end tell
545
+ delay 0.3
546
+ do script "${attachCmd}" in front window
330
547
  end tell
331
- delay 0.3
332
- do script "${attachCmd}" in front window
333
- end tell
334
- '`);
548
+ '`);
549
+ }
335
550
  break;
336
551
  }
337
552
  return {
@@ -347,6 +562,41 @@ exec $SHELL
347
562
  }
348
563
  }
349
564
  // =============================================================================
565
+ // GitHub Token Check
566
+ // =============================================================================
567
+ /**
568
+ * Check if GitHub token is available for git push operations.
569
+ * Checks environment variables first, then tries gh auth token.
570
+ * Returns the token if available, null otherwise.
571
+ */
572
+ export function getGitHubToken() {
573
+ // Check environment variables first
574
+ if (process.env.GITHUB_TOKEN) {
575
+ return process.env.GITHUB_TOKEN;
576
+ }
577
+ if (process.env.GH_TOKEN) {
578
+ return process.env.GH_TOKEN;
579
+ }
580
+ // Try to get token from gh CLI
581
+ try {
582
+ const token = execSync('gh auth token', { encoding: 'utf-8', stdio: 'pipe' }).trim();
583
+ if (token) {
584
+ return token;
585
+ }
586
+ }
587
+ catch {
588
+ // gh auth token failed - user not logged in
589
+ }
590
+ return null;
591
+ }
592
+ /**
593
+ * Check if GitHub token is available.
594
+ * Returns true if token is available via env vars or gh CLI.
595
+ */
596
+ export function isGitHubTokenAvailable() {
597
+ return getGitHubToken() !== null;
598
+ }
599
+ // =============================================================================
350
600
  // Docker Status Check
351
601
  // =============================================================================
352
602
  /**
@@ -372,8 +622,314 @@ export function isDockerRunning() {
372
622
  }
373
623
  return false;
374
624
  }
625
+ /**
626
+ * Check if the devcontainer CLI is installed.
627
+ * Returns true if the CLI is available, false otherwise.
628
+ * @deprecated No longer required - we use raw Docker commands now
629
+ */
630
+ export function isDevcontainerCliInstalled() {
631
+ // Always return true since we no longer require devcontainer CLI
632
+ // We use raw Docker commands instead
633
+ return true;
634
+ }
635
+ // =============================================================================
636
+ // Docker Container Management (Raw Docker, no devcontainer CLI)
637
+ // =============================================================================
638
+ /**
639
+ * Get the container name for an agent.
640
+ * Format: prlt-agent-{agentName}
641
+ */
642
+ export function getAgentContainerName(agentName) {
643
+ // Sanitize agent name for Docker container naming (alphanumeric, dash, underscore only)
644
+ const sanitized = agentName.replace(/[^a-zA-Z0-9_-]/g, '-');
645
+ return `prlt-agent-${sanitized}`;
646
+ }
647
+ // Alias for internal use
648
+ const getContainerName = getAgentContainerName;
649
+ /**
650
+ * Get the image name for an agent.
651
+ * Format: prlt-agent-{agentName}:latest
652
+ */
653
+ function getImageName(agentName) {
654
+ const sanitized = agentName.replace(/[^a-zA-Z0-9_-]/g, '-');
655
+ return `prlt-agent-${sanitized}:latest`;
656
+ }
657
+ /**
658
+ * Check if a Docker container exists (running or stopped).
659
+ */
660
+ export function containerExists(containerName) {
661
+ try {
662
+ execSync(`docker container inspect ${containerName}`, { stdio: 'pipe' });
663
+ return true;
664
+ }
665
+ catch {
666
+ return false;
667
+ }
668
+ }
669
+ /**
670
+ * Check if a Docker container is running.
671
+ */
672
+ export function isContainerRunning(containerName) {
673
+ try {
674
+ const status = execSync(`docker container inspect -f '{{.State.Running}}' ${containerName}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
675
+ return status === 'true';
676
+ }
677
+ catch {
678
+ return false;
679
+ }
680
+ }
681
+ /**
682
+ * Get the container ID for a running container.
683
+ */
684
+ export function getContainerId(containerName) {
685
+ try {
686
+ const containerId = execSync(`docker container inspect -f '{{.Id}}' ${containerName}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
687
+ return containerId ? containerId.substring(0, 12) : null;
688
+ }
689
+ catch {
690
+ return null;
691
+ }
692
+ }
693
+ /**
694
+ * Build Docker image for an agent from its Dockerfile.
695
+ */
696
+ function buildDockerImage(agentDir, imageName, buildArgs = {}) {
697
+ const dockerfilePath = path.join(agentDir, '.devcontainer', 'Dockerfile');
698
+ if (!fs.existsSync(dockerfilePath)) {
699
+ console.debug(`[runners:docker] Dockerfile not found at ${dockerfilePath}`);
700
+ return false;
701
+ }
702
+ try {
703
+ // Build --build-arg flags
704
+ const buildArgFlags = Object.entries(buildArgs)
705
+ .map(([key, value]) => `--build-arg ${key}="${value}"`)
706
+ .join(' ');
707
+ const buildCmd = `docker build -t ${imageName} -f "${dockerfilePath}" ${buildArgFlags} "${path.join(agentDir, '.devcontainer')}"`;
708
+ console.debug(`[runners:docker] Building image: ${buildCmd}`);
709
+ execSync(buildCmd, { stdio: 'pipe' });
710
+ return true;
711
+ }
712
+ catch (error) {
713
+ console.debug(`[runners:docker] Failed to build image:`, error);
714
+ return false;
715
+ }
716
+ }
717
+ /**
718
+ * Check if a Docker image exists.
719
+ */
720
+ function imageExists(imageName) {
721
+ try {
722
+ execSync(`docker image inspect ${imageName}`, { stdio: 'pipe' });
723
+ return true;
724
+ }
725
+ catch {
726
+ return false;
727
+ }
728
+ }
729
+ /**
730
+ * Create and start a Docker container for an agent.
731
+ * Uses raw Docker commands instead of devcontainer CLI.
732
+ */
733
+ function createDockerContainer(context, containerName, imageName, config) {
734
+ // Build mount flags
735
+ // KEY: Use a named Docker volume for Claude credentials - this is how devcontainer.json
736
+ // was handling it. The volume persists across containers, so login once = logged in everywhere.
737
+ // This avoids corruption from concurrent writes to host filesystem.
738
+ const mounts = [
739
+ // Agent workspace
740
+ `-v "${context.agentDir}:/workspace"`,
741
+ // HQ .proletariat directory (for database access)
742
+ ...(context.hqPath ? [`-v "${context.hqPath}/.proletariat:/hq/.proletariat"`] : []),
743
+ // PMO path
744
+ ...(context.pmoPath ? [`-v "${context.pmoPath}:/hq/pmo"`] : []),
745
+ // Claude credentials - shared named volume (login once, all containers share)
746
+ `-v "claude-credentials:/home/node/.claude"`,
747
+ ];
748
+ // Build environment flags
749
+ const envVars = [
750
+ `-e DEVCONTAINER=true`,
751
+ `-e PRLT_HQ_PATH=/hq`,
752
+ `-e PRLT_AGENT_NAME="${context.agentName}"`,
753
+ `-e PRLT_HOST_PATH="${context.agentDir}"`,
754
+ ...(process.env.ANTHROPIC_API_KEY ? [`-e ANTHROPIC_API_KEY="${process.env.ANTHROPIC_API_KEY}"`] : []),
755
+ ...(process.env.GITHUB_TOKEN ? [`-e GITHUB_TOKEN="${process.env.GITHUB_TOKEN}"`] : []),
756
+ ...(process.env.GH_TOKEN ? [`-e GH_TOKEN="${process.env.GH_TOKEN}"`] : []),
757
+ // NOTE: Do NOT pass CLAUDE_CODE_OAUTH_TOKEN - it overrides credentials file
758
+ // and setup-token generates invalid tokens. Use "prlt agent auth" instead.
759
+ ];
760
+ // Resource limits
761
+ const resourceFlags = [
762
+ `--memory=${config.devcontainer.memory}`,
763
+ `--cpus=${config.devcontainer.cpus}`,
764
+ ];
765
+ // Security flags - these provide the sandboxing
766
+ const securityFlags = [
767
+ '--cap-add=NET_ADMIN', // For firewall setup
768
+ '--cap-add=NET_RAW', // For firewall setup
769
+ // Note: After firewall is set up, the container is network-restricted
770
+ ];
771
+ try {
772
+ const createCmd = [
773
+ 'docker run -d',
774
+ `--name ${containerName}`,
775
+ '--user node',
776
+ '-w /workspace',
777
+ ...mounts,
778
+ ...envVars,
779
+ ...resourceFlags,
780
+ ...securityFlags,
781
+ imageName,
782
+ 'sleep infinity', // Keep container running
783
+ ].join(' ');
784
+ console.debug(`[runners:docker] Creating container: ${createCmd}`);
785
+ execSync(createCmd, { stdio: 'pipe' });
786
+ return true;
787
+ }
788
+ catch (error) {
789
+ console.debug(`[runners:docker] Failed to create container:`, error);
790
+ return false;
791
+ }
792
+ }
793
+ /**
794
+ * Run the post-start setup commands in a container.
795
+ * This includes firewall initialization, prlt setup, and Claude settings.
796
+ * @param containerId - Docker container ID
797
+ * @param sandboxed - Whether running in safe mode (true) or danger mode (false)
798
+ */
799
+ function runContainerSetup(containerId, sandboxed = true) {
800
+ try {
801
+ // Run firewall init (requires sudo since we're running as node user)
802
+ execSync(`docker exec ${containerId} sudo /usr/local/bin/init-firewall.sh`, { stdio: 'pipe' });
803
+ // Run prlt setup
804
+ execSync(`docker exec ${containerId} /usr/local/bin/setup-prlt.sh`, { stdio: 'pipe' });
805
+ }
806
+ catch (error) {
807
+ console.debug(`[runners:docker] Container setup scripts failed:`, error);
808
+ // Continue - setup might partially work
809
+ }
810
+ // Copy Claude settings file (.claude.json) from host to container
811
+ // This is needed for Claude Code to recognize settings and bypass prompts
812
+ // Note: Auth tokens are in the claude-credentials volume at /home/node/.claude/.credentials.json
813
+ // But settings (.claude.json) need to be at /home/node/.claude.json (outside the .claude dir)
814
+ try {
815
+ const hostClaudeJson = path.join(os.homedir(), '.claude.json');
816
+ let settings = {};
817
+ if (fs.existsSync(hostClaudeJson)) {
818
+ // Read host file content as base
819
+ const content = fs.readFileSync(hostClaudeJson, 'utf-8');
820
+ try {
821
+ settings = JSON.parse(content);
822
+ }
823
+ catch {
824
+ console.debug('[runners:docker] Failed to parse host .claude.json, using empty settings');
825
+ }
826
+ }
827
+ // Only set bypassPermissionsModeAccepted when user chose danger mode (!sandboxed)
828
+ // This doesn't modify the host file - only the container copy
829
+ if (!sandboxed) {
830
+ settings.bypassPermissionsModeAccepted = true;
831
+ }
832
+ // Skip first-run onboarding (theme picker, tips, etc.) for automated agents
833
+ // These flags indicate Claude Code has been run before
834
+ settings.numStartups = settings.numStartups || 1;
835
+ settings.hasCompletedOnboarding = true;
836
+ settings.theme = settings.theme || 'dark';
837
+ // Ensure tipsHistory exists to prevent tip prompts
838
+ if (!settings.tipsHistory || typeof settings.tipsHistory !== 'object') {
839
+ settings.tipsHistory = {};
840
+ }
841
+ const tips = settings.tipsHistory;
842
+ tips['new-user-warmup'] = tips['new-user-warmup'] || 1;
843
+ // Base64 encode to avoid shell escaping issues
844
+ const base64Content = Buffer.from(JSON.stringify(settings)).toString('base64');
845
+ // Write to container at /home/node/.claude.json
846
+ execSync(`docker exec ${containerId} bash -c 'echo "${base64Content}" | base64 -d > /home/node/.claude.json'`, { stdio: 'pipe' });
847
+ console.debug(`[runners:docker] Copied .claude.json settings to container (bypassPermissionsModeAccepted=${!sandboxed})`);
848
+ }
849
+ catch (error) {
850
+ console.debug('[runners:docker] Failed to copy .claude.json to container:', error);
851
+ // Non-fatal - Claude will just prompt for settings
852
+ }
853
+ // NOTE: Auth credentials come from the claude-credentials volume.
854
+ // Run "prlt agent auth" to set up authentication (one-time).
855
+ // Do NOT sync CLAUDE_CODE_OAUTH_TOKEN env var - it causes issues
856
+ // (setup-token generates invalid tokens, and env var overrides valid credentials file).
857
+ return true;
858
+ }
859
+ /**
860
+ * Ensure a Docker container is running for the agent.
861
+ * Builds image and creates container if needed.
862
+ * Returns the container ID if successful, null otherwise.
863
+ */
864
+ function ensureDockerContainer(context, config) {
865
+ const containerName = getContainerName(context.agentName);
866
+ const imageName = getImageName(context.agentName);
867
+ // Always create fresh container to ensure mounts are up-to-date
868
+ // TODO: Revisit container reuse strategy - for now, fresh containers ensure
869
+ // correct volume mounts (especially claude-credentials) are applied
870
+ if (containerExists(containerName)) {
871
+ console.debug(`[runners:docker] Removing existing container ${containerName} to create fresh one`);
872
+ try {
873
+ execSync(`docker rm -f ${containerName}`, { stdio: 'pipe' });
874
+ }
875
+ catch {
876
+ // Ignore removal errors
877
+ }
878
+ }
879
+ // Build image if it doesn't exist
880
+ if (!imageExists(imageName)) {
881
+ console.debug(`[runners:docker] Building image ${imageName}`);
882
+ const buildArgs = {
883
+ TZ: 'America/Los_Angeles',
884
+ PRLT_REGISTRY: 'npm',
885
+ PRLT_VERSION: 'latest',
886
+ };
887
+ if (!buildDockerImage(context.agentDir, imageName, buildArgs)) {
888
+ return null;
889
+ }
890
+ }
891
+ // Create and start container
892
+ console.debug(`[runners:docker] Creating container ${containerName}`);
893
+ if (!createDockerContainer(context, containerName, imageName, config)) {
894
+ return null;
895
+ }
896
+ const containerId = getContainerId(containerName);
897
+ if (!containerId) {
898
+ return null;
899
+ }
900
+ // Run post-start setup (firewall, prlt, Claude settings)
901
+ // Pass sandboxed config to determine whether to set bypassPermissionsModeAccepted
902
+ console.debug(`[runners:docker] Running container setup (sandboxed=${config.sandboxed})`);
903
+ if (!runContainerSetup(containerId, config.sandboxed)) {
904
+ console.debug(`[runners:docker] Setup failed, but continuing...`);
905
+ // Don't fail completely - setup might partially work
906
+ }
907
+ // NOTE: Claude credentials are copied to workspace before container creation
908
+ // (see copyClaudeCredentials call in runDevcontainer)
909
+ return containerId;
910
+ }
911
+ /**
912
+ * Copy Claude Code credentials (~/.claude.json) into the agent directory.
913
+ * This makes the subscription credentials available inside the devcontainer
914
+ * since the agent directory is mounted at /workspace.
915
+ *
916
+ * This was the original working approach before the raw Docker refactor.
917
+ */
918
+ function copyClaudeCredentials(agentDir) {
919
+ const sourceFile = path.join(os.homedir(), '.claude.json');
920
+ const destFile = path.join(agentDir, '.claude.json');
921
+ if (fs.existsSync(sourceFile)) {
922
+ try {
923
+ fs.copyFileSync(sourceFile, destFile);
924
+ console.debug('[runners:credentials] Copied .claude.json to workspace');
925
+ }
926
+ catch (err) {
927
+ console.debug('[runners:credentials] Failed to copy .claude.json:', err);
928
+ }
929
+ }
930
+ }
375
931
  // =============================================================================
376
- // Devcontainer Runner
932
+ // Devcontainer Runner (now uses raw Docker)
377
933
  // =============================================================================
378
934
  /**
379
935
  * Clean up old prompt files from the worktree.
@@ -425,31 +981,9 @@ function writePromptFile(context) {
425
981
  }
426
982
  /**
427
983
  * Build the command to run Claude inside the container.
428
- * Uses devcontainer exec which handles user context and working directory automatically.
984
+ * Uses docker exec for direct container access.
429
985
  * Uses a prompt file to avoid shell escaping issues.
430
986
  */
431
- /**
432
- * Get the container ID for a devcontainer workspace.
433
- */
434
- function getDevcontainerContainerId(agentDir) {
435
- try {
436
- // devcontainer up outputs JSON with container ID
437
- const result = execSync(`devcontainer up --workspace-folder "${agentDir}" 2>/dev/null | tail -1`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
438
- const json = JSON.parse(result.trim());
439
- return json.containerId || null;
440
- }
441
- catch (err) {
442
- console.debug('[runners:devcontainer] devcontainer up failed, trying docker ps fallback:', err);
443
- try {
444
- const containerId = execSync(`docker ps -q --filter "label=devcontainer.local_folder=${agentDir}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
445
- return containerId || null;
446
- }
447
- catch (fallbackErr) {
448
- console.debug('[runners:devcontainer] docker ps fallback also failed:', fallbackErr);
449
- return null;
450
- }
451
- }
452
- }
453
987
  function buildDevcontainerCommand(context, executor, promptFile, containerId, outputMode = 'interactive', sandboxed = true, displayMode = 'terminal') {
454
988
  // Get base command (just 'claude' for claude-code)
455
989
  let baseCmd;
@@ -475,69 +1009,39 @@ function buildDevcontainerCommand(context, executor, promptFile, containerId, ou
475
1009
  const printFlag = outputMode === 'print' ? '-p ' : '';
476
1010
  // sandboxed=true means safe mode (no --dangerously-skip-permissions)
477
1011
  // sandboxed=false means danger mode (use --dangerously-skip-permissions)
1012
+ // --permission-mode bypassPermissions: skips the "trust this folder" dialog
1013
+ const bypassTrustFlag = '--permission-mode bypassPermissions ';
478
1014
  const permissionsFlag = !sandboxed ? '--dangerously-skip-permissions ' : '';
479
1015
  // Build the claude command
480
- const claudeCmd = `${cdCmd}${baseCmd} ${permissionsFlag}${printFlag}"$(cat ${promptFile})" && rm -f ${promptFile}`;
481
- // If we have a container ID, use docker exec for streaming
482
- if (containerId) {
483
- // Use -it flags only for terminal/foreground modes where a TTY is available
484
- // Background mode runs without a TTY, so -it flags would cause "not a TTY" error
485
- const ttyFlags = displayMode === 'background' ? '' : '-it ';
486
- // Direct mode - run claude directly (tmux setup is handled by runDevcontainerInTmux)
487
- return `docker exec ${ttyFlags}${containerId} bash -c '${claudeCmd}'`;
488
- }
489
- // Fallback to devcontainer exec (no streaming, but works)
490
- return `devcontainer exec --workspace-folder "${context.agentDir}" bash -c '${claudeCmd}'`;
1016
+ const claudeCmd = `${cdCmd}${baseCmd} ${bypassTrustFlag}${permissionsFlag}${printFlag}"$(cat ${promptFile})" && rm -f ${promptFile}`;
1017
+ // Use docker exec for running commands in the container
1018
+ // Use -it flags only for terminal/foreground modes where a TTY is available
1019
+ // Background mode runs without a TTY, so -it flags would cause "not a TTY" error
1020
+ const ttyFlags = displayMode === 'background' ? '' : '-it ';
1021
+ // Direct mode - run claude directly (tmux setup is handled by runDevcontainerInTmux)
1022
+ return `docker exec ${ttyFlags}${containerId} bash -c '${claudeCmd}'`;
491
1023
  }
492
1024
  /**
493
- * Copy Claude Code credentials (~/.claude.json) into the agent directory.
494
- * This makes the subscription credentials available inside the devcontainer
495
- * since the agent directory is mounted at /workspace.
496
- */
497
- function copyClaudeCredentials(agentDir) {
498
- const sourceFile = path.join(os.homedir(), '.claude.json');
499
- const destFile = path.join(agentDir, '.claude.json');
500
- if (fs.existsSync(sourceFile)) {
501
- try {
502
- fs.copyFileSync(sourceFile, destFile);
503
- }
504
- catch (err) {
505
- console.debug('[runners:credentials] Failed to copy .claude.json:', err);
506
- }
507
- }
508
- }
509
- /**
510
- * Run command inside a devcontainer.
511
- * Uses the devcontainer CLI to start/exec in a VS Code devcontainer.
512
- * Provides filesystem isolation - agent can only access mounted worktrees.
1025
+ * Run command inside a Docker container.
1026
+ * Uses raw Docker commands for filesystem isolation - no devcontainer CLI required.
1027
+ * Agent can only access mounted worktrees and configured paths.
513
1028
  *
514
1029
  * @param displayMode - How to display output (terminal, foreground, background, tmux)
515
1030
  * @param sessionManager - How to manage the session inside the container (tmux, direct)
516
1031
  */
517
- export async function runDevcontainer(context, executor, config, displayMode = 'terminal', sessionManager = 'direct') {
518
- // Devcontainer config is in the agent directory, not the worktree
519
- // (worktree may be a subdirectory like agents/altman/textdeck)
1032
+ export async function runDevcontainer(context, executor, config, displayMode = 'terminal', sessionManager = 'tmux' // Default to tmux for session persistence
1033
+ ) {
1034
+ // Docker config is in the agent directory (still uses .devcontainer for Dockerfile)
520
1035
  const devcontainerPath = path.join(context.agentDir, '.devcontainer');
521
- const devcontainerJson = path.join(devcontainerPath, 'devcontainer.json');
522
- // Check if devcontainer config exists
523
- if (!fs.existsSync(devcontainerJson)) {
1036
+ const dockerfile = path.join(devcontainerPath, 'Dockerfile');
1037
+ // Check if Dockerfile exists
1038
+ if (!fs.existsSync(dockerfile)) {
524
1039
  return {
525
1040
  success: false,
526
- error: `No devcontainer.json found at ${devcontainerPath}. Run 'prlt agent add' to set up the agent with devcontainer config.`,
1041
+ error: `No Dockerfile found at ${devcontainerPath}. Run 'prlt agent add' to set up the agent with Docker config.`,
527
1042
  };
528
1043
  }
529
1044
  try {
530
- // Check devcontainer CLI is installed
531
- try {
532
- execSync('which devcontainer', { stdio: 'pipe' });
533
- }
534
- catch (err) {
535
- console.debug('[runners:devcontainer] devcontainer CLI not found:', err);
536
- return {
537
- success: false,
538
- error: 'devcontainer CLI not found. Install with: npm install -g @devcontainers/cli',
539
- };
540
- }
541
1045
  // Check if Docker is running
542
1046
  if (!isDockerRunning()) {
543
1047
  return {
@@ -545,65 +1049,51 @@ export async function runDevcontainer(context, executor, config, displayMode = '
545
1049
  error: 'Docker is not running. Please start Docker Desktop and try again.',
546
1050
  };
547
1051
  }
548
- // Copy Claude credentials into agent directory so container can access them
549
- copyClaudeCredentials(context.agentDir);
550
- // Set environment variables for devcontainer mounts
551
- // PRLT_HQ_PATH: allows agent to access the HQ database and run `prlt ticket complete`
552
- // PRLT_PMO_PATH: allows agent to access the PMO (can be anywhere, e.g., /hq/repos/myrepo/pmo)
553
- // PRLT_REPO_PATH: mounts the entire proletariat repo into the container (until prlt is on npm)
554
- const env = { ...process.env };
555
- if (context.hqPath) {
556
- env.PRLT_HQ_PATH = context.hqPath;
557
- }
558
- if (context.pmoPath) {
559
- env.PRLT_PMO_PATH = context.pmoPath;
560
- }
561
1052
  // Ensure GitHub token is available for git push operations
562
1053
  // Try to get token from gh CLI if not already in environment
563
- if (!env.GITHUB_TOKEN && !env.GH_TOKEN) {
1054
+ if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
564
1055
  try {
565
1056
  const token = execSync('gh auth token', { encoding: 'utf-8', stdio: 'pipe' }).trim();
566
1057
  if (token) {
567
- env.GITHUB_TOKEN = token;
568
- env.GH_TOKEN = token;
1058
+ process.env.GITHUB_TOKEN = token;
1059
+ process.env.GH_TOKEN = token;
569
1060
  }
570
1061
  }
571
1062
  catch (err) {
572
- console.debug('[runners:devcontainer] gh auth token failed:', err);
573
- }
574
- }
575
- // Set repo path to the proletariat monorepo (auto-detect from current CLI location)
576
- // We mount the entire repo so node_modules resolution works correctly
577
- if (!env.PRLT_REPO_PATH) {
578
- // Get the directory where this CLI is running from (apps/cli)
579
- const cliDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..');
580
- // Go up to the monorepo root (repos/proletariat)
581
- const repoDir = path.resolve(cliDir, '..', '..');
582
- if (fs.existsSync(path.join(repoDir, 'apps', 'cli', 'bin', 'run.js'))) {
583
- env.PRLT_REPO_PATH = repoDir;
1063
+ console.debug('[runners:docker] gh auth token failed:', err);
584
1064
  }
585
1065
  }
586
- // Start or reuse container (devcontainer up is idempotent)
587
- // Use agentDir as the workspace folder since that's where .devcontainer is
588
- try {
589
- execSync(`devcontainer up --workspace-folder "${context.agentDir}"`, {
590
- stdio: 'pipe',
591
- env,
592
- });
593
- }
594
- catch (error) {
1066
+ // Copy Claude credentials into agent directory so container can access them
1067
+ // This was the original working approach - credentials at /workspace/.claude.json
1068
+ copyClaudeCredentials(context.agentDir);
1069
+ // Start or reuse container using raw Docker commands
1070
+ // No devcontainer CLI required!
1071
+ const containerId = ensureDockerContainer(context, config);
1072
+ if (!containerId) {
595
1073
  return {
596
1074
  success: false,
597
- error: `Failed to start devcontainer: ${error instanceof Error ? error.message : error}`,
1075
+ error: 'Failed to start Docker container. Check Docker logs for details.',
598
1076
  };
599
1077
  }
600
1078
  // Write prompt to file in worktree (accessible by container)
601
1079
  const { hostPath: promptHostPath, containerPath: promptFile } = writePromptFile(context);
602
- // Get container ID for docker exec (enables streaming output with TTY)
603
- const containerId = getDevcontainerContainerId(context.agentDir);
604
- // Build the devcontainer exec command (just runs claude directly)
1080
+ // Inject fresh GitHub token into container (containers may be reused with stale/empty tokens)
1081
+ // This ensures git push works even if the container was created before token was available
1082
+ const githubToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
1083
+ if (containerId && githubToken) {
1084
+ try {
1085
+ // Write token to file and configure git credential helper
1086
+ execSync(`docker exec ${containerId} bash -c 'echo "${githubToken}" > /home/node/.github-token && chmod 600 /home/node/.github-token && git config --global credential.helper "!f() { echo \\"username=x-access-token\\"; echo \\"password=\\$(cat /home/node/.github-token)\\"; }; f" && git config --global url."https://github.com/".insteadOf "git@github.com:"'`, {
1087
+ stdio: 'pipe',
1088
+ });
1089
+ }
1090
+ catch {
1091
+ // Non-fatal - token injection failed but execution can continue
1092
+ }
1093
+ }
1094
+ // Build the docker exec command (just runs claude directly)
605
1095
  // tmux session setup is handled by runDevcontainerInTmux, not buildDevcontainerCommand
606
- const devcontainerCmd = buildDevcontainerCommand(context, executor, promptFile, containerId || undefined, config.outputMode, config.sandboxed, displayMode);
1096
+ const devcontainerCmd = buildDevcontainerCommand(context, executor, promptFile, containerId, config.outputMode, config.sandboxed, displayMode);
607
1097
  // Execute based on display mode
608
1098
  // When sessionManager is 'tmux', always use tmux inside container for session persistence
609
1099
  // (allows reattach via `prlt session attach` even for background mode)
@@ -650,7 +1140,7 @@ export async function runDevcontainer(context, executor, config, displayMode = '
650
1140
  await new Promise(resolve => setTimeout(resolve, 3000));
651
1141
  // Check if tmux session exists inside the container
652
1142
  try {
653
- const checkResult = execSync(`docker exec ${containerId} tmux has-session -t "${sessionId}" 2>&1`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
1143
+ execSync(`docker exec ${containerId} tmux has-session -t "${sessionId}" 2>&1`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
654
1144
  // Session exists - success
655
1145
  }
656
1146
  catch (err) {
@@ -712,21 +1202,66 @@ rm -f "${scriptPath}"
712
1202
  exec $SHELL
713
1203
  `;
714
1204
  fs.writeFileSync(scriptPath, scriptContent, { mode: 0o755 });
1205
+ // Check if we should open in background (don't steal focus)
1206
+ const openInBackground = config.terminal.openInBackground ?? true;
715
1207
  try {
716
1208
  switch (terminalApp) {
717
1209
  case 'iTerm':
718
1210
  // Run script file directly - iTerm will execute it with proper TTY
719
- execSync(`osascript -e '
720
- tell application "iTerm"
721
- activate
722
- tell current window
723
- set newTab to (create tab with default profile)
724
- tell current session of newTab
725
- write text "${scriptPath}"
726
- end tell
1211
+ // When openInBackground is true, save frontmost app and restore after
1212
+ if (openInBackground) {
1213
+ execSync(`osascript -e '
1214
+ -- Save the currently active application and window
1215
+ tell application "System Events"
1216
+ set frontApp to name of first application process whose frontmost is true
1217
+ set frontAppBundle to bundle identifier of first application process whose frontmost is true
727
1218
  end tell
728
- end tell
729
- '`);
1219
+
1220
+ tell application "iTerm"
1221
+ if (count of windows) = 0 then
1222
+ create window with default profile
1223
+ tell current session of current window
1224
+ write text "${scriptPath}"
1225
+ end tell
1226
+ else
1227
+ tell current window
1228
+ set newTab to (create tab with default profile)
1229
+ tell current session of newTab
1230
+ write text "${scriptPath}"
1231
+ end tell
1232
+ end tell
1233
+ end if
1234
+ end tell
1235
+
1236
+ -- Restore focus to the original application
1237
+ delay 0.2
1238
+ tell application "System Events"
1239
+ set frontmost of process frontApp to true
1240
+ end tell
1241
+ delay 0.1
1242
+ do shell script "open -b " & quoted form of frontAppBundle
1243
+ '`);
1244
+ }
1245
+ else {
1246
+ execSync(`osascript -e '
1247
+ tell application "iTerm"
1248
+ activate
1249
+ if (count of windows) = 0 then
1250
+ create window with default profile
1251
+ tell current session of current window
1252
+ write text "${scriptPath}"
1253
+ end tell
1254
+ else
1255
+ tell current window
1256
+ set newTab to (create tab with default profile)
1257
+ tell current session of newTab
1258
+ write text "${scriptPath}"
1259
+ end tell
1260
+ end tell
1261
+ end if
1262
+ end tell
1263
+ '`);
1264
+ }
730
1265
  break;
731
1266
  case 'Ghostty':
732
1267
  // Use source to preserve TTY for docker exec
@@ -771,18 +1306,29 @@ exec $SHELL
771
1306
  case 'Terminal':
772
1307
  default:
773
1308
  // Use source to preserve TTY for docker exec
774
- execSync(`osascript -e '
775
- tell application "Terminal"
776
- activate
777
- tell application "System Events"
778
- tell process "Terminal"
779
- keystroke "t" using command down
1309
+ if (openInBackground) {
1310
+ // Open in background: use 'do script' which creates a new window without activating
1311
+ execSync(`osascript -e '
1312
+ tell application "Terminal"
1313
+ do script "source ${scriptPath}"
1314
+ end tell
1315
+ '`);
1316
+ }
1317
+ else {
1318
+ // Bring to front: use traditional Cmd+T for new tab
1319
+ execSync(`osascript -e '
1320
+ tell application "Terminal"
1321
+ activate
1322
+ tell application "System Events"
1323
+ tell process "Terminal"
1324
+ keystroke "t" using command down
1325
+ end tell
780
1326
  end tell
1327
+ delay 0.3
1328
+ do script "source ${scriptPath}" in front window
781
1329
  end tell
782
- delay 0.3
783
- do script "source ${scriptPath}" in front window
784
- end tell
785
- '`);
1330
+ '`);
1331
+ }
786
1332
  break;
787
1333
  }
788
1334
  return {
@@ -836,6 +1382,10 @@ async function runDevcontainerInTmux(context, devcontainerCmd, config, displayMo
836
1382
  // Session name: {ticketId}-{action} (e.g., TKT-347-implement)
837
1383
  const sessionName = buildTmuxWindowName(context);
838
1384
  const windowTitle = buildWindowTitle(context);
1385
+ // Check if we should use iTerm control mode (-CC)
1386
+ // When using -CC, iTerm handles scrolling/selection natively, so we DON'T set mouse on
1387
+ const terminalApp = config.terminal.app;
1388
+ const useControlMode = shouldUseControlMode(terminalApp, config.tmux.controlMode);
839
1389
  try {
840
1390
  // Get container ID - prefer passed value, fallback to extracting from command
841
1391
  // The devcontainerCmd is like: docker exec [-it] <containerId> bash -c '...'
@@ -870,7 +1420,14 @@ async function runDevcontainerInTmux(context, devcontainerCmd, config, displayMo
870
1420
  const cmdMatch = devcontainerCmd.match(/bash -c '(.+)'$/);
871
1421
  const claudeCmd = cmdMatch ? cmdMatch[1] : devcontainerCmd;
872
1422
  // Create a script inside the container that runs claude and keeps shell open
1423
+ // TERM must be set for Claude's TUI to render properly
1424
+ // Unset DEVCONTAINER and CI to prevent Claude from detecting container/CI environment
1425
+ // which might cause it to suppress TUI output
873
1426
  const tmuxScript = `#!/bin/bash
1427
+ export TERM=xterm-256color
1428
+ export COLORTERM=truecolor
1429
+ unset DEVCONTAINER
1430
+ unset CI
874
1431
  echo "🚀 Starting: ${sessionName}"
875
1432
  echo ""
876
1433
  ${claudeCmd}
@@ -881,18 +1438,46 @@ exec bash
881
1438
  const base64Script = Buffer.from(tmuxScript).toString('base64');
882
1439
  const scriptPath = `/tmp/prlt-${sessionName}.sh`;
883
1440
  // Write script and start tmux session inside container
1441
+ // IMPORTANT: We create the session with bash first, then send keys to run the script.
1442
+ // This ensures bash is running interactively (required for Claude's TUI to render).
1443
+ // If we pass the script as the session command, bash runs non-interactively and Claude won't show TUI.
884
1444
  // -n sets the window name (shows in iTerm tab title with -CC mode)
885
1445
  // sessionName is already ticket-action-agent format
886
- // Enable mouse mode for native scrolling (trackpad/mouse wheel works without -CC mode)
1446
+ // Only enable mouse mode if NOT using control mode (control mode lets iTerm handle mouse natively)
887
1447
  // set-titles on + set-titles-string: makes tmux set terminal title to window name
888
- const setupCmd = `echo ${base64Script} | base64 -d > ${scriptPath} && chmod +x ${scriptPath} && tmux new-session -d -s "${sessionName}" -n "${sessionName}" "${scriptPath}" \\; set-option -g mouse on \\; set-option -g set-titles on \\; set-option -g set-titles-string "#{window_name}"`;
1448
+ const mouseOption = buildTmuxMouseOption(useControlMode);
1449
+ // Step 1: Write the script to the container
1450
+ const writeScriptCmd = `echo ${base64Script} | base64 -d > ${scriptPath} && chmod +x ${scriptPath}`;
1451
+ try {
1452
+ execSync(`docker exec ${actualContainerId} bash -c '${writeScriptCmd}'`, { stdio: 'pipe' });
1453
+ }
1454
+ catch (error) {
1455
+ return {
1456
+ success: false,
1457
+ error: `Failed to write script to container: ${error instanceof Error ? error.message : error}`,
1458
+ };
1459
+ }
1460
+ // Step 2: Create tmux session with bash explicitly (not default shell which may be zsh)
1461
+ // Using bash avoids zsh-newuser-install prompt that blocks the session
1462
+ const createSessionCmd = `tmux new-session -d -s "${sessionName}" -n "${sessionName}" bash${mouseOption} \\; set-option -g set-titles on \\; set-option -g set-titles-string "#{window_name}"`;
889
1463
  try {
890
- execSync(`docker exec ${actualContainerId} bash -c '${setupCmd}'`, { stdio: 'pipe' });
1464
+ execSync(`docker exec ${actualContainerId} bash -c '${createSessionCmd}'`, { stdio: 'pipe' });
891
1465
  }
892
1466
  catch (error) {
893
1467
  return {
894
1468
  success: false,
895
- error: `Failed to start tmux inside container: ${error instanceof Error ? error.message : error}`,
1469
+ error: `Failed to create tmux session inside container: ${error instanceof Error ? error.message : error}`,
1470
+ };
1471
+ }
1472
+ // Step 3: Send keys to run the script (this runs in the interactive bash)
1473
+ const sendKeysCmd = `tmux send-keys -t "${sessionName}" "source ${scriptPath}" Enter`;
1474
+ try {
1475
+ execSync(`docker exec ${actualContainerId} bash -c '${sendKeysCmd}'`, { stdio: 'pipe' });
1476
+ }
1477
+ catch (error) {
1478
+ return {
1479
+ success: false,
1480
+ error: `Failed to start script in tmux session: ${error instanceof Error ? error.message : error}`,
896
1481
  };
897
1482
  }
898
1483
  // Step 2: Open iTerm tab that attaches directly to container's tmux
@@ -905,11 +1490,74 @@ exec bash
905
1490
  sessionId: sessionName, // Container tmux session name for tracking
906
1491
  };
907
1492
  }
908
- // NOTE: We don't use tmux -CC (control mode) here because we're already
909
- // creating a tab via AppleScript. Using -CC would cause iTerm to create
910
- // another window for the tmux session (double windows).
911
- // Users can reattach with `prlt session attach` which uses -CC for native scrolling.
912
- const attachCmd = `docker exec -it ${actualContainerId} tmux -u attach -t "${sessionName}"`;
1493
+ // Foreground mode: attach to container's tmux session in current terminal (blocking)
1494
+ if (displayMode === 'foreground') {
1495
+ try {
1496
+ // Clear screen and attach - this blocks until user detaches or claude exits
1497
+ // Use -CC for iTerm when control mode is enabled
1498
+ const fgTmuxAttach = buildTmuxAttachCommand(useControlMode, true);
1499
+ execSync(`clear && docker exec -it ${actualContainerId} ${fgTmuxAttach} -t "${sessionName}"`, { stdio: 'inherit' });
1500
+ return {
1501
+ success: true,
1502
+ containerId: actualContainerId,
1503
+ sessionId: sessionName,
1504
+ };
1505
+ }
1506
+ catch (error) {
1507
+ return {
1508
+ success: false,
1509
+ error: `Failed to attach to container tmux session: ${error instanceof Error ? error.message : error}`,
1510
+ };
1511
+ }
1512
+ }
1513
+ // Use tmux -CC (control mode) for iTerm when enabled in config
1514
+ // -CC gives native iTerm scrolling, selection, and gesture support
1515
+ // Without -CC, use regular attach (relies on mouse mode for scrolling)
1516
+ const tmuxAttach = buildTmuxAttachCommand(useControlMode, true);
1517
+ const attachCmd = `docker exec -it ${actualContainerId} ${tmuxAttach} -t "${sessionName}"`;
1518
+ // Open terminal and run the attach command
1519
+ const terminalApp = config.terminal.app;
1520
+ // For iTerm with control mode, create a new tab and run -CC attach there
1521
+ // This avoids interfering with the terminal where prlt is running
1522
+ if (terminalApp === 'iTerm' && useControlMode) {
1523
+ // Configure iTerm to open tmux windows as tabs or windows based on user preference
1524
+ configureITermTmuxWindowMode(config.tmux.windowMode);
1525
+ const openInBackground = config.terminal.openInBackground ?? true;
1526
+ if (openInBackground) {
1527
+ // Open tab without stealing focus - save frontmost app and restore after
1528
+ execSync(`osascript -e '
1529
+ set frontApp to path to frontmost application as text
1530
+ tell application "iTerm"
1531
+ tell current window
1532
+ set newTab to (create tab with default profile)
1533
+ tell current session of newTab
1534
+ write text "docker exec -it ${actualContainerId} tmux -u -CC attach -t \\"${sessionName}\\""
1535
+ end tell
1536
+ end tell
1537
+ end tell
1538
+ tell application frontApp to activate
1539
+ '`);
1540
+ }
1541
+ else {
1542
+ execSync(`osascript -e '
1543
+ tell application "iTerm"
1544
+ activate
1545
+ tell current window
1546
+ set newTab to (create tab with default profile)
1547
+ tell current session of newTab
1548
+ write text "docker exec -it ${actualContainerId} tmux -u -CC attach -t \\"${sessionName}\\""
1549
+ end tell
1550
+ end tell
1551
+ end tell
1552
+ '`);
1553
+ }
1554
+ return {
1555
+ success: true,
1556
+ containerId: actualContainerId,
1557
+ sessionId: sessionName,
1558
+ };
1559
+ }
1560
+ // For all other cases, create a script file and open in a new tab
913
1561
  const baseDir = context.hqPath
914
1562
  ? path.join(context.hqPath, '.proletariat', 'scripts')
915
1563
  : path.join(os.homedir(), '.proletariat', 'scripts');
@@ -928,32 +1576,69 @@ rm -f "${hostScriptPath}"
928
1576
  exec $SHELL
929
1577
  `;
930
1578
  fs.writeFileSync(hostScriptPath, hostScript, { mode: 0o755 });
931
- // Open iTerm tab and run the attach script
932
- const terminalApp = config.terminal.app;
1579
+ // Check if we should open in background (don't steal focus)
1580
+ const openInBackground = config.terminal.openInBackground ?? true;
933
1581
  switch (terminalApp) {
934
1582
  case 'iTerm':
935
- // Create new tab in existing window, or create new window if none exists
936
- // Set tab name via AppleScript for reliable naming
937
- execSync(`osascript -e '
938
- tell application "iTerm"
939
- activate
940
- if (count of windows) = 0 then
941
- create window with default profile
942
- tell current session of current window
943
- set name to "${windowTitle}"
944
- write text "${hostScriptPath}"
945
- end tell
946
- else
947
- tell current window
948
- create tab with default profile
949
- tell current session
1583
+ // Without control mode, create a new tab and attach normally
1584
+ // When openInBackground is true, save frontmost app and restore after
1585
+ if (openInBackground) {
1586
+ execSync(`osascript -e '
1587
+ -- Save the currently active application and window
1588
+ tell application "System Events"
1589
+ set frontApp to name of first application process whose frontmost is true
1590
+ set frontAppBundle to bundle identifier of first application process whose frontmost is true
1591
+ end tell
1592
+
1593
+ tell application "iTerm"
1594
+ if (count of windows) = 0 then
1595
+ create window with default profile
1596
+ tell current session of current window
950
1597
  set name to "${windowTitle}"
951
1598
  write text "${hostScriptPath}"
952
1599
  end tell
953
- end tell
954
- end if
955
- end tell
956
- '`);
1600
+ else
1601
+ tell current window
1602
+ create tab with default profile
1603
+ tell current session
1604
+ set name to "${windowTitle}"
1605
+ write text "${hostScriptPath}"
1606
+ end tell
1607
+ end tell
1608
+ end if
1609
+ end tell
1610
+
1611
+ -- Restore focus to the original application
1612
+ delay 0.2
1613
+ tell application "System Events"
1614
+ set frontmost of process frontApp to true
1615
+ end tell
1616
+ delay 0.1
1617
+ do shell script "open -b " & quoted form of frontAppBundle
1618
+ '`);
1619
+ }
1620
+ else {
1621
+ execSync(`osascript -e '
1622
+ tell application "iTerm"
1623
+ activate
1624
+ if (count of windows) = 0 then
1625
+ create window with default profile
1626
+ tell current session of current window
1627
+ set name to "${windowTitle}"
1628
+ write text "${hostScriptPath}"
1629
+ end tell
1630
+ else
1631
+ tell current window
1632
+ create tab with default profile
1633
+ tell current session
1634
+ set name to "${windowTitle}"
1635
+ write text "${hostScriptPath}"
1636
+ end tell
1637
+ end tell
1638
+ end if
1639
+ end tell
1640
+ '`);
1641
+ }
957
1642
  break;
958
1643
  case 'Ghostty':
959
1644
  execSync(`osascript -e '
@@ -972,18 +1657,29 @@ exec $SHELL
972
1657
  break;
973
1658
  case 'Terminal':
974
1659
  default:
975
- execSync(`osascript -e '
976
- tell application "Terminal"
977
- activate
978
- tell application "System Events"
979
- tell process "Terminal"
980
- keystroke "t" using command down
1660
+ if (openInBackground) {
1661
+ // Open in background: use 'do script' which creates a new window without activating
1662
+ execSync(`osascript -e '
1663
+ tell application "Terminal"
1664
+ do script "${hostScriptPath}"
1665
+ end tell
1666
+ '`);
1667
+ }
1668
+ else {
1669
+ // Bring to front: use traditional Cmd+T for new tab
1670
+ execSync(`osascript -e '
1671
+ tell application "Terminal"
1672
+ activate
1673
+ tell application "System Events"
1674
+ tell process "Terminal"
1675
+ keystroke "t" using command down
1676
+ end tell
981
1677
  end tell
1678
+ delay 0.3
1679
+ do script "${hostScriptPath}" in front window
982
1680
  end tell
983
- delay 0.3
984
- do script "${hostScriptPath}" in front window
985
- end tell
986
- '`);
1681
+ '`);
1682
+ }
987
1683
  break;
988
1684
  }
989
1685
  return {
@@ -1002,6 +1698,7 @@ exec $SHELL
1002
1698
  /**
1003
1699
  * Legacy: Run devcontainer in host-side tmux (kept for non-container modes)
1004
1700
  */
1701
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1005
1702
  async function runDevcontainerInHostTmux(context, devcontainerCmd, config) {
1006
1703
  const sessionName = config.tmux.session;
1007
1704
  const windowName = buildTmuxWindowName(context);