relay-codex 0.4.1 → 0.5.1

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