relay-codex 0.5.3 → 0.6.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 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 run(command, args) {
95
- const result = spawnSync(command, args, { stdio: 'inherit' });
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
- run(python, ['-m', 'venv', path.join(destination, '.venv')]);
118
- run(interpreter, ['-m', 'pip', 'install', '--disable-pip-version-check', '-r', path.join(destination, 'requirements.lock')]);
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,20 @@ 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
+ Accounts assigned to live Relay sessions have a checkmark and an “In use” label;
113
+ shared accounts also show their session count. To switch, open the Relay icon,
114
+ then your session, then **Switch account**, and choose the account email. The
115
+ current choice is marked **Current**. The menu tracks tasks started through Relay.
116
+ Each session menu can switch to another enabled account after work and approvals
117
+ finish. The worker checks allowance and session capacity before switching, then
118
+ checks again that the session is idle before changing its login. Rejected actions
119
+ show their reason. Pending menu actions cannot be submitted twice, and results
120
+ remain visible until dismissed or another action starts. Stopped workers offer
121
+ Restart; restarting retains any review hold.
122
+
123
+ Account menus can disable or enable future routing. Disabling an account does not
124
+ interrupt work already using it. The account chooser marks the current account
125
+ and disables accounts excluded by their saved routing/model settings.
102
126
  It opens at Mac login after `relay menubar`; disable that in its menu or with
103
127
  `relay menubar --no-startup --configure-only`.
104
128
 
@@ -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.5.3
12
- npx --yes github:blackspider-ops/relay#v0.5.3 --version
11
+ npm install -g github:blackspider-ops/relay#v0.6.1
12
+ npx --yes github:blackspider-ops/relay#v0.6.1 --version
13
13
  ```
14
14
 
15
15
  `npx` runs packages; it is not a separate publishing service. Publishing
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-codex",
3
- "version": "0.5.3",
3
+ "version": "0.6.1",
4
4
  "description": "Keep your Codex sessions moving between your own accounts.",
5
5
  "license": "MIT",
6
6
  "author": "blackspider-ops",
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,15 +175,33 @@ 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]] ?? []
189
+ let liveSessions = sessions.filter { $0["running"] as? Bool ?? true }
176
190
  let menu = NSMenu(); menu.delegate = self
191
+ menu.autoenablesItems = false
177
192
  text("Relay · \(sessions.count) sessions", into: menu)
178
193
  if let message = message { text(message, into: menu) }
194
+ if let notice = actionNotice {
195
+ text(notice, into: menu)
196
+ if !actionRunning { action("Dismiss message", args: ["dismiss"], into: menu) }
197
+ }
179
198
  if accounts.isEmpty { text("Add an account with relay setup in Terminal.", into: menu) }
199
+ if liveSessions.isEmpty && !accounts.isEmpty { text("No Relay sessions running. Start with relay run.", into: menu) }
180
200
  for account in accounts {
181
201
  let name = account["name"] as? String ?? "Account"
202
+ let accountID = account["id"] as? String ?? ""
203
+ let assigned = liveSessions.filter { $0["account"] as? String == accountID }
204
+ let inUse = assigned.isEmpty ? "" : assigned.count == 1 ? " · In use" : " · In use by \(assigned.count) sessions"
182
205
  let primary = account["primary"] as? [String: Any]
183
206
  let short = account["short_remaining"] as? String ?? (primary?["usedPercent"] as? NSNumber).map { "\(max(0, 100 - $0.intValue))%" } ?? "?"
184
207
  let remaining = short == "?" ? "allowance unknown" : short + " left"
@@ -186,7 +209,15 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
186
209
  let weekly = account["weekly_remaining"] as? String ?? (secondary?["usedPercent"] as? NSNumber).map { "\(max(0, 100 - $0.intValue))%" } ?? "?"
187
210
  let label = (account["enabled"] as? Bool ?? true) ? "Short \(short) · Week \(weekly)" : "disabled"
188
211
  let submenu = NSMenu()
212
+ submenu.autoenablesItems = false
189
213
  if let email = account["email"] as? String { text(email, into: submenu) }
214
+ if !assigned.isEmpty {
215
+ for session in assigned {
216
+ let sessionName = session["name"] as? String ?? String((session["id"] as? String ?? "").prefix(10))
217
+ text("In use by: \(sessionName)", into: submenu)
218
+ }
219
+ text("To switch: open that session → Switch account.", into: submenu)
220
+ }
190
221
  text("Short window: \(remaining)", into: submenu)
191
222
  if weekly != "?" { text("Weekly window: \(weekly) left", into: submenu) }
192
223
  for row in MenuController.accountTimingRows(account) {
@@ -195,7 +226,14 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
195
226
  }
196
227
  let lastChecked = MenuController.timestamp(account["usage_checked_at"]).map { date($0) } ?? "Not checked yet"
197
228
  text("Last checked: \(lastChecked)", into: submenu)
198
- let entry = NSMenuItem(title: "\(name) · \(label)", action: nil, keyEquivalent: "")
229
+ if let id = account["id"] as? String {
230
+ submenu.addItem(.separator())
231
+ let enabled = account["enabled"] as? Bool ?? true
232
+ action(enabled ? "Disable for future routing" : "Enable for routing", args: ["account", enabled ? "disable" : "enable", id], into: submenu)
233
+ text("Current tasks keep running.", into: submenu)
234
+ }
235
+ let entry = NSMenuItem(title: "\(name)\(inUse) · \(label)", action: nil, keyEquivalent: "")
236
+ entry.state = assigned.isEmpty ? .off : .on
199
237
  entry.identifier = NSUserInterfaceItemIdentifier("account:" + (account["id"] as? String ?? name))
200
238
  entry.submenu = submenu; menu.addItem(entry)
201
239
  }
@@ -205,14 +243,37 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
205
243
  let name = session["name"] as? String ?? String(id.prefix(10))
206
244
  let status = (session["state"] as? String ?? "unknown").replacingOccurrences(of: "_", with: " ")
207
245
  let submenu = NSMenu()
246
+ submenu.autoenablesItems = false
208
247
  text("Account: \(session["account_name"] as? String ?? session["account"] as? String ?? "unknown")", into: submenu)
209
248
  text("Model: \(session["model"] as? String ?? "unknown")", into: submenu)
210
249
  if let next = MenuController.timestamp(session["next_wake"]) { countdown("Next action", value: next, into: submenu) }
211
250
  if let reason = session["reason"] as? String { text(reason.replacingOccurrences(of: "_", with: " "), into: submenu) }
212
251
  action("Copy reconnect command", args: ["copy", "relay attach " + id], into: submenu)
213
- action("Pause recovery", args: ["pause", id, "--wait"], into: submenu)
214
- action("Resume recovery", args: ["unpause", id, "--wait"], into: submenu)
215
- action("Review and retry…", args: ["confirm-retry", id], into: submenu)
252
+ if session["running"] as? Bool ?? true {
253
+ let paused = session["paused"] as? Bool ?? false
254
+ action(paused ? "Resume recovery" : "Pause recovery", args: [paused ? "unpause" : "pause", id, "--wait"], into: submenu)
255
+ let choices = NSMenu(); choices.autoenablesItems = false
256
+ for account in accounts {
257
+ guard let accountID = account["id"] as? String else { continue }
258
+ let selected = accountID == session["account"] as? String
259
+ let models = account["models"] as? [String] ?? []
260
+ let model = session["model"] as? String
261
+ let modelAllowed = models.isEmpty || model == nil || models.contains(model!)
262
+ let available = (account["enabled"] as? Bool ?? true) && !(account["maintenance"] as? Bool ?? false) && modelAllowed
263
+ let name = account["name"] as? String ?? accountID
264
+ let choice = action(name + (selected ? " · Current" : available ? "" : " · unavailable"), args: ["switch", id, "--account", accountID, "--wait"], into: choices)
265
+ choice.state = selected ? .on : .off
266
+ choice.isEnabled = !selected && available && !actionRunning
267
+ }
268
+ text("Switch after current work and approvals finish.", into: choices)
269
+ let switchItem = NSMenuItem(title: "Switch account", action: nil, keyEquivalent: "")
270
+ switchItem.identifier = NSUserInterfaceItemIdentifier("switch:" + id)
271
+ switchItem.submenu = choices; submenu.addItem(switchItem)
272
+ action("Review and retry…", args: ["confirm-retry", id], into: submenu)
273
+ } else {
274
+ text("Worker stopped. Restart to reconnect; review holds are retained.", into: submenu)
275
+ action("Restart session", args: ["restart", id, "--if-stopped"], into: submenu)
276
+ }
216
277
  let entry = NSMenuItem(title: "\(name) · \(status)", action: nil, keyEquivalent: "")
217
278
  entry.identifier = NSUserInterfaceItemIdentifier("session:" + id)
218
279
  entry.submenu = submenu; menu.addItem(entry)
@@ -221,12 +282,11 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
221
282
  action("Copy dashboard command", args: ["copy", "relay status --watch"], into: menu)
222
283
  action("Check allowance now", args: ["refresh"], into: menu)
223
284
  let login = NSMenuItem(title: "Open at login", action: #selector(toggleLogin), keyEquivalent: ""); login.target = self
285
+ login.isEnabled = !actionRunning
224
286
  login.state = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist") ? .on : .off
225
287
  menu.addItem(login)
226
288
  let quit = NSMenuItem(title: "Quit Relay menu", action: #selector(quitMenu), keyEquivalent: "q"); quit.target = self; menu.addItem(quit)
227
- updateTimes(menu)
228
- if let visible = item.menu { MenuController.synchronise(visible, with: menu) }
229
- else { item.menu = menu }
289
+ return menu
230
290
  }
231
291
  func menuWillOpen(_ menu: NSMenu) {
232
292
  refreshSnapshot()
@@ -238,6 +298,8 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
238
298
  @objc func runAction(_ sender: NSMenuItem) {
239
299
  guard var args = sender.representedObject as? [String], !args.isEmpty else { return }
240
300
  if args[0] == "copy" { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(args[1], forType: .string); return }
301
+ if args[0] == "dismiss" { actionNotice = nil; actionFailed = false; render(); return }
302
+ guard !actionRunning else { return }
241
303
  if args[0] == "confirm-retry" {
242
304
  let alert = NSAlert(); alert.messageText = "Continue this task?"; alert.informativeText = "Review any interrupted command or external action in the conversation before continuing."
243
305
  alert.addButton(withTitle: "Continue task"); alert.addButton(withTitle: "Cancel")
@@ -245,19 +307,39 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
245
307
  guard alert.runModal() == .alertFirstButtonReturn else { return }
246
308
  args = ["retry", args[1], "--wait"]
247
309
  }
310
+ actionRunning = true; actionFailed = false; actionNotice = "Applying change…"; render()
248
311
  let proc = process(args); let pipe = Pipe(); proc.standardOutput = FileHandle.nullDevice; proc.standardError = pipe
249
312
  DispatchQueue.global(qos: .utility).async { [weak self] in
250
313
  do {
251
314
  try proc.run(); let bytes = pipe.fileHandleForReading.readDataToEndOfFile(); proc.waitUntilExit()
252
- if proc.terminationStatus != 0 { DispatchQueue.main.async { self?.message = String(data: bytes, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Action failed. Check relay events."; self?.render() } }
253
- } catch { DispatchQueue.main.async { self?.message = "Could not run that action."; self?.render() } }
315
+ let failed = proc.terminationStatus != 0
316
+ let detail = String(data: bytes, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
317
+ DispatchQueue.main.async {
318
+ self?.actionRunning = false; self?.actionFailed = failed
319
+ self?.actionNotice = failed ? (detail.isEmpty ? "Action failed. Check relay events." : detail) : MenuController.successMessage(args)
320
+ self?.render(); self?.refreshSnapshot()
321
+ }
322
+ } catch { DispatchQueue.main.async { self?.actionRunning = false; self?.actionFailed = true; self?.actionNotice = "Could not run that action."; self?.render() } }
323
+ }
324
+ }
325
+ static func successMessage(_ args: [String]) -> String {
326
+ switch args.first {
327
+ case "switch": return "Account switched."
328
+ case "pause": return "Recovery paused. Current work can finish."
329
+ case "unpause": return "Recovery resumed."
330
+ case "retry": return "Task allowed to continue."
331
+ case "restart": return "Session restarted."
332
+ case "refresh": return "Allowance check finished. See each account’s last checked time."
333
+ case "account": return args.dropFirst().first == "disable" ? "Account disabled for future routing." : "Account enabled for routing."
334
+ default: return "Change applied."
254
335
  }
255
336
  }
256
337
  @objc func toggleLogin() {
257
338
  let enabled = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist")
258
- runCommand(enabled ? ["menubar", "--no-startup", "--configure-only"] : ["menubar", "--configure-only"])
339
+ let sender = NSMenuItem()
340
+ sender.representedObject = enabled ? ["menubar", "--no-startup", "--configure-only"] : ["menubar", "--configure-only"]
341
+ runAction(sender)
259
342
  }
260
- func runCommand(_ args: [String]) { let proc = process(args); proc.standardOutput = FileHandle.nullDevice; proc.standardError = FileHandle.nullDevice; try? proc.run() }
261
343
  @objc func quitMenu() { stopping = true; reconnect?.cancel(); stream?.terminate(); NSApp.terminate(nil) }
262
344
  func applicationWillTerminate(_ notification: Notification) { stopping = true; stream?.terminate() }
263
345
  }
@@ -281,7 +363,43 @@ if CommandLine.arguments.contains("--self-check") {
281
363
  let scheduled = MenuController.accountTimingRows(["next_check": 2000200000.0])
282
364
  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
365
  print("Empty retry schedule handled: \(correctTiming)")
284
- exit(normal != nil && attention != nil && liveUpdate && safeIdentity && correctTiming ? 0 : 1)
366
+ func findAction(_ args: [String], in menu: NSMenu) -> NSMenuItem? {
367
+ for entry in menu.items {
368
+ if entry.representedObject as? [String] == args { return entry }
369
+ if let child = entry.submenu, let found = findAction(args, in: child) { return found }
370
+ }
371
+ return nil
372
+ }
373
+ let controller = MenuController()
374
+ 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]]]
375
+ let controls = controller.makeMenu()
376
+ let switchArgs = ["switch", "live", "--account", "two", "--wait"]
377
+ 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
378
+ let stoppedControls = findAction(["restart", "stopped", "--if-stopped"], in: controls) != nil && findAction(["pause", "stopped", "--wait"], in: controls) == nil
379
+ let activeMarker = controls.items.first { $0.identifier?.rawValue == "account:one" }
380
+ let currentChoice = findAction(["switch", "live", "--account", "one", "--wait"], in: controls)
381
+ let correctMarker = activeMarker?.state == .on && activeMarker?.title.contains("In use") == true && currentChoice?.title.contains("Current") == true
382
+ let originalSessions = controller.current["sessions"]
383
+ controller.current["sessions"] = [["id": "a", "account": "one", "running": true], ["id": "b", "account": "one", "running": true], ["id": "c", "account": "two", "running": true], ["id": "d", "account": "off", "running": false]]
384
+ let multiple = controller.makeMenu()
385
+ let one = multiple.items.first { $0.identifier?.rawValue == "account:one" }
386
+ let two = multiple.items.first { $0.identifier?.rawValue == "account:two" }
387
+ let off = multiple.items.first { $0.identifier?.rawValue == "account:off" }
388
+ let correctMultiple = one?.title.contains("In use by 2 sessions") == true && two?.state == .on && off?.state == .off
389
+ controller.current["sessions"] = [["id": "stopped", "account": "one", "running": false]]
390
+ let empty = controller.makeMenu()
391
+ let correctEmpty = empty.items.contains { $0.title.contains("No Relay sessions running") } && empty.items.first { $0.identifier?.rawValue == "account:one" }?.state == .off
392
+ controller.current["sessions"] = originalSessions
393
+ controller.actionRunning = true; controller.actionNotice = "Applying change…"
394
+ let pending = controller.makeMenu()
395
+ let correctPending = findAction(switchArgs, in: pending)?.isEnabled == false && findAction(["refresh"], in: pending)?.isEnabled == false && findAction(["copy", "relay attach live"], in: pending)?.isEnabled == true
396
+ controller.actionRunning = false; controller.actionNotice = "Task is busy."
397
+ controller.current["at"] = 2000000001.0
398
+ let retainedResult = controller.makeMenu().items.contains { $0.title == "Task is busy." }
399
+ print("Account choices and stopped controls correct: \(correctChoices && stoppedControls)")
400
+ print("Pending action protected and result retained: \(correctPending && retainedResult)")
401
+ print("In-use accounts and current choice marked: \(correctMarker && correctMultiple && correctEmpty)")
402
+ exit(normal != nil && attention != nil && liveUpdate && safeIdentity && correctTiming && correctChoices && stoppedControls && correctPending && retainedResult && correctMarker && correctMultiple && correctEmpty ? 0 : 1)
285
403
  } else {
286
404
  let app = NSApplication.shared
287
405
  let delegate = MenuController(); app.delegate = delegate; app.run()
@@ -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": prior.get("account") or job.get("first") or "",
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.5.3"
27
+ VERSION = "0.6.1"
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
- self.pool.ack(command, "failed")
687
- self.note("control_failed", operation=op)
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 RouterError("Unknown account")
802
+ raise ControlRejected("unknown_account")
785
803
  if self.active_threads or self.active_items or self.server_requests:
786
- raise RouterError("Session is busy; switch at an idle boundary")
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 RouterError(
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 RouterError("Account allowance is exhausted")
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 RouterError("Account capacity is full or maintenance has started")
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
- result = await self.backend.call("turn/start", params)
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
- "Session rejected that action. Check status and events; no change was assumed."
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()