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.
- {pocketshell-0.4.8 → pocketshell-0.4.9}/PKG-INFO +1 -1
- {pocketshell-0.4.8 → pocketshell-0.4.9}/pyproject.toml +1 -1
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/agents.py +66 -0
- pocketshell-0.4.9/src/pocketshell/agents_kind.py +216 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/cli.py +2 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_agents.py +116 -0
- pocketshell-0.4.9/tests/test_agents_kind.py +332 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/.gitignore +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/README.md +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/scheduler/README.md +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/scheduler/pocketshell-usage-capture.service +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/scheduler/pocketshell-usage-capture.timer +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/agent_log.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/cgroup_agents.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/daemon.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/env.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/github.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/jobs.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/profiles.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/prune_attachments.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/push.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/resume.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/sessions.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/usage.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/usage_capture.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/src/pocketshell/usage_reset.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/__init__.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_agent_log.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_cgroup_agents.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_cli.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_daemon.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_env.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_github.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_hooks.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_jobs.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_logs.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_profiles.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_prune_attachments.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_push.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_qr_share.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_repos.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_resume.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_sessions.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_usage.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_usage_capture.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.9}/tests/test_usage_reset.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|