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,84 @@
1
+ """Codex CLI.
2
+
3
+ Every entry is an envelope: a type, a timestamp, and a `payload` holding the
4
+ actual record. The working directory arrives in `session_meta` at the top of the
5
+ file, the model in the most recent `turn_context`.
6
+ """
7
+
8
+ import os
9
+
10
+ from .. import jsonl
11
+ from .base import Backend, STATUS_BUSY, STATUS_IDLE, prompt_title
12
+
13
+
14
+ class CodexBackend(Backend):
15
+ name = "codex"
16
+ root = os.path.expanduser("~/.codex")
17
+ session_glob = "sessions/*/*/*/*.jsonl"
18
+ process_patterns = ("codex",)
19
+ resume_binary = "codex"
20
+ resume_flag = "resume"
21
+ extra_dirs = ("shell_snapshots", "thread-writer-locks")
22
+
23
+ def disk_paths(self, session_id, path):
24
+ # the shell snapshot is routinely larger than the transcript it belongs
25
+ # to, and it is named after the session with a timestamp appended
26
+ return [path,
27
+ os.path.join(self.root, "shell_snapshots", f"{session_id}.*.sh"),
28
+ os.path.join(self.root, "thread-writer-locks", f"{session_id}.lock")]
29
+
30
+ def extract(self, reader):
31
+ info = {}
32
+ for entry in reader.head():
33
+ payload = entry.get("payload") or {}
34
+ if entry.get("type") == "session_meta":
35
+ info["cwd"] = payload.get("cwd", "")
36
+ break
37
+ for line in reader.tail():
38
+ if '"turn_context"' not in line:
39
+ continue
40
+ entry = jsonl.parse_line(line)
41
+ payload = (entry or {}).get("payload") or {}
42
+ if payload.get("model"):
43
+ info["model"] = payload["model"]
44
+ break
45
+ info["title"] = self._opening_prompt(reader)
46
+ info["status"] = self._turn(reader)
47
+ return info
48
+
49
+ @staticmethod
50
+ def _turn(reader):
51
+ """Codex marks the start and end of a turn itself, so read the marker."""
52
+ for line in reader.tail():
53
+ if '"task_started"' not in line and '"task_complete"' not in line:
54
+ continue
55
+ entry = jsonl.parse_line(line)
56
+ kind = ((entry or {}).get("payload") or {}).get("type")
57
+ if kind == "task_started":
58
+ return STATUS_BUSY
59
+ if kind == "task_complete":
60
+ return STATUS_IDLE
61
+ return ""
62
+
63
+ @staticmethod
64
+ def _opening_prompt(reader):
65
+ for entry in reader.head():
66
+ payload = entry.get("payload") or {}
67
+ if entry.get("type") != "response_item" or payload.get("role") != "user":
68
+ continue
69
+ content = payload.get("content")
70
+ if isinstance(content, str):
71
+ text = content
72
+ else:
73
+ text = " ".join(c.get("text", "") for c in content or []
74
+ if isinstance(c, dict))
75
+ title = prompt_title(text)
76
+ if title:
77
+ return title
78
+ return ""
79
+
80
+ def session_id(self, path):
81
+ # named rollout-<timestamp>-<uuid>.jsonl
82
+ stem = os.path.basename(path)[:-len(".jsonl")]
83
+ parts = stem.split("-")
84
+ return "-".join(parts[-5:]) if len(parts) >= 5 else stem
@@ -0,0 +1,211 @@
1
+ """GitHub Copilot Chat, in VS Code.
2
+
3
+ The odd one out: Copilot runs inside the editor's extension host, so there is no
4
+ process to find and no process to stop. What it does have is the richest
5
+ accounting of the six, including the credits a turn spent, which the free tier
6
+ meters and nothing else surfaces.
7
+
8
+ A chat is alive when its workspace is open in VS Code, which is answered by
9
+ asking the editor which storage directories it currently holds open.
10
+ """
11
+
12
+ import glob
13
+ import json
14
+ import os
15
+
16
+ from ..procs import run
17
+ from ..util import directory_size
18
+ from .base import (Backend, KIND_ENDED, KIND_SESSION, STATUS_BUSY,
19
+ STATUS_IDLE)
20
+
21
+
22
+ class CopilotBackend(Backend):
23
+ name = "copilot"
24
+ root = os.path.expanduser(
25
+ os.environ.get("VSCODE_USER_DIR",
26
+ "~/Library/Application Support/Code/User"))
27
+ session_glob = "workspaceStorage/*/chatSessions/*.jsonl"
28
+ #: Nothing in the process table belongs to Copilot, so it claims no process.
29
+ process_patterns = ()
30
+ #: A chat lives in the editor's UI, so there is no command that reopens one.
31
+ resume_binary = ""
32
+
33
+ def __init__(self):
34
+ super().__init__()
35
+ self._open = None
36
+
37
+ def owns(self, proc):
38
+ return False
39
+
40
+ def open_workspaces(self):
41
+ """Storage directories VS Code currently has open.
42
+
43
+ One lsof call for every editor process, cached for the pass. A workspace
44
+ that is not open cannot have a live chat, which is the only way to tell
45
+ an abandoned conversation from one still on screen.
46
+ """
47
+ if self._open is not None:
48
+ return self._open
49
+ self._open = set()
50
+ pids = run(["pgrep", "-f", "Visual Studio Code|Code Helper"]).split()
51
+ if pids:
52
+ out = run(["lsof", "-p", ",".join(pids[:60])], timeout=15)
53
+ for line in out.splitlines():
54
+ marker = line.find("workspaceStorage/")
55
+ if marker < 0:
56
+ continue
57
+ tail = line[marker + len("workspaceStorage/"):]
58
+ self._open.add(tail.split("/")[0])
59
+ return self._open
60
+
61
+ def session_id(self, path):
62
+ return os.path.basename(path)[:-len(".jsonl")]
63
+
64
+ def workspace_of(self, path):
65
+ return os.path.basename(os.path.dirname(os.path.dirname(path)))
66
+
67
+ def fallback_cwd(self, path):
68
+ """The folder a workspace points at, from the editor's own record."""
69
+ marker = os.path.join(os.path.dirname(os.path.dirname(path)),
70
+ "workspace.json")
71
+ try:
72
+ with open(marker) as handle:
73
+ folder = json.load(handle).get("folder", "")
74
+ except (OSError, ValueError):
75
+ return ""
76
+ return folder[len("file://"):] if folder.startswith("file://") else folder
77
+
78
+ def extract(self, reader):
79
+ state = replay(reader.path)
80
+ requests = [r for r in (state.get("requests") or []) if isinstance(r, dict)]
81
+ if not requests:
82
+ # a chat that was opened and never used, of which there are many
83
+ return {"skip": True}
84
+ last = requests[-1]
85
+ info = {
86
+ "title": state.get("customTitle") or "",
87
+ "model": model_of(state, requests),
88
+ "status": turn_status(last),
89
+ }
90
+ stamp = last.get("timestamp")
91
+ if stamp:
92
+ info["last_active"] = stamp / 1000.0
93
+ return info
94
+
95
+ def describe(self, path):
96
+ row = super().describe(path)
97
+ row["kind"] = (KIND_SESSION if self.workspace_of(path) in self.open_workspaces()
98
+ else KIND_ENDED)
99
+ return row
100
+
101
+ def sessions(self, limit=None):
102
+ self._open = None
103
+ rows = [r for r in super().sessions(limit) if r["title"] or r["model"]]
104
+ return rows
105
+
106
+ def live_sessions(self):
107
+ """Chats in a window that is currently open."""
108
+ return [r for r in self.sessions(limit=200) if r["kind"] == KIND_SESSION]
109
+
110
+ def details(self, row):
111
+ matches = glob.glob(os.path.join(self.root, "workspaceStorage", "*",
112
+ "chatSessions", f"{row['session_id']}.jsonl"))
113
+ if not matches:
114
+ return []
115
+ requests = [r for r in (replay(matches[0]).get("requests") or [])
116
+ if isinstance(r, dict)]
117
+ if not requests:
118
+ return []
119
+ prompt = sum(r.get("promptTokens") or 0 for r in requests)
120
+ output = sum(r.get("completionTokens") or 0 for r in requests)
121
+ credits = sum(r.get("copilotCredits") or 0 for r in requests)
122
+ return [
123
+ ("host", "VS Code, no process of its own"),
124
+ ("tokens", f"in {prompt:,} out {output:,}"),
125
+ ("turns", f"{len(requests)}"),
126
+ ("credits", f"{credits:.2f} used"),
127
+ ]
128
+
129
+ def disk_paths(self, session_id, path):
130
+ base = os.path.dirname(os.path.dirname(path))
131
+ return [path, os.path.join(base, "chatEditingSessions", session_id)]
132
+
133
+ def history_bytes(self):
134
+ return sum(directory_size(p) for p in
135
+ glob.glob(os.path.join(self.root, "workspaceStorage", "*",
136
+ "chatSessions")))
137
+
138
+
139
+ def replay(path):
140
+ """Rebuild a session from its journal of changes.
141
+
142
+ The file is not a log of messages but a list of edits: an initial object,
143
+ then `{kind, k: [path], v}` entries that set a value somewhere inside it.
144
+ Replaying them in order is the only way to read any field.
145
+ """
146
+ state = {}
147
+ try:
148
+ handle = open(path, errors="ignore")
149
+ except OSError:
150
+ return state
151
+ with handle:
152
+ for line in handle:
153
+ try:
154
+ entry = json.loads(line)
155
+ except ValueError:
156
+ continue
157
+ key, value = entry.get("k"), entry.get("v")
158
+ if not key:
159
+ if isinstance(value, dict):
160
+ state.update(value)
161
+ continue
162
+ node = state
163
+ try:
164
+ for step in key[:-1]:
165
+ node = _descend(node, step)
166
+ _assign(node, key[-1], value)
167
+ except (TypeError, AttributeError, IndexError):
168
+ continue
169
+ return state
170
+
171
+
172
+ def _descend(node, step):
173
+ if isinstance(step, int):
174
+ while len(node) <= step:
175
+ node.append({})
176
+ return node[step]
177
+ return node.setdefault(step, {})
178
+
179
+
180
+ def _assign(node, key, value):
181
+ if isinstance(key, int):
182
+ while len(node) <= key:
183
+ node.append({})
184
+ node[key] = value
185
+ else:
186
+ node[key] = value
187
+
188
+
189
+ def model_of(state, requests):
190
+ """What actually answered, not the picker that chose it.
191
+
192
+ A request records `modelId`, but under the default setting that is the
193
+ alias `copilot/auto`, which says nothing. The family behind the alias is
194
+ kept with the model the user selected.
195
+ """
196
+ selected = ((state.get("inputState") or {}).get("selectedModel") or {})
197
+ family = (selected.get("metadata") or {}).get("family")
198
+ if family:
199
+ return family
200
+ for request in reversed(requests):
201
+ raw = request.get("modelId")
202
+ if raw and raw != "copilot/auto":
203
+ return raw
204
+ return ""
205
+
206
+
207
+ def turn_status(request):
208
+ state = request.get("modelState")
209
+ if isinstance(state, dict) and not state.get("completedAt"):
210
+ return STATUS_BUSY
211
+ return STATUS_IDLE if request.get("response") is not None else STATUS_BUSY
@@ -0,0 +1,129 @@
1
+ """Hermes.
2
+
3
+ Keeps its state in one SQLite database, and keeps more of it than the others:
4
+ a session row already holds the working directory, the title, the model, and
5
+ when it was last active, so a row costs a single query.
6
+
7
+ Hermes also runs a gateway process that outlives the sessions it serves, which
8
+ is the sort of thing worth noticing before it sits there for a week.
9
+ """
10
+
11
+ import glob
12
+ import os
13
+
14
+ from ..util import directory_size
15
+ from .base import KIND_GATEWAY, KIND_SESSION, STATUS_BUSY, STATUS_IDLE
16
+ from .sqlite import SqliteBackend
17
+
18
+ KIND_SUPERVISOR = "supervisor"
19
+ BACKGROUND = {KIND_GATEWAY, KIND_SUPERVISOR}
20
+
21
+
22
+ class HermesBackend(SqliteBackend):
23
+ name = "hermes"
24
+ root = os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))
25
+ process_patterns = ("hermes", "hermes_cli.main", "hermes_cli.stderr_timestamp")
26
+ resume_binary = "hermes"
27
+ # the --resume flag reopens a session; `hermes resume` is a different
28
+ # command that lifts an emergency stop, so do not "simplify" this
29
+ resume_flag = "--resume"
30
+
31
+ db_filename = "state.db"
32
+
33
+ def available(self):
34
+ return bool(self.database)
35
+
36
+ def classify(self, proc):
37
+ """Tell the gateway apart from the thing that watches it.
38
+
39
+ Hermes starts a messaging gateway on its own and detaches it, so it
40
+ outlives the session that caused it. The gateway runs under a wrapper
41
+ that timestamps its stderr, and since the wrapper carries the gateway
42
+ command after a `--`, both would otherwise read as the gateway.
43
+ """
44
+ cmd = proc["cmd"]
45
+ if "stderr_timestamp" in cmd:
46
+ return KIND_SUPERVISOR
47
+ if "gateway run" in cmd:
48
+ return KIND_GATEWAY
49
+ return KIND_SESSION
50
+
51
+ def is_background(self, kind):
52
+ return kind in BACKGROUND
53
+
54
+ def sessions(self, limit=None):
55
+ return self.rows_from(
56
+ "SELECT id, cwd, title, model, last_activity_at, ended_at "
57
+ "FROM sessions ORDER BY last_activity_at DESC",
58
+ limit,
59
+ lambda r: {
60
+ "session_id": r["id"],
61
+ "title": r["title"] or "",
62
+ "model": r["model"] or "",
63
+ "cwd": r["cwd"] or "",
64
+ "last_active": r["last_activity_at"] or 0,
65
+ "status": "" if r["ended_at"] else self._turn(r["id"]),
66
+ "disk": self._session_bytes(r["id"]),
67
+ })
68
+
69
+ def _turn(self, session_id):
70
+ """Whose turn it is, from the newest message.
71
+
72
+ Hermes records a finish reason when a reply completes, so an assistant
73
+ message without one is still being written.
74
+ """
75
+ found = self.query("SELECT role, finish_reason FROM messages "
76
+ "WHERE session_id = ? ORDER BY timestamp DESC LIMIT 1",
77
+ (session_id,))
78
+ if not found:
79
+ return ""
80
+ role = found[0]["role"]
81
+ if role == "user":
82
+ return STATUS_BUSY
83
+ if role == "assistant":
84
+ return STATUS_IDLE if found[0]["finish_reason"] else STATUS_BUSY
85
+ return ""
86
+
87
+ def _session_bytes(self, session_id):
88
+ """The conversation in the database, plus what it dumped beside it.
89
+
90
+ Hermes writes a full request dump per turn, and those are two orders of
91
+ magnitude larger than the messages they record.
92
+ """
93
+ found = self.query("SELECT total(length(content)) + total(length(tool_calls)) "
94
+ "FROM messages WHERE session_id = ?", (session_id,))
95
+ total = int(found[0][0] or 0) if found else 0
96
+ dumps = os.path.join(self.root, "sessions", f"request_dump_{session_id}_*.json")
97
+ return total + sum(directory_size(p) for p in glob.glob(dumps))
98
+
99
+ def details(self, row):
100
+ found = self.query(
101
+ "SELECT input_tokens, output_tokens, reasoning_tokens, api_call_count, "
102
+ "estimated_cost_usd, billing_provider, message_count, tool_call_count "
103
+ "FROM sessions WHERE id = ?", (row["session_id"],))
104
+ if not found:
105
+ return []
106
+ r = found[0]
107
+ return [
108
+ ("provider", r["billing_provider"] or "-"),
109
+ ("tokens", f"in {r['input_tokens'] or 0:,} out {r['output_tokens'] or 0:,}"
110
+ f" reasoning {r['reasoning_tokens'] or 0:,}"),
111
+ ("calls", f"{r['api_call_count'] or 0} api, {r['message_count'] or 0} messages,"
112
+ f" {r['tool_call_count'] or 0} tool calls"),
113
+ ("cost", f"${r['estimated_cost_usd'] or 0:.4f} estimated"),
114
+ ]
115
+
116
+ def history_counts(self, since):
117
+ found = self.query("SELECT count(*), "
118
+ "sum(CASE WHEN last_activity_at > ? THEN 1 ELSE 0 END) "
119
+ "FROM sessions", (since,))
120
+ if not found:
121
+ return 0, 0
122
+ return int(found[0][0] or 0), int(found[0][1] or 0)
123
+
124
+ def history_bytes(self):
125
+ # the request dumps under sessions/ are debugging artefacts, and they are
126
+ # larger than the database that holds the actual conversations
127
+ return (directory_size(self.database)
128
+ + directory_size(os.path.join(self.root, "sessions"))
129
+ + directory_size(os.path.join(self.root, "logs")))
@@ -0,0 +1,191 @@
1
+ """OpenCode.
2
+
3
+ Newer versions keep everything in one SQLite database, where a session row
4
+ already carries its directory, title, and model. Nothing has to be parsed to
5
+ build a row, which makes this the cheapest backend of the set.
6
+
7
+ Older versions wrote a tree of JSON files under `storage/`. Installs that never
8
+ migrated still have only that, so it is read when the database is absent.
9
+ """
10
+
11
+ import glob
12
+ import json
13
+ import os
14
+
15
+ from .. import jsonl
16
+ from ..util import directory_size
17
+ from .base import Backend, KIND_SESSION, STATUS_BUSY, STATUS_IDLE
18
+ from .sqlite import SqliteBackend
19
+
20
+ KIND_SERVE = "serve"
21
+
22
+
23
+ class OpenCodeBackend(SqliteBackend):
24
+ name = "opencode"
25
+ root = os.path.expanduser(
26
+ os.environ.get("OPENCODE_DATA", "~/.local/share/opencode"))
27
+ session_glob = "storage/session/*/*.json"
28
+ process_patterns = ("opencode",)
29
+ resume_binary = "opencode"
30
+ resume_flag = "--session"
31
+
32
+ db_filename = "opencode.db"
33
+
34
+ def classify(self, proc):
35
+ # `opencode serve` hosts sessions for editors and keeps running after
36
+ # every one of them is closed
37
+ return KIND_SERVE if " serve" in proc["cmd"] else KIND_SESSION
38
+
39
+ def is_background(self, kind):
40
+ return kind == KIND_SERVE
41
+
42
+ # SQLite ---------------------------------------------------------------
43
+
44
+ def sessions(self, limit=None):
45
+ if not self.database:
46
+ return super(SqliteBackend, self).sessions(limit)
47
+ return self.rows_from(
48
+ "SELECT id, directory, title, model, time_updated "
49
+ "FROM session ORDER BY time_updated DESC",
50
+ limit,
51
+ lambda r: {
52
+ "session_id": r["id"],
53
+ "title": r["title"] or "",
54
+ "model": _model_name(r["model"]),
55
+ "cwd": r["directory"] or "",
56
+ "last_active": (r["time_updated"] or 0) / 1000.0,
57
+ "status": self._turn(r["id"]),
58
+ "disk": self._session_bytes(r["id"]),
59
+ })
60
+
61
+ def _turn(self, session_id):
62
+ """Whose turn it is, from the newest message.
63
+
64
+ An assistant message carries a `finish` reason once its turn is over, so
65
+ one without it is still being written.
66
+ """
67
+ found = self.query("SELECT data FROM message WHERE session_id = ? "
68
+ "ORDER BY time_created DESC LIMIT 1", (session_id,))
69
+ if not found:
70
+ return ""
71
+ try:
72
+ data = json.loads(found[0]["data"])
73
+ except ValueError:
74
+ return ""
75
+ if data.get("role") == "user":
76
+ return STATUS_BUSY
77
+ if data.get("role") == "assistant":
78
+ return STATUS_IDLE if data.get("finish") else STATUS_BUSY
79
+ return ""
80
+
81
+ def _session_bytes(self, session_id):
82
+ """What one session holds in the database, rather than on its own."""
83
+ found = self.query(
84
+ "SELECT (SELECT total(length(data)) FROM message WHERE session_id = ?)"
85
+ " + (SELECT total(length(data)) FROM part WHERE session_id = ?)",
86
+ (session_id, session_id))
87
+ return int(found[0][0] or 0) if found else 0
88
+
89
+ def details(self, row):
90
+ if not self.database:
91
+ return []
92
+ found = self.query(
93
+ "SELECT cost, tokens_input, tokens_output, tokens_reasoning, "
94
+ "tokens_cache_read, tokens_cache_write, agent "
95
+ "FROM session WHERE id = ?", (row["session_id"],))
96
+ if not found:
97
+ return []
98
+ r = found[0]
99
+ return [
100
+ ("agent mode", r["agent"] or "-"),
101
+ ("tokens", f"in {r['tokens_input'] or 0:,} out {r['tokens_output'] or 0:,}"
102
+ f" reasoning {r['tokens_reasoning'] or 0:,}"),
103
+ ("cache", f"read {r['tokens_cache_read'] or 0:,}"
104
+ f" write {r['tokens_cache_write'] or 0:,}"),
105
+ ("cost", f"${r['cost'] or 0:.4f}"),
106
+ ]
107
+
108
+ def history_counts(self, since):
109
+ if not self.database:
110
+ return super().history_counts(since)
111
+ found = self.query("SELECT count(*), "
112
+ "sum(CASE WHEN time_updated > ? THEN 1 ELSE 0 END) "
113
+ "FROM session", (since * 1000,))
114
+ if not found:
115
+ return 0, 0
116
+ return int(found[0][0] or 0), int(found[0][1] or 0)
117
+
118
+ def history_bytes(self):
119
+ # a migrated install keeps the old tree around, and those are still
120
+ # bytes sitting on the disk
121
+ return (directory_size(self.database) if self.database else 0) \
122
+ + directory_size(os.path.join(self.root, "storage"))
123
+
124
+ # The JSON store older versions wrote ----------------------------------
125
+
126
+ def extract(self, reader):
127
+ data = jsonl.load(reader.path)
128
+ model, status = self._latest(data.get("id", ""))
129
+ info = {
130
+ "cwd": data.get("directory", ""),
131
+ "title": data.get("title", ""),
132
+ "model": model,
133
+ "status": status,
134
+ }
135
+ updated = (data.get("time") or {}).get("updated")
136
+ if updated:
137
+ info["last_active"] = updated / 1000.0
138
+ return info
139
+
140
+ def _latest(self, session_id):
141
+ """Model and turn state from the newest message files, newest first."""
142
+ if not session_id:
143
+ return "", ""
144
+ paths = glob.glob(os.path.join(self.root, "storage", "message",
145
+ session_id, "*.json"))
146
+ model = status = ""
147
+ for path in sorted(paths, key=_mtime, reverse=True):
148
+ data = jsonl.load(path)
149
+ if not status:
150
+ role = data.get("role")
151
+ if role == "user":
152
+ status = STATUS_BUSY
153
+ elif role == "assistant":
154
+ status = STATUS_IDLE
155
+ if not model and data.get("modelID"):
156
+ provider = data.get("providerID", "")
157
+ model = f"{provider}/{data['modelID']}" if provider else data["modelID"]
158
+ if model and status:
159
+ break
160
+ return model, status
161
+
162
+ def session_id(self, path):
163
+ return os.path.basename(path)[:-len(".json")]
164
+
165
+ def disk_paths(self, session_id, path):
166
+ return [path,
167
+ os.path.join(self.root, "storage", "message", session_id),
168
+ os.path.join(self.root, "storage", "session_diff",
169
+ f"{session_id}.json")]
170
+
171
+
172
+ def _model_name(value):
173
+ """`{"id": "mimo-claude", "providerID": "9router"}` as `9router/mimo-claude`."""
174
+ if not value:
175
+ return ""
176
+ try:
177
+ data = json.loads(value)
178
+ except ValueError:
179
+ return str(value)
180
+ if not isinstance(data, dict):
181
+ return str(value)
182
+ model = data.get("id", "")
183
+ provider = data.get("providerID", "")
184
+ return f"{provider}/{model}" if provider and model else model
185
+
186
+
187
+ def _mtime(path):
188
+ try:
189
+ return os.path.getmtime(path)
190
+ except OSError:
191
+ return 0