pocketshell 0.4.7__tar.gz → 0.4.9__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 (53) hide show
  1. {pocketshell-0.4.7 → pocketshell-0.4.9}/PKG-INFO +1 -1
  2. {pocketshell-0.4.7 → pocketshell-0.4.9}/pyproject.toml +1 -1
  3. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/agents.py +66 -0
  4. pocketshell-0.4.9/src/pocketshell/agents_kind.py +216 -0
  5. pocketshell-0.4.9/src/pocketshell/cgroup_agents.py +309 -0
  6. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/cli.py +2 -0
  7. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/daemon.py +48 -0
  8. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_agents.py +116 -0
  9. pocketshell-0.4.9/tests/test_agents_kind.py +332 -0
  10. pocketshell-0.4.9/tests/test_cgroup_agents.py +365 -0
  11. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_daemon.py +57 -0
  12. {pocketshell-0.4.7 → pocketshell-0.4.9}/.gitignore +0 -0
  13. {pocketshell-0.4.7 → pocketshell-0.4.9}/README.md +0 -0
  14. {pocketshell-0.4.7 → pocketshell-0.4.9}/scheduler/README.md +0 -0
  15. {pocketshell-0.4.7 → pocketshell-0.4.9}/scheduler/pocketshell-usage-capture.service +0 -0
  16. {pocketshell-0.4.7 → pocketshell-0.4.9}/scheduler/pocketshell-usage-capture.timer +0 -0
  17. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/__init__.py +0 -0
  18. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/__main__.py +0 -0
  19. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/agent_log.py +0 -0
  20. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/env.py +0 -0
  21. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/github.py +0 -0
  22. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/hooks.py +0 -0
  23. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/jobs.py +0 -0
  24. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/logs.py +0 -0
  25. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/profiles.py +0 -0
  26. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/prune_attachments.py +0 -0
  27. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/push.py +0 -0
  28. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/qr_share.py +0 -0
  29. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/repos.py +0 -0
  30. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/resume.py +0 -0
  31. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/sessions.py +0 -0
  32. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/usage.py +0 -0
  33. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/usage_capture.py +0 -0
  34. {pocketshell-0.4.7 → pocketshell-0.4.9}/src/pocketshell/usage_reset.py +0 -0
  35. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/__init__.py +0 -0
  36. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_agent_log.py +0 -0
  37. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_cli.py +0 -0
  38. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_env.py +0 -0
  39. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_github.py +0 -0
  40. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_hooks.py +0 -0
  41. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_jobs.py +0 -0
  42. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_logs.py +0 -0
  43. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_profiles.py +0 -0
  44. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_prune_attachments.py +0 -0
  45. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_push.py +0 -0
  46. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_qr_share.py +0 -0
  47. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_repos.py +0 -0
  48. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_resume.py +0 -0
  49. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_sessions.py +0 -0
  50. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_usage.py +0 -0
  51. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_usage_capture.py +0 -0
  52. {pocketshell-0.4.7 → pocketshell-0.4.9}/tests/test_usage_reset.py +0 -0
  53. {pocketshell-0.4.7 → pocketshell-0.4.9}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.4.7
3
+ Version: 0.4.9
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.7"
11
+ version = "0.4.9"
12
12
  description = "Unified server-side Python utility for the PocketShell Android client."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -74,6 +74,7 @@ from __future__ import annotations
74
74
  import json
75
75
  import os
76
76
  import shutil
77
+ import subprocess
77
78
  from pathlib import Path
78
79
  from typing import Optional
79
80
 
@@ -354,6 +355,56 @@ def _resolve_dir(ctx: click.Context, directory: str) -> Path:
354
355
  return path
355
356
 
356
357
 
