relay-codex 0.0.0-stage → 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 blackspider-ops
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,100 @@
1
- # Temporary Holding Version
1
+ <p align="center"><img src="https://raw.githubusercontent.com/blackspider-ops/relay/main/assets/relay.svg" alt="Relay — keep Codex moving" width="840"></p>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Relay keeps your Codex sessions moving between your own accounts. When an account
4
+ runs out, it finds another available account. When all accounts are waiting for a
5
+ reset, it waits too, then continues the saved task.
6
+
7
+ **macOS · Native Codex terminal · One account or many**
8
+
9
+ ## Get started
10
+
11
+ You'll need [Codex](https://learn.chatgpt.com/docs/cli), Node.js 20 or newer, and
12
+ Python 3.11 or newer. If you use Homebrew, `brew install node python@3.13` installs
13
+ Node and Python.
14
+
15
+ ```sh
16
+ npm install -g github:blackspider-ops/relay#v0.4.1
17
+ relay setup --count 2
18
+ relay run
19
+ ```
20
+
21
+ Choose however many accounts you have. Each person signs into their own accounts;
22
+ this project does not include anyone's saved logins. Existing logins from the
23
+ earlier `codex-accounts` tool are picked up automatically.
24
+
25
+ The first run downloads Relay's Python libraries. Later runs use the saved copy.
26
+ Your usual `codex` command keeps working.
27
+
28
+ Prefer trying it without a global install?
29
+
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
33
+ ```
34
+
35
+ ## Use it like Codex
36
+
37
+ ```sh
38
+ relay run -- resume --yolo
39
+ relay status --watch
40
+ ```
41
+
42
+ `--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.
44
+
45
+ If a conversation is already open in another app, close it there before resuming
46
+ it here, or use Codex's `fork` command for a separate copy. To move a Relay task
47
+ between terminals:
48
+
49
+ ```sh
50
+ relay attach --takeover SESSION_ID
51
+ ```
52
+
53
+ ## What happens when something stops?
54
+
55
+ - **Account limit:** use an available account, or wait for the earliest usable reset.
56
+ - **One account:** wait for its reset and continue the same conversation.
57
+ - **Connection or sign-in failure:** retry at 5, 10, and 15 minutes, then try another account.
58
+ - **“Selected model is at capacity”:** wait five minutes and try the same model again.
59
+ Relay keeps waiting rather than cycling logins. A different model is used only
60
+ if you explicitly configure a fallback.
61
+ - **An unclear interruption during a command:** pause for review before continuing.
62
+ Relay cannot guarantee that an external action happened exactly once.
63
+
64
+ ## Leave work running
65
+
66
+ ```sh
67
+ relay start --name website --cwd ~/projects/website --prompt "Finish the remaining checks"
68
+ relay attach SESSION_ID
69
+ relay stop SESSION_ID
70
+ ```
71
+
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.
75
+
76
+ ## A few useful commands
77
+
78
+ ```sh
79
+ relay add personal
80
+ relay account disable personal
81
+ relay pause SESSION_ID
82
+ relay unpause SESSION_ID
83
+ relay retry SESSION_ID
84
+ relay doctor --self-test
85
+ relay menubar
86
+ ```
87
+
88
+ Accounts stay in your Mac's Keychain. Relay keeps its settings and task records
89
+ in your private Application Support folder. Never share that folder.
90
+
91
+ ## Updates and limits
92
+
93
+ Relay checks the installed Codex version and keeps a working copy for running
94
+ sessions. Future Codex changes may still need a Relay update.
95
+
96
+ This is an independent tool, not an OpenAI product. Short local tests cover
97
+ parallel tasks and recovery; they are not an overnight reliability guarantee.
98
+
99
+ [Commands](docs/commands.md) · [Troubleshooting](docs/troubleshooting.md) ·
100
+ [Publishing to npm or Homebrew](docs/publishing.md) · [Contributing](CONTRIBUTING.md)
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="360" viewBox="0 0 1280 360" role="img" aria-labelledby="title desc">
2
+ <title id="title">Relay</title>
3
+ <desc id="desc">Keep Codex moving. Four paths meet in one forward arrow.</desc>
4
+ <rect width="1280" height="360" rx="24" fill="#111916"/>
5
+ <g fill="none" stroke="#a8d9bd" stroke-width="10" stroke-linecap="round">
6
+ <path d="M96 108h62c48 0 44 72 94 72h44" opacity=".5"/>
7
+ <path d="M96 156h82c34 0 30 24 72 24h46" opacity=".7"/>
8
+ <path d="M96 204h82c34 0 30-24 72-24h46"/>
9
+ <path d="M96 252h62c48 0 44-72 94-72h44" opacity=".5"/>
10
+ <path d="m278 158 24 22-24 22" stroke="#f7be74"/>
11
+ </g>
12
+ <text x="366" y="185" fill="#f5f3ec" font-family="Helvetica Neue,Arial,sans-serif" font-size="98" font-weight="650" letter-spacing="-5">Relay</text>
13
+ <text x="371" y="239" fill="#acb9b0" font-family="Helvetica Neue,Arial,sans-serif" font-size="27" letter-spacing=".2">Keep Codex moving.</text>
14
+ <text x="1140" y="73" text-anchor="end" fill="#a8d9bd" font-family="Menlo,monospace" font-size="15">FOR YOUR TERMINAL</text>
15
+ </svg>
package/bin/relay.cjs ADDED
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const crypto = require('node:crypto');
8
+ const { spawnSync, spawn } = require('node:child_process');
9
+ const { setTimeout: delay } = require('node:timers/promises');
10
+
11
+ const packageRoot = path.resolve(__dirname, '..');
12
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
13
+
14
+ function stateDirectory(env = process.env, home = os.homedir()) {
15
+ if (env.RELAY_STATE || env.CODEX_ACCOUNTS_STATE) {
16
+ return path.resolve(env.RELAY_STATE || env.CODEX_ACCOUNTS_STATE);
17
+ }
18
+ const support = path.join(home, 'Library', 'Application Support');
19
+ const legacy = path.join(support, 'Codex Accounts');
20
+ return fs.existsSync(path.join(legacy, 'accounts')) ? legacy : path.join(support, 'Relay');
21
+ }
22
+
23
+ function pythonCommand() {
24
+ const choices = [process.env.RELAY_PYTHON, 'python3', 'python3.14', 'python3.13', 'python3.12', 'python3.11'].filter(Boolean);
25
+ for (const command of choices) {
26
+ const result = spawnSync(command, ['-c', 'import sys; print(sys.executable); raise SystemExit(sys.version_info < (3, 11))'], { encoding: 'utf8' });
27
+ if (result.status === 0) return result.stdout.trim();
28
+ }
29
+ throw new Error('Relay needs Python 3.11 or newer. Install it with: brew install python@3.13');
30
+ }
31
+
32
+ function privateDirectory(directory) {
33
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
34
+ const info = fs.lstatSync(directory);
35
+ if (info.isSymbolicLink() || (process.getuid && info.uid !== process.getuid())) {
36
+ throw new Error('Relay needs a private folder owned by your user.');
37
+ }
38
+ fs.chmodSync(directory, 0o700);
39
+ }
40
+
41
+ function fingerprint() {
42
+ const source = path.join(packageRoot, 'python');
43
+ const names = fs.readdirSync(source).filter(name => /\.(py|swift|lock|txt)$/.test(name)).sort();
44
+ for (const name of fs.readdirSync(path.join(packageRoot, 'tests')).filter(name => name.endsWith('.py')).sort()) names.push(`tests/${name}`);
45
+ const hash = crypto.createHash('sha256');
46
+ hash.update(pkg.version);
47
+ for (const name of names) hash.update(name).update(fs.readFileSync(name.startsWith('tests/') ? path.join(packageRoot, name) : path.join(source, name)));
48
+ return { id: `${pkg.version}-${hash.digest('hex').slice(0, 16)}`, names, source };
49
+ }
50
+
51
+ function processBirth(pid) {
52
+ const result = spawnSync('/bin/ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8' });
53
+ return result.status === 0 ? result.stdout.trim() : '';
54
+ }
55
+
56
+ function ownerAlive(owner) {
57
+ try {
58
+ process.kill(owner.pid, 0);
59
+ return Boolean(owner.birth) && processBirth(owner.pid) === owner.birth;
60
+ } catch { return false; }
61
+ }
62
+
63
+ async function withSetupLock(state, operation) {
64
+ const lock = path.join(state, 'relay-setup.lock');
65
+ const deadline = Date.now() + 10 * 60 * 1000;
66
+ while (true) {
67
+ try { fs.mkdirSync(lock, { mode: 0o700 }); break; }
68
+ catch (error) {
69
+ if (error.code !== 'EEXIST') throw error;
70
+ let stale = false;
71
+ try {
72
+ const owner = JSON.parse(fs.readFileSync(path.join(lock, 'owner.json'), 'utf8'));
73
+ stale = !ownerAlive(owner);
74
+ } catch {
75
+ // Another process may be between mkdir and writing its owner record.
76
+ try { stale = Date.now() - fs.statSync(lock).mtimeMs > 30_000; }
77
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
78
+ }
79
+ if (stale) {
80
+ const abandoned = `${lock}.stale-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
81
+ try { fs.renameSync(lock, abandoned); fs.rmSync(abandoned, { recursive: true, force: true }); }
82
+ catch (error) { if (!['ENOENT', 'EEXIST'].includes(error.code)) throw error; }
83
+ continue;
84
+ }
85
+ if (Date.now() > deadline) throw new Error('Another Relay setup is still running. Let it finish, then try again.');
86
+ await delay(200);
87
+ }
88
+ }
89
+ fs.writeFileSync(path.join(lock, 'owner.json'), JSON.stringify({ pid: process.pid, birth: processBirth(process.pid) }), { mode: 0o600 });
90
+ try { return await operation(); }
91
+ finally { fs.rmSync(lock, { recursive: true, force: true }); }
92
+ }
93
+
94
+ function run(command, args) {
95
+ const result = spawnSync(command, args, { stdio: 'inherit' });
96
+ if (result.error || result.status !== 0) throw new Error('Setup did not finish. Run Relay again to retry.');
97
+ }
98
+
99
+ async function prepare(state) {
100
+ const bundle = fingerprint();
101
+ const destination = path.join(state, 'relay-runtimes', bundle.id);
102
+ const interpreter = path.join(destination, '.venv', 'bin', 'python');
103
+ const marker = path.join(destination, 'ready.json');
104
+ const ready = () => fs.existsSync(marker) && fs.existsSync(interpreter) && spawnSync(interpreter, ['-c', 'import websockets, jsonschema'], { stdio: 'ignore' }).status === 0;
105
+ if (ready()) return { interpreter, router: path.join(destination, 'router.py') };
106
+ const python = pythonCommand();
107
+ return withSetupLock(state, async () => {
108
+ if (ready()) return { interpreter, router: path.join(destination, 'router.py') };
109
+ privateDirectory(destination);
110
+ for (const name of bundle.names) {
111
+ const source = name.startsWith('tests/') ? path.join(packageRoot, name) : path.join(bundle.source, name);
112
+ fs.mkdirSync(path.dirname(path.join(destination, name)), { recursive: true, mode: 0o700 });
113
+ fs.copyFileSync(source, path.join(destination, name));
114
+ }
115
+ console.error('Preparing Relay for this Mac. This only happens on first use or after an update.');
116
+ // Always retry dependency installation if a previous setup was interrupted.
117
+ run(python, ['-m', 'venv', path.join(destination, '.venv')]);
118
+ run(interpreter, ['-m', 'pip', 'install', '--disable-pip-version-check', '-r', path.join(destination, 'requirements.lock')]);
119
+ fs.writeFileSync(marker, JSON.stringify({ version: pkg.version }), { mode: 0o600 });
120
+ return { interpreter, router: path.join(destination, 'router.py') };
121
+ });
122
+ }
123
+
124
+ async function main() {
125
+ if (process.platform !== 'darwin') throw new Error('Relay currently supports macOS.');
126
+ if (process.argv.length === 3 && ['--version', '-V'].includes(process.argv[2])) {
127
+ console.log(`Relay ${pkg.version}`); return;
128
+ }
129
+ const state = stateDirectory(); privateDirectory(state);
130
+ const runtime = await prepare(state);
131
+ const child = spawn(runtime.interpreter, [runtime.router, ...process.argv.slice(2)], {
132
+ stdio: 'inherit', env: { ...process.env, RELAY_STATE: state, CODEX_ACCOUNTS_STATE: state }
133
+ });
134
+ // Terminal Ctrl-C already reaches both foreground processes.
135
+ const interrupt = () => {};
136
+ const terminate = () => child.kill('SIGTERM');
137
+ process.on('SIGINT', interrupt); process.on('SIGTERM', terminate);
138
+ process.on('SIGHUP', terminate);
139
+ child.on('error', () => { console.error('Relay could not start. Run it again to retry.'); process.exitCode = 1; });
140
+ await new Promise(resolve => child.on('close', (code, signal) => {
141
+ process.exitCode = code ?? (signal === 'SIGINT' ? 130 : 1); resolve();
142
+ }));
143
+ process.off('SIGINT', interrupt); process.off('SIGTERM', terminate);
144
+ process.off('SIGHUP', terminate);
145
+ }
146
+
147
+ module.exports = { stateDirectory, fingerprint, ownerAlive, withSetupLock, prepare };
148
+ if (require.main === module) main().catch(error => { console.error(`relay: ${error.message}`); process.exitCode = 1; });
@@ -0,0 +1,92 @@
1
+ # Commands
2
+
3
+ ## Accounts
4
+
5
+ ```sh
6
+ relay setup --count 1
7
+ relay setup --count 6
8
+ relay add personal
9
+ relay list
10
+ relay refresh
11
+ relay account rename account1 personal
12
+ relay account enable personal
13
+ relay account disable personal
14
+ relay account priority personal 10
15
+ relay account cap personal 2
16
+ relay account models personal YOUR_MODEL
17
+ relay account reauth personal
18
+ relay account remove personal
19
+ ```
20
+
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.
24
+
25
+ ## Sessions
26
+
27
+ ```sh
28
+ relay run --name website -- --model YOUR_MODEL
29
+ relay run -- resume --yolo
30
+ relay start --name website --cwd ~/projects/website --prompt "Run the checks"
31
+ relay status --watch
32
+ relay status --all
33
+ relay status --json
34
+ 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
41
+ relay restart SESSION_ID
42
+ ```
43
+
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.
48
+
49
+ ## Preferences
50
+
51
+ ```sh
52
+ relay config
53
+ relay config notifications true
54
+ relay config background_on_disconnect true
55
+ relay config retry_interval 300
56
+ relay config retry_attempts 3
57
+ relay config routing quota
58
+ relay config preferred_accounts '["account1","account3"]'
59
+ relay config fallback_models '["YOUR_CHOSEN_MODEL"]'
60
+ relay config retry_interval 60 --profile quick
61
+ relay run --profile quick
62
+ ```
63
+
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.
68
+
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
74
+
75
+ ```sh
76
+ relay check
77
+ relay doctor --self-test --backup
78
+ relay doctor --restore-backup
79
+ relay events --limit 30
80
+ relay usage
81
+ 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
87
+ ```
88
+
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.
@@ -0,0 +1,56 @@
1
+ # Sharing Relay
2
+
3
+ ## GitHub and npx
4
+
5
+ The GitHub repository is enough for friends to install or try Relay:
6
+
7
+ ```sh
8
+ npm install -g github:blackspider-ops/relay#v0.4.1
9
+ npx --yes github:blackspider-ops/relay#v0.4.1 --version
10
+ ```
11
+
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.
14
+
15
+ ## Publish on npm
16
+
17
+ Only a maintainer needs to do this:
18
+
19
+ ```sh
20
+ npm login
21
+ npm test
22
+ npm run check
23
+ npm pack --dry-run
24
+ npm publish --access public
25
+ ```
26
+
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
+ After publishing, friends can use:
32
+
33
+ ```sh
34
+ npx relay-codex setup --count 2
35
+ npm install -g relay-codex
36
+ ```
37
+
38
+ ## Homebrew
39
+
40
+ The repository includes a formula under `Formula/`. It can be installed from this
41
+ repository as a personal tap; it does not need acceptance into Homebrew's main
42
+ collection. The formula must point to a tagged release archive with its actual
43
+ SHA-256 checksum.
44
+
45
+ ```sh
46
+ brew tap blackspider-ops/relay https://github.com/blackspider-ops/relay
47
+ brew install blackspider-ops/relay/relay-codex
48
+ ```
49
+
50
+ For a new release, update the package and Python versions together, run the checks,
51
+ tag it, upload the archive, then update the formula URL and checksum. Keep the
52
+ previous release available for users who need to roll back.
53
+
54
+ Official references: [npm publishing](https://docs.npmjs.com/creating-and-publishing-unscoped-public-packages),
55
+ [npx](https://docs.npmjs.com/cli/v11/commands/npx/), and
56
+ [Homebrew taps](https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap).
@@ -0,0 +1,37 @@
1
+ # Troubleshooting
2
+
3
+ **“This conversation is open in another app.”**
4
+
5
+ Close or unload that conversation in its original app before resuming it. Or fork
6
+ it into a separate conversation. For a Relay-owned task, use
7
+ `relay attach --takeover SESSION_ID`.
8
+
9
+ **A session needs attention.**
10
+
11
+ Run `relay status`, then `relay events`. Once the cause is resolved, use
12
+ `relay restart SESSION_ID` if the worker stopped, or `relay retry SESSION_ID` if it
13
+ is still running and you have reviewed the interrupted work.
14
+
15
+ **Setup was interrupted.**
16
+
17
+ Run the command again. Relay retries unfinished dependency installation. If Python
18
+ is missing or too old, install it with `brew install python@3.13`.
19
+
20
+ **An account needs another sign-in.**
21
+
22
+ Move its live tasks to another account, then run `relay account reauth ACCOUNT`.
23
+ Other accounts can keep working.
24
+
25
+ **The terminal disconnected after a worker restart.**
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.
30
+
31
+ **Something still seems wrong.**
32
+
33
+ Run `relay doctor --self-test`. Include the Relay and Codex versions when reporting
34
+ a bug. Do not post auth files, tokens, connection files, or private task prompts.
35
+
36
+ Long runs, physical sleep/wake cycles, real revoked logins, and future Codex
37
+ versions need broader live testing. The tests do not remove those limits.
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "relay-codex",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.1",
4
+ "description": "Keep your Codex sessions moving between your own accounts.",
5
+ "license": "MIT",
6
+ "author": "blackspider-ops",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/blackspider-ops/relay.git"
10
+ },
11
+ "homepage": "https://github.com/blackspider-ops/relay#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/blackspider-ops/relay/issues"
14
+ },
15
+ "bin": {
16
+ "relay": "bin/relay.cjs"
17
+ },
18
+ "files": [
19
+ "bin/*.cjs",
20
+ "python/*.py",
21
+ "python/*.swift",
22
+ "python/*.lock",
23
+ "python/*.txt",
24
+ "tests/*.py",
25
+ "README.md",
26
+ "LICENSE",
27
+ "docs/*.md",
28
+ "assets/*.svg"
29
+ ],
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "os": [
34
+ "darwin"
35
+ ],
36
+ "keywords": [
37
+ "codex",
38
+ "terminal",
39
+ "accounts",
40
+ "macos"
41
+ ],
42
+ "scripts": {
43
+ "test": "node --test tests/bootstrap.test.cjs",
44
+ "check": "node scripts/check-release.cjs"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }
@@ -0,0 +1,70 @@
1
+ import AppKit
2
+
3
+ final class MenuController: NSObject, NSApplicationDelegate {
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")
8
+ func applicationDidFinishLaunching(_ notification: Notification) {
9
+ 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() }
14
+ }
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) }
30
+ }
31
+ }
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())
39
+ for account in accounts {
40
+ let name = account["name"] as? String ?? "Account"
41
+ 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: ""))
46
+ }
47
+ 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: ""))
51
+ }
52
+ 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
58
+ }
59
+ @objc func copyDashboard() {
60
+ NSPasteboard.general.clearContents(); NSPasteboard.general.setString("relay status --watch", forType: .string)
61
+ }
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()
66
+ }
67
+ @objc func quitMenu() { NSApp.terminate(nil) }
68
+ }
69
+ let app=NSApplication.shared
70
+ let delegate=MenuController(); app.delegate=delegate; app.run()