@proletariat/cli 0.3.24 → 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 (134) hide show
  1. package/dist/commands/action/create.js +3 -3
  2. package/dist/commands/action/index.js +2 -2
  3. package/dist/commands/action/update.js +3 -3
  4. package/dist/commands/agent/auth.js +1 -1
  5. package/dist/commands/agent/cleanup.js +6 -6
  6. package/dist/commands/agent/discover.js +1 -1
  7. package/dist/commands/agent/remove.js +4 -4
  8. package/dist/commands/autocomplete/setup.d.ts +2 -2
  9. package/dist/commands/autocomplete/setup.js +5 -5
  10. package/dist/commands/branch/create.js +31 -30
  11. package/dist/commands/category/create.js +4 -5
  12. package/dist/commands/category/delete.js +2 -3
  13. package/dist/commands/category/rename.js +2 -3
  14. package/dist/commands/claude.d.ts +2 -8
  15. package/dist/commands/claude.js +26 -26
  16. package/dist/commands/commit.d.ts +2 -8
  17. package/dist/commands/commit.js +4 -26
  18. package/dist/commands/config/index.d.ts +2 -10
  19. package/dist/commands/config/index.js +8 -34
  20. package/dist/commands/docker/index.d.ts +2 -2
  21. package/dist/commands/docker/index.js +8 -8
  22. package/dist/commands/epic/activate.js +9 -17
  23. package/dist/commands/epic/archive.js +13 -24
  24. package/dist/commands/epic/create.js +7 -6
  25. package/dist/commands/epic/delete.js +4 -5
  26. package/dist/commands/epic/move.js +28 -47
  27. package/dist/commands/epic/progress.js +10 -14
  28. package/dist/commands/epic/project.js +42 -59
  29. package/dist/commands/epic/reorder.js +25 -30
  30. package/dist/commands/epic/spec.d.ts +1 -0
  31. package/dist/commands/epic/spec.js +39 -40
  32. package/dist/commands/epic/ticket.d.ts +2 -0
  33. package/dist/commands/epic/ticket.js +63 -37
  34. package/dist/commands/feedback/index.d.ts +10 -0
  35. package/dist/commands/feedback/index.js +60 -0
  36. package/dist/commands/feedback/list.d.ts +12 -0
  37. package/dist/commands/feedback/list.js +126 -0
  38. package/dist/commands/feedback/submit.d.ts +16 -0
  39. package/dist/commands/feedback/submit.js +220 -0
  40. package/dist/commands/feedback/view.d.ts +15 -0
  41. package/dist/commands/feedback/view.js +109 -0
  42. package/dist/commands/gh/index.js +4 -0
  43. package/dist/commands/link/index.js +2 -2
  44. package/dist/commands/pmo/init.d.ts +2 -2
  45. package/dist/commands/pmo/init.js +7 -7
  46. package/dist/commands/project/spec.js +6 -6
  47. package/dist/commands/repo/create.d.ts +38 -0
  48. package/dist/commands/repo/create.js +283 -0
  49. package/dist/commands/repo/index.js +7 -0
  50. package/dist/commands/roadmap/add-project.js +9 -22
  51. package/dist/commands/roadmap/create.d.ts +0 -1
  52. package/dist/commands/roadmap/create.js +46 -40
  53. package/dist/commands/roadmap/delete.js +10 -24
  54. package/dist/commands/roadmap/generate.d.ts +1 -0
  55. package/dist/commands/roadmap/generate.js +21 -22
  56. package/dist/commands/roadmap/remove-project.js +14 -34
  57. package/dist/commands/roadmap/reorder.js +19 -26
  58. package/dist/commands/roadmap/update.js +27 -26
  59. package/dist/commands/roadmap/view.js +5 -12
  60. package/dist/commands/session/attach.d.ts +1 -8
  61. package/dist/commands/session/attach.js +93 -59
  62. package/dist/commands/session/health.d.ts +29 -0
  63. package/dist/commands/session/health.js +495 -0
  64. package/dist/commands/session/index.js +4 -0
  65. package/dist/commands/session/list.d.ts +0 -8
  66. package/dist/commands/session/list.js +130 -81
  67. package/dist/commands/spec/create.js +1 -1
  68. package/dist/commands/spec/edit.js +64 -35
  69. package/dist/commands/staff/add.d.ts +2 -2
  70. package/dist/commands/staff/add.js +15 -14
  71. package/dist/commands/staff/index.js +2 -2
  72. package/dist/commands/staff/remove.js +4 -4
  73. package/dist/commands/status/index.js +6 -7
  74. package/dist/commands/support/book.d.ts +10 -0
  75. package/dist/commands/support/book.js +54 -0
  76. package/dist/commands/support/discord.d.ts +10 -0
  77. package/dist/commands/support/discord.js +54 -0
  78. package/dist/commands/support/docs.d.ts +10 -0
  79. package/dist/commands/support/docs.js +54 -0
  80. package/dist/commands/support/index.d.ts +19 -0
  81. package/dist/commands/support/index.js +81 -0
  82. package/dist/commands/support/issues.d.ts +11 -0
  83. package/dist/commands/support/issues.js +77 -0
  84. package/dist/commands/support/logs.d.ts +18 -0
  85. package/dist/commands/support/logs.js +247 -0
  86. package/dist/commands/template/apply.js +10 -11
  87. package/dist/commands/template/create.js +18 -17
  88. package/dist/commands/template/index.d.ts +2 -2
  89. package/dist/commands/template/index.js +6 -6
  90. package/dist/commands/template/save.js +8 -7
  91. package/dist/commands/template/update.js +6 -7
  92. package/dist/commands/terminal/title.d.ts +2 -26
  93. package/dist/commands/terminal/title.js +4 -33
  94. package/dist/commands/theme/index.d.ts +2 -2
  95. package/dist/commands/theme/index.js +19 -18
  96. package/dist/commands/theme/set.d.ts +2 -2
  97. package/dist/commands/theme/set.js +5 -5
  98. package/dist/commands/ticket/create.js +52 -26
  99. package/dist/commands/ticket/delete.js +15 -13
  100. package/dist/commands/ticket/edit.js +59 -20
  101. package/dist/commands/ticket/epic.js +12 -10
  102. package/dist/commands/ticket/move.d.ts +7 -0
  103. package/dist/commands/ticket/move.js +132 -0
  104. package/dist/commands/ticket/project.js +11 -9
  105. package/dist/commands/ticket/reassign.js +23 -19
  106. package/dist/commands/ticket/spec.js +7 -5
  107. package/dist/commands/ticket/update.js +55 -53
  108. package/dist/commands/whoami.js +1 -0
  109. package/dist/commands/work/ready.js +7 -7
  110. package/dist/commands/work/revise.js +13 -11
  111. package/dist/commands/work/spawn.d.ts +1 -0
  112. package/dist/commands/work/spawn.js +225 -64
  113. package/dist/commands/work/start.d.ts +1 -0
  114. package/dist/commands/work/start.js +301 -173
  115. package/dist/hooks/init.js +4 -0
  116. package/dist/lib/execution/runners.js +21 -17
  117. package/dist/lib/execution/session-utils.d.ts +60 -0
  118. package/dist/lib/execution/session-utils.js +162 -0
  119. package/dist/lib/execution/spawner.d.ts +2 -0
  120. package/dist/lib/execution/spawner.js +42 -0
  121. package/dist/lib/flags/resolver.d.ts +2 -2
  122. package/dist/lib/flags/resolver.js +15 -0
  123. package/dist/lib/init/index.js +18 -0
  124. package/dist/lib/multiline-input.d.ts +63 -0
  125. package/dist/lib/multiline-input.js +360 -0
  126. package/dist/lib/pr/index.d.ts +4 -0
  127. package/dist/lib/pr/index.js +32 -14
  128. package/dist/lib/prompt-command.d.ts +3 -0
  129. package/dist/lib/prompt-json.d.ts +77 -6
  130. package/dist/lib/prompt-json.js +46 -0
  131. package/dist/lib/repos/git.d.ts +7 -0
  132. package/dist/lib/repos/git.js +20 -0
  133. package/oclif.manifest.json +2913 -2246
  134. package/package.json +1 -1
@@ -0,0 +1,29 @@
1
+ import { PMOCommand } from '../../lib/pmo/index.js';
2
+ export default class SessionHealth extends PMOCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ fix: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ watch: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ interval: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
9
+ threshold: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
10
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ project: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ };
13
+ protected getPMOOptions(): {
14
+ promptIfMultiple: boolean;
15
+ };
16
+ execute(): Promise<void>;
17
+ /**
18
+ * Single health check pass. Returns the list of agent health infos.
19
+ */
20
+ private runHealthCheck;
21
+ /**
22
+ * Display the health status table.
23
+ */
24
+ private displayHealthTable;
25
+ /**
26
+ * Watchdog mode: continuously monitor and auto-recover hung agents.
27
+ */
28
+ private watchMode;
29
+ }
@@ -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
  }
@@ -11,12 +11,4 @@ export default class SessionList extends PMOCommand {
11
11
  promptIfMultiple: boolean;
12
12
  };
13
13
  execute(): Promise<void>;
14
- /**
15
- * Get list of host tmux session names
16
- */
17
- private getHostTmuxSessionNames;
18
- /**
19
- * Get map of containerId -> tmux session names
20
- */
21
- private getContainerTmuxSessionMap;
22
14
  }