relay-codex 0.4.1 → 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.
package/python/router.py CHANGED
@@ -7,6 +7,7 @@ import argparse
7
7
  import asyncio
8
8
  import base64
9
9
  import contextlib
10
+ import contextvars
10
11
  import hashlib
11
12
  import json
12
13
  import os
@@ -20,10 +21,10 @@ import time
20
21
  import uuid
21
22
 
22
23
  from pool import Pool, blocked, reset_time, failure_kind, file_lock
23
- from launch import translate
24
+ from launch import translate, RESUME_FIELDS
24
25
  import settings
25
26
 
26
- VERSION = "0.4.1"
27
+ VERSION = "0.5.0"
27
28
  _support = Path.home() / "Library/Application Support"
28
29
  _legacy = _support / "Codex Accounts"
29
30
  _default_state = _legacy if (_legacy / "accounts").exists() else _support / "Relay"
@@ -44,6 +45,16 @@ class RouterError(Exception):
44
45
  """A message safe to display; never include a server error or credential."""
45
46
 
46
47
 
48
+ class RecoveryTransition(Exception):
49
+ """Return to the recovery driver without growing the Python call stack."""
50
+
51
+ def __init__(self, kind):
52
+ self.kind = kind
53
+
54
+
55
+ _recovery_active = contextvars.ContextVar("relay_recovery_active", default=None)
56
+
57
+
47
58
  class RemoteError(RouterError):
48
59
  def __init__(self, payload):
49
60
  super().__init__(
@@ -95,7 +106,7 @@ def labels(state: Path) -> list[str]:
95
106
  )
96
107
 
97
108
 
98
- def read_keychain(home: Path) -> dict:
109
+ def read_keychain(home: Path, *, include_identity=False) -> dict:
99
110
  """Native Codex direct-keyring layout in 0.166.0. stdout remains in memory."""
100
111
  key = "cli|" + hashlib.sha256(str(home.resolve()).encode()).hexdigest()[:16]
101
112
  try:
@@ -134,11 +145,16 @@ def read_keychain(home: Path) -> dict:
134
145
  raise RouterError(
135
146
  "Account token expired; enroll this account again if automatic refresh fails."
136
147
  )
137
- return {
148
+ result = {
138
149
  "accessToken": token,
139
150
  "chatgptAccountId": account_id,
140
151
  "chatgptPlanType": metadata.get("chatgpt_plan_type"),
141
152
  }
153
+ if include_identity:
154
+ result["email"] = claims.get("email") or claims.get(
155
+ "https://api.openai.com/profile", {}
156
+ ).get("email")
157
+ return result
142
158
  except (KeyError, ValueError, TypeError, IndexError):
143
159
  raise RouterError(
144
160
  "This Keychain entry is not a usable ChatGPT login."
@@ -174,10 +190,9 @@ class RPC:
174
190
  binary,
175
191
  "app-server",
176
192
  "--stdio",
177
- "-c",
178
- f'cli_auth_credentials_store="{mode}"',
179
193
  ]
180
194
  cmd.extend(extra_args or [])
195
+ cmd += ["-c", f'cli_auth_credentials_store="{mode}"']
181
196
  if helper:
182
197
  cmd += ["-c", "features.secret_auth_storage=false"]
183
198
  else:
@@ -392,6 +407,7 @@ class Controller:
392
407
  self.thread_settings = {}
393
408
  self.status_changed = asyncio.Event()
394
409
  self.launch_overrides = {}
410
+ self.additional_roots = []
395
411
  self.preferences = (
396
412
  settings.load(pool.state) if pool else settings.DEFAULTS.copy()
397
413
  )
@@ -416,6 +432,7 @@ class Controller:
416
432
  self.project = os.getcwd()
417
433
  self.profile = None
418
434
  self.network_state = None
435
+ self.awake_process = None
419
436
  self.fallback_tried = {}
420
437
 
421
438
  def attached(self):
@@ -457,6 +474,12 @@ class Controller:
457
474
  "environments",
458
475
  "disabledPluginIds",
459
476
  "runtimeWorkspaceRoots",
477
+ "modelProvider",
478
+ "personality",
479
+ "baseInstructions",
480
+ "developerInstructions",
481
+ "outputSchema",
482
+ "cyberAccessProgram",
460
483
  }
461
484
  current = self.thread_settings.setdefault(thread, {})
462
485
  params = dict(params)
@@ -483,6 +506,25 @@ class Controller:
483
506
  if params.get("sandbox") or params.get("sandboxPolicy"):
484
507
  current.pop("permissions", None)
485
508
 
509
+ def hold_for_review(self, thread, reason="uncertain_action"):
510
+ self.paused_uncertain.add(thread)
511
+ self.persist(
512
+ thread, review_required=True, review_reason=reason, state="needs_attention"
513
+ )
514
+
515
+ def clear_review(self, thread):
516
+ self.paused_uncertain.discard(thread)
517
+ self.persist(thread, review_required=False, review_reason=None)
518
+
519
+ def can_continue(self, thread, generation):
520
+ saved = self.pool.intent(thread) if self.pool else {}
521
+ return (
522
+ generation == self.user_generation
523
+ and self.attached()
524
+ and thread not in self.paused_uncertain
525
+ and not (saved or {}).get("review_required", False)
526
+ )
527
+
486
528
  async def latest(self, thread):
487
529
  result = await self.backend.call(
488
530
  "thread/turns/list",
@@ -530,12 +572,12 @@ class Controller:
530
572
  return
531
573
  outcome, turn = await self.reconcile(thread, baseline)
532
574
  if outcome in {"running", "completed"}:
533
- self.paused_uncertain.discard(thread)
575
+ self.clear_review(thread)
534
576
  if outcome == "completed":
535
577
  self.note("completed", thread=thread)
536
578
  return
537
579
  if outcome == "failed":
538
- self.paused_uncertain.discard(thread)
580
+ self.clear_review(thread)
539
581
  code = (turn.get("error") or {}).get("codexErrorInfo")
540
582
  kind = failure_kind(code, (turn.get("error") or {}).get("message"))
541
583
  if kind:
@@ -553,7 +595,7 @@ class Controller:
553
595
  return
554
596
  if attempt < self.retry_attempts:
555
597
  await self.wait_until(self.clock() + self.retry_interval, generation)
556
- self.paused_uncertain.add(thread)
598
+ self.hold_for_review(thread)
557
599
  self.note("needs_attention", thread=thread, reason="acceptance_unknown")
558
600
  self.report(
559
601
  "Could not prove the request was accepted. It was not replayed. Use retry after reviewing the saved thread."
@@ -628,6 +670,23 @@ class Controller:
628
670
  except Exception:
629
671
  self.pool.ack(command, "failed")
630
672
  self.note("control_failed", operation=op)
673
+ if self.preferences.get("keep_awake") and self.active_threads:
674
+ if (
675
+ self.awake_process is None
676
+ or self.awake_process.returncode is not None
677
+ ):
678
+ self.awake_process = await asyncio.create_subprocess_exec(
679
+ "/usr/bin/caffeinate",
680
+ "-i",
681
+ "-w",
682
+ str(os.getpid()),
683
+ stdout=asyncio.subprocess.DEVNULL,
684
+ stderr=asyncio.subprocess.DEVNULL,
685
+ )
686
+ elif self.awake_process and self.awake_process.returncode is None:
687
+ self.awake_process.terminate()
688
+ await self.awake_process.wait()
689
+ self.awake_process = None
631
690
  if (
632
691
  self.network_state is False
633
692
  and now - last_network >= self.preferences["offline_retry"]
@@ -691,7 +750,11 @@ class Controller:
691
750
  if thread:
692
751
  await self.backend.call("thread/resume", {"threadId": thread, **params})
693
752
  self.merge_settings(thread, params)
694
- self.launch_overrides = dict(params)
753
+ self.launch_overrides = {**self.launch_overrides, **params}
754
+ if params.get("permissions"):
755
+ self.launch_overrides.pop("sandbox", None)
756
+ elif params.get("sandbox"):
757
+ self.launch_overrides.pop("permissions", None)
695
758
  elif op == "switch":
696
759
  label = params.get("account")
697
760
  index = next(
@@ -740,7 +803,7 @@ class Controller:
740
803
  )
741
804
  self.pool.invalidate(self.account.label)
742
805
  if thread and (not self.recovery or self.recovery.done()):
743
- self.paused_uncertain.discard(thread)
806
+ self.clear_review(thread)
744
807
  self.recovery = asyncio.create_task(
745
808
  self.continue_thread(thread, self.user_generation)
746
809
  )
@@ -783,28 +846,21 @@ class Controller:
783
846
  {
784
847
  k: v
785
848
  for k, v in self.thread_settings.get(thread, {}).items()
786
- if k
787
- in {
788
- "model",
789
- "cwd",
790
- "approvalPolicy",
791
- "approvalsReviewer",
792
- "sandbox",
793
- "permissions",
794
- "serviceTier",
795
- }
849
+ if k in RESUME_FIELDS
796
850
  }
797
851
  )
852
+ if self.launch_overrides.get("config"):
853
+ params["config"] = self.launch_overrides["config"]
798
854
  try:
799
855
  await self.backend.call("thread/resume", params)
800
856
  except (RouterError, asyncio.TimeoutError):
801
- self.paused_uncertain.add(thread)
857
+ self.hold_for_review(thread)
802
858
  self.note(
803
859
  "needs_attention", thread=thread, reason="backend_resume_failed"
804
860
  )
805
861
  continue
806
862
  if in_flight:
807
- self.paused_uncertain.add(thread)
863
+ self.hold_for_review(thread)
808
864
  self.note(
809
865
  "needs_attention",
810
866
  thread=thread,
@@ -1082,12 +1138,19 @@ class Controller:
1082
1138
  return generation == self.user_generation and self.attached()
1083
1139
 
1084
1140
  async def continue_thread(self, thread, generation):
1085
- if not await self.wait_idle(generation):
1141
+ if not self.can_continue(thread, generation):
1142
+ return
1143
+ if not await self.wait_idle(generation) or not self.can_continue(
1144
+ thread, generation
1145
+ ):
1086
1146
  return
1087
1147
  params = {
1088
1148
  "threadId": thread,
1089
1149
  "input": [{"type": "text", "text": CONTINUE, "text_elements": []}],
1090
1150
  }
1151
+ for key in ("outputSchema", "cyberAccessProgram", "effort", "summary"):
1152
+ if self.thread_settings.get(thread, {}).get(key) is not None:
1153
+ params[key] = self.thread_settings[thread][key]
1091
1154
  params["clientUserMessageId"] = str(uuid.uuid4())
1092
1155
  baseline = self.turn_ids.get(thread) or "__unknown_before_submission__"
1093
1156
  self.pending_intents[thread] = {"baseline": baseline, "accepted": False}
@@ -1115,6 +1178,11 @@ class Controller:
1115
1178
  async def restore_recovery(self, thread, persisted):
1116
1179
  """Honor an accepted retry's original deadline after a process restart."""
1117
1180
  generation = self.user_generation
1181
+ if persisted.get("review_required"):
1182
+ self.hold_for_review(
1183
+ thread, persisted.get("review_reason") or "review_required"
1184
+ )
1185
+ return
1118
1186
  episode = persisted.get("episode")
1119
1187
  if episode:
1120
1188
  episode = dict(episode)
@@ -1131,7 +1199,7 @@ class Controller:
1131
1199
  deadline, generation, allow_early=persisted.get("kind") == "quota"
1132
1200
  )
1133
1201
  if persisted.get("active_items") or not persisted.get("accepted", False):
1134
- self.paused_uncertain.add(thread)
1202
+ self.hold_for_review(thread)
1135
1203
  self.note(
1136
1204
  "needs_attention",
1137
1205
  thread=thread,
@@ -1236,6 +1304,20 @@ class Controller:
1236
1304
  return None, min(deadlines, default=self.clock() + self.interval)
1237
1305
 
1238
1306
  async def recover(self, thread, generation, kind="quota"):
1307
+ if _recovery_active.get() is asyncio.current_task():
1308
+ raise RecoveryTransition(kind)
1309
+ token = _recovery_active.set(asyncio.current_task())
1310
+ try:
1311
+ while self.can_continue(thread, generation):
1312
+ try:
1313
+ return await self._recover_episode(thread, generation, kind)
1314
+ except RecoveryTransition as transition:
1315
+ kind = transition.kind
1316
+ await asyncio.sleep(0)
1317
+ finally:
1318
+ _recovery_active.reset(token)
1319
+
1320
+ async def _recover_episode(self, thread, generation, kind):
1239
1321
  try:
1240
1322
  model = self.model_for(thread)
1241
1323
  if kind == "model":
@@ -1375,9 +1457,10 @@ class Controller:
1375
1457
  state="waiting_for_reset",
1376
1458
  )
1377
1459
  await self.wait_until(max(self.clock() + 1, deadline), generation)
1378
- except asyncio.CancelledError:
1460
+ except (asyncio.CancelledError, RecoveryTransition):
1379
1461
  raise
1380
1462
  except Exception:
1463
+ self.hold_for_review(thread, "handoff_failed")
1381
1464
  self.note("needs_attention", thread=thread, reason="handoff_failed")
1382
1465
  self.report(
1383
1466
  "Automatic handoff stopped. Your saved thread is retained; continue manually."
@@ -1392,6 +1475,23 @@ class Controller:
1392
1475
  ):
1393
1476
  message = dict(message)
1394
1477
  message["params"] = {**message.get("params", {}), **self.launch_overrides}
1478
+ if (
1479
+ method in {"thread/start", "thread/resume", "thread/fork"}
1480
+ and self.additional_roots
1481
+ ):
1482
+ message = {**message, "params": dict(message.get("params", {}))}
1483
+ params = message["params"]
1484
+ params["runtimeWorkspaceRoots"] = list(
1485
+ dict.fromkeys(
1486
+ [
1487
+ *(
1488
+ params.get("runtimeWorkspaceRoots")
1489
+ or [params.get("cwd") or self.project]
1490
+ ),
1491
+ *self.additional_roots,
1492
+ ]
1493
+ )
1494
+ )
1395
1495
  if not method:
1396
1496
  # Approval responses keep the server's original ID, never our call IDs.
1397
1497
  if request_id in self.server_requests:
@@ -1443,6 +1543,7 @@ class Controller:
1443
1543
  self.fallback_tried.pop(message["params"]["threadId"], None)
1444
1544
  self.status_changed.set()
1445
1545
  if method == "turn/start":
1546
+ self.clear_review(message["params"]["threadId"])
1446
1547
  self.last_turn[message["params"]["threadId"]] = message["params"]
1447
1548
  self.merge_settings(message["params"]["threadId"], message["params"])
1448
1549
  try:
@@ -1636,12 +1737,19 @@ class Controller:
1636
1737
  async def run_pool(args, state, binary):
1637
1738
  import service
1638
1739
 
1639
- settings.load(state, os.getcwd(), args.profile)
1640
- frontend, overrides = translate(args.codex_args)
1641
- target = resume_target(
1740
+ from launch import frontend_arguments, working_directory, prepare_worktree
1741
+
1742
+ invocation_cwd = os.getcwd()
1743
+ cwd = working_directory(args.codex_args, invocation_cwd)
1744
+ settings.load(state, cwd, args.profile)
1745
+ arguments = frontend_arguments(args.codex_args, invocation_cwd)
1746
+ arguments, cwd = prepare_worktree(arguments, state, cwd)
1747
+ frontend, overrides = translate(arguments)
1748
+ target = await resolve_resume_target(
1642
1749
  frontend,
1643
1750
  Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")),
1644
- os.getcwd(),
1751
+ cwd,
1752
+ binary,
1645
1753
  )
1646
1754
  pool = Pool(state)
1647
1755
  try:
@@ -1652,27 +1760,29 @@ async def run_pool(args, state, binary):
1652
1760
  print(
1653
1761
  "Reconnecting to existing wrapper session " + owner[:10] + ".", flush=True
1654
1762
  )
1655
- return await service.attach(state, owner, binary, frontend, wait_forever=True)
1763
+ return await service.attach(
1764
+ state, owner, binary, arguments, wait_forever=True, cwd=invocation_cwd
1765
+ )
1656
1766
  ident = service.start(
1657
1767
  state,
1658
- name=args.name or Path.cwd().name,
1659
- cwd=os.getcwd(),
1768
+ name=args.name or Path(cwd).name,
1769
+ cwd=cwd,
1660
1770
  first=args.first,
1661
- arguments=args.codex_args,
1771
+ arguments=arguments,
1662
1772
  profile=args.profile,
1663
1773
  interactive=True,
1664
1774
  binary=binary,
1665
1775
  )
1666
1776
  try:
1667
1777
  return await service.attach(
1668
- state, ident, binary, frontend or [], wait_forever=True
1778
+ state, ident, binary, arguments, wait_forever=True, cwd=invocation_cwd
1669
1779
  )
1670
1780
  finally:
1671
1781
  # A foreground launch normally closes its worker after the terminal exits.
1672
1782
  # Background mode and explicit takeover preserve the supervised worker.
1673
1783
  spec_path = service.folder(state) / (ident + ".job.json")
1674
1784
  spec = json.loads(spec_path.read_text()) if spec_path.exists() else {}
1675
- current_preferences = settings.load(state, os.getcwd(), args.profile)
1785
+ current_preferences = settings.load(state, cwd, args.profile)
1676
1786
  if not current_preferences.get("background_on_disconnect") and not spec.get(
1677
1787
  "takeover"
1678
1788
  ):
@@ -1686,41 +1796,79 @@ async def run_pool(args, state, binary):
1686
1796
  await asyncio.sleep(0.1)
1687
1797
 
1688
1798
 
1689
- def resume_target(arguments, home, cwd):
1690
- if "resume" not in arguments:
1691
- return None
1692
- index = arguments.index("resume")
1693
- for item in arguments[index + 1 :]:
1694
- try:
1695
- return str(uuid.UUID(item))
1696
- except (ValueError, AttributeError):
1697
- pass
1698
- if "--last" not in arguments:
1699
- return None
1700
- database = home / "state_5.sqlite"
1701
- if not database.exists():
1702
- return None
1703
- import sqlite3
1799
+ def resume_selection(arguments):
1800
+ from launch import tokens
1801
+
1802
+ parsed = list(tokens(arguments))
1803
+ positionals = [
1804
+ value for name, value, raw in parsed if name is None and len(raw) == 1
1805
+ ]
1806
+ if not positionals or positionals[0] not in {"resume", "fork"}:
1807
+ return None, set()
1808
+ flags = {name for name, _, _ in parsed}
1809
+ return positionals[1] if len(positionals) > 1 else None, flags
1704
1810
 
1811
+
1812
+ def resume_target(arguments, home=None, cwd=None):
1813
+ """Explicit IDs need no native database or backend."""
1814
+ selected, _ = resume_selection(arguments)
1705
1815
  try:
1706
- db = sqlite3.connect("file:" + str(database) + "?mode=ro", uri=True)
1707
- try:
1708
- if "--all" in arguments:
1709
- row = db.execute(
1710
- "SELECT id FROM threads WHERE archived=0 ORDER BY updated_at DESC LIMIT 1"
1711
- ).fetchone()
1712
- else:
1713
- row = db.execute(
1714
- "SELECT id FROM threads WHERE archived=0 AND cwd=? ORDER BY updated_at DESC LIMIT 1",
1715
- (cwd,),
1716
- ).fetchone()
1717
- finally:
1718
- db.close()
1719
- return row[0] if row else None
1720
- except sqlite3.Error:
1816
+ return str(uuid.UUID(selected)) if selected else None
1817
+ except (ValueError, AttributeError):
1721
1818
  return None
1722
1819
 
1723
1820
 
1821
+ async def resolve_resume_target(arguments, home, cwd, binary=None, backend=None):
1822
+ selected, flags = resume_selection(arguments)
1823
+ explicit = resume_target(arguments)
1824
+ if explicit or not selected and "--last" not in flags:
1825
+ return explicit
1826
+ own_backend = backend is None
1827
+ if own_backend:
1828
+ backend = await RPC.start(binary, home)
1829
+ await backend.initialize()
1830
+ try:
1831
+ params = {
1832
+ "archived": False,
1833
+ "sortKey": "updated_at",
1834
+ "sortDirection": "desc",
1835
+ "limit": 100,
1836
+ }
1837
+ if "--all" not in flags:
1838
+ params["cwd"] = cwd
1839
+ if "--include-non-interactive" in flags:
1840
+ params["sourceKinds"] = [
1841
+ "cli",
1842
+ "vscode",
1843
+ "exec",
1844
+ "appServer",
1845
+ "subAgent",
1846
+ "subAgentReview",
1847
+ "subAgentCompact",
1848
+ "subAgentThreadSpawn",
1849
+ "subAgentOther",
1850
+ "unknown",
1851
+ ]
1852
+ if selected:
1853
+ params["searchTerm"] = selected
1854
+ while True:
1855
+ response = await backend.call("thread/list", params)
1856
+ for thread in response.get("data", []):
1857
+ if not selected or selected in {
1858
+ thread.get("name"),
1859
+ thread.get("title"),
1860
+ thread.get("id"),
1861
+ }:
1862
+ return thread["id"]
1863
+ cursor = response.get("nextCursor")
1864
+ if not cursor:
1865
+ return None
1866
+ params["cursor"] = cursor
1867
+ finally:
1868
+ if own_backend:
1869
+ await backend.close()
1870
+
1871
+
1724
1872
  def add_account(args, state, binary):
1725
1873
  home = private_dir(account_home(state, args.label))
1726
1874
  old_identity = (
@@ -1743,7 +1891,7 @@ def add_account(args, state, binary):
1743
1891
  # Once native login changes this home's credential, an old marker must no
1744
1892
  # longer make a failed/duplicate enrollment selectable.
1745
1893
  (home / "enrolled").unlink(missing_ok=True)
1746
- credentials = read_keychain(home)
1894
+ credentials = read_keychain(home, include_identity=True)
1747
1895
  identity = hashlib.sha256(credentials["chatgptAccountId"].encode()).hexdigest()
1748
1896
  for label in labels(state):
1749
1897
  if label == args.label:
@@ -1756,6 +1904,13 @@ def add_account(args, state, binary):
1756
1904
  marker = home / "enrolled"
1757
1905
  marker.write_text(json.dumps({"identity": identity}) + "\n")
1758
1906
  marker.chmod(0o600)
1907
+ if credentials.get("email"):
1908
+ from settings import atomic_json
1909
+
1910
+ atomic_json(
1911
+ home / "identity.json",
1912
+ {"email": credentials["email"], "identity": identity},
1913
+ )
1759
1914
  pool = Pool(state)
1760
1915
  if old_identity != identity:
1761
1916
  pool.reset_identity(args.label)
@@ -1787,10 +1942,14 @@ def transport_guard(token):
1787
1942
 
1788
1943
  def parser():
1789
1944
  p = argparse.ArgumentParser(
1790
- description="Keep Codex moving between your own accounts (macOS)."
1945
+ prog="relay", description="Keep Codex moving between your own accounts (macOS)."
1791
1946
  )
1792
1947
  p.add_argument("--version", action="version", version="Relay " + VERSION)
1793
1948
  sub = p.add_subparsers(dest="command", required=True)
1949
+ help_command = sub.add_parser(
1950
+ "help", help="Show Relay help or help for one command"
1951
+ )
1952
+ help_command.add_argument("topic", nargs="*")
1794
1953
  add = sub.add_parser("add", help="Sign into one account using native Codex login")
1795
1954
  add.add_argument("label")
1796
1955
  add.add_argument("--device-auth", action="store_true")
@@ -1807,6 +1966,11 @@ def parser():
1807
1966
  "--profile", help="Recovery profile, separate from the native Codex profile"
1808
1967
  )
1809
1968
  run.add_argument("--name", help="Friendly session name")
1969
+ run.add_argument(
1970
+ "--require-recovery",
1971
+ action="store_true",
1972
+ help="Fail rather than run a native mode outside Relay recovery",
1973
+ )
1810
1974
  run.add_argument(
1811
1975
  "codex_args",
1812
1976
  nargs=argparse.REMAINDER,
@@ -1830,9 +1994,37 @@ def parser():
1830
1994
  return p
1831
1995
 
1832
1996
 
1997
+ def parse_arguments(argv):
1998
+ argv = list(argv)
1999
+ if argv[:1] == ["attach"]:
2000
+ boundary = argv.index("--") if "--" in argv else len(argv)
2001
+ if "--takeover" in argv[1:boundary]:
2002
+ argv.remove("--takeover")
2003
+ argv.insert(1, "--takeover")
2004
+ return parser().parse_args(argv)
2005
+
2006
+
1833
2007
  async def dispatch(args, state, binary):
1834
2008
  import commands
1835
2009
 
2010
+ if args.command == "run":
2011
+ from launch import arguments_only, native_mode
2012
+
2013
+ if native_mode(args.codex_args, binary):
2014
+ if getattr(args, "require_recovery", False):
2015
+ raise RouterError(
2016
+ "This mode runs in native Codex outside Relay recovery. Use relay explain -- to inspect the launch."
2017
+ )
2018
+ if sys.stderr.isatty():
2019
+ print(
2020
+ "Running native Codex; automatic account recovery is inactive for this mode.",
2021
+ file=sys.stderr,
2022
+ )
2023
+ process = await asyncio.create_subprocess_exec(
2024
+ binary, *arguments_only(args.codex_args)
2025
+ )
2026
+ return await process.wait()
2027
+
1836
2028
  if args.command == "_broker":
1837
2029
  import broker
1838
2030
 
@@ -1957,6 +2149,10 @@ async def dispatch(args, state, binary):
1957
2149
  return await commands.manage(args, state, binary)
1958
2150
  if args.first:
1959
2151
  args.first = commands.resolve_label(state, args.first)
2152
+ import execution
2153
+
2154
+ if execution.command(args.codex_args):
2155
+ return await execution.run(args, state, binary)
1960
2156
  return await run_pool(args, state, binary)
1961
2157
 
1962
2158
 
@@ -1990,6 +2186,11 @@ def selected_binary(args, state):
1990
2186
  "restart",
1991
2187
  "menubar",
1992
2188
  "_supervise",
2189
+ "diagnostics",
2190
+ "clean",
2191
+ "workspace",
2192
+ "explain",
2193
+ "startup",
1993
2194
  }:
1994
2195
  return None
1995
2196
  if args.command == "group" and args.action != "start":
@@ -2019,7 +2220,33 @@ def selected_binary(args, state):
2019
2220
 
2020
2221
  def main():
2021
2222
  os.umask(0o077)
2022
- args = parser().parse_args()
2223
+ argv = sys.argv[1:]
2224
+ if argv[:2] == ["help", "run"]:
2225
+ argv = ["run", "--help"]
2226
+ before_separator = argv[: argv.index("--")] if "--" in argv else argv
2227
+ if argv[:1] == ["run"] and any(
2228
+ flag in before_separator for flag in ("--help", "-h")
2229
+ ):
2230
+ result = subprocess.call([codex_path(), "--help"])
2231
+ print(
2232
+ "\nRelay launch options (before --):\n"
2233
+ " --first ACCOUNT Start with an enrolled account\n"
2234
+ " --profile NAME Select a Relay recovery profile\n"
2235
+ " --name NAME Name this Relay session\n"
2236
+ " --require-recovery Refuse modes outside account recovery\n\n"
2237
+ "Examples:\n relay run -- resume --yolo\n"
2238
+ " relay run -- -m MODEL -C PATH\n"
2239
+ ' relay run -- exec --json "Run the checks"\n\n'
2240
+ "Native Codex flags go after --. For native command help:\n"
2241
+ " relay run -- exec --help\n relay help codex resume"
2242
+ )
2243
+ return result
2244
+ if not argv or argv[:1] == ["help"]:
2245
+ topic = argv[1:] if argv else []
2246
+ if topic[:1] == ["codex"]:
2247
+ return subprocess.call([codex_path(), *topic[1:], "--help"])
2248
+ return parser().parse_args([*topic, "--help"])
2249
+ args = parse_arguments(argv)
2023
2250
  if args.command in {"run", "attach"}:
2024
2251
 
2025
2252
  def interrupted(signum, frame):
@@ -2035,7 +2262,15 @@ def main():
2035
2262
  private_dir(state / "accounts")
2036
2263
  binary = selected_binary(args, state)
2037
2264
  if args.command == "list":
2038
- print("\n".join(labels(state)) or "No accounts enrolled.")
2265
+ import commands
2266
+
2267
+ value = commands.snapshot(state)
2268
+ print(
2269
+ "\n".join(
2270
+ f"{a['id']}: {a.get('email') or a['name']}" for a in value["accounts"]
2271
+ )
2272
+ or "No accounts enrolled."
2273
+ )
2039
2274
  return 0
2040
2275
  return asyncio.run(dispatch(args, state, binary))
2041
2276