@proletariat/cli 0.3.36 → 0.3.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +37 -2
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/branch/where.js +6 -17
  4. package/dist/commands/epic/ticket.js +7 -24
  5. package/dist/commands/execution/config.js +4 -14
  6. package/dist/commands/execution/logs.js +6 -0
  7. package/dist/commands/execution/view.js +8 -0
  8. package/dist/commands/init.js +4 -8
  9. package/dist/commands/mcp-server.js +2 -1
  10. package/dist/commands/pmo/init.js +12 -40
  11. package/dist/commands/qa/index.d.ts +54 -0
  12. package/dist/commands/qa/index.js +762 -0
  13. package/dist/commands/repo/view.js +2 -8
  14. package/dist/commands/session/attach.js +4 -4
  15. package/dist/commands/session/health.js +4 -4
  16. package/dist/commands/session/list.js +1 -19
  17. package/dist/commands/session/peek.js +6 -6
  18. package/dist/commands/session/poke.js +2 -2
  19. package/dist/commands/ticket/epic.js +17 -43
  20. package/dist/commands/work/spawn-all.js +1 -1
  21. package/dist/commands/work/spawn.js +15 -4
  22. package/dist/commands/work/start.js +17 -9
  23. package/dist/commands/work/watch.js +1 -1
  24. package/dist/commands/workspace/prune.js +3 -3
  25. package/dist/hooks/init.js +21 -10
  26. package/dist/lib/agents/commands.d.ts +5 -0
  27. package/dist/lib/agents/commands.js +143 -97
  28. package/dist/lib/database/drizzle-schema.d.ts +465 -0
  29. package/dist/lib/database/drizzle-schema.js +53 -0
  30. package/dist/lib/database/index.d.ts +47 -1
  31. package/dist/lib/database/index.js +138 -20
  32. package/dist/lib/execution/runners.d.ts +34 -0
  33. package/dist/lib/execution/runners.js +134 -7
  34. package/dist/lib/execution/session-utils.d.ts +5 -0
  35. package/dist/lib/execution/session-utils.js +45 -3
  36. package/dist/lib/execution/spawner.js +15 -2
  37. package/dist/lib/execution/storage.d.ts +1 -1
  38. package/dist/lib/execution/storage.js +17 -2
  39. package/dist/lib/execution/types.d.ts +1 -0
  40. package/dist/lib/mcp/tools/index.d.ts +1 -0
  41. package/dist/lib/mcp/tools/index.js +1 -0
  42. package/dist/lib/mcp/tools/tmux.d.ts +16 -0
  43. package/dist/lib/mcp/tools/tmux.js +182 -0
  44. package/dist/lib/mcp/tools/work.js +52 -0
  45. package/dist/lib/pmo/schema.d.ts +1 -1
  46. package/dist/lib/pmo/schema.js +1 -0
  47. package/dist/lib/pmo/storage/base.js +207 -0
  48. package/dist/lib/pmo/storage/dependencies.d.ts +1 -0
  49. package/dist/lib/pmo/storage/dependencies.js +11 -3
  50. package/dist/lib/pmo/storage/epics.js +1 -1
  51. package/dist/lib/pmo/storage/helpers.d.ts +4 -4
  52. package/dist/lib/pmo/storage/helpers.js +36 -26
  53. package/dist/lib/pmo/storage/projects.d.ts +2 -0
  54. package/dist/lib/pmo/storage/projects.js +207 -119
  55. package/dist/lib/pmo/storage/specs.d.ts +2 -0
  56. package/dist/lib/pmo/storage/specs.js +274 -188
  57. package/dist/lib/pmo/storage/tickets.d.ts +2 -0
  58. package/dist/lib/pmo/storage/tickets.js +350 -290
  59. package/dist/lib/pmo/storage/views.d.ts +2 -0
  60. package/dist/lib/pmo/storage/views.js +183 -130
  61. package/dist/lib/prompt-json.d.ts +5 -0
  62. package/dist/lib/prompt-json.js +9 -0
  63. package/oclif.manifest.json +3293 -3190
  64. package/package.json +11 -6
  65. package/LICENSE +0 -190
