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.
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/assets/relay.svg +15 -0
- package/bin/relay.cjs +148 -0
- package/docs/commands.md +92 -0
- package/docs/publishing.md +56 -0
- package/docs/troubleshooting.md +37 -0
- package/package.json +47 -4
- package/python/Menu.swift +70 -0
- package/python/broker.py +297 -0
- package/python/commands.py +701 -0
- package/python/compatibility.py +225 -0
- package/python/desktop.py +52 -0
- package/python/launch.py +117 -0
- package/python/metadata.py +82 -0
- package/python/notifications.py +36 -0
- package/python/pool.py +579 -0
- package/python/probe_native.py +91 -0
- package/python/processes.py +12 -0
- package/python/requirements.lock +6 -0
- package/python/requirements.txt +2 -0
- package/python/router.py +2053 -0
- package/python/runtime.py +97 -0
- package/python/service.py +663 -0
- package/python/settings.py +109 -0
- package/tests/fake_native.py +238 -0
- package/tests/test_capacity.py +217 -0
- package/tests/test_controller.py +519 -0
- package/tests/test_pool.py +446 -0
- package/tests/test_relay.py +173 -0
- package/tests/test_router.py +314 -0
- package/tests/test_services.py +473 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
"""Local management commands. Status and diagnostics never return credentials."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from pool import Pool, file_lock
|
|
13
|
+
import settings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def resolve_label(state, value):
|
|
17
|
+
from router import labels, RouterError
|
|
18
|
+
|
|
19
|
+
if value in labels(state):
|
|
20
|
+
return value
|
|
21
|
+
pool = Pool(state)
|
|
22
|
+
try:
|
|
23
|
+
matching = [
|
|
24
|
+
label for label in labels(state) if pool.options(label).get("name") == value
|
|
25
|
+
]
|
|
26
|
+
if len(matching) == 1:
|
|
27
|
+
return matching[0]
|
|
28
|
+
finally:
|
|
29
|
+
pool.close()
|
|
30
|
+
raise RouterError("Unknown or ambiguous account name")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def snapshot(state, include_inactive=False):
|
|
34
|
+
from router import labels, VERSION
|
|
35
|
+
|
|
36
|
+
pool = Pool(state)
|
|
37
|
+
try:
|
|
38
|
+
sessions = pool.detail()
|
|
39
|
+
accounts = []
|
|
40
|
+
with pool.db() as db:
|
|
41
|
+
blocks = [
|
|
42
|
+
dict(account=r[0], scope=r[1], next_check=r[2], reason=r[3])
|
|
43
|
+
for r in db.execute(
|
|
44
|
+
"SELECT label,scope,until,reason FROM blocks WHERE until>? ORDER BY until",
|
|
45
|
+
(time.time(),),
|
|
46
|
+
)
|
|
47
|
+
]
|
|
48
|
+
for label in labels(state):
|
|
49
|
+
options = pool.options(label)
|
|
50
|
+
usage = pool.cached(label, 86400)
|
|
51
|
+
windows = (usage or {}).get("rateLimits", {})
|
|
52
|
+
accounts.append(
|
|
53
|
+
{
|
|
54
|
+
"id": label,
|
|
55
|
+
"name": options.get("name", label),
|
|
56
|
+
"enabled": options.get("enabled", True),
|
|
57
|
+
"priority": options.get("priority", 0),
|
|
58
|
+
"max_sessions": options.get("max_sessions", 0),
|
|
59
|
+
"sessions": sum(s["account"] == label for s in sessions),
|
|
60
|
+
"primary": windows.get("primary"),
|
|
61
|
+
"secondary": windows.get("secondary"),
|
|
62
|
+
"usage_known": usage is not None,
|
|
63
|
+
"maintenance": options.get("maintenance", False),
|
|
64
|
+
"next_check": max(
|
|
65
|
+
(b["next_check"] for b in blocks if b["account"] == label),
|
|
66
|
+
default=None,
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
active = {s["id"] for s in sessions}
|
|
71
|
+
with pool.db() as db:
|
|
72
|
+
saved = {
|
|
73
|
+
r[0]: json.loads(r[1])
|
|
74
|
+
for r in db.execute("SELECT id,details FROM session_details")
|
|
75
|
+
}
|
|
76
|
+
for path in (state / "services").glob("*.job.json"):
|
|
77
|
+
try:
|
|
78
|
+
job = json.loads(path.read_text())
|
|
79
|
+
except (OSError, ValueError):
|
|
80
|
+
continue
|
|
81
|
+
ident = job.get("id", path.name.split(".")[0])
|
|
82
|
+
if ident in active:
|
|
83
|
+
continue
|
|
84
|
+
if not include_inactive and job.get("state") != "needs_attention":
|
|
85
|
+
continue
|
|
86
|
+
prior = saved.get(ident, {})
|
|
87
|
+
sessions.append(
|
|
88
|
+
{
|
|
89
|
+
"id": ident,
|
|
90
|
+
"pid": None,
|
|
91
|
+
"name": job.get("name", ident[:10]),
|
|
92
|
+
"cwd": job.get("cwd"),
|
|
93
|
+
"thread": job.get("resume"),
|
|
94
|
+
"account": prior.get("account") or job.get("first") or "",
|
|
95
|
+
"model": prior.get("model"),
|
|
96
|
+
"state": job.get("state", "unknown"),
|
|
97
|
+
"paused": prior.get("paused", False),
|
|
98
|
+
"last_error": job.get("last_error"),
|
|
99
|
+
"running": False,
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
return {
|
|
103
|
+
"version": VERSION,
|
|
104
|
+
"at": time.time(),
|
|
105
|
+
"sessions": sessions,
|
|
106
|
+
"accounts": accounts,
|
|
107
|
+
"blocks": blocks,
|
|
108
|
+
}
|
|
109
|
+
finally:
|
|
110
|
+
pool.close()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def render(value):
|
|
114
|
+
def percent(window):
|
|
115
|
+
return (
|
|
116
|
+
"?" if not window else str(max(0, 100 - window.get("usedPercent", 0))) + "%"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
lines = [
|
|
120
|
+
f"Relay · {len(value['sessions'])} sessions · {len(value['accounts'])} accounts",
|
|
121
|
+
"",
|
|
122
|
+
f"{'ACCOUNT':<23}{'STATE':<13}{'SHORT LEFT':<13}{'WEEK LEFT':<13}{'SESSIONS':<10}NEXT CHECK",
|
|
123
|
+
]
|
|
124
|
+
for account in value["accounts"]:
|
|
125
|
+
label = (
|
|
126
|
+
account["name"]
|
|
127
|
+
+ (" [" + account["id"] + "]" if account["name"] != account["id"] else "")
|
|
128
|
+
)[:22]
|
|
129
|
+
state = (
|
|
130
|
+
"maintenance"
|
|
131
|
+
if account["maintenance"]
|
|
132
|
+
else "enabled"
|
|
133
|
+
if account["enabled"]
|
|
134
|
+
else "disabled"
|
|
135
|
+
)
|
|
136
|
+
next_at = (
|
|
137
|
+
time.strftime("%m-%d %H:%M:%S", time.localtime(account["next_check"]))
|
|
138
|
+
if account["next_check"]
|
|
139
|
+
else "—"
|
|
140
|
+
)
|
|
141
|
+
lines.append(
|
|
142
|
+
f"{label:<23}{state:<13}{percent(account['primary']):<13}{percent(account['secondary']):<13}{account['sessions']:<10}{next_at}"
|
|
143
|
+
)
|
|
144
|
+
if value["sessions"]:
|
|
145
|
+
lines += [
|
|
146
|
+
"",
|
|
147
|
+
f"{'SESSION':<12}{'ACCOUNT':<14}{'STATE':<23}{'MODEL':<23}PROJECT / NAME",
|
|
148
|
+
]
|
|
149
|
+
for row in value["sessions"]:
|
|
150
|
+
lines.append(
|
|
151
|
+
f"{row['id'][:10]:<12}{row.get('account', ''):<14}{str(row.get('state', 'unknown'))[:22]:<23}{str(row.get('model') or '?')[:22]:<23}{row.get('cwd') or row.get('name') or ''}"
|
|
152
|
+
)
|
|
153
|
+
if row.get("next_wake"):
|
|
154
|
+
lines.append(
|
|
155
|
+
" next action: "
|
|
156
|
+
+ time.strftime("%m-%d %H:%M:%S", time.localtime(row["next_wake"]))
|
|
157
|
+
)
|
|
158
|
+
lines += [
|
|
159
|
+
"",
|
|
160
|
+
"Percentages are last observed allowance; native account limits remain authoritative.",
|
|
161
|
+
]
|
|
162
|
+
return "\n".join(lines)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
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)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def control(args, state):
|
|
180
|
+
from router import RouterError
|
|
181
|
+
|
|
182
|
+
pool = Pool(state)
|
|
183
|
+
try:
|
|
184
|
+
sessions = pool.detail()
|
|
185
|
+
if args.target == "all":
|
|
186
|
+
matching = sessions
|
|
187
|
+
elif args.target:
|
|
188
|
+
matching = [
|
|
189
|
+
s
|
|
190
|
+
for s in sessions
|
|
191
|
+
if s["id"].startswith(args.target) or s.get("name") == args.target
|
|
192
|
+
]
|
|
193
|
+
else:
|
|
194
|
+
matching = sessions if len(sessions) == 1 else []
|
|
195
|
+
if not matching or args.target != "all" and len(matching) != 1:
|
|
196
|
+
raise RouterError(
|
|
197
|
+
"Choose a unique session ID/name from status, or use all."
|
|
198
|
+
)
|
|
199
|
+
params = {}
|
|
200
|
+
if args.command == "switch":
|
|
201
|
+
params["account"] = resolve_label(state, args.account)
|
|
202
|
+
if getattr(args, "thread", None):
|
|
203
|
+
params["thread"] = args.thread
|
|
204
|
+
for session in matching:
|
|
205
|
+
op = "resume" if args.command == "unpause" else args.command
|
|
206
|
+
ident = pool.command(session["id"], op, params)
|
|
207
|
+
print(f"Queued {op} for {session['id'][:10]} (command {ident}).")
|
|
208
|
+
return 0
|
|
209
|
+
finally:
|
|
210
|
+
pool.close()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def manage(args, state, binary):
|
|
214
|
+
async with file_lock(state / "enrollment.lock"):
|
|
215
|
+
return await _manage(args, state, binary)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def _manage(args, state, binary):
|
|
219
|
+
from router import labels, account_home, add_account, RouterError, clean_env
|
|
220
|
+
import broker
|
|
221
|
+
|
|
222
|
+
pool = Pool(state)
|
|
223
|
+
try:
|
|
224
|
+
label = resolve_label(state, args.label) if args.action != "add" else args.label
|
|
225
|
+
if args.action in {"enable", "disable", "priority", "cap", "rename", "models"}:
|
|
226
|
+
if args.action in {"enable", "disable"}:
|
|
227
|
+
pool.set_options(label, enabled=args.action == "enable")
|
|
228
|
+
elif args.action == "priority":
|
|
229
|
+
pool.set_options(label, priority=args.value)
|
|
230
|
+
elif args.action == "cap":
|
|
231
|
+
if args.value < 0:
|
|
232
|
+
raise RouterError(
|
|
233
|
+
"Session cap must be nonnegative; 0 is unlimited."
|
|
234
|
+
)
|
|
235
|
+
pool.set_options(label, max_sessions=args.value)
|
|
236
|
+
elif args.action == "models":
|
|
237
|
+
pool.set_options(label, models=args.values)
|
|
238
|
+
else:
|
|
239
|
+
if not args.value.strip():
|
|
240
|
+
raise RouterError("Choose a nonempty name")
|
|
241
|
+
if any(
|
|
242
|
+
pool.options(other).get("name", other) == args.value
|
|
243
|
+
for other in labels(state)
|
|
244
|
+
if other != label
|
|
245
|
+
):
|
|
246
|
+
raise RouterError("That account name is already used")
|
|
247
|
+
pool.set_options(label, name=args.value)
|
|
248
|
+
print(f"{label}: {args.action} updated.")
|
|
249
|
+
return 0
|
|
250
|
+
home = account_home(state, label)
|
|
251
|
+
home.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
252
|
+
if not pool.begin_maintenance(label):
|
|
253
|
+
raise RouterError(
|
|
254
|
+
"This account is assigned to a live session. Switch those sessions to another account first; other accounts can keep running."
|
|
255
|
+
)
|
|
256
|
+
try:
|
|
257
|
+
with contextlib.suppress(Exception):
|
|
258
|
+
await broker.exchange(state, {"op": "invalidate", "label": label}, 15)
|
|
259
|
+
async with file_lock(home / "credential.lock"):
|
|
260
|
+
if args.action in {"add", "reauth"}:
|
|
261
|
+
args.label = label
|
|
262
|
+
await asyncio.to_thread(add_account, args, state, binary)
|
|
263
|
+
elif args.action == "remove":
|
|
264
|
+
if args.revoke:
|
|
265
|
+
result = await asyncio.create_subprocess_exec(
|
|
266
|
+
binary,
|
|
267
|
+
"logout",
|
|
268
|
+
env=clean_env(home),
|
|
269
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
270
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
271
|
+
)
|
|
272
|
+
if await result.wait():
|
|
273
|
+
raise RouterError(
|
|
274
|
+
"Native sign-out failed; account was retained."
|
|
275
|
+
)
|
|
276
|
+
marker = home / "enrolled"
|
|
277
|
+
if marker.exists():
|
|
278
|
+
marker.rename(home / "enrolled.removed")
|
|
279
|
+
pool.reset_identity(label)
|
|
280
|
+
pool.set_options(label, enabled=False)
|
|
281
|
+
print(
|
|
282
|
+
f"{label}: removed from routing. "
|
|
283
|
+
+ (
|
|
284
|
+
"Native login revoked."
|
|
285
|
+
if args.revoke
|
|
286
|
+
else "Keychain login retained; use add to enroll again."
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
finally:
|
|
290
|
+
pool.set_options(label, maintenance=False)
|
|
291
|
+
pool.signal()
|
|
292
|
+
return 0
|
|
293
|
+
finally:
|
|
294
|
+
pool.close()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def history(state, label=None):
|
|
298
|
+
pool = Pool(state)
|
|
299
|
+
try:
|
|
300
|
+
with pool.db() as db:
|
|
301
|
+
rows = db.execute(
|
|
302
|
+
"SELECT at,label,snapshot FROM history WHERE (? IS NULL OR label=?) ORDER BY at DESC LIMIT 1000",
|
|
303
|
+
(label, label),
|
|
304
|
+
).fetchall()
|
|
305
|
+
grouped = {}
|
|
306
|
+
for at, name, raw in rows:
|
|
307
|
+
grouped.setdefault(name, []).append((at, json.loads(raw)))
|
|
308
|
+
output = []
|
|
309
|
+
for name, points in grouped.items():
|
|
310
|
+
latest = points[0][1].get("rateLimits", {})
|
|
311
|
+
item = {
|
|
312
|
+
"account": name,
|
|
313
|
+
"samples": len(points),
|
|
314
|
+
"at": points[0][0],
|
|
315
|
+
"primary": latest.get("primary"),
|
|
316
|
+
"secondary": latest.get("secondary"),
|
|
317
|
+
"forecast": None,
|
|
318
|
+
}
|
|
319
|
+
if len(points) > 1:
|
|
320
|
+
first_time, first = points[-1]
|
|
321
|
+
last_time, last = points[0]
|
|
322
|
+
a = first.get("rateLimits", {}).get("primary") or {}
|
|
323
|
+
b = last.get("rateLimits", {}).get("primary") or {}
|
|
324
|
+
elapsed = last_time - first_time
|
|
325
|
+
delta = b.get("usedPercent", 0) - a.get("usedPercent", 0)
|
|
326
|
+
if (
|
|
327
|
+
elapsed >= 60
|
|
328
|
+
and delta > 0
|
|
329
|
+
and a.get("resetsAt") == b.get("resetsAt")
|
|
330
|
+
):
|
|
331
|
+
item["forecast"] = {
|
|
332
|
+
"estimated_seconds_to_depletion": max(0, 100 - b["usedPercent"])
|
|
333
|
+
* elapsed
|
|
334
|
+
/ delta,
|
|
335
|
+
"basis": "Observed primary allowance change in one reset window; estimate only.",
|
|
336
|
+
}
|
|
337
|
+
output.append(item)
|
|
338
|
+
return output
|
|
339
|
+
finally:
|
|
340
|
+
pool.close()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
async def doctor(args, state, binary):
|
|
344
|
+
import compatibility
|
|
345
|
+
|
|
346
|
+
result = {
|
|
347
|
+
"platform": sys.platform,
|
|
348
|
+
"python": sys.version.split()[0],
|
|
349
|
+
"state": str(state),
|
|
350
|
+
"checks": {},
|
|
351
|
+
}
|
|
352
|
+
checks = result["checks"]
|
|
353
|
+
if args.restore_backup:
|
|
354
|
+
from metadata import restore_backup
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
async with asyncio.timeout(2):
|
|
358
|
+
async with file_lock(state / "metadata.lock"):
|
|
359
|
+
try:
|
|
360
|
+
checks["metadata_before_restore"] = restore_backup(state)
|
|
361
|
+
except sqlite3.Error:
|
|
362
|
+
raise ValueError(
|
|
363
|
+
"Metadata backup is corrupt or unreadable; existing files were retained"
|
|
364
|
+
) from None
|
|
365
|
+
except asyncio.TimeoutError:
|
|
366
|
+
raise ValueError(
|
|
367
|
+
"Stop wrapper sessions before restoring metadata"
|
|
368
|
+
) from None
|
|
369
|
+
try:
|
|
370
|
+
db = sqlite3.connect(state / "pool.sqlite")
|
|
371
|
+
checks["database"] = db.execute("PRAGMA quick_check").fetchone()[0]
|
|
372
|
+
db.close()
|
|
373
|
+
except sqlite3.Error:
|
|
374
|
+
checks["database"] = "corrupt or unreadable"
|
|
375
|
+
checks["state_permissions"] = oct(state.stat().st_mode & 0o777)
|
|
376
|
+
try:
|
|
377
|
+
async with file_lock(state / "compatibility.lock"):
|
|
378
|
+
checks["compatibility"] = await asyncio.to_thread(
|
|
379
|
+
compatibility.check, binary, state
|
|
380
|
+
)
|
|
381
|
+
except Exception:
|
|
382
|
+
checks["compatibility"] = "failed; no credentials were read"
|
|
383
|
+
checks["keychain_reader"] = Path("/usr/bin/security").exists()
|
|
384
|
+
try:
|
|
385
|
+
import broker
|
|
386
|
+
|
|
387
|
+
checks["broker"] = await broker.exchange(state, {"op": "ping"}, 2)
|
|
388
|
+
except Exception:
|
|
389
|
+
checks["broker"] = "not running (starts on demand)"
|
|
390
|
+
if args.self_test:
|
|
391
|
+
source = Path(__file__).parent
|
|
392
|
+
tests = (
|
|
393
|
+
source / "tests" if (source / "tests").is_dir() else source.parent / "tests"
|
|
394
|
+
)
|
|
395
|
+
if not tests.is_dir():
|
|
396
|
+
raise ValueError(
|
|
397
|
+
"Offline checks are missing. Reinstall Relay and try again."
|
|
398
|
+
)
|
|
399
|
+
env = {
|
|
400
|
+
**os.environ,
|
|
401
|
+
"PYTHONPATH": os.pathsep.join(
|
|
402
|
+
[str(source), str(tests), os.environ.get("PYTHONPATH", "")]
|
|
403
|
+
),
|
|
404
|
+
}
|
|
405
|
+
proc = await asyncio.create_subprocess_exec(
|
|
406
|
+
sys.executable,
|
|
407
|
+
"-m",
|
|
408
|
+
"unittest",
|
|
409
|
+
"discover",
|
|
410
|
+
"-s",
|
|
411
|
+
str(tests),
|
|
412
|
+
"-q",
|
|
413
|
+
cwd=str(tests),
|
|
414
|
+
env=env,
|
|
415
|
+
)
|
|
416
|
+
checks["offline_tests_exit_code"] = await proc.wait()
|
|
417
|
+
if args.backup and checks["database"] == "ok":
|
|
418
|
+
target = state / "pool.backup.sqlite"
|
|
419
|
+
source = sqlite3.connect(state / "pool.sqlite")
|
|
420
|
+
dest = sqlite3.connect(target)
|
|
421
|
+
source.backup(dest)
|
|
422
|
+
source.close()
|
|
423
|
+
dest.close()
|
|
424
|
+
target.chmod(0o600)
|
|
425
|
+
checks["backup"] = str(target)
|
|
426
|
+
healthy = (
|
|
427
|
+
checks.get("database") == "ok"
|
|
428
|
+
and isinstance(checks.get("compatibility"), dict)
|
|
429
|
+
and checks.get("offline_tests_exit_code", 0) == 0
|
|
430
|
+
)
|
|
431
|
+
print(json.dumps(result, indent=2))
|
|
432
|
+
return 0 if healthy else 1
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
async def group(args, state, binary):
|
|
436
|
+
from router import RouterError
|
|
437
|
+
import service
|
|
438
|
+
|
|
439
|
+
path = state / "groups.json"
|
|
440
|
+
data = json.loads(path.read_text()) if path.exists() else {}
|
|
441
|
+
if args.action == "list":
|
|
442
|
+
print(json.dumps(data, indent=2))
|
|
443
|
+
return 0
|
|
444
|
+
if args.action == "save":
|
|
445
|
+
data[args.name] = [
|
|
446
|
+
{"name": Path(p).name, "cwd": str(Path(p).expanduser().resolve())}
|
|
447
|
+
for p in args.paths
|
|
448
|
+
]
|
|
449
|
+
settings.atomic_json(path, data)
|
|
450
|
+
print("Saved group " + args.name)
|
|
451
|
+
return 0
|
|
452
|
+
if args.action == "remove":
|
|
453
|
+
data.pop(args.name, None)
|
|
454
|
+
settings.atomic_json(path, data)
|
|
455
|
+
return 0
|
|
456
|
+
if args.name not in data:
|
|
457
|
+
raise RouterError("Unknown task group")
|
|
458
|
+
if args.action == "start":
|
|
459
|
+
import runtime
|
|
460
|
+
|
|
461
|
+
async with file_lock(state / "compatibility.lock"):
|
|
462
|
+
binary, _, _ = await asyncio.to_thread(runtime.select, binary, state)
|
|
463
|
+
for spec in data[args.name]:
|
|
464
|
+
ident = service.start(
|
|
465
|
+
state,
|
|
466
|
+
name=args.name + "/" + spec["name"],
|
|
467
|
+
cwd=spec["cwd"],
|
|
468
|
+
arguments=["--yolo"] if args.yolo else [],
|
|
469
|
+
binary=binary,
|
|
470
|
+
)
|
|
471
|
+
print(ident + " " + spec["name"])
|
|
472
|
+
return 0
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def add_parsers(sub):
|
|
476
|
+
status = sub.add_parser(
|
|
477
|
+
"status", help="Account and session dashboard without live network requests"
|
|
478
|
+
)
|
|
479
|
+
status.add_argument("--json", action="store_true")
|
|
480
|
+
status.add_argument("--watch", action="store_true")
|
|
481
|
+
status.add_argument(
|
|
482
|
+
"--all",
|
|
483
|
+
action="store_true",
|
|
484
|
+
help="Include stopped and completed saved sessions",
|
|
485
|
+
)
|
|
486
|
+
restart = sub.add_parser(
|
|
487
|
+
"restart", help="Restart a stopped session from saved history"
|
|
488
|
+
)
|
|
489
|
+
restart.add_argument("session")
|
|
490
|
+
status.add_argument("--interval", type=float, default=2)
|
|
491
|
+
for command in ["pause", "unpause", "retry", "wake", "stop", "switch"]:
|
|
492
|
+
p = sub.add_parser(command, help=command + " one live session or all")
|
|
493
|
+
p.add_argument("target", nargs="?")
|
|
494
|
+
p.add_argument("--thread")
|
|
495
|
+
if command == "switch":
|
|
496
|
+
p.add_argument("--account", required=True)
|
|
497
|
+
account = sub.add_parser("account", help="Manage any number of enrolled accounts")
|
|
498
|
+
child = account.add_subparsers(dest="action", required=True)
|
|
499
|
+
for action in [
|
|
500
|
+
"add",
|
|
501
|
+
"reauth",
|
|
502
|
+
"remove",
|
|
503
|
+
"enable",
|
|
504
|
+
"disable",
|
|
505
|
+
"rename",
|
|
506
|
+
"priority",
|
|
507
|
+
"cap",
|
|
508
|
+
"models",
|
|
509
|
+
]:
|
|
510
|
+
p = child.add_parser(action)
|
|
511
|
+
p.add_argument("label")
|
|
512
|
+
if action in {"add", "reauth"}:
|
|
513
|
+
p.add_argument("--device-auth", action="store_true")
|
|
514
|
+
if action == "remove":
|
|
515
|
+
p.add_argument("--revoke", action="store_true")
|
|
516
|
+
if action == "rename":
|
|
517
|
+
p.add_argument("value")
|
|
518
|
+
if action in {"priority", "cap"}:
|
|
519
|
+
p.add_argument("value", type=int)
|
|
520
|
+
if action == "models":
|
|
521
|
+
p.add_argument("values", nargs="*")
|
|
522
|
+
config = sub.add_parser("config", help="Set global or named recovery profiles")
|
|
523
|
+
config.add_argument("key", nargs="?")
|
|
524
|
+
config.add_argument("value", nargs="?")
|
|
525
|
+
config.add_argument("--profile")
|
|
526
|
+
logs = sub.add_parser("events", help="Read bounded metadata-only event history")
|
|
527
|
+
logs.add_argument("--limit", type=int, default=30)
|
|
528
|
+
logs.add_argument("--json", action="store_true")
|
|
529
|
+
hist = sub.add_parser("usage", help="Last observed allowance and local estimates")
|
|
530
|
+
hist.add_argument("--account")
|
|
531
|
+
hist.add_argument("--json", action="store_true")
|
|
532
|
+
doc = sub.add_parser(
|
|
533
|
+
"doctor", help="Check installation, database, broker and compatibility"
|
|
534
|
+
)
|
|
535
|
+
doc.add_argument("--self-test", action="store_true")
|
|
536
|
+
doc.add_argument("--backup", action="store_true")
|
|
537
|
+
doc.add_argument(
|
|
538
|
+
"--restore-backup",
|
|
539
|
+
action="store_true",
|
|
540
|
+
help="Restore metadata when wrapper sessions are stopped; retain the replaced database",
|
|
541
|
+
)
|
|
542
|
+
runtime = sub.add_parser(
|
|
543
|
+
"runtime", help="Preflight, snapshot or select a tested native Codex runtime"
|
|
544
|
+
)
|
|
545
|
+
runtime.add_argument("action", choices=["list", "cache", "use", "preflight"])
|
|
546
|
+
runtime.add_argument("value", nargs="?")
|
|
547
|
+
menu = sub.add_parser(
|
|
548
|
+
"menubar", help="Build or launch the optional native menu app"
|
|
549
|
+
)
|
|
550
|
+
menu.add_argument("--build-only", action="store_true")
|
|
551
|
+
start = sub.add_parser(
|
|
552
|
+
"start", help="Start a supervised background task; attach later"
|
|
553
|
+
)
|
|
554
|
+
for flag in ["name", "cwd", "first", "resume", "prompt", "profile"]:
|
|
555
|
+
start.add_argument("--" + flag)
|
|
556
|
+
start.add_argument("codex_args", nargs="...")
|
|
557
|
+
attach = sub.add_parser(
|
|
558
|
+
"attach", help="Attach the native terminal to a background session"
|
|
559
|
+
)
|
|
560
|
+
attach.add_argument("session")
|
|
561
|
+
attach.add_argument("codex_args", nargs="...")
|
|
562
|
+
attach.add_argument(
|
|
563
|
+
"--takeover",
|
|
564
|
+
action="store_true",
|
|
565
|
+
help="Move an existing terminal attachment without stopping its task",
|
|
566
|
+
)
|
|
567
|
+
groups = sub.add_parser("group", help="Save and launch groups of project tasks")
|
|
568
|
+
g = groups.add_subparsers(dest="action", required=True)
|
|
569
|
+
g.add_parser("list")
|
|
570
|
+
save = g.add_parser("save")
|
|
571
|
+
save.add_argument("name")
|
|
572
|
+
save.add_argument("paths", nargs="+")
|
|
573
|
+
rm = g.add_parser("remove")
|
|
574
|
+
rm.add_argument("name")
|
|
575
|
+
launch = g.add_parser("start")
|
|
576
|
+
launch.add_argument("name")
|
|
577
|
+
launch.add_argument("--yolo", action="store_true")
|
|
578
|
+
for command in ["_supervise", "_serve"]:
|
|
579
|
+
p = sub.add_parser(command, help=__import__("argparse").SUPPRESS)
|
|
580
|
+
p.add_argument("session")
|
|
581
|
+
b = sub.add_parser("_broker", help=__import__("argparse").SUPPRESS)
|
|
582
|
+
b.add_argument("--binary")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
async def dispatch(args, state, binary):
|
|
586
|
+
from router import RouterError
|
|
587
|
+
|
|
588
|
+
if args.command == "restart":
|
|
589
|
+
import service
|
|
590
|
+
|
|
591
|
+
async with file_lock(state / "restart.lock"):
|
|
592
|
+
ident = service.restart(state, args.session)
|
|
593
|
+
print("Restarted " + ident)
|
|
594
|
+
return 0
|
|
595
|
+
if args.command == "status":
|
|
596
|
+
if args.interval < 0.5:
|
|
597
|
+
raise RouterError("Watch interval must be at least 0.5 seconds")
|
|
598
|
+
return await status(args, state)
|
|
599
|
+
if args.command in {"pause", "unpause", "retry", "wake", "stop", "switch"}:
|
|
600
|
+
return await control(args, state)
|
|
601
|
+
if args.command == "account":
|
|
602
|
+
return await manage(args, state, binary)
|
|
603
|
+
if args.command == "config":
|
|
604
|
+
if args.key:
|
|
605
|
+
if args.value is None:
|
|
606
|
+
raise RouterError("Provide a setting value")
|
|
607
|
+
settings.edit(state, args.key, args.value, args.profile)
|
|
608
|
+
pool = Pool(state)
|
|
609
|
+
pool.signal()
|
|
610
|
+
pool.close()
|
|
611
|
+
print(json.dumps(settings.load(state, os.getcwd(), args.profile), indent=2))
|
|
612
|
+
return 0
|
|
613
|
+
if args.command == "events":
|
|
614
|
+
pool = Pool(state)
|
|
615
|
+
try:
|
|
616
|
+
with pool.db() as db:
|
|
617
|
+
rows = db.execute(
|
|
618
|
+
"SELECT at,owner,kind,details FROM events ORDER BY id DESC LIMIT ?",
|
|
619
|
+
(max(1, min(args.limit, 2000)),),
|
|
620
|
+
).fetchall()
|
|
621
|
+
value = [
|
|
622
|
+
dict(at=r[0], session=r[1], kind=r[2], details=json.loads(r[3]))
|
|
623
|
+
for r in rows
|
|
624
|
+
]
|
|
625
|
+
print(
|
|
626
|
+
json.dumps(value, indent=2)
|
|
627
|
+
if args.json
|
|
628
|
+
else "\n".join(
|
|
629
|
+
time.strftime("%m-%d %H:%M:%S", time.localtime(v["at"]))
|
|
630
|
+
+ " "
|
|
631
|
+
+ v["session"][:10]
|
|
632
|
+
+ " "
|
|
633
|
+
+ v["kind"]
|
|
634
|
+
+ " "
|
|
635
|
+
+ json.dumps(v["details"])
|
|
636
|
+
for v in value
|
|
637
|
+
)
|
|
638
|
+
)
|
|
639
|
+
return 0
|
|
640
|
+
finally:
|
|
641
|
+
pool.close()
|
|
642
|
+
if args.command == "usage":
|
|
643
|
+
label = resolve_label(state, args.account) if args.account else None
|
|
644
|
+
print(json.dumps(history(state, label), indent=2))
|
|
645
|
+
return 0
|
|
646
|
+
if args.command == "doctor":
|
|
647
|
+
return await doctor(args, state, binary)
|
|
648
|
+
if args.command == "runtime":
|
|
649
|
+
import runtime, compatibility
|
|
650
|
+
|
|
651
|
+
async with file_lock(state / "compatibility.lock"):
|
|
652
|
+
if args.action == "list":
|
|
653
|
+
print(json.dumps(runtime.index(state), indent=2))
|
|
654
|
+
elif args.action == "use":
|
|
655
|
+
runtime.choose(state, args.value)
|
|
656
|
+
print(
|
|
657
|
+
"Wrapper runtime selection updated; existing sessions are unchanged."
|
|
658
|
+
)
|
|
659
|
+
elif args.action == "cache":
|
|
660
|
+
path, result = await asyncio.to_thread(
|
|
661
|
+
runtime.capture, args.value or binary, state
|
|
662
|
+
)
|
|
663
|
+
print(result["version"] + " cached at " + path)
|
|
664
|
+
else:
|
|
665
|
+
result = await asyncio.to_thread(
|
|
666
|
+
compatibility.check, args.value or binary, state
|
|
667
|
+
)
|
|
668
|
+
print(json.dumps(result, indent=2))
|
|
669
|
+
return 0
|
|
670
|
+
if args.command == "menubar":
|
|
671
|
+
import desktop
|
|
672
|
+
|
|
673
|
+
app = await asyncio.to_thread(desktop.menubar, state, None, args.build_only)
|
|
674
|
+
print(str(app))
|
|
675
|
+
return 0
|
|
676
|
+
if args.command == "group":
|
|
677
|
+
return await group(args, state, binary)
|
|
678
|
+
if args.command == "start":
|
|
679
|
+
import service
|
|
680
|
+
|
|
681
|
+
first = resolve_label(state, args.first) if args.first else None
|
|
682
|
+
ident = service.start(
|
|
683
|
+
state,
|
|
684
|
+
name=args.name,
|
|
685
|
+
cwd=args.cwd,
|
|
686
|
+
first=first,
|
|
687
|
+
arguments=args.codex_args,
|
|
688
|
+
resume=args.resume,
|
|
689
|
+
prompt=args.prompt,
|
|
690
|
+
profile=args.profile,
|
|
691
|
+
binary=binary,
|
|
692
|
+
)
|
|
693
|
+
print("Started " + ident + "\nAttach: relay attach " + ident[:10])
|
|
694
|
+
return 0
|
|
695
|
+
if args.command == "attach":
|
|
696
|
+
import service
|
|
697
|
+
|
|
698
|
+
return await service.attach(
|
|
699
|
+
state, args.session, binary, args.codex_args or None, takeover=args.takeover
|
|
700
|
+
)
|
|
701
|
+
return None
|