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/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 +2 -2
- package/python/Menu.swift +169 -51
- package/python/commands.py +228 -22
- 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 +321 -74
- 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 +345 -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.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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
1659
|
-
cwd=
|
|
1780
|
+
name=args.name or Path(cwd).name,
|
|
1781
|
+
cwd=cwd,
|
|
1660
1782
|
first=args.first,
|
|
1661
|
-
arguments=
|
|
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,
|
|
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,
|
|
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
|
|
1690
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|