@@ -424,29 +424,56 @@ export function addAgentsToDatabase(workspacePath, agentNames, themeId, mountMod
424
424
  db.close();
425
425
  }
426
426
  /**
427
- * Add an ephemeral agent to the database
427
+ * Add an ephemeral agent to the database.
428
+ * Throws on name collision — use tryAddEphemeralAgentToDatabase for
429
+ * concurrency-safe insertion with conflict detection.
428
430
  */
429
431
  export function addEphemeralAgentToDatabase(workspacePath, agentName, baseName, themeId, mountMode = 'worktree') {
432
+ const result = tryAddEphemeralAgentToDatabase(workspacePath, agentName, baseName, themeId, mountMode);
433
+ if (!result) {
434
+ throw new Error(`Agent name "${agentName}" already exists (UNIQUE constraint failed: agents.name)`);
435
+ }
436
+ return result;
437
+ }
438
+ /**
439
+ * Try to add an ephemeral agent to the database.
440
+ * Returns the Agent on success, or null if the name already exists
441
+ * (SQLITE_CONSTRAINT_PRIMARYKEY). This is concurrency-safe: parallel
442
+ * processes that generate the same name will not crash — the loser
443
+ * simply gets null and can retry with a different name.
444
+ */
445
+ export function tryAddEphemeralAgentToDatabase(workspacePath, agentName, baseName, themeId, mountMode = 'worktree') {
430
446
  const db = openWorkspaceDatabase(workspacePath);
431
- const now = new Date().toISOString();
432
- const worktreePath = `agents/temp/${agentName}`;
433
- db.prepare(`
434
- INSERT INTO agents (name, type, status, base_name, theme_id, worktree_path, mount_mode, created_at)
435
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
436
- `).run(agentName, 'ephemeral', 'active', baseName, themeId || null, worktreePath, mountMode, now);
437
- const agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentName);
438
- db.close();
439
- return {
440
- name: agent.name,
441
- type: agent.type,
442
- status: agent.status,
443
- base_name: agent.base_name,
444
- theme_id: agent.theme_id,
445
- worktree_path: agent.worktree_path,
446
- mount_mode: (agent.mount_mode || 'clone'),
447
- created_at: agent.created_at,
448
- cleaned_at: agent.cleaned_at,
449
- };
447
+ try {
448
+ const now = new Date().toISOString();
449
+ const worktreePath = `agents/temp/${agentName}`;
450
+ db.prepare(`
451
+ INSERT INTO agents (name, type, status, base_name, theme_id, worktree_path, mount_mode, created_at)
452
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
453
+ `).run(agentName, 'ephemeral', 'active', baseName, themeId || null, worktreePath, mountMode, now);
454
+ const agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentName);
455
+ return {
456
+ name: agent.name,
457
+ type: agent.type,
458
+ status: agent.status,
459
+ base_name: agent.base_name,
460
+ theme_id: agent.theme_id,
461
+ worktree_path: agent.worktree_path,
462
+ mount_mode: (agent.mount_mode || 'clone'),
463
+ created_at: agent.created_at,
464
+ cleaned_at: agent.cleaned_at,
465
+ };
466
+ }
467
+ catch (err) {
468
+ const sqliteErr = err;
469
+ if (sqliteErr.code === 'SQLITE_CONSTRAINT_PRIMARYKEY' || sqliteErr.code === 'SQLITE_CONSTRAINT_UNIQUE') {
470
+ return null;
471
+ }
472
+ throw err;
473
+ }
474
+ finally {
475
+ db.close();
476
+ }
450
477
  }
