relay-codex 0.0.0-stage → 0.4.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.
@@ -0,0 +1,2053 @@
1
+ #!/usr/bin/env python3
2
+ """Private, experimental Codex CLI account controller. Never log RPC payloads."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import asyncio
8
+ import base64
9
+ import contextlib
10
+ import hashlib
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import re
15
+ import signal
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ import uuid
21
+
22
+ from pool import Pool, blocked, reset_time, failure_kind, file_lock
23
+ from launch import translate
24
+ import settings
25
+
26
+ VERSION = "0.4.1"
27
+ _support = Path.home() / "Library/Application Support"
28
+ _legacy = _support / "Codex Accounts"
29
+ _default_state = _legacy if (_legacy / "accounts").exists() else _support / "Relay"
30
+ STATE = Path(
31
+ os.environ.get("RELAY_STATE")
32
+ or os.environ.get("CODEX_ACCOUNTS_STATE")
33
+ or _default_state
34
+ )
35
+ CONTINUE = (
36
+ "Continue the original task from its saved state after a service interruption. "
37
+ "Review the existing conversation and completed tool results before acting. "
38
+ "Do not repeat completed commands or external actions. Preserve all existing "
39
+ "user requirements and permissions. Verify any uncertain partial operation first."
40
+ )
41
+
42
+
43
+ class RouterError(Exception):
44
+ """A message safe to display; never include a server error or credential."""
45
+
46
+
47
+ class RemoteError(RouterError):
48
+ def __init__(self, payload):
49
+ super().__init__(
50
+ "Codex rejected an RPC operation; no account change was assumed."
51
+ )
52
+ self.payload = payload
53
+
54
+
55
+ def private_dir(path: Path) -> Path:
56
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
57
+ if path.is_symlink():
58
+ raise RouterError("State directories must not be symlinks.")
59
+ path.chmod(0o700)
60
+ return path
61
+
62
+
63
+ def clean_env(home: Path) -> dict:
64
+ env = dict(os.environ)
65
+ # The pool must not accidentally select an environment-provided identity.
66
+ for key in ("OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_ACCESS_TOKEN"):
67
+ env.pop(key, None)
68
+ env["CODEX_HOME"] = str(home)
69
+ return env
70
+
71
+
72
+ def codex_path() -> str:
73
+ path = shutil.which("codex")
74
+ if not path:
75
+ raise RouterError("Codex CLI is not installed or not on PATH.")
76
+ return path
77
+
78
+
79
+ def account_home(state: Path, label: str) -> Path:
80
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}", label):
81
+ raise RouterError(
82
+ "Use a short account label containing letters, numbers, - or _."
83
+ )
84
+ return state / "accounts" / label
85
+
86
+
87
+ def labels(state: Path) -> list[str]:
88
+ root = state / "accounts"
89
+ return (
90
+ sorted(
91
+ p.name for p in root.iterdir() if p.is_dir() and (p / "enrolled").exists()
92
+ )
93
+ if root.exists()
94
+ else []
95
+ )
96
+
97
+
98
+ def read_keychain(home: Path) -> dict:
99
+ """Native Codex direct-keyring layout in 0.166.0. stdout remains in memory."""
100
+ key = "cli|" + hashlib.sha256(str(home.resolve()).encode()).hexdigest()[:16]
101
+ try:
102
+ result = subprocess.run(
103
+ [
104
+ "/usr/bin/security",
105
+ "find-generic-password",
106
+ "-s",
107
+ "Codex Auth",
108
+ "-a",
109
+ key,
110
+ "-w",
111
+ ],
112
+ capture_output=True,
113
+ timeout=20,
114
+ )
115
+ except subprocess.TimeoutExpired:
116
+ raise RouterError(
117
+ "Keychain access timed out. Check for a macOS Keychain prompt."
118
+ ) from None
119
+ if result.returncode:
120
+ raise RouterError(
121
+ "Cannot read this account's Keychain login. Unlock Keychain or enroll the account again."
122
+ )
123
+ try:
124
+ auth = json.loads(result.stdout)
125
+ tokens = auth["tokens"]
126
+ token = tokens["access_token"]
127
+ part = token.split(".")[1]
128
+ claims = json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)))
129
+ metadata = claims.get("https://api.openai.com/auth", {})
130
+ account_id = tokens.get("account_id") or metadata["chatgpt_account_id"]
131
+ if not token or not account_id or auth.get("OPENAI_API_KEY"):
132
+ raise ValueError()
133
+ if claims.get("exp", 0) <= time.time() + 10:
134
+ raise RouterError(
135
+ "Account token expired; enroll this account again if automatic refresh fails."
136
+ )
137
+ return {
138
+ "accessToken": token,
139
+ "chatgptAccountId": account_id,
140
+ "chatgptPlanType": metadata.get("chatgpt_plan_type"),
141
+ }
142
+ except (KeyError, ValueError, TypeError, IndexError):
143
+ raise RouterError(
144
+ "This Keychain entry is not a usable ChatGPT login."
145
+ ) from None
146
+
147
+
148
+ def limited(snapshot: dict, model: str | None = None) -> bool:
149
+ """Only explicit server flags establish a block; percentages are advisory."""
150
+ return blocked(snapshot, model)
151
+
152
+
153
+ class RPC:
154
+ """Multiplex JSONL RPC. Only safe, fixed error messages leave this object."""
155
+
156
+ def __init__(self, proc, handler=None):
157
+ self.proc, self.handler = proc, handler
158
+ self.pending = {}
159
+ self.sequence = 0
160
+ self.writer_lock = asyncio.Lock()
161
+ self.handlers = set()
162
+ self.reader = asyncio.create_task(self.read_loop())
163
+ self.startup_guard = None
164
+
165
+ @classmethod
166
+ async def start(
167
+ cls, binary: str, home: Path, *, helper=False, handler=None, extra_args=None
168
+ ):
169
+ home.mkdir(parents=True, exist_ok=True, mode=0o700)
170
+ guard = file_lock(home / ".accounts-native-startup.lock")
171
+ await guard.__aenter__()
172
+ mode = "keyring" if helper else "ephemeral"
173
+ cmd = [
174
+ binary,
175
+ "app-server",
176
+ "--stdio",
177
+ "-c",
178
+ f'cli_auth_credentials_store="{mode}"',
179
+ ]
180
+ cmd.extend(extra_args or [])
181
+ if helper:
182
+ cmd += ["-c", "features.secret_auth_storage=false"]
183
+ else:
184
+ # Let failures reach our finite 5/10/15-minute schedule instead of
185
+ # the native client's optional unbounded reconnect loop.
186
+ cmd += ["-c", "features.unbounded_connection_retries=false"]
187
+ try:
188
+ proc = await asyncio.create_subprocess_exec(
189
+ *cmd,
190
+ env=clean_env(home),
191
+ stdin=asyncio.subprocess.PIPE,
192
+ stdout=asyncio.subprocess.PIPE,
193
+ stderr=asyncio.subprocess.DEVNULL,
194
+ limit=32 * 1024 * 1024,
195
+ )
196
+ rpc = cls(proc, handler)
197
+ rpc.startup_guard = guard
198
+ return rpc
199
+ except BaseException:
200
+ await guard.__aexit__(None, None, None)
201
+ raise
202
+
203
+ async def send(self, obj):
204
+ async with self.writer_lock:
205
+ try:
206
+ self.proc.stdin.write(
207
+ json.dumps(obj, separators=(",", ":")).encode() + b"\n"
208
+ )
209
+ await self.proc.stdin.drain()
210
+ except (ConnectionError, OSError):
211
+ raise RouterError(
212
+ "Codex backend disconnected; resume the saved thread after restarting the wrapper."
213
+ ) from None
214
+
215
+ async def call(self, method, params=None, timeout=45):
216
+ if self.reader.done():
217
+ raise RouterError(
218
+ "Codex backend disconnected; resume the saved thread after restarting the wrapper."
219
+ )
220
+ self.sequence += 1
221
+ request_id = f"accounts-{self.sequence}"
222
+ future = asyncio.get_running_loop().create_future()
223
+ self.pending[request_id] = future
224
+ request = {"id": request_id, "method": method}
225
+ if params is not None:
226
+ request["params"] = params
227
+ try:
228
+ await self.send(request)
229
+ return await asyncio.wait_for(future, timeout)
230
+ finally:
231
+ self.pending.pop(request_id, None)
232
+
233
+ async def initialize(self):
234
+ try:
235
+ result = await self.call(
236
+ "initialize",
237
+ {
238
+ "clientInfo": {
239
+ "name": "local_codex_accounts",
240
+ "title": "Relay",
241
+ "version": VERSION,
242
+ },
243
+ "capabilities": {"experimentalApi": True},
244
+ },
245
+ )
246
+ await self.send({"method": "initialized"})
247
+ except BaseException:
248
+ await self.close()
249
+ raise
250
+ await self.release_startup()
251
+ return result
252
+
253
+ async def release_startup(self):
254
+ if self.startup_guard:
255
+ guard, self.startup_guard = self.startup_guard, None
256
+ await guard.__aexit__(None, None, None)
257
+
258
+ async def read_loop(self):
259
+ try:
260
+ while raw := await self.proc.stdout.readline():
261
+ message = json.loads(raw)
262
+ if "method" not in message and message.get("id") in self.pending:
263
+ future = self.pending[message["id"]]
264
+ if not future.done():
265
+ if "error" in message:
266
+ future.set_exception(RemoteError(message["error"]))
267
+ else:
268
+ future.set_result(message.get("result", {}))
269
+ elif self.handler:
270
+ task = asyncio.create_task(self.handler(message))
271
+ self.handlers.add(task)
272
+ task.add_done_callback(self.handlers.discard)
273
+ except (ValueError, OSError, asyncio.LimitOverrunError):
274
+ pass
275
+ finally:
276
+ for future in list(self.pending.values()):
277
+ if not future.done():
278
+ future.set_exception(RouterError("Codex backend disconnected."))
279
+
280
+ async def close(self):
281
+ if self.proc.returncode is None:
282
+ self.proc.terminate()
283
+ try:
284
+ await asyncio.wait_for(self.proc.wait(), 5)
285
+ except asyncio.TimeoutError:
286
+ self.proc.kill()
287
+ await self.proc.wait()
288
+ await self.release_startup()
289
+ self.reader.cancel()
290
+ for task in list(self.handlers):
291
+ task.cancel()
292
+ await asyncio.gather(self.reader, *self.handlers, return_exceptions=True)
293
+
294
+
295
+ class Account:
296
+ def __init__(self, label, home, binary, pool=None):
297
+ self.label, self.home, self.binary = label, home, binary
298
+ self.pool = pool
299
+ self.rpc = None
300
+ self.lock = asyncio.Lock()
301
+
302
+ async def start(self):
303
+ if self.rpc and self.rpc.proc.returncode is not None:
304
+ await self.rpc.close()
305
+ self.rpc = None
306
+ if not self.rpc:
307
+ self.rpc = await RPC.start(self.binary, self.home, helper=True)
308
+ await self.rpc.initialize()
309
+
310
+ @contextlib.asynccontextmanager
311
+ async def operation(self):
312
+ async with self.lock:
313
+ async with file_lock(self.home / "credential.lock"):
314
+ # Fresh process reads latest Keychain state. Never retain a stale
315
+ # refresh token while another terminal rotates the same login.
316
+ try:
317
+ await self.start()
318
+ yield self.rpc
319
+ finally:
320
+ await self.close()
321
+ self.rpc = None
322
+
323
+ async def credentials(self, refresh=False):
324
+ async with self.operation() as rpc:
325
+ response = await rpc.call("account/read", {"refreshToken": refresh})
326
+ if not response.get("account"):
327
+ raise RouterError("Account requires sign-in; enroll it again.")
328
+ return await asyncio.to_thread(read_keychain, self.home)
329
+
330
+ async def usage(self, force=False):
331
+ async with self.lock:
332
+ async with file_lock(self.home / "credential.lock"):
333
+ cached = (
334
+ self.pool.cached(self.label) if self.pool and not force else None
335
+ )
336
+ if cached is None and force and self.pool:
337
+ # Concurrent failures may all request a fresh read. Reuse
338
+ # a just-completed, explicitly blocked snapshot for 1s.
339
+ recent = self.pool.cached(self.label, max_age=1)
340
+ if recent and limited(recent):
341
+ cached = recent
342
+ if cached is not None:
343
+ return cached
344
+ try:
345
+ await self.start()
346
+ snapshot = await self.rpc.call("account/rateLimits/read")
347
+ if self.pool:
348
+ self.pool.save(self.label, snapshot)
349
+ return snapshot
350
+ finally:
351
+ await self.close()
352
+ self.rpc = None
353
+
354
+ async def close(self):
355
+ if self.rpc:
356
+ await self.rpc.close()
357
+
358
+
359
+ class Controller:
360
+ def __init__(
361
+ self,
362
+ accounts,
363
+ backend,
364
+ report=print,
365
+ interval=300,
366
+ *,
367
+ pool=None,
368
+ retry_interval=300,
369
+ retry_attempts=3,
370
+ clock=time.time,
371
+ sleep=asyncio.sleep,
372
+ ):
373
+ self.accounts, self.backend, self.report = accounts, backend, report
374
+ self.interval = interval
375
+ self.index = 0
376
+ self.client = None
377
+ self.active_threads = set()
378
+ self.user_generation = 0
379
+ self.last_turn = {}
380
+ self.seen_failures = set()
381
+ self.recovery = None
382
+ self.login_lock = asyncio.Lock()
383
+ self.client_tasks = set()
384
+ self.server_requests = set()
385
+ self.pending_server_requests = {}
386
+ self.replayed_requests = set()
387
+ self.pool = pool
388
+ self.retry_interval, self.retry_attempts = retry_interval, retry_attempts
389
+ self.clock, self.sleep = clock, sleep
390
+ self.episodes = {}
391
+ self.local_deadlines = {}
392
+ self.thread_settings = {}
393
+ self.status_changed = asyncio.Event()
394
+ self.launch_overrides = {}
395
+ self.preferences = (
396
+ settings.load(pool.state) if pool else settings.DEFAULTS.copy()
397
+ )
398
+ self.paused = False
399
+ self.background = False
400
+ self.wake = asyncio.Event()
401
+ self.force_retry = asyncio.Event()
402
+ self.turn_ids = {}
403
+ self.recovery_jobs = {}
404
+ self.pending_intents = {}
405
+ self.active_items = set()
406
+ self.backend_factory = None
407
+ self.restart_lock = asyncio.Lock()
408
+ self.restart_task = None
409
+ self.paused_uncertain = set()
410
+ self.control_task = None
411
+ self.stopping = asyncio.Event()
412
+ self.extra_accounts = None
413
+ self.starting = False
414
+ self.on_thread = None
415
+ self.on_detach = None
416
+ self.project = os.getcwd()
417
+ self.profile = None
418
+ self.network_state = None
419
+ self.fallback_tried = {}
420
+
421
+ def attached(self):
422
+ return bool(self.client) or self.background or self.starting
423
+
424
+ def persist(self, thread, **values):
425
+ if self.pool:
426
+ previous = self.pool.intent(thread) or {}
427
+ previous.update(values)
428
+ previous["settings"] = self.thread_settings.get(thread, {})
429
+ previous.setdefault(
430
+ "role", "foreground" if thread in self.last_turn else "child"
431
+ )
432
+ self.pool.intent(thread, previous)
433
+
434
+ def note(self, kind, **fields):
435
+ if self.pool:
436
+ self.pool.record(kind, **fields)
437
+ if kind in {"working", "completed", "switched", "backend_restored"}:
438
+ fields.setdefault("next_wake", None)
439
+ self.pool.touch(state=kind, account=self.account.label, **fields)
440
+ if self.preferences.get("notifications"):
441
+ from notifications import notify
442
+
443
+ notify(kind, self.account.label)
444
+
445
+ def merge_settings(self, thread, params):
446
+ allowed = {
447
+ "model",
448
+ "cwd",
449
+ "approvalPolicy",
450
+ "approvalsReviewer",
451
+ "sandbox",
452
+ "sandboxPolicy",
453
+ "permissions",
454
+ "effort",
455
+ "serviceTier",
456
+ "summary",
457
+ "environments",
458
+ "disabledPluginIds",
459
+ "runtimeWorkspaceRoots",
460
+ }
461
+ current = self.thread_settings.setdefault(thread, {})
462
+ params = dict(params)
463
+ if params.get("reasoningEffort") is not None:
464
+ params["effort"] = params["reasoningEffort"]
465
+ if params.get("sandboxPolicy"):
466
+ policy = params["sandboxPolicy"].get("type")
467
+ translated = {
468
+ "dangerFullAccess": "danger-full-access",
469
+ "readOnly": "read-only",
470
+ "workspaceWrite": "workspace-write",
471
+ "danger-full-access": "danger-full-access",
472
+ "read-only": "read-only",
473
+ "workspace-write": "workspace-write",
474
+ }.get(policy)
475
+ if translated:
476
+ params["sandbox"] = translated
477
+ for key in allowed:
478
+ if params.get(key) is not None:
479
+ current[key] = params[key]
480
+ if "permissions" in params and params.get("permissions"):
481
+ current.pop("sandbox", None)
482
+ current.pop("sandboxPolicy", None)
483
+ if params.get("sandbox") or params.get("sandboxPolicy"):
484
+ current.pop("permissions", None)
485
+
486
+ async def latest(self, thread):
487
+ result = await self.backend.call(
488
+ "thread/turns/list",
489
+ {
490
+ "threadId": thread,
491
+ "limit": 1,
492
+ "sortDirection": "desc",
493
+ "itemsView": "summary",
494
+ },
495
+ timeout=15,
496
+ )
497
+ return (result.get("data") or [None])[0]
498
+
499
+ async def reconcile(self, thread, baseline=None):
500
+ """Read acceptance/status. Never replay a request merely because it timed out."""
501
+ try:
502
+ latest = await self.latest(thread)
503
+ info = await self.backend.call(
504
+ "thread/read", {"threadId": thread}, timeout=15
505
+ )
506
+ except (RouterError, asyncio.TimeoutError):
507
+ return "unknown", None
508
+ status = (info.get("thread") or {}).get("status", {}).get("type")
509
+ if status == "active":
510
+ self.active_threads.add(thread)
511
+ elif status in {"idle", "systemError", "notLoaded"}:
512
+ self.active_threads.discard(thread)
513
+ if baseline == "__unknown_before_submission__" and not self.pending_intents.get(
514
+ thread, {}
515
+ ).get("accepted"):
516
+ return "unknown", latest
517
+ if latest and (baseline is None or latest.get("id") != baseline):
518
+ if latest.get("status") == "inProgress":
519
+ return "running", latest
520
+ if latest.get("status") == "completed":
521
+ return "completed", latest
522
+ if latest.get("status") in {"failed", "interrupted"}:
523
+ return "failed", latest
524
+ return "unknown", latest
525
+
526
+ async def uncertain(self, thread, generation, baseline):
527
+ self.note("reconciling", thread=thread)
528
+ for attempt in range(self.retry_attempts + 1):
529
+ if generation != self.user_generation:
530
+ return
531
+ outcome, turn = await self.reconcile(thread, baseline)
532
+ if outcome in {"running", "completed"}:
533
+ self.paused_uncertain.discard(thread)
534
+ if outcome == "completed":
535
+ self.note("completed", thread=thread)
536
+ return
537
+ if outcome == "failed":
538
+ self.paused_uncertain.discard(thread)
539
+ code = (turn.get("error") or {}).get("codexErrorInfo")
540
+ kind = failure_kind(code, (turn.get("error") or {}).get("message"))
541
+ if kind:
542
+ return await self.recover(thread, generation, kind)
543
+ self.note(
544
+ "needs_attention", thread=thread, reason="accepted_turn_failed"
545
+ )
546
+ return
547
+ if (
548
+ getattr(getattr(self.backend, "proc", None), "returncode", None)
549
+ is not None
550
+ and self.backend_factory
551
+ ):
552
+ await self.restart_backend()
553
+ return
554
+ if attempt < self.retry_attempts:
555
+ await self.wait_until(self.clock() + self.retry_interval, generation)
556
+ self.paused_uncertain.add(thread)
557
+ self.note("needs_attention", thread=thread, reason="acceptance_unknown")
558
+ self.report(
559
+ "Could not prove the request was accepted. It was not replayed. Use retry after reviewing the saved thread."
560
+ )
561
+
562
+ async def wait_until(self, deadline, generation, *, allow_early=True):
563
+ if self.pool:
564
+ self.pool.touch(next_wake=deadline)
565
+ # Injected sleeps keep deterministic test clocks working.
566
+ if self.sleep is not asyncio.sleep:
567
+ return await self.sleep(max(0, deadline - self.clock()))
568
+ while (
569
+ generation == self.user_generation
570
+ and self.attached()
571
+ and self.clock() < deadline
572
+ ):
573
+ self.wake.clear()
574
+ try:
575
+ await asyncio.wait_for(
576
+ self.wake.wait(), min(30, max(0.01, deadline - self.clock()))
577
+ )
578
+ except asyncio.TimeoutError:
579
+ continue
580
+ if allow_early or self.force_retry.is_set():
581
+ self.force_retry.clear()
582
+ break
583
+
584
+ async def maintain(self):
585
+ revision = self.pool.revision() if self.pool else 0
586
+ last_watch = 0
587
+ last_heartbeat = 0
588
+ account_revision = self.pool.revision("accounts") if self.pool else 0
589
+ last_tick = time.time()
590
+ last_network = 0
591
+ while not self.stopping.is_set():
592
+ now = time.time()
593
+ if now - last_tick > 60 or now < last_tick:
594
+ self.wake.set()
595
+ last_tick = now
596
+ if self.pool:
597
+ if now - last_heartbeat > 10:
598
+ self.pool.renew_reservation(self.preferences["reservation_seconds"])
599
+ self.pool.touch(
600
+ threads=list(self.last_turn),
601
+ model=next((self.model_for(t) for t in self.last_turn), None),
602
+ paused=self.paused,
603
+ background=self.background,
604
+ )
605
+ last_heartbeat = now
606
+ new_revision = self.pool.revision()
607
+ if new_revision != revision:
608
+ self.wake.set()
609
+ revision = new_revision
610
+ try:
611
+ self.preferences = settings.load(
612
+ self.pool.state, self.project, self.profile
613
+ )
614
+ self.retry_interval = self.preferences["retry_interval"]
615
+ self.retry_attempts = self.preferences["retry_attempts"]
616
+ self.interval = self.preferences["unknown_reset_interval"]
617
+ except ValueError:
618
+ self.note("needs_attention", reason="invalid_settings")
619
+ changed = self.pool.revision("accounts")
620
+ if changed != account_revision and self.extra_accounts:
621
+ self.accounts = await self.extra_accounts(self.accounts)
622
+ account_revision = changed
623
+ self.wake.set()
624
+ for command, op, params in self.pool.take_commands():
625
+ try:
626
+ await self.control(op, params)
627
+ self.pool.ack(command, "done")
628
+ except Exception:
629
+ self.pool.ack(command, "failed")
630
+ self.note("control_failed", operation=op)
631
+ if (
632
+ self.network_state is False
633
+ and now - last_network >= self.preferences["offline_retry"]
634
+ and hasattr(self.account, "network")
635
+ ):
636
+ last_network = now
637
+ try:
638
+ self.network_state = await self.account.network()
639
+ if self.network_state:
640
+ self.note("connection_restored")
641
+ if self.recovery and not self.recovery.done():
642
+ self.force_retry.set()
643
+ self.wake.set()
644
+ except (RouterError, asyncio.TimeoutError):
645
+ pass
646
+ if now - last_watch >= self.preferences["watchdog_interval"]:
647
+ last_watch = now
648
+ if self.backend.proc.returncode is not None and self.backend_factory:
649
+ if not self.restart_task or self.restart_task.done():
650
+ self.restart_task = asyncio.create_task(self.restart_backend())
651
+ elif self.active_threads:
652
+ for thread in list(self.active_threads):
653
+ with contextlib.suppress(RouterError, asyncio.TimeoutError):
654
+ await self.reconcile(thread)
655
+ self.status_changed.set()
656
+ try:
657
+ await asyncio.wait_for(self.stopping.wait(), 2)
658
+ except asyncio.TimeoutError:
659
+ pass
660
+
661
+ async def control(self, op, params):
662
+ thread = params.get("thread") or next(reversed(self.last_turn), None)
663
+ if op == "pause":
664
+ self.paused = True
665
+ self.note("paused")
666
+ elif op == "resume":
667
+ self.paused = False
668
+ self.wake.set()
669
+ self.note("resumed")
670
+ elif op == "stop":
671
+ self.stopping.set()
672
+ elif op == "detach":
673
+ if self.pool:
674
+ from service import folder
675
+ from settings import atomic_json
676
+
677
+ file = folder(self.pool.state) / (self.pool.session + ".job.json")
678
+ if file.exists():
679
+ spec = json.loads(file.read_text())
680
+ spec["takeover"] = True
681
+ atomic_json(file, spec)
682
+ if self.on_detach:
683
+ await self.on_detach()
684
+ if self.client:
685
+ await self.client.close(
686
+ code=1000, reason="Moving terminal attachment; task continues"
687
+ )
688
+ elif op == "permissions":
689
+ if self.active_threads or self.active_items:
690
+ raise RouterError("Session is busy")
691
+ if thread:
692
+ await self.backend.call("thread/resume", {"threadId": thread, **params})
693
+ self.merge_settings(thread, params)
694
+ self.launch_overrides = dict(params)
695
+ elif op == "switch":
696
+ label = params.get("account")
697
+ index = next(
698
+ (i for i, a in enumerate(self.accounts) if a.label == label), None
699
+ )
700
+ if index is None:
701
+ raise RouterError("Unknown account")
702
+ if self.active_threads or self.active_items or self.server_requests:
703
+ raise RouterError("Session is busy; switch at an idle boundary")
704
+ options = self.pool.options(label) if self.pool else {}
705
+ model = self.model_for(thread) if thread else None
706
+ if (
707
+ not options.get("enabled", True)
708
+ or options.get("maintenance")
709
+ or options.get("models")
710
+ and model
711
+ and model not in options["models"]
712
+ ):
713
+ raise RouterError(
714
+ "Account is disabled, under maintenance, or unavailable for this model"
715
+ )
716
+ if limited(await self.accounts[index].usage(force=True), model):
717
+ raise RouterError("Account allowance is exhausted")
718
+ if self.pool and not self.pool.reserve(
719
+ label,
720
+ limit=options.get("max_sessions")
721
+ or self.preferences["max_sessions_per_account"],
722
+ ):
723
+ raise RouterError("Account capacity is full or maintenance has started")
724
+ try:
725
+ await self.login(index)
726
+ finally:
727
+ if self.pool:
728
+ self.pool.release_reservation()
729
+ self.note("switched", to=label)
730
+ elif op == "retry":
731
+ self.paused = False
732
+ self.wake.set()
733
+ if self.recovery and not self.recovery.done():
734
+ self.force_retry.set()
735
+ else:
736
+ self.force_retry.clear()
737
+ if self.pool:
738
+ self.pool.clear(
739
+ self.account.label, self.model_for(thread) if thread else None
740
+ )
741
+ self.pool.invalidate(self.account.label)
742
+ if thread and (not self.recovery or self.recovery.done()):
743
+ self.paused_uncertain.discard(thread)
744
+ self.recovery = asyncio.create_task(
745
+ self.continue_thread(thread, self.user_generation)
746
+ )
747
+ elif op == "wake":
748
+ self.wake.set()
749
+ else:
750
+ raise RouterError("Unknown control operation")
751
+ if self.pool:
752
+ self.pool.touch(paused=self.paused)
753
+ self.status_changed.set()
754
+
755
+ async def restart_backend(self):
756
+ async with self.restart_lock:
757
+ if self.backend.proc.returncode is None:
758
+ return
759
+ self.note("restarting")
760
+ in_flight = bool(self.active_items or self.server_requests)
761
+ old = self.backend
762
+ candidate = None
763
+ try:
764
+ candidate = await self.backend_factory()
765
+ candidate.handler = self.event
766
+ await candidate.initialize()
767
+ self.backend = candidate
768
+ await self.login(self.index)
769
+ except Exception:
770
+ self.backend = old
771
+ if candidate:
772
+ await candidate.close()
773
+ self.note("backend_restart_failed", reason="startup_or_login_failed")
774
+ return
775
+ self.active_threads.clear()
776
+ self.server_requests.clear()
777
+ self.pending_server_requests.clear()
778
+ self.replayed_requests.clear()
779
+ self.active_items.clear()
780
+ for thread in list(self.last_turn):
781
+ params = {"threadId": thread}
782
+ params.update(
783
+ {
784
+ k: v
785
+ 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
+ }
796
+ }
797
+ )
798
+ try:
799
+ await self.backend.call("thread/resume", params)
800
+ except (RouterError, asyncio.TimeoutError):
801
+ self.paused_uncertain.add(thread)
802
+ self.note(
803
+ "needs_attention", thread=thread, reason="backend_resume_failed"
804
+ )
805
+ continue
806
+ if in_flight:
807
+ self.paused_uncertain.add(thread)
808
+ self.note(
809
+ "needs_attention",
810
+ thread=thread,
811
+ reason="action_interrupted_by_crash",
812
+ )
813
+ else:
814
+ state, turn = await self.reconcile(thread)
815
+ persisted = self.pool.intent(thread) if self.pool else {}
816
+ if (
817
+ state != "completed"
818
+ and not self.active_threads
819
+ and persisted
820
+ and persisted.get("state") not in {"completed", "idle", None}
821
+ ):
822
+ self.recovery = asyncio.create_task(
823
+ self.restore_recovery(thread, persisted)
824
+ )
825
+ await old.close()
826
+ if self.paused_uncertain:
827
+ self.note("needs_attention", reason="backend_restored_review_required")
828
+ else:
829
+ self.note("backend_restored")
830
+ self.status_changed.set()
831
+
832
+ @property
833
+ def account(self):
834
+ return self.accounts[self.index]
835
+
836
+ async def login(self, index):
837
+ # A sent login cannot be undone by cancelling its waiter. Complete the
838
+ # state transition before allowing a new user turn to use this backend.
839
+ task = asyncio.create_task(self._login(index))
840
+ try:
841
+ return await asyncio.shield(task)
842
+ except asyncio.CancelledError:
843
+ await task
844
+ raise
845
+
846
+ async def _login(self, index):
847
+ async with self.login_lock:
848
+ credentials = await self.accounts[index].credentials()
849
+ await self.backend.call(
850
+ "account/login/start", {"type": "chatgptAuthTokens", **credentials}
851
+ )
852
+ self.index = index
853
+ # Acceptance of token syntax alone isn't a verified, usable login.
854
+ result = await self.backend.call("account/read", {"refreshToken": False})
855
+ if not result.get("account"):
856
+ raise RouterError("The selected account was not confirmed by Codex.")
857
+ if self.pool:
858
+ self.pool.assign(self.account.label)
859
+
860
+ async def emit(self, message):
861
+ if self.client:
862
+ try:
863
+ await self.client.send(json.dumps(message))
864
+ except Exception:
865
+ pass
866
+
867
+ async def event(self, message):
868
+ method, params = message.get("method"), message.get("params", {})
869
+ if method == "account/chatgptAuthTokens/refresh":
870
+ try:
871
+ # Never return another identity in response to a refresh request.
872
+ credentials = await self.account.credentials(refresh=True)
873
+ previous = params.get("previousAccountId")
874
+ if previous and previous != credentials["chatgptAccountId"]:
875
+ raise RouterError(
876
+ "Refresh account mismatch; re-enroll the account."
877
+ )
878
+ await self.backend.send({"id": message["id"], "result": credentials})
879
+ except (RouterError, asyncio.TimeoutError):
880
+ await self.backend.send(
881
+ {
882
+ "id": message["id"],
883
+ "error": {
884
+ "code": -32000,
885
+ "message": "Account requires sign-in.",
886
+ },
887
+ }
888
+ )
889
+ return
890
+ if "id" in message and method:
891
+ self.server_requests.add(message["id"])
892
+ self.pending_server_requests[message["id"]] = message
893
+ if not self.client:
894
+ self.note(
895
+ "needs_attention",
896
+ reason="approval_or_tool_response_required",
897
+ thread=params.get("threadId"),
898
+ )
899
+ if method == "serverRequest/resolved":
900
+ self.server_requests.discard(params.get("requestId"))
901
+ self.pending_server_requests.pop(params.get("requestId"), None)
902
+ if method == "thread/started":
903
+ thread = params.get("thread", {})
904
+ if self.pool and thread.get("id"):
905
+ self.pool.claim(thread["id"])
906
+ if method == "turn/started":
907
+ self.active_threads.add(params["threadId"])
908
+ self.turn_ids[params["threadId"]] = params.get("turn", {}).get("id")
909
+ if params["threadId"] in self.pending_intents:
910
+ self.pending_intents[params["threadId"]]["accepted"] = True
911
+ self.note("working", thread=params["threadId"])
912
+ self.persist(
913
+ params["threadId"],
914
+ accepted=True,
915
+ turn=params.get("turn", {}).get("id"),
916
+ state="working",
917
+ )
918
+ elif method == "item/started" and params.get("item", {}).get("type") in {
919
+ "commandExecution",
920
+ "mcpToolCall",
921
+ "fileChange",
922
+ "dynamicToolCall",
923
+ }:
924
+ self.active_items.add(params["item"]["id"])
925
+ if params.get("threadId"):
926
+ self.persist(params["threadId"], active_items=True)
927
+ elif method == "item/completed":
928
+ self.active_items.discard(params.get("item", {}).get("id"))
929
+ if params.get("threadId"):
930
+ self.persist(params["threadId"], active_items=bool(self.active_items))
931
+ elif method == "model/rerouted":
932
+ if params.get("toModel"):
933
+ self.merge_settings(params["threadId"], {"model": params["toModel"]})
934
+ elif method == "thread/closed":
935
+ self.active_threads.discard(params["threadId"])
936
+ elif method == "thread/status/changed":
937
+ if params.get("status", {}).get("type") == "active":
938
+ self.active_threads.add(params["threadId"])
939
+ else:
940
+ self.active_threads.discard(params["threadId"])
941
+ elif method == "turn/completed":
942
+ thread = params["threadId"]
943
+ self.active_threads.discard(thread)
944
+ turn = params.get("turn", {})
945
+ self.turn_ids[thread] = turn.get("id")
946
+ self.persist(
947
+ thread,
948
+ state=turn.get("status"),
949
+ accepted=True,
950
+ turn=turn.get("id"),
951
+ active_items=bool(self.active_items),
952
+ )
953
+ failure = (turn.get("error") or {}).get("codexErrorInfo")
954
+ key = (thread, turn.get("id"))
955
+ kind = failure_kind(failure, (turn.get("error") or {}).get("message"))
956
+ if failure == "modelNotFound" and self.preferences.get("fallback_models"):
957
+ kind = "model"
958
+ if (
959
+ turn.get("status") == "failed"
960
+ and failure is not None
961
+ and not kind
962
+ and key in self.seen_failures
963
+ ):
964
+ if self.recovery:
965
+ self.recovery.cancel()
966
+ self.episodes.pop(thread, None)
967
+ self.note(
968
+ "needs_attention",
969
+ thread=thread,
970
+ reason="terminal_error_requires_review",
971
+ )
972
+ if turn.get("status") == "completed":
973
+ if (
974
+ key in self.seen_failures
975
+ and self.recovery
976
+ and self.recovery is not asyncio.current_task()
977
+ ):
978
+ self.recovery.cancel()
979
+ self.episodes.pop(thread, None)
980
+ self.persist(thread, kind=None, episode=None, next_wake=None)
981
+ self.note("completed", thread=thread)
982
+ if (
983
+ turn.get("status") == "failed"
984
+ and kind
985
+ and key not in self.seen_failures
986
+ ):
987
+ self.seen_failures.add(key)
988
+ if thread in self.last_turn:
989
+ if self.recovery and not self.recovery.done():
990
+ self.recovery.cancel()
991
+ self.recovery = asyncio.create_task(
992
+ self.recover(thread, self.user_generation, kind)
993
+ )
994
+ elif method == "error" and params.get("willRetry") is False:
995
+ error = params.get("error") or {}
996
+ thread = params.get("threadId")
997
+ key = (thread, params.get("turnId"))
998
+ if (
999
+ failure_kind(error.get("codexErrorInfo"), error.get("message"))
1000
+ == "capacity"
1001
+ and thread in self.last_turn
1002
+ and key not in self.seen_failures
1003
+ ):
1004
+ self.seen_failures.add(key)
1005
+ if self.recovery and not self.recovery.done():
1006
+ self.recovery.cancel()
1007
+ self.recovery = asyncio.create_task(
1008
+ self.recover(thread, self.user_generation, "capacity")
1009
+ )
1010
+ self.status_changed.set()
1011
+ await self.emit(message)
1012
+
1013
+ def model_for(self, thread):
1014
+ return self.thread_settings.get(thread, {}).get("model") or self.last_turn.get(
1015
+ thread, {}
1016
+ ).get("model")
1017
+
1018
+ def deadline(self, index, model):
1019
+ return (
1020
+ self.pool.deadline(self.accounts[index].label, model)
1021
+ if self.pool
1022
+ else self.local_deadlines.get(index, 0)
1023
+ )
1024
+
1025
+ async def mark(self, index, model, reason="quota"):
1026
+ snapshot = None
1027
+ if (
1028
+ self.pool
1029
+ and reason == "quota"
1030
+ and self.pool.deadline(self.accounts[index].label, model) > self.clock()
1031
+ ):
1032
+ return self.pool.deadline(self.accounts[index].label, model)
1033
+ if reason == "quota":
1034
+ try:
1035
+ snapshot = await self.accounts[index].usage(force=True)
1036
+ except (RouterError, asyncio.TimeoutError):
1037
+ pass
1038
+ if self.pool:
1039
+ return self.pool.mark(
1040
+ self.accounts[index].label,
1041
+ model,
1042
+ snapshot=snapshot,
1043
+ reason=reason,
1044
+ delay=self.interval,
1045
+ )
1046
+ until = (
1047
+ reset_time(snapshot or {}, model, self.clock())
1048
+ or self.clock() + self.interval
1049
+ )
1050
+ self.local_deadlines[index] = until
1051
+ return until
1052
+
1053
+ async def wait_idle(self, generation):
1054
+ began = time.monotonic()
1055
+ while (
1056
+ self.active_threads
1057
+ or self.active_items
1058
+ or self.server_requests
1059
+ or self.paused
1060
+ ):
1061
+ if generation != self.user_generation or not self.attached():
1062
+ return False
1063
+ self.status_changed.clear()
1064
+ try:
1065
+ await asyncio.wait_for(
1066
+ self.status_changed.wait(), self.preferences["watchdog_interval"]
1067
+ )
1068
+ except asyncio.TimeoutError:
1069
+ for thread in list(self.active_threads):
1070
+ with contextlib.suppress(RouterError, asyncio.TimeoutError):
1071
+ await self.reconcile(thread)
1072
+ if time.monotonic() - began > self.preferences["idle_warning_after"]:
1073
+ self.note(
1074
+ "waiting_for_activity", reason="actions_or_approvals_not_idle"
1075
+ )
1076
+ if self.paused:
1077
+ self.wake.clear()
1078
+ try:
1079
+ await asyncio.wait_for(self.wake.wait(), 2)
1080
+ except asyncio.TimeoutError:
1081
+ pass
1082
+ return generation == self.user_generation and self.attached()
1083
+
1084
+ async def continue_thread(self, thread, generation):
1085
+ if not await self.wait_idle(generation):
1086
+ return
1087
+ params = {
1088
+ "threadId": thread,
1089
+ "input": [{"type": "text", "text": CONTINUE, "text_elements": []}],
1090
+ }
1091
+ params["clientUserMessageId"] = str(uuid.uuid4())
1092
+ baseline = self.turn_ids.get(thread) or "__unknown_before_submission__"
1093
+ self.pending_intents[thread] = {"baseline": baseline, "accepted": False}
1094
+ self.persist(thread, accepted=False, state="submitting", baseline=baseline)
1095
+ try:
1096
+ result = await self.backend.call("turn/start", params)
1097
+ turn = result.get("turn", {})
1098
+ if turn.get("id"):
1099
+ self.turn_ids[thread] = turn["id"]
1100
+ self.pending_intents[thread]["accepted"] = True
1101
+ self.persist(
1102
+ thread,
1103
+ accepted=True,
1104
+ turn=turn["id"],
1105
+ state=turn.get("status", "working"),
1106
+ )
1107
+ except RemoteError as error:
1108
+ if error.payload.get("code") == -32001:
1109
+ return await self.recover(thread, generation, "transient")
1110
+ raise
1111
+ except (RouterError, asyncio.TimeoutError):
1112
+ return await self.uncertain(thread, generation, baseline)
1113
+ self.report(f"Continued the saved thread with {self.account.label}.")
1114
+
1115
+ async def restore_recovery(self, thread, persisted):
1116
+ """Honor an accepted retry's original deadline after a process restart."""
1117
+ generation = self.user_generation
1118
+ episode = persisted.get("episode")
1119
+ if episode:
1120
+ episode = dict(episode)
1121
+ label = persisted.get("account_label")
1122
+ episode["account"] = next(
1123
+ (i for i, a in enumerate(self.accounts) if a.label == label),
1124
+ episode.get("account", self.index),
1125
+ )
1126
+ self.episodes[thread] = episode
1127
+ deadline = persisted.get("next_wake")
1128
+ if deadline and deadline > self.clock():
1129
+ self.note("retry_scheduled", thread=thread, next_wake=deadline)
1130
+ await self.wait_until(
1131
+ deadline, generation, allow_early=persisted.get("kind") == "quota"
1132
+ )
1133
+ if persisted.get("active_items") or not persisted.get("accepted", False):
1134
+ self.paused_uncertain.add(thread)
1135
+ self.note(
1136
+ "needs_attention",
1137
+ thread=thread,
1138
+ reason="worker_restarted_during_uncertain_action",
1139
+ )
1140
+ return
1141
+ if not await self.wait_idle(generation):
1142
+ return
1143
+ if persisted.get("kind") == "auth":
1144
+ try:
1145
+ await self.account.credentials(refresh=True)
1146
+ await self.login(self.index)
1147
+ except (RouterError, asyncio.TimeoutError):
1148
+ return await self.recover(thread, generation, "auth")
1149
+ await self.continue_thread(thread, generation)
1150
+
1151
+ async def choose(self, model=None, preferred=None):
1152
+ """Return (account index or None, earliest next read timestamp)."""
1153
+ loads = self.pool.loads() if self.pool else {}
1154
+ start = self.index
1155
+
1156
+ def score(i):
1157
+ account = self.accounts[i]
1158
+ options = self.pool.options(account.label) if self.pool else {}
1159
+ snapshot = self.pool.cached(account.label, 3600) if self.pool else None
1160
+ remaining = min(
1161
+ (
1162
+ 100 - (b.get(k) or {}).get("usedPercent", 0)
1163
+ for b in __import__("pool").buckets(snapshot or {}, model)
1164
+ for k in ("primary", "secondary")
1165
+ ),
1166
+ default=100,
1167
+ )
1168
+ mode = self.preferences["routing"]
1169
+ fav = self.preferences.get("preferred_accounts", [])
1170
+ priority = -options.get("priority", 0)
1171
+ load = loads.get(account.label, 0)
1172
+ if mode == "quota":
1173
+ rank = (-remaining, load, priority)
1174
+ elif mode == "priority":
1175
+ rank = (priority, load, -remaining)
1176
+ elif mode == "round-robin":
1177
+ rank = ((i - start - 1) % len(self.accounts),)
1178
+ else:
1179
+ rank = (load, priority, -remaining)
1180
+ return (
1181
+ 0 if i == preferred else 1,
1182
+ fav.index(account.label) if account.label in fav else len(fav),
1183
+ *rank,
1184
+ (i - start - 1) % len(self.accounts),
1185
+ )
1186
+
1187
+ order = sorted(range(len(self.accounts)), key=score)
1188
+ deadlines = []
1189
+ for index in order:
1190
+ deadline = self.deadline(index, model)
1191
+ if deadline > self.clock():
1192
+ deadlines.append(deadline)
1193
+ continue
1194
+ account = self.accounts[index]
1195
+ options = self.pool.options(account.label) if self.pool else {}
1196
+ if (
1197
+ not options.get("enabled", True)
1198
+ or options.get("maintenance")
1199
+ or options.get("models")
1200
+ and model
1201
+ and model not in options["models"]
1202
+ ):
1203
+ continue
1204
+ try:
1205
+ # A reset timestamp authorizes a new check, not usage itself.
1206
+ snapshot = await account.usage(force=bool(deadline))
1207
+ if limited(snapshot, model):
1208
+ if self.pool:
1209
+ until = self.pool.mark(
1210
+ account.label, model, snapshot=snapshot, delay=self.interval
1211
+ )
1212
+ else:
1213
+ until = (
1214
+ reset_time(snapshot, model, self.clock())
1215
+ or self.clock() + self.interval
1216
+ )
1217
+ self.local_deadlines[index] = until
1218
+ deadlines.append(until)
1219
+ continue
1220
+ if self.pool:
1221
+ self.pool.clear(account.label, model)
1222
+ else:
1223
+ self.local_deadlines.pop(index, None)
1224
+ if self.pool and not self.pool.reserve(
1225
+ account.label,
1226
+ expected_load=loads.get(account.label, 0),
1227
+ limit=options.get("max_sessions")
1228
+ or self.preferences["max_sessions_per_account"],
1229
+ ttl=self.preferences["reservation_seconds"],
1230
+ ):
1231
+ deadlines.append(self.clock() + 2)
1232
+ continue
1233
+ return index, 0
1234
+ except (RouterError, asyncio.TimeoutError):
1235
+ deadlines.append(await self.mark(index, model, "unavailable"))
1236
+ return None, min(deadlines, default=self.clock() + self.interval)
1237
+
1238
+ async def recover(self, thread, generation, kind="quota"):
1239
+ try:
1240
+ model = self.model_for(thread)
1241
+ if kind == "model":
1242
+ tried = self.fallback_tried.setdefault(thread, set())
1243
+ tried.add(model)
1244
+ fallback = next(
1245
+ (
1246
+ m
1247
+ for m in self.preferences.get("fallback_models", [])
1248
+ if m not in tried
1249
+ ),
1250
+ None,
1251
+ )
1252
+ if not fallback:
1253
+ self.note(
1254
+ "needs_attention",
1255
+ thread=thread,
1256
+ reason="configured_model_fallbacks_exhausted",
1257
+ )
1258
+ return
1259
+ tried.add(fallback)
1260
+ self.merge_settings(thread, {"model": fallback})
1261
+ await self.backend.call(
1262
+ "thread/resume", {"threadId": thread, "model": fallback}
1263
+ )
1264
+ self.note("model_fallback", thread=thread, model=fallback)
1265
+ return await self.continue_thread(thread, generation)
1266
+ if kind == "transient" and hasattr(self.account, "network"):
1267
+ with contextlib.suppress(RouterError, asyncio.TimeoutError):
1268
+ self.network_state = await self.account.network()
1269
+ if self.network_state is False:
1270
+ self.note("connection_probe_failed", thread=thread)
1271
+ if kind != "quota":
1272
+ episode = self.episodes.setdefault(
1273
+ thread, {"account": self.index, "start": self.clock(), "attempt": 0}
1274
+ )
1275
+ if episode["account"] != self.index:
1276
+ episode = {
1277
+ "account": self.index,
1278
+ "start": self.clock(),
1279
+ "attempt": 0,
1280
+ }
1281
+ self.episodes[thread] = episode
1282
+ if kind == "capacity" and episode["attempt"] >= self.retry_attempts:
1283
+ if self.preferences.get("fallback_models"):
1284
+ return await self.recover(thread, generation, "model")
1285
+ if not self.retry_attempts:
1286
+ self.note(
1287
+ "needs_attention",
1288
+ thread=thread,
1289
+ reason="capacity_retries_disabled",
1290
+ )
1291
+ return
1292
+ # Model-wide capacity does not establish an account problem.
1293
+ # Keep waiting on this account/model instead of cycling logins.
1294
+ episode = {
1295
+ "account": self.index,
1296
+ "start": self.clock(),
1297
+ "attempt": 0,
1298
+ }
1299
+ self.episodes[thread] = episode
1300
+ if episode["attempt"] < self.retry_attempts:
1301
+ episode["attempt"] += 1
1302
+ target = episode["start"] + episode["attempt"] * self.retry_interval
1303
+ 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))}."
1305
+ )
1306
+ self.note(
1307
+ "retry_scheduled",
1308
+ thread=thread,
1309
+ next_wake=target,
1310
+ attempt=episode["attempt"],
1311
+ )
1312
+ self.persist(
1313
+ thread,
1314
+ generation=generation,
1315
+ kind=kind,
1316
+ episode=episode,
1317
+ next_wake=target,
1318
+ account_label=self.account.label,
1319
+ state="retry_scheduled",
1320
+ )
1321
+ await self.wait_until(target, generation, allow_early=False)
1322
+ if not await self.wait_idle(generation):
1323
+ return
1324
+ if kind == "auth":
1325
+ try:
1326
+ await self.account.credentials(refresh=True)
1327
+ await self.login(self.index)
1328
+ except (RouterError, asyncio.TimeoutError):
1329
+ # Keep this same schedule even if login is revoked.
1330
+ return await self.recover(thread, generation, kind)
1331
+ return await self.continue_thread(thread, generation)
1332
+ self.report(
1333
+ "Three retries over 15 minutes failed; trying another account."
1334
+ )
1335
+ self.episodes.pop(thread, None)
1336
+ self.report(
1337
+ f"Selecting another account after {kind} failure on {self.account.label}."
1338
+ )
1339
+ self.note("selecting_account", thread=thread, reason=kind)
1340
+ await self.mark(self.index, model, kind)
1341
+ if kind != "quota" and self.preferences.get("fallback_models"):
1342
+ configured = self.preferences["fallback_models"]
1343
+ if kind in {"transient", "capacity"} and model not in configured:
1344
+ self.merge_settings(thread, {"model": configured[0]})
1345
+ await self.backend.call(
1346
+ "thread/resume", {"threadId": thread, "model": configured[0]}
1347
+ )
1348
+ model = configured[0]
1349
+ self.note("model_fallback", thread=thread, model=model)
1350
+ while generation == self.user_generation and self.attached():
1351
+ if not await self.wait_idle(generation):
1352
+ return
1353
+ index, deadline = await self.choose(model)
1354
+ if index is not None:
1355
+ if not await self.wait_idle(generation):
1356
+ return
1357
+ try:
1358
+ await self.login(index)
1359
+ return await self.continue_thread(thread, generation)
1360
+ except (RouterError, asyncio.TimeoutError):
1361
+ if self.pool:
1362
+ self.pool.release_reservation()
1363
+ await self.mark(index, model, "unavailable")
1364
+ continue
1365
+ self.report(
1366
+ f"All accounts unavailable. Sleeping until {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(deadline))}; no usage requests before then."
1367
+ )
1368
+ self.note("waiting_for_reset", thread=thread, next_wake=deadline)
1369
+ self.persist(
1370
+ thread,
1371
+ generation=generation,
1372
+ kind=kind,
1373
+ next_wake=deadline,
1374
+ account_label=self.account.label,
1375
+ state="waiting_for_reset",
1376
+ )
1377
+ await self.wait_until(max(self.clock() + 1, deadline), generation)
1378
+ except asyncio.CancelledError:
1379
+ raise
1380
+ except Exception:
1381
+ self.note("needs_attention", thread=thread, reason="handoff_failed")
1382
+ self.report(
1383
+ "Automatic handoff stopped. Your saved thread is retained; continue manually."
1384
+ )
1385
+
1386
+ async def forward(self, message, init_result):
1387
+ method = message.get("method")
1388
+ request_id = message.get("id")
1389
+ if (
1390
+ method in {"thread/start", "thread/resume", "thread/fork"}
1391
+ and self.launch_overrides
1392
+ ):
1393
+ message = dict(message)
1394
+ message["params"] = {**message.get("params", {}), **self.launch_overrides}
1395
+ if not method:
1396
+ # Approval responses keep the server's original ID, never our call IDs.
1397
+ if request_id in self.server_requests:
1398
+ self.server_requests.discard(request_id)
1399
+ self.pending_server_requests.pop(request_id, None)
1400
+ await self.backend.send(message)
1401
+ return
1402
+ if method == "initialized":
1403
+ return
1404
+ if method == "initialize":
1405
+ await self.emit({"id": request_id, "result": init_result})
1406
+ return
1407
+ if method.startswith("account/login") or method == "account/logout":
1408
+ await self.emit(
1409
+ {
1410
+ "id": request_id,
1411
+ "error": {
1412
+ "code": -32600,
1413
+ "message": "Close this controller before managing pool logins with relay add.",
1414
+ },
1415
+ }
1416
+ )
1417
+ return
1418
+ if method in {"thread/resume", "turn/start", "turn/steer"}:
1419
+ thread = message.get("params", {}).get("threadId")
1420
+ if self.pool and thread and not self.pool.claim(thread):
1421
+ await self.emit(
1422
+ {
1423
+ "id": request_id,
1424
+ "error": {
1425
+ "code": -32600,
1426
+ "message": "This saved conversation is already open in another wrapper terminal. Use a different thread or close that terminal.",
1427
+ },
1428
+ }
1429
+ )
1430
+ return
1431
+ if method in {
1432
+ "turn/start",
1433
+ "turn/steer",
1434
+ "turn/interrupt",
1435
+ "thread/archive",
1436
+ "thread/delete",
1437
+ }:
1438
+ self.user_generation += 1
1439
+ if self.recovery and not self.recovery.done():
1440
+ self.recovery.cancel()
1441
+ self.episodes.clear()
1442
+ if method == "turn/start" and message.get("params", {}).get("model"):
1443
+ self.fallback_tried.pop(message["params"]["threadId"], None)
1444
+ self.status_changed.set()
1445
+ if method == "turn/start":
1446
+ self.last_turn[message["params"]["threadId"]] = message["params"]
1447
+ self.merge_settings(message["params"]["threadId"], message["params"])
1448
+ try:
1449
+ if request_id is None:
1450
+ await self.backend.send(message)
1451
+ else:
1452
+ if method == "turn/start" and self.pool:
1453
+ thread = message["params"]["threadId"]
1454
+ model = self.model_for(thread)
1455
+ allowed = self.pool.options(self.account.label).get("models", [])
1456
+ if allowed and model and model not in allowed:
1457
+ if (
1458
+ self.active_threads
1459
+ or self.active_items
1460
+ or self.server_requests
1461
+ ):
1462
+ raise RemoteError(
1463
+ {
1464
+ "code": -32600,
1465
+ "message": "This account does not allow that model. Switch accounts after the current work finishes.",
1466
+ }
1467
+ )
1468
+ index, _ = await self.choose(model)
1469
+ if index is None:
1470
+ raise RemoteError(
1471
+ {
1472
+ "code": -32600,
1473
+ "message": "No available account allows that model. Choose another model or update your account model settings.",
1474
+ }
1475
+ )
1476
+ await self.login(index)
1477
+ self.note("switched", to=self.account.label)
1478
+ guard = (
1479
+ self.login_lock
1480
+ if method
1481
+ in {
1482
+ "turn/start",
1483
+ "turn/steer",
1484
+ "turn/interrupt",
1485
+ "thread/start",
1486
+ "thread/resume",
1487
+ }
1488
+ else contextlib.nullcontext()
1489
+ )
1490
+ if method == "turn/start":
1491
+ thread = message["params"]["threadId"]
1492
+ if thread not in self.turn_ids:
1493
+ try:
1494
+ latest = await self.latest(thread)
1495
+ self.turn_ids[thread] = latest.get("id") if latest else None
1496
+ except (RouterError, asyncio.TimeoutError):
1497
+ self.turn_ids[thread] = "__unknown_before_submission__"
1498
+ message["params"].setdefault(
1499
+ "clientUserMessageId", str(uuid.uuid4())
1500
+ )
1501
+ self.pending_intents[thread] = {
1502
+ "baseline": self.turn_ids.get(thread),
1503
+ "generation": self.user_generation,
1504
+ }
1505
+ self.persist(
1506
+ thread,
1507
+ accepted=False,
1508
+ baseline=self.turn_ids.get(thread),
1509
+ state="submitting",
1510
+ )
1511
+ async with guard:
1512
+ result = await self.backend.call(
1513
+ method, message.get("params"), timeout=300
1514
+ )
1515
+ if method == "turn/start" and result.get("turn", {}).get("id"):
1516
+ thread = message["params"]["threadId"]
1517
+ self.turn_ids[thread] = result["turn"]["id"]
1518
+ self.pending_intents[thread]["accepted"] = True
1519
+ self.persist(
1520
+ thread,
1521
+ accepted=True,
1522
+ turn=result["turn"]["id"],
1523
+ state=result["turn"].get("status"),
1524
+ )
1525
+ if method in {"thread/start", "thread/resume", "thread/fork"}:
1526
+ thread = result.get("thread", {})
1527
+ if thread.get("id"):
1528
+ if thread.get("canAcceptDirectInput") is False:
1529
+ if self.pool:
1530
+ self.pool.release_thread(thread["id"])
1531
+ self.report(
1532
+ "This conversation is owned by another Codex backend. Keep using its existing terminal, or fork it into a separate conversation. No running task was stopped."
1533
+ )
1534
+ await self.emit(
1535
+ {
1536
+ "id": request_id,
1537
+ "error": {
1538
+ "code": -32600,
1539
+ "message": "Conversation is open in another Codex backend. Close/unload it there before resuming here, or use relay run -- fork "
1540
+ + thread["id"]
1541
+ + " --yolo for an independent copy. No running task was stopped.",
1542
+ },
1543
+ }
1544
+ )
1545
+ return
1546
+ self.merge_settings(thread["id"], result)
1547
+ self.merge_settings(thread["id"], message.get("params", {}))
1548
+ self.last_turn.setdefault(
1549
+ thread["id"], {"threadId": thread["id"]}
1550
+ )
1551
+ self.persist(thread["id"], role="foreground")
1552
+ if self.on_thread:
1553
+ await self.on_thread(
1554
+ thread["id"], self.thread_settings[thread["id"]]
1555
+ )
1556
+ if self.pool:
1557
+ self.pool.claim(thread["id"])
1558
+ self.pool.touch(
1559
+ thread=thread["id"],
1560
+ cwd=result.get("cwd") or thread.get("cwd"),
1561
+ model=self.model_for(thread["id"]),
1562
+ )
1563
+ await self.emit({"id": request_id, "result": result})
1564
+ if method in {"thread/start", "thread/resume", "thread/fork"}:
1565
+ thread_id = result.get("thread", {}).get("id")
1566
+ for ident, pending in list(self.pending_server_requests.items()):
1567
+ if ident not in self.replayed_requests and pending.get(
1568
+ "params", {}
1569
+ ).get("threadId") in {None, thread_id}:
1570
+ await self.emit(pending)
1571
+ self.replayed_requests.add(ident)
1572
+ except RemoteError as error:
1573
+ await self.emit({"id": request_id, "error": error.payload})
1574
+ # Ingress overload rejects a turn before a turn/completed event
1575
+ # exists. Its documented code is explicit, not message matching.
1576
+ if method == "turn/start" and error.payload.get("code") == -32001:
1577
+ if self.recovery and not self.recovery.done():
1578
+ self.recovery.cancel()
1579
+ thread = message["params"]["threadId"]
1580
+ self.recovery = asyncio.create_task(
1581
+ self.recover(thread, self.user_generation, "transient")
1582
+ )
1583
+ except (RouterError, asyncio.TimeoutError):
1584
+ await self.emit(
1585
+ {
1586
+ "id": request_id,
1587
+ "error": {
1588
+ "code": -32000,
1589
+ "message": "Codex request failed. No automatic retry of this action was made.",
1590
+ },
1591
+ }
1592
+ )
1593
+ if method == "turn/start":
1594
+ thread = message["params"]["threadId"]
1595
+ intent = self.pending_intents.get(thread, {})
1596
+ if self.recovery and not self.recovery.done():
1597
+ self.recovery.cancel()
1598
+ self.recovery = asyncio.create_task(
1599
+ self.uncertain(thread, self.user_generation, intent.get("baseline"))
1600
+ )
1601
+
1602
+ async def connect(self, websocket, init_result):
1603
+ if self.client:
1604
+ await websocket.close(code=1008, reason="One terminal per controller.")
1605
+ return
1606
+ self.client = websocket
1607
+ self.replayed_requests.clear()
1608
+ if self.pool:
1609
+ self.pool.touch(attached=True)
1610
+ try:
1611
+ async for raw in websocket:
1612
+ try:
1613
+ message = json.loads(raw)
1614
+ if not isinstance(message, dict):
1615
+ raise ValueError()
1616
+ except ValueError:
1617
+ await websocket.close(code=1008, reason="Invalid RPC message.")
1618
+ break
1619
+ # Keep notifications ordered; independent long RPC calls must not
1620
+ # block approval responses or interruption requests.
1621
+ task = asyncio.create_task(self.forward(message, init_result))
1622
+ self.client_tasks.add(task)
1623
+ task.add_done_callback(self.client_tasks.discard)
1624
+ finally:
1625
+ self.client = None
1626
+ if self.pool:
1627
+ self.pool.touch(attached=False)
1628
+ if not self.background:
1629
+ self.user_generation += 1
1630
+ if self.recovery:
1631
+ self.recovery.cancel()
1632
+ for task in list(self.client_tasks):
1633
+ task.cancel()
1634
+
1635
+
1636
+ async def run_pool(args, state, binary):
1637
+ import service
1638
+
1639
+ settings.load(state, os.getcwd(), args.profile)
1640
+ frontend, overrides = translate(args.codex_args)
1641
+ target = resume_target(
1642
+ frontend,
1643
+ Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")),
1644
+ os.getcwd(),
1645
+ )
1646
+ pool = Pool(state)
1647
+ try:
1648
+ owner = pool.owner(target) if target else None
1649
+ finally:
1650
+ pool.close()
1651
+ if owner:
1652
+ print(
1653
+ "Reconnecting to existing wrapper session " + owner[:10] + ".", flush=True
1654
+ )
1655
+ return await service.attach(state, owner, binary, frontend, wait_forever=True)
1656
+ ident = service.start(
1657
+ state,
1658
+ name=args.name or Path.cwd().name,
1659
+ cwd=os.getcwd(),
1660
+ first=args.first,
1661
+ arguments=args.codex_args,
1662
+ profile=args.profile,
1663
+ interactive=True,
1664
+ binary=binary,
1665
+ )
1666
+ try:
1667
+ return await service.attach(
1668
+ state, ident, binary, frontend or [], wait_forever=True
1669
+ )
1670
+ finally:
1671
+ # A foreground launch normally closes its worker after the terminal exits.
1672
+ # Background mode and explicit takeover preserve the supervised worker.
1673
+ spec_path = service.folder(state) / (ident + ".job.json")
1674
+ spec = json.loads(spec_path.read_text()) if spec_path.exists() else {}
1675
+ current_preferences = settings.load(state, os.getcwd(), args.profile)
1676
+ if not current_preferences.get("background_on_disconnect") and not spec.get(
1677
+ "takeover"
1678
+ ):
1679
+ pool = Pool(state)
1680
+ pool.command(ident, "stop")
1681
+ pool.close()
1682
+ deadline = time.monotonic() + 10
1683
+ while (
1684
+ service.folder(state) / (ident + ".connection.json")
1685
+ ).exists() and time.monotonic() < deadline:
1686
+ await asyncio.sleep(0.1)
1687
+
1688
+
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
1704
+
1705
+ 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:
1721
+ return None
1722
+
1723
+
1724
+ def add_account(args, state, binary):
1725
+ home = private_dir(account_home(state, args.label))
1726
+ old_identity = (
1727
+ json.loads((home / "enrolled").read_text()).get("identity")
1728
+ if (home / "enrolled").exists()
1729
+ else None
1730
+ )
1731
+ # Only these isolated homes use the direct Keychain layout. Existing Codex
1732
+ # settings, login entries, session files and binaries are never replaced.
1733
+ config = home / "config.toml"
1734
+ config.write_text(
1735
+ 'cli_auth_credentials_store = "keyring"\n[features]\nsecret_auth_storage = false\n[analytics]\nenabled = false\n'
1736
+ )
1737
+ config.chmod(0o600)
1738
+ command = [binary, "login"] + (["--device-auth"] if args.device_auth else [])
1739
+ print(f"Sign into the ChatGPT account you want to label {args.label}.")
1740
+ result = subprocess.run(command, env=clean_env(home))
1741
+ if result.returncode:
1742
+ raise RouterError("Sign-in did not finish. No account was added to the pool.")
1743
+ # Once native login changes this home's credential, an old marker must no
1744
+ # longer make a failed/duplicate enrollment selectable.
1745
+ (home / "enrolled").unlink(missing_ok=True)
1746
+ credentials = read_keychain(home)
1747
+ identity = hashlib.sha256(credentials["chatgptAccountId"].encode()).hexdigest()
1748
+ for label in labels(state):
1749
+ if label == args.label:
1750
+ continue
1751
+ other = json.loads((account_home(state, label) / "enrolled").read_text())
1752
+ if identity == other.get("identity"):
1753
+ raise RouterError(
1754
+ "That account is already enrolled under another label. Select a different browser account and try again."
1755
+ )
1756
+ marker = home / "enrolled"
1757
+ marker.write_text(json.dumps({"identity": identity}) + "\n")
1758
+ marker.chmod(0o600)
1759
+ pool = Pool(state)
1760
+ if old_identity != identity:
1761
+ pool.reset_identity(args.label)
1762
+ else:
1763
+ pool.invalidate(args.label)
1764
+ with pool.db() as db:
1765
+ db.execute(
1766
+ "DELETE FROM blocks WHERE label=? AND reason<>'quota'", (args.label,)
1767
+ )
1768
+ pool.set_options(args.label, enabled=True)
1769
+ pool.close()
1770
+ print(
1771
+ f"Saved {args.label} in macOS Keychain. No credentials were printed or saved in the project."
1772
+ )
1773
+
1774
+
1775
+ def transport_guard(token):
1776
+ async def authenticate(connection, request):
1777
+ # Browser origins are rejected even if they present a transport token.
1778
+ expected = token() if callable(token) else token
1779
+ if (
1780
+ request.headers.get("Origin")
1781
+ or request.headers.get("Authorization") != f"Bearer {expected}"
1782
+ ):
1783
+ return connection.respond(403, "Forbidden\n")
1784
+
1785
+ return authenticate
1786
+
1787
+
1788
+ def parser():
1789
+ p = argparse.ArgumentParser(
1790
+ description="Keep Codex moving between your own accounts (macOS)."
1791
+ )
1792
+ p.add_argument("--version", action="version", version="Relay " + VERSION)
1793
+ sub = p.add_subparsers(dest="command", required=True)
1794
+ add = sub.add_parser("add", help="Sign into one account using native Codex login")
1795
+ add.add_argument("label")
1796
+ add.add_argument("--device-auth", action="store_true")
1797
+ setup = sub.add_parser(
1798
+ "setup", help="Enroll any number of accounts using native browser sign-in"
1799
+ )
1800
+ setup.add_argument("--device-auth", action="store_true")
1801
+ setup.add_argument("labels", nargs="*")
1802
+ setup.add_argument("--count", type=int)
1803
+ sub.add_parser("list", help="List enrolled labels; never show credentials")
1804
+ run = sub.add_parser("run", help="Open Codex with automatic account recovery")
1805
+ run.add_argument("--first", help="Account label to start with")
1806
+ run.add_argument(
1807
+ "--profile", help="Recovery profile, separate from the native Codex profile"
1808
+ )
1809
+ run.add_argument("--name", help="Friendly session name")
1810
+ run.add_argument(
1811
+ "codex_args",
1812
+ nargs=argparse.REMAINDER,
1813
+ help="Arguments after -- are forwarded to Codex",
1814
+ )
1815
+ sub.add_parser(
1816
+ "check",
1817
+ help="Check CLI version, dependency and account count without signing in",
1818
+ )
1819
+ sub.add_parser(
1820
+ "refresh",
1821
+ help="Fetch current allowance and reset times, even for cached exhausted accounts",
1822
+ )
1823
+ import commands
1824
+
1825
+ commands.add_parsers(sub)
1826
+ sub.metavar = "COMMAND"
1827
+ sub._choices_actions[:] = [
1828
+ item for item in sub._choices_actions if not item.dest.startswith("_")
1829
+ ]
1830
+ return p
1831
+
1832
+
1833
+ async def dispatch(args, state, binary):
1834
+ import commands
1835
+
1836
+ if args.command == "_broker":
1837
+ import broker
1838
+
1839
+ return await broker.serve(state, args.binary or binary)
1840
+ if args.command == "_supervise":
1841
+ import service
1842
+
1843
+ return await service.supervise(state, args.session)
1844
+ if args.command == "_serve":
1845
+ import service
1846
+
1847
+ return await service.serve(state, args.session, binary)
1848
+ if args.command not in {
1849
+ "run",
1850
+ "start",
1851
+ "attach",
1852
+ "add",
1853
+ "setup",
1854
+ "check",
1855
+ "refresh",
1856
+ }:
1857
+ result = await commands.dispatch(args, state, binary)
1858
+ if result is not None:
1859
+ return result
1860
+ async with file_lock(state / "compatibility.lock"):
1861
+ try:
1862
+ import runtime
1863
+
1864
+ binary, result, runtime_mode = await asyncio.to_thread(
1865
+ runtime.select, binary, state
1866
+ )
1867
+ except (ValueError, OSError, KeyError, subprocess.SubprocessError):
1868
+ raise RouterError(
1869
+ "Installed Codex does not expose a compatible protocol. Ordinary codex still works; the wrapper needs an adapter update."
1870
+ ) from None
1871
+ if args.command == "check":
1872
+ print(
1873
+ f"{result['version']}; {len(labels(state))} accounts enrolled; capability checks passed."
1874
+ )
1875
+ print(
1876
+ "Parallel terminals enabled; 5/10/15-minute retries; reset-aware scheduling."
1877
+ )
1878
+ print("Native runtime: " + runtime_mode)
1879
+ return 0
1880
+ if args.command in {"start", "attach"}:
1881
+ return await commands.dispatch(args, state, binary)
1882
+ if args.command == "refresh":
1883
+ async with file_lock(state / "enrollment.lock", shared=True):
1884
+ pool = Pool(state)
1885
+ from broker import ClientAccount
1886
+
1887
+ accounts = [
1888
+ ClientAccount(label, account_home(state, label), binary, pool)
1889
+ for label in labels(state)
1890
+ ]
1891
+ try:
1892
+
1893
+ async def refresh(account):
1894
+ try:
1895
+ snapshot = await account.usage(force=True)
1896
+ with pool.db() as db:
1897
+ scopes = [
1898
+ r[0]
1899
+ for r in db.execute(
1900
+ "SELECT scope FROM blocks WHERE label=?",
1901
+ (account.label,),
1902
+ )
1903
+ ]
1904
+ for scope in set(scopes + ["*"]):
1905
+ model = None if scope == "*" else scope
1906
+ if limited(snapshot, model):
1907
+ pool.mark(account.label, model, snapshot=snapshot)
1908
+ else:
1909
+ pool.clear(account.label, model)
1910
+ print(
1911
+ f"{account.label}: allowance and reset times refreshed.",
1912
+ flush=True,
1913
+ )
1914
+ except (RouterError, asyncio.TimeoutError):
1915
+ print(
1916
+ f"{account.label}: refresh unavailable; existing reset schedule retained.",
1917
+ flush=True,
1918
+ )
1919
+
1920
+ await asyncio.gather(*(refresh(a) for a in accounts))
1921
+ finally:
1922
+ await asyncio.gather(
1923
+ *(a.close() for a in accounts), return_exceptions=True
1924
+ )
1925
+ pool.close()
1926
+ pool = Pool(state)
1927
+ pool.signal()
1928
+ pool.close()
1929
+ return 0
1930
+ if args.command in {"add", "setup"}:
1931
+ if args.command == "setup":
1932
+ chosen = list(args.labels)
1933
+ if args.count is not None:
1934
+ if args.count < 1:
1935
+ raise RouterError("Account count must be at least 1")
1936
+ chosen.extend(
1937
+ "account" + str(i)
1938
+ for i in range(1, args.count + 1)
1939
+ if "account" + str(i) not in labels(state)
1940
+ )
1941
+ elif not chosen and not labels(state):
1942
+ chosen = ["account1"]
1943
+ if not chosen:
1944
+ print(
1945
+ f"{len(labels(state))} accounts enrolled. Use setup --count N or add LABEL to add more."
1946
+ )
1947
+ return 0
1948
+ for label in dict.fromkeys(chosen):
1949
+ import argparse
1950
+
1951
+ request = argparse.Namespace(
1952
+ action="add", label=label, device_auth=args.device_auth
1953
+ )
1954
+ await commands.manage(request, state, binary)
1955
+ return 0
1956
+ args.action = "add"
1957
+ return await commands.manage(args, state, binary)
1958
+ if args.first:
1959
+ args.first = commands.resolve_label(state, args.first)
1960
+ return await run_pool(args, state, binary)
1961
+
1962
+
1963
+ def selected_binary(args, state):
1964
+ if args.command == "_broker" and args.binary:
1965
+ return args.binary
1966
+ if (
1967
+ args.command == "runtime"
1968
+ and args.action in {"cache", "preflight"}
1969
+ and args.value
1970
+ ):
1971
+ return args.value
1972
+ if args.command == "_serve":
1973
+ job = state / "services" / (args.session + ".job.json")
1974
+ if job.exists():
1975
+ binary = json.loads(job.read_text()).get("binary")
1976
+ if binary:
1977
+ return binary
1978
+ if args.command in {
1979
+ "list",
1980
+ "status",
1981
+ "events",
1982
+ "usage",
1983
+ "config",
1984
+ "pause",
1985
+ "unpause",
1986
+ "retry",
1987
+ "wake",
1988
+ "stop",
1989
+ "switch",
1990
+ "restart",
1991
+ "menubar",
1992
+ "_supervise",
1993
+ }:
1994
+ return None
1995
+ if args.command == "group" and args.action != "start":
1996
+ return None
1997
+ if args.command == "runtime" and args.action in {"list", "use"}:
1998
+ return None
1999
+ if (
2000
+ args.command == "account"
2001
+ and args.action not in {"add", "reauth"}
2002
+ and not getattr(args, "revoke", False)
2003
+ ):
2004
+ return None
2005
+ try:
2006
+ return codex_path()
2007
+ except RouterError:
2008
+ import compatibility
2009
+ import runtime
2010
+
2011
+ for row in reversed(runtime.index(state)["runtimes"]):
2012
+ try:
2013
+ if compatibility.digest(row["binary"]) == row["sha256"]:
2014
+ return row["binary"]
2015
+ except OSError:
2016
+ continue
2017
+ raise
2018
+
2019
+
2020
+ def main():
2021
+ os.umask(0o077)
2022
+ args = parser().parse_args()
2023
+ if args.command in {"run", "attach"}:
2024
+
2025
+ def interrupted(signum, frame):
2026
+ raise KeyboardInterrupt
2027
+
2028
+ signal.signal(signal.SIGTERM, interrupted)
2029
+ signal.signal(signal.SIGHUP, interrupted)
2030
+ if sys.platform != "darwin":
2031
+ raise RouterError(
2032
+ "This build uses macOS Keychain and currently supports macOS only."
2033
+ )
2034
+ state = private_dir(STATE)
2035
+ private_dir(state / "accounts")
2036
+ binary = selected_binary(args, state)
2037
+ if args.command == "list":
2038
+ print("\n".join(labels(state)) or "No accounts enrolled.")
2039
+ return 0
2040
+ return asyncio.run(dispatch(args, state, binary))
2041
+
2042
+
2043
+ if __name__ == "__main__":
2044
+ try:
2045
+ sys.exit(main())
2046
+ except KeyboardInterrupt:
2047
+ sys.exit(130)
2048
+ except (RouterError, ValueError, asyncio.TimeoutError) as error:
2049
+ print(
2050
+ f"relay: {error if isinstance(error, (RouterError, ValueError)) else 'Codex operation timed out.'}",
2051
+ file=sys.stderr,
2052
+ )
2053
+ sys.exit(1)