zwarm 2.3.5__py3-none-any.whl → 3.6.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.
- zwarm/cli/interactive.py +1065 -0
- zwarm/cli/main.py +525 -934
- zwarm/cli/pilot.py +1240 -0
- zwarm/core/__init__.py +20 -0
- zwarm/core/checkpoints.py +216 -0
- zwarm/core/config.py +26 -9
- zwarm/core/costs.py +71 -0
- zwarm/core/registry.py +329 -0
- zwarm/core/test_config.py +2 -3
- zwarm/orchestrator.py +17 -43
- zwarm/prompts/__init__.py +3 -0
- zwarm/prompts/orchestrator.py +36 -29
- zwarm/prompts/pilot.py +147 -0
- zwarm/sessions/__init__.py +48 -9
- zwarm/sessions/base.py +501 -0
- zwarm/sessions/claude.py +481 -0
- zwarm/sessions/manager.py +233 -486
- zwarm/tools/delegation.py +150 -187
- zwarm-3.6.0.dist-info/METADATA +445 -0
- zwarm-3.6.0.dist-info/RECORD +39 -0
- zwarm/adapters/__init__.py +0 -21
- zwarm/adapters/base.py +0 -109
- zwarm/adapters/claude_code.py +0 -357
- zwarm/adapters/codex_mcp.py +0 -1262
- zwarm/adapters/registry.py +0 -69
- zwarm/adapters/test_codex_mcp.py +0 -274
- zwarm/adapters/test_registry.py +0 -68
- zwarm-2.3.5.dist-info/METADATA +0 -309
- zwarm-2.3.5.dist-info/RECORD +0 -38
- {zwarm-2.3.5.dist-info → zwarm-3.6.0.dist-info}/WHEEL +0 -0
- {zwarm-2.3.5.dist-info → zwarm-3.6.0.dist-info}/entry_points.txt +0 -0
zwarm/sessions/claude.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude Session Manager - Background process management for Claude Code agents.
|
|
3
|
+
|
|
4
|
+
This module implements the ClaudeSessionManager, which handles:
|
|
5
|
+
- Spawning `claude -p --output-format stream-json` subprocesses
|
|
6
|
+
- Parsing Claude Code's stream-json output format
|
|
7
|
+
- Loading config from .zwarm/claude.toml
|
|
8
|
+
|
|
9
|
+
Inherits shared functionality from BaseSessionManager.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import subprocess
|
|
16
|
+
import tomllib
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
from uuid import uuid4
|
|
21
|
+
|
|
22
|
+
from .base import (
|
|
23
|
+
BaseSessionManager,
|
|
24
|
+
Session,
|
|
25
|
+
SessionMessage,
|
|
26
|
+
SessionStatus,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ClaudeSessionManager(BaseSessionManager):
|
|
31
|
+
"""
|
|
32
|
+
Manages background Claude Code sessions.
|
|
33
|
+
|
|
34
|
+
Sessions are stored in:
|
|
35
|
+
.zwarm/sessions/<session_id>/
|
|
36
|
+
meta.json - Session metadata
|
|
37
|
+
turns/
|
|
38
|
+
turn_1.jsonl
|
|
39
|
+
turn_2.jsonl
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
Uses claude -p --output-format stream-json --verbose for JSON output.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
adapter_name = "claude"
|
|
46
|
+
default_model = "sonnet" # Claude uses aliases like sonnet, opus, haiku
|
|
47
|
+
|
|
48
|
+
# =========================================================================
|
|
49
|
+
# Claude-specific config handling
|
|
50
|
+
# =========================================================================
|
|
51
|
+
|
|
52
|
+
def _load_claude_config(self) -> dict[str, Any]:
|
|
53
|
+
"""
|
|
54
|
+
Load claude.toml from state_dir.
|
|
55
|
+
|
|
56
|
+
Returns parsed TOML as dict, or empty dict if not found.
|
|
57
|
+
"""
|
|
58
|
+
claude_toml = self.state_dir / "claude.toml"
|
|
59
|
+
if not claude_toml.exists():
|
|
60
|
+
return {}
|
|
61
|
+
try:
|
|
62
|
+
with open(claude_toml, "rb") as f:
|
|
63
|
+
return tomllib.load(f)
|
|
64
|
+
except Exception:
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
# =========================================================================
|
|
68
|
+
# Session lifecycle (Claude-specific implementation)
|
|
69
|
+
# =========================================================================
|
|
70
|
+
|
|
71
|
+
def start_session(
|
|
72
|
+
self,
|
|
73
|
+
task: str,
|
|
74
|
+
working_dir: Path | None = None,
|
|
75
|
+
model: str | None = None,
|
|
76
|
+
sandbox: str = "workspace-write",
|
|
77
|
+
source: str = "user",
|
|
78
|
+
) -> Session:
|
|
79
|
+
"""
|
|
80
|
+
Start a new Claude Code session in the background.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
task: The task description
|
|
84
|
+
working_dir: Working directory for claude (default: cwd)
|
|
85
|
+
model: Model override (default: from claude.toml or sonnet)
|
|
86
|
+
sandbox: Sandbox mode - maps to permission modes
|
|
87
|
+
source: Who spawned this session ("user" or "orchestrator:<id>")
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The created session
|
|
91
|
+
|
|
92
|
+
Note:
|
|
93
|
+
Settings are read from .zwarm/claude.toml.
|
|
94
|
+
Run `zwarm init` to set up the config.
|
|
95
|
+
"""
|
|
96
|
+
session_id = str(uuid4())
|
|
97
|
+
working_dir = working_dir or Path.cwd()
|
|
98
|
+
now = datetime.now().isoformat()
|
|
99
|
+
|
|
100
|
+
# Load claude config from .zwarm/claude.toml
|
|
101
|
+
claude_config = self._load_claude_config()
|
|
102
|
+
|
|
103
|
+
# Get model from config or use default
|
|
104
|
+
effective_model = model or claude_config.get("model", self.default_model)
|
|
105
|
+
|
|
106
|
+
# Check if full_danger mode is enabled
|
|
107
|
+
full_danger = claude_config.get("full_danger", False)
|
|
108
|
+
|
|
109
|
+
session = Session(
|
|
110
|
+
id=session_id,
|
|
111
|
+
task=task,
|
|
112
|
+
status=SessionStatus.PENDING,
|
|
113
|
+
working_dir=working_dir,
|
|
114
|
+
created_at=now,
|
|
115
|
+
updated_at=now,
|
|
116
|
+
model=effective_model,
|
|
117
|
+
turn=1,
|
|
118
|
+
messages=[SessionMessage(role="user", content=task, timestamp=now)],
|
|
119
|
+
source=source,
|
|
120
|
+
adapter=self.adapter_name,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Create session directory
|
|
124
|
+
session_dir = self._session_dir(session_id)
|
|
125
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
|
|
127
|
+
# Build command
|
|
128
|
+
cmd = [
|
|
129
|
+
"claude",
|
|
130
|
+
"-p", # Print mode (non-interactive)
|
|
131
|
+
"--output-format", "stream-json",
|
|
132
|
+
"--verbose", # Required for stream-json
|
|
133
|
+
"--model", effective_model,
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
# Add working directory access
|
|
137
|
+
if working_dir != Path.cwd():
|
|
138
|
+
cmd.extend(["--add-dir", str(working_dir.absolute())])
|
|
139
|
+
|
|
140
|
+
# Full danger mode bypasses all permission checks
|
|
141
|
+
if full_danger:
|
|
142
|
+
cmd.append("--dangerously-skip-permissions")
|
|
143
|
+
|
|
144
|
+
# Add the task as the prompt
|
|
145
|
+
cmd.append(task)
|
|
146
|
+
|
|
147
|
+
# Start process with output redirected to file
|
|
148
|
+
output_path = self._output_path(session_id, 1)
|
|
149
|
+
output_file = open(output_path, "w")
|
|
150
|
+
|
|
151
|
+
proc = subprocess.Popen(
|
|
152
|
+
cmd,
|
|
153
|
+
cwd=working_dir,
|
|
154
|
+
stdout=output_file,
|
|
155
|
+
stderr=subprocess.STDOUT,
|
|
156
|
+
start_new_session=True, # Detach from parent process group
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
session.pid = proc.pid
|
|
160
|
+
session.status = SessionStatus.RUNNING
|
|
161
|
+
self._save_session(session)
|
|
162
|
+
|
|
163
|
+
return session
|
|
164
|
+
|
|
165
|
+
def inject_message(
|
|
166
|
+
self,
|
|
167
|
+
session_id: str,
|
|
168
|
+
message: str,
|
|
169
|
+
) -> Session | None:
|
|
170
|
+
"""
|
|
171
|
+
Inject a follow-up message into a completed session.
|
|
172
|
+
|
|
173
|
+
Claude Code supports --continue to continue a session, but for
|
|
174
|
+
simplicity we use the same context-injection pattern as Codex.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
session_id: Session to continue
|
|
178
|
+
message: The follow-up message
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Updated session or None if not found/not ready
|
|
182
|
+
"""
|
|
183
|
+
session = self.get_session(session_id)
|
|
184
|
+
if not session:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
if session.status == SessionStatus.RUNNING:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
# Build context from previous messages
|
|
191
|
+
context_parts = []
|
|
192
|
+
for msg in session.messages:
|
|
193
|
+
if msg.role == "user":
|
|
194
|
+
context_parts.append(f"USER: {msg.content}")
|
|
195
|
+
elif msg.role == "assistant":
|
|
196
|
+
context_parts.append(f"ASSISTANT: {msg.content}")
|
|
197
|
+
|
|
198
|
+
# Create augmented prompt with context
|
|
199
|
+
augmented_task = f"""Continue the following conversation:
|
|
200
|
+
|
|
201
|
+
{chr(10).join(context_parts)}
|
|
202
|
+
|
|
203
|
+
USER: {message}
|
|
204
|
+
|
|
205
|
+
Continue from where you left off, addressing the user's new message."""
|
|
206
|
+
|
|
207
|
+
# Start new turn
|
|
208
|
+
session.turn += 1
|
|
209
|
+
now = datetime.now().isoformat()
|
|
210
|
+
session.messages.append(
|
|
211
|
+
SessionMessage(role="user", content=message, timestamp=now)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Build command
|
|
215
|
+
claude_config = self._load_claude_config()
|
|
216
|
+
full_danger = claude_config.get("full_danger", False)
|
|
217
|
+
|
|
218
|
+
cmd = [
|
|
219
|
+
"claude",
|
|
220
|
+
"-p",
|
|
221
|
+
"--output-format", "stream-json",
|
|
222
|
+
"--verbose",
|
|
223
|
+
"--model", session.model,
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
if session.working_dir != Path.cwd():
|
|
227
|
+
cmd.extend(["--add-dir", str(session.working_dir.absolute())])
|
|
228
|
+
|
|
229
|
+
if full_danger:
|
|
230
|
+
cmd.append("--dangerously-skip-permissions")
|
|
231
|
+
|
|
232
|
+
cmd.append(augmented_task)
|
|
233
|
+
|
|
234
|
+
# Start process
|
|
235
|
+
output_path = self._output_path(session.id, session.turn)
|
|
236
|
+
output_file = open(output_path, "w")
|
|
237
|
+
|
|
238
|
+
proc = subprocess.Popen(
|
|
239
|
+
cmd,
|
|
240
|
+
cwd=session.working_dir,
|
|
241
|
+
stdout=output_file,
|
|
242
|
+
stderr=subprocess.STDOUT,
|
|
243
|
+
start_new_session=True,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
session.pid = proc.pid
|
|
247
|
+
session.status = SessionStatus.RUNNING
|
|
248
|
+
self._save_session(session)
|
|
249
|
+
|
|
250
|
+
return session
|
|
251
|
+
|
|
252
|
+
# =========================================================================
|
|
253
|
+
# Output parsing (Claude-specific stream-json format)
|
|
254
|
+
# =========================================================================
|
|
255
|
+
|
|
256
|
+
def _is_output_complete(self, session_id: str, turn: int) -> bool:
|
|
257
|
+
"""
|
|
258
|
+
Check if output file indicates the task completed.
|
|
259
|
+
|
|
260
|
+
Looks for {"type":"result",...} in the stream-json output.
|
|
261
|
+
"""
|
|
262
|
+
output_path = self._output_path(session_id, turn)
|
|
263
|
+
if not output_path.exists():
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
content = output_path.read_text()
|
|
268
|
+
for line in content.strip().split("\n"):
|
|
269
|
+
if not line.strip():
|
|
270
|
+
continue
|
|
271
|
+
try:
|
|
272
|
+
event = json.loads(line)
|
|
273
|
+
event_type = event.get("type", "")
|
|
274
|
+
# Claude uses "result" for completion
|
|
275
|
+
if event_type == "result":
|
|
276
|
+
return True
|
|
277
|
+
except json.JSONDecodeError:
|
|
278
|
+
continue
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
def _parse_output(
|
|
285
|
+
self, output_path: Path
|
|
286
|
+
) -> tuple[list[SessionMessage], dict[str, int], str | None]:
|
|
287
|
+
"""
|
|
288
|
+
Parse stream-json output from claude -p.
|
|
289
|
+
|
|
290
|
+
Claude's stream-json format:
|
|
291
|
+
- {"type":"system","subtype":"init",...}
|
|
292
|
+
- {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
|
|
293
|
+
- {"type":"user","message":{"content":[{"type":"tool_result",...}]}}
|
|
294
|
+
- {"type":"result","usage":{...},...}
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
(messages, token_usage, error)
|
|
298
|
+
"""
|
|
299
|
+
messages: list[SessionMessage] = []
|
|
300
|
+
usage: dict[str, int] = {}
|
|
301
|
+
error: str | None = None
|
|
302
|
+
|
|
303
|
+
if not output_path.exists():
|
|
304
|
+
return messages, usage, "Output file not found"
|
|
305
|
+
|
|
306
|
+
content = output_path.read_text()
|
|
307
|
+
|
|
308
|
+
for line in content.strip().split("\n"):
|
|
309
|
+
if not line.strip():
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
event = json.loads(line)
|
|
314
|
+
except json.JSONDecodeError:
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
event_type = event.get("type", "")
|
|
318
|
+
|
|
319
|
+
if event_type == "assistant":
|
|
320
|
+
# Assistant message with content array
|
|
321
|
+
msg = event.get("message", {})
|
|
322
|
+
content_blocks = msg.get("content", [])
|
|
323
|
+
|
|
324
|
+
for block in content_blocks:
|
|
325
|
+
block_type = block.get("type", "")
|
|
326
|
+
|
|
327
|
+
if block_type == "text":
|
|
328
|
+
text = block.get("text", "")
|
|
329
|
+
if text:
|
|
330
|
+
messages.append(
|
|
331
|
+
SessionMessage(
|
|
332
|
+
role="assistant",
|
|
333
|
+
content=text,
|
|
334
|
+
timestamp=datetime.now().isoformat(),
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
elif block_type == "tool_use":
|
|
339
|
+
# Track tool calls
|
|
340
|
+
tool_name = block.get("name", "unknown")
|
|
341
|
+
messages.append(
|
|
342
|
+
SessionMessage(
|
|
343
|
+
role="tool",
|
|
344
|
+
content=f"[Calling: {tool_name}]",
|
|
345
|
+
metadata={"function": tool_name, "tool_use_id": block.get("id")},
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
elif event_type == "user":
|
|
350
|
+
# Tool results
|
|
351
|
+
msg = event.get("message", {})
|
|
352
|
+
content_blocks = msg.get("content", [])
|
|
353
|
+
|
|
354
|
+
for block in content_blocks:
|
|
355
|
+
if block.get("type") == "tool_result":
|
|
356
|
+
tool_content = block.get("content", "")
|
|
357
|
+
is_error = block.get("is_error", False)
|
|
358
|
+
if tool_content and len(str(tool_content)) < 500:
|
|
359
|
+
prefix = "[Error]" if is_error else "[Output]"
|
|
360
|
+
messages.append(
|
|
361
|
+
SessionMessage(
|
|
362
|
+
role="tool",
|
|
363
|
+
content=f"{prefix}: {str(tool_content)[:500]}",
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
elif event_type == "result":
|
|
368
|
+
# Final result with usage info
|
|
369
|
+
result_usage = event.get("usage", {})
|
|
370
|
+
|
|
371
|
+
# Map Claude's usage fields to our standard format
|
|
372
|
+
usage["input_tokens"] = result_usage.get("input_tokens", 0)
|
|
373
|
+
usage["output_tokens"] = result_usage.get("output_tokens", 0)
|
|
374
|
+
usage["cache_read_input_tokens"] = result_usage.get("cache_read_input_tokens", 0)
|
|
375
|
+
usage["cache_creation_input_tokens"] = result_usage.get("cache_creation_input_tokens", 0)
|
|
376
|
+
usage["total_tokens"] = usage["input_tokens"] + usage["output_tokens"]
|
|
377
|
+
|
|
378
|
+
# Check for errors
|
|
379
|
+
if event.get("is_error"):
|
|
380
|
+
error = event.get("result", "Unknown error")
|
|
381
|
+
elif event.get("subtype") == "error":
|
|
382
|
+
error = event.get("result", "Unknown error")
|
|
383
|
+
|
|
384
|
+
return messages, usage, error
|
|
385
|
+
|
|
386
|
+
def get_trajectory(
|
|
387
|
+
self, session_id: str, full: bool = False, max_output_len: int = 200
|
|
388
|
+
) -> list[dict]:
|
|
389
|
+
"""
|
|
390
|
+
Get the full trajectory of a session - all steps in order.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
session_id: Session to get trajectory for
|
|
394
|
+
full: If True, include full untruncated content
|
|
395
|
+
max_output_len: Max length for outputs when full=False
|
|
396
|
+
|
|
397
|
+
Returns a list of step dicts with type, summary, and details.
|
|
398
|
+
"""
|
|
399
|
+
if full:
|
|
400
|
+
max_output_len = 999999
|
|
401
|
+
|
|
402
|
+
session = self.get_session(session_id)
|
|
403
|
+
if not session:
|
|
404
|
+
return []
|
|
405
|
+
|
|
406
|
+
trajectory = []
|
|
407
|
+
|
|
408
|
+
for turn in range(1, session.turn + 1):
|
|
409
|
+
output_path = self._output_path(session.id, turn)
|
|
410
|
+
if not output_path.exists():
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
content = output_path.read_text()
|
|
414
|
+
step_num = 0
|
|
415
|
+
|
|
416
|
+
for line in content.strip().split("\n"):
|
|
417
|
+
if not line.strip():
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
event = json.loads(line)
|
|
422
|
+
except json.JSONDecodeError:
|
|
423
|
+
continue
|
|
424
|
+
|
|
425
|
+
event_type = event.get("type", "")
|
|
426
|
+
|
|
427
|
+
if event_type == "assistant":
|
|
428
|
+
msg = event.get("message", {})
|
|
429
|
+
content_blocks = msg.get("content", [])
|
|
430
|
+
|
|
431
|
+
for block in content_blocks:
|
|
432
|
+
block_type = block.get("type", "")
|
|
433
|
+
step_num += 1
|
|
434
|
+
|
|
435
|
+
if block_type == "text":
|
|
436
|
+
text = block.get("text", "")
|
|
437
|
+
summary_len = max_output_len if full else 200
|
|
438
|
+
trajectory.append({
|
|
439
|
+
"turn": turn,
|
|
440
|
+
"step": step_num,
|
|
441
|
+
"type": "message",
|
|
442
|
+
"summary": text[:summary_len] + ("..." if len(text) > summary_len else ""),
|
|
443
|
+
"full_text": text if full else None,
|
|
444
|
+
"full_length": len(text),
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
elif block_type == "tool_use":
|
|
448
|
+
tool_name = block.get("name", "unknown")
|
|
449
|
+
args = block.get("input", {})
|
|
450
|
+
args_str = str(args)
|
|
451
|
+
args_len = max_output_len if full else 100
|
|
452
|
+
trajectory.append({
|
|
453
|
+
"turn": turn,
|
|
454
|
+
"step": step_num,
|
|
455
|
+
"type": "tool_call",
|
|
456
|
+
"tool": tool_name,
|
|
457
|
+
"args_preview": args_str[:args_len] + ("..." if len(args_str) > args_len else ""),
|
|
458
|
+
"full_args": args if full else None,
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
elif event_type == "user":
|
|
462
|
+
# Tool results
|
|
463
|
+
msg = event.get("message", {})
|
|
464
|
+
content_blocks = msg.get("content", [])
|
|
465
|
+
|
|
466
|
+
for block in content_blocks:
|
|
467
|
+
if block.get("type") == "tool_result":
|
|
468
|
+
step_num += 1
|
|
469
|
+
output = str(block.get("content", ""))
|
|
470
|
+
output_preview = output[:max_output_len]
|
|
471
|
+
if len(output) > max_output_len:
|
|
472
|
+
output_preview += "..."
|
|
473
|
+
trajectory.append({
|
|
474
|
+
"turn": turn,
|
|
475
|
+
"step": step_num,
|
|
476
|
+
"type": "tool_output",
|
|
477
|
+
"output": output_preview,
|
|
478
|
+
"is_error": block.get("is_error", False),
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
return trajectory
|