relay-codex 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,41 @@ from pool import Pool, file_lock
13
13
  import settings
14
14
 
15
15
 
16
+ def account_identity(state, label):
17
+ from router import account_home
18
+
19
+ home = account_home(state, label)
20
+ for name in ("identity.json", "enrolled"):
21
+ path = home / name
22
+ try:
23
+ value = json.loads(path.read_text())
24
+ if value.get("email"):
25
+ return {"email": value["email"]}
26
+ except (OSError, ValueError):
27
+ pass
28
+ return {}
29
+
30
+
31
+ def identify_accounts(state):
32
+ from router import labels, account_home, read_keychain, RouterError
33
+
34
+ updated = 0
35
+ for label in labels(state):
36
+ if account_identity(state, label).get("email"):
37
+ continue
38
+ try:
39
+ identity = read_keychain(account_home(state, label), include_identity=True)
40
+ except RouterError:
41
+ continue
42
+ if identity.get("email"):
43
+ settings.atomic_json(
44
+ account_home(state, label) / "identity.json",
45
+ {"email": identity["email"]},
46
+ )
47
+ updated += 1
48
+ return updated
49
+
50
+
16
51
  def resolve_label(state, value):
17
52
  from router import labels, RouterError
18
53
 
@@ -21,7 +56,10 @@ def resolve_label(state, value):
21
56
  pool = Pool(state)
22
57
  try:
23
58
  matching = [
24
- label for label in labels(state) if pool.options(label).get("name") == value
59
+ label
60
+ for label in labels(state)
61
+ if pool.options(label).get("name") == value
62
+ or account_identity(state, label).get("email") == value
25
63
  ]
26
64
  if len(matching) == 1:
27
65
  return matching[0]
@@ -47,12 +85,14 @@ def snapshot(state, include_inactive=False):
47
85
  ]
48
86
  for label in labels(state):
49
87
  options = pool.options(label)
88
+ identity = account_identity(state, label)
50
89
  usage = pool.cached(label, 86400)
51
90
  windows = (usage or {}).get("rateLimits", {})
52
91
  accounts.append(
53
92
  {
54
93
  "id": label,
55
- "name": options.get("name", label),
94
+ "name": options.get("name") or identity.get("email") or label,
95
+ "email": identity.get("email"),
56
96
  "enabled": options.get("enabled", True),
57
97
  "priority": options.get("priority", 0),
58
98
  "max_sessions": options.get("max_sessions", 0),
@@ -60,13 +100,27 @@ def snapshot(state, include_inactive=False):
60
100
  "primary": windows.get("primary"),
61
101
  "secondary": windows.get("secondary"),
62
102
  "usage_known": usage is not None,
103
+ "usage_checked_at": pool.checked_at(label),
63
104
  "maintenance": options.get("maintenance", False),
64
- "next_check": max(
105
+ "next_check": min(
65
106
  (b["next_check"] for b in blocks if b["account"] == label),
66
107
  default=None,
67
108
  ),
68
109
  }
69
110
  )
111
+ with pool.db() as db:
112
+ for session in sessions:
113
+ saved_intent = db.execute(
114
+ "SELECT details FROM intents WHERE owner=? AND thread=?",
115
+ (session["id"], session.get("thread", "")),
116
+ ).fetchone()
117
+ intent = json.loads(saved_intent[0]) if saved_intent else {}
118
+ session["reason"] = intent.get("review_reason") or intent.get("kind")
119
+ session["attempt"] = (intent.get("episode") or {}).get("attempt")
120
+ session["review_required"] = intent.get("review_required", False)
121
+ if session["review_required"]:
122
+ session["state"] = "needs_attention"
123
+ session.setdefault("recovery_enabled", True)
70
124
  active = {s["id"] for s in sessions}
71
125
  with pool.db() as db:
72
126
  saved = {
@@ -119,13 +173,13 @@ def render(value):
119
173
  lines = [
120
174
  f"Relay · {len(value['sessions'])} sessions · {len(value['accounts'])} accounts",
121
175
  "",
122
- f"{'ACCOUNT':<23}{'STATE':<13}{'SHORT LEFT':<13}{'WEEK LEFT':<13}{'SESSIONS':<10}NEXT CHECK",
176
+ f"{'ACCOUNT':<39}{'STATE':<13}{'SHORT LEFT':<13}{'WEEK LEFT':<13}{'SESSIONS':<10}NEXT CHECK",
123
177
  ]
124
178
  for account in value["accounts"]:
125
179
  label = (
126
180
  account["name"]
127
181
  + (" [" + account["id"] + "]" if account["name"] != account["id"] else "")
128
- )[:22]
182
+ )[:38]
129
183
  state = (
130
184
  "maintenance"
131
185
  if account["maintenance"]
@@ -139,7 +193,7 @@ def render(value):
139
193
  else "—"
140
194
  )
141
195
  lines.append(
142
- f"{label:<23}{state:<13}{percent(account['primary']):<13}{percent(account['secondary']):<13}{account['sessions']:<10}{next_at}"
196
+ f"{label:<39}{state:<13}{percent(account['primary']):<13}{percent(account['secondary']):<13}{account['sessions']:<10}{next_at}"
143
197
  )
144
198
  if value["sessions"]:
145
199
  lines += [
@@ -163,17 +217,30 @@ def render(value):
163
217
 
164
218
 
165
219
  async def status(args, state):
166
- while True:
167
- value = snapshot(state, getattr(args, "all", False))
168
- if args.json:
169
- print(json.dumps(value, indent=None if args.watch else 2), flush=True)
170
- else:
171
- if args.watch and sys.stdout.isatty():
172
- print("\033[2J\033[H", end="")
173
- print(render(value), flush=True)
174
- if not args.watch:
175
- return 0
176
- await asyncio.sleep(args.interval)
220
+ from watch import LocalChanges, fingerprint
221
+
222
+ previous = None
223
+ watcher = LocalChanges(state) if args.watch else None
224
+ try:
225
+ while True:
226
+ value = snapshot(state, getattr(args, "all", False))
227
+ current = fingerprint(value)
228
+ if current != previous:
229
+ if args.json:
230
+ print(
231
+ json.dumps(value, indent=None if args.watch else 2), flush=True
232
+ )
233
+ else:
234
+ if args.watch and sys.stdout.isatty():
235
+ print("\033[2J\033[H", end="")
236
+ print(render(value), flush=True)
237
+ previous = current
238
+ if not args.watch:
239
+ return 0
240
+ await watcher.changed(max(1, args.interval))
241
+ finally:
242
+ if watcher:
243
+ watcher.close()
177
244
 
178
245
 
179
246
  async def control(args, state):
@@ -204,7 +271,13 @@ async def control(args, state):
204
271
  for session in matching:
205
272
  op = "resume" if args.command == "unpause" else args.command
206
273
  ident = pool.command(session["id"], op, params)
207
- print(f"Queued {op} for {session['id'][:10]} (command {ident}).")
274
+ if getattr(args, "wait", False):
275
+ from service import wait_control
276
+
277
+ await wait_control(pool, ident, timeout=args.timeout)
278
+ print(f"{op.capitalize()} applied to {session['id'][:10]}.")
279
+ else:
280
+ print(f"Queued {op} for {session['id'][:10]} (command {ident}).")
208
281
  return 0
209
282
  finally:
210
283
  pool.close()
@@ -473,6 +546,37 @@ async def group(args, state, binary):
473
546
 
474
547
 
475
548
  def add_parsers(sub):
549
+ preview = sub.add_parser(
550
+ "explain", help="Preview a native Codex launch without starting a task"
551
+ )
552
+ preview.add_argument("codex_args", nargs="...")
553
+ diagnostics = sub.add_parser(
554
+ "diagnostics",
555
+ help="Export redacted operational facts without credentials, prompts, or project paths",
556
+ )
557
+ diagnostics.add_argument("output")
558
+ clean = sub.add_parser(
559
+ "clean", help="Preview safe cleanup of unreferenced old runtime files"
560
+ )
561
+ clean.add_argument("--apply", action="store_true")
562
+ clean.add_argument("--older-than", type=int, default=30, dest="days")
563
+ clean.add_argument(
564
+ "--jobs",
565
+ action="store_true",
566
+ help="Also remove old stopped jobs; saved workspace references are retained",
567
+ )
568
+ workspaces = sub.add_parser(
569
+ "workspace",
570
+ help="Save and restore the same conversations across several terminals",
571
+ )
572
+ ws = workspaces.add_subparsers(dest="action", required=True)
573
+ ws.add_parser("list")
574
+ save_ws = ws.add_parser("save")
575
+ save_ws.add_argument("name")
576
+ save_ws.add_argument("sessions", nargs="*")
577
+ for action in ("restore", "remove"):
578
+ child = ws.add_parser(action)
579
+ child.add_argument("name")
476
580
  status = sub.add_parser(
477
581
  "status", help="Account and session dashboard without live network requests"
478
582
  )
@@ -487,15 +591,28 @@ def add_parsers(sub):
487
591
  "restart", help="Restart a stopped session from saved history"
488
592
  )
489
593
  restart.add_argument("session")
490
- status.add_argument("--interval", type=float, default=2)
594
+ restart.add_argument("--if-stopped", action="store_true")
595
+ status.add_argument(
596
+ "--interval",
597
+ type=float,
598
+ default=30,
599
+ help="Maximum local filesystem wait; output changes only when data changes",
600
+ )
491
601
  for command in ["pause", "unpause", "retry", "wake", "stop", "switch"]:
492
602
  p = sub.add_parser(command, help=command + " one live session or all")
493
603
  p.add_argument("target", nargs="?")
494
604
  p.add_argument("--thread")
605
+ p.add_argument(
606
+ "--wait",
607
+ action="store_true",
608
+ help="Wait for the worker to confirm the action",
609
+ )
610
+ p.add_argument("--timeout", type=float, default=15)
495
611
  if command == "switch":
496
612
  p.add_argument("--account", required=True)
497
613
  account = sub.add_parser("account", help="Manage any number of enrolled accounts")
498
614
  child = account.add_subparsers(dest="action", required=True)
615
+ child.add_parser("identify", help="Fill in account emails from saved local logins")
499
616
  for action in [
500
617
  "add",
501
618
  "reauth",
@@ -544,10 +661,21 @@ def add_parsers(sub):
544
661
  )
545
662
  runtime.add_argument("action", choices=["list", "cache", "use", "preflight"])
546
663
  runtime.add_argument("value", nargs="?")
664
+ startup = sub.add_parser(
665
+ "startup", help="Choose background sessions to recover at Mac login"
666
+ )
667
+ startup.add_argument("action", choices=["list", "enable", "disable"])
668
+ startup.add_argument("session", nargs="?")
547
669
  menu = sub.add_parser(
548
670
  "menubar", help="Build or launch the optional native menu app"
549
671
  )
550
672
  menu.add_argument("--build-only", action="store_true")
673
+ menu.add_argument(
674
+ "--no-startup", action="store_true", help="Disable menu login startup"
675
+ )
676
+ menu.add_argument(
677
+ "--configure-only", action="store_true", help="Only update the login setting"
678
+ )
551
679
  start = sub.add_parser(
552
680
  "start", help="Start a supervised background task; attach later"
553
681
  )
@@ -585,9 +713,65 @@ def add_parsers(sub):
585
713
  async def dispatch(args, state, binary):
586
714
  from router import RouterError
587
715
 
716
+ if args.command in {"explain", "diagnostics", "clean", "workspace"}:
717
+ import tools
718
+
719
+ if args.command == "explain":
720
+ result = tools.explain(args.codex_args, os.getcwd(), binary)
721
+ elif args.command == "diagnostics":
722
+ result = {"written": tools.diagnostics(state, args.output)}
723
+ elif args.command == "clean":
724
+ if args.days < 1:
725
+ raise ValueError("Retention must be at least one day")
726
+ from pool import file_lock
727
+
728
+ async with file_lock(state / "cleanup.lock"):
729
+ if args.apply:
730
+ try:
731
+ async with asyncio.timeout(2):
732
+ async with file_lock(state / "metadata.lock"):
733
+ result = tools.cleanup(
734
+ state, apply=True, days=args.days, jobs=args.jobs
735
+ )
736
+ except asyncio.TimeoutError:
737
+ raise RouterError(
738
+ "Stop supervised sessions before applying cleanup. The dry run works while sessions are active."
739
+ ) from None
740
+ else:
741
+ result = tools.cleanup(
742
+ state, apply=args.apply, days=args.days, jobs=args.jobs
743
+ )
744
+ else:
745
+ result = tools.workspace(
746
+ state,
747
+ args.action,
748
+ getattr(args, "name", None),
749
+ getattr(args, "sessions", None),
750
+ binary,
751
+ )
752
+ print(json.dumps(result, indent=2))
753
+ return 0
754
+ if args.command == "startup":
755
+ import desktop
756
+
757
+ if args.action != "list" and not args.session:
758
+ raise ValueError("Choose a saved session ID")
759
+ print(
760
+ json.dumps(desktop.job_startup(state, args.action, args.session), indent=2)
761
+ )
762
+ return 0
588
763
  if args.command == "restart":
589
764
  import service
590
765
 
766
+ if args.if_stopped:
767
+ pool = Pool(state)
768
+ try:
769
+ ident = service.resolve(state, args.session)
770
+ if any(s["id"] == ident for s in pool.detail()):
771
+ return 0
772
+ finally:
773
+ pool.close()
774
+
591
775
  async with file_lock(state / "restart.lock"):
592
776
  ident = service.restart(state, args.session)
593
777
  print("Restarted " + ident)
@@ -599,6 +783,11 @@ async def dispatch(args, state, binary):
599
783
  if args.command in {"pause", "unpause", "retry", "wake", "stop", "switch"}:
600
784
  return await control(args, state)
601
785
  if args.command == "account":
786
+ if args.action == "identify":
787
+ print(
788
+ f"Identified {await asyncio.to_thread(identify_accounts, state)} saved accounts."
789
+ )
790
+ return 0
602
791
  return await manage(args, state, binary)
603
792
  if args.command == "config":
604
793
  if args.key:
@@ -670,7 +859,17 @@ async def dispatch(args, state, binary):
670
859
  if args.command == "menubar":
671
860
  import desktop
672
861
 
673
- app = await asyncio.to_thread(desktop.menubar, state, None, args.build_only)
862
+ if args.no_startup or args.configure_only:
863
+ desktop.login_item(state, enabled=not args.no_startup)
864
+ if args.configure_only:
865
+ print(
866
+ "Menu login startup "
867
+ + ("disabled" if args.no_startup else "enabled")
868
+ )
869
+ return 0
870
+ app = await asyncio.to_thread(
871
+ desktop.menubar, state, None, args.build_only, not args.no_startup
872
+ )
674
873
  print(str(app))
675
874
  return 0
676
875
  if args.command == "group":
@@ -7,7 +7,7 @@ import subprocess
7
7
  import tempfile
8
8
  import sys
9
9
 
10
- ADAPTER = 4
10
+ ADAPTER = 5
11
11
 
12
12
 
13
13
  def executable(path):
@@ -130,6 +130,8 @@ def validate_schema(root):
130
130
  "threadId": ident,
131
131
  "sandbox": "danger-full-access",
132
132
  "approvalPolicy": "never",
133
+ "approvalsReviewer": "user",
134
+ "runtimeWorkspaceRoots": ["/tmp/relay-probe"],
133
135
  },
134
136
  },
135
137
  {
package/python/desktop.py CHANGED
@@ -1,39 +1,99 @@
1
- """Optional macOS menu app."""
1
+ """Native menu app and per-user login startup. No account credentials in launch files."""
2
2
 
3
3
  from pathlib import Path
4
+ import hashlib
5
+ import os
4
6
  import plistlib
5
7
  import shutil
6
8
  import subprocess
7
9
  from processes import spawn
8
10
 
11
+ LABEL = "local.relay.menu"
9
12
 
10
- def menubar(state, launcher=None, build_only=False):
13
+
14
+ def login_item(state, app=None, enabled=True, home=None):
15
+ home = Path(home or Path.home())
16
+ path = home / "Library/LaunchAgents" / (LABEL + ".plist")
17
+ domain = f"gui/{os.getuid()}"
18
+ if not enabled:
19
+ # Removing login startup should not close the menu currently in use.
20
+ path.unlink(missing_ok=True)
21
+ return path
22
+ if path.exists():
23
+ subprocess.run(
24
+ ["/bin/launchctl", "bootout", domain, str(path)], capture_output=True
25
+ )
26
+ if app is None:
27
+ app = Path(state) / "Relay Menu.app"
28
+ path.parent.mkdir(parents=True, exist_ok=True)
29
+ payload = {
30
+ "Label": LABEL,
31
+ "ProgramArguments": [str(Path(app) / "Contents/MacOS/RelayMenu")],
32
+ "RunAtLoad": True,
33
+ "KeepAlive": {"SuccessfulExit": False},
34
+ "ProcessType": "Interactive",
35
+ "EnvironmentVariables": {
36
+ "PATH": os.environ.get(
37
+ "PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
38
+ ),
39
+ "RELAY_STATE": str(Path(state).resolve()),
40
+ },
41
+ "ThrottleInterval": 30,
42
+ }
43
+ with path.open("wb") as stream:
44
+ plistlib.dump(payload, stream)
45
+ path.chmod(0o600)
46
+ result = subprocess.run(
47
+ ["/bin/launchctl", "bootstrap", domain, str(path)], capture_output=True
48
+ )
49
+ if result.returncode:
50
+ raise ValueError(
51
+ "Login startup was saved, but launchctl could not load it. Run relay menubar again after signing in to macOS."
52
+ )
53
+ return path
54
+
55
+
56
+ def menubar(state, launcher=None, build_only=False, startup=True):
11
57
  from router import VERSION
12
58
 
13
59
  launcher = launcher or shutil.which("relay")
14
60
  if not launcher:
15
61
  raise ValueError("The relay command is not on PATH")
62
+ launcher = str(Path(launcher).absolute())
16
63
  source = Path(__file__).with_name("Menu.swift")
17
64
  app = state / "Relay Menu.app"
18
65
  contents = app / "Contents"
19
66
  binary = contents / "MacOS" / "RelayMenu"
20
- binary.parent.mkdir(parents=True, exist_ok=True)
21
- if not binary.exists() or source.stat().st_mtime > binary.stat().st_mtime:
67
+ stamp = state / "menu-build.txt"
68
+ (contents / "relay-build.txt").unlink(missing_ok=True)
69
+ identity = hashlib.sha256(
70
+ source.read_bytes() + launcher.encode() + VERSION.encode()
71
+ ).hexdigest()
72
+ if not binary.exists() or not stamp.exists() or stamp.read_text() != identity:
22
73
  compiler = shutil.which("swiftc")
23
74
  if not compiler:
24
75
  raise ValueError(
25
- "Swift compiler is unavailable; install Apple command-line tools to build the optional menu app"
76
+ "Install Apple command-line tools to build the menu app: xcode-select --install"
26
77
  )
27
- subprocess.run([compiler, str(source), "-o", str(binary)], check=True)
78
+ binary.parent.mkdir(parents=True, exist_ok=True)
79
+ temporary = binary.with_suffix(".new")
80
+ subprocess.run([compiler, str(source), "-o", str(temporary)], check=True)
81
+ temporary.replace(binary)
28
82
  with (contents / "Info.plist").open("wb") as stream:
29
83
  plistlib.dump(
30
84
  {
31
- "CFBundleIdentifier": "local.relay.menu",
85
+ "CFBundleIdentifier": LABEL,
32
86
  "CFBundleName": "Relay",
33
87
  "CFBundleExecutable": "RelayMenu",
34
88
  "CFBundleVersion": VERSION,
35
89
  "RelayCommand": launcher,
90
+ "RelayState": str(state),
91
+ "RelayNodeDirectory": str(
92
+ Path(shutil.which("node") or "/usr/bin/node").parent
93
+ ),
94
+ "CFBundlePackageType": "APPL",
36
95
  "LSUIElement": True,
96
+ "NSHighResolutionCapable": True,
37
97
  },
38
98
  stream,
39
99
  )
@@ -42,11 +102,70 @@ def menubar(state, launcher=None, build_only=False):
42
102
  check=True,
43
103
  capture_output=True,
44
104
  )
105
+ stamp.write_text(identity)
45
106
  if not build_only:
46
- spawn(
47
- ["/usr/bin/open", "-n", str(app), "--args", str(launcher)],
48
- stdin=subprocess.DEVNULL,
49
- stdout=subprocess.DEVNULL,
50
- stderr=subprocess.DEVNULL,
107
+ # Replace only this user's previous Relay menu, preserving all Codex tasks.
108
+ subprocess.run(
109
+ ["/usr/bin/pkill", "-u", str(os.getuid()), "-x", "RelayMenu"],
110
+ capture_output=True,
51
111
  )
112
+ if startup:
113
+ login_item(state, app, enabled=True)
114
+ else:
115
+ spawn(
116
+ ["/usr/bin/open", str(app)],
117
+ stdin=subprocess.DEVNULL,
118
+ stdout=subprocess.DEVNULL,
119
+ stderr=subprocess.DEVNULL,
120
+ )
52
121
  return app
122
+
123
+
124
+ def job_startup(state, action, session=None, launcher=None, home=None):
125
+ """Opt-in recovery of explicitly selected background jobs at Mac login."""
126
+ from service import resolve
127
+
128
+ home = Path(home or Path.home())
129
+ root = home / "Library/LaunchAgents"
130
+ if action == "list":
131
+ return [
132
+ p.stem.removeprefix("local.relay.job.")
133
+ for p in root.glob("local.relay.job.*.plist")
134
+ ]
135
+ ident = resolve(state, session)
136
+ path = root / ("local.relay.job." + ident + ".plist")
137
+ domain = f"gui/{os.getuid()}"
138
+ if path.exists():
139
+ subprocess.run(
140
+ ["/bin/launchctl", "bootout", domain, str(path)], capture_output=True
141
+ )
142
+ if action == "disable":
143
+ path.unlink(missing_ok=True)
144
+ return {"session": ident, "startup": False}
145
+ launcher = launcher or shutil.which("relay")
146
+ if not launcher:
147
+ raise ValueError("The relay command is not on PATH")
148
+ root.mkdir(parents=True, exist_ok=True)
149
+ with path.open("wb") as stream:
150
+ plistlib.dump(
151
+ {
152
+ "Label": "local.relay.job." + ident,
153
+ "ProgramArguments": [
154
+ str(Path(launcher).absolute()),
155
+ "restart",
156
+ ident,
157
+ "--if-stopped",
158
+ ],
159
+ "RunAtLoad": True,
160
+ "EnvironmentVariables": {
161
+ "PATH": os.environ.get(
162
+ "PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
163
+ ),
164
+ "RELAY_STATE": str(Path(state).resolve()),
165
+ },
166
+ },
167
+ stream,
168
+ )
169
+ path.chmod(0o600)
170
+ # Do not run a stopped task immediately; load it at the next login.
171
+ return {"session": ident, "startup": True}