zwarm 1.3.3__py3-none-any.whl → 1.3.8__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/adapters/codex_mcp.py +124 -23
- zwarm/cli/main.py +735 -310
- zwarm/sessions/__init__.py +24 -0
- zwarm/sessions/manager.py +589 -0
- zwarm/tools/delegation.py +143 -1
- {zwarm-1.3.3.dist-info → zwarm-1.3.8.dist-info}/METADATA +1 -1
- {zwarm-1.3.3.dist-info → zwarm-1.3.8.dist-info}/RECORD +9 -7
- {zwarm-1.3.3.dist-info → zwarm-1.3.8.dist-info}/WHEEL +0 -0
- {zwarm-1.3.3.dist-info → zwarm-1.3.8.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Codex Session Manager.
|
|
3
|
+
|
|
4
|
+
A standalone session manager for running Codex agents in the background.
|
|
5
|
+
Similar to Sculptor/Claude parallel tools but for Codex.
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- Start codex exec tasks in background processes
|
|
9
|
+
- Monitor status and view message history
|
|
10
|
+
- Inject follow-up messages (continue conversations)
|
|
11
|
+
- Kill running sessions
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from zwarm.sessions.manager import (
|
|
15
|
+
CodexSession,
|
|
16
|
+
CodexSessionManager,
|
|
17
|
+
SessionStatus,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CodexSession",
|
|
22
|
+
"CodexSessionManager",
|
|
23
|
+
"SessionStatus",
|
|
24
|
+
]
|
|
@@ -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
|