@proletariat/cli 0.3.25 → 0.3.26

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