@proletariat/cli 0.3.25 → 0.3.27

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 (123) hide show
  1. package/dist/commands/action/index.js +2 -2
  2. package/dist/commands/action/show.js +7 -1
  3. package/dist/commands/agent/auth.js +1 -1
  4. package/dist/commands/agent/cleanup.js +6 -6
  5. package/dist/commands/agent/discover.js +1 -1
  6. package/dist/commands/agent/remove.js +4 -4
  7. package/dist/commands/autocomplete/setup.d.ts +2 -2
  8. package/dist/commands/autocomplete/setup.js +5 -5
  9. package/dist/commands/branch/create.js +31 -30
  10. package/dist/commands/branch/list.js +14 -11
  11. package/dist/commands/branch/validate.js +10 -1
  12. package/dist/commands/category/create.js +4 -5
  13. package/dist/commands/category/delete.js +2 -3
  14. package/dist/commands/category/rename.js +2 -3
  15. package/dist/commands/claude.d.ts +2 -8
  16. package/dist/commands/claude.js +26 -26
  17. package/dist/commands/commit.d.ts +2 -8
  18. package/dist/commands/commit.js +4 -26
  19. package/dist/commands/config/index.d.ts +2 -10
  20. package/dist/commands/config/index.js +8 -34
  21. package/dist/commands/docker/clean.js +7 -9
  22. package/dist/commands/docker/index.d.ts +2 -2
  23. package/dist/commands/docker/index.js +13 -12
  24. package/dist/commands/docker/list.d.ts +1 -0
  25. package/dist/commands/docker/list.js +31 -17
  26. package/dist/commands/docker/status.d.ts +3 -1
  27. package/dist/commands/docker/status.js +28 -2
  28. package/dist/commands/docker/sync.js +7 -6
  29. package/dist/commands/epic/delete.js +4 -5
  30. package/dist/commands/epic/list.js +17 -2
  31. package/dist/commands/execution/list.js +25 -17
  32. package/dist/commands/feedback/submit.d.ts +2 -2
  33. package/dist/commands/feedback/submit.js +9 -9
  34. package/dist/commands/link/index.js +2 -2
  35. package/dist/commands/pmo/init.d.ts +2 -2
  36. package/dist/commands/pmo/init.js +29 -10
  37. package/dist/commands/project/spec.js +6 -6
  38. package/dist/commands/repo/list.js +14 -8
  39. package/dist/commands/repo/view.js +2 -1
  40. package/dist/commands/roadmap/list.js +16 -1
  41. package/dist/commands/session/health.d.ts +29 -0
  42. package/dist/commands/session/health.js +496 -0
  43. package/dist/commands/session/index.js +4 -0
  44. package/dist/commands/session/list.js +15 -8
  45. package/dist/commands/spec/edit.js +2 -3
  46. package/dist/commands/staff/add.d.ts +2 -2
  47. package/dist/commands/staff/add.js +15 -14
  48. package/dist/commands/staff/index.js +2 -2
  49. package/dist/commands/staff/list.d.ts +3 -1
  50. package/dist/commands/staff/list.js +15 -1
  51. package/dist/commands/staff/remove.js +4 -4
  52. package/dist/commands/status/index.js +6 -7
  53. package/dist/commands/template/apply.js +10 -11
  54. package/dist/commands/template/create.js +18 -17
  55. package/dist/commands/template/index.d.ts +2 -2
  56. package/dist/commands/template/index.js +6 -6
  57. package/dist/commands/template/save.js +8 -7
  58. package/dist/commands/template/update.js +6 -7
  59. package/dist/commands/terminal/title.d.ts +2 -26
  60. package/dist/commands/terminal/title.js +4 -33
  61. package/dist/commands/theme/index.d.ts +2 -2
  62. package/dist/commands/theme/index.js +19 -18
  63. package/dist/commands/theme/list.d.ts +3 -0
  64. package/dist/commands/theme/list.js +25 -0
  65. package/dist/commands/theme/set.d.ts +2 -2
  66. package/dist/commands/theme/set.js +5 -5
  67. package/dist/commands/ticket/complete.js +4 -1
  68. package/dist/commands/ticket/create.d.ts +1 -0
  69. package/dist/commands/ticket/create.js +64 -16
  70. package/dist/commands/ticket/delete.js +18 -16
  71. package/dist/commands/ticket/edit.js +22 -14
  72. package/dist/commands/ticket/epic.js +12 -10
  73. package/dist/commands/ticket/list.js +24 -5
  74. package/dist/commands/ticket/move.js +4 -1
  75. package/dist/commands/ticket/project.js +11 -9
  76. package/dist/commands/ticket/reassign.js +23 -19
  77. package/dist/commands/ticket/spec.js +7 -5
  78. package/dist/commands/ticket/update.js +55 -53
  79. package/dist/commands/ticket/view.js +4 -2
  80. package/dist/commands/whoami.d.ts +3 -0
  81. package/dist/commands/whoami.js +22 -4
  82. package/dist/commands/work/complete.js +2 -2
  83. package/dist/commands/work/ready.js +9 -9
  84. package/dist/commands/work/revise.js +15 -13
  85. package/dist/commands/work/spawn.js +154 -57
  86. package/dist/commands/work/start.d.ts +1 -0
  87. package/dist/commands/work/start.js +299 -177
  88. package/dist/commands/workspace/prune.d.ts +3 -2
  89. package/dist/commands/workspace/prune.js +70 -10
  90. package/dist/hooks/init.js +4 -0
  91. package/dist/lib/agents/commands.js +4 -0
  92. package/dist/lib/agents/index.js +12 -0
  93. package/dist/lib/execution/devcontainer.d.ts +4 -0
  94. package/dist/lib/execution/devcontainer.js +63 -0
  95. package/dist/lib/mcp/helpers.d.ts +15 -0
  96. package/dist/lib/mcp/helpers.js +15 -0
  97. package/dist/lib/mcp/tools/action.js +5 -5
  98. package/dist/lib/mcp/tools/board.js +7 -7
  99. package/dist/lib/mcp/tools/category.js +5 -5
  100. package/dist/lib/mcp/tools/cli-passthrough.js +30 -30
  101. package/dist/lib/mcp/tools/epic.js +8 -8
  102. package/dist/lib/mcp/tools/phase.js +7 -7
  103. package/dist/lib/mcp/tools/project.js +10 -10
  104. package/dist/lib/mcp/tools/roadmap.js +7 -7
  105. package/dist/lib/mcp/tools/spec.js +9 -9
  106. package/dist/lib/mcp/tools/status.js +6 -6
  107. package/dist/lib/mcp/tools/template.js +6 -6
  108. package/dist/lib/mcp/tools/ticket.js +19 -19
  109. package/dist/lib/mcp/tools/view.js +4 -4
  110. package/dist/lib/mcp/tools/work.js +6 -6
  111. package/dist/lib/mcp/tools/workflow.js +5 -5
  112. package/dist/lib/pmo/index.js +4 -0
  113. package/dist/lib/pmo/storage/base.js +49 -0
  114. package/dist/lib/pr/index.d.ts +9 -0
  115. package/dist/lib/pr/index.js +101 -14
  116. package/dist/lib/prompt-command.d.ts +3 -0
  117. package/dist/lib/prompt-json.d.ts +72 -1
  118. package/dist/lib/prompt-json.js +46 -0
  119. package/dist/lib/repos/index.js +4 -0
  120. package/dist/lib/string-utils.d.ts +10 -0
  121. package/dist/lib/string-utils.js +16 -0
  122. package/oclif.manifest.json +594 -449
  123. package/package.json +3 -2
