@proletariat/cli 0.3.9 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +25 -0
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/action/index.js +1 -1
  4. package/dist/commands/action/run.js +8 -12
  5. package/dist/commands/agent/auth.d.ts +30 -0
  6. package/dist/commands/agent/auth.js +172 -0
  7. package/dist/commands/agent/discover.d.ts +9 -0
  8. package/dist/commands/agent/discover.js +67 -0
  9. package/dist/commands/agent/index.js +47 -12
  10. package/dist/commands/agent/list.d.ts +4 -1
  11. package/dist/commands/agent/list.js +78 -16
  12. package/dist/commands/agent/login.js +35 -31
  13. package/dist/commands/agent/restart.js +2 -0
  14. package/dist/commands/agent/shell.js +78 -19
  15. package/dist/commands/agent/staff/add.js +1 -12
  16. package/dist/commands/agent/staff/remove.js +9 -7
  17. package/dist/commands/agent/status.js +17 -4
  18. package/dist/commands/agent/temp/cleanup.js +7 -3
  19. package/dist/commands/agent/themes/index.js +4 -5
  20. package/dist/commands/agent/themes/list.js +5 -5
  21. package/dist/commands/agent/visit.js +17 -4
  22. package/dist/commands/branch/create.d.ts +4 -0
  23. package/dist/commands/branch/create.js +16 -8
  24. package/dist/commands/branch/index.js +1 -1
  25. package/dist/commands/branch/where.js +1 -0
  26. package/dist/commands/claude.d.ts +38 -0
  27. package/dist/commands/claude.js +899 -0
  28. package/dist/commands/commit.js +1 -1
  29. package/dist/commands/config/index.d.ts +12 -0
  30. package/dist/commands/config/index.js +271 -0
  31. package/dist/commands/docker/clean.js +2 -2
  32. package/dist/commands/docker/index.js +2 -2
  33. package/dist/commands/docker/list.js +3 -8
  34. package/dist/commands/docker/logs.js +2 -2
  35. package/dist/commands/docker/prune.js +1 -1
  36. package/dist/commands/docker/restart.js +2 -2
  37. package/dist/commands/docker/shell.js +2 -2
  38. package/dist/commands/docker/start.js +2 -2
  39. package/dist/commands/docker/status.js +1 -1
  40. package/dist/commands/docker/stop.js +2 -2
  41. package/dist/commands/docker/sync.js +2 -2
  42. package/dist/commands/epic/index.js +1 -1
  43. package/dist/commands/epic/link/index.js +25 -14
  44. package/dist/commands/epic/link/remove.js +2 -0
  45. package/dist/commands/epic/list.js +5 -5
  46. package/dist/commands/epic/progress.js +10 -4
  47. package/dist/commands/epic/spec.js +2 -0
  48. package/dist/commands/epic/ticket.js +3 -0
  49. package/dist/commands/execution/stop.js +1 -0
  50. package/dist/commands/init.js +4 -4
  51. package/dist/commands/project/index.js +1 -1
  52. package/dist/commands/project/spec.js +7 -0
  53. package/dist/commands/repo/add.js +1 -0
  54. package/dist/commands/repo/remove.js +1 -0
  55. package/dist/commands/roadmap/add-project.d.ts +18 -0
  56. package/dist/commands/roadmap/add-project.js +135 -0
  57. package/dist/commands/roadmap/create.d.ts +22 -0
  58. package/dist/commands/roadmap/create.js +156 -0
  59. package/dist/commands/roadmap/delete.d.ts +17 -0
  60. package/dist/commands/roadmap/delete.js +104 -0
  61. package/dist/commands/roadmap/generate.d.ts +22 -0
  62. package/dist/commands/roadmap/generate.js +201 -0
  63. package/dist/commands/roadmap/index.d.ts +13 -0
  64. package/dist/commands/roadmap/index.js +61 -0
  65. package/dist/commands/roadmap/list.d.ts +12 -0
  66. package/dist/commands/roadmap/list.js +42 -0
  67. package/dist/commands/roadmap/remove-project.d.ts +18 -0
  68. package/dist/commands/roadmap/remove-project.js +147 -0
  69. package/dist/commands/roadmap/reorder.d.ts +17 -0
  70. package/dist/commands/roadmap/reorder.js +157 -0
  71. package/dist/commands/roadmap/update.d.ts +19 -0
  72. package/dist/commands/roadmap/update.js +136 -0
  73. package/dist/commands/roadmap/view.d.ts +16 -0
  74. package/dist/commands/roadmap/view.js +103 -0
  75. package/dist/commands/spec/index.js +1 -1
  76. package/dist/commands/spec/link/index.js +24 -13
  77. package/dist/commands/spec/link/remove.js +2 -0
  78. package/dist/commands/status/index.js +1 -1
  79. package/dist/commands/status/list.js +0 -8
  80. package/dist/commands/template/delete.js +2 -0
  81. package/dist/commands/terminal/title.d.ts +12 -0
  82. package/dist/commands/terminal/title.js +48 -0
  83. package/dist/commands/ticket/complete.js +2 -0
  84. package/dist/commands/ticket/create.js +4 -2
  85. package/dist/commands/ticket/delete.js +2 -0
  86. package/dist/commands/ticket/edit.js +8 -2
  87. package/dist/commands/ticket/link/index.js +17 -3
  88. package/dist/commands/ticket/link/remove.js +2 -0
  89. package/dist/commands/ticket/list.js +1 -2
  90. package/dist/commands/ticket/move.js +2 -0
  91. package/dist/commands/ticket/project.js +3 -1
  92. package/dist/commands/ticket/reassign.js +2 -0
  93. package/dist/commands/ticket/spec.js +4 -2
  94. package/dist/commands/ticket/template/apply.js +4 -3
  95. package/dist/commands/ticket/template/create.js +2 -0
  96. package/dist/commands/ticket/template/index.js +1 -1
  97. package/dist/commands/ticket/update.js +2 -0
  98. package/dist/commands/work/index.js +1 -1
  99. package/dist/commands/work/revise.js +7 -1
  100. package/dist/commands/work/spawn.d.ts +2 -1
  101. package/dist/commands/work/spawn.js +131 -36
  102. package/dist/commands/work/start.d.ts +2 -1
  103. package/dist/commands/work/start.js +349 -69
  104. package/dist/commands/work/watch.js +10 -2
  105. package/dist/commands/workflow/create.js +3 -3
  106. package/dist/commands/workflow/switch.js +2 -1
  107. package/dist/commands/workspace/remove.js +0 -8
  108. package/dist/commands/workspace/use.js +1 -9
  109. package/dist/lib/agents/commands.js +18 -13
  110. package/dist/lib/database/index.d.ts +19 -12
  111. package/dist/lib/database/index.js +158 -42
  112. package/dist/lib/docker/resolve.js +1 -1
  113. package/dist/lib/execution/config.d.ts +6 -0
  114. package/dist/lib/execution/config.js +15 -2
  115. package/dist/lib/execution/devcontainer.d.ts +2 -0
  116. package/dist/lib/execution/devcontainer.js +41 -9
  117. package/dist/lib/execution/runners.d.ts +85 -3
  118. package/dist/lib/execution/runners.js +925 -228
  119. package/dist/lib/execution/spawner.d.ts +2 -2
  120. package/dist/lib/execution/spawner.js +4 -3
  121. package/dist/lib/execution/storage.d.ts +2 -1
  122. package/dist/lib/execution/storage.js +9 -13
  123. package/dist/lib/execution/types.d.ts +10 -1
  124. package/dist/lib/execution/types.js +3 -1
  125. package/dist/lib/init/index.js +1 -0
  126. package/dist/lib/machine-config.js +1 -1
  127. package/dist/lib/pmo/base-command.js +5 -9
  128. package/dist/lib/pmo/index.js +2 -0
  129. package/dist/lib/pmo/schema.d.ts +6 -0
  130. package/dist/lib/pmo/schema.js +36 -0
  131. package/dist/lib/pmo/storage/base.js +3 -3
  132. package/dist/lib/pmo/storage/index.d.ts +16 -1
  133. package/dist/lib/pmo/storage/index.js +45 -0
  134. package/dist/lib/pmo/storage/roadmaps.d.ts +62 -0
  135. package/dist/lib/pmo/storage/roadmaps.js +301 -0
  136. package/dist/lib/pmo/storage/specs.js +2 -0
  137. package/dist/lib/pmo/storage/types.d.ts +14 -0
  138. package/dist/lib/pmo/sync-manager.d.ts +1 -1
  139. package/dist/lib/pmo/sync-manager.js +1 -1
  140. package/dist/lib/pmo/types.d.ts +41 -0
  141. package/dist/lib/pmo/utils.d.ts +2 -0
  142. package/dist/lib/pmo/utils.js +22 -1
  143. package/dist/lib/repos/index.js +7 -1
  144. package/dist/lib/terminal.d.ts +31 -0
  145. package/dist/lib/terminal.js +48 -0
  146. package/dist/lib/themes.d.ts +21 -3
  147. package/dist/lib/themes.js +80 -23
  148. package/dist/lib/workspace-config.d.ts +80 -0
  149. package/dist/lib/workspace-config.js +100 -0
  150. package/oclif.manifest.json +4065 -3225
  151. package/package.json +10 -6
  152. package/LICENSE +0 -21
