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 +3 -0
- ccherd/__main__.py +5 -0
- ccherd/agents.py +253 -0
- ccherd/api.py +28 -0
- ccherd/cli.py +100 -0
- ccherd/commands.py +211 -0
- ccherd/config.py +135 -0
- ccherd/credentials.py +85 -0
- ccherd/data/SKILL.md +89 -0
- ccherd/data/claude-local.md +13 -0
- ccherd/doctor.py +197 -0
- ccherd/fmt.py +19 -0
- ccherd/links.py +138 -0
- ccherd/permissions.py +59 -0
- ccherd/procs.py +37 -0
- ccherd/profile.py +49 -0
- ccherd/sessions.py +112 -0
- ccherd/setup.py +287 -0
- ccherd/tui.py +217 -0
- ccherd/usage.py +136 -0
- ccherd-0.1.0.dist-info/METADATA +123 -0
- ccherd-0.1.0.dist-info/RECORD +25 -0
- ccherd-0.1.0.dist-info/WHEEL +4 -0
- ccherd-0.1.0.dist-info/entry_points.txt +2 -0
- ccherd-0.1.0.dist-info/licenses/LICENSE +21 -0
ccherd/__init__.py
ADDED
ccherd/__main__.py
ADDED
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
|