@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.
- package/dist/commands/action/create.js +3 -3
- package/dist/commands/action/index.js +2 -2
- package/dist/commands/action/update.js +3 -3
- 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/activate.js +9 -17
- package/dist/commands/epic/archive.js +13 -24
- package/dist/commands/epic/create.js +7 -6
- package/dist/commands/epic/delete.js +4 -5
- package/dist/commands/epic/move.js +28 -47
- package/dist/commands/epic/progress.js +10 -14
- package/dist/commands/epic/project.js +42 -59
- package/dist/commands/epic/reorder.js +25 -30
- package/dist/commands/epic/spec.d.ts +1 -0
- package/dist/commands/epic/spec.js +39 -40
- package/dist/commands/epic/ticket.d.ts +2 -0
- package/dist/commands/epic/ticket.js +63 -37
- package/dist/commands/feedback/index.d.ts +10 -0
- package/dist/commands/feedback/index.js +60 -0
- package/dist/commands/feedback/list.d.ts +12 -0
- package/dist/commands/feedback/list.js +126 -0
- package/dist/commands/feedback/submit.d.ts +16 -0
- package/dist/commands/feedback/submit.js +220 -0
- package/dist/commands/feedback/view.d.ts +15 -0
- package/dist/commands/feedback/view.js +109 -0
- package/dist/commands/gh/index.js +4 -0
- 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/repo/create.d.ts +38 -0
- package/dist/commands/repo/create.js +283 -0
- package/dist/commands/repo/index.js +7 -0
- package/dist/commands/roadmap/add-project.js +9 -22
- package/dist/commands/roadmap/create.d.ts +0 -1
- package/dist/commands/roadmap/create.js +46 -40
- package/dist/commands/roadmap/delete.js +10 -24
- package/dist/commands/roadmap/generate.d.ts +1 -0
- package/dist/commands/roadmap/generate.js +21 -22
- package/dist/commands/roadmap/remove-project.js +14 -34
- package/dist/commands/roadmap/reorder.js +19 -26
- package/dist/commands/roadmap/update.js +27 -26
- package/dist/commands/roadmap/view.js +5 -12
- package/dist/commands/session/attach.d.ts +1 -8
- package/dist/commands/session/attach.js +93 -59
- 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/session/list.d.ts +0 -8
- package/dist/commands/session/list.js +130 -81
- package/dist/commands/spec/create.js +1 -1
- package/dist/commands/spec/edit.js +64 -35
- 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/support/book.d.ts +10 -0
- package/dist/commands/support/book.js +54 -0
- package/dist/commands/support/discord.d.ts +10 -0
- package/dist/commands/support/discord.js +54 -0
- package/dist/commands/support/docs.d.ts +10 -0
- package/dist/commands/support/docs.js +54 -0
- package/dist/commands/support/index.d.ts +19 -0
- package/dist/commands/support/index.js +81 -0
- package/dist/commands/support/issues.d.ts +11 -0
- package/dist/commands/support/issues.js +77 -0
- package/dist/commands/support/logs.d.ts +18 -0
- package/dist/commands/support/logs.js +247 -0
- 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 +52 -26
- package/dist/commands/ticket/delete.js +15 -13
- package/dist/commands/ticket/edit.js +59 -20
- package/dist/commands/ticket/epic.js +12 -10
- package/dist/commands/ticket/move.d.ts +7 -0
- package/dist/commands/ticket/move.js +132 -0
- 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.d.ts +1 -0
- package/dist/commands/work/spawn.js +225 -64
- package/dist/commands/work/start.d.ts +1 -0
- package/dist/commands/work/start.js +301 -173
- package/dist/hooks/init.js +4 -0
- package/dist/lib/execution/runners.js +21 -17
- package/dist/lib/execution/session-utils.d.ts +60 -0
- package/dist/lib/execution/session-utils.js +162 -0
- package/dist/lib/execution/spawner.d.ts +2 -0
- package/dist/lib/execution/spawner.js +42 -0
- package/dist/lib/flags/resolver.d.ts +2 -2
- package/dist/lib/flags/resolver.js +15 -0
- package/dist/lib/init/index.js +18 -0
- package/dist/lib/multiline-input.d.ts +63 -0
- package/dist/lib/multiline-input.js +360 -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 +77 -6
- package/dist/lib/prompt-json.js +46 -0
- package/dist/lib/repos/git.d.ts +7 -0
- package/dist/lib/repos/git.js +20 -0
- package/oclif.manifest.json +2913 -2246
- 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
|
}
|