358
+ def record_agent_kind(
359
+ kind: str,
360
+ env: Optional[dict[str, str]] = None,
361
+ runner=None,
362
+ ) -> bool:
363
+ """Record the launched agent ``kind`` as a per-session tmux user option.
364
+
365
+ Workstream A / epic #821: the durable "what is this session running"
366
+ state lives **host-side** as the tmux user option ``@ps_agent_kind`` on
367
+ the session this wrapper runs in. Writing it here (in the same process
368
+ that becomes the agent) means the recorded kind cannot drift from what
369
+ actually launched, and it covers every launch caller — the folder
370
+ picker, the assistant, the repo browser — with zero Kotlin launch-exec
371
+ change. The client reads it back through its session enumeration
372
+ (``tmux list-sessions -F '…#{@ps_agent_kind}'``).
373
+
374
+ The option is session-scoped (not global): ``tmux set-option`` without
375
+ ``-g`` sets it on the current session, which is the session the agent
376
+ was launched into. tmux session options persist for the life of the
377
+ session, so the recorded kind survives reconnect / app restart /
378
+ app-kill / reinstall — exactly the durability the epic requires.
379
+
380
+ No-op (returns ``False``) when not running inside tmux (``$TMUX``
381
+ unset) — e.g. a bare SSH ``pocketshell agent`` invocation — or when the
382
+ kind is unknown. A failure of the ``tmux`` call is swallowed: recording
383
+ the kind must never prevent the agent from launching.
384
+
385
+ ``runner`` is injected so tests can assert the exact ``tmux`` argv
386
+ without spawning a real process; production passes ``None`` and it
387
+ resolves to :func:`subprocess.run`.
388
+ """
389
+ if not kind:
390
+ return False
391
+ source_env = os.environ if env is None else env
392
+ if not source_env.get("TMUX"):
393
+ # Not inside a tmux server — nothing to record onto.
394
+ return False
395
+ if runner is None:
396
+ runner = subprocess.run
397
+ try:
398
+ runner(
399
+ ["tmux", "set-option", "@ps_agent_kind", kind],
400
+ check=False,
401
+ )
402
+ except Exception:
403
+ # Recording the kind is best-effort; never block the launch on it.
404
+ return False
405
+ return True
406
+
407
+
357
408
  def launch_agent(
358
409
  ctx: click.Context,
359
410
  kind: str,
@@ -363,6 +414,7 @@ def launch_agent(
363
414
  config_dir: Optional[str],
364
415
  extra_env: Optional[dict[str, str]] = None,
365
416
  execvpe=None,
417
+ record_kind=None,
366
418
  ) -> None:
367
419
  """Resolve the dir, build env+argv, suppress prompts, exec the agent.
368
420
 
@@ -376,9 +428,17 @@ def launch_agent(
376
428
  ``os`` so a monkeypatch on ``agents.os.execvpe`` is honoured (a default
377
429
  argument would bind the original at def-time and bypass the patch).
378
430
  :func:`os.execvpe` never returns on success.
431
+
432
+ Before the exec, when running inside tmux, the launched ``kind`` is
433
+ recorded as the per-session ``@ps_agent_kind`` user option
434
+ (:func:`record_agent_kind`) so the client can read the agent type back
435
+ from the host without output-parsing detection (epic #821 Workstream A).
436
+ ``record_kind`` is injected the same way as ``execvpe`` for tests.
379
437
  """
380
438
  if execvpe is None:
381
439
  execvpe = os.execvpe
440
+ if record_kind is None:
441
+ record_kind = record_agent_kind
382
442
 
383
443
  path = _resolve_dir(ctx, directory)
384
444
  resolved_dir = str(path)
@@ -409,6 +469,12 @@ def launch_agent(
409
469
  if kind == "claude":
410
470
  seed_claude_trust(claude_config_path(env), resolved_dir)
411
471
 
472
+ # Record the launched kind on the tmux session BEFORE the exec replaces
473
+ # this process (epic #821 Workstream A). Use this wrapper's own
474
+ # environment (os.environ) for the TMUX detection — `env` is the
475
+ # provider-stripped launch env that does not necessarily carry $TMUX.
476
+ record_kind(kind, dict(os.environ))
477
+
412
478
  # Replace this process with the agent so it owns the pty cleanly.
413
479
  execvpe(argv[0], argv, env)
414
480
 
@@ -0,0 +1,216 @@
1
+ """`pocketshell agents kind` — CLI seam over the cgroup agent-kind detector.
2
+
3
+ Epic #821 (workstream A2-infra). The daemon RPC ``agents.kind_for_panes``
4
+ (:func:`pocketshell.cgroup_agents.kind_for_panes`,
5
+ :func:`pocketshell.daemon._agents_kind_for_panes_handler`) does cgroup-v2 +
6
+ ``/proc`` agent-kind detection on the host, but it is **daemon-registry-only**:
7
+ the PocketShell Android client only ever execs ``pocketshell <subcommand>`` over
8
+ its warm SSH session — it never speaks JSON-RPC directly. So the detection has
9
+ no way to be reached from the client today.
10
+
11
+ This module adds the missing seam: ``pocketshell agents kind`` accepts a pane
12
+ list (each pane ``{pane_id, pane_pid}``, matching the RPC's input shape),
13
+ dispatches to the daemon when it is up (mirroring the
14
+ :func:`pocketshell.jobs._try_daemon_jobs_call` CLI→daemon pattern), falls back
15
+ to calling :func:`pocketshell.cgroup_agents.kind_for_panes` in-process when the
16
+ daemon is absent (the detection is pure cgroupfs/``/proc`` reads — no shell-out,
17
+ so the in-process call is the same computation the daemon performs), and emits
18
+ the RPC's ``{"results": [{pane_id, agent_kind, scope, evidence_pid?}]}`` as
19
+ stable, client-parseable JSON on stdout.
20
+
21
+ Input forms (pick whichever is convenient — they merge):
22
+
23
+ - **stdin JSON** — ``{"panes": [{"pane_id": "%1", "pane_pid": 2647034}, ...]}``,
24
+ byte-for-byte the RPC request shape. This is the primary form the client uses
25
+ (it pipes the pane snapshot it already has).
26
+ - **``--pane PANE_ID=PANE_PID``** (repeatable) — an ergonomic alternative for a
27
+ one-shot SSH exec / manual debugging.
28
+
29
+ ``none`` / ``unknown`` / empty-pane inputs never error: an empty pane list
30
+ yields ``{"results": []}``, a missing/invalid ``pane_pid`` yields
31
+ ``agent_kind="unknown"`` for that pane, and one bad pane never sinks the batch
32
+ (the detector is defensive by design — see ``cgroup_agents``).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import sys
39
+ from typing import Any, Mapping, Optional
40
+
41
+ import click
42
+
43
+ from pocketshell.cgroup_agents import (
44
+ DEFAULT_CGROUP_MOUNT,
45
+ DEFAULT_PROC_ROOT,
46
+ )
47
+
48
+
49
+ def _parse_stdin_panes() -> list[dict[str, Any]]:
50
+ """Read ``{"panes": [...]}`` from stdin, returning the pane list.
51
+
52
+ A non-TTY empty stdin (the common ``--pane``-only or no-input case) yields
53
+ an empty list rather than an error. Malformed JSON is a clear usage error.
54
+ """
55
+ if sys.stdin is None or sys.stdin.isatty():
56
+ return []
57
+ raw = sys.stdin.read()
58
+ if not raw.strip():
59
+ return []
60
+ try:
61
+ doc = json.loads(raw)
62
+ except json.JSONDecodeError as exc:
63
+ raise click.ClickException(
64
+ f"agents kind: stdin is not valid JSON: {exc}"
65
+ ) from exc
66
+ if not isinstance(doc, Mapping):
67
+ raise click.ClickException(
68
+ "agents kind: stdin JSON must be an object with a `panes` list"
69
+ )
70
+ raw_panes = doc.get("panes")
71
+ if raw_panes is None:
72
+ return []
73
+ if not isinstance(raw_panes, list):
74
+ raise click.ClickException(
75
+ "agents kind: `panes` must be a list of objects"
76
+ )
77
+ return [dict(p) for p in raw_panes if isinstance(p, Mapping)]
78
+
79
+
80
+ def _parse_pane_options(pane_specs: tuple[str, ...]) -> list[dict[str, Any]]:
81
+ """Parse ``--pane PANE_ID=PANE_PID`` specs into pane mappings.
82
+
83
+ ``PANE_PID`` is left as the raw string; the detector coerces it to ``int``
84
+ and degrades an invalid value to ``agent_kind="unknown"`` rather than
85
+ failing — so a non-numeric pid here is not a CLI error, mirroring the RPC.
86
+ """
87
+ panes: list[dict[str, Any]] = []
88
+ for spec in pane_specs:
89
+ pane_id, sep, pane_pid = spec.partition("=")
90
+ if not sep:
91
+ raise click.ClickException(
92
+ f"agents kind: --pane must be PANE_ID=PANE_PID (got {spec!r})"
93
+ )
94
+ panes.append({"pane_id": pane_id, "pane_pid": pane_pid})
95
+ return panes
96
+
97
+
98
+ def _try_daemon_call(
99
+ panes: list[dict[str, Any]],
100
+ *,
101
+ proc_root: str,
102
+ cgroup_mount: str,
103
+ timeout: float = 5.0,
104
+ ) -> Optional[dict[str, Any]]:
105
+ """Dispatch ``agents.kind_for_panes`` to the daemon; ``None`` on miss/error.
106
+
107
+ Mirrors :func:`pocketshell.jobs._try_daemon_jobs_call`. Only used when the
108
+ detection runs against the real host roots — when the caller overrode
109
+ ``--proc-root`` / ``--cgroup-mount`` (tests, debugging), the in-process path
110
+ is used directly so the override is honoured (the daemon reads the live
111
+ host roots and cannot see a synthetic tree).
112
+ """
113
+ if proc_root != DEFAULT_PROC_ROOT or cgroup_mount != DEFAULT_CGROUP_MOUNT:
114
+ return None
115
+
116
+ from pocketshell import daemon as _daemon
117
+
118
+ socket_path = _daemon.resolve_socket_path()
119
+ if not socket_path.exists():
120
+ return None
121
+ try:
122
+ result = _daemon.call(
123
+ "agents.kind_for_panes",
124
+ params={"panes": panes},
125
+ socket_path=socket_path,
126
+ timeout=timeout,
127
+ )
128
+ except (_daemon.DaemonClientError, RuntimeError, OSError):
129
+ return None
130
+ if not isinstance(result, dict) or "results" not in result:
131
+ return None
132
+ return result
133
+
134
+
135
+ def _classify_in_process(
136
+ panes: list[dict[str, Any]],
137
+ *,
138
+ proc_root: str,
139
+ cgroup_mount: str,
140
+ ) -> dict[str, Any]:
141
+ """Call the detector in-process and wrap it in the RPC's result envelope."""
142
+ from pocketshell import cgroup_agents as _cgroup_agents
143
+
144
+ results = _cgroup_agents.kind_for_panes(
145
+ panes, proc_root=proc_root, cgroup_mount=cgroup_mount
146
+ )
147
+ return {"results": results}
148
+
149
+
150
+ @click.group(
151
+ name="agents",
152
+ context_settings={"help_option_names": ["-h", "--help"]},
153
+ help=(
154
+ "Host-side agent-awareness helpers for the PocketShell client.\n\n"
155
+ "`kind` classifies the coding-agent (claude / codex / opencode) "
156
+ "running in each tmux pane's cgroup scope — the CLI seam over the "
157
+ "`agents.kind_for_panes` daemon RPC. See epic #821."
158
+ ),
159
+ )
160
+ def agents_group() -> None:
161
+ """Top-level `agents` group registered onto the root `pocketshell` CLI."""
162
+
163
+
164
+ @agents_group.command(
165
+ name="kind",
166
+ context_settings={"help_option_names": ["-h", "--help"]},
167
+ help=(
168
+ "Classify the agent kind in each pane's cgroup scope.\n\n"
169
+ "Reads a pane list as `{\"panes\": [{\"pane_id\", \"pane_pid\"}, ...]}` "
170
+ "JSON on stdin (the RPC request shape), and/or via repeatable "
171
+ "`--pane PANE_ID=PANE_PID`. Emits "
172
+ "`{\"results\": [{\"pane_id\", \"agent_kind\", \"scope\", "
173
+ "\"evidence_pid\"?}]}` as JSON on stdout. `agent_kind` is one of "
174
+ "claude / codex / opencode / none (scope, no agent) / unknown "
175
+ "(pane pid/cgroup unreadable). Empty input -> `{\"results\": []}`."
176
+ ),
177
+ )
178
+ @click.option(
179
+ "--pane",
180
+ "pane_specs",
181
+ multiple=True,
182
+ metavar="PANE_ID=PANE_PID",
183
+ help=(
184
+ "A pane to classify, as PANE_ID=PANE_PID. Repeatable. Merges with any "
185
+ "panes read from stdin JSON."
186
+ ),
187
+ )
188
+ @click.option(
189
+ "--proc-root",
190
+ default=DEFAULT_PROC_ROOT,
191
+ show_default=True,
192
+ help="Override the /proc root (testing / debugging).",
193
+ )
194
+ @click.option(
195
+ "--cgroup-mount",
196
+ default=DEFAULT_CGROUP_MOUNT,
197
+ show_default=True,
198
+ help="Override the cgroup v2 mount point (testing / debugging).",
199
+ )
200
+ def agents_kind_command(
201
+ pane_specs: tuple[str, ...],
202
+ proc_root: str,
203
+ cgroup_mount: str,
204
+ ) -> None:
205
+ """Classify the agent kind running in each pane's cgroup scope."""
206
+ panes = _parse_stdin_panes() + _parse_pane_options(pane_specs)
207
+
208
+ envelope = _try_daemon_call(
209
+ panes, proc_root=proc_root, cgroup_mount=cgroup_mount
210
+ )
211
+ if envelope is None:
212
+ envelope = _classify_in_process(
213
+ panes, proc_root=proc_root, cgroup_mount=cgroup_mount
214
+ )
215
+
216
+ click.echo(json.dumps(envelope))
@@ -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
@@ -24,6 +24,7 @@ import click
24
24
  from pocketshell import __version__
25
25
  from pocketshell.agent_log import agent_log_command
26
26
  from pocketshell.agents import agent_group
27
+ from pocketshell.agents_kind import agents_group
27
28
  from pocketshell.env import env_group
28
29
  from pocketshell.github import github_group
29
30
  from pocketshell.hooks import hooks_group
@@ -55,6 +56,7 @@ def cli() -> None:
55
56
 
56
57
  cli.add_command(usage_command, name="usage")
57
58
  cli.add_command(agent_group, name="agent")
59
+ cli.add_command(agents_group, name="agents")
58
60
  cli.add_command(profiles_group, name="profiles")
59
61
  cli.add_command(jobs_group, name="jobs")
60
62
  cli.add_command(sessions_group, name="sessions")