zwarm 1.3.10__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,589 @@
1
+ """
2
+ Codex Session Manager - Background process management for Codex agents.
3
+
4
+ Architecture:
5
+ - Each session runs `codex exec --json` in a background subprocess
6
+ - Output is streamed to .zwarm/sessions/<session_id>/output.jsonl
7
+ - Session metadata stored in meta.json
8
+ - Can inject follow-up messages by starting new turns with context
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import signal
16
+ import subprocess
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime
20
+ from enum import Enum
21
+ from pathlib import Path
22
+ from typing import Any
23
+ from uuid import uuid4
24
+
25
+
26
+ class SessionStatus(str, Enum):
27
+ """Status of a codex session."""
28
+ PENDING = "pending" # Created but not started
29
+ RUNNING = "running" # Process is running
30
+ COMPLETED = "completed" # Process exited successfully
31
+ FAILED = "failed" # Process exited with error
32
+ KILLED = "killed" # Manually killed
33
+
34
+
35
+ @dataclass
36
+ class SessionMessage:
37
+ """A message in a session's history."""
38
+ role: str # "user", "assistant", "system", "tool"
39
+ content: str
40
+ timestamp: str = ""
41
+ metadata: dict[str, Any] = field(default_factory=dict)
42
+
43
+ def to_dict(self) -> dict:
44
+ return {
45
+ "role": self.role,
46
+ "content": self.content,
47
+ "timestamp": self.timestamp,
48
+ "metadata": self.metadata,
49
+ }
50
+
51
+ @classmethod
52
+ def from_dict(cls, data: dict) -> "SessionMessage":
53
+ return cls(
54
+ role=data.get("role", "unknown"),
55
+ content=data.get("content", ""),
56
+ timestamp=data.get("timestamp", ""),
57
+ metadata=data.get("metadata", {}),
58
+ )
59
+
60
+
61
+ @dataclass
62
+ class CodexSession:
63
+ """A managed Codex session."""
64
+ id: str
65
+ task: str
66
+ status: SessionStatus
67
+ working_dir: Path
68
+ created_at: str
69
+ updated_at: str
70
+ pid: int | None = None
71
+ exit_code: int | None = None
72
+ model: str = "gpt-5.1-codex-mini"
73
+ turn: int = 1
74
+ messages: list[SessionMessage] = field(default_factory=list)
75
+ token_usage: dict[str, int] = field(default_factory=dict)
76
+ error: str | None = None
77
+ # Source tracking: "user" for direct spawns, "orchestrator:<instance_id>" for delegated
78
+ source: str = "user"
79
+ # Adapter used: "codex", "claude_code", etc.
80
+ adapter: str = "codex"
81
+
82
+ def to_dict(self) -> dict:
83
+ return {
84
+ "id": self.id,
85
+ "task": self.task,
86
+ "status": self.status.value,
87
+ "working_dir": str(self.working_dir),
88
+ "created_at": self.created_at,
89
+ "updated_at": self.updated_at,
90
+ "pid": self.pid,
91
+ "exit_code": self.exit_code,
92
+ "model": self.model,
93
+ "turn": self.turn,
94
+ "messages": [m.to_dict() for m in self.messages],
95
+ "token_usage": self.token_usage,
96
+ "error": self.error,
97
+ "source": self.source,
98
+ "adapter": self.adapter,
99
+ }
100
+
101
+ @classmethod
102
+ def from_dict(cls, data: dict) -> "CodexSession":
103
+ return cls(
104
+ id=data["id"],
105
+ task=data["task"],
106
+ status=SessionStatus(data["status"]),
107
+ working_dir=Path(data["working_dir"]),
108
+ created_at=data["created_at"],
109
+ updated_at=data["updated_at"],
110
+ pid=data.get("pid"),
111
+ exit_code=data.get("exit_code"),
112
+ model=data.get("model", "gpt-5.1-codex-mini"),
113
+ turn=data.get("turn", 1),
114
+ messages=[SessionMessage.from_dict(m) for m in data.get("messages", [])],
115
+ token_usage=data.get("token_usage", {}),
116
+ error=data.get("error"),
117
+ source=data.get("source", "user"),
118
+ adapter=data.get("adapter", "codex"),
119
+ )
120
+
121
+ @property
122
+ def is_running(self) -> bool:
123
+ """Check if the session process is still running."""
124
+ if self.pid is None:
125
+ return False
126
+ try:
127
+ os.kill(self.pid, 0) # Signal 0 just checks if process exists
128
+ return True
129
+ except OSError:
130
+ return False
131
+
132
+ @property
133
+ def short_id(self) -> str:
134
+ """Get first 8 chars of ID for display."""
135
+ return self.id[:8]
136
+
137
+ @property
138
+ def runtime(self) -> str:
139
+ """Get human-readable runtime."""
140
+ created = datetime.fromisoformat(self.created_at)
141
+ now = datetime.now()
142
+ delta = now - created
143
+
144
+ if delta.total_seconds() < 60:
145
+ return f"{int(delta.total_seconds())}s"
146
+ elif delta.total_seconds() < 3600:
147
+ return f"{int(delta.total_seconds() / 60)}m"
148
+ else:
149
+ return f"{delta.total_seconds() / 3600:.1f}h"
150
+
151
+ @property
152
+ def source_display(self) -> str:
153
+ """Get short display string for source."""
154
+ if self.source == "user":
155
+ return "you"
156
+ elif self.source.startswith("orchestrator:"):
157
+ # Extract instance ID and shorten it
158
+ instance_id = self.source.split(":", 1)[1]
159
+ return f"orch:{instance_id[:4]}"
160
+ else:
161
+ return self.source[:8]
162
+
163
+
164
+ class CodexSessionManager:
165
+ """
166
+ Manages background Codex sessions.
167
+
168
+ Sessions are stored in:
169
+ .zwarm/sessions/<session_id>/
170
+ meta.json - Session metadata
171
+ output.jsonl - Raw JSONL output from codex exec
172
+ turns/
173
+ turn_1.jsonl
174
+ turn_2.jsonl
175
+ ...
176
+ """
177
+
178
+ def __init__(self, state_dir: Path | str = ".zwarm"):
179
+ self.state_dir = Path(state_dir)
180
+ self.sessions_dir = self.state_dir / "sessions"
181
+ self.sessions_dir.mkdir(parents=True, exist_ok=True)
182
+
183
+ def _session_dir(self, session_id: str) -> Path:
184
+ """Get the directory for a session."""
185
+ return self.sessions_dir / session_id
186
+
187
+ def _meta_path(self, session_id: str) -> Path:
188
+ """Get the metadata file path for a session."""
189
+ return self._session_dir(session_id) / "meta.json"
190
+
191
+ def _output_path(self, session_id: str, turn: int = 1) -> Path:
192
+ """Get the output file path for a session turn."""
193
+ session_dir = self._session_dir(session_id)
194
+ turns_dir = session_dir / "turns"
195
+ turns_dir.mkdir(parents=True, exist_ok=True)
196
+ return turns_dir / f"turn_{turn}.jsonl"
197
+
198
+ def _save_session(self, session: CodexSession) -> None:
199
+ """Save session metadata."""
200
+ session.updated_at = datetime.now().isoformat()
201
+ meta_path = self._meta_path(session.id)
202
+ meta_path.parent.mkdir(parents=True, exist_ok=True)
203
+ meta_path.write_text(json.dumps(session.to_dict(), indent=2))
204
+
205
+ def _load_session(self, session_id: str) -> CodexSession | None:
206
+ """Load session from disk."""
207
+ meta_path = self._meta_path(session_id)
208
+ if not meta_path.exists():
209
+ return None
210
+ try:
211
+ data = json.loads(meta_path.read_text())
212
+ return CodexSession.from_dict(data)
213
+ except (json.JSONDecodeError, KeyError) as e:
214
+ print(f"Error loading session {session_id}: {e}")
215
+ return None
216
+
217
+ def list_sessions(self, status: SessionStatus | None = None) -> list[CodexSession]:
218
+ """List all sessions, optionally filtered by status."""
219
+ sessions = []
220
+ if not self.sessions_dir.exists():
221
+ return sessions
222
+
223
+ for session_dir in self.sessions_dir.iterdir():
224
+ if not session_dir.is_dir():
225
+ continue
226
+ session = self._load_session(session_dir.name)
227
+ if session:
228
+ # Update status if process died
229
+ if session.status == SessionStatus.RUNNING and not session.is_running:
230
+ self._update_session_status(session)
231
+
232
+ if status is None or session.status == status:
233
+ sessions.append(session)
234
+
235
+ # Sort by created_at descending (newest first)
236
+ sessions.sort(key=lambda s: s.created_at, reverse=True)
237
+ return sessions
238
+
239
+ def get_session(self, session_id: str) -> CodexSession | None:
240
+ """Get a session by ID (supports partial ID matching)."""
241
+ # Try exact match first
242
+ session = self._load_session(session_id)
243
+ if session:
244
+ if session.status == SessionStatus.RUNNING and not session.is_running:
245
+ self._update_session_status(session)
246
+ return session
247
+
248
+ # Try partial match
249
+ for session_dir in self.sessions_dir.iterdir():
250
+ if session_dir.name.startswith(session_id):
251
+ session = self._load_session(session_dir.name)
252
+ if session:
253
+ if session.status == SessionStatus.RUNNING and not session.is_running:
254
+ self._update_session_status(session)
255
+ return session
256
+
257
+ return None
258
+
259
+ def _update_session_status(self, session: CodexSession) -> None:
260
+ """Update session status after process completion."""
261
+ # Parse output to determine status
262
+ output_path = self._output_path(session.id, session.turn)
263
+ if output_path.exists():
264
+ messages, usage, error = self._parse_output(output_path)
265
+ session.messages = messages
266
+ session.token_usage = usage
267
+
268
+ if error:
269
+ session.status = SessionStatus.FAILED
270
+ session.error = error
271
+ else:
272
+ session.status = SessionStatus.COMPLETED
273
+ else:
274
+ session.status = SessionStatus.FAILED
275
+ session.error = "No output file found"
276
+
277
+ self._save_session(session)
278
+
279
+ def start_session(
280
+ self,
281
+ task: str,
282
+ working_dir: Path | None = None,
283
+ model: str = "gpt-5.1-codex-mini",
284
+ sandbox: str = "workspace-write",
285
+ source: str = "user",
286
+ adapter: str = "codex",
287
+ ) -> CodexSession:
288
+ """
289
+ Start a new Codex session in the background.
290
+
291
+ Args:
292
+ task: The task description
293
+ working_dir: Working directory for codex (default: cwd)
294
+ model: Model to use
295
+ sandbox: Sandbox mode
296
+ source: Who spawned this session ("user" or "orchestrator:<id>")
297
+ adapter: Which adapter to use ("codex", "claude_code")
298
+
299
+ Returns:
300
+ The created session
301
+ """
302
+ session_id = str(uuid4())
303
+ working_dir = working_dir or Path.cwd()
304
+ now = datetime.now().isoformat()
305
+
306
+ session = CodexSession(
307
+ id=session_id,
308
+ task=task,
309
+ status=SessionStatus.PENDING,
310
+ working_dir=working_dir,
311
+ created_at=now,
312
+ updated_at=now,
313
+ model=model,
314
+ turn=1,
315
+ messages=[SessionMessage(role="user", content=task, timestamp=now)],
316
+ source=source,
317
+ adapter=adapter,
318
+ )
319
+
320
+ # Create session directory
321
+ session_dir = self._session_dir(session_id)
322
+ session_dir.mkdir(parents=True, exist_ok=True)
323
+
324
+ # Build command
325
+ cmd = [
326
+ "codex", "exec",
327
+ "--json",
328
+ "--model", model,
329
+ "-C", str(working_dir.absolute()),
330
+ ]
331
+
332
+ # Add sandbox mode
333
+ if sandbox == "danger-full-access":
334
+ cmd.append("--dangerously-bypass-approvals-and-sandbox")
335
+ elif sandbox == "workspace-write":
336
+ # Default codex behavior
337
+ pass
338
+
339
+ cmd.extend(["--", task])
340
+
341
+ # Start process with output redirected to file
342
+ output_path = self._output_path(session_id, 1)
343
+ output_file = open(output_path, "w")
344
+
345
+ proc = subprocess.Popen(
346
+ cmd,
347
+ cwd=working_dir,
348
+ stdout=output_file,
349
+ stderr=subprocess.STDOUT,
350
+ start_new_session=True, # Detach from parent process group
351
+ )
352
+
353
+ session.pid = proc.pid
354
+ session.status = SessionStatus.RUNNING
355
+ self._save_session(session)
356
+
357
+ return session
358
+
359
+ def inject_message(
360
+ self,
361
+ session_id: str,
362
+ message: str,
363
+ ) -> CodexSession | None:
364
+ """
365
+ Inject a follow-up message into a completed session.
366
+
367
+ This starts a new turn with the conversation context.
368
+
369
+ Args:
370
+ session_id: Session to continue
371
+ message: The follow-up message
372
+
373
+ Returns:
374
+ Updated session or None if not found/not ready
375
+ """
376
+ session = self.get_session(session_id)
377
+ if not session:
378
+ return None
379
+
380
+ if session.status == SessionStatus.RUNNING:
381
+ # Can't inject while running - would need to implement
382
+ # a more complex IPC mechanism for that
383
+ return None
384
+
385
+ # Build context from previous messages
386
+ context_parts = []
387
+ for msg in session.messages:
388
+ if msg.role == "user":
389
+ context_parts.append(f"USER: {msg.content}")
390
+ elif msg.role == "assistant":
391
+ context_parts.append(f"ASSISTANT: {msg.content}")
392
+
393
+ # Create augmented prompt with context
394
+ augmented_task = f"""Continue the following conversation:
395
+
396
+ {chr(10).join(context_parts)}
397
+
398
+ USER: {message}
399
+
400
+ Continue from where you left off, addressing the user's new message."""
401
+
402
+ # Start new turn
403
+ session.turn += 1
404
+ now = datetime.now().isoformat()
405
+ session.messages.append(SessionMessage(role="user", content=message, timestamp=now))
406
+
407
+ # Build command
408
+ cmd = [
409
+ "codex", "exec",
410
+ "--json",
411
+ "--model", session.model,
412
+ "-C", str(session.working_dir.absolute()),
413
+ "--", augmented_task,
414
+ ]
415
+
416
+ # Start process
417
+ output_path = self._output_path(session.id, session.turn)
418
+ output_file = open(output_path, "w")
419
+
420
+ proc = subprocess.Popen(
421
+ cmd,
422
+ cwd=session.working_dir,
423
+ stdout=output_file,
424
+ stderr=subprocess.STDOUT,
425
+ start_new_session=True,
426
+ )
427
+
428
+ session.pid = proc.pid
429
+ session.status = SessionStatus.RUNNING
430
+ self._save_session(session)
431
+
432
+ return session
433
+
434
+ def kill_session(self, session_id: str) -> bool:
435
+ """
436
+ Kill a running session.
437
+
438
+ Returns True if killed, False if not found or not running.
439
+ """
440
+ session = self.get_session(session_id)
441
+ if not session:
442
+ return False
443
+
444
+ if session.pid and session.is_running:
445
+ try:
446
+ # Kill the entire process group
447
+ os.killpg(os.getpgid(session.pid), signal.SIGTERM)
448
+ time.sleep(0.5)
449
+
450
+ # Force kill if still running
451
+ if session.is_running:
452
+ os.killpg(os.getpgid(session.pid), signal.SIGKILL)
453
+ except (OSError, ProcessLookupError):
454
+ pass
455
+
456
+ session.status = SessionStatus.KILLED
457
+ session.error = "Manually killed"
458
+ self._save_session(session)
459
+ return True
460
+
461
+ def get_output(self, session_id: str, turn: int | None = None) -> str:
462
+ """Get raw JSONL output for a session."""
463
+ session = self.get_session(session_id)
464
+ if not session:
465
+ return ""
466
+
467
+ if turn is None:
468
+ turn = session.turn
469
+
470
+ output_path = self._output_path(session.id, turn)
471
+ if not output_path.exists():
472
+ return ""
473
+
474
+ return output_path.read_text()
475
+
476
+ def get_messages(self, session_id: str) -> list[SessionMessage]:
477
+ """Get parsed messages for a session across all turns."""
478
+ session = self.get_session(session_id)
479
+ if not session:
480
+ return []
481
+
482
+ all_messages = []
483
+
484
+ # Get messages from each turn
485
+ for turn in range(1, session.turn + 1):
486
+ output_path = self._output_path(session.id, turn)
487
+ if output_path.exists():
488
+ messages, _, _ = self._parse_output(output_path)
489
+ all_messages.extend(messages)
490
+
491
+ return all_messages
492
+
493
+ def _parse_output(self, output_path: Path) -> tuple[list[SessionMessage], dict[str, int], str | None]:
494
+ """
495
+ Parse JSONL output from codex exec.
496
+
497
+ Returns:
498
+ (messages, token_usage, error)
499
+ """
500
+ messages: list[SessionMessage] = []
501
+ usage: dict[str, int] = {}
502
+ error: str | None = None
503
+
504
+ if not output_path.exists():
505
+ return messages, usage, "Output file not found"
506
+
507
+ content = output_path.read_text()
508
+
509
+ for line in content.strip().split("\n"):
510
+ if not line.strip():
511
+ continue
512
+
513
+ try:
514
+ event = json.loads(line)
515
+ except json.JSONDecodeError:
516
+ continue
517
+
518
+ event_type = event.get("type", "")
519
+
520
+ if event_type == "item.completed":
521
+ item = event.get("item", {})
522
+ item_type = item.get("type", "")
523
+
524
+ if item_type == "agent_message":
525
+ text = item.get("text", "")
526
+ if text:
527
+ messages.append(SessionMessage(
528
+ role="assistant",
529
+ content=text,
530
+ timestamp=datetime.now().isoformat(),
531
+ ))
532
+
533
+ elif item_type == "reasoning":
534
+ # Could optionally capture reasoning
535
+ pass
536
+
537
+ elif item_type == "function_call":
538
+ # Track tool calls
539
+ func_name = item.get("name", "unknown")
540
+ messages.append(SessionMessage(
541
+ role="tool",
542
+ content=f"[Calling: {func_name}]",
543
+ metadata={"function": func_name},
544
+ ))
545
+
546
+ elif item_type == "function_call_output":
547
+ output = item.get("output", "")
548
+ if output and len(output) < 500:
549
+ messages.append(SessionMessage(
550
+ role="tool",
551
+ content=f"[Output]: {output[:500]}",
552
+ ))
553
+
554
+ elif event_type == "turn.completed":
555
+ turn_usage = event.get("usage", {})
556
+ for key, value in turn_usage.items():
557
+ usage[key] = usage.get(key, 0) + value
558
+
559
+ elif event_type == "error":
560
+ error = event.get("message", str(event))
561
+
562
+ return messages, usage, error
563
+
564
+ def cleanup_completed(self, keep_days: int = 7) -> int:
565
+ """
566
+ Remove old completed/failed/killed sessions.
567
+
568
+ Args:
569
+ keep_days: Keep sessions newer than this many days
570
+
571
+ Returns:
572
+ Number of sessions cleaned up
573
+ """
574
+ import shutil
575
+ from datetime import timedelta
576
+
577
+ cutoff = datetime.now() - timedelta(days=keep_days)
578
+ cleaned = 0
579
+
580
+ for session in self.list_sessions():
581
+ if session.status in (SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.KILLED):
582
+ created = datetime.fromisoformat(session.created_at)
583
+ if created < cutoff:
584
+ session_dir = self._session_dir(session.id)
585
+ if session_dir.exists():
586
+ shutil.rmtree(session_dir)
587
+ cleaned += 1
588
+
589
+ return cleaned
@@ -0,0 +1,23 @@
1
+ """Tests for orchestrator watcher integration."""
2
+
3
+ from zwarm.core.config import WeaveConfig, ZwarmConfig
4
+ from zwarm.core.environment import OrchestratorEnv
5
+ from zwarm.orchestrator import Orchestrator
6
+ from zwarm.prompts import get_orchestrator_prompt
7
+ from zwarm.watchers import WatcherAction
8
+
9
+
10
+ def test_run_watchers_builds_context(tmp_path):
11
+ """Orchestrator should build WatcherContext without crashing."""
12
+ config = ZwarmConfig(weave=WeaveConfig(enabled=False))
13
+ env = OrchestratorEnv(task="Test task", working_dir=tmp_path)
14
+
15
+ orchestrator = Orchestrator(
16
+ config=config,
17
+ working_dir=tmp_path,
18
+ system_prompt=get_orchestrator_prompt(working_dir=str(tmp_path)),
19
+ maxSteps=3,
20
+ env=env,
21
+ )
22
+
23
+ assert orchestrator._run_watchers() == WatcherAction.CONTINUE
@@ -0,0 +1,17 @@
1
+ """Orchestrator tools for delegating work to executors."""
2
+
3
+ from zwarm.tools.delegation import (
4
+ check_session,
5
+ converse,
6
+ delegate,
7
+ end_session,
8
+ list_sessions,
9
+ )
10
+
11
+ __all__ = [
12
+ "delegate",
13
+ "converse",
14
+ "check_session",
15
+ "end_session",
16
+ "list_sessions",
17
+ ]