@ailuntz/codex-desktop-router 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ import { Transform } from 'node:stream';
2
+
3
+ // A byte-oriented framer preserves UTF-8 split across chunks and leaves every
4
+ // non-routed frame exactly as received (including whitespace and CRLF).
5
+ export class JsonLineRouter extends Transform {
6
+ constructor({ onReject, rewriteMessage, runFrame = operation => operation(), maxFrameBytes = 64 * 1024 * 1024 }) {
7
+ super();
8
+ Object.assign(this, { onReject, rewriteMessage, runFrame, maxFrameBytes });
9
+ this.parts = []; this.size = 0;
10
+ }
11
+ async frame(buffer) { return this.runFrame(() => this.routeFrame(buffer)); }
12
+ async routeFrame(buffer) {
13
+ let message;
14
+ try { message = JSON.parse(buffer.toString('utf8')); } catch { this.push(buffer); return; }
15
+ let routed;
16
+ try { routed = await this.rewriteMessage(message); }
17
+ catch (error) {
18
+ // A broken enabled configuration must never silently spend OpenAI quota.
19
+ this.onReject({ id: message.id, error: { code: -32603, message: `codex-desktop-router: ${error.message}` } });
20
+ return;
21
+ }
22
+ if (routed === message) this.push(buffer);
23
+ else if (routed == null) return;
24
+ else { this.push(Buffer.from(JSON.stringify(routed) + '\n')); }
25
+ }
26
+ _transform(chunk, encoding, callback) {
27
+ (async () => {
28
+ let start = 0;
29
+ while (start < chunk.length) {
30
+ const newline = chunk.indexOf(10, start);
31
+ const end = newline === -1 ? chunk.length : newline + 1;
32
+ const piece = chunk.subarray(start, end);
33
+ this.size += piece.length;
34
+ if (this.size > this.maxFrameBytes) throw new Error('JSONL 请求超过 64 MiB 上限。');
35
+ this.parts.push(piece);
36
+ if (newline !== -1) {
37
+ await this.frame(this.parts.length === 1 ? piece : Buffer.concat(this.parts, this.size));
38
+ this.parts = []; this.size = 0;
39
+ }
40
+ start = end;
41
+ }
42
+ })().then(() => callback(), callback);
43
+ }
44
+ _flush(callback) {
45
+ (async () => { if (this.size) await this.frame(Buffer.concat(this.parts, this.size)); })().then(() => callback(), callback);
46
+ }
47
+ }
@@ -0,0 +1,207 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { decodeAlias } from './model-menu.mjs';
4
+ import { isRoutedProvider, findRoute, isChatProvider } from './config.mjs';
5
+
6
+ // Private requests use the same initialized app-server connection. Their replies
7
+ // and temporary archive lifecycle events must not appear as Desktop requests.
8
+ export class ProviderSwitch {
9
+ constructor({ send, emit, getConfig, menu }) {
10
+ Object.assign(this, { send, emit, getConfig, menu });
11
+ this.prefix = `cdr-internal-${randomUUID()}-`;
12
+ this.sequence = 0; this.pending = new Map(); this.hidden = new Set();
13
+ this.settings = new Map(); this.waiters = new Set();
14
+ }
15
+ rpc(method, params) {
16
+ const id = this.prefix + (++this.sequence);
17
+ return new Promise((resolve, reject) => {
18
+ const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('原生接口响应超时。')); }, 20000);
19
+ this.pending.set(id, { resolve, reject, timer });
20
+ this.send({ id, method, params });
21
+ });
22
+ }
23
+ response(message) {
24
+ if (message?.method === 'thread/settings/updated') {
25
+ const id = message.params?.threadId;
26
+ const value = message.params?.threadSettings;
27
+ if (id && value) {
28
+ this.settings.set(id, value);
29
+ if (this.settings.size > 10000) this.settings.delete(this.settings.keys().next().value);
30
+ for (const waiter of this.waiters) if (waiter.threadId === id && (!waiter.accept || waiter.accept(value))) { clearTimeout(waiter.timer); this.waiters.delete(waiter); waiter.resolve(value); }
31
+ }
32
+ }
33
+ const internal = typeof message?.id === 'string' && message.id.startsWith(this.prefix) && !message.method;
34
+ const pending = internal && this.pending.get(message.id);
35
+ if (pending) {
36
+ clearTimeout(pending.timer); this.pending.delete(message.id);
37
+ if (message.error) pending.reject(new Error('原生接口未能完成提供商切换。'));
38
+ else pending.resolve(message.result);
39
+ return null;
40
+ }
41
+ if (internal) return null; // Also discard late replies after a timeout.
42
+ const threadId = message?.params?.threadId || message?.params?.thread?.id;
43
+ if (threadId && this.hidden.has(threadId) && !Object.hasOwn(message, 'id')) return null;
44
+ if (message?.method === 'thread/closed') this.settings.delete(threadId);
45
+ return message;
46
+ }
47
+ async snapshot(threadId, loaded) {
48
+ if (this.settings.has(threadId)) return this.settings.get(threadId);
49
+ // A resumed thread seeds a native baseline and emits no unchanged snapshot.
50
+ // Elicit one by temporarily changing a reversible setting while input is
51
+ // serialized and the task is idle, then restore it before archiving.
52
+ let change, restore;
53
+ if (loaded.serviceTier != null && loaded.serviceTier !== 'default') {
54
+ change = { serviceTier: null }; restore = { serviceTier: loaded.serviceTier };
55
+ } else {
56
+ let cursor, model = findRoute(loaded.modelProvider, this.getConfig())?.catalog?.find(m => m.model === loaded.model);
57
+ while (!model) {
58
+ const page = await this.rpc('model/list', { includeHidden: true, limit: 100, ...(cursor ? { cursor } : {}) });
59
+ model = page.data.find(m => m.model === loaded.model); cursor = page.nextCursor;
60
+ if (!cursor) break;
61
+ }
62
+ const effort = model?.supportedReasoningEfforts?.find(e => e.reasoningEffort !== loaded.reasoningEffort && !['ultra', 'persistent'].includes(e.reasoningEffort));
63
+ if (effort) {
64
+ change = { effort: effort.reasoningEffort }; restore = { effort: loaded.reasoningEffort };
65
+ } else if (model?.serviceTiers?.length) {
66
+ change = { serviceTier: model.serviceTiers[0].id }; restore = { serviceTier: null };
67
+ } else throw new Error('原生接口未提供可安全读取的完整设置,请先在原菜单调整一次推理强度再试。');
68
+ }
69
+ let waiter;
70
+ const notification = new Promise((resolve, reject) => {
71
+ waiter = { threadId, resolve, reject, timer: setTimeout(() => { this.waiters.delete(waiter); reject(new Error('无法读取任务当前设置。')); }, 10000) };
72
+ this.waiters.add(waiter);
73
+ });
74
+ try {
75
+ const [, snapshot] = await Promise.all([this.rpc('thread/settings/update', { threadId, ...change }), notification]);
76
+ if (Object.hasOwn(restore, 'effort') && restore.effort == null) restore.collaborationMode = { ...snapshot.collaborationMode, settings: { ...snapshot.collaborationMode.settings, reasoning_effort: null } };
77
+ await this.applySettings({ threadId, model: snapshot.model, ...restore }, snapshot.modelProvider);
78
+ return this.settings.get(threadId);
79
+ } catch (error) {
80
+ await this.rpc('thread/settings/update', { threadId, ...restore });
81
+ throw error;
82
+ } finally { clearTimeout(waiter.timer); this.waiters.delete(waiter); }
83
+ }
84
+ async applySettings(params, provider) {
85
+ const current = this.settings.get(params.threadId);
86
+ // Native settings updates are no-ops (with no notification) when unchanged.
87
+ const same = current?.modelProvider === provider && Object.entries(params).every(([key, value]) => {
88
+ if (key === 'threadId' || (value == null && key !== 'serviceTier')) return true;
89
+ if (key === 'permissions') return value === current.activePermissionProfile?.id;
90
+ if (key === 'serviceTier') return (value ?? 'default') === (current[key] ?? 'default');
91
+ return isDeepStrictEqual(value, current[key]);
92
+ });
93
+ if (same) return;
94
+ let waiter;
95
+ const notification = new Promise((resolve, reject) => {
96
+ waiter = { threadId: params.threadId, accept: value => value.modelProvider === provider && value.model === params.model && (params.effort == null || value.effort === params.effort) && (!Object.hasOwn(params, 'serviceTier') || (value.serviceTier ?? 'default') === (params.serviceTier ?? 'default')),
97
+ resolve, reject, timer: setTimeout(() => { this.waiters.delete(waiter); reject(new Error('无法确认恢复后的任务设置。')); }, 10000) };
98
+ this.waiters.add(waiter);
99
+ });
100
+ try { await Promise.all([this.rpc('thread/settings/update', params), notification]); }
101
+ finally { clearTimeout(waiter.timer); this.waiters.delete(waiter); }
102
+ }
103
+ async prepare(message) {
104
+ if (!message || !Object.hasOwn(message, 'id') || !['thread/settings/update', 'turn/settings/update', 'turn/start'].includes(message.method)) return;
105
+ const params = message.params;
106
+ if (!params || typeof params.threadId !== 'string') return;
107
+ const config = this.getConfig();
108
+ const values = [params.model, params.config?.model, params.collaborationMode?.settings?.model].filter(v => v != null);
109
+ if (!values.length) return;
110
+ const selections = values.map(v => decodeAlias(v, config));
111
+ const models = values.map((v, i) => selections[i]?.model || v);
112
+ if (models.some(m => typeof m !== 'string' || m !== models[0])) throw new Error('请求中包含冲突的模型选择,请重新选择模型。');
113
+ const selected = selections.find(Boolean);
114
+ if (selections.some(s => s && s.provider !== selected.provider)) throw new Error("请求中包含冲突的提供商。");
115
+ const custom = Boolean(selected);
116
+ const known = this.menu.threads.get(params.threadId);
117
+ if (!custom && !isRoutedProvider(known, config)) return;
118
+ if (custom) this.menu.requireProvider(findRoute(selected.provider, config));
119
+ const { thread } = await this.rpc('thread/read', { threadId: params.threadId, includeTurns: false });
120
+ this.menu.rememberThread(thread);
121
+ if (custom && findRoute(thread.modelProvider, config) === findRoute(selected.provider, config) && isChatProvider(thread.modelProvider, config) === isChatProvider(selected.provider, config)) return;
122
+ const nativeProvider = (await this.rpc('config/read', { includeLayers: false })).config.model_provider || 'openai';
123
+ const provider = custom ? selected.provider : nativeProvider;
124
+ if (provider === thread.modelProvider) return;
125
+ if ((!isRoutedProvider(thread.modelProvider, config) && thread.modelProvider !== nativeProvider) || (!isRoutedProvider(provider, config) && provider !== nativeProvider)) throw new Error('当前任务属于其他提供商,请使用它原有的切换方式。');
126
+ if (custom && !config.enabled) throw new Error('Profile 路由已关闭;请先开启。');
127
+ if (thread.status?.type !== 'idle') throw new Error('请等当前回复和工具运行结束后,再切换提供商。');
128
+ if (thread.ephemeral || !thread.path) throw new Error('此任务尚未保存历史,无法安全切换提供商;请新建任务选择目标模型。');
129
+ // Native archive also archives descendants, so do not use it on an agent tree.
130
+ for (const archived of [false, true]) {
131
+ const children = await this.rpc('thread/list', { ancestorThreadId: thread.id, archived, limit: 1, modelProviders: [], sourceKinds: ['subAgent', 'subAgentReview', 'subAgentCompact', 'subAgentThreadSpawn', 'subAgentOther'] });
132
+ if (children.data.length) throw new Error('此任务含有子代理,暂不支持跨提供商切换;请新建任务选择目标模型。');
133
+ }
134
+ await this.switchThread(thread, provider, models[0], params);
135
+ }
136
+ resumeParams(threadId, snapshot, loaded, provider, model) {
137
+ const params = { threadId, excludeTurns: true, modelProvider: provider, model, cwd: snapshot.cwd,
138
+ approvalPolicy: snapshot.approvalPolicy, approvalsReviewer: snapshot.approvalsReviewer,
139
+ runtimeWorkspaceRoots: loaded.runtimeWorkspaceRoots, serviceTier: snapshot.serviceTier,
140
+ config: snapshot.effort == null ? {} : { model_reasoning_effort: snapshot.effort } };
141
+ const route = findRoute(provider, this.getConfig());
142
+ if (route?.catalogPath) params.config.model_catalog_json = route.catalogPath;
143
+ if (snapshot.activePermissionProfile?.id) params.permissions = snapshot.activePermissionProfile.id;
144
+ return params;
145
+ }
146
+ restoreParams(threadId, snapshot, model) {
147
+ const result = { threadId, cwd: snapshot.cwd, approvalPolicy: snapshot.approvalPolicy,
148
+ approvalsReviewer: snapshot.approvalsReviewer, model, serviceTier: snapshot.serviceTier,
149
+ disabledPluginIds: snapshot.disabledPluginIds, effort: snapshot.effort, summary: snapshot.summary, personality: snapshot.personality,
150
+ collaborationMode: { ...snapshot.collaborationMode, settings: { ...snapshot.collaborationMode.settings, model } } };
151
+ if (snapshot.activePermissionProfile?.id) result.permissions = snapshot.activePermissionProfile.id;
152
+ else result.sandboxPolicy = snapshot.sandboxPolicy;
153
+ return result;
154
+ }
155
+ async switchThread(thread, provider, model, requested = {}) {
156
+ const id = thread.id;
157
+ let snapshot, loaded, archived = false, closed = false, targetLoaded = false;
158
+ this.hidden.add(id);
159
+ try {
160
+ loaded = await this.rpc('thread/resume', { threadId: id, excludeTurns: true });
161
+ snapshot = await this.snapshot(id, loaded);
162
+ const desired = { ...snapshot };
163
+ if (requested.effort != null) desired.effort = requested.effort;
164
+ if (requested.collaborationMode != null) {
165
+ desired.collaborationMode = requested.collaborationMode;
166
+ if (requested.collaborationMode.settings.reasoning_effort != null) desired.effort = requested.collaborationMode.settings.reasoning_effort;
167
+ } else if (requested.effort != null) {
168
+ desired.collaborationMode = { ...snapshot.collaborationMode, settings: { ...snapshot.collaborationMode.settings, reasoning_effort: requested.effort } };
169
+ }
170
+ // Archiving uses Codex's own graceful shutdown and journal flush. Unarchive
171
+ // immediately restores the same thread before resuming with the new provider.
172
+ await this.rpc('thread/archive', { threadId: id }); archived = true; closed = true;
173
+ await this.rpc('thread/unarchive', { threadId: id }); archived = false;
174
+ const resumed = await this.rpc('thread/resume', this.resumeParams(id, desired, loaded, provider, model));
175
+ targetLoaded = true;
176
+ if (resumed.thread.id !== id || resumed.modelProvider !== provider) throw new Error('恢复后的提供商不匹配。');
177
+ this.settings.delete(id);
178
+ await this.snapshot(id, resumed);
179
+ await this.applySettings(this.restoreParams(id, desired, model), provider);
180
+ this.menu.rememberThread(resumed.thread);
181
+ } catch (error) {
182
+ if (closed && snapshot && loaded) {
183
+ try {
184
+ if (targetLoaded) { await this.rpc('thread/archive', { threadId: id }); archived = true; }
185
+ if (archived) { await this.rpc('thread/unarchive', { threadId: id }); archived = false; }
186
+ const original = await this.rpc('thread/resume', this.resumeParams(id, snapshot, loaded, snapshot.modelProvider, snapshot.model));
187
+ this.settings.delete(id);
188
+ await this.snapshot(id, original);
189
+ await this.applySettings(this.restoreParams(id, snapshot, snapshot.model), snapshot.modelProvider);
190
+ this.menu.rememberThread(original.thread);
191
+ } catch {
192
+ throw new Error('提供商切换未完成。历史记录已保留,请重新打开任务;不要重复发送消息。');
193
+ }
194
+ }
195
+ throw new Error(`提供商未切换,仍保留原任务。${error.message}`);
196
+ } finally {
197
+ this.hidden.delete(id);
198
+ const current = this.settings.get(id);
199
+ if (current) this.emit({ method: 'thread/settings/updated', params: { threadId: id, threadSettings: current } });
200
+ }
201
+ }
202
+ close() {
203
+ for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error('原生进程已退出。')); }
204
+ for (const waiter of this.waiters) { clearTimeout(waiter.timer); waiter.reject(new Error('原生进程已退出。')); }
205
+ this.pending.clear(); this.waiters.clear();
206
+ }
207
+ }
package/src/proxy.mjs ADDED
@@ -0,0 +1,164 @@
1
+ import { watchUpdates } from './update.mjs';
2
+ import { spawn } from 'node:child_process';
3
+ import { join } from 'node:path';
4
+ import { rmSync, existsSync, readFileSync } from 'node:fs';
5
+ import { JsonLineRouter } from './protocol.mjs';
6
+ import { ModelMenu } from './model-menu.mjs';
7
+ import { ProviderSwitch } from './provider-switch.mjs';
8
+ import { Picker } from './picker.mjs';
9
+ import { Chat } from './chat.mjs';
10
+ import { ProfileCatalogs, applyLayers } from './profiles.mjs';
11
+ import { VERSION, readConfig, configPath, codexHome, atomicJSON, resolveRealCodex, providerArgs, childEnvironment, discoverProfiles } from './config.mjs';
12
+
13
+ export function isStdioServer(args) {
14
+ // Skip values of global flags before finding the CLI subcommand.
15
+ let index = 0;
16
+ const valueFlags = new Set(['-c', '--config', '--enable', '--disable', '-p', '--profile', '--code-mode-host']);
17
+ while (index < args.length && args[index].startsWith('-')) {
18
+ if (valueFlags.has(args[index])) index += 2; else index++;
19
+ }
20
+ if (args[index] !== 'app-server') return false;
21
+ for (let i = index + 1; i < args.length; i++) {
22
+ const arg = args[i];
23
+ if (['--help', '-h', '--version', '-V'].includes(arg)) return false;
24
+ if (arg.startsWith('--listen=')) { if (arg.slice(9) !== 'stdio://') return false; continue; }
25
+ if (arg === '--listen') { if (args[++i] !== 'stdio://') return false; continue; }
26
+ if (valueFlags.has(arg) || ['--ws-auth', '--ws-token-file', '--ws-token-sha256', '--ws-shared-secret-file', '--ws-issuer', '--ws-audience', '--ws-max-clock-skew-seconds'].includes(arg)) { i++; continue; }
27
+ if (!arg.startsWith('-')) return false; // daemon/proxy/schema tooling
28
+ }
29
+ return true;
30
+ }
31
+
32
+ export async function runProxy(args, selfPath) {
33
+ if ((Number.parseInt(process.env.CODEX_DESKTOP_ROUTER_ACTIVE, 10) || 0) >= 8) throw new Error('检测到 router 递归调用;CODEX_DESKTOP_ROUTER_REAL_CODEX 必须指向真正的 codex。');
34
+ const path = configPath();
35
+ readConfig(path);
36
+ const hadConfig = existsSync(path);
37
+ const executable = resolveRealCodex(selfPath);
38
+ const routed = isStdioServer(args);
39
+ const stopUpdates = routed ? watchUpdates() : () => {};
40
+ const routes = routed ? discoverProfiles() : [];
41
+ const env = childEnvironment(routes);
42
+ env.CODEX_CLI_PATH = executable;
43
+ const chats = routes.filter(r => r.chat).map(route => new Chat({ config: route, key: env[route.providerDefinition.env_key], env }));
44
+ const picker = routed ? new Picker({ chats }) : null;
45
+ const endpoint = picker ? await picker.listen() : null;
46
+ for (const chat of chats) env[chat.envKey] = chat.token;
47
+ const definitions = new Map(chats.map(chat => [chat.config.provider, chat.definition(endpoint.port)]));
48
+ // app-server's own -c options replace root-level -c values. Desktop sends
49
+ // both, so append our provider in the subcommand scope or it disappears.
50
+ const child = spawn(executable, [...args, ...(routed ? providerArgs(routes, definitions) : [])], { env, stdio: routed ? ['pipe', 'pipe', 'inherit'] : 'inherit' });
51
+ let killTimer, hardTimer, router, output, switches, catalogs, initializeId, failed = false;
52
+ const runtimePath = join(codexHome(), 'desktop-router-runs', `${process.pid}.json`);
53
+ const pickerPath = join(codexHome(), 'desktop-router-runs', `${process.pid}.picker.json`);
54
+ const metadata = { pid: process.pid, childPid: child.pid, version: VERSION, startedAt: new Date().toISOString(), configPath: path, routedStarts: 0, modelListsAugmented: 0, menuSelections: 0, prewarmsSkipped: 0, executable };
55
+ const save = () => { try { atomicJSON(runtimePath, metadata); } catch { /* diagnostics must not interrupt work */ } };
56
+ const stop = (signal = 'SIGTERM') => {
57
+ child.kill(signal);
58
+ if (!hardTimer) { hardTimer = setTimeout(() => child.kill('SIGKILL'), 5000); hardTimer.unref(); }
59
+ };
60
+ const fail = () => { failed = true; process.stderr.write('codex-desktop-router: 传输失败,已停止 app-server。\n'); stop(); };
61
+ const signals = new Map(['SIGINT', 'SIGTERM', 'SIGHUP'].map(signal => [signal, () => stop(signal)]));
62
+ for (const [signal, handler] of signals) process.on(signal, handler);
63
+ if (routed) {
64
+ save();
65
+ const getConfig = () => {
66
+ if (hadConfig && !existsSync(path)) throw new Error('路由配置被删除;请恢复配置。');
67
+ return { ...readConfig(path), routes };
68
+ };
69
+ const requireProvider = route => {
70
+ if (!route || route.error) throw new Error(route?.error || 'Profile 已不存在,请重启 Desktop。');
71
+ const current = discoverProfiles().find(r => r.profile === route.profile);
72
+ if (!current || current.fingerprint !== route.fingerprint) throw new Error('Profile 已改变,请重启 Desktop 后再选择。');
73
+ if (!route.providerDefinition) throw new Error(`Profile ${route.profile} 引用的提供商没有定义。`);
74
+ const key = route.providerDefinition.env_key;
75
+ if (key && !env[key]) throw new Error(`Profile ${route.profile} 缺少所需环境变量。`);
76
+ };
77
+ const selectionPath = `${path}.selection.json`;
78
+ const menu = new ModelMenu({
79
+ getConfig, requireProvider,
80
+ readSelection: () => { try { return JSON.parse(readFileSync(selectionPath, 'utf8')); } catch { return null; } },
81
+ saveSelection: selection => { try { atomicJSON(selectionPath, selection); } catch { process.stderr.write('codex-desktop-router: 菜单默认选择未能保存。\n'); } },
82
+ onCatalog: () => { metadata.modelListsAugmented++; save(); },
83
+ onPrewarmSkipped: () => { metadata.prewarmsSkipped++; save(); },
84
+ onSelection: method => { metadata.menuSelections++; if (method === 'thread/start') metadata.routedStarts++; save(); }
85
+ });
86
+ let present;
87
+ const emit = message => present(message).then(visible => { if (visible != null) process.stdout.write(JSON.stringify(visible) + '\n'); }).catch(fail);
88
+ switches = new ProviderSwitch({
89
+ getConfig, menu,
90
+ send: message => { catalogs.forward(message).then(forwarded => { if (!forwarded) child.stdin.write(JSON.stringify(message) + '\n'); }).catch(() => emit({ id: message.id, error: { code: -32603, message: '模型目录进程无法接收请求。' } })); },
91
+ emit: message => process.stdout.write(JSON.stringify(menu.response(message)) + '\n')
92
+ });
93
+ let queue = Promise.resolve();
94
+ const serialize = operation => { const result = queue.then(operation); queue = result.catch(() => {}); return result; };
95
+ catalogs = new ProfileCatalogs(executable, [...args, ...providerArgs(routes, definitions)], env, getConfig, emit);
96
+ catalogs.readThread = async threadId => (await switches.rpc('thread/read', { threadId, includeTurns: false })).thread;
97
+ let ready = Promise.resolve();
98
+ const refreshCatalogs = async () => {
99
+ await ready;
100
+ for (const route of routes) if (route.catalogPath) {
101
+ try { route.catalog = await catalogs.load(route.catalogPath); route.error = null; }
102
+ catch { route.error = `Profile ${route.profile} 的模型目录无法加载,请检查 model_catalog_json。`; }
103
+ }
104
+ };
105
+ Object.assign(picker, { bridge: switches, menu, getConfig, serialize, refreshCatalogs });
106
+ for (const chat of chats) Object.assign(chat, { bridge: switches, menu });
107
+ present = async message => {
108
+ picker.response(message);
109
+ for (const chat of chats) chat.response(message);
110
+ if (initializeId !== undefined && message?.id === initializeId && !message.method && message.result && menu.desktopClient) {
111
+ initializeId = undefined;
112
+ ready = switches.rpc('config/read', { includeLayers: true }).then(({ layers }) => {
113
+ for (const route of routes) Object.assign(route, applyLayers(route, layers));
114
+ });
115
+ ready.catch(() => {});
116
+ try { atomicJSON(pickerPath, { pid: process.pid, ...endpoint }); }
117
+ catch { process.stderr.write('codex-desktop-router: 独立模型选择页启动失败,原生菜单仍可使用。\n'); }
118
+ }
119
+ const visible = switches.response(message); return visible == null ? null : menu.response(visible);
120
+ };
121
+ // Frame the primary stream before emitting worker messages. Independent
122
+ // processes may split JSON across chunks; their bytes must never interleave.
123
+ output = new JsonLineRouter({ rewriteMessage: async message => present(await catalogs.observe(message)), onReject: fail });
124
+ router = new JsonLineRouter({
125
+ runFrame: serialize,
126
+ rewriteMessage: async message => {
127
+ if (message?.method === 'initialize') { initializeId = message.id; catalogs.initialize = message.params; }
128
+ if (message?.method !== 'initialize') await ready;
129
+ if (message?.method === 'model/list') await refreshCatalogs();
130
+ const selected = picker.request(message);
131
+ await switches.prepare(selected);
132
+ const routed = menu.request(selected);
133
+ for (const chat of chats) await chat.prepare(routed);
134
+ return await catalogs.forward(routed) ? null : routed;
135
+ },
136
+ onReject: error => {
137
+ picker.response(error);
138
+ for (const chat of chats) chat.response(error);
139
+ menu.response(error);
140
+ if (!process.stdout.write(JSON.stringify(error) + '\n')) {
141
+ process.stdin.pause();
142
+ process.stdout.once('drain', () => process.stdin.resume());
143
+ }
144
+ }
145
+ });
146
+ for (const stream of [router, output, child.stdin, child.stdout]) stream.on('error', fail);
147
+ process.stdin.pipe(router).pipe(child.stdin);
148
+ child.stdout.pipe(output).pipe(process.stdout, { end: false });
149
+ process.stdin.once('end', () => { killTimer = setTimeout(() => stop(), 10000); killTimer.unref(); });
150
+ process.stdin.on('error', fail);
151
+ process.stdout.on('error', fail);
152
+ }
153
+ return await new Promise(resolve => {
154
+ child.once('error', () => { failed = true; process.stderr.write('codex-desktop-router: 无法启动真正的 codex。\n'); });
155
+ child.once('close', async (code, signal) => {
156
+ stopUpdates(); clearTimeout(killTimer); clearTimeout(hardTimer); await catalogs?.close(); switches?.close(); picker?.close();
157
+ for (const [name, handler] of signals) process.removeListener(name, handler);
158
+ process.stdin.removeListener('error', fail); process.stdout.removeListener('error', fail);
159
+ if (router) { process.stdin.unpipe(router); process.stdin.pause(); router.destroy(); }
160
+ if (routed) { rmSync(runtimePath, { force: true }); rmSync(pickerPath, { force: true }); }
161
+ resolve(failed ? 1 : code ?? ({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }[signal] || 1));
162
+ });
163
+ });
164
+ }
package/src/rpc.mjs ADDED
@@ -0,0 +1,55 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { createInterface } from 'node:readline';
4
+
5
+ export function rpcClient(executable, args, env, timeout = 30000, onMessage = null) {
6
+ const child = spawn(executable, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
7
+ child.stderr.resume();
8
+ let seq = 0, closed = false;
9
+ const prefix = `cdr-rpc-${randomUUID()}-`;
10
+ const pending = new Map(), notifications = [], waiters = new Set();
11
+ const lineReader = createInterface({ input: child.stdout });
12
+ const abort = () => {
13
+ closed = true;
14
+ for (const p of [...pending.values(), ...waiters]) { clearTimeout(p.timer); p.reject(new Error('app-server 已退出。')); }
15
+ pending.clear(); waiters.clear();
16
+ };
17
+ child.stdin.on('error', abort); child.on('error', abort); child.on('close', abort);
18
+ const done = new Promise(resolve => child.once('close', resolve));
19
+ lineReader.on('line', line => {
20
+ let message;
21
+ try { message = JSON.parse(line); } catch { abort(); child.kill(); return; }
22
+ if (Object.hasOwn(message, 'id') && pending.has(message.id)) {
23
+ const p = pending.get(message.id); clearTimeout(p.timer); pending.delete(message.id);
24
+ if (message.error) { p.error.message = `RPC ${p.method} 返回错误 ${message.error.code}。`; p.reject(p.error); } else p.resolve(message.result);
25
+ } else {
26
+ if (onMessage) { onMessage(message); return; }
27
+ notifications.push(message);
28
+ if (notifications.length > 10000) notifications.shift();
29
+ for (const p of waiters) if (p.predicate(message)) { clearTimeout(p.timer); waiters.delete(p); p.resolve(message); }
30
+ }
31
+ });
32
+ return {
33
+ child,
34
+ send(message) { if (closed) throw new Error('app-server 已退出。'); child.stdin.write(JSON.stringify(message) + '\n'); },
35
+ rpc(method, params) { return new Promise((resolve, reject) => {
36
+ if (closed) return reject(new Error('app-server 已退出。'));
37
+ const id = prefix + (++seq);
38
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`RPC ${method} 超时。`)); }, timeout);
39
+ pending.set(id, { method, resolve, reject, timer, error: new Error(`RPC ${method}`) }); child.stdin.write(JSON.stringify({ id, method, params }) + '\n');
40
+ }); },
41
+ wait(predicate) {
42
+ const found = notifications.find(predicate); if (found) return Promise.resolve(found);
43
+ return new Promise((resolve, reject) => {
44
+ if (closed) return reject(new Error('app-server 已退出。'));
45
+ const p = { predicate, resolve, reject };
46
+ p.timer = setTimeout(() => { waiters.delete(p); reject(new Error('等待完成通知超时。')); }, timeout); waiters.add(p);
47
+ });
48
+ },
49
+ async close() {
50
+ if (closed) return;
51
+ child.stdin.end(); const timer = setTimeout(() => child.kill('SIGKILL'), 12000);
52
+ await done; clearTimeout(timer);
53
+ }
54
+ };
55
+ }
package/src/update.mjs ADDED
@@ -0,0 +1,58 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { VERSION, codexHome, atomicJSON } from './config.mjs';
4
+
5
+ const name = '@ailuntz/codex-desktop-router';
6
+ const interval = 24 * 60 * 60 * 1000;
7
+ const version = value => typeof value === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value) && value.split('.').every(n => Number.isSafeInteger(Number(n)));
8
+ const disabled = () => Boolean(process.env.CODEX_DESKTOP_ROUTER_NO_UPDATE_CHECK || process.env.CI || process.env.NODE_TEST_CONTEXT);
9
+ const pathFor = home => join(home, 'desktop-router-update.json');
10
+
11
+ export function isNewer(latest, current) {
12
+ if (!version(latest) || !version(current)) return false;
13
+ const left = latest.split('.').map(Number), right = current.split('.').map(Number);
14
+ for (let i = 0; i < 3; i++) if (left[i] !== right[i]) return left[i] > right[i];
15
+ return false;
16
+ }
17
+
18
+ function read(home) {
19
+ try {
20
+ const value = JSON.parse(readFileSync(pathFor(home), 'utf8'));
21
+ return { checkedAt: Number.isFinite(value.checkedAt) ? value.checkedAt : 0, latest: version(value.latest) ? value.latest : null };
22
+ } catch { return { checkedAt: 0, latest: null }; }
23
+ }
24
+
25
+ export function updateStatus(home = codexHome()) {
26
+ const state = read(home);
27
+ return { current: VERSION, latest: state.latest, available: isNewer(state.latest, VERSION),
28
+ command: `npm install -g ${name}@latest && codex-desktop-router install` };
29
+ }
30
+
31
+ export async function checkForUpdates({ home = codexHome(), fetcher = fetch, now = Date.now(), skip = disabled() } = {}) {
32
+ const state = read(home);
33
+ if (skip || (state.checkedAt > 0 && now >= state.checkedAt && now - state.checkedAt < interval)) return updateStatus(home);
34
+ try {
35
+ // Record the attempt before I/O so offline starts also respect the interval.
36
+ atomicJSON(pathFor(home), { ...state, checkedAt: now });
37
+ const response = await fetcher(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, {
38
+ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(1500), redirect: 'error'
39
+ });
40
+ if (!response.ok) { await response.body?.cancel(); return updateStatus(home); }
41
+ const chunks = []; let size = 0;
42
+ for await (const chunk of response.body) {
43
+ size += chunk.length;
44
+ if (size > 65536) throw new Error('Oversized registry response');
45
+ chunks.push(chunk);
46
+ }
47
+ const manifest = JSON.parse(Buffer.concat(chunks).toString('utf8'));
48
+ if (manifest.name === name && version(manifest.version)) atomicJSON(pathFor(home), { checkedAt: now, latest: manifest.version });
49
+ } catch { /* Updates must never interrupt native requests or offline use. */ }
50
+ return updateStatus(home);
51
+ }
52
+
53
+ export function watchUpdates() {
54
+ void checkForUpdates();
55
+ const timer = setInterval(() => { void checkForUpdates(); }, interval);
56
+ timer.unref();
57
+ return () => clearInterval(timer);
58
+ }