@proletariat/cli 0.3.8 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Chris McDermut
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/bin/dev.js CHANGED
File without changes
@@ -0,0 +1,21 @@
1
+ import { PMOCommand } from '../../lib/pmo/index.js';
2
+ export default class BranchWhere extends PMOCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ project: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ };
9
+ static args: {
10
+ search: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
11
+ };
12
+ protected getPMOOptions(): {
13
+ promptIfMultiple: boolean;
14
+ };
15
+ execute(): Promise<void>;
16
+ private getGitWorktrees;
17
+ private findMatchingWorktrees;
18
+ private findDatabaseWorktrees;
19
+ private getWorkspacePath;
20
+ private mergeResults;
21
+ }
@@ -0,0 +1,213 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { execSync } from 'node:child_process';
3
+ import * as path from 'node:path';
4
+ import { PMOCommand, pmoBaseFlags } from '../../lib/pmo/index.js';
5
+ import { styles } from '../../lib/styles.js';
6
+ import { isGitRepo, isTicketId } from '../../lib/branch/index.js';
7
+ import { openWorkspaceDatabase } from '../../lib/database/index.js';
8
+ export default class BranchWhere extends PMOCommand {
9
+ static description = 'Find which directory a branch is checked out in';
10
+ static examples = [
11
+ '<%= config.bin %> <%= command.id %> TKT-468',
12
+ '<%= config.bin %> <%= command.id %> feat/chris/add-auth',
13
+ '<%= config.bin %> <%= command.id %> TKT-468 --json',
14
+ ];
15
+ static flags = {
16
+ ...pmoBaseFlags,
17
+ json: Flags.boolean({
18
+ description: 'Output in JSON format',
19
+ default: false,
20
+ }),
21
+ };
22
+ static args = {
23
+ search: Args.string({
24
+ description: 'Branch name or ticket ID to search for',
25
+ required: true,
26
+ }),
27
+ };
28
+ getPMOOptions() {
29
+ return { promptIfMultiple: false };
30
+ }
31
+ async execute() {
32
+ const { args, flags } = await this.parse(BranchWhere);
33
+ const search = args.search;
34
+ // Check if in git repo
35
+ if (!isGitRepo()) {
36
+ this.error('Not in a git repository.');
37
+ }
38
+ // Get all worktrees from git
39
+ const worktrees = this.getGitWorktrees();
40
+ // Search for matching branches
41
+ const matches = this.findMatchingWorktrees(worktrees, search);
42
+ // Also check the database for agent worktrees
43
+ const dbMatches = this.findDatabaseWorktrees(search);
44
+ // Combine results, preferring git worktree info
45
+ const allMatches = this.mergeResults(matches, dbMatches);
46
+ if (allMatches.length === 0) {
47
+ if (flags.json) {
48
+ this.log(JSON.stringify({ found: false, search, matches: [] }, null, 2));
49
+ }
50
+ else {
51
+ this.log(styles.muted(`\nNo worktree found for "${search}"\n`));
52
+ }
53
+ return;
54
+ }
55
+ if (flags.json) {
56
+ this.log(JSON.stringify({
57
+ found: true,
58
+ search,
59
+ matches: allMatches.map(m => ({
60
+ path: m.path,
61
+ branch: m.branch,
62
+ commit: m.commit,
63
+ })),
64
+ }, null, 2));
65
+ }
66
+ else {
67
+ this.log('');
68
+ if (allMatches.length === 1) {
69
+ const match = allMatches[0];
70
+ this.log(styles.header(`Branch: ${match.branch}`));
71
+ this.log(`Path: ${styles.success(match.path)}`);
72
+ if (match.commit) {
73
+ this.log(`Commit: ${styles.muted(match.commit)}`);
74
+ }
75
+ }
76
+ else {
77
+ this.log(styles.header(`Found ${allMatches.length} matching worktrees:`));
78
+ this.log('');
79
+ for (const match of allMatches) {
80
+ this.log(` ${styles.success(match.branch)}`);
81
+ this.log(` ${match.path}`);
82
+ if (match.commit) {
83
+ this.log(` ${styles.muted(`commit: ${match.commit}`)}`);
84
+ }
85
+ this.log('');
86
+ }
87
+ }
88
+ this.log('');
89
+ }
90
+ }
91
+ getGitWorktrees() {
92
+ try {
93
+ const output = execSync('git worktree list --porcelain', {
94
+ encoding: 'utf-8',
95
+ stdio: ['pipe', 'pipe', 'pipe'],
96
+ });
97
+ const worktrees = [];
98
+ let current = {};
99
+ for (const line of output.split('\n')) {
100
+ if (line.startsWith('worktree ')) {
101
+ if (current.path) {
102
+ worktrees.push(current);
103
+ }
104
+ current = { path: line.substring(9) };
105
+ }
106
+ else if (line.startsWith('HEAD ')) {
107
+ current.commit = line.substring(5);
108
+ }
109
+ else if (line.startsWith('branch refs/heads/')) {
110
+ current.branch = line.substring(18);
111
+ }
112
+ else if (line === 'bare') {
113
+ current.bare = true;
114
+ }
115
+ else if (line === 'detached') {
116
+ current.detached = true;
117
+ }
118
+ }
119
+ // Don't forget the last entry
120
+ if (current.path) {
121
+ worktrees.push(current);
122
+ }
123
+ return worktrees.filter(w => w.branch && !w.bare);
124
+ }
125
+ catch {
126
+ return [];
127
+ }
128
+ }
129
+ findMatchingWorktrees(worktrees, search) {
130
+ const searchLower = search.toLowerCase();
131
+ const isTicket = isTicketId(search);
132
+ return worktrees.filter(w => {
133
+ if (!w.branch)
134
+ return false;
135
+ const branchLower = w.branch.toLowerCase();
136
+ // Exact match
137
+ if (branchLower === searchLower)
138
+ return true;
139
+ // If searching by ticket ID, match branches that start with it
140
+ if (isTicket && branchLower.startsWith(searchLower + '/')) {
141
+ return true;
142
+ }
143
+ // Partial match: branch contains the search term
144
+ if (branchLower.includes(searchLower))
145
+ return true;
146
+ return false;
147
+ });
148
+ }
149
+ findDatabaseWorktrees(search) {
150
+ try {
151
+ const workspacePath = this.getWorkspacePath();
152
+ if (!workspacePath)
153
+ return [];
154
+ const db = openWorkspaceDatabase(workspacePath);
155
+ const searchLower = search.toLowerCase();
156
+ const isTicket = isTicketId(search);
157
+ // Query for matching branches
158
+ let query;
159
+ let params;
160
+ if (isTicket) {
161
+ // Match branches starting with ticket ID
162
+ query = 'SELECT * FROM agent_worktrees WHERE LOWER(branch) LIKE ?';
163
+ params = [`${searchLower}/%`];
164
+ }
165
+ else {
166
+ // Match branches containing the search term
167
+ query = 'SELECT * FROM agent_worktrees WHERE LOWER(branch) LIKE ?';
168
+ params = [`%${searchLower}%`];
169
+ }
170
+ const rows = db.prepare(query).all(...params);
171
+ db.close();
172
+ return rows.map(row => ({
173
+ path: path.join(workspacePath, row.worktree_path),
174
+ branch: row.branch,
175
+ commit: row.last_commit_hash || '',
176
+ }));
177
+ }
178
+ catch {
179
+ return [];
180
+ }
181
+ }
182
+ getWorkspacePath() {
183
+ // Try to find workspace by looking for .proletariat directory
184
+ let current = process.cwd();
185
+ const root = path.parse(current).root;
186
+ while (current !== root) {
187
+ try {
188
+ const dbPath = path.join(current, '.proletariat', 'workspace.db');
189
+ const fs = require('node:fs');
190
+ if (fs.existsSync(dbPath)) {
191
+ return current;
192
+ }
193
+ }
194
+ catch {
195
+ // Continue searching
196
+ }
197
+ current = path.dirname(current);
198
+ }
199
+ return null;
200
+ }
201
+ mergeResults(gitWorktrees, dbWorktrees) {
202
+ // Use git worktrees as primary source, add any db-only entries
203
+ const seen = new Set(gitWorktrees.map(w => w.branch));
204
+ const merged = [...gitWorktrees];
205
+ for (const dbw of dbWorktrees) {
206
+ if (!seen.has(dbw.branch)) {
207
+ merged.push(dbw);
208
+ seen.add(dbw.branch);
209
+ }
210
+ }
211
+ return merged;
212
+ }
213
+ }
@@ -5,16 +5,18 @@ import { execSync } from 'node:child_process';
5
5
  import chalk from 'chalk';
