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 +21 -0
- package/README.md +99 -2
- package/assets/relay.svg +15 -0
- package/bin/relay.cjs +148 -0
- package/docs/commands.md +92 -0
- package/docs/publishing.md +56 -0
- package/docs/troubleshooting.md +37 -0
- package/package.json +47 -4
- package/python/Menu.swift +70 -0
- package/python/broker.py +297 -0
- package/python/commands.py +701 -0
- package/python/compatibility.py +225 -0
- package/python/desktop.py +52 -0
- package/python/launch.py +117 -0
- package/python/metadata.py +82 -0
- package/python/notifications.py +36 -0
- package/python/pool.py +579 -0
- package/python/probe_native.py +91 -0
- package/python/processes.py +12 -0
- package/python/requirements.lock +6 -0
- package/python/requirements.txt +2 -0
- package/python/router.py +2053 -0
- package/python/runtime.py +97 -0
- package/python/service.py +663 -0
- package/python/settings.py +109 -0
- package/tests/fake_native.py +238 -0
- package/tests/test_capacity.py +217 -0
- package/tests/test_controller.py +519 -0
- package/tests/test_pool.py +446 -0
- package/tests/test_relay.py +173 -0
- package/tests/test_router.py +314 -0
- package/tests/test_services.py +473 -0
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
|
-
|
|
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
|
-
|
|
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)
|
package/assets/relay.svg
ADDED
|
@@ -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; });
|
package/docs/commands.md
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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()
|