codex2opencode 0.1.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.
- codex2opencode/__init__.py +1 -0
- codex2opencode/__main__.py +3 -0
- codex2opencode/cli.py +692 -0
- codex2opencode/errors.py +36 -0
- codex2opencode/event_stream.py +58 -0
- codex2opencode/locking.py +32 -0
- codex2opencode/logging_utils.py +20 -0
- codex2opencode/models.py +84 -0
- codex2opencode/opencode_cli.py +86 -0
- codex2opencode/paths.py +46 -0
- codex2opencode/state.py +31 -0
- codex2opencode/threading.py +18 -0
- codex2opencode/version.py +1 -0
- codex2opencode-0.1.0.dist-info/METADATA +279 -0
- codex2opencode-0.1.0.dist-info/RECORD +19 -0
- codex2opencode-0.1.0.dist-info/WHEEL +5 -0
- codex2opencode-0.1.0.dist-info/entry_points.txt +2 -0
- codex2opencode-0.1.0.dist-info/licenses/LICENSE +21 -0
- codex2opencode-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .version import __version__
|
codex2opencode/cli.py
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .errors import (
|
|
15
|
+
EXIT_OK,
|
|
16
|
+
BridgeError,
|
|
17
|
+
InvalidArgsError,
|
|
18
|
+
SessionError,
|
|
19
|
+
StateError,
|
|
20
|
+
StreamError,
|
|
21
|
+
TimeoutError,
|
|
22
|
+
)
|
|
23
|
+
from .event_stream import parse_event_lines
|
|
24
|
+
from .locking import acquire_thread_lock
|
|
25
|
+
from .locking import fcntl
|
|
26
|
+
from .logging_utils import append_bridge_log, utc_now_iso
|
|
27
|
+
from .models import ThreadState
|
|
28
|
+
from .opencode_cli import (
|
|
29
|
+
build_delete_session_command,
|
|
30
|
+
build_export_command,
|
|
31
|
+
build_run_command,
|
|
32
|
+
read_debug_config,
|
|
33
|
+
read_debug_paths,
|
|
34
|
+
read_opencode_version,
|
|
35
|
+
)
|
|
36
|
+
from .paths import lock_file_path, logs_dir, runs_dir, thread_file_path, threads_dir
|
|
37
|
+
from .state import load_thread_state, save_thread_state
|
|
38
|
+
from .threading import make_thread_key, resolve_workspace_root
|
|
39
|
+
from .version import __version__
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
DEFAULT_TIMEOUT_SECONDS = 300
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
46
|
+
parser = argparse.ArgumentParser(prog="codex2opencode")
|
|
47
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
48
|
+
|
|
49
|
+
ask = subparsers.add_parser("ask")
|
|
50
|
+
ask.add_argument("--prompt", required=True)
|
|
51
|
+
ask.add_argument("--workspace")
|
|
52
|
+
ask.add_argument("--thread")
|
|
53
|
+
ask.add_argument("--new", action="store_true")
|
|
54
|
+
ask.add_argument("--fork", action="store_true")
|
|
55
|
+
ask.add_argument("--title")
|
|
56
|
+
ask.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS)
|
|
57
|
+
|
|
58
|
+
status = subparsers.add_parser("status")
|
|
59
|
+
status.add_argument("--workspace")
|
|
60
|
+
status.add_argument("--thread")
|
|
61
|
+
|
|
62
|
+
forget = subparsers.add_parser("forget")
|
|
63
|
+
forget.add_argument("--workspace")
|
|
64
|
+
forget.add_argument("--thread")
|
|
65
|
+
|
|
66
|
+
gc = subparsers.add_parser("gc")
|
|
67
|
+
gc.add_argument("--max-age-days", type=int, default=7)
|
|
68
|
+
|
|
69
|
+
doctor = subparsers.add_parser("doctor")
|
|
70
|
+
doctor.add_argument("--workspace")
|
|
71
|
+
doctor.add_argument("--thread")
|
|
72
|
+
|
|
73
|
+
return parser
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve_thread(workspace: str | None, thread_name: str | None) -> tuple[str, str]:
|
|
77
|
+
workspace_root = resolve_workspace_root(workspace or ".")
|
|
78
|
+
return workspace_root, make_thread_key(workspace_root, thread_name)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read_optional_thread_state(path: Path) -> ThreadState | None:
|
|
82
|
+
if not path.exists():
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
return load_thread_state(path)
|
|
86
|
+
except BridgeError:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _write_run_record(
|
|
91
|
+
thread_key: str,
|
|
92
|
+
prompt: str,
|
|
93
|
+
started_at: str,
|
|
94
|
+
duration_ms: int,
|
|
95
|
+
run_mode: str,
|
|
96
|
+
summary_session_id: str | None,
|
|
97
|
+
export_verified: bool,
|
|
98
|
+
text_output: str,
|
|
99
|
+
stderr_text: str,
|
|
100
|
+
event_counts: dict[str, int],
|
|
101
|
+
) -> None:
|
|
102
|
+
record = {
|
|
103
|
+
"run_id": uuid.uuid4().hex,
|
|
104
|
+
"thread_key": thread_key,
|
|
105
|
+
"started_at": started_at,
|
|
106
|
+
"ended_at": utc_now_iso(),
|
|
107
|
+
"duration_ms": duration_ms,
|
|
108
|
+
"run_mode": run_mode,
|
|
109
|
+
"prompt_sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
|
|
110
|
+
"session_id": summary_session_id,
|
|
111
|
+
"export_verified": export_verified,
|
|
112
|
+
"stdout_preview": text_output[:200],
|
|
113
|
+
"stderr_preview": stderr_text[:200],
|
|
114
|
+
"event_counts": event_counts,
|
|
115
|
+
}
|
|
116
|
+
run_dir = runs_dir() / thread_key
|
|
117
|
+
filename = f"{record['ended_at'].replace(':', '-')}-{record['run_id']}.json"
|
|
118
|
+
path = run_dir / filename
|
|
119
|
+
temp_path = path.with_suffix(".tmp")
|
|
120
|
+
try:
|
|
121
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
temp_path.write_text(json.dumps(record, indent=2, sort_keys=True), encoding="utf-8")
|
|
123
|
+
temp_path.replace(path)
|
|
124
|
+
except OSError as exc:
|
|
125
|
+
raise StateError(f"Failed to write run record to {path}: {exc}") from exc
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _run_opencode(command: list[str], timeout_seconds: int, cwd: str) -> subprocess.CompletedProcess[str]:
|
|
129
|
+
try:
|
|
130
|
+
return subprocess.run(
|
|
131
|
+
command,
|
|
132
|
+
check=False,
|
|
133
|
+
capture_output=True,
|
|
134
|
+
text=True,
|
|
135
|
+
timeout=timeout_seconds,
|
|
136
|
+
cwd=cwd,
|
|
137
|
+
)
|
|
138
|
+
except FileNotFoundError as exc:
|
|
139
|
+
raise BridgeError(f"Opencode CLI not found: {command[0]}") from exc
|
|
140
|
+
except subprocess.TimeoutExpired as exc:
|
|
141
|
+
raise TimeoutError(f"Opencode timed out after {timeout_seconds}s") from exc
|
|
142
|
+
except OSError as exc:
|
|
143
|
+
raise BridgeError(f"Failed to execute Opencode CLI: {exc}") from exc
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _extract_export_json(raw_output: str) -> str:
|
|
147
|
+
lines = raw_output.splitlines()
|
|
148
|
+
for index, line in enumerate(lines):
|
|
149
|
+
if line.lstrip().startswith("{"):
|
|
150
|
+
return "\n".join(lines[index:])
|
|
151
|
+
return raw_output
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _extract_response_metadata(export_payload: dict[str, object]) -> tuple[str | None, str | None]:
|
|
155
|
+
messages = export_payload.get("messages")
|
|
156
|
+
if not isinstance(messages, list):
|
|
157
|
+
return None, None
|
|
158
|
+
for message in reversed(messages):
|
|
159
|
+
if not isinstance(message, dict):
|
|
160
|
+
continue
|
|
161
|
+
info = message.get("info")
|
|
162
|
+
if not isinstance(info, dict):
|
|
163
|
+
continue
|
|
164
|
+
if info.get("role") != "assistant":
|
|
165
|
+
continue
|
|
166
|
+
provider_id = info.get("providerID") if isinstance(info.get("providerID"), str) else None
|
|
167
|
+
model_id = info.get("modelID") if isinstance(info.get("modelID"), str) else None
|
|
168
|
+
return provider_id, model_id
|
|
169
|
+
return None, None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _write_response_text(
|
|
173
|
+
text_output: str,
|
|
174
|
+
session_id: str,
|
|
175
|
+
provider_id: str | None = None,
|
|
176
|
+
model_id: str | None = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
header_parts = ["[oc"]
|
|
179
|
+
if provider_id:
|
|
180
|
+
header_parts.append(f"provider={provider_id}")
|
|
181
|
+
if model_id:
|
|
182
|
+
header_parts.append(f"model={model_id}")
|
|
183
|
+
header_parts.append(f"session={session_id}]")
|
|
184
|
+
sys.stdout.write(" ".join(header_parts) + "\n")
|
|
185
|
+
if text_output:
|
|
186
|
+
sys.stdout.write(text_output)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _export_session(session_id: str, opencode_bin: str, workspace_root: str) -> dict[str, object]:
|
|
190
|
+
completed = _run_opencode(
|
|
191
|
+
build_export_command(session_id, opencode_bin),
|
|
192
|
+
timeout_seconds=20,
|
|
193
|
+
cwd=workspace_root,
|
|
194
|
+
)
|
|
195
|
+
if completed.returncode != 0:
|
|
196
|
+
message = completed.stderr.strip() or completed.stdout.strip() or f"Session export failed: {session_id}"
|
|
197
|
+
raise SessionError(message)
|
|
198
|
+
try:
|
|
199
|
+
payload = json.loads(_extract_export_json(completed.stdout))
|
|
200
|
+
except json.JSONDecodeError as exc:
|
|
201
|
+
raise SessionError("Opencode export returned malformed JSON") from exc
|
|
202
|
+
if not isinstance(payload, dict):
|
|
203
|
+
raise SessionError("Opencode export returned invalid payload")
|
|
204
|
+
return payload
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _build_thread_state(
|
|
208
|
+
thread_key: str,
|
|
209
|
+
workspace_root: str,
|
|
210
|
+
thread_name: str | None,
|
|
211
|
+
summary_session_id: str,
|
|
212
|
+
prior_state: ThreadState | None,
|
|
213
|
+
run_mode: str,
|
|
214
|
+
title_override: str | None,
|
|
215
|
+
export_payload: dict[str, object],
|
|
216
|
+
opencode_bin: str,
|
|
217
|
+
) -> ThreadState:
|
|
218
|
+
now = utc_now_iso()
|
|
219
|
+
info = export_payload.get("info")
|
|
220
|
+
info_dict = info if isinstance(info, dict) else {}
|
|
221
|
+
messages = export_payload.get("messages")
|
|
222
|
+
message_count = len(messages) if isinstance(messages, list) else 0
|
|
223
|
+
exported_title = info_dict.get("title") if isinstance(info_dict.get("title"), str) else None
|
|
224
|
+
exported_version = info_dict.get("version") if isinstance(info_dict.get("version"), str) else None
|
|
225
|
+
opencode_version = read_opencode_version(opencode_bin, cwd=workspace_root) or exported_version
|
|
226
|
+
|
|
227
|
+
created_at = now if prior_state is None or run_mode in {"new", "fork"} else prior_state.created_at
|
|
228
|
+
return ThreadState(
|
|
229
|
+
thread_key=thread_key,
|
|
230
|
+
workspace_root=workspace_root,
|
|
231
|
+
thread_name=thread_name,
|
|
232
|
+
opencode_session_id=summary_session_id,
|
|
233
|
+
opencode_title=title_override or exported_title,
|
|
234
|
+
created_at=created_at,
|
|
235
|
+
last_used_at=now,
|
|
236
|
+
last_status="ok",
|
|
237
|
+
last_run_mode=run_mode,
|
|
238
|
+
bridge_version=__version__,
|
|
239
|
+
opencode_version=opencode_version,
|
|
240
|
+
last_error=None,
|
|
241
|
+
message_count=message_count,
|
|
242
|
+
last_exported_at=now,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _build_unverified_thread_state(
|
|
247
|
+
thread_key: str,
|
|
248
|
+
workspace_root: str,
|
|
249
|
+
thread_name: str | None,
|
|
250
|
+
summary_session_id: str,
|
|
251
|
+
prior_state: ThreadState | None,
|
|
252
|
+
run_mode: str,
|
|
253
|
+
title_override: str | None,
|
|
254
|
+
opencode_bin: str,
|
|
255
|
+
error_message: str,
|
|
256
|
+
) -> ThreadState:
|
|
257
|
+
now = utc_now_iso()
|
|
258
|
+
created_at = now if prior_state is None or run_mode in {"new", "fork"} else prior_state.created_at
|
|
259
|
+
opencode_version = read_opencode_version(opencode_bin, cwd=workspace_root) or (
|
|
260
|
+
prior_state.opencode_version if prior_state else None
|
|
261
|
+
)
|
|
262
|
+
return ThreadState(
|
|
263
|
+
thread_key=thread_key,
|
|
264
|
+
workspace_root=workspace_root,
|
|
265
|
+
thread_name=thread_name,
|
|
266
|
+
opencode_session_id=summary_session_id,
|
|
267
|
+
opencode_title=title_override or (prior_state.opencode_title if prior_state else None),
|
|
268
|
+
created_at=created_at,
|
|
269
|
+
last_used_at=now,
|
|
270
|
+
last_status="session_unverified",
|
|
271
|
+
last_run_mode=run_mode,
|
|
272
|
+
bridge_version=__version__,
|
|
273
|
+
opencode_version=opencode_version,
|
|
274
|
+
last_error=error_message,
|
|
275
|
+
message_count=prior_state.message_count if prior_state else 0,
|
|
276
|
+
last_exported_at=prior_state.last_exported_at if prior_state else None,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _handle_ask(args: argparse.Namespace) -> int:
|
|
281
|
+
if args.new and args.fork:
|
|
282
|
+
raise InvalidArgsError("--new and --fork cannot be combined")
|
|
283
|
+
|
|
284
|
+
workspace_root, thread_key = _resolve_thread(args.workspace, args.thread)
|
|
285
|
+
state_path = thread_file_path(thread_key)
|
|
286
|
+
lock_path = lock_file_path(thread_key)
|
|
287
|
+
opencode_bin = os.environ.get("CODEX2OPENCODE_OPENCODE_BIN", "opencode")
|
|
288
|
+
started_at = utc_now_iso()
|
|
289
|
+
monotonic_start = time.monotonic()
|
|
290
|
+
|
|
291
|
+
with acquire_thread_lock(lock_path):
|
|
292
|
+
prior_state = None if args.new else _read_optional_thread_state(state_path)
|
|
293
|
+
session_id = None if args.new else (prior_state.opencode_session_id if prior_state else None)
|
|
294
|
+
if args.fork and not session_id:
|
|
295
|
+
raise InvalidArgsError("--fork requires an existing thread session")
|
|
296
|
+
|
|
297
|
+
run_mode = "fork" if args.fork else ("new" if args.new or session_id is None else "resume")
|
|
298
|
+
command = build_run_command(
|
|
299
|
+
prompt=args.prompt,
|
|
300
|
+
cwd=workspace_root,
|
|
301
|
+
session_id=session_id,
|
|
302
|
+
fork=args.fork,
|
|
303
|
+
title=args.title,
|
|
304
|
+
opencode_bin=opencode_bin,
|
|
305
|
+
)
|
|
306
|
+
completed = _run_opencode(command, timeout_seconds=args.timeout, cwd=workspace_root)
|
|
307
|
+
if completed.returncode != 0:
|
|
308
|
+
message = completed.stderr.strip() or completed.stdout.strip() or "Opencode run failed"
|
|
309
|
+
raise BridgeError(message)
|
|
310
|
+
|
|
311
|
+
summary = parse_event_lines(completed.stdout.splitlines(keepends=True))
|
|
312
|
+
if summary.session_id is None:
|
|
313
|
+
raise StreamError("Opencode stream did not include a sessionID")
|
|
314
|
+
|
|
315
|
+
duration_ms = max(1, int((time.monotonic() - monotonic_start) * 1000))
|
|
316
|
+
try:
|
|
317
|
+
export_payload = _export_session(summary.session_id, opencode_bin, workspace_root)
|
|
318
|
+
except SessionError as exc:
|
|
319
|
+
state = _build_unverified_thread_state(
|
|
320
|
+
thread_key=thread_key,
|
|
321
|
+
workspace_root=workspace_root,
|
|
322
|
+
thread_name=args.thread,
|
|
323
|
+
summary_session_id=summary.session_id,
|
|
324
|
+
prior_state=prior_state,
|
|
325
|
+
run_mode=run_mode,
|
|
326
|
+
title_override=args.title,
|
|
327
|
+
opencode_bin=opencode_bin,
|
|
328
|
+
error_message=str(exc),
|
|
329
|
+
)
|
|
330
|
+
save_thread_state(state_path, state)
|
|
331
|
+
_write_run_record(
|
|
332
|
+
thread_key=thread_key,
|
|
333
|
+
prompt=args.prompt,
|
|
334
|
+
started_at=started_at,
|
|
335
|
+
duration_ms=duration_ms,
|
|
336
|
+
run_mode=run_mode,
|
|
337
|
+
summary_session_id=summary.session_id,
|
|
338
|
+
export_verified=False,
|
|
339
|
+
text_output=summary.text_output,
|
|
340
|
+
stderr_text=str(exc),
|
|
341
|
+
event_counts=summary.event_counts,
|
|
342
|
+
)
|
|
343
|
+
append_bridge_log(
|
|
344
|
+
{
|
|
345
|
+
"action": "ask",
|
|
346
|
+
"thread_key": thread_key,
|
|
347
|
+
"run_mode": run_mode,
|
|
348
|
+
"session_id": summary.session_id,
|
|
349
|
+
"outcome": "session_unverified",
|
|
350
|
+
"message": str(exc),
|
|
351
|
+
}
|
|
352
|
+
)
|
|
353
|
+
_write_response_text(
|
|
354
|
+
text_output=summary.text_output,
|
|
355
|
+
session_id=summary.session_id,
|
|
356
|
+
)
|
|
357
|
+
raise
|
|
358
|
+
|
|
359
|
+
state = _build_thread_state(
|
|
360
|
+
thread_key=thread_key,
|
|
361
|
+
workspace_root=workspace_root,
|
|
362
|
+
thread_name=args.thread,
|
|
363
|
+
summary_session_id=summary.session_id,
|
|
364
|
+
prior_state=prior_state,
|
|
365
|
+
run_mode=run_mode,
|
|
366
|
+
title_override=args.title,
|
|
367
|
+
export_payload=export_payload,
|
|
368
|
+
opencode_bin=opencode_bin,
|
|
369
|
+
)
|
|
370
|
+
provider_id, model_id = _extract_response_metadata(export_payload)
|
|
371
|
+
save_thread_state(state_path, state)
|
|
372
|
+
_write_run_record(
|
|
373
|
+
thread_key=thread_key,
|
|
374
|
+
prompt=args.prompt,
|
|
375
|
+
started_at=started_at,
|
|
376
|
+
duration_ms=duration_ms,
|
|
377
|
+
run_mode=run_mode,
|
|
378
|
+
summary_session_id=summary.session_id,
|
|
379
|
+
export_verified=True,
|
|
380
|
+
text_output=summary.text_output,
|
|
381
|
+
stderr_text=completed.stderr,
|
|
382
|
+
event_counts=summary.event_counts,
|
|
383
|
+
)
|
|
384
|
+
append_bridge_log(
|
|
385
|
+
{
|
|
386
|
+
"action": "ask",
|
|
387
|
+
"thread_key": thread_key,
|
|
388
|
+
"run_mode": run_mode,
|
|
389
|
+
"session_id": summary.session_id,
|
|
390
|
+
"outcome": "success",
|
|
391
|
+
}
|
|
392
|
+
)
|
|
393
|
+
_write_response_text(
|
|
394
|
+
text_output=summary.text_output,
|
|
395
|
+
session_id=summary.session_id,
|
|
396
|
+
provider_id=provider_id,
|
|
397
|
+
model_id=model_id,
|
|
398
|
+
)
|
|
399
|
+
return EXIT_OK
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _probe_lock_path(lock_path: Path) -> dict[str, object]:
|
|
403
|
+
payload: dict[str, object] = {
|
|
404
|
+
"path": str(lock_path),
|
|
405
|
+
"present": lock_path.exists(),
|
|
406
|
+
}
|
|
407
|
+
if not lock_path.exists():
|
|
408
|
+
payload["status"] = "ok"
|
|
409
|
+
return payload
|
|
410
|
+
if fcntl is None:
|
|
411
|
+
payload["status"] = "error"
|
|
412
|
+
payload["message"] = "Lock probing unsupported on this platform."
|
|
413
|
+
return payload
|
|
414
|
+
try:
|
|
415
|
+
with lock_path.open("a+", encoding="utf-8") as handle:
|
|
416
|
+
try:
|
|
417
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
418
|
+
except BlockingIOError:
|
|
419
|
+
payload["status"] = "locked"
|
|
420
|
+
return payload
|
|
421
|
+
finally:
|
|
422
|
+
try:
|
|
423
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
424
|
+
except OSError:
|
|
425
|
+
pass
|
|
426
|
+
except OSError as exc:
|
|
427
|
+
payload["status"] = "error"
|
|
428
|
+
payload["message"] = str(exc)
|
|
429
|
+
return payload
|
|
430
|
+
payload["status"] = "ok"
|
|
431
|
+
return payload
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _unlink_lock_file_if_stale_unlocked(lock_path: Path, cutoff: float) -> bool:
|
|
435
|
+
if fcntl is None or not lock_path.exists():
|
|
436
|
+
return False
|
|
437
|
+
try:
|
|
438
|
+
if lock_path.stat().st_mtime >= cutoff:
|
|
439
|
+
return False
|
|
440
|
+
with lock_path.open("a+", encoding="utf-8") as handle:
|
|
441
|
+
try:
|
|
442
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
443
|
+
except BlockingIOError:
|
|
444
|
+
return False
|
|
445
|
+
if lock_path.exists() and lock_path.stat().st_mtime < cutoff:
|
|
446
|
+
lock_path.unlink(missing_ok=True)
|
|
447
|
+
return True
|
|
448
|
+
return False
|
|
449
|
+
except FileNotFoundError:
|
|
450
|
+
return False
|
|
451
|
+
except OSError:
|
|
452
|
+
return False
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _handle_status(args: argparse.Namespace) -> int:
|
|
456
|
+
_, thread_key = _resolve_thread(args.workspace, args.thread)
|
|
457
|
+
state_path = thread_file_path(thread_key)
|
|
458
|
+
if not state_path.exists():
|
|
459
|
+
sys.stderr.write(f"No thread state found for {thread_key}\n")
|
|
460
|
+
return BridgeError.exit_code
|
|
461
|
+
state = load_thread_state(state_path)
|
|
462
|
+
sys.stdout.write(json.dumps(state.to_dict(), indent=2, sort_keys=True) + "\n")
|
|
463
|
+
return EXIT_OK
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _handle_forget(args: argparse.Namespace) -> int:
|
|
467
|
+
workspace_root, thread_key = _resolve_thread(args.workspace, args.thread)
|
|
468
|
+
state_path = thread_file_path(thread_key)
|
|
469
|
+
lock_path = lock_file_path(thread_key)
|
|
470
|
+
opencode_bin = os.environ.get("CODEX2OPENCODE_OPENCODE_BIN", "opencode")
|
|
471
|
+
with acquire_thread_lock(lock_path):
|
|
472
|
+
state = _read_optional_thread_state(state_path)
|
|
473
|
+
|
|
474
|
+
warning_message: str | None = None
|
|
475
|
+
if state and state.opencode_session_id:
|
|
476
|
+
delete_cwd = state.workspace_root or workspace_root
|
|
477
|
+
try:
|
|
478
|
+
completed = _run_opencode(
|
|
479
|
+
build_delete_session_command(state.opencode_session_id, opencode_bin),
|
|
480
|
+
timeout_seconds=20,
|
|
481
|
+
cwd=delete_cwd,
|
|
482
|
+
)
|
|
483
|
+
if completed.returncode != 0:
|
|
484
|
+
warning_message = (
|
|
485
|
+
completed.stderr.strip()
|
|
486
|
+
or completed.stdout.strip()
|
|
487
|
+
or f"Failed to delete Opencode session {state.opencode_session_id}"
|
|
488
|
+
)
|
|
489
|
+
except BridgeError as exc:
|
|
490
|
+
warning_message = str(exc)
|
|
491
|
+
|
|
492
|
+
state_path.unlink(missing_ok=True)
|
|
493
|
+
lock_path.unlink(missing_ok=True)
|
|
494
|
+
|
|
495
|
+
if warning_message:
|
|
496
|
+
append_bridge_log(
|
|
497
|
+
{
|
|
498
|
+
"action": "forget",
|
|
499
|
+
"outcome": "warning",
|
|
500
|
+
"thread_key": thread_key,
|
|
501
|
+
"message": warning_message,
|
|
502
|
+
}
|
|
503
|
+
)
|
|
504
|
+
sys.stderr.write(f"{warning_message}\n")
|
|
505
|
+
append_bridge_log({"action": "forget", "outcome": "success", "thread_key": thread_key})
|
|
506
|
+
return EXIT_OK
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _handle_gc(args: argparse.Namespace) -> int:
|
|
510
|
+
threads_dir().mkdir(parents=True, exist_ok=True)
|
|
511
|
+
runs_dir().mkdir(parents=True, exist_ok=True)
|
|
512
|
+
logs_dir().mkdir(parents=True, exist_ok=True)
|
|
513
|
+
cutoff = time.time() - (args.max_age_days * 24 * 60 * 60)
|
|
514
|
+
|
|
515
|
+
locked_thread_keys: set[str] = set()
|
|
516
|
+
for lock_path in threads_dir().glob("*.lock"):
|
|
517
|
+
if _probe_lock_path(lock_path)["status"] == "locked":
|
|
518
|
+
locked_thread_keys.add(lock_path.stem)
|
|
519
|
+
|
|
520
|
+
deleted = 0
|
|
521
|
+
for path in threads_dir().glob("*"):
|
|
522
|
+
try:
|
|
523
|
+
if path.suffix == ".lock":
|
|
524
|
+
if _unlink_lock_file_if_stale_unlocked(path, cutoff):
|
|
525
|
+
deleted += 1
|
|
526
|
+
continue
|
|
527
|
+
if path.stem in locked_thread_keys:
|
|
528
|
+
continue
|
|
529
|
+
if path.stat().st_mtime >= cutoff:
|
|
530
|
+
continue
|
|
531
|
+
path.unlink()
|
|
532
|
+
deleted += 1
|
|
533
|
+
except FileNotFoundError:
|
|
534
|
+
continue
|
|
535
|
+
|
|
536
|
+
for run_dir in runs_dir().iterdir():
|
|
537
|
+
try:
|
|
538
|
+
if not run_dir.is_dir():
|
|
539
|
+
continue
|
|
540
|
+
if run_dir.name in locked_thread_keys:
|
|
541
|
+
continue
|
|
542
|
+
newest_mtime = max(
|
|
543
|
+
(child.stat().st_mtime for child in run_dir.rglob("*")),
|
|
544
|
+
default=run_dir.stat().st_mtime,
|
|
545
|
+
)
|
|
546
|
+
if newest_mtime >= cutoff:
|
|
547
|
+
continue
|
|
548
|
+
shutil.rmtree(run_dir)
|
|
549
|
+
deleted += 1
|
|
550
|
+
except FileNotFoundError:
|
|
551
|
+
continue
|
|
552
|
+
|
|
553
|
+
append_bridge_log({"action": "gc", "outcome": "success", "deleted": deleted})
|
|
554
|
+
return EXIT_OK
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _thread_state_doctor_payload(
|
|
558
|
+
state_path: Path,
|
|
559
|
+
thread_key: str,
|
|
560
|
+
workspace_root: str,
|
|
561
|
+
opencode_bin: str,
|
|
562
|
+
lock_path: Path,
|
|
563
|
+
) -> dict[str, object]:
|
|
564
|
+
if not state_path.exists():
|
|
565
|
+
return {
|
|
566
|
+
"status": "missing",
|
|
567
|
+
"path": str(state_path),
|
|
568
|
+
"thread_key": thread_key,
|
|
569
|
+
"lock": _probe_lock_path(lock_path),
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
state = load_thread_state(state_path)
|
|
574
|
+
except BridgeError as exc:
|
|
575
|
+
return {
|
|
576
|
+
"status": "error",
|
|
577
|
+
"path": str(state_path),
|
|
578
|
+
"thread_key": thread_key,
|
|
579
|
+
"message": str(exc),
|
|
580
|
+
"lock": _probe_lock_path(lock_path),
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
payload: dict[str, object] = {
|
|
584
|
+
"status": "ok",
|
|
585
|
+
"path": str(state_path),
|
|
586
|
+
"thread_key": thread_key,
|
|
587
|
+
"session_id": state.opencode_session_id,
|
|
588
|
+
"last_status": state.last_status,
|
|
589
|
+
"last_used_at": state.last_used_at,
|
|
590
|
+
"lock": _probe_lock_path(lock_path),
|
|
591
|
+
"session_verified": False,
|
|
592
|
+
}
|
|
593
|
+
if not state.opencode_session_id:
|
|
594
|
+
return payload
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
_export_session(state.opencode_session_id, opencode_bin, state.workspace_root or workspace_root)
|
|
598
|
+
except SessionError as exc:
|
|
599
|
+
payload["status"] = "orphaned"
|
|
600
|
+
payload["message"] = str(exc)
|
|
601
|
+
return payload
|
|
602
|
+
except BridgeError as exc:
|
|
603
|
+
payload["status"] = "error"
|
|
604
|
+
payload["message"] = str(exc)
|
|
605
|
+
return payload
|
|
606
|
+
|
|
607
|
+
payload["session_verified"] = True
|
|
608
|
+
return payload
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _handle_doctor(args: argparse.Namespace) -> int:
|
|
612
|
+
workspace_root, thread_key = _resolve_thread(args.workspace, args.thread)
|
|
613
|
+
state_path = thread_file_path(thread_key)
|
|
614
|
+
lock_path = lock_file_path(thread_key)
|
|
615
|
+
opencode_bin = os.environ.get("CODEX2OPENCODE_OPENCODE_BIN", "opencode")
|
|
616
|
+
|
|
617
|
+
opencode_version = read_opencode_version(opencode_bin, cwd=workspace_root)
|
|
618
|
+
debug_paths = read_debug_paths(opencode_bin, cwd=workspace_root)
|
|
619
|
+
debug_config = read_debug_config(opencode_bin, cwd=workspace_root)
|
|
620
|
+
thread_state_payload = _thread_state_doctor_payload(
|
|
621
|
+
state_path=state_path,
|
|
622
|
+
thread_key=thread_key,
|
|
623
|
+
workspace_root=workspace_root,
|
|
624
|
+
opencode_bin=opencode_bin,
|
|
625
|
+
lock_path=lock_path,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
payload = {
|
|
629
|
+
"ok": (
|
|
630
|
+
opencode_version is not None
|
|
631
|
+
and debug_paths is not None
|
|
632
|
+
and debug_config is not None
|
|
633
|
+
and thread_state_payload["status"] not in {"error", "orphaned"}
|
|
634
|
+
),
|
|
635
|
+
"bridge_version": __version__,
|
|
636
|
+
"workspace_root": workspace_root,
|
|
637
|
+
"bridge_root": {
|
|
638
|
+
"status": "ok",
|
|
639
|
+
"path": str(threads_dir().parent),
|
|
640
|
+
},
|
|
641
|
+
"paths": {
|
|
642
|
+
"threads": str(threads_dir()),
|
|
643
|
+
"runs": str(runs_dir()),
|
|
644
|
+
"logs": str(logs_dir()),
|
|
645
|
+
"state_file": str(state_path),
|
|
646
|
+
"lock_file": str(lock_path),
|
|
647
|
+
},
|
|
648
|
+
"opencode": {
|
|
649
|
+
"status": "ok" if opencode_version is not None else "error",
|
|
650
|
+
"bin": opencode_bin,
|
|
651
|
+
"version": opencode_version,
|
|
652
|
+
},
|
|
653
|
+
"opencode_debug": {
|
|
654
|
+
"paths": {
|
|
655
|
+
"status": "ok" if debug_paths is not None else "error",
|
|
656
|
+
"value": debug_paths,
|
|
657
|
+
},
|
|
658
|
+
"config": {
|
|
659
|
+
"status": "ok" if debug_config is not None else "error",
|
|
660
|
+
"value": debug_config,
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
"thread_state": thread_state_payload,
|
|
664
|
+
}
|
|
665
|
+
sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
|
666
|
+
return EXIT_OK if payload["ok"] else BridgeError.exit_code
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def main(argv: list[str] | None = None) -> int:
|
|
670
|
+
parser = build_parser()
|
|
671
|
+
try:
|
|
672
|
+
args = parser.parse_args(argv)
|
|
673
|
+
if args.command == "ask":
|
|
674
|
+
return _handle_ask(args)
|
|
675
|
+
if args.command == "status":
|
|
676
|
+
return _handle_status(args)
|
|
677
|
+
if args.command == "forget":
|
|
678
|
+
return _handle_forget(args)
|
|
679
|
+
if args.command == "gc":
|
|
680
|
+
return _handle_gc(args)
|
|
681
|
+
if args.command == "doctor":
|
|
682
|
+
return _handle_doctor(args)
|
|
683
|
+
raise InvalidArgsError(f"Unknown command: {args.command}")
|
|
684
|
+
except BridgeError as exc:
|
|
685
|
+
append_bridge_log({"action": "error", "outcome": type(exc).__name__, "message": str(exc)})
|
|
686
|
+
sys.stderr.write(f"{exc}\n")
|
|
687
|
+
return exc.exit_code
|
|
688
|
+
except OSError as exc:
|
|
689
|
+
error = StateError(str(exc))
|
|
690
|
+
append_bridge_log({"action": "error", "outcome": type(error).__name__, "message": str(error)})
|
|
691
|
+
sys.stderr.write(f"{error}\n")
|
|
692
|
+
return error.exit_code
|