6
6
  import inquirer from 'inquirer';
7
7
  import Database from 'better-sqlite3';
8
- import { SQLiteStorage, getColumnsForTemplate, createPMO, promptForPMOLocation, promptForBoardTemplate, promptForBoardName, promptForCustomColumns, determinePMOPath, } from '../../lib/pmo/index.js';
8
+ import { SQLiteStorage, getColumnsForTemplate, createPMO, promptForPMOLocation, promptForBoardTemplate, promptForBoardName, promptForCustomColumns, determinePMOPath, getPickerTemplates, } from '../../lib/pmo/index.js';
9
9
  import { styles } from '../../lib/styles.js';
10
10
  import { isGHInstalled, isGHAuthenticated, getGHUsername, isGHTokenInEnv } from '../../lib/pr/index.js';
11
11
  import { shouldOutputJson, outputPromptAsJson, createMetadata, buildPromptConfig, } from '../../lib/prompt-json.js';
12
+ // Build template options dynamically from shared definitions (picker templates + custom)
13
+ const PICKER_TEMPLATE_IDS = [...getPickerTemplates().map(t => t.id), 'custom'];
12
14
  export default class PMOInit extends Command {
13
15
  static description = 'Initialize PMO (Project Management Org) in current directory or HQ';
14
16
  static examples = [
15
17
  '<%= config.bin %> <%= command.id %>',
16
- '<%= config.bin %> <%= command.id %> --location repo:proletariat --template founder',
17
- '<%= config.bin %> <%= command.id %> --location separate --template scrum',
18
+ '<%= config.bin %> <%= command.id %> --location repo:proletariat --template 5-tool',
19
+ '<%= config.bin %> <%= command.id %> --location separate --template linear',
18
20
  ];
19
21
  static flags = {
20
22
  location: Flags.string({
@@ -24,7 +26,7 @@ export default class PMOInit extends Command {
24
26
  template: Flags.string({
25
27
  char: 't',
26
28
  description: 'Board template',
27
- options: ['kanban', 'scrum', 'founder', 'custom'],
29
+ options: PICKER_TEMPLATE_IDS,
28
30
  }),
29
31
  name: Flags.string({
30
32
  char: 'n',
@@ -93,12 +95,28 @@ export default class PMOInit extends Command {
93
95
  location = await promptForPMOLocation(hqRoot);
94
96
  }
95
97
  // Get board template using shared prompt (or from flag)
98
+ // If DB exists, query it for templates (source of truth)
96
99
  let template;
97
100
  if (flags.template) {
98
101
  template = flags.template;
99
102
  }
100
103
  else {
101
- template = await promptForBoardTemplate();
104
+ let storage;
105
+ if (hqRoot) {
106
+ const dbPath = path.join(hqRoot, '.proletariat', 'workspace.db');
107
+ if (fs.existsSync(dbPath)) {
108
+ try {
109
+ storage = new SQLiteStorage(dbPath);
110
+ }
111
+ catch {
112
+ // Ignore - will fall back to builtin templates
113
+ }
114
+ }
115
+ }
116
+ template = await promptForBoardTemplate(storage);
117
+ if (storage) {
118
+ await storage.close();
119
+ }
102
120
  }
103
121
  // Get columns for template
104
122
  let columns = getColumnsForTemplate(template);
@@ -2,10 +2,12 @@ import { Flags, Args } from '@oclif/core';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import inquirer from 'inquirer';
5
- import { createBoardContent, createSpecFolders, PMOCommand, pmoBaseFlags } from '../../lib/pmo/index.js';
5
+ import { createBoardContent, createSpecFolders, PMOCommand, pmoBaseFlags, BUILTIN_TEMPLATES } from '../../lib/pmo/index.js';
6
6
  import { styles } from '../../lib/styles.js';
7
7
  import { slugify } from '../../lib/pmo/utils.js';
8
8
  import { shouldOutputJson, outputPromptAsJson, createMetadata, buildFormPromptConfig, } from '../../lib/prompt-json.js';
9
+ // Build template options dynamically from shared definitions
10
+ const TEMPLATE_IDS = BUILTIN_TEMPLATES.map(t => t.id);
9
11
  export default class ProjectCreate extends PMOCommand {
10
12
  static description = 'Create a new project in the PMO';
11
13
  static examples = [
@@ -35,7 +37,7 @@ export default class ProjectCreate extends PMOCommand {
35
37
  template: Flags.string({
36
38
  char: 't',
37
39
  description: 'Workflow template',
38
- options: ['kanban', 'linear', 'bug-smash', '5-tool-founder', 'gtm'],
40
+ options: TEMPLATE_IDS,
39
41
  default: 'kanban',
40
42
  }),
41
43
  interactive: Flags.boolean({
@@ -58,14 +60,11 @@ export default class ProjectCreate extends PMOCommand {
58
60
  // Get project data first (before storage so prompts work)
59
61
  let projectData;
60
62
  if (flags.interactive || (!args.name && !flags.name)) {
61
- // Build choices once - single source of truth
62
- const templateChoices = [
63
- { name: 'Kanban - Backlog To Do In Progress → Done', value: 'kanban' },
64
- { name: 'Linear - Backlog, Triage, Todo, In Progress, In Review, Done', value: 'linear' },
65
- { name: 'Bug Smash - Reported → Confirmed → Fixing → Verifying → Fixed', value: 'bug-smash' },
66
- { name: '5-Tool Founder - Ship, Grow, Support, Strategy, BizOps → In Progress → Review → Done', value: '5-tool-founder' },
67
- { name: 'GTM - Ideation → Planning → In Development → Ready to Launch → Launched', value: 'gtm' },
68
- ];
63
+ // Build choices dynamically from shared template definitions
64
+ const templateChoices = BUILTIN_TEMPLATES.map(t => ({
65
+ name: `${t.name} - ${t.statuses.map(s => s.name).join(' → ')}`,
66
+ value: t.id,
67
+ }));
69
68
  // Define fields once - single source of truth for both JSON and interactive modes
70
69
  const fields = [
71
70
  { type: 'input', name: 'name', message: 'Project name:', default: flags.name },
@@ -4,6 +4,7 @@ export default class Whoami extends Command {
4
4
  static examples: string[];
5
5
  run(): Promise<void>;
6
6
  private detectAgentName;
7
+ private findWorkspaceRoot;
7
8
  private detectRepoName;
8
9
  private getCurrentBranch;
9
10
  }
@@ -1,7 +1,9 @@
1
1
  import { Command } from '@oclif/core';
2
2
  import * as path from 'node:path';
3
+ import * as fs from 'node:fs';
3
4
  import { execSync } from 'node:child_process';
4
5
  import { colors } from '../lib/colors.js';
6
+ import { getAgentByPath } from '../lib/database/index.js';
5
7
  export default class Whoami extends Command {
6
8
  static description = 'Show current agent/environment context';
7
9
  static examples = [
@@ -43,6 +45,11 @@ export default class Whoami extends Command {
43
45
  if (pmoPath) {
44
46
  this.log(` PMO path: ${colors.textMuted(pmoPath)}`);
45
47
  }
48
+ // Show host path if available (set in devcontainer for agent identity)
49
+ const hostPath = process.env.PRLT_HOST_PATH;
50
+ if (hostPath) {
51
+ this.log(` Host path: ${colors.textMuted(hostPath)}`);
52
+ }
46
53
  this.log('');
47
54
  }
48
55
  detectAgentName() {
@@ -50,18 +57,30 @@ export default class Whoami extends Command {
50
57
  if (process.env.PRLT_AGENT_NAME) {
51
58
  return process.env.PRLT_AGENT_NAME;
52
59
  }
53
- // Try to detect from directory structure
54
- // Pattern: /workspace/proletariat-{agentName} or agents/staff/{agentName}
55
60
  const cwd = process.cwd();
61
+ // Try database lookup (most reliable on host)
62
+ const workspacePath = this.findWorkspaceRoot(cwd);
63
+ if (workspacePath) {
64
+ try {
65
+ const agent = getAgentByPath(workspacePath, cwd);
66
+ if (agent) {
67
+ return agent.name;
68
+ }
69
+ }
70
+ catch {
71
+ // DB lookup failed, fall back to other methods
72
+ }
73
+ }
74
+ // Fallback: detect from directory structure
56
75
  // Devcontainer pattern: /workspace/proletariat-{agent}
57
76
  const workspaceMatch = cwd.match(/\/workspace\/[^/]+-(\w+)/);
58
77
  if (workspaceMatch) {
59
78
  return workspaceMatch[1];
60
79
  }
61
- // Host pattern: agents/staff/{agent}
62
- const staffMatch = cwd.match(/agents\/staff\/(\w+)/);
63
- if (staffMatch) {
64
- return staffMatch[1];
80
+ // Host pattern: agents/staff/{agent} or agents/temp/{agent}
81
+ const agentDirMatch = cwd.match(/agents\/(?:staff|temp)\/([\w-]+)/);
82
+ if (agentDirMatch) {
83
+ return agentDirMatch[1];
65
84
  }
66
85
  // Try git branch pattern: agent-{name}
67
86
  try {
@@ -76,6 +95,17 @@ export default class Whoami extends Command {
76
95
  }
77
96
  return null;
78
97
  }
98
+ findWorkspaceRoot(startDir) {
99
+ let currentDir = startDir;
100
+ while (currentDir !== '/') {
101
+ const dbPath = path.join(currentDir, '.proletariat', 'workspace.db');
102
+ if (fs.existsSync(dbPath)) {
103
+ return currentDir;
104
+ }
105
+ currentDir = path.dirname(currentDir);
106
+ }
107
+ return null;
108
+ }
79
109
  detectRepoName() {
80
110
  // Try to get repo name from directory or git remote
81
111
  try {
@@ -318,6 +318,12 @@ export default class WorkStart extends PMOCommand {
318
318
  // Get staff agents that exist on disk (warns about missing directories)
319
319
  const activeStaffAgents = getActiveStaffAgents(workspaceInfo, (msg) => this.log(msg));
320
320
  if (activeStaffAgents.length > 0) {
321
+ // Clean up stale executions before checking availability (TKT-604)
322
+ // This fixes agents appearing as "busy" when their sessions have terminated
323
+ const cleanedUp = executionStorage.cleanupStaleExecutions();
324
+ if (cleanedUp > 0) {
325
+ this.log(styles.muted(` Cleaned up ${cleanedUp} stale execution(s)`));
326
+ }
321
327
  // Get list of busy agents (already running something)
322
328
  const busyAgentNames = new Set();
323
329
  for (const agent of activeStaffAgents) {
@@ -1156,6 +1162,11 @@ export default class WorkStart extends PMOCommand {
1156
1162
  this.log('');
1157
1163
  // Get staff agents that exist on disk (warns about missing directories)
1158
1164
  const activeStaffAgents = getActiveStaffAgents(workspaceInfo, (msg) => this.log(msg));
1165
+ // Clean up stale executions before checking availability (TKT-604)
1166
+ const cleanedUp = executionStorage.cleanupStaleExecutions();
1167
+ if (cleanedUp > 0) {
1168
+ this.log(styles.muted(` Cleaned up ${cleanedUp} stale execution(s)`));
1169
+ }
1159
1170
  const busyAgentNames = new Set();
1160
1171
  for (const agent of activeStaffAgents) {
1161
1172
  const runningExecutions = executionStorage.getAgentRunningExecutions(agent.name);
@@ -109,6 +109,12 @@ export declare function removeEphemeralAgent(workspacePath: string, agentName: s
109
109
  * Get all agents in workspace
110
110
  */
111
111
  export declare function getWorkspaceAgents(workspacePath: string, includeCleanedUp?: boolean): Agent[];
112
+ /**
113
+ * Get an agent by directory path.
114
+ * Looks up agent where the given absolute path is inside the agent's worktree.
115
+ * Returns null if no matching agent found.
116
+ */
117
+ export declare function getAgentByPath(workspacePath: string, absolutePath: string): Agent | null;
112
118
  /**
113
119
  * Mark an agent as cleaned up (keeps the record for history)
114
120
  */
@@ -379,6 +379,44 @@ export function getWorkspaceAgents(workspacePath, includeCleanedUp = false) {
379
379
  cleaned_at: row.cleaned_at,
380
380
  }));
381
381
  }
382
+ /**
383
+ * Get an agent by directory path.
384
+ * Looks up agent where the given absolute path is inside the agent's worktree.
385
+ * Returns null if no matching agent found.
386
+ */
387
+ export function getAgentByPath(workspacePath, absolutePath) {
388
+ // Normalize paths
389
+ const normalizedWorkspace = path.resolve(workspacePath);
390
+ const normalizedPath = path.resolve(absolutePath);
391
+ // Path must be inside workspace
392
+ if (!normalizedPath.startsWith(normalizedWorkspace)) {
393
+ return null;
394
+ }
395
+ // Get relative path from workspace root
396
+ const relativePath = path.relative(normalizedWorkspace, normalizedPath);
397
+ const db = openWorkspaceDatabase(workspacePath);
398
+ const agents = db.prepare("SELECT * FROM agents WHERE status = 'active' OR status IS NULL").all();
399
+ db.close();
400
+ // Find agent whose worktree_path matches or contains the relative path
401
+ for (const row of agents) {
402
+ if (row.worktree_path) {
403
+ // Check if relativePath starts with or equals the agent's worktree_path
404
+ if (relativePath === row.worktree_path || relativePath.startsWith(row.worktree_path + '/')) {
405
+ return {
406
+ name: row.name,
407
+ type: (row.type || 'persistent'),
408
+ status: (row.status || 'active'),
409
+ base_name: row.base_name,
410
+ theme_id: row.theme_id,
411
+ worktree_path: row.worktree_path,
412
+ created_at: row.created_at,
413
+ cleaned_at: row.cleaned_at,
414
+ };
415
+ }
416
+ }
417
+ }
418
+ return null;
419
+ }
382
420
  /**
383
421
  * Mark an agent as cleaned up (keeps the record for history)
384
422
  */
@@ -65,6 +65,9 @@ export function generateDevcontainerJson(options, config) {
65
65
  GH_TOKEN: '${localEnv:GH_TOKEN}',
66
66
  GITHUB_TOKEN: '${localEnv:GITHUB_TOKEN}',
67
67
  PRLT_HQ_PATH: '/hq',
68
+ // Agent identity - allows agent to know its name and host path
69
+ PRLT_AGENT_NAME: options.agentName,
70
+ PRLT_HOST_PATH: options.agentDir,
68
71
  // /hq/.proletariat/bin contains prlt wrapper with ESM loader for native modules
69
72
  PATH: '/hq/.proletariat/bin:/home/node/.npm-global/bin:/usr/local/bin:/usr/bin:/bin',
70
73
  },
@@ -639,8 +639,9 @@ export async function runDevcontainer(context, executor, config, displayMode = '
639
639
  result.containerId = containerId;
640
640
  }
641
641
  // Set sessionId when using tmux inside the container
642
+ // Use buildSessionName to match the actual tmux session name format: {ticketId}-{action}-{agentName}
642
643
  if (result.success && sessionManager === 'tmux') {
643
- const sessionId = context.ticketId.replace(/[^a-zA-Z0-9-]/g, '-');
644
+ const sessionId = buildSessionName(context);
644
645
  result.sessionId = sessionId;
645
646
  // For terminal display mode, verify the tmux session was actually created
646
647
  // (terminal spawns asynchronously, so we need to wait and check)
@@ -649,7 +650,7 @@ export async function runDevcontainer(context, executor, config, displayMode = '
649
650
  await new Promise(resolve => setTimeout(resolve, 3000));
650
651
  // Check if tmux session exists inside the container
651
652
  try {
652
- const checkResult = execSync(`docker exec ${containerId} tmux has-session -t ${sessionId} 2>&1`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
653
+ const checkResult = execSync(`docker exec ${containerId} tmux has-session -t "${sessionId}" 2>&1`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
653
654
  // Session exists - success
654
655
  }
655
656
  catch (err) {
@@ -57,7 +57,9 @@ interface AgentWithExecutionCount {
57
57
  */
58
58
  export declare function getAgentsWithCounts(workspaceInfo: WorkspaceInfo, executionStorage: ExecutionStorage): AgentWithExecutionCount[];
59
59
  /**
60
- * Get agents that are not currently running any executions.
60
+ * Get staff agents that are not currently running any executions.
61
+ * Only considers persistent (staff) agents with status='active'.
62
+ * Cleans up stale executions before checking availability (TKT-604).
61
63
  */
62
64
  export declare function getAvailableAgents(workspaceInfo: WorkspaceInfo, executionStorage: ExecutionStorage): string[];
63
65
  /**
@@ -93,11 +93,18 @@ export function getAgentsWithCounts(workspaceInfo, executionStorage) {
93
93
  });
94
94
  }
95
95
  /**
96
- * Get agents that are not currently running any executions.
96
+ * Get staff agents that are not currently running any executions.
97
+ * Only considers persistent (staff) agents with status='active'.
98
+ * Cleans up stale executions before checking availability (TKT-604).
97
99
  */
98
100
  export function getAvailableAgents(workspaceInfo, executionStorage) {
101
+ // Clean up stale executions first (TKT-604)
102
+ executionStorage.cleanupStaleExecutions();
103
+ // Filter for active staff agents only (not ephemeral agents)
99
104
  return workspaceInfo.agents
100
- .filter(agent => executionStorage.isAgentAvailable(agent.name))
105
+ .filter(agent => agent.type === 'persistent' &&
106
+ agent.status === 'active' &&
107
+ executionStorage.isAgentAvailable(agent.name))
101
108
  .map(agent => agent.name);
102
109
  }
103
110
  /**
@@ -64,6 +64,20 @@ export declare class ExecutionStorage {
64
64
  * Check if agent is available (not running anything)
65
65
  */
66
66
  isAgentAvailable(agentName: string): boolean;
67
+ /**
68
+ * Clean up stale executions where the tmux session no longer exists.
69
+ * This fixes the bug where agents appear "busy" after sessions terminate unexpectedly.
70
+ * Returns the number of stale executions cleaned up.
71
+ */
72
+ cleanupStaleExecutions(): number;
73
+ /**
74
+ * Get list of host tmux session names
75
+ */
76
+ private getHostTmuxSessionNames;
77
+ /**
78
+ * Get map of containerId -> tmux session names
79
+ */
80
+ private getContainerTmuxSessionMap;
67
81
  /**
68
82
  * Get total execution count for an agent (historical)
69
83
  * Used by least-busy agent selection strategy.