@@ -0,0 +1,496 @@
1
+ import { Flags } from '@oclif/core';
2
+ import * as path from 'node:path';
3
+ import { execSync } from 'node:child_process';
4
+ import Database from 'better-sqlite3';
5
+ import { styles } from '../../lib/styles.js';
6
+ import { getWorkspaceInfo } from '../../lib/agents/commands.js';
7
+ import { ExecutionStorage } from '../../lib/execution/index.js';
8
+ import { parseSessionName, getHostTmuxSessionNames, getContainerTmuxSessionMap, flattenContainerSessions, findSessionForExecution, } from '../../lib/execution/session-utils.js';
9
+ import { PMOCommand, pmoBaseFlags } from '../../lib/pmo/index.js';
10
+ import { visualPadEnd } from '../../lib/string-utils.js';
11
+ import { shouldOutputJson, outputSuccessAsJson, outputErrorAsJson, createMetadata, } from '../../lib/prompt-json.js';
12
+ // =============================================================================
13
+ // Detection Logic
14
+ // =============================================================================
15
+ /**
16
+ * Capture the last N lines from a tmux pane.
17
+ */
18
+ function captureTmuxPane(sessionId, lines, containerId) {
19
+ try {
20
+ const captureCmd = `tmux capture-pane -t "${sessionId}" -p -S -${lines}`;
21
+ if (containerId) {
22
+ return execSync(`docker exec ${containerId} bash -c '${captureCmd}'`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000 }).trim();
23
+ }
24
+ return execSync(captureCmd, {
25
+ encoding: 'utf-8',
26
+ stdio: ['pipe', 'pipe', 'pipe'],
27
+ timeout: 5000,
28
+ }).trim();
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /**
35
+ * Detect agent health state from tmux pane content.
36
+ *
37
+ * Detection patterns (checked in priority order):
38
+ * - '0 tokens' (the down-arrow + 0 tokens pattern) = HUNG
39
+ * - 'esc to interrupt' = WORKING
40
+ * - 'Agent work complete' or similar completion messages = DONE
41
+ * - Idle prompt patterns ($ or ❯ at end of line) = IDLE
42
+ */
43
+ function detectState(paneContent) {
44
+ if (!paneContent)
45
+ return 'UNKNOWN';
46
+ // Check last few lines for patterns
47
+ const lines = paneContent.split('\n');
48
+ const lastLines = lines.slice(-10).join('\n');
49
+ // HUNG: stuck API call showing '0 tokens' (the ↓ 0 tokens pattern)
50
+ if (/0 tokens/.test(lastLines)) {
51
+ return 'HUNG';
52
+ }
53
+ // WORKING: agent is actively processing
54
+ if (/esc to interrupt/i.test(lastLines)) {
55
+ return 'WORKING';
56
+ }
57
+ // DONE: agent work is complete
58
+ if (/agent work complete/i.test(lastLines) || /work ready/i.test(lastLines)) {
59
+ return 'DONE';
60
+ }
61
+ // IDLE: shell prompt visible (agent has finished or is waiting)
62
+ // Match common prompt patterns at end of last non-empty line
63
+ const lastNonEmpty = lines.filter(l => l.trim().length > 0).pop() || '';
64
+ if (/[$❯#>]\s*$/.test(lastNonEmpty) || /^\s*\$\s*$/.test(lastNonEmpty)) {
65
+ return 'IDLE';
66
+ }
67
+ return 'UNKNOWN';
68
+ }
69
+ /**
70
+ * Format elapsed time from a start date to now.
71
+ */
72
+ function formatElapsed(startedAt) {
73
+ const ms = Date.now() - startedAt.getTime();
74
+ const totalMinutes = Math.floor(ms / 60000);
75
+ const hours = Math.floor(totalMinutes / 60);
76
+ const minutes = totalMinutes % 60;
77
+ if (hours > 0) {
78
+ return `${hours}h ${minutes}m`;
79
+ }
80
+ return `${minutes}m`;
81
+ }
82
+ /**
83
+ * Send Escape key to a tmux session to unstick a hung agent.
84
+ */
85
+ function sendEscape(sessionId, containerId) {
86
+ try {
87
+ const sendCmd = `tmux send-keys -t "${sessionId}" Escape`;
88
+ if (containerId) {
89
+ execSync(`docker exec ${containerId} bash -c '${sendCmd}'`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000 });
90
+ }
91
+ else {
92
+ execSync(sendCmd, {
93
+ encoding: 'utf-8',
94
+ stdio: ['pipe', 'pipe', 'pipe'],
95
+ timeout: 5000,
96
+ });
97
+ }
98
+ return true;
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
104
+ // =============================================================================
105
+ // Command
106
+ // =============================================================================
107
+ export default class SessionHealth extends PMOCommand {
108
+ static description = 'Check health of running agent sessions and detect/recover hung agents';
109
+ static examples = [
110
+ '<%= config.bin %> session health',
111
+ '<%= config.bin %> session health --fix',
112
+ '<%= config.bin %> session health --watch',
113
+ '<%= config.bin %> session health --watch --interval 3 --threshold 5',
114
+ ];
115
+ static flags = {
116
+ ...pmoBaseFlags,
117
+ fix: Flags.boolean({
118
+ description: 'Send Escape to hung agents to unstick them',
119
+ default: false,
120
+ }),
121
+ watch: Flags.boolean({
122
+ description: 'Continuously monitor agents and auto-recover hung ones',
123
+ default: false,
124
+ }),
125
+ interval: Flags.integer({
126
+ description: 'Watch polling interval in minutes',
127
+ default: 5,
128
+ }),
129
+ threshold: Flags.integer({
130
+ description: 'Minutes an agent must be hung before auto-recovery in watch mode',
131
+ default: 10,
132
+ }),
133
+ };
134
+ getPMOOptions() {
135
+ return { promptIfMultiple: false };
136
+ }
137
+ async execute() {
138
+ const { flags } = await this.parse(SessionHealth);
139
+ const jsonMode = shouldOutputJson(flags);
140
+ if (flags.watch) {
141
+ await this.watchMode(flags.interval, flags.threshold);
142
+ }
143
+ else {
144
+ await this.runHealthCheck(flags.fix, jsonMode, flags);
145
+ }
146
+ }
147
+ /**
148
+ * Single health check pass. Returns the list of agent health infos.
149
+ */
150
+ async runHealthCheck(fix, jsonMode = false, flags = {}) {
151
+ let executionStorage = null;
152
+ let db = null;
153
+ try {
154
+ const workspaceInfo = getWorkspaceInfo();
155
+ const dbPath = path.join(workspaceInfo.path, '.proletariat', 'workspace.db');
156
+ db = new Database(dbPath);
157
+ executionStorage = new ExecutionStorage(db);
158
+ }
159
+ catch {
160
+ if (jsonMode) {
161
+ outputErrorAsJson('NOT_IN_WORKSPACE', 'Not in a workspace. Run from a proletariat HQ directory.', createMetadata('session health', flags));
162
+ }
163
+ this.log('');
164
+ this.log(styles.error('Not in a workspace. Run from a proletariat HQ directory.'));
165
+ this.log('');
166
+ return [];
167
+ }
168
+ try {
169
+ // Get active executions from DB
170
+ const runningExecutions = executionStorage.listExecutions({ status: 'running' });
171
+ const startingExecutions = executionStorage.listExecutions({ status: 'starting' });
172
+ const activeExecutions = [...runningExecutions, ...startingExecutions];
173
+ // Get actual tmux sessions
174
+ const hostTmuxSessions = getHostTmuxSessionNames();
175
+ const containerTmuxSessions = getContainerTmuxSessionMap();
176
+ const allContainerSessions = flattenContainerSessions(containerTmuxSessions);
177
+ // Track matched sessions
178
+ const matchedHostSessions = new Set();
179
+ const matchedContainerSessions = new Set();
180
+ const agents = [];
181
+ // Process DB-tracked executions
182
+ for (const exec of activeExecutions) {
183
+ const isContainer = exec.environment === 'devcontainer';
184
+ let exists = false;
185
+ let containerId;
186
+ let actualSessionId = exec.sessionId;
187
+ // Try to find session if sessionId is NULL
188
+ if (!exec.sessionId) {
189
+ if (isContainer && exec.containerId) {
190
+ const containerSessions = containerTmuxSessions.get(exec.containerId) || [];
191
+ const match = findSessionForExecution(exec.ticketId, exec.agentName, containerSessions);
192
+ if (match) {
193
+ actualSessionId = match;
194
+ exists = true;
195
+ containerId = exec.containerId;
196
+ }
197
+ }
198
+ else {
199
+ const match = findSessionForExecution(exec.ticketId, exec.agentName, hostTmuxSessions);
200
+ if (match) {
201
+ actualSessionId = match;
202
+ exists = true;
203
+ }
204
+ }
205
+ if (!actualSessionId)
206
+ continue;
207
+ }
208
+ else {
209
+ if (isContainer && exec.containerId) {
210
+ const containerSessions = containerTmuxSessions.get(exec.containerId);
211
+ exists = containerSessions?.includes(exec.sessionId) ?? false;
212
+ containerId = exec.containerId;
213
+ }
214
+ else {
215
+ exists = hostTmuxSessions.includes(exec.sessionId);
216
+ }
217
+ }
218
+ if (exists && actualSessionId) {
219
+ if (isContainer && containerId) {
220
+ matchedContainerSessions.add(`${containerId}:${actualSessionId}`);
221
+ }
222
+ else {
223
+ matchedHostSessions.add(actualSessionId);
224
+ }
225
+ // Capture pane and detect state
226
+ const paneContent = captureTmuxPane(actualSessionId, 10, containerId);
227
+ const state = detectState(paneContent);
228
+ agents.push({
229
+ sessionId: actualSessionId,
230
+ ticketId: exec.ticketId,
231
+ agentName: exec.agentName,
232
+ state,
233
+ environment: isContainer ? 'container' : 'host',
234
+ containerId,
235
+ elapsed: formatElapsed(exec.startedAt),
236
+ paneContent: paneContent || undefined,
237
+ });
238
+ }
239
+ }
240
+ // Also discover orphan tmux sessions matching prlt pattern
241
+ for (const sessionName of hostTmuxSessions) {
242
+ if (matchedHostSessions.has(sessionName))
243
+ continue;
244
+ const parsed = parseSessionName(sessionName);
245
+ if (parsed) {
246
+ const paneContent = captureTmuxPane(sessionName, 10);
247
+ const state = detectState(paneContent);
248
+ agents.push({
249
+ sessionId: sessionName,
250
+ ticketId: parsed.ticketId,
251
+ agentName: parsed.agentName,
252
+ state,
253
+ environment: 'host',
254
+ elapsed: '?',
255
+ paneContent: paneContent || undefined,
256
+ });
257
+ }
258
+ }
259
+ for (const { sessionName, containerId: cId } of allContainerSessions) {
260
+ if (matchedContainerSessions.has(`${cId}:${sessionName}`))
261
+ continue;
262
+ const parsed = parseSessionName(sessionName);
263
+ if (parsed) {
264
+ const paneContent = captureTmuxPane(sessionName, 10, cId);
265
+ const state = detectState(paneContent);
266
+ agents.push({
267
+ sessionId: sessionName,
268
+ ticketId: parsed.ticketId,
269
+ agentName: parsed.agentName,
270
+ state,
271
+ environment: 'container',
272
+ containerId: cId,
273
+ elapsed: '?',
274
+ paneContent: paneContent || undefined,
275
+ });
276
+ }
277
+ }
278
+ // JSON mode: output structured data and exit
279
+ if (jsonMode) {
280
+ const hungAgents = agents.filter(a => a.state === 'HUNG');
281
+ const fixResults = [];
282
+ if (fix && hungAgents.length > 0) {
283
+ for (const agent of hungAgents) {
284
+ const success = sendEscape(agent.sessionId, agent.containerId);
285
+ fixResults.push({
286
+ agentName: agent.agentName,
287
+ ticketId: agent.ticketId,
288
+ recovered: success,
289
+ });
290
+ }
291
+ }
292
+ outputSuccessAsJson({
293
+ agents: agents.map(a => ({
294
+ ticketId: a.ticketId,
295
+ agentName: a.agentName,
296
+ state: a.state,
297
+ environment: a.environment,
298
+ containerId: a.containerId,
299
+ sessionId: a.sessionId,
300
+ elapsed: a.elapsed,
301
+ })),
302
+ summary: {
303
+ total: agents.length,
304
+ hung: agents.filter(a => a.state === 'HUNG').length,
305
+ working: agents.filter(a => a.state === 'WORKING').length,
306
+ done: agents.filter(a => a.state === 'DONE').length,
307
+ idle: agents.filter(a => a.state === 'IDLE').length,
308
+ unknown: agents.filter(a => a.state === 'UNKNOWN').length,
309
+ },
310
+ ...(fix && fixResults.length > 0 ? { recovered: fixResults } : {}),
311
+ commands: {
312
+ fix: 'prlt session health --fix',
313
+ watch: 'prlt session health --watch',
314
+ },
315
+ }, createMetadata('session health', flags));
316
+ }
317
+ // Display status table
318
+ this.displayHealthTable(agents);
319
+ // Auto-fix hung agents if --fix
320
+ if (fix) {
321
+ const hungAgents = agents.filter(a => a.state === 'HUNG');
322
+ if (hungAgents.length > 0) {
323
+ this.log('');
324
+ this.log(styles.header('Recovering hung agents...'));
325
+ this.log('');
326
+ for (const agent of hungAgents) {
327
+ const success = sendEscape(agent.sessionId, agent.containerId);
328
+ if (success) {
329
+ this.log(styles.success(` Sent Escape to ${agent.agentName} (${agent.ticketId}) - recovered`));
330
+ }
331
+ else {
332
+ this.log(styles.error(` Failed to send Escape to ${agent.agentName} (${agent.ticketId})`));
333
+ }
334
+ }
335
+ this.log('');
336
+ }
337
+ else {
338
+ this.log('');
339
+ this.log(styles.success('No hung agents found.'));
340
+ this.log('');
341
+ }
342
+ }
343
+ return agents;
344
+ }
345
+ finally {
346
+ db?.close();
347
+ }
348
+ }
349
+ /**
350
+ * Display the health status table.
351
+ */
352
+ displayHealthTable(agents) {
353
+ if (agents.length === 0) {
354
+ this.log('');
355
+ this.log(styles.muted('No active agent sessions found.'));
356
+ this.log('');
357
+ this.log(styles.muted('Start work with: prlt work start <ticket-id>'));
358
+ this.log('');
359
+ return;
360
+ }
361
+ this.log('');
362
+ this.log(styles.header('Agent Health Status'));
363
+ this.log('═'.repeat(95));
364
+ this.log(styles.muted(' ' +
365
+ visualPadEnd('Ticket', 12) +
366
+ visualPadEnd('Agent', 20) +
367
+ visualPadEnd('State', 12) +
368
+ visualPadEnd('Type', 15) +
369
+ visualPadEnd('Elapsed', 10) +
370
+ 'Session'));
371
+ this.log(' ' + '─'.repeat(93));
372
+ for (const agent of agents) {
373
+ const typeIcon = agent.environment === 'container' ? '🐳 container' : '💻 host';
374
+ const stateColor = agent.state === 'HUNG' ? styles.error :
375
+ agent.state === 'WORKING' ? styles.success :
376
+ agent.state === 'DONE' ? styles.info :
377
+ agent.state === 'IDLE' ? styles.warning :
378
+ styles.muted;
379
+ const stateIcon = agent.state === 'HUNG' ? '🔴' :
380
+ agent.state === 'WORKING' ? '🟢' :
381
+ agent.state === 'DONE' ? '✅' :
382
+ agent.state === 'IDLE' ? '🟡' :
383
+ '⚪';
384
+ const displaySession = agent.sessionId.length > 24
385
+ ? agent.sessionId.substring(0, 21) + '...'
386
+ : agent.sessionId;
387
+ this.log(' ' +
388
+ visualPadEnd(agent.ticketId, 12) +
389
+ visualPadEnd(agent.agentName, 20) +
390
+ stateColor(visualPadEnd(`${stateIcon} ${agent.state}`, 12)) +
391
+ visualPadEnd(typeIcon, 15) +
392
+ visualPadEnd(agent.elapsed, 10) +
393
+ styles.muted(displaySession));
394
+ }
395
+ this.log('');
396
+ this.log('═'.repeat(95));
397
+ // Summary counts
398
+ const counts = {
399
+ HUNG: agents.filter(a => a.state === 'HUNG').length,
400
+ WORKING: agents.filter(a => a.state === 'WORKING').length,
401
+ DONE: agents.filter(a => a.state === 'DONE').length,
402
+ IDLE: agents.filter(a => a.state === 'IDLE').length,
403
+ UNKNOWN: agents.filter(a => a.state === 'UNKNOWN').length,
404
+ };
405
+ const parts = [];
406
+ if (counts.WORKING > 0)
407
+ parts.push(styles.success(`${counts.WORKING} working`));
408
+ if (counts.HUNG > 0)
409
+ parts.push(styles.error(`${counts.HUNG} hung`));
410
+ if (counts.DONE > 0)
411
+ parts.push(styles.info(`${counts.DONE} done`));
412
+ if (counts.IDLE > 0)
413
+ parts.push(styles.warning(`${counts.IDLE} idle`));
414
+ if (counts.UNKNOWN > 0)
415
+ parts.push(styles.muted(`${counts.UNKNOWN} unknown`));
416
+ this.log(` ${parts.join(' ')}`);
417
+ this.log('');
418
+ // Show fix hint if there are hung agents
419
+ if (counts.HUNG > 0) {
420
+ this.log(styles.warning(` ${counts.HUNG} agent(s) appear hung. Run with --fix to send Escape and recover them.`));
421
+ this.log(styles.muted(' prlt session health --fix'));
422
+ this.log('');
423
+ }
424
+ }
425
+ /**
426
+ * Watchdog mode: continuously monitor and auto-recover hung agents.
427
+ */
428
+ async watchMode(intervalMinutes, thresholdMinutes) {
429
+ this.log('');
430
+ this.log(styles.header('Watchdog Mode'));
431
+ this.log(styles.muted(` Polling every ${intervalMinutes} minute(s)`));
432
+ this.log(styles.muted(` Auto-recovering agents hung for >${thresholdMinutes} minute(s)`));
433
+ this.log(styles.muted(' Press Ctrl+C to stop'));
434
+ this.log('');
435
+ // Track how long each agent has been in HUNG state (by sessionId)
436
+ const hungSince = new Map();
437
+ const poll = async () => {
438
+ const timestamp = new Date().toLocaleTimeString();
439
+ this.log(styles.muted(`[${timestamp}] Checking agent health...`));
440
+ const agents = await this.runHealthCheck(false);
441
+ // Track hung durations and auto-recover
442
+ const currentHungIds = new Set();
443
+ for (const agent of agents) {
444
+ if (agent.state === 'HUNG') {
445
+ currentHungIds.add(agent.sessionId);
446
+ if (!hungSince.has(agent.sessionId)) {
447
+ hungSince.set(agent.sessionId, Date.now());
448
+ this.log(styles.warning(` Detected hung agent: ${agent.agentName} (${agent.ticketId})`));
449
+ }
450
+ const hungDurationMs = Date.now() - hungSince.get(agent.sessionId);
451
+ const hungMinutes = Math.floor(hungDurationMs / 60000);
452
+ if (hungMinutes >= thresholdMinutes) {
453
+ this.log(styles.warning(` Agent ${agent.agentName} hung for ${hungMinutes}m (threshold: ${thresholdMinutes}m) - recovering...`));
454
+ const success = sendEscape(agent.sessionId, agent.containerId);
455
+ if (success) {
456
+ this.log(styles.success(` Sent Escape to ${agent.agentName} (${agent.ticketId}) - recovered`));
457
+ hungSince.delete(agent.sessionId);
458
+ }
459
+ else {
460
+ this.log(styles.error(` Failed to send Escape to ${agent.agentName} (${agent.ticketId})`));
461
+ }
462
+ }
463
+ }
464
+ }
465
+ // Clear hung tracking for agents no longer hung
466
+ for (const sessionId of hungSince.keys()) {
467
+ if (!currentHungIds.has(sessionId)) {
468
+ hungSince.delete(sessionId);
469
+ }
470
+ }
471
+ };
472
+ // Initial check
473
+ await poll();
474
+ // Poll loop
475
+ const intervalMs = intervalMinutes * 60 * 1000;
476
+ await new Promise((resolve) => {
477
+ const timer = setInterval(async () => {
478
+ try {
479
+ await poll();
480
+ }
481
+ catch (error) {
482
+ this.log(styles.error(` Error during health check: ${error}`));
483
+ }
484
+ }, intervalMs);
485
+ // Handle graceful shutdown
486
+ const cleanup = () => {
487
+ clearInterval(timer);
488
+ this.log('');
489
+ this.log(styles.muted('Watchdog stopped.'));
490
+ resolve();
491
+ };
492
+ process.on('SIGINT', cleanup);
493
+ process.on('SIGTERM', cleanup);
494
+ });
495
+ }
496
+ }
@@ -30,6 +30,7 @@ export default class Session extends PMOCommand {
30
30
  choices: [
31
31
  { name: 'List active sessions', value: 'list', command: 'prlt session list --json' },
32
32
  { name: 'Attach to a session', value: 'attach', command: 'prlt session attach --json' },
33
+ { name: 'Check agent health', value: 'health', command: 'prlt session health --json' },
33
34
  { name: 'Cancel', value: 'cancel' },
34
35
  ],
35
36
  }], jsonModeConfig);
@@ -44,6 +45,9 @@ export default class Session extends PMOCommand {
44
45
  case 'attach':
45
46
  await this.config.runCommand('session:attach', []);
46
47
  break;
48
+ case 'health':
49
+ await this.config.runCommand('session:health', []);
50
+ break;
47
51
  }
48
52
  }
