relay-codex 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -7
- package/docs/commands.md +123 -39
- package/docs/publishing.md +29 -13
- package/docs/troubleshooting.md +15 -3
- package/package.json +1 -1
- package/python/Menu.swift +169 -51
- package/python/commands.py +219 -20
- package/python/compatibility.py +3 -1
- package/python/desktop.py +131 -12
- package/python/execution.py +551 -0
- package/python/launch.py +375 -75
- package/python/pool.py +7 -0
- package/python/router.py +305 -70
- package/python/service.py +216 -142
- package/python/settings.py +1 -0
- package/python/tools.py +233 -0
- package/python/watch.py +74 -0
- package/tests/fake_native.py +4 -2
- package/tests/test_audit.py +320 -0
- package/tests/test_controller.py +3 -5
- package/tests/test_features.py +350 -0
- package/tests/test_launch.py +150 -0
- package/tests/test_services.py +6 -0
package/python/commands.py
CHANGED
|
@@ -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
|
|
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"
|
|
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":
|
|
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':<
|
|
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
|
-
)[:
|
|
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:<
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
package/python/compatibility.py
CHANGED
|
@@ -7,7 +7,7 @@ import subprocess
|
|
|
7
7
|
import tempfile
|
|
8
8
|
import sys
|
|
9
9
|
|
|
10
|
-
ADAPTER =
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
"
|
|
76
|
+
"Install Apple command-line tools to build the menu app: xcode-select --install"
|
|
26
77
|
)
|
|
27
|
-
|
|
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":
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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}
|