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.
@@ -0,0 +1 @@
1
+ from .version import __version__
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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