claude-comms 1.0.35 → 1.0.37
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/.agents/AGENTS.md +6 -6
- package/.agents/repo.md +10 -2
- package/.agents/tools/agent-job/README.md +208 -0
- package/.agents/tools/agent-job/agent_job.py +857 -0
- package/.agents/tools/agent-job/agent_monitor.py +575 -0
- package/{.claude/agents/core/consultant.md → .agents/tools/agent-job/consultant_template.txt} +14 -72
- package/.claude/agents/core/consultants/codex.md +39 -0
- package/.claude/agents/core/consultants/gemini.md +39 -0
- package/.claude/agents/core/designer.md +1 -1
- package/.claude/agents/core/engineer.md +1 -1
- package/.claude/agents/core/uat.md +1 -1
- package/.claude/commands/analyse-document.md +1 -1
- package/.claude/commands/cook.md +8 -10
- package/.claude/commands/install-feedback-widget.md +74 -42
- package/.claude/commands/learn.md +1 -1
- package/CLAUDE.md +9 -11
- package/package.json +1 -1
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = []
|
|
5
|
+
# ///
|
|
6
|
+
"""
|
|
7
|
+
Agent Job Monitor - TUI for monitoring agent jobs.
|
|
8
|
+
|
|
9
|
+
Real-time display of all agent jobs with status indicators.
|
|
10
|
+
Similar to `top` but for agent jobs.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
uv run agent_monitor.py [--interval SECONDS]
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import curses
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import signal
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class JobInfo:
|
|
32
|
+
job_id: str
|
|
33
|
+
harness: str
|
|
34
|
+
status: str
|
|
35
|
+
status_reason: Optional[str]
|
|
36
|
+
runtime_sec: float
|
|
37
|
+
idle_sec: Optional[float]
|
|
38
|
+
operations: int
|
|
39
|
+
start_time: Optional[str]
|
|
40
|
+
logs: Optional[str] = None
|
|
41
|
+
pid: Optional[int] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_jobs() -> list[JobInfo]:
|
|
45
|
+
"""Fetch current job list via agent_job.py"""
|
|
46
|
+
script_dir = Path(__file__).parent
|
|
47
|
+
agent_job = script_dir / "agent_job.py"
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
["uv", "run", str(agent_job), "list"],
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
timeout=10,
|
|
55
|
+
)
|
|
56
|
+
if result.returncode != 0:
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
jobs_data = json.loads(result.stdout)
|
|
60
|
+
jobs = []
|
|
61
|
+
for job in jobs_data:
|
|
62
|
+
jobs.append(JobInfo(
|
|
63
|
+
job_id=job.get("job_id", ""),
|
|
64
|
+
harness=job.get("harness", ""),
|
|
65
|
+
status=job.get("status", "unknown"),
|
|
66
|
+
status_reason=job.get("status_reason"),
|
|
67
|
+
runtime_sec=job.get("runtime_sec", 0),
|
|
68
|
+
idle_sec=job.get("idle_sec"),
|
|
69
|
+
operations=job.get("operations", 0),
|
|
70
|
+
start_time=job.get("start_time"),
|
|
71
|
+
logs=job.get("logs"),
|
|
72
|
+
pid=job.get("pid"),
|
|
73
|
+
))
|
|
74
|
+
return jobs
|
|
75
|
+
except Exception:
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def read_log_file(log_path: str) -> list[str]:
|
|
80
|
+
"""Read log file and return lines"""
|
|
81
|
+
try:
|
|
82
|
+
with open(log_path, "r") as f:
|
|
83
|
+
return f.readlines()
|
|
84
|
+
except Exception:
|
|
85
|
+
return ["(Unable to read log file)"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_job_status(job_id: str) -> Optional[dict]:
|
|
89
|
+
"""Fetch detailed status for a single job"""
|
|
90
|
+
script_dir = Path(__file__).parent
|
|
91
|
+
agent_job = script_dir / "agent_job.py"
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
result = subprocess.run(
|
|
95
|
+
["uv", "run", str(agent_job), "status", job_id],
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
timeout=10,
|
|
99
|
+
)
|
|
100
|
+
if result.returncode != 0:
|
|
101
|
+
return None
|
|
102
|
+
return json.loads(result.stdout)
|
|
103
|
+
except Exception:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def kill_job(pid: int) -> bool:
|
|
108
|
+
"""Kill a job by PID. Returns True if successful."""
|
|
109
|
+
if not pid:
|
|
110
|
+
return False
|
|
111
|
+
try:
|
|
112
|
+
os.kill(pid, signal.SIGTERM)
|
|
113
|
+
# Give it a moment, then force kill if still running
|
|
114
|
+
time.sleep(0.5)
|
|
115
|
+
try:
|
|
116
|
+
os.kill(pid, 0) # Check if still running
|
|
117
|
+
os.kill(pid, signal.SIGKILL) # Force kill
|
|
118
|
+
except ProcessLookupError:
|
|
119
|
+
pass # Already dead
|
|
120
|
+
return True
|
|
121
|
+
except ProcessLookupError:
|
|
122
|
+
return True # Already dead
|
|
123
|
+
except PermissionError:
|
|
124
|
+
return False
|
|
125
|
+
except Exception:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def format_duration(seconds: float) -> str:
|
|
130
|
+
"""Format seconds as human-readable duration"""
|
|
131
|
+
if seconds < 60:
|
|
132
|
+
return f"{seconds:.1f}s"
|
|
133
|
+
elif seconds < 3600:
|
|
134
|
+
mins = int(seconds // 60)
|
|
135
|
+
secs = int(seconds % 60)
|
|
136
|
+
return f"{mins}m{secs:02d}s"
|
|
137
|
+
else:
|
|
138
|
+
hours = int(seconds // 3600)
|
|
139
|
+
mins = int((seconds % 3600) // 60)
|
|
140
|
+
return f"{hours}h{mins:02d}m"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def format_time_ago(iso_time: Optional[str]) -> str:
|
|
144
|
+
"""Format ISO time as 'X ago'"""
|
|
145
|
+
if not iso_time:
|
|
146
|
+
return "?"
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
if iso_time.endswith("Z"):
|
|
150
|
+
iso_time = iso_time[:-1] + "+00:00"
|
|
151
|
+
dt = datetime.fromisoformat(iso_time)
|
|
152
|
+
now = datetime.now(timezone.utc)
|
|
153
|
+
delta = (now - dt).total_seconds()
|
|
154
|
+
return format_duration(delta) + " ago"
|
|
155
|
+
except Exception:
|
|
156
|
+
return "?"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def truncate(s: str, max_len: int) -> str:
|
|
160
|
+
"""Truncate string with ellipsis if too long"""
|
|
161
|
+
if len(s) <= max_len:
|
|
162
|
+
return s
|
|
163
|
+
return s[:max_len-1] + "…"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def wrap_line(line: str, width: int, max_lines: int = 5) -> list[str]:
|
|
167
|
+
"""Wrap a line to fit within width, max number of wrapped lines.
|
|
168
|
+
|
|
169
|
+
Returns a list of display lines. If content exceeds max_lines,
|
|
170
|
+
the last line is truncated with ellipsis.
|
|
171
|
+
"""
|
|
172
|
+
line = line.rstrip('\n\r')
|
|
173
|
+
if not line:
|
|
174
|
+
return [""]
|
|
175
|
+
|
|
176
|
+
if len(line) <= width:
|
|
177
|
+
return [line]
|
|
178
|
+
|
|
179
|
+
wrapped = []
|
|
180
|
+
remaining = line
|
|
181
|
+
|
|
182
|
+
while remaining and len(wrapped) < max_lines:
|
|
183
|
+
if len(remaining) <= width:
|
|
184
|
+
wrapped.append(remaining)
|
|
185
|
+
remaining = ""
|
|
186
|
+
else:
|
|
187
|
+
# Check if this will be the last allowed line
|
|
188
|
+
if len(wrapped) == max_lines - 1:
|
|
189
|
+
# Truncate with ellipsis
|
|
190
|
+
wrapped.append(remaining[:width-1] + "…")
|
|
191
|
+
remaining = ""
|
|
192
|
+
else:
|
|
193
|
+
wrapped.append(remaining[:width])
|
|
194
|
+
remaining = remaining[width:]
|
|
195
|
+
|
|
196
|
+
return wrapped if wrapped else [""]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Monitor:
|
|
200
|
+
def __init__(self, stdscr, interval: float = 2.0):
|
|
201
|
+
self.stdscr = stdscr
|
|
202
|
+
self.interval = interval
|
|
203
|
+
self.jobs: list[JobInfo] = []
|
|
204
|
+
self.selected_idx = 0
|
|
205
|
+
self.scroll_offset = 0
|
|
206
|
+
self.show_log = False
|
|
207
|
+
self.log_job: Optional[JobInfo] = None
|
|
208
|
+
self.log_lines: list[str] = []
|
|
209
|
+
self.log_scroll = 0
|
|
210
|
+
self.log_display_line_count = 0 # Cached count of wrapped display lines
|
|
211
|
+
|
|
212
|
+
# Setup colors
|
|
213
|
+
curses.start_color()
|
|
214
|
+
curses.use_default_colors()
|
|
215
|
+
curses.init_pair(1, curses.COLOR_GREEN, -1) # complete
|
|
216
|
+
curses.init_pair(2, curses.COLOR_YELLOW, -1) # running
|
|
217
|
+
curses.init_pair(3, curses.COLOR_RED, -1) # error/timeout
|
|
218
|
+
curses.init_pair(4, curses.COLOR_CYAN, -1) # header
|
|
219
|
+
curses.init_pair(5, curses.COLOR_MAGENTA, -1) # selected
|
|
220
|
+
curses.init_pair(6, curses.COLOR_WHITE, -1) # normal
|
|
221
|
+
|
|
222
|
+
# Hide cursor
|
|
223
|
+
curses.curs_set(0)
|
|
224
|
+
|
|
225
|
+
# Non-blocking input
|
|
226
|
+
self.stdscr.nodelay(True)
|
|
227
|
+
self.stdscr.keypad(True)
|
|
228
|
+
|
|
229
|
+
def get_status_color(self, status: str) -> int:
|
|
230
|
+
"""Get color pair for status"""
|
|
231
|
+
if status == "complete":
|
|
232
|
+
return curses.color_pair(1)
|
|
233
|
+
elif status == "running":
|
|
234
|
+
return curses.color_pair(2)
|
|
235
|
+
elif status in ("error", "timeout"):
|
|
236
|
+
return curses.color_pair(3)
|
|
237
|
+
return curses.color_pair(6)
|
|
238
|
+
|
|
239
|
+
def get_status_indicator(self, status: str) -> str:
|
|
240
|
+
"""Get status indicator symbol"""
|
|
241
|
+
if status == "complete":
|
|
242
|
+
return "✓"
|
|
243
|
+
elif status == "running":
|
|
244
|
+
return "●"
|
|
245
|
+
elif status == "error":
|
|
246
|
+
return "✗"
|
|
247
|
+
elif status == "timeout":
|
|
248
|
+
return "⏱"
|
|
249
|
+
return "?"
|
|
250
|
+
|
|
251
|
+
def draw_header(self):
|
|
252
|
+
"""Draw the header bar"""
|
|
253
|
+
height, width = self.stdscr.getmaxyx()
|
|
254
|
+
|
|
255
|
+
# Title
|
|
256
|
+
title = " Agent Job Monitor "
|
|
257
|
+
now = datetime.now().strftime("%H:%M:%S")
|
|
258
|
+
header = f"{'─' * 2}{title}{'─' * (width - len(title) - len(now) - 6)} {now} ─"
|
|
259
|
+
|
|
260
|
+
self.stdscr.attron(curses.color_pair(4) | curses.A_BOLD)
|
|
261
|
+
self.stdscr.addnstr(0, 0, header, width - 1)
|
|
262
|
+
self.stdscr.attroff(curses.color_pair(4) | curses.A_BOLD)
|
|
263
|
+
|
|
264
|
+
def draw_column_headers(self, y: int):
|
|
265
|
+
"""Draw column headers"""
|
|
266
|
+
height, width = self.stdscr.getmaxyx()
|
|
267
|
+
|
|
268
|
+
# Column layout
|
|
269
|
+
headers = f"{'ST':<3} {'HARNESS':<7} {'PID':<7} {'JOB ID':<20} {'STATUS':<26} {'STARTED':<10} {'RUNTIME':<8} {'OPS':<4}"
|
|
270
|
+
|
|
271
|
+
self.stdscr.attron(curses.color_pair(4) | curses.A_REVERSE)
|
|
272
|
+
self.stdscr.addnstr(y, 0, headers.ljust(width - 1), width - 1)
|
|
273
|
+
self.stdscr.attroff(curses.color_pair(4) | curses.A_REVERSE)
|
|
274
|
+
|
|
275
|
+
def draw_job_row(self, y: int, job: JobInfo, selected: bool):
|
|
276
|
+
"""Draw a single job row"""
|
|
277
|
+
height, width = self.stdscr.getmaxyx()
|
|
278
|
+
|
|
279
|
+
indicator = self.get_status_indicator(job.status)
|
|
280
|
+
color = self.get_status_color(job.status)
|
|
281
|
+
|
|
282
|
+
# Build status reason display
|
|
283
|
+
reason = job.status_reason or job.status
|
|
284
|
+
if job.status == "running" and job.idle_sec is not None:
|
|
285
|
+
reason = f"{reason} (idle {format_duration(job.idle_sec)})"
|
|
286
|
+
|
|
287
|
+
# Format start time as HH:MM:SS
|
|
288
|
+
start_display = "?"
|
|
289
|
+
if job.start_time:
|
|
290
|
+
try:
|
|
291
|
+
# Parse ISO time and format as local time
|
|
292
|
+
ts = job.start_time
|
|
293
|
+
if ts.endswith("Z"):
|
|
294
|
+
ts = ts[:-1] + "+00:00"
|
|
295
|
+
dt = datetime.fromisoformat(ts)
|
|
296
|
+
start_display = dt.strftime("%H:%M:%S")
|
|
297
|
+
except Exception:
|
|
298
|
+
start_display = "?"
|
|
299
|
+
|
|
300
|
+
# Format PID
|
|
301
|
+
pid_display = str(job.pid) if job.pid else "?"
|
|
302
|
+
|
|
303
|
+
# Format row
|
|
304
|
+
job_id_short = job.job_id[:18] if len(job.job_id) > 18 else job.job_id
|
|
305
|
+
row = f" {indicator} {job.harness:<7} {pid_display:<7} {job_id_short:<20} {truncate(reason, 24):<26} {start_display:<10} {format_duration(job.runtime_sec):<8} {job.operations:<4}"
|
|
306
|
+
|
|
307
|
+
if selected:
|
|
308
|
+
self.stdscr.attron(curses.color_pair(5) | curses.A_REVERSE)
|
|
309
|
+
self.stdscr.addnstr(y, 0, row.ljust(width - 1), width - 1)
|
|
310
|
+
self.stdscr.attroff(curses.color_pair(5) | curses.A_REVERSE)
|
|
311
|
+
else:
|
|
312
|
+
self.stdscr.attron(color)
|
|
313
|
+
self.stdscr.addnstr(y, 0, row.ljust(width - 1), width - 1)
|
|
314
|
+
self.stdscr.attroff(color)
|
|
315
|
+
|
|
316
|
+
def draw_job_list(self):
|
|
317
|
+
"""Draw the job list"""
|
|
318
|
+
height, width = self.stdscr.getmaxyx()
|
|
319
|
+
|
|
320
|
+
# Draw column headers
|
|
321
|
+
self.draw_column_headers(2)
|
|
322
|
+
|
|
323
|
+
# Calculate visible area
|
|
324
|
+
list_start = 3
|
|
325
|
+
list_height = height - 5 # Leave room for header, columns, and footer
|
|
326
|
+
|
|
327
|
+
# Adjust scroll offset
|
|
328
|
+
if self.selected_idx < self.scroll_offset:
|
|
329
|
+
self.scroll_offset = self.selected_idx
|
|
330
|
+
elif self.selected_idx >= self.scroll_offset + list_height:
|
|
331
|
+
self.scroll_offset = self.selected_idx - list_height + 1
|
|
332
|
+
|
|
333
|
+
# Draw visible jobs
|
|
334
|
+
for i, job in enumerate(self.jobs[self.scroll_offset:self.scroll_offset + list_height]):
|
|
335
|
+
actual_idx = i + self.scroll_offset
|
|
336
|
+
y = list_start + i
|
|
337
|
+
if y >= height - 2:
|
|
338
|
+
break
|
|
339
|
+
self.draw_job_row(y, job, actual_idx == self.selected_idx)
|
|
340
|
+
|
|
341
|
+
# Show if more items above/below
|
|
342
|
+
if self.scroll_offset > 0:
|
|
343
|
+
self.stdscr.addstr(list_start, width - 3, "▲")
|
|
344
|
+
if self.scroll_offset + list_height < len(self.jobs):
|
|
345
|
+
self.stdscr.addstr(min(list_start + list_height - 1, height - 3), width - 3, "▼")
|
|
346
|
+
|
|
347
|
+
def draw_log_view(self):
|
|
348
|
+
"""Draw scrollable log view for selected job"""
|
|
349
|
+
if not self.log_job:
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
height, width = self.stdscr.getmaxyx()
|
|
353
|
+
|
|
354
|
+
# Header with job info
|
|
355
|
+
job = self.log_job
|
|
356
|
+
indicator = self.get_status_indicator(job.status)
|
|
357
|
+
color = self.get_status_color(job.status)
|
|
358
|
+
|
|
359
|
+
# Job header line
|
|
360
|
+
self.stdscr.attron(curses.A_BOLD)
|
|
361
|
+
header = f" {indicator} {job.harness} | {job.job_id} | {job.status_reason or job.status} | {format_duration(job.runtime_sec)}"
|
|
362
|
+
self.stdscr.addnstr(2, 0, header, width - 1)
|
|
363
|
+
self.stdscr.attroff(curses.A_BOLD)
|
|
364
|
+
|
|
365
|
+
# Log file path
|
|
366
|
+
self.stdscr.attron(curses.color_pair(4))
|
|
367
|
+
self.stdscr.addnstr(3, 0, f" Log: {job.logs or '?'}", width - 1)
|
|
368
|
+
self.stdscr.attroff(curses.color_pair(4))
|
|
369
|
+
|
|
370
|
+
# Separator
|
|
371
|
+
self.stdscr.addnstr(4, 0, "─" * (width - 1), width - 1)
|
|
372
|
+
|
|
373
|
+
# Log content area
|
|
374
|
+
log_start = 5
|
|
375
|
+
log_height = height - log_start - 2
|
|
376
|
+
content_width = width - 4 # Leave margin for wrap indicator
|
|
377
|
+
|
|
378
|
+
# Build display lines (wrapped) from source lines
|
|
379
|
+
display_lines = []
|
|
380
|
+
for source_line in self.log_lines:
|
|
381
|
+
wrapped = wrap_line(source_line, content_width, max_lines=5)
|
|
382
|
+
display_lines.extend(wrapped)
|
|
383
|
+
|
|
384
|
+
# Cache display line count for scroll handling
|
|
385
|
+
self.log_display_line_count = len(display_lines)
|
|
386
|
+
|
|
387
|
+
# Clamp scroll to display lines
|
|
388
|
+
max_scroll = max(0, len(display_lines) - log_height)
|
|
389
|
+
self.log_scroll = max(0, min(self.log_scroll, max_scroll))
|
|
390
|
+
|
|
391
|
+
# Draw display lines
|
|
392
|
+
visible_lines = display_lines[self.log_scroll:self.log_scroll + log_height]
|
|
393
|
+
for i, line in enumerate(visible_lines):
|
|
394
|
+
y = log_start + i
|
|
395
|
+
if y >= height - 2:
|
|
396
|
+
break
|
|
397
|
+
self.stdscr.addnstr(y, 1, line, width - 2)
|
|
398
|
+
|
|
399
|
+
# Scroll indicators
|
|
400
|
+
if self.log_scroll > 0:
|
|
401
|
+
self.stdscr.attron(curses.color_pair(4))
|
|
402
|
+
self.stdscr.addstr(log_start, width - 2, "▲")
|
|
403
|
+
self.stdscr.attroff(curses.color_pair(4))
|
|
404
|
+
if self.log_scroll + log_height < len(display_lines):
|
|
405
|
+
self.stdscr.attron(curses.color_pair(4))
|
|
406
|
+
self.stdscr.addstr(min(log_start + log_height - 1, height - 3), width - 2, "▼")
|
|
407
|
+
self.stdscr.attroff(curses.color_pair(4))
|
|
408
|
+
|
|
409
|
+
# Line count
|
|
410
|
+
total = len(display_lines)
|
|
411
|
+
showing = f"Lines {self.log_scroll + 1}-{min(self.log_scroll + log_height, total)} of {total}"
|
|
412
|
+
self.stdscr.attron(curses.color_pair(4))
|
|
413
|
+
self.stdscr.addnstr(height - 2, width - len(showing) - 2, showing, len(showing))
|
|
414
|
+
self.stdscr.attroff(curses.color_pair(4))
|
|
415
|
+
|
|
416
|
+
def draw_footer(self):
|
|
417
|
+
"""Draw the footer with help"""
|
|
418
|
+
height, width = self.stdscr.getmaxyx()
|
|
419
|
+
|
|
420
|
+
# Summary
|
|
421
|
+
running = sum(1 for j in self.jobs if j.status == "running")
|
|
422
|
+
complete = sum(1 for j in self.jobs if j.status == "complete")
|
|
423
|
+
failed = sum(1 for j in self.jobs if j.status in ("error", "timeout"))
|
|
424
|
+
|
|
425
|
+
summary = f" Jobs: {len(self.jobs)} total, {running} running, {complete} complete, {failed} failed "
|
|
426
|
+
|
|
427
|
+
# Help
|
|
428
|
+
if self.show_log:
|
|
429
|
+
help_text = " [↑↓/PgUp/PgDn] Scroll [r] Reload [ESC] Back [q] Quit "
|
|
430
|
+
else:
|
|
431
|
+
help_text = " [↑↓] Select [Enter] Log [x] Kill [r] Refresh [q] Quit "
|
|
432
|
+
|
|
433
|
+
# Draw footer bar
|
|
434
|
+
footer = f"{summary}{'─' * (width - len(summary) - len(help_text) - 2)}{help_text}"
|
|
435
|
+
|
|
436
|
+
self.stdscr.attron(curses.color_pair(4))
|
|
437
|
+
self.stdscr.addnstr(height - 1, 0, footer, width - 1)
|
|
438
|
+
self.stdscr.attroff(curses.color_pair(4))
|
|
439
|
+
|
|
440
|
+
def draw(self):
|
|
441
|
+
"""Draw the full screen"""
|
|
442
|
+
self.stdscr.clear()
|
|
443
|
+
|
|
444
|
+
self.draw_header()
|
|
445
|
+
|
|
446
|
+
if self.show_log:
|
|
447
|
+
self.draw_log_view()
|
|
448
|
+
else:
|
|
449
|
+
self.draw_job_list()
|
|
450
|
+
|
|
451
|
+
self.draw_footer()
|
|
452
|
+
|
|
453
|
+
self.stdscr.refresh()
|
|
454
|
+
|
|
455
|
+
def handle_input(self) -> bool:
|
|
456
|
+
"""Handle keyboard input. Returns False to quit."""
|
|
457
|
+
try:
|
|
458
|
+
key = self.stdscr.getch()
|
|
459
|
+
except Exception:
|
|
460
|
+
return True
|
|
461
|
+
|
|
462
|
+
if key == -1:
|
|
463
|
+
return True
|
|
464
|
+
|
|
465
|
+
if key == ord('q') or key == ord('Q'):
|
|
466
|
+
return False
|
|
467
|
+
|
|
468
|
+
if self.show_log:
|
|
469
|
+
# Log view controls
|
|
470
|
+
height, _ = self.stdscr.getmaxyx()
|
|
471
|
+
log_height = height - 7 # Visible log lines
|
|
472
|
+
|
|
473
|
+
if key == 27 or key == curses.KEY_BACKSPACE: # ESC or Backspace
|
|
474
|
+
self.show_log = False
|
|
475
|
+
self.log_job = None
|
|
476
|
+
self.log_lines = []
|
|
477
|
+
self.log_scroll = 0
|
|
478
|
+
elif key == curses.KEY_UP or key == ord('k'):
|
|
479
|
+
self.log_scroll = max(0, self.log_scroll - 1)
|
|
480
|
+
elif key == curses.KEY_DOWN or key == ord('j'):
|
|
481
|
+
self.log_scroll += 1
|
|
482
|
+
elif key == curses.KEY_PPAGE: # Page Up
|
|
483
|
+
self.log_scroll = max(0, self.log_scroll - log_height)
|
|
484
|
+
elif key == curses.KEY_NPAGE: # Page Down
|
|
485
|
+
self.log_scroll += log_height
|
|
486
|
+
elif key == curses.KEY_HOME:
|
|
487
|
+
self.log_scroll = 0
|
|
488
|
+
elif key == curses.KEY_END:
|
|
489
|
+
self.log_scroll = max(0, self.log_display_line_count - log_height)
|
|
490
|
+
elif key == ord('r') or key == ord('R'):
|
|
491
|
+
# Reload log file
|
|
492
|
+
if self.log_job and self.log_job.logs:
|
|
493
|
+
self.log_lines = read_log_file(self.log_job.logs)
|
|
494
|
+
else:
|
|
495
|
+
# Job list controls
|
|
496
|
+
if key == curses.KEY_UP or key == ord('k'):
|
|
497
|
+
self.selected_idx = max(0, self.selected_idx - 1)
|
|
498
|
+
elif key == curses.KEY_DOWN or key == ord('j'):
|
|
499
|
+
if self.selected_idx < len(self.jobs) - 1:
|
|
500
|
+
self.selected_idx += 1
|
|
501
|
+
elif key == ord('\n') or key == curses.KEY_ENTER:
|
|
502
|
+
if self.jobs and 0 <= self.selected_idx < len(self.jobs):
|
|
503
|
+
job = self.jobs[self.selected_idx]
|
|
504
|
+
self.show_log = True
|
|
505
|
+
self.log_job = job
|
|
506
|
+
self.log_scroll = 0
|
|
507
|
+
if job.logs:
|
|
508
|
+
self.log_lines = read_log_file(job.logs)
|
|
509
|
+
else:
|
|
510
|
+
self.log_lines = ["(No log file available)"]
|
|
511
|
+
elif key == ord('r') or key == ord('R'):
|
|
512
|
+
self.jobs = get_jobs()
|
|
513
|
+
elif key == ord('x') or key == ord('X'):
|
|
514
|
+
# Kill selected job
|
|
515
|
+
if self.jobs and 0 <= self.selected_idx < len(self.jobs):
|
|
516
|
+
job = self.jobs[self.selected_idx]
|
|
517
|
+
if job.pid and job.status == "running":
|
|
518
|
+
kill_job(job.pid)
|
|
519
|
+
# Refresh job list after kill
|
|
520
|
+
time.sleep(0.2)
|
|
521
|
+
self.jobs = get_jobs()
|
|
522
|
+
elif key == curses.KEY_HOME:
|
|
523
|
+
self.selected_idx = 0
|
|
524
|
+
elif key == curses.KEY_END:
|
|
525
|
+
self.selected_idx = max(0, len(self.jobs) - 1)
|
|
526
|
+
|
|
527
|
+
return True
|
|
528
|
+
|
|
529
|
+
def run(self):
|
|
530
|
+
"""Main loop"""
|
|
531
|
+
last_refresh = 0
|
|
532
|
+
|
|
533
|
+
while True:
|
|
534
|
+
# Refresh data periodically
|
|
535
|
+
now = time.time()
|
|
536
|
+
if now - last_refresh >= self.interval:
|
|
537
|
+
self.jobs = get_jobs()
|
|
538
|
+
# Clamp selected index
|
|
539
|
+
if self.jobs:
|
|
540
|
+
self.selected_idx = min(self.selected_idx, len(self.jobs) - 1)
|
|
541
|
+
else:
|
|
542
|
+
self.selected_idx = 0
|
|
543
|
+
last_refresh = now
|
|
544
|
+
|
|
545
|
+
# Draw
|
|
546
|
+
self.draw()
|
|
547
|
+
|
|
548
|
+
# Handle input
|
|
549
|
+
if not self.handle_input():
|
|
550
|
+
break
|
|
551
|
+
|
|
552
|
+
# Small sleep to reduce CPU usage
|
|
553
|
+
time.sleep(0.05)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def main(stdscr, interval: float):
|
|
557
|
+
"""Main entry point for curses"""
|
|
558
|
+
monitor = Monitor(stdscr, interval)
|
|
559
|
+
monitor.run()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
if __name__ == "__main__":
|
|
563
|
+
parser = argparse.ArgumentParser(description="Agent Job Monitor TUI")
|
|
564
|
+
parser.add_argument(
|
|
565
|
+
"--interval", "-i",
|
|
566
|
+
type=float,
|
|
567
|
+
default=2.0,
|
|
568
|
+
help="Refresh interval in seconds (default: 2.0)"
|
|
569
|
+
)
|
|
570
|
+
args = parser.parse_args()
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
curses.wrapper(lambda stdscr: main(stdscr, args.interval))
|
|
574
|
+
except KeyboardInterrupt:
|
|
575
|
+
pass
|