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 +26 -7
- package/docs/commands.md +123 -39
- package/docs/publishing.md +29 -13
- package/docs/troubleshooting.md +15 -3
- package/package.json +1 -1
- package/python/Menu.swift +169 -51
- package/python/commands.py +219 -20
- package/python/compatibility.py +3 -1
- package/python/desktop.py +131 -12
- package/python/execution.py +551 -0
- package/python/launch.py +375 -75
- package/python/pool.py +7 -0
- package/python/router.py +305 -70
- package/python/service.py +216 -142
- package/python/settings.py +1 -0
- package/python/tools.py +233 -0
- package/python/watch.py +74 -0
- package/tests/fake_native.py +4 -2
- package/tests/test_audit.py +320 -0
- package/tests/test_controller.py +3 -5
- package/tests/test_features.py +350 -0
- package/tests/test_launch.py +150 -0
- package/tests/test_services.py +6 -0
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
|
|
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
|
|
32
|
-
npx --yes
|
|
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.
|
|
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.
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
##
|
|
42
|
+
## Launch and recovery
|
|
26
43
|
|
|
27
44
|
```sh
|
|
28
|
-
relay run
|
|
45
|
+
relay run
|
|
29
46
|
relay run -- resume --yolo
|
|
30
|
-
relay
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
a
|
|
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 '["
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
relay doctor --
|
|
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/
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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.
|
package/docs/publishing.md
CHANGED
|
@@ -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.
|
|
9
|
-
npx --yes github:blackspider-ops/relay#v0.
|
|
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
|
|
13
|
-
`relay-codex`
|
|
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
|
-
|
|
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 --
|
|
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: [
|
|
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).
|
package/docs/troubleshooting.md
CHANGED
|
@@ -24,9 +24,9 @@ Other accounts can keep working.
|
|
|
24
24
|
|
|
25
25
|
**The terminal disconnected after a worker restart.**
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
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
|
|
6
|
-
var
|
|
7
|
-
|
|
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.
|
|
11
|
-
item.button?.title = "
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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(
|
|
33
|
-
let sessions =
|
|
34
|
-
let accounts =
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
menu
|
|
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
|
|
43
|
-
let
|
|
44
|
-
let
|
|
45
|
-
|
|
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
|
|
49
|
-
let
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
let
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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()
|