codex-rpc 1.0.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/LICENSE +21 -0
- package/README.md +146 -0
- package/codex-rpc.js +905 -0
- package/install.sh +36 -0
- package/package.json +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SSHdotCodes
|
|
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
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# codex-rpc
|
|
2
|
+
|
|
3
|
+
**[codex-rpc.ssh.codes](https://codex-rpc.ssh.codes)** · Discord Rich Presence
|
|
4
|
+
for the **OpenAI Codex CLI / Desktop** — like
|
|
5
|
+
[claude-rpc](https://claude-rpc.com) but for Codex. Your Discord profile shows
|
|
6
|
+
**"Gaming on Codex"** with a cute animated Codex mascot that changes based on
|
|
7
|
+
what Codex is actually doing right now, your active model, lifetime token
|
|
8
|
+
count, and "Get Codex RPC" / "GitHub" buttons.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npx -y codex-rpc
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
That one line installs it and starts it **in the background** — no terminal
|
|
15
|
+
window, starts at login, restarts if it crashes. Re-run it any time to update.
|
|
16
|
+
|
|
17
|
+
Zero dependencies, single file, Node ≥ 18. It tails Codex's session logs
|
|
18
|
+
(`~/.codex/sessions/**/rollout-*.jsonl`) — no hooks, no wrappers, works with
|
|
19
|
+
both the CLI and the desktop app, and nothing ever leaves your machine.
|
|
20
|
+
|
|
21
|
+
## States
|
|
22
|
+
|
|
23
|
+
| state | shown as | animation | triggered by |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| `thinking` | 🧠 Thinking… | thinking | reasoning, new prompts, task start, chewing on command results |
|
|
26
|
+
| `coding` | ⌨️ Writing code | typing | `apply_patch` file edits |
|
|
27
|
+
| `reading` | 📖 Reading files | typing | `cat`, `head`, `tail`, `sed -n`, … |
|
|
28
|
+
| `searching` | 🔍 Searching | typing | `rg`/`grep`/`find`, web search, browsing tools |
|
|
29
|
+
| `building` | ⚙️ Working… | typing | builds, installs, running commands (held while a command runs) |
|
|
30
|
+
| `debugging` | 🐛 Debugging | typing | failing test runners / real command errors |
|
|
31
|
+
| `success` | ✅ Task complete! | sleeping | `task_complete` (lingers ~3 min, then sleeps) |
|
|
32
|
+
| `error` | ⚠️ Hit a snag | typing | failed patches, stream errors, aborted turns |
|
|
33
|
+
| `sleeping` | 😴 Sleeping | sleeping | no activity for 5 min, or Codex not running |
|
|
34
|
+
| `deploying` | 🚀 Shipping it | typing | `git push`, `rsync`/`scp`, publish/deploy commands |
|
|
35
|
+
|
|
36
|
+
Three seamless ~19s loops cover everything — **thinking** while Codex
|
|
37
|
+
reasons, **typing** while it edits files and runs commands, **sleeping** when
|
|
38
|
+
it's idle or not running — so a long build or think just keeps looping
|
|
39
|
+
cleanly. The state line also shows your **lifetime Codex token usage**
|
|
40
|
+
(summed across every session in `~/.codex/sessions`, updated live), and
|
|
41
|
+
hovering the art shows your 5-hour rate-limit usage. Set
|
|
42
|
+
`clearWhenQuit: true` to hide the presence entirely when Codex is closed
|
|
43
|
+
instead of showing 😴, and `showTokens: false` to hide the token counter.
|
|
44
|
+
|
|
45
|
+
## Setup
|
|
46
|
+
|
|
47
|
+
**None, out of the box.** A shared "Gaming on Codex" Discord application id is
|
|
48
|
+
baked in (app ids are public identifiers, not secrets — same model as
|
|
49
|
+
claude-rpc), and the animations are served from this repo's raw GitHub URLs —
|
|
50
|
+
Discord only animates presence images that come from external URLs (it
|
|
51
|
+
flattens uploaded art assets to static PNGs). Just run it.
|
|
52
|
+
|
|
53
|
+
<details>
|
|
54
|
+
<summary>Using your own Discord application instead</summary>
|
|
55
|
+
|
|
56
|
+
1. **Create a Discord application** at
|
|
57
|
+
<https://discord.com/developers/applications> → *New Application*.
|
|
58
|
+
The application **name is the "Playing …" headline** on your profile.
|
|
59
|
+
2. **Upload the animations**: in your app → *Rich Presence* → *Art Assets* →
|
|
60
|
+
upload everything in [`assets/`](assets/) (10 GIFs + `codex.png`).
|
|
61
|
+
Keep the file names as the asset keys: `thinking`, `coding`, `reading`,
|
|
62
|
+
`searching`, `building`, `debugging`, `success`, `error`, `sleeping`,
|
|
63
|
+
`deploying`, `codex`. Assets can take a few minutes to propagate.
|
|
64
|
+
3. **Save your client id** (the *Application ID* on the app's General page):
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
codex-rpc setup --client-id 123456789012345678
|
|
68
|
+
```
|
|
69
|
+
</details>
|
|
70
|
+
|
|
71
|
+
Run `npx -y codex-rpc` to install/update and start the background agent.
|
|
72
|
+
|
|
73
|
+
## Use
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
codex-rpc # start in the background (auto-starts at login)
|
|
77
|
+
codex-rpc stop # stop it codex-rpc uninstall # remove agent
|
|
78
|
+
codex-rpc logs # tail the daemon log
|
|
79
|
+
codex-rpc run # run in the foreground instead (--dry: no Discord)
|
|
80
|
+
codex-rpc demo # cycle all states every 12s — check your profile!
|
|
81
|
+
codex-rpc set success # hold one state manually
|
|
82
|
+
codex-rpc status # print the detected state (add --follow to stream)
|
|
83
|
+
codex-rpc doctor # sanity-check: node, sessions dir, Discord socket, config
|
|
84
|
+
codex-rpc clear # wipe the presence
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The details line shows the **active model** (from the live session), and the
|
|
88
|
+
presence carries two **buttons** — "Get Codex RPC" and "GitHub" (Discord
|
|
89
|
+
never shows you your own buttons; ask a friend or use a second account).
|
|
90
|
+
Both are configurable (`showModel`, `buttons` in `~/.codex-rpc.json`).
|
|
91
|
+
|
|
92
|
+
The Discord **desktop app** must be running on the same machine (presence goes
|
|
93
|
+
over Discord's local IPC socket). If Discord restarts, codex-rpc reconnects on
|
|
94
|
+
its own.
|
|
95
|
+
|
|
96
|
+
### Options / config
|
|
97
|
+
|
|
98
|
+
Flags: `--client-id`, `--details "Gaming on Codex"`, `--codex-home`,
|
|
99
|
+
`--sleep-after <sec>`, `--dry` (no Discord, log states only).
|
|
100
|
+
Persistent config lives in `~/.codex-rpc.json`:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"clientId": "123456789012345678",
|
|
105
|
+
"details": "Gaming on Codex",
|
|
106
|
+
"sleepAfterSec": 300,
|
|
107
|
+
"successHoldSec": 180,
|
|
108
|
+
"smallImage": "codex",
|
|
109
|
+
"assets": { "thinking": "https://example.com/custom-thinking.gif" }
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`assets` entries may be uploaded asset keys **or** https URLs (Discord proxies
|
|
114
|
+
external images).
|
|
115
|
+
|
|
116
|
+
### Run it in the background (macOS)
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
cat > ~/Library/LaunchAgents/codes.ssh.codex-rpc.plist <<'EOF'
|
|
120
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
121
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
122
|
+
<plist version="1.0"><dict>
|
|
123
|
+
<key>Label</key><string>codes.ssh.codex-rpc</string>
|
|
124
|
+
<key>ProgramArguments</key>
|
|
125
|
+
<array><string>/usr/local/bin/node</string><string>/Users/YOU/codex-rpc/codex-rpc.js</string></array>
|
|
126
|
+
<key>RunAtLoad</key><true/>
|
|
127
|
+
<key>KeepAlive</key><true/>
|
|
128
|
+
</dict></plist>
|
|
129
|
+
EOF
|
|
130
|
+
launchctl load ~/Library/LaunchAgents/codes.ssh.codex-rpc.plist
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
(Adjust the node path — `which node` — and your username.)
|
|
134
|
+
|
|
135
|
+
## How detection works
|
|
136
|
+
|
|
137
|
+
Codex appends every session event to a rollout JSONL file. codex-rpc finds the
|
|
138
|
+
newest one (rescanning every 5s, so new sessions are picked up automatically),
|
|
139
|
+
tails it, and classifies each event — reasoning → thinking, `apply_patch` →
|
|
140
|
+
coding, `exec_command` by its command string, `task_complete` → success, etc.
|
|
141
|
+
Heartbeat events (token counts, streamed messages) keep the current state
|
|
142
|
+
alive; silence rolls over to 😴 after `sleepAfterSec`.
|
|
143
|
+
|
|
144
|
+
The animation sources (1024×1024 15s loop MP4s + the renderer that made them)
|
|
145
|
+
live in `~/codex-animations/` — re-render with `python3 render.py all`, then
|
|
146
|
+
regenerate the GIFs with the ffmpeg one-liner in that folder's history.
|
package/codex-rpc.js
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* codex-rpc — Discord Rich Presence for the OpenAI Codex CLI / Desktop.
|
|
4
|
+
*
|
|
5
|
+
* Tails ~/.codex/sessions rollout logs, classifies what Codex is doing,
|
|
6
|
+
* and shows "Gaming on Codex" + a cute animation per state, claude-rpc style.
|
|
7
|
+
*
|
|
8
|
+
* Zero dependencies. Node >= 18.
|
|
9
|
+
*
|
|
10
|
+
* codex-rpc start watching + updating presence
|
|
11
|
+
* codex-rpc demo cycle through all 10 states (great for testing)
|
|
12
|
+
* codex-rpc set <state> hold one state
|
|
13
|
+
* codex-rpc status print detected state (add --follow to stream)
|
|
14
|
+
* codex-rpc doctor check Discord socket / sessions / config
|
|
15
|
+
* codex-rpc setup --client-id <id> save your Discord application id
|
|
16
|
+
* codex-rpc clear clear presence and exit
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const { execFile } = require('child_process');
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const net = require('net');
|
|
24
|
+
const os = require('os');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------- states
|
|
28
|
+
const STATES = {
|
|
29
|
+
thinking: { text: '🧠 Thinking…', blurb: 'Codex is pondering' },
|
|
30
|
+
coding: { text: '⌨️ Writing code', blurb: 'Codex is writing code' },
|
|
31
|
+
reading: { text: '📖 Reading files', blurb: 'Codex is reading the codebase' },
|
|
32
|
+
searching: { text: '🔍 Searching', blurb: 'Codex is hunting for answers' },
|
|
33
|
+
building: { text: '⚙️ Working…', blurb: 'Codex is running commands' },
|
|
34
|
+
debugging: { text: '🐛 Debugging', blurb: 'Codex is squashing bugs' },
|
|
35
|
+
success: { text: '✅ Task complete!', blurb: 'Codex finished the task' },
|
|
36
|
+
error: { text: '⚠️ Hit a snag', blurb: 'Codex hit an error' },
|
|
37
|
+
sleeping: { text: '😴 Sleeping', blurb: 'Codex is napping' },
|
|
38
|
+
deploying: { text: '🚀 Shipping it', blurb: 'Codex is shipping' },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// The three hero animations cover all states:
|
|
42
|
+
// thinking = reasoning/thinking · coding = editing files/working ·
|
|
43
|
+
// sleeping = idle / Codex not running (and post-task rest)
|
|
44
|
+
// Override per-state via the `assets` config map.
|
|
45
|
+
const STATE_IMAGE = {
|
|
46
|
+
thinking: 'thinking',
|
|
47
|
+
coding: 'coding', reading: 'coding', searching: 'coding', building: 'coding',
|
|
48
|
+
debugging: 'coding', deploying: 'coding', error: 'coding',
|
|
49
|
+
sleeping: 'sleeping', success: 'sleeping',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Default shared "Gaming on Codex" Discord application (public identifier,
|
|
53
|
+
// not a secret — same model as claude-rpc). Override with --client-id,
|
|
54
|
+
// CODEX_RPC_CLIENT_ID, or ~/.codex-rpc.json.
|
|
55
|
+
const DEFAULT_APP_ID = '1522026697908813864';
|
|
56
|
+
|
|
57
|
+
const DEFAULTS = {
|
|
58
|
+
clientId: process.env.CODEX_RPC_CLIENT_ID || DEFAULT_APP_ID,
|
|
59
|
+
details: 'Gaming on Codex',
|
|
60
|
+
codexHome: process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
|
|
61
|
+
sleepAfterSec: 300, // no activity -> sleeping
|
|
62
|
+
successHoldSec: 180, // how long "task complete" lingers
|
|
63
|
+
updateEverySec: 5, // min seconds between presence pushes
|
|
64
|
+
assets: {}, // per-state override: asset key or https URL
|
|
65
|
+
smallImage: 'codex', // set to '' to disable
|
|
66
|
+
showTokens: true, // append lifetime Codex token usage to the state line
|
|
67
|
+
showModel: true, // append the active model to the details line
|
|
68
|
+
clearWhenQuit: false, // true = hide presence when Codex isn't running
|
|
69
|
+
// (default shows 😴 Sleeping instead)
|
|
70
|
+
buttons: [ // up to 2 presence buttons (label ≤ 32 chars)
|
|
71
|
+
{ label: 'Get Codex RPC', url: 'https://codex-rpc.ssh.codes' },
|
|
72
|
+
{ label: 'GitHub', url: 'https://github.com/SSHdotCodes/codex-rpc' },
|
|
73
|
+
],
|
|
74
|
+
// Where the animated GIFs are hosted. Discord flattens *uploaded* art
|
|
75
|
+
// assets to static PNGs; presence only animates via external image URLs
|
|
76
|
+
// (same trick claude-rpc uses). Set to '' to fall back to uploaded assets.
|
|
77
|
+
assetBase: 'https://raw.githubusercontent.com/SSHdotCodes/codex-rpc/main/assets',
|
|
78
|
+
// Discord's media proxy caches external URLs forever — bump this whenever
|
|
79
|
+
// the GIFs change so clients fetch the new frames.
|
|
80
|
+
assetVersion: 2,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const CONFIG_PATH = path.join(os.homedir(), '.codex-rpc.json');
|
|
84
|
+
|
|
85
|
+
function loadConfig(argv) {
|
|
86
|
+
let cfg = { ...DEFAULTS };
|
|
87
|
+
try {
|
|
88
|
+
cfg = { ...cfg, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) };
|
|
89
|
+
} catch { /* no config yet */ }
|
|
90
|
+
const take = (flag) => {
|
|
91
|
+
const i = argv.indexOf(flag);
|
|
92
|
+
if (i !== -1 && argv[i + 1] !== undefined) {
|
|
93
|
+
const v = argv[i + 1];
|
|
94
|
+
argv.splice(i, 2);
|
|
95
|
+
return v;
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
};
|
|
99
|
+
const cid = take('--client-id');
|
|
100
|
+
if (cid) cfg.clientId = cid;
|
|
101
|
+
const det = take('--details');
|
|
102
|
+
if (det) cfg.details = det;
|
|
103
|
+
const home = take('--codex-home');
|
|
104
|
+
if (home) cfg.codexHome = home;
|
|
105
|
+
const sleep = take('--sleep-after');
|
|
106
|
+
if (sleep) cfg.sleepAfterSec = Number(sleep);
|
|
107
|
+
return cfg;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------- classifier
|
|
111
|
+
const RX = {
|
|
112
|
+
deploy: /\b(git\s+push|rsync|scp\s|vercel|netlify|fly\s+deploy|gh\s+(release|pr\s+create)|npm\s+publish|twine\s+upload|cargo\s+publish|wrangler\s+(deploy|publish))\b/,
|
|
113
|
+
test: /\b(pytest|jest|vitest|mocha|playwright\s+test|cargo\s+test|go\s+test|ctest|tox|php\s?unit|rspec)\b|\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b/,
|
|
114
|
+
build: /\b(cargo\s+build|go\s+build|xcodebuild|swift\s+build|docker\s+build|mvn|gradle|make|cmake|ninja|tsc|vite\s+build|webpack|rollup|esbuild|emcc|gcc|g\+\+|clang)\b|\b(npm|pnpm|yarn|bun)\s+(run\s+)?build\b|\b(npm|pnpm)\s+(i|install)\b|\bpip3?\s+install\b|\bbrew\s+install\b|\bcargo\s+add\b/,
|
|
115
|
+
search: /\b(rg|grep|egrep|fgrep|ag|ack|fd|fzf)\b|^\s*(find|ls|tree)\b|\b(curl|wget)\b/,
|
|
116
|
+
read: /\b(cat|bat|head|tail|less|more|nl)\b|\bsed\s+-n\b|\bwc\s+-l\b/,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function classifyCmd(cmd) {
|
|
120
|
+
if (RX.deploy.test(cmd)) return 'deploying';
|
|
121
|
+
if (RX.test.test(cmd)) return 'debugging';
|
|
122
|
+
if (RX.build.test(cmd)) return 'building';
|
|
123
|
+
if (RX.search.test(cmd)) return 'searching';
|
|
124
|
+
if (RX.read.test(cmd)) return 'reading';
|
|
125
|
+
return 'building'; // generic command execution
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const BROWSER_TOOLS = new Set(['click', 'type_text', 'js', 'get_app_state', 'list_apps', 'scroll', 'screenshot']);
|
|
129
|
+
const THINKY_TOOLS = new Set(['update_plan', 'create_goal', 'update_goal', 'get_goal']);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Classify one rollout line.
|
|
133
|
+
*
|
|
134
|
+
* Codex logs items on COMPLETION, so "what is Codex doing right now" is
|
|
135
|
+
* really "what comes after the last logged item":
|
|
136
|
+
* - a function_call with no output yet → that command is running NOW (kind 'call')
|
|
137
|
+
* - an output landed, log went quiet → Codex is chewing on the result (kind
|
|
138
|
+
* 'result'; the Presence machine flips to 'thinking' after a short gap)
|
|
139
|
+
*
|
|
140
|
+
* Returns {state, kind, keepAlive} — state=null means "no state change".
|
|
141
|
+
* callStates correlates call_id → state so exit codes can be judged in context
|
|
142
|
+
* (rg/grep exit 1 just means "no match"; a failing test runner is a real bug).
|
|
143
|
+
*/
|
|
144
|
+
function classifyLine(json, callStates) {
|
|
145
|
+
const p = json.payload || {};
|
|
146
|
+
const t = json.type;
|
|
147
|
+
const pt = p.type;
|
|
148
|
+
|
|
149
|
+
if (t === 'response_item') {
|
|
150
|
+
if (pt === 'reasoning') return { state: 'thinking' };
|
|
151
|
+
if (pt === 'web_search_call') return { state: 'searching', kind: 'call' };
|
|
152
|
+
if (pt === 'tool_search_call') return { state: 'searching', kind: 'call' };
|
|
153
|
+
if (pt === 'tool_search_output') return { state: null, kind: 'result', keepAlive: true };
|
|
154
|
+
if (pt === 'image_generation_call') return { state: 'building', kind: 'call' };
|
|
155
|
+
if (pt === 'function_call' || pt === 'custom_tool_call') {
|
|
156
|
+
const name = p.name || '';
|
|
157
|
+
if (name === 'write_stdin') return { state: null, keepAlive: true };
|
|
158
|
+
let state = 'building';
|
|
159
|
+
if (name === 'apply_patch') state = 'coding';
|
|
160
|
+
else if (name === 'exec_command' || name === 'shell' || name === 'local_shell') {
|
|
161
|
+
try {
|
|
162
|
+
const args = JSON.parse(p.arguments || p.input || '{}');
|
|
163
|
+
const cmd = Array.isArray(args.command) ? args.command.join(' ') : (args.cmd || args.command || '');
|
|
164
|
+
if (cmd) state = classifyCmd(String(cmd));
|
|
165
|
+
} catch { /* keep 'building' */ }
|
|
166
|
+
} else if (BROWSER_TOOLS.has(name)) state = 'searching';
|
|
167
|
+
else if (THINKY_TOOLS.has(name)) state = 'thinking';
|
|
168
|
+
if (p.call_id && callStates) {
|
|
169
|
+
callStates.set(p.call_id, state);
|
|
170
|
+
if (callStates.size > 300) callStates.delete(callStates.keys().next().value);
|
|
171
|
+
}
|
|
172
|
+
return { state, kind: 'call' };
|
|
173
|
+
}
|
|
174
|
+
if (pt === 'function_call_output' || pt === 'custom_tool_call_output') {
|
|
175
|
+
const m = /Exit code:\s*(\d+)/.exec(p.output || '');
|
|
176
|
+
if (m) {
|
|
177
|
+
const code = Number(m[1]);
|
|
178
|
+
const prev = p.call_id && callStates ? callStates.get(p.call_id) : undefined;
|
|
179
|
+
if (code !== 0 && (prev === 'debugging' || code > 1)) {
|
|
180
|
+
return { state: 'debugging', kind: 'result' };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { state: null, kind: 'result', keepAlive: true };
|
|
184
|
+
}
|
|
185
|
+
if (pt === 'message') return { state: null, keepAlive: true };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (t === 'event_msg') {
|
|
189
|
+
if (pt === 'task_started') return { state: 'thinking' };
|
|
190
|
+
if (pt === 'task_complete') return { state: 'success' };
|
|
191
|
+
if (pt === 'user_message') return { state: 'thinking' };
|
|
192
|
+
if (pt === 'web_search_begin') return { state: 'searching', kind: 'call' };
|
|
193
|
+
if (pt === 'web_search_end') return { state: 'searching', kind: 'result' };
|
|
194
|
+
if (pt === 'patch_apply_begin') return { state: 'coding', kind: 'call' };
|
|
195
|
+
if (pt === 'patch_apply_end') {
|
|
196
|
+
return p.success === false
|
|
197
|
+
? { state: 'error', kind: 'result' }
|
|
198
|
+
: { state: 'coding', kind: 'result' };
|
|
199
|
+
}
|
|
200
|
+
if (pt === 'error' || pt === 'stream_error' || pt === 'turn_aborted') return { state: 'error' };
|
|
201
|
+
if (pt === 'mcp_tool_call_begin') return { state: 'building', kind: 'call' };
|
|
202
|
+
if (pt === 'mcp_tool_call_end') return { state: null, kind: 'result', keepAlive: true };
|
|
203
|
+
if (pt === 'image_generation_end') return { state: null, kind: 'result', keepAlive: true };
|
|
204
|
+
if (pt === 'agent_message' || pt === 'token_count' || pt === 'agent_reasoning' ||
|
|
205
|
+
pt === 'context_compacted' || pt === 'thread_rolled_back') {
|
|
206
|
+
return { state: null, keepAlive: true };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { state: null };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------- session watcher
|
|
213
|
+
function listDirs(p) {
|
|
214
|
+
try { return fs.readdirSync(p, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name); }
|
|
215
|
+
catch { return []; }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function topRollouts(sessionsRoot, k) {
|
|
219
|
+
const all = [];
|
|
220
|
+
for (const y of listDirs(sessionsRoot)) {
|
|
221
|
+
for (const m of listDirs(path.join(sessionsRoot, y))) {
|
|
222
|
+
for (const d of listDirs(path.join(sessionsRoot, y, m))) {
|
|
223
|
+
const dir = path.join(sessionsRoot, y, m, d);
|
|
224
|
+
let files;
|
|
225
|
+
try { files = fs.readdirSync(dir); } catch { continue; }
|
|
226
|
+
for (const f of files) {
|
|
227
|
+
if (!f.startsWith('rollout-') || !f.endsWith('.jsonl')) continue;
|
|
228
|
+
const fp = path.join(dir, f);
|
|
229
|
+
let st;
|
|
230
|
+
try { st = fs.statSync(fp); } catch { continue; }
|
|
231
|
+
all.push({ path: fp, mtimeMs: st.mtimeMs });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
all.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
237
|
+
return all.slice(0, k);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function newestRollout(sessionsRoot) {
|
|
241
|
+
return topRollouts(sessionsRoot, 1)[0] || null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Last cumulative token total recorded in a rollout file (reads the tail). */
|
|
245
|
+
function lastSessionTokens(fp) {
|
|
246
|
+
try {
|
|
247
|
+
const st = fs.statSync(fp);
|
|
248
|
+
const len = Math.min(st.size, 64 * 1024);
|
|
249
|
+
if (!len) return 0;
|
|
250
|
+
const buf = Buffer.alloc(len);
|
|
251
|
+
const fd = fs.openSync(fp, 'r');
|
|
252
|
+
fs.readSync(fd, buf, 0, len, st.size - len);
|
|
253
|
+
fs.closeSync(fd);
|
|
254
|
+
const text = buf.toString('utf8');
|
|
255
|
+
let idx = text.lastIndexOf('"token_count"');
|
|
256
|
+
while (idx !== -1) {
|
|
257
|
+
const lineStart = text.lastIndexOf('\n', idx) + 1;
|
|
258
|
+
const lineEnd = text.indexOf('\n', idx);
|
|
259
|
+
try {
|
|
260
|
+
const j = JSON.parse(text.slice(lineStart, lineEnd === -1 ? undefined : lineEnd));
|
|
261
|
+
const tot = j.payload?.info?.total_token_usage?.total_tokens;
|
|
262
|
+
if (typeof tot === 'number') return tot;
|
|
263
|
+
} catch { /* partial line, keep looking */ }
|
|
264
|
+
idx = idx > 0 ? text.lastIndexOf('"token_count"', idx - 1) : -1;
|
|
265
|
+
}
|
|
266
|
+
} catch { /* unreadable */ }
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Sum lifetime token usage across every rollout file. */
|
|
271
|
+
function scanAllTokens(sessionsRoot) {
|
|
272
|
+
const perFile = new Map();
|
|
273
|
+
let sum = 0;
|
|
274
|
+
for (const y of listDirs(sessionsRoot)) {
|
|
275
|
+
for (const m of listDirs(path.join(sessionsRoot, y))) {
|
|
276
|
+
for (const d of listDirs(path.join(sessionsRoot, y, m))) {
|
|
277
|
+
const dir = path.join(sessionsRoot, y, m, d);
|
|
278
|
+
let files;
|
|
279
|
+
try { files = fs.readdirSync(dir); } catch { continue; }
|
|
280
|
+
for (const f of files) {
|
|
281
|
+
if (!f.startsWith('rollout-') || !f.endsWith('.jsonl')) continue;
|
|
282
|
+
const fp = path.join(dir, f);
|
|
283
|
+
const n = lastSessionTokens(fp);
|
|
284
|
+
perFile.set(fp, n);
|
|
285
|
+
sum += n;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { sum, perFile };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function fmtTokens(n) {
|
|
294
|
+
const unit = (v, s) => (v < 10 ? v.toFixed(1).replace(/\.0$/, '') : String(Math.round(v))) + s;
|
|
295
|
+
if (n >= 1e9) return unit(n / 1e9, 'B');
|
|
296
|
+
if (n >= 1e6) return unit(n / 1e6, 'M');
|
|
297
|
+
if (n >= 1e3) return unit(n / 1e3, 'k');
|
|
298
|
+
return String(n);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Follows one rollout file: seeded from its tail, then appended lines. */
|
|
302
|
+
class Tailer {
|
|
303
|
+
constructor(file, onLine) {
|
|
304
|
+
this.file = file;
|
|
305
|
+
this.onLine = onLine;
|
|
306
|
+
this.partial = '';
|
|
307
|
+
this.meta = {}; // {cwd, startedAt, sessionTokens, limitPct}
|
|
308
|
+
this.callStates = new Map();
|
|
309
|
+
let size = 0;
|
|
310
|
+
try { size = fs.statSync(file).size; } catch { /* gone already */ }
|
|
311
|
+
this.offset = Math.max(0, size - 256 * 1024);
|
|
312
|
+
this.readAppended(true);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
poll() {
|
|
316
|
+
let st;
|
|
317
|
+
try { st = fs.statSync(this.file); } catch { return; }
|
|
318
|
+
if (st.size > this.offset) this.readAppended(false);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
readAppended(seeding) {
|
|
322
|
+
let fd;
|
|
323
|
+
try { fd = fs.openSync(this.file, 'r'); } catch { return; }
|
|
324
|
+
try {
|
|
325
|
+
const st = fs.fstatSync(fd);
|
|
326
|
+
if (st.size <= this.offset) return;
|
|
327
|
+
const len = st.size - this.offset;
|
|
328
|
+
const buf = Buffer.alloc(Math.min(len, 8 * 1024 * 1024));
|
|
329
|
+
fs.readSync(fd, buf, 0, buf.length, this.offset);
|
|
330
|
+
this.offset += buf.length;
|
|
331
|
+
const chunk = this.partial + buf.toString('utf8');
|
|
332
|
+
const lines = chunk.split('\n');
|
|
333
|
+
this.partial = lines.pop() || '';
|
|
334
|
+
for (const line of lines) this.handleLine(line, seeding);
|
|
335
|
+
} finally { fs.closeSync(fd); }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
handleLine(line, seeding) {
|
|
339
|
+
if (!line.trim()) return;
|
|
340
|
+
let j;
|
|
341
|
+
try { j = JSON.parse(line); } catch { return; }
|
|
342
|
+
if (j.type === 'session_meta' && j.payload) {
|
|
343
|
+
this.meta.cwd = j.payload.cwd;
|
|
344
|
+
this.meta.startedAt = Date.parse(j.payload.timestamp || j.timestamp) || Date.now();
|
|
345
|
+
}
|
|
346
|
+
if (j.type === 'turn_context' && j.payload) {
|
|
347
|
+
if (j.payload.cwd) this.meta.cwd = j.payload.cwd;
|
|
348
|
+
if (j.payload.model) this.meta.model = j.payload.model;
|
|
349
|
+
}
|
|
350
|
+
if (j.type === 'event_msg' && j.payload && j.payload.type === 'token_count') {
|
|
351
|
+
const tot = j.payload.info?.total_token_usage?.total_tokens;
|
|
352
|
+
if (typeof tot === 'number') this.meta.sessionTokens = tot;
|
|
353
|
+
const pct = j.payload.rate_limits?.primary?.used_percent;
|
|
354
|
+
if (typeof pct === 'number') this.meta.limitPct = pct;
|
|
355
|
+
}
|
|
356
|
+
const ts = Date.parse(j.timestamp) || Date.now();
|
|
357
|
+
const res = classifyLine(j, this.callStates);
|
|
358
|
+
this.onLine(res, { ts, seeding, meta: this.meta, file: this.file });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Tails the K most recently modified rollout files at once. Codex Desktop
|
|
364
|
+
* keeps several threads alive simultaneously; following just the newest file
|
|
365
|
+
* flaps between them. With every live session feeding one Presence machine,
|
|
366
|
+
* the latest event across all of them wins.
|
|
367
|
+
*/
|
|
368
|
+
class SessionWatcher {
|
|
369
|
+
constructor(cfg, onEvent, maxTails = 4) {
|
|
370
|
+
this.cfg = cfg;
|
|
371
|
+
this.onEvent = onEvent; // ({state, kind, keepAlive}, {ts, meta, file})
|
|
372
|
+
this.root = path.join(cfg.codexHome, 'sessions');
|
|
373
|
+
this.maxTails = maxTails;
|
|
374
|
+
this.tails = new Map(); // file -> Tailer
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
start() {
|
|
378
|
+
this.rescan(true);
|
|
379
|
+
this.pollTimer = setInterval(() => { for (const t of this.tails.values()) t.poll(); }, 900);
|
|
380
|
+
this.scanTimer = setInterval(() => this.rescan(false), 5000);
|
|
381
|
+
}
|
|
382
|
+
stop() { clearInterval(this.pollTimer); clearInterval(this.scanTimer); }
|
|
383
|
+
|
|
384
|
+
rescan(initial) {
|
|
385
|
+
const top = topRollouts(this.root, this.maxTails);
|
|
386
|
+
const keep = new Set(top.map(f => f.path));
|
|
387
|
+
for (const file of this.tails.keys()) {
|
|
388
|
+
if (!keep.has(file)) this.tails.delete(file);
|
|
389
|
+
}
|
|
390
|
+
for (const f of top) {
|
|
391
|
+
if (!this.tails.has(f.path)) {
|
|
392
|
+
this.tails.set(f.path, new Tailer(f.path, this.onEvent));
|
|
393
|
+
if (!initial) log(`session → ${path.basename(f.path)}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
tailedFiles() { return [...this.tails.keys()]; }
|
|
399
|
+
liveTokens() {
|
|
400
|
+
let sum = 0;
|
|
401
|
+
for (const t of this.tails.values()) sum += t.meta.sessionTokens || 0;
|
|
402
|
+
return sum;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------- state machine
|
|
407
|
+
class Presence {
|
|
408
|
+
constructor(cfg) {
|
|
409
|
+
this.cfg = cfg;
|
|
410
|
+
this.lastState = 'sleeping';
|
|
411
|
+
this.lastKind = null; // 'call' = a command is running right now
|
|
412
|
+
this.lastActivityTs = 0;
|
|
413
|
+
this.successTs = 0;
|
|
414
|
+
this.errorTs = 0;
|
|
415
|
+
this.meta = {};
|
|
416
|
+
}
|
|
417
|
+
onEvent({ state, kind, keepAlive }, { ts, meta }) {
|
|
418
|
+
// Events from several tailed sessions interleave (and seeding replays
|
|
419
|
+
// history), so only newer-or-equal events may move the state.
|
|
420
|
+
if (state && ts >= (this.lastStateTs || 0)) {
|
|
421
|
+
this.lastState = state;
|
|
422
|
+
this.lastStateTs = ts;
|
|
423
|
+
this.lastActivityTs = Math.max(this.lastActivityTs, ts);
|
|
424
|
+
if (meta) this.meta = meta; // display follows the session doing the work
|
|
425
|
+
if (state === 'success') this.successTs = ts;
|
|
426
|
+
if (state === 'error') this.errorTs = ts;
|
|
427
|
+
if (kind) this.lastKind = kind;
|
|
428
|
+
} else if (state || keepAlive) {
|
|
429
|
+
this.lastActivityTs = Math.max(this.lastActivityTs, ts);
|
|
430
|
+
if (kind && ts >= (this.lastStateTs || 0)) this.lastKind = kind;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
current(now = Date.now()) {
|
|
434
|
+
const age = (now - this.lastActivityTs) / 1000;
|
|
435
|
+
if (this.lastState === 'success' && (now - this.successTs) / 1000 < this.cfg.successHoldSec) {
|
|
436
|
+
return 'success';
|
|
437
|
+
}
|
|
438
|
+
if (age > this.cfg.sleepAfterSec) return 'sleeping';
|
|
439
|
+
if (this.lastState === 'error' && (now - this.errorTs) / 1000 < 25) return 'error';
|
|
440
|
+
// The log records items when they FINISH. Quiet after a result means the
|
|
441
|
+
// model is reading/reasoning about it → thinking. Quiet after a call means
|
|
442
|
+
// that command is still running → keep showing its state.
|
|
443
|
+
if (this.lastKind === 'result' && age > 4) return 'thinking';
|
|
444
|
+
return this.lastState;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------- discord ipc
|
|
449
|
+
class DiscordRPC {
|
|
450
|
+
constructor(clientId) {
|
|
451
|
+
this.clientId = clientId;
|
|
452
|
+
this.sock = null;
|
|
453
|
+
this.ready = false;
|
|
454
|
+
this.buf = Buffer.alloc(0);
|
|
455
|
+
this.backoff = 2000;
|
|
456
|
+
this.pending = null; // last activity we want visible
|
|
457
|
+
this.onready = null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
socketCandidates() {
|
|
461
|
+
const dirs = [];
|
|
462
|
+
if (process.env.XDG_RUNTIME_DIR) {
|
|
463
|
+
dirs.push(process.env.XDG_RUNTIME_DIR);
|
|
464
|
+
dirs.push(path.join(process.env.XDG_RUNTIME_DIR, 'app/com.discordapp.Discord'));
|
|
465
|
+
dirs.push(path.join(process.env.XDG_RUNTIME_DIR, 'snap.discord'));
|
|
466
|
+
}
|
|
467
|
+
if (process.env.TMPDIR) dirs.push(process.env.TMPDIR);
|
|
468
|
+
dirs.push('/tmp');
|
|
469
|
+
const out = [];
|
|
470
|
+
for (const d of dirs) for (let i = 0; i < 10; i++) out.push(path.join(d, `discord-ipc-${i}`));
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
connect() {
|
|
475
|
+
const candidates = this.socketCandidates().filter(p => {
|
|
476
|
+
try { return fs.statSync(p).isSocket?.() ?? true; } catch { return false; }
|
|
477
|
+
});
|
|
478
|
+
if (!candidates.length) return this.retry('Discord IPC socket not found (is Discord running?)');
|
|
479
|
+
this.tryNext(candidates, 0);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
tryNext(cands, i) {
|
|
483
|
+
if (i >= cands.length) return this.retry('could not connect to any Discord IPC socket');
|
|
484
|
+
const sock = net.createConnection({ path: cands[i] });
|
|
485
|
+
let settled = false;
|
|
486
|
+
sock.once('connect', () => {
|
|
487
|
+
settled = true;
|
|
488
|
+
this.sock = sock;
|
|
489
|
+
this.buf = Buffer.alloc(0);
|
|
490
|
+
sock.on('data', (d) => this.onData(d));
|
|
491
|
+
sock.on('close', () => this.onClose());
|
|
492
|
+
sock.on('error', () => { /* close handles it */ });
|
|
493
|
+
this.send(0, { v: 1, client_id: this.clientId });
|
|
494
|
+
});
|
|
495
|
+
sock.once('error', () => { if (!settled) this.tryNext(cands, i + 1); });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
retry(msg) {
|
|
499
|
+
if (msg) log(`discord: ${msg} — retrying in ${Math.round(this.backoff / 1000)}s`);
|
|
500
|
+
setTimeout(() => this.connect(), this.backoff);
|
|
501
|
+
this.backoff = Math.min(this.backoff * 1.6, 60000);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
onClose() {
|
|
505
|
+
this.ready = false;
|
|
506
|
+
this.sock = null;
|
|
507
|
+
this.retry('connection closed');
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
send(op, obj) {
|
|
511
|
+
if (!this.sock) return;
|
|
512
|
+
const data = Buffer.from(JSON.stringify(obj));
|
|
513
|
+
const head = Buffer.alloc(8);
|
|
514
|
+
head.writeInt32LE(op, 0);
|
|
515
|
+
head.writeInt32LE(data.length, 4);
|
|
516
|
+
this.sock.write(Buffer.concat([head, data]));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
onData(d) {
|
|
520
|
+
this.buf = Buffer.concat([this.buf, d]);
|
|
521
|
+
while (this.buf.length >= 8) {
|
|
522
|
+
const op = this.buf.readInt32LE(0);
|
|
523
|
+
const len = this.buf.readInt32LE(4);
|
|
524
|
+
if (this.buf.length < 8 + len) break;
|
|
525
|
+
const body = this.buf.subarray(8, 8 + len).toString('utf8');
|
|
526
|
+
this.buf = this.buf.subarray(8 + len);
|
|
527
|
+
let j = {};
|
|
528
|
+
try { j = JSON.parse(body); } catch { /* ignore */ }
|
|
529
|
+
if (op === 3) { this.send(4, j); continue; } // PING → PONG
|
|
530
|
+
if (op === 2) { // CLOSE
|
|
531
|
+
const why = j.message || JSON.stringify(j);
|
|
532
|
+
log(`discord closed the connection: ${why}`);
|
|
533
|
+
if (/client_id|Invalid Client ID/i.test(why)) {
|
|
534
|
+
log('check your --client-id / ~/.codex-rpc.json clientId');
|
|
535
|
+
}
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (j.evt === 'READY') {
|
|
539
|
+
this.ready = true;
|
|
540
|
+
this.backoff = 2000;
|
|
541
|
+
const u = j.data && j.data.user ? `${j.data.user.username}` : 'ok';
|
|
542
|
+
log(`discord connected (${u})`);
|
|
543
|
+
if (this.pending) this.setActivity(this.pending);
|
|
544
|
+
if (this.onready) this.onready();
|
|
545
|
+
}
|
|
546
|
+
if (j.evt === 'ERROR') log(`discord error: ${j.data && j.data.message}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
setActivity(activity) {
|
|
551
|
+
this.pending = activity;
|
|
552
|
+
if (!this.ready) return;
|
|
553
|
+
this.send(1, {
|
|
554
|
+
cmd: 'SET_ACTIVITY',
|
|
555
|
+
args: { pid: process.pid, activity },
|
|
556
|
+
nonce: String(Date.now()) + Math.random().toString(36).slice(2),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
clearActivity() {
|
|
561
|
+
this.pending = null;
|
|
562
|
+
if (!this.ready) return;
|
|
563
|
+
this.send(1, { cmd: 'SET_ACTIVITY', args: { pid: process.pid }, nonce: String(Date.now()) });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ---------------------------------------------------------------- glue
|
|
568
|
+
function log(...a) { console.log(new Date().toTimeString().slice(0, 8), ...a); }
|
|
569
|
+
|
|
570
|
+
function activityFor(cfg, state, meta, startedAt, totalTokens) {
|
|
571
|
+
const s = STATES[state];
|
|
572
|
+
const project = meta && meta.cwd ? path.basename(meta.cwd) : null;
|
|
573
|
+
const img = STATE_IMAGE[state] || state;
|
|
574
|
+
const large = cfg.assets[state] ||
|
|
575
|
+
(cfg.assetBase ? `${cfg.assetBase}/${img}.gif?v=${cfg.assetVersion}` : img);
|
|
576
|
+
let stateText = s.text;
|
|
577
|
+
if (cfg.showTokens && totalTokens > 0) stateText += ` · ${fmtTokens(totalTokens)} tokens`;
|
|
578
|
+
let details = cfg.details;
|
|
579
|
+
if (cfg.showModel && meta && meta.model) details += ` · ${meta.model}`;
|
|
580
|
+
let hover = project ? `${s.blurb} • ${project}` : s.blurb;
|
|
581
|
+
if (meta && typeof meta.limitPct === 'number') {
|
|
582
|
+
hover += ` • ${Math.round(meta.limitPct)}% of 5h limit used`;
|
|
583
|
+
}
|
|
584
|
+
const act = {
|
|
585
|
+
type: 0,
|
|
586
|
+
details,
|
|
587
|
+
state: stateText,
|
|
588
|
+
assets: {
|
|
589
|
+
large_image: large,
|
|
590
|
+
large_text: hover,
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
const btns = (cfg.buttons || []).filter(b => b && b.label && b.url).slice(0, 2);
|
|
594
|
+
if (btns.length) act.buttons = btns;
|
|
595
|
+
if (cfg.smallImage) {
|
|
596
|
+
act.assets.small_image = /^https?:/.test(cfg.smallImage) || !cfg.assetBase
|
|
597
|
+
? cfg.smallImage
|
|
598
|
+
: `${cfg.assetBase}/${cfg.smallImage}.png?v=${cfg.assetVersion}`;
|
|
599
|
+
act.assets.small_text = 'Codex CLI';
|
|
600
|
+
}
|
|
601
|
+
if (startedAt) act.timestamps = { start: Math.floor(startedAt / 1000) * 1000 };
|
|
602
|
+
return act;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** True if the Codex CLI or desktop app is running (matched by basename so
|
|
606
|
+
* paths that merely contain "Codex" — like project folders — don't count). */
|
|
607
|
+
function checkCodexRunning(cb) {
|
|
608
|
+
execFile('ps', ['-Axo', 'comm='], { maxBuffer: 4 * 1024 * 1024 }, (err, out) => {
|
|
609
|
+
if (err) return cb(true); // fail open: never hide presence on a ps hiccup
|
|
610
|
+
cb(out.split('\n').some((l) => {
|
|
611
|
+
const c = l.trim();
|
|
612
|
+
const base = c.split('/').pop();
|
|
613
|
+
if (base === 'codex') return true; // CLI binary
|
|
614
|
+
return base === 'Codex' && c.includes('Codex.app'); // desktop app
|
|
615
|
+
}));
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function runStart(cfg, dry) {
|
|
620
|
+
if (!cfg.clientId && !dry) {
|
|
621
|
+
console.error('No Discord client id set. Run: codex-rpc setup --client-id <your app id>');
|
|
622
|
+
console.error('(create an app at https://discord.com/developers/applications — see README)');
|
|
623
|
+
process.exit(1);
|
|
624
|
+
}
|
|
625
|
+
const sessionsRoot = path.join(cfg.codexHome, 'sessions');
|
|
626
|
+
const presence = new Presence(cfg);
|
|
627
|
+
const watcher = new SessionWatcher(cfg, (res, m) => presence.onEvent(res, m));
|
|
628
|
+
watcher.start();
|
|
629
|
+
|
|
630
|
+
// Lifetime token usage: baseline scan of every past session, plus live
|
|
631
|
+
// counts from the tailed sessions (rebased whenever the tailed set changes).
|
|
632
|
+
let tokens = scanAllTokens(sessionsRoot);
|
|
633
|
+
let scannedKey = watcher.tailedFiles().sort().join('|');
|
|
634
|
+
log(`lifetime tokens across ${tokens.perFile.size} sessions: ${fmtTokens(tokens.sum)}`);
|
|
635
|
+
const totalTokens = () => {
|
|
636
|
+
const key = watcher.tailedFiles().sort().join('|');
|
|
637
|
+
if (key !== scannedKey) { // sessions appeared/rotated
|
|
638
|
+
tokens = scanAllTokens(sessionsRoot);
|
|
639
|
+
scannedKey = key;
|
|
640
|
+
}
|
|
641
|
+
let total = tokens.sum;
|
|
642
|
+
for (const f of watcher.tailedFiles()) {
|
|
643
|
+
const baseline = tokens.perFile.get(f) || 0;
|
|
644
|
+
const live = watcher.tails.get(f).meta.sessionTokens ?? baseline;
|
|
645
|
+
total += live - baseline;
|
|
646
|
+
}
|
|
647
|
+
return total;
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
let rpc = null;
|
|
651
|
+
if (!dry) {
|
|
652
|
+
rpc = new DiscordRPC(cfg.clientId);
|
|
653
|
+
rpc.connect();
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
let codexRunning = true;
|
|
657
|
+
checkCodexRunning((r) => { codexRunning = r; });
|
|
658
|
+
setInterval(() => checkCodexRunning((r) => { codexRunning = r; }), 30000);
|
|
659
|
+
|
|
660
|
+
let lastSent = '';
|
|
661
|
+
let hidden = false;
|
|
662
|
+
let startedShownAt = Date.now();
|
|
663
|
+
const tick = () => {
|
|
664
|
+
const state = presence.current();
|
|
665
|
+
if (cfg.clearWhenQuit && !codexRunning && state === 'sleeping') {
|
|
666
|
+
if (!hidden) {
|
|
667
|
+
hidden = true;
|
|
668
|
+
lastSent = '';
|
|
669
|
+
log('codex is not running — hiding presence');
|
|
670
|
+
if (rpc) rpc.clearActivity();
|
|
671
|
+
}
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (hidden) { hidden = false; log('codex is back — showing presence'); }
|
|
675
|
+
const startedAt = presence.meta.startedAt || startedShownAt;
|
|
676
|
+
const act = activityFor(cfg, state, presence.meta, startedAt, totalTokens());
|
|
677
|
+
const key = JSON.stringify(act);
|
|
678
|
+
if (key !== lastSent) {
|
|
679
|
+
lastSent = key;
|
|
680
|
+
log(`state → ${state} ${act.state}${presence.meta.cwd ? ' (' + path.basename(presence.meta.cwd) + ')' : ''}`);
|
|
681
|
+
if (rpc) rpc.setActivity(act);
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
tick();
|
|
685
|
+
setInterval(tick, cfg.updateEverySec * 1000);
|
|
686
|
+
|
|
687
|
+
const bye = () => {
|
|
688
|
+
if (rpc) rpc.clearActivity();
|
|
689
|
+
setTimeout(() => process.exit(0), 300);
|
|
690
|
+
};
|
|
691
|
+
process.on('SIGINT', bye);
|
|
692
|
+
process.on('SIGTERM', bye); // launchd stops us with SIGTERM
|
|
693
|
+
log(`watching ${sessionsRoot}${dry ? ' (dry run, no Discord)' : ''}`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ---------------------------------------------------------------- daemon
|
|
697
|
+
const AGENT_LABEL = 'codes.ssh.codex-rpc';
|
|
698
|
+
const LOG_PATH = path.join(os.homedir(), '.codex-rpc.log');
|
|
699
|
+
const INSTALL_DIR = path.join(os.homedir(), '.codex-rpc');
|
|
700
|
+
const INSTALLED_SCRIPT = path.join(INSTALL_DIR, 'codex-rpc.js');
|
|
701
|
+
|
|
702
|
+
function agentPlistPath() {
|
|
703
|
+
return path.join(os.homedir(), 'Library', 'LaunchAgents', `${AGENT_LABEL}.plist`);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function stableScriptPath() {
|
|
707
|
+
const self = fs.realpathSync(__filename);
|
|
708
|
+
if (path.resolve(self) === path.resolve(INSTALLED_SCRIPT)) return self;
|
|
709
|
+
fs.mkdirSync(INSTALL_DIR, { recursive: true });
|
|
710
|
+
fs.copyFileSync(self, INSTALLED_SCRIPT);
|
|
711
|
+
fs.chmodSync(INSTALLED_SCRIPT, 0o755);
|
|
712
|
+
return INSTALLED_SCRIPT;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function launchctl(args) {
|
|
716
|
+
try {
|
|
717
|
+
require('child_process').execFileSync('launchctl', args, { stdio: 'pipe' });
|
|
718
|
+
return true;
|
|
719
|
+
} catch { return false; }
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Default command: run in the background with no terminal, starting at login. */
|
|
723
|
+
function runDaemonStart() {
|
|
724
|
+
const self = stableScriptPath();
|
|
725
|
+
if (process.platform === 'darwin') {
|
|
726
|
+
const uid = process.getuid();
|
|
727
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
728
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
729
|
+
<plist version="1.0"><dict>
|
|
730
|
+
<key>Label</key><string>${AGENT_LABEL}</string>
|
|
731
|
+
<key>ProgramArguments</key>
|
|
732
|
+
<array><string>${process.execPath}</string><string>${self}</string><string>run</string></array>
|
|
733
|
+
<key>RunAtLoad</key><true/>
|
|
734
|
+
<key>KeepAlive</key><true/>
|
|
735
|
+
<key>StandardOutPath</key><string>${LOG_PATH}</string>
|
|
736
|
+
<key>StandardErrorPath</key><string>${LOG_PATH}</string>
|
|
737
|
+
</dict></plist>
|
|
738
|
+
`;
|
|
739
|
+
fs.mkdirSync(path.dirname(agentPlistPath()), { recursive: true });
|
|
740
|
+
fs.writeFileSync(agentPlistPath(), plist);
|
|
741
|
+
launchctl(['bootout', `gui/${uid}/${AGENT_LABEL}`]); // restart if already loaded
|
|
742
|
+
// bootout is async; retry bootstrap while the old instance drains
|
|
743
|
+
let ok = false;
|
|
744
|
+
for (let i = 0; i < 10 && !ok; i++) {
|
|
745
|
+
ok = launchctl(['bootstrap', `gui/${uid}`, agentPlistPath()]);
|
|
746
|
+
if (!ok) require('child_process').execSync('sleep 0.5');
|
|
747
|
+
}
|
|
748
|
+
if (!ok) {
|
|
749
|
+
console.error('failed to start the launchd agent — try: codex-rpc run (foreground)');
|
|
750
|
+
process.exit(1);
|
|
751
|
+
}
|
|
752
|
+
console.log('✅ codex-rpc is running in the background (and will start at login).');
|
|
753
|
+
} else {
|
|
754
|
+
// Non-macOS fallback: detached background process (no auto-start at boot).
|
|
755
|
+
const out = fs.openSync(LOG_PATH, 'a');
|
|
756
|
+
const child = require('child_process').spawn(process.execPath, [self, 'run'],
|
|
757
|
+
{ detached: true, stdio: ['ignore', out, out] });
|
|
758
|
+
child.unref();
|
|
759
|
+
console.log(`✅ codex-rpc is running in the background (pid ${child.pid}).`);
|
|
760
|
+
}
|
|
761
|
+
console.log(` logs: codex-rpc logs (${LOG_PATH})`);
|
|
762
|
+
console.log(' stop: codex-rpc stop remove: codex-rpc uninstall');
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function runDaemonStop(remove) {
|
|
766
|
+
if (process.platform === 'darwin') {
|
|
767
|
+
const ok = launchctl(['bootout', `gui/${process.getuid()}/${AGENT_LABEL}`]);
|
|
768
|
+
console.log(ok ? 'stopped.' : 'was not running.');
|
|
769
|
+
if (remove) {
|
|
770
|
+
try { fs.unlinkSync(agentPlistPath()); console.log('launch agent removed.'); } catch { /* absent */ }
|
|
771
|
+
}
|
|
772
|
+
} else {
|
|
773
|
+
console.log('on this platform, find the pid in the log and kill it manually.');
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function runLogs() {
|
|
778
|
+
try {
|
|
779
|
+
const text = fs.readFileSync(LOG_PATH, 'utf8').trimEnd().split('\n');
|
|
780
|
+
console.log(text.slice(-30).join('\n'));
|
|
781
|
+
} catch { console.log(`no logs yet at ${LOG_PATH}`); }
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function runDemo(cfg, argv) {
|
|
785
|
+
const dry = argv.includes('--dry');
|
|
786
|
+
const pi = argv.indexOf('--period');
|
|
787
|
+
const period = pi !== -1 ? Number(argv[pi + 1]) : 12;
|
|
788
|
+
const order = Object.keys(STATES);
|
|
789
|
+
let i = 0;
|
|
790
|
+
let rpc = null;
|
|
791
|
+
if (!dry) {
|
|
792
|
+
if (!cfg.clientId) { console.error('No client id — run codex-rpc setup, or use --dry'); process.exit(1); }
|
|
793
|
+
rpc = new DiscordRPC(cfg.clientId);
|
|
794
|
+
rpc.connect();
|
|
795
|
+
}
|
|
796
|
+
const started = Date.now();
|
|
797
|
+
const show = () => {
|
|
798
|
+
const state = order[i % order.length];
|
|
799
|
+
i++;
|
|
800
|
+
log(`demo → ${state} ${STATES[state].text}`);
|
|
801
|
+
if (rpc) {
|
|
802
|
+
rpc.setActivity(activityFor(cfg, state,
|
|
803
|
+
{ cwd: 'demo-project', limitPct: 25, model: 'gpt-5.5' },
|
|
804
|
+
started, 1234567 + i * 98765));
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
show();
|
|
808
|
+
setInterval(show, period * 1000);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function runSet(cfg, argv) {
|
|
812
|
+
const state = argv.find(a => STATES[a]);
|
|
813
|
+
if (!state) { console.error(`usage: codex-rpc set <${Object.keys(STATES).join('|')}>`); process.exit(1); }
|
|
814
|
+
if (!cfg.clientId) { console.error('No client id — run codex-rpc setup first'); process.exit(1); }
|
|
815
|
+
const rpc = new DiscordRPC(cfg.clientId);
|
|
816
|
+
rpc.onready = () => log(`holding "${STATES[state].text}" — ctrl-c to stop`);
|
|
817
|
+
rpc.connect();
|
|
818
|
+
rpc.setActivity(activityFor(cfg, state, null, Date.now()));
|
|
819
|
+
process.on('SIGINT', () => { rpc.clearActivity(); setTimeout(() => process.exit(0), 300); });
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function runStatus(cfg, argv) {
|
|
823
|
+
const follow = argv.includes('--follow');
|
|
824
|
+
const presence = new Presence(cfg);
|
|
825
|
+
const watcher = new SessionWatcher(cfg, (res, m) => presence.onEvent(res, m));
|
|
826
|
+
watcher.rescan(true);
|
|
827
|
+
const report = () => {
|
|
828
|
+
const state = presence.current();
|
|
829
|
+
const files = watcher.tailedFiles();
|
|
830
|
+
console.log(`${state} ${STATES[state].text}` +
|
|
831
|
+
(presence.meta.cwd ? ` project=${path.basename(presence.meta.cwd)}` : '') +
|
|
832
|
+
` live-session-tokens=${fmtTokens(watcher.liveTokens())}` +
|
|
833
|
+
(files.length ? ` tailing=${files.map(f => path.basename(f)).join(', ')}` : ' (no sessions found)'));
|
|
834
|
+
};
|
|
835
|
+
if (!follow) { report(); process.exit(0); }
|
|
836
|
+
watcher.start();
|
|
837
|
+
report();
|
|
838
|
+
setInterval(report, 5000);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function runDoctor(cfg) {
|
|
842
|
+
const ok = (b, label, extra = '') => console.log(`${b ? ' ✅' : ' ❌'} ${label}${extra ? ' — ' + extra : ''}`);
|
|
843
|
+
console.log('codex-rpc doctor\n');
|
|
844
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
845
|
+
ok(major >= 18, `node ${process.versions.node}`);
|
|
846
|
+
const sessions = path.join(cfg.codexHome, 'sessions');
|
|
847
|
+
const hasSessions = fs.existsSync(sessions);
|
|
848
|
+
ok(hasSessions, `codex sessions dir`, sessions);
|
|
849
|
+
if (hasSessions) {
|
|
850
|
+
const best = newestRollout(sessions);
|
|
851
|
+
ok(!!best, 'newest rollout log', best ? `${path.basename(best.path)} (${Math.round((Date.now() - best.mtimeMs) / 60000)}m old)` : 'none found');
|
|
852
|
+
}
|
|
853
|
+
const rpc = new DiscordRPC('0');
|
|
854
|
+
const socks = rpc.socketCandidates().filter(p => { try { fs.statSync(p); return true; } catch { return false; } });
|
|
855
|
+
ok(socks.length > 0, 'discord ipc socket', socks[0] || 'not found — is the Discord app running?');
|
|
856
|
+
ok(!!cfg.clientId, 'client id configured', cfg.clientId ? cfg.clientId : `run: codex-rpc setup --client-id <id>`);
|
|
857
|
+
console.log('\nasset keys expected on your Discord app (Rich Presence → Art Assets):');
|
|
858
|
+
console.log(' ' + Object.keys(STATES).join(', ') + (cfg.smallImage ? `, ${cfg.smallImage}` : ''));
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function runSetup(cfg, argv) {
|
|
862
|
+
if (!cfg.clientId) {
|
|
863
|
+
console.error('usage: codex-rpc setup --client-id <your discord application id>');
|
|
864
|
+
process.exit(1);
|
|
865
|
+
}
|
|
866
|
+
let existing = {};
|
|
867
|
+
try { existing = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch { /* new */ }
|
|
868
|
+
existing.clientId = cfg.clientId;
|
|
869
|
+
if (cfg.details !== DEFAULTS.details) existing.details = cfg.details;
|
|
870
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n');
|
|
871
|
+
console.log(`saved ${CONFIG_PATH}`);
|
|
872
|
+
console.log('now run: codex-rpc demo (to test) or codex-rpc (to go live)');
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function runClear(cfg) {
|
|
876
|
+
if (!cfg.clientId) process.exit(0);
|
|
877
|
+
const rpc = new DiscordRPC(cfg.clientId);
|
|
878
|
+
rpc.onready = () => { rpc.clearActivity(); setTimeout(() => process.exit(0), 300); };
|
|
879
|
+
rpc.connect();
|
|
880
|
+
setTimeout(() => process.exit(0), 5000);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ---------------------------------------------------------------- main
|
|
884
|
+
const argv = process.argv.slice(2);
|
|
885
|
+
const cmd = argv[0] && !argv[0].startsWith('-') ? argv.shift() : 'start';
|
|
886
|
+
const cfg = loadConfig(argv);
|
|
887
|
+
|
|
888
|
+
switch (cmd) {
|
|
889
|
+
case 'start': runDaemonStart(); break; // background, no terminal
|
|
890
|
+
case 'run': runStart(cfg, argv.includes('--dry')); break; // foreground
|
|
891
|
+
case 'stop': runDaemonStop(false); break;
|
|
892
|
+
case 'uninstall': runDaemonStop(true); break;
|
|
893
|
+
case 'logs': runLogs(); break;
|
|
894
|
+
case 'demo': runDemo(cfg, argv); break;
|
|
895
|
+
case 'set': runSet(cfg, argv); break;
|
|
896
|
+
case 'status': runStatus(cfg, argv); break;
|
|
897
|
+
case 'doctor': runDoctor(cfg); break;
|
|
898
|
+
case 'setup': runSetup(cfg, argv); break;
|
|
899
|
+
case 'clear': runClear(cfg); break;
|
|
900
|
+
default:
|
|
901
|
+
console.log('usage: codex-rpc [start|run|stop|uninstall|logs|demo|set <state>|status|doctor|setup|clear]');
|
|
902
|
+
console.log(' codex-rpc start in the background (auto-starts at login)');
|
|
903
|
+
console.log(' codex-rpc run --dry foreground, log states without Discord');
|
|
904
|
+
process.exit(1);
|
|
905
|
+
}
|
package/install.sh
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# codex-rpc installer — https://codex-rpc.ssh.codes
|
|
3
|
+
# Installs the single-file CLI, then starts it in the background (auto-starts
|
|
4
|
+
# at login on macOS). Re-run any time to update to the latest version.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
RAW="https://raw.githubusercontent.com/SSHdotCodes/codex-rpc/main/codex-rpc.js"
|
|
8
|
+
|
|
9
|
+
NODE="$(command -v node || true)"
|
|
10
|
+
if [ -z "$NODE" ]; then
|
|
11
|
+
echo "codex-rpc needs Node.js 18+ — install it first (e.g. brew install node)"; exit 1
|
|
12
|
+
fi
|
|
13
|
+
"$NODE" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 18 ? 0 : 1)' || {
|
|
14
|
+
echo "codex-rpc needs Node.js 18+ (you have $("$NODE" -v))"; exit 1; }
|
|
15
|
+
|
|
16
|
+
DIR="$HOME/.codex-rpc"
|
|
17
|
+
mkdir -p "$DIR"
|
|
18
|
+
echo "downloading codex-rpc…"
|
|
19
|
+
curl -fsSL "$RAW" -o "$DIR/codex-rpc.js"
|
|
20
|
+
|
|
21
|
+
BIN="$HOME/.local/bin"
|
|
22
|
+
if [ -w /usr/local/bin ]; then BIN=/usr/local/bin; fi
|
|
23
|
+
mkdir -p "$BIN"
|
|
24
|
+
printf '#!/bin/sh\nexec "%s" "%s" "$@"\n' "$NODE" "$DIR/codex-rpc.js" > "$BIN/codex-rpc"
|
|
25
|
+
chmod +x "$BIN/codex-rpc"
|
|
26
|
+
echo "installed: $BIN/codex-rpc"
|
|
27
|
+
|
|
28
|
+
case ":$PATH:" in
|
|
29
|
+
*":$BIN:"*) ;;
|
|
30
|
+
*) echo "note: add $BIN to your PATH → export PATH=\"$BIN:\$PATH\"" ;;
|
|
31
|
+
esac
|
|
32
|
+
|
|
33
|
+
"$BIN/codex-rpc" start
|
|
34
|
+
echo
|
|
35
|
+
echo "Done! Open Discord and your profile will show 'Gaming on Codex' as you use Codex."
|
|
36
|
+
echo "Commands: codex-rpc logs · codex-rpc stop · codex-rpc uninstall · codex-rpc demo"
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "codex-rpc",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Discord Rich Presence for the OpenAI Codex CLI — Gaming on Codex, with cute per-state animations",
|
|
5
|
+
"homepage": "https://codex-rpc.ssh.codes",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/SSHdotCodes/codex-rpc.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/SSHdotCodes/codex-rpc/issues"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"codex",
|
|
15
|
+
"discord",
|
|
16
|
+
"rich-presence",
|
|
17
|
+
"rpc",
|
|
18
|
+
"cli"
|
|
19
|
+
],
|
|
20
|
+
"bin": {
|
|
21
|
+
"codex-rpc": "codex-rpc.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"codex-rpc.js",
|
|
25
|
+
"install.sh"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT"
|
|
31
|
+
}
|