@@ -7,7 +7,7 @@ import { styles } from '../../lib/styles.js';
7
7
  import { getWorkspaceInfo } from '../../lib/agents/commands.js';
8
8
  import { ExecutionStorage } from '../../lib/execution/storage.js';
9
9
  import { hasDevcontainerConfig } from '../../lib/execution/devcontainer.js';
10
- import { spawnForColumn, getAvailableAgents, isDockerRunning, } from '../../lib/execution/spawner.js';
10
+ import { spawnForColumn, getAvailableAgents, isDockerRunning, isDevcontainerCliInstalled, } from '../../lib/execution/spawner.js';
11
11
  import { promptExecutionSettings } from '../../lib/execution/config.js';
12
12
  import { shouldOutputJson, outputPromptAsJson, outputErrorAsJson, createMetadata, buildPromptConfig, } from '../../lib/prompt-json.js';
13
13
  export default class WorkWatch extends PMOCommand {
@@ -169,11 +169,17 @@ export default class WorkWatch extends PMOCommand {
169
169
  this.warn('Docker is not running. Agents will run on host instead of devcontainer.\n' +
170
170
  'Start Docker Desktop for sandboxed execution.');
171
171
  }
172
+ // Devcontainer CLI check
173
+ const devcontainerCliInstalled = isDevcontainerCliInstalled();
174
+ if (hasDevcontainer && dockerRunning && !devcontainerCliInstalled) {
175
+ this.warn('devcontainer CLI is not installed. Agents will run on host instead of devcontainer.\n' +
176
+ 'Install with: npm install -g @devcontainers/cli');
177
+ }
172
178
  // Prompt for environment and display mode if not provided
173
179
  this.environment = 'host';
174
180
  this.displayMode = 'terminal';
175
181
  if (!flags.mode) {
176
- if (hasDevcontainer && dockerRunning) {
182
+ if (hasDevcontainer && dockerRunning && devcontainerCliInstalled) {
177
183
  // Prompt for environment choice
178
184
  const { selectedEnvironment } = await inquirer.prompt([
179
185
  {
@@ -275,8 +281,10 @@ export default class WorkWatch extends PMOCommand {
275
281
  this.log(styles.muted('Press Ctrl+C to stop watching'));
276
282
  this.log('');
277
283
  while (this.isRunning) {
284
+ // eslint-disable-next-line no-await-in-loop -- Continuous polling loop
278
285
  await this.pollForNewTickets(flags, executionStorage, workspaceInfo, db);
279
286
  // Wait for interval
287
+ // eslint-disable-next-line no-await-in-loop -- Poll interval delay
280
288
  await new Promise(resolve => setTimeout(resolve, flags.interval * 1000));
281
289
  }
282
290
  db.close();
@@ -72,10 +72,9 @@ export default class WorkflowCreate extends PMOCommand {
72
72
  name: name,
73
73
  description: flags.description,
74
74
  });
75
- // If statuses were provided, add them
75
+ // If statuses were provided, add them sequentially for consistent ordering
76
76
  if (flags.statuses) {
77
- const statusNames = flags.statuses.split(',').map(s => s.trim()).filter(s => s);
78
- const defaultCategories = ['backlog', 'unstarted', 'started', 'started', 'completed'];
77
+ const statusNames = flags.statuses.split(',').map(s => s.trim()).filter(Boolean);
79
78
  for (let i = 0; i < statusNames.length; i++) {
80
79
  const statusName = statusNames[i];
81
80
  // Assign categories based on position: first = backlog, second = unstarted, middle = started, last = completed
@@ -92,6 +91,7 @@ export default class WorkflowCreate extends PMOCommand {
92
91
  else {
93
92
  category = 'started';
94
93
  }
94
+ // eslint-disable-next-line no-await-in-loop
95
95
  await this.storage.createStatus(workflow.id, {
96
96
  name: statusName,
97
97
  category: category,
@@ -93,7 +93,7 @@ export default class WorkflowSwitch extends PMOCommand {
93
93
  const defaultStatus = newStatuses.find(s => s.isDefault) || newStatuses[0];
94
94
  // Update project workflow_id
95
95
  await this.storage.updateProject(projectId, { workflowId: workflowId });
96
- // Migrate tickets to new statuses
96
+ // Migrate tickets to new statuses - sequential for data integrity
97
97
  let migratedCount = 0;
98
98
  for (const ticket of tickets) {
99
99
  // Get old status category
@@ -102,6 +102,7 @@ export default class WorkflowSwitch extends PMOCommand {
102
102
  const newStatus = categoryToStatus[oldCategory] ||
103
103
  (defaultStatus ? { id: defaultStatus.id, name: defaultStatus.name } : null);
104
104
  if (newStatus) {
105
+ // eslint-disable-next-line no-await-in-loop
105
106
  await this.storage.moveTicket(projectId, ticket.id, newStatus.name);
106
107
  migratedCount++;
107
108
  }
@@ -25,14 +25,6 @@ export default class WorkspaceRemove extends Command {
25
25
  const { args, flags } = await this.parse(WorkspaceRemove);
26
26
  // Check if JSON output mode is active
27
27
  const jsonMode = shouldOutputJson(flags);
28
- // Helper to handle errors in JSON mode
29
- const handleError = (code, message) => {
30
- if (jsonMode) {
31
- outputErrorAsJson(code, message, createMetadata('workspace remove', flags));
32
- this.exit(1);
33
- }
34
- this.error(message);
35
- };
36
28
  const input = args.nameOrPath;
37
29
  // Resolve workspace path
38
30
  const workspacePath = await this.resolveWorkspacePath(input, jsonMode, flags);
@@ -4,7 +4,7 @@ import inquirer from 'inquirer';
4
4
  import * as fs from 'node:fs';
5
5
  import { isValidHQ } from '../../lib/workspace.js';
6
6
  import { findWorkspacesByName, findWorkspaceByPath, setActiveWorkspace, normalizePath, getRegisteredWorkspaces, } from '../../lib/machine-config.js';
7
- import { shouldOutputJson, outputPromptAsJson, outputErrorAsJson, createMetadata, buildPromptConfig, } from '../../lib/prompt-json.js';
7
+ import { shouldOutputJson, outputPromptAsJson, createMetadata, buildPromptConfig, } from '../../lib/prompt-json.js';
8
8
  export default class WorkspaceUse extends Command {
9
9
  static description = 'Set the active workspace';
10
10
  static examples = [
@@ -27,14 +27,6 @@ export default class WorkspaceUse extends Command {
27
27
  const { args, flags } = await this.parse(WorkspaceUse);
28
28
  // Check if JSON output mode is active
29
29
  const jsonMode = shouldOutputJson(flags);
30
- // Helper to handle errors in JSON mode
31
- const handleError = (code, message) => {
32
- if (jsonMode) {
33
- outputErrorAsJson(code, message, createMetadata('workspace use', flags));
34
- this.exit(1);
35
- }
36
- this.error(message);
37
- };
38
30
  const input = args.nameOrPath;
39
31
  // First, try to find by path
40
32
  const normalizedPath = normalizePath(input);
@@ -5,8 +5,8 @@ import * as fs from 'node:fs';
5
5
  import * as path from 'node:path';
6
6
  import { execSync } from 'node:child_process';
7
7
  import inquirer from 'inquirer';
8
- import { getWorkspaceConfig, getWorkspaceAgents, getWorkspaceRepositories, getAgentWorktrees, addAgentsToDatabase, removeAgentsFromDatabase, addEphemeralAgentToDatabase, getEphemeralAgentNames, getActiveTheme, markAgentCleaned, syncAgentsWithDisk } from '../database/index.js';
9
- import { isValidAgentName, getSuggestedAgentNames, generateEphemeralAgentName, getThemePersistentDir, getThemeEphemeralDir, } from '../themes.js';
8
+ import { getWorkspaceConfig, getWorkspaceAgents, getWorkspaceRepositories, getAgentWorktrees, addAgentsToDatabase, removeAgentsFromDatabase, addEphemeralAgentToDatabase, getEphemeralAgentNames, getActiveTheme, markAgentCleaned, discoverAgentsOnDisk } from '../database/index.js';
9
+ import { isValidAgentName, getSuggestedAgentNames, generateEphemeralAgentName, getThemePersistentDir, getThemeEphemeralDir, extractBaseName, getAgentBaseName, } from '../themes.js';
10
10
  import { createDevcontainerConfig } from '../execution/devcontainer.js';
11
11
  import { getPMOContext } from '../pmo/index.js';
12
12
  /**
@@ -29,8 +29,8 @@ export function getWorkspaceInfo() {
29
29
  try {
30
30
  const config = getWorkspaceConfig(hqPath);
31
31
  if (config) {
32
- // Sync agents with disk - mark missing ones as cleaned
33
- syncAgentsWithDisk(hqPath);
32
+ // Discover agents on disk and sync with database
33
+ discoverAgentsOnDisk(hqPath);
34
34
  const agents = getWorkspaceAgents(hqPath);
35
35
  const repositories = getWorkspaceRepositories(hqPath);
36
36
  const activeTheme = getActiveTheme(hqPath);
@@ -66,8 +66,8 @@ export function getWorkspaceInfo() {
66
66
  try {
67
67
  const config = getWorkspaceConfig(currentDir);
68
68
  if (config) {
69
- // Sync agents with disk - mark missing ones as cleaned
70
- syncAgentsWithDisk(currentDir);
69
+ // Discover agents on disk and sync with database
70
+ discoverAgentsOnDisk(currentDir);
71
71
  const agents = getWorkspaceAgents(currentDir);
72
72
  const repositories = getWorkspaceRepositories(currentDir);
73
73
  const activeTheme = getActiveTheme(currentDir);
@@ -366,10 +366,12 @@ export async function removeAgentsFromWorkspace(workspaceInfo, agentNames) {
366
366
  // Clear ticket assignees for removed agents
367
367
  try {
368
368
  const { storage } = await getPMOContext();
369
+ // eslint-disable-next-line unicorn/no-useless-undefined
369
370
  const allTickets = await storage.listTickets(undefined);
370
371
  for (const ticket of allTickets) {
371
372
  if (ticket.assignee && removed.includes(ticket.assignee)) {
372
373
  // Pass null to clear the assignee in the database
374
+ // eslint-disable-next-line no-await-in-loop -- Sequential updates for cleanup
373
375
  await storage.updateTicket(ticket.id, { assignee: null });
374
376
  }
375
377
  }
@@ -388,8 +390,8 @@ export async function removeAgentsFromWorkspace(workspaceInfo, agentNames) {
388
390
  export async function createEphemeralAgent(workspaceInfo, options) {
389
391
  // Get existing agent names for uniqueness check
390
392
  const existingNames = new Set([
391
- ...workspaceInfo.agents.map(a => a.name.toLowerCase()),
392
- ...Array.from(getEphemeralAgentNames(workspaceInfo.path))
393
+ ...Array.from(getEphemeralAgentNames(workspaceInfo.path)),
394
+ ...workspaceInfo.agents.map(a => a.name.toLowerCase())
393
395
  ]);
394
396
  const log = options?.log;
395
397
  // Get theme: use provided themeId, or fall back to workspace's active theme
@@ -400,6 +402,9 @@ export async function createEphemeralAgent(workspaceInfo, options) {
400
402
  // Use theme-specific ephemeral directory
401
403
  const ephemeralDir = themeId ? getThemeEphemeralDir(themeId) : workspaceInfo.ephemeralAgentsDir;
402
404
  const tempAgentsBasePath = path.join(workspaceInfo.path, 'agents', ephemeralDir);
405
+ // Extract base names currently in use by active agents
406
+ // This helps the generator prefer fresh base names
407
+ const inUseBaseNames = new Set(workspaceInfo.agents.map(agent => getAgentBaseName(agent).toLowerCase()));
403
408
  // Create a conflict checker for external resources (tmux sessions, directories)
404
409
  const checkExternalConflict = (candidateName) => {
405
410
  // Check if a tmux session with this name already exists (could be from manual creation)
@@ -421,12 +426,12 @@ export async function createEphemeralAgent(workspaceInfo, options) {
421
426
  const nameOptions = {
422
427
  themeId,
423
428
  checkExternalConflict,
424
- onConflictSkipped
429
+ onConflictSkipped,
430
+ inUseBaseNames
425
431
  };
426
432
  const agentName = generateEphemeralAgentName(existingNames, nameOptions);
427
- // Extract base name from the generated name (e.g., "bezos" from "bold-bezos-1")
428
- const parts = agentName.split('-');
429
- const baseName = parts.length >= 3 ? parts.slice(1, -1).join('-') : agentName;
433
+ // Extract base name from the generated name (e.g., "bezos" from "bold-bezos" or "bold-bezos-2")
434
+ const baseName = extractBaseName(agentName);
430
435
  // Create temp agents directory if it doesn't exist
431
436
  if (!fs.existsSync(tempAgentsBasePath)) {
432
437
  fs.mkdirSync(tempAgentsBasePath, { recursive: true });
@@ -452,7 +457,7 @@ export async function createEphemeralAgent(workspaceInfo, options) {
452
457
  stdio: 'pipe'
453
458
  });
454
459
  }
455
- catch (error) {
460
+ catch {
456
461
  // If worktree creation fails, try to just create the directory
457
462
  // The agent can still work without a worktree (e.g., for non-git projects)
458
463
  if (!fs.existsSync(worktreePath)) {
@@ -38,7 +38,6 @@ export interface AgentTheme {
38
38
  export interface AgentThemeName {
39
39
  theme_id: string;
40
40
  name: string;
41
- used: boolean;
42
41
  }
43
42
  export interface AgentWorktree {
44
43
  agent_name: string;
@@ -125,6 +124,20 @@ export declare function markAgentCleaned(workspacePath: string, agentName: strin
125
124
  * Returns list of agents that were cleaned up.
126
125
  */
127
126
  export declare function syncAgentsWithDisk(workspacePath: string): string[];
127
+ export interface DiscoverResult {
128
+ discovered: {
129
+ name: string;
130
+ type: AgentType;
131
+ path: string;
132
+ }[];
133
+ cleaned: string[];
134
+ }
135
+ /**
136
+ * Discover agents on disk that aren't in the database and register them.
137
+ * Also cleans up agents in DB whose directories no longer exist.
138
+ * Returns both discovered and cleaned agents.
139
+ */
140
+ export declare function discoverAgentsOnDisk(workspacePath: string): DiscoverResult;
128
141
  /**
129
142
  * Get all repositories in workspace
130
143
  */
@@ -162,21 +175,15 @@ export declare function deleteTheme(workspacePath: string, themeId: string): boo
162
175
  /**
163
176
  * Get names for a theme
164
177
  */
165
- export declare function getThemeNames(workspacePath: string, themeId: string, includeUsed?: boolean): AgentThemeName[];
178
+ export declare function getThemeNames(workspacePath: string, themeId: string): AgentThemeName[];
166
179
  /**
167
- * Get available (unused) names for a theme
168
- * Also excludes names that match existing agents (case-insensitive)
180
+ * Get available names for a theme.
181
+ * A name is available if:
182
+ * 1. No staff agent exists in the database with that name (case-insensitive), OR
183
+ * 2. The agent exists but its worktree directory is missing (manually deleted)
169
184
  */
170
185
  export declare function getAvailableThemeNames(workspacePath: string, themeId: string): string[];
171
186
  /**
172
187
  * Add names to a theme (case-insensitive uniqueness)
173
188
  */
174
189
  export declare function addThemeNames(workspacePath: string, themeId: string, names: string[]): void;
175
- /**
176
- * Mark a theme name as used
177
- */
178
- export declare function markThemeNameUsed(workspacePath: string, themeId: string, name: string): void;
179
- /**
180
- * Mark a theme name as available
181
- */
182
- export declare function markThemeNameAvailable(workspacePath: string, themeId: string, name: string): void;
@@ -1,7 +1,7 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
- import { getThemePersistentDir } from '../themes.js';
4
+ import { getThemePersistentDir, isEphemeralAgentName } from '../themes.js';
5
5
  import { PMO_SCHEMA_SQL } from '../pmo/schema.js';
6
6
  const CREATE_TABLES_SQL = `
7
7
  -- Core workspace metadata
@@ -39,7 +39,6 @@ CREATE TABLE IF NOT EXISTS agent_themes (
39
39
  CREATE TABLE IF NOT EXISTS agent_theme_names (
40
40
  theme_id TEXT NOT NULL,
41
41
  name TEXT NOT NULL,
42
- used BOOLEAN DEFAULT FALSE,
43
42
  PRIMARY KEY (theme_id, name),
44
43
  FOREIGN KEY (theme_id) REFERENCES agent_themes(id) ON DELETE CASCADE
45
44
  );
@@ -102,6 +101,20 @@ function ensureEphemeralAgentTypes(db) {
102
101
  WHERE type != 'ephemeral'
103
102
  AND name GLOB '*-*-[0-9]*'
104
103
  `);
104
+ // Also detect numberless ephemeral names (e.g., bold-bezos) using isEphemeralAgentName()
105
+ // This catches agents that match the adjective-name pattern but don't have a number suffix
106
+ const potentialEphemeral = db.prepare(`
107
+ SELECT name FROM agents
108
+ WHERE type != 'ephemeral'
109
+ AND name LIKE '%-%'
110
+ AND name NOT GLOB '*-*-[0-9]*'
111
+ `).all();
112
+ const updateStmt = db.prepare("UPDATE agents SET type = 'ephemeral' WHERE name = ?");
113
+ for (const agent of potentialEphemeral) {
114
+ if (isEphemeralAgentName(agent.name)) {
115
+ updateStmt.run(agent.name);
116
+ }
117
+ }
105
118
  }
106
119
  /**
107
120
  * Get the database path for a workspace
@@ -125,6 +138,7 @@ export function openWorkspaceDatabase(workspacePath) {
125
138
  }
126
139
  const db = new Database(dbPath);
127
140
  db.pragma('foreign_keys = ON');
141
+ db.pragma('busy_timeout = 5000'); // Wait up to 5 seconds if database is locked
128
142
  // Ensure ephemeral agents are correctly typed
129
143
  ensureEphemeralAgentTypes(db);
130
144
  // Ensure theme tables exist
@@ -140,13 +154,35 @@ export function openWorkspaceDatabase(workspacePath) {
140
154
  CREATE TABLE IF NOT EXISTS agent_theme_names (
141
155
  theme_id TEXT NOT NULL,
142
156
  name TEXT NOT NULL,
143
- used BOOLEAN DEFAULT FALSE,
144
157
  PRIMARY KEY (theme_id, name),
145
158
  FOREIGN KEY (theme_id) REFERENCES agent_themes(id) ON DELETE CASCADE
146
159
  );
147
160
  CREATE INDEX IF NOT EXISTS idx_theme_names_theme ON agent_theme_names(theme_id);
148
161
  CREATE INDEX IF NOT EXISTS idx_agents_theme ON agents(theme_id);
149
162
  `);
163
+ // Migration: drop 'used' column if it exists (no longer needed)
164
+ try {
165
+ const tableInfo = db.prepare("PRAGMA table_info(agent_theme_names)").all();
166
+ if (tableInfo.some(col => col.name === 'used')) {
167
+ // SQLite doesn't support DROP COLUMN directly, so recreate the table
168
+ db.exec(`
169
+ CREATE TABLE IF NOT EXISTS agent_theme_names_new (
170
+ theme_id TEXT NOT NULL,
171
+ name TEXT NOT NULL,
172
+ PRIMARY KEY (theme_id, name),
173
+ FOREIGN KEY (theme_id) REFERENCES agent_themes(id) ON DELETE CASCADE
174
+ );
175
+ INSERT OR IGNORE INTO agent_theme_names_new (theme_id, name)
176
+ SELECT theme_id, name FROM agent_theme_names;
177
+ DROP TABLE agent_theme_names;
178
+ ALTER TABLE agent_theme_names_new RENAME TO agent_theme_names;
179
+ CREATE INDEX IF NOT EXISTS idx_theme_names_theme ON agent_theme_names(theme_id);
180
+ `);
181
+ }
182
+ }
183
+ catch {
184
+ // Ignore migration errors - table might not exist yet
185
+ }
150
186
  return db;
151
187
  }
152
188
  /**
@@ -454,6 +490,93 @@ export function syncAgentsWithDisk(workspacePath) {
454
490
  }
455
491
  return cleanedAgents;
456
492
  }
493
+ /**
494
+ * Discover agents on disk that aren't in the database and register them.
495
+ * Also cleans up agents in DB whose directories no longer exist.
496
+ * Returns both discovered and cleaned agents.
497
+ */
498
+ export function discoverAgentsOnDisk(workspacePath) {
499
+ const result = { discovered: [], cleaned: [] };
500
+ // First, clean up missing agents
501
+ result.cleaned = syncAgentsWithDisk(workspacePath);
502
+ // Get existing ACTIVE agents from DB (case-insensitive lookup)
503
+ const activeAgents = getWorkspaceAgents(workspacePath, false); // Only active agents
504
+ const activeNames = new Set(activeAgents.map(a => a.name.toLowerCase()));
505
+ // Get ALL agents including cleaned (for reactivation)
506
+ const allAgents = getWorkspaceAgents(workspacePath, true);
507
+ const cleanedAgents = new Map(allAgents.filter(a => a.status === 'cleaned').map(a => [a.name.toLowerCase(), a]));
508
+ const db = openWorkspaceDatabase(workspacePath);
509
+ try {
510
+ // Scan staff directory
511
+ const staffDir = path.join(workspacePath, 'agents', 'staff');
512
+ if (fs.existsSync(staffDir)) {
513
+ const staffEntries = fs.readdirSync(staffDir, { withFileTypes: true });
514
+ for (const entry of staffEntries) {
515
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
516
+ const nameLower = entry.name.toLowerCase();
517
+ if (!activeNames.has(nameLower)) {
518
+ const worktreePath = `agents/staff/${entry.name}`;
519
+ const now = new Date().toISOString();
520
+ // Check if this is a cleaned agent that should be reactivated
521
+ const cleanedAgent = cleanedAgents.get(nameLower);
522
+ if (cleanedAgent) {
523
+ // Reactivate the cleaned agent
524
+ db.prepare(`
525
+ UPDATE agents SET status = 'active', cleaned_at = NULL, worktree_path = ?
526
+ WHERE LOWER(name) = LOWER(?)
527
+ `).run(worktreePath, entry.name);
528
+ }
529
+ else {
530
+ // Register new agent
531
+ db.prepare(`
532
+ INSERT INTO agents (name, type, status, worktree_path, created_at)
533
+ VALUES (?, 'persistent', 'active', ?, ?)
534
+ `).run(entry.name, worktreePath, now);
535
+ }
536
+ result.discovered.push({ name: entry.name, type: 'persistent', path: worktreePath });
537
+ activeNames.add(nameLower);
538
+ }
539
+ }
540
+ }
541
+ }
542
+ // Scan temp directory
543
+ const tempDir = path.join(workspacePath, 'agents', 'temp');
544
+ if (fs.existsSync(tempDir)) {
545
+ const tempEntries = fs.readdirSync(tempDir, { withFileTypes: true });
546
+ for (const entry of tempEntries) {
547
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
548
+ const nameLower = entry.name.toLowerCase();
549
+ if (!activeNames.has(nameLower)) {
550
+ const worktreePath = `agents/temp/${entry.name}`;
551
+ const now = new Date().toISOString();
552
+ // Check if this is a cleaned agent that should be reactivated
553
+ const cleanedAgent = cleanedAgents.get(nameLower);
554
+ if (cleanedAgent) {
555
+ // Reactivate the cleaned agent
556
+ db.prepare(`
557
+ UPDATE agents SET status = 'active', cleaned_at = NULL, worktree_path = ?
558
+ WHERE LOWER(name) = LOWER(?)
559
+ `).run(worktreePath, entry.name);
560
+ }
561
+ else {
562
+ // Register new agent
563
+ db.prepare(`
564
+ INSERT INTO agents (name, type, status, worktree_path, created_at)
565
+ VALUES (?, 'ephemeral', 'active', ?, ?)
566
+ `).run(entry.name, worktreePath, now);
567
+ }
568
+ result.discovered.push({ name: entry.name, type: 'ephemeral', path: worktreePath });
569
+ activeNames.add(nameLower);
570
+ }
571
+ }
572
+ }
573
+ }
574
+ }
575
+ finally {
576
+ db.close();
577
+ }
578
+ return result;
579
+ }
457
580
  /**
458
581
  * Get all repositories in workspace
459
582
  */
@@ -477,17 +600,10 @@ export function getAgentWorktrees(workspacePath, agentName) {
477
600
  */
478
601
  export function removeAgentsFromDatabase(workspacePath, agentNames) {
479
602
  const db = openWorkspaceDatabase(workspacePath);
480
- const getAgent = db.prepare('SELECT theme_id, name FROM agents WHERE name = ?');
481
603
  const deleteAgent = db.prepare('DELETE FROM agents WHERE name = ?');
482
- const clearUsedFlag = db.prepare('UPDATE agent_theme_names SET used = 0 WHERE theme_id = ? AND name = ?');
483
604
  // Note: agent_worktrees will be deleted automatically due to CASCADE
484
605
  const transaction = db.transaction(() => {
485
606
  for (const agentName of agentNames) {
486
- // Clear used flag if agent came from a theme
487
- const agent = getAgent.get(agentName);
488
- if (agent?.theme_id) {
489
- clearUsedFlag.run(agent.theme_id, agentName);
490
- }
491
607
  deleteAgent.run(agentName);
492
608
  }
493
609
  });
@@ -551,31 +667,47 @@ export function deleteTheme(workspacePath, themeId) {
551
667
  /**
552
668
  * Get names for a theme
553
669
  */
554
- export function getThemeNames(workspacePath, themeId, includeUsed = true) {
670
+ export function getThemeNames(workspacePath, themeId) {
555
671
  const db = openWorkspaceDatabase(workspacePath);
556
- const query = includeUsed
557
- ? 'SELECT * FROM agent_theme_names WHERE theme_id = ? ORDER BY name'
558
- : 'SELECT * FROM agent_theme_names WHERE theme_id = ? AND used = 0 ORDER BY name';
559
- const names = db.prepare(query).all(themeId);
672
+ const names = db.prepare('SELECT * FROM agent_theme_names WHERE theme_id = ? ORDER BY name').all(themeId);
560
673
  db.close();
561
674
  return names;
562
675
  }
563
676
  /**
564
- * Get available (unused) names for a theme
565
- * Also excludes names that match existing agents (case-insensitive)
677
+ * Get available names for a theme.
678
+ * A name is available if:
679
+ * 1. No staff agent exists in the database with that name (case-insensitive), OR
680
+ * 2. The agent exists but its worktree directory is missing (manually deleted)
566
681
  */
567
682
  export function getAvailableThemeNames(workspacePath, themeId) {
568
683
  const db = openWorkspaceDatabase(workspacePath);
569
- // Get unused theme names
570
- const names = db.prepare('SELECT name FROM agent_theme_names WHERE theme_id = ? AND used = 0 ORDER BY name').all(themeId);
571
- // Get existing agent names (lowercase for comparison)
572
- const existingAgents = db.prepare('SELECT LOWER(name) as name FROM agents').all();
573
- const existingSet = new Set(existingAgents.map(a => a.name));
684
+ // Get all theme names
685
+ const names = db.prepare('SELECT name FROM agent_theme_names WHERE theme_id = ? ORDER BY name').all(themeId);
686
+ // Get existing staff agents with their worktree paths (persistent type only)
687
+ const existingAgents = db.prepare(`
688
+ SELECT LOWER(name) as name, worktree_path
689
+ FROM agents
690
+ WHERE type = 'persistent' AND (status = 'active' OR status IS NULL)
691
+ `).all();
574
692
  db.close();
575
- // Filter out names that match existing agents
693
+ // Build a set of names that are truly in use (agent exists AND worktree exists)
694
+ const inUseNames = new Set();
695
+ for (const agent of existingAgents) {
696
+ if (agent.worktree_path) {
697
+ const fullPath = path.join(workspacePath, agent.worktree_path);
698
+ if (fs.existsSync(fullPath)) {
699
+ inUseNames.add(agent.name);
700
+ }
701
+ }
702
+ else {
703
+ // No worktree path means we can't verify - treat as in use to be safe
704
+ inUseNames.add(agent.name);
705
+ }
706
+ }
707
+ // Filter out names that are truly in use
576
708
  return names
577
709
  .map(n => n.name)
578
- .filter(name => !existingSet.has(name.toLowerCase()));
710
+ .filter(name => !inUseNames.has(name.toLowerCase()));
579
711
  }
580
712
  /**
581
713
  * Add names to a theme (case-insensitive uniqueness)
@@ -585,8 +717,8 @@ export function addThemeNames(workspacePath, themeId, names) {
585
717
  // Check for existing name (case-insensitive)
586
718
  const checkExisting = db.prepare('SELECT name FROM agent_theme_names WHERE theme_id = ? AND LOWER(name) = LOWER(?)');
587
719
  const insertName = db.prepare(`
588
- INSERT INTO agent_theme_names (theme_id, name, used)
589
- VALUES (?, ?, 0)
720
+ INSERT INTO agent_theme_names (theme_id, name)
721
+ VALUES (?, ?)
590
722
  `);
591
723
  const transaction = db.transaction(() => {
592
724
  for (const name of names) {
@@ -601,19 +733,3 @@ export function addThemeNames(workspacePath, themeId, names) {
601
733
  transaction();
602
734
  db.close();
603
735
  }
604
- /**
605
- * Mark a theme name as used
606
- */
607
- export function markThemeNameUsed(workspacePath, themeId, name) {
608
- const db = openWorkspaceDatabase(workspacePath);
609
- db.prepare('UPDATE agent_theme_names SET used = 1 WHERE theme_id = ? AND name = ?').run(themeId, name);
610
- db.close();
611
- }
612
- /**
613
- * Mark a theme name as available
614
- */
615
- export function markThemeNameAvailable(workspacePath, themeId, name) {
616
- const db = openWorkspaceDatabase(workspacePath);
617
- db.prepare('UPDATE agent_theme_names SET used = 0 WHERE theme_id = ? AND name = ?').run(themeId, name);
618
- db.close();
619
- }
@@ -4,7 +4,7 @@
4
4
  * Resolves various identifiers to Docker container IDs.
5
5
  * Accepts: execution ID (WORK-XXX), agent name, or container ID.
6
6
  */
7
- import { execSync } from 'child_process';
7
+ import { execSync } from 'node:child_process';
8
8
  /**
9
9
  * Validate and sanitize a container ID to prevent shell injection.
10
10
  * Docker container IDs are 64-character hex strings (or 12-char short form).
@@ -8,6 +8,7 @@ import Database from 'better-sqlite3';
8
8
  import { ExecutionConfig, TerminalApp, Shell, DisplayMode, OutputMode, ExecutionEnvironment } from './types.js';
9
9
  declare const CONFIG_KEYS: {
10
10
  terminalApp: string;
11
+ terminalOpenInBackground: string;
11
12
  shell: string;
12
13
  defaultMode: string;
13
14
  defaultExecutor: string;
@@ -46,6 +47,11 @@ export declare function saveShell(db: Database.Database, shell: Shell): void;
46
47
  * When enabled and using iTerm, tmux -CC is used for native tab integration.
47
48
  */
48
49
  export declare function saveTmuxControlMode(db: Database.Database, enabled: boolean): void;
50
+ /**
51
+ * Save terminal open in background preference.
52
+ * When enabled, new terminal tabs open without stealing focus from current window.
53
+ */
54
+ export declare function saveTerminalOpenInBackground(db: Database.Database, enabled: boolean): void;
49
55
  /**
50
56
  * Check if terminal app preference has been set
51
57
  */
@@ -7,11 +7,12 @@
7
7
  import inquirer from 'inquirer';
8
8
  import { DEFAULT_EXECUTION_CONFIG } from './types.js';
9
9
  import { isGHInstalled, isGHAuthenticated } from '../pr/index.js';
10
- import { execSync } from 'child_process';
10
+ import { execSync } from 'node:child_process';
11
11
  const SETTINGS_TABLE = 'workspace_settings';
12
12
  // Config keys stored in workspace_settings table
13
13
  const CONFIG_KEYS = {
14
14
  terminalApp: 'execution.terminal.app',
15
+ terminalOpenInBackground: 'execution.terminal.open_in_background',
15
16
  shell: 'execution.shell',
16
17
  defaultMode: 'execution.default_mode',
17
18
  defaultExecutor: 'execution.default_executor',
@@ -56,7 +57,12 @@ export function loadExecutionConfig(db) {
56
57
  // Load terminal app
57
58
  const terminalApp = getSetting(db, CONFIG_KEYS.terminalApp);
58
59
  if (terminalApp) {
59
- config.terminal = { app: terminalApp };
60
+ config.terminal = { ...config.terminal, app: terminalApp };
61
+ }
62
+ // Load terminal open in background setting
63
+ const terminalOpenInBackground = getSetting(db, CONFIG_KEYS.terminalOpenInBackground);
64
+ if (terminalOpenInBackground !== null) {
65
+ config.terminal = { ...config.terminal, openInBackground: terminalOpenInBackground === 'true' };
60
66
  }
61
67
  // Load shell
62
68
  const shell = getSetting(db, CONFIG_KEYS.shell);
@@ -152,6 +158,13 @@ export function saveShell(db, shell) {
152
158
  export function saveTmuxControlMode(db, enabled) {
153
159
  setSetting(db, CONFIG_KEYS.tmuxControlMode, enabled.toString());
154
160
  }
161
+ /**
162
+ * Save terminal open in background preference.
163
+ * When enabled, new terminal tabs open without stealing focus from current window.
164
+ */
165
+ export function saveTerminalOpenInBackground(db, enabled) {
166
+ setSetting(db, CONFIG_KEYS.terminalOpenInBackground, enabled.toString());
167
+ }
155
168
  /**
156
169
  * Check if terminal app preference has been set
157
170
  */
@@ -12,6 +12,8 @@ export interface DevcontainerOptions {
12
12
  memory?: string;
13
13
  cpus?: number;
14
14
  timezone?: string;
15
+ /** prlt channel: "npm", "npm:dev", "gh", "gh:dev", "mount", or version like "npm:1.2.3" */
16
+ prltChannel?: string;
15
17
  }
16
18
  export interface DevcontainerJson {
17
19
  name: string;