@ailuntz/minilink 0.8.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,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 = { 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: 'minilink', 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 = `minilink-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
+ }
@@ -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: `minilink: ${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 = `minilink-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 { setting } from './legacy.mjs';
2
+ import { runtimeDirectory } from './runtime.mjs';
3
+ import { spawn } from 'node:child_process';
4
+ import { join } from 'node:path';
5
+ import { rmSync, existsSync, readFileSync } from 'node:fs';
6
+ import { JsonLineRouter } from './protocol.mjs';
7
+ import { ModelMenu } from './model-menu.mjs';
8
+ import { ProviderSwitch } from './provider-switch.mjs';
9
+ import { Picker } from './picker.mjs';
10
+ import { Chat } from './chat.mjs';
11
+ import { ProfileCatalogs, applyLayers } from './profiles.mjs';
12
+ import { VERSION, readConfig, configPath, atomicJSON, resolveRealCodex, providerArgs, childEnvironment, discoverProfiles } from './config.mjs';
13
+
14
+ export function isStdioServer(args) {
15
+ // Skip values of global flags before finding the CLI subcommand.
16
+ let index = 0;
17
+ const valueFlags = new Set(['-c', '--config', '--enable', '--disable', '-p', '--profile', '--code-mode-host']);
18
+ while (index < args.length && args[index].startsWith('-')) {
19
+ if (valueFlags.has(args[index])) index += 2; else index++;
20
+ }
21
+ if (args[index] !== 'app-server') return false;
22
+ for (let i = index + 1; i < args.length; i++) {
23
+ const arg = args[i];
24
+ if (['--help', '-h', '--version', '-V'].includes(arg)) return false;
25
+ if (arg.startsWith('--listen=')) { if (arg.slice(9) !== 'stdio://') return false; continue; }
26
+ if (arg === '--listen') { if (args[++i] !== 'stdio://') return false; continue; }
27
+ 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; }
28
+ if (!arg.startsWith('-')) return false; // daemon/proxy/schema tooling
29
+ }
30
+ return true;
31
+ }
32
+
33
+ export async function runProxy(args, selfPath) {
34
+ if ((Number.parseInt(setting('ACTIVE'), 10) || 0) >= 8) throw new Error('检测到 minilink 递归调用;MINILINK_REAL_CODEX 必须指向真正的 codex。');
35
+ const path = configPath();
36
+ readConfig(path);
37
+ const hadConfig = existsSync(path);
38
+ const executable = resolveRealCodex(selfPath);
39
+ const routed = isStdioServer(args);
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(runtimeDirectory(), `${process.pid}.json`);
53
+ const pickerPath = join(runtimeDirectory(), `${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('minilink: 传输失败,已停止 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('minilink: 菜单默认选择未能保存。\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('minilink: 独立模型选择页启动失败,原生菜单仍可使用。\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('minilink: 无法启动真正的 codex。\n'); });
155
+ child.once('close', async (code, signal) => {
156
+ 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,42 @@
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 = () => {}) {
6
+ const child = spawn(executable, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
7
+ child.stderr.resume();
8
+ let seq = 0, closed = false;
9
+ const prefix = `minilink-rpc-${randomUUID()}-`;
10
+ const pending = new Map();
11
+ const lineReader = createInterface({ input: child.stdout });
12
+ const abort = () => {
13
+ closed = true;
14
+ for (const p of pending.values()) { clearTimeout(p.timer); p.reject(new Error('app-server 已退出。')); }
15
+ pending.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 onMessage(message);
26
+ });
27
+ return {
28
+ child,
29
+ send(message) { if (closed) throw new Error('app-server 已退出。'); child.stdin.write(JSON.stringify(message) + '\n'); },
30
+ rpc(method, params) { return new Promise((resolve, reject) => {
31
+ if (closed) return reject(new Error('app-server 已退出。'));
32
+ const id = prefix + (++seq);
33
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`RPC ${method} 超时。`)); }, timeout);
34
+ pending.set(id, { method, resolve, reject, timer, error: new Error(`RPC ${method}`) }); child.stdin.write(JSON.stringify({ id, method, params }) + '\n');
35
+ }); },
36
+ async close() {
37
+ if (closed) return;
38
+ child.stdin.end(); const timer = setTimeout(() => child.kill('SIGKILL'), 12000);
39
+ await done; clearTimeout(timer);
40
+ }
41
+ };
42
+ }
@@ -0,0 +1,24 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { codexHome } from './config.mjs';
4
+ import { legacyRuns } from './legacy.mjs';
5
+
6
+ export const runtimeDirectory = (home = codexHome()) => join(home, 'minilink-runs');
7
+ export function activeRuns(home = codexHome()) {
8
+ const active = [];
9
+ for (const directory of [runtimeDirectory(home), legacyRuns(home)]) {
10
+ let files;
11
+ try { files = readdirSync(directory); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
12
+ for (const file of files) {
13
+ if (!/^\d+\.json$/.test(file)) continue;
14
+ try {
15
+ const record = JSON.parse(readFileSync(join(directory, file), 'utf8'));
16
+ if (!Number.isInteger(record.pid) || record.pid <= 0 || file !== `${record.pid}.json`) continue;
17
+ process.kill(record.pid, 0);
18
+ if (record.childPid) process.kill(record.childPid, 0);
19
+ active.push({ ...record, directory });
20
+ } catch { /* A process may exit between listing and reading. */ }
21
+ }
22
+ }
23
+ return active;
24
+ }