copse-agents 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.
- copse/__init__.py +3 -0
- copse/__main__.py +3 -0
- copse/agents.py +289 -0
- copse/builtin_agents/__init__.py +0 -0
- copse/builtin_agents/developer.md +13 -0
- copse/builtin_agents/reviewer.md +11 -0
- copse/builtin_agents/supervisor.md +24 -0
- copse/cli.py +379 -0
- copse/config.py +108 -0
- copse/db.py +240 -0
- copse/git.py +300 -0
- copse/mcp_server.py +187 -0
- copse/profiles.py +86 -0
- copse/providers.py +155 -0
- copse/tmux.py +88 -0
- copse/workspaces.py +361 -0
- copse_agents-0.1.0.dist-info/METADATA +183 -0
- copse_agents-0.1.0.dist-info/RECORD +21 -0
- copse_agents-0.1.0.dist-info/WHEEL +4 -0
- copse_agents-0.1.0.dist-info/entry_points.txt +2 -0
- copse_agents-0.1.0.dist-info/licenses/LICENSE +202 -0
copse/__init__.py
ADDED
copse/__main__.py
ADDED
copse/agents.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Agents: a CLI agent process in a tmux window inside a workspace.
|
|
2
|
+
|
|
3
|
+
Messaging follows CAO's inbox model: a message to a busy agent waits in its
|
|
4
|
+
inbox and is delivered the moment the agent goes idle. With hook-capable
|
|
5
|
+
providers, delivery happens inside the ``Stop`` hook itself (the hook tells
|
|
6
|
+
Claude Code to keep going with the message as its next instruction), so
|
|
7
|
+
nothing ever types into a terminal while the agent is mid-turn.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
|
|
17
|
+
from copse import git, tmux, workspaces
|
|
18
|
+
from copse.db import DB, Agent, Workspace
|
|
19
|
+
from copse.profiles import load_profile
|
|
20
|
+
from copse.providers import LaunchContext, get_provider
|
|
21
|
+
|
|
22
|
+
WORKER_FOOTER = """
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
You are running as a copse worker (agent id {agent_id}) on branch `{branch}`.
|
|
26
|
+
When you have finished:
|
|
27
|
+
1. Commit your work to this branch with a clear message (do not push or merge).
|
|
28
|
+
2. Call the `report_result` tool from the `copse` MCP server with a concise
|
|
29
|
+
summary: what you changed, anything left undone, and anything the
|
|
30
|
+
supervisor should check.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AgentError(RuntimeError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def new_id() -> str:
|
|
39
|
+
return uuid.uuid4().hex[:8]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def agent_env(ws: Workspace, agent_id: str) -> dict[str, str]:
|
|
43
|
+
return {**workspaces.workspace_env(ws), "COPSE_AGENT_ID": agent_id}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def spawn(
|
|
47
|
+
db: DB,
|
|
48
|
+
ws: Workspace,
|
|
49
|
+
profile_name: str,
|
|
50
|
+
*,
|
|
51
|
+
prompt: str | None = None,
|
|
52
|
+
provider_name: str | None = None,
|
|
53
|
+
parent_id: str | None = None,
|
|
54
|
+
mode: str = "interactive",
|
|
55
|
+
) -> Agent:
|
|
56
|
+
profile = load_profile(profile_name, ws.repo_root)
|
|
57
|
+
provider = get_provider(provider_name or profile.provider)
|
|
58
|
+
agent_id = new_id()
|
|
59
|
+
|
|
60
|
+
if prompt and mode in ("handoff", "assign"):
|
|
61
|
+
prompt += WORKER_FOOTER.format(agent_id=agent_id, branch=ws.branch)
|
|
62
|
+
|
|
63
|
+
# Record the agent before launching: its hooks may fire within milliseconds.
|
|
64
|
+
status = "processing" if prompt else "starting"
|
|
65
|
+
if not provider.uses_hooks:
|
|
66
|
+
status = "unknown"
|
|
67
|
+
agent = Agent(
|
|
68
|
+
id=agent_id, workspace_id=ws.id, profile=profile.name, provider=provider.name,
|
|
69
|
+
parent_id=parent_id, mode=mode, status=status, tmux_window="",
|
|
70
|
+
result=None, created_at=time.time(),
|
|
71
|
+
)
|
|
72
|
+
db.add_agent(agent)
|
|
73
|
+
|
|
74
|
+
env = agent_env(ws, agent_id)
|
|
75
|
+
try:
|
|
76
|
+
tmux.ensure_session(ws.tmux_session, ws.path, workspaces.workspace_env(ws))
|
|
77
|
+
target = tmux.new_window(
|
|
78
|
+
ws.tmux_session, f"{profile.name}-{agent_id[:4]}", ws.path,
|
|
79
|
+
provider.command(LaunchContext(agent_id, profile, prompt)), env,
|
|
80
|
+
)
|
|
81
|
+
except Exception:
|
|
82
|
+
db.delete_agent(agent_id)
|
|
83
|
+
raise
|
|
84
|
+
db.update_agent(agent_id, tmux_window=target)
|
|
85
|
+
agent.tmux_window = target
|
|
86
|
+
|
|
87
|
+
if provider.name == "shell" and prompt:
|
|
88
|
+
tmux.paste(target, prompt)
|
|
89
|
+
provider.after_launch(target)
|
|
90
|
+
return agent
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get(db: DB, agent_id: str) -> Agent:
|
|
94
|
+
agent = db.get_agent(agent_id)
|
|
95
|
+
if not agent:
|
|
96
|
+
# Allow unambiguous prefixes, like git does for hashes.
|
|
97
|
+
matches = [a for a in db.list_agents() if a.id.startswith(agent_id)]
|
|
98
|
+
if len(matches) == 1:
|
|
99
|
+
return matches[0]
|
|
100
|
+
raise AgentError(f"no agent {agent_id!r}")
|
|
101
|
+
return agent
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_alive(agent: Agent) -> bool:
|
|
105
|
+
return bool(agent.tmux_window) and tmux.window_alive(agent.tmux_window)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def format_message(db: DB, body: str, sender_id: str | None) -> str:
|
|
109
|
+
if not sender_id:
|
|
110
|
+
return body
|
|
111
|
+
sender = db.get_agent(sender_id)
|
|
112
|
+
who = f"{sender.profile} agent {sender_id}" if sender else f"agent {sender_id}"
|
|
113
|
+
return f"[Message from {who}. Reply with the copse send_message tool, to_agent_id={sender_id}]\n\n{body}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def send_message(db: DB, to_id: str, body: str, sender_id: str | None = None) -> str:
|
|
117
|
+
"""Deliver now if the agent is idle; otherwise queue until it is.
|
|
118
|
+
Returns ``"delivered"`` or ``"queued"``."""
|
|
119
|
+
agent = get(db, to_id)
|
|
120
|
+
if not is_alive(agent):
|
|
121
|
+
raise AgentError(f"agent {agent.id} is not running")
|
|
122
|
+
provider = get_provider(agent.provider)
|
|
123
|
+
text = format_message(db, body, sender_id)
|
|
124
|
+
if not provider.uses_hooks:
|
|
125
|
+
tmux.paste(agent.tmux_window, text)
|
|
126
|
+
return "delivered"
|
|
127
|
+
db.enqueue(agent.id, text, sender_id)
|
|
128
|
+
return "delivered" if flush(db, agent.id) else "queued"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def flush(db: DB, agent_id: str) -> bool:
|
|
132
|
+
"""If the agent is idle, type its oldest pending message. Returns True if
|
|
133
|
+
something was delivered."""
|
|
134
|
+
if db.pending_count(agent_id) == 0 or not db.claim_idle(agent_id):
|
|
135
|
+
return False
|
|
136
|
+
msg = db.pop_pending(agent_id)
|
|
137
|
+
if not msg:
|
|
138
|
+
db.set_status(agent_id, "idle", only_if="processing")
|
|
139
|
+
return False
|
|
140
|
+
agent = db.get_agent(agent_id)
|
|
141
|
+
assert agent is not None
|
|
142
|
+
tmux.paste(agent.tmux_window, msg.body)
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def report_result(db: DB, agent_id: str, result: str) -> str:
|
|
147
|
+
agent = get(db, agent_id)
|
|
148
|
+
db.set_result(agent.id, result)
|
|
149
|
+
if agent.mode == "assign" and agent.parent_id and db.get_agent(agent.parent_id):
|
|
150
|
+
ws = db.get_workspace(agent.workspace_id)
|
|
151
|
+
where = f" on branch `{ws.branch}` (workspace {ws.id})" if ws else ""
|
|
152
|
+
send_message(
|
|
153
|
+
db, agent.parent_id,
|
|
154
|
+
f"Assigned task finished{where}.\n\n{result}",
|
|
155
|
+
sender_id=agent.id,
|
|
156
|
+
)
|
|
157
|
+
return "result recorded and sent to your supervisor"
|
|
158
|
+
return "result recorded"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def wait_for_result(db: DB, agent_id: str, timeout: float, poll: float = 2.0) -> str:
|
|
162
|
+
deadline = time.time() + timeout
|
|
163
|
+
while time.time() < deadline:
|
|
164
|
+
agent = db.get_agent(agent_id)
|
|
165
|
+
if agent is None:
|
|
166
|
+
raise AgentError(f"agent {agent_id} was removed before reporting")
|
|
167
|
+
if agent.result is not None:
|
|
168
|
+
return agent.result
|
|
169
|
+
if not is_alive(agent):
|
|
170
|
+
screen = ""
|
|
171
|
+
try:
|
|
172
|
+
screen = tmux.capture(agent.tmux_window, lines=40)
|
|
173
|
+
except tmux.TmuxError:
|
|
174
|
+
pass
|
|
175
|
+
raise AgentError(f"agent {agent_id} exited without reporting. Last output:\n{screen}")
|
|
176
|
+
time.sleep(poll)
|
|
177
|
+
agent = db.get_agent(agent_id)
|
|
178
|
+
hint = " It is waiting for a permission approval: attach to its workspace to answer." if agent and agent.status == "waiting" else ""
|
|
179
|
+
raise AgentError(
|
|
180
|
+
f"agent {agent_id} did not report within {int(timeout)}s; it's still running "
|
|
181
|
+
f"(status: {agent.status if agent else '?'}).{hint}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def kill(db: DB, agent_id: str) -> None:
|
|
186
|
+
agent = get(db, agent_id)
|
|
187
|
+
if agent.tmux_window:
|
|
188
|
+
tmux.kill_window(agent.tmux_window)
|
|
189
|
+
db.delete_agent(agent.id)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# -- delegation (used by the MCP tools) ------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _branch_from_task(profile: str, task: str, agent_hint: str) -> str:
|
|
196
|
+
words = re.findall(r"[A-Za-z0-9]+", task.lower())[:5]
|
|
197
|
+
return f"copse/{profile}/{'-'.join(words) or 'task'}-{agent_hint}"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def delegate(
|
|
201
|
+
db: DB,
|
|
202
|
+
caller: Agent | None,
|
|
203
|
+
caller_ws: Workspace,
|
|
204
|
+
profile: str,
|
|
205
|
+
task: str,
|
|
206
|
+
mode: str,
|
|
207
|
+
*,
|
|
208
|
+
isolate: bool = True,
|
|
209
|
+
branch: str | None = None,
|
|
210
|
+
) -> tuple[Agent, Workspace]:
|
|
211
|
+
"""Start a worker. With ``isolate``, the worker gets a new worktree whose
|
|
212
|
+
branch starts from the caller's current branch, so it sees the caller's
|
|
213
|
+
committed work, and nobody edits the same files."""
|
|
214
|
+
if isolate:
|
|
215
|
+
caller_ws = workspaces.refresh_branch(db, caller_ws)
|
|
216
|
+
base = caller_ws.branch
|
|
217
|
+
branch = branch or _branch_from_task(profile, task, new_id()[:4])
|
|
218
|
+
start = base if git.branch_exists(caller_ws.repo_root, base) else "HEAD"
|
|
219
|
+
created = workspaces.create(
|
|
220
|
+
db, caller_ws.path, branch, base, fetch=False, start=start,
|
|
221
|
+
)
|
|
222
|
+
if created.setup and not created.setup.ok:
|
|
223
|
+
raise AgentError(f"workspace setup failed:\n{created.setup.log}")
|
|
224
|
+
ws = created.workspace
|
|
225
|
+
else:
|
|
226
|
+
ws = caller_ws
|
|
227
|
+
agent = spawn(
|
|
228
|
+
db, ws, profile, prompt=task, parent_id=caller.id if caller else None, mode=mode
|
|
229
|
+
)
|
|
230
|
+
return agent, ws
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# -- hook entry point --------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None:
|
|
237
|
+
"""Called from ``copse _hook <event>`` inside the agent's own process tree.
|
|
238
|
+
Returns JSON for Claude Code to read on stdout, or None."""
|
|
239
|
+
agent = db.get_agent(agent_id)
|
|
240
|
+
if agent is None:
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
if event == "session-start":
|
|
244
|
+
db.set_status(agent_id, "idle", only_if="starting")
|
|
245
|
+
if db.pending_count(agent_id):
|
|
246
|
+
# Claude Code hasn't drawn its input box yet; deliver shortly after,
|
|
247
|
+
# from a detached process so this hook returns immediately.
|
|
248
|
+
import subprocess
|
|
249
|
+
|
|
250
|
+
from copse.providers import copse_invocation
|
|
251
|
+
|
|
252
|
+
subprocess.Popen(
|
|
253
|
+
[*copse_invocation(), "_flush", agent_id, "--delay", "3"],
|
|
254
|
+
start_new_session=True,
|
|
255
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
256
|
+
)
|
|
257
|
+
elif event == "prompt-submit":
|
|
258
|
+
db.set_status(agent_id, "processing")
|
|
259
|
+
elif event == "notification":
|
|
260
|
+
text = str(payload.get("message", "")).lower()
|
|
261
|
+
if "permission" in text or "approval" in text:
|
|
262
|
+
db.set_status(agent_id, "waiting")
|
|
263
|
+
elif event == "tool-done":
|
|
264
|
+
db.set_status(agent_id, "processing", only_if="waiting")
|
|
265
|
+
elif event == "stop":
|
|
266
|
+
msg = db.pop_pending(agent_id)
|
|
267
|
+
if msg:
|
|
268
|
+
db.set_status(agent_id, "processing")
|
|
269
|
+
return {"decision": "block", "reason": msg.body}
|
|
270
|
+
needs_report = agent.mode in ("handoff", "assign") and agent.result is None
|
|
271
|
+
if needs_report and not payload.get("stop_hook_active"):
|
|
272
|
+
db.set_status(agent_id, "processing")
|
|
273
|
+
return {
|
|
274
|
+
"decision": "block",
|
|
275
|
+
"reason": "You haven't called the copse `report_result` tool yet. "
|
|
276
|
+
"If your task is finished, commit your work and call it now. "
|
|
277
|
+
"If you are blocked, call it with a description of what's blocking you.",
|
|
278
|
+
}
|
|
279
|
+
db.set_status(agent_id, "idle")
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def hook_main(db: DB, agent_id: str, event: str, stdin_text: str) -> str:
|
|
284
|
+
try:
|
|
285
|
+
payload = json.loads(stdin_text) if stdin_text.strip() else {}
|
|
286
|
+
except json.JSONDecodeError:
|
|
287
|
+
payload = {}
|
|
288
|
+
out = handle_hook(db, agent_id, event, payload)
|
|
289
|
+
return json.dumps(out) if out else ""
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: developer
|
|
3
|
+
description: Implements a well-scoped coding task on its own branch
|
|
4
|
+
provider: claude
|
|
5
|
+
permission_mode: acceptEdits
|
|
6
|
+
allowed_tools: Bash(git add:*), Bash(git commit:*), Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(pytest:*), Bash(python -m pytest:*), Bash(uv run:*), Bash(uv sync:*), Bash(npm test:*), Bash(npm run:*), Bash(npm ci:*), Bash(pnpm test:*), Bash(pnpm run:*), Bash(pnpm install:*), Bash(yarn test:*), Bash(yarn run:*), Bash(cargo build:*), Bash(cargo test:*), Bash(cargo check:*), Bash(cargo clippy:*), Bash(go build:*), Bash(go test:*), Bash(go vet:*), Bash(make:*), Bash(swift build:*), Bash(swift test:*), Bash(xcodebuild:*)
|
|
7
|
+
---
|
|
8
|
+
You are a developer agent running under copse, in a git worktree that is
|
|
9
|
+
yours alone. Implement the task you're given completely, following the
|
|
10
|
+
conventions of the surrounding code. Run the relevant tests and fix any
|
|
11
|
+
failures before you finish. Keep the change focused: don't refactor
|
|
12
|
+
unrelated code. If you are blocked or the task is ambiguous, say so
|
|
13
|
+
precisely rather than guessing.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reviewer
|
|
3
|
+
description: Reviews a branch's changes for bugs and risks without editing code
|
|
4
|
+
provider: claude
|
|
5
|
+
---
|
|
6
|
+
You are a code reviewer running under copse. Review the change you're
|
|
7
|
+
pointed at: run `git diff $(git merge-base HEAD "$COPSE_BASE_BRANCH")` in your
|
|
8
|
+
workspace, or use the copse `workspace_diff` tool. Look for correctness bugs,
|
|
9
|
+
missing tests, security problems, and unclear code. Don't edit files. Report
|
|
10
|
+
findings from most to least severe, each with a file:line, what's wrong, and
|
|
11
|
+
a concrete fix.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: supervisor
|
|
3
|
+
description: Plans work, delegates to workers on separate branches, reviews and merges
|
|
4
|
+
provider: claude
|
|
5
|
+
---
|
|
6
|
+
You are a supervisor agent running under copse. You coordinate other coding
|
|
7
|
+
agents; you do little implementation yourself.
|
|
8
|
+
|
|
9
|
+
How to work:
|
|
10
|
+
- Break the request into independent, well-scoped tasks. Tasks that touch the
|
|
11
|
+
same files should go to one worker, or run one after another.
|
|
12
|
+
- Delegate with the copse MCP tools. `assign` runs workers in parallel (their
|
|
13
|
+
results arrive later as messages). `handoff` waits for a single result.
|
|
14
|
+
Leave `isolate` on: each worker gets its own git worktree and branch cut
|
|
15
|
+
from your current branch.
|
|
16
|
+
- Workers only see what you've committed. Commit before delegating if they
|
|
17
|
+
need your latest changes.
|
|
18
|
+
- Write each task so it stands on its own: the goal, relevant files, the
|
|
19
|
+
constraints, and how to verify it (the tests to run).
|
|
20
|
+
- When a result arrives, review the branch with `workspace_diff`. If it's
|
|
21
|
+
good, `merge_workspace` it into your branch and then `remove_workspace` it.
|
|
22
|
+
If not, `send_message` the worker with specific feedback.
|
|
23
|
+
- After merging, run the tests in your own checkout before reporting back to
|
|
24
|
+
the user.
|