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/__init__.py
ADDED
agent_ps/__main__.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import curses
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import VERSION, backends
|
|
9
|
+
from .collect import Snapshot, idle_seconds, is_live
|
|
10
|
+
from .procs import stop_tree, terminate, table
|
|
11
|
+
from .resume import open_in_terminal, resume_command
|
|
12
|
+
from .ui import Tui, matches_filter, print_table
|
|
13
|
+
from .util import FAILURES, human_bytes
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnknownAgent(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build(args):
|
|
21
|
+
known = backends.names()
|
|
22
|
+
only = None
|
|
23
|
+
if getattr(args, "agent", ""):
|
|
24
|
+
only = set(args.agent.split(","))
|
|
25
|
+
unknown = sorted(only - set(known))
|
|
26
|
+
if unknown:
|
|
27
|
+
raise UnknownAgent(f"Unknown agent(s): {', '.join(unknown)}. "
|
|
28
|
+
f"Known agents: {', '.join(known)}.")
|
|
29
|
+
chosen = backends.available(only)
|
|
30
|
+
if not chosen:
|
|
31
|
+
print(f"No agent data found. Known agents: {', '.join(known)}.",
|
|
32
|
+
file=sys.stderr)
|
|
33
|
+
return Snapshot(chosen)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def select(snapshot, args, show_ended):
|
|
37
|
+
rows = snapshot.rows(show_ended=show_ended, limit=getattr(args, "limit", 40))
|
|
38
|
+
needle = (getattr(args, "filter", "") or "").lower()
|
|
39
|
+
return [r for r in rows if matches_filter(r, needle)] if needle else rows
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cmd_list(args):
|
|
43
|
+
snapshot = build(args)
|
|
44
|
+
rows = select(snapshot, args, getattr(args, "all", False))
|
|
45
|
+
|
|
46
|
+
if args.json:
|
|
47
|
+
for row in rows:
|
|
48
|
+
row["idle"] = idle_seconds(row)
|
|
49
|
+
print(json.dumps(rows, indent=2))
|
|
50
|
+
return 0
|
|
51
|
+
if not rows:
|
|
52
|
+
if FAILURES:
|
|
53
|
+
print(f"Could not run {', '.join(sorted(FAILURES))}, so the table "
|
|
54
|
+
f"could not be built.", file=sys.stderr)
|
|
55
|
+
return 1
|
|
56
|
+
print("No coding agent sessions running.")
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
print_table(rows)
|
|
60
|
+
live = [r for r in rows if is_live(r)]
|
|
61
|
+
disk = sum(r.get("disk", 0) for r in rows)
|
|
62
|
+
history = snapshot.history(force=True)
|
|
63
|
+
agents = sorted({r["agent"] for r in live})
|
|
64
|
+
print(f"\n{len(live)} running ({', '.join(agents) or 'none'}), "
|
|
65
|
+
f"{human_bytes(disk)} on disk. "
|
|
66
|
+
f"{history['ended']} session{'' if history['ended'] == 1 else 's'} "
|
|
67
|
+
f"on record, "
|
|
68
|
+
f"{human_bytes(max(0, history['bytes'] - disk))} in history.")
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_stop(args):
|
|
73
|
+
snapshot = build(args)
|
|
74
|
+
# one process table for both the check and the stop, so the tree that gets
|
|
75
|
+
# signalled is the one that was just validated
|
|
76
|
+
procs = table()
|
|
77
|
+
row = next((r for r in snapshot.rows() if r["pid"] == args.pid), None)
|
|
78
|
+
if not row:
|
|
79
|
+
print(f"PID {args.pid} is not a coding agent process.", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
order, stopped = stop_tree(args.pid, procs, args.dry_run)
|
|
82
|
+
if args.dry_run:
|
|
83
|
+
print("Would stop:", ", ".join(str(p) for p in order))
|
|
84
|
+
return 0
|
|
85
|
+
print(f"Stopped {len(stopped)} of {len(order)} processes.")
|
|
86
|
+
return 0 if len(stopped) == len(order) else 1
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_resume(args):
|
|
90
|
+
snapshot = build(args)
|
|
91
|
+
rows = snapshot.rows(show_ended=True, limit=1000)
|
|
92
|
+
match = next((r for r in rows
|
|
93
|
+
if not r["pid"] and r["session_id"].startswith(args.session)), None)
|
|
94
|
+
if not match:
|
|
95
|
+
print(f"No ended session starts with {args.session}.", file=sys.stderr)
|
|
96
|
+
return 1
|
|
97
|
+
backend = snapshot.find_backend(match["agent"])
|
|
98
|
+
if not backend or not backend.resume_binary:
|
|
99
|
+
print(f"{match['agent']} sessions cannot be reopened from a terminal.",
|
|
100
|
+
file=sys.stderr)
|
|
101
|
+
return 1
|
|
102
|
+
command = resume_command(backend, match["session_id"], match["cwd"])
|
|
103
|
+
if args.print_only:
|
|
104
|
+
print(command)
|
|
105
|
+
return 0
|
|
106
|
+
ok, note = open_in_terminal(command)
|
|
107
|
+
print(note if ok else f"{note}\nRun: {command}")
|
|
108
|
+
return 0 if ok else 1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_stop_background(args):
|
|
112
|
+
snapshot = build(args)
|
|
113
|
+
targets = [r for r in snapshot.rows() if r["background"]]
|
|
114
|
+
if not targets:
|
|
115
|
+
print("No background processes running.")
|
|
116
|
+
return 0
|
|
117
|
+
if args.dry_run:
|
|
118
|
+
print("Would stop:", ", ".join(str(r["pid"]) for r in targets))
|
|
119
|
+
return 0
|
|
120
|
+
stopped = [r for r in targets if terminate(r["pid"])]
|
|
121
|
+
print(f"Stopped {len(stopped)} of {len(targets)} background processes.")
|
|
122
|
+
return 0 if len(stopped) == len(targets) else 1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_agents(args):
|
|
126
|
+
for cls in backends.ALL:
|
|
127
|
+
backend = cls()
|
|
128
|
+
if not backend.available():
|
|
129
|
+
print(f" {backend.name:<13} present=no sessions=- {backend.root}")
|
|
130
|
+
continue
|
|
131
|
+
count, _ = backend.history_counts(0)
|
|
132
|
+
print(f" {backend.name:<13} present=yes sessions={count:<6} {backend.root}")
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main():
|
|
137
|
+
parser = argparse.ArgumentParser(
|
|
138
|
+
prog="agent-ps", description="List and stop coding agent sessions.")
|
|
139
|
+
parser.add_argument("--version", action="version", version=f"agent-ps {VERSION}")
|
|
140
|
+
parser.add_argument("--agent", default="",
|
|
141
|
+
help="limit to these agents, comma separated")
|
|
142
|
+
sub = parser.add_subparsers(dest="command")
|
|
143
|
+
|
|
144
|
+
p_list = sub.add_parser("list", help="print the table and exit")
|
|
145
|
+
p_list.add_argument("--json", action="store_true", help="output as JSON")
|
|
146
|
+
p_list.add_argument("--all", action="store_true",
|
|
147
|
+
help="include sessions that have ended")
|
|
148
|
+
p_list.add_argument("--filter", metavar="TEXT",
|
|
149
|
+
help="match session, title, agent, model, directory, or PID")
|
|
150
|
+
p_list.add_argument("--limit", type=int, default=40,
|
|
151
|
+
help="how many ended sessions to show (default 40)")
|
|
152
|
+
p_list.set_defaults(func=cmd_list)
|
|
153
|
+
|
|
154
|
+
p_stop = sub.add_parser("stop", help="stop a process and its children")
|
|
155
|
+
p_stop.add_argument("pid", type=int)
|
|
156
|
+
p_stop.add_argument("--dry-run", action="store_true")
|
|
157
|
+
p_stop.set_defaults(func=cmd_stop)
|
|
158
|
+
|
|
159
|
+
p_resume = sub.add_parser("resume", help="reopen an ended session")
|
|
160
|
+
p_resume.add_argument("session", help="session id, or a unique prefix")
|
|
161
|
+
p_resume.add_argument("--print", dest="print_only", action="store_true",
|
|
162
|
+
help="print the command instead of running it")
|
|
163
|
+
p_resume.set_defaults(func=cmd_resume)
|
|
164
|
+
|
|
165
|
+
p_bg = sub.add_parser("stop-background", help="stop daemons and warm spares")
|
|
166
|
+
p_bg.add_argument("--dry-run", action="store_true")
|
|
167
|
+
p_bg.set_defaults(func=cmd_stop_background)
|
|
168
|
+
|
|
169
|
+
p_agents = sub.add_parser("agents", help="show which agents were found")
|
|
170
|
+
p_agents.set_defaults(func=cmd_agents)
|
|
171
|
+
|
|
172
|
+
args = parser.parse_args()
|
|
173
|
+
try:
|
|
174
|
+
if args.command:
|
|
175
|
+
return args.func(args)
|
|
176
|
+
except UnknownAgent as error:
|
|
177
|
+
print(error, file=sys.stderr)
|
|
178
|
+
return 2
|
|
179
|
+
|
|
180
|
+
if not sys.stdout.isatty():
|
|
181
|
+
args.json = args.all = False
|
|
182
|
+
args.filter, args.limit = "", 40
|
|
183
|
+
return cmd_list(args)
|
|
184
|
+
try:
|
|
185
|
+
snapshot = build(args)
|
|
186
|
+
except UnknownAgent as error:
|
|
187
|
+
print(error, file=sys.stderr)
|
|
188
|
+
return 2
|
|
189
|
+
try:
|
|
190
|
+
curses.wrapper(lambda screen: Tui(screen, snapshot).loop())
|
|
191
|
+
except KeyboardInterrupt:
|
|
192
|
+
pass
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
sys.exit(main())
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The registry.
|
|
2
|
+
|
|
3
|
+
Adding an agent means writing one class and adding it to this list. Order sets
|
|
4
|
+
the order rows appear in when nothing else separates them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .claude import ClaudeBackend
|
|
8
|
+
from .codex import CodexBackend
|
|
9
|
+
from .copilot import CopilotBackend
|
|
10
|
+
from .hermes import HermesBackend
|
|
11
|
+
from .opencode import OpenCodeBackend
|
|
12
|
+
from .pilike import CommandCodeBackend, PiBackend
|
|
13
|
+
|
|
14
|
+
ALL = (ClaudeBackend, PiBackend, CommandCodeBackend, CodexBackend,
|
|
15
|
+
OpenCodeBackend, HermesBackend, CopilotBackend)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def available(only=None):
|
|
19
|
+
"""Backends whose data directory exists, optionally narrowed by name."""
|
|
20
|
+
chosen = []
|
|
21
|
+
for cls in ALL:
|
|
22
|
+
backend = cls()
|
|
23
|
+
if only and backend.name not in only:
|
|
24
|
+
continue
|
|
25
|
+
if backend.available():
|
|
26
|
+
chosen.append(backend)
|
|
27
|
+
return chosen
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def names():
|
|
31
|
+
return [cls.name for cls in ALL]
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""What every agent has to provide, and what most of them get for free.
|
|
2
|
+
|
|
3
|
+
A backend answers questions about one agent and returns plain data. It never
|
|
4
|
+
draws anything and never decides how a row is displayed, so the table never has
|
|
5
|
+
to ask which agent a row came from.
|
|
6
|
+
|
|
7
|
+
Most agents keep one JSONL log per session, so that is the default behaviour
|
|
8
|
+
here. A backend overrides only the parts where its agent differs.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import glob
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from .. import jsonl
|
|
16
|
+
from ..procs import matches, working_dirs
|
|
17
|
+
from ..util import directory_size
|
|
18
|
+
|
|
19
|
+
KIND_SESSION = "session"
|
|
20
|
+
KIND_ENDED = "ended"
|
|
21
|
+
KIND_GATEWAY = "gateway"
|
|
22
|
+
|
|
23
|
+
STATUS_BUSY = "busy"
|
|
24
|
+
STATUS_IDLE = "idle"
|
|
25
|
+
|
|
26
|
+
#: How long a size is trusted before the directories are walked again.
|
|
27
|
+
DISK_POLL_SECONDS = 5.0
|
|
28
|
+
|
|
29
|
+
ATTACH_NONE = ""
|
|
30
|
+
ATTACH_DIRECT = "pid"
|
|
31
|
+
ATTACH_INFERRED = "cwd"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
TITLE_LENGTH = 60
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def prompt_title(text):
|
|
38
|
+
"""An opening prompt, trimmed to serve as a title.
|
|
39
|
+
|
|
40
|
+
Agents inject a context block as the first user message. That is machinery,
|
|
41
|
+
not what the session was about, so it is rejected and the caller moves on to
|
|
42
|
+
the next message.
|
|
43
|
+
"""
|
|
44
|
+
text = " ".join((text or "").split())
|
|
45
|
+
if not text or text.startswith("<"):
|
|
46
|
+
return ""
|
|
47
|
+
return text[:TITLE_LENGTH]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def blank_row(agent):
|
|
51
|
+
return {
|
|
52
|
+
"agent": agent,
|
|
53
|
+
"pid": 0,
|
|
54
|
+
"ppid": 0,
|
|
55
|
+
"cmd": "",
|
|
56
|
+
"kind": KIND_ENDED,
|
|
57
|
+
"background": False,
|
|
58
|
+
"session_id": "",
|
|
59
|
+
# the file backing this session, which for a SQLite agent is the one
|
|
60
|
+
# database every one of its sessions lives in
|
|
61
|
+
"path": "",
|
|
62
|
+
"name": "",
|
|
63
|
+
"title": "",
|
|
64
|
+
"cwd": "",
|
|
65
|
+
"model": "",
|
|
66
|
+
"uptime": 0,
|
|
67
|
+
"last_active": 0,
|
|
68
|
+
"cpu": 0.0,
|
|
69
|
+
"rss": 0,
|
|
70
|
+
"disk": 0,
|
|
71
|
+
"attach": ATTACH_NONE,
|
|
72
|
+
"status": "",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Backend:
|
|
77
|
+
name = ""
|
|
78
|
+
root = ""
|
|
79
|
+
session_glob = ""
|
|
80
|
+
process_patterns = ()
|
|
81
|
+
resume_binary = ""
|
|
82
|
+
resume_flag = "--resume"
|
|
83
|
+
|
|
84
|
+
#: Extra directories that grow alongside the logs, counted in the totals.
|
|
85
|
+
extra_dirs = ()
|
|
86
|
+
|
|
87
|
+
#: How long a turn may appear to be running before it is called idle. A live
|
|
88
|
+
#: turn keeps appending as it works, so silence for this long means the turn
|
|
89
|
+
#: ended without a final entry, or the agent stopped.
|
|
90
|
+
busy_timeout = 900
|
|
91
|
+
|
|
92
|
+
def __init__(self):
|
|
93
|
+
self.cache = jsonl.Cache()
|
|
94
|
+
self._disk = {}
|
|
95
|
+
self._disk_at = {}
|
|
96
|
+
|
|
97
|
+
# Discovery -----------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def available(self):
|
|
100
|
+
return bool(self.root) and os.path.isdir(self.root)
|
|
101
|
+
|
|
102
|
+
def session_paths(self):
|
|
103
|
+
return glob.glob(os.path.join(self.root, self.session_glob))
|
|
104
|
+
|
|
105
|
+
def owns(self, proc):
|
|
106
|
+
return matches(proc["cmd"], self.process_patterns)
|
|
107
|
+
|
|
108
|
+
# One session ---------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def extract(self, reader):
|
|
111
|
+
"""Title, model, and working directory from one session log.
|
|
112
|
+
|
|
113
|
+
Returning a partial dict is fine. Anything absent stays blank, which the
|
|
114
|
+
table renders as a dash rather than treating as a special case.
|
|
115
|
+
"""
|
|
116
|
+
return {}
|
|
117
|
+
|
|
118
|
+
def session_id(self, path):
|
|
119
|
+
return os.path.basename(path).split(".")[0]
|
|
120
|
+
|
|
121
|
+
def describe(self, path):
|
|
122
|
+
"""One session as a row, whether or not a process is running it."""
|
|
123
|
+
blank = {"title": "", "model": "", "cwd": "", "status": "", "last_active": 0}
|
|
124
|
+
info = self.cache.info(path, self.extract, blank)
|
|
125
|
+
row = blank_row(self.name)
|
|
126
|
+
session_id = self.session_id(path)
|
|
127
|
+
cwd = info.get("cwd") or self.fallback_cwd(path)
|
|
128
|
+
row.update({
|
|
129
|
+
"session_id": session_id,
|
|
130
|
+
"path": path,
|
|
131
|
+
"title": info.get("title", ""),
|
|
132
|
+
"model": info.get("model", ""),
|
|
133
|
+
"cwd": cwd,
|
|
134
|
+
"name": os.path.basename(cwd) if cwd else "",
|
|
135
|
+
"last_active": info.get("last_active", 0),
|
|
136
|
+
"status": info.get("status", ""),
|
|
137
|
+
"disk": self.disk_usage(session_id, path),
|
|
138
|
+
})
|
|
139
|
+
return row
|
|
140
|
+
|
|
141
|
+
def fallback_cwd(self, path):
|
|
142
|
+
"""Where to look when a log never recorded its working directory."""
|
|
143
|
+
return ""
|
|
144
|
+
|
|
145
|
+
def sessions(self, limit=None):
|
|
146
|
+
"""Known sessions, most recently active first."""
|
|
147
|
+
rows = []
|
|
148
|
+
for path in self.session_paths():
|
|
149
|
+
try:
|
|
150
|
+
rows.append((os.path.getmtime(path), path))
|
|
151
|
+
except OSError:
|
|
152
|
+
continue
|
|
153
|
+
rows.sort(reverse=True)
|
|
154
|
+
if limit is not None:
|
|
155
|
+
rows = rows[:limit]
|
|
156
|
+
return [self.describe(path) for _, path in rows]
|
|
157
|
+
|
|
158
|
+
# Processes -----------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def classify(self, proc):
|
|
161
|
+
"""Which sort of process this is. Agents with helpers override this."""
|
|
162
|
+
return KIND_SESSION
|
|
163
|
+
|
|
164
|
+
def is_background(self, kind):
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
def live_sessions(self):
|
|
168
|
+
"""Sessions that are alive without a process of their own.
|
|
169
|
+
|
|
170
|
+
An agent embedded in an editor has no process to find in `ps`, but its
|
|
171
|
+
sessions are still open and still spending. Backends that own processes
|
|
172
|
+
return nothing here.
|
|
173
|
+
"""
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
def attach(self, procs, index):
|
|
177
|
+
"""Pair live processes with the sessions they are running.
|
|
178
|
+
|
|
179
|
+
Only Claude Code records the pairing, so the default is to match on
|
|
180
|
+
working directory. That is a guess: two sessions of the same agent in one
|
|
181
|
+
directory are indistinguishable, so the newer is chosen and the row is
|
|
182
|
+
marked inferred rather than presented as fact.
|
|
183
|
+
"""
|
|
184
|
+
rows = []
|
|
185
|
+
dirs = working_dirs([p["pid"] for p in procs])
|
|
186
|
+
claimed = {}
|
|
187
|
+
# Sessions come newest first, so processes are matched newest first too.
|
|
188
|
+
# Pairing them in process table order would hand the oldest process the
|
|
189
|
+
# session that was active most recently, which is backwards whenever
|
|
190
|
+
# someone has two of the same agent open in one directory.
|
|
191
|
+
for proc in sorted(procs, key=lambda p: p["uptime"]):
|
|
192
|
+
found = self._best_match(index, dirs.get(proc["pid"], ""), set(claimed.values()))
|
|
193
|
+
if found:
|
|
194
|
+
claimed[proc["pid"]] = found["session_id"]
|
|
195
|
+
|
|
196
|
+
for proc in procs:
|
|
197
|
+
row = blank_row(self.name)
|
|
198
|
+
row.update({
|
|
199
|
+
"pid": proc["pid"],
|
|
200
|
+
"ppid": proc["ppid"],
|
|
201
|
+
"cmd": proc["cmd"],
|
|
202
|
+
"uptime": proc["uptime"],
|
|
203
|
+
"cpu": proc["cpu"],
|
|
204
|
+
"rss": proc["rss"],
|
|
205
|
+
})
|
|
206
|
+
row["kind"] = self.classify(proc)
|
|
207
|
+
row["background"] = self.is_background(row["kind"])
|
|
208
|
+
cwd = dirs.get(proc["pid"], "")
|
|
209
|
+
if cwd:
|
|
210
|
+
row["cwd"] = cwd
|
|
211
|
+
row["name"] = os.path.basename(cwd)
|
|
212
|
+
match = self._session_by_id(index, cwd, claimed.get(proc["pid"]))
|
|
213
|
+
if match:
|
|
214
|
+
row.update({
|
|
215
|
+
"session_id": match["session_id"],
|
|
216
|
+
"path": match["path"],
|
|
217
|
+
"title": match["title"],
|
|
218
|
+
"model": match["model"],
|
|
219
|
+
"cwd": match["cwd"] or cwd,
|
|
220
|
+
"name": match["name"] or row["name"],
|
|
221
|
+
"last_active": match["last_active"],
|
|
222
|
+
"status": self.settle(match["status"], match["last_active"]),
|
|
223
|
+
"disk": match["disk"],
|
|
224
|
+
"attach": ATTACH_INFERRED,
|
|
225
|
+
})
|
|
226
|
+
rows.append(row)
|
|
227
|
+
return rows
|
|
228
|
+
|
|
229
|
+
def settle(self, status, last_active):
|
|
230
|
+
"""Drop a busy claim the log has stopped backing up."""
|
|
231
|
+
if status != STATUS_BUSY or not last_active:
|
|
232
|
+
return status
|
|
233
|
+
if time.time() - last_active > self.busy_timeout:
|
|
234
|
+
return STATUS_IDLE
|
|
235
|
+
return status
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _best_match(index, cwd, claimed):
|
|
239
|
+
"""The most recently active session in a directory that is still free."""
|
|
240
|
+
if not cwd:
|
|
241
|
+
return None
|
|
242
|
+
for candidate in index.get(cwd, []):
|
|
243
|
+
if candidate["session_id"] not in claimed:
|
|
244
|
+
return candidate
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _session_by_id(index, cwd, session_id):
|
|
249
|
+
if not session_id:
|
|
250
|
+
return None
|
|
251
|
+
return next((c for c in index.get(cwd, [])
|
|
252
|
+
if c["session_id"] == session_id), None)
|
|
253
|
+
|
|
254
|
+
# Disk ----------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
def disk_paths(self, session_id, path):
|
|
257
|
+
"""Everything on disk that belongs to one session.
|
|
258
|
+
|
|
259
|
+
Entries may contain a `*`, since some agents name files after the
|
|
260
|
+
session and then append a timestamp.
|
|
261
|
+
"""
|
|
262
|
+
return [path]
|
|
263
|
+
|
|
264
|
+
def disk_usage(self, session_id, path):
|
|
265
|
+
now = time.time()
|
|
266
|
+
if now - self._disk_at.get(session_id, 0) < DISK_POLL_SECONDS:
|
|
267
|
+
return self._disk.get(session_id, 0)
|
|
268
|
+
total = 0
|
|
269
|
+
for entry in self.disk_paths(session_id, path):
|
|
270
|
+
if "*" in entry:
|
|
271
|
+
total += sum(directory_size(p) for p in glob.glob(entry))
|
|
272
|
+
else:
|
|
273
|
+
total += directory_size(entry)
|
|
274
|
+
self._disk[session_id] = total
|
|
275
|
+
self._disk_at[session_id] = now
|
|
276
|
+
return total
|
|
277
|
+
|
|
278
|
+
def history_counts(self, since):
|
|
279
|
+
"""How many sessions exist, and how many were active since a moment.
|
|
280
|
+
|
|
281
|
+
Asked as a question rather than as a list of files, because not every
|
|
282
|
+
agent keeps one file per session.
|
|
283
|
+
"""
|
|
284
|
+
total = recent = 0
|
|
285
|
+
for path in self.session_paths():
|
|
286
|
+
total += 1
|
|
287
|
+
try:
|
|
288
|
+
if os.path.getmtime(path) > since:
|
|
289
|
+
recent += 1
|
|
290
|
+
except OSError:
|
|
291
|
+
pass
|
|
292
|
+
return total, recent
|
|
293
|
+
|
|
294
|
+
def history_bytes(self):
|
|
295
|
+
roots = [os.path.join(self.root, self.session_glob.split("*")[0])]
|
|
296
|
+
roots += [os.path.join(self.root, d) for d in self.extra_dirs]
|
|
297
|
+
return sum(directory_size(r) for r in roots)
|
|
298
|
+
|
|
299
|
+
def details(self, row):
|
|
300
|
+
"""Extra facts about one session, as label and value pairs.
|
|
301
|
+
|
|
302
|
+
Some agents count tokens and cost and some do not, so this is optional
|
|
303
|
+
and the panel simply shows fewer lines where there is less to say.
|
|
304
|
+
"""
|
|
305
|
+
return []
|
|
306
|
+
|
|
307
|
+
# Resume --------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def resume(self, session_id):
|
|
310
|
+
return f"{self.resume_binary} {self.resume_flag} {session_id}"
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Claude Code.
|
|
2
|
+
|
|
3
|
+
The only agent that records which process is running which session, so it is
|
|
4
|
+
also the only one whose PID column is a fact rather than an inference.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import glob
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from ..procs import environment
|
|
12
|
+
from ..util import decode_project_dir
|
|
13
|
+
from .base import (ATTACH_DIRECT, Backend, KIND_GATEWAY, KIND_SESSION,
|
|
14
|
+
blank_row)
|
|
15
|
+
|
|
16
|
+
KIND_DAEMON = "daemon"
|
|
17
|
+
KIND_SPARE = "bg-spare"
|
|
18
|
+
KIND_PTY = "bg-pty"
|
|
19
|
+
BACKGROUND = {KIND_DAEMON, KIND_SPARE, KIND_PTY}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClaudeBackend(Backend):
|
|
23
|
+
name = "claude"
|
|
24
|
+
root = os.path.expanduser(os.environ.get("CLAUDE_CONFIG_DIR", "~/.claude"))
|
|
25
|
+
session_glob = "projects/*/*.jsonl"
|
|
26
|
+
process_patterns = ("claude",)
|
|
27
|
+
resume_binary = "claude"
|
|
28
|
+
extra_dirs = ("file-history", "tasks", "session-env")
|
|
29
|
+
|
|
30
|
+
def extract(self, reader):
|
|
31
|
+
info = {"cwd": reader.find_head("cwd")}
|
|
32
|
+
for line in reader.tail():
|
|
33
|
+
if info.get("title") and info.get("model"):
|
|
34
|
+
break
|
|
35
|
+
if '"ai-title"' not in line and '"model"' not in line:
|
|
36
|
+
continue
|
|
37
|
+
try:
|
|
38
|
+
entry = json.loads(line)
|
|
39
|
+
except ValueError:
|
|
40
|
+
continue
|
|
41
|
+
if not info.get("title") and entry.get("type") == "ai-title":
|
|
42
|
+
info["title"] = entry.get("aiTitle", "")
|
|
43
|
+
if not info.get("model"):
|
|
44
|
+
model = (entry.get("message") or {}).get("model")
|
|
45
|
+
# <synthetic> marks messages Claude Code wrote itself, such as
|
|
46
|
+
# compaction notices, not a model that served a turn
|
|
47
|
+
if model and not model.startswith("<"):
|
|
48
|
+
info["model"] = model
|
|
49
|
+
return info
|
|
50
|
+
|
|
51
|
+
def fallback_cwd(self, path):
|
|
52
|
+
return decode_project_dir(os.path.dirname(path))
|
|
53
|
+
|
|
54
|
+
def classify(self, proc):
|
|
55
|
+
cmd = proc["cmd"]
|
|
56
|
+
if "claude daemon" in cmd:
|
|
57
|
+
return KIND_DAEMON
|
|
58
|
+
if "bg-spare" in cmd:
|
|
59
|
+
return KIND_SPARE
|
|
60
|
+
if "bg-pty-host" in cmd:
|
|
61
|
+
return KIND_PTY
|
|
62
|
+
env = environment(proc["pid"], ("ANTHROPIC_",))
|
|
63
|
+
proc["_env"] = env
|
|
64
|
+
return KIND_GATEWAY if env.get("ANTHROPIC_BASE_URL") else KIND_SESSION
|
|
65
|
+
|
|
66
|
+
def is_background(self, kind):
|
|
67
|
+
return kind in BACKGROUND
|
|
68
|
+
|
|
69
|
+
def attach(self, procs, index):
|
|
70
|
+
"""Read the pairing rather than guessing it.
|
|
71
|
+
|
|
72
|
+
Claude Code writes <config dir>/sessions/<pid>.json for every live
|
|
73
|
+
session, which also carries the name and whether it is mid-turn. That
|
|
74
|
+
file is authoritative and stops being written when the process exits, so
|
|
75
|
+
unlike the inferred backends its busy state never needs a stall timeout.
|
|
76
|
+
"""
|
|
77
|
+
rows = []
|
|
78
|
+
for proc in procs:
|
|
79
|
+
row = blank_row(self.name)
|
|
80
|
+
row.update({
|
|
81
|
+
"pid": proc["pid"],
|
|
82
|
+
"ppid": proc["ppid"],
|
|
83
|
+
"cmd": proc["cmd"],
|
|
84
|
+
"uptime": proc["uptime"],
|
|
85
|
+
"cpu": proc["cpu"],
|
|
86
|
+
"rss": proc["rss"],
|
|
87
|
+
})
|
|
88
|
+
row["kind"] = self.classify(proc)
|
|
89
|
+
row["background"] = self.is_background(row["kind"])
|
|
90
|
+
|
|
91
|
+
data = self._pid_file(proc["pid"])
|
|
92
|
+
session_id = data.get("sessionId", "")
|
|
93
|
+
if session_id:
|
|
94
|
+
row.update({
|
|
95
|
+
"session_id": session_id,
|
|
96
|
+
"name": data.get("name", ""),
|
|
97
|
+
"status": data.get("status", ""),
|
|
98
|
+
"cwd": data.get("cwd", ""),
|
|
99
|
+
"attach": ATTACH_DIRECT,
|
|
100
|
+
})
|
|
101
|
+
for candidate in index.get(data.get("cwd", ""), []):
|
|
102
|
+
if candidate["session_id"] == session_id:
|
|
103
|
+
row.update({
|
|
104
|
+
"path": candidate["path"],
|
|
105
|
+
"title": candidate["title"],
|
|
106
|
+
"model": candidate["model"],
|
|
107
|
+
"last_active": candidate["last_active"],
|
|
108
|
+
"disk": candidate["disk"],
|
|
109
|
+
})
|
|
110
|
+
break
|
|
111
|
+
else:
|
|
112
|
+
row.update(self._by_id(session_id))
|
|
113
|
+
# a launcher's routing alias is what the user picked, so it wins over
|
|
114
|
+
# whichever upstream model happened to answer last
|
|
115
|
+
alias = (proc.get("_env") or {}).get("ANTHROPIC_MODEL")
|
|
116
|
+
if alias:
|
|
117
|
+
row["model"] = alias
|
|
118
|
+
rows.append(row)
|
|
119
|
+
return rows
|
|
120
|
+
|
|
121
|
+
def _pid_file(self, pid):
|
|
122
|
+
try:
|
|
123
|
+
with open(os.path.join(self.root, "sessions", f"{pid}.json")) as handle:
|
|
124
|
+
data = json.load(handle)
|
|
125
|
+
return data if isinstance(data, dict) else {}
|
|
126
|
+
except (OSError, ValueError):
|
|
127
|
+
return {}
|
|
128
|
+
|
|
129
|
+
def _by_id(self, session_id):
|
|
130
|
+
found = glob.glob(os.path.join(self.root, "projects", "*", f"{session_id}.jsonl"))
|
|
131
|
+
if not found:
|
|
132
|
+
return {}
|
|
133
|
+
row = self.describe(found[0])
|
|
134
|
+
return {"path": row["path"], "title": row["title"],
|
|
135
|
+
"model": row["model"], "last_active": row["last_active"],
|
|
136
|
+
"disk": row["disk"]}
|
|
137
|
+
|
|
138
|
+
def disk_paths(self, session_id, path):
|
|
139
|
+
base = os.path.dirname(path)
|
|
140
|
+
return [path,
|
|
141
|
+
os.path.join(base, session_id),
|
|
142
|
+
os.path.join(self.root, "file-history", session_id),
|
|
143
|
+
os.path.join(self.root, "tasks", session_id),
|
|
144
|
+
os.path.join(self.root, "session-env", session_id)]
|