relay-codex 0.4.1 → 0.5.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
@@ -13,7 +13,7 @@ Python 3.11 or newer. If you use Homebrew, `brew install node python@3.13` insta
13
13
  Node and Python.
14
14
 
15
15
  ```sh
16
- npm install -g github:blackspider-ops/relay#v0.4.1
16
+ npm install -g relay-codex
17
17
  relay setup --count 2
18
18
  relay run
19
19
  ```
@@ -28,19 +28,31 @@ Your usual `codex` command keeps working.
28
28
  Prefer trying it without a global install?
29
29
 
30
30
  ```sh
31
- npx --yes github:blackspider-ops/relay#v0.4.1 setup --count 2
32
- npx --yes github:blackspider-ops/relay#v0.4.1 run
31
+ npx --yes relay-codex setup --count 2
32
+ npx --yes relay-codex run
33
33
  ```
34
34
 
35
35
  ## Use it like Codex
36
36
 
37
37
  ```sh
38
38
  relay run -- resume --yolo
39
+ relay run -- -C ~/projects/website resume
40
+ relay run -- exec --json "Review this project"
39
41
  relay status --watch
42
+ relay menubar
40
43
  ```
41
44
 
42
45
  `--yolo` keeps its usual full-access meaning. Without it, Relay keeps the task's
43
- permissions. Different terminals can work on different conversations at once.
46
+ permissions. Resume starts with sessions from your current folder, just like
47
+ `codex resume`. Add `--all` to browse every folder, or `-C PATH` to choose a folder.
48
+ Different terminals can work on different conversations at once.
49
+
50
+ Put native Codex commands and options after `relay run --`. Use `relay run --help`
51
+ for the full Codex command and flag list, or `relay help` for Relay management.
52
+ Profiles, extra writable directories, Git worktrees, and noninteractive tasks can
53
+ use the account pool. `relay explain -- <arguments>` shows how a launch will run.
54
+ Native utilities, external/local-provider endpoints, and a few native-only setup
55
+ flags run directly; `--require-recovery` rejects a launch outside Relay recovery.
44
56
 
45
57
  If a conversation is already open in another app, close it there before resuming
46
58
  it here, or use Codex's `fork` command for a separate copy. To move a Relay task
@@ -50,6 +62,10 @@ between terminals:
50
62
  relay attach --takeover SESSION_ID
51
63
  ```
52
64
 
65
+ Accounts show their saved email addresses. The watch view updates only when local
66
+ status changes; it uses no model allowance. The menu bar uses an icon and opens
67
+ at Mac login. `relay menubar --no-startup --configure-only` disables login startup.
68
+
53
69
  ## What happens when something stops?
54
70
 
55
71
  - **Account limit:** use an available account, or wait for the earliest usable reset.
@@ -69,9 +85,11 @@ relay attach SESSION_ID
69
85
  relay stop SESSION_ID
70
86
  ```
71
87
 
72
- Background tasks keep running after their terminal closes. They do not start
73
- automatically after a Mac reboot. Use `relay status --all` to find saved sessions
74
- and `relay restart SESSION_ID` to recover a stopped one.
88
+ Background tasks keep running after their terminal closes. Use `relay status --all`
89
+ to find saved sessions and `relay restart SESSION_ID` to recover a stopped one.
90
+ Opt into task recovery at Mac login with `relay startup enable SESSION_ID`.
91
+ `relay workspace save daily` and `relay workspace restore daily` retain the same
92
+ conversations across several terminals.
75
93
 
76
94
  ## A few useful commands
77
95
 
@@ -92,6 +110,7 @@ in your private Application Support folder. Never share that folder.
92
110
 
93
111
  Relay checks the installed Codex version and keeps a working copy for running
94
112
  sessions. Future Codex changes may still need a Relay update.
113
+ Update Relay with `npm install -g relay-codex@latest`.
95
114
 
96
115
  This is an independent tool, not an OpenAI product. Short local tests cover
97
116
  parallel tasks and recovery; they are not an overnight reliability guarantee.
package/docs/commands.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Commands
2
2
 
3
+ ## Help
4
+
5
+ ```sh
6
+ relay help
7
+ relay help account
8
+ relay help workspace
9
+ relay run --help
10
+ relay run -- exec --help
11
+ relay help codex resume
12
+ ```
13
+
14
+ `relay run --help` shows the installed Codex command and flag list, followed by
15
+ Relay's launch options. Put native arguments after `relay run --`.
16
+
3
17
  ## Accounts
4
18
 
