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.
- zwarm/cli/interactive.py +346 -30
- zwarm/cli/main.py +221 -90
- zwarm/cli/pilot.py +107 -9
- zwarm/core/config.py +26 -9
- zwarm/core/costs.py +55 -183
- zwarm/core/registry.py +329 -0
- zwarm/core/test_config.py +2 -3
- zwarm/orchestrator.py +17 -43
- 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 +93 -31
- {zwarm-3.2.1.dist-info → zwarm-3.6.0.dist-info}/METADATA +73 -21
- {zwarm-3.2.1.dist-info → zwarm-3.6.0.dist-info}/RECORD +17 -21
- 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-3.2.1.dist-info → zwarm-3.6.0.dist-info}/WHEEL +0 -0
- {zwarm-3.2.1.dist-info → zwarm-3.6.0.dist-info}/entry_points.txt +0 -0
zwarm/sessions/manager.py
CHANGED
|
@@ -1,387 +1,178 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Codex Session Manager - Background process management for Codex agents.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
|
|
4
|
+
This module implements the CodexSessionManager, which handles:
|
|
5
|
+
- Spawning `codex exec --json` subprocesses
|
|
6
|
+
- Parsing Codex's JSONL output format
|
|
7
|
+
- Loading config from .zwarm/codex.toml
|
|
8
|
+
|
|
9
|
+
Inherits shared functionality from BaseSessionManager.
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
12
|
from __future__ import annotations
|
|
12
13
|
|
|
13
14
|
import json
|
|
14
|
-
import os
|
|
15
|
-
import signal
|
|
16
15
|
import subprocess
|
|
17
|
-
import
|
|
18
|
-
from dataclasses import dataclass, field
|
|
16
|
+
import tomllib
|
|
19
17
|
from datetime import datetime
|
|
20
|
-
from enum import Enum
|
|
21
18
|
from pathlib import Path
|
|
22
19
|
from typing import Any
|
|
23
20
|
from uuid import uuid4
|
|
24
21
|
|
|
22
|
+
from .base import (
|
|
23
|
+
BaseSessionManager,
|
|
24
|
+
Session,
|
|
25
|
+
SessionMessage,
|
|
26
|
+
SessionStatus,
|
|
27
|
+
)
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
)
|
|
29
|
+
# Re-export for backwards compatibility
|
|
30
|
+
CodexSession = Session
|
|
120
31
|
|
|
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
32
|
|
|
132
|
-
|
|
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:
|
|
33
|
+
class CodexSessionManager(BaseSessionManager):
|
|
165
34
|
"""
|
|
166
35
|
Manages background Codex sessions.
|
|
167
36
|
|
|
168
37
|
Sessions are stored in:
|
|
169
38
|
.zwarm/sessions/<session_id>/
|
|
170
39
|
meta.json - Session metadata
|
|
171
|
-
output.jsonl - Raw JSONL output from codex exec
|
|
172
40
|
turns/
|
|
173
41
|
turn_1.jsonl
|
|
174
42
|
turn_2.jsonl
|
|
175
43
|
...
|
|
176
44
|
"""
|
|
177
45
|
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
46
|
+
adapter_name = "codex"
|
|
47
|
+
default_model = "gpt-5.1-codex-mini"
|
|
222
48
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
session = self._load_session(session_dir.name)
|
|
227
|
-
if session:
|
|
228
|
-
# Update status if process died OR output indicates completion
|
|
229
|
-
# (output check is more reliable than PID check due to PID reuse)
|
|
230
|
-
if session.status == SessionStatus.RUNNING:
|
|
231
|
-
if self._is_output_complete(session.id, session.turn) or not session.is_running:
|
|
232
|
-
self._update_session_status(session)
|
|
233
|
-
|
|
234
|
-
if status is None or session.status == status:
|
|
235
|
-
sessions.append(session)
|
|
236
|
-
|
|
237
|
-
# Sort by created_at descending (newest first)
|
|
238
|
-
sessions.sort(key=lambda s: s.created_at, reverse=True)
|
|
239
|
-
return sessions
|
|
240
|
-
|
|
241
|
-
def get_session(self, session_id: str) -> CodexSession | None:
|
|
242
|
-
"""Get a session by ID (supports partial ID matching)."""
|
|
243
|
-
# Try exact match first
|
|
244
|
-
session = self._load_session(session_id)
|
|
245
|
-
if session:
|
|
246
|
-
if session.status == SessionStatus.RUNNING:
|
|
247
|
-
if self._is_output_complete(session.id, session.turn) or not session.is_running:
|
|
248
|
-
self._update_session_status(session)
|
|
249
|
-
return session
|
|
250
|
-
|
|
251
|
-
# Try partial match
|
|
252
|
-
for session_dir in self.sessions_dir.iterdir():
|
|
253
|
-
if session_dir.name.startswith(session_id):
|
|
254
|
-
session = self._load_session(session_dir.name)
|
|
255
|
-
if session:
|
|
256
|
-
if session.status == SessionStatus.RUNNING:
|
|
257
|
-
if self._is_output_complete(session.id, session.turn) or not session.is_running:
|
|
258
|
-
self._update_session_status(session)
|
|
259
|
-
return session
|
|
260
|
-
|
|
261
|
-
return None
|
|
49
|
+
# =========================================================================
|
|
50
|
+
# Codex-specific config handling
|
|
51
|
+
# =========================================================================
|
|
262
52
|
|
|
263
|
-
def
|
|
53
|
+
def _load_codex_config(self) -> dict[str, Any]:
|
|
264
54
|
"""
|
|
265
|
-
|
|
55
|
+
Load codex.toml from state_dir.
|
|
266
56
|
|
|
267
|
-
|
|
268
|
-
in the JSONL output. This is more reliable than PID checking.
|
|
57
|
+
Returns parsed TOML as dict, or empty dict if not found.
|
|
269
58
|
"""
|
|
270
|
-
|
|
271
|
-
if not
|
|
272
|
-
return
|
|
273
|
-
|
|
59
|
+
codex_toml = self.state_dir / "codex.toml"
|
|
60
|
+
if not codex_toml.exists():
|
|
61
|
+
return {}
|
|
274
62
|
try:
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if not line.strip():
|
|
278
|
-
continue
|
|
279
|
-
try:
|
|
280
|
-
event = json.loads(line)
|
|
281
|
-
event_type = event.get("type", "")
|
|
282
|
-
# Check for any completion marker
|
|
283
|
-
if event_type in ("turn.completed", "task.completed", "completed", "done"):
|
|
284
|
-
return True
|
|
285
|
-
# Also check for error as a form of completion
|
|
286
|
-
if event_type == "error":
|
|
287
|
-
return True
|
|
288
|
-
except json.JSONDecodeError:
|
|
289
|
-
continue
|
|
63
|
+
with open(codex_toml, "rb") as f:
|
|
64
|
+
return tomllib.load(f)
|
|
290
65
|
except Exception:
|
|
291
|
-
|
|
66
|
+
return {}
|
|
292
67
|
|
|
293
|
-
|
|
68
|
+
def _build_codex_overrides(self, config: dict[str, Any]) -> list[str]:
|
|
69
|
+
"""
|
|
70
|
+
Convert codex.toml config to -c override flags.
|
|
294
71
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
# Got response but also an error (e.g., network disconnect at end)
|
|
313
|
-
# Treat as completed but note the error
|
|
314
|
-
session.status = SessionStatus.COMPLETED
|
|
315
|
-
session.error = f"Completed with error: {error}"
|
|
72
|
+
Handles nested sections like [features] and [sandbox_workspace_write].
|
|
73
|
+
|
|
74
|
+
Returns list of ["-c", "key=value", "-c", "key2=value2", ...]
|
|
75
|
+
"""
|
|
76
|
+
overrides = []
|
|
77
|
+
|
|
78
|
+
def add_override(key: str, value: Any):
|
|
79
|
+
"""Add a -c override for a key=value pair."""
|
|
80
|
+
if isinstance(value, bool):
|
|
81
|
+
value = "true" if value else "false"
|
|
82
|
+
overrides.extend(["-c", f"{key}={value}"])
|
|
83
|
+
|
|
84
|
+
for key, value in config.items():
|
|
85
|
+
if isinstance(value, dict):
|
|
86
|
+
# Nested section like [features] or [sandbox_workspace_write]
|
|
87
|
+
for subkey, subvalue in value.items():
|
|
88
|
+
add_override(f"{key}.{subkey}", subvalue)
|
|
316
89
|
else:
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
session.status = SessionStatus.FAILED
|
|
320
|
-
session.error = "No output file found"
|
|
90
|
+
# Top-level key
|
|
91
|
+
add_override(key, value)
|
|
321
92
|
|
|
322
|
-
|
|
93
|
+
return overrides
|
|
94
|
+
|
|
95
|
+
# =========================================================================
|
|
96
|
+
# Session lifecycle (Codex-specific implementation)
|
|
97
|
+
# =========================================================================
|
|
323
98
|
|
|
324
99
|
def start_session(
|
|
325
100
|
self,
|
|
326
101
|
task: str,
|
|
327
102
|
working_dir: Path | None = None,
|
|
328
|
-
model: str =
|
|
103
|
+
model: str | None = None,
|
|
329
104
|
sandbox: str = "workspace-write",
|
|
330
105
|
source: str = "user",
|
|
331
|
-
|
|
332
|
-
) -> CodexSession:
|
|
106
|
+
) -> Session:
|
|
333
107
|
"""
|
|
334
108
|
Start a new Codex session in the background.
|
|
335
109
|
|
|
336
110
|
Args:
|
|
337
111
|
task: The task description
|
|
338
112
|
working_dir: Working directory for codex (default: cwd)
|
|
339
|
-
model: Model
|
|
340
|
-
sandbox: Sandbox mode
|
|
113
|
+
model: Model override (default: from codex.toml or gpt-5.1-codex-mini)
|
|
114
|
+
sandbox: Sandbox mode (ignored if full_danger=true in codex.toml)
|
|
341
115
|
source: Who spawned this session ("user" or "orchestrator:<id>")
|
|
342
|
-
adapter: Which adapter to use ("codex", "claude_code")
|
|
343
116
|
|
|
344
117
|
Returns:
|
|
345
118
|
The created session
|
|
119
|
+
|
|
120
|
+
Note:
|
|
121
|
+
Settings are read from .zwarm/codex.toml and passed via -c overrides.
|
|
122
|
+
Run `zwarm init` to set up the config.
|
|
346
123
|
"""
|
|
347
124
|
session_id = str(uuid4())
|
|
348
125
|
working_dir = working_dir or Path.cwd()
|
|
349
126
|
now = datetime.now().isoformat()
|
|
350
127
|
|
|
351
|
-
|
|
128
|
+
# Load codex config from .zwarm/codex.toml
|
|
129
|
+
codex_config = self._load_codex_config()
|
|
130
|
+
|
|
131
|
+
# Get model from config or use default
|
|
132
|
+
effective_model = model or codex_config.get("model", self.default_model)
|
|
133
|
+
|
|
134
|
+
# Check if full_danger mode is enabled
|
|
135
|
+
full_danger = codex_config.get("full_danger", False)
|
|
136
|
+
|
|
137
|
+
session = Session(
|
|
352
138
|
id=session_id,
|
|
353
139
|
task=task,
|
|
354
140
|
status=SessionStatus.PENDING,
|
|
355
141
|
working_dir=working_dir,
|
|
356
142
|
created_at=now,
|
|
357
143
|
updated_at=now,
|
|
358
|
-
model=
|
|
144
|
+
model=effective_model,
|
|
359
145
|
turn=1,
|
|
360
146
|
messages=[SessionMessage(role="user", content=task, timestamp=now)],
|
|
361
147
|
source=source,
|
|
362
|
-
adapter=
|
|
148
|
+
adapter=self.adapter_name,
|
|
363
149
|
)
|
|
364
150
|
|
|
365
151
|
# Create session directory
|
|
366
152
|
session_dir = self._session_dir(session_id)
|
|
367
153
|
session_dir.mkdir(parents=True, exist_ok=True)
|
|
368
154
|
|
|
369
|
-
# Build command
|
|
370
|
-
cmd = [
|
|
371
|
-
|
|
155
|
+
# Build command with -c overrides from codex.toml
|
|
156
|
+
cmd = ["codex"]
|
|
157
|
+
|
|
158
|
+
# Add -c overrides from codex.toml (excluding special keys we handle separately)
|
|
159
|
+
config_for_overrides = {k: v for k, v in codex_config.items() if k not in ("model", "full_danger")}
|
|
160
|
+
cmd.extend(self._build_codex_overrides(config_for_overrides))
|
|
161
|
+
|
|
162
|
+
# Add exec command and flags
|
|
163
|
+
cmd.extend([
|
|
164
|
+
"exec",
|
|
372
165
|
"--json",
|
|
373
|
-
"--full-auto",
|
|
374
166
|
"--skip-git-repo-check",
|
|
375
|
-
"--model",
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
167
|
+
"--model",
|
|
168
|
+
effective_model,
|
|
169
|
+
"-C",
|
|
170
|
+
str(working_dir.absolute()),
|
|
171
|
+
])
|
|
172
|
+
|
|
173
|
+
# Full danger mode bypasses all safety controls
|
|
174
|
+
if full_danger:
|
|
381
175
|
cmd.append("--dangerously-bypass-approvals-and-sandbox")
|
|
382
|
-
elif sandbox == "workspace-write":
|
|
383
|
-
# Default codex behavior
|
|
384
|
-
pass
|
|
385
176
|
|
|
386
177
|
cmd.extend(["--", task])
|
|
387
178
|
|
|
@@ -407,7 +198,7 @@ class CodexSessionManager:
|
|
|
407
198
|
self,
|
|
408
199
|
session_id: str,
|
|
409
200
|
message: str,
|
|
410
|
-
) ->
|
|
201
|
+
) -> Session | None:
|
|
411
202
|
"""
|
|
412
203
|
Inject a follow-up message into a completed session.
|
|
413
204
|
|
|
@@ -425,8 +216,6 @@ class CodexSessionManager:
|
|
|
425
216
|
return None
|
|
426
217
|
|
|
427
218
|
if session.status == SessionStatus.RUNNING:
|
|
428
|
-
# Can't inject while running - would need to implement
|
|
429
|
-
# a more complex IPC mechanism for that
|
|
430
219
|
return None
|
|
431
220
|
|
|
432
221
|
# Build context from previous messages
|
|
@@ -449,18 +238,35 @@ Continue from where you left off, addressing the user's new message."""
|
|
|
449
238
|
# Start new turn
|
|
450
239
|
session.turn += 1
|
|
451
240
|
now = datetime.now().isoformat()
|
|
452
|
-
session.messages.append(
|
|
241
|
+
session.messages.append(
|
|
242
|
+
SessionMessage(role="user", content=message, timestamp=now)
|
|
243
|
+
)
|
|
453
244
|
|
|
454
|
-
# Build command
|
|
455
|
-
|
|
456
|
-
|
|
245
|
+
# Build command with -c overrides from codex.toml
|
|
246
|
+
codex_config = self._load_codex_config()
|
|
247
|
+
full_danger = codex_config.get("full_danger", False)
|
|
248
|
+
|
|
249
|
+
cmd = ["codex"]
|
|
250
|
+
|
|
251
|
+
# Add -c overrides from codex.toml (excluding special keys)
|
|
252
|
+
config_for_overrides = {k: v for k, v in codex_config.items() if k not in ("model", "full_danger")}
|
|
253
|
+
cmd.extend(self._build_codex_overrides(config_for_overrides))
|
|
254
|
+
|
|
255
|
+
cmd.extend([
|
|
256
|
+
"exec",
|
|
457
257
|
"--json",
|
|
458
|
-
"--full-auto",
|
|
459
258
|
"--skip-git-repo-check",
|
|
460
|
-
"--model",
|
|
461
|
-
|
|
462
|
-
"
|
|
463
|
-
|
|
259
|
+
"--model",
|
|
260
|
+
session.model,
|
|
261
|
+
"-C",
|
|
262
|
+
str(session.working_dir.absolute()),
|
|
263
|
+
])
|
|
264
|
+
|
|
265
|
+
# Full danger mode bypasses all safety controls
|
|
266
|
+
if full_danger:
|
|
267
|
+
cmd.append("--dangerously-bypass-approvals-and-sandbox")
|
|
268
|
+
|
|
269
|
+
cmd.extend(["--", augmented_task])
|
|
464
270
|
|
|
465
271
|
# Start process
|
|
466
272
|
output_path = self._output_path(session.id, session.turn)
|
|
@@ -480,105 +286,50 @@ Continue from where you left off, addressing the user's new message."""
|
|
|
480
286
|
|
|
481
287
|
return session
|
|
482
288
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
Args:
|
|
488
|
-
session_id: Session to kill
|
|
489
|
-
delete: If True, also delete session data entirely
|
|
490
|
-
|
|
491
|
-
Returns True if killed, False if not found or not running.
|
|
492
|
-
"""
|
|
493
|
-
session = self.get_session(session_id)
|
|
494
|
-
if not session:
|
|
495
|
-
return False
|
|
496
|
-
|
|
497
|
-
if session.pid and session.is_running:
|
|
498
|
-
try:
|
|
499
|
-
# Kill the entire process group
|
|
500
|
-
os.killpg(os.getpgid(session.pid), signal.SIGTERM)
|
|
501
|
-
time.sleep(0.5)
|
|
502
|
-
|
|
503
|
-
# Force kill if still running
|
|
504
|
-
if session.is_running:
|
|
505
|
-
os.killpg(os.getpgid(session.pid), signal.SIGKILL)
|
|
506
|
-
except (OSError, ProcessLookupError):
|
|
507
|
-
pass
|
|
289
|
+
# =========================================================================
|
|
290
|
+
# Output parsing (Codex-specific JSONL format)
|
|
291
|
+
# =========================================================================
|
|
508
292
|
|
|
509
|
-
|
|
510
|
-
return self.delete_session(session.id)
|
|
511
|
-
|
|
512
|
-
session.status = SessionStatus.KILLED
|
|
513
|
-
session.error = "Manually killed"
|
|
514
|
-
self._save_session(session)
|
|
515
|
-
return True
|
|
516
|
-
|
|
517
|
-
def delete_session(self, session_id: str) -> bool:
|
|
293
|
+
def _is_output_complete(self, session_id: str, turn: int) -> bool:
|
|
518
294
|
"""
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
Kills the process first if still running.
|
|
295
|
+
Check if output file indicates the task completed.
|
|
522
296
|
|
|
523
|
-
|
|
297
|
+
Looks for completion markers like 'turn.completed' or 'task.completed'
|
|
298
|
+
in the JSONL output. This is more reliable than PID checking.
|
|
524
299
|
"""
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
session = self.get_session(session_id)
|
|
528
|
-
if not session:
|
|
300
|
+
output_path = self._output_path(session_id, turn)
|
|
301
|
+
if not output_path.exists():
|
|
529
302
|
return False
|
|
530
303
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
304
|
+
try:
|
|
305
|
+
content = output_path.read_text()
|
|
306
|
+
for line in content.strip().split("\n"):
|
|
307
|
+
if not line.strip():
|
|
308
|
+
continue
|
|
309
|
+
try:
|
|
310
|
+
event = json.loads(line)
|
|
311
|
+
event_type = event.get("type", "")
|
|
312
|
+
# Check for any completion marker
|
|
313
|
+
if event_type in (
|
|
314
|
+
"turn.completed",
|
|
315
|
+
"task.completed",
|
|
316
|
+
"completed",
|
|
317
|
+
"done",
|
|
318
|
+
):
|
|
319
|
+
return True
|
|
320
|
+
# Also check for error as a form of completion
|
|
321
|
+
if event_type == "error":
|
|
322
|
+
return True
|
|
323
|
+
except json.JSONDecodeError:
|
|
324
|
+
continue
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
546
327
|
|
|
547
328
|
return False
|
|
548
329
|
|
|
549
|
-
def
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
if not session:
|
|
553
|
-
return ""
|
|
554
|
-
|
|
555
|
-
if turn is None:
|
|
556
|
-
turn = session.turn
|
|
557
|
-
|
|
558
|
-
output_path = self._output_path(session.id, turn)
|
|
559
|
-
if not output_path.exists():
|
|
560
|
-
return ""
|
|
561
|
-
|
|
562
|
-
return output_path.read_text()
|
|
563
|
-
|
|
564
|
-
def get_messages(self, session_id: str) -> list[SessionMessage]:
|
|
565
|
-
"""Get parsed messages for a session across all turns."""
|
|
566
|
-
session = self.get_session(session_id)
|
|
567
|
-
if not session:
|
|
568
|
-
return []
|
|
569
|
-
|
|
570
|
-
all_messages = []
|
|
571
|
-
|
|
572
|
-
# Get messages from each turn
|
|
573
|
-
for turn in range(1, session.turn + 1):
|
|
574
|
-
output_path = self._output_path(session.id, turn)
|
|
575
|
-
if output_path.exists():
|
|
576
|
-
messages, _, _ = self._parse_output(output_path)
|
|
577
|
-
all_messages.extend(messages)
|
|
578
|
-
|
|
579
|
-
return all_messages
|
|
580
|
-
|
|
581
|
-
def _parse_output(self, output_path: Path) -> tuple[list[SessionMessage], dict[str, int], str | None]:
|
|
330
|
+
def _parse_output(
|
|
331
|
+
self, output_path: Path
|
|
332
|
+
) -> tuple[list[SessionMessage], dict[str, int], str | None]:
|
|
582
333
|
"""
|
|
583
334
|
Parse JSONL output from codex exec.
|
|
584
335
|
|
|
@@ -612,11 +363,13 @@ Continue from where you left off, addressing the user's new message."""
|
|
|
612
363
|
if item_type == "agent_message":
|
|
613
364
|
text = item.get("text", "")
|
|
614
365
|
if text:
|
|
615
|
-
messages.append(
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
366
|
+
messages.append(
|
|
367
|
+
SessionMessage(
|
|
368
|
+
role="assistant",
|
|
369
|
+
content=text,
|
|
370
|
+
timestamp=datetime.now().isoformat(),
|
|
371
|
+
)
|
|
372
|
+
)
|
|
620
373
|
|
|
621
374
|
elif item_type == "reasoning":
|
|
622
375
|
# Could optionally capture reasoning
|
|
@@ -625,19 +378,23 @@ Continue from where you left off, addressing the user's new message."""
|
|
|
625
378
|
elif item_type == "function_call":
|
|
626
379
|
# Track tool calls
|
|
627
380
|
func_name = item.get("name", "unknown")
|
|
628
|
-
messages.append(
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
381
|
+
messages.append(
|
|
382
|
+
SessionMessage(
|
|
383
|
+
role="tool",
|
|
384
|
+
content=f"[Calling: {func_name}]",
|
|
385
|
+
metadata={"function": func_name},
|
|
386
|
+
)
|
|
387
|
+
)
|
|
633
388
|
|
|
634
389
|
elif item_type == "function_call_output":
|
|
635
390
|
output = item.get("output", "")
|
|
636
391
|
if output and len(output) < 500:
|
|
637
|
-
messages.append(
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
392
|
+
messages.append(
|
|
393
|
+
SessionMessage(
|
|
394
|
+
role="tool",
|
|
395
|
+
content=f"[Output]: {output[:500]}",
|
|
396
|
+
)
|
|
397
|
+
)
|
|
641
398
|
|
|
642
399
|
elif event_type == "turn.completed":
|
|
643
400
|
turn_usage = event.get("usage", {})
|
|
@@ -645,14 +402,18 @@ Continue from where you left off, addressing the user's new message."""
|
|
|
645
402
|
usage[key] = usage.get(key, 0) + value
|
|
646
403
|
# Compute total_tokens if not present
|
|
647
404
|
if "total_tokens" not in usage:
|
|
648
|
-
usage["total_tokens"] = usage.get("input_tokens", 0) + usage.get(
|
|
405
|
+
usage["total_tokens"] = usage.get("input_tokens", 0) + usage.get(
|
|
406
|
+
"output_tokens", 0
|
|
407
|
+
)
|
|
649
408
|
|
|
650
409
|
elif event_type == "error":
|
|
651
410
|
error = event.get("message", str(event))
|
|
652
411
|
|
|
653
412
|
return messages, usage, error
|
|
654
413
|
|
|
655
|
-
def get_trajectory(
|
|
414
|
+
def get_trajectory(
|
|
415
|
+
self, session_id: str, full: bool = False, max_output_len: int = 200
|
|
416
|
+
) -> list[dict]:
|
|
656
417
|
"""
|
|
657
418
|
Get the full trajectory of a session - all steps in order.
|
|
658
419
|
|
|
@@ -699,13 +460,16 @@ Continue from where you left off, addressing the user's new message."""
|
|
|
699
460
|
if item_type == "reasoning":
|
|
700
461
|
text = item.get("text", "")
|
|
701
462
|
summary_len = max_output_len if full else 100
|
|
702
|
-
trajectory.append(
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
463
|
+
trajectory.append(
|
|
464
|
+
{
|
|
465
|
+
"turn": turn,
|
|
466
|
+
"step": step_num,
|
|
467
|
+
"type": "reasoning",
|
|
468
|
+
"summary": text[:summary_len]
|
|
469
|
+
+ ("..." if len(text) > summary_len else ""),
|
|
470
|
+
"full_text": text if full else None,
|
|
471
|
+
}
|
|
472
|
+
)
|
|
709
473
|
|
|
710
474
|
elif item_type == "command_execution":
|
|
711
475
|
cmd = item.get("command", "")
|
|
@@ -715,78 +479,61 @@ Continue from where you left off, addressing the user's new message."""
|
|
|
715
479
|
output_preview = output[:max_output_len]
|
|
716
480
|
if len(output) > max_output_len:
|
|
717
481
|
output_preview += "..."
|
|
718
|
-
trajectory.append(
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
482
|
+
trajectory.append(
|
|
483
|
+
{
|
|
484
|
+
"turn": turn,
|
|
485
|
+
"step": step_num,
|
|
486
|
+
"type": "command",
|
|
487
|
+
"command": cmd,
|
|
488
|
+
"output": output_preview.strip(),
|
|
489
|
+
"exit_code": exit_code,
|
|
490
|
+
}
|
|
491
|
+
)
|
|
726
492
|
|
|
727
493
|
elif item_type == "function_call":
|
|
728
494
|
func_name = item.get("name", "unknown")
|
|
729
495
|
args = item.get("arguments", {})
|
|
730
496
|
args_str = str(args)
|
|
731
497
|
args_len = max_output_len if full else 100
|
|
732
|
-
trajectory.append(
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
498
|
+
trajectory.append(
|
|
499
|
+
{
|
|
500
|
+
"turn": turn,
|
|
501
|
+
"step": step_num,
|
|
502
|
+
"type": "tool_call",
|
|
503
|
+
"tool": func_name,
|
|
504
|
+
"args_preview": args_str[:args_len]
|
|
505
|
+
+ ("..." if len(args_str) > args_len else ""),
|
|
506
|
+
"full_args": args if full else None,
|
|
507
|
+
}
|
|
508
|
+
)
|
|
740
509
|
|
|
741
510
|
elif item_type == "function_call_output":
|
|
742
511
|
output = item.get("output", "")
|
|
743
512
|
output_preview = output[:max_output_len]
|
|
744
513
|
if len(output) > max_output_len:
|
|
745
514
|
output_preview += "..."
|
|
746
|
-
trajectory.append(
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
515
|
+
trajectory.append(
|
|
516
|
+
{
|
|
517
|
+
"turn": turn,
|
|
518
|
+
"step": step_num,
|
|
519
|
+
"type": "tool_output",
|
|
520
|
+
"output": output_preview,
|
|
521
|
+
}
|
|
522
|
+
)
|
|
752
523
|
|
|
753
524
|
elif item_type == "agent_message":
|
|
754
525
|
text = item.get("text", "")
|
|
755
526
|
summary_len = max_output_len if full else 200
|
|
756
|
-
trajectory.append(
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
527
|
+
trajectory.append(
|
|
528
|
+
{
|
|
529
|
+
"turn": turn,
|
|
530
|
+
"step": step_num,
|
|
531
|
+
"type": "message",
|
|
532
|
+
"summary": text[:summary_len]
|
|
533
|
+
+ ("..." if len(text) > summary_len else ""),
|
|
534
|
+
"full_text": text if full else None,
|
|
535
|
+
"full_length": len(text),
|
|
536
|
+
}
|
|
537
|
+
)
|
|
764
538
|
|
|
765
539
|
return trajectory
|
|
766
|
-
|
|
767
|
-
def cleanup_completed(self, keep_days: int = 7) -> int:
|
|
768
|
-
"""
|
|
769
|
-
Remove old completed/failed/killed sessions.
|
|
770
|
-
|
|
771
|
-
Args:
|
|
772
|
-
keep_days: Keep sessions newer than this many days
|
|
773
|
-
|
|
774
|
-
Returns:
|
|
775
|
-
Number of sessions cleaned up
|
|
776
|
-
"""
|
|
777
|
-
import shutil
|
|
778
|
-
from datetime import timedelta
|
|
779
|
-
|
|
780
|
-
cutoff = datetime.now() - timedelta(days=keep_days)
|
|
781
|
-
cleaned = 0
|
|
782
|
-
|
|
783
|
-
for session in self.list_sessions():
|
|
784
|
-
if session.status in (SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.KILLED):
|
|
785
|
-
created = datetime.fromisoformat(session.created_at)
|
|
786
|
-
if created < cutoff:
|
|
787
|
-
session_dir = self._session_dir(session.id)
|
|
788
|
-
if session_dir.exists():
|
|
789
|
-
shutil.rmtree(session_dir)
|
|
790
|
-
cleaned += 1
|
|
791
|
-
|
|
792
|
-
return cleaned
|