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.
@@ -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."