pocketshell 0.4.8__tar.gz → 0.4.10__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.10}/PKG-INFO +1 -1
- {pocketshell-0.4.8 → pocketshell-0.4.10}/pyproject.toml +1 -1
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/agents.py +66 -0
- pocketshell-0.4.10/src/pocketshell/agents_kind.py +216 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/cli.py +4 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/daemon.py +42 -0
- pocketshell-0.4.10/src/pocketshell/tree.py +620 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/usage.py +28 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_agents.py +116 -0
- pocketshell-0.4.10/tests/test_agents_kind.py +332 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_daemon.py +50 -0
- pocketshell-0.4.10/tests/test_tree.py +353 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_usage.py +89 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/.gitignore +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/README.md +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/scheduler/README.md +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/scheduler/pocketshell-usage-capture.service +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/scheduler/pocketshell-usage-capture.timer +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/agent_log.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/cgroup_agents.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/env.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/github.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/jobs.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/profiles.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/prune_attachments.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/push.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/resume.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/sessions.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/usage_capture.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/src/pocketshell/usage_reset.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/__init__.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_agent_log.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_cgroup_agents.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_cli.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_env.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_github.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_hooks.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_jobs.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_logs.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_profiles.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_prune_attachments.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_push.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_qr_share.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_repos.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_resume.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_sessions.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_usage_capture.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/tests/test_usage_reset.py +0 -0
- {pocketshell-0.4.8 → pocketshell-0.4.10}/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.10
|
|
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.10"
|
|
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
|
|
@@ -35,6 +36,7 @@ from pocketshell.push import push_group
|
|
|
35
36
|
from pocketshell.qr_share import qr_share_command
|
|
36
37
|
from pocketshell.repos import repos_group
|
|
37
38
|
from pocketshell.sessions import sessions_group
|
|
39
|
+
from pocketshell.tree import tree_group
|
|
38
40
|
from pocketshell.usage import usage_command
|
|
39
41
|
|
|
40
42
|
|
|
@@ -55,9 +57,11 @@ def cli() -> None:
|
|
|
55
57
|
|
|
56
58
|
cli.add_command(usage_command, name="usage")
|
|
57
59
|
cli.add_command(agent_group, name="agent")
|
|
60
|
+
cli.add_command(agents_group, name="agents")
|
|
58
61
|
cli.add_command(profiles_group, name="profiles")
|
|
59
62
|
cli.add_command(jobs_group, name="jobs")
|
|
60
63
|
cli.add_command(sessions_group, name="sessions")
|
|
64
|
+
cli.add_command(tree_group, name="tree")
|
|
61
65
|
cli.add_command(agent_log_command, name="agent-log")
|
|
62
66
|
cli.add_command(repos_group, name="repos")
|
|
63
67
|
cli.add_command(github_group, name="github")
|
|
@@ -92,6 +92,12 @@ METHOD_TTLS: Mapping[str, float] = {
|
|
|
92
92
|
# not hidden for long.
|
|
93
93
|
"sessions.list": 5.0,
|
|
94
94
|
"jobs.list": 5.0,
|
|
95
|
+
# `tree.get` is the cold-start hydrate read. Short TTL like `sessions.list`
|
|
96
|
+
# so a `tree.upsert` mutation (which also invalidates it explicitly) is not
|
|
97
|
+
# masked for long and an external edit is not hidden. `tree.upsert` and
|
|
98
|
+
# `tree.reconcile` carry NO TTL (mutations) so their results are never
|
|
99
|
+
# cached.
|
|
100
|
+
"tree.get": 5.0,
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
# Length-prefix is a 4-byte unsigned big-endian integer. ``struct``
|
|
@@ -574,6 +580,32 @@ def _agents_kind_for_panes_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
574
580
|
return {"results": _cgroup_agents.kind_for_panes(panes)}
|
|
575
581
|
|
|
576
582
|
|
|
583
|
+
# ---------------------------------------------------------------------------
|
|
584
|
+
# Methods: tree.* (epic #821 slice C / issue #837)
|
|
585
|
+
# ---------------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _tree_get_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
589
|
+
"""Delegate ``tree.get`` to the durable per-host tree registry."""
|
|
590
|
+
from pocketshell import tree as _tree
|
|
591
|
+
|
|
592
|
+
return _tree.daemon_handler_get(dict(params))
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _tree_upsert_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
596
|
+
"""Delegate ``tree.upsert`` to the durable per-host tree registry."""
|
|
597
|
+
from pocketshell import tree as _tree
|
|
598
|
+
|
|
599
|
+
return _tree.daemon_handler_upsert(dict(params))
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _tree_reconcile_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
603
|
+
"""Delegate ``tree.reconcile`` to the durable per-host tree registry."""
|
|
604
|
+
from pocketshell import tree as _tree
|
|
605
|
+
|
|
606
|
+
return _tree.daemon_handler_reconcile(dict(params))
|
|
607
|
+
|
|
608
|
+
|
|
577
609
|
# Single shared registry; tests can register additional methods via
|
|
578
610
|
# :meth:`Daemon.register_method` on a fresh instance without touching
|
|
579
611
|
# this dict.
|
|
@@ -592,6 +624,9 @@ DEFAULT_METHODS: Mapping[str, RpcHandler] = {
|
|
|
592
624
|
"jobs.remove": _jobs_remove_handler,
|
|
593
625
|
"jobs.status": _jobs_status_handler,
|
|
594
626
|
"agents.kind_for_panes": _agents_kind_for_panes_handler,
|
|
627
|
+
"tree.get": _tree_get_handler,
|
|
628
|
+
"tree.upsert": _tree_upsert_handler,
|
|
629
|
+
"tree.reconcile": _tree_reconcile_handler,
|
|
595
630
|
}
|
|
596
631
|
|
|
597
632
|
|
|
@@ -612,6 +647,13 @@ METHOD_CACHE_INVALIDATIONS: Mapping[str, tuple[str, ...]] = {
|
|
|
612
647
|
"jobs.edit": ("jobs.list",),
|
|
613
648
|
"jobs.remove": ("jobs.list",),
|
|
614
649
|
"jobs.trigger": ("jobs.list",),
|
|
650
|
+
# `tree.upsert` rewrites the host's persisted node list, so the cached
|
|
651
|
+
# `tree.get` cold-start read is stale the moment it lands — drop it so the
|
|
652
|
+
# very next `tree.get` reflects the just-persisted ordering/expansion.
|
|
653
|
+
# `tree.reconcile` prunes gone nodes from the registry, so it must also
|
|
654
|
+
# invalidate the `tree.get` cache.
|
|
655
|
+
"tree.upsert": ("tree.get",),
|
|
656
|
+
"tree.reconcile": ("tree.get",),
|
|
615
657
|
}
|
|
616
658
|
|
|
617
659
|
|