codex-ringside 0.0.0-stage → 0.1.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.
@@ -0,0 +1,222 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const { execFileSync } = require('node:child_process');
6
+ const launchd = require('./launchd.cjs');
7
+ const { DEFAULT_APP, isExecutable, resolveCodexBin, resolveNode } = require('./resolve.cjs');
8
+
9
+ const SWIFTC = '/usr/bin/swiftc';
10
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
11
+ const defaults = {
12
+ repoRoot: path.resolve(__dirname, '..'),
13
+ current: { ...launchd.paths, label: launchd.LABEL },
14
+ legacy: { ...launchd.legacyPaths, label: launchd.LEGACY_LABEL },
15
+ backupRoot: path.join(os.homedir(), 'Library/Application Support/CodexRingside Backups'),
16
+ trashRoot: path.join(os.homedir(), '.Trash'),
17
+ services: launchd
18
+ };
19
+
20
+ function readJson(file) {
21
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
22
+ catch (error) {
23
+ if (error.code === 'ENOENT') return null;
24
+ throw new Error(`无法读取设置:${file}`, { cause: error });
25
+ }
26
+ }
27
+
28
+ function writeJson(file, data) {
29
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
30
+ }
31
+
32
+ function preflight(settings) {
33
+ if (process.platform !== 'darwin') throw new Error('只支持 macOS');
34
+ const app = fs.realpathSync(settings.app);
35
+ resolveCodexBin(app);
36
+ if (!isExecutable(SWIFTC)) throw new Error('需要 Xcode Command Line Tools:xcode-select --install');
37
+ return { app, node: resolveNode() };
38
+ }
39
+
40
+ function compile(repoRoot, stage) {
41
+ execFileSync(SWIFTC, ['-O', '-swift-version', '5', path.join(repoRoot, 'native/bridge.swift'), '-o', path.join(stage, 'bridge')], {
42
+ stdio: ['ignore', 'ignore', 'inherit'], timeout: 180000
43
+ });
44
+ }
45
+
46
+ async function waitReady(current, startedAt) {
47
+ for (let i = 0; i < 100; i++) {
48
+ const state = readJson(path.join(current.installDir, 'state.json'));
49
+ if (['watching', 'observing'].includes(state?.event) && state.updatedAt >= startedAt) return;
50
+ await sleep(100);
51
+ }
52
+ throw new Error(`后台任务未就绪,请查看 ${path.join(current.logDir, 'agent.err.log')}`);
53
+ }
54
+
55
+ function copyIfPresent(source, destination) {
56
+ if (!fs.existsSync(source)) return;
57
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
58
+ fs.cpSync(source, destination, { recursive: true, preserveTimestamps: true });
59
+ }
60
+
61
+ function capture(locations, backup) {
62
+ return locations.flatMap((location, index) => ['installDir', 'logDir', 'plist'].map(key => {
63
+ const source = location[key];
64
+ const destination = path.join(backup, String(index), key);
65
+ const exists = fs.existsSync(source);
66
+ if (exists) copyIfPresent(source, destination);
67
+ return { source, destination, exists };
68
+ }));
69
+ }
70
+
71
+ // Serializes install/uninstall. A dead install process leaves a recoverable lock, not a permanent block.
72
+ async function withLock(current, action) {
73
+ const file = `${current.installDir}.lock`;
74
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
75
+ let fd;
76
+ for (let attempt = 0; attempt < 2; attempt++) {
77
+ try { fd = fs.openSync(file, 'wx', 0o600); break; }
78
+ catch (error) {
79
+ if (error.code !== 'EEXIST') throw error;
80
+ const pid = Number(fs.readFileSync(file, 'utf8'));
81
+ if (!Number.isInteger(pid) || pid <= 0) throw new Error(`安装锁无效,请检查:${file}`);
82
+ try { process.kill(pid, 0); }
83
+ catch (failure) {
84
+ if (failure.code === 'ESRCH') { fs.rmSync(file); continue; }
85
+ throw failure;
86
+ }
87
+ throw new Error('另一个安装或卸载任务正在运行');
88
+ }
89
+ }
90
+ if (fd === undefined) throw new Error('无法取得安装锁');
91
+ fs.writeFileSync(fd, String(process.pid));
92
+ try { return await action(); }
93
+ finally { fs.closeSync(fd); fs.rmSync(file, { force: true }); }
94
+ }
95
+
96
+ function selectSettings(current, legacy, env, observe) {
97
+ const saved = readJson(path.join(current.installDir, 'settings.json'))
98
+ ?? readJson(path.join(legacy.installDir, 'settings.json')) ?? {};
99
+ return {
100
+ app: env.CODEX_RINGSIDE_APP || env.CODEX_PLUS_APP || saved.app || DEFAULT_APP,
101
+ allowClientRestart: observe === undefined ? saved.allowClientRestart !== false : !observe
102
+ };
103
+ }
104
+
105
+ function copyUserData(source, stage, destination) {
106
+ for (const name of ['background.json', 'background', 'user-images']) {
107
+ copyIfPresent(path.join(source, name), path.join(stage, name));
108
+ }
109
+ const file = path.join(stage, 'background.json');
110
+ const config = readJson(file);
111
+ // An image stored inside the old managed directory must survive that directory being archived.
112
+ if (typeof config?.image === 'string') {
113
+ const relative = path.relative(source, config.image);
114
+ if (relative && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) {
115
+ const name = path.join('user-images', `imported${path.extname(config.image)}`);
116
+ copyIfPresent(config.image, path.join(stage, name));
117
+ config.image = path.join(destination, name);
118
+ writeJson(file, config);
119
+ }
120
+ }
121
+ }
122
+
123
+ async function install(options = {}) {
124
+ const { current, legacy, backupRoot, repoRoot, services } = { ...defaults, ...options };
125
+ return withLock(current, async () => {
126
+ const settings = selectSettings(current, legacy, options.env ?? process.env, options.observe);
127
+ const checked = await (options.preflight ?? preflight)(settings);
128
+ settings.app = checked.app;
129
+ const stage = fs.mkdtempSync(`${current.installDir}.staging-`);
130
+ const locations = [current, legacy];
131
+ let backup;
132
+ let snapshots;
133
+ let running;
134
+ let touched = false;
135
+ try {
136
+ fs.cpSync(path.join(repoRoot, 'src'), path.join(stage, 'src'), { recursive: true });
137
+ for (const name of ['package.json', 'LICENSE', 'NOTICE']) fs.copyFileSync(path.join(repoRoot, name), path.join(stage, name));
138
+ await (options.compile ?? compile)(repoRoot, stage);
139
+ fs.mkdirSync(backupRoot, { recursive: true, mode: 0o700 });
140
+ backup = fs.mkdtempSync(path.join(backupRoot, 'install-'));
141
+ snapshots = capture(locations, backup);
142
+ running = locations.map(location => !!services.printJob(location.label));
143
+ writeJson(path.join(backup, 'receipt.json'), { locations, running, createdAt: new Date().toISOString() });
144
+ touched = true;
145
+ for (const location of locations) await services.bootout(location.label);
146
+
147
+ // Copy after stopping writers. Existing Ringside data takes precedence over legacy data.
148
+ const dataRoot = fs.existsSync(path.join(current.installDir, 'settings.json')) ? current.installDir : legacy.installDir;
149
+ copyUserData(dataRoot, stage, current.installDir);
150
+ const cooldown = Math.max(...locations.map(location => {
151
+ const value = readJson(path.join(location.installDir, 'state.json'))?.lastAttemptAt;
152
+ return Number.isFinite(value) ? value : 0;
153
+ }));
154
+ writeJson(path.join(stage, 'settings.json'), settings);
155
+ writeJson(path.join(stage, 'state.json'), { lastAttemptAt: cooldown });
156
+ fs.rmSync(current.installDir, { recursive: true, force: true });
157
+ fs.renameSync(stage, current.installDir);
158
+ fs.mkdirSync(current.logDir, { recursive: true, mode: 0o700 });
159
+ services.writePlist(services.agentConfig({ ...current, node: checked.node }), current.plist);
160
+ const startedAt = Date.now();
161
+ services.bootstrap(current.plist);
162
+ await (options.waitReady ?? waitReady)(current, startedAt);
163
+ for (const key of ['installDir', 'logDir', 'plist']) fs.rmSync(legacy[key], { recursive: true, force: true });
164
+ return { backup, settings };
165
+ } catch (error) {
166
+ const failures = [];
167
+ if (touched) {
168
+ for (const location of locations) {
169
+ try { await services.bootout(location.label); } catch (failure) { failures.push(failure); }
170
+ }
171
+ // Never replace files underneath a job we could not stop.
172
+ if (locations.some(location => services.printJob(location.label))) {
173
+ throw new Error(`安装失败,后台任务无法停止;备份保留在 ${backup}。原因:${error.message}`, { cause: error });
174
+ }
175
+ for (const item of snapshots) {
176
+ try {
177
+ fs.rmSync(item.source, { recursive: true, force: true });
178
+ if (item.exists) copyIfPresent(item.destination, item.source);
179
+ } catch (failure) { failures.push(failure); }
180
+ }
181
+ for (let i = 0; i < locations.length; i++) {
182
+ if (running[i]) {
183
+ try { services.bootstrap(locations[i].plist); } catch (failure) { failures.push(failure); }
184
+ }
185
+ }
186
+ }
187
+ throw new Error(`${error.message}${touched ? `;已尝试恢复原安装,备份:${backup}` : ''}${failures.length ? `;恢复失败:${failures.map(f => f.message).join(' / ')}` : ''}`, { cause: error });
188
+ } finally {
189
+ fs.rmSync(stage, { recursive: true, force: true });
190
+ }
191
+ });
192
+ }
193
+
194
+ async function uninstall(options = {}) {
195
+ const { current, legacy, trashRoot, services } = { ...defaults, ...options };
196
+ return withLock(current, async () => {
197
+ const locations = [current, legacy];
198
+ const running = locations.map(location => !!services.printJob(location.label));
199
+ const items = locations.flatMap((location, i) => launchd.trashItems(location)
200
+ .filter(([file]) => fs.existsSync(file)).map(([file, name]) => [file, `${i ? 'CodexPlus' : 'CodexRingside'}-${name}`]));
201
+ if (!items.length && !running.some(Boolean)) return null;
202
+ fs.mkdirSync(trashRoot, { recursive: true, mode: 0o700 });
203
+ const trash = fs.mkdtempSync(path.join(trashRoot, 'CodexRingside-'));
204
+ const moved = [];
205
+ try {
206
+ for (const location of locations) await services.bootout(location.label);
207
+ await options.removeUi?.();
208
+ for (const [file, name] of items) {
209
+ const destination = path.join(trash, name);
210
+ fs.renameSync(file, destination);
211
+ moved.push([file, destination]);
212
+ }
213
+ return trash;
214
+ } catch (error) {
215
+ for (const [file, destination] of moved.reverse()) fs.renameSync(destination, file);
216
+ for (let i = 0; i < locations.length; i++) if (running[i] && !services.printJob(locations[i].label)) services.bootstrap(locations[i].plist);
217
+ throw error;
218
+ }
219
+ });
220
+ }
221
+
222
+ module.exports = { install, uninstall, selectSettings };
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const { execFileSync } = require('node:child_process');
6
+
7
+ const LABEL = 'com.codexringside.agent';
8
+ const home = os.homedir();
9
+ const paths = {
10
+ installDir: path.join(home, 'Library/Application Support/CodexRingside'),
11
+ logDir: path.join(home, 'Library/Logs/CodexRingside'),
12
+ plist: path.join(home, 'Library/LaunchAgents', `${LABEL}.plist`)
13
+ };
14
+ const LEGACY_LABEL = 'com.codexplus.agent';
15
+ const legacyPaths = {
16
+ installDir: path.join(home, 'Library/Application Support/CodexPlus'),
17
+ logDir: path.join(home, 'Library/Logs/CodexPlus'),
18
+ plist: path.join(home, 'Library/LaunchAgents', `${LEGACY_LABEL}.plist`)
19
+ };
20
+ const domain = () => `gui/${process.getuid()}`;
21
+
22
+ // [installed path, name inside the Trash folder]. The two directories share a basename.
23
+ const trashItems = (locations = paths) => [
24
+ [locations.installDir, 'Application Support'],
25
+ [locations.logDir, 'Logs'],
26
+ [locations.plist, path.basename(locations.plist)]
27
+ ];
28
+
29
+ const escapeXml = value => String(value)
30
+ .replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
31
+
32
+ function xml(value) {
33
+ if (typeof value === 'boolean') return value ? '<true/>' : '<false/>';
34
+ if (typeof value === 'number') return `<integer>${value}</integer>`;
35
+ if (Array.isArray(value)) return `<array>${value.map(xml).join('')}</array>`;
36
+ if (value && typeof value === 'object') {
37
+ const entries = Object.entries(value).map(([key, item]) => `<key>${escapeXml(key)}</key>${xml(item)}`);
38
+ return `<dict>${entries.join('')}</dict>`;
39
+ }
40
+ return `<string>${escapeXml(value)}</string>`;
41
+ }
42
+
43
+ function plistXml(value) {
44
+ return '<?xml version="1.0" encoding="UTF-8"?>\n'
45
+ + '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
46
+ + `<plist version="1.0">${xml(value)}</plist>\n`;
47
+ }
48
+
49
+ function agentConfig({ node, installDir = paths.installDir, logDir = paths.logDir, label = LABEL }) {
50
+ return {
51
+ Label: label,
52
+ ProgramArguments: [node, path.join(installDir, 'src/agent.cjs')],
53
+ RunAtLoad: true,
54
+ KeepAlive: { SuccessfulExit: false },
55
+ ThrottleInterval: 10,
56
+ ProcessType: 'Background',
57
+ StandardOutPath: path.join(logDir, 'agent.log'),
58
+ StandardErrorPath: path.join(logDir, 'agent.err.log')
59
+ };
60
+ }
61
+
62
+ const launchctl = (...args) => execFileSync('/bin/launchctl', args, {
63
+ encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'pipe']
64
+ });
65
+
66
+ function printJob(label = LABEL) {
67
+ try {
68
+ return launchctl('print', `${domain()}/${label}`);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ function bootstrap(plist = paths.plist) {
75
+ launchctl('bootstrap', domain(), plist);
76
+ }
77
+
78
+ async function bootout(label = LABEL) {
79
+ if (!printJob(label)) return;
80
+ launchctl('bootout', `${domain()}/${label}`);
81
+ for (let i = 0; i < 50 && printJob(label); i++) await new Promise(resolve => setTimeout(resolve, 100));
82
+ if (printJob(label)) throw new Error(`后台任务未退出:${label}`);
83
+ }
84
+
85
+ function writePlist(config, file = paths.plist) {
86
+ fs.mkdirSync(path.dirname(file), { recursive: true });
87
+ fs.writeFileSync(file, plistXml(config), { mode: 0o644 });
88
+ execFileSync('/usr/bin/plutil', ['-lint', file], { stdio: 'ignore' });
89
+ }
90
+
91
+ module.exports = { LABEL, LEGACY_LABEL, legacyPaths, agentConfig, bootout, bootstrap, paths, plistXml, printJob, trashItems, writePlist };
package/src/limits.cjs ADDED
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+ // Pure helpers: turn an account/rateLimits/read response into what the card renders.
3
+
4
+ const FIVE_HOURS = 300;
5
+ const ONE_WEEK = 10080;
6
+ const ONE_MONTH = 43200;
7
+
8
+ const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value);
9
+ const hasField = (value, key) => isRecord(value) && Object.hasOwn(value, key);
10
+ const validWindow = value => isRecord(value) && Number.isFinite(value.usedPercent);
11
+ const isCodexLimit = value => value?.limitId == null || value.limitId === 'codex';
12
+ const near = (minutes, target) => Math.abs((minutes ?? 0) - target) <= 1;
13
+
14
+ function remainingPercent(used) {
15
+ return Math.max(0, Math.min(100, Math.round(100 - used)));
16
+ }
17
+
18
+ function tone(percent) {
19
+ if (!Number.isFinite(percent)) return 'muted';
20
+ if (percent < 10) return 'danger';
21
+ return percent <= 50 ? 'warning' : 'normal';
22
+ }
23
+
24
+ function windowLabels(minutes) {
25
+ if (near(minutes, FIVE_HOURS)) return { short: '5小时', long: '5 小时额度' };
26
+ if (near(minutes, ONE_WEEK)) return { short: '周', long: '每周额度' };
27
+ if (near(minutes, ONE_MONTH)) return { short: '月', long: '每月额度' };
28
+ for (const [unit, label] of [[1440, '天'], [60, '小时']]) {
29
+ if (minutes > 0 && minutes % unit === 0) {
30
+ return { short: `${minutes / unit}${label}`, long: `${minutes / unit} ${label}额度` };
31
+ }
32
+ }
33
+ return { short: '额度', long: '额度' };
34
+ }
35
+
36
+ function formatResetTime(seconds) {
37
+ if (!Number.isFinite(seconds) || seconds <= 0) return null;
38
+ const date = new Date(seconds * 1000);
39
+ if (!Number.isFinite(date.getTime())) return null;
40
+ return new Intl.DateTimeFormat('zh-CN', {
41
+ month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false
42
+ }).format(date);
43
+ }
44
+
45
+ function pickCodexSnapshot(response) {
46
+ if (!isRecord(response)) return null;
47
+ if (hasField(response.rateLimitsByLimitId, 'codex')) {
48
+ const keyed = response.rateLimitsByLimitId.codex;
49
+ return isRecord(keyed) && isCodexLimit(keyed) ? keyed : null;
50
+ }
51
+ if (hasField(response, 'rateLimits')) {
52
+ return isRecord(response.rateLimits) && isCodexLimit(response.rateLimits) ? response.rateLimits : null;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function mergeWindow(current, patch) {
58
+ if (patch === undefined) return current ?? null;
59
+ if (!isRecord(patch)) return null;
60
+ const sameWindow = current
61
+ && (!Number.isFinite(patch.windowDurationMins) || current.windowDurationMins === patch.windowDurationMins);
62
+ return sameWindow ? { ...current, ...patch } : { ...patch };
63
+ }
64
+
65
+ function mergeSnapshot(current, patch) {
66
+ if (patch === undefined) return current ?? null;
67
+ if (!isRecord(patch)) return null;
68
+ // A plan change means a different account: nothing from the old windows applies.
69
+ const base = current?.planType && patch.planType && current.planType !== patch.planType ? null : current;
70
+ return {
71
+ ...base,
72
+ ...patch,
73
+ primary: mergeWindow(base?.primary, patch.primary),
74
+ secondary: mergeWindow(base?.secondary, patch.secondary)
75
+ };
76
+ }
77
+
78
+ // account/rateLimits/updated carries deltas; a read is a full snapshot (pass current = null).
79
+ function mergeResponse(current, patch) {
80
+ if (patch === undefined) return current ?? null;
81
+ if (!isRecord(patch)) return null;
82
+ const changedAccount = hasField(patch, 'accountId') && hasField(current, 'accountId')
83
+ && patch.accountId !== current.accountId;
84
+ const base = changedAccount ? {} : current ?? {};
85
+ const merged = { ...base, ...patch };
86
+ merged.rateLimits = base.rateLimits ?? null;
87
+ if (isRecord(base.rateLimitsByLimitId)) merged.rateLimitsByLimitId = base.rateLimitsByLimitId;
88
+
89
+ if (hasField(patch, 'rateLimits') && isCodexLimit(patch.rateLimits)) {
90
+ merged.rateLimits = mergeSnapshot(base.rateLimits, patch.rateLimits);
91
+ if (isRecord(base.rateLimitsByLimitId)) {
92
+ merged.rateLimitsByLimitId = { ...base.rateLimitsByLimitId, codex: merged.rateLimits };
93
+ }
94
+ }
95
+
96
+ if (hasField(patch, 'rateLimitsByLimitId')) {
97
+ if (!isRecord(patch.rateLimitsByLimitId)) {
98
+ merged.rateLimitsByLimitId = null;
99
+ if (!hasField(patch, 'rateLimits')) merged.rateLimits = null;
100
+ } else {
101
+ const next = { ...merged.rateLimitsByLimitId };
102
+ for (const [id, snapshot] of Object.entries(patch.rateLimitsByLimitId)) {
103
+ const previous = base.rateLimitsByLimitId?.[id] ?? (id === 'codex' ? base.rateLimits : null);
104
+ Object.defineProperty(next, id, {
105
+ value: mergeSnapshot(previous, snapshot), enumerable: true, configurable: true, writable: true
106
+ });
107
+ }
108
+ merged.rateLimitsByLimitId = next;
109
+ if (hasField(next, 'codex')) merged.rateLimits = next.codex;
110
+ }
111
+ }
112
+ return merged;
113
+ }
114
+
115
+ function formatUsage(response) {
116
+ const snapshot = pickCodexSnapshot(response);
117
+ const windows = [snapshot?.primary, snapshot?.secondary]
118
+ .filter(validWindow)
119
+ .sort((a, b) => (a.windowDurationMins ?? 0) - (b.windowDurationMins ?? 0));
120
+ const rings = windows.map(window => {
121
+ const labels = windowLabels(window.windowDurationMins);
122
+ const percent = remainingPercent(window.usedPercent);
123
+ return { label: labels.short, name: labels.long, percent, tone: tone(percent), resetsAt: formatResetTime(window.resetsAt) };
124
+ });
125
+ return {
126
+ rings,
127
+ message: rings.length ? null : '暂时无法读取 Codex 剩余额度',
128
+ note: resetCreditsNote(response)
129
+ };
130
+ }
131
+
132
+ function resetCreditsNote(response) {
133
+ const count = Number(response?.rateLimitResetCredits?.availableCount);
134
+ return Number.isFinite(count) && count > 0 ? `重置卡:${Math.floor(count)} 张可用` : null;
135
+ }
136
+
137
+ function unavailable(value, message = '暂时无法读取 Codex 剩余额度,正在重试') {
138
+ return {
139
+ ...value,
140
+ message,
141
+ stale: true,
142
+ rings: (value?.rings ?? []).map(ring => ({ ...ring, percent: null, tone: 'muted', resetsAt: null }))
143
+ };
144
+ }
145
+
146
+ module.exports = { formatResetTime, formatUsage, mergeResponse, pickCodexSnapshot, remainingPercent, tone, unavailable };
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const { execFileSync } = require('node:child_process');
6
+
7
+ const DEFAULT_APP = '/Applications/ChatGPT.app';
8
+ const MIN_NODE_MAJOR = 22;
9
+
10
+ function isExecutable(file) {
11
+ try {
12
+ fs.accessSync(file, fs.constants.X_OK);
13
+ return fs.statSync(file).isFile();
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ // Always this client's own CLI: a PATH install can lag behind what the desktop app expects.
20
+ function resolveCodexBin(appPath = DEFAULT_APP) {
21
+ const found = [
22
+ `${appPath}/Contents/Resources/codex-cli/bin/codex`,
23
+ `${appPath}/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex`,
24
+ `${appPath}/Contents/Resources/codex`
25
+ ].find(isExecutable);
26
+ if (!found) throw new Error(`找不到 ${appPath} 内置的 Codex CLI`);
27
+ return found;
28
+ }
29
+
30
+ function nodeMajor(file) {
31
+ try {
32
+ const version = execFileSync(file, ['--version'], { encoding: 'utf8', timeout: 5000 });
33
+ return Number(version.match(/^v(\d+)/)?.[1]);
34
+ } catch {
35
+ return 0;
36
+ }
37
+ }
38
+
39
+ // Prefer a stable symlink: Homebrew's real path contains the version and vanishes on upgrade.
40
+ function resolveNode() {
41
+ const found = ['/opt/homebrew/bin/node', '/usr/local/bin/node', process.execPath]
42
+ .find(file => isExecutable(file) && nodeMajor(file) >= MIN_NODE_MAJOR);
43
+ if (!found) throw new Error(`需要 Node.js ${MIN_NODE_MAJOR} 或更高版本`);
44
+ return found;
45
+ }
46
+
47
+ function authFile() {
48
+ return path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'auth.json');
49
+ }
50
+
51
+ module.exports = { DEFAULT_APP, MIN_NODE_MAJOR, authFile, isExecutable, resolveCodexBin, resolveNode };
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
4
+ const MAX_LAUNCH_AGE_MS = 8000;
5
+ const QUIT_TIMEOUT_MS = 10000;
6
+
7
+ // Decides whether a freshly opened Codex may be swapped for one with the debugging port.
8
+ // All side effects go through `adapter`, so every guard below is testable without a real app.
9
+ class StartupController {
10
+ constructor(adapter, { now = Date.now, sleep = delay, lastAttemptAt = 0, cooldownMs = 120000, allowClientRestart = true } = {}) {
11
+ Object.assign(this, { adapter, now, sleep, lastAttemptAt, cooldownMs });
12
+ this.allowClientRestart = allowClientRestart === true;
13
+ this.seen = new Set();
14
+ this.baselined = false;
15
+ this.busy = false;
16
+ this.stopped = false;
17
+ this.pendingLaunch = false;
18
+ }
19
+
20
+ stop() {
21
+ this.stopped = true;
22
+ }
23
+
24
+ remember(apps) {
25
+ for (const app of apps) this.seen.add(app.key);
26
+ }
27
+
28
+ async tick() {
29
+ if (this.busy || this.stopped) return;
30
+ this.busy = true;
31
+ this.pendingLaunch = false;
32
+ try {
33
+ await this.evaluate();
34
+ } catch (error) {
35
+ await this.adapter.record('error', { message: error.message }).catch(() => {});
36
+ } finally {
37
+ this.busy = false;
38
+ }
39
+ }
40
+
41
+ async evaluate() {
42
+ const snapshot = await this.adapter.snapshot();
43
+ if (!this.allowClientRestart) {
44
+ this.seen = new Set(snapshot.apps.map(app => app.key));
45
+ this.baselined = true;
46
+ await this.adapter.record('observing', { existingApps: snapshot.apps.length });
47
+ return;
48
+ }
49
+ // Anything already running when we start is somebody's work session: never touch it.
50
+ if (!this.baselined) {
51
+ this.remember(snapshot.apps);
52
+ this.baselined = true;
53
+ await this.adapter.record('watching', { existingApps: snapshot.apps.length });
54
+ return;
55
+ }
56
+
57
+ const fresh = snapshot.apps.filter(app => !this.seen.has(app.key));
58
+ if (this.seen.size > 256) this.seen = new Set(snapshot.apps.map(app => app.key));
59
+ if (fresh.length !== 1 || snapshot.apps.length !== 1) {
60
+ this.remember(snapshot.apps);
61
+ return;
62
+ }
63
+
64
+ const app = fresh[0];
65
+ const age = this.now() - app.launchedAt;
66
+ if (!app.argumentsKnown || app.debugPort !== null) {
67
+ this.seen.add(app.key);
68
+ return;
69
+ }
70
+ const untouched = age >= 0 && age <= MAX_LAUNCH_AGE_MS && snapshot.inputIdleMs >= age;
71
+ // Never interrupt a partially initialized app; look again shortly.
72
+ if (untouched && (!app.finishedLaunching || snapshot.frontmostPid !== app.pid)) {
73
+ this.pendingLaunch = true;
74
+ return;
75
+ }
76
+
77
+ // Remember rejected candidates too, so a running window is never revisited later.
78
+ this.seen.add(app.key);
79
+ if (!untouched || snapshot.frontmostPid !== app.pid) {
80
+ await this.adapter.record('skipped-active-or-background');
81
+ return;
82
+ }
83
+ if (this.lastAttemptAt && this.now() - this.lastAttemptAt < this.cooldownMs) {
84
+ await this.adapter.record('skipped-cooldown');
85
+ return;
86
+ }
87
+ if (await this.adapter.portInUse()) {
88
+ await this.adapter.record('port-in-use');
89
+ return;
90
+ }
91
+ await this.handoff(app, snapshot);
92
+ }
93
+
94
+ async handoff(app, snapshot) {
95
+ if (this.stopped) return;
96
+ // The receipt must be durable before quitting, so a crash here cannot become a restart loop.
97
+ this.lastAttemptAt = this.now();
98
+ await this.adapter.record('attempt', { lastAttemptAt: this.lastAttemptAt, processKey: app.key });
99
+ if (this.stopped) return;
100
+
101
+ const quit = await this.adapter.quit(app, snapshot.inputStamp);
102
+ if (!quit.accepted) {
103
+ await this.adapter.record('quit-refused');
104
+ return;
105
+ }
106
+
107
+ const absent = await this.waitForExit(app);
108
+ if (!absent) return;
109
+
110
+ await this.sleep(250);
111
+ const beforeLaunch = await this.adapter.snapshot();
112
+ if (this.stopped || beforeLaunch.apps.length) {
113
+ this.remember(beforeLaunch.apps);
114
+ await this.adapter.record('reopen-canceled');
115
+ return;
116
+ }
117
+
118
+ // Past a successful quit, input only suppresses focus; the app itself is always reopened.
119
+ const opened = await this.adapter.launch();
120
+ if (!opened.launched) {
121
+ await this.adapter.record('launch-failed');
122
+ return;
123
+ }
124
+ if (opened.key) this.seen.add(opened.key);
125
+ if (this.stopped) return;
126
+
127
+ const beforeShow = await this.adapter.snapshot();
128
+ this.remember(beforeShow.apps);
129
+ const undisturbed = absent.inputStamp === snapshot.inputStamp
130
+ && beforeLaunch.inputStamp === snapshot.inputStamp
131
+ && beforeShow.inputStamp === snapshot.inputStamp
132
+ && beforeShow.frontmostPid === beforeLaunch.frontmostPid
133
+ && !beforeShow.apps.some(current => current.key !== opened.key);
134
+ const shown = undisturbed && !this.stopped
135
+ ? await this.adapter.show(opened, snapshot.inputStamp, beforeLaunch.frontmostPid)
136
+ : { shown: false };
137
+ await this.adapter.record('reopened', { shown: !!shown.shown, processKey: opened.key });
138
+ }
139
+
140
+ async waitForExit(app) {
141
+ const deadline = this.now() + QUIT_TIMEOUT_MS;
142
+ while (!this.stopped && this.now() < deadline) {
143
+ await this.sleep(200);
144
+ const current = await this.adapter.snapshot();
145
+ if (current.apps.some(other => other.key !== app.key)) {
146
+ this.remember(current.apps);
147
+ await this.adapter.record('another-instance-started');
148
+ return null;
149
+ }
150
+ if (current.apps.length === 0) return current;
151
+ }
152
+ if (!this.stopped) await this.adapter.record('quit-timeout');
153
+ return null;
154
+ }
155
+ }
156
+
157
+ module.exports = { StartupController };