relay-codex 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -7
- package/docs/commands.md +123 -39
- package/docs/publishing.md +29 -13
- package/docs/troubleshooting.md +15 -3
- package/package.json +2 -2
- package/python/Menu.swift +169 -51
- package/python/commands.py +228 -22
- package/python/compatibility.py +3 -1
- package/python/desktop.py +131 -12
- package/python/execution.py +551 -0
- package/python/launch.py +375 -75
- package/python/pool.py +7 -0
- package/python/router.py +321 -74
- package/python/service.py +216 -142
- package/python/settings.py +1 -0
- package/python/tools.py +233 -0
- package/python/watch.py +74 -0
- package/tests/fake_native.py +4 -2
- package/tests/test_audit.py +345 -0
- package/tests/test_controller.py +3 -5
- package/tests/test_features.py +350 -0
- package/tests/test_launch.py +150 -0
- package/tests/test_services.py +6 -0
package/python/Menu.swift
CHANGED
|
@@ -1,70 +1,188 @@
|
|
|
1
1
|
import AppKit
|
|
2
2
|
|
|
3
|
-
final class MenuController: NSObject, NSApplicationDelegate {
|
|
3
|
+
final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
4
4
|
var item: NSStatusItem!
|
|
5
|
-
var
|
|
6
|
-
var
|
|
7
|
-
|
|
5
|
+
var stream: Process?
|
|
6
|
+
var buffer = Data()
|
|
7
|
+
var current: [String: Any] = [:]
|
|
8
|
+
var message: String?
|
|
9
|
+
var reconnect: DispatchWorkItem?
|
|
10
|
+
var stopping = false
|
|
11
|
+
let command = Bundle.main.object(forInfoDictionaryKey: "RelayCommand") as? String ?? "/usr/bin/false"
|
|
12
|
+
let state = Bundle.main.object(forInfoDictionaryKey: "RelayState") as? String ?? ""
|
|
13
|
+
|
|
8
14
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
15
|
+
let others = NSRunningApplication.runningApplications(withBundleIdentifier: "local.relay.menu")
|
|
16
|
+
if others.contains(where: { $0.processIdentifier != ProcessInfo.processInfo.processIdentifier }) { NSApp.terminate(nil); return }
|
|
9
17
|
NSApp.setActivationPolicy(.accessory)
|
|
10
|
-
item = NSStatusBar.system.statusItem(withLength: NSStatusItem.
|
|
11
|
-
item.button?.title = "
|
|
12
|
-
|
|
13
|
-
|
|
18
|
+
item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
|
19
|
+
item.button?.title = ""
|
|
20
|
+
item.button?.setAccessibilityLabel("Relay account and session status")
|
|
21
|
+
updateIcon(attention: false)
|
|
22
|
+
render()
|
|
23
|
+
startStream()
|
|
14
24
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
func process(_ args: [String]) -> Process {
|
|
26
|
+
let proc = Process()
|
|
27
|
+
proc.executableURL = URL(fileURLWithPath: command)
|
|
28
|
+
proc.arguments = args
|
|
29
|
+
var env = ProcessInfo.processInfo.environment
|
|
30
|
+
let node = Bundle.main.object(forInfoDictionaryKey: "RelayNodeDirectory") as? String ?? "/opt/homebrew/bin"
|
|
31
|
+
env["PATH"] = node + ":/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:" + (env["PATH"] ?? "")
|
|
32
|
+
env["RELAY_STATE"] = state
|
|
33
|
+
proc.environment = env
|
|
34
|
+
return proc
|
|
35
|
+
}
|
|
36
|
+
func startStream() {
|
|
37
|
+
guard !stopping else { return }
|
|
38
|
+
let proc = process(["status", "--watch", "--json"])
|
|
39
|
+
let pipe = Pipe()
|
|
40
|
+
proc.standardOutput = pipe; proc.standardError = FileHandle.nullDevice
|
|
41
|
+
pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
|
42
|
+
let bytes = handle.availableData
|
|
43
|
+
guard !bytes.isEmpty else { handle.readabilityHandler = nil; return }
|
|
44
|
+
DispatchQueue.main.async { self?.receive(bytes) }
|
|
45
|
+
}
|
|
46
|
+
proc.terminationHandler = { [weak self] _ in
|
|
47
|
+
DispatchQueue.main.async {
|
|
48
|
+
guard let self = self, !self.stopping else { return }
|
|
49
|
+
self.message = "Status connection interrupted. Retrying in 30 seconds."
|
|
50
|
+
self.render()
|
|
51
|
+
let retry = DispatchWorkItem { [weak self] in self?.startStream() }
|
|
52
|
+
self.reconnect = retry
|
|
53
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: retry)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
do { buffer.removeAll(); try proc.run(); stream = proc }
|
|
57
|
+
catch { message = "Relay could not start. Open Terminal and run relay doctor."; render() }
|
|
58
|
+
}
|
|
59
|
+
func receive(_ bytes: Data) {
|
|
60
|
+
buffer.append(bytes)
|
|
61
|
+
while let newline = buffer.firstIndex(of: 10) {
|
|
62
|
+
let line = buffer.prefix(upTo: newline); buffer.removeSubrange(...newline)
|
|
63
|
+
if let value = (try? JSONSerialization.jsonObject(with: line)) as? [String: Any] {
|
|
64
|
+
current = value; message = nil; render()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
func updateIcon(attention: Bool) {
|
|
69
|
+
let symbol = attention ? "exclamationmark.circle.fill" : "arrow.triangle.branch"
|
|
70
|
+
let image = NSImage(systemSymbolName: symbol, accessibilityDescription: attention ? "Relay needs attention" : "Relay")
|
|
71
|
+
image?.isTemplate = true
|
|
72
|
+
item.button?.image = image
|
|
73
|
+
}
|
|
74
|
+
func text(_ title: String, into menu: NSMenu) {
|
|
75
|
+
menu.addItem(NSMenuItem(title: title, action: nil, keyEquivalent: ""))
|
|
76
|
+
}
|
|
77
|
+
func action(_ title: String, args: [String], into menu: NSMenu) {
|
|
78
|
+
let entry = NSMenuItem(title: title, action: #selector(runAction(_:)), keyEquivalent: "")
|
|
79
|
+
entry.target = self; entry.representedObject = args; menu.addItem(entry)
|
|
80
|
+
}
|
|
81
|
+
func date(_ value: Any?) -> String {
|
|
82
|
+
guard let seconds = value as? Double else { return "unknown" }
|
|
83
|
+
return Date(timeIntervalSince1970: seconds).formatted(date: .abbreviated, time: .shortened)
|
|
84
|
+
}
|
|
85
|
+
func countdown(_ title: String, value: Any, into menu: NSMenu) {
|
|
86
|
+
let entry = NSMenuItem(title: title + ": " + date(value), action: nil, keyEquivalent: "")
|
|
87
|
+
entry.representedObject = ["label": title, "at": value]
|
|
88
|
+
menu.addItem(entry)
|
|
89
|
+
}
|
|
90
|
+
func updateTimes(_ menu: NSMenu) {
|
|
91
|
+
for entry in menu.items {
|
|
92
|
+
if let data = entry.representedObject as? [String: Any], let at = data["at"] as? Double, let label = data["label"] as? String {
|
|
93
|
+
let seconds = max(0, Int(at - Date().timeIntervalSince1970))
|
|
94
|
+
let remaining = seconds < 60 ? "under a minute" : seconds < 3600 ? "\(seconds / 60)m" : "\(seconds / 3600)h \((seconds % 3600) / 60)m"
|
|
95
|
+
entry.title = label + ": " + (seconds == 0 ? "due now" : "in " + remaining) + " · " + date(at)
|
|
96
|
+
}
|
|
97
|
+
if let child = entry.submenu { updateTimes(child) }
|
|
30
98
|
}
|
|
31
99
|
}
|
|
32
|
-
func render(
|
|
33
|
-
let sessions =
|
|
34
|
-
let accounts =
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
menu
|
|
100
|
+
func render() {
|
|
101
|
+
let sessions = current["sessions"] as? [[String: Any]] ?? []
|
|
102
|
+
let accounts = current["accounts"] as? [[String: Any]] ?? []
|
|
103
|
+
let attention = message != nil || sessions.contains { ($0["state"] as? String) == "needs_attention" }
|
|
104
|
+
updateIcon(attention: attention)
|
|
105
|
+
item.button?.toolTip = "Relay · \(sessions.count) sessions · \(accounts.count) accounts"
|
|
106
|
+
let menu = NSMenu(); menu.delegate = self
|
|
107
|
+
text("Relay · \(sessions.count) sessions", into: menu)
|
|
108
|
+
if let message = message { text(message, into: menu) }
|
|
109
|
+
if accounts.isEmpty { text("Add an account with relay setup in Terminal.", into: menu) }
|
|
39
110
|
for account in accounts {
|
|
40
111
|
let name = account["name"] as? String ?? "Account"
|
|
41
112
|
let primary = account["primary"] as? [String: Any]
|
|
42
|
-
let
|
|
43
|
-
let
|
|
44
|
-
let
|
|
45
|
-
|
|
113
|
+
let remaining = (primary?["usedPercent"] as? NSNumber).map { "\(max(0, 100 - $0.intValue))% left" } ?? "allowance unknown"
|
|
114
|
+
let label = (account["enabled"] as? Bool ?? true) ? remaining : "disabled"
|
|
115
|
+
let submenu = NSMenu()
|
|
116
|
+
if let email = account["email"] as? String { text(email, into: submenu) }
|
|
117
|
+
text("Short window: \(remaining)", into: submenu)
|
|
118
|
+
if let secondary = account["secondary"] as? [String: Any], let used = secondary["usedPercent"] as? NSNumber { text("Weekly window: \(max(0, 100 - used.intValue))% left", into: submenu) }
|
|
119
|
+
if let reset = account["next_check"] { countdown("Next check", value: reset, into: submenu) }
|
|
120
|
+
text("Last checked: \(date(account["usage_checked_at"]))", into: submenu)
|
|
121
|
+
let entry = NSMenuItem(title: "\(name) · \(label)", action: nil, keyEquivalent: ""); entry.submenu = submenu; menu.addItem(entry)
|
|
46
122
|
}
|
|
123
|
+
menu.addItem(.separator())
|
|
47
124
|
for session in sessions {
|
|
48
|
-
let
|
|
49
|
-
let
|
|
50
|
-
|
|
125
|
+
let id = session["id"] as? String ?? ""
|
|
126
|
+
let name = session["name"] as? String ?? String(id.prefix(10))
|
|
127
|
+
let status = (session["state"] as? String ?? "unknown").replacingOccurrences(of: "_", with: " ")
|
|
128
|
+
let submenu = NSMenu()
|
|
129
|
+
text("Account: \(session["account_name"] as? String ?? session["account"] as? String ?? "unknown")", into: submenu)
|
|
130
|
+
text("Model: \(session["model"] as? String ?? "unknown")", into: submenu)
|
|
131
|
+
if let next = session["next_wake"] { countdown("Next action", value: next, into: submenu) }
|
|
132
|
+
if let reason = session["reason"] as? String { text(reason.replacingOccurrences(of: "_", with: " "), into: submenu) }
|
|
133
|
+
action("Copy reconnect command", args: ["copy", "relay attach " + id], into: submenu)
|
|
134
|
+
action("Pause recovery", args: ["pause", id, "--wait"], into: submenu)
|
|
135
|
+
action("Resume recovery", args: ["unpause", id, "--wait"], into: submenu)
|
|
136
|
+
action("Review and retry…", args: ["confirm-retry", id], into: submenu)
|
|
137
|
+
let entry = NSMenuItem(title: "\(name) · \(status)", action: nil, keyEquivalent: ""); entry.submenu = submenu; menu.addItem(entry)
|
|
51
138
|
}
|
|
52
139
|
menu.addItem(.separator())
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
let
|
|
56
|
-
|
|
57
|
-
|
|
140
|
+
action("Copy dashboard command", args: ["copy", "relay status --watch"], into: menu)
|
|
141
|
+
action("Check allowance now", args: ["refresh"], into: menu)
|
|
142
|
+
let login = NSMenuItem(title: "Open at login", action: #selector(toggleLogin), keyEquivalent: ""); login.target = self
|
|
143
|
+
login.state = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist") ? .on : .off
|
|
144
|
+
menu.addItem(login)
|
|
145
|
+
let quit = NSMenuItem(title: "Quit Relay menu", action: #selector(quitMenu), keyEquivalent: "q"); quit.target = self; menu.addItem(quit)
|
|
146
|
+
item.menu = menu
|
|
58
147
|
}
|
|
59
|
-
|
|
60
|
-
|
|
148
|
+
func menuWillOpen(_ menu: NSMenu) {
|
|
149
|
+
updateTimes(menu)
|
|
150
|
+
for entry in menu.items where entry.title == "Open at login" {
|
|
151
|
+
entry.state = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist") ? .on : .off
|
|
152
|
+
}
|
|
61
153
|
}
|
|
62
|
-
@objc func
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
154
|
+
@objc func runAction(_ sender: NSMenuItem) {
|
|
155
|
+
guard var args = sender.representedObject as? [String], !args.isEmpty else { return }
|
|
156
|
+
if args[0] == "copy" { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(args[1], forType: .string); return }
|
|
157
|
+
if args[0] == "confirm-retry" {
|
|
158
|
+
let alert = NSAlert(); alert.messageText = "Continue this task?"; alert.informativeText = "Review any interrupted command or external action in the conversation before continuing."
|
|
159
|
+
alert.addButton(withTitle: "Continue task"); alert.addButton(withTitle: "Cancel")
|
|
160
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
161
|
+
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
|
162
|
+
args = ["retry", args[1], "--wait"]
|
|
163
|
+
}
|
|
164
|
+
let proc = process(args); let pipe = Pipe(); proc.standardOutput = FileHandle.nullDevice; proc.standardError = pipe
|
|
165
|
+
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
166
|
+
do {
|
|
167
|
+
try proc.run(); let bytes = pipe.fileHandleForReading.readDataToEndOfFile(); proc.waitUntilExit()
|
|
168
|
+
if proc.terminationStatus != 0 { DispatchQueue.main.async { self?.message = String(data: bytes, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Action failed. Check relay events."; self?.render() } }
|
|
169
|
+
} catch { DispatchQueue.main.async { self?.message = "Could not run that action."; self?.render() } }
|
|
170
|
+
}
|
|
66
171
|
}
|
|
67
|
-
@objc func
|
|
172
|
+
@objc func toggleLogin() {
|
|
173
|
+
let enabled = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist")
|
|
174
|
+
runCommand(enabled ? ["menubar", "--no-startup", "--configure-only"] : ["menubar", "--configure-only"])
|
|
175
|
+
}
|
|
176
|
+
func runCommand(_ args: [String]) { let proc = process(args); proc.standardOutput = FileHandle.nullDevice; proc.standardError = FileHandle.nullDevice; try? proc.run() }
|
|
177
|
+
@objc func quitMenu() { stopping = true; reconnect?.cancel(); stream?.terminate(); NSApp.terminate(nil) }
|
|
178
|
+
func applicationWillTerminate(_ notification: Notification) { stopping = true; stream?.terminate() }
|
|
179
|
+
}
|
|
180
|
+
if CommandLine.arguments.contains("--self-check") {
|
|
181
|
+
let normal = NSImage(systemSymbolName: "arrow.triangle.branch", accessibilityDescription: "Relay")
|
|
182
|
+
let attention = NSImage(systemSymbolName: "exclamationmark.circle.fill", accessibilityDescription: "Relay needs attention")
|
|
183
|
+
print("Relay menu symbols available: \(normal != nil && attention != nil)")
|
|
184
|
+
exit(normal != nil && attention != nil ? 0 : 1)
|
|
185
|
+
} else {
|
|
186
|
+
let app = NSApplication.shared
|
|
187
|
+
let delegate = MenuController(); app.delegate = delegate; app.run()
|
|
68
188
|
}
|
|
69
|
-
let app=NSApplication.shared
|
|
70
|
-
let delegate=MenuController(); app.delegate=delegate; app.run()
|
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,34 @@ 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
|
+
account = next(
|
|
114
|
+
(a for a in accounts if a["id"] == session.get("account")), {}
|
|
115
|
+
)
|
|
116
|
+
session["account_name"] = account.get(
|
|
117
|
+
"name", session.get("account", "")
|
|
118
|
+
)
|
|
119
|
+
session["account_email"] = account.get("email")
|
|
120
|
+
saved_intent = db.execute(
|
|
121
|
+
"SELECT details FROM intents WHERE owner=? AND thread=?",
|
|
122
|
+
(session["id"], session.get("thread", "")),
|
|
123
|
+
).fetchone()
|
|
124
|
+
intent = json.loads(saved_intent[0]) if saved_intent else {}
|
|
125
|
+
session["reason"] = intent.get("review_reason") or intent.get("kind")
|
|
126
|
+
session["attempt"] = (intent.get("episode") or {}).get("attempt")
|
|
127
|
+
session["review_required"] = intent.get("review_required", False)
|
|
128
|
+
if session["review_required"]:
|
|
129
|
+
session["state"] = "needs_attention"
|
|
130
|
+
session.setdefault("recovery_enabled", True)
|
|
70
131
|
active = {s["id"] for s in sessions}
|
|
71
132
|
with pool.db() as db:
|
|
72
133
|
saved = {
|
|
@@ -119,13 +180,13 @@ def render(value):
|
|
|
119
180
|
lines = [
|
|
120
181
|
f"Relay · {len(value['sessions'])} sessions · {len(value['accounts'])} accounts",
|
|
121
182
|
"",
|
|
122
|
-
f"{'ACCOUNT':<
|
|
183
|
+
f"{'ACCOUNT':<39}{'STATE':<13}{'SHORT LEFT':<13}{'WEEK LEFT':<13}{'SESSIONS':<10}NEXT CHECK",
|
|
123
184
|
]
|
|
124
185
|
for account in value["accounts"]:
|
|
125
186
|
label = (
|
|
126
187
|
account["name"]
|
|
127
188
|
+ (" [" + account["id"] + "]" if account["name"] != account["id"] else "")
|
|
128
|
-
)[:
|
|
189
|
+
)[:38]
|
|
129
190
|
state = (
|
|
130
191
|
"maintenance"
|
|
131
192
|
if account["maintenance"]
|
|
@@ -139,16 +200,16 @@ def render(value):
|
|
|
139
200
|
else "—"
|
|
140
201
|
)
|
|
141
202
|
lines.append(
|
|
142
|
-
f"{label:<
|
|
203
|
+
f"{label:<39}{state:<13}{percent(account['primary']):<13}{percent(account['secondary']):<13}{account['sessions']:<10}{next_at}"
|
|
143
204
|
)
|
|
144
205
|
if value["sessions"]:
|
|
145
206
|
lines += [
|
|
146
207
|
"",
|
|
147
|
-
f"{'SESSION':<12}{'ACCOUNT':<
|
|
208
|
+
f"{'SESSION':<12}{'ACCOUNT':<32}{'STATE':<23}{'MODEL':<23}PROJECT / NAME",
|
|
148
209
|
]
|
|
149
210
|
for row in value["sessions"]:
|
|
150
211
|
lines.append(
|
|
151
|
-
f"{row['id'][:10]:<12}{row.get('account', ''):<
|
|
212
|
+
f"{row['id'][:10]:<12}{str(row.get('account_name') or row.get('account', '')):<32}{str(row.get('state', 'unknown'))[:22]:<23}{str(row.get('model') or '?')[:22]:<23}{row.get('cwd') or row.get('name') or ''}"
|
|
152
213
|
)
|
|
153
214
|
if row.get("next_wake"):
|
|
154
215
|
lines.append(
|
|
@@ -163,17 +224,30 @@ def render(value):
|
|
|
163
224
|
|
|
164
225
|
|
|
165
226
|
async def status(args, state):
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
227
|
+
from watch import LocalChanges, fingerprint
|
|
228
|
+
|
|
229
|
+
previous = None
|
|
230
|
+
watcher = LocalChanges(state) if args.watch else None
|
|
231
|
+
try:
|
|
232
|
+
while True:
|
|
233
|
+
value = snapshot(state, getattr(args, "all", False))
|
|
234
|
+
current = fingerprint(value)
|
|
235
|
+
if current != previous:
|
|
236
|
+
if args.json:
|
|
237
|
+
print(
|
|
238
|
+
json.dumps(value, indent=None if args.watch else 2), flush=True
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
if args.watch and sys.stdout.isatty():
|
|
242
|
+
print("\033[2J\033[H", end="")
|
|
243
|
+
print(render(value), flush=True)
|
|
244
|
+
previous = current
|
|
245
|
+
if not args.watch:
|
|
246
|
+
return 0
|
|
247
|
+
await watcher.changed(max(1, args.interval))
|
|
248
|
+
finally:
|
|
249
|
+
if watcher:
|
|
250
|
+
watcher.close()
|
|
177
251
|
|
|
178
252
|
|
|
179
253
|
async def control(args, state):
|
|
@@ -204,7 +278,13 @@ async def control(args, state):
|
|
|
204
278
|
for session in matching:
|
|
205
279
|
op = "resume" if args.command == "unpause" else args.command
|
|
206
280
|
ident = pool.command(session["id"], op, params)
|
|
207
|
-
|
|
281
|
+
if getattr(args, "wait", False):
|
|
282
|
+
from service import wait_control
|
|
283
|
+
|
|
284
|
+
await wait_control(pool, ident, timeout=args.timeout)
|
|
285
|
+
print(f"{op.capitalize()} applied to {session['id'][:10]}.")
|
|
286
|
+
else:
|
|
287
|
+
print(f"Queued {op} for {session['id'][:10]} (command {ident}).")
|
|
208
288
|
return 0
|
|
209
289
|
finally:
|
|
210
290
|
pool.close()
|
|
@@ -473,6 +553,37 @@ async def group(args, state, binary):
|
|
|
473
553
|
|
|
474
554
|
|
|
475
555
|
def add_parsers(sub):
|
|
556
|
+
preview = sub.add_parser(
|
|
557
|
+
"explain", help="Preview a native Codex launch without starting a task"
|
|
558
|
+
)
|
|
559
|
+
preview.add_argument("codex_args", nargs="...")
|
|
560
|
+
diagnostics = sub.add_parser(
|
|
561
|
+
"diagnostics",
|
|
562
|
+
help="Export redacted operational facts without credentials, prompts, or project paths",
|
|
563
|
+
)
|
|
564
|
+
diagnostics.add_argument("output")
|
|
565
|
+
clean = sub.add_parser(
|
|
566
|
+
"clean", help="Preview safe cleanup of unreferenced old runtime files"
|
|
567
|
+
)
|
|
568
|
+
clean.add_argument("--apply", action="store_true")
|
|
569
|
+
clean.add_argument("--older-than", type=int, default=30, dest="days")
|
|
570
|
+
clean.add_argument(
|
|
571
|
+
"--jobs",
|
|
572
|
+
action="store_true",
|
|
573
|
+
help="Also remove old stopped jobs; saved workspace references are retained",
|
|
574
|
+
)
|
|
575
|
+
workspaces = sub.add_parser(
|
|
576
|
+
"workspace",
|
|
577
|
+
help="Save and restore the same conversations across several terminals",
|
|
578
|
+
)
|
|
579
|
+
ws = workspaces.add_subparsers(dest="action", required=True)
|
|
580
|
+
ws.add_parser("list")
|
|
581
|
+
save_ws = ws.add_parser("save")
|
|
582
|
+
save_ws.add_argument("name")
|
|
583
|
+
save_ws.add_argument("sessions", nargs="*")
|
|
584
|
+
for action in ("restore", "remove"):
|
|
585
|
+
child = ws.add_parser(action)
|
|
586
|
+
child.add_argument("name")
|
|
476
587
|
status = sub.add_parser(
|
|
477
588
|
"status", help="Account and session dashboard without live network requests"
|
|
478
589
|
)
|
|
@@ -487,15 +598,28 @@ def add_parsers(sub):
|
|
|
487
598
|
"restart", help="Restart a stopped session from saved history"
|
|
488
599
|
)
|
|
489
600
|
restart.add_argument("session")
|
|
490
|
-
|
|
601
|
+
restart.add_argument("--if-stopped", action="store_true")
|
|
602
|
+
status.add_argument(
|
|
603
|
+
"--interval",
|
|
604
|
+
type=float,
|
|
605
|
+
default=30,
|
|
606
|
+
help="Maximum local filesystem wait; output changes only when data changes",
|
|
607
|
+
)
|
|
491
608
|
for command in ["pause", "unpause", "retry", "wake", "stop", "switch"]:
|
|
492
609
|
p = sub.add_parser(command, help=command + " one live session or all")
|
|
493
610
|
p.add_argument("target", nargs="?")
|
|
494
611
|
p.add_argument("--thread")
|
|
612
|
+
p.add_argument(
|
|
613
|
+
"--wait",
|
|
614
|
+
action="store_true",
|
|
615
|
+
help="Wait for the worker to confirm the action",
|
|
616
|
+
)
|
|
617
|
+
p.add_argument("--timeout", type=float, default=15)
|
|
495
618
|
if command == "switch":
|
|
496
619
|
p.add_argument("--account", required=True)
|
|
497
620
|
account = sub.add_parser("account", help="Manage any number of enrolled accounts")
|
|
498
621
|
child = account.add_subparsers(dest="action", required=True)
|
|
622
|
+
child.add_parser("identify", help="Fill in account emails from saved local logins")
|
|
499
623
|
for action in [
|
|
500
624
|
"add",
|
|
501
625
|
"reauth",
|
|
@@ -544,10 +668,21 @@ def add_parsers(sub):
|
|
|
544
668
|
)
|
|
545
669
|
runtime.add_argument("action", choices=["list", "cache", "use", "preflight"])
|
|
546
670
|
runtime.add_argument("value", nargs="?")
|
|
671
|
+
startup = sub.add_parser(
|
|
672
|
+
"startup", help="Choose background sessions to recover at Mac login"
|
|
673
|
+
)
|
|
674
|
+
startup.add_argument("action", choices=["list", "enable", "disable"])
|
|
675
|
+
startup.add_argument("session", nargs="?")
|
|
547
676
|
menu = sub.add_parser(
|
|
548
677
|
"menubar", help="Build or launch the optional native menu app"
|
|
549
678
|
)
|
|
550
679
|
menu.add_argument("--build-only", action="store_true")
|
|
680
|
+
menu.add_argument(
|
|
681
|
+
"--no-startup", action="store_true", help="Disable menu login startup"
|
|
682
|
+
)
|
|
683
|
+
menu.add_argument(
|
|
684
|
+
"--configure-only", action="store_true", help="Only update the login setting"
|
|
685
|
+
)
|
|
551
686
|
start = sub.add_parser(
|
|
552
687
|
"start", help="Start a supervised background task; attach later"
|
|
553
688
|
)
|
|
@@ -585,9 +720,65 @@ def add_parsers(sub):
|
|
|
585
720
|
async def dispatch(args, state, binary):
|
|
586
721
|
from router import RouterError
|
|
587
722
|
|
|
723
|
+
if args.command in {"explain", "diagnostics", "clean", "workspace"}:
|
|
724
|
+
import tools
|
|
725
|
+
|
|
726
|
+
if args.command == "explain":
|
|
727
|
+
result = tools.explain(args.codex_args, os.getcwd(), binary)
|
|
728
|
+
elif args.command == "diagnostics":
|
|
729
|
+
result = {"written": tools.diagnostics(state, args.output)}
|
|
730
|
+
elif args.command == "clean":
|
|
731
|
+
if args.days < 1:
|
|
732
|
+
raise ValueError("Retention must be at least one day")
|
|
733
|
+
from pool import file_lock
|
|
734
|
+
|
|
735
|
+
async with file_lock(state / "cleanup.lock"):
|
|
736
|
+
if args.apply:
|
|
737
|
+
try:
|
|
738
|
+
async with asyncio.timeout(2):
|
|
739
|
+
async with file_lock(state / "metadata.lock"):
|
|
740
|
+
result = tools.cleanup(
|
|
741
|
+
state, apply=True, days=args.days, jobs=args.jobs
|
|
742
|
+
)
|
|
743
|
+
except asyncio.TimeoutError:
|
|
744
|
+
raise RouterError(
|
|
745
|
+
"Stop supervised sessions before applying cleanup. The dry run works while sessions are active."
|
|
746
|
+
) from None
|
|
747
|
+
else:
|
|
748
|
+
result = tools.cleanup(
|
|
749
|
+
state, apply=args.apply, days=args.days, jobs=args.jobs
|
|
750
|
+
)
|
|
751
|
+
else:
|
|
752
|
+
result = tools.workspace(
|
|
753
|
+
state,
|
|
754
|
+
args.action,
|
|
755
|
+
getattr(args, "name", None),
|
|
756
|
+
getattr(args, "sessions", None),
|
|
757
|
+
binary,
|
|
758
|
+
)
|
|
759
|
+
print(json.dumps(result, indent=2))
|
|
760
|
+
return 0
|
|
761
|
+
if args.command == "startup":
|
|
762
|
+
import desktop
|
|
763
|
+
|
|
764
|
+
if args.action != "list" and not args.session:
|
|
765
|
+
raise ValueError("Choose a saved session ID")
|
|
766
|
+
print(
|
|
767
|
+
json.dumps(desktop.job_startup(state, args.action, args.session), indent=2)
|
|
768
|
+
)
|
|
769
|
+
return 0
|
|
588
770
|
if args.command == "restart":
|
|
589
771
|
import service
|
|
590
772
|
|
|
773
|
+
if args.if_stopped:
|
|
774
|
+
pool = Pool(state)
|
|
775
|
+
try:
|
|
776
|
+
ident = service.resolve(state, args.session)
|
|
777
|
+
if any(s["id"] == ident for s in pool.detail()):
|
|
778
|
+
return 0
|
|
779
|
+
finally:
|
|
780
|
+
pool.close()
|
|
781
|
+
|
|
591
782
|
async with file_lock(state / "restart.lock"):
|
|
592
783
|
ident = service.restart(state, args.session)
|
|
593
784
|
print("Restarted " + ident)
|
|
@@ -599,6 +790,11 @@ async def dispatch(args, state, binary):
|
|
|
599
790
|
if args.command in {"pause", "unpause", "retry", "wake", "stop", "switch"}:
|
|
600
791
|
return await control(args, state)
|
|
601
792
|
if args.command == "account":
|
|
793
|
+
if args.action == "identify":
|
|
794
|
+
print(
|
|
795
|
+
f"Identified {await asyncio.to_thread(identify_accounts, state)} saved accounts."
|
|
796
|
+
)
|
|
797
|
+
return 0
|
|
602
798
|
return await manage(args, state, binary)
|
|
603
799
|
if args.command == "config":
|
|
604
800
|
if args.key:
|
|
@@ -670,7 +866,17 @@ async def dispatch(args, state, binary):
|
|
|
670
866
|
if args.command == "menubar":
|
|
671
867
|
import desktop
|
|
672
868
|
|
|
673
|
-
|
|
869
|
+
if args.no_startup or args.configure_only:
|
|
870
|
+
desktop.login_item(state, enabled=not args.no_startup)
|
|
871
|
+
if args.configure_only:
|
|
872
|
+
print(
|
|
873
|
+
"Menu login startup "
|
|
874
|
+
+ ("disabled" if args.no_startup else "enabled")
|
|
875
|
+
)
|
|
876
|
+
return 0
|
|
877
|
+
app = await asyncio.to_thread(
|
|
878
|
+
desktop.menubar, state, None, args.build_only, not args.no_startup
|
|
879
|
+
)
|
|
674
880
|
print(str(app))
|
|
675
881
|
return 0
|
|
676
882
|
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
|
{
|