zwarm 3.2.1__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.
@@ -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