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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Pi and CommandCode.
|
|
2
|
+
|
|
3
|
+
These write the same log: a `session` entry carrying the working directory, then
|
|
4
|
+
`message` entries, with `model_change` whenever the model is switched. Only the
|
|
5
|
+
field naming the model differs, so one parser serves both.
|
|
6
|
+
|
|
7
|
+
Neither records a title, so the opening prompt stands in for one. That is what
|
|
8
|
+
the session was about, which is the question a title answers.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from .. import jsonl
|
|
14
|
+
from .base import Backend, STATUS_BUSY, STATUS_IDLE, prompt_title
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PiLikeBackend(Backend):
|
|
18
|
+
#: Fields on a model_change entry, joined with a slash when there are two.
|
|
19
|
+
model_fields = ("model",)
|
|
20
|
+
|
|
21
|
+
def extract(self, reader):
|
|
22
|
+
info = {"cwd": reader.find_head("cwd")}
|
|
23
|
+
for line in reader.tail():
|
|
24
|
+
if '"model_change"' not in line:
|
|
25
|
+
continue
|
|
26
|
+
entry = jsonl.parse_line(line)
|
|
27
|
+
if entry and entry.get("type") == "model_change":
|
|
28
|
+
parts = [entry.get(f) for f in self.model_fields if entry.get(f)]
|
|
29
|
+
if parts:
|
|
30
|
+
info["model"] = "/".join(parts)
|
|
31
|
+
break
|
|
32
|
+
info["title"] = self._opening_prompt(reader)
|
|
33
|
+
info["status"] = self._turn(reader)
|
|
34
|
+
return info
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _turn(reader):
|
|
38
|
+
"""Whose turn it is, from the last message in the log.
|
|
39
|
+
|
|
40
|
+
A user message with no reply after it means the agent is answering. An
|
|
41
|
+
assistant message means it is waiting for the next prompt.
|
|
42
|
+
"""
|
|
43
|
+
for line in reader.tail():
|
|
44
|
+
if '"message"' not in line:
|
|
45
|
+
continue
|
|
46
|
+
entry = jsonl.parse_line(line)
|
|
47
|
+
if not entry or entry.get("type") != "message":
|
|
48
|
+
continue
|
|
49
|
+
role = (entry.get("message") or {}).get("role")
|
|
50
|
+
if role == "user":
|
|
51
|
+
return STATUS_BUSY
|
|
52
|
+
if role == "assistant":
|
|
53
|
+
return STATUS_IDLE
|
|
54
|
+
return ""
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _opening_prompt(reader):
|
|
58
|
+
for entry in reader.head():
|
|
59
|
+
message = entry.get("message") or {}
|
|
60
|
+
if message.get("role") != "user":
|
|
61
|
+
continue
|
|
62
|
+
content = message.get("content")
|
|
63
|
+
if isinstance(content, str):
|
|
64
|
+
text = content
|
|
65
|
+
else:
|
|
66
|
+
text = next((c.get("text", "") for c in content or []
|
|
67
|
+
if isinstance(c, dict) and c.get("type") == "text"), "")
|
|
68
|
+
title = prompt_title(text)
|
|
69
|
+
if title:
|
|
70
|
+
return title
|
|
71
|
+
return ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PiBackend(PiLikeBackend):
|
|
75
|
+
name = "pi"
|
|
76
|
+
root = os.path.expanduser("~/.pi/agent")
|
|
77
|
+
session_glob = "sessions/*/*.jsonl"
|
|
78
|
+
process_patterns = ("pi",)
|
|
79
|
+
resume_binary = "pi"
|
|
80
|
+
# --resume opens a picker and takes no argument; --session is the one that
|
|
81
|
+
# accepts an id
|
|
82
|
+
resume_flag = "--session"
|
|
83
|
+
model_fields = ("provider", "modelId")
|
|
84
|
+
|
|
85
|
+
def session_id(self, path):
|
|
86
|
+
# named <timestamp>_<uuid>.jsonl, and only the second half identifies it
|
|
87
|
+
stem = os.path.basename(path)[:-len(".jsonl")]
|
|
88
|
+
return stem.split("_", 1)[-1]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CommandCodeBackend(PiLikeBackend):
|
|
92
|
+
name = "commandcode"
|
|
93
|
+
root = os.path.expanduser("~/.commandcode")
|
|
94
|
+
session_glob = "projects/*/*.jsonl"
|
|
95
|
+
process_patterns = ("cmd", "commandcode")
|
|
96
|
+
resume_binary = "cmd"
|
|
97
|
+
extra_dirs = ("file-history",)
|
|
98
|
+
|
|
99
|
+
def session_paths(self):
|
|
100
|
+
# checkpoint files sit beside the logs and share the session id
|
|
101
|
+
return [p for p in super().session_paths() if ".checkpoints." not in p]
|
|
102
|
+
|
|
103
|
+
def disk_paths(self, session_id, path):
|
|
104
|
+
base = os.path.dirname(path)
|
|
105
|
+
return [path,
|
|
106
|
+
os.path.join(base, f"{session_id}.checkpoints.jsonl"),
|
|
107
|
+
os.path.join(base, f"{session_id}.meta.json"),
|
|
108
|
+
os.path.join(self.root, "file-history", session_id)]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Shared behaviour for agents that keep their sessions in SQLite.
|
|
2
|
+
|
|
3
|
+
Two of them do, and both are read while the agent is writing, so the connection
|
|
4
|
+
is opened read only and a locked or half written database costs a row of dashes
|
|
5
|
+
rather than an error.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sqlite3
|
|
10
|
+
|
|
11
|
+
from .base import Backend, blank_row
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SqliteBackend(Backend):
|
|
15
|
+
#: File inside `root` holding the sessions.
|
|
16
|
+
db_filename = ""
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def database(self):
|
|
20
|
+
path = os.path.join(self.root, self.db_filename)
|
|
21
|
+
return path if os.path.exists(path) else ""
|
|
22
|
+
|
|
23
|
+
def query(self, sql, args=()):
|
|
24
|
+
if not self.database:
|
|
25
|
+
return []
|
|
26
|
+
connection = None
|
|
27
|
+
try:
|
|
28
|
+
connection = sqlite3.connect(f"file:{self.database}?mode=ro",
|
|
29
|
+
uri=True, timeout=1.0)
|
|
30
|
+
connection.row_factory = sqlite3.Row
|
|
31
|
+
return connection.execute(sql, args).fetchall()
|
|
32
|
+
except sqlite3.Error:
|
|
33
|
+
return []
|
|
34
|
+
finally:
|
|
35
|
+
if connection is not None:
|
|
36
|
+
connection.close()
|
|
37
|
+
|
|
38
|
+
def rows_from(self, sql, limit, build):
|
|
39
|
+
"""Run a session query and turn each record into a row.
|
|
40
|
+
|
|
41
|
+
`build` fills in the fields that differ between agents; everything a row
|
|
42
|
+
always has is set here so the two backends cannot drift apart.
|
|
43
|
+
"""
|
|
44
|
+
if limit is not None:
|
|
45
|
+
sql += f" LIMIT {int(limit)}"
|
|
46
|
+
rows = []
|
|
47
|
+
for record in self.query(sql):
|
|
48
|
+
row = blank_row(self.name)
|
|
49
|
+
row["path"] = self.database
|
|
50
|
+
fields = build(record)
|
|
51
|
+
cwd = fields.get("cwd") or ""
|
|
52
|
+
row.update(fields)
|
|
53
|
+
row["name"] = os.path.basename(cwd) if cwd else ""
|
|
54
|
+
rows.append(row)
|
|
55
|
+
return rows
|
agent_ps/collect.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Turning backends into one table.
|
|
2
|
+
|
|
3
|
+
Every backend answers about its own agent. This is where those answers are put
|
|
4
|
+
side by side, and it is the only place that knows more than one agent exists.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from . import procs as proc_table
|
|
10
|
+
from .backends.base import KIND_ENDED
|
|
11
|
+
|
|
12
|
+
#: How many recent sessions per agent are considered when pairing processes.
|
|
13
|
+
#: A session with a live process was active moments ago, so the newest few
|
|
14
|
+
#: hundred always cover it, and older logs are never parsed to find one.
|
|
15
|
+
ATTACH_WINDOW = 200
|
|
16
|
+
|
|
17
|
+
HISTORY_POLL_SECONDS = 30.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Snapshot:
|
|
21
|
+
"""One pass over everything, with per-backend caches kept between passes."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, backends):
|
|
24
|
+
self.backends = backends
|
|
25
|
+
self._history = {"bytes": 0, "ended": 0, "recent": 0}
|
|
26
|
+
self._history_at = 0.0
|
|
27
|
+
|
|
28
|
+
def rows(self, show_ended=False, limit=40):
|
|
29
|
+
table = proc_table.table()
|
|
30
|
+
taken = set()
|
|
31
|
+
running = []
|
|
32
|
+
ended = []
|
|
33
|
+
|
|
34
|
+
for backend in self.backends:
|
|
35
|
+
mine = proc_table.drop_shims(
|
|
36
|
+
[p for p in table if p["pid"] not in taken and backend.owns(p)])
|
|
37
|
+
taken.update(p["pid"] for p in mine)
|
|
38
|
+
|
|
39
|
+
recent = backend.sessions(limit=ATTACH_WINDOW)
|
|
40
|
+
index = {}
|
|
41
|
+
for session in recent:
|
|
42
|
+
index.setdefault(session["cwd"], []).append(session)
|
|
43
|
+
|
|
44
|
+
attached = backend.attach(mine, index) if mine else []
|
|
45
|
+
hosted = backend.live_sessions()
|
|
46
|
+
running.extend(attached)
|
|
47
|
+
running.extend(hosted)
|
|
48
|
+
|
|
49
|
+
if show_ended:
|
|
50
|
+
live = {r["session_id"] for r in attached + hosted if r["session_id"]}
|
|
51
|
+
for session in recent:
|
|
52
|
+
if session["session_id"] in live:
|
|
53
|
+
continue
|
|
54
|
+
session["kind"] = KIND_ENDED
|
|
55
|
+
ended.append(session)
|
|
56
|
+
|
|
57
|
+
ended.sort(key=lambda r: r["last_active"], reverse=True)
|
|
58
|
+
return running + ended[:limit]
|
|
59
|
+
|
|
60
|
+
def history(self, force=False):
|
|
61
|
+
"""What finished sessions are costing on disk, refreshed slowly.
|
|
62
|
+
|
|
63
|
+
Walking the log directories is the one expensive call here, and the
|
|
64
|
+
answer moves by megabytes an hour, so it runs on its own schedule.
|
|
65
|
+
"""
|
|
66
|
+
now = time.time()
|
|
67
|
+
if not force and now - self._history_at < HISTORY_POLL_SECONDS:
|
|
68
|
+
return self._history
|
|
69
|
+
week_ago = now - 7 * 86400
|
|
70
|
+
total = ended = recent = 0
|
|
71
|
+
for backend in self.backends:
|
|
72
|
+
total += backend.history_bytes()
|
|
73
|
+
count, fresh = backend.history_counts(week_ago)
|
|
74
|
+
ended += count
|
|
75
|
+
recent += fresh
|
|
76
|
+
self._history = {"bytes": total, "ended": ended, "recent": recent}
|
|
77
|
+
self._history_at = now
|
|
78
|
+
return self._history
|
|
79
|
+
|
|
80
|
+
def find_backend(self, name):
|
|
81
|
+
return next((b for b in self.backends if b.name == name), None)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_live(row):
|
|
85
|
+
"""Whether a session is still open.
|
|
86
|
+
|
|
87
|
+
Not the same as having a process. An agent embedded in an editor is running
|
|
88
|
+
without one, so a PID is evidence of life rather than the definition of it.
|
|
89
|
+
"""
|
|
90
|
+
return row["kind"] != KIND_ENDED
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def idle_seconds(row):
|
|
94
|
+
"""How long since the session last wrote to its log.
|
|
95
|
+
|
|
96
|
+
Every turn appends, so the modification time is when the session was last
|
|
97
|
+
used. That is a different question from uptime: a process can be five days
|
|
98
|
+
old and have answered a minute ago.
|
|
99
|
+
"""
|
|
100
|
+
last = row.get("last_active", 0)
|
|
101
|
+
return int(time.time() - last) if last else 0
|
agent_ps/jsonl.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Cached reading of session logs.
|
|
2
|
+
|
|
3
|
+
Session logs reach several megabytes, and the fields worth showing sit at one
|
|
4
|
+
end or the other: the working directory is written when the session starts, the
|
|
5
|
+
title and current model near the last turn. So the whole file is never parsed.
|
|
6
|
+
|
|
7
|
+
Backends say what to pull out; the caching and the file handling live here, so
|
|
8
|
+
no backend reimplements either.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
HEAD_LINES = 40
|
|
15
|
+
TAIL_BYTES = 262144
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Reader:
|
|
19
|
+
"""The two ends of one log file, parsed lazily and at most once."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, path):
|
|
22
|
+
self.path = path
|
|
23
|
+
self._head = None
|
|
24
|
+
self._tail = None
|
|
25
|
+
|
|
26
|
+
def head(self):
|
|
27
|
+
"""Entries from the start of the file, in order."""
|
|
28
|
+
if self._head is None:
|
|
29
|
+
self._head = []
|
|
30
|
+
try:
|
|
31
|
+
with open(self.path, "r", errors="ignore") as handle:
|
|
32
|
+
for _ in range(HEAD_LINES):
|
|
33
|
+
line = handle.readline()
|
|
34
|
+
if not line:
|
|
35
|
+
break
|
|
36
|
+
entry = parse_line(line)
|
|
37
|
+
if entry is not None:
|
|
38
|
+
self._head.append(entry)
|
|
39
|
+
except OSError:
|
|
40
|
+
pass
|
|
41
|
+
return self._head
|
|
42
|
+
|
|
43
|
+
def tail(self):
|
|
44
|
+
"""Raw lines from the end of the file, newest first.
|
|
45
|
+
|
|
46
|
+
Raw rather than parsed, so a caller can skip most lines with a substring
|
|
47
|
+
test before paying for json.loads.
|
|
48
|
+
"""
|
|
49
|
+
if self._tail is None:
|
|
50
|
+
self._tail = []
|
|
51
|
+
try:
|
|
52
|
+
with open(self.path, "rb") as handle:
|
|
53
|
+
handle.seek(0, os.SEEK_END)
|
|
54
|
+
size = handle.tell()
|
|
55
|
+
handle.seek(max(0, size - TAIL_BYTES))
|
|
56
|
+
chunk = handle.read().decode("utf-8", "ignore")
|
|
57
|
+
self._tail = list(reversed(chunk.splitlines()))
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
60
|
+
return self._tail
|
|
61
|
+
|
|
62
|
+
def find_head(self, *fields):
|
|
63
|
+
"""First value found for any of these fields near the start."""
|
|
64
|
+
for entry in self.head():
|
|
65
|
+
for field in fields:
|
|
66
|
+
if entry.get(field):
|
|
67
|
+
return entry[field]
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Cache:
|
|
72
|
+
"""Extraction results, keyed by path and invalidated by modification time."""
|
|
73
|
+
|
|
74
|
+
def __init__(self):
|
|
75
|
+
self._values = {}
|
|
76
|
+
self._mtimes = {}
|
|
77
|
+
|
|
78
|
+
def info(self, path, extract, blank):
|
|
79
|
+
try:
|
|
80
|
+
mtime = os.path.getmtime(path)
|
|
81
|
+
except OSError:
|
|
82
|
+
return dict(blank)
|
|
83
|
+
if self._mtimes.get(path) == mtime:
|
|
84
|
+
return self._values[path]
|
|
85
|
+
value = dict(blank, last_active=mtime)
|
|
86
|
+
try:
|
|
87
|
+
value.update(extract(Reader(path)) or {})
|
|
88
|
+
except (OSError, ValueError):
|
|
89
|
+
pass
|
|
90
|
+
self._values[path] = value
|
|
91
|
+
self._mtimes[path] = mtime
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_line(line):
|
|
96
|
+
try:
|
|
97
|
+
entry = json.loads(line)
|
|
98
|
+
except ValueError:
|
|
99
|
+
return None
|
|
100
|
+
return entry if isinstance(entry, dict) else None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load(path):
|
|
104
|
+
"""A whole JSON file, or an empty dict. For sidecar metadata, not logs."""
|
|
105
|
+
try:
|
|
106
|
+
with open(path, "r", errors="ignore") as handle:
|
|
107
|
+
data = json.load(handle)
|
|
108
|
+
return data if isinstance(data, dict) else {}
|
|
109
|
+
except (OSError, ValueError):
|
|
110
|
+
return {}
|
agent_ps/procs.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""The process table, and stopping things in it."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from .util import parse_elapsed, run
|
|
10
|
+
|
|
11
|
+
SELF_PIDS = {os.getpid(), os.getppid()}
|
|
12
|
+
|
|
13
|
+
#: Programs that are never a session. Matched on the executable only, so a
|
|
14
|
+
#: session that merely mentions one of these in a prompt survives.
|
|
15
|
+
SKIP = ("agent-ps", "grep", "lsof")
|
|
16
|
+
|
|
17
|
+
#: Helpers launched through an interpreter, where the executable is `node` or
|
|
18
|
+
#: `python` and the name to look for is an argument. Matched against path
|
|
19
|
+
#: arguments as well, which is a looser test and so kept to what needs it.
|
|
20
|
+
SKIP_SCRIPTS = ("statusline",)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def table():
|
|
24
|
+
"""Every process on the machine, as plain dicts. One ps call."""
|
|
25
|
+
out = run(["ps", "-axo", "pid=,ppid=,etime=,pcpu=,rss=,command="])
|
|
26
|
+
procs = []
|
|
27
|
+
for line in out.splitlines():
|
|
28
|
+
parts = line.split(None, 5)
|
|
29
|
+
if len(parts) < 6:
|
|
30
|
+
continue
|
|
31
|
+
pid, ppid, etime, pcpu, rss, cmd = parts
|
|
32
|
+
if int(pid) in SELF_PIDS or is_tool(cmd, SKIP, SKIP_SCRIPTS):
|
|
33
|
+
continue
|
|
34
|
+
procs.append({
|
|
35
|
+
"pid": int(pid),
|
|
36
|
+
"ppid": int(ppid),
|
|
37
|
+
"cmd": cmd.strip(),
|
|
38
|
+
"uptime": parse_elapsed(etime),
|
|
39
|
+
"cpu": float(pcpu),
|
|
40
|
+
"rss": int(rss),
|
|
41
|
+
})
|
|
42
|
+
return procs
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def drop_shims(procs):
|
|
46
|
+
"""Remove launcher processes that only exist to exec the real one.
|
|
47
|
+
|
|
48
|
+
Version managers put a shim on PATH which spawns the actual interpreter
|
|
49
|
+
under the same name, so one session shows up twice. The shim is always the
|
|
50
|
+
parent and always carries an identical command line, which is what separates
|
|
51
|
+
it from a supervisor: those spawn helpers with different arguments, so a
|
|
52
|
+
daemon and its workers are left alone.
|
|
53
|
+
"""
|
|
54
|
+
by_parent = {}
|
|
55
|
+
for proc in procs:
|
|
56
|
+
by_parent.setdefault(proc["ppid"], []).append(proc)
|
|
57
|
+
return [p for p in procs
|
|
58
|
+
if not any(c["cmd"] == p["cmd"] for c in by_parent.get(p["pid"], []))]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_tool(cmd, programs, scripts=()):
|
|
62
|
+
"""Whether a command line runs one of these.
|
|
63
|
+
|
|
64
|
+
`programs` is checked against the executable alone, so a session whose
|
|
65
|
+
prompt happens to contain the word `grep` is not mistaken for grep itself.
|
|
66
|
+
`scripts` is also checked against path arguments, since a helper run as
|
|
67
|
+
`node .../statusline.js` names itself there rather than in the executable.
|
|
68
|
+
"""
|
|
69
|
+
tokens = cmd.split()
|
|
70
|
+
if not tokens:
|
|
71
|
+
return False
|
|
72
|
+
if _stem(tokens[0]) in programs or _stem(tokens[0]) in scripts:
|
|
73
|
+
return True
|
|
74
|
+
return any(_stem(t) in scripts for t in tokens[1:] if "/" in t)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _stem(token):
|
|
78
|
+
return os.path.basename(token).split(".")[0]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def environment(pid, prefixes):
|
|
82
|
+
"""Environment variables of one process, filtered by prefix."""
|
|
83
|
+
env = {}
|
|
84
|
+
for token in run(["ps", "eww", "-p", str(pid)]).split():
|
|
85
|
+
if "=" not in token:
|
|
86
|
+
continue
|
|
87
|
+
key, _, value = token.partition("=")
|
|
88
|
+
if key.startswith(prefixes):
|
|
89
|
+
env[key] = value
|
|
90
|
+
return env
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def working_dirs(pids):
|
|
94
|
+
"""Working directory of each PID, as one lookup rather than one per process.
|
|
95
|
+
|
|
96
|
+
This is how a session is matched to a process when the agent does not record
|
|
97
|
+
the pairing itself. Linux exposes it in /proc; macOS needs lsof, which is
|
|
98
|
+
slow enough that asking once for every PID matters.
|
|
99
|
+
"""
|
|
100
|
+
if not pids:
|
|
101
|
+
return {}
|
|
102
|
+
if sys.platform.startswith("linux"):
|
|
103
|
+
found = {}
|
|
104
|
+
for pid in pids:
|
|
105
|
+
try:
|
|
106
|
+
found[pid] = os.readlink(f"/proc/{pid}/cwd")
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
return found
|
|
110
|
+
|
|
111
|
+
joined = ",".join(str(p) for p in pids)
|
|
112
|
+
out = run(["lsof", "-a", "-d", "cwd", "-p", joined, "-Fpn"], timeout=10)
|
|
113
|
+
found = {}
|
|
114
|
+
current = None
|
|
115
|
+
for line in out.splitlines():
|
|
116
|
+
if line.startswith("p"):
|
|
117
|
+
current = int(line[1:]) if line[1:].isdigit() else None
|
|
118
|
+
elif line.startswith("n") and current is not None:
|
|
119
|
+
found[current] = line[1:]
|
|
120
|
+
current = None
|
|
121
|
+
return found
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def matches(cmd, patterns):
|
|
125
|
+
"""Whether a command line belongs to an agent.
|
|
126
|
+
|
|
127
|
+
Matched on the executable rather than anywhere in the string, because short
|
|
128
|
+
names like `pi` otherwise match any path that happens to contain them.
|
|
129
|
+
"""
|
|
130
|
+
for pattern in patterns:
|
|
131
|
+
if re.search(r"(^|[/\s])" + re.escape(pattern) + r"(\s|$)", cmd):
|
|
132
|
+
return True
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def alive(pid):
|
|
137
|
+
try:
|
|
138
|
+
os.kill(pid, 0)
|
|
139
|
+
return True
|
|
140
|
+
except ProcessLookupError:
|
|
141
|
+
return False
|
|
142
|
+
except PermissionError:
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def children_of(procs):
|
|
147
|
+
tree = {}
|
|
148
|
+
for proc in procs:
|
|
149
|
+
tree.setdefault(proc["ppid"], []).append(proc)
|
|
150
|
+
return tree
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def collect_tree(pid, tree, seen=None):
|
|
154
|
+
"""PIDs of a process and its descendants, deepest first."""
|
|
155
|
+
if seen is None:
|
|
156
|
+
seen = []
|
|
157
|
+
for child in tree.get(pid, []):
|
|
158
|
+
collect_tree(child["pid"], tree, seen)
|
|
159
|
+
if pid not in seen:
|
|
160
|
+
seen.append(pid)
|
|
161
|
+
return seen
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def terminate(pid, grace=1.0):
|
|
165
|
+
"""SIGTERM, a moment to exit cleanly, then SIGKILL."""
|
|
166
|
+
try:
|
|
167
|
+
os.kill(pid, signal.SIGTERM)
|
|
168
|
+
except ProcessLookupError:
|
|
169
|
+
return True
|
|
170
|
+
except PermissionError:
|
|
171
|
+
return False
|
|
172
|
+
deadline = time.time() + grace
|
|
173
|
+
while time.time() < deadline:
|
|
174
|
+
if not alive(pid):
|
|
175
|
+
return True
|
|
176
|
+
time.sleep(0.05)
|
|
177
|
+
try:
|
|
178
|
+
os.kill(pid, signal.SIGKILL)
|
|
179
|
+
except (ProcessLookupError, PermissionError):
|
|
180
|
+
pass
|
|
181
|
+
return not alive(pid)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def stop_tree(pid, procs, dry_run=False):
|
|
185
|
+
order = collect_tree(pid, children_of(procs))
|
|
186
|
+
if dry_run:
|
|
187
|
+
return order, []
|
|
188
|
+
return order, [p for p in order if terminate(p)]
|
agent_ps/resume.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Reopening a session in a new terminal."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resume_command(backend, session_id, cwd):
|
|
12
|
+
"""Shell line that reopens a session in the directory it ran in."""
|
|
13
|
+
where = shlex.quote(cwd) if cwd else "."
|
|
14
|
+
return f"cd {where} && {backend.resume(shlex.quote(session_id))}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def open_in_terminal(command):
|
|
18
|
+
"""Run a command in a new terminal window or tab.
|
|
19
|
+
|
|
20
|
+
Falls through the terminals that can be scripted, and returns a message
|
|
21
|
+
either way so the caller can show what happened.
|
|
22
|
+
"""
|
|
23
|
+
if sys.platform == "darwin":
|
|
24
|
+
if os.environ.get("TERM_PROGRAM") == "iTerm.app":
|
|
25
|
+
script = f'''
|
|
26
|
+
tell application "iTerm"
|
|
27
|
+
tell current window
|
|
28
|
+
create tab with default profile
|
|
29
|
+
tell current session to write text {json.dumps(command)}
|
|
30
|
+
end tell
|
|
31
|
+
end tell
|
|
32
|
+
'''
|
|
33
|
+
else:
|
|
34
|
+
script = f'''
|
|
35
|
+
tell application "Terminal"
|
|
36
|
+
do script {json.dumps(command)}
|
|
37
|
+
activate
|
|
38
|
+
end tell
|
|
39
|
+
'''
|
|
40
|
+
try:
|
|
41
|
+
# a terminal that hangs on the AppleScript would otherwise freeze
|
|
42
|
+
# the whole view, since this runs on the drawing thread
|
|
43
|
+
result = subprocess.run(["osascript", "-e", script],
|
|
44
|
+
capture_output=True, text=True, timeout=10)
|
|
45
|
+
except (subprocess.SubprocessError, OSError):
|
|
46
|
+
return False, "The terminal did not respond."
|
|
47
|
+
if result.returncode == 0:
|
|
48
|
+
return True, "Opened in a new tab."
|
|
49
|
+
return False, "Could not open a terminal tab."
|
|
50
|
+
|
|
51
|
+
for launcher in (["x-terminal-emulator", "-e"], ["gnome-terminal", "--"],
|
|
52
|
+
["konsole", "-e"], ["xterm", "-e"]):
|
|
53
|
+
if shutil.which(launcher[0]):
|
|
54
|
+
try:
|
|
55
|
+
subprocess.Popen(launcher + ["sh", "-c", command],
|
|
56
|
+
start_new_session=True)
|
|
57
|
+
return True, "Opened in a new window."
|
|
58
|
+
except OSError:
|
|
59
|
+
continue
|
|
60
|
+
return False, "No terminal emulator found."
|