relay-codex 0.4.1 → 0.5.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.
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.1"
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,8 +432,20 @@ 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
 
438
+ def account_name(self):
439
+ if self.pool:
440
+ from commands import account_identity
441
+
442
+ return (
443
+ account_identity(self.pool.state, self.account.label).get("email")
444
+ or self.pool.options(self.account.label).get("name")
445
+ or self.account.label
446
+ )
447
+ return self.account.label
448
+
421
449
  def attached(self):
422
450
  return bool(self.client) or self.background or self.starting
423
451
 
@@ -440,7 +468,7 @@ class Controller:
440
468
  if self.preferences.get("notifications"):
441
469
  from notifications import notify
442
470
 
443
- notify(kind, self.account.label)
471
+ notify(kind, self.account_name())
444
472
 
445
473
  def merge_settings(self, thread, params):
446
474
  allowed = {
@@ -457,6 +485,12 @@ class Controller:
457
485
  "environments",
458
486
  "disabledPluginIds",
459
487
  "runtimeWorkspaceRoots",
488
+ "modelProvider",
489
+ "personality",
490
+ "baseInstructions",
491
+ "developerInstructions",
492
+ "outputSchema",
493
+ "cyberAccessProgram",
460
494
  }
461
495
  current = self.thread_settings.setdefault(thread, {})
462
496
  params = dict(params)
@@ -483,6 +517,25 @@ class Controller:
483
517
  if params.get("sandbox") or params.get("sandboxPolicy"):
484
518
  current.pop("permissions", None)
485
519
 
520
+ def hold_for_review(self, thread, reason="uncertain_action"):
521
+ self.paused_uncertain.add(thread)
522
+ self.persist(
523
+ thread, review_required=True, review_reason=reason, state="needs_attention"
524
+ )
525
+
526
+ def clear_review(self, thread):
527
+ self.paused_uncertain.discard(thread)
528
+ self.persist(thread, review_required=False, review_reason=None)
529
+
530
+ def can_continue(self, thread, generation):
531
+ saved = self.pool.intent(thread) if self.pool else {}
532
+ return (
533
+ generation == self.user_generation
534
+ and self.attached()
535
+ and thread not in self.paused_uncertain
536
+ and not (saved or {}).get("review_required", False)
537
+ )
538
+
486
539
  async def latest(self, thread):
487
540
  result = await self.backend.call(
488
541
  "thread/turns/list",
@@ -530,12 +583,12 @@ class Controller:
530
583
  return
531
584
  outcome, turn = await self.reconcile(thread, baseline)
532
585
  if outcome in {"running", "completed"}:
533
- self.paused_uncertain.discard(thread)
586
+ self.clear_review(thread)
534
587
  if outcome == "completed":
535
588
  self.note("completed", thread=thread)
536
589
  return
537
590
  if outcome == "failed":
538
- self.paused_uncertain.discard(thread)
591
+ self.clear_review(thread)
539
592
  code = (turn.get("error") or {}).get("codexErrorInfo")
540
593
  kind = failure_kind(code, (turn.get("error") or {}).get("message"))
541
594
  if kind:
@@ -553,7 +606,7 @@ class Controller:
553
606
  return
554
607
  if attempt < self.retry_attempts:
555
608
  await self.wait_until(self.clock() + self.retry_interval, generation)
556
- self.paused_uncertain.add(thread)
609
+ self.hold_for_review(thread)
557
610
  self.note("needs_attention", thread=thread, reason="acceptance_unknown")
558
611
  self.report(
559
612
  "Could not prove the request was accepted. It was not replayed. Use retry after reviewing the saved thread."
@@ -628,6 +681,23 @@ class Controller:
628
681
  except Exception:
629
682
  self.pool.ack(command, "failed")
630
683
  self.note("control_failed", operation=op)
684
+ if self.preferences.get("keep_awake") and self.active_threads:
685
+ if (
686
+ self.awake_process is None
687
+ or self.awake_process.returncode is not None
688
+ ):
689
+ self.awake_process = await asyncio.create_subprocess_exec(
690
+ "/usr/bin/caffeinate",
691
+ "-i",
692
+ "-w",
693
+ str(os.getpid()),
694
+ stdout=asyncio.subprocess.DEVNULL,
695
+ stderr=asyncio.subprocess.DEVNULL,
696
+ )
697
+ elif self.awake_process and self.awake_process.returncode is None:
698
+ self.awake_process.terminate()
699
+ await self.awake_process.wait()
700
+ self.awake_process = None
631
701
  if (
632
702
  self.network_state is False
633
703
  and now - last_network >= self.preferences["offline_retry"]
@@ -691,7 +761,11 @@ class Controller:
691
761
  if thread:
692
762
  await self.backend.call("thread/resume", {"threadId": thread, **params})
693
763
  self.merge_settings(thread, params)
694
- self.launch_overrides = dict(params)
764
+ self.launch_overrides = {**self.launch_overrides, **params}
765
+ if params.get("permissions"):
766
+ self.launch_overrides.pop("sandbox", None)
767
+ elif params.get("sandbox"):
768
+ self.launch_overrides.pop("permissions", None)
695
769
  elif op == "switch":
696
770
  label = params.get("account")
697
771
  index = next(
@@ -729,6 +803,8 @@ class Controller:
729
803
  self.note("switched", to=label)
730
804
  elif op == "retry":
731
805
  self.paused = False
806
+ if thread:
807
+ self.clear_review(thread)
732
808
  self.wake.set()
733
809
  if self.recovery and not self.recovery.done():
734
810
  self.force_retry.set()
@@ -740,7 +816,6 @@ class Controller:
740
816
  )
741
817
  self.pool.invalidate(self.account.label)
742
818
  if thread and (not self.recovery or self.recovery.done()):
743
- self.paused_uncertain.discard(thread)
744
819
  self.recovery = asyncio.create_task(
745
820
  self.continue_thread(thread, self.user_generation)
746
821
  )
@@ -783,28 +858,21 @@ class Controller:
783
858
  {
784
859
  k: v
785
860
  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
- }
861
+ if k in RESUME_FIELDS
796
862
  }
797
863
  )
864
+ if self.launch_overrides.get("config"):
865
+ params["config"] = self.launch_overrides["config"]
798
866
  try:
799
867
  await self.backend.call("thread/resume", params)
800
868
  except (RouterError, asyncio.TimeoutError):
801
- self.paused_uncertain.add(thread)
869
+ self.hold_for_review(thread)
802
870
  self.note(
803
871
  "needs_attention", thread=thread, reason="backend_resume_failed"
804
872
  )
805
873
  continue
806
874
  if in_flight:
807
- self.paused_uncertain.add(thread)
875
+ self.hold_for_review(thread)
808
876
  self.note(
809
877
  "needs_attention",
810
878
  thread=thread,
@@ -1082,12 +1150,19 @@ class Controller:
1082
1150
  return generation == self.user_generation and self.attached()
1083
1151
 
1084
1152
  async def continue_thread(self, thread, generation):
1085
- if not await self.wait_idle(generation):
1153
+ if not self.can_continue(thread, generation):
1154
+ return
1155
+ if not await self.wait_idle(generation) or not self.can_continue(
1156
+ thread, generation
1157
+ ):
1086
1158
  return
1087
1159
  params = {
1088
1160
  "threadId": thread,
1089
1161
  "input": [{"type": "text", "text": CONTINUE, "text_elements": []}],
1090
1162
  }
1163
+ for key in ("outputSchema", "cyberAccessProgram", "effort", "summary"):
1164
+ if self.thread_settings.get(thread, {}).get(key) is not None:
1165
+ params[key] = self.thread_settings[thread][key]
1091
1166
  params["clientUserMessageId"] = str(uuid.uuid4())
1092
1167
  baseline = self.turn_ids.get(thread) or "__unknown_before_submission__"
1093
1168
  self.pending_intents[thread] = {"baseline": baseline, "accepted": False}
@@ -1110,11 +1185,16 @@ class Controller:
1110
1185
  raise
1111
1186
  except (RouterError, asyncio.TimeoutError):
1112
1187
  return await self.uncertain(thread, generation, baseline)
1113
- self.report(f"Continued the saved thread with {self.account.label}.")
1188
+ self.report(f"Continued the saved thread with {self.account_name()}.")
1114
1189
 
1115
1190
  async def restore_recovery(self, thread, persisted):
1116
1191
  """Honor an accepted retry's original deadline after a process restart."""
1117
1192
  generation = self.user_generation
1193
+ if persisted.get("review_required"):
1194
+ self.hold_for_review(
1195
+ thread, persisted.get("review_reason") or "review_required"
1196
+ )
1197
+ return
1118
1198
  episode = persisted.get("episode")
1119
1199
  if episode:
1120
1200
  episode = dict(episode)
@@ -1131,7 +1211,7 @@ class Controller:
1131
1211
  deadline, generation, allow_early=persisted.get("kind") == "quota"
1132
1212
  )
1133
1213
  if persisted.get("active_items") or not persisted.get("accepted", False):
1134
- self.paused_uncertain.add(thread)
1214
+ self.hold_for_review(thread)
1135
1215
  self.note(
1136
1216
  "needs_attention",
1137
1217
  thread=thread,
@@ -1236,6 +1316,20 @@ class Controller:
1236
1316
  return None, min(deadlines, default=self.clock() + self.interval)
1237
1317
 
1238
1318
  async def recover(self, thread, generation, kind="quota"):
1319
+ if _recovery_active.get() is asyncio.current_task():
1320
+ raise RecoveryTransition(kind)
1321
+ token = _recovery_active.set(asyncio.current_task())
1322
+ try:
1323
+ while self.can_continue(thread, generation):
1324
+ try:
1325
+ return await self._recover_episode(thread, generation, kind)
1326
+ except RecoveryTransition as transition:
1327
+ kind = transition.kind
1328
+ await asyncio.sleep(0)
1329
+ finally:
1330
+ _recovery_active.reset(token)
1331
+
1332
+ async def _recover_episode(self, thread, generation, kind):
1239
1333
  try:
1240
1334
  model = self.model_for(thread)
1241
1335
  if kind == "model":
@@ -1301,7 +1395,7 @@ class Controller:
1301
1395
  episode["attempt"] += 1
1302
1396
  target = episode["start"] + episode["attempt"] * self.retry_interval
1303
1397
  self.report(
1304
- f"Service/authentication failure on {self.account.label}. Retry {episode['attempt']}/{self.retry_attempts} at {time.strftime('%H:%M:%S', time.localtime(target))}."
1398
+ f"Service/authentication failure on {self.account_name()}. Retry {episode['attempt']}/{self.retry_attempts} at {time.strftime('%H:%M:%S', time.localtime(target))}."
1305
1399
  )
1306
1400
  self.note(
1307
1401
  "retry_scheduled",
@@ -1334,7 +1428,7 @@ class Controller:
1334
1428
  )
1335
1429
  self.episodes.pop(thread, None)
1336
1430
  self.report(
1337
- f"Selecting another account after {kind} failure on {self.account.label}."
1431
+ f"Selecting another account after {kind} failure on {self.account_name()}."
1338
1432
  )
1339
1433
  self.note("selecting_account", thread=thread, reason=kind)
1340
1434
  await self.mark(self.index, model, kind)
@@ -1375,9 +1469,10 @@ class Controller:
1375
1469
  state="waiting_for_reset",
1376
1470
  )
1377
1471
  await self.wait_until(max(self.clock() + 1, deadline), generation)
1378
- except asyncio.CancelledError:
1472
+ except (asyncio.CancelledError, RecoveryTransition):
1379
1473
  raise
1380
1474
  except Exception:
1475
+ self.hold_for_review(thread, "handoff_failed")
1381
1476
  self.note("needs_attention", thread=thread, reason="handoff_failed")
1382
1477
  self.report(
1383
1478
  "Automatic handoff stopped. Your saved thread is retained; continue manually."
@@ -1392,6 +1487,23 @@ class Controller:
1392
1487
  ):
1393
1488
  message = dict(message)
1394
1489
  message["params"] = {**message.get("params", {}), **self.launch_overrides}
1490
+ if (
1491
+ method in {"thread/start", "thread/resume", "thread/fork"}
1492
+ and self.additional_roots
1493
+ ):
1494
+ message = {**message, "params": dict(message.get("params", {}))}
1495
+ params = message["params"]
1496
+ params["runtimeWorkspaceRoots"] = list(
1497
+ dict.fromkeys(
1498
+ [
1499
+ *(
1500
+ params.get("runtimeWorkspaceRoots")
1501
+ or [params.get("cwd") or self.project]
1502
+ ),
1503
+ *self.additional_roots,
1504
+ ]
1505
+ )
1506
+ )
1395
1507
  if not method:
1396
1508
  # Approval responses keep the server's original ID, never our call IDs.
1397
1509
  if request_id in self.server_requests:
@@ -1443,6 +1555,7 @@ class Controller:
1443
1555
  self.fallback_tried.pop(message["params"]["threadId"], None)
1444
1556
  self.status_changed.set()
1445
1557
  if method == "turn/start":
1558
+ self.clear_review(message["params"]["threadId"])
1446
1559
  self.last_turn[message["params"]["threadId"]] = message["params"]
1447
1560
  self.merge_settings(message["params"]["threadId"], message["params"])
1448
1561
  try:
@@ -1636,12 +1749,19 @@ class Controller:
1636
1749
  async def run_pool(args, state, binary):
1637
1750
  import service
1638
1751
 
1639
- settings.load(state, os.getcwd(), args.profile)
1640
- frontend, overrides = translate(args.codex_args)
1641
- target = resume_target(
1752
+ from launch import frontend_arguments, working_directory, prepare_worktree
1753
+
1754
+ invocation_cwd = os.getcwd()
1755
+ cwd = working_directory(args.codex_args, invocation_cwd)
1756
+ settings.load(state, cwd, args.profile)
1757
+ arguments = frontend_arguments(args.codex_args, invocation_cwd)
1758
+ arguments, cwd = prepare_worktree(arguments, state, cwd)
1759
+ frontend, overrides = translate(arguments)
1760
+ target = await resolve_resume_target(
1642
1761
  frontend,
1643
1762
  Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")),
1644
- os.getcwd(),
1763
+ cwd,
1764
+ binary,
1645
1765
  )
1646
1766
  pool = Pool(state)
1647
1767
  try:
@@ -1652,27 +1772,29 @@ async def run_pool(args, state, binary):
1652
1772
  print(
1653
1773
  "Reconnecting to existing wrapper session " + owner[:10] + ".", flush=True
1654
1774
  )
1655
- return await service.attach(state, owner, binary, frontend, wait_forever=True)
1775
+ return await service.attach(
1776
+ state, owner, binary, arguments, wait_forever=True, cwd=invocation_cwd
1777
+ )
1656
1778
  ident = service.start(
1657
1779
  state,
1658
- name=args.name or Path.cwd().name,
1659
- cwd=os.getcwd(),
1780
+ name=args.name or Path(cwd).name,
1781
+ cwd=cwd,
1660
1782
  first=args.first,
1661
- arguments=args.codex_args,
1783
+ arguments=arguments,
1662
1784
  profile=args.profile,
1663
1785
  interactive=True,
1664
1786
  binary=binary,
1665
1787
  )
1666
1788
  try:
1667
1789
  return await service.attach(
1668
- state, ident, binary, frontend or [], wait_forever=True
1790
+ state, ident, binary, arguments, wait_forever=True, cwd=invocation_cwd
1669
1791
  )
1670
1792
  finally:
1671
1793
  # A foreground launch normally closes its worker after the terminal exits.
1672
1794
  # Background mode and explicit takeover preserve the supervised worker.
1673
1795
  spec_path = service.folder(state) / (ident + ".job.json")
1674
1796
  spec = json.loads(spec_path.read_text()) if spec_path.exists() else {}
1675
- current_preferences = settings.load(state, os.getcwd(), args.profile)
1797
+ current_preferences = settings.load(state, cwd, args.profile)
1676
1798
  if not current_preferences.get("background_on_disconnect") and not spec.get(
1677
1799
  "takeover"
1678
1800
  ):
@@ -1686,41 +1808,79 @@ async def run_pool(args, state, binary):
1686
1808
  await asyncio.sleep(0.1)
1687
1809
 
1688
1810
 
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
1811
+ def resume_selection(arguments):
1812
+ from launch import tokens
1704
1813
 
1814
+ parsed = list(tokens(arguments))
1815
+ positionals = [
1816
+ value for name, value, raw in parsed if name is None and len(raw) == 1
1817
+ ]
1818
+ if not positionals or positionals[0] not in {"resume", "fork"}:
1819
+ return None, set()
1820
+ flags = {name for name, _, _ in parsed}
1821
+ return positionals[1] if len(positionals) > 1 else None, flags
1822
+
1823
+
1824
+ def resume_target(arguments, home=None, cwd=None):
1825
+ """Explicit IDs need no native database or backend."""
1826
+ selected, _ = resume_selection(arguments)
1705
1827
  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:
1828
+ return str(uuid.UUID(selected)) if selected else None
1829
+ except (ValueError, AttributeError):
1721
1830
  return None
1722
1831
 
1723
1832
 
1833
+ async def resolve_resume_target(arguments, home, cwd, binary=None, backend=None):
1834
+ selected, flags = resume_selection(arguments)
1835
+ explicit = resume_target(arguments)
1836
+ if explicit or not selected and "--last" not in flags:
1837
+ return explicit
1838
+ own_backend = backend is None
1839
+ if own_backend:
1840
+ backend = await RPC.start(binary, home)
1841
+ await backend.initialize()
1842
+ try:
1843
+ params = {
1844
+ "archived": False,
1845
+ "sortKey": "updated_at",
1846
+ "sortDirection": "desc",
1847
+ "limit": 100,
1848
+ }
1849
+ if "--all" not in flags:
1850
+ params["cwd"] = cwd
1851
+ if "--include-non-interactive" in flags:
1852
+ params["sourceKinds"] = [
1853
+ "cli",
1854
+ "vscode",
1855
+ "exec",
1856
+ "appServer",
1857
+ "subAgent",
1858
+ "subAgentReview",
1859
+ "subAgentCompact",
1860
+ "subAgentThreadSpawn",
1861
+ "subAgentOther",
1862
+ "unknown",
1863
+ ]
1864
+ if selected:
1865
+ params["searchTerm"] = selected
1866
+ while True:
1867
+ response = await backend.call("thread/list", params)
1868
+ for thread in response.get("data", []):
1869
+ if not selected or selected in {
1870
+ thread.get("name"),
1871
+ thread.get("title"),
1872
+ thread.get("id"),
1873
+ }:
1874
+ return thread["id"]
1875
+ cursor = response.get("nextCursor")
1876
+ if not cursor:
1877
+ return None
1878
+ params["cursor"] = cursor
1879
+ finally:
1880
+ if own_backend:
1881
+ await backend.close()
1882
+
1883
+
1724
1884
  def add_account(args, state, binary):
1725
1885
  home = private_dir(account_home(state, args.label))
1726
1886
  old_identity = (
@@ -1743,7 +1903,7 @@ def add_account(args, state, binary):
1743
1903
  # Once native login changes this home's credential, an old marker must no
1744
1904
  # longer make a failed/duplicate enrollment selectable.
1745
1905
  (home / "enrolled").unlink(missing_ok=True)
1746
- credentials = read_keychain(home)
1906
+ credentials = read_keychain(home, include_identity=True)
1747
1907
  identity = hashlib.sha256(credentials["chatgptAccountId"].encode()).hexdigest()
1748
1908
  for label in labels(state):
1749
1909
  if label == args.label:
@@ -1756,6 +1916,13 @@ def add_account(args, state, binary):
1756
1916
  marker = home / "enrolled"
1757
1917
  marker.write_text(json.dumps({"identity": identity}) + "\n")
1758
1918
  marker.chmod(0o600)
1919
+ if credentials.get("email"):
1920
+ from settings import atomic_json
1921
+
1922
+ atomic_json(
1923
+ home / "identity.json",
1924
+ {"email": credentials["email"], "identity": identity},
1925
+ )
1759
1926
  pool = Pool(state)
1760
1927
  if old_identity != identity:
1761
1928
  pool.reset_identity(args.label)
@@ -1787,10 +1954,14 @@ def transport_guard(token):
1787
1954
 
1788
1955
  def parser():
1789
1956
  p = argparse.ArgumentParser(
1790
- description="Keep Codex moving between your own accounts (macOS)."
1957
+ prog="relay", description="Keep Codex moving between your own accounts (macOS)."
1791
1958
  )
1792
1959
  p.add_argument("--version", action="version", version="Relay " + VERSION)
1793
1960
  sub = p.add_subparsers(dest="command", required=True)
1961
+ help_command = sub.add_parser(
1962
+ "help", help="Show Relay help or help for one command"
1963
+ )
1964
+ help_command.add_argument("topic", nargs="*")
1794
1965
  add = sub.add_parser("add", help="Sign into one account using native Codex login")
1795
1966
  add.add_argument("label")
1796
1967
  add.add_argument("--device-auth", action="store_true")
@@ -1807,6 +1978,11 @@ def parser():
1807
1978
  "--profile", help="Recovery profile, separate from the native Codex profile"
1808
1979
  )
1809
1980
  run.add_argument("--name", help="Friendly session name")
1981
+ run.add_argument(
1982
+ "--require-recovery",
1983
+ action="store_true",
1984
+ help="Fail rather than run a native mode outside Relay recovery",
1985
+ )
1810
1986
  run.add_argument(
1811
1987
  "codex_args",
1812
1988
  nargs=argparse.REMAINDER,
@@ -1830,9 +2006,37 @@ def parser():
1830
2006
  return p
1831
2007
 
1832
2008
 
2009
+ def parse_arguments(argv):
2010
+ argv = list(argv)
2011
+ if argv[:1] == ["attach"]:
2012
+ boundary = argv.index("--") if "--" in argv else len(argv)
2013
+ if "--takeover" in argv[1:boundary]:
2014
+ argv.remove("--takeover")
2015
+ argv.insert(1, "--takeover")
2016
+ return parser().parse_args(argv)
2017
+
2018
+
1833
2019
  async def dispatch(args, state, binary):
1834
2020
  import commands
1835
2021
 
2022
+ if args.command == "run":
2023
+ from launch import arguments_only, native_mode
2024
+
2025
+ if native_mode(args.codex_args, binary):
2026
+ if getattr(args, "require_recovery", False):
2027
+ raise RouterError(
2028
+ "This mode runs in native Codex outside Relay recovery. Use relay explain -- to inspect the launch."
2029
+ )
2030
+ if sys.stderr.isatty():
2031
+ print(
2032
+ "Running native Codex; automatic account recovery is inactive for this mode.",
2033
+ file=sys.stderr,
2034
+ )
2035
+ process = await asyncio.create_subprocess_exec(
2036
+ binary, *arguments_only(args.codex_args)
2037
+ )
2038
+ return await process.wait()
2039
+
1836
2040
  if args.command == "_broker":
1837
2041
  import broker
1838
2042
 
@@ -1957,6 +2161,10 @@ async def dispatch(args, state, binary):
1957
2161
  return await commands.manage(args, state, binary)
1958
2162
  if args.first:
1959
2163
  args.first = commands.resolve_label(state, args.first)
2164
+ import execution
2165
+
2166
+ if execution.command(args.codex_args):
2167
+ return await execution.run(args, state, binary)
1960
2168
  return await run_pool(args, state, binary)
1961
2169
 
1962
2170
 
@@ -1990,6 +2198,11 @@ def selected_binary(args, state):
1990
2198
  "restart",
1991
2199
  "menubar",
1992
2200
  "_supervise",
2201
+ "diagnostics",
2202
+ "clean",
2203
+ "workspace",
2204
+ "explain",
2205
+ "startup",
1993
2206
  }:
1994
2207
  return None
1995
2208
  if args.command == "group" and args.action != "start":
@@ -2019,7 +2232,33 @@ def selected_binary(args, state):
2019
2232
 
2020
2233
  def main():
2021
2234
  os.umask(0o077)
2022
- args = parser().parse_args()
2235
+ argv = sys.argv[1:]
2236
+ if argv[:2] == ["help", "run"]:
2237
+ argv = ["run", "--help"]
2238
+ before_separator = argv[: argv.index("--")] if "--" in argv else argv
2239
+ if argv[:1] == ["run"] and any(
2240
+ flag in before_separator for flag in ("--help", "-h")
2241
+ ):
2242
+ result = subprocess.call([codex_path(), "--help"])
2243
+ print(
2244
+ "\nRelay launch options (before --):\n"
2245
+ " --first ACCOUNT Start with an enrolled account\n"
2246
+ " --profile NAME Select a Relay recovery profile\n"
2247
+ " --name NAME Name this Relay session\n"
2248
+ " --require-recovery Refuse modes outside account recovery\n\n"
2249
+ "Examples:\n relay run -- resume --yolo\n"
2250
+ " relay run -- -m MODEL -C PATH\n"
2251
+ ' relay run -- exec --json "Run the checks"\n\n'
2252
+ "Native Codex flags go after --. For native command help:\n"
2253
+ " relay run -- exec --help\n relay help codex resume"
2254
+ )
2255
+ return result
2256
+ if not argv or argv[:1] == ["help"]:
2257
+ topic = argv[1:] if argv else []
2258
+ if topic[:1] == ["codex"]:
2259
+ return subprocess.call([codex_path(), *topic[1:], "--help"])
2260
+ return parser().parse_args([*topic, "--help"])
2261
+ args = parse_arguments(argv)
2023
2262
  if args.command in {"run", "attach"}:
2024
2263
 
2025
2264
  def interrupted(signum, frame):
@@ -2035,7 +2274,15 @@ def main():
2035
2274
  private_dir(state / "accounts")
2036
2275
  binary = selected_binary(args, state)
2037
2276
  if args.command == "list":
2038
- print("\n".join(labels(state)) or "No accounts enrolled.")
2277
+ import commands
2278
+
2279
+ value = commands.snapshot(state)
2280
+ print(
2281
+ "\n".join(
2282
+ f"{a['id']}: {a.get('email') or a['name']}" for a in value["accounts"]
2283
+ )
2284
+ or "No accounts enrolled."
2285
+ )
2039
2286
  return 0
2040
2287
  return asyncio.run(dispatch(args, state, binary))
2041
2288