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 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
+ }