relay-codex 0.5.2 → 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 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,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
 
@@ -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.2
12
- npx --yes github:blackspider-ops/relay#v0.5.2 --version
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
@@ -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.2",
3
+ "version": "0.6.0",
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,13 +125,35 @@ 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
134
+ }
135
+ struct TimingRow {
136
+ let title: String
137
+ let at: Double?
138
+ }
139
+ static func timestamp(_ value: Any?) -> Double? {
140
+ guard let seconds = value as? Double, seconds.isFinite, seconds > 0 else { return nil }
141
+ return seconds
142
+ }
143
+ static func accountTimingRows(_ account: [String: Any]) -> [TimingRow] {
144
+ var rows: [TimingRow] = []
145
+ if let primary = account["primary"] as? [String: Any], let at = timestamp(primary["resetsAt"]) {
146
+ rows.append(TimingRow(title: "Short reset", at: at))
147
+ }
148
+ if let secondary = account["secondary"] as? [String: Any], let at = timestamp(secondary["resetsAt"]) {
149
+ rows.append(TimingRow(title: "Weekly reset", at: at))
150
+ }
151
+ if let at = timestamp(account["next_check"]) { rows.append(TimingRow(title: "Next allowance check", at: at)) }
152
+ else { rows.append(TimingRow(title: "Allowance checked when needed", at: nil)) }
153
+ return rows
129
154
  }
130
155
  func date(_ value: Any?) -> String {
131
- guard let seconds = value as? Double else { return "unknown" }
156
+ guard let seconds = MenuController.timestamp(value) else { return "Not available" }
132
157
  return Date(timeIntervalSince1970: seconds).formatted(date: .abbreviated, time: .shortened)
133
158
  }
134
159
  func countdown(_ title: String, value: Any, into menu: NSMenu) {
@@ -140,8 +165,9 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
140
165
  for entry in menu.items {
141
166
  if let data = entry.representedObject as? [String: Any], let at = data["at"] as? Double, let label = data["label"] as? String {
142
167
  let seconds = max(0, Int(at - Date().timeIntervalSince1970))
143
- let remaining = seconds < 60 ? "under a minute" : seconds < 3600 ? "\(seconds / 60)m" : "\(seconds / 3600)h \((seconds % 3600) / 60)m"
144
- entry.title = label + ": " + (seconds == 0 ? "due now" : "in " + remaining) + " · " + date(at)
168
+ let remaining = seconds < 60 ? "under a minute" : seconds < 3600 ? "\(seconds / 60)m" : seconds < 86400 ? "\(seconds / 3600)h \((seconds % 3600) / 60)m" : "\(seconds / 86400)d \((seconds % 86400) / 3600)h"
169
+ let elapsed = label.contains("reset") ? "reported time passed" : "due now"
170
+ entry.title = label + ": " + (seconds == 0 ? elapsed : "in " + remaining) + " · " + date(at)
145
171
  }
146
172
  if let child = entry.submenu { updateTimes(child) }
147
173
  }
@@ -149,12 +175,25 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
149
175
  func render() {
150
176
  let sessions = current["sessions"] as? [[String: Any]] ?? []
151
177
  let accounts = current["accounts"] as? [[String: Any]] ?? []
152
- 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" }
153
179
  updateIcon(attention: attention)
154
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]] ?? []
155
189
  let menu = NSMenu(); menu.delegate = self
190
+ menu.autoenablesItems = false
156
191
  text("Relay · \(sessions.count) sessions", into: menu)
157
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
+ }
158
197
  if accounts.isEmpty { text("Add an account with relay setup in Terminal.", into: menu) }
159
198
  for account in accounts {
160
199
  let name = account["name"] as? String ?? "Account"
@@ -165,11 +204,22 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
165
204
  let weekly = account["weekly_remaining"] as? String ?? (secondary?["usedPercent"] as? NSNumber).map { "\(max(0, 100 - $0.intValue))%" } ?? "?"
166
205
  let label = (account["enabled"] as? Bool ?? true) ? "Short \(short) · Week \(weekly)" : "disabled"
167
206
  let submenu = NSMenu()
207
+ submenu.autoenablesItems = false
168
208
  if let email = account["email"] as? String { text(email, into: submenu) }
169
209
  text("Short window: \(remaining)", into: submenu)
170
210
  if weekly != "?" { text("Weekly window: \(weekly) left", into: submenu) }
171
- if let reset = account["next_check"] { countdown("Next check", value: reset, into: submenu) }
172
- text("Last checked: \(date(account["usage_checked_at"]))", into: submenu)
211
+ for row in MenuController.accountTimingRows(account) {
212
+ if let at = row.at { countdown(row.title, value: at, into: submenu) }
213
+ else { text(row.title, into: submenu) }
214
+ }
215
+ let lastChecked = MenuController.timestamp(account["usage_checked_at"]).map { date($0) } ?? "Not checked yet"
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
+ }
173
223
  let entry = NSMenuItem(title: "\(name) · \(label)", action: nil, keyEquivalent: "")
174
224
  entry.identifier = NSUserInterfaceItemIdentifier("account:" + (account["id"] as? String ?? name))
175
225
  entry.submenu = submenu; menu.addItem(entry)
@@ -180,14 +230,37 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
180
230
  let name = session["name"] as? String ?? String(id.prefix(10))
181
231
  let status = (session["state"] as? String ?? "unknown").replacingOccurrences(of: "_", with: " ")
182
232
  let submenu = NSMenu()
