@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.
- package/dist/commands/action/index.js +2 -2
- package/dist/commands/agent/auth.js +1 -1
- package/dist/commands/agent/cleanup.js +6 -6
- package/dist/commands/agent/discover.js +1 -1
- package/dist/commands/agent/remove.js +4 -4
- package/dist/commands/autocomplete/setup.d.ts +2 -2
- package/dist/commands/autocomplete/setup.js +5 -5
- package/dist/commands/branch/create.js +31 -30
- package/dist/commands/category/create.js +4 -5
- package/dist/commands/category/delete.js +2 -3
- package/dist/commands/category/rename.js +2 -3
- package/dist/commands/claude.d.ts +2 -8
- package/dist/commands/claude.js +26 -26
- package/dist/commands/commit.d.ts +2 -8
- package/dist/commands/commit.js +4 -26
- package/dist/commands/config/index.d.ts +2 -10
- package/dist/commands/config/index.js +8 -34
- package/dist/commands/docker/index.d.ts +2 -2
- package/dist/commands/docker/index.js +8 -8
- package/dist/commands/epic/delete.js +4 -5
- package/dist/commands/feedback/submit.d.ts +2 -2
- package/dist/commands/feedback/submit.js +9 -9
- package/dist/commands/link/index.js +2 -2
- package/dist/commands/pmo/init.d.ts +2 -2
- package/dist/commands/pmo/init.js +7 -7
- package/dist/commands/project/spec.js +6 -6
- package/dist/commands/session/health.d.ts +29 -0
- package/dist/commands/session/health.js +495 -0
- package/dist/commands/session/index.js +4 -0
- package/dist/commands/spec/edit.js +2 -3
- package/dist/commands/staff/add.d.ts +2 -2
- package/dist/commands/staff/add.js +15 -14
- package/dist/commands/staff/index.js +2 -2
- package/dist/commands/staff/remove.js +4 -4
- package/dist/commands/status/index.js +6 -7
- package/dist/commands/template/apply.js +10 -11
- package/dist/commands/template/create.js +18 -17
- package/dist/commands/template/index.d.ts +2 -2
- package/dist/commands/template/index.js +6 -6
- package/dist/commands/template/save.js +8 -7
- package/dist/commands/template/update.js +6 -7
- package/dist/commands/terminal/title.d.ts +2 -26
- package/dist/commands/terminal/title.js +4 -33
- package/dist/commands/theme/index.d.ts +2 -2
- package/dist/commands/theme/index.js +19 -18
- package/dist/commands/theme/set.d.ts +2 -2
- package/dist/commands/theme/set.js +5 -5
- package/dist/commands/ticket/create.js +34 -16
- package/dist/commands/ticket/delete.js +15 -13
- package/dist/commands/ticket/edit.js +20 -12
- package/dist/commands/ticket/epic.js +12 -10
- package/dist/commands/ticket/project.js +11 -9
- package/dist/commands/ticket/reassign.js +23 -19
- package/dist/commands/ticket/spec.js +7 -5
- package/dist/commands/ticket/update.js +55 -53
- package/dist/commands/whoami.js +1 -0
- package/dist/commands/work/ready.js +7 -7
- package/dist/commands/work/revise.js +13 -11
- package/dist/commands/work/spawn.js +154 -57
- package/dist/commands/work/start.d.ts +1 -0
- package/dist/commands/work/start.js +295 -173
- package/dist/hooks/init.js +4 -0
- package/dist/lib/pr/index.d.ts +4 -0
- package/dist/lib/pr/index.js +32 -14
- package/dist/lib/prompt-command.d.ts +3 -0
- package/dist/lib/prompt-json.d.ts +72 -1
- package/dist/lib/prompt-json.js +46 -0
- package/oclif.manifest.json +1184 -1116
- 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
|
|
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 {
|
|
2
|
-
export default class Add extends
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|