@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,100 @@
1
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, cpSync, rmSync, mkdtempSync, renameSync, realpathSync, readlinkSync } from 'node:fs';
2
+ import { join, dirname, resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { atomicJSON, configPath, codexHome } from './config.mjs';
7
+
8
+ const label = 'com.ailuntz.codex-desktop-router';
9
+ const installRoot = () => join(homedir(), '.local/share/codex-desktop-router');
10
+ const plistPath = () => join(homedir(), 'Library/LaunchAgents', `${label}.plist`);
11
+ const statePath = () => join(installRoot(), 'installation.json');
12
+ export const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'";
13
+ export const xml = value => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
14
+ const run = (args, required = true) => { try { return execFileSync('/bin/launchctl', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); } catch { if (required) throw new Error(`launchctl ${args[0]} 失败。`); return null; } };
15
+ const currentEnv = () => run(['getenv', 'CODEX_CLI_PATH'], false) || null;
16
+ const macOnly = () => { if (process.platform !== 'darwin') throw new Error('Desktop 自动安装目前仅支持 macOS。'); };
17
+
18
+ export function installation() {
19
+ try { return JSON.parse(readFileSync(statePath(), 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw new Error('安装状态损坏,请先检查 installation.json。'); }
20
+ }
21
+
22
+ export function install() {
23
+ macOnly();
24
+ const previous = installation();
25
+ if (existsSync(plistPath()) && !previous) throw new Error('同名 LaunchAgent 已存在且不属于本次安装,已停止。');
26
+ const root = installRoot();
27
+ const source = resolve(dirname(fileURLToPath(import.meta.url)), '..');
28
+ const runtime = join(root, 'runtime');
29
+ const wrapper = join(root, 'codex-desktop-router');
30
+ const env = currentEnv();
31
+ if (env && env !== wrapper) throw new Error('当前 CODEX_CLI_PATH 已指向其他程序;请先移除该覆盖再安装。');
32
+ mkdirSync(root, { recursive: true, mode: 0o700 });
33
+ // A local copy survives USB removal and npm cache cleanup. Node stays pinned
34
+ // to the executable used for installation, avoiding Finder's minimal PATH.
35
+ if (realpathSync(source) !== (existsSync(runtime) ? realpathSync(runtime) : runtime)) {
36
+ const staged = mkdtempSync(join(root, '.runtime-'));
37
+ const retired = `${staged}.previous`;
38
+ try {
39
+ for (const entry of ['bin', 'src', 'package.json', 'LICENSE']) cpSync(join(source, entry), join(staged, entry), { recursive: true });
40
+ // npm can hoist dependencies outside this package. Resolve the actual
41
+ // installed dependency rather than assuming source/node_modules exists.
42
+ let dependency = dirname(fileURLToPath(import.meta.resolve('smol-toml')));
43
+ while (!existsSync(join(dependency, 'package.json'))) {
44
+ const parent = dirname(dependency);
45
+ if (parent === dependency) throw new Error('找不到 smol-toml 依赖包。');
46
+ dependency = parent;
47
+ }
48
+ if (JSON.parse(readFileSync(join(dependency, 'package.json'), 'utf8')).name !== 'smol-toml') throw new Error('smol-toml 依赖包路径无效。');
49
+ cpSync(dependency, join(staged, 'node_modules/smol-toml'), { recursive: true });
50
+ if (existsSync(runtime)) renameSync(runtime, retired);
51
+ try { renameSync(staged, runtime); }
52
+ catch (error) { if (existsSync(retired)) renameSync(retired, runtime); throw error; }
53
+ rmSync(retired, { recursive: true, force: true });
54
+ } finally { rmSync(staged, { recursive: true, force: true }); }
55
+ }
56
+ const config = configPath();
57
+ const node = process.execPath;
58
+ writeFileSync(wrapper, `#!/bin/sh\nexport CODEX_HOME=${shellQuote(codexHome())}\nexport CODEX_DESKTOP_ROUTER_CONFIG=${shellQuote(config)}\nexec ${shellQuote(node)} ${shellQuote(join(runtime, 'bin/codex-desktop-router.mjs'))} "$@"\n`, { mode: 0o755 });
59
+ rmSync(join(root, '模型选择.command'), { force: true });
60
+ const activate = join(root, 'activate.sh');
61
+ writeFileSync(activate, `#!/bin/sh\nif [ -x ${shellQuote(node)} ] && [ -x ${shellQuote(wrapper)} ]; then\n exec /bin/launchctl setenv CODEX_CLI_PATH ${shellQuote(wrapper)}\nfi\nexit 1\n`, { mode: 0o700 });
62
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${label}</string>\n<key>ProgramArguments</key><array><string>/bin/sh</string><string>${xml(activate)}</string></array>\n<key>RunAtLoad</key><true/>\n<key>LimitLoadToSessionType</key><string>Aqua</string>\n</dict></plist>\n`;
63
+ mkdirSync(dirname(plistPath()), { recursive: true });
64
+ writeFileSync(plistPath(), plist, { mode: 0o600 });
65
+ const state = { version: 1, wrapper, node, codexHome: codexHome(), configPath: config, previousEnv: previous ? previous.previousEnv : env, installedAt: new Date().toISOString() };
66
+ atomicJSON(statePath(), state);
67
+ run(['bootout', `gui/${process.getuid()}/${label}`], false);
68
+ run(['bootstrap', `gui/${process.getuid()}`, plistPath()]);
69
+ run(['setenv', 'CODEX_CLI_PATH', wrapper]);
70
+ if (currentEnv() !== wrapper) throw new Error('启动环境验证失败。');
71
+ // npm owns the terminal command. Retire our old link so it cannot shadow
72
+ // a newly upgraded npm package with the previous local runtime.
73
+ if (previous?.binLink) {
74
+ try { if (readlinkSync(previous.binLink) === wrapper) rmSync(previous.binLink); } catch { /* user replaced the link */ }
75
+ }
76
+ return state;
77
+ }
78
+
79
+ export function uninstall() {
80
+ macOnly();
81
+ const state = installation();
82
+ if (!state) return false;
83
+ run(['bootout', `gui/${process.getuid()}/${label}`], false);
84
+ if (currentEnv() === state.wrapper) {
85
+ if (state.previousEnv) run(['setenv', 'CODEX_CLI_PATH', state.previousEnv]);
86
+ else run(['unsetenv', 'CODEX_CLI_PATH']);
87
+ }
88
+ rmSync(plistPath(), { force: true });
89
+ if (state.binLink) {
90
+ try { if (readlinkSync(state.binLink) === state.wrapper) rmSync(state.binLink); } catch { /* user replaced the link */ }
91
+ }
92
+ // Keep runtime files: an existing Desktop sidecar may still be using them.
93
+ // Configuration and credentials are deliberately retained for reinstall.
94
+ rmSync(statePath(), { force: true });
95
+ return true;
96
+ }
97
+
98
+ export function environmentStatus() {
99
+ return process.platform === 'darwin' ? currentEnv() : null;
100
+ }
@@ -0,0 +1,170 @@
1
+ import { object, chatProviderId, isRoutedProvider, findRoute, isChatProvider } from './config.mjs';
2
+
3
+ export const aliasFor = (provider, model) => `cdr:${provider}:${encodeURIComponent(model)}`;
4
+ const isAlias = value => typeof value === 'string' && value.startsWith('cdr:');
5
+ const threadMethods = new Set(['thread/start', 'thread/resume', 'thread/fork']);
6
+ const settingMethods = new Set(['turn/start', 'turn/settings/update', 'thread/settings/update']);
7
+ const trackedMethods = new Set([...threadMethods, ...settingMethods, 'thread/read', 'model/list', 'config/read', 'config/value/write', 'config/batchWrite']);
8
+
9
+ export function decodeAlias(value, config) {
10
+ if (!isAlias(value)) return null;
11
+ const parts = value.split(':');
12
+ let model;
13
+ try { model = decodeURIComponent(parts[2]); } catch { throw new Error('Profile 模型选项格式无效;请刷新模型列表。'); }
14
+ if (parts.length !== 3 || !isRoutedProvider(parts[1], config) || !model || model.length > 200 || /[\s\x00-\x1f]/u.test(model) || aliasFor(parts[1], model) !== value) throw new Error('Profile 模型选项不属于当前提供商;请刷新模型列表。');
15
+ if (isChatProvider(parts[1], config) && !findRoute(parts[1], config).chat) throw new Error('此 profile 尚未提供可用于 Chat 的连接。');
16
+ return { provider: parts[1], model };
17
+ }
18
+
19
+ export function additionalModels(catalog, config, { firstPage = true, includeHidden = false } = {}) {
20
+ return config.routes.flatMap(route => {
21
+ if (!route.providerDefinition || route.error || (!firstPage && route.catalogPath)) return [];
22
+ const seen = new Set();
23
+ const models = (route.catalogPath ? route.catalog || [] : catalog).filter(entry => {
24
+ if (typeof entry?.model !== 'string' || !entry.model || isAlias(entry.model) || seen.has(entry.model) || (!includeHidden && entry.hidden)) return false;
25
+ seen.add(entry.model); return true;
26
+ });
27
+ return [route.provider, ...(route.chat ? [chatProviderId(route)] : [])].flatMap(provider => models.map(entry => ({
28
+ ...entry, id: aliasFor(provider, entry.model), model: aliasFor(provider, entry.model),
29
+ displayName: `${route.profile} · ${entry.displayName || entry.model}${provider === route.provider ? '' : '-Chat'}`,
30
+ isDefault: false,
31
+ ...(provider === route.provider ? {} : { inputModalities: ['text'], description: '纯文字聊天,不向模型提供工作区上下文或工具。' })
32
+ })));
33
+ });
34
+ }
35
+
36
+ function modelSlots(params) {
37
+ return [params?.model, params?.config?.model, params?.collaborationMode?.settings?.model].filter(v => v != null);
38
+ }
39
+
40
+ function normalizedParams(params, config) {
41
+ const result = { ...params };
42
+ if (isAlias(result.model)) result.model = decodeAlias(result.model, config).model;
43
+ if (isAlias(result.config?.model)) result.config = { ...result.config, model: decodeAlias(result.config.model, config).model };
44
+ if (isAlias(result.collaborationMode?.settings?.model)) result.collaborationMode = { ...result.collaborationMode, settings: { ...result.collaborationMode.settings, model: decodeAlias(result.collaborationMode.settings.model, config).model } };
45
+ return result;
46
+ }
47
+
48
+ // Only model routes and catalog settings are translated here. Native task
49
+ // permissions, prompts and persisted provider metadata remain untouched.
50
+ export class ModelMenu {
51
+ constructor({ getConfig, requireProvider, readSelection = () => null, saveSelection = () => {}, onCatalog = () => {}, onSelection = () => {}, onPrewarmSkipped = () => {} }) {
52
+ Object.assign(this, { getConfig, requireProvider, readSelection, saveSelection, onCatalog, onSelection, onPrewarmSkipped });
53
+ this.pending = new Map(); this.threads = new Map();
54
+ this.desktopClient = false;
55
+ }
56
+ rememberThread(thread) {
57
+ if (!object(thread) || typeof thread.id !== 'string' || typeof thread.modelProvider !== 'string') return;
58
+ this.threads.set(thread.id, thread.modelProvider);
59
+ if (this.threads.size > 10000) this.threads.delete(this.threads.keys().next().value);
60
+ }
61
+ request(message) {
62
+ if (!object(message) || !Object.hasOwn(message, 'id') || typeof message.method !== 'string') return message;
63
+ const method = message.method;
64
+ if (method === 'initialize') {
65
+ this.desktopClient = message.params?.clientInfo?.title === 'Codex Desktop';
66
+ return message;
67
+ }
68
+ if (!trackedMethods.has(method)) return message;
69
+ const params = message.params ?? {};
70
+ if (!object(params)) return message;
71
+ const config = this.getConfig();
72
+ // Verified in Desktop 26.908: preparePrewarmRequest omits threadSource;
73
+ // actual prepareConversation defaults it to "user". Desktop catches a
74
+ // failed prewarm and starts a fresh thread on send. Otherwise its cache
75
+ // (keyed by cwd, not provider/model) can pin a new task to the wrong provider.
76
+ if (this.desktopClient && config.enabled && method === 'thread/start' && params.threadSource == null) {
77
+ this.onPrewarmSkipped();
78
+ throw new Error('菜单路由已跳过 Desktop 预热;正常发送时会按所选模型创建新任务。');
79
+ }
80
+ let result = message;
81
+ const record = { method, time: Date.now(), threadId: params.threadId, firstPage: !params.cursor, includeHidden: params.includeHidden === true };
82
+ if (threadMethods.has(method) || settingMethods.has(method)) {
83
+ const values = modelSlots(params);
84
+ const selected = values.map(v => decodeAlias(v, config)).filter(Boolean);
85
+ if (selected.length && (selected.some(s => s.model !== selected[0].model || s.provider !== selected[0].provider) || values.some(v => !isAlias(v) && v !== selected[0].model))) throw new Error('请求中包含冲突的模型选择,请重新选择模型。');
86
+ if (selected.length) {
87
+ const selection = selected[0];
88
+ this.requireProvider(findRoute(selection.provider, config));
89
+ if (settingMethods.has(method)) {
90
+ const provider = this.threads.get(params.threadId);
91
+ if (provider !== selection.provider && !(findRoute(provider, config) === findRoute(selection.provider, config) && isChatProvider(provider, config) === isChatProvider(selection.provider, config))) throw new Error('任务路由尚未就绪,请重新打开任务后再选择 profile 模型。');
92
+ } else if (!config.enabled) throw new Error('Profile 新任务路由已关闭;请先执行 codex-desktop-router on。');
93
+ const rewritten = normalizedParams(params, config);
94
+ if (threadMethods.has(method)) {
95
+ if (rewritten.modelProvider && rewritten.modelProvider !== 'openai' && !isRoutedProvider(rewritten.modelProvider, config)) throw new Error('模型选项与显式提供商不一致。');
96
+ rewritten.modelProvider = selection.provider;
97
+ rewritten.model = selection.model;
98
+ const route = findRoute(selection.provider, config);
99
+ rewritten.config = { ...(route.catalogPath ? { model_catalog_json: route.catalogPath } : {}), ...rewritten.config };
100
+ }
101
+ result = { ...message, params: rewritten };
102
+ this.onSelection(method);
103
+ } else if (settingMethods.has(method) && values.length && isRoutedProvider(this.threads.get(params.threadId), config)) {
104
+ // An official picker row must not silently use the existing custom key.
105
+ throw new Error('任务路由尚未切换,请重新打开任务后再选择模型。');
106
+ }
107
+ } else if (method === 'config/value/write' || method === 'config/batchWrite') {
108
+ const edits = method === 'config/value/write' ? [params] : params.edits;
109
+ if (Array.isArray(edits)) {
110
+ let changed = false;
111
+ const normalized = edits.map(edit => {
112
+ if (object(edit) && edit.keyPath === 'model' && params.filePath == null && !isAlias(edit.value)) record.savedSelection = null;
113
+ if (!object(edit) || !isAlias(edit.value)) return edit;
114
+ if (edit.keyPath !== 'model' || params.filePath != null) throw new Error('Profile 菜单默认值目前只支持主配置;不要将虚拟模型保存进独立 profile。');
115
+ const selection = decodeAlias(edit.value, config);
116
+ this.requireProvider(findRoute(selection.provider, config));
117
+ record.savedSelection = selection;
118
+ changed = true;
119
+ return { ...edit, value: selection.model };
120
+ });
121
+ if (changed) result = { ...message, params: method === 'config/value/write' ? normalized[0] : { ...params, edits: normalized } };
122
+ }
123
+ }
124
+ for (const [id, request] of this.pending) if (Date.now() - request.time > 300000) this.pending.delete(id);
125
+ if (this.pending.size >= 4096) throw new Error('路由器等待中的请求过多,请稍后重试。');
126
+ this.pending.set(message.id, record);
127
+ return result;
128
+ }
129
+ response(message) {
130
+ if (!object(message)) return message;
131
+ const record = Object.hasOwn(message, 'id') && !message.method ? this.pending.get(message.id) : undefined;
132
+ if (record) this.pending.delete(message.id);
133
+ this.rememberThread(message.result?.thread);
134
+ this.rememberThread(message.params?.thread);
135
+ if (message.method === 'thread/settings/updated') this.rememberThread({ id: message.params?.threadId, modelProvider: message.params?.threadSettings?.modelProvider });
136
+ let config;
137
+ try { config = this.getConfig(); } catch { return message; }
138
+ if (message.error) return message;
139
+ if (record?.method === 'model/list' && Array.isArray(message.result?.data)) {
140
+ if (!config.enabled) return message;
141
+ // Inherited profiles mirror each native page; explicit catalogs appear once.
142
+ const additions = additionalModels(message.result.data, config, record).filter(entry => !message.result.data.some(m => m.id === entry.id || m.model === entry.model));
143
+ this.onCatalog(additions.length);
144
+ return { ...message, result: { ...message.result, data: [...message.result.data, ...additions] } };
145
+ }
146
+ if (record && Object.hasOwn(record, 'savedSelection')) this.saveSelection(record.savedSelection);
147
+ if (record?.method === 'config/read' && object(message.result?.config) && config.enabled) {
148
+ const saved = this.readSelection();
149
+ if (saved && isRoutedProvider(saved.provider, config) && saved.model === message.result.config.model) return { ...message, result: { ...message.result, config: { ...message.result.config, model: aliasFor(isChatProvider(saved.provider, config) ? chatProviderId(findRoute(saved.provider, config)) : findRoute(saved.provider, config).provider, saved.model) } } };
150
+ }
151
+ const threadId = message.params?.threadId || message.result?.thread?.id || record?.threadId;
152
+ const actualProvider = message.result?.modelProvider || this.threads.get(threadId);
153
+ const route = findRoute(actualProvider, config);
154
+ const provider = route ? (isChatProvider(actualProvider, config) ? chatProviderId(route) : route.provider) : actualProvider;
155
+ if (!isRoutedProvider(provider, config)) return message;
156
+ const decorate = value => {
157
+ if (!object(value)) return value;
158
+ let updated = value;
159
+ if (typeof value.model === 'string' && !isAlias(value.model)) updated = { ...updated, model: aliasFor(provider, value.model) };
160
+ if (typeof value.collaborationMode?.settings?.model === 'string' && !isAlias(value.collaborationMode.settings.model)) updated = { ...updated, collaborationMode: { ...value.collaborationMode, settings: { ...value.collaborationMode.settings, model: aliasFor(provider, value.collaborationMode.settings.model) } } };
161
+ return updated;
162
+ };
163
+ if (record && (threadMethods.has(record.method) || record.method === 'thread/settings/update')) {
164
+ const result = decorate(message.result);
165
+ if (result !== message.result) return { ...message, result };
166
+ }
167
+ if (message.method === 'thread/settings/updated') return { ...message, params: { ...message.params, threadSettings: decorate(message.params.threadSettings) } };
168
+ return message;
169
+ }
170
+ }
@@ -0,0 +1,51 @@
1
+ const $ = id => document.getElementById(id);
2
+ const token = location.hash.slice(1) || sessionStorage.getItem('picker-token');
3
+ if (token) sessionStorage.setItem('picker-token', token);
4
+ history.replaceState(null, '', '/');
5
+ let state, busy = false;
6
+ const say = (text, error = false) => { $('message').textContent = text; $('message').className = error ? 'error' : ''; };
7
+ async function api(path, body) {
8
+ const response = await fetch(path, { method: body ? 'POST' : 'GET', headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
9
+ const data = await response.json(); if (!response.ok) throw new Error(data.error); return data;
10
+ }
11
+ function fill(select, options, selected) {
12
+ select.replaceChildren(...options.map(([value, text]) => { const option = document.createElement('option'); option.value = value; option.textContent = text; return option; }));
13
+ if (options.some(([value]) => value === selected)) select.value = selected;
14
+ }
15
+ function changeModel(effort) {
16
+ const model = state.models.find(m => m.model === $('model').value);
17
+ fill($('effort'), model?.efforts?.length ? model.efforts.map(e => [e.reasoningEffort, e.reasoningEffort]) : [['default', '默认']], effort || model?.effort);
18
+ }
19
+ function changeThread() {
20
+ const thread = state.threads.find(t => t.id === $('thread').value);
21
+ const pin = thread?.pin || ($('thread').value === 'next' ? state.next : null);
22
+ const current = state.models.find(m => m.model === pin?.model);
23
+ $('details').textContent = thread ? `${thread.cwd || ''}\n${thread.status === 'active' ? '正在回复,请等待完成。' : '任务已载入。'}${pin ? ` 由此页管理:${current?.name || pin.model}` : ' 由 Desktop 菜单管理。'}` : pin ? `下一个新任务将使用 ${current?.name || pin.model}。` : '先在这里选好模型,再回 Desktop 新建任务并发送。';
24
+ fill($('model'), state.models.map(m => [m.model, m.name]), pin?.model || thread?.model);
25
+ changeModel(pin?.effort);
26
+ $('apply').disabled = busy || (thread && thread.status !== 'idle');
27
+ $('release').disabled = busy || !pin;
28
+ }
29
+ async function refresh() {
30
+ const selected = $('thread').value;
31
+ state = await api('/state');
32
+ $('update').hidden = !state.update?.available;
33
+ $('update').textContent = state.update?.available ? `新版 ${state.update.latest} 可用\n${state.update.command}\n升级后重启 Desktop。` : '';
34
+ fill($('thread'), [['next', '下一个新任务'], ...state.threads.map(t => [t.id, t.name.slice(0, 90)])], selected);
35
+ changeThread();
36
+ }
37
+ async function action(operation) {
38
+ if (busy) return;
39
+ busy = true; $('apply').disabled = true; $('release').disabled = true;
40
+ try { await operation(); } catch (error) { say(error.message || '连接已结束,请重新打开选择页。', true); }
41
+ finally { busy = false; if (state) changeThread(); }
42
+ }
43
+ $('thread').onchange = () => { changeThread(); say(''); };
44
+ $('model').onchange = () => changeModel();
45
+ $('refresh').onclick = () => action(refresh);
46
+ $('form').onsubmit = event => { event.preventDefault(); action(async () => {
47
+ await api('/choose', { threadId: $('thread').value, model: $('model').value, effort: $('effort').value === 'default' ? null : $('effort').value });
48
+ await refresh(); say('已设置。回到 Desktop 继续发送消息即可。');
49
+ }); };
50
+ $('release').onclick = () => action(async () => { await api('/release', { threadId: $('thread').value }); await refresh(); say('已交回 Desktop 菜单。'); });
51
+ action(refresh);
@@ -0,0 +1,8 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>模型选择 · Codex Desktop Router</title>
3
+ <style>
4
+ :root{color-scheme:light dark;font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f5f5f3;color:#242623}body{margin:0;padding:56px 20px}main{max-width:640px;margin:auto}header{margin-bottom:32px}.eyebrow{font-size:12px;letter-spacing:.1em;color:#687368}h1{font-size:32px;line-height:1.2;margin:12px 0}p{color:#646d65;margin:12px 0}form{background:#fff;border:1px solid #e2e6df;border-radius:18px;padding:28px;box-shadow:0 8px 28px #18371a08}label{display:block;font-weight:600;font-size:14px;margin:16px 0 7px}label:first-child{margin-top:0}select,button{font:inherit;border-radius:9px}select{width:100%;padding:11px;border:1px solid #cad2c8;background:#fafbf9;color:inherit}button{cursor:pointer;border:0;padding:11px 17px;background:#255b3d;color:white;font-weight:600}button:disabled{opacity:.45;cursor:default}.secondary{background:#eef1ec;color:#425044}nav{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}.detail{font-size:13px;white-space:pre-wrap;overflow-wrap:anywhere}.note{font-size:13px;margin:20px 0 0}#message{min-height:26px;margin-top:18px;color:#255b3d}#message.error{color:#ad3434}footer{font-size:12px;color:#778076;margin-top:24px}a{color:inherit}#refresh{float:right;padding:5px 10px;font-size:13px}@media(prefers-color-scheme:dark){:root{background:#171c18;color:#e0e8e1}form{background:#202721;border-color:#394238}p,footer{color:#abb7ad}select{background:#252e27;border-color:#526152}.secondary{background:#364337;color:#d4e0d6}#message{color:#87d6a4}#message.error{color:#ffa0a0}}
5
+ </style>
6
+ <main><header><button id="refresh" class="secondary" type="button">刷新</button><div class="eyebrow">CODEX DESKTOP ROUTER</div><h1>选择下一条回复的模型</h1><p>Desktop 的额度提示挡住菜单时,在这里切换。</p></header>
7
+ <form id="form"><label for="thread">任务</label><select id="thread" required></select><p id="details" class="detail"></p><label for="model">模型</label><select id="model" required></select><label for="effort">推理强度</label><select id="effort" required></select><nav><button id="apply" type="submit">使用此模型</button><button id="release" class="secondary" type="button">交回 Desktop 菜单</button></nav><p class="note">启用后,该任务的模型与推理强度由此页管理,直到交回菜单或退出 Desktop。原生菜单可能仍显示额度提示,以此页的选择为准。</p><div id="message" role="status" aria-live="polite"></div></form>
8
+ <p id="update" class="detail" hidden></p><footer>模型和推理选项来自客户端及 profile 指定的目录。此页不改变账户额度。新任务选项只作用于下一次正式创建的本地任务。</footer></main><script src="/picker.js"></script></html>
package/src/picker.mjs ADDED
@@ -0,0 +1,155 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { readFileSync, readdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { checkForUpdates, updateStatus } from './update.mjs';
7
+ import { VERSION } from './config.mjs';
8
+ import { additionalModels } from './model-menu.mjs';
9
+
10
+ // A choice in this page explicitly owns model selection until released. Desktop
11
+ // can substitute its reserve model at submission time, not just hide its menu.
12
+ export class Picker {
13
+ constructor({ bridge, menu, getConfig, serialize, chats = [] } = {}) {
14
+ Object.assign(this, { bridge, menu, getConfig, serialize, chats });
15
+ this.pins = new Map(); this.starts = new Map(); this.next = null; this.sequence = 0;
16
+ }
17
+ request(message) {
18
+ if (!this.getConfig().enabled) { this.pins.clear(); this.starts.clear(); this.next = null; }
19
+ const params = message?.params;
20
+ if (!params || !Object.hasOwn(message, 'id')) return message;
21
+ let pin = this.pins.get(params.threadId);
22
+ if (message.method === 'thread/start' && params.threadSource === 'user' && this.next) {
23
+ pin = this.next;
24
+ this.next = null;
25
+ this.starts.set(message.id, pin);
26
+ }
27
+ if (!pin || !['thread/start', 'thread/resume', 'thread/settings/update', 'turn/settings/update', 'turn/start'].includes(message.method)) return message;
28
+ const updated = { ...params, model: pin.model, effort: pin.effort };
29
+ if (params.config?.model != null) updated.config = { ...params.config, model: pin.model };
30
+ if (params.collaborationMode) updated.collaborationMode = { ...params.collaborationMode, settings: { ...params.collaborationMode.settings, model: pin.model, reasoning_effort: pin.effort } };
31
+ return { ...message, params: updated };
32
+ }
33
+ response(message) {
34
+ const pin = this.starts.get(message?.id);
35
+ if (!pin || message.method) return;
36
+ this.starts.delete(message.id);
37
+ if (message.result?.thread?.id) this.pins.set(message.result.thread.id, pin);
38
+ else if (message.error && !this.next) this.next = pin;
39
+ }
40
+ async catalog() {
41
+ await this.refreshCatalogs?.();
42
+ const models = []; let cursor;
43
+ do {
44
+ const page = await this.bridge.rpc('model/list', { limit: 100, includeHidden: false, ...(cursor ? { cursor } : {}) });
45
+ models.push(...page.data); cursor = page.nextCursor;
46
+ if (models.length > 10000) throw new Error('模型目录过大,请重新打开选择页。');
47
+ } while (cursor);
48
+ const config = this.getConfig();
49
+ return [...models, ...(config.enabled ? additionalModels(models, config) : [])].filter(m => !m.hidden);
50
+ }
51
+ async state() {
52
+ void checkForUpdates();
53
+ this.request(null);
54
+ const models = await this.catalog();
55
+ const threads = [];
56
+ let cursor;
57
+ do {
58
+ const page = await this.bridge.rpc('thread/loaded/list', { limit: 100, ...(cursor ? { cursor } : {}) });
59
+ for (const id of page.data) {
60
+ try {
61
+ const { thread } = await this.bridge.rpc('thread/read', { threadId: id, includeTurns: false });
62
+ if (thread.parentThreadId || thread.source?.subAgent) continue;
63
+ this.menu.rememberThread(thread);
64
+ threads.push({ id, name: thread.name || thread.preview || '未命名任务', cwd: thread.cwd, status: thread.status?.type,
65
+ model: thread.model, provider: thread.modelProvider, pin: this.pins.get(id) || null });
66
+ } catch { /* a task may close while the list is being read */ }
67
+ }
68
+ cursor = page.nextCursor;
69
+ } while (cursor);
70
+ return { update: updateStatus(), threads, next: this.next, models: models.map(m => ({ model: m.model, name: m.displayName || m.model,
71
+ effort: m.defaultReasoningEffort, efforts: m.supportedReasoningEfforts || [] })) };
72
+ }
73
+ async choose({ threadId, model, effort }) {
74
+ if (!this.getConfig().enabled) throw new Error('路由已关闭,请先运行 codex-desktop-router on。');
75
+ if (typeof threadId !== 'string') throw new Error('请选择任务。');
76
+ const entry = (await this.catalog()).find(m => m.model === model);
77
+ if (!entry) throw new Error('此模型已不在原生目录中,请刷新。');
78
+ const supported = entry.supportedReasoningEfforts || [];
79
+ effort = supported.length ? (effort ?? entry.defaultReasoningEffort) : null;
80
+ if (supported.length && !supported.some(e => e.reasoningEffort === effort)) throw new Error('请选择该模型支持的推理强度。');
81
+ const pin = { model, effort };
82
+ if (threadId === 'next') { this.next = pin; return { ok: true }; }
83
+ const { thread } = await this.bridge.rpc('thread/read', { threadId, includeTurns: false });
84
+ if (thread.status?.type !== 'idle') throw new Error('请等待任务回复和工具运行结束。');
85
+ this.menu.rememberThread(thread);
86
+ const message = { id: `picker-${++this.sequence}`, method: 'thread/settings/update', params: { threadId, model, effort } };
87
+ await this.bridge.prepare(message);
88
+ const routed = this.menu.request(message);
89
+ this.menu.pending.delete(message.id);
90
+ await this.bridge.rpc(message.method, routed.params);
91
+ this.pins.set(threadId, pin);
92
+ return { ok: true };
93
+ }
94
+ release({ threadId }) {
95
+ if (typeof threadId !== 'string') throw new Error('请选择任务。');
96
+ if (threadId === 'next') this.next = null;
97
+ else this.pins.delete(threadId);
98
+ return { ok: true };
99
+ }
100
+ async listen() {
101
+ const token = randomBytes(32).toString('hex');
102
+ let origin;
103
+ const server = createServer(async (req, res) => {
104
+ const reply = (code, body, type = 'application/json') => {
105
+ res.writeHead(code, { 'content-type': `${type}; charset=utf-8`, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff',
106
+ 'referrer-policy': 'no-referrer', 'content-security-policy': "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" });
107
+ res.end(type === 'application/json' ? JSON.stringify(body) : body);
108
+ };
109
+ if (req.headers.host !== new URL(origin).host || (req.headers.origin && req.headers.origin !== origin)) return reply(403, { error: '访问来源不匹配。' });
110
+ const chat = this.chats.find(c => req.url?.startsWith(c.path + '/'));
111
+ if (chat) return chat.handle(req, res);
112
+ if (req.method === 'GET' && ['/', '/picker.js'].includes(req.url)) {
113
+ const filename = req.url === '/' ? 'picker.html' : 'picker-client.js';
114
+ return reply(200, readFileSync(new URL(filename, import.meta.url), 'utf8'), req.url === '/' ? 'text/html' : 'text/javascript');
115
+ }
116
+ if (req.headers.authorization !== `Bearer ${token}`) return reply(401, { error: '入口已失效,请重新打开模型选择页。' });
117
+ try {
118
+ if (req.method === 'GET' && req.url === '/state') return reply(200, await this.serialize(() => this.state()));
119
+ if (req.method === 'POST' && ['/choose', '/release'].includes(req.url)) {
120
+ if (req.headers['content-type'] !== 'application/json') return reply(415, { error: '请求格式不正确。' });
121
+ let raw = ''; for await (const data of req) { raw += data; if (raw.length > 8192) return reply(413, { error: '请求过大。' }); }
122
+ const body = JSON.parse(raw);
123
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('请求格式不正确。');
124
+ return reply(200, await this.serialize(() => req.url === '/choose' ? this.choose(body) : this.release(body)));
125
+ }
126
+ reply(404, { error: '没有这个操作。' });
127
+ } catch (error) { reply(400, { error: error instanceof SyntaxError ? '请求格式不正确。' : error.message }); }
128
+ });
129
+ server.requestTimeout = 10000; server.headersTimeout = 10000;
130
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
131
+ origin = `http://127.0.0.1:${server.address().port}`;
132
+ server.unref(); this.server = server;
133
+ return { port: server.address().port, token };
134
+ }
135
+ close() { for (const chat of this.chats) chat.close(); this.server?.closeAllConnections(); this.server?.close(); }
136
+ }
137
+
138
+ export function openPicker(home) {
139
+ const directory = join(home, 'desktop-router-runs');
140
+ const active = [];
141
+ try {
142
+ for (const file of readdirSync(directory)) {
143
+ if (!/^\d+\.picker\.json$/.test(file)) continue;
144
+ try { const item = JSON.parse(readFileSync(join(directory, file), 'utf8')); const run = JSON.parse(readFileSync(join(directory, `${item.pid}.json`), 'utf8')); process.kill(item.pid, 0); if (run.version === VERSION) active.push(item); } catch { /* stale process */ }
145
+ }
146
+ } catch { /* no active router */ }
147
+ if (!active.length) return false;
148
+ // Each native connection has its own set of loaded tasks. Open all active pages
149
+ // when Desktop has more than one connection, instead of silently picking one.
150
+ for (const item of active) {
151
+ if (!Number.isInteger(item.port) || item.port < 1 || item.port > 65535 || !/^[a-f0-9]{64}$/.test(item.token)) continue;
152
+ execFileSync('/usr/bin/open', [`http://127.0.0.1:${item.port}/#${item.token}`], { stdio: 'ignore' });
153
+ }
154
+ return true;
155
+ }
@@ -0,0 +1,127 @@
1
+ import { resolve, dirname } from 'node:path';
2
+ import { statSync } from 'node:fs';
3
+ import { rpcClient } from './rpc.mjs';
4
+ import { codexHome, merge, findRoute } from './config.mjs';
5
+
6
+ // Model routing uses the client's layers, with the profile between user and
7
+ // project/session settings. Task permissions remain owned by Desktop/Codex.
8
+ export function applyLayers(route, layers) {
9
+ let effective = {}, inserted = false;
10
+ const upper = new Set(['project', 'sessionFlags', 'legacyManagedConfigTomlFromFile', 'legacyManagedConfigTomlFromMdm']);
11
+ // config/read returns highest precedence first.
12
+ for (const layer of [...(layers || [])].reverse()) {
13
+ if (layer.disabledReason) continue;
14
+ if (!inserted && upper.has(layer.name.type)) { effective = merge(effective, route.layer); inserted = true; }
15
+ const config = { ...layer.config };
16
+ if (typeof config.model_catalog_json === 'string') {
17
+ const base = layer.name.file ? dirname(layer.name.file) : layer.name.dotCodexFolder || process.cwd();
18
+ config.model_catalog_json = resolve(base, config.model_catalog_json);
19
+ }
20
+ effective = merge(effective, config);
21
+ }
22
+ if (!inserted) effective = merge(effective, route.layer);
23
+ const catalogPath = effective.model_catalog_json == null ? null : resolve(codexHome(), effective.model_catalog_json);
24
+ // Choosing a route is an explicit provider selection. Do not reassign its
25
+ // credentials using another project's default provider.
26
+ return { ...route, catalogPath };
27
+ }
28
+
29
+ // Codex owns model metadata at process scope. Catalog-backed tasks therefore
30
+ // share a native worker for each catalog revision, not a hand-built metadata map.
31
+ export class ProfileCatalogs {
32
+ constructor(executable, args, env, getConfig, emit) {
33
+ Object.assign(this, { executable, args, env, getConfig, emit });
34
+ this.workers = new Map(); this.owners = new Map(); this.pending = new Map(); this.serverRequests = new Map();
35
+ }
36
+ async worker(path) {
37
+ const stat = statSync(path), key = `${path}:${stat.mtimeMs}:${stat.size}`;
38
+ if (this.workers.has(key)) return this.workers.get(key);
39
+ const worker = { key, path, models: null };
40
+ worker.client = rpcClient(this.executable, [...this.args, '-c', `model_catalog_json=${JSON.stringify(path)}`], this.env, 30000, message => {
41
+ this.observe(message, worker).then(visible => {
42
+ // Connection-wide account/config notifications belong to the primary.
43
+ if (visible && (Object.hasOwn(visible, 'id') || visible.params?.threadId || visible.params?.thread?.id)) this.emit(visible);
44
+ }).catch(() => this.emit({ method: 'error', params: { message: '模型目录进程通信失败。' } }));
45
+ });
46
+ worker.client.child.once('close', () => {
47
+ this.workers.delete(key);
48
+ for (const [id, owner] of this.owners) if (owner === worker) this.owners.delete(id);
49
+ for (const [id, origin] of this.serverRequests) if (origin.worker === worker) this.serverRequests.delete(id);
50
+ for (const [id, request] of this.pending) if (request.worker === worker) {
51
+ this.pending.delete(id); this.emit({ id, error: { code: -32603, message: '模型目录进程已退出,请重新打开任务。' } });
52
+ }
53
+ });
54
+ try {
55
+ await worker.client.rpc('initialize', this.initialize || { clientInfo: { name: 'codex_desktop_router', version: '1' } });
56
+ worker.client.send({ method: 'initialized' });
57
+ this.workers.set(key, worker);
58
+ for (const previous of this.workers.values()) if (previous !== worker && previous.path === path && ![...this.owners.values()].includes(previous)) await previous.client.close();
59
+ return worker;
60
+ } catch (error) { await worker.client.close(); throw error; }
61
+ }
62
+ async load(path) {
63
+ const worker = await this.worker(path);
64
+ if (worker.models) return worker.models;
65
+ const models = []; let cursor;
66
+ do {
67
+ const page = await worker.client.rpc('model/list', { includeHidden: true, limit: 100, ...(cursor ? { cursor } : {}) });
68
+ models.push(...page.data); cursor = page.nextCursor;
69
+ if (models.length > 10000) throw new Error('模型目录超过读取上限。');
70
+ } while (cursor);
71
+ worker.models = models;
72
+ return models;
73
+ }
74
+ async forward(message) {
75
+ if (!message || !Object.hasOwn(message, 'id')) return false;
76
+ if (!message.method) {
77
+ const origin = this.serverRequests.get(message.id);
78
+ if (!origin) return false;
79
+ this.serverRequests.delete(message.id); origin.worker.client.send({ ...message, id: origin.id }); return true;
80
+ }
81
+ const params = message.params || {}, config = this.getConfig();
82
+ let owner = this.owners.get(params.threadId) || null;
83
+ if (['thread/start', 'thread/resume', 'thread/fork'].includes(message.method)) {
84
+ let provider = params.modelProvider;
85
+ if (message.method === 'thread/resume' && !provider && !owner && params.threadId) {
86
+ provider = (await this.readThread(params.threadId)).modelProvider;
87
+ }
88
+ const route = findRoute(provider, config);
89
+ if (provider || message.method === 'thread/start') {
90
+ const path = route && (params.config?.model_catalog_json ?? route.catalogPath);
91
+ owner = path ? await this.worker(resolve(codexHome(), path)) : null;
92
+ }
93
+ }
94
+ this.pending.set(message.id, { method: message.method, firstPage: !params.cursor, worker: owner, time: Date.now() });
95
+ for (const [id, request] of this.pending) if (Date.now() - request.time > 300000) this.pending.delete(id);
96
+ if (!owner) return false;
97
+ owner.client.send(message); return true;
98
+ }
99
+ async observe(message, worker = null) {
100
+ if (worker && message?.method === 'thread/started' && message.params?.thread?.id) this.owners.set(message.params.thread.id, worker);
101
+ if (message?.method && Object.hasOwn(message, 'id') && worker) {
102
+ const id = `cdr-worker-${worker.client.child.pid}-${message.id}`;
103
+ this.serverRequests.set(id, { worker, id: message.id });
104
+ return { ...message, id };
105
+ }
106
+ const request = !message?.method && this.pending.get(message?.id);
107
+ if (request) {
108
+ this.pending.delete(message.id);
109
+ if (message.result?.thread?.id && ['thread/start', 'thread/resume', 'thread/fork'].includes(request.method)) {
110
+ this.owners.set(message.result.thread.id, worker);
111
+ }
112
+ if (!worker && request.method === 'thread/loaded/list' && request.firstPage && message.result?.data) {
113
+ const extra = [];
114
+ for (const other of this.workers.values()) {
115
+ let cursor;
116
+ do {
117
+ const page = await other.client.rpc('thread/loaded/list', { limit: 100, ...(cursor ? { cursor } : {}) });
118
+ extra.push(...page.data); cursor = page.nextCursor;
119
+ } while (cursor);
120
+ }
121
+ if (extra.length) return { ...message, result: { ...message.result, data: [...new Set([...message.result.data, ...extra])] } };
122
+ }
123
+ }
124
+ return message;
125
+ }
126
+ async close() { await Promise.all([...this.workers.values()].map(w => w.client.close())); }
127
+ }