5
19
  ```sh
@@ -7,10 +21,11 @@ relay setup --count 1
7
21
  relay setup --count 6
8
22
  relay add personal
9
23
  relay list
24
+ relay account identify
10
25
  relay refresh
11
- relay account rename account1 personal
12
- relay account enable personal
26
+ relay account rename personal "My account"
13
27
  relay account disable personal
28
+ relay account enable personal
14
29
  relay account priority personal 10
15
30
  relay account cap personal 2
16
31
  relay account models personal YOUR_MODEL
@@ -18,33 +33,98 @@ relay account reauth personal
18
33
  relay account remove personal
19
34
  ```
20
35
 
21
- Renaming changes the label, so saved logins stay usable. A cap of zero means no
22
- session cap. Removing an account keeps its login unless `--revoke` is requested.
23
- Switch active sessions away from an account before signing it out or replacing it.
36
+ Accounts display their saved email addresses. `account identify` fills in emails
37
+ for earlier installations without another sign-in. Commands accept an account's
38
+ label, unique email, or chosen display name. Renaming keeps the login usable.
39
+ A session cap of zero means unlimited. Removing an account retains its login
40
+ unless you request `--revoke`.
24
41
 
25
- ## Sessions
42
+ ## Launch and recovery
26
43
 
27
44
  ```sh
28
- relay run --name website -- --model YOUR_MODEL
45
+ relay run
29
46
  relay run -- resume --yolo
30
- relay start --name website --cwd ~/projects/website --prompt "Run the checks"
47
+ relay run -- -C ~/projects/website resume
48
+ relay run -- -p work --add-dir ~/shared
49
+ relay run -- --worktree "Try the next change"
50
+ relay run -- exec --json "Run the checks"
51
+ relay run -- review --uncommitted
52
+ relay explain -- resume --yolo
53
+ relay run --require-recovery -- resume
54
+ ```
55
+
56
+ Resume defaults to the current folder. Add `--all` to browse other folders or
57
+ `-C/--cd` to choose another working folder. Native profile files are read from
58
+ `$CODEX_HOME/NAME.config.toml`; explicit config flags override the profile.
59
+ Relay worktrees are detached Git worktrees under its private state folder. Their
60
+ files are retained for review; cleanup does not delete worktrees.
61
+
62
+ Interactive work, resume/fork, profiles, extra directories, worktrees, and supported
63
+ noninteractive exec/review runs use the account pool. Exec supports text/stdin,
64
+ images, JSON events, output schemas, last-message files, and resume/fork. Review
65
+ supports the working tree, base branch, commit, or custom instructions.
66
+
67
+ Native utility commands are forwarded unchanged. Local providers, explicitly
68
+ remote servers, hook-trust bypass, and ignoring native config/rules use native
69
+ Codex directly with its ordinary login. Help/version also run directly. These
70
+ modes do not use Relay recovery. `--require-recovery` rejects such a launch, and
71
+ `explain` shows which mode applies without making an inference request.
72
+
73
+ Noninteractive runs cannot approve a tool request on your behalf. Use explicit
74
+ native permissions such as `--yolo` only when intended, or resume interactively
75
+ when input is required. Automatic review is supported by `--approve-for-me`.
76
+
77
+ ## Dashboard and session controls
78
+
79
+ ```sh
80
+ relay status
31
81
  relay status --watch
32
82
  relay status --all
33
83
  relay status --json
84
+ relay menubar
85
+ relay menubar --no-startup
34
86
  relay attach SESSION_ID
35
- relay attach --takeover SESSION_ID
36
- relay pause SESSION_ID
37
- relay unpause SESSION_ID
38
- relay retry SESSION_ID
39
- relay switch SESSION_ID --account personal
40
- relay stop SESSION_ID
87
+ relay attach SESSION_ID --takeover
88
+ relay pause SESSION_ID --wait
89
+ relay unpause SESSION_ID --wait
90
+ relay retry SESSION_ID --wait
91
+ relay switch SESSION_ID --account personal --wait
92
+ relay stop SESSION_ID --wait
41
93
  relay restart SESSION_ID
42
94
  ```
43
95
 
44
- Session IDs can be shortened as long as the prefix is unique. `pause` pauses
45
- automatic recovery; it lets a command already running finish. `retry` explicitly
46
- overrides the retry timer. Switching waits for an idle task; it does not interrupt
47
- a command or invent an approval.
96
+ The watch view waits for local file changes and redraws only when useful status
97
+ changes. Heartbeats do not repeatedly print the table. Watching uses no model
98
+ allowance and does not fetch account allowance. `refresh` explicitly checks it.
99
+ The menu uses one local status stream, a template icon, and native session actions.
100
+ It opens at Mac login after `relay menubar`; disable that in its menu or with
101
+ `relay menubar --no-startup --configure-only`.
102
+
103
+ IDs may be shortened to a unique prefix. Without `--wait`, controls are queued;
104
+ with it, Relay waits for the worker's confirmation and reports a rejection.
105
+ Pause stops automatic recovery after current work finishes. Retry clears a review
106
+ hold: inspect an interrupted external action in the conversation first.
107
+
108
+ ## Background tasks and saved workspaces
109
+
110
+ ```sh
111
+ relay start --name website --cwd ~/projects/website --prompt "Run the checks"
112
+ relay workspace save daily
113
+ relay workspace list
114
+ relay workspace restore daily
115
+ relay workspace remove daily
116
+ relay startup enable SESSION_ID
117
+ relay startup list
118
+ relay startup disable SESSION_ID
119
+ ```
120
+
121
+ A workspace saves the same supervised session IDs, rather than starting fresh
122
+ conversations. Restore restarts stopped jobs and prints reconnect commands; live
123
+ jobs are retained. Save specific sessions by appending their IDs to `workspace save`.
124
+
125
+ The normal command remains available after reboot. Background task recovery at
126
+ login is a separate opt-in setting; enabling the menu does not enable task startup.
127
+ Uncertain work still pauses for review after recovery.
48
128
 
