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