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.
Files changed (51) hide show
  1. {pocketshell-0.4.6 → pocketshell-0.4.8}/PKG-INFO +1 -1
  2. {pocketshell-0.4.6 → pocketshell-0.4.8}/pyproject.toml +1 -1
  3. pocketshell-0.4.8/src/pocketshell/cgroup_agents.py +309 -0
  4. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/daemon.py +48 -0
  5. pocketshell-0.4.8/tests/test_cgroup_agents.py +365 -0
  6. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_daemon.py +57 -0
  7. {pocketshell-0.4.6 → pocketshell-0.4.8}/.gitignore +0 -0
  8. {pocketshell-0.4.6 → pocketshell-0.4.8}/README.md +0 -0
  9. {pocketshell-0.4.6 → pocketshell-0.4.8}/scheduler/README.md +0 -0
  10. {pocketshell-0.4.6 → pocketshell-0.4.8}/scheduler/pocketshell-usage-capture.service +0 -0
  11. {pocketshell-0.4.6 → pocketshell-0.4.8}/scheduler/pocketshell-usage-capture.timer +0 -0
  12. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/__init__.py +0 -0
  13. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/__main__.py +0 -0
  14. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/agent_log.py +0 -0
  15. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/agents.py +0 -0
  16. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/cli.py +0 -0
  17. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/env.py +0 -0
  18. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/github.py +0 -0
  19. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/hooks.py +0 -0
  20. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/jobs.py +0 -0
  21. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/logs.py +0 -0
  22. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/profiles.py +0 -0
  23. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/prune_attachments.py +0 -0
  24. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/push.py +0 -0
  25. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/qr_share.py +0 -0
  26. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/repos.py +0 -0
  27. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/resume.py +0 -0
  28. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/sessions.py +0 -0
  29. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/usage.py +0 -0
  30. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/usage_capture.py +0 -0
  31. {pocketshell-0.4.6 → pocketshell-0.4.8}/src/pocketshell/usage_reset.py +0 -0
  32. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/__init__.py +0 -0
  33. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_agent_log.py +0 -0
  34. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_agents.py +0 -0
  35. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_cli.py +0 -0
  36. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_env.py +0 -0
  37. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_github.py +0 -0
  38. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_hooks.py +0 -0
  39. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_jobs.py +0 -0
  40. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_logs.py +0 -0
  41. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_profiles.py +0 -0
  42. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_prune_attachments.py +0 -0
  43. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_push.py +0 -0
  44. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_qr_share.py +0 -0
  45. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_repos.py +0 -0
  46. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_resume.py +0 -0
  47. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_sessions.py +0 -0
  48. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_usage.py +0 -0
  49. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_usage_capture.py +0 -0
  50. {pocketshell-0.4.6 → pocketshell-0.4.8}/tests/test_usage_reset.py +0 -0
  51. {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.6
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.6"
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