pocketshell 0.4.8__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.8 → pocketshell-0.4.9}/PKG-INFO +1 -1
  2. {pocketshell-0.4.8 → pocketshell-0.4.9}/pyproject.toml +1 -1
  3. {pocketshell-0.4.8 → 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.8 → pocketshell-0.4.9}/src/pocketshell/cli.py +2 -0
  6. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_agents.py +116 -0
  7. pocketshell-0.4.9/tests/test_agents_kind.py +332 -0
  8. {pocketshell-0.4.8 → pocketshell-0.4.9}/.gitignore +0 -0
  9. {pocketshell-0.4.8 → pocketshell-0.4.9}/README.md +0 -0
  10. {pocketshell-0.4.8 → pocketshell-0.4.9}/scheduler/README.md +0 -0
  11. {pocketshell-0.4.8 → pocketshell-0.4.9}/scheduler/pocketshell-usage-capture.service +0 -0
  12. {pocketshell-0.4.8 → pocketshell-0.4.9}/scheduler/pocketshell-usage-capture.timer +0 -0
  13. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/__init__.py +0 -0
  14. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/__main__.py +0 -0
  15. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/agent_log.py +0 -0
  16. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/cgroup_agents.py +0 -0
  17. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/daemon.py +0 -0
  18. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/env.py +0 -0
  19. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/github.py +0 -0
  20. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/hooks.py +0 -0
  21. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/jobs.py +0 -0
  22. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/logs.py +0 -0
  23. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/profiles.py +0 -0
  24. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/prune_attachments.py +0 -0
  25. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/push.py +0 -0
  26. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/qr_share.py +0 -0
  27. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/repos.py +0 -0
  28. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/resume.py +0 -0
  29. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/sessions.py +0 -0
  30. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/usage.py +0 -0
  31. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/usage_capture.py +0 -0
  32. {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/usage_reset.py +0 -0
  33. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/__init__.py +0 -0
  34. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_agent_log.py +0 -0
  35. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_cgroup_agents.py +0 -0
  36. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_cli.py +0 -0
  37. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_daemon.py +0 -0
  38. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_env.py +0 -0
  39. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_github.py +0 -0
  40. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_hooks.py +0 -0
  41. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_jobs.py +0 -0
  42. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_logs.py +0 -0
  43. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_profiles.py +0 -0
  44. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_prune_attachments.py +0 -0
  45. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_push.py +0 -0
  46. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_qr_share.py +0 -0
  47. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_repos.py +0 -0
  48. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_resume.py +0 -0
  49. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_sessions.py +0 -0
  50. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_usage.py +0 -0
  51. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_usage_capture.py +0 -0
  52. {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_usage_reset.py +0 -0
  53. {pocketshell-0.4.8 → 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.8
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.8"
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))
@@ -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")
@@ -453,6 +453,122 @@ def test_launch_agent_missing_dir_exits_two():
453
453
  assert exc.value.exit_code == 2
454
454
 
455
455
 
456
+ # ---------------------------------------------------------------------------
457
+ # Record agent kind as the host-side @ps_agent_kind tmux user option
458
+ # (epic #821 Workstream A). The wrapper writes the launched kind onto the
459
+ # tmux session it runs in, so the client reads the type back from the host
460
+ # without output-parsing detection.
461
+ # ---------------------------------------------------------------------------
462
+
463
+
464
+ @pytest.mark.parametrize("kind", ["claude", "codex", "opencode"])
465
+ def test_record_agent_kind_sets_session_option_inside_tmux(kind):
466
+ calls = []
467
+ ok = agents.record_agent_kind(
468
+ kind,
469
+ env={"TMUX": "/tmp/tmux-1000/default,1234,0"},
470
+ runner=lambda argv, **kw: calls.append((argv, kw)),
471
+ )
472
+ assert ok is True
473
+ assert len(calls) == 1
474
+ argv, _kw = calls[0]
475
+ # Session-scoped (no -g): tmux applies it to the current session, which
476
+ # is the one the agent was launched into.
477
+ assert argv == ["tmux", "set-option", "@ps_agent_kind", kind]
478
+
479
+
480
+ def test_record_agent_kind_noop_when_not_in_tmux():
481
+ calls = []
482
+ ok = agents.record_agent_kind(
483
+ "claude",
484
+ env={}, # $TMUX unset -> bare SSH invocation
485
+ runner=lambda argv, **kw: calls.append(argv),
486
+ )
487
+ assert ok is False
488
+ assert calls == []
489
+
490
+
491
+ def test_record_agent_kind_noop_for_blank_kind():
492
+ calls = []
493
+ ok = agents.record_agent_kind(
494
+ "",
495
+ env={"TMUX": "x"},
496
+ runner=lambda argv, **kw: calls.append(argv),
497
+ )
498
+ assert ok is False
499
+ assert calls == []
500
+
501
+
502
+ def test_record_agent_kind_swallows_runner_failure():
503
+ def boom(argv, **kw):
504
+ raise RuntimeError("tmux not found")
505
+
506
+ # A failure to record must never propagate (the agent must still launch).
507
+ ok = agents.record_agent_kind(
508
+ "codex",
509
+ env={"TMUX": "x"},
510
+ runner=boom,
511
+ )
512
+ assert ok is False
513
+
514
+
515
+ @pytest.mark.parametrize("kind", ["claude", "codex", "opencode"])
516
+ def test_launch_agent_records_kind_before_exec(tmp_path, monkeypatch, kind):
517
+ monkeypatch.setenv("HOME", str(tmp_path))
518
+ monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,1234,0")
519
+ order = []
520
+
521
+ def fake_record(k, env=None, runner=None):
522
+ order.append(("record", k, env.get("TMUX") if env else None))
523
+ return True
524
+
525
+ def fake_execvpe(file, argv, env):
526
+ order.append(("exec", file))
527
+
528
+ agents.launch_agent(
529
+ _FakeCtx(),
530
+ kind,
531
+ str(tmp_path),
532
+ skip_permissions=True,
533
+ config_dir=None,
534
+ execvpe=fake_execvpe,
535
+ record_kind=fake_record,
536
+ )
537
+ # The kind is recorded with the wrapper's own $TMUX, and recorded BEFORE
538
+ # the exec replaces the process.
539
+ assert order == [
540
+ ("record", kind, "/tmp/tmux-1000/default,1234,0"),
541
+ ("exec", kind),
542
+ ]
543
+
544
+
545
+ def test_launch_agent_execs_even_when_record_is_noop(tmp_path, monkeypatch):
546
+ # When recording is a no-op (e.g. not inside tmux -> record returns
547
+ # False), the agent must still exec normally. Production
548
+ # record_agent_kind swallows its own errors and returns False rather
549
+ # than raising (covered by test_record_agent_kind_swallows_runner_failure),
550
+ # so the launch is never blocked by a recording problem.
551
+ monkeypatch.setenv("HOME", str(tmp_path))
552
+ captured = {}
553
+
554
+ def quiet_record(k, env=None, runner=None):
555
+ return False
556
+
557
+ def fake_execvpe(file, argv, env):
558
+ captured["file"] = file
559
+
560
+ agents.launch_agent(
561
+ _FakeCtx(),
562
+ "codex",
563
+ str(tmp_path),
564
+ skip_permissions=True,
565
+ config_dir=None,
566
+ execvpe=fake_execvpe,
567
+ record_kind=quiet_record,
568
+ )
569
+ assert captured["file"] == "codex"
570
+
571
+
456
572
  # ---------------------------------------------------------------------------
457
573
  # Missing agent binary -> friendly 127 (issue #774 §3)
458
574
  #
@@ -0,0 +1,332 @@
1
+ """Tests for the ``pocketshell agents kind`` CLI subcommand (epic #821 A2-infra).
2
+
3
+ The subcommand is the missing CLI seam over the daemon RPC
4
+ ``agents.kind_for_panes`` (``cgroup_agents.kind_for_panes``): the PocketShell
5
+ Android client only ever execs ``pocketshell <subcommand>`` over its warm SSH
6
+ session — it never speaks JSON-RPC — so the cgroup/scope agent-kind detection
7
+ needs a subcommand to reach it. ``pocketshell agents kind`` accepts a pane list
8
+ (matching the RPC's ``{pane_id, pane_pid}`` shape), classifies each pane, and
9
+ emits the RPC's ``{"results": [...]}`` envelope as stable JSON on stdout.
10
+
11
+ These tests point the classifier at a synthetic ``/proc`` + cgroup-v2 tree
12
+ (reusing the same fake-host shape as ``test_cgroup_agents``) via the injectable
13
+ ``--proc-root`` / ``--cgroup-mount`` options, so they exercise the real
14
+ classifier with zero live sessions, covering the claude / codex / opencode /
15
+ none / unknown / empty cases.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import signal
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ from pathlib import Path
27
+ from typing import Iterator, Optional
28
+
29
+ import pytest
30
+ from click.testing import CliRunner
31
+
32
+ from pocketshell import daemon as daemon_mod
33
+ from pocketshell.cli import cli
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Synthetic-filesystem fixture builder (mirrors test_cgroup_agents._FakeHost)
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ class _FakeHost:
42
+ """Builds a synthetic ``/proc`` + cgroup-v2 tree under ``tmp_path``."""
43
+
44
+ ROBUST_PREFIX = "/user.slice/user-1000.slice/user@1000.service/robust.slice"
45
+
46
+ def __init__(self, root: Path) -> None:
47
+ self.proc_root = root / "proc"
48
+ self.cgroup_mount = root / "cgroup"
49
+ self.proc_root.mkdir(parents=True, exist_ok=True)
50
+ self.cgroup_mount.mkdir(parents=True, exist_ok=True)
51
+
52
+ def _scope_relpath(self, scope: str) -> str:
53
+ return f"{self.ROBUST_PREFIX}/{scope}"
54
+
55
+ def add_proc(
56
+ self,
57
+ pid: int,
58
+ *,
59
+ scope: Optional[str],
60
+ comm: str,
61
+ cmdline: str,
62
+ ) -> None:
63
+ proc_dir = self.proc_root / str(pid)
64
+ proc_dir.mkdir(parents=True, exist_ok=True)
65
+ if scope is None:
66
+ rel = "/user.slice/user-1000.slice/session-3.scope"
67
+ else:
68
+ rel = self._scope_relpath(scope)
69
+ (proc_dir / "cgroup").write_text(f"0::{rel}\n", encoding="utf-8")
70
+ (proc_dir / "comm").write_text(comm + "\n", encoding="utf-8")
71
+ (proc_dir / "cmdline").write_text(
72
+ cmdline.replace(" ", "\0") + "\0", encoding="utf-8"
73
+ )
74
+
75
+ def add_scope(self, scope: str, pids: list[int]) -> None:
76
+ scope_dir = self.cgroup_mount / self._scope_relpath(scope).lstrip("/")
77
+ scope_dir.mkdir(parents=True, exist_ok=True)
78
+ (scope_dir / "cgroup.procs").write_text(
79
+ "".join(f"{p}\n" for p in pids), encoding="utf-8"
80
+ )
81
+
82
+
83
+ def _seed_claude(host: _FakeHost) -> None:
84
+ host.add_proc(
85
+ 1001, scope="tmuxctl-claude-main.scope", comm="bash", cmdline="-bash"
86
+ )
87
+ host.add_proc(
88
+ 1002,
89
+ scope="tmuxctl-claude-main.scope",
90
+ comm="claude",
91
+ cmdline="claude",
92
+ )
93
+ host.add_scope("tmuxctl-claude-main.scope", [1001, 1002])
94
+
95
+
96
+ def _seed_codex(host: _FakeHost) -> None:
97
+ host.add_proc(
98
+ 2001, scope="tmuxctl-codex.scope", comm="bash", cmdline="-bash"
99
+ )
100
+ host.add_proc(
101
+ 2002,
102
+ scope="tmuxctl-codex.scope",
103
+ comm="MainThread",
104
+ cmdline="node /usr/lib/node_modules/codex/bin/codex.js",
105
+ )
106
+ host.add_scope("tmuxctl-codex.scope", [2001, 2002])
107
+
108
+
109
+ def _seed_opencode(host: _FakeHost) -> None:
110
+ host.add_proc(
111
+ 3001, scope="tmuxctl-opencode-lab.scope", comm="bash", cmdline="-bash"
112
+ )
113
+ host.add_proc(
114
+ 3002,
115
+ scope="tmuxctl-opencode-lab.scope",
116
+ comm="opencode",
117
+ cmdline="opencode",
118
+ )
119
+ host.add_scope("tmuxctl-opencode-lab.scope", [3001, 3002])
120
+
121
+
122
+ def _seed_plain_shell(host: _FakeHost) -> None:
123
+ """A pane that resolves to a scope but runs no agent -> ``none``."""
124
+ host.add_proc(
125
+ 4001, scope="tmuxctl-shell.scope", comm="bash", cmdline="-bash"
126
+ )
127
+ host.add_scope("tmuxctl-shell.scope", [4001])
128
+
129
+
130
+ def _invoke(panes: list[dict], host: _FakeHost) -> dict:
131
+ runner = CliRunner()
132
+ result = runner.invoke(
133
+ cli,
134
+ [
135
+ "agents",
136
+ "kind",
137
+ "--proc-root",
138
+ str(host.proc_root),
139
+ "--cgroup-mount",
140
+ str(host.cgroup_mount),
141
+ ],
142
+ input=json.dumps({"panes": panes}),
143
+ )
144
+ assert result.exit_code == 0, result.output
145
+ return json.loads(result.output)
146
+
147
+
148
+ def test_help_lists_the_kind_subcommand() -> None:
149
+ runner = CliRunner()
150
+ result = runner.invoke(cli, ["agents", "kind", "--help"])
151
+ assert result.exit_code == 0, result.output
152
+ assert "kind" in result.output.lower()
153
+
154
+
155
+ def test_classifies_claude(tmp_path: Path) -> None:
156
+ host = _FakeHost(tmp_path)
157
+ _seed_claude(host)
158
+ out = _invoke([{"pane_id": "%1", "pane_pid": 1001}], host)
159
+ results = out["results"]
160
+ assert len(results) == 1
161
+ assert results[0]["pane_id"] == "%1"
162
+ assert results[0]["agent_kind"] == "claude"
163
+ assert results[0]["scope"] == "tmuxctl-claude-main.scope"
164
+ assert results[0]["evidence_pid"] == 1002
165
+
166
+
167
+ def test_classifies_codex_node_wrapped(tmp_path: Path) -> None:
168
+ host = _FakeHost(tmp_path)
169
+ _seed_codex(host)
170
+ out = _invoke([{"pane_id": "%2", "pane_pid": 2001}], host)
171
+ assert out["results"][0]["agent_kind"] == "codex"
172
+ assert out["results"][0]["scope"] == "tmuxctl-codex.scope"
173
+
174
+
175
+ def test_classifies_opencode(tmp_path: Path) -> None:
176
+ host = _FakeHost(tmp_path)
177
+ _seed_opencode(host)
178
+ out = _invoke([{"pane_id": "%3", "pane_pid": 3001}], host)
179
+ assert out["results"][0]["agent_kind"] == "opencode"
180
+
181
+
182
+ def test_plain_shell_resolves_to_none(tmp_path: Path) -> None:
183
+ host = _FakeHost(tmp_path)
184
+ _seed_plain_shell(host)
185
+ out = _invoke([{"pane_id": "%4", "pane_pid": 4001}], host)
186
+ assert out["results"][0]["agent_kind"] == "none"
187
+ assert out["results"][0]["scope"] == "tmuxctl-shell.scope"
188
+
189
+
190
+ def test_unreadable_pane_resolves_to_unknown(tmp_path: Path) -> None:
191
+ host = _FakeHost(tmp_path)
192
+ # pid 9999 has no /proc entry -> cgroup unreadable -> unknown.
193
+ out = _invoke([{"pane_id": "%9", "pane_pid": 9999}], host)
194
+ assert out["results"][0]["agent_kind"] == "unknown"
195
+
196
+
197
+ def test_missing_pane_pid_is_unknown_not_error(tmp_path: Path) -> None:
198
+ host = _FakeHost(tmp_path)
199
+ out = _invoke([{"pane_id": "%5"}], host)
200
+ assert out["results"][0]["agent_kind"] == "unknown"
201
+ assert out["results"][0]["pane_id"] == "%5"
202
+
203
+
204
+ def test_empty_pane_list_returns_empty_results(tmp_path: Path) -> None:
205
+ host = _FakeHost(tmp_path)
206
+ out = _invoke([], host)
207
+ assert out == {"results": []}
208
+
209
+
210
+ def test_no_stdin_no_panes_returns_empty_results() -> None:
211
+ runner = CliRunner()
212
+ result = runner.invoke(cli, ["agents", "kind"], input="")
213
+ assert result.exit_code == 0, result.output
214
+ assert json.loads(result.output) == {"results": []}
215
+
216
+
217
+ def test_mixed_batch_one_bad_pane_does_not_sink_others(tmp_path: Path) -> None:
218
+ host = _FakeHost(tmp_path)
219
+ _seed_claude(host)
220
+ _seed_codex(host)
221
+ out = _invoke(
222
+ [
223
+ {"pane_id": "%1", "pane_pid": 1001},
224
+ {"pane_id": "%bad", "pane_pid": "not-an-int"},
225
+ {"pane_id": "%2", "pane_pid": 2001},
226
+ ],
227
+ host,
228
+ )
229
+ kinds = {r["pane_id"]: r["agent_kind"] for r in out["results"]}
230
+ assert kinds["%1"] == "claude"
231
+ assert kinds["%bad"] == "unknown"
232
+ assert kinds["%2"] == "codex"
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # CLI -> daemon round-trip (proves the daemon RPC path, not just in-process)
237
+ # ---------------------------------------------------------------------------
238
+
239
+
240
+ def _spawn_daemon(socket_path: Path) -> subprocess.Popen:
241
+ env = dict(os.environ)
242
+ env["POCKETSHELL_DAEMON_SOCKET"] = str(socket_path)
243
+ env["POCKETSHELL_DAEMON_IDLE_SECS"] = "120"
244
+ proc = subprocess.Popen(
245
+ [sys.executable, "-m", "pocketshell", "daemon", "_serve"],
246
+ stdin=subprocess.DEVNULL,
247
+ stdout=subprocess.PIPE,
248
+ stderr=subprocess.PIPE,
249
+ env=env,
250
+ start_new_session=True,
251
+ )
252
+ assert daemon_mod.wait_until_ready(socket_path=socket_path, deadline=10.0), (
253
+ proc.stderr.read().decode(errors="replace") if proc.stderr else ""
254
+ )
255
+ return proc
256
+
257
+
258
+ @pytest.fixture()
259
+ def running_daemon(
260
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
261
+ ) -> Iterator[Path]:
262
+ socket_path = tmp_path / "ps-agents-kind.sock"
263
+ monkeypatch.setenv("POCKETSHELL_DAEMON_SOCKET", str(socket_path))
264
+ proc = _spawn_daemon(socket_path)
265
+ try:
266
+ yield socket_path
267
+ finally:
268
+ if proc.poll() is None:
269
+ proc.send_signal(signal.SIGTERM)
270
+ try:
271
+ proc.wait(timeout=5.0)
272
+ except subprocess.TimeoutExpired:
273
+ proc.kill()
274
+ proc.wait(timeout=2.0)
275
+ for path in (socket_path, socket_path.with_suffix(".pid")):
276
+ try:
277
+ path.unlink()
278
+ except FileNotFoundError:
279
+ pass
280
+
281
+
282
+ def test_cli_reaches_daemon_rpc(
283
+ running_daemon: Path, monkeypatch: pytest.MonkeyPatch
284
+ ) -> None:
285
+ """When a daemon is up on the default roots, the CLI dispatches to the
286
+ ``agents.kind_for_panes`` RPC instead of computing in-process.
287
+
288
+ Proven by sabotaging the in-process classifier *in the CLI process* so a
289
+ well-formed envelope can only have come from the daemon subprocess (a
290
+ separate process whose classifier is intact).
291
+ """
292
+ def _boom(*_a, **_k):
293
+ raise AssertionError("in-process classifier must not run when daemon up")
294
+
295
+ monkeypatch.setattr(
296
+ "pocketshell.cgroup_agents.kind_for_panes", _boom
297
+ )
298
+ # The daemon classifies against the live host; pid 1 always exists, so the
299
+ # envelope is well-formed regardless of what scope it resolves to.
300
+ runner = CliRunner()
301
+ result = runner.invoke(
302
+ cli, ["agents", "kind", "--pane", "%live=1"]
303
+ )
304
+ assert result.exit_code == 0, result.output
305
+ out = json.loads(result.output)
306
+ assert out["results"][0]["pane_id"] == "%live"
307
+ assert "agent_kind" in out["results"][0]
308
+
309
+
310
+ def test_pane_option_form(tmp_path: Path) -> None:
311
+ """The ``--pane PANE_ID=PANE_PID`` repeatable form is an ergonomic
312
+ alternative to stdin JSON for a one-shot SSH exec."""
313
+ host = _FakeHost(tmp_path)
314
+ _seed_claude(host)
315
+ runner = CliRunner()
316
+ result = runner.invoke(
317
+ cli,
318
+ [
319
+ "agents",
320
+ "kind",
321
+ "--proc-root",
322
+ str(host.proc_root),
323
+ "--cgroup-mount",
324
+ str(host.cgroup_mount),
325
+ "--pane",
326
+ "%1=1001",
327
+ ],
328
+ )
329
+ assert result.exit_code == 0, result.output
330
+ out = json.loads(result.output)
331
+ assert out["results"][0]["pane_id"] == "%1"
332
+ assert out["results"][0]["agent_kind"] == "claude"
File without changes
File without changes
File without changes