relay-codex 0.0.0-stage → 0.4.1

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,701 @@
1
+ """Local management commands. Status and diagnostics never return credentials."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import sqlite3
9
+ import sys
10
+ import time
11
+
12
+ from pool import Pool, file_lock
13
+ import settings
14
+
15
+
16
+ def resolve_label(state, value):
17
+ from router import labels, RouterError
18
+
19
+ if value in labels(state):
20
+ return value
21
+ pool = Pool(state)
22
+ try:
23
+ matching = [
24
+ label for label in labels(state) if pool.options(label).get("name") == value
25
+ ]
26
+ if len(matching) == 1:
27
+ return matching[0]
28
+ finally:
29
+ pool.close()
30
+ raise RouterError("Unknown or ambiguous account name")
31
+
32
+
33
+ def snapshot(state, include_inactive=False):
34
+ from router import labels, VERSION
35
+
36
+ pool = Pool(state)
37
+ try:
38
+ sessions = pool.detail()
39
+ accounts = []
40
+ with pool.db() as db:
41
+ blocks = [
42
+ dict(account=r[0], scope=r[1], next_check=r[2], reason=r[3])
43
+ for r in db.execute(
44
+ "SELECT label,scope,until,reason FROM blocks WHERE until>? ORDER BY until",
45
+ (time.time(),),
46
+ )
47
+ ]
48
+ for label in labels(state):
49
+ options = pool.options(label)
50
+ usage = pool.cached(label, 86400)
51
+ windows = (usage or {}).get("rateLimits", {})
52
+ accounts.append(
53
+ {
54
+ "id": label,
55
+ "name": options.get("name", label),
56
+ "enabled": options.get("enabled", True),
57
+ "priority": options.get("priority", 0),
58
+ "max_sessions": options.get("max_sessions", 0),
59
+ "sessions": sum(s["account"] == label for s in sessions),
60
+ "primary": windows.get("primary"),
61
+ "secondary": windows.get("secondary"),
62
+ "usage_known": usage is not None,
63
+ "maintenance": options.get("maintenance", False),
64
+ "next_check": max(
65
+ (b["next_check"] for b in blocks if b["account"] == label),
66
+ default=None,
67
+ ),
68
+ }
69
+ )
70
+ active = {s["id"] for s in sessions}
71
+ with pool.db() as db:
72
+ saved = {
73
+ r[0]: json.loads(r[1])
74
+ for r in db.execute("SELECT id,details FROM session_details")
75
+ }
76
+ for path in (state / "services").glob("*.job.json"):
77
+ try:
78
+ job = json.loads(path.read_text())
79
+ except (OSError, ValueError):
80
+ continue
81
+ ident = job.get("id", path.name.split(".")[0])
82
+ if ident in active:
83
+ continue
84
+ if not include_inactive and job.get("state") != "needs_attention":
85
+ continue
86
+ prior = saved.get(ident, {})
87
+ sessions.append(
88
+ {
89
+ "id": ident,
90
+ "pid": None,
91
+ "name": job.get("name", ident[:10]),
92
+ "cwd": job.get("cwd"),
93
+ "thread": job.get("resume"),
94
+ "account": prior.get("account") or job.get("first") or "",
95
+ "model": prior.get("model"),
96
+ "state": job.get("state", "unknown"),
97
+ "paused": prior.get("paused", False),
98
+ "last_error": job.get("last_error"),
99
+ "running": False,
100
+ }
101
+ )
102
+ return {
103
+ "version": VERSION,
104
+ "at": time.time(),
105
+ "sessions": sessions,
106
+ "accounts": accounts,
107
+ "blocks": blocks,
108
+ }
109
+ finally:
110
+ pool.close()
111
+
112
+
113
+ def render(value):
114
+ def percent(window):
115
+ return (
116
+ "?" if not window else str(max(0, 100 - window.get("usedPercent", 0))) + "%"
117
+ )
118
+
119
+ lines = [
120
+ f"Relay · {len(value['sessions'])} sessions · {len(value['accounts'])} accounts",
121
+ "",
122
+ f"{'ACCOUNT':<23}{'STATE':<13}{'SHORT LEFT':<13}{'WEEK LEFT':<13}{'SESSIONS':<10}NEXT CHECK",
123
+ ]
124
+ for account in value["accounts"]:
125
+ label = (
126
+ account["name"]
127
+ + (" [" + account["id"] + "]" if account["name"] != account["id"] else "")
128
+ )[:22]
129
+ state = (
130
+ "maintenance"
131
+ if account["maintenance"]
132
+ else "enabled"
133
+ if account["enabled"]
134
+ else "disabled"
135
+ )
136
+ next_at = (
137
+ time.strftime("%m-%d %H:%M:%S", time.localtime(account["next_check"]))
138
+ if account["next_check"]
139
+ else "—"
140
+ )
141
+ lines.append(
142
+ f"{label:<23}{state:<13}{percent(account['primary']):<13}{percent(account['secondary']):<13}{account['sessions']:<10}{next_at}"
143
+ )
144
+ if value["sessions"]:
145
+ lines += [
146
+ "",
147
+ f"{'SESSION':<12}{'ACCOUNT':<14}{'STATE':<23}{'MODEL':<23}PROJECT / NAME",
148
+ ]
149
+ for row in value["sessions"]:
150
+ lines.append(
151
+ f"{row['id'][:10]:<12}{row.get('account', ''):<14}{str(row.get('state', 'unknown'))[:22]:<23}{str(row.get('model') or '?')[:22]:<23}{row.get('cwd') or row.get('name') or ''}"
152
+ )
153
+ if row.get("next_wake"):
154
+ lines.append(
155
+ " next action: "
156
+ + time.strftime("%m-%d %H:%M:%S", time.localtime(row["next_wake"]))
157
+ )
158
+ lines += [
159
+ "",
160
+ "Percentages are last observed allowance; native account limits remain authoritative.",
161
+ ]
162
+ return "\n".join(lines)
163
+
164
+
165
+ async def status(args, state):
166
+ while True:
167
+ value = snapshot(state, getattr(args, "all", False))
168
+ if args.json:
169
+ print(json.dumps(value, indent=None if args.watch else 2), flush=True)
170
+ else:
171
+ if args.watch and sys.stdout.isatty():
172
+ print("\033[2J\033[H", end="")
173
+ print(render(value), flush=True)
174
+ if not args.watch:
175
+ return 0
176
+ await asyncio.sleep(args.interval)
177
+
178
+
179
+ async def control(args, state):
180
+ from router import RouterError
181
+
182
+ pool = Pool(state)
183
+ try:
184
+ sessions = pool.detail()
185
+ if args.target == "all":
186
+ matching = sessions
187
+ elif args.target:
188
+ matching = [
189
+ s
190
+ for s in sessions
191
+ if s["id"].startswith(args.target) or s.get("name") == args.target
192
+ ]
193
+ else:
194
+ matching = sessions if len(sessions) == 1 else []
195
+ if not matching or args.target != "all" and len(matching) != 1:
196
+ raise RouterError(
197
+ "Choose a unique session ID/name from status, or use all."
198
+ )
199
+ params = {}
200
+ if args.command == "switch":
201
+ params["account"] = resolve_label(state, args.account)
202
+ if getattr(args, "thread", None):
203
+ params["thread"] = args.thread
204
+ for session in matching:
205
+ op = "resume" if args.command == "unpause" else args.command
206
+ ident = pool.command(session["id"], op, params)
207
+ print(f"Queued {op} for {session['id'][:10]} (command {ident}).")
208
+ return 0
209
+ finally:
210
+ pool.close()
211
+
212
+
213
+ async def manage(args, state, binary):
214
+ async with file_lock(state / "enrollment.lock"):
215
+ return await _manage(args, state, binary)
216
+
217
+
218
+ async def _manage(args, state, binary):
219
+ from router import labels, account_home, add_account, RouterError, clean_env
220
+ import broker
221
+
222
+ pool = Pool(state)
223
+ try:
224
+ label = resolve_label(state, args.label) if args.action != "add" else args.label
225
+ if args.action in {"enable", "disable", "priority", "cap", "rename", "models"}:
226
+ if args.action in {"enable", "disable"}:
227
+ pool.set_options(label, enabled=args.action == "enable")
228
+ elif args.action == "priority":
229
+ pool.set_options(label, priority=args.value)
230
+ elif args.action == "cap":
231
+ if args.value < 0:
232
+ raise RouterError(
233
+ "Session cap must be nonnegative; 0 is unlimited."
234
+ )
235
+ pool.set_options(label, max_sessions=args.value)
236
+ elif args.action == "models":
237
+ pool.set_options(label, models=args.values)
238
+ else:
239
+ if not args.value.strip():
240
+ raise RouterError("Choose a nonempty name")
241
+ if any(
242
+ pool.options(other).get("name", other) == args.value
243
+ for other in labels(state)
244
+ if other != label
245
+ ):
246
+ raise RouterError("That account name is already used")
247
+ pool.set_options(label, name=args.value)
248
+ print(f"{label}: {args.action} updated.")
249
+ return 0
250
+ home = account_home(state, label)
251
+ home.mkdir(parents=True, exist_ok=True, mode=0o700)
252
+ if not pool.begin_maintenance(label):
253
+ raise RouterError(
254
+ "This account is assigned to a live session. Switch those sessions to another account first; other accounts can keep running."
255
+ )
256
+ try:
257
+ with contextlib.suppress(Exception):
258
+ await broker.exchange(state, {"op": "invalidate", "label": label}, 15)
259
+ async with file_lock(home / "credential.lock"):
260
+ if args.action in {"add", "reauth"}:
261
+ args.label = label
262
+ await asyncio.to_thread(add_account, args, state, binary)
263
+ elif args.action == "remove":
264
+ if args.revoke:
265
+ result = await asyncio.create_subprocess_exec(
266
+ binary,
267
+ "logout",
268
+ env=clean_env(home),
269
+ stdout=asyncio.subprocess.DEVNULL,
270
+ stderr=asyncio.subprocess.DEVNULL,
271
+ )
272
+ if await result.wait():
273
+ raise RouterError(
274
+ "Native sign-out failed; account was retained."
275
+ )
276
+ marker = home / "enrolled"
277
+ if marker.exists():
278
+ marker.rename(home / "enrolled.removed")
279
+ pool.reset_identity(label)
280
+ pool.set_options(label, enabled=False)
281
+ print(
282
+ f"{label}: removed from routing. "
283
+ + (
284
+ "Native login revoked."
285
+ if args.revoke
286
+ else "Keychain login retained; use add to enroll again."
287
+ )
288
+ )
289
+ finally:
290
+ pool.set_options(label, maintenance=False)
291
+ pool.signal()
292
+ return 0
293
+ finally:
294
+ pool.close()
295
+
296
+
297
+ def history(state, label=None):
298
+ pool = Pool(state)
299
+ try:
300
+ with pool.db() as db:
301
+ rows = db.execute(
302
+ "SELECT at,label,snapshot FROM history WHERE (? IS NULL OR label=?) ORDER BY at DESC LIMIT 1000",
303
+ (label, label),
304
+ ).fetchall()
305
+ grouped = {}
306
+ for at, name, raw in rows:
307
+ grouped.setdefault(name, []).append((at, json.loads(raw)))
308
+ output = []
309
+ for name, points in grouped.items():
310
+ latest = points[0][1].get("rateLimits", {})
311
+ item = {
312
+ "account": name,
313
+ "samples": len(points),
314
+ "at": points[0][0],
315
+ "primary": latest.get("primary"),
316
+ "secondary": latest.get("secondary"),
317
+ "forecast": None,
318
+ }
319
+ if len(points) > 1:
320
+ first_time, first = points[-1]
321
+ last_time, last = points[0]
322
+ a = first.get("rateLimits", {}).get("primary") or {}
323
+ b = last.get("rateLimits", {}).get("primary") or {}
324
+ elapsed = last_time - first_time
325
+ delta = b.get("usedPercent", 0) - a.get("usedPercent", 0)
326
+ if (
327
+ elapsed >= 60
328
+ and delta > 0
329
+ and a.get("resetsAt") == b.get("resetsAt")
330
+ ):
331
+ item["forecast"] = {
332
+ "estimated_seconds_to_depletion": max(0, 100 - b["usedPercent"])
333
+ * elapsed
334
+ / delta,
335
+ "basis": "Observed primary allowance change in one reset window; estimate only.",
336
+ }
337
+ output.append(item)
338
+ return output
339
+ finally:
340
+ pool.close()
341
+
342
+
343
+ async def doctor(args, state, binary):
344
+ import compatibility
345
+
346
+ result = {
347
+ "platform": sys.platform,
348
+ "python": sys.version.split()[0],
349
+ "state": str(state),
350
+ "checks": {},
351
+ }
352
+ checks = result["checks"]
353
+ if args.restore_backup:
354
+ from metadata import restore_backup
355
+
356
+ try:
357
+ async with asyncio.timeout(2):
358
+ async with file_lock(state / "metadata.lock"):
359
+ try:
360
+ checks["metadata_before_restore"] = restore_backup(state)
361
+ except sqlite3.Error:
362
+ raise ValueError(
363
+ "Metadata backup is corrupt or unreadable; existing files were retained"
364
+ ) from None
365
+ except asyncio.TimeoutError:
366
+ raise ValueError(
367
+ "Stop wrapper sessions before restoring metadata"
368
+ ) from None
369
+ try:
370
+ db = sqlite3.connect(state / "pool.sqlite")
371
+ checks["database"] = db.execute("PRAGMA quick_check").fetchone()[0]
372
+ db.close()
373
+ except sqlite3.Error:
374
+ checks["database"] = "corrupt or unreadable"
375
+ checks["state_permissions"] = oct(state.stat().st_mode & 0o777)
376
+ try:
377
+ async with file_lock(state / "compatibility.lock"):
378
+ checks["compatibility"] = await asyncio.to_thread(
379
+ compatibility.check, binary, state
380
+ )
381
+ except Exception:
382
+ checks["compatibility"] = "failed; no credentials were read"
383
+ checks["keychain_reader"] = Path("/usr/bin/security").exists()
384
+ try:
385
+ import broker
386
+
387
+ checks["broker"] = await broker.exchange(state, {"op": "ping"}, 2)
388
+ except Exception:
389
+ checks["broker"] = "not running (starts on demand)"
390
+ if args.self_test:
391
+ source = Path(__file__).parent
392
+ tests = (
393
+ source / "tests" if (source / "tests").is_dir() else source.parent / "tests"
394
+ )
395
+ if not tests.is_dir():
396
+ raise ValueError(
397
+ "Offline checks are missing. Reinstall Relay and try again."
398
+ )
399
+ env = {
400
+ **os.environ,
401
+ "PYTHONPATH": os.pathsep.join(
402
+ [str(source), str(tests), os.environ.get("PYTHONPATH", "")]
403
+ ),
404
+ }
405
+ proc = await asyncio.create_subprocess_exec(
406
+ sys.executable,
407
+ "-m",
408
+ "unittest",
409
+ "discover",
410
+ "-s",
411
+ str(tests),
412
+ "-q",
413
+ cwd=str(tests),
414
+ env=env,
415
+ )
416
+ checks["offline_tests_exit_code"] = await proc.wait()
417
+ if args.backup and checks["database"] == "ok":
418
+ target = state / "pool.backup.sqlite"
419
+ source = sqlite3.connect(state / "pool.sqlite")
420
+ dest = sqlite3.connect(target)
421
+ source.backup(dest)
422
+ source.close()
423
+ dest.close()
424
+ target.chmod(0o600)
425
+ checks["backup"] = str(target)
426
+ healthy = (
427
+ checks.get("database") == "ok"
428
+ and isinstance(checks.get("compatibility"), dict)
429
+ and checks.get("offline_tests_exit_code", 0) == 0
430
+ )
431
+ print(json.dumps(result, indent=2))
432
+ return 0 if healthy else 1
433
+
434
+
435
+ async def group(args, state, binary):
436
+ from router import RouterError
437
+ import service
438
+
439
+ path = state / "groups.json"
440
+ data = json.loads(path.read_text()) if path.exists() else {}
441
+ if args.action == "list":
442
+ print(json.dumps(data, indent=2))
443
+ return 0
444
+ if args.action == "save":
445
+ data[args.name] = [
446
+ {"name": Path(p).name, "cwd": str(Path(p).expanduser().resolve())}
447
+ for p in args.paths
448
+ ]
449
+ settings.atomic_json(path, data)
450
+ print("Saved group " + args.name)
451
+ return 0
452
+ if args.action == "remove":
453
+ data.pop(args.name, None)
454
+ settings.atomic_json(path, data)
455
+ return 0
456
+ if args.name not in data:
457
+ raise RouterError("Unknown task group")
458
+ if args.action == "start":
459
+ import runtime
460
+
461
+ async with file_lock(state / "compatibility.lock"):
462
+ binary, _, _ = await asyncio.to_thread(runtime.select, binary, state)
463
+ for spec in data[args.name]:
464
+ ident = service.start(
465
+ state,
466
+ name=args.name + "/" + spec["name"],
467
+ cwd=spec["cwd"],
468
+ arguments=["--yolo"] if args.yolo else [],
469
+ binary=binary,
470
+ )
471
+ print(ident + " " + spec["name"])
472
+ return 0
473
+
474
+
475
+ def add_parsers(sub):
476
+ status = sub.add_parser(
477
+ "status", help="Account and session dashboard without live network requests"
478
+ )
479
+ status.add_argument("--json", action="store_true")
480
+ status.add_argument("--watch", action="store_true")
481
+ status.add_argument(
482
+ "--all",
483
+ action="store_true",
484
+ help="Include stopped and completed saved sessions",
485
+ )
486
+ restart = sub.add_parser(
487
+ "restart", help="Restart a stopped session from saved history"
488
+ )
489
+ restart.add_argument("session")
490
+ status.add_argument("--interval", type=float, default=2)
491
+ for command in ["pause", "unpause", "retry", "wake", "stop", "switch"]:
492
+ p = sub.add_parser(command, help=command + " one live session or all")
493
+ p.add_argument("target", nargs="?")
494
+ p.add_argument("--thread")
495
+ if command == "switch":
496
+ p.add_argument("--account", required=True)
497
+ account = sub.add_parser("account", help="Manage any number of enrolled accounts")
498
+ child = account.add_subparsers(dest="action", required=True)
499
+ for action in [
500
+ "add",
501
+ "reauth",
502
+ "remove",
503
+ "enable",
504
+ "disable",
505
+ "rename",
506
+ "priority",
507
+ "cap",
508
+ "models",
509
+ ]:
510
+ p = child.add_parser(action)
511
+ p.add_argument("label")
512
+ if action in {"add", "reauth"}:
513
+ p.add_argument("--device-auth", action="store_true")
514
+ if action == "remove":
515
+ p.add_argument("--revoke", action="store_true")
516
+ if action == "rename":
517
+ p.add_argument("value")
518
+ if action in {"priority", "cap"}:
519
+ p.add_argument("value", type=int)
520
+ if action == "models":
521
+ p.add_argument("values", nargs="*")
522
+ config = sub.add_parser("config", help="Set global or named recovery profiles")
523
+ config.add_argument("key", nargs="?")
524
+ config.add_argument("value", nargs="?")
525
+ config.add_argument("--profile")
526
+ logs = sub.add_parser("events", help="Read bounded metadata-only event history")
527
+ logs.add_argument("--limit", type=int, default=30)
528
+ logs.add_argument("--json", action="store_true")
529
+ hist = sub.add_parser("usage", help="Last observed allowance and local estimates")
530
+ hist.add_argument("--account")
531
+ hist.add_argument("--json", action="store_true")
532
+ doc = sub.add_parser(
533
+ "doctor", help="Check installation, database, broker and compatibility"
534
+ )
535
+ doc.add_argument("--self-test", action="store_true")
536
+ doc.add_argument("--backup", action="store_true")
537
+ doc.add_argument(
538
+ "--restore-backup",
539
+ action="store_true",
540
+ help="Restore metadata when wrapper sessions are stopped; retain the replaced database",
541
+ )
542
+ runtime = sub.add_parser(
543
+ "runtime", help="Preflight, snapshot or select a tested native Codex runtime"
544
+ )
545
+ runtime.add_argument("action", choices=["list", "cache", "use", "preflight"])
546
+ runtime.add_argument("value", nargs="?")
547
+ menu = sub.add_parser(
548
+ "menubar", help="Build or launch the optional native menu app"
549
+ )
550
+ menu.add_argument("--build-only", action="store_true")
551
+ start = sub.add_parser(
552
+ "start", help="Start a supervised background task; attach later"
553
+ )
554
+ for flag in ["name", "cwd", "first", "resume", "prompt", "profile"]:
555
+ start.add_argument("--" + flag)
556
+ start.add_argument("codex_args", nargs="...")
557
+ attach = sub.add_parser(
558
+ "attach", help="Attach the native terminal to a background session"
559
+ )
560
+ attach.add_argument("session")
561
+ attach.add_argument("codex_args", nargs="...")
562
+ attach.add_argument(
563
+ "--takeover",
564
+ action="store_true",
565
+ help="Move an existing terminal attachment without stopping its task",
566
+ )
567
+ groups = sub.add_parser("group", help="Save and launch groups of project tasks")
568
+ g = groups.add_subparsers(dest="action", required=True)
569
+ g.add_parser("list")
570
+ save = g.add_parser("save")
571
+ save.add_argument("name")
572
+ save.add_argument("paths", nargs="+")
573
+ rm = g.add_parser("remove")
574
+ rm.add_argument("name")
575
+ launch = g.add_parser("start")
576
+ launch.add_argument("name")
577
+ launch.add_argument("--yolo", action="store_true")
578
+ for command in ["_supervise", "_serve"]:
579
+ p = sub.add_parser(command, help=__import__("argparse").SUPPRESS)
580
+ p.add_argument("session")
581
+ b = sub.add_parser("_broker", help=__import__("argparse").SUPPRESS)
582
+ b.add_argument("--binary")
583
+
584
+
585
+ async def dispatch(args, state, binary):
586
+ from router import RouterError
587
+
588
+ if args.command == "restart":
589
+ import service
590
+
591
+ async with file_lock(state / "restart.lock"):
592
+ ident = service.restart(state, args.session)
593
+ print("Restarted " + ident)
594
+ return 0
595
+ if args.command == "status":
596
+ if args.interval < 0.5:
597
+ raise RouterError("Watch interval must be at least 0.5 seconds")
598
+ return await status(args, state)
599
+ if args.command in {"pause", "unpause", "retry", "wake", "stop", "switch"}:
600
+ return await control(args, state)
601
+ if args.command == "account":
602
+ return await manage(args, state, binary)
603
+ if args.command == "config":
604
+ if args.key:
605
+ if args.value is None:
606
+ raise RouterError("Provide a setting value")
607
+ settings.edit(state, args.key, args.value, args.profile)
608
+ pool = Pool(state)
609
+ pool.signal()
610
+ pool.close()
611
+ print(json.dumps(settings.load(state, os.getcwd(), args.profile), indent=2))
612
+ return 0
613
+ if args.command == "events":
614
+ pool = Pool(state)
615
+ try:
616
+ with pool.db() as db:
617
+ rows = db.execute(
618
+ "SELECT at,owner,kind,details FROM events ORDER BY id DESC LIMIT ?",
619
+ (max(1, min(args.limit, 2000)),),
620
+ ).fetchall()
621
+ value = [
622
+ dict(at=r[0], session=r[1], kind=r[2], details=json.loads(r[3]))
623
+ for r in rows
624
+ ]
625
+ print(
626
+ json.dumps(value, indent=2)
627
+ if args.json
628
+ else "\n".join(
629
+ time.strftime("%m-%d %H:%M:%S", time.localtime(v["at"]))
630
+ + " "
631
+ + v["session"][:10]
632
+ + " "
633
+ + v["kind"]
634
+ + " "
635
+ + json.dumps(v["details"])
636
+ for v in value
637
+ )
638
+ )
639
+ return 0
640
+ finally:
641
+ pool.close()
642
+ if args.command == "usage":
643
+ label = resolve_label(state, args.account) if args.account else None
644
+ print(json.dumps(history(state, label), indent=2))
645
+ return 0
646
+ if args.command == "doctor":
647
+ return await doctor(args, state, binary)
648
+ if args.command == "runtime":
649
+ import runtime, compatibility
650
+
651
+ async with file_lock(state / "compatibility.lock"):
652
+ if args.action == "list":
653
+ print(json.dumps(runtime.index(state), indent=2))
654
+ elif args.action == "use":
655
+ runtime.choose(state, args.value)
656
+ print(
657
+ "Wrapper runtime selection updated; existing sessions are unchanged."
658
+ )
659
+ elif args.action == "cache":
660
+ path, result = await asyncio.to_thread(
661
+ runtime.capture, args.value or binary, state
662
+ )
663
+ print(result["version"] + " cached at " + path)
664
+ else:
665
+ result = await asyncio.to_thread(
666
+ compatibility.check, args.value or binary, state
667
+ )
668
+ print(json.dumps(result, indent=2))
669
+ return 0
670
+ if args.command == "menubar":
671
+ import desktop
672
+
673
+ app = await asyncio.to_thread(desktop.menubar, state, None, args.build_only)
674
+ print(str(app))
675
+ return 0
676
+ if args.command == "group":
677
+ return await group(args, state, binary)
678
+ if args.command == "start":
679
+ import service
680
+
681
+ first = resolve_label(state, args.first) if args.first else None
682
+ ident = service.start(
683
+ state,
684
+ name=args.name,
685
+ cwd=args.cwd,
686
+ first=first,
687
+ arguments=args.codex_args,
688
+ resume=args.resume,
689
+ prompt=args.prompt,
690
+ profile=args.profile,
691
+ binary=binary,
692
+ )
693
+ print("Started " + ident + "\nAttach: relay attach " + ident[:10])
694
+ return 0
695
+ if args.command == "attach":
696
+ import service
697
+
698
+ return await service.attach(
699
+ state, args.session, binary, args.codex_args or None, takeover=args.takeover
700
+ )
701
+ return None