451
478
  /**
452
479
  * Get all ephemeral agent names from the database
@@ -669,6 +696,34 @@ export function getAgentWorktrees(workspacePath, agentName) {
669
696
  db.close();
670
697
  return worktrees;
671
698
  }
699
+ /**
700
+ * Find agent worktrees matching a branch pattern (case-insensitive LIKE).
701
+ */
702
+ export function findWorktreesByBranch(workspacePath, branchPattern) {
703
+ const db = openWorkspaceDatabase(workspacePath);
704
+ const worktrees = db.prepare('SELECT * FROM agent_worktrees WHERE LOWER(branch) LIKE ?').all(branchPattern);
705
+ db.close();
706
+ return worktrees;
707
+ }
708
+ /**
709
+ * Get agent worktrees for a specific repository.
710
+ */
711
+ export function getWorktreesForRepo(workspacePath, repoName) {
712
+ const db = openWorkspaceDatabase(workspacePath);
713
+ const worktrees = db.prepare('SELECT agent_name, is_clean, commits_ahead, branch FROM agent_worktrees WHERE repo_name = ?').all(repoName);
714
+ db.close();
715
+ return worktrees;
716
+ }
717
+ /**
718
+ * Upsert a workspace setting (key-value pair).
719
+ */
720
+ export function upsertWorkspaceSetting(db, key, value) {
721
+ db.prepare(`
722
+ INSERT INTO workspace_settings (key, value)
723
+ VALUES (?, ?)
724
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
725
+ `).run(key, value);
726
+ }
672
727
  /**
673
728
  * Remove agents from database
674
729
  */
@@ -685,6 +740,69 @@ export function removeAgentsFromDatabase(workspacePath, agentNames) {
685
740
  db.close();
686
741
  }
687
742
  // =============================================================================
743
+ // PMO Bootstrapping Operations
744
+ // =============================================================================
745
+ /**
746
+ * Check if PMO tables exist and get basic stats.
747
+ * Used by pmo init to detect existing PMO before storage layer is available.
748
+ */
749
+ export function checkPMOExists(dbPath) {
750
+ const db = new Database(dbPath);
751
+ try {
752
+ const result = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='pmo_projects'").get();
753
+ if (result === undefined) {
754
+ return { exists: false, projectCount: 0, ticketCount: 0 };
755
+ }
756
+ const projectCountResult = db.prepare('SELECT COUNT(*) as count FROM pmo_projects').get();
757
+ const ticketCountResult = db.prepare('SELECT COUNT(*) as count FROM pmo_tickets').get();
758
+ return {
759
+ exists: true,
760
+ projectCount: projectCountResult.count,
761
+ ticketCount: ticketCountResult.count,
762
+ };
763
+ }
764
+ finally {
765
+ db.close();
766
+ }
767
+ }
768
+ /**
769
+ * Get a PMO setting from the pmo_settings table.
770
+ * Used for bootstrapping queries before storage layer is available.
771
+ */
772
+ export function getPMOSetting(dbPath, key) {
773
+ const db = new Database(dbPath);
774
+ try {
775
+ const result = db.prepare('SELECT value FROM pmo_settings WHERE key = ?').get(key);
776
+ return result?.value ?? null;
777
+ }
778
+ catch {
779
+ return null;
780
+ }
781
+ finally {
782
+ db.close();
783
+ }
784
+ }
785
+ /**
786
+ * Drop PMO tables from the database.
787
+ * Used during PMO reinitialization.
788
+ */
789
+ export function dropPMOTables(dbPath, tables) {
790
+ const db = new Database(dbPath);
791
+ try {
792
+ for (const table of tables) {
793
+ try {
794
+ db.prepare(`DROP TABLE IF EXISTS ${table}`).run();
795
+ }
796
+ catch {
797
+ // Ignore errors - table might not exist
798
+ }
799
+ }
800
+ }
801
+ finally {
802
+ db.close();
803
+ }
804
+ }
805
+ // =============================================================================
688
806
  // Theme CRUD Operations
689
807
  // =============================================================================