233
+ submenu.autoenablesItems = false
183
234
  text("Account: \(session["account_name"] as? String ?? session["account"] as? String ?? "unknown")", into: submenu)
184
235
  text("Model: \(session["model"] as? String ?? "unknown")", into: submenu)
185
- if let next = session["next_wake"] { countdown("Next action", value: next, into: submenu) }
236
+ if let next = MenuController.timestamp(session["next_wake"]) { countdown("Next action", value: next, into: submenu) }
186
237
  if let reason = session["reason"] as? String { text(reason.replacingOccurrences(of: "_", with: " "), into: submenu) }
187
238
  action("Copy reconnect command", args: ["copy", "relay attach " + id], into: submenu)
188
- action("Pause recovery", args: ["pause", id, "--wait"], into: submenu)
189
- action("Resume recovery", args: ["unpause", id, "--wait"], into: submenu)
190
- action("Review and retry…", args: ["confirm-retry", id], into: submenu)
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
+ }
191
264
  let entry = NSMenuItem(title: "\(name) · \(status)", action: nil, keyEquivalent: "")
192
265
  entry.identifier = NSUserInterfaceItemIdentifier("session:" + id)
193
266
  entry.submenu = submenu; menu.addItem(entry)
@@ -196,11 +269,11 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
196
269
  action("Copy dashboard command", args: ["copy", "relay status --watch"], into: menu)
197
270
  action("Check allowance now", args: ["refresh"], into: menu)
198
271
  let login = NSMenuItem(title: "Open at login", action: #selector(toggleLogin), keyEquivalent: ""); login.target = self
272
+ login.isEnabled = !actionRunning
199
273
  login.state = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist") ? .on : .off
200
274
  menu.addItem(login)
201
275
  let quit = NSMenuItem(title: "Quit Relay menu", action: #selector(quitMenu), keyEquivalent: "q"); quit.target = self; menu.addItem(quit)
202
- if let visible = item.menu { MenuController.synchronise(visible, with: menu) }
203
- else { item.menu = menu }
276
+ return menu
204
277
  }
205
278
  func menuWillOpen(_ menu: NSMenu) {
206
279
  refreshSnapshot()
@@ -212,6 +285,8 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
212
285
  @objc func runAction(_ sender: NSMenuItem) {
213
286
  guard var args = sender.representedObject as? [String], !args.isEmpty else { return }
214
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 }
215
290
  if args[0] == "confirm-retry" {
216
291
  let alert = NSAlert(); alert.messageText = "Continue this task?"; alert.informativeText = "Review any interrupted command or external action in the conversation before continuing."
217
292
  alert.addButton(withTitle: "Continue task"); alert.addButton(withTitle: "Cancel")
@@ -219,19 +294,39 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
219
294
  guard alert.runModal() == .alertFirstButtonReturn else { return }
220
295
  args = ["retry", args[1], "--wait"]
221
296
  }
297
+ actionRunning = true; actionFailed = false; actionNotice = "Applying change…"; render()
222
298
  let proc = process(args); let pipe = Pipe(); proc.standardOutput = FileHandle.nullDevice; proc.standardError = pipe
223
299
  DispatchQueue.global(qos: .utility).async { [weak self] in
224
300
  do {
225
301
  try proc.run(); let bytes = pipe.fileHandleForReading.readDataToEndOfFile(); proc.waitUntilExit()
226
- if proc.terminationStatus != 0 { DispatchQueue.main.async { self?.message = String(data: bytes, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Action failed. Check relay events."; self?.render() } }
227
- } catch { DispatchQueue.main.async { self?.message = "Could not run that action."; self?.render() } }
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."
228
322
  }
229
323
  }
230
324
  @objc func toggleLogin() {
231
325
  let enabled = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist")
232
- runCommand(enabled ? ["menubar", "--no-startup", "--configure-only"] : ["menubar", "--configure-only"])
326
+ let sender = NSMenuItem()
327
+ sender.representedObject = enabled ? ["menubar", "--no-startup", "--configure-only"] : ["menubar", "--configure-only"]
328
+ runAction(sender)
233
329
  }
234
- func runCommand(_ args: [String]) { let proc = process(args); proc.standardOutput = FileHandle.nullDevice; proc.standardError = FileHandle.nullDevice; try? proc.run() }
235
330
  @objc func quitMenu() { stopping = true; reconnect?.cancel(); stream?.terminate(); NSApp.terminate(nil) }
236
331
  func applicationWillTerminate(_ notification: Notification) { stopping = true; stream?.terminate() }
237
332
  }
@@ -251,7 +346,32 @@ if CommandLine.arguments.contains("--self-check") {
251
346
  MenuController.synchronise(visible, with: replacement)
252
347
  let safeIdentity = visible.items[0] !== existing && visible.items[0].identifier == newItem.identifier
253
348
  print("Session action identity preserved: \(safeIdentity)")
254
- exit(normal != nil && attention != nil && liveUpdate && safeIdentity ? 0 : 1)
349
+ let timing = MenuController.accountTimingRows(["next_check": NSNull(), "primary": ["resetsAt": 2000000000.0], "secondary": ["resetsAt": 2000100000.0]])
350
+ let scheduled = MenuController.accountTimingRows(["next_check": 2000200000.0])
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
352
+ print("Empty retry schedule handled: \(correctTiming)")
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)
255
375
  } else {
256
376
  let app = NSApplication.shared
257
377
  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.2"
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
- 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()