agent-ps 0.2.0__py3-none-any.whl
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.
- agent_ps/__init__.py +3 -0
- agent_ps/__main__.py +197 -0
- agent_ps/backends/__init__.py +31 -0
- agent_ps/backends/base.py +310 -0
- agent_ps/backends/claude.py +144 -0
- agent_ps/backends/codex.py +84 -0
- agent_ps/backends/copilot.py +211 -0
- agent_ps/backends/hermes.py +129 -0
- agent_ps/backends/opencode.py +191 -0
- agent_ps/backends/pilike.py +108 -0
- agent_ps/backends/sqlite.py +55 -0
- agent_ps/collect.py +101 -0
- agent_ps/jsonl.py +110 -0
- agent_ps/procs.py +188 -0
- agent_ps/resume.py +60 -0
- agent_ps/ui.py +670 -0
- agent_ps/util.py +114 -0
- agent_ps-0.2.0.dist-info/METADATA +397 -0
- agent_ps-0.2.0.dist-info/RECORD +23 -0
- agent_ps-0.2.0.dist-info/WHEEL +5 -0
- agent_ps-0.2.0.dist-info/entry_points.txt +2 -0
- agent_ps-0.2.0.dist-info/licenses/LICENSE +21 -0
- agent_ps-0.2.0.dist-info/top_level.txt +1 -0
agent_ps/ui.py
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
"""The table and the live view.
|
|
2
|
+
|
|
3
|
+
Nothing here knows which agent a row came from. Every backend fills the same
|
|
4
|
+
shape, so a column is one lookup and never a branch.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import curses
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from . import backends
|
|
11
|
+
from .collect import ATTACH_WINDOW, idle_seconds, is_live
|
|
12
|
+
from .backends.base import (ATTACH_INFERRED, KIND_ENDED, KIND_SESSION,
|
|
13
|
+
STATUS_BUSY)
|
|
14
|
+
from .procs import children_of, collect_tree, terminate
|
|
15
|
+
from .resume import open_in_terminal, resume_command
|
|
16
|
+
from .util import FAILURES, human_bytes, human_duration, short_path
|
|
17
|
+
|
|
18
|
+
REFRESH_SECONDS = 2.0
|
|
19
|
+
MESSAGE_SECONDS = 6.0
|
|
20
|
+
|
|
21
|
+
#: Assigned to agents in registry order, so a new backend gets a colour without
|
|
22
|
+
#: anything here having to learn its name.
|
|
23
|
+
AGENT_COLOURS = (
|
|
24
|
+
curses.COLOR_YELLOW,
|
|
25
|
+
curses.COLOR_CYAN,
|
|
26
|
+
curses.COLOR_MAGENTA,
|
|
27
|
+
curses.COLOR_GREEN,
|
|
28
|
+
curses.COLOR_BLUE,
|
|
29
|
+
curses.COLOR_RED,
|
|
30
|
+
curses.COLOR_WHITE,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def agent_label(row):
|
|
35
|
+
"""Agent name, with the sort of process appended when it is not a session."""
|
|
36
|
+
kind = row["kind"]
|
|
37
|
+
if kind in (KIND_ENDED, KIND_SESSION):
|
|
38
|
+
return row["agent"]
|
|
39
|
+
return f"{row['agent']}:{kind}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def status_label(row):
|
|
43
|
+
"""What the session is doing, or a dash when nothing says.
|
|
44
|
+
|
|
45
|
+
A process with no session paired to it has no turn to report.
|
|
46
|
+
"""
|
|
47
|
+
if row["kind"] == KIND_ENDED:
|
|
48
|
+
return "ended"
|
|
49
|
+
return row["status"] or "-"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def pid_label(row):
|
|
53
|
+
"""PID, marked when the pairing with a session was inferred.
|
|
54
|
+
|
|
55
|
+
Only Claude Code records which process runs which session. Elsewhere the two
|
|
56
|
+
are matched on working directory, which cannot tell apart two sessions of the
|
|
57
|
+
same agent in one folder, so the guess is shown as a guess.
|
|
58
|
+
"""
|
|
59
|
+
if not row["pid"]:
|
|
60
|
+
return "-"
|
|
61
|
+
return f"{row['pid']}?" if row["attach"] == ATTACH_INFERRED else str(row["pid"])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def model_label(row):
|
|
65
|
+
model = row.get("model", "")
|
|
66
|
+
if model.startswith("claude-"):
|
|
67
|
+
model = model[len("claude-"):]
|
|
68
|
+
return model or "-"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def idle_label(row):
|
|
72
|
+
"""Time since the last turn, coarser the further back it goes.
|
|
73
|
+
|
|
74
|
+
A session last touched four months ago is old, and knowing it was 120 days
|
|
75
|
+
and 18 hours does not make it any older. Keeping long spans short is what
|
|
76
|
+
lets this column stay narrow enough to sit beside uptime.
|
|
77
|
+
"""
|
|
78
|
+
seconds = idle_seconds(row)
|
|
79
|
+
if not seconds:
|
|
80
|
+
return "-"
|
|
81
|
+
if seconds < 60:
|
|
82
|
+
return "now"
|
|
83
|
+
if seconds < 30 * 86400:
|
|
84
|
+
return human_duration(seconds) + " ago"
|
|
85
|
+
months = seconds // (30 * 86400)
|
|
86
|
+
return f"{months}mo ago" if months < 12 else f"{months // 12}y ago"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
COLUMNS = [
|
|
90
|
+
("PID", 8, pid_label),
|
|
91
|
+
("AGENT", 18, agent_label),
|
|
92
|
+
("SESSION", 16, lambda r: r["name"] or "-"),
|
|
93
|
+
("STATUS", 7, status_label),
|
|
94
|
+
("MODEL", 20, model_label),
|
|
95
|
+
("UPTIME", 8, lambda r: human_duration(r["uptime"]) if r["pid"] else "-"),
|
|
96
|
+
("ACTIVE", 10, idle_label),
|
|
97
|
+
("CPU", 6, lambda r: f"{r['cpu']:.1f}%" if r["pid"] else "-"),
|
|
98
|
+
("MEM", 7, lambda r: human_bytes(r["rss"], unit_kb=True) if r["pid"] else "-"),
|
|
99
|
+
("DISK", 8, lambda r: human_bytes(r["disk"]) if r.get("disk") else "-"),
|
|
100
|
+
("DIR", 24, lambda r: short_path(r.get("cwd", ""), 23)),
|
|
101
|
+
("TITLE", 0, lambda r: r["title"] or "-"),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def column_span(label):
|
|
106
|
+
"""Where a column starts and how wide it is, derived from COLUMNS itself.
|
|
107
|
+
|
|
108
|
+
Computed rather than written down, so changing a width in one place does not
|
|
109
|
+
quietly misalign anything that paints over a cell.
|
|
110
|
+
"""
|
|
111
|
+
start = 0
|
|
112
|
+
for name, size, _ in COLUMNS:
|
|
113
|
+
if name == label:
|
|
114
|
+
return start, size
|
|
115
|
+
start += size
|
|
116
|
+
return 0, 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
#: Cycled with `s`. The first keeps the natural order: agents in registry order,
|
|
120
|
+
#: running sessions before ended ones. The rest sort everything together, since
|
|
121
|
+
#: the question they answer is which session is the largest or the busiest, and
|
|
122
|
+
#: that does not care whether its process is still alive.
|
|
123
|
+
def _text(field):
|
|
124
|
+
"""Sort text A to Z, with the blanks pushed to the end either way."""
|
|
125
|
+
return lambda row: (not row[field], row[field].lower())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
#: Each is a name, the column it marks, the value to sort on, and whether the
|
|
129
|
+
#: useful end comes first. Numbers read largest first, which is what makes the
|
|
130
|
+
#: question worth asking; names read A to Z.
|
|
131
|
+
SORTS = (
|
|
132
|
+
("agent", None, None, True),
|
|
133
|
+
("active", "ACTIVE", lambda r: r["last_active"] or 0, True),
|
|
134
|
+
("disk", "DISK", lambda r: r.get("disk", 0), True),
|
|
135
|
+
("cpu", "CPU", lambda r: r["cpu"], True),
|
|
136
|
+
("mem", "MEM", lambda r: r["rss"], True),
|
|
137
|
+
("uptime", "UPTIME", lambda r: r["uptime"], True),
|
|
138
|
+
("session", "SESSION", _text("name"), False),
|
|
139
|
+
("title", "TITLE", _text("title"), False),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def format_row(row, width):
|
|
144
|
+
cells = []
|
|
145
|
+
used = 0
|
|
146
|
+
for _, size, getter in COLUMNS:
|
|
147
|
+
value = getter(row)
|
|
148
|
+
if size == 0:
|
|
149
|
+
cells.append(value[:max(0, width - used - 1)])
|
|
150
|
+
else:
|
|
151
|
+
cells.append(f"{value[: size - 1]:<{size}}")
|
|
152
|
+
used += size
|
|
153
|
+
return "".join(cells)[:width]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def format_header(width):
|
|
157
|
+
parts = [label if size == 0 else f"{label[: size - 1]:<{size}}"
|
|
158
|
+
for label, size, _ in COLUMNS]
|
|
159
|
+
return "".join(parts)[:width]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def print_table(rows):
|
|
163
|
+
width = 220
|
|
164
|
+
print(format_header(width).rstrip())
|
|
165
|
+
for row in rows:
|
|
166
|
+
print(format_row(row, width).rstrip())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _wrap(text, width):
|
|
170
|
+
words, lines, current = text.split(), [], ""
|
|
171
|
+
for word in words:
|
|
172
|
+
if current and len(current) + 1 + len(word) > width:
|
|
173
|
+
lines.append(current)
|
|
174
|
+
current = word
|
|
175
|
+
else:
|
|
176
|
+
current = f"{current} {word}" if current else word
|
|
177
|
+
lines.append(current)
|
|
178
|
+
return lines or [""]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def matches_filter(row, needle):
|
|
182
|
+
return (needle in row["name"].lower()
|
|
183
|
+
or needle in row["title"].lower()
|
|
184
|
+
or needle in row["agent"].lower()
|
|
185
|
+
or needle in model_label(row).lower()
|
|
186
|
+
or needle in row.get("cwd", "").lower()
|
|
187
|
+
or needle in str(row["pid"]))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Tui:
|
|
191
|
+
C_HEADER = 1
|
|
192
|
+
C_BUSY = 2
|
|
193
|
+
C_BACKGROUND = 3
|
|
194
|
+
C_SELECTED = 4
|
|
195
|
+
C_WARNING = 5
|
|
196
|
+
C_SORT = 6
|
|
197
|
+
C_AGENT_BASE = 7
|
|
198
|
+
|
|
199
|
+
def __init__(self, screen, snapshot):
|
|
200
|
+
self.screen = screen
|
|
201
|
+
self.snapshot = snapshot
|
|
202
|
+
self.rows = []
|
|
203
|
+
self.cursor = 0
|
|
204
|
+
self.message = ""
|
|
205
|
+
self.message_at = 0.0
|
|
206
|
+
self.paused = False
|
|
207
|
+
self.show_ended = False
|
|
208
|
+
self.filter_text = ""
|
|
209
|
+
self.editing_filter = False
|
|
210
|
+
self.pending_kill = None
|
|
211
|
+
self.detail = None
|
|
212
|
+
self.sort = 0
|
|
213
|
+
self.descending = True
|
|
214
|
+
self.last_poll = 0.0
|
|
215
|
+
self.history = {"bytes": 0, "ended": 0, "recent": 0}
|
|
216
|
+
|
|
217
|
+
def setup(self):
|
|
218
|
+
curses.curs_set(0)
|
|
219
|
+
self.agent_colour = {}
|
|
220
|
+
if not curses.has_colors():
|
|
221
|
+
self.screen.timeout(200)
|
|
222
|
+
return
|
|
223
|
+
curses.start_color()
|
|
224
|
+
curses.use_default_colors()
|
|
225
|
+
curses.init_pair(self.C_HEADER, curses.COLOR_BLACK, curses.COLOR_WHITE)
|
|
226
|
+
curses.init_pair(self.C_BUSY, curses.COLOR_GREEN, -1)
|
|
227
|
+
curses.init_pair(self.C_BACKGROUND, curses.COLOR_BLUE, -1)
|
|
228
|
+
curses.init_pair(self.C_SELECTED, curses.COLOR_BLACK, curses.COLOR_CYAN)
|
|
229
|
+
curses.init_pair(self.C_WARNING, curses.COLOR_YELLOW, -1)
|
|
230
|
+
# the heading row is black on white, so the sorted column needs its own
|
|
231
|
+
# background. Cyan rather than yellow, because a light terminal theme
|
|
232
|
+
# renders a yellow background close enough to white to disappear.
|
|
233
|
+
curses.init_pair(self.C_SORT, curses.COLOR_BLACK, curses.COLOR_CYAN)
|
|
234
|
+
for index, name in enumerate(backends.names()):
|
|
235
|
+
pair = self.C_AGENT_BASE + index
|
|
236
|
+
curses.init_pair(pair, AGENT_COLOURS[index % len(AGENT_COLOURS)], -1)
|
|
237
|
+
self.agent_colour[name] = curses.color_pair(pair)
|
|
238
|
+
self.screen.timeout(200)
|
|
239
|
+
|
|
240
|
+
def poll(self):
|
|
241
|
+
# the same window already loaded to pair processes with sessions, so
|
|
242
|
+
# showing all of it costs nothing and makes sorting by size honest
|
|
243
|
+
rows = self.snapshot.rows(show_ended=self.show_ended, limit=ATTACH_WINDOW)
|
|
244
|
+
if self.filter_text:
|
|
245
|
+
needle = self.filter_text.lower()
|
|
246
|
+
rows = [r for r in rows if matches_filter(r, needle)]
|
|
247
|
+
key = SORTS[self.sort][2]
|
|
248
|
+
if key:
|
|
249
|
+
rows = sorted(rows, key=key, reverse=self.descending)
|
|
250
|
+
self.rows = rows
|
|
251
|
+
self.cursor = max(0, min(self.cursor, len(self.rows) - 1))
|
|
252
|
+
self.last_poll = time.time()
|
|
253
|
+
self.history = self.snapshot.history()
|
|
254
|
+
|
|
255
|
+
def selected(self):
|
|
256
|
+
return self.rows[self.cursor] if self.rows else None
|
|
257
|
+
|
|
258
|
+
def notify(self, text):
|
|
259
|
+
"""Show a note under the key bar for a few seconds.
|
|
260
|
+
|
|
261
|
+
These report what an action just did, so they stop being useful the
|
|
262
|
+
moment attention moves on, and one left behind reads as the outcome of
|
|
263
|
+
whatever was done next.
|
|
264
|
+
"""
|
|
265
|
+
self.message = text
|
|
266
|
+
self.message_at = time.time()
|
|
267
|
+
|
|
268
|
+
def draw(self):
|
|
269
|
+
self.screen.erase()
|
|
270
|
+
height, width = self.screen.getmaxyx()
|
|
271
|
+
self.draw_summary(width)
|
|
272
|
+
if self.detail and not self.pending_kill:
|
|
273
|
+
self.draw_detail(height, width)
|
|
274
|
+
self.screen.noutrefresh()
|
|
275
|
+
curses.doupdate()
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
self.screen.attron(curses.color_pair(self.C_HEADER))
|
|
279
|
+
self.screen.addnstr(2, 0, format_header(width).ljust(width - 1), width - 1)
|
|
280
|
+
self.screen.attroff(curses.color_pair(self.C_HEADER))
|
|
281
|
+
self.mark_sorted_column(width)
|
|
282
|
+
|
|
283
|
+
body = max(1, height - 5)
|
|
284
|
+
first = max(0, self.cursor - body + 1) if self.cursor >= body else 0
|
|
285
|
+
for index, row in enumerate(self.rows[first: first + body]):
|
|
286
|
+
line = format_row(row, width - 1)
|
|
287
|
+
if first + index == self.cursor:
|
|
288
|
+
attr = curses.color_pair(self.C_SELECTED)
|
|
289
|
+
line = line.ljust(width - 1)
|
|
290
|
+
elif row["kind"] == KIND_ENDED:
|
|
291
|
+
attr = curses.A_DIM
|
|
292
|
+
elif row["background"]:
|
|
293
|
+
attr = curses.color_pair(self.C_BACKGROUND)
|
|
294
|
+
elif row["status"] == STATUS_BUSY:
|
|
295
|
+
attr = curses.color_pair(self.C_BUSY)
|
|
296
|
+
else:
|
|
297
|
+
attr = curses.A_NORMAL
|
|
298
|
+
screen_row = 3 + index
|
|
299
|
+
self.screen.addnstr(screen_row, 0, line, width - 1, attr)
|
|
300
|
+
# the cursor has to stay unmistakable, so a selected row keeps its
|
|
301
|
+
# own colour rather than being broken up by the agent's
|
|
302
|
+
if first + index != self.cursor:
|
|
303
|
+
self.paint_agent(screen_row, row, width, attr)
|
|
304
|
+
|
|
305
|
+
if not self.rows:
|
|
306
|
+
self.screen.addnstr(4, 2, "No coding agent sessions running.", width - 3)
|
|
307
|
+
|
|
308
|
+
self.draw_footer(height, width)
|
|
309
|
+
self.screen.noutrefresh()
|
|
310
|
+
curses.doupdate()
|
|
311
|
+
|
|
312
|
+
def paint_agent(self, screen_row, row, width, base):
|
|
313
|
+
"""Repaint the agent cell in that agent's colour.
|
|
314
|
+
|
|
315
|
+
With five agents on screen the name is read constantly, and a colour is
|
|
316
|
+
quicker to recognise than a word. Only this cell is coloured; the rest of
|
|
317
|
+
the row still carries what the row is doing.
|
|
318
|
+
"""
|
|
319
|
+
colour = self.agent_colour.get(row["agent"])
|
|
320
|
+
if not colour:
|
|
321
|
+
return
|
|
322
|
+
start, size = column_span("AGENT")
|
|
323
|
+
if start >= width - 1:
|
|
324
|
+
return
|
|
325
|
+
text = agent_label(row)[: size - 1].ljust(size)
|
|
326
|
+
self.screen.addnstr(screen_row, start, text, min(size, width - 1 - start),
|
|
327
|
+
colour | (base & curses.A_DIM) | curses.A_BOLD)
|
|
328
|
+
|
|
329
|
+
def mark_sorted_column(self, width):
|
|
330
|
+
"""Show which column is sorting, on the column itself.
|
|
331
|
+
|
|
332
|
+
A word in the summary line says the same thing, but the eye reading a
|
|
333
|
+
table looks at the table, so the marker belongs in the heading it
|
|
334
|
+
describes.
|
|
335
|
+
"""
|
|
336
|
+
label = SORTS[self.sort][1]
|
|
337
|
+
if not label:
|
|
338
|
+
return
|
|
339
|
+
start, size = column_span(label)
|
|
340
|
+
if start >= width - 1:
|
|
341
|
+
return
|
|
342
|
+
arrow = "v" if self.descending else "^"
|
|
343
|
+
# the last column has no fixed width, so it is marked to its own length
|
|
344
|
+
cell = size or len(label) + 1
|
|
345
|
+
text = f"{label}{arrow}"[:cell].ljust(cell)
|
|
346
|
+
self.screen.addnstr(2, start, text, min(cell, width - 1 - start),
|
|
347
|
+
curses.color_pair(self.C_SORT) | curses.A_BOLD)
|
|
348
|
+
|
|
349
|
+
def draw_detail(self, height, width):
|
|
350
|
+
"""Everything known about one session, on one screen."""
|
|
351
|
+
row = self.detail
|
|
352
|
+
backend = self.snapshot.find_backend(row["agent"])
|
|
353
|
+
lines = [
|
|
354
|
+
("agent", agent_label(row)),
|
|
355
|
+
("session", row["session_id"] or "not matched to a session"),
|
|
356
|
+
("status", status_label(row)),
|
|
357
|
+
("model", row["model"] or "-"),
|
|
358
|
+
("directory", row["cwd"] or "-"),
|
|
359
|
+
("title", row["title"] or "-"),
|
|
360
|
+
("pid", f"{row['pid']} (parent {row['ppid']})"),
|
|
361
|
+
("uptime", f"{human_duration(row['uptime'])} last turn {idle_label(row)}"),
|
|
362
|
+
("usage", f"cpu {row['cpu']:.1f}% memory "
|
|
363
|
+
f"{human_bytes(row['rss'], unit_kb=True)} disk "
|
|
364
|
+
f"{human_bytes(row['disk']) if row['disk'] else '-'}"),
|
|
365
|
+
]
|
|
366
|
+
if row["attach"] == ATTACH_INFERRED:
|
|
367
|
+
lines.append(("paired", "matched by working directory, not reported"))
|
|
368
|
+
if backend and row["session_id"]:
|
|
369
|
+
lines.extend(backend.details(row))
|
|
370
|
+
lines.append(("command", row["cmd"] or "-"))
|
|
371
|
+
|
|
372
|
+
self.screen.attron(curses.color_pair(self.C_HEADER))
|
|
373
|
+
self.screen.addnstr(2, 0, " session details".ljust(width - 1), width - 1)
|
|
374
|
+
self.screen.attroff(curses.color_pair(self.C_HEADER))
|
|
375
|
+
|
|
376
|
+
screen_row = 4
|
|
377
|
+
for label, value in lines:
|
|
378
|
+
if screen_row >= height - 2:
|
|
379
|
+
break
|
|
380
|
+
self.screen.addnstr(screen_row, 2, f"{label:>10}", 10, curses.A_DIM)
|
|
381
|
+
# the command line is the one field that runs long, so it wraps
|
|
382
|
+
# instead of being cut off where it stops being useful
|
|
383
|
+
for chunk in _wrap(str(value), max(10, width - 16)):
|
|
384
|
+
if screen_row >= height - 2:
|
|
385
|
+
break
|
|
386
|
+
self.screen.addnstr(screen_row, 14, chunk, width - 15)
|
|
387
|
+
screen_row += 1
|
|
388
|
+
keys = " enter or esc close"
|
|
389
|
+
if row["pid"]:
|
|
390
|
+
keys += " k stop this process"
|
|
391
|
+
self.screen.addnstr(height - 1, 0, keys.ljust(width - 1), width - 1,
|
|
392
|
+
curses.color_pair(self.C_HEADER))
|
|
393
|
+
|
|
394
|
+
def draw_summary(self, width):
|
|
395
|
+
"""Two lines: what is running, then what it is costing."""
|
|
396
|
+
live = [r for r in self.rows if is_live(r)]
|
|
397
|
+
busy = sum(1 for r in live if r["status"] == STATUS_BUSY)
|
|
398
|
+
background = sum(1 for r in live if r["background"])
|
|
399
|
+
sessions = len(live) - background
|
|
400
|
+
|
|
401
|
+
parts = [f"{sessions} session" + ("s" if sessions != 1 else "")]
|
|
402
|
+
if busy:
|
|
403
|
+
parts.append(f"{busy} busy")
|
|
404
|
+
if background:
|
|
405
|
+
parts.append(f"{background} background")
|
|
406
|
+
|
|
407
|
+
state = "PAUSED" if self.paused else "LIVE"
|
|
408
|
+
if self.show_ended:
|
|
409
|
+
state = "ALL " + state
|
|
410
|
+
if self.filter_text:
|
|
411
|
+
state = f"/{self.filter_text} " + state
|
|
412
|
+
|
|
413
|
+
self.screen.attron(curses.A_BOLD)
|
|
414
|
+
self.screen.addnstr(0, 1, "agent-ps", width - 2)
|
|
415
|
+
self.screen.attroff(curses.A_BOLD)
|
|
416
|
+
self.screen.addnstr(0, 10, f" {' '.join(parts)} {state}", width - 11)
|
|
417
|
+
|
|
418
|
+
disk = sum(r.get("disk", 0) for r in self.rows)
|
|
419
|
+
stale = max(0, self.history["bytes"] - disk)
|
|
420
|
+
line = (f" cpu {sum(r['cpu'] for r in live):.1f}%"
|
|
421
|
+
f" mem {human_bytes(sum(r['rss'] for r in live), unit_kb=True)}"
|
|
422
|
+
f" disk {human_bytes(disk)} active"
|
|
423
|
+
f" history {self.history['ended']} sessions, {human_bytes(stale)}")
|
|
424
|
+
self.screen.addnstr(1, 0, line, width - 1, curses.A_DIM)
|
|
425
|
+
|
|
426
|
+
note = self.advisory()
|
|
427
|
+
if note:
|
|
428
|
+
column = min(len(line) + 3, width - 2)
|
|
429
|
+
self.screen.addnstr(1, column, note, max(0, width - column - 1),
|
|
430
|
+
curses.color_pair(self.C_WARNING))
|
|
431
|
+
|
|
432
|
+
def advisory(self):
|
|
433
|
+
"""One short note when something on screen deserves attention.
|
|
434
|
+
|
|
435
|
+
Only the most useful is shown. A wall of warnings trains people to ignore
|
|
436
|
+
the line entirely.
|
|
437
|
+
"""
|
|
438
|
+
# an empty table because a command would not run is a different thing
|
|
439
|
+
# from an empty table, and saying so first matters more than anything
|
|
440
|
+
# else this line could report
|
|
441
|
+
if FAILURES:
|
|
442
|
+
return f"could not run {', '.join(sorted(FAILURES))}; rows may be missing"
|
|
443
|
+
|
|
444
|
+
orphans = [r for r in self.rows if r["background"] and r["uptime"] > 3600]
|
|
445
|
+
if orphans:
|
|
446
|
+
return f"{len(orphans)} background helper(s) over an hour old, press b"
|
|
447
|
+
|
|
448
|
+
stale = [r for r in self.rows if r["pid"] and idle_seconds(r) > 86400]
|
|
449
|
+
if stale:
|
|
450
|
+
return f"{len(stale)} session(s) untouched for over a day, still holding memory"
|
|
451
|
+
|
|
452
|
+
recent = self.history["recent"]
|
|
453
|
+
if recent and not self.show_ended:
|
|
454
|
+
return f"{recent} session(s) ended in the past week, press e to show them"
|
|
455
|
+
return ""
|
|
456
|
+
|
|
457
|
+
def draw_footer(self, height, width):
|
|
458
|
+
if self.editing_filter:
|
|
459
|
+
prompt = f" filter: {self.filter_text}"
|
|
460
|
+
self.screen.addnstr(height - 1, 0, prompt.ljust(width - 1), width - 1,
|
|
461
|
+
curses.color_pair(self.C_SELECTED))
|
|
462
|
+
return
|
|
463
|
+
if self.pending_kill:
|
|
464
|
+
_, order, note = self.pending_kill
|
|
465
|
+
text = f" Stop {len(order)} process(es){note}? [y] confirm [n] cancel"
|
|
466
|
+
self.screen.addnstr(height - 1, 0, text.ljust(width - 1), width - 1,
|
|
467
|
+
curses.color_pair(self.C_WARNING))
|
|
468
|
+
return
|
|
469
|
+
# the note and the legend share a line: a note is worth interrupting the
|
|
470
|
+
# legend for, and it is gone again in a few seconds
|
|
471
|
+
if self.message and time.time() - self.message_at < MESSAGE_SECONDS:
|
|
472
|
+
self.screen.addnstr(height - 2, 0, f" {self.message}", width - 1,
|
|
473
|
+
curses.color_pair(self.C_WARNING))
|
|
474
|
+
else:
|
|
475
|
+
self.draw_legend(height - 2, width)
|
|
476
|
+
mode = "hide ended" if self.show_ended else "show ended"
|
|
477
|
+
keys = (" up/down select enter details k stop b background"
|
|
478
|
+
f" e {mode} s sort S reverse / filter q quit")
|
|
479
|
+
self.screen.addnstr(height - 1, 0, keys.ljust(width - 1), width - 1,
|
|
480
|
+
curses.color_pair(self.C_HEADER))
|
|
481
|
+
|
|
482
|
+
def draw_legend(self, screen_row, width):
|
|
483
|
+
"""Which agents are on screen, each in its own colour.
|
|
484
|
+
|
|
485
|
+
It sits above the keys rather than in the header, next to the column it
|
|
486
|
+
explains being less useful than being where the eye already goes.
|
|
487
|
+
"""
|
|
488
|
+
agents = sorted({r["agent"] for r in self.rows})
|
|
489
|
+
if not agents:
|
|
490
|
+
return
|
|
491
|
+
text = " agents " + " ".join(agents)
|
|
492
|
+
self.screen.addnstr(screen_row, 0, text, width - 1, curses.A_DIM)
|
|
493
|
+
|
|
494
|
+
# a long agent list leaves no room for a sentence on an 80 column
|
|
495
|
+
# terminal, and dropping the note entirely is worse than abbreviating it
|
|
496
|
+
name, column, _, _ = SORTS[self.sort]
|
|
497
|
+
arrow = "v" if self.descending else "^"
|
|
498
|
+
wordy = ("sorted by agent, running first" if not column
|
|
499
|
+
else f"sorted by {name}, "
|
|
500
|
+
f"{'high to low' if self.descending else 'low to high'}")
|
|
501
|
+
for note in (wordy, f"sort: {name} {arrow}" if column else "sort: agent"):
|
|
502
|
+
at = width - len(note) - 2
|
|
503
|
+
if at > len(text) + 3:
|
|
504
|
+
self.screen.addnstr(screen_row, at, note, len(note),
|
|
505
|
+
curses.color_pair(self.C_SORT) | curses.A_BOLD)
|
|
506
|
+
break
|
|
507
|
+
|
|
508
|
+
for agent in agents:
|
|
509
|
+
at = text.find(agent)
|
|
510
|
+
if at < 0 or at >= width - 1:
|
|
511
|
+
continue
|
|
512
|
+
self.screen.addnstr(screen_row, at, agent, min(len(agent), width - 1 - at),
|
|
513
|
+
self.agent_colour.get(agent, 0) | curses.A_BOLD)
|
|
514
|
+
|
|
515
|
+
def confirm_kill(self, row):
|
|
516
|
+
"""Ask before stopping, and say where the process is.
|
|
517
|
+
|
|
518
|
+
A PID matched by working directory could be the wrong session, so the
|
|
519
|
+
directory is part of the question rather than something to check
|
|
520
|
+
afterwards.
|
|
521
|
+
"""
|
|
522
|
+
order = collect_tree(row["pid"], children_of(self.rows))
|
|
523
|
+
note = f" in {short_path(row['cwd'], 40)}" if row["cwd"] else ""
|
|
524
|
+
if row["attach"] == ATTACH_INFERRED:
|
|
525
|
+
note += ", session matched by directory"
|
|
526
|
+
self.pending_kill = (row["pid"], order, note)
|
|
527
|
+
|
|
528
|
+
def open_selected(self):
|
|
529
|
+
"""Enter does the obvious thing for the row it is on.
|
|
530
|
+
|
|
531
|
+
A session that has ended is one you would want back, so it reopens. One
|
|
532
|
+
that is running is already open, so the useful answer is what it is doing
|
|
533
|
+
and what it has spent.
|
|
534
|
+
"""
|
|
535
|
+
row = self.selected()
|
|
536
|
+
if not row:
|
|
537
|
+
return
|
|
538
|
+
if is_live(row):
|
|
539
|
+
self.detail = row
|
|
540
|
+
return
|
|
541
|
+
self.resume_selected(row)
|
|
542
|
+
|
|
543
|
+
def resume_selected(self, row):
|
|
544
|
+
if not row["session_id"]:
|
|
545
|
+
self.notify("No session id for that row.")
|
|
546
|
+
return
|
|
547
|
+
backend = self.snapshot.find_backend(row["agent"])
|
|
548
|
+
if not backend or not backend.resume_binary:
|
|
549
|
+
self.notify(f"{row['agent']} sessions cannot be reopened from a terminal.")
|
|
550
|
+
return
|
|
551
|
+
command = resume_command(backend, row["session_id"], row.get("cwd", ""))
|
|
552
|
+
ok, note = open_in_terminal(command)
|
|
553
|
+
self.notify(note if ok else f"{note} Run: {command}")
|
|
554
|
+
|
|
555
|
+
def apply_kill(self):
|
|
556
|
+
_, order, _ = self.pending_kill
|
|
557
|
+
self.pending_kill = None
|
|
558
|
+
self.detail = None
|
|
559
|
+
stopped = [p for p in order if terminate(p)]
|
|
560
|
+
self.notify(f"Stopped {len(stopped)} of {len(order)} processes.")
|
|
561
|
+
self.poll()
|
|
562
|
+
|
|
563
|
+
def handle(self, key):
|
|
564
|
+
if self.editing_filter:
|
|
565
|
+
return self.handle_filter(key)
|
|
566
|
+
|
|
567
|
+
# moving around means the last result has been read, so drop the note
|
|
568
|
+
# early rather than leaving it to time out under a different row
|
|
569
|
+
if key in (curses.KEY_UP, curses.KEY_DOWN, curses.KEY_HOME, curses.KEY_END,
|
|
570
|
+
ord("j"), ord("K")):
|
|
571
|
+
self.message = ""
|
|
572
|
+
|
|
573
|
+
if self.detail and not self.pending_kill:
|
|
574
|
+
if key == ord("k") and self.detail["pid"]:
|
|
575
|
+
self.confirm_kill(self.detail)
|
|
576
|
+
elif key in (10, 13, curses.KEY_ENTER, 27, ord("q")):
|
|
577
|
+
self.detail = None
|
|
578
|
+
return True
|
|
579
|
+
|
|
580
|
+
if self.pending_kill:
|
|
581
|
+
if key in (ord("y"), ord("Y")):
|
|
582
|
+
self.apply_kill()
|
|
583
|
+
elif key in (ord("n"), ord("N"), 27):
|
|
584
|
+
self.pending_kill = None
|
|
585
|
+
self.notify("Cancelled.")
|
|
586
|
+
return True
|
|
587
|
+
|
|
588
|
+
if key in (ord("q"), ord("Q")):
|
|
589
|
+
return False
|
|
590
|
+
if key in (10, 13, curses.KEY_ENTER):
|
|
591
|
+
self.open_selected()
|
|
592
|
+
elif key in (curses.KEY_DOWN, ord("j")):
|
|
593
|
+
self.cursor = min(self.cursor + 1, max(0, len(self.rows) - 1))
|
|
594
|
+
elif key in (curses.KEY_UP, ord("K")):
|
|
595
|
+
self.cursor = max(0, self.cursor - 1)
|
|
596
|
+
elif key == curses.KEY_HOME:
|
|
597
|
+
self.cursor = 0
|
|
598
|
+
elif key == curses.KEY_END:
|
|
599
|
+
self.cursor = max(0, len(self.rows) - 1)
|
|
600
|
+
elif key == ord(" "):
|
|
601
|
+
self.paused = not self.paused
|
|
602
|
+
elif key == ord("s"):
|
|
603
|
+
self.sort = (self.sort + 1) % len(SORTS)
|
|
604
|
+
self.descending = SORTS[self.sort][3]
|
|
605
|
+
self.cursor = 0
|
|
606
|
+
self.poll()
|
|
607
|
+
elif key == ord("S"):
|
|
608
|
+
self.descending = not self.descending
|
|
609
|
+
self.cursor = 0
|
|
610
|
+
self.poll()
|
|
611
|
+
elif key in (ord("r"), ord("R")):
|
|
612
|
+
self.poll()
|
|
613
|
+
self.message = ""
|
|
614
|
+
elif key in (ord("e"), ord("E")):
|
|
615
|
+
self.show_ended = not self.show_ended
|
|
616
|
+
self.cursor = 0
|
|
617
|
+
self.snapshot.history(force=True)
|
|
618
|
+
self.poll()
|
|
619
|
+
elif key == ord("/"):
|
|
620
|
+
self.editing_filter = True
|
|
621
|
+
elif key == ord("k"):
|
|
622
|
+
row = self.selected()
|
|
623
|
+
if row and row["pid"]:
|
|
624
|
+
self.confirm_kill(row)
|
|
625
|
+
elif row and is_live(row):
|
|
626
|
+
self.notify(f"{row['agent']} has no process of its own to stop.")
|
|
627
|
+
elif row:
|
|
628
|
+
self.notify("That session has already ended.")
|
|
629
|
+
elif key in (ord("b"), ord("B")):
|
|
630
|
+
background = [r["pid"] for r in self.rows if r["background"]]
|
|
631
|
+
if background:
|
|
632
|
+
self.pending_kill = (background[0], background, "")
|
|
633
|
+
else:
|
|
634
|
+
self.notify("No background processes.")
|
|
635
|
+
return True
|
|
636
|
+
|
|
637
|
+
def handle_filter(self, key):
|
|
638
|
+
if key in (10, 13, curses.KEY_ENTER):
|
|
639
|
+
self.editing_filter = False
|
|
640
|
+
self.poll()
|
|
641
|
+
elif key == 27:
|
|
642
|
+
self.editing_filter = False
|
|
643
|
+
self.filter_text = ""
|
|
644
|
+
self.poll()
|
|
645
|
+
elif key in (curses.KEY_BACKSPACE, 127, 8):
|
|
646
|
+
# backspacing past the last character leaves filter mode, so the key
|
|
647
|
+
# hints come back without needing escape
|
|
648
|
+
if not self.filter_text:
|
|
649
|
+
self.editing_filter = False
|
|
650
|
+
else:
|
|
651
|
+
self.filter_text = self.filter_text[:-1]
|
|
652
|
+
if not self.filter_text:
|
|
653
|
+
self.editing_filter = False
|
|
654
|
+
self.poll()
|
|
655
|
+
elif 32 <= key < 127:
|
|
656
|
+
self.filter_text += chr(key)
|
|
657
|
+
self.poll()
|
|
658
|
+
return True
|
|
659
|
+
|
|
660
|
+
def loop(self):
|
|
661
|
+
self.setup()
|
|
662
|
+
self.poll()
|
|
663
|
+
while True:
|
|
664
|
+
self.draw()
|
|
665
|
+
key = self.screen.getch()
|
|
666
|
+
if key != -1 and not self.handle(key):
|
|
667
|
+
break
|
|
668
|
+
if not self.paused and not self.pending_kill:
|
|
669
|
+
if time.time() - self.last_poll >= REFRESH_SECONDS:
|
|
670
|
+
self.poll()
|