690
808
  /**
@@ -74,6 +74,40 @@ export declare function getDockerCredentialInfo(): {
74
74
  expiresAt: Date;
75
75
  subscriptionType?: string;
76
76
  } | null;
77
+ /**
78
+ * Preflight result indicating whether the executor is ready to run.
79
+ */
80
+ export interface PreflightResult {
81
+ ok: boolean;
82
+ error?: string;
83
+ }
84
+ /**
85
+ * Check if an executor binary is available on the host.
86
+ * Returns a PreflightResult with ok=true if the binary is found,
87
+ * or ok=false with a descriptive error and remediation hint.
88
+ */
89
+ export declare function checkExecutorOnHost(executor: ExecutorType): PreflightResult;
90
+ /**
91
+ * Check if an executor binary is available inside a Docker container.
92
+ * Returns a PreflightResult with ok=true if the binary is found,
93
+ * or ok=false with a descriptive error and remediation hint.
94
+ */
95
+ export declare function checkExecutorInContainer(executor: ExecutorType, containerId: string): PreflightResult;
96
+ /**
97
+ * Run preflight checks for a given execution environment and executor.
98
+ * Validates that the executor binary is available before spawning.
99
+ *
100
+ * Checks performed per environment:
101
+ * - host: Verify binary on PATH
102
+ * - devcontainer: Verify binary inside container (if container running)
103
+ * - docker: Verify binary on host (used in docker run command)
104
+ * - vm: Verify binary on host (will be checked on remote separately)
105
+ */
106
+ export declare function runExecutorPreflight(executor: ExecutorType, environment: ExecutionEnvironment, containerId?: string): PreflightResult;
107
+ export declare function getExecutorCommand(executor: ExecutorType, prompt: string, skipPermissions?: boolean): {
108
+ cmd: string;
109
+ args: string[];
110
+ };
77
111
  export interface RunnerResult {
78
112
  success: boolean;
79
113
  pid?: string;
@@ -188,10 +188,125 @@ export function getDockerCredentialInfo() {
188
188
  return null;
189
189
  }
190
190
  }
191
+ /**
192
+ * Get the binary name for an executor type.
193
+ */
194
+ function getExecutorBinary(executor) {
195
+ switch (executor) {
196
+ case 'claude-code':
197
+ return 'claude';
198
+ case 'codex':
199
+ return 'codex';
200
+ case 'aider':
201
+ return 'aider';
202
+ case 'custom':
203
+ return 'echo'; // placeholder
204
+ default:
205
+ return 'claude';
206
+ }
207
+ }
208
+ /**
209
+ * Get install/setup hints for a missing executor binary.
210
+ */
211
+ function getExecutorRemediationHint(executor) {
212
+ switch (executor) {
213
+ case 'claude-code':
214
+ return 'Install Claude Code: npm install -g @anthropic-ai/claude-code\n' +
215
+ ' Then authenticate: claude login';
216
+ case 'codex':
217
+ return 'Install Codex: npm install -g @openai/codex\n' +
218
+ ' Then authenticate: set OPENAI_API_KEY or run codex --login';
219
+ case 'aider':
220
+ return 'Install aider: pip install aider-chat\n' +
221
+ ' Then authenticate: set OPENAI_API_KEY or ANTHROPIC_API_KEY';
222
+ case 'custom':
223
+ return 'Configure a custom executor command in your execution settings.';
224
+ default:
225
+ return 'Install the required executor and ensure it is on your PATH.';
226
+ }
227
+ }
228
+ /**
229
+ * Check if an executor binary is available on the host.
230
+ * Returns a PreflightResult with ok=true if the binary is found,
231
+ * or ok=false with a descriptive error and remediation hint.
232
+ */
233
+ export function checkExecutorOnHost(executor) {
234
+ const binary = getExecutorBinary(executor);
235
+ try {
236
+ execSync(`${binary} --version`, {
237
+ stdio: ['pipe', 'pipe', 'pipe'],
238
+ timeout: 10000,
239
+ });
240
+ return { ok: true };
241
+ }
242
+ catch {
243
+ return {
244
+ ok: false,
245
+ error: `Executor "${executor}" not found: "${binary}" is not installed or not on PATH.\n\n` +
246
+ `Remediation:\n ${getExecutorRemediationHint(executor)}`,
247
+ };
248
+ }
249
+ }
250
+ /**
251
+ * Check if an executor binary is available inside a Docker container.
252
+ * Returns a PreflightResult with ok=true if the binary is found,
253
+ * or ok=false with a descriptive error and remediation hint.
254
+ */
255
+ export function checkExecutorInContainer(executor, containerId) {
256
+ const binary = getExecutorBinary(executor);
257
+ try {
258
+ execSync(`docker exec ${containerId} which ${binary}`, {
259
+ stdio: 'pipe',
260
+ timeout: 10000,
261
+ });
262
+ return { ok: true };
263
+ }
264
+ catch {
265
+ return {
266
+ ok: false,
267
+ error: `Executor "${executor}" not found in container ${containerId}: "${binary}" is not installed.\n\n` +
268
+ `Remediation:\n Ensure "${binary}" is installed in the devcontainer image.\n ` +
269
+ getExecutorRemediationHint(executor),
270
+ };
271
+ }
272
+ }
273
+ /**
274
+ * Run preflight checks for a given execution environment and executor.
275
+ * Validates that the executor binary is available before spawning.
276
+ *
277
+ * Checks performed per environment:
278
+ * - host: Verify binary on PATH
279
+ * - devcontainer: Verify binary inside container (if container running)
280
+ * - docker: Verify binary on host (used in docker run command)
281
+ * - vm: Verify binary on host (will be checked on remote separately)
282
+ */
283
+ export function runExecutorPreflight(executor, environment, containerId) {
284
+ switch (environment) {
285
+ case 'host':
286
+ return checkExecutorOnHost(executor);
287
+ case 'devcontainer':
288
+ // For devcontainer, check inside the container if it's already running
289
+ if (containerId) {
290
+ return checkExecutorInContainer(executor, containerId);
291
+ }
292
+ // Container not yet running - will be checked after container start
293
+ return { ok: true };
294
+ case 'docker':
295
+ // Docker runner builds the command on host, executor runs inside container
296
+ // Can't check until container is created, so skip for now
297
+ return { ok: true };
298
+ case 'vm':
299
+ // VM executor runs remotely - can't check from host
300
+ // Could add SSH-based check in the future
301
+ return { ok: true };
302
+ default:
303
+ return { ok: true };
304
+ }
305
+ }
191
306
  // =============================================================================
