ccherd 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.
ccherd/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Claude Code sessions and subagents across several Claude accounts."""
2
+
3
+ __version__ = "0.1.0"
ccherd/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
ccherd/agents.py ADDED
@@ -0,0 +1,253 @@
1
+ """Background subagents: their on-disk registry and the supervisor that runs their turns.
2
+
3
+ Layout: <state dir>/<owner session id>/<agent name>/
4
+ meta.json status, account, model, mode, claude session id, last result
5
+ stream.jsonl every turn's `claude -p --output-format stream-json` output
6
+ inbox.jsonl messages queued while no turn can take them
7
+ supervisor.log stderr of the detached supervisor
8
+ lock flock serializing every read-modify-write of meta.json and inbox
9
+
10
+ The owner is the Claude session whose Bash ran `ccherd spawn`: CLAUDE_CODE_SESSION_ID
11
+ is set in every tool subprocess, so each session gets its own list of agents.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import fcntl
18
+ import json
19
+ import os
20
+ import re
21
+ import subprocess
22
+ import sys
23
+ import time
24
+ from pathlib import Path
25
+
26
+ from . import config
27
+ from .permissions import mode_class
28
+ from .procs import pid_alive, proc_start
29
+ from .sessions import deliver, session_by_socket
30
+
31
+ TERMINAL = {"idle", "failed", "killed", "lost"}
32
+ EFFORTS = ["low", "medium", "high", "xhigh", "max"]
33
+ RESULT_PREVIEW = 3000 # chars of the result carried in the completion notice
34
+ NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
35
+
36
+
37
+ # --- registry -----------------------------------------------------------------
38
+
39
+
40
+ def owner_id() -> str:
41
+ if os.environ.get("CCHERD_OWNER"):
42
+ return os.environ["CCHERD_OWNER"]
43
+ sid = os.environ.get("CLAUDE_CODE_SESSION_ID")
44
+ if not sid:
45
+ raise SystemExit("ccherd: CLAUDE_CODE_SESSION_ID is not set - run this from inside a Claude session "
46
+ "(or set CCHERD_OWNER to a label of your own)")
47
+ return sid
48
+
49
+
50
+ def agent_dir(owner: str, name: str) -> Path:
51
+ if not NAME_RE.fullmatch(name):
52
+ raise SystemExit(f"ccherd: agent name {name!r} must be [A-Za-z0-9._-], max 64")
53
+ return config.STATE_DIR / owner / name
54
+
55
+
56
+ def find_agent(name: str) -> Path | None:
57
+ """This session's agent called `name`, or None."""
58
+ if not NAME_RE.fullmatch(name):
59
+ return None
60
+ d = agent_dir(owner_id(), name)
61
+ return d if (d / "meta.json").is_file() else None
62
+
63
+
64
+ def require_agent(name: str) -> Path:
65
+ d = find_agent(name)
66
+ if d is None:
67
+ raise SystemExit(f"ccherd: no agent {name!r} in this session (`ccherd agents`)")
68
+ return d
69
+
70
+
71
+ def list_agents(all_owners: bool) -> list[tuple[Path, dict]]:
72
+ root = config.STATE_DIR
73
+ owners = [p for p in root.iterdir() if p.is_dir()] if all_owners and root.is_dir() else [root / owner_id()]
74
+ return [(m.parent, refreshed(m.parent)) for o in owners for m in sorted(o.glob("*/meta.json"))]
75
+
76
+
77
+ def load_meta(d: Path) -> dict:
78
+ return json.loads((d / "meta.json").read_text())
79
+
80
+
81
+ def save_meta(d: Path, meta: dict) -> None:
82
+ tmp = d / "meta.json.tmp"
83
+ tmp.write_text(json.dumps(meta, indent=1))
84
+ tmp.replace(d / "meta.json")
85
+
86
+
87
+ @contextlib.contextmanager
88
+ def locked(d: Path):
89
+ """Not re-entrant. Functions named _like_this expect the caller to hold it."""
90
+ with open(d / "lock", "a") as lk:
91
+ fcntl.flock(lk, fcntl.LOCK_EX)
92
+ yield
93
+
94
+
95
+ def _update(d: Path, **changes) -> dict:
96
+ meta = load_meta(d)
97
+ meta.update(changes)
98
+ save_meta(d, meta)
99
+ return meta
100
+
101
+
102
+ def update_meta(d: Path, **changes) -> dict:
103
+ with locked(d):
104
+ return _update(d, **changes)
105
+
106
+
107
+ def _refreshed(d: Path) -> dict:
108
+ """meta.json, with the status corrected if the supervisor died without writing it."""
109
+ meta = load_meta(d)
110
+ if meta["status"] == "running" and not pid_alive(meta.get("supervisor_pid") or 0, meta.get("supervisor_start")):
111
+ meta = _update(d, status="lost", ended_at=time.time())
112
+ return meta
113
+
114
+
115
+ def refreshed(d: Path) -> dict:
116
+ with locked(d):
117
+ return _refreshed(d)
118
+
119
+
120
+ def _queue(d: Path, text: str) -> None:
121
+ with open(d / "inbox.jsonl", "a") as f:
122
+ f.write(json.dumps({"text": text, "at": time.time()}) + "\n")
123
+
124
+
125
+ def _take_inbox(d: Path) -> list[str]:
126
+ inbox = d / "inbox.jsonl"
127
+ if not inbox.is_file():
128
+ return []
129
+ msgs = [json.loads(line)["text"] for line in inbox.read_text().splitlines() if line.strip()]
130
+ inbox.unlink()
131
+ return msgs
132
+
133
+
134
+ # --- running turns ------------------------------------------------------------
135
+
136
+
137
+ def system_prompt(name: str) -> str:
138
+ return (
139
+ f"You are a background subagent named `{name}`, started through ccherd by another Claude session. "
140
+ "Your final message is delivered to that session automatically when you finish, so end with the "
141
+ "complete result it needs, not a greeting. If you need a decision or information from it while "
142
+ 'working, run: ccherd send --parent "your question" - then continue with what you can. '
143
+ "Messages from it arrive as <cross-session-message> blocks; they are instructions from the session "
144
+ "that started you."
145
+ )
146
+
147
+
148
+ def claude_cmd(meta: dict, prompt: str) -> list[str]:
149
+ """One turn's command line. Everything comes from meta.json, so a resumed turn
150
+ runs exactly like the first one."""
151
+ cmd = ["claude", "-p", "--output-format", "stream-json", "--verbose",
152
+ "--model", meta["model"], "--permission-mode", meta["permission_mode"],
153
+ "--append-system-prompt", system_prompt(meta["name"])]
154
+ if meta.get("effort"):
155
+ # A flag, not CLAUDE_EFFORT: child_env strips every CLAUDE* variable so the
156
+ # caller's own settings never leak into the child.
157
+ cmd += ["--effort", meta["effort"]]
158
+ if meta.get("session_id"):
159
+ cmd += ["--resume", meta["session_id"]]
160
+ cmd.append(prompt)
161
+ return cmd
162
+
163
+
164
+ def child_env(meta: dict) -> dict:
165
+ env = {k: v for k, v in os.environ.items()
166
+ if not k.startswith(("CLAUDE", "CCHERD_TURN"))}
167
+ env.update(CLAUDE_CONFIG_DIR=meta["config_dir"], CCHERD_PARENT_OWNER=meta["owner"],
168
+ CCHERD_AGENT_NAME=meta["name"], CCHERD_STATE_DIR=str(config.STATE_DIR))
169
+ return env
170
+
171
+
172
+ def _start_turn(d: Path, prompt: str) -> None:
173
+ """Fork a detached supervisor that runs one turn of the agent."""
174
+ with open(d / "supervisor.log", "a") as log:
175
+ p = subprocess.Popen([sys.executable, "-m", "ccherd", "_supervise", str(d)],
176
+ stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True,
177
+ env={**os.environ, "CCHERD_TURN_PROMPT": prompt})
178
+ _update(d, status="running", supervisor_pid=p.pid, supervisor_start=None,
179
+ child_pid=None, turn_started_at=time.time())
180
+
181
+
182
+ def _run_turn(d: Path, meta: dict, prompt: str) -> tuple[int, str | None, str | None, bool, float | None]:
183
+ """Run `claude -p` once; returns (exit code, result, session id, is_error, cost)."""
184
+ result, session_id, is_error, cost = None, meta.get("session_id"), False, None
185
+ with open(d / "stream.jsonl", "a") as out:
186
+ out.write(json.dumps({"type": "ccherd_turn", "turn": meta["turns"] + 1, "prompt": prompt,
187
+ "at": time.time()}) + "\n")
188
+ out.flush()
189
+ child = subprocess.Popen(claude_cmd(meta, prompt), cwd=meta["cwd"], env=child_env(meta),
190
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
191
+ text=True)
192
+ update_meta(d, child_pid=child.pid)
193
+ for line in child.stdout:
194
+ out.write(line)
195
+ out.flush()
196
+ try:
197
+ ev = json.loads(line)
198
+ except ValueError:
199
+ continue
200
+ session_id = ev.get("session_id") or session_id
201
+ if ev.get("type") == "result":
202
+ result = ev.get("result")
203
+ is_error = bool(ev.get("is_error"))
204
+ cost = ev.get("total_cost_usd")
205
+ return child.wait(), result, session_id, is_error, cost
206
+
207
+
208
+ def supervise(d: Path) -> int:
209
+ """Body of the detached supervisor: run turns until nothing is queued, then notify the owner."""
210
+ update_meta(d, supervisor_pid=os.getpid(), supervisor_start=proc_start(os.getpid()))
211
+ prompt = os.environ.pop("CCHERD_TURN_PROMPT")
212
+ while True:
213
+ meta = load_meta(d)
214
+ rc, result, session_id, is_error, cost = _run_turn(d, meta, prompt)
215
+ with locked(d):
216
+ # Status and inbox are settled under ONE lock: `ccherd send` queues only while
217
+ # it reads "running" under the same lock, so no message lands after the
218
+ # last drain and sits there unrun.
219
+ killed = load_meta(d)["status"] == "killed"
220
+ pending = [] if killed else _take_inbox(d)
221
+ if killed:
222
+ status = "killed"
223
+ elif pending and session_id:
224
+ status = "running"
225
+ else:
226
+ status = "failed" if (rc != 0 or is_error) else "idle"
227
+ meta = _update(d, status=status, session_id=session_id, turns=meta["turns"] + 1,
228
+ child_pid=None, last_result=result, last_exit=rc, ended_at=time.time(),
229
+ last_cost_usd=cost)
230
+ if status == "killed":
231
+ return 0
232
+ if status == "running":
233
+ # Messages that arrived while the turn was ending run as the next turn,
234
+ # so the owner gets one notice per settled state.
235
+ prompt = "\n\n".join(pending)
236
+ continue
237
+ notify_owner(meta, status, result)
238
+ return 0
239
+
240
+
241
+ def notify_owner(meta: dict, status: str, result: str | None) -> None:
242
+ sess = session_by_socket(meta.get("owner_socket"))
243
+ if not sess:
244
+ return # the owner is gone; the result stays readable via `ccherd result`
245
+ body = result or "(no result text)"
246
+ if len(body) > RESULT_PREVIEW:
247
+ body = body[:RESULT_PREVIEW] + f"\n[... truncated, full text: ccherd result {meta['name']}]"
248
+ head = (f"ccherd: subagent `{meta['name']}` is {status} (account {meta['account']}, {meta['model']}, "
249
+ f"turn {meta['turns']}). Reply with: ccherd send {meta['name']} \"...\"")
250
+ try:
251
+ deliver(sess, f"{head}\n\n{body}", sender=f"ccherd:{meta['name']}", mode=mode_class(meta["permission_mode"]))
252
+ except OSError:
253
+ pass
ccherd/api.py ADDED
@@ -0,0 +1,28 @@
1
+ """Read-only calls to the Anthropic OAuth API, with the token of one account."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.request
8
+ from pathlib import Path
9
+
10
+ BASE_URL = "https://api.anthropic.com/api/oauth"
11
+
12
+
13
+ def get(path: str, token: str) -> dict:
14
+ """GET {BASE_URL}/{path}. Raises urllib.error.URLError, OSError or ValueError."""
15
+ req = urllib.request.Request(f"{BASE_URL}/{path}", headers={
16
+ "Authorization": f"Bearer {token}",
17
+ "anthropic-beta": "oauth-2025-04-20",
18
+ })
19
+ with urllib.request.urlopen(req, timeout=10) as r:
20
+ return json.load(r)
21
+
22
+
23
+ def write_cache(path: Path, data: dict) -> None:
24
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
25
+ tmp = path.with_suffix(".tmp")
26
+ tmp.write_text(json.dumps(data))
27
+ os.chmod(tmp, 0o600)
28
+ tmp.replace(path)
ccherd/cli.py ADDED
@@ -0,0 +1,100 @@
1
+ """Command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import __version__, agents, commands, doctor, setup
10
+ from .permissions import MODES
11
+
12
+
13
+ def parser() -> argparse.ArgumentParser:
14
+ p = argparse.ArgumentParser(prog="ccherd", description="Claude Code sessions and subagents across several Claude accounts.")
15
+ p.add_argument("--version", action="version", version=f"ccherd {__version__}")
16
+ sub = p.add_subparsers(dest="cmd", required=True, metavar="COMMAND")
17
+
18
+ s = sub.add_parser("setup", help="choose accounts, install the skill (run inside your repo)")
19
+ s.add_argument("--dir", action="append", metavar="PATH", help="a config dir to use (repeatable; skips the picker)")
20
+ s.add_argument("--schema", action="append", metavar="PATH",
21
+ help="use PATH and every numbered sibling, e.g. ~/.claude-work (repeatable; skips the picker)")
22
+ s.add_argument("--organization", metavar="NAME",
23
+ help="use only accounts of this organization (as `ccherd doctor` names it)")
24
+ s.add_argument("--skill", choices=setup.SKILL_TARGETS, help="where to install the skill")
25
+ s.add_argument("--claude-local", action=argparse.BooleanOptionalAction, default=None,
26
+ help="add a ccherd note to CLAUDE.local.md")
27
+ s.add_argument("--new", type=int, metavar="N",
28
+ help="you have N subscriptions but no config dirs yet: create them and print how to log in")
29
+ s.add_argument("-y", "--yes", action="store_true", help="take the default for every question not answered by a flag")
30
+ s.set_defaults(fn=setup.run)
31
+
32
+ s = sub.add_parser("doctor", help="check claude CLI, logins, shared config and skill")
33
+ s.add_argument("--fix", action="store_true", help="link each account's skills, memories etc. to the primary")
34
+ s.add_argument("-y", "--yes", action="store_true", help="with --fix: do not ask")
35
+ s.set_defaults(fn=doctor.run)
36
+
37
+ s = sub.add_parser("accounts", help="usage and score per account")
38
+ s.add_argument("--model", help="score against this model's weekly limit too")
39
+ s.add_argument("--refresh", action="store_true", help="ignore the 60s cache")
40
+ s.add_argument("--json", action="store_true")
41
+ s.set_defaults(fn=commands.accounts)
42
+
43
+ s = sub.add_parser("sessions", help="live sessions of all accounts")
44
+ s.add_argument("--json", action="store_true")
45
+ s.set_defaults(fn=commands.sessions)
46
+
47
+ s = sub.add_parser("spawn", help="start a background subagent on the best account")
48
+ s.add_argument("name")
49
+ s.add_argument("task")
50
+ s.add_argument("--model", required=True, help="e.g. opus, sonnet, haiku")
51
+ s.add_argument("--effort", choices=agents.EFFORTS, help="claude's --effort for every turn of this agent")
52
+ s.add_argument("--account", default="auto", help="auto (default) or a label from `ccherd accounts`")
53
+ s.add_argument("--cwd", default=".")
54
+ s.add_argument("--permission-mode", choices=MODES,
55
+ help="default: the calling session's current mode; only narrower modes are accepted")
56
+ s.set_defaults(fn=commands.spawn)
57
+
58
+ s = sub.add_parser("agents", help="this session's subagents")
59
+ s.add_argument("--all", action="store_true", help="every session's subagents")
60
+ s.add_argument("--json", action="store_true")
61
+ s.set_defaults(fn=commands.list_agents)
62
+
63
+ s = sub.add_parser("send", help="message a subagent or any live session")
64
+ s.add_argument("target", nargs="?", help="agent name, session name or uds: address")
65
+ s.add_argument("text")
66
+ s.add_argument("--parent", action="store_true", help="from inside a subagent: message the session that started it")
67
+ s.set_defaults(fn=commands.send)
68
+
69
+ s = sub.add_parser("result", help="a subagent's last answer in full")
70
+ s.add_argument("name")
71
+ s.set_defaults(fn=commands.result)
72
+
73
+ s = sub.add_parser("log", help="a subagent's transcript")
74
+ s.add_argument("name")
75
+ s.add_argument("--tail", type=int, default=0)
76
+ s.set_defaults(fn=commands.log)
77
+
78
+ s = sub.add_parser("kill", help="stop a subagent and everything it started")
79
+ s.add_argument("name")
80
+ s.set_defaults(fn=commands.kill)
81
+
82
+ s = sub.add_parser("_supervise") # internal: the detached process that runs one agent's turns
83
+ s.add_argument("dir")
84
+ s.set_defaults(fn=lambda a: agents.supervise(Path(a.dir)))
85
+ return p
86
+
87
+
88
+ def main(argv: list[str] | None = None) -> int:
89
+ p = parser()
90
+ a = p.parse_args(argv)
91
+ if a.cmd == "send" and not a.parent and not a.target:
92
+ p.error("send needs a TARGET (or --parent)")
93
+ try:
94
+ return a.fn(a)
95
+ except KeyboardInterrupt:
96
+ return 130
97
+
98
+
99
+ if __name__ == "__main__":
100
+ sys.exit(main())
ccherd/commands.py ADDED
@@ -0,0 +1,211 @@
1
+ """The subcommands, one function each. Argument parsing lives in cli.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import signal
8
+ import time
9
+ from argparse import Namespace
10
+
11
+ from . import agents, config
12
+ from .agents import TERMINAL, locked
13
+ from .fmt import fmt_age, fmt_ts
14
+ from .permissions import caller_mode, check_not_wider, mode_class
15
+ from .sessions import deliver, find_session, live_sessions, session_by_socket
16
+ from .usage import pick_account, rank_accounts
17
+
18
+
19
+ def _own_account() -> str | None:
20
+ cur_path = config.current_dir().resolve()
21
+ return next((a.label for a in config.load_accounts() if a.dir.resolve() == cur_path), None)
22
+
23
+
24
+ def _account(label: str) -> config.Account:
25
+ accounts = config.load_accounts()
26
+ for a in accounts:
27
+ if a.label == label:
28
+ return a
29
+ raise SystemExit(f"ccherd: no account {label!r} (have: {', '.join(a.label for a in accounts)})")
30
+
31
+
32
+ def accounts(a: Namespace) -> int:
33
+ rows = rank_accounts(a.model, refresh=a.refresh)
34
+ if a.json:
35
+ print(json.dumps(rows, indent=1))
36
+ return 0
37
+ me = _own_account()
38
+ width = max([len(r["account"]) + 1 for r in rows] + [8])
39
+ print(f"{'account':{width}} {'5h':>5} {'5h reset':>16} {'week':>5} {'week reset':>16} {'%/h':>6} {'score':>6} note")
40
+ for i, r in enumerate(rows):
41
+ pick = " <- auto" if i == 0 and r["score"] is not None else ""
42
+ label = r["account"] + ("*" if r["account"] == me else "")
43
+ sc = "-" if r["score"] is None else f"{r['score']:.2f}"
44
+ print(f"{label:{width}} {r.get('five_hour', 0):>4.0f}% {fmt_ts(r.get('five_hour_reset')):>16} "
45
+ f"{r.get('weekly', 0):>4.0f}% {fmt_ts(r.get('weekly_reset')):>16} {r.get('rate', 0):>6.2f} "
46
+ f"{sc:>6} {r['reason']}{pick}")
47
+ print("* = this session's account; score = weekly % left per hour until reset x free share of the 5h window")
48
+ return 0
49
+
50
+
51
+ def sessions(a: Namespace) -> int:
52
+ ss = live_sessions()
53
+ if a.json:
54
+ print(json.dumps(ss, indent=1))
55
+ return 0
56
+ mine = os.environ.get("CLAUDE_CODE_SESSION_ID")
57
+ for s in sorted(ss, key=lambda s: (s["account"], s.get("name") or "")):
58
+ me = " (this session)" if s.get("sessionId") == mine else ""
59
+ print(f"[{s['account']}] {s.get('name') or '-':40} {s.get('status', '?'):5} "
60
+ f"uds:{s.get('messagingSocketPath')} {s.get('cwd', '')}{me}")
61
+ return 0
62
+
63
+
64
+ def spawn(a: Namespace) -> int:
65
+ owner = agents.owner_id()
66
+ d = agents.agent_dir(owner, a.name)
67
+ if (d / "meta.json").is_file():
68
+ meta = agents.refreshed(d)
69
+ state = "is" if meta["status"] not in TERMINAL else "exists,"
70
+ raise SystemExit(f"ccherd: agent {a.name!r} {state} {meta['status']} - `ccherd send {a.name}` "
71
+ f"continues it, or pick another name")
72
+ caller = caller_mode()
73
+ mode = a.permission_mode or caller
74
+ check_not_wider(mode, caller)
75
+ account = _account(pick_account(a.model) if a.account == "auto" else a.account)
76
+ cwd = os.path.abspath(a.cwd)
77
+ d.mkdir(parents=True, mode=0o700)
78
+ agents.save_meta(d, {
79
+ "name": a.name, "owner": owner, "owner_socket": os.environ.get("CLAUDE_CODE_MESSAGING_SOCKET"),
80
+ "owner_account": _own_account(), "account": account.label, "config_dir": str(account.dir),
81
+ "model": a.model, "effort": a.effort, "permission_mode": "manual" if mode == "default" else mode,
82
+ "cwd": cwd, "created_at": time.time(), "status": "starting", "turns": 0, "session_id": None,
83
+ })
84
+ with locked(d):
85
+ agents._start_turn(d, a.task)
86
+ print(f"ccherd: started `{a.name}` on account {account.label} ({a.model}, effort {a.effort or 'default'}, "
87
+ f"{mode}) in {cwd}.\n A notice arrives in this session when it finishes; `ccherd agents` shows its state.")
88
+ return 0
89
+
90
+
91
+ def list_agents(a: Namespace) -> int:
92
+ rows = agents.list_agents(a.all)
93
+ if a.json:
94
+ print(json.dumps([m for _, m in rows], indent=1))
95
+ return 0
96
+ if not rows:
97
+ print("no subagents" + ("" if a.all else " for this session (--all for every session's)"))
98
+ for _, m in rows:
99
+ last = (m.get("last_result") or "").strip().splitlines()
100
+ owner = f" owner {m['owner'][:8]}" if a.all else ""
101
+ print(f"{m['name']:20} {m['status']:8} {m['account']:8} {m['model']:10} {m.get('effort') or '-':6} "
102
+ f"turns {m['turns']:<3} since {fmt_age(m.get('turn_started_at'))}{owner} {last[0][:60] if last else ''}")
103
+ return 0
104
+
105
+
106
+ def send(a: Namespace) -> int:
107
+ if a.parent:
108
+ return _send_to_parent(a.text)
109
+ d = agents.find_agent(a.target)
110
+ if d is None:
111
+ return _send_to_session(a.target, a.text)
112
+ return _send_to_agent(d, a.target, a.text)
113
+
114
+
115
+ def _send_to_parent(text: str) -> int:
116
+ owner, name = os.environ.get("CCHERD_PARENT_OWNER"), os.environ.get("CCHERD_AGENT_NAME")
117
+ if not (owner and name):
118
+ raise SystemExit("ccherd: --parent works only inside a ccherd subagent")
119
+ meta = agents.load_meta(agents.agent_dir(owner, name))
120
+ sess = session_by_socket(meta.get("owner_socket"))
121
+ if not sess:
122
+ raise SystemExit("ccherd: the session that started you is no longer running")
123
+ deliver(sess, f"ccherd: subagent `{name}` asks:\n\n{text}\n\nAnswer with: ccherd send {name} \"...\"",
124
+ sender=f"ccherd:{name}", mode=mode_class(meta["permission_mode"]))
125
+ print("sent to parent")
126
+ return 0
127
+
128
+
129
+ def _send_to_session(target: str, text: str) -> int:
130
+ # Assert THIS session's real current class. That is a true statement about the
131
+ # sender, so it grants nothing; without it a receiver that bypasses permissions
132
+ # holds the message for its user, and with it two bypass sessions talk directly.
133
+ sess = find_session(target)
134
+ mode = mode_class(caller_mode())
135
+ deliver(sess, text, mode=mode)
136
+ held = ("" if mode else " without a permission class (this session's mode is unreadable),"
137
+ " so a receiver that bypasses permissions holds it for its user's approval")
138
+ print(f"delivered to [{sess['account']}] {sess.get('name')}{held}. A receiver in a different permission "
139
+ f"class holds it for approval; ccherd gets no receipt either way. "
140
+ f"Replies arrive here as <cross-session-message from=\"uds:...\">.")
141
+ return 0
142
+
143
+
144
+ def _send_to_agent(d, name: str, text: str) -> int:
145
+ # The agent keeps the mode it was spawned with. If this session has been
146
+ # narrowed since, it may no longer steer an agent that is allowed more.
147
+ check_not_wider(agents.load_meta(d)["permission_mode"], caller_mode())
148
+ with locked(d):
149
+ meta = agents._refreshed(d)
150
+ if meta["status"] not in ("running", "starting"):
151
+ if not meta.get("session_id"):
152
+ raise SystemExit(f"ccherd: `{name}` never got a session id (status {meta['status']}) - see `ccherd log`")
153
+ agents._start_turn(d, text)
154
+ print(f"resumed `{name}` on account {meta['account']} with your message")
155
+ return 0
156
+ child = next((s for s in live_sessions() if meta.get("child_pid") and s["pid"] == meta["child_pid"]), None)
157
+ if child is None:
158
+ agents._queue(d, text)
159
+ print(f"`{name}` has no inbox open right now - queued, it runs as its next turn")
160
+ return 0
161
+ # The owner chose this mode at spawn, so asserting it grants nothing new.
162
+ deliver(child, text, mode=mode_class(meta["permission_mode"]))
163
+ print(f"sent into running `{name}` (it reads it at its next tool round)")
164
+ return 0
165
+
166
+
167
+ def result(a: Namespace) -> int:
168
+ m = agents.refreshed(agents.require_agent(a.name))
169
+ print(m.get("last_result") or f"(no result yet - status {m['status']})")
170
+ return 0
171
+
172
+
173
+ def log(a: Namespace) -> int:
174
+ d = agents.require_agent(a.name)
175
+ stream = d / "stream.jsonl"
176
+ lines = stream.read_text().splitlines() if stream.is_file() else []
177
+ for line in lines[-a.tail:] if a.tail else lines:
178
+ try:
179
+ ev = json.loads(line)
180
+ except ValueError:
181
+ print(line)
182
+ continue
183
+ kind = ev.get("type")
184
+ if kind == "ccherd_turn":
185
+ print(f"\n=== turn {ev['turn']}: {ev['prompt'][:200]}")
186
+ elif kind == "assistant":
187
+ for c in ev.get("message", {}).get("content", []):
188
+ if c.get("type") == "text":
189
+ print(f"[assistant] {c['text']}")
190
+ elif c.get("type") == "tool_use":
191
+ print(f"[tool] {c.get('name')} {json.dumps(c.get('input'))[:160]}")
192
+ elif kind == "result":
193
+ print(f"[result] {'ERROR ' if ev.get('is_error') else ''}{(ev.get('result') or '')[:300]}")
194
+ return 0
195
+
196
+
197
+ def kill(a: Namespace) -> int:
198
+ d = agents.require_agent(a.name)
199
+ m = agents.refreshed(d)
200
+ if m["status"] != "running":
201
+ print(f"`{a.name}` is {m['status']}, nothing to stop")
202
+ return 0
203
+ agents.update_meta(d, status="killed")
204
+ try:
205
+ # The supervisor leads its own session, so its pid is the process group of
206
+ # claude and every tool process claude started.
207
+ os.killpg(m["supervisor_pid"], signal.SIGTERM)
208
+ except (ProcessLookupError, KeyError, TypeError):
209
+ pass
210
+ print(f"stopped `{a.name}`")
211
+ return 0