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.
- ccherd-0.1.0/.github/workflows/release.yml +21 -0
- ccherd-0.1.0/.github/workflows/test.yml +21 -0
- ccherd-0.1.0/.gitignore +5 -0
- ccherd-0.1.0/LICENSE +21 -0
- ccherd-0.1.0/PKG-INFO +123 -0
- ccherd-0.1.0/README.md +109 -0
- ccherd-0.1.0/docs/herd.png +0 -0
- ccherd-0.1.0/pyproject.toml +30 -0
- ccherd-0.1.0/src/ccherd/__init__.py +3 -0
- ccherd-0.1.0/src/ccherd/__main__.py +5 -0
- ccherd-0.1.0/src/ccherd/agents.py +253 -0
- ccherd-0.1.0/src/ccherd/api.py +28 -0
- ccherd-0.1.0/src/ccherd/cli.py +100 -0
- ccherd-0.1.0/src/ccherd/commands.py +211 -0
- ccherd-0.1.0/src/ccherd/config.py +135 -0
- ccherd-0.1.0/src/ccherd/credentials.py +85 -0
- ccherd-0.1.0/src/ccherd/data/SKILL.md +89 -0
- ccherd-0.1.0/src/ccherd/data/claude-local.md +13 -0
- ccherd-0.1.0/src/ccherd/doctor.py +197 -0
- ccherd-0.1.0/src/ccherd/fmt.py +19 -0
- ccherd-0.1.0/src/ccherd/links.py +138 -0
- ccherd-0.1.0/src/ccherd/permissions.py +59 -0
- ccherd-0.1.0/src/ccherd/procs.py +37 -0
- ccherd-0.1.0/src/ccherd/profile.py +49 -0
- ccherd-0.1.0/src/ccherd/sessions.py +112 -0
- ccherd-0.1.0/src/ccherd/setup.py +287 -0
- ccherd-0.1.0/src/ccherd/tui.py +217 -0
- ccherd-0.1.0/src/ccherd/usage.py +136 -0
- ccherd-0.1.0/tests/test_agents.py +52 -0
- ccherd-0.1.0/tests/test_config.py +82 -0
- ccherd-0.1.0/tests/test_doctor.py +126 -0
- ccherd-0.1.0/tests/test_links.py +86 -0
- ccherd-0.1.0/tests/test_permissions.py +39 -0
- ccherd-0.1.0/tests/test_sessions.py +62 -0
- ccherd-0.1.0/tests/test_setup.py +103 -0
- ccherd-0.1.0/tests/test_usage.py +59 -0
|
@@ -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
|
ccherd-0.1.0/.gitignore
ADDED
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
|
+

|
|
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
|
+

|
|
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,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)
|