agents-tree 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """Show coding-agent sessions and their subagents as a live tree."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from agents_tree.cli import main
4
+
5
+ sys.exit(main())
agents_tree/cli.py ADDED
@@ -0,0 +1,105 @@
1
+ """Command line: one-shot print, or a top-like live view with --watch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import signal
8
+ import sys
9
+ from collections.abc import Callable
10
+
11
+ from agents_tree import __version__, live
12
+ from agents_tree.model import Session, override_window, sanitize
13
+ from agents_tree.providers import PROVIDERS
14
+ from agents_tree.render import render, style
15
+
16
+ DESCRIPTION = "Show coding-agent sessions and their subagents as a tree: " \
17
+ "model (effort), context use, elapsed time and status."
18
+ EPILOG = """\
19
+ targets:
20
+ (none) every running session on this machine
21
+ DIR sessions running in DIR ("." works); if none, DIR's newest session
22
+ SESSION-ID one session (a unique prefix is enough)
23
+
24
+ live view keys: ↑/↓ or j/k select, enter or a click shows the agent's detail,
25
+ esc goes back, r toggles running-only, q quits
26
+
27
+ Reads local transcripts only. Run it in its own terminal (or the herdr plugin):
28
+ started with `!` inside an agent, its output lands in that agent's context.
29
+ """
30
+
31
+
32
+ def _positive_int(value: str) -> int:
33
+ try:
34
+ n = int(value)
35
+ except ValueError:
36
+ raise argparse.ArgumentTypeError(f"not a whole number of tokens: {value!r}") from None
37
+ if n <= 0:
38
+ raise argparse.ArgumentTypeError("must be positive")
39
+ return n
40
+
41
+
42
+ def _env_window() -> int | None:
43
+ value = os.environ.get("AGENTS_TREE_WINDOW", "").strip()
44
+ if not value:
45
+ return None
46
+ try:
47
+ return _positive_int(value)
48
+ except argparse.ArgumentTypeError:
49
+ print(f"agents-tree: ignoring AGENTS_TREE_WINDOW={value!r}", file=sys.stderr)
50
+ return None
51
+
52
+
53
+ def parse_args(argv: list[str] | None) -> argparse.Namespace:
54
+ ap = argparse.ArgumentParser(prog="agents-tree", description=DESCRIPTION, epilog=EPILOG,
55
+ formatter_class=argparse.RawDescriptionHelpFormatter)
56
+ ap.add_argument("target", nargs="?", metavar="DIR|SESSION-ID")
57
+ ap.add_argument("-w", "--watch", action="store_true", help="live view, like top")
58
+ ap.add_argument("-n", "--interval", type=float, default=2.0, metavar="SECONDS",
59
+ help="live view refresh interval (default 2)")
60
+ ap.add_argument("-r", "--running", action="store_true",
61
+ help="only running subagents (and their parents)")
62
+ ap.add_argument("--window", type=_positive_int, metavar="TOKENS", default=_env_window(),
63
+ help="context window for the %% column (default: per model)")
64
+ ap.add_argument("--provider", choices=sorted(PROVIDERS), default="claude")
65
+ ap.add_argument("--color", choices=["auto", "always", "never"], default="auto")
66
+ ap.add_argument("--no-mouse", dest="mouse", action="store_false",
67
+ help="live view: leave the mouse to the terminal (text selection)")
68
+ ap.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
69
+ args = ap.parse_args(argv)
70
+ if args.interval <= 0:
71
+ ap.error("--interval must be positive")
72
+ return args
73
+
74
+
75
+ def loader(args: argparse.Namespace) -> Callable[[], list[Session]]:
76
+ """Sessions as the views get them: sanitised once, with --window applied."""
77
+ def load() -> list[Session]:
78
+ sessions = PROVIDERS[args.provider].sessions(args.target)
79
+ sanitize(sessions)
80
+ override_window(sessions, args.window)
81
+ return sessions
82
+
83
+ return load
84
+
85
+
86
+ def main(argv: list[str] | None = None) -> int:
87
+ signal.signal(signal.SIGPIPE, signal.SIG_DFL)
88
+ args = parse_args(argv)
89
+ color = args.color == "always" or (args.color == "auto" and sys.stdout.isatty()
90
+ and not os.environ.get("NO_COLOR"))
91
+ load = loader(args)
92
+ if not args.watch:
93
+ try:
94
+ sessions = load()
95
+ except LookupError as e:
96
+ print(style(color, "90", str(e)))
97
+ return 1
98
+ print(render(sessions, running_only=args.running, color=color))
99
+ return 0
100
+ try:
101
+ live.run(load, interval=args.interval, color=color, running_only=args.running,
102
+ mouse=args.mouse)
103
+ except KeyboardInterrupt:
104
+ pass
105
+ return 0
agents_tree/herdr.py ADDED
@@ -0,0 +1,326 @@
1
+ """herdr plugin glue: resolve the focused agent pane's session and open the live tree.
2
+
3
+ `python3 -m agents_tree.herdr open <scope> [placement]` (plugin action)
4
+ Opens the tree pane over the focused pane, or closes it when it is the
5
+ focused pane already, so one key toggles it.
6
+ `python3 -m agents_tree.herdr pane` (plugin pane command)
7
+ Runs the live tree for the target the action resolved.
8
+ `python3 -m agents_tree.herdr keys setup|remove` (plugin actions)
9
+ Adds or removes a marked block of key bindings in herdr's config.toml.
10
+
11
+ scope "focused": the focused pane's session (by the session id herdr's agent
12
+ integration reports, else the pane's directory) when agents-tree reads that
13
+ agent; every running session otherwise. scope "all": every running session.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import shutil
22
+ import stat
23
+ import subprocess
24
+ import sys
25
+ import traceback
26
+ from collections.abc import Callable
27
+ from pathlib import Path
28
+
29
+ from agents_tree import cli
30
+ from agents_tree.providers import PROVIDERS
31
+
32
+ TARGET_ENV = "AGENTS_TREE_TARGET"
33
+ PROVIDER_ENV = "AGENTS_TREE_PROVIDER"
34
+ PLUGIN_ID = "agents-tree"
35
+ DEFAULT_PROVIDER = "claude"
36
+ PLACEMENTS = ("overlay", "split", "tab", "zoomed")
37
+
38
+
39
+ def _herdr() -> str:
40
+ return os.environ.get("HERDR_BIN_PATH") or "herdr"
41
+
42
+
43
+ def _plugin_id() -> str:
44
+ return os.environ.get("HERDR_PLUGIN_ID") or PLUGIN_ID
45
+
46
+
47
+ def _run(*args: str) -> subprocess.CompletedProcess | None:
48
+ try:
49
+ return subprocess.run([_herdr(), *args], capture_output=True, text=True, timeout=10,
50
+ check=False)
51
+ except (OSError, subprocess.SubprocessError):
52
+ return None
53
+
54
+
55
+ def _json_dict(text: str | None) -> dict:
56
+ try:
57
+ data = json.loads(text or "{}")
58
+ except json.JSONDecodeError:
59
+ return {}
60
+ return data if isinstance(data, dict) else {}
61
+
62
+
63
+ def focused_pane_id() -> str | None:
64
+ """The pane focused when the plugin was invoked (HERDR_PANE_ID is our own pane)."""
65
+ pane = _json_dict(os.environ.get("HERDR_PLUGIN_CONTEXT_JSON")).get("focused_pane_id")
66
+ return pane if isinstance(pane, str) and pane else None
67
+
68
+
69
+ def pane_info(pane_id: str) -> dict:
70
+ result = _run("pane", "get", pane_id)
71
+ info = _json_dict(result.stdout if result else None).get("result") or {}
72
+ pane = info.get("pane", info) if isinstance(info, dict) else {}
73
+ return pane if isinstance(pane, dict) else {}
74
+
75
+
76
+ def target_for(pane: dict) -> tuple[str, str]:
77
+ """(provider, target) for a pane: its session id or directory when agents-tree
78
+ reads its agent; every running session of the default provider otherwise."""
79
+ session = pane.get("agent_session")
80
+ if not isinstance(session, dict):
81
+ session = {}
82
+ agent = session.get("agent") or pane.get("agent")
83
+ if agent not in PROVIDERS:
84
+ return DEFAULT_PROVIDER, ""
85
+ if session.get("kind") == "id" and session.get("value"):
86
+ return str(agent), str(session["value"])
87
+ return str(agent), str(pane.get("foreground_cwd") or pane.get("cwd") or "")
88
+
89
+
90
+ # --- our own panes ------------------------------------------------------------------
91
+ # A tree pane records itself (its pane id and pid) while it runs, so the toggle key
92
+ # recognises it by identity rather than by a title another pane could share.
93
+
94
+ def _marks_dir() -> Path:
95
+ """A directory only this user can write: herdr's state dir for the plugin, else
96
+ the user's runtime or cache directory, never a shared one like /tmp."""
97
+ base = (os.environ.get("HERDR_PLUGIN_STATE_DIR") or os.environ.get("XDG_RUNTIME_DIR")
98
+ or Path.home() / ".cache")
99
+ return Path(base) / "agents-tree-panes"
100
+
101
+
102
+ def _private_marks_dir() -> Path:
103
+ """The marks directory, created 0700; refuses one that is a symlink or not ours."""
104
+ d = _marks_dir()
105
+ d.mkdir(mode=0o700, parents=True, exist_ok=True)
106
+ st = os.lstat(d)
107
+ if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid():
108
+ raise PermissionError(f"{d} is not a private directory of this user")
109
+ return d
110
+
111
+
112
+ def _mark_path(pane_id: str) -> Path:
113
+ return _marks_dir() / re.sub(r"[^A-Za-z0-9_.-]", "_", pane_id)
114
+
115
+
116
+ def is_our_pane(pane_id: str) -> bool:
117
+ try:
118
+ pid = int(_mark_path(pane_id).read_text(encoding="utf-8"))
119
+ os.kill(pid, 0)
120
+ except (OSError, ValueError):
121
+ return False
122
+ return True
123
+
124
+
125
+ def open_pane(scope: str, placement: str) -> int:
126
+ focused = focused_pane_id()
127
+ if focused and is_our_pane(focused):
128
+ result = _run("plugin", "pane", "close", focused)
129
+ else:
130
+ provider, target = (target_for(pane_info(focused)) if focused and scope == "focused"
131
+ else (DEFAULT_PROVIDER, ""))
132
+ result = _run("plugin", "pane", "open", "--plugin", _plugin_id(), "--entrypoint", "tree",
133
+ "--placement", placement, "--env", f"{PROVIDER_ENV}={provider}",
134
+ "--env", f"{TARGET_ENV}={target}")
135
+ if result is None:
136
+ print("agents-tree: could not run herdr", file=sys.stderr)
137
+ return 1
138
+ sys.stdout.write(result.stdout)
139
+ sys.stderr.write(result.stderr)
140
+ return result.returncode
141
+
142
+
143
+ def run_pane() -> int:
144
+ """The pane's terminal is the only place an error is seen: hold it until enter."""
145
+ mark = _mark_path(os.environ["HERDR_PANE_ID"]) if os.environ.get("HERDR_PANE_ID") else None
146
+ if mark:
147
+ try:
148
+ _private_marks_dir()
149
+ fd = os.open(mark, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
150
+ with os.fdopen(fd, "w") as f:
151
+ f.write(str(os.getpid()))
152
+ except OSError:
153
+ mark = None # the tree still runs; the toggle key just opens a second one
154
+ target = os.environ.get(TARGET_ENV, "")
155
+ args = ["-w", "--provider", os.environ.get(PROVIDER_ENV) or DEFAULT_PROVIDER]
156
+ try:
157
+ return cli.main([*args, *(["--", target] if target else [])])
158
+ except KeyboardInterrupt:
159
+ return 0
160
+ except SystemExit as e: # argparse rejecting the target
161
+ if not e.code:
162
+ return 0
163
+ _hold_error()
164
+ return 1
165
+ except Exception: # pylint: disable=broad-exception-caught # shown, not lost
166
+ _hold_error()
167
+ return 1
168
+ finally:
169
+ if mark:
170
+ mark.unlink(missing_ok=True)
171
+
172
+
173
+ def _hold_error() -> None:
174
+ traceback.print_exc()
175
+ try:
176
+ input("\n press enter to close ")
177
+ except (EOFError, KeyboardInterrupt):
178
+ pass
179
+
180
+
181
+ # --- key bindings -----------------------------------------------------------------
182
+
183
+ KEYS = [("prefix+a", "open", "agents-tree: live tree of the focused agent"),
184
+ ("prefix+shift+a", "open-all", "agents-tree: live tree of every running session")]
185
+ ALTERNATES = [("open-split", "agents-tree: focused agent, in a split"),
186
+ ("open-tab", "agents-tree: focused agent, in a tab")]
187
+ BEGIN = "# >>> agents-tree keys (managed by the agents-tree plugin: setup-keys / remove-keys)"
188
+ END = "# <<< agents-tree keys"
189
+
190
+
191
+ def config_path() -> Path:
192
+ if "HERDR_CONFIG_PATH" in os.environ:
193
+ return Path(os.environ["HERDR_CONFIG_PATH"])
194
+ base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
195
+ return Path(base) / "herdr" / "config.toml"
196
+
197
+
198
+ def strip_block(text: str) -> str:
199
+ """The config without our block. Raises ValueError when a marker lacks its pair,
200
+ rather than guess where the block ends and take the user's settings with it."""
201
+ lines = text.splitlines()
202
+ begins = [n for n, line in enumerate(lines) if line == BEGIN]
203
+ ends = [n for n, line in enumerate(lines) if line == END]
204
+ if not begins and not ends:
205
+ return text.rstrip("\n")
206
+ if len(begins) != 1 or len(ends) != 1 or ends[0] < begins[0]:
207
+ raise ValueError(f"the agents-tree block markers ({BEGIN!r} ... {END!r}) are "
208
+ "incomplete; fix or remove them by hand")
209
+ return "\n".join(lines[:begins[0]] + lines[ends[0] + 1:]).strip("\n")
210
+
211
+
212
+ def key_block(plugin: str, keys: list[tuple[str, str, str]]) -> str:
213
+ lines = [BEGIN]
214
+ for key, action, desc in keys:
215
+ lines += ["[[keys.command]]", f'key = "{key}"', 'type = "plugin_action"',
216
+ f'command = "{plugin}.{action}"', f'description = "{desc}"', ""]
217
+ lines.append("# Same tree in a split or a tab instead of over the pane: point a key here.")
218
+ for action, desc in ALTERNATES:
219
+ lines += ["# [[keys.command]]", '# key = "prefix+..."', '# type = "plugin_action"',
220
+ f'# command = "{plugin}.{action}"', f'# description = "{desc}"', ""]
221
+ lines.append(END)
222
+ return "\n".join(lines)
223
+
224
+
225
+ def _config_ok() -> bool:
226
+ result = _run("config", "check")
227
+ return result is not None and result.returncode == 0
228
+
229
+
230
+ def _notify(body: str) -> None:
231
+ print(body)
232
+ _run("notification", "show", "agents-tree", "--body", body, "--sound", "none")
233
+
234
+
235
+ def _write_atomic(path: Path, text: str) -> None:
236
+ tmp = path.with_name(f".{path.name}.agents-tree-tmp")
237
+ tmp.write_text(text, encoding="utf-8")
238
+ if path.exists():
239
+ shutil.copymode(path, tmp)
240
+ os.replace(tmp, path)
241
+
242
+
243
+ def _is_bound(config: str, key: str) -> bool:
244
+ pattern = rf"""^\s*key\s*=\s*["']{re.escape(key)}["']"""
245
+ return re.search(pattern, config, re.M | re.I) is not None
246
+
247
+
248
+ def _edit_config(build: Callable[[str], str]) -> int:
249
+ """Rewrite the herdr config through build(config_without_our_block), which
250
+ returns the new text or raises ValueError with the reason to change nothing.
251
+ Backs up, checks the result with `herdr config check`, restores on failure."""
252
+ path = config_path()
253
+ path.parent.mkdir(parents=True, exist_ok=True)
254
+ original = path.read_text(encoding="utf-8") if path.exists() else ""
255
+ try:
256
+ if original and not _config_ok():
257
+ raise ValueError("it does not pass `herdr config check`")
258
+ new = build(strip_block(original))
259
+ except ValueError as e:
260
+ _notify(f"{path}: {e}; nothing changed.")
261
+ return 1
262
+ if path.exists():
263
+ shutil.copy2(path, path.with_name(path.name + ".agents-tree-backup"))
264
+ _write_atomic(path, new)
265
+ if not _config_ok():
266
+ _write_atomic(path, original)
267
+ _notify(f"{path}: the changed config failed `herdr config check`; restored it.")
268
+ return 1
269
+ _run("server", "reload-config")
270
+ return 0
271
+
272
+
273
+ def setup_keys() -> int:
274
+ taken: list[str] = []
275
+ free: list[tuple[str, str, str]] = []
276
+
277
+ def build(rest: str) -> str:
278
+ taken.extend(k for k, _, _ in KEYS if _is_bound(rest, k))
279
+ free.extend(k for k in KEYS if k[0] not in taken)
280
+ if not free:
281
+ raise ValueError(f"{', '.join(taken)} already bound")
282
+ return (rest + "\n\n" if rest else "") + key_block(_plugin_id(), free) + "\n"
283
+
284
+ if _edit_config(build):
285
+ return 1
286
+ msg = f"Bound {', '.join(k for k, _, _ in free)} in {config_path()}."
287
+ if taken:
288
+ msg += f" Left {', '.join(taken)} alone: already bound."
289
+ _notify(msg)
290
+ return 0
291
+
292
+
293
+ def remove_keys() -> int:
294
+ def build(rest: str) -> str:
295
+ return rest + "\n" if rest else ""
296
+
297
+ path = config_path()
298
+ original = path.read_text(encoding="utf-8") if path.exists() else ""
299
+ if BEGIN not in original and END not in original:
300
+ _notify(f"No agents-tree keys in {config_path()}.")
301
+ return 0
302
+ if _edit_config(build):
303
+ return 1
304
+ _notify(f"Removed the agents-tree keys from {config_path()}.")
305
+ return 0
306
+
307
+
308
+ def run(argv: list[str]) -> int:
309
+ match argv:
310
+ case ["open", ("focused" | "all") as scope]:
311
+ return open_pane(scope, "overlay")
312
+ case ["open", ("focused" | "all") as scope, placement] if placement in PLACEMENTS:
313
+ return open_pane(scope, placement)
314
+ case ["pane"]:
315
+ return run_pane()
316
+ case ["keys", "setup"]:
317
+ return setup_keys()
318
+ case ["keys", "remove"]:
319
+ return remove_keys()
320
+ print(f"usage: python3 -m agents_tree.herdr open focused|all [{'|'.join(PLACEMENTS)}]"
321
+ " | pane | keys setup|remove", file=sys.stderr)
322
+ return 2
323
+
324
+
325
+ if __name__ == "__main__":
326
+ sys.exit(run(sys.argv[1:]))