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 ADDED
@@ -0,0 +1,3 @@
1
+ """agent-ps: a process table for coding agent sessions."""
2
+
3
+ VERSION = "0.2.0"
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)]