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