49
53
  }
@@ -6,6 +6,8 @@ import { getWorkspaceInfo } from '../../lib/agents/commands.js';
6
6
  import { ExecutionStorage } from '../../lib/execution/index.js';
7
7
  import { parseSessionName, getHostTmuxSessionNames, getContainerTmuxSessionMap, flattenContainerSessions, findSessionForExecution, } from '../../lib/execution/session-utils.js';
8
8
  import { PMOCommand, pmoBaseFlags } from '../../lib/pmo/index.js';
9
+ import { shouldOutputJson } from '../../lib/prompt-json.js';
10
+ import { visualPadEnd } from '../../lib/string-utils.js';
9
11
  export default class SessionList extends PMOCommand {
10
12
  static description = 'List active tmux sessions (host and container)';
11
13
  static examples = [
@@ -25,6 +27,7 @@ export default class SessionList extends PMOCommand {
25
27
  }
26
28
  async execute() {
27
29
  const { flags } = await this.parse(SessionList);
30
+ const jsonMode = shouldOutputJson(flags);
28
31
  // Get workspace info for execution records
29
32
  let executionStorage = null;
30
33
  let db = null;
@@ -153,15 +156,19 @@ export default class SessionList extends PMOCommand {
153
156
  });
154
157
  }
155
158
  }