192
307
  // Executor Commands
193
308
  // =============================================================================
194
- function getExecutorCommand(executor, prompt, skipPermissions = true) {
309
+ export function getExecutorCommand(executor, prompt, skipPermissions = true) {
195
310
  switch (executor) {
196
311
  case 'claude-code':
197
312
  if (skipPermissions) {
@@ -1157,6 +1272,14 @@ export async function runDevcontainer(context, executor, config, displayMode = '
1157
1272
  error: 'Failed to start Docker container. Check Docker logs for details.',
1158
1273
  };
1159
1274
  }
1275
+ // Executor preflight check (TKT-1082): verify executor binary is available inside container
1276
+ const preflight = checkExecutorInContainer(executor, containerId);
1277
+ if (!preflight.ok) {
1278
+ return {
1279
+ success: false,
1280
+ error: preflight.error,
1281
+ };
1282
+ }
1160
1283
  // Write prompt to file in worktree (accessible by container)
1161
1284
  const { hostPath: promptHostPath, containerPath: promptFile } = writePromptFile(context);
1162
1285
  // Inject fresh GitHub token into container (containers may be reused with stale/empty tokens)
@@ -1774,6 +1897,8 @@ exec $SHELL
1774
1897
  export async function runDocker(context, executor, config) {
1775
1898
  const prompt = buildPrompt(context);
1776
1899
  const containerName = `work-${context.ticketId}-${Date.now()}`;
1900
+ // Get the correct executor command (claude, codex, aider, etc.)
1901
+ const { cmd, args } = getExecutorCommand(executor, prompt, !config.sandboxed);
1777
1902
  try {
1778
1903
  // Check if docker is available
1779
1904
  execSync('which docker', { stdio: 'pipe' });
@@ -1798,10 +1923,10 @@ export async function runDocker(context, executor, config) {
1798
1923
  if (config.docker.cpus) {
1799
1924
  dockerCmd += ` --cpus ${config.docker.cpus}`;
1800
1925
  }
1801
- // Escape prompt for shell
1802
- const escapedPrompt = prompt.replace(/'/g, "'\\''");
1926
+ // Build executor command with properly escaped args
1927
+ const escapedArgs = args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ');
1803
1928
  dockerCmd += ` ${config.docker.image}`;
1804
- dockerCmd += ` claude --print '${escapedPrompt}'`;
1929
+ dockerCmd += ` ${cmd} ${escapedArgs}`;
1805
1930
  const containerId = execSync(dockerCmd, { encoding: 'utf-8' }).trim();
1806
1931
  return {
1807
1932
  success: true,
@@ -1830,6 +1955,8 @@ export async function runVm(context, executor, config, host) {
1830
1955
  const user = config.vm.user;
1831
1956
  const keyPath = config.vm.keyPath;
1832
1957
  const remoteWorkspace = `/workspace/${context.agentName}`;
1958
+ // Get the correct executor command (claude, codex, aider, etc.)
1959
+ const { cmd, args } = getExecutorCommand(executor, prompt, !config.sandboxed);
1833
1960
  try {
1834
1961
  // Build SSH options
1835
1962
  let sshOpts = '';
@@ -1851,9 +1978,9 @@ export async function runVm(context, executor, config, host) {
1851
1978
  const gitPullCmd = `cd ${remoteWorkspace} && git fetch && git checkout ${context.branch}`;
1852
1979
  execSync(`ssh ${sshOpts} ${user}@${targetHost} "${gitPullCmd}"`, { stdio: 'pipe' });
1853
1980
  }
1854
- // Execute on remote
1855
- const escapedPrompt = prompt.replace(/'/g, "'\\''");
1856
- const remoteCmd = `cd ${remoteWorkspace} && claude --print '${escapedPrompt}'`;
1981
+ // Execute on remote using the correct executor
1982
+ const escapedArgs = args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ');
1983
+ const remoteCmd = `cd ${remoteWorkspace} && ${cmd} ${escapedArgs}`;
1857
1984
  const sshCmd = `ssh ${sshOpts} ${user}@${targetHost} "nohup ${remoteCmd} > /tmp/work-${context.ticketId}.log 2>&1 &"`;
1858
1985
  execSync(sshCmd, { stdio: 'pipe' });
1859
1986
  return {
@@ -60,6 +60,11 @@ export declare function flattenContainerSessions(containerTmuxSessions: Map<stri
60
60
  sessionName: string;
61
61
  containerId: string;
62
62
  }>;
63
+ /**
64
+ * Find container sessions using prefix matching.
65
+ * Handles short vs full container ID mismatches between DB records and docker output.
66
+ */
67
+ export declare function findContainerSessionsByPrefix(containerTmuxSessions: Map<string, string[]>, containerId: string): string[];
63
68
  /**
64
69
  * Try to find a matching tmux session for an execution with NULL sessionId.
65
70
  * First tries exact matches with known action names, then falls back to
@@ -127,10 +127,37 @@ export function getHostTmuxSessionNames() {
127
127
  export function getContainerTmuxSessionMap() {
128
128
  const sessionMap = new Map();
129
129
  try {
130
- const containersOutput = execSync('docker ps --filter "label=devcontainer.local_folder" --format "{{.ID}}"', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
131
- if (!containersOutput)
130
+ const containerIds = new Set();
131
+ // Primary: devcontainer-labeled containers (historical behavior).
132
+ try {
133
+ const labeledOutput = execSync('docker ps --filter "label=devcontainer.local_folder" --format "{{.ID}}"', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
134
+ if (labeledOutput) {
135
+ for (const id of labeledOutput.split('\n')) {
136
+ if (id.trim())
137
+ containerIds.add(id.trim());
138
+ }
139
+ }
140
+ }
141
+ catch {
142
+ // Ignore and continue with broad fallback discovery.
143
+ }
144
+ // Fallback: include all running containers so prlt-managed devcontainers
145
+ // without the devcontainer.local_folder label are still discoverable.
146
+ try {
147
+ const allOutput = execSync('docker ps --format "{{.ID}}"', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
148
+ if (allOutput) {
149
+ for (const id of allOutput.split('\n')) {
150
+ if (id.trim())
151
+ containerIds.add(id.trim());
152
+ }
153
+ }
154
+ }
155
+ catch {
156
+ // Docker not available.
157
+ }
158
+ if (containerIds.size === 0)
132
159
  return sessionMap;
133
- for (const containerId of containersOutput.split('\n')) {
160
+ for (const containerId of containerIds) {
134
161
  try {
135
162
  const tmuxOutput = execSync(`docker exec ${containerId} tmux list-sessions -F "#{session_name}" 2>/dev/null`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
136
163
  if (tmuxOutput) {
@@ -159,6 +186,21 @@ export function flattenContainerSessions(containerTmuxSessions) {
159
186
  });
160
187
  return result;
161
188
  }
189
+ /**
190
+ * Find container sessions using prefix matching.
191
+ * Handles short vs full container ID mismatches between DB records and docker output.
192
+ */
193
+ export function findContainerSessionsByPrefix(containerTmuxSessions, containerId) {
194
+ const exact = containerTmuxSessions.get(containerId);
195
+ if (exact)
196
+ return exact;
197
+ for (const [key, sessions] of containerTmuxSessions) {
198
+ if (key.startsWith(containerId) || containerId.startsWith(key)) {
199
+ return sessions;
200
+ }
201
+ }
202
+ return [];
203
+ }
162
204
  /**
163
205
  * Try to find a matching tmux session for an execution with NULL sessionId.
164
206
  * First tries exact matches with known action names, then falls back to
@@ -13,7 +13,7 @@ import { findHQRoot } from '../repos/index.js';
13
13
  import { hasGitHubRemote } from '../repos/git.js';
14
14
  import { hasDevcontainerConfig } from './devcontainer.js';
15
15
  import { loadExecutionConfig, getOrPromptCoderName } from './config.js';
16
- import { runExecution, isDockerRunning, isGitHubTokenAvailable, isDevcontainerCliInstalled } from './runners.js';
16
+ import { runExecution, isDockerRunning, isGitHubTokenAvailable, isDevcontainerCliInstalled, runExecutorPreflight } from './runners.js';
17
17
  import { detectRepoWorktrees, resolveWorktreePath } from './context.js';
18
18
  import { generateBranchName, DEFAULT_EXECUTION_CONFIG, } from './types.js';
19
19
  // =============================================================================
@@ -282,6 +282,19 @@ export async function spawnAgentForTicket(ticket, agentName, storage, executionS
282
282
  }
283
283
  const displayMode = options.displayMode || 'terminal';
284
284
  const sandboxed = !(options.skipPermissions ?? false);
285
+ // Executor preflight check (TKT-1082): verify binary is available before proceeding
286
+ // For host environment, check immediately. For devcontainer, check happens after container start.
287
+ if (environment === 'host') {
288
+ const preflight = runExecutorPreflight(executor, environment);
289
+ if (!preflight.ok) {
290
+ return {
291
+ success: false,
292
+ ticketId: ticket.id,
293
+ agentName,
294
+ error: preflight.error,
295
+ };
296
+ }
297
+ }
285
298
  // Create branch in worktree(s)
286
299
  // For devcontainer environments, run git commands inside the container
287
300
  // because the worktree .git file has container paths, not host paths
@@ -438,7 +451,7 @@ export async function spawnAgentForTicket(ticket, agentName, storage, executionS
438
451
  };
439
452
  }
440
453
  else {
441
- executionStorage.updateStatus(execution.id, 'failed');
454
+ executionStorage.updateStatus(execution.id, 'failed', undefined, result.error);
442
455
  return {
443
456
  success: false,
444
457
  executionId: execution.id,
@@ -33,7 +33,7 @@ export declare class ExecutionStorage {
33
33
  /**
34
34
  * Update execution status
35
35
  */
36
- updateStatus(id: string, status: ExecutionStatus, exitCode?: number): void;
36
+ updateStatus(id: string, status: ExecutionStatus, exitCode?: number, errorMessage?: string): void;
37
37
  /**
38
38
  * Update execution with process info
39
39
  */
@@ -29,6 +29,7 @@ function rowToAgentWork(row) {
29
29
  startedAt: new Date(row.started_at),
30
30
  completedAt: row.completed_at ? new Date(row.completed_at) : undefined,
31
31
  exitCode: row.exit_code ?? undefined,
32
+ errorMessage: row.error_message || undefined,
32
33
  };
33
34
  }
34
35
  // =============================================================================
@@ -68,14 +69,28 @@ export class ExecutionStorage {
68
69
  /**
69
70
  * Update execution status
70
71
  */
71
- updateStatus(id, status, exitCode) {
72
+ updateStatus(id, status, exitCode, errorMessage) {
72
73
  const completedAt = ['completed', 'failed', 'stopped'].includes(status) ? Date.now() : null;
73
- if (exitCode !== undefined) {
74
+ if (exitCode !== undefined && errorMessage) {
75
+ this.db.prepare(`
76
+ UPDATE ${T.agent_work}
77
+ SET status = ?, completed_at = ?, exit_code = ?, error_message = ?
78
+ WHERE id = ?
79
+ `).run(status, completedAt, exitCode, errorMessage, id);
80
+ }
81
+ else if (exitCode !== undefined) {
74
82
  this.db.prepare(`
75
83
  UPDATE ${T.agent_work}
76
84
  SET status = ?, completed_at = ?, exit_code = ?
77
85
  WHERE id = ?
78
86
  `).run(status, completedAt, exitCode, id);
87
+ }
88
+ else if (errorMessage) {
89
+ this.db.prepare(`
90
+ UPDATE ${T.agent_work}
91
+ SET status = ?, completed_at = ?, error_message = ?
92
+ WHERE id = ?
93
+ `).run(status, completedAt, errorMessage, id);
79
94
  }
80
95
  else {
81
96
  this.db.prepare(`
@@ -57,6 +57,7 @@ export interface AgentWork {
57
57
  startedAt: Date;
58
58
  completedAt?: Date;
59
59
  exitCode?: number;
60
+ errorMessage?: string;
60
61
  }
61
62
  export interface ExecutionContext {
62
63
  ticketId: string;
@@ -17,4 +17,5 @@ export { registerTemplateTools } from './template.js';
17
17
  export { registerViewTools } from './view.js';
18
18
  export { registerDietTools } from './diet.js';
19
19
  export { registerLabelTools } from './label.js';
20
+ export { registerTmuxTools } from './tmux.js';
20
21
  export { registerAgentTools, registerDockerTools, registerRepoTools, registerBranchTools, registerGitHubTools, registerInitTools, registerUtilityTools, } from './cli-passthrough.js';
@@ -17,5 +17,6 @@ export { registerTemplateTools } from './template.js';
17
17
  export { registerViewTools } from './view.js';
18
18
  export { registerDietTools } from './diet.js';
19
19
  export { registerLabelTools } from './label.js';
20
+ export { registerTmuxTools } from './tmux.js';
20
21
  // CLI passthrough tools
21
22
  export { registerAgentTools, registerDockerTools, registerRepoTools, registerBranchTools, registerGitHubTools, registerInitTools, registerUtilityTools, } from './cli-passthrough.js';
@@ -0,0 +1,16 @@
1
+ /**
2
+ * MCP Tmux Tools
3
+ *
4
+ * Provides tmux interaction tools for AI agents to drive interactive CLI sessions.
5
+ * Used by the explore-cli action to perform exploratory QA testing.
6
+ *
7
+ * Tools:
8
+ * - tmux_send_keys: Send keystrokes to a tmux session
9
+ * - tmux_capture_pane: Capture current terminal screen from a tmux session
10
+ * - tmux_start_session: Start a new tmux session with an optional command
11
+ * - tmux_list_sessions: List active tmux sessions
12
+ * - tmux_kill_session: Kill a tmux session
13
+ */
14
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
+ import type { McpToolContext } from '../types.js';
16
+ export declare function registerTmuxTools(server: McpServer, _ctx: McpToolContext): void;