@proletariat/cli 0.3.35 → 0.3.40

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 (148) hide show
  1. package/README.md +37 -2
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/agent/auth.d.ts +12 -2
  4. package/dist/commands/agent/auth.js +128 -4
  5. package/dist/commands/agent/list.js +16 -7
  6. package/dist/commands/agent/status.js +32 -4
  7. package/dist/commands/board/watch.js +6 -0
  8. package/dist/commands/branch/list.d.ts +1 -0
  9. package/dist/commands/branch/list.js +43 -12
  10. package/dist/commands/branch/where.js +9 -19
  11. package/dist/commands/category/list.d.ts +2 -1
  12. package/dist/commands/category/list.js +38 -13
  13. package/dist/commands/{claude.d.ts → claude/index.d.ts} +1 -1
  14. package/dist/commands/{claude.js → claude/index.js} +12 -12
  15. package/dist/commands/claude/open.d.ts +13 -0
  16. package/dist/commands/claude/open.js +175 -0
  17. package/dist/commands/diet.js +18 -2
  18. package/dist/commands/docker/logs.js +7 -3
  19. package/dist/commands/docker/shell.js +6 -0
  20. package/dist/commands/docker/start.js +20 -4
  21. package/dist/commands/docker/sync.d.ts +4 -0
  22. package/dist/commands/docker/sync.js +30 -2
  23. package/dist/commands/epic/show.d.ts +13 -0
  24. package/dist/commands/epic/show.js +16 -0
  25. package/dist/commands/epic/ticket.js +7 -24
  26. package/dist/commands/epic/view.js +27 -0
  27. package/dist/commands/execution/config.d.ts +0 -4
  28. package/dist/commands/execution/config.js +14 -46
  29. package/dist/commands/execution/index.js +2 -1
  30. package/dist/commands/execution/logs.js +7 -1
  31. package/dist/commands/execution/stop.js +2 -1
  32. package/dist/commands/execution/view.js +30 -26
  33. package/dist/commands/init.js +2 -19
  34. package/dist/commands/label/create.js +2 -1
  35. package/dist/commands/label/delete.js +2 -1
  36. package/dist/commands/label/group/create.js +2 -1
  37. package/dist/commands/label/group/list.js +2 -1
  38. package/dist/commands/label/list.js +2 -1
  39. package/dist/commands/mcp-server.js +27 -1
  40. package/dist/commands/phase/template/list.js +2 -1
  41. package/dist/commands/pmo/init.js +12 -40
  42. package/dist/commands/project/create.js +3 -4
  43. package/dist/commands/project/update.js +5 -6
  44. package/dist/commands/pull.js +24 -0
  45. package/dist/commands/qa/index.d.ts +54 -0
  46. package/dist/commands/qa/index.js +762 -0
  47. package/dist/commands/repo/view.js +2 -8
  48. package/dist/commands/session/attach.js +4 -4
  49. package/dist/commands/session/create.d.ts +19 -0
  50. package/dist/commands/session/create.js +102 -0
  51. package/dist/commands/session/health.js +4 -23
  52. package/dist/commands/session/index.js +14 -1
  53. package/dist/commands/session/list.js +9 -8
  54. package/dist/commands/session/peek.d.ts +38 -0
  55. package/dist/commands/session/peek.js +316 -0
  56. package/dist/commands/session/poke.d.ts +27 -0
  57. package/dist/commands/session/poke.js +219 -0
  58. package/dist/commands/spec/view.js +29 -0
  59. package/dist/commands/template/list.js +2 -1
  60. package/dist/commands/theme/add-names.d.ts +4 -0
  61. package/dist/commands/theme/add-names.js +11 -1
  62. package/dist/commands/theme/create.d.ts +2 -0
  63. package/dist/commands/theme/create.js +8 -0
  64. package/dist/commands/ticket/bulk.js +2 -2
  65. package/dist/commands/ticket/complete.js +2 -2
  66. package/dist/commands/ticket/create.js +21 -0
  67. package/dist/commands/ticket/delete.js +8 -0
  68. package/dist/commands/ticket/edit.js +25 -0
  69. package/dist/commands/ticket/epic.js +17 -43
  70. package/dist/commands/ticket/index.js +2 -2
  71. package/dist/commands/ticket/move.js +25 -2
  72. package/dist/commands/ticket/resolve.js +3 -4
  73. package/dist/commands/ticket/show.d.ts +13 -0
  74. package/dist/commands/ticket/show.js +16 -0
  75. package/dist/commands/ticket/template/list.js +2 -1
  76. package/dist/commands/ticket/view.d.ts +0 -1
  77. package/dist/commands/ticket/view.js +30 -1
  78. package/dist/commands/work/index.js +4 -0
  79. package/dist/commands/work/spawn-all.js +1 -1
  80. package/dist/commands/work/spawn.js +15 -4
  81. package/dist/commands/work/start.js +186 -103
  82. package/dist/commands/work/status.d.ts +14 -0
  83. package/dist/commands/work/status.js +60 -0
  84. package/dist/commands/work/watch.js +1 -1
  85. package/dist/commands/workflow/index.js +2 -1
  86. package/dist/commands/workflow/show.d.ts +13 -0
  87. package/dist/commands/workflow/show.js +16 -0
  88. package/dist/commands/workspace/add.js +15 -0
  89. package/dist/commands/workspace/list.js +2 -1
  90. package/dist/commands/workspace/prune.js +7 -7
  91. package/dist/hooks/init.js +10 -2
  92. package/dist/lib/agents/commands.d.ts +5 -0
  93. package/dist/lib/agents/commands.js +143 -97
  94. package/dist/lib/branch/index.d.ts +1 -0
  95. package/dist/lib/database/drizzle-schema.d.ts +465 -0
  96. package/dist/lib/database/drizzle-schema.js +53 -0
  97. package/dist/lib/database/index.d.ts +47 -1
  98. package/dist/lib/database/index.js +138 -20
  99. package/dist/lib/execution/config.d.ts +15 -1
  100. package/dist/lib/execution/config.js +28 -0
  101. package/dist/lib/execution/runners.d.ts +45 -0
  102. package/dist/lib/execution/runners.js +187 -26
  103. package/dist/lib/execution/session-utils.d.ts +16 -1
  104. package/dist/lib/execution/session-utils.js +71 -4
  105. package/dist/lib/execution/spawner.js +15 -2
  106. package/dist/lib/execution/storage.d.ts +6 -1
  107. package/dist/lib/execution/storage.js +35 -5
  108. package/dist/lib/execution/types.d.ts +3 -0
  109. package/dist/lib/mcp/tools/board.js +4 -6
  110. package/dist/lib/mcp/tools/cli-passthrough.js +25 -6
  111. package/dist/lib/mcp/tools/epic.js +8 -3
  112. package/dist/lib/mcp/tools/index.d.ts +1 -0
  113. package/dist/lib/mcp/tools/index.js +1 -0
  114. package/dist/lib/mcp/tools/spec.js +1 -1
  115. package/dist/lib/mcp/tools/ticket.js +11 -9
  116. package/dist/lib/mcp/tools/tmux.d.ts +16 -0
  117. package/dist/lib/mcp/tools/tmux.js +182 -0
  118. package/dist/lib/mcp/tools/work.js +148 -6
  119. package/dist/lib/mcp/types.d.ts +10 -0
  120. package/dist/lib/multiline-input.js +2 -1
  121. package/dist/lib/pmo/base-command.js +4 -4
  122. package/dist/lib/pmo/schema.d.ts +1 -1
  123. package/dist/lib/pmo/schema.js +1 -0
  124. package/dist/lib/pmo/storage/actions.js +1 -1
  125. package/dist/lib/pmo/storage/base.js +402 -50
  126. package/dist/lib/pmo/storage/dependencies.d.ts +1 -0
  127. package/dist/lib/pmo/storage/dependencies.js +11 -3
  128. package/dist/lib/pmo/storage/epics.js +1 -1
  129. package/dist/lib/pmo/storage/helpers.d.ts +4 -4
  130. package/dist/lib/pmo/storage/helpers.js +36 -26
  131. package/dist/lib/pmo/storage/projects.d.ts +2 -0
  132. package/dist/lib/pmo/storage/projects.js +207 -119
  133. package/dist/lib/pmo/storage/specs.d.ts +2 -0
  134. package/dist/lib/pmo/storage/specs.js +274 -188
  135. package/dist/lib/pmo/storage/tickets.d.ts +2 -0
  136. package/dist/lib/pmo/storage/tickets.js +350 -290
  137. package/dist/lib/pmo/storage/types.d.ts +1 -0
  138. package/dist/lib/pmo/storage/views.d.ts +2 -0
  139. package/dist/lib/pmo/storage/views.js +183 -130
  140. package/dist/lib/prompt-command.d.ts +20 -0
  141. package/dist/lib/prompt-command.js +38 -2
  142. package/dist/lib/prompt-json.d.ts +41 -4
  143. package/dist/lib/prompt-json.js +138 -7
  144. package/dist/lib/styles.d.ts +37 -0
  145. package/dist/lib/styles.js +73 -0
  146. package/oclif.manifest.json +4046 -3385
  147. package/package.json +11 -6
  148. 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
  /**
@@ -5,7 +5,7 @@
5
5
  * Uses the workspace_settings table (not pmo_settings - execution is workspace-level).
6
6
  */
7
7
  import Database from 'better-sqlite3';
8
- import { ExecutionConfig, TerminalApp, Shell, DisplayMode, OutputMode, ExecutionEnvironment } from './types.js';
8
+ import { ExecutionConfig, TerminalApp, Shell, DisplayMode, OutputMode, ExecutionEnvironment, AuthMethod } from './types.js';
9
9
  import { type JsonFlags } from '../prompt-json.js';
10
10
  declare const CONFIG_KEYS: {
11
11
  terminalApp: string;
@@ -28,6 +28,7 @@ declare const CONFIG_KEYS: {
28
28
  vmKeyPath: string;
29
29
  vmSyncMethod: string;
30
30
  coderName: string;
31
+ authMethod: string;
31
32
  };
32
33
  /**
33
34
  * Load execution config from database, merging with defaults
@@ -55,6 +56,19 @@ export declare function saveTmuxControlMode(db: Database.Database, enabled: bool
55
56
  * When enabled, new terminal tabs open without stealing focus from current window.
56
57
  */
57
58
  export declare function saveTerminalOpenInBackground(db: Database.Database, enabled: boolean): void;
59
+ /**
60
+ * Save auth method preference (oauth or apikey)
61
+ */
62
+ export declare function saveAuthMethod(db: Database.Database, method: AuthMethod): void;
63
+ /**
64
+ * Get saved auth method preference.
65
+ * Returns null if no preference has been saved (user should be prompted).
66
+ */
67
+ export declare function getAuthMethod(db: Database.Database): AuthMethod | null;
68
+ /**
69
+ * Clear saved auth method preference (will prompt again next time)
70
+ */
71
+ export declare function clearAuthMethod(db: Database.Database): void;
58
72
  /**
59
73
  * Check if terminal app preference has been set
60
74
  */
@@ -32,6 +32,7 @@ const CONFIG_KEYS = {
32
32
  vmKeyPath: 'execution.vm.key_path',
33
33
  vmSyncMethod: 'execution.vm.sync_method',
34
34
  coderName: 'coder.name',
35
+ authMethod: 'execution.auth_method',
35
36
  };
36
37
  /**
37
38
  * Get a setting value from the database
@@ -97,6 +98,11 @@ export function loadExecutionConfig(db) {
97
98
  if (sandboxed !== null) {
98
99
  config.sandboxed = sandboxed === 'true';
99
100
  }
101
+ // Load auth method preference
102
+ const authMethod = getSetting(db, CONFIG_KEYS.authMethod);
103
+ if (authMethod) {
104
+ config.authMethod = authMethod;
105
+ }
100
106
  // Load tmux settings
101
107
  const tmuxSession = getSetting(db, CONFIG_KEYS.tmuxSession);
102
108
  if (tmuxSession) {
@@ -178,6 +184,28 @@ export function saveTmuxControlMode(db, enabled) {
178
184
  export function saveTerminalOpenInBackground(db, enabled) {
179
185
  setSetting(db, CONFIG_KEYS.terminalOpenInBackground, enabled.toString());
180
186
  }
187
+ /**
188
+ * Save auth method preference (oauth or apikey)
189
+ */
190
+ export function saveAuthMethod(db, method) {
191
+ setSetting(db, CONFIG_KEYS.authMethod, method);
192
+ }
193
+ /**
194
+ * Get saved auth method preference.
195
+ * Returns null if no preference has been saved (user should be prompted).
196
+ */
197
+ export function getAuthMethod(db) {
198
+ const value = getSetting(db, CONFIG_KEYS.authMethod);
199
+ if (value === 'oauth' || value === 'apikey')
200
+ return value;
201
+ return null;
202
+ }
203
+ /**
204
+ * Clear saved auth method preference (will prompt again next time)
205
+ */
206
+ export function clearAuthMethod(db) {
207
+ db.prepare(`DELETE FROM ${SETTINGS_TABLE} WHERE key = ?`).run(CONFIG_KEYS.authMethod);
208
+ }
181
209
  /**
182
210
  * Check if terminal app preference has been set
183
211
  */
@@ -41,6 +41,17 @@ export declare function buildTmuxAttachCommand(useControlMode: boolean, includeU
41
41
  */
42
42
  export declare function configureITermTmuxPreferences(mode: 'tab' | 'window'): void;
43
43
  export declare function configureITermTmuxWindowMode(mode: 'tab' | 'window'): void;
44
+ /**
45
+ * Build the tmux script that runs inside the container.
46
+ * In background mode: kills PID 1 (sleep infinity) after Claude exits to stop/remove container.
47
+ * In terminal/foreground mode: drops into exec bash for user inspection.
48
+ */
49
+ export declare function buildTmuxScript(sessionName: string, claudeCmd: string, displayMode: DisplayMode): string;
50
+ /**
51
+ * Get the auto-remove flags for docker run based on display mode.
52
+ * Background mode containers get --rm so Docker removes them when they stop.
53
+ */
54
+ export declare function getDockerAutoRemoveFlags(displayMode: DisplayMode): string[];
44
55
  /**
45
56
  * Check if the claude-credentials Docker volume exists.
46
57
  */
@@ -63,6 +74,40 @@ export declare function getDockerCredentialInfo(): {
63
74
  expiresAt: Date;
64
75
  subscriptionType?: string;
65
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
+ };
66
111
  export interface RunnerResult {
67
112
  success: boolean;
68
113
  pid?: string;
@@ -87,6 +87,47 @@ export function configureITermTmuxWindowMode(mode) {
87
87
  configureITermTmuxPreferences(mode);
88
88
  }
89
89
  // =============================================================================
90
+ // Background Mode Cleanup Helpers (TKT-988)
91
+ // =============================================================================
92
+ /**
93
+ * Build the tmux script that runs inside the container.
94
+ * In background mode: kills PID 1 (sleep infinity) after Claude exits to stop/remove container.
95
+ * In terminal/foreground mode: drops into exec bash for user inspection.
96
+ */
97
+ export function buildTmuxScript(sessionName, claudeCmd, displayMode) {
98
+ if (displayMode === 'background') {
99
+ return `#!/bin/bash
100
+ export TERM=xterm-256color
101
+ export COLORTERM=truecolor
102
+ unset CI
103
+ echo "🚀 Starting: ${sessionName}"
104
+ echo ""
105
+ ${claudeCmd}
106
+ echo ""
107
+ echo "✅ Agent work complete. Cleaning up container..."
108
+ kill 1
109
+ `;
110
+ }
111
+ return `#!/bin/bash
112
+ export TERM=xterm-256color
113
+ export COLORTERM=truecolor
114
+ unset CI
115
+ echo "🚀 Starting: ${sessionName}"
116
+ echo ""
117
+ ${claudeCmd}
118
+ echo ""
119
+ echo "✅ Agent work complete. Press Enter to close or run more commands."
120
+ exec bash
121
+ `;
122
+ }
123
+ /**
124
+ * Get the auto-remove flags for docker run based on display mode.
125
+ * Background mode containers get --rm so Docker removes them when they stop.
126
+ */
127
+ export function getDockerAutoRemoveFlags(displayMode) {
128
+ return displayMode === 'background' ? ['--rm'] : [];
129
+ }
130
+ // =============================================================================
90
131
  // Docker Credential Helpers
91
132
  // =============================================================================
92
133
  const CLAUDE_CREDENTIALS_VOLUME = 'claude-credentials';
@@ -147,10 +188,125 @@ export function getDockerCredentialInfo() {
147
188
  return null;
148
189
  }
149
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
+ }
150
306
  // =============================================================================
151
307
  // Executor Commands
152
308
  // =============================================================================
153
- function getExecutorCommand(executor, prompt, skipPermissions = true) {
309
+ export function getExecutorCommand(executor, prompt, skipPermissions = true) {
154
310
  switch (executor) {
155
311
  case 'claude-code':
156
312
  if (skipPermissions) {
@@ -740,7 +896,7 @@ function imageExists(imageName) {
740
896
  * Create and start a Docker container for an agent.
741
897
  * Uses raw Docker commands instead of devcontainer CLI.
742
898
  */
743
- function createDockerContainer(context, containerName, imageName, config) {
899
+ function createDockerContainer(context, containerName, imageName, config, displayMode = 'terminal') {
744
900
  // Build mount flags
745
901
  // KEY: Use a named Docker volume for Claude credentials - this is how devcontainer.json
746
902
  // was handling it. The volume persists across containers, so login once = logged in everywhere.
@@ -794,12 +950,16 @@ function createDockerContainer(context, containerName, imageName, config) {
794
950
  '--cap-add=NET_RAW', // For firewall setup
795
951
  // Note: After firewall is set up, the container is network-restricted
796
952
  ];
953
+ // Auto-remove container on stop for background mode (R5)
954
+ // Background containers should be cleaned up after work completes — nobody will attach to inspect
955
+ const autoRemoveFlags = getDockerAutoRemoveFlags(displayMode);
797
956
  try {
798
957
  const createCmd = [
799
958
  'docker run -d',
800
959
  `--name ${containerName}`,
801
960
  '--user node',
802
961
  '-w /workspace',
962
+ ...autoRemoveFlags,
803
963
  ...mounts,
804
964
  ...envVars,
805
965
  ...resourceFlags,
@@ -898,7 +1058,7 @@ function runContainerSetup(containerId, sandboxed = true) {
898
1058
  * Builds image and creates container if needed.
899
1059
  * Returns the container ID if successful, null otherwise.
900
1060
  */
901
- function ensureDockerContainer(context, config) {
1061
+ function ensureDockerContainer(context, config, displayMode = 'terminal') {
902
1062
  const containerName = getContainerName(context.agentName);
903
1063
  const imageName = getImageName(context.agentName);
904
1064
  // Always create fresh container to ensure mounts are up-to-date
@@ -927,7 +1087,7 @@ function ensureDockerContainer(context, config) {
927
1087
  }
928
1088
  // Create and start container
929
1089
  console.debug(`[runners:docker] Creating container ${containerName}`);
930
- if (!createDockerContainer(context, containerName, imageName, config)) {
1090
+ if (!createDockerContainer(context, containerName, imageName, config, displayMode)) {
931
1091
  return null;
932
1092
  }
933
1093
  const containerId = getContainerId(containerName);
@@ -1105,13 +1265,21 @@ export async function runDevcontainer(context, executor, config, displayMode = '
1105
1265
  copyClaudeCredentials(context.agentDir);
1106
1266
  // Start or reuse container using raw Docker commands
1107
1267
  // No devcontainer CLI required!
1108
- const containerId = ensureDockerContainer(context, config);
1268
+ const containerId = ensureDockerContainer(context, config, displayMode);
1109
1269
  if (!containerId) {
1110
1270
  return {
1111
1271
  success: false,
1112
1272
  error: 'Failed to start Docker container. Check Docker logs for details.',
1113
1273
  };
1114
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
+ }
1115
1283
  // Write prompt to file in worktree (accessible by container)
1116
1284
  const { hostPath: promptHostPath, containerPath: promptFile } = writePromptFile(context);
1117
1285
  // Inject fresh GitHub token into container (containers may be reused with stale/empty tokens)
@@ -1456,21 +1624,10 @@ async function runDevcontainerInTmux(context, devcontainerCmd, config, displayMo
1456
1624
  // Extract the claude command from the devcontainer command
1457
1625
  const cmdMatch = devcontainerCmd.match(/bash -c '(.+)'$/);
1458
1626
  const claudeCmd = cmdMatch ? cmdMatch[1] : devcontainerCmd;
1459
- // Create a script inside the container that runs claude and keeps shell open
1460
- // TERM must be set for Claude's TUI to render properly
1461
- // Unset CI to prevent Claude from detecting CI environment which suppresses TUI output
1462
- // Note: We keep DEVCONTAINER set so prlt workspace detection works correctly
1463
- const tmuxScript = `#!/bin/bash
1464
- export TERM=xterm-256color
1465
- export COLORTERM=truecolor
1466
- unset CI
1467
- echo "🚀 Starting: ${sessionName}"
1468
- echo ""
1469
- ${claudeCmd}
1470
- echo ""
1471
- echo "✅ Agent work complete. Press Enter to close or run more commands."
1472
- exec bash
1473
- `;
1627
+ // Create a script inside the container that runs claude
1628
+ // Background mode (R1): kills PID 1 to stop container after completion
1629
+ // Terminal/foreground mode (R2): drops into exec bash for user inspection
1630
+ const tmuxScript = buildTmuxScript(sessionName, claudeCmd, displayMode);
1474
1631
  const scriptPath = `/tmp/prlt-${sessionName}.sh`;
1475
1632
  // Write script and start tmux session inside container
1476
1633
  // IMPORTANT: We create the session with bash first, then send keys to run the script.
@@ -1740,6 +1897,8 @@ exec $SHELL
1740
1897
  export async function runDocker(context, executor, config) {
1741
1898
  const prompt = buildPrompt(context);
1742
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);
1743
1902
  try {
1744
1903
  // Check if docker is available
1745
1904
  execSync('which docker', { stdio: 'pipe' });
@@ -1764,10 +1923,10 @@ export async function runDocker(context, executor, config) {
1764
1923
  if (config.docker.cpus) {
1765
1924
  dockerCmd += ` --cpus ${config.docker.cpus}`;
1766
1925
  }
1767
- // Escape prompt for shell
1768
- const escapedPrompt = prompt.replace(/'/g, "'\\''");
1926
+ // Build executor command with properly escaped args
1927
+ const escapedArgs = args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ');
1769
1928
  dockerCmd += ` ${config.docker.image}`;
1770
- dockerCmd += ` claude --print '${escapedPrompt}'`;
1929
+ dockerCmd += ` ${cmd} ${escapedArgs}`;
1771
1930
  const containerId = execSync(dockerCmd, { encoding: 'utf-8' }).trim();
1772
1931
  return {
1773
1932
  success: true,
@@ -1796,6 +1955,8 @@ export async function runVm(context, executor, config, host) {
1796
1955
  const user = config.vm.user;
1797
1956
  const keyPath = config.vm.keyPath;
1798
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);
1799
1960
  try {
1800
1961
  // Build SSH options
1801
1962
  let sshOpts = '';
@@ -1817,9 +1978,9 @@ export async function runVm(context, executor, config, host) {
1817
1978
  const gitPullCmd = `cd ${remoteWorkspace} && git fetch && git checkout ${context.branch}`;
1818
1979
  execSync(`ssh ${sshOpts} ${user}@${targetHost} "${gitPullCmd}"`, { stdio: 'pipe' });
1819
1980
  }
1820
- // Execute on remote
1821
- const escapedPrompt = prompt.replace(/'/g, "'\\''");
1822
- 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}`;
1823
1984
  const sshCmd = `ssh ${sshOpts} ${user}@${targetHost} "nohup ${remoteCmd} > /tmp/work-${context.ticketId}.log 2>&1 &"`;
1824
1985
  execSync(sshCmd, { stdio: 'pipe' });
1825
1986
  return {
@@ -2,8 +2,18 @@
2
2
  * Session Utilities
3
3
  *
4
4
  * Shared utilities for tmux session naming, parsing, and discovery.
5
- * Used by session/list.ts and session/attach.ts commands.
5
+ * Used by session/list.ts, session/attach.ts, session/health.ts, and session/peek.ts commands.
6
6
  */
7
+ /**
8
+ * Capture the last N lines from a tmux pane.
9
+ * Supports both host tmux sessions and container tmux sessions (via docker exec).
10
+ *
11
+ * @param sessionId - The tmux session ID to capture from
12
+ * @param lines - Number of scrollback lines to capture
13
+ * @param containerId - Optional container ID for container-based sessions
14
+ * @returns The captured pane content, or null if capture fails
15
+ */
16
+ export declare function captureTmuxPane(sessionId: string, lines: number, containerId?: string): string | null;
7
17
  /**
8
18
  * Known action names used in session naming.
9
19
  * These are the actions defined in pmo/actions/ that may be used when spawning agents.
@@ -50,6 +60,11 @@ export declare function flattenContainerSessions(containerTmuxSessions: Map<stri
50
60
  sessionName: string;
51
61
  containerId: string;
52
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[];
53
68
  /**
54
69
  * Try to find a matching tmux session for an execution with NULL sessionId.
55
70
  * First tries exact matches with known action names, then falls back to