159
+ if (jsonMode) {
160
+ this.log(JSON.stringify(sessions, null, 2));
161
+ return;
162
+ }
156
163
  if (sessions.length > 0) {
157
164
  this.log('');
158
165
  this.log(styles.header('🖥️ Active Sessions'));
159
166
  this.log('═'.repeat(90));
160
167
  this.log(styles.muted(' ' +
161
- 'Session'.padEnd(34) +
162
- 'Ticket'.padEnd(12) +
163
- 'Agent'.padEnd(18) +
164
- 'Type'.padEnd(15) +
168
+ visualPadEnd('Session', 34) +
169
+ visualPadEnd('Ticket', 12) +
170
+ visualPadEnd('Agent', 18) +
171
+ visualPadEnd('Type', 15) +
165
172
  'Status'));
166
173
  this.log(' ' + '─'.repeat(88));
167
174
  for (const session of sessions) {
@@ -177,10 +184,10 @@ export default class SessionList extends PMOCommand {
177
184
  ? session.sessionId.substring(0, 29) + '...'
178
185
  : session.sessionId;
179
186
  this.log(' ' +
180
- displaySession.padEnd(34) +
181
- session.ticketId.padEnd(12) +
182
- session.agentName.padEnd(18) +
183
- typeIcon.padEnd(15) +
187
+ visualPadEnd(displaySession, 34) +
188
+ visualPadEnd(session.ticketId, 12) +
189
+ visualPadEnd(session.agentName, 18) +
190
+ visualPadEnd(typeIcon, 15) +
184
191
  statusColor(statusText));
185
192
  }
186
193
  this.log('');
@@ -1,5 +1,4 @@
1
1
  import { Flags, Args } from '@oclif/core';
2
- import inquirer from 'inquirer';
3
2
  import { PMOCommand, pmoBaseFlags } from '../../lib/pmo/index.js';
4
3
  import { styles } from '../../lib/styles.js';
5
4
  import { shouldOutputJson, outputErrorAsJson, createMetadata, } from '../../lib/prompt-json.js';
@@ -183,7 +182,7 @@ export default class SpecEdit extends PMOCommand {
183
182
  }
184
183
  async promptForEdits(spec, typeChoices, statusChoices) {
185
184
  // First prompt for title, status, and type
186
- const basicAnswers = await inquirer.prompt([
185
+ const basicAnswers = await this.prompt([
187
186
  {
188
187
  type: 'input',
189
188
  name: 'title',
@@ -205,7 +204,7 @@ export default class SpecEdit extends PMOCommand {
205
204
  choices: typeChoices,
206
205
  default: spec.type || '',
207
206
  },
208
- ]);
207
+ ], null);
209
208
  // Prompt for problem statement using multiline input
210
209
  const problemResult = await multiLineInput({
211
210
  message: 'Problem statement:',
@@ -1,5 +1,5 @@
1
- import { Command } from '@oclif/core';
2
- export default class Add extends Command {
1
+ import { PromptCommand } from '../../lib/prompt-command.js';
2
+ export default class Add extends PromptCommand {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static args: {