49
129
  ## Preferences
50
130
 
@@ -52,41 +132,45 @@ a command or invent an approval.
52
132
  relay config
53
133
  relay config notifications true
54
134
  relay config background_on_disconnect true
135
+ relay config keep_awake true
55
136
  relay config retry_interval 300
56
137
  relay config retry_attempts 3
57
138
  relay config routing quota
58
- relay config preferred_accounts '["account1","account3"]'
139
+ relay config preferred_accounts '["personal"]'
59
140
  relay config fallback_models '["YOUR_CHOSEN_MODEL"]'
60
141
  relay config retry_interval 60 --profile quick
61
142
  relay run --profile quick
62
143
  ```
63
144
 
64
- Available routing choices are `balanced`, `quota`, `priority`, and `round-robin`.
65
- No fallback model is selected by default. A capacity error preserves the selected
66
- model and account unless you have enabled a fallback. Setting `retry_attempts` to
67
- zero disables automatic capacity retries.
145
+ Routing choices are `balanced`, `quota`, `priority`, and `round-robin`. Model
146
+ fallback is opt-in. Capacity errors otherwise retain the selected model/account.
147
+ Keep-awake prevents idle system sleep only while a managed task is active; it does
148
+ not keep the display on. Project preferences may live in `.relay.toml` under
149
+ `[settings]` or `[profiles.NAME]`. Legacy `.codex-accounts.toml` files still work.
150
+ Relay recovery profiles go before `--`; native Codex profiles go after it.
68
151
 
69
- Project preferences can live in `.relay.toml`, under `[settings]` or
70
- `[profiles.NAME]`. Existing `.codex-accounts.toml` files are also supported. Native Codex
71
- profiles still go after the separator: `relay run -- -p work`.
72
-
73
- ## Checks and extras
152
+ ## Diagnostics and cleanup
74
153
 
75
154
  ```sh
76
- relay check
77
- relay doctor --self-test --backup
155
+ relay doctor --self-test
156
+ relay doctor --backup
78
157
  relay doctor --restore-backup
158
+ relay diagnostics relay-diagnostics.json
159
+ relay clean
160
+ relay clean --older-than 30
161
+ relay clean --apply
79
162
  relay events --limit 30
80
163
  relay usage
81
164
  relay runtime list
82
- relay runtime preflight /path/to/candidate/codex
83
- relay runtime use installed
84
- relay menubar
85
- relay group save daily ~/projects/website ~/projects/api
86
- relay group start daily
165
+ relay runtime preflight /path/to/codex
87
166
  ```
88
167
 
89
- Restore requires stopped Relay sessions and preserves a copy of the replaced
90
- database. It does not change Keychain logins. Usage forecasts are estimates based
91
- on local observations. Groups currently start separate tasks in saved folders;
92
- they do not restore a whole previous workspace.
168
+ Diagnostics export allowlisted versions, event types, and counts without account
169
+ emails, task names, project paths, prompts, or credentials. Review other files
170
+ before sharing them; private state and connection files must stay private.
171
+
172
+ Cleanup is a dry run unless `--apply` is given. It retains live/restartable runtime
173
+ references and saved workspaces. `--jobs` also allows removal of old stopped-job
174
+ records. Stop supervised jobs before applying cleanup. Worktrees and account
175
+ logins are retained. Metadata restore also requires stopped tasks and preserves a
176
+ copy of the replaced database. Usage forecasts are local estimates.
@@ -1,33 +1,47 @@
1
1
  # Sharing Relay
2
2
 
3
+ Relay is published on npm as [relay-codex](https://www.npmjs.com/package/relay-codex).
4
+ Friends can run `npx relay-codex` or install it with `npm install -g relay-codex`.
5
+
3
6
  ## GitHub and npx
4
7
 
5
8
  The GitHub repository is enough for friends to install or try Relay:
6
9
 
7
10
  ```sh
8
- npm install -g github:blackspider-ops/relay#v0.4.1
9
- npx --yes github:blackspider-ops/relay#v0.4.1 --version
11
+ npm install -g github:blackspider-ops/relay#v0.5.0
12
+ npx --yes github:blackspider-ops/relay#v0.5.0 --version
10
13
  ```
11
14
 
12
- `npx` runs packages. It is not a separate publishing service. Publishing the
13
- `relay-codex` package on npm would also make `npx relay-codex` available.
15
+ `npx` runs packages; it is not a separate publishing service. Publishing
16
+ `relay-codex` on npm also makes `npx relay-codex` available.
14
17
 
15
18
  ## Publish on npm
16
19
 
17
- Only a maintainer needs to do this:
20
+ The repository includes `.github/workflows/publish.yml`. Configure a trusted
21
+ publisher for `relay-codex` using GitHub user `blackspider-ops`, repository `relay`,
22
+ and workflow filename `publish.yml`. Select direct `npm publish` permission if
23
+ releases should publish automatically. No long-lived publishing token is needed.
24
+ A new trusted publisher must complete its first publish within npm's validation
25
+ window. Run the workflow for an existing reviewed version tag, or publish the
26
+ GitHub release; the workflow runs checks before publishing. After the first
27
+ successful trusted publish, set the repository variable
28
+ `NPM_TRUSTED_PUBLISHING_READY=true` to enable automatic release publishing. Until
29
+ then, releases do not launch a publishing attempt that requires authentication.
30
+
31
+ Initial trust setup requires npm account verification. Interactive local
32
+ `npm publish` also requires verification when package 2FA applies. Installing and
33
+ running the public package does not require npm sign-in. An expired publishing
34
+ prompt should be restarted once, rather than repeatedly running `npm login`.
35
+
36
+ For a manual release, review and publish the exact packed artifact:
18
37
 
19
38
  ```sh
20
- npm login
21
39
  npm test
22
40
  npm run check
23
- npm pack --dry-run
24
- npm publish --access public
41
+ npm pack --ignore-scripts
42
+ npm publish ./relay-codex-VERSION.tgz --access public
25
43
  ```
26
44
 
27
- Use an npm account with publishing access and two-factor authentication, or the
28
- supported trusted-publishing setup. Package-name availability is checked again at
29
- publication; the GitHub repository does not reserve the npm name.
30
-
31
45
  After publishing, friends can use:
32
46
 
33
47
  ```sh
@@ -51,6 +65,8 @@ For a new release, update the package and Python versions together, run the chec
51
65
  tag it, upload the archive, then update the formula URL and checksum. Keep the
52
66
  previous release available for users who need to roll back.
53
67
 
54
- Official references: [npm publishing](https://docs.npmjs.com/creating-and-publishing-unscoped-public-packages),
68
+ Official references: [trusted publishing](https://docs.npmjs.com/trusted-publishers/),
69
+ [package publishing verification](https://docs.npmjs.com/requiring-2fa-for-package-publishing-and-settings-modification/),
70
+ [npm publishing](https://docs.npmjs.com/creating-and-publishing-unscoped-public-packages),
55
71
  [npx](https://docs.npmjs.com/cli/v11/commands/npx/), and
56
72
  [Homebrew taps](https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap).
@@ -24,9 +24,9 @@ Other accounts can keep working.
24
24
 
25
25
  **The terminal disconnected after a worker restart.**
26
26
 
27
- The background task may still be running. Check `relay status` and reattach with
28
- `relay attach SESSION_ID`. Automatic terminal reconnection across a full worker
29
- replacement is not guaranteed.
27
+ Relay reconnects the terminal when a replacement worker publishes a recovered
28
+ connection. If recovery requires review or the project folder is unavailable,
29
+ check `relay status` and `relay events`, then reattach with `relay attach SESSION_ID`.
30
30
 
31
31
  **Something still seems wrong.**
32
32
 
@@ -35,3 +35,15 @@ a bug. Do not post auth files, tokens, connection files, or private task prompts
35
35
 
36
36
  Long runs, physical sleep/wake cycles, real revoked logins, and future Codex
37
37
  versions need broader live testing. The tests do not remove those limits.
38
+
39
+ **The dashboard repeats or an email is missing.**
40
+
41
+ Check `relay --version` and update Relay. Watch now prints only meaningful changes
42
+ from local state, without using model allowance. Earlier enrolled accounts can be
43
+ identified with `relay account identify`; this reads saved login metadata locally.
44
+
45
+ **Why does npm ask for authentication?**
46
+
47
+ That verification protects package publishing. Installing and running the public
48
+ Relay package does not require npm sign-in. Maintainers can use the repository's
49
+ trusted-publishing workflow once npm has authorized that workflow.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-codex",
3
- "version": "0.4.1",
3
+ "version": "0.5.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
@@ -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"] 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()