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 ADDED
@@ -0,0 +1,3 @@
1
+ """copse: run CLI coding agents in tmux, each isolated on its own git worktree."""
2
+
3
+ __version__ = "0.1.0"
copse/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from copse.cli import app
2
+
3
+ app()
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.