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/README.md +26 -7
- package/docs/commands.md +123 -39
- package/docs/publishing.md +29 -13
- package/docs/troubleshooting.md +15 -3
- package/package.json +1 -1
- package/python/Menu.swift +169 -51
- package/python/commands.py +219 -20
- package/python/compatibility.py +3 -1
- package/python/desktop.py +131 -12
- package/python/execution.py +551 -0
- package/python/launch.py +375 -75
- package/python/pool.py +7 -0
- package/python/router.py +305 -70
- package/python/service.py +216 -142
- package/python/settings.py +1 -0
- package/python/tools.py +233 -0
- package/python/watch.py +74 -0
- package/tests/fake_native.py +4 -2
- package/tests/test_audit.py +320 -0
- package/tests/test_controller.py +3 -5
- package/tests/test_features.py +350 -0
- package/tests/test_launch.py +150 -0
- package/tests/test_services.py +6 -0
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
1659
|
-
cwd=
|
|
1768
|
+
name=args.name or Path(cwd).name,
|
|
1769
|
+
cwd=cwd,
|
|
1660
1770
|
first=args.first,
|
|
1661
|
-
arguments=
|
|
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,
|
|
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,
|
|
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
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
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
|
-
|
|
1707
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|