relay-codex 0.0.0-stage → 0.5.0

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