ccherd 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ name: release
2
+
3
+ # Publishes to PyPI when a version tag is pushed. Uses PyPI trusted publishing:
4
+ # register this repo and workflow once under the project's "Publishing" settings
5
+ # on pypi.org; no token is stored here.
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+
10
+ jobs:
11
+ pypi:
12
+ runs-on: ubuntu-latest
13
+ environment: pypi
14
+ permissions:
15
+ contents: read
16
+ id-token: write
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: astral-sh/setup-uv@v6
20
+ - run: uv build
21
+ - run: uv publish
@@ -0,0 +1,21 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ strategy:
11
+ matrix:
12
+ os: [ubuntu-latest, macos-latest]
13
+ python: ["3.10", "3.13"]
14
+ runs-on: ${{ matrix.os }}
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python }}
20
+ - run: PYTHONPATH=src python -m unittest discover -s tests -v
21
+ - run: pip install . && ccherd --version
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .venv/
ccherd-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 bocode labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ccherd-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.5
2
+ Name: ccherd
3
+ Version: 0.1.0
4
+ Summary: Claude Code sessions and subagents across several Claude accounts
5
+ Author: bocode labs
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Environment :: Console
9
+ Classifier: Operating System :: MacOS
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+
15
+ # ccherd
16
+
17
+ ![A herd of Claude Code sheep](docs/herd.png)
18
+
19
+ Run Claude Code subagents across several Claude accounts.
20
+
21
+ If you have more than one Claude subscription, each lives in its own
22
+ `CLAUDE_CONFIG_DIR` (`~/.claude`, `~/.claude-work`, ...). Claude Code only sees
23
+ the sessions of the account it runs under, and a subagent always spends the
24
+ quota of its parent. ccherd fixes both:
25
+
26
+ - it lists and messages sessions across all your accounts, and
27
+ - it starts background subagents on the account whose weekly quota is most
28
+ likely to expire unused, so you use what you pay for before paying extra.
29
+
30
+ macOS and Linux. Needs Python 3.10+ and the `claude` CLI.
31
+
32
+ ## Install
33
+
34
+ ```sh
35
+ uv tool install ccherd
36
+ # or
37
+ pipx install ccherd
38
+ ```
39
+
40
+ ## Setup
41
+
42
+ Run this inside the repo where you want to use ccherd:
43
+
44
+ ```sh
45
+ ccherd setup
46
+ ```
47
+
48
+ It asks:
49
+
50
+ 0. **Whether each subscription already has its own config dir.** If not, it asks
51
+ how many subscriptions you have, creates numbered dirs next to your current
52
+ one (`~/.claude`, `~/.claude-2`, ...), prints the command to log in to each,
53
+ and stops. Log in, then run `ccherd setup` again.
54
+ 1. **Which organization.** If your dirs are logged in to more than one (say a
55
+ private plan and a company team), ccherd uses one of them: subagents should
56
+ run with the same skills and memories as their caller. Dirs of other
57
+ organizations are left out, even if a pattern matches them.
58
+ 2. **Which config dirs are your accounts.** It lists every `~/.claude*` dir.
59
+ A numbered family like `~/.claude-work`, `~/.claude-work2`, `~/.claude-work3`
60
+ gets one extra line: tick it to save the pattern, so `~/.claude-work4` is
61
+ picked up later without running setup again. Or tick single dirs. Anything
62
+ elsewhere goes into the "Other" line.
63
+ 3. **Where to put the Claude skill:** in this repo (`.claude/skills/ccherd`) or in
64
+ every account's config dir.
65
+ 4. **Whether to add a short note to `CLAUDE.local.md`.** Creates the file if needed and
66
+ adds it to `.gitignore`.
67
+
68
+ At the end it runs `ccherd doctor`.
69
+
70
+ ## Shared skills and memories
71
+
72
+ A subagent should behave like the session that started it. So all accounts
73
+ should share `skills`, `projects` (memories and conversations), `agents`,
74
+ `plugins`, `settings.json` and `CLAUDE.md`: one account holds them, the others
75
+ symlink to it. `ccherd doctor` shows what is linked; `ccherd doctor --fix`
76
+ links the rest. An account's own copy is merged into the shared one first;
77
+ files that differ stay in a `*.ccherd-backup-*` dir next to it. Nothing is
78
+ deleted.
79
+
80
+ Non-interactive: `ccherd setup --new 3`, or `ccherd setup --organization "Acme" --schema ~/.claude-work --dir ~/.claude --skill repo --claude-local`.
81
+
82
+ Config is stored in `~/.config/ccherd/config.json`.
83
+
84
+ ## Commands
85
+
86
+ | Command | What it does |
87
+ | --- | --- |
88
+ | `ccherd setup` | Pick accounts, install the skill |
89
+ | `ccherd doctor` | Per account: login, organization, token, shared config |
90
+ | `ccherd doctor --fix` | Symlink every account's skills, memories etc. to one shared place |
91
+ | `ccherd accounts` | 5-hour and weekly usage per account, and which one `auto` picks |
92
+ | `ccherd sessions` | Live Claude sessions of all accounts |
93
+ | `ccherd spawn NAME "task" --model M` | Start a background subagent on the best account |
94
+ | `ccherd agents` | Subagents started by the current session |
95
+ | `ccherd send TARGET "text"` | Message a subagent (resumes it if idle) or any live session |
96
+ | `ccherd result NAME` | A subagent's last answer |
97
+ | `ccherd log NAME` | A subagent's transcript |
98
+ | `ccherd kill NAME` | Stop a subagent |
99
+
100
+ `spawn`, `agents`, `send`, `result`, `log` and `kill` are meant to be run by
101
+ Claude from inside a session; the skill tells it how. A finished subagent
102
+ reports back to the session that started it as a message.
103
+
104
+ ## How `auto` picks an account
105
+
106
+ Score = weekly % left ÷ hours until the weekly reset × free share of the 5-hour
107
+ window. The highest score wins. Accounts at ≥ 90 % of their 5-hour window or
108
+ ≥ 98 % of their week are skipped.
109
+
110
+ ccherd never refreshes an OAuth token itself - the refresh token rotates, so
111
+ that would log out the Claude Code instance that owns the account. When an
112
+ access token has expired and no session runs on that account, it lets Claude
113
+ Code renew it (`claude auth status`).
114
+
115
+ ## Development
116
+
117
+ ```sh
118
+ PYTHONPATH=src python3 -m unittest discover -s tests
119
+ ```
120
+
121
+ ## License
122
+
123
+ MIT
ccherd-0.1.0/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # ccherd
2
+
3
+ ![A herd of Claude Code sheep](docs/herd.png)
4
+
5
+ Run Claude Code subagents across several Claude accounts.
6
+
7
+ If you have more than one Claude subscription, each lives in its own
8
+ `CLAUDE_CONFIG_DIR` (`~/.claude`, `~/.claude-work`, ...). Claude Code only sees
9
+ the sessions of the account it runs under, and a subagent always spends the
10
+ quota of its parent. ccherd fixes both:
11
+
12
+ - it lists and messages sessions across all your accounts, and
13
+ - it starts background subagents on the account whose weekly quota is most
14
+ likely to expire unused, so you use what you pay for before paying extra.
15
+
16
+ macOS and Linux. Needs Python 3.10+ and the `claude` CLI.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ uv tool install ccherd
22
+ # or
23
+ pipx install ccherd
24
+ ```
25
+
26
+ ## Setup
27
+
28
+ Run this inside the repo where you want to use ccherd:
29
+
30
+ ```sh
31
+ ccherd setup
32
+ ```
33
+
34
+ It asks:
35
+
36
+ 0. **Whether each subscription already has its own config dir.** If not, it asks
37
+ how many subscriptions you have, creates numbered dirs next to your current
38
+ one (`~/.claude`, `~/.claude-2`, ...), prints the command to log in to each,
39
+ and stops. Log in, then run `ccherd setup` again.
40
+ 1. **Which organization.** If your dirs are logged in to more than one (say a
41
+ private plan and a company team), ccherd uses one of them: subagents should
42
+ run with the same skills and memories as their caller. Dirs of other
43
+ organizations are left out, even if a pattern matches them.
44
+ 2. **Which config dirs are your accounts.** It lists every `~/.claude*` dir.
45
+ A numbered family like `~/.claude-work`, `~/.claude-work2`, `~/.claude-work3`
46
+ gets one extra line: tick it to save the pattern, so `~/.claude-work4` is
47
+ picked up later without running setup again. Or tick single dirs. Anything
48
+ elsewhere goes into the "Other" line.
49
+ 3. **Where to put the Claude skill:** in this repo (`.claude/skills/ccherd`) or in
50
+ every account's config dir.
51
+ 4. **Whether to add a short note to `CLAUDE.local.md`.** Creates the file if needed and
52
+ adds it to `.gitignore`.
53
+
54
+ At the end it runs `ccherd doctor`.
55
+
56
+ ## Shared skills and memories
57
+
58
+ A subagent should behave like the session that started it. So all accounts
59
+ should share `skills`, `projects` (memories and conversations), `agents`,
60
+ `plugins`, `settings.json` and `CLAUDE.md`: one account holds them, the others
61
+ symlink to it. `ccherd doctor` shows what is linked; `ccherd doctor --fix`
62
+ links the rest. An account's own copy is merged into the shared one first;
63
+ files that differ stay in a `*.ccherd-backup-*` dir next to it. Nothing is
64
+ deleted.
65
+
66
+ Non-interactive: `ccherd setup --new 3`, or `ccherd setup --organization "Acme" --schema ~/.claude-work --dir ~/.claude --skill repo --claude-local`.
67
+
68
+ Config is stored in `~/.config/ccherd/config.json`.
69
+
70
+ ## Commands
71
+
72
+ | Command | What it does |
73
+ | --- | --- |
74
+ | `ccherd setup` | Pick accounts, install the skill |
75
+ | `ccherd doctor` | Per account: login, organization, token, shared config |
76
+ | `ccherd doctor --fix` | Symlink every account's skills, memories etc. to one shared place |
77
+ | `ccherd accounts` | 5-hour and weekly usage per account, and which one `auto` picks |
78
+ | `ccherd sessions` | Live Claude sessions of all accounts |
79
+ | `ccherd spawn NAME "task" --model M` | Start a background subagent on the best account |
80
+ | `ccherd agents` | Subagents started by the current session |
81
+ | `ccherd send TARGET "text"` | Message a subagent (resumes it if idle) or any live session |
82
+ | `ccherd result NAME` | A subagent's last answer |
83
+ | `ccherd log NAME` | A subagent's transcript |
84
+ | `ccherd kill NAME` | Stop a subagent |
85
+
86
+ `spawn`, `agents`, `send`, `result`, `log` and `kill` are meant to be run by
87
+ Claude from inside a session; the skill tells it how. A finished subagent
88
+ reports back to the session that started it as a message.
89
+
90
+ ## How `auto` picks an account
91
+
92
+ Score = weekly % left ÷ hours until the weekly reset × free share of the 5-hour
93
+ window. The highest score wins. Accounts at ≥ 90 % of their 5-hour window or
94
+ ≥ 98 % of their week are skipped.
95
+
96
+ ccherd never refreshes an OAuth token itself - the refresh token rotates, so
97
+ that would log out the Claude Code instance that owns the account. When an
98
+ access token has expired and no session runs on that account, it lets Claude
99
+ Code renew it (`claude auth status`).
100
+
101
+ ## Development
102
+
103
+ ```sh
104
+ PYTHONPATH=src python3 -m unittest discover -s tests
105
+ ```
106
+
107
+ ## License
108
+
109
+ MIT
Binary file
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ccherd"
7
+ dynamic = ["version"]
8
+ description = "Claude Code sessions and subagents across several Claude accounts"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "bocode labs" }]
13
+ classifiers = [
14
+ "Environment :: Console",
15
+ "Operating System :: MacOS",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Programming Language :: Python :: 3",
18
+ ]
19
+
20
+ [project.scripts]
21
+ ccherd = "ccherd.cli:main"
22
+
23
+ [tool.hatch.version]
24
+ path = "src/ccherd/__init__.py"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/ccherd"]
28
+
29
+ [tool.ruff]
30
+ line-length = 120
@@ -0,0 +1,3 @@
1
+ """Claude Code sessions and subagents across several Claude accounts."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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
@@ -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)