pocketshell 0.4.6__tar.gz → 0.4.8__tar.gz
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.
- {pocketshell-0.4.6 → pocketshell-0.4.8}/PKG-INFO +1 -1
- {pocketshell-0.4.6 → pocketshell-0.4.8}/pyproject.toml +1 -1
- pocketshell-0.4.8/src/pocketshell/cgroup_agents.py +309 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/daemon.py +48 -0
- pocketshell-0.4.8/tests/test_cgroup_agents.py +365 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_daemon.py +57 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/.gitignore +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/README.md +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/scheduler/README.md +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/scheduler/pocketshell-usage-capture.service +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/scheduler/pocketshell-usage-capture.timer +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/agent_log.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/agents.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/cli.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/env.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/github.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/jobs.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/profiles.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/prune_attachments.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/push.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/resume.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/sessions.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/usage.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/usage_capture.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/usage_reset.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/__init__.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_agent_log.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_agents.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_cli.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_env.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_github.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_hooks.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_jobs.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_logs.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_profiles.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_prune_attachments.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_push.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_qr_share.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_repos.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_resume.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_sessions.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_usage.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_usage_capture.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_usage_reset.py +0 -0
- {pocketshell-0.4.6 → pocketshell-0.4.8}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pocketshell
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.8
|
|
4
4
|
Summary: Unified server-side Python utility for the PocketShell Android client.
|
|
5
5
|
Project-URL: Homepage, https://github.com/alexeygrigorev/pocketshell
|
|
6
6
|
Project-URL: Issues, https://github.com/alexeygrigorev/pocketshell/issues
|
|
@@ -8,7 +8,7 @@ name = "pocketshell"
|
|
|
8
8
|
# scripts/check-pypi-version.sh enforces this; .github/workflows/build.yml
|
|
9
9
|
# runs that check before publishing to PyPI. See
|
|
10
10
|
# tools/pocketshell/README.md ("Release flow") for the bump procedure.
|
|
11
|
-
version = "0.4.
|
|
11
|
+
version = "0.4.8"
|
|
12
12
|
description = "Unified server-side Python utility for the PocketShell Android client."
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Scope/cgroup-based agent-kind detection for the ``agents.kind_for_panes`` RPC.
|
|
2
|
+
|
|
3
|
+
Replaces the fragile client-side ``ps -eo … | grep`` agent scan (issue #809 /
|
|
4
|
+
#811) with a deterministic server-side read of cgroup v2 + ``/proc``:
|
|
5
|
+
|
|
6
|
+
1. **pane → scope** — read ``/proc/<pane_pid>/cgroup``. On a cgroup-v2 host the
|
|
7
|
+
sole ``0::`` line carries the cgroup-relative path of the leaf, which for a
|
|
8
|
+
PocketShell session is ``…/robust.slice/tmuxctl-<session>.scope`` (the
|
|
9
|
+
deterministic name `tmuxctl` assigns at ``robust.scope_unit_name``). Sessions
|
|
10
|
+
started outside tmuxctl resolve to ``tmux-spawn-<uuid>.scope`` or a login
|
|
11
|
+
scope; "no scope" is detectable, never a crash.
|
|
12
|
+
2. **scope → procs** — read that cgroup's ``cgroup.procs`` then each
|
|
13
|
+
``/proc/<pid>/comm`` + ``/proc/<pid>/cmdline``. No ``systemctl status``
|
|
14
|
+
shell-out (it forks, pretty-prints ~100 ms, and truncates the proc list);
|
|
15
|
+
raw cgroupfs reads are ~7-17 ms and complete.
|
|
16
|
+
3. **classify** — match comm/cmdline tokens for claude / codex / opencode,
|
|
17
|
+
mirroring the proven token rules in
|
|
18
|
+
``shared/core-agents/.../AgentDetector.kt`` (``namesAgent`` /
|
|
19
|
+
``containsCommandToken``): a word-boundary match so ``codex`` matches a bare
|
|
20
|
+
``codex`` comm AND a ``node …/codex`` node-wrapped cmdline, but
|
|
21
|
+
``codex-helper`` substrings of an unrelated path do not false-positive on the
|
|
22
|
+
raw token alone.
|
|
23
|
+
|
|
24
|
+
All filesystem roots are injectable (``proc_root`` / ``cgroup_mount``) so the
|
|
25
|
+
classifier unit-tests against a synthetic ``/proc``-like tree with zero live
|
|
26
|
+
sessions. Every read is defensive: a missing pid, a pid that vanished between
|
|
27
|
+
the ``cgroup.procs`` read and the ``comm`` read, a no-scope pane, or a
|
|
28
|
+
permission error degrades to ``agent_kind = "none"`` (or an explicit
|
|
29
|
+
``"unknown"`` for an unreadable pane) — never an exception that fails the whole
|
|
30
|
+
batch.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import re
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Iterable, Mapping, Optional, Sequence
|
|
39
|
+
|
|
40
|
+
# Default cgroup v2 mount point on Linux. Injectable for tests.
|
|
41
|
+
DEFAULT_CGROUP_MOUNT = "/sys/fs/cgroup"
|
|
42
|
+
DEFAULT_PROC_ROOT = "/proc"
|
|
43
|
+
|
|
44
|
+
# Agent kinds returned to the client. ``none`` = pane resolved to a scope (or a
|
|
45
|
+
# readable proc set) but no agent process is present. ``unknown`` = the pane's
|
|
46
|
+
# pid / cgroup could not be read at all (vanished, permission), so we cannot
|
|
47
|
+
# even assert "no agent" — the client should treat it as not-yet-known, not as
|
|
48
|
+
# a confirmed plain shell.
|
|
49
|
+
AGENT_CLAUDE = "claude"
|
|
50
|
+
AGENT_CODEX = "codex"
|
|
51
|
+
AGENT_OPENCODE = "opencode"
|
|
52
|
+
AGENT_NONE = "none"
|
|
53
|
+
AGENT_UNKNOWN = "unknown"
|
|
54
|
+
|
|
55
|
+
# Token patterns mirror AgentDetector.namesAgent (Kotlin). Each is wrapped in
|
|
56
|
+
# the same word-boundary guard as Kotlin's `containsCommandToken` so the token
|
|
57
|
+
# only matches as a whole command word — bounded by start/end, whitespace, or
|
|
58
|
+
# the shell/path delimiters `/ | ; & ( ) ' " \``. This lets `codex` match both
|
|
59
|
+
# a bare `codex` comm and a `node /…/codex` node-wrapped cmdline while a stray
|
|
60
|
+
# substring inside an unrelated token does not light up.
|
|
61
|
+
_AGENT_TOKEN_PATTERNS: Sequence[tuple[str, str]] = (
|
|
62
|
+
# (agent_kind, command-token regex — verbatim from the Kotlin rules)
|
|
63
|
+
(AGENT_CLAUDE, r"claude(?:-?code)?"),
|
|
64
|
+
(AGENT_CODEX, r"codex"),
|
|
65
|
+
(AGENT_OPENCODE, r"open[-_]?code(?:[-_][a-z0-9]+)?"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Pre-compile the boundary-wrapped matchers once. The guard is identical to
|
|
69
|
+
# `containsCommandToken`: a leading start/delimiter and a trailing
|
|
70
|
+
# end/delimiter lookahead, case-insensitive.
|
|
71
|
+
_BOUNDARY_LEAD = r"(^|[\s/|;&('\"`])"
|
|
72
|
+
_BOUNDARY_TAIL = r"(?=$|[\s/|;&):'\"`])"
|
|
73
|
+
_COMPILED_TOKENS: Sequence[tuple[str, "re.Pattern[str]"]] = tuple(
|
|
74
|
+
(kind, re.compile(_BOUNDARY_LEAD + pattern + _BOUNDARY_TAIL))
|
|
75
|
+
for kind, pattern in _AGENT_TOKEN_PATTERNS
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Extract the scope unit basename from a cgroup-v2 relative path. Matches both
|
|
79
|
+
# the tmuxctl session scope and the non-tmuxctl spawn scope so "which scope"
|
|
80
|
+
# is reported even when no agent is detected.
|
|
81
|
+
_SCOPE_BASENAME_RE = re.compile(r"(?P<scope>(?:tmuxctl|tmux-spawn)-[^/]+\.scope)")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class PaneAgentResult:
|
|
86
|
+
"""One pane's classification result.
|
|
87
|
+
|
|
88
|
+
``scope`` is the cgroup scope basename the pane resolved to (e.g.
|
|
89
|
+
``tmuxctl-git-pocketshell.scope``) or ``None`` if the pane had no
|
|
90
|
+
tmuxctl/spawn scope (login shell, no systemd scopes). ``evidence_pid`` is
|
|
91
|
+
the pid of the process whose comm/cmdline named the agent, for debugging.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
pane_id: Optional[str]
|
|
95
|
+
agent_kind: str
|
|
96
|
+
scope: Optional[str]
|
|
97
|
+
evidence_pid: Optional[int] = None
|
|
98
|
+
|
|
99
|
+
def to_json(self) -> dict[str, object]:
|
|
100
|
+
out: dict[str, object] = {
|
|
101
|
+
"pane_id": self.pane_id,
|
|
102
|
+
"agent_kind": self.agent_kind,
|
|
103
|
+
"scope": self.scope,
|
|
104
|
+
}
|
|
105
|
+
if self.evidence_pid is not None:
|
|
106
|
+
out["evidence_pid"] = self.evidence_pid
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def classify_token(text: str) -> Optional[str]:
|
|
111
|
+
"""Return the agent kind named by ``text`` (a comm or cmdline), or ``None``.
|
|
112
|
+
|
|
113
|
+
Mirrors ``AgentDetector.namesAgent``: lowercases then applies the
|
|
114
|
+
word-boundary command-token match for each engine, in claude→codex→opencode
|
|
115
|
+
order. The order only matters in the impossible case of a single string
|
|
116
|
+
naming two engines; a real comm/cmdline names at most one.
|
|
117
|
+
"""
|
|
118
|
+
lowered = text.lower()
|
|
119
|
+
for kind, pattern in _COMPILED_TOKENS:
|
|
120
|
+
if pattern.search(lowered):
|
|
121
|
+
return kind
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _read_text(path: Path) -> Optional[str]:
|
|
126
|
+
"""Read a small ``/proc``/cgroupfs file, returning ``None`` on any error.
|
|
127
|
+
|
|
128
|
+
cgroupfs and ``/proc`` files can race away between enumeration and read
|
|
129
|
+
(the pid exits), or be unreadable; callers treat ``None`` as "skip", never
|
|
130
|
+
as a fatal error.
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
134
|
+
except (FileNotFoundError, ProcessLookupError, PermissionError, OSError):
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _read_cmdline(path: Path) -> Optional[str]:
|
|
139
|
+
"""Read a ``/proc/<pid>/cmdline`` (NUL-delimited) as a space-joined string."""
|
|
140
|
+
try:
|
|
141
|
+
raw = path.read_bytes()
|
|
142
|
+
except (FileNotFoundError, ProcessLookupError, PermissionError, OSError):
|
|
143
|
+
return None
|
|
144
|
+
if not raw:
|
|
145
|
+
return ""
|
|
146
|
+
# cmdline args are NUL-separated, trailing NUL terminates the last arg.
|
|
147
|
+
decoded = raw.decode("utf-8", errors="replace")
|
|
148
|
+
return " ".join(part for part in decoded.split("\x00") if part).strip()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def scope_relpath_for_pid(
|
|
152
|
+
pane_pid: int,
|
|
153
|
+
*,
|
|
154
|
+
proc_root: str = DEFAULT_PROC_ROOT,
|
|
155
|
+
) -> Optional[str]:
|
|
156
|
+
"""Resolve a pid's cgroup-v2 relative scope path from ``/proc/<pid>/cgroup``.
|
|
157
|
+
|
|
158
|
+
Returns the cgroup-relative path of the leaf cgroup (the part after the
|
|
159
|
+
``0::``), e.g.
|
|
160
|
+
``/user.slice/…/robust.slice/tmuxctl-git-pocketshell.scope``. Returns
|
|
161
|
+
``None`` when the pid is gone, unreadable, or the cgroup file is not
|
|
162
|
+
cgroup-v2 unified (no ``0::`` line).
|
|
163
|
+
"""
|
|
164
|
+
content = _read_text(Path(proc_root) / str(pane_pid) / "cgroup")
|
|
165
|
+
if content is None:
|
|
166
|
+
return None
|
|
167
|
+
for line in content.splitlines():
|
|
168
|
+
# cgroup v2 unified hierarchy: exactly one line "0::<path>".
|
|
169
|
+
if line.startswith("0::"):
|
|
170
|
+
relpath = line[len("0::"):].strip()
|
|
171
|
+
return relpath or None
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def scope_basename(relpath: str) -> Optional[str]:
|
|
176
|
+
"""Return the ``tmuxctl-*.scope`` / ``tmux-spawn-*.scope`` basename, if any."""
|
|
177
|
+
match = _SCOPE_BASENAME_RE.search(relpath)
|
|
178
|
+
if match:
|
|
179
|
+
return match.group("scope")
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _scope_procs(
|
|
184
|
+
relpath: str,
|
|
185
|
+
*,
|
|
186
|
+
cgroup_mount: str = DEFAULT_CGROUP_MOUNT,
|
|
187
|
+
) -> Optional[list[int]]:
|
|
188
|
+
"""Read ``cgroup.procs`` for the cgroup at ``relpath``.
|
|
189
|
+
|
|
190
|
+
``relpath`` is the cgroup-v2 relative path from
|
|
191
|
+
:func:`scope_relpath_for_pid`; the absolute cgroupfs path is the mount plus
|
|
192
|
+
that relative path. Returns the list of pids, or ``None`` if the cgroup is
|
|
193
|
+
gone / unreadable (e.g. the session ended between the pane read and now).
|
|
194
|
+
"""
|
|
195
|
+
# relpath is absolute-style ("/user.slice/…"); join under the mount.
|
|
196
|
+
cgroup_dir = Path(cgroup_mount) / relpath.lstrip("/")
|
|
197
|
+
content = _read_text(cgroup_dir / "cgroup.procs")
|
|
198
|
+
if content is None:
|
|
199
|
+
return None
|
|
200
|
+
pids: list[int] = []
|
|
201
|
+
for token in content.split():
|
|
202
|
+
try:
|
|
203
|
+
pids.append(int(token))
|
|
204
|
+
except ValueError:
|
|
205
|
+
continue
|
|
206
|
+
return pids
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def classify_scope_procs(
|
|
210
|
+
pids: Iterable[int],
|
|
211
|
+
*,
|
|
212
|
+
proc_root: str = DEFAULT_PROC_ROOT,
|
|
213
|
+
) -> tuple[str, Optional[int]]:
|
|
214
|
+
"""Classify the agent kind present among ``pids`` by their comm + cmdline.
|
|
215
|
+
|
|
216
|
+
Returns ``(agent_kind, evidence_pid)``. Reads each pid's ``comm`` first
|
|
217
|
+
(cheap, definitive for a non-wrapped CLI like ``claude``/``codex``) then
|
|
218
|
+
its ``cmdline`` (catches the node-wrapped form where ``comm`` is
|
|
219
|
+
``node``/``MainThread`` but the cmdline carries ``node /…/codex``). The
|
|
220
|
+
first pid that names an agent wins; if none do, returns ``("none", None)``.
|
|
221
|
+
"""
|
|
222
|
+
proc = Path(proc_root)
|
|
223
|
+
for pid in pids:
|
|
224
|
+
comm = _read_text(proc / str(pid) / "comm")
|
|
225
|
+
if comm is not None:
|
|
226
|
+
kind = classify_token(comm.strip())
|
|
227
|
+
if kind is not None:
|
|
228
|
+
return kind, pid
|
|
229
|
+
cmdline = _read_cmdline(proc / str(pid) / "cmdline")
|
|
230
|
+
if cmdline:
|
|
231
|
+
kind = classify_token(cmdline)
|
|
232
|
+
if kind is not None:
|
|
233
|
+
return kind, pid
|
|
234
|
+
return AGENT_NONE, None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def kind_for_pane(
|
|
238
|
+
pane_pid: int,
|
|
239
|
+
*,
|
|
240
|
+
pane_id: Optional[str] = None,
|
|
241
|
+
proc_root: str = DEFAULT_PROC_ROOT,
|
|
242
|
+
cgroup_mount: str = DEFAULT_CGROUP_MOUNT,
|
|
243
|
+
) -> PaneAgentResult:
|
|
244
|
+
"""Resolve one pane's agent kind end-to-end (pane_pid → scope → classify).
|
|
245
|
+
|
|
246
|
+
Never raises: an unreadable pane pid yields ``agent_kind="unknown"`` (we
|
|
247
|
+
cannot even assert "no agent"); a readable scope with no agent process
|
|
248
|
+
yields ``agent_kind="none"``.
|
|
249
|
+
"""
|
|
250
|
+
relpath = scope_relpath_for_pid(pane_pid, proc_root=proc_root)
|
|
251
|
+
if relpath is None:
|
|
252
|
+
# pid gone / cgroup unreadable — we cannot say anything definitive.
|
|
253
|
+
return PaneAgentResult(
|
|
254
|
+
pane_id=pane_id, agent_kind=AGENT_UNKNOWN, scope=None
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
scope = scope_basename(relpath)
|
|
258
|
+
pids = _scope_procs(relpath, cgroup_mount=cgroup_mount)
|
|
259
|
+
if pids is None:
|
|
260
|
+
# The cgroup itself is gone/unreadable even though the pane's cgroup
|
|
261
|
+
# line resolved — treat as unknown (the session likely just ended).
|
|
262
|
+
return PaneAgentResult(
|
|
263
|
+
pane_id=pane_id, agent_kind=AGENT_UNKNOWN, scope=scope
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
agent_kind, evidence_pid = classify_scope_procs(pids, proc_root=proc_root)
|
|
267
|
+
return PaneAgentResult(
|
|
268
|
+
pane_id=pane_id,
|
|
269
|
+
agent_kind=agent_kind,
|
|
270
|
+
scope=scope,
|
|
271
|
+
evidence_pid=evidence_pid,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def kind_for_panes(
|
|
276
|
+
panes: Iterable[Mapping[str, object]],
|
|
277
|
+
*,
|
|
278
|
+
proc_root: str = DEFAULT_PROC_ROOT,
|
|
279
|
+
cgroup_mount: str = DEFAULT_CGROUP_MOUNT,
|
|
280
|
+
) -> list[dict[str, object]]:
|
|
281
|
+
"""Batch classifier: resolve agent kind for every pane in ``panes``.
|
|
282
|
+
|
|
283
|
+
Each pane is a mapping with ``pane_pid`` (int, required) and an optional
|
|
284
|
+
``pane_id`` (passed through so the client can correlate). A pane with a
|
|
285
|
+
missing/invalid ``pane_pid`` yields ``agent_kind="unknown"`` rather than
|
|
286
|
+
failing the batch — one bad pane never sinks the others.
|
|
287
|
+
"""
|
|
288
|
+
results: list[dict[str, object]] = []
|
|
289
|
+
for pane in panes:
|
|
290
|
+
pane_id = pane.get("pane_id")
|
|
291
|
+
pane_id_str = str(pane_id) if pane_id is not None else None
|
|
292
|
+
raw_pid = pane.get("pane_pid")
|
|
293
|
+
try:
|
|
294
|
+
pane_pid = int(raw_pid) # type: ignore[arg-type]
|
|
295
|
+
except (TypeError, ValueError):
|
|
296
|
+
results.append(
|
|
297
|
+
PaneAgentResult(
|
|
298
|
+
pane_id=pane_id_str, agent_kind=AGENT_UNKNOWN, scope=None
|
|
299
|
+
).to_json()
|
|
300
|
+
)
|
|
301
|
+
continue
|
|
302
|
+
result = kind_for_pane(
|
|
303
|
+
pane_pid,
|
|
304
|
+
pane_id=pane_id_str,
|
|
305
|
+
proc_root=proc_root,
|
|
306
|
+
cgroup_mount=cgroup_mount,
|
|
307
|
+
)
|
|
308
|
+
results.append(result.to_json())
|
|
309
|
+
return results
|
|
@@ -527,6 +527,53 @@ def _jobs_status_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
527
527
|
return _jobs.daemon_handler_status(dict(params))
|
|
528
528
|
|
|
529
529
|
|
|
530
|
+
# ---------------------------------------------------------------------------
|
|
531
|
+
# Method: agents.kind_for_panes
|
|
532
|
+
# ---------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _agents_kind_for_panes_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
536
|
+
"""Classify the agent kind running in each pane's cgroup scope.
|
|
537
|
+
|
|
538
|
+
Replaces the client's fragile ``ps -eo … | grep`` agent scan (#809/#811)
|
|
539
|
+
with a server-side cgroup-v2 + ``/proc`` read: each pane's ``pane_pid``
|
|
540
|
+
resolves to its ``tmuxctl-<session>.scope`` via ``/proc/<pid>/cgroup``, the
|
|
541
|
+
scope's ``cgroup.procs`` are read, and each proc's ``comm``/``cmdline`` is
|
|
542
|
+
matched against the claude/codex/opencode token rules (mirrored from
|
|
543
|
+
``AgentDetector.namesAgent``). No ``systemctl`` shell-out — raw cgroupfs.
|
|
544
|
+
|
|
545
|
+
Request params::
|
|
546
|
+
|
|
547
|
+
{"panes": [{"pane_id": "%1", "pane_pid": 2647034}, ...]}
|
|
548
|
+
|
|
549
|
+
Result::
|
|
550
|
+
|
|
551
|
+
{"results": [
|
|
552
|
+
{"pane_id": "%1", "agent_kind": "claude",
|
|
553
|
+
"scope": "tmuxctl-git-pocketshell.scope", "evidence_pid": 2647069},
|
|
554
|
+
...
|
|
555
|
+
]}
|
|
556
|
+
|
|
557
|
+
``agent_kind`` is one of ``claude`` / ``codex`` / ``opencode`` / ``none``
|
|
558
|
+
(readable scope, no agent) / ``unknown`` (pane pid/cgroup unreadable). One
|
|
559
|
+
bad pane never sinks the batch.
|
|
560
|
+
"""
|
|
561
|
+
from pocketshell import cgroup_agents as _cgroup_agents
|
|
562
|
+
|
|
563
|
+
raw_panes = params.get("panes")
|
|
564
|
+
if raw_panes is None:
|
|
565
|
+
panes: list[Mapping[str, Any]] = []
|
|
566
|
+
elif isinstance(raw_panes, list):
|
|
567
|
+
panes = [p for p in raw_panes if isinstance(p, Mapping)]
|
|
568
|
+
else:
|
|
569
|
+
raise _RpcError(
|
|
570
|
+
JSONRPC_INVALID_PARAMS,
|
|
571
|
+
"agents.kind_for_panes: `panes` must be a list of objects",
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
return {"results": _cgroup_agents.kind_for_panes(panes)}
|
|
575
|
+
|
|
576
|
+
|
|
530
577
|
# Single shared registry; tests can register additional methods via
|
|
531
578
|
# :meth:`Daemon.register_method` on a fresh instance without touching
|
|
532
579
|
# this dict.
|
|
@@ -544,6 +591,7 @@ DEFAULT_METHODS: Mapping[str, RpcHandler] = {
|
|
|
544
591
|
"jobs.edit": _jobs_edit_handler,
|
|
545
592
|
"jobs.remove": _jobs_remove_handler,
|
|
546
593
|
"jobs.status": _jobs_status_handler,
|
|
594
|
+
"agents.kind_for_panes": _agents_kind_for_panes_handler,
|
|
547
595
|
}
|
|
548
596
|
|
|
549
597
|
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Tests for cgroup/scope-based agent classification (issue #809 / #811).
|
|
2
|
+
|
|
3
|
+
The classifier reads a cgroup-v2 ``/sys/fs/cgroup`` tree plus ``/proc`` to map
|
|
4
|
+
each pane's ``pane_pid`` to its ``tmuxctl-<session>.scope`` and classify the
|
|
5
|
+
agent process running inside. These tests build a **synthetic** ``/proc``-like
|
|
6
|
+
and cgroupfs-like tree on ``tmp_path`` and point the classifier at it via the
|
|
7
|
+
injectable ``proc_root`` / ``cgroup_mount`` roots, so nothing depends on a live
|
|
8
|
+
tmux/agent session — they reproduce the real on-box layout proven in the #811
|
|
9
|
+
design spike (claude as ``comm=claude``; codex node-wrapped as
|
|
10
|
+
``comm=MainThread`` + ``cmdline=node …/codex``; etc.).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
from pocketshell import cgroup_agents
|
|
21
|
+
from pocketshell.cgroup_agents import (
|
|
22
|
+
AGENT_CLAUDE,
|
|
23
|
+
AGENT_CODEX,
|
|
24
|
+
AGENT_NONE,
|
|
25
|
+
AGENT_OPENCODE,
|
|
26
|
+
AGENT_UNKNOWN,
|
|
27
|
+
classify_token,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Synthetic-filesystem fixture builder
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _FakeHost:
|
|
37
|
+
"""Builds a synthetic ``/proc`` + cgroup-v2 tree under ``tmp_path``.
|
|
38
|
+
|
|
39
|
+
Mirrors the real on-box layout: a pane shell pid lives directly inside its
|
|
40
|
+
``…/robust.slice/<scope>``; the scope's ``cgroup.procs`` lists every pid in
|
|
41
|
+
the scope; each pid has a ``/proc/<pid>/comm`` and ``/proc/<pid>/cmdline``.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# The relative cgroup path prefix every scope lives under, matching the
|
|
45
|
+
# real `/proc/<pid>/cgroup` `0::` line on this box.
|
|
46
|
+
ROBUST_PREFIX = (
|
|
47
|
+
"/user.slice/user-1000.slice/user@1000.service/robust.slice"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def __init__(self, root: Path) -> None:
|
|
51
|
+
self.proc_root = root / "proc"
|
|
52
|
+
self.cgroup_mount = root / "cgroup"
|
|
53
|
+
self.proc_root.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
self.cgroup_mount.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
def _scope_relpath(self, scope: str) -> str:
|
|
57
|
+
return f"{self.ROBUST_PREFIX}/{scope}"
|
|
58
|
+
|
|
59
|
+
def add_proc(
|
|
60
|
+
self,
|
|
61
|
+
pid: int,
|
|
62
|
+
*,
|
|
63
|
+
scope: Optional[str],
|
|
64
|
+
comm: str,
|
|
65
|
+
cmdline: str,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Create a synthetic ``/proc/<pid>`` whose cgroup line names ``scope``.
|
|
68
|
+
|
|
69
|
+
When ``scope`` is ``None`` the cgroup line points at a bare login path
|
|
70
|
+
with no tmuxctl/spawn scope (the non-tmuxctl session case).
|
|
71
|
+
"""
|
|
72
|
+
proc_dir = self.proc_root / str(pid)
|
|
73
|
+
proc_dir.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
(proc_dir / "comm").write_text(comm + "\n", encoding="utf-8")
|
|
75
|
+
# cmdline is NUL-separated with a trailing NUL on real /proc.
|
|
76
|
+
cmdline_bytes = b"\x00".join(
|
|
77
|
+
part.encode("utf-8") for part in cmdline.split(" ")
|
|
78
|
+
)
|
|
79
|
+
if cmdline_bytes:
|
|
80
|
+
cmdline_bytes += b"\x00"
|
|
81
|
+
(proc_dir / "cmdline").write_bytes(cmdline_bytes)
|
|
82
|
+
|
|
83
|
+
if scope is None:
|
|
84
|
+
relpath = f"{self.ROBUST_PREFIX}/../user-1000.slice/session-1.scope"
|
|
85
|
+
else:
|
|
86
|
+
relpath = self._scope_relpath(scope)
|
|
87
|
+
(proc_dir / "cgroup").write_text(f"0::{relpath}\n", encoding="utf-8")
|
|
88
|
+
|
|
89
|
+
def set_scope_procs(self, scope: str, pids: list[int]) -> None:
|
|
90
|
+
"""Write ``cgroup.procs`` for a scope listing the given pids."""
|
|
91
|
+
scope_dir = (
|
|
92
|
+
self.cgroup_mount
|
|
93
|
+
/ self.ROBUST_PREFIX.lstrip("/")
|
|
94
|
+
/ scope
|
|
95
|
+
)
|
|
96
|
+
scope_dir.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
body = "".join(f"{pid}\n" for pid in pids)
|
|
98
|
+
(scope_dir / "cgroup.procs").write_text(body, encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.fixture()
|
|
102
|
+
def host(tmp_path: Path) -> _FakeHost:
|
|
103
|
+
return _FakeHost(tmp_path)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _classify(host: _FakeHost, pane_pid: int, pane_id: str = "%1"):
|
|
107
|
+
return cgroup_agents.kind_for_pane(
|
|
108
|
+
pane_pid,
|
|
109
|
+
pane_id=pane_id,
|
|
110
|
+
proc_root=str(host.proc_root),
|
|
111
|
+
cgroup_mount=str(host.cgroup_mount),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# classify_token — the pure token-rule mirror of AgentDetector.namesAgent
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.parametrize(
|
|
121
|
+
("text", "expected"),
|
|
122
|
+
[
|
|
123
|
+
("claude", AGENT_CLAUDE),
|
|
124
|
+
("claude --dangerously-skip-permissions", AGENT_CLAUDE),
|
|
125
|
+
("claude-code", AGENT_CLAUDE),
|
|
126
|
+
("codex", AGENT_CODEX),
|
|
127
|
+
("/home/alexey/.nvm/versions/node/v24/bin/codex --foo", AGENT_CODEX),
|
|
128
|
+
("node /home/alexey/.nvm/.../codex --bypass", AGENT_CODEX),
|
|
129
|
+
("opencode", AGENT_OPENCODE),
|
|
130
|
+
("open-code", AGENT_OPENCODE),
|
|
131
|
+
("opencode-tui", AGENT_OPENCODE),
|
|
132
|
+
# Plain shells / unrelated processes name no agent.
|
|
133
|
+
("bash", None),
|
|
134
|
+
("/bin/bash -l", None),
|
|
135
|
+
("node /some/other/app.js", None),
|
|
136
|
+
# `codex` as a whole command word (space-delimited) matches; the
|
|
137
|
+
# `codexicon` substring case is covered separately below.
|
|
138
|
+
("git commit && codex", AGENT_CODEX),
|
|
139
|
+
("", None),
|
|
140
|
+
],
|
|
141
|
+
)
|
|
142
|
+
def test_classify_token(text: str, expected: Optional[str]) -> None:
|
|
143
|
+
assert classify_token(text) == expected
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_classify_token_substring_does_not_false_positive() -> None:
|
|
147
|
+
# `codexedit` is one token, not a `codex` command word — the boundary
|
|
148
|
+
# guard (mirroring containsCommandToken) must NOT match it.
|
|
149
|
+
assert classify_token("codexedit") is None
|
|
150
|
+
assert classify_token("myclaudeproxy") is None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# kind_for_pane — end-to-end pane_pid → scope → classify
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_claude_scope(host: _FakeHost) -> None:
|
|
159
|
+
"""A scope with a bare `comm=claude` shell child classifies as claude."""
|
|
160
|
+
scope = "tmuxctl-git-pocketshell.scope"
|
|
161
|
+
host.add_proc(2647034, scope=scope, comm="bash", cmdline="/bin/bash -l")
|
|
162
|
+
host.add_proc(
|
|
163
|
+
2647069,
|
|
164
|
+
scope=scope,
|
|
165
|
+
comm="claude",
|
|
166
|
+
cmdline="claude --dangerously-skip-permissions",
|
|
167
|
+
)
|
|
168
|
+
host.set_scope_procs(scope, [2647034, 2647069])
|
|
169
|
+
|
|
170
|
+
result = _classify(host, 2647034)
|
|
171
|
+
assert result.agent_kind == AGENT_CLAUDE
|
|
172
|
+
assert result.scope == scope
|
|
173
|
+
assert result.evidence_pid == 2647069
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_codex_node_wrapped_scope(host: _FakeHost) -> None:
|
|
177
|
+
"""Node-wrapped codex: comm is `MainThread`, cmdline carries node …/codex.
|
|
178
|
+
|
|
179
|
+
This is the exact live layout proven in the #811 spike for
|
|
180
|
+
`tmuxctl-git-3d-models.scope`.
|
|
181
|
+
"""
|
|
182
|
+
scope = "tmuxctl-git-3d-models.scope"
|
|
183
|
+
host.add_proc(756261, scope=scope, comm="bash", cmdline="/bin/bash -l")
|
|
184
|
+
host.add_proc(
|
|
185
|
+
756501,
|
|
186
|
+
scope=scope,
|
|
187
|
+
comm="MainThread",
|
|
188
|
+
cmdline=(
|
|
189
|
+
"node /home/alexey/.nvm/versions/node/v24.13.1/bin/codex "
|
|
190
|
+
"--dangerously-bypass-approvals-and-sandbox"
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
host.add_proc(
|
|
194
|
+
756656,
|
|
195
|
+
scope=scope,
|
|
196
|
+
comm="codex",
|
|
197
|
+
cmdline="/home/alexey/.../@openai/codex/node_modules/...",
|
|
198
|
+
)
|
|
199
|
+
host.set_scope_procs(scope, [756261, 756501, 756656])
|
|
200
|
+
|
|
201
|
+
result = _classify(host, 756261)
|
|
202
|
+
assert result.agent_kind == AGENT_CODEX
|
|
203
|
+
assert result.scope == scope
|
|
204
|
+
# The node-wrapper MainThread pid is enumerated before the bare codex pid,
|
|
205
|
+
# so its cmdline is the evidence.
|
|
206
|
+
assert result.evidence_pid == 756501
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_opencode_scope(host: _FakeHost) -> None:
|
|
210
|
+
scope = "tmuxctl-git-faq-assistant.scope"
|
|
211
|
+
host.add_proc(900001, scope=scope, comm="bash", cmdline="/bin/bash -l")
|
|
212
|
+
host.add_proc(
|
|
213
|
+
900002, scope=scope, comm="opencode", cmdline="opencode --foo"
|
|
214
|
+
)
|
|
215
|
+
host.set_scope_procs(scope, [900001, 900002])
|
|
216
|
+
|
|
217
|
+
result = _classify(host, 900001)
|
|
218
|
+
assert result.agent_kind == AGENT_OPENCODE
|
|
219
|
+
assert result.scope == scope
|
|
220
|
+
assert result.evidence_pid == 900002
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_plain_shell_scope_is_none(host: _FakeHost) -> None:
|
|
224
|
+
"""A scope with only a plain shell (no agent) classifies as `none`."""
|
|
225
|
+
scope = "tmuxctl-git-datamailer.scope"
|
|
226
|
+
host.add_proc(800001, scope=scope, comm="bash", cmdline="/bin/bash -l")
|
|
227
|
+
host.add_proc(800002, scope=scope, comm="vim", cmdline="vim notes.txt")
|
|
228
|
+
host.set_scope_procs(scope, [800001, 800002])
|
|
229
|
+
|
|
230
|
+
result = _classify(host, 800001)
|
|
231
|
+
assert result.agent_kind == AGENT_NONE
|
|
232
|
+
assert result.scope == scope
|
|
233
|
+
assert result.evidence_pid is None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_missing_pid_is_unknown(host: _FakeHost) -> None:
|
|
237
|
+
"""A pane_pid with no /proc entry (vanished) is `unknown`, not a crash."""
|
|
238
|
+
result = _classify(host, 4242424)
|
|
239
|
+
assert result.agent_kind == AGENT_UNKNOWN
|
|
240
|
+
assert result.scope is None
|
|
241
|
+
assert result.evidence_pid is None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_no_tmuxctl_scope_is_none_with_null_scope(host: _FakeHost) -> None:
|
|
245
|
+
"""A non-tmuxctl pane (login scope) reports scope=None.
|
|
246
|
+
|
|
247
|
+
The pid resolves (cgroup readable) but there is no tmuxctl/spawn scope, so
|
|
248
|
+
we cannot read a scope's cgroup.procs — degrade to `unknown` rather than
|
|
249
|
+
pretending we proved "no agent".
|
|
250
|
+
"""
|
|
251
|
+
host.add_proc(700001, scope=None, comm="bash", cmdline="/bin/bash -l")
|
|
252
|
+
result = _classify(host, 700001)
|
|
253
|
+
# No scope basename, and the login-scope cgroup.procs path does not exist
|
|
254
|
+
# in our synthetic tree → unknown.
|
|
255
|
+
assert result.scope is None
|
|
256
|
+
assert result.agent_kind == AGENT_UNKNOWN
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_scope_procs_missing_is_unknown(host: _FakeHost) -> None:
|
|
260
|
+
"""Pane cgroup resolves to a scope, but the scope's cgroup.procs is gone.
|
|
261
|
+
|
|
262
|
+
Simulates the session ending between the pane read and the procs read:
|
|
263
|
+
the scope basename is still reported, but agent_kind is `unknown`.
|
|
264
|
+
"""
|
|
265
|
+
scope = "tmuxctl-git-ended.scope"
|
|
266
|
+
host.add_proc(810001, scope=scope, comm="bash", cmdline="/bin/bash -l")
|
|
267
|
+
# Deliberately do NOT call set_scope_procs — no cgroup.procs file.
|
|
268
|
+
result = _classify(host, 810001)
|
|
269
|
+
assert result.scope == scope
|
|
270
|
+
assert result.agent_kind == AGENT_UNKNOWN
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_proc_vanishes_between_enumeration_and_read(host: _FakeHost) -> None:
|
|
274
|
+
"""A pid listed in cgroup.procs but with no /proc entry is skipped.
|
|
275
|
+
|
|
276
|
+
The classifier must not crash when a pid races away after the
|
|
277
|
+
cgroup.procs read; it skips it and still classifies the survivors.
|
|
278
|
+
"""
|
|
279
|
+
scope = "tmuxctl-git-race.scope"
|
|
280
|
+
host.add_proc(820001, scope=scope, comm="bash", cmdline="/bin/bash -l")
|
|
281
|
+
host.add_proc(
|
|
282
|
+
820003, scope=scope, comm="claude", cmdline="claude --skip"
|
|
283
|
+
)
|
|
284
|
+
# 820002 is listed but never created → simulates a vanished pid.
|
|
285
|
+
host.set_scope_procs(scope, [820001, 820002, 820003])
|
|
286
|
+
|
|
287
|
+
result = _classify(host, 820001)
|
|
288
|
+
assert result.agent_kind == AGENT_CLAUDE
|
|
289
|
+
assert result.evidence_pid == 820003
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
# kind_for_panes — the batch entry the RPC calls
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_batch_classifies_each_pane_and_isolates_failures(
|
|
298
|
+
host: _FakeHost,
|
|
299
|
+
) -> None:
|
|
300
|
+
"""One bad pane (invalid pane_pid) must not sink the rest of the batch."""
|
|
301
|
+
claude_scope = "tmuxctl-git-pocketshell.scope"
|
|
302
|
+
host.add_proc(100, scope=claude_scope, comm="bash", cmdline="/bin/bash")
|
|
303
|
+
host.add_proc(101, scope=claude_scope, comm="claude", cmdline="claude")
|
|
304
|
+
host.set_scope_procs(claude_scope, [100, 101])
|
|
305
|
+
|
|
306
|
+
shell_scope = "tmuxctl-git-plain.scope"
|
|
307
|
+
host.add_proc(200, scope=shell_scope, comm="bash", cmdline="/bin/bash")
|
|
308
|
+
host.set_scope_procs(shell_scope, [200])
|
|
309
|
+
|
|
310
|
+
panes = [
|
|
311
|
+
{"pane_id": "%1", "pane_pid": 100},
|
|
312
|
+
{"pane_id": "%2", "pane_pid": 200},
|
|
313
|
+
{"pane_id": "%3", "pane_pid": "not-an-int"}, # bad pane
|
|
314
|
+
{"pane_id": "%4"}, # missing pane_pid entirely
|
|
315
|
+
]
|
|
316
|
+
results = cgroup_agents.kind_for_panes(
|
|
317
|
+
panes,
|
|
318
|
+
proc_root=str(host.proc_root),
|
|
319
|
+
cgroup_mount=str(host.cgroup_mount),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
by_id = {r["pane_id"]: r for r in results}
|
|
323
|
+
assert by_id["%1"]["agent_kind"] == AGENT_CLAUDE
|
|
324
|
+
assert by_id["%1"]["scope"] == claude_scope
|
|
325
|
+
assert by_id["%1"]["evidence_pid"] == 101
|
|
326
|
+
assert by_id["%2"]["agent_kind"] == AGENT_NONE
|
|
327
|
+
assert by_id["%2"]["scope"] == shell_scope
|
|
328
|
+
# Bad / missing panes degrade to unknown, never raise.
|
|
329
|
+
assert by_id["%3"]["agent_kind"] == AGENT_UNKNOWN
|
|
330
|
+
assert by_id["%3"]["scope"] is None
|
|
331
|
+
assert by_id["%4"]["agent_kind"] == AGENT_UNKNOWN
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_batch_pane_id_passthrough_and_correlation(host: _FakeHost) -> None:
|
|
335
|
+
"""pane_id is passed through verbatim so the client can correlate."""
|
|
336
|
+
scope = "tmuxctl-git-pocketshell.scope"
|
|
337
|
+
host.add_proc(300, scope=scope, comm="bash", cmdline="/bin/bash")
|
|
338
|
+
host.add_proc(301, scope=scope, comm="claude", cmdline="claude")
|
|
339
|
+
host.set_scope_procs(scope, [300, 301])
|
|
340
|
+
|
|
341
|
+
results = cgroup_agents.kind_for_panes(
|
|
342
|
+
[{"pane_id": "%99", "pane_pid": 300}],
|
|
343
|
+
proc_root=str(host.proc_root),
|
|
344
|
+
cgroup_mount=str(host.cgroup_mount),
|
|
345
|
+
)
|
|
346
|
+
assert results[0]["pane_id"] == "%99"
|
|
347
|
+
assert results[0]["agent_kind"] == AGENT_CLAUDE
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_batch_empty_panes() -> None:
|
|
351
|
+
assert cgroup_agents.kind_for_panes([]) == []
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_result_json_omits_evidence_pid_when_absent(host: _FakeHost) -> None:
|
|
355
|
+
"""The `none` result envelope has no evidence_pid key (compact wire)."""
|
|
356
|
+
scope = "tmuxctl-git-empty.scope"
|
|
357
|
+
host.add_proc(400, scope=scope, comm="bash", cmdline="/bin/bash")
|
|
358
|
+
host.set_scope_procs(scope, [400])
|
|
359
|
+
results = cgroup_agents.kind_for_panes(
|
|
360
|
+
[{"pane_id": "%1", "pane_pid": 400}],
|
|
361
|
+
proc_root=str(host.proc_root),
|
|
362
|
+
cgroup_mount=str(host.cgroup_mount),
|
|
363
|
+
)
|
|
364
|
+
assert "evidence_pid" not in results[0]
|
|
365
|
+
assert results[0]["agent_kind"] == AGENT_NONE
|
|
@@ -836,6 +836,63 @@ def test_daemon_registry_includes_sessions_and_jobs_methods() -> None:
|
|
|
836
836
|
assert daemon_mod.METHOD_CACHE_INVALIDATIONS["jobs.trigger"] == ("jobs.list",)
|
|
837
837
|
|
|
838
838
|
|
|
839
|
+
def test_daemon_registry_includes_agents_kind_for_panes() -> None:
|
|
840
|
+
assert "agents.kind_for_panes" in daemon_mod.DEFAULT_METHODS
|
|
841
|
+
# No TTL: agent processes change live, so the method must never be cached
|
|
842
|
+
# (the spike explicitly forbids a TTL cache here — procs change live).
|
|
843
|
+
assert "agents.kind_for_panes" not in daemon_mod.METHOD_TTLS
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def test_agents_kind_for_panes_round_trip(
|
|
847
|
+
running_daemon: subprocess.Popen,
|
|
848
|
+
sandbox_socket: Path,
|
|
849
|
+
) -> None:
|
|
850
|
+
"""The RPC returns a well-formed batch envelope over the real transport.
|
|
851
|
+
|
|
852
|
+
A pane_pid that does not exist resolves to ``unknown`` (never an error),
|
|
853
|
+
and the pane_id is passed through verbatim so the client can correlate.
|
|
854
|
+
This exercises the full JSON-RPC framing + handler dispatch, not just the
|
|
855
|
+
in-process classifier.
|
|
856
|
+
"""
|
|
857
|
+
result = daemon_mod.call(
|
|
858
|
+
"agents.kind_for_panes",
|
|
859
|
+
{"panes": [{"pane_id": "%1", "pane_pid": 999999999}]},
|
|
860
|
+
socket_path=sandbox_socket,
|
|
861
|
+
)
|
|
862
|
+
assert isinstance(result, dict)
|
|
863
|
+
assert isinstance(result["results"], list)
|
|
864
|
+
assert len(result["results"]) == 1
|
|
865
|
+
entry = result["results"][0]
|
|
866
|
+
assert entry["pane_id"] == "%1"
|
|
867
|
+
# A non-existent pid resolves to `unknown` (no /proc entry), never errors.
|
|
868
|
+
assert entry["agent_kind"] == "unknown"
|
|
869
|
+
assert entry["scope"] is None
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def test_agents_kind_for_panes_rejects_non_list_panes(
|
|
873
|
+
running_daemon: subprocess.Popen,
|
|
874
|
+
sandbox_socket: Path,
|
|
875
|
+
) -> None:
|
|
876
|
+
"""A `panes` param that is not a list is an invalid-params error."""
|
|
877
|
+
with pytest.raises(RuntimeError, match="must be a list"):
|
|
878
|
+
daemon_mod.call(
|
|
879
|
+
"agents.kind_for_panes",
|
|
880
|
+
{"panes": "not-a-list"},
|
|
881
|
+
socket_path=sandbox_socket,
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def test_agents_kind_for_panes_empty_when_no_panes(
|
|
886
|
+
running_daemon: subprocess.Popen,
|
|
887
|
+
sandbox_socket: Path,
|
|
888
|
+
) -> None:
|
|
889
|
+
"""Omitting `panes` yields an empty results list, not an error."""
|
|
890
|
+
result = daemon_mod.call(
|
|
891
|
+
"agents.kind_for_panes", {}, socket_path=sandbox_socket
|
|
892
|
+
)
|
|
893
|
+
assert result == {"results": []}
|
|
894
|
+
|
|
895
|
+
|
|
839
896
|
def test_failed_usage_fetch_is_not_cached(
|
|
840
897
|
sandbox_socket: Path,
|
|
841
898
|
tmp_path: Path,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|