orbit-codex-bridge 0.0.0-stage → 0.1.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.
package/server.mjs ADDED
@@ -0,0 +1,491 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import crypto from 'node:crypto';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { WebSocketServer, WebSocket } from 'ws';
8
+ import QRCode from 'qrcode';
9
+ import { NativeStore } from './lib/store.mjs';
10
+ import { CodexRpc } from './lib/rpc.mjs';
11
+ import { DesktopRpc } from './lib/desktop.mjs';
12
+
13
+ const root = path.dirname(fileURLToPath(import.meta.url));
14
+ const packageVersion = JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8')).version;
15
+ const sessionLifetime = 12 * 60 * 60 * 1000;
16
+ const safeError = error => String(error.message || error).replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]').replace(/(?:sk-|sk_)[\w-]{12,}/g, '[REDACTED]').slice(0, 700);
17
+
18
+ export function lanAddresses() {
19
+ return Object.values(os.networkInterfaces()).flat().filter(address => address && address.family === 'IPv4' && !address.internal).map(address => address.address);
20
+ }
21
+
22
+ export function createOrbitServer(options = {}) {
23
+ const home = options.home || process.env.ORBIT_CODEX_HOME || process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
24
+ const dataDir = options.dataDir || path.join(root, '.runtime');
25
+ fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
26
+ const credentialPath = path.join(dataDir, 'pairing-key');
27
+ let pairingKey = fs.existsSync(credentialPath) ? fs.readFileSync(credentialPath, 'utf8').trim() : crypto.randomBytes(24).toString('base64url');
28
+ fs.writeFileSync(credentialPath, pairingKey, { mode: 0o600 });
29
+ const store = options.store || new NativeStore(home);
30
+ const endpoint = options.endpoint ?? process.env.ORBIT_APP_SERVER_URL ?? '';
31
+ const rpc = options.rpc || options.desktopRpc || (endpoint ? new CodexRpc({ home, endpoint, tokenFile: process.env.ORBIT_APP_SERVER_TOKEN_FILE }) : new DesktopRpc());
32
+ const executionMode = rpc.mode || (endpoint ? 'endpoint' : 'cold-resume');
33
+ const listModels = options.listModels || (async () => {
34
+ const catalogRpc = new CodexRpc({ home, endpoint, tokenFile: process.env.ORBIT_APP_SERVER_TOKEN_FILE });
35
+ try {
36
+ await catalogRpc.connect();
37
+ const models = [];
38
+ let cursor = null;
39
+ for (let page = 0; page < 5; page++) {
40
+ const result = await catalogRpc.request('model/list', { limit: 100, includeHidden: false, ...(cursor ? { cursor } : {}) }, 12000);
41
+ if (!Array.isArray(result.data)) throw new Error('模型目录格式不兼容。');
42
+ models.push(...result.data);
43
+ cursor = result.nextCursor;
44
+ if (!cursor) return models;
45
+ }
46
+ throw new Error('模型目录未加载完整,未开放参数切换。');
47
+ } finally { catalogRpc.close(); }
48
+ });
49
+ let modelCache = null;
50
+ let modelCacheAt = 0;
51
+ let modelLoading = null;
52
+ async function modelCatalog() {
53
+ if (modelCache && Date.now() - modelCacheAt < 120000) return modelCache;
54
+ if (!modelLoading) modelLoading = Promise.resolve().then(listModels).then(data => {
55
+ if (!Array.isArray(data)) throw new Error('模型目录格式不兼容。');
56
+ modelCache = data.filter(model => model && !model.hidden && typeof (model.model || model.id) === 'string' && (model.model || model.id).length <= 180).map(model => ({
57
+ model: model.model || model.id, displayName: model.displayName || model.model || model.id,
58
+ isDefault: model.isDefault === true, defaultReasoningEffort: model.defaultReasoningEffort || null,
59
+ supportedReasoningEfforts: (Array.isArray(model.supportedReasoningEfforts) ? model.supportedReasoningEfforts : [])
60
+ .map(effort => typeof effort === 'string' ? effort : effort?.reasoningEffort).filter(effort => typeof effort === 'string' && effort.length < 30)
61
+ }));
62
+ modelCacheAt = Date.now();
63
+ return modelCache;
64
+ }).finally(() => { modelLoading = null; });
65
+ return modelLoading;
66
+ }
67
+ const publicUrl = options.publicUrl || process.env.ORBIT_PUBLIC_URL || '';
68
+ if (publicUrl && new URL(publicUrl).protocol !== 'https:') throw new Error('ORBIT_PUBLIC_URL 必须使用 HTTPS。');
69
+ const host = options.host || process.env.ORBIT_HOST || '127.0.0.1';
70
+ const sessions = new Map();
71
+ const attempts = new Map();
72
+ const dedup = new Map();
73
+ const sockets = new Set();
74
+ const desktopWatchers = new Map();
75
+ const queuedSettingsInFlight = new Set();
76
+ const allowedHosts = new Set(['localhost','127.0.0.1','[::1]',...lanAddresses(), ...(publicUrl ? [new URL(publicUrl).hostname] : [])]);
77
+ let port = Number(options.port ?? process.env.ORBIT_PORT ?? 4318);
78
+ let revision = store.revision();
79
+ let timer;
80
+ let lastDesktopConnect = 0;
81
+
82
+ function requestOrigin(request) {
83
+ const hostname = new URL(`http://${request.headers.host || 'invalid'}`).hostname;
84
+ if (!allowedHosts.has(hostname)) throw new Error('不允许的主机名。');
85
+ const origin = `http://${request.headers.host}`;
86
+ return publicUrl && request.headers.host === new URL(publicUrl).host ? new URL(publicUrl).origin : origin;
87
+ }
88
+
89
+ function checkOrigin(request, required = true) {
90
+ try { return (!request.headers.origin && !required) || request.headers.origin === requestOrigin(request); }
91
+ catch { return false; }
92
+ }
93
+
94
+ function sessionFor(request) {
95
+ const cookie = (request.headers.cookie || '').split(';').map(part => part.trim()).find(part => part.startsWith('orbit_session='));
96
+ const key = cookie?.slice('orbit_session='.length);
97
+ const session = sessions.get(key);
98
+ if (!session || session.expires < Date.now()) { if (key) sessions.delete(key); return null; }
99
+ return { ...session, key };
100
+ }
101
+
102
+ function json(response, status, body, extra = {}) {
103
+ response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...extra });
104
+ response.end(JSON.stringify(body));
105
+ }
106
+
107
+ async function readBody(request) {
108
+ let length = 0;
109
+ const chunks = [];
110
+ for await (const chunk of request) {
111
+ length += chunk.length;
112
+ if (length > 65536) throw new Error('请求过大。');
113
+ chunks.push(chunk);
114
+ }
115
+ return JSON.parse(Buffer.concat(chunks).toString() || '{}');
116
+ }
117
+
118
+ function status(session) {
119
+ const threads = store.list();
120
+ for (const id of session.enabled.keys()) controlFor(session, id);
121
+ return {
122
+ product: 'Orbit', version: packageVersion, device: os.hostname(), platform: process.platform, home,
123
+ threadCount: threads.length, projectCount: new Set(threads.map(thread => thread.projectId)).size,
124
+ archivedCount: threads.filter(thread => thread.archived).length, lockedCount: threads.filter(thread => thread.locked).length,
125
+ bridge: 'connected', transport: 'websocket', readOnlyMirror: true,
126
+ desktopHotAttachReady: executionMode === 'desktop' && rpc.connected,
127
+ desktopHotAttachVerified: executionMode === 'desktop' && [...rpc.threads.values()].some(thread => thread.state),
128
+ execution: { connected: rpc.connected, mode: executionMode, enabled: [...session.enabled.keys()], grants: Object.fromEntries(session.enabled), owned: [...rpc.threads.keys()] },
129
+ connectionUrls: [publicUrl || `http://127.0.0.1:${port}`, ...(host === '0.0.0.0' ? lanAddresses().map(address => `http://${address}:${port}`) : [])],
130
+ secureTransport: Boolean(publicUrl), lanEnabled: host === '0.0.0.0'
131
+ };
132
+ }
133
+
134
+ function controlFor(session, id) {
135
+ const control = session.enabled.get(id);
136
+ if (control && control.expires <= Date.now()) { session.enabled.delete(id); return null; }
137
+ return control || null;
138
+ }
139
+
140
+ function sendSessionEvent(sessionKey, event) {
141
+ for (const socket of sockets) {
142
+ if (socket.sessionKey === sessionKey && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(event));
143
+ }
144
+ }
145
+
146
+ async function applyQueuedDesktopSettings(id, runtime) {
147
+ if (!runtime?.attached || runtime.turnId || queuedSettingsInFlight.has(id)) return;
148
+ queuedSettingsInFlight.add(id);
149
+ try {
150
+ for (const [sessionKey,session] of sessions) {
151
+ const queued = session.preferences.get(id);
152
+ if (!queued?.pending) continue;
153
+ if (!controlFor(session, id)) {
154
+ session.preferences.delete(id);
155
+ sendSessionEvent(sessionKey, { method:'orbit/settings-error',params:{ threadId:id,error:'授权已失效;下一轮模型参数未应用。' } });
156
+ continue;
157
+ }
158
+ try {
159
+ const model = runtime.model || null;
160
+ const effort = runtime.effort ?? null;
161
+ if (model === queued.model && effort === queued.effort) {
162
+ session.preferences.set(id,{ model:queued.model,effort:queued.effort,pending:false });
163
+ } else {
164
+ if (model !== queued.expectedModel || effort !== queued.expectedEffort) throw new Error('桌面模型设置已变化,请重新选择。');
165
+ const result = await rpc.updateThreadSettings(id,queued.model,queued.effort,queued.expectedModel,queued.expectedEffort);
166
+ session.preferences.set(id,{ model:result.model,effort:result.effort,pending:false });
167
+ }
168
+ sendSessionEvent(sessionKey,{ method:'orbit/settings-applied',params:{ threadId:id,model:queued.model,effort:queued.effort } });
169
+ } catch (error) {
170
+ session.preferences.delete(id);
171
+ sendSessionEvent(sessionKey,{ method:'orbit/settings-error',params:{ threadId:id,error:safeError(error) } });
172
+ }
173
+ }
174
+ } finally { queuedSettingsInFlight.delete(id); }
175
+ }
176
+
177
+ function unwatchDesktop(socket) {
178
+ const id = socket.desktopSubscription;
179
+ if (!id) return;
180
+ socket.desktopSubscription = null;
181
+ const count = desktopWatchers.get(id) || 0;
182
+ if (count > 1) desktopWatchers.set(id, count - 1);
183
+ else {
184
+ desktopWatchers.delete(id);
185
+ rpc.detach(id, '已取消实时跟随。');
186
+ }
187
+ }
188
+
189
+ function tryDesktopConnect() {
190
+ if (executionMode !== 'desktop' || rpc.connected || Date.now() - lastDesktopConnect < 5000) return;
191
+ lastDesktopConnect = Date.now();
192
+ rpc.connect().catch(() => {});
193
+ }
194
+
195
+ const vendor = new Map([
196
+ ['/vendor/marked.js', ['node_modules/marked/lib/marked.umd.js','text/javascript']],
197
+ ['/vendor/purify.js', ['node_modules/dompurify/dist/purify.min.js','text/javascript']]
198
+ ]);
199
+
200
+ const server = http.createServer(async (request, response) => {
201
+ response.setHeader('Cache-Control', 'no-store');
202
+ response.setHeader('X-Content-Type-Options', 'nosniff');
203
+ response.setHeader('Referrer-Policy', 'no-referrer');
204
+ response.setHeader('X-Frame-Options', 'DENY');
205
+ response.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
206
+ try {
207
+ requestOrigin(request);
208
+ const url = new URL(request.url, `http://${request.headers.host}`);
209
+ if (request.method === 'GET' && url.pathname === '/healthz') return json(response, 200, { ok: true });
210
+ if (request.method === 'GET' && url.pathname.startsWith('/api/cloud/')) return json(response, 404, { error:'当前运行的是本地工作站,不是云端控制平面。' });
211
+ if (request.method === 'POST' && !checkOrigin(request)) return json(response, 403, { error: '来源校验失败。' });
212
+ if (url.pathname === '/api/pair' && request.method === 'POST') {
213
+ const address = request.socket.remoteAddress;
214
+ const previous = attempts.get(address) || { count: 0, reset: Date.now() + 300000 };
215
+ if (previous.reset < Date.now()) { previous.count = 0; previous.reset = Date.now() + 300000; }
216
+ attempts.set(address, previous);
217
+ if (previous.count >= 8) return json(response, 429, { error: '尝试过多,请五分钟后重试。' });
218
+ previous.count++;
219
+ const body = await readBody(request);
220
+ const value = Buffer.from(typeof body.code === 'string' ? body.code.trim() : '');
221
+ const expected = Buffer.from(pairingKey);
222
+ if (value.length !== expected.length || !crypto.timingSafeEqual(value, expected)) return json(response, 401, { error: '配对密钥不正确。请在运行电脑上查看。' });
223
+ previous.count = 0;
224
+ if (sessions.size >= 64) return json(response, 429, { error: '已连接设备过多,请重启桥接服务后重新配对。' });
225
+ const key = crypto.randomBytes(32).toString('base64url');
226
+ sessions.set(key, { expires: Date.now() + sessionLifetime, enabled: new Map(), preferences: new Map() });
227
+ const secure = publicUrl ? '; Secure' : '';
228
+ return json(response, 200, { ok: true }, { 'Set-Cookie': `orbit_session=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=43200${secure}` });
229
+ }
230
+ if (url.pathname.startsWith('/api/')) {
231
+ const session = sessionFor(request);
232
+ if (!session) return json(response, 401, { error: '请先配对这台电脑。' });
233
+ if (request.method === 'GET' && url.pathname === '/api/status') return json(response, 200, status(session));
234
+ if (request.method === 'GET' && url.pathname === '/api/threads') return json(response, 200, { threads: store.list(), revision });
235
+ if (request.method === 'GET' && url.pathname === '/api/models') return json(response, 200, { models: await modelCatalog() });
236
+ if (request.method === 'POST' && url.pathname === '/api/logout') {
237
+ sessions.delete(session.key);
238
+ for (const socket of sockets) if (socket.sessionKey === session.key) socket.close(1008, 'Logged out');
239
+ return json(response, 200, { ok: true }, { 'Set-Cookie': 'orbit_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0' });
240
+ }
241
+ if (request.method === 'POST' && url.pathname === '/api/pair/qr') {
242
+ const body = await readBody(request);
243
+ const connection = status(session).connectionUrls.find(value => value === body.url);
244
+ if (!connection) return json(response, 400, { error: '请选择桥接服务提供的连接地址。' });
245
+ return json(response, 200, { image: await QRCode.toDataURL(`${connection}/#pair=${encodeURIComponent(pairingKey)}`, { width: 280, margin: 2 }), warning: '扫码者将能查看本机会话。仅向可信设备展示。' });
246
+ }
247
+ const match = url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/messages$/);
248
+ if (request.method === 'GET' && match) {
249
+ const history = store.historyFor(match[1], url.searchParams.get('before'), url.searchParams.get('limit') || 60);
250
+ return history ? json(response, 200, history) : json(response, 404, { error: '会话不存在。' });
251
+ }
252
+ return json(response, 404, { error: '接口不存在。' });
253
+ }
254
+ if (request.method !== 'GET' && request.method !== 'HEAD') return json(response, 405, { error: '不支持的方法。' });
255
+ let file;
256
+ let type;
257
+ if (vendor.has(url.pathname)) {
258
+ const [relative, mime] = vendor.get(url.pathname);
259
+ file = path.join(root, relative);
260
+ type = mime;
261
+ } else {
262
+ const files = { '/': ['index.html','text/html'], '/app.js': ['app.js','text/javascript'], '/style.css': ['style.css','text/css'], '/layout.css': ['layout.css','text/css'], '/icon.svg': ['icon.svg','image/svg+xml'], '/manifest.webmanifest': ['manifest.webmanifest','application/manifest+json'] };
263
+ if (!files[url.pathname]) return json(response, 404, { error: '页面不存在。' });
264
+ const [name, mime] = files[url.pathname]; file = path.join(root, 'public', name); type = mime;
265
+ }
266
+ response.writeHead(200, { 'Content-Type': `${type}; charset=utf-8` });
267
+ if (request.method === 'HEAD') response.end(); else fs.createReadStream(file).pipe(response);
268
+ } catch (error) { if (!response.headersSent) json(response, 400, { error: safeError(error) }); else response.end(); }
269
+ });
270
+
271
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 65536 });
272
+ server.on('upgrade', (request, socket, head) => {
273
+ const session = sessionFor(request);
274
+ if (request.url !== '/socket' || !session || !checkOrigin(request)) { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return; }
275
+ wss.handleUpgrade(request, socket, head, websocket => {
276
+ websocket.sessionKey = session.key;
277
+ websocket.alive = true;
278
+ websocket.subscription = null;
279
+ websocket.desktopSubscription = null;
280
+ sockets.add(websocket);
281
+ websocket.on('pong', () => { websocket.alive = true; });
282
+ websocket.on('close', () => { sockets.delete(websocket); unwatchDesktop(websocket); });
283
+ websocket.on('error', () => {});
284
+ websocket.send(JSON.stringify({ method: 'bridge/ready', params: { revision } }));
285
+ websocket.on('message', async raw => {
286
+ let message;
287
+ try {
288
+ const current = sessions.get(websocket.sessionKey);
289
+ if (!current || current.expires < Date.now()) { websocket.close(1008, 'Session expired'); return; }
290
+ message = JSON.parse(raw);
291
+ if (typeof message.id !== 'string' || message.id.length > 100) throw new Error('缺少请求 ID。');
292
+ const key = `${websocket.sessionKey}:${message.id}`;
293
+ const fingerprint = crypto.createHash('sha256').update(JSON.stringify([message.method, message.params])).digest('hex');
294
+ let entry = dedup.get(key);
295
+ if (entry && entry.fingerprint !== fingerprint) throw new Error('请求 ID 已用于其他操作。');
296
+ if (!entry) {
297
+ if (dedup.size > 1000) throw new Error('请求过多,请稍后重试。');
298
+ entry = { fingerprint, created: Date.now(), promise: command(websocket, current, message).then(result => ({ id: message.id, result }), error => ({ id: message.id, error: safeError(error), code: error.code || null })) };
299
+ dedup.set(key, entry);
300
+ }
301
+ const result = await entry.promise;
302
+ if (websocket.readyState === WebSocket.OPEN) websocket.send(JSON.stringify(result));
303
+ } catch (error) { if (websocket.readyState === WebSocket.OPEN) websocket.send(JSON.stringify({ id: message?.id || null, error: safeError(error), code: error.code || null })); }
304
+ });
305
+ });
306
+ });
307
+
308
+ async function command(socket, session, message) {
309
+ const body = message.params || {};
310
+ const id = body.threadId;
311
+ if (message.method === 'thread.subscribe') {
312
+ const row = id ? store.row(id) : null;
313
+ if (id && !row) throw new Error('会话不存在。');
314
+ if (socket.desktopSubscription && socket.desktopSubscription !== (id || null)) unwatchDesktop(socket);
315
+ socket.subscription = id || null;
316
+ let runtime = null;
317
+ if (executionMode === 'desktop' && id) {
318
+ if (socket.desktopSubscription !== id) {
319
+ socket.desktopSubscription = id;
320
+ desktopWatchers.set(id, (desktopWatchers.get(id) || 0) + 1);
321
+ }
322
+ try { runtime = await rpc.attach(id); }
323
+ catch (error) {
324
+ unwatchDesktop(socket);
325
+ lostListener(id,error.message,error.code);
326
+ runtime = {
327
+ threadId:id,mode:'desktop',attached:false,turnId:null,messages:[],approvals:[],
328
+ reason:safeError(error),reasonCode:error.code || 'DESKTOP_NOT_READY'
329
+ };
330
+ }
331
+ }
332
+ if (executionMode === 'desktop' && runtime?.attached && body.autoControl === true
333
+ && !row.archived && !String(row.source).startsWith('{')) {
334
+ session.enabled.set(id, {sharedExecution:true,desktop:true,writable:false,expires:Date.now() + 1800000});
335
+ }
336
+ const control = controlFor(session, id);
337
+ const approvals = control ? (executionMode === 'desktop' ? runtime?.approvals || [] : [...rpc.approvals.values()].filter(request => request.params.threadId === id).map(request => ({ ...request.params, requestId: String(request.id), method: request.method }))) : [];
338
+ return { subscribed: id || null, control, runtime, settings: control ? session.preferences.get(id) || null : null, turnId: runtime?.turnId || rpc.threads.get(id)?.turnId || null, approvals };
339
+ }
340
+ if (message.method === 'thread.enable') {
341
+ const row = store.row(id);
342
+ if (!row || row.archived) throw new Error('归档或不存在的会话不能直接接续。');
343
+ if (String(row.source).startsWith('{')) throw new Error('子代理不能独立接续,请打开其父会话。');
344
+ if (body.confirm !== true) throw new Error('必须明确确认接续方式。');
345
+ if (executionMode === 'desktop') {
346
+ let runtime;
347
+ try { runtime = await rpc.attach(id); }
348
+ catch (error) { lostListener(id,error.message,error.code); throw error; }
349
+ if (!runtime.attached) throw new Error('桌面实时快照未就绪,未授权控制。');
350
+ const control = { sharedExecution: true, desktop: true, writable: false, expires: Date.now() + 1800000 };
351
+ session.enabled.set(id, control);
352
+ return { threadId: id, ...control, mode: 'desktop', turnId: runtime.turnId };
353
+ }
354
+ let sharedExecution = false;
355
+ if (endpoint) {
356
+ await rpc.connect();
357
+ const result = await rpc.request('thread/loaded/list', {});
358
+ sharedExecution = (result.data || result.threadIds || []).includes(id);
359
+ }
360
+ if (!sharedExecution && !rpc.threads.has(id) && store.locked(id)) throw new Error('会话由桌面或其他进程持有。首版不会强行抢占,请先在原客户端释放该会话。');
361
+ const control = { sharedExecution, writable: body.writable === true, expires: Date.now() + 1800000 };
362
+ session.enabled.set(id, control);
363
+ return { threadId: id, ...control, mode: endpoint ? 'endpoint' : 'cold-resume' };
364
+ }
365
+ if (message.method === 'turn.start') {
366
+ const approval = controlFor(session, id);
367
+ if (!approval) throw new Error('请先确认这条会话的接续方式。');
368
+ if (typeof body.text !== 'string' || !body.text.trim() || body.text.length > 20000) throw new Error('消息必须为 1–20000 个字符。');
369
+ if (executionMode === 'desktop') return rpc.startTurn(id, body.text.trim());
370
+ if (!rpc.threads.has(id) && !endpoint && store.locked(id)) throw new Error('会话已被其他进程占用,未发送消息。');
371
+ return rpc.startTurn(id, body.text.trim(), approval.writable, session.preferences.get(id) || null);
372
+ }
373
+ if (message.method === 'thread.settings') {
374
+ if (!controlFor(session, id) || !store.row(id)) throw new Error('没有此会话的控制授权。');
375
+ if (typeof body.model !== 'string' || !body.model || body.model.length > 180 || (body.effort !== null && typeof body.effort !== 'string')) throw new Error('模型参数无效。');
376
+ const model = (await modelCatalog()).find(entry => entry.model === body.model);
377
+ if (!model || (body.effort !== null && !model.supportedReasoningEfforts.includes(body.effort))) throw new Error('所选模型或推理级别不可用,请重新加载目录。');
378
+ if (executionMode === 'desktop') {
379
+ if (typeof body.expectedModel !== 'string' || !Object.hasOwn(body, 'expectedEffort')) throw new Error('缺少当前模型版本,请刷新会话。');
380
+ const runtime = rpc.runtime(id);
381
+ if (!runtime.attached) throw new Error('桌面实时快照未就绪,请重新连接会话。');
382
+ if (runtime.model !== body.expectedModel || runtime.effort !== body.expectedEffort) throw new Error('模型设置已变化,请刷新后重试。');
383
+ if (runtime.turnId) {
384
+ session.preferences.set(id,{
385
+ model:body.model,effort:body.effort,pending:true,
386
+ expectedModel:runtime.model,expectedEffort:runtime.effort
387
+ });
388
+ return { model:body.model,effort:body.effort,queued:true,applied:false };
389
+ }
390
+ const result = await rpc.updateThreadSettings(id,body.model,body.effort,body.expectedModel,body.expectedEffort);
391
+ session.preferences.set(id,{ model:result.model,effort:result.effort,pending:false });
392
+ return result;
393
+ }
394
+ if (rpc.threads.get(id)?.turnId) throw new Error('当前任务正在执行,请完成后再切换模型参数。');
395
+ const settings = { model: body.model, effort: body.effort };
396
+ session.preferences.set(id, settings);
397
+ return { ...settings, applied: true, mode: 'next-turn' };
398
+ }
399
+ if (message.method === 'turn.steer') {
400
+ if (!controlFor(session, id)) throw new Error('没有此会话的控制授权。');
401
+ if (executionMode !== 'desktop') throw new Error('当前执行通道不支持向桌面任务追加指令。');
402
+ if (typeof body.text !== 'string' || !body.text.trim() || body.text.length > 20000) throw new Error('消息必须为 1–20000 个字符。');
403
+ if (typeof body.turnId !== 'string' || !body.turnId) throw new Error('缺少当前任务标识,请刷新后重试。');
404
+ return rpc.steer(id, body.text.trim(), body.turnId);
405
+ }
406
+ if (message.method === 'turn.interrupt') {
407
+ if (!controlFor(session, id)) throw new Error('没有此会话的控制授权。');
408
+ if (executionMode === 'desktop') return rpc.interrupt(id, body.turnId);
409
+ return rpc.interrupt(id);
410
+ }
411
+ if (message.method === 'approval.reply') {
412
+ const approval = rpc.approvals.get(String(body.requestId));
413
+ if (!approval || !controlFor(session, approval.params.threadId)) throw new Error('没有此审批的控制授权。');
414
+ return rpc.replyApproval(body.requestId, body);
415
+ }
416
+ throw new Error('不支持的操作。');
417
+ }
418
+
419
+ const eventListener = event => {
420
+ if (!/^(item\/|turn\/|thread\/status|orbit\/|serverRequest\/resolved|error$)/.test(event.method)) return;
421
+ if (event.method === 'orbit/desktop' && event.params?.threadId) void applyQueuedDesktopSettings(event.params.threadId,event.params);
422
+ for (const socket of sockets) {
423
+ const threadId = event.params?.threadId;
424
+ if ((!threadId || socket.subscription === threadId) && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(event));
425
+ }
426
+ };
427
+ const lostListener = (threadId, reason, reasonCode) => {
428
+ for (const session of sessions.values()) {
429
+ session.enabled.delete(threadId);
430
+ session.preferences.delete(threadId);
431
+ }
432
+ for (const socket of sockets) {
433
+ if (socket.subscription === threadId && socket.readyState === WebSocket.OPEN) {
434
+ socket.send(JSON.stringify({ method: 'orbit/control-revoked', params: {
435
+ threadId,reason:reason ? safeError(new Error(reason)) : '桌面会话连接已变化,请重新连接并授权。',
436
+ reasonCode:reasonCode || 'DESKTOP_THREAD_UNAVAILABLE'
437
+ } }));
438
+ }
439
+ }
440
+ };
441
+ rpc.on('event', eventListener);
442
+ rpc.on('lost', lostListener);
443
+ async function listen() {
444
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); });
445
+ port = server.address().port;
446
+ timer = setInterval(() => {
447
+ try {
448
+ tryDesktopConnect();
449
+ const next = store.revision();
450
+ if (next !== revision) {
451
+ revision = next;
452
+ for (const socket of sockets) if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ method: 'store/changed', params: { revision } }));
453
+ }
454
+ for (const [key, value] of dedup) if (Date.now() - value.created > 300000) dedup.delete(key);
455
+ for (const [key, value] of attempts) if (value.reset < Date.now()) attempts.delete(key);
456
+ for (const [key, value] of sessions) if (value.expires < Date.now()) sessions.delete(key);
457
+ } catch {}
458
+ }, 1200);
459
+ timer.unref();
460
+ return { port, url: `http://127.0.0.1:${port}`, credentialPath };
461
+ }
462
+ const heartbeat = setInterval(() => {
463
+ for (const socket of sockets) {
464
+ if (!socket.alive || !sessions.has(socket.sessionKey)) { socket.terminate(); continue; }
465
+ socket.alive = false; socket.ping();
466
+ }
467
+ }, 30000);
468
+ heartbeat.unref();
469
+ async function close() {
470
+ clearInterval(timer); clearInterval(heartbeat);
471
+ for (const socket of sockets) unwatchDesktop(socket);
472
+ for (const socket of sockets) socket.terminate();
473
+ rpc.off('event', eventListener); rpc.off('lost', lostListener); rpc.close();
474
+ await new Promise(resolve => wss.close(resolve));
475
+ server.closeAllConnections();
476
+ await new Promise(resolve => server.close(resolve));
477
+ store.close();
478
+ }
479
+ return { listen, close, server, store, rpc, credentialPath, pairingKey, status };
480
+ }
481
+
482
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
483
+ const app = createOrbitServer();
484
+ const info = await app.listen();
485
+ console.log(`Orbit ${packageVersion} · ${info.url}`);
486
+ console.log(`Native conversations: ${app.store.list().length} · default read-only`);
487
+ console.log(`Pairing key file: ${info.credentialPath}`);
488
+ console.log(`Execution channel: ${app.rpc.mode || 'cold-resume'} · desktop chats support automatic hot attach.`);
489
+ const shutdown = async () => { await app.close(); process.exit(0); };
490
+ process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown);
491
+ }