relay-codex 0.5.3 → 0.6.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 +8 -0
- package/bin/relay.cjs +6 -5
- package/docs/commands.md +20 -0
- package/docs/publishing.md +2 -2
- package/docs/troubleshooting.md +14 -0
- package/package.json +1 -1
- package/python/Menu.swift +103 -13
- package/python/commands.py +14 -1
- package/python/completion.py +248 -0
- package/python/pool.py +12 -1
- package/python/router.py +84 -18
- package/python/service.py +6 -3
- package/tests/test_usability.py +287 -0
package/README.md
CHANGED
|
@@ -65,6 +65,14 @@ relay attach --takeover SESSION_ID
|
|
|
65
65
|
Accounts show their saved email addresses. The watch view updates only when local
|
|
66
66
|
status changes; it uses no model allowance. The menu bar uses an icon and opens
|
|
67
67
|
at Mac login. `relay menubar --no-startup --configure-only` disables login startup.
|
|
68
|
+
Use a session's menu to switch accounts after its work finishes. Account menus can
|
|
69
|
+
enable or disable future routing without stopping current tasks. Actions show
|
|
70
|
+
their result, and stopped workers offer a restart.
|
|
71
|
+
|
|
72
|
+
For tab completion in Zsh, run `source <(relay completion zsh)` after `compinit`.
|
|
73
|
+
Bash users can run `source <(relay completion bash)`. Add the same line to your
|
|
74
|
+
shell's startup file to keep it enabled. Relay completes its own commands and
|
|
75
|
+
saved account/session names; arguments after `relay run --` use Codex's completion.
|
|
68
76
|
|
|
69
77
|
## What happens when something stops?
|
|
70
78
|
|
package/bin/relay.cjs
CHANGED
|
@@ -91,8 +91,9 @@ async function withSetupLock(state, operation) {
|
|
|
91
91
|
finally { fs.rmSync(lock, { recursive: true, force: true }); }
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
function
|
|
95
|
-
|
|
94
|
+
function runSetup(command, args) {
|
|
95
|
+
// Setup chatter must not contaminate JSON or generated completion on stdout.
|
|
96
|
+
const result = spawnSync(command, args, { stdio: ['inherit', 2, 'inherit'] });
|
|
96
97
|
if (result.error || result.status !== 0) throw new Error('Setup did not finish. Run Relay again to retry.');
|
|
97
98
|
}
|
|
98
99
|
|
|
@@ -114,8 +115,8 @@ async function prepare(state) {
|
|
|
114
115
|
}
|
|
115
116
|
console.error('Preparing Relay for this Mac. This only happens on first use or after an update.');
|
|
116
117
|
// Always retry dependency installation if a previous setup was interrupted.
|
|
117
|
-
|
|
118
|
-
|
|
118
|
+
runSetup(python, ['-m', 'venv', path.join(destination, '.venv')]);
|
|
119
|
+
runSetup(interpreter, ['-m', 'pip', 'install', '--disable-pip-version-check', '-r', path.join(destination, 'requirements.lock')]);
|
|
119
120
|
fs.writeFileSync(marker, JSON.stringify({ version: pkg.version }), { mode: 0o600 });
|
|
120
121
|
return { interpreter, router: path.join(destination, 'router.py') };
|
|
121
122
|
});
|
|
@@ -144,5 +145,5 @@ async function main() {
|
|
|
144
145
|
process.off('SIGHUP', terminate);
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
module.exports = { stateDirectory, fingerprint, ownerAlive, withSetupLock, prepare };
|
|
148
|
+
module.exports = { stateDirectory, fingerprint, ownerAlive, withSetupLock, prepare, runSetup };
|
|
148
149
|
if (require.main === module) main().catch(error => { console.error(`relay: ${error.message}`); process.exitCode = 1; });
|
package/docs/commands.md
CHANGED
|
@@ -9,11 +9,21 @@ relay help workspace
|
|
|
9
9
|
relay run --help
|
|
10
10
|
relay run -- exec --help
|
|
11
11
|
relay help codex resume
|
|
12
|
+
relay completion zsh
|
|
13
|
+
relay completion bash
|
|
12
14
|
```
|
|
13
15
|
|
|
14
16
|
`relay run --help` shows the installed Codex command and flag list, followed by
|
|
15
17
|
Relay's launch options. Put native arguments after `relay run --`.
|
|
16
18
|
|
|
19
|
+
Enable tab completion with `source <(relay completion zsh)` after Zsh's `compinit`,
|
|
20
|
+
or `source <(relay completion bash)` in Bash. Put that line in `.zshrc` or `.bashrc`
|
|
21
|
+
to load it in new shells. Management choices come from Relay's parser and local
|
|
22
|
+
saved metadata, including account labels/emails, sessions, profiles and workspaces.
|
|
23
|
+
Native arguments use the installed Codex completion, loaded once per shell on
|
|
24
|
+
first use; start a new shell after updating Codex to load its new completion.
|
|
25
|
+
Completion does not read Keychain credentials, fetch allowance, or start a task.
|
|
26
|
+
|
|
17
27
|
## Accounts
|
|
18
28
|
|
|
19
29
|
```sh
|
|
@@ -99,6 +109,16 @@ allowance and does not fetch account allowance. `refresh` explicitly checks it.
|
|
|
99
109
|
The menu uses one local status stream, a template icon, and native session actions.
|
|
100
110
|
Opening it reads a fresh local snapshot, and visible rows update in place.
|
|
101
111
|
Short-window and weekly allowance are labeled separately.
|
|
112
|
+
Each session menu can switch to another enabled account after work and approvals
|
|
113
|
+
finish. The worker checks allowance and session capacity before switching, then
|
|
114
|
+
checks again that the session is idle before changing its login. Rejected actions
|
|
115
|
+
show their reason. Pending menu actions cannot be submitted twice, and results
|
|
116
|
+
remain visible until dismissed or another action starts. Stopped workers offer
|
|
117
|
+
Restart; restarting retains any review hold.
|
|
118
|
+
|
|
119
|
+
Account menus can disable or enable future routing. Disabling an account does not
|
|
120
|
+
interrupt work already using it. The account chooser marks the current account
|
|
121
|
+
and disables accounts excluded by their saved routing/model settings.
|
|
102
122
|
It opens at Mac login after `relay menubar`; disable that in its menu or with
|
|
103
123
|
`relay menubar --no-startup --configure-only`.
|
|
104
124
|
|
package/docs/publishing.md
CHANGED
|
@@ -8,8 +8,8 @@ Friends can run `npx relay-codex` or install it with `npm install -g relay-codex
|
|
|
8
8
|
The GitHub repository is enough for friends to install or try Relay:
|
|
9
9
|
|
|
10
10
|
```sh
|
|
11
|
-
npm install -g github:blackspider-ops/relay#v0.
|
|
12
|
-
npx --yes github:blackspider-ops/relay#v0.
|
|
11
|
+
npm install -g github:blackspider-ops/relay#v0.6.0
|
|
12
|
+
npx --yes github:blackspider-ops/relay#v0.6.0 --version
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
`npx` runs packages; it is not a separate publishing service. Publishing
|
package/docs/troubleshooting.md
CHANGED
|
@@ -42,6 +42,20 @@ Check `relay --version` and update Relay. Watch now prints only meaningful chang
|
|
|
42
42
|
from local state, without using model allowance. Earlier enrolled accounts can be
|
|
43
43
|
identified with `relay account identify`; this reads saved login metadata locally.
|
|
44
44
|
|
|
45
|
+
**Switching accounts says the task is busy.**
|
|
46
|
+
|
|
47
|
+
Wait for its current work, tools and approvals to finish, then switch again.
|
|
48
|
+
Relay rechecks this after checking the target account, so work starting during
|
|
49
|
+
that check also prevents switching. A rejected menu action keeps its explanation
|
|
50
|
+
visible. Use Restart for a stopped worker rather than sending it live controls.
|
|
51
|
+
|
|
52
|
+
**Tab completion is missing.**
|
|
53
|
+
|
|
54
|
+
Load `source <(relay completion zsh)` after `compinit`, or use
|
|
55
|
+
`source <(relay completion bash)`. Put native arguments after `relay run --`.
|
|
56
|
+
Native completion has the same choices and limitations as Codex's own generated
|
|
57
|
+
completion. Start a new shell after a Codex update to reload those choices.
|
|
58
|
+
|
|
45
59
|
**Why does npm ask for authentication?**
|
|
46
60
|
|
|
47
61
|
That verification protects package publishing. Installing and running the public
|
package/package.json
CHANGED
package/python/Menu.swift
CHANGED
|
@@ -6,6 +6,9 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
6
6
|
var buffer = Data()
|
|
7
7
|
var current: [String: Any] = [:]
|
|
8
8
|
var message: String?
|
|
9
|
+
var actionNotice: String?
|
|
10
|
+
var actionFailed = false
|
|
11
|
+
var actionRunning = false
|
|
9
12
|
var reconnect: DispatchWorkItem?
|
|
10
13
|
var stopping = false
|
|
11
14
|
var snapshotInFlight = false
|
|
@@ -122,10 +125,12 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
122
125
|
let entry = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
|
123
126
|
entry.isEnabled = false; menu.addItem(entry)
|
|
124
127
|
}
|
|
125
|
-
func action(_ title: String, args: [String], into menu: NSMenu) {
|
|
128
|
+
@discardableResult func action(_ title: String, args: [String], into menu: NSMenu) -> NSMenuItem {
|
|
126
129
|
let entry = NSMenuItem(title: title, action: #selector(runAction(_:)), keyEquivalent: "")
|
|
127
130
|
entry.target = self; entry.representedObject = args
|
|
131
|
+
entry.isEnabled = !actionRunning || args.first == "copy"
|
|
128
132
|
entry.identifier = NSUserInterfaceItemIdentifier("action:" + args.joined(separator: "|")); menu.addItem(entry)
|
|
133
|
+
return entry
|
|
129
134
|
}
|
|
130
135
|
struct TimingRow {
|
|
131
136
|
let title: String
|
|
@@ -170,12 +175,25 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
170
175
|
func render() {
|
|
171
176
|
let sessions = current["sessions"] as? [[String: Any]] ?? []
|
|
172
177
|
let accounts = current["accounts"] as? [[String: Any]] ?? []
|
|
173
|
-
let attention = message != nil || sessions.contains { ($0["state"] as? String) == "needs_attention" }
|
|
178
|
+
let attention = message != nil || actionFailed || sessions.contains { ($0["state"] as? String) == "needs_attention" }
|
|
174
179
|
updateIcon(attention: attention)
|
|
175
180
|
item.button?.toolTip = "Relay · \(sessions.count) sessions · \(accounts.count) accounts"
|
|
181
|
+
let menu = makeMenu()
|
|
182
|
+
updateTimes(menu)
|
|
183
|
+
if let visible = item.menu { MenuController.synchronise(visible, with: menu) }
|
|
184
|
+
else { item.menu = menu }
|
|
185
|
+
}
|
|
186
|
+
func makeMenu() -> NSMenu {
|
|
187
|
+
let sessions = current["sessions"] as? [[String: Any]] ?? []
|
|
188
|
+
let accounts = current["accounts"] as? [[String: Any]] ?? []
|
|
176
189
|
let menu = NSMenu(); menu.delegate = self
|
|
190
|
+
menu.autoenablesItems = false
|
|
177
191
|
text("Relay · \(sessions.count) sessions", into: menu)
|
|
178
192
|
if let message = message { text(message, into: menu) }
|
|
193
|
+
if let notice = actionNotice {
|
|
194
|
+
text(notice, into: menu)
|
|
195
|
+
if !actionRunning { action("Dismiss message", args: ["dismiss"], into: menu) }
|
|
196
|
+
}
|
|
179
197
|
if accounts.isEmpty { text("Add an account with relay setup in Terminal.", into: menu) }
|
|
180
198
|
for account in accounts {
|
|
181
199
|
let name = account["name"] as? String ?? "Account"
|
|
@@ -186,6 +204,7 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
186
204
|
let weekly = account["weekly_remaining"] as? String ?? (secondary?["usedPercent"] as? NSNumber).map { "\(max(0, 100 - $0.intValue))%" } ?? "?"
|
|
187
205
|
let label = (account["enabled"] as? Bool ?? true) ? "Short \(short) · Week \(weekly)" : "disabled"
|
|
188
206
|
let submenu = NSMenu()
|
|
207
|
+
submenu.autoenablesItems = false
|
|
189
208
|
if let email = account["email"] as? String { text(email, into: submenu) }
|
|
190
209
|
text("Short window: \(remaining)", into: submenu)
|
|
191
210
|
if weekly != "?" { text("Weekly window: \(weekly) left", into: submenu) }
|
|
@@ -195,6 +214,12 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
195
214
|
}
|
|
196
215
|
let lastChecked = MenuController.timestamp(account["usage_checked_at"]).map { date($0) } ?? "Not checked yet"
|
|
197
216
|
text("Last checked: \(lastChecked)", into: submenu)
|
|
217
|
+
if let id = account["id"] as? String {
|
|
218
|
+
submenu.addItem(.separator())
|
|
219
|
+
let enabled = account["enabled"] as? Bool ?? true
|
|
220
|
+
action(enabled ? "Disable for future routing" : "Enable for routing", args: ["account", enabled ? "disable" : "enable", id], into: submenu)
|
|
221
|
+
text("Current tasks keep running.", into: submenu)
|
|
222
|
+
}
|
|
198
223
|
let entry = NSMenuItem(title: "\(name) · \(label)", action: nil, keyEquivalent: "")
|
|
199
224
|
entry.identifier = NSUserInterfaceItemIdentifier("account:" + (account["id"] as? String ?? name))
|
|
200
225
|
entry.submenu = submenu; menu.addItem(entry)
|
|
@@ -205,14 +230,37 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
205
230
|
let name = session["name"] as? String ?? String(id.prefix(10))
|
|
206
231
|
let status = (session["state"] as? String ?? "unknown").replacingOccurrences(of: "_", with: " ")
|
|
207
232
|
let submenu = NSMenu()
|
|
233
|
+
submenu.autoenablesItems = false
|
|
208
234
|
text("Account: \(session["account_name"] as? String ?? session["account"] as? String ?? "unknown")", into: submenu)
|
|
209
235
|
text("Model: \(session["model"] as? String ?? "unknown")", into: submenu)
|
|
210
236
|
if let next = MenuController.timestamp(session["next_wake"]) { countdown("Next action", value: next, into: submenu) }
|
|
211
237
|
if let reason = session["reason"] as? String { text(reason.replacingOccurrences(of: "_", with: " "), into: submenu) }
|
|
212
238
|
action("Copy reconnect command", args: ["copy", "relay attach " + id], into: submenu)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
239
|
+
if session["running"] as? Bool ?? true {
|
|
240
|
+
let paused = session["paused"] as? Bool ?? false
|
|
241
|
+
action(paused ? "Resume recovery" : "Pause recovery", args: [paused ? "unpause" : "pause", id, "--wait"], into: submenu)
|
|
242
|
+
let choices = NSMenu(); choices.autoenablesItems = false
|
|
243
|
+
for account in accounts {
|
|
244
|
+
guard let accountID = account["id"] as? String else { continue }
|
|
245
|
+
let selected = accountID == session["account"] as? String
|
|
246
|
+
let models = account["models"] as? [String] ?? []
|
|
247
|
+
let model = session["model"] as? String
|
|
248
|
+
let modelAllowed = models.isEmpty || model == nil || models.contains(model!)
|
|
249
|
+
let available = (account["enabled"] as? Bool ?? true) && !(account["maintenance"] as? Bool ?? false) && modelAllowed
|
|
250
|
+
let name = account["name"] as? String ?? accountID
|
|
251
|
+
let choice = action(name + (available ? "" : " · unavailable"), args: ["switch", id, "--account", accountID, "--wait"], into: choices)
|
|
252
|
+
choice.state = selected ? .on : .off
|
|
253
|
+
choice.isEnabled = !selected && available && !actionRunning
|
|
254
|
+
}
|
|
255
|
+
text("Switch after current work and approvals finish.", into: choices)
|
|
256
|
+
let switchItem = NSMenuItem(title: "Switch account", action: nil, keyEquivalent: "")
|
|
257
|
+
switchItem.identifier = NSUserInterfaceItemIdentifier("switch:" + id)
|
|
258
|
+
switchItem.submenu = choices; submenu.addItem(switchItem)
|
|
259
|
+
action("Review and retry…", args: ["confirm-retry", id], into: submenu)
|
|
260
|
+
} else {
|
|
261
|
+
text("Worker stopped. Restart to reconnect; review holds are retained.", into: submenu)
|
|
262
|
+
action("Restart session", args: ["restart", id, "--if-stopped"], into: submenu)
|
|
263
|
+
}
|
|
216
264
|
let entry = NSMenuItem(title: "\(name) · \(status)", action: nil, keyEquivalent: "")
|
|
217
265
|
entry.identifier = NSUserInterfaceItemIdentifier("session:" + id)
|
|
218
266
|
entry.submenu = submenu; menu.addItem(entry)
|
|
@@ -221,12 +269,11 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
221
269
|
action("Copy dashboard command", args: ["copy", "relay status --watch"], into: menu)
|
|
222
270
|
action("Check allowance now", args: ["refresh"], into: menu)
|
|
223
271
|
let login = NSMenuItem(title: "Open at login", action: #selector(toggleLogin), keyEquivalent: ""); login.target = self
|
|
272
|
+
login.isEnabled = !actionRunning
|
|
224
273
|
login.state = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist") ? .on : .off
|
|
225
274
|
menu.addItem(login)
|
|
226
275
|
let quit = NSMenuItem(title: "Quit Relay menu", action: #selector(quitMenu), keyEquivalent: "q"); quit.target = self; menu.addItem(quit)
|
|
227
|
-
|
|
228
|
-
if let visible = item.menu { MenuController.synchronise(visible, with: menu) }
|
|
229
|
-
else { item.menu = menu }
|
|
276
|
+
return menu
|
|
230
277
|
}
|
|
231
278
|
func menuWillOpen(_ menu: NSMenu) {
|
|
232
279
|
refreshSnapshot()
|
|
@@ -238,6 +285,8 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
238
285
|
@objc func runAction(_ sender: NSMenuItem) {
|
|
239
286
|
guard var args = sender.representedObject as? [String], !args.isEmpty else { return }
|
|
240
287
|
if args[0] == "copy" { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(args[1], forType: .string); return }
|
|
288
|
+
if args[0] == "dismiss" { actionNotice = nil; actionFailed = false; render(); return }
|
|
289
|
+
guard !actionRunning else { return }
|
|
241
290
|
if args[0] == "confirm-retry" {
|
|
242
291
|
let alert = NSAlert(); alert.messageText = "Continue this task?"; alert.informativeText = "Review any interrupted command or external action in the conversation before continuing."
|
|
243
292
|
alert.addButton(withTitle: "Continue task"); alert.addButton(withTitle: "Cancel")
|
|
@@ -245,19 +294,39 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
245
294
|
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
|
246
295
|
args = ["retry", args[1], "--wait"]
|
|
247
296
|
}
|
|
297
|
+
actionRunning = true; actionFailed = false; actionNotice = "Applying change…"; render()
|
|
248
298
|
let proc = process(args); let pipe = Pipe(); proc.standardOutput = FileHandle.nullDevice; proc.standardError = pipe
|
|
249
299
|
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
250
300
|
do {
|
|
251
301
|
try proc.run(); let bytes = pipe.fileHandleForReading.readDataToEndOfFile(); proc.waitUntilExit()
|
|
252
|
-
|
|
253
|
-
|
|
302
|
+
let failed = proc.terminationStatus != 0
|
|
303
|
+
let detail = String(data: bytes, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
304
|
+
DispatchQueue.main.async {
|
|
305
|
+
self?.actionRunning = false; self?.actionFailed = failed
|
|
306
|
+
self?.actionNotice = failed ? (detail.isEmpty ? "Action failed. Check relay events." : detail) : MenuController.successMessage(args)
|
|
307
|
+
self?.render(); self?.refreshSnapshot()
|
|
308
|
+
}
|
|
309
|
+
} catch { DispatchQueue.main.async { self?.actionRunning = false; self?.actionFailed = true; self?.actionNotice = "Could not run that action."; self?.render() } }
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
static func successMessage(_ args: [String]) -> String {
|
|
313
|
+
switch args.first {
|
|
314
|
+
case "switch": return "Account switched."
|
|
315
|
+
case "pause": return "Recovery paused. Current work can finish."
|
|
316
|
+
case "unpause": return "Recovery resumed."
|
|
317
|
+
case "retry": return "Task allowed to continue."
|
|
318
|
+
case "restart": return "Session restarted."
|
|
319
|
+
case "refresh": return "Allowance check finished. See each account’s last checked time."
|
|
320
|
+
case "account": return args.dropFirst().first == "disable" ? "Account disabled for future routing." : "Account enabled for routing."
|
|
321
|
+
default: return "Change applied."
|
|
254
322
|
}
|
|
255
323
|
}
|
|
256
324
|
@objc func toggleLogin() {
|
|
257
325
|
let enabled = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist")
|
|
258
|
-
|
|
326
|
+
let sender = NSMenuItem()
|
|
327
|
+
sender.representedObject = enabled ? ["menubar", "--no-startup", "--configure-only"] : ["menubar", "--configure-only"]
|
|
328
|
+
runAction(sender)
|
|
259
329
|
}
|
|
260
|
-
func runCommand(_ args: [String]) { let proc = process(args); proc.standardOutput = FileHandle.nullDevice; proc.standardError = FileHandle.nullDevice; try? proc.run() }
|
|
261
330
|
@objc func quitMenu() { stopping = true; reconnect?.cancel(); stream?.terminate(); NSApp.terminate(nil) }
|
|
262
331
|
func applicationWillTerminate(_ notification: Notification) { stopping = true; stream?.terminate() }
|
|
263
332
|
}
|
|
@@ -281,7 +350,28 @@ if CommandLine.arguments.contains("--self-check") {
|
|
|
281
350
|
let scheduled = MenuController.accountTimingRows(["next_check": 2000200000.0])
|
|
282
351
|
let correctTiming = timing.map { $0.title } == ["Short reset", "Weekly reset", "Allowance checked when needed"] && scheduled.first?.title == "Next allowance check" && scheduled.first?.at == 2000200000.0
|
|
283
352
|
print("Empty retry schedule handled: \(correctTiming)")
|
|
284
|
-
|
|
353
|
+
func findAction(_ args: [String], in menu: NSMenu) -> NSMenuItem? {
|
|
354
|
+
for entry in menu.items {
|
|
355
|
+
if entry.representedObject as? [String] == args { return entry }
|
|
356
|
+
if let child = entry.submenu, let found = findAction(args, in: child) { return found }
|
|
357
|
+
}
|
|
358
|
+
return nil
|
|
359
|
+
}
|
|
360
|
+
let controller = MenuController()
|
|
361
|
+
controller.current = ["accounts": [["id": "one", "name": "One"], ["id": "two", "name": "Two"], ["id": "off", "enabled": false]], "sessions": [["id": "live", "account": "one", "running": true], ["id": "stopped", "running": false]]]
|
|
362
|
+
let controls = controller.makeMenu()
|
|
363
|
+
let switchArgs = ["switch", "live", "--account", "two", "--wait"]
|
|
364
|
+
let correctChoices = findAction(switchArgs, in: controls)?.isEnabled == true && findAction(["switch", "live", "--account", "one", "--wait"], in: controls)?.state == .on && findAction(["switch", "live", "--account", "off", "--wait"], in: controls)?.isEnabled == false
|
|
365
|
+
let stoppedControls = findAction(["restart", "stopped", "--if-stopped"], in: controls) != nil && findAction(["pause", "stopped", "--wait"], in: controls) == nil
|
|
366
|
+
controller.actionRunning = true; controller.actionNotice = "Applying change…"
|
|
367
|
+
let pending = controller.makeMenu()
|
|
368
|
+
let correctPending = findAction(switchArgs, in: pending)?.isEnabled == false && findAction(["refresh"], in: pending)?.isEnabled == false && findAction(["copy", "relay attach live"], in: pending)?.isEnabled == true
|
|
369
|
+
controller.actionRunning = false; controller.actionNotice = "Task is busy."
|
|
370
|
+
controller.current["at"] = 2000000001.0
|
|
371
|
+
let retainedResult = controller.makeMenu().items.contains { $0.title == "Task is busy." }
|
|
372
|
+
print("Account choices and stopped controls correct: \(correctChoices && stoppedControls)")
|
|
373
|
+
print("Pending action protected and result retained: \(correctPending && retainedResult)")
|
|
374
|
+
exit(normal != nil && attention != nil && liveUpdate && safeIdentity && correctTiming && correctChoices && stoppedControls && correctPending && retainedResult ? 0 : 1)
|
|
285
375
|
} else {
|
|
286
376
|
let app = NSApplication.shared
|
|
287
377
|
let delegate = MenuController(); app.delegate = delegate; app.run()
|
package/python/commands.py
CHANGED
|
@@ -103,6 +103,7 @@ def snapshot(state, include_inactive=False):
|
|
|
103
103
|
"enabled": options.get("enabled", True),
|
|
104
104
|
"priority": options.get("priority", 0),
|
|
105
105
|
"max_sessions": options.get("max_sessions", 0),
|
|
106
|
+
"models": options.get("models", []),
|
|
106
107
|
"sessions": sum(s["account"] == label for s in sessions),
|
|
107
108
|
"primary": windows.get("primary"),
|
|
108
109
|
"secondary": windows.get("secondary"),
|
|
@@ -119,6 +120,7 @@ def snapshot(state, include_inactive=False):
|
|
|
119
120
|
)
|
|
120
121
|
with pool.db() as db:
|
|
121
122
|
for session in sessions:
|
|
123
|
+
session["running"] = True
|
|
122
124
|
account = next(
|
|
123
125
|
(a for a in accounts if a["id"] == session.get("account")), {}
|
|
124
126
|
)
|
|
@@ -154,6 +156,8 @@ def snapshot(state, include_inactive=False):
|
|
|
154
156
|
if not include_inactive and job.get("state") != "needs_attention":
|
|
155
157
|
continue
|
|
156
158
|
prior = saved.get(ident, {})
|
|
159
|
+
account_id = prior.get("account") or job.get("first") or ""
|
|
160
|
+
account = next((a for a in accounts if a["id"] == account_id), {})
|
|
157
161
|
sessions.append(
|
|
158
162
|
{
|
|
159
163
|
"id": ident,
|
|
@@ -161,7 +165,9 @@ def snapshot(state, include_inactive=False):
|
|
|
161
165
|
"name": job.get("name", ident[:10]),
|
|
162
166
|
"cwd": job.get("cwd"),
|
|
163
167
|
"thread": job.get("resume"),
|
|
164
|
-
"account":
|
|
168
|
+
"account": account_id,
|
|
169
|
+
"account_name": account.get("name", account_id),
|
|
170
|
+
"account_email": account.get("email"),
|
|
165
171
|
"model": prior.get("model"),
|
|
166
172
|
"state": job.get("state", "unknown"),
|
|
167
173
|
"paused": prior.get("paused", False),
|
|
@@ -558,6 +564,13 @@ async def group(args, state, binary):
|
|
|
558
564
|
|
|
559
565
|
|
|
560
566
|
def add_parsers(sub):
|
|
567
|
+
completion = sub.add_parser(
|
|
568
|
+
"completion",
|
|
569
|
+
help="Generate shell completion for Relay and native Codex arguments",
|
|
570
|
+
)
|
|
571
|
+
completion.add_argument("shell", choices=["zsh", "bash"])
|
|
572
|
+
complete = sub.add_parser("_complete", help=__import__("argparse").SUPPRESS)
|
|
573
|
+
complete.add_argument("words", nargs="...")
|
|
561
574
|
preview = sub.add_parser(
|
|
562
575
|
"explain", help="Preview a native Codex launch without starting a task"
|
|
563
576
|
)
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Shell adapters and local completion. Never start Codex or read credentials here."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sqlite3
|
|
7
|
+
import tomllib
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def document(path):
|
|
11
|
+
try:
|
|
12
|
+
value = (
|
|
13
|
+
tomllib.loads(path.read_text())
|
|
14
|
+
if path.suffix == ".toml"
|
|
15
|
+
else json.loads(path.read_text())
|
|
16
|
+
)
|
|
17
|
+
return value if isinstance(value, dict) else {}
|
|
18
|
+
except (OSError, ValueError):
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def rows(state, query):
|
|
23
|
+
path = state / "pool.sqlite"
|
|
24
|
+
if not path.exists():
|
|
25
|
+
return []
|
|
26
|
+
try:
|
|
27
|
+
db = sqlite3.connect(
|
|
28
|
+
path.resolve().as_uri() + "?mode=ro", uri=True, timeout=0.15
|
|
29
|
+
)
|
|
30
|
+
try:
|
|
31
|
+
return db.execute(query).fetchall()
|
|
32
|
+
finally:
|
|
33
|
+
db.close()
|
|
34
|
+
except sqlite3.Error:
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def values(action, context, chosen, state):
|
|
39
|
+
from router import labels
|
|
40
|
+
from commands import account_identity
|
|
41
|
+
from settings import DEFAULTS
|
|
42
|
+
|
|
43
|
+
if action.choices is not None and not isinstance(
|
|
44
|
+
action, argparse._SubParsersAction
|
|
45
|
+
):
|
|
46
|
+
return list(action.choices)
|
|
47
|
+
name = action.dest
|
|
48
|
+
if name in {"first", "account", "label"}:
|
|
49
|
+
if name == "label" and context[-1:] == ["add"]:
|
|
50
|
+
return []
|
|
51
|
+
accounts = labels(state)
|
|
52
|
+
aliases = [account_identity(state, label).get("email") for label in accounts]
|
|
53
|
+
for label, raw in rows(state, "SELECT label,options FROM account_options"):
|
|
54
|
+
if label in accounts:
|
|
55
|
+
try:
|
|
56
|
+
aliases.append(json.loads(raw).get("name"))
|
|
57
|
+
except ValueError:
|
|
58
|
+
pass
|
|
59
|
+
# Do not offer an ambiguous email/display name.
|
|
60
|
+
return accounts + [
|
|
61
|
+
a for a in aliases if isinstance(a, str) and aliases.count(a) == 1
|
|
62
|
+
]
|
|
63
|
+
if name in {"session", "target", "sessions"}:
|
|
64
|
+
from pool import Pool
|
|
65
|
+
|
|
66
|
+
live = [
|
|
67
|
+
ident
|
|
68
|
+
for ident, pid in rows(state, "SELECT id,pid FROM sessions")
|
|
69
|
+
if Pool.alive(pid)
|
|
70
|
+
]
|
|
71
|
+
if name == "target":
|
|
72
|
+
return live + ["all"]
|
|
73
|
+
saved = [
|
|
74
|
+
p.name.removesuffix(".job.json")
|
|
75
|
+
for p in (state / "services").glob("*.job.json")
|
|
76
|
+
]
|
|
77
|
+
return live if name == "sessions" else live + saved
|
|
78
|
+
if name == "profile":
|
|
79
|
+
result = []
|
|
80
|
+
for path in [
|
|
81
|
+
state / "settings.json",
|
|
82
|
+
Path.cwd() / ".relay.toml",
|
|
83
|
+
Path.cwd() / ".codex-accounts.toml",
|
|
84
|
+
]:
|
|
85
|
+
profiles = document(path).get("profiles", {})
|
|
86
|
+
if isinstance(profiles, dict):
|
|
87
|
+
result.extend(profiles)
|
|
88
|
+
return result
|
|
89
|
+
if name == "key" and context[:1] == ["config"]:
|
|
90
|
+
return list(DEFAULTS)
|
|
91
|
+
if name == "value" and context[:1] == ["config"]:
|
|
92
|
+
key = chosen.get("key")
|
|
93
|
+
if key == "routing":
|
|
94
|
+
return ["balanced", "quota", "priority", "round-robin"]
|
|
95
|
+
if isinstance(DEFAULTS.get(key), bool):
|
|
96
|
+
return ["true", "false"]
|
|
97
|
+
if (
|
|
98
|
+
name == "name"
|
|
99
|
+
and context[:1] in (["workspace"], ["group"])
|
|
100
|
+
and context[-1:] != ["save"]
|
|
101
|
+
):
|
|
102
|
+
return list(
|
|
103
|
+
document(
|
|
104
|
+
state
|
|
105
|
+
/ ("workspaces.json" if context[0] == "workspace" else "groups.json")
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
if name == "topic":
|
|
109
|
+
from router import parser
|
|
110
|
+
|
|
111
|
+
sub = next(
|
|
112
|
+
a for a in parser()._actions if isinstance(a, argparse._SubParsersAction)
|
|
113
|
+
)
|
|
114
|
+
return [k for k in sub.choices if not k.startswith("_")] + ["codex"]
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def candidates(arguments, state):
|
|
119
|
+
"""Last word is the word being completed. @codex:N delegates after N words."""
|
|
120
|
+
from router import parser
|
|
121
|
+
|
|
122
|
+
arguments = list(arguments) or [""]
|
|
123
|
+
prefix, previous = arguments[-1], arguments[:-1]
|
|
124
|
+
current = parser()
|
|
125
|
+
context, chosen = [], {}
|
|
126
|
+
pending = None
|
|
127
|
+
positional = 0
|
|
128
|
+
options = True
|
|
129
|
+
for index, word in enumerate(previous):
|
|
130
|
+
if pending is not None:
|
|
131
|
+
chosen[pending.dest] = word
|
|
132
|
+
pending = None
|
|
133
|
+
continue
|
|
134
|
+
positionals = [a for a in current._actions if not a.option_strings]
|
|
135
|
+
action = positionals[positional] if positional < len(positionals) else None
|
|
136
|
+
if options and word == "--":
|
|
137
|
+
if action is not None and action.nargs == argparse.REMAINDER:
|
|
138
|
+
return [f"@codex:{index + 1}"]
|
|
139
|
+
options = False
|
|
140
|
+
continue
|
|
141
|
+
if options and word.startswith("-"):
|
|
142
|
+
flag, equal, value = word.partition("=")
|
|
143
|
+
option = current._option_string_actions.get(flag)
|
|
144
|
+
if option is None:
|
|
145
|
+
return []
|
|
146
|
+
if option.nargs != 0:
|
|
147
|
+
if equal:
|
|
148
|
+
chosen[option.dest] = value
|
|
149
|
+
else:
|
|
150
|
+
pending = option
|
|
151
|
+
continue
|
|
152
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
153
|
+
if word not in action.choices or word.startswith("_"):
|
|
154
|
+
return []
|
|
155
|
+
context.append(word)
|
|
156
|
+
current = action.choices[word]
|
|
157
|
+
positional = 0
|
|
158
|
+
elif action is not None:
|
|
159
|
+
if action.nargs == argparse.REMAINDER:
|
|
160
|
+
return [f"@codex:{index}"]
|
|
161
|
+
chosen[action.dest] = word
|
|
162
|
+
if action.nargs not in {"*", "+"}:
|
|
163
|
+
positional += 1
|
|
164
|
+
if pending is not None:
|
|
165
|
+
result = values(pending, context, chosen, state)
|
|
166
|
+
elif options and prefix.startswith("-"):
|
|
167
|
+
flag, equal, value = prefix.partition("=")
|
|
168
|
+
action = current._option_string_actions.get(flag)
|
|
169
|
+
if equal and action is not None and action.nargs != 0:
|
|
170
|
+
return sorted(
|
|
171
|
+
{
|
|
172
|
+
flag + "=" + item
|
|
173
|
+
for item in values(action, context, chosen, state)
|
|
174
|
+
if item.startswith(value) and "\n" not in item and "\r" not in item
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
result = list(current._option_string_actions)
|
|
178
|
+
else:
|
|
179
|
+
positionals = [a for a in current._actions if not a.option_strings]
|
|
180
|
+
action = positionals[positional] if positional < len(positionals) else None
|
|
181
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
182
|
+
result = [k for k in action.choices if not k.startswith("_")]
|
|
183
|
+
elif action is not None and action.nargs == argparse.REMAINDER:
|
|
184
|
+
# Show wrapper flags until the user enters native arguments or --.
|
|
185
|
+
result = list(current._option_string_actions)
|
|
186
|
+
if not prefix:
|
|
187
|
+
result.append("--")
|
|
188
|
+
else:
|
|
189
|
+
return [f"@codex:{len(previous)}"]
|
|
190
|
+
else:
|
|
191
|
+
result = values(action, context, chosen, state) if action else []
|
|
192
|
+
return sorted(
|
|
193
|
+
{
|
|
194
|
+
str(item)
|
|
195
|
+
for item in result
|
|
196
|
+
if isinstance(item, str)
|
|
197
|
+
and item.startswith(prefix)
|
|
198
|
+
and "\n" not in item
|
|
199
|
+
and "\r" not in item
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def script(shell):
|
|
205
|
+
if shell == "zsh":
|
|
206
|
+
return r"""# Relay completion. Load after compinit: source <(relay completion zsh)
|
|
207
|
+
_relay() {
|
|
208
|
+
local -a replies
|
|
209
|
+
replies=("${(@f)$(command relay _complete -- "${words[@]:1:$((CURRENT - 1))}")}")
|
|
210
|
+
if [[ ${replies[1]} == @codex:<-> ]]; then
|
|
211
|
+
local skip=${replies[1]#@codex:}
|
|
212
|
+
if (( ! $+functions[_codex] )); then
|
|
213
|
+
local native
|
|
214
|
+
native=$(command codex completion zsh 2>/dev/null) || { _files; return; }
|
|
215
|
+
eval "$native"
|
|
216
|
+
fi
|
|
217
|
+
local -a words=(codex "${words[@]:$((skip + 1))}")
|
|
218
|
+
local CURRENT=$((CURRENT - skip))
|
|
219
|
+
_codex
|
|
220
|
+
return
|
|
221
|
+
fi
|
|
222
|
+
[[ -n ${replies[1]} ]] && compadd -a replies || _files
|
|
223
|
+
}
|
|
224
|
+
compdef _relay relay"""
|
|
225
|
+
if shell == "bash":
|
|
226
|
+
return r"""# Relay completion: source <(relay completion bash)
|
|
227
|
+
_relay() {
|
|
228
|
+
local item skip native cur="${COMP_WORDS[COMP_CWORD]}"
|
|
229
|
+
local -a replies=()
|
|
230
|
+
while IFS= read -r item; do
|
|
231
|
+
[[ -n "$item" ]] && replies+=("$item")
|
|
232
|
+
done < <(command relay _complete -- "${COMP_WORDS[@]:1:COMP_CWORD}")
|
|
233
|
+
if [[ ${replies[0]} == @codex:* ]]; then
|
|
234
|
+
skip=${replies[0]#@codex:}
|
|
235
|
+
[[ "$skip" =~ ^[0-9]+$ ]] || return
|
|
236
|
+
if ! declare -F _codex >/dev/null; then
|
|
237
|
+
native=$(command codex completion bash 2>/dev/null) || return
|
|
238
|
+
eval "$native"
|
|
239
|
+
fi
|
|
240
|
+
local -a COMP_WORDS=(codex "${COMP_WORDS[@]:$((skip + 1))}")
|
|
241
|
+
local COMP_CWORD=$((COMP_CWORD - skip))
|
|
242
|
+
_codex codex "$cur" "${COMP_WORDS[COMP_CWORD-1]}"
|
|
243
|
+
return
|
|
244
|
+
fi
|
|
245
|
+
COMPREPLY=("${replies[@]}")
|
|
246
|
+
}
|
|
247
|
+
complete -o default -F _relay relay"""
|
|
248
|
+
raise ValueError("Choose zsh or bash")
|
package/python/pool.py
CHANGED
|
@@ -377,9 +377,20 @@ class Pool:
|
|
|
377
377
|
)
|
|
378
378
|
return [(r[0], r[1], json.loads(r[2])) for r in rows]
|
|
379
379
|
|
|
380
|
-
def ack(self, command, status):
|
|
380
|
+
def ack(self, command, status, *, reason=None):
|
|
381
381
|
with self.db() as db:
|
|
382
382
|
db.execute("UPDATE commands SET status=? WHERE id=?", (status, command))
|
|
383
|
+
if reason is not None:
|
|
384
|
+
row = db.execute(
|
|
385
|
+
"SELECT details FROM commands WHERE id=?", (command,)
|
|
386
|
+
).fetchone()
|
|
387
|
+
if row:
|
|
388
|
+
details = json.loads(row[0])
|
|
389
|
+
details["_rejection"] = reason
|
|
390
|
+
db.execute(
|
|
391
|
+
"UPDATE commands SET details=? WHERE id=?",
|
|
392
|
+
(json.dumps(details), command),
|
|
393
|
+
)
|
|
383
394
|
|
|
384
395
|
def claim(self, thread):
|
|
385
396
|
with self.db() as db:
|
package/python/router.py
CHANGED
|
@@ -24,7 +24,7 @@ from pool import Pool, blocked, reset_time, failure_kind, file_lock
|
|
|
24
24
|
from launch import translate, RESUME_FIELDS
|
|
25
25
|
import settings
|
|
26
26
|
|
|
27
|
-
VERSION = "0.
|
|
27
|
+
VERSION = "0.6.0"
|
|
28
28
|
_support = Path.home() / "Library/Application Support"
|
|
29
29
|
_legacy = _support / "Codex Accounts"
|
|
30
30
|
_default_state = _legacy if (_legacy / "accounts").exists() else _support / "Relay"
|
|
@@ -45,6 +45,21 @@ class RouterError(Exception):
|
|
|
45
45
|
"""A message safe to display; never include a server error or credential."""
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
CONTROL_REJECTIONS = {
|
|
49
|
+
"unknown_account": "That account is no longer enrolled. Check relay list.",
|
|
50
|
+
"busy": "Session is busy; switch after its current work and approvals finish.",
|
|
51
|
+
"account_unavailable": "Account is disabled, under maintenance, or unavailable for this model.",
|
|
52
|
+
"allowance_exhausted": "Account allowance is exhausted. Choose another account or wait for its reset.",
|
|
53
|
+
"account_full": "Account session capacity is full or its routing settings changed.",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ControlRejected(RouterError):
|
|
58
|
+
def __init__(self, reason):
|
|
59
|
+
self.reason = reason
|
|
60
|
+
super().__init__(CONTROL_REJECTIONS[reason])
|
|
61
|
+
|
|
62
|
+
|
|
48
63
|
class RecoveryTransition(Exception):
|
|
49
64
|
"""Return to the recovery driver without growing the Python call stack."""
|
|
50
65
|
|
|
@@ -682,9 +697,12 @@ class Controller:
|
|
|
682
697
|
try:
|
|
683
698
|
await self.control(op, params)
|
|
684
699
|
self.pool.ack(command, "done")
|
|
685
|
-
except Exception:
|
|
686
|
-
|
|
687
|
-
|
|
700
|
+
except Exception as error:
|
|
701
|
+
reason = (
|
|
702
|
+
error.reason if isinstance(error, ControlRejected) else None
|
|
703
|
+
)
|
|
704
|
+
self.pool.ack(command, "failed", reason=reason)
|
|
705
|
+
self.pool.record("control_failed", operation=op, reason=reason)
|
|
688
706
|
if self.lifecycle_check:
|
|
689
707
|
await self.lifecycle_check()
|
|
690
708
|
if self.stopping.is_set():
|
|
@@ -781,9 +799,9 @@ class Controller:
|
|
|
781
799
|
(i for i, a in enumerate(self.accounts) if a.label == label), None
|
|
782
800
|
)
|
|
783
801
|
if index is None:
|
|
784
|
-
raise
|
|
802
|
+
raise ControlRejected("unknown_account")
|
|
785
803
|
if self.active_threads or self.active_items or self.server_requests:
|
|
786
|
-
raise
|
|
804
|
+
raise ControlRejected("busy")
|
|
787
805
|
options = self.pool.options(label) if self.pool else {}
|
|
788
806
|
model = self.model_for(thread) if thread else None
|
|
789
807
|
if (
|
|
@@ -793,19 +811,17 @@ class Controller:
|
|
|
793
811
|
and model
|
|
794
812
|
and model not in options["models"]
|
|
795
813
|
):
|
|
796
|
-
raise
|
|
797
|
-
"Account is disabled, under maintenance, or unavailable for this model"
|
|
798
|
-
)
|
|
814
|
+
raise ControlRejected("account_unavailable")
|
|
799
815
|
if limited(await self.accounts[index].usage(force=True), model):
|
|
800
|
-
raise
|
|
816
|
+
raise ControlRejected("allowance_exhausted")
|
|
801
817
|
if self.pool and not self.pool.reserve(
|
|
802
818
|
label,
|
|
803
819
|
limit=options.get("max_sessions")
|
|
804
820
|
or self.preferences["max_sessions_per_account"],
|
|
805
821
|
):
|
|
806
|
-
raise
|
|
822
|
+
raise ControlRejected("account_full")
|
|
807
823
|
try:
|
|
808
|
-
await self.login(index)
|
|
824
|
+
await self.login(index, idle_only=True)
|
|
809
825
|
finally:
|
|
810
826
|
if self.pool:
|
|
811
827
|
self.pool.release_reservation()
|
|
@@ -910,19 +926,39 @@ class Controller:
|
|
|
910
926
|
def account(self):
|
|
911
927
|
return self.accounts[self.index]
|
|
912
928
|
|
|
913
|
-
async def login(self, index):
|
|
929
|
+
async def login(self, index, *, idle_only=False):
|
|
914
930
|
# A sent login cannot be undone by cancelling its waiter. Complete the
|
|
915
931
|
# state transition before allowing a new user turn to use this backend.
|
|
916
|
-
task = asyncio.create_task(self._login(index))
|
|
932
|
+
task = asyncio.create_task(self._login(index, idle_only=idle_only))
|
|
917
933
|
try:
|
|
918
934
|
return await asyncio.shield(task)
|
|
919
935
|
except asyncio.CancelledError:
|
|
920
936
|
await task
|
|
921
937
|
raise
|
|
922
938
|
|
|
923
|
-
async def _login(self, index):
|
|
939
|
+
async def _login(self, index, *, idle_only=False):
|
|
924
940
|
async with self.login_lock:
|
|
925
941
|
credentials = await self.accounts[index].credentials()
|
|
942
|
+
# An allowance or credential read can outlast the idle boundary.
|
|
943
|
+
# User turns use this same lock; validate again before sending tokens.
|
|
944
|
+
if idle_only and (
|
|
945
|
+
self.active_threads or self.active_items or self.server_requests
|
|
946
|
+
):
|
|
947
|
+
raise ControlRejected("busy")
|
|
948
|
+
if idle_only and self.pool:
|
|
949
|
+
options = self.pool.options(self.accounts[index].label)
|
|
950
|
+
thread = next(reversed(self.last_turn), None)
|
|
951
|
+
model = self.model_for(thread) if thread else None
|
|
952
|
+
if (
|
|
953
|
+
not options.get("enabled", True)
|
|
954
|
+
or options.get("maintenance")
|
|
955
|
+
or (
|
|
956
|
+
options.get("models")
|
|
957
|
+
and model
|
|
958
|
+
and model not in options["models"]
|
|
959
|
+
)
|
|
960
|
+
):
|
|
961
|
+
raise ControlRejected("account_unavailable")
|
|
926
962
|
await self.backend.call(
|
|
927
963
|
"account/login/start", {"type": "chatgptAuthTokens", **credentials}
|
|
928
964
|
)
|
|
@@ -1174,10 +1210,28 @@ class Controller:
|
|
|
1174
1210
|
params[key] = self.thread_settings[thread][key]
|
|
1175
1211
|
params["clientUserMessageId"] = str(uuid.uuid4())
|
|
1176
1212
|
baseline = self.turn_ids.get(thread) or "__unknown_before_submission__"
|
|
1177
|
-
self.pending_intents[thread] = {"baseline": baseline, "accepted": False}
|
|
1178
|
-
self.persist(thread, accepted=False, state="submitting", baseline=baseline)
|
|
1179
1213
|
try:
|
|
1180
|
-
|
|
1214
|
+
while True:
|
|
1215
|
+
async with self.login_lock:
|
|
1216
|
+
if not self.can_continue(thread, generation):
|
|
1217
|
+
return
|
|
1218
|
+
if not (
|
|
1219
|
+
self.active_threads or self.active_items or self.server_requests
|
|
1220
|
+
):
|
|
1221
|
+
self.pending_intents[thread] = {
|
|
1222
|
+
"baseline": baseline,
|
|
1223
|
+
"accepted": False,
|
|
1224
|
+
}
|
|
1225
|
+
self.persist(
|
|
1226
|
+
thread,
|
|
1227
|
+
accepted=False,
|
|
1228
|
+
state="submitting",
|
|
1229
|
+
baseline=baseline,
|
|
1230
|
+
)
|
|
1231
|
+
result = await self.backend.call("turn/start", params)
|
|
1232
|
+
break
|
|
1233
|
+
if not await self.wait_idle(generation):
|
|
1234
|
+
return
|
|
1181
1235
|
turn = result.get("turn", {})
|
|
1182
1236
|
if turn.get("id"):
|
|
1183
1237
|
self.turn_ids[thread] = turn["id"]
|
|
@@ -2242,6 +2296,18 @@ def selected_binary(args, state):
|
|
|
2242
2296
|
def main():
|
|
2243
2297
|
os.umask(0o077)
|
|
2244
2298
|
argv = sys.argv[1:]
|
|
2299
|
+
if argv[:1] == ["completion"]:
|
|
2300
|
+
import completion
|
|
2301
|
+
|
|
2302
|
+
args = parser().parse_args(argv)
|
|
2303
|
+
print(completion.script(args.shell))
|
|
2304
|
+
return 0
|
|
2305
|
+
if argv[:1] == ["_complete"]:
|
|
2306
|
+
import completion
|
|
2307
|
+
|
|
2308
|
+
words = argv[2:] if argv[1:2] == ["--"] else argv[1:]
|
|
2309
|
+
print("\n".join(completion.candidates(words, STATE)))
|
|
2310
|
+
return 0
|
|
2245
2311
|
if argv[:2] == ["help", "run"]:
|
|
2246
2312
|
argv = ["run", "--help"]
|
|
2247
2313
|
before_separator = argv[: argv.index("--")] if "--" in argv else argv
|
package/python/service.py
CHANGED
|
@@ -784,19 +784,22 @@ async def _serve(state, ident, binary):
|
|
|
784
784
|
|
|
785
785
|
|
|
786
786
|
async def wait_control(pool, command, timeout=15):
|
|
787
|
-
from router import RouterError
|
|
787
|
+
from router import RouterError, CONTROL_REJECTIONS
|
|
788
788
|
|
|
789
789
|
deadline = time.monotonic() + timeout
|
|
790
790
|
while time.monotonic() < deadline:
|
|
791
791
|
with pool.db() as db:
|
|
792
792
|
row = db.execute(
|
|
793
|
-
"SELECT status FROM commands WHERE id=?", (command,)
|
|
793
|
+
"SELECT status,details FROM commands WHERE id=?", (command,)
|
|
794
794
|
).fetchone()
|
|
795
795
|
if row and row[0] == "done":
|
|
796
796
|
return
|
|
797
797
|
if row and row[0] == "failed":
|
|
798
798
|
raise RouterError(
|
|
799
|
-
|
|
799
|
+
CONTROL_REJECTIONS.get(
|
|
800
|
+
json.loads(row[1]).get("_rejection"),
|
|
801
|
+
"Session rejected that action. Check status and events; no change was assumed.",
|
|
802
|
+
)
|
|
800
803
|
)
|
|
801
804
|
await asyncio.sleep(0.1)
|
|
802
805
|
raise RouterError("Session control timed out; check status before retrying.")
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Offline checks for completion and account controls across idle boundaries."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
import commands
|
|
14
|
+
import completion
|
|
15
|
+
from pool import Pool
|
|
16
|
+
from router import Controller, ControlRejected, RouterError
|
|
17
|
+
import service
|
|
18
|
+
from settings import atomic_json
|
|
19
|
+
from test_router import FakeAccount, FakeBackend, FakeClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SwitchBoundaries(unittest.IsolatedAsyncioTestCase):
|
|
23
|
+
async def test_target_disabled_during_credentials_is_not_selected(self):
|
|
24
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
25
|
+
pool = Pool(Path(folder))
|
|
26
|
+
pool.assign("one")
|
|
27
|
+
target = FakeAccount("two")
|
|
28
|
+
original = target.credentials
|
|
29
|
+
|
|
30
|
+
async def credentials(*args, **kwargs):
|
|
31
|
+
pool.set_options("two", enabled=False)
|
|
32
|
+
return await original(*args, **kwargs)
|
|
33
|
+
|
|
34
|
+
target.credentials = credentials
|
|
35
|
+
c = Controller([FakeAccount("one"), target], FakeBackend(), pool=pool)
|
|
36
|
+
try:
|
|
37
|
+
with self.assertRaisesRegex(ControlRejected, "disabled"):
|
|
38
|
+
await c.control("switch", {"account": "two"})
|
|
39
|
+
self.assertEqual(c.index, 0)
|
|
40
|
+
self.assertFalse(c.backend.calls)
|
|
41
|
+
finally:
|
|
42
|
+
pool.close()
|
|
43
|
+
|
|
44
|
+
async def test_running_worker_returns_reason_without_replacing_its_work_status(
|
|
45
|
+
self,
|
|
46
|
+
):
|
|
47
|
+
from test_services import ServiceTests, wait_for
|
|
48
|
+
|
|
49
|
+
case = ServiceTests("test_background_work_completes_without_frontend")
|
|
50
|
+
await case.asyncSetUp()
|
|
51
|
+
pool = Pool(case.state)
|
|
52
|
+
try:
|
|
53
|
+
await wait_for(lambda: json.loads(case.job.read_text()).get("resume"))
|
|
54
|
+
command = pool.command(case.ident, "switch", {"account": "not-enrolled"})
|
|
55
|
+
with self.assertRaisesRegex(RouterError, "no longer enrolled"):
|
|
56
|
+
await service.wait_control(pool, command, timeout=5)
|
|
57
|
+
self.assertNotEqual(pool.detail()[0].get("state"), "control_failed")
|
|
58
|
+
finally:
|
|
59
|
+
pool.close()
|
|
60
|
+
await case.asyncTearDown()
|
|
61
|
+
|
|
62
|
+
async def test_work_arriving_during_allowance_or_credentials_prevents_switch(self):
|
|
63
|
+
for operation in ("usage", "credentials"):
|
|
64
|
+
for field, marker in (
|
|
65
|
+
("active_threads", "thread"),
|
|
66
|
+
("active_items", "tool"),
|
|
67
|
+
("server_requests", 7),
|
|
68
|
+
):
|
|
69
|
+
with (
|
|
70
|
+
self.subTest(operation=operation, field=field),
|
|
71
|
+
tempfile.TemporaryDirectory() as folder,
|
|
72
|
+
):
|
|
73
|
+
pool = Pool(Path(folder))
|
|
74
|
+
pool.assign("one")
|
|
75
|
+
account = FakeAccount("two")
|
|
76
|
+
original = getattr(account, operation)
|
|
77
|
+
entered, release = asyncio.Event(), asyncio.Event()
|
|
78
|
+
|
|
79
|
+
async def delayed(*args, **kwargs):
|
|
80
|
+
entered.set()
|
|
81
|
+
await release.wait()
|
|
82
|
+
return await original(*args, **kwargs)
|
|
83
|
+
|
|
84
|
+
setattr(account, operation, delayed)
|
|
85
|
+
backend = FakeBackend()
|
|
86
|
+
c = Controller([FakeAccount("one"), account], backend, pool=pool)
|
|
87
|
+
task = asyncio.create_task(c.control("switch", {"account": "two"}))
|
|
88
|
+
try:
|
|
89
|
+
await asyncio.wait_for(entered.wait(), 1)
|
|
90
|
+
getattr(c, field).add(marker)
|
|
91
|
+
release.set()
|
|
92
|
+
with self.assertRaisesRegex(ControlRejected, "busy"):
|
|
93
|
+
await asyncio.wait_for(task, 1)
|
|
94
|
+
self.assertEqual(c.account.label, "one")
|
|
95
|
+
self.assertFalse(
|
|
96
|
+
any(m == "account/login/start" for m, _ in backend.calls)
|
|
97
|
+
)
|
|
98
|
+
with pool.db() as db:
|
|
99
|
+
self.assertEqual(
|
|
100
|
+
db.execute(
|
|
101
|
+
"SELECT count(*) FROM reservations"
|
|
102
|
+
).fetchone()[0],
|
|
103
|
+
0,
|
|
104
|
+
)
|
|
105
|
+
finally:
|
|
106
|
+
task.cancel()
|
|
107
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
108
|
+
pool.close()
|
|
109
|
+
|
|
110
|
+
async def test_switch_rechecks_after_waiting_for_login_lock(self):
|
|
111
|
+
c = Controller([FakeAccount("one"), FakeAccount("two")], FakeBackend())
|
|
112
|
+
await c.login_lock.acquire()
|
|
113
|
+
task = asyncio.create_task(c.control("switch", {"account": "two"}))
|
|
114
|
+
await asyncio.sleep(0)
|
|
115
|
+
c.active_threads.add("new-work")
|
|
116
|
+
c.login_lock.release()
|
|
117
|
+
with self.assertRaises(ControlRejected):
|
|
118
|
+
await asyncio.wait_for(task, 1)
|
|
119
|
+
self.assertEqual(c.index, 0)
|
|
120
|
+
|
|
121
|
+
async def test_automatic_continuation_waits_for_account_transition(self):
|
|
122
|
+
c = Controller([FakeAccount("one")], FakeBackend())
|
|
123
|
+
c.client = FakeClient()
|
|
124
|
+
await c.login_lock.acquire()
|
|
125
|
+
task = asyncio.create_task(c.continue_thread("thread", 0))
|
|
126
|
+
await asyncio.sleep(0)
|
|
127
|
+
self.assertFalse(any(m == "turn/start" for m, _ in c.backend.calls))
|
|
128
|
+
c.login_lock.release()
|
|
129
|
+
await asyncio.wait_for(task, 1)
|
|
130
|
+
self.assertEqual(sum(m == "turn/start" for m, _ in c.backend.calls), 1)
|
|
131
|
+
|
|
132
|
+
async def test_control_rejection_is_specific_but_unknown_details_are_not_echoed(
|
|
133
|
+
self,
|
|
134
|
+
):
|
|
135
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
136
|
+
pool = Pool(Path(folder))
|
|
137
|
+
for reason, expected in (
|
|
138
|
+
("busy", "Session is busy"),
|
|
139
|
+
("PRIVATE_TEST_SECRET", "Session rejected"),
|
|
140
|
+
(None, "Session rejected"),
|
|
141
|
+
):
|
|
142
|
+
command = pool.command("worker", "switch", {"account": "two"})
|
|
143
|
+
pool.ack(command, "failed", reason=reason)
|
|
144
|
+
with self.assertRaisesRegex(RouterError, expected) as error:
|
|
145
|
+
await service.wait_control(pool, command)
|
|
146
|
+
self.assertNotIn("PRIVATE_TEST_SECRET", str(error.exception))
|
|
147
|
+
pool.close()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class CompletionTests(unittest.TestCase):
|
|
151
|
+
def setUp(self):
|
|
152
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
153
|
+
self.root = Path(self.tmp.name)
|
|
154
|
+
self.state = self.root / "state"
|
|
155
|
+
home = self.state / "accounts" / "personal"
|
|
156
|
+
home.mkdir(parents=True)
|
|
157
|
+
atomic_json(home / "enrolled", {})
|
|
158
|
+
atomic_json(home / "identity.json", {"email": "person@example.invalid"})
|
|
159
|
+
atomic_json(self.state / "workspaces.json", {"daily work": ["saved"]})
|
|
160
|
+
atomic_json(self.state / "settings.json", {"profiles": {"quiet": {}}})
|
|
161
|
+
atomic_json(
|
|
162
|
+
self.state / "services" / "saved.job.json",
|
|
163
|
+
{"id": "saved", "first": "personal", "state": "needs_attention"},
|
|
164
|
+
)
|
|
165
|
+
self.pool = Pool(self.state)
|
|
166
|
+
self.pool.session = "live-test"
|
|
167
|
+
self.pool.assign("personal")
|
|
168
|
+
|
|
169
|
+
def tearDown(self):
|
|
170
|
+
self.pool.close()
|
|
171
|
+
self.tmp.cleanup()
|
|
172
|
+
|
|
173
|
+
def complete(self, *args):
|
|
174
|
+
with (
|
|
175
|
+
patch(
|
|
176
|
+
"router.read_keychain",
|
|
177
|
+
side_effect=AssertionError("No credential reads"),
|
|
178
|
+
),
|
|
179
|
+
patch("router.codex_path", side_effect=AssertionError("No native process")),
|
|
180
|
+
):
|
|
181
|
+
return completion.candidates(args, self.state)
|
|
182
|
+
|
|
183
|
+
def test_parser_commands_options_and_dynamic_values(self):
|
|
184
|
+
self.assertIn("completion", self.complete(""))
|
|
185
|
+
self.assertFalse(any(x.startswith("_") for x in self.complete("")))
|
|
186
|
+
self.assertIn("reauth", self.complete("account", "re"))
|
|
187
|
+
self.assertEqual(self.complete("completion", "z"), ["zsh"])
|
|
188
|
+
self.assertEqual(
|
|
189
|
+
self.complete("switch", "--account", "pe"),
|
|
190
|
+
["person@example.invalid", "personal"],
|
|
191
|
+
)
|
|
192
|
+
self.assertEqual(
|
|
193
|
+
self.complete("switch", "--account=pers"),
|
|
194
|
+
["--account=person@example.invalid", "--account=personal"],
|
|
195
|
+
)
|
|
196
|
+
self.assertEqual(self.complete("run", "--profile", ""), ["quiet"])
|
|
197
|
+
self.assertEqual(self.complete("workspace", "restore", ""), ["daily work"])
|
|
198
|
+
self.assertEqual(self.complete("pause", "live"), ["live-test"])
|
|
199
|
+
self.assertIn("saved", self.complete("restart", ""))
|
|
200
|
+
self.assertEqual(
|
|
201
|
+
self.complete("config", "notifications", ""), ["false", "true"]
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def test_native_boundaries_consume_wrapper_values(self):
|
|
205
|
+
for args, skip in (
|
|
206
|
+
(["run", "--", "resume", "--la"], 2),
|
|
207
|
+
(["run", "--name", "--", "--", "resume", ""], 4),
|
|
208
|
+
(["run", "resume", ""], 1),
|
|
209
|
+
(["attach", "saved", "--", ""], 3),
|
|
210
|
+
(["explain", "--", ""], 2),
|
|
211
|
+
):
|
|
212
|
+
self.assertEqual(self.complete(*args), [f"@codex:{skip}"])
|
|
213
|
+
self.assertIn("--require-recovery", self.complete("run", "--"))
|
|
214
|
+
|
|
215
|
+
def test_stopped_sessions_keep_account_identity_and_correct_liveness(self):
|
|
216
|
+
value = commands.snapshot(self.state)
|
|
217
|
+
saved = next(s for s in value["sessions"] if s["id"] == "saved")
|
|
218
|
+
self.assertFalse(saved["running"])
|
|
219
|
+
self.assertEqual(saved["account_name"], "person@example.invalid")
|
|
220
|
+
self.assertTrue(
|
|
221
|
+
next(s for s in value["sessions"] if s["id"] == "live-test")["running"]
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def test_real_shell_adapters_preserve_spaces_and_delegate_native_arguments(self):
|
|
225
|
+
bin_dir = self.root / "bin"
|
|
226
|
+
bin_dir.mkdir()
|
|
227
|
+
relay = bin_dir / "relay"
|
|
228
|
+
relay.write_text(
|
|
229
|
+
"#!"
|
|
230
|
+
+ sys.executable
|
|
231
|
+
+ "\nimport sys\nfrom pathlib import Path\nfrom completion import candidates\nprint('\\n'.join(candidates(sys.argv[3:], Path("
|
|
232
|
+
+ repr(str(self.state))
|
|
233
|
+
+ "))))\n"
|
|
234
|
+
)
|
|
235
|
+
relay.chmod(0o700)
|
|
236
|
+
env = {
|
|
237
|
+
**os.environ,
|
|
238
|
+
"PATH": str(bin_dir) + os.pathsep + os.environ.get("PATH", ""),
|
|
239
|
+
"PYTHONPATH": str(Path(__file__).resolve().parent.parent / "python"),
|
|
240
|
+
}
|
|
241
|
+
for shell in ("bash", "zsh"):
|
|
242
|
+
script = self.root / ("completion." + shell)
|
|
243
|
+
script.write_text(completion.script(shell))
|
|
244
|
+
if shell == "bash":
|
|
245
|
+
command = """source "$1"
|
|
246
|
+
COMP_WORDS=(relay workspace restore ""); COMP_CWORD=3
|
|
247
|
+
_relay; printf '%s\n' "${COMPREPLY[@]}"
|
|
248
|
+
_codex() { COMPREPLY=("native:${COMP_WORDS[*]}:${COMP_CWORD}"); }
|
|
249
|
+
COMP_WORDS=(relay run -- resume --la); COMP_CWORD=4
|
|
250
|
+
_relay; printf '%s\n' "${COMPREPLY[@]}"
|
|
251
|
+
"""
|
|
252
|
+
else:
|
|
253
|
+
command = """autoload -Uz compinit; compinit -D
|
|
254
|
+
source "$1"
|
|
255
|
+
_files() { return 1; }
|
|
256
|
+
compadd() { local -a result; result=("${(@P)2}"); printf '%s\n' "${result[@]}"; }
|
|
257
|
+
words=(relay workspace restore ""); CURRENT=4; _relay
|
|
258
|
+
_codex() { print -r -- "native:${words[*]}:${CURRENT}"; }
|
|
259
|
+
words=(relay run -- resume --la); CURRENT=5; _relay
|
|
260
|
+
"""
|
|
261
|
+
result = subprocess.run(
|
|
262
|
+
[
|
|
263
|
+
"/bin/" + shell,
|
|
264
|
+
"-f" if shell == "zsh" else "--noprofile",
|
|
265
|
+
"-c",
|
|
266
|
+
command,
|
|
267
|
+
"test",
|
|
268
|
+
str(script),
|
|
269
|
+
],
|
|
270
|
+
capture_output=True,
|
|
271
|
+
text=True,
|
|
272
|
+
env=env,
|
|
273
|
+
)
|
|
274
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
275
|
+
self.assertEqual(
|
|
276
|
+
result.stdout.splitlines(),
|
|
277
|
+
[
|
|
278
|
+
"daily work",
|
|
279
|
+
"native:codex resume --la:2"
|
|
280
|
+
if shell == "bash"
|
|
281
|
+
else "native:codex resume --la:3",
|
|
282
|
+
],
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
unittest.main()
|