orbit-codex-bridge 0.1.3 → 0.1.7

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 CHANGED
@@ -5,13 +5,15 @@ import os from 'node:os';
5
5
  import crypto from 'node:crypto';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { WebSocketServer, WebSocket } from 'ws';
8
- import QRCode from 'qrcode';
9
8
  import { NativeStore } from './lib/store.mjs';
10
9
  import { CodexRpc } from './lib/rpc.mjs';
11
10
  import { DesktopRpc } from './lib/desktop.mjs';
11
+ import { ResumeManager } from './lib/resume-manager.mjs';
12
+ import { createUpdateChecker } from './lib/updates.mjs';
12
13
 
13
14
  const root = path.dirname(fileURLToPath(import.meta.url));
14
- const packageVersion = JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8')).version;
15
+ const packageManifest = JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
16
+ const packageVersion = packageManifest.version;
15
17
  const sessionLifetime = 12 * 60 * 60 * 1000;
16
18
  const attachmentLifetime = 30 * 60 * 1000;
17
19
  const attachmentRetention = 24 * 60 * 60 * 1000;
@@ -46,107 +48,9 @@ function cleanupStaleAttachments(store) {
46
48
  }
47
49
  }
48
50
 
49
- const excludedProjectEntries=new Set([
50
- '.git','.codex','.orbit-attachments','node_modules','.venv','venv','dist','build',
51
- '.ssh','.aws','.azure','.config','.npm','.docker','secrets','credentials'
52
- ]);
53
- const excludedProjectFiles=/^(?:\.env(?:\..*)?|\.npmrc|\.pypirc|\.netrc|\.git-credentials|id_(?:rsa|ed25519)(?:\.pub)?|.*(?:secret|credential)(?:\..*)?|.*\.(?:pem|p12|pfx|key|keystore))$/i;
54
-
55
- function checkedProjectEntry(row,relativePath=''){
56
- if(!row?.cwd||typeof relativePath!=='string'||relativePath.length>1024||relativePath.includes('\0'))throw new Error('项目文件路径无效。');
57
- const root=fs.realpathSync(row.cwd);
58
- if(!fs.statSync(root).isDirectory())throw new Error('会话工作目录不可用。');
59
- const parts=relativePath?relativePath.split(/[\\/]/):[];
60
- if(parts.some(part=>!part||part==='.'||part==='..'))throw new Error('不允许访问项目目录之外的路径。');
61
- let current=root;
62
- for(const part of parts){
63
- if(excludedProjectEntries.has(part.toLowerCase())||excludedProjectFiles.test(part))throw new Error('此系统目录或凭证文件不开放传输。');
64
- current=path.join(current,part);
65
- const stat=fs.lstatSync(current);
66
- if(stat.isSymbolicLink())throw new Error('不支持通过符号链接传输文件。');
67
- }
68
- const resolved=path.resolve(current);
69
- const relative=path.relative(root,resolved);
70
- if(relative==='..'||relative.startsWith(`..${path.sep}`)||path.isAbsolute(relative))throw new Error('不允许访问项目目录之外的路径。');
71
- return {root,path:resolved,stat:fs.statSync(resolved),relative:parts.join(path.sep)};
72
- }
73
-
74
- function projectFileList(row,relativePath=''){
75
- const directory=checkedProjectEntry(row,relativePath);
76
- if(!directory.stat.isDirectory())throw new Error('所选路径不是文件夹。');
77
- const entries=[];
78
- const visit=(current,relative,depth)=>{
79
- if(depth>8||entries.length>=1000)return;
80
- for(const name of fs.readdirSync(current)){
81
- if(excludedProjectEntries.has(name.toLowerCase())||excludedProjectFiles.test(name))continue;
82
- const target=path.join(current,name);
83
- let stat;
84
- try{stat=fs.lstatSync(target)}catch{continue}
85
- if(stat.isSymbolicLink())continue;
86
- const child=relative?`${relative}/${name}`:name;
87
- if(stat.isDirectory())entries.push({name,path:child,type:'directory',size:null,updatedAt:stat.mtimeMs});
88
- else if(stat.isFile())entries.push({name,path:child,type:'file',size:stat.size,updatedAt:stat.mtimeMs});
89
- if(stat.isDirectory())visit(target,child,depth+1);
90
- if(entries.length>=1000)break;
91
- }
92
- };
93
- visit(directory.path,directory.relative,0);
94
- entries.sort((left,right)=>left.type===right.type?left.name.localeCompare(right.name):left.type==='directory'?-1:1);
95
- return {path:directory.relative,entries:entries.slice(0,1000),truncated:entries.length>=1000};
96
- }
97
-
98
- const projectMimeTypes=new Map([
99
- ['.txt','text/plain; charset=utf-8'],['.md','text/markdown; charset=utf-8'],['.json','application/json'],
100
- ['.csv','text/csv; charset=utf-8'],['.html','text/html; charset=utf-8'],['.css','text/css; charset=utf-8'],
101
- ['.js','text/javascript; charset=utf-8'],['.ts','text/plain; charset=utf-8'],['.xml','application/xml'],
102
- ['.pdf','application/pdf'],['.png','image/png'],['.jpg','image/jpeg'],['.jpeg','image/jpeg'],['.gif','image/gif'],
103
- ['.webp','image/webp'],['.svg','image/svg+xml'],['.zip','application/zip']
104
- ]);
105
-
106
- function sendFileRange(request,response,row,relativePath){
107
- const file=checkedProjectEntry(row,relativePath);
108
- if(!file.stat.isFile())throw new Error('所选路径不是文件。');
109
- if(file.stat.size>8*1024*1024)throw Object.assign(new Error('单个文件下载不得超过 8 MiB。'),{status:413});
110
- const filename=path.basename(file.path);
111
- const fallback=filename.replace(/[^\x20-\x7e]|["\\;]/g,'_');
112
- const encoded=encodeURIComponent(filename).replace(/[!'()*]/g,character=>`%${character.charCodeAt(0).toString(16).toUpperCase()}`);
113
- const etag=`"orbit-${file.stat.size}-${Math.trunc(file.stat.mtimeMs)}"`;
114
- const headers={
115
- 'Content-Type':projectMimeTypes.get(path.extname(filename).toLowerCase())||'application/octet-stream',
116
- 'Accept-Ranges':'bytes',
117
- 'ETag':etag,
118
- 'Content-Disposition':`attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`,
119
- 'X-Content-Type-Options':'nosniff'
120
- };
121
- if(request.headers['if-none-match']===etag){response.writeHead(304,headers);return response.end()}
122
- const ifRange=request.headers['if-range'];
123
- const range=ifRange&&ifRange!==etag?null:request.headers.range;
124
- if(!range){
125
- response.writeHead(200,{...headers,'Content-Length':file.stat.size});
126
- if(request.method==='HEAD')return response.end();
127
- return fs.createReadStream(file.path).pipe(response);
128
- }
129
- const match=String(range).match(/^bytes=(\d*)-(\d*)$/);
130
- if(!match||(!match[1]&&!match[2])){response.writeHead(416,{...headers,'Content-Range':`bytes */${file.stat.size}`});return response.end()}
131
- let start,end;
132
- if(!match[1]){
133
- const suffix=Number(match[2]);
134
- if(!Number.isSafeInteger(suffix)||suffix<=0)return response.writeHead(416,{'Content-Range':`bytes */${file.stat.size}`}).end();
135
- start=Math.max(0,file.stat.size-suffix);end=file.stat.size-1;
136
- }else{
137
- start=Number(match[1]);end=match[2]?Number(match[2]):file.stat.size-1;
138
- }
139
- if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||start>=file.stat.size||end<start){
140
- response.writeHead(416,{...headers,'Content-Range':`bytes */${file.stat.size}`});return response.end();
141
- }
142
- end=Math.min(end,file.stat.size-1);
143
- const length=end-start+1;
144
- response.writeHead(206,{...headers,'Content-Length':length,'Content-Range':`bytes ${start}-${end}/${file.stat.size}`});
145
- if(request.method==='HEAD')return response.end();
146
- fs.createReadStream(file.path,{start,end}).pipe(response);
147
- }
148
-
149
51
  export function createOrbitServer(options = {}) {
52
+ const clientOnly=options.clientOnly ?? packageManifest.orbitDistribution==='client';
53
+ const updateChecker=options.updateChecker || createUpdateChecker(packageVersion);
150
54
  const home = options.home || process.env.ORBIT_CODEX_HOME || process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
151
55
  const dataDir = options.dataDir || path.join(root, '.runtime');
152
56
  fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
@@ -156,8 +60,30 @@ export function createOrbitServer(options = {}) {
156
60
  const store = options.store || new NativeStore(home);
157
61
  cleanupStaleAttachments(store);
158
62
  const endpoint = options.endpoint ?? process.env.ORBIT_APP_SERVER_URL ?? '';
159
- const rpc = options.rpc || options.desktopRpc || (endpoint ? new CodexRpc({ home, endpoint, tokenFile: process.env.ORBIT_APP_SERVER_TOKEN_FILE }) : new DesktopRpc());
160
- const executionMode = rpc.mode || (endpoint ? 'endpoint' : 'cold-resume');
63
+ const injectedRpc = options.rpc || options.desktopRpc || null;
64
+ const rpc = injectedRpc || new DesktopRpc();
65
+ const executionMode = rpc.mode || (injectedRpc ? endpoint ? 'endpoint' : 'cold-resume' : 'desktop');
66
+ const resumeManager = options.resumeManager || (executionMode === 'desktop' && (!injectedRpc || options.enableResumeFallback === true)
67
+ ? new ResumeManager({
68
+ home,
69
+ createRpc:options.createResumeRpc || (({ home:resumeHome }) => new CodexRpc({
70
+ home:resumeHome, endpoint, tokenFile:process.env.ORBIT_APP_SERVER_TOKEN_FILE
71
+ }))
72
+ })
73
+ : null);
74
+ function runtimeForThread(id) {
75
+ return resumeManager?.runtime(id) || rpc.runtime?.(id) || null;
76
+ }
77
+ async function attachThread(id) {
78
+ if (executionMode !== 'desktop') return null;
79
+ if (resumeManager?.has(id)) return resumeManager.resume(id);
80
+ try { return await rpc.attach(id); }
81
+ catch (error) {
82
+ const row = store.row(id);
83
+ if (!resumeManager || !row || row.archived || String(row.source).startsWith('{')) throw error;
84
+ return resumeManager.resume(id, { model:row.model || null });
85
+ }
86
+ }
161
87
  const listModels = options.listModels || (async () => {
162
88
  const catalogRpc = new CodexRpc({ home, endpoint, tokenFile: process.env.ORBIT_APP_SERVER_TOKEN_FILE });
163
89
  try {
@@ -192,6 +118,21 @@ export function createOrbitServer(options = {}) {
192
118
  }).finally(() => { modelLoading = null; });
193
119
  return modelLoading;
194
120
  }
121
+ function workspaceCatalog() {
122
+ const workspaces=new Map();
123
+ workspaces.set('personal',{id:'personal',cwd:path.join(dataDir,'workspaces','personal'),projectId:null,label:'个人会话 · 不绑定项目',isDefault:true});
124
+ const add=(directory,label,projectId=null)=>{
125
+ try{
126
+ const resolved=fs.realpathSync(directory);
127
+ if(!fs.statSync(resolved).isDirectory())return;
128
+ const key=`${projectId||''}:${process.platform==='win32'?resolved.toLowerCase():resolved}`;
129
+ if(!workspaces.has(key))workspaces.set(key,{id:key,cwd:resolved,label,projectId,isDefault:false});
130
+ }catch{}
131
+ };
132
+ for(const workspace of store.projectWorkspaces?.()||[])add(workspace.cwd,workspace.label,workspace.projectId);
133
+ for(const thread of store.list())if(thread.cwd)add(thread.cwd,thread.projectName||path.basename(thread.cwd),thread.nativeProjectId||null);
134
+ return [...workspaces.values()].sort((left,right)=>Number(right.isDefault)-Number(left.isDefault)||left.label.localeCompare(right.label));
135
+ }
195
136
  const publicUrl = options.publicUrl || process.env.ORBIT_PUBLIC_URL || '';
196
137
  if (publicUrl && new URL(publicUrl).protocol !== 'https:') throw new Error('ORBIT_PUBLIC_URL 必须使用 HTTPS。');
197
138
  const host = options.host || process.env.ORBIT_HOST || '127.0.0.1';
@@ -206,6 +147,7 @@ export function createOrbitServer(options = {}) {
206
147
  let port = Number(options.port ?? process.env.ORBIT_PORT ?? 4318);
207
148
  let revision = store.revision();
208
149
  let timer;
150
+ let updateTimer;
209
151
  let lastDesktopConnect = 0;
210
152
  let lastAttachmentCleanup=Date.now();
211
153
 
@@ -266,7 +208,8 @@ export function createOrbitServer(options = {}) {
266
208
  if(!row||row.archived||String(row.source).startsWith('{'))return json(response,400,{error:'归档会话或子代理不能接收文件。'});
267
209
  if(executionMode!=='desktop'||!rpc.connected)return json(response,409,{error:'文件传输仅支持已连接的 Codex 桌面原会话。'});
268
210
  if(!controlFor(session,threadId))return json(response,403,{error:'请先连接并接管这条原会话。'});
269
- if(!rpc.runtime(threadId)?.attached)return json(response,409,{error:'请先在 Codex 桌面打开此会话并重新连接。'});
211
+ const runtime=runtimeForThread(threadId);
212
+ if(!runtime?.attached||runtime.mode==='same-id-resume')return json(response,409,{error:'该会话当前通过原 ID 接续运行;文件传输需要桌面热接管。'});
270
213
  for(const [id,attachment]of session.attachments)if(attachment.expires<Date.now())removeAttachment(session,id);
271
214
  if([...session.attachments.values()].filter(item=>item.threadId===threadId&&!item.consumed).length>=maxPendingAttachments)return json(response,429,{error:`这条会话最多可暂存 ${maxPendingAttachments} 个待发送文件。`});
272
215
  const rawName=url.searchParams.get('name')||'';
@@ -306,15 +249,16 @@ export function createOrbitServer(options = {}) {
306
249
 
307
250
  function status(session) {
308
251
  const threads = store.list();
252
+ const resumedIds = resumeManager?.ids() || [];
309
253
  for (const id of session.enabled.keys()) controlFor(session, id);
310
254
  return {
311
- product: 'Orbit', version: packageVersion, device: os.hostname(), platform: process.platform, home,
255
+ product: 'Orbit', version: packageVersion, updates:updateChecker.snapshot(), device: os.hostname(), platform: process.platform, home,
312
256
  threadCount: threads.length, projectCount: new Set(threads.map(thread => thread.projectId)).size,
313
257
  archivedCount: threads.filter(thread => thread.archived).length, lockedCount: threads.filter(thread => thread.locked).length,
314
258
  bridge: 'connected', transport: 'websocket', readOnlyMirror: true,
315
259
  desktopHotAttachReady: executionMode === 'desktop' && rpc.connected,
316
260
  desktopHotAttachVerified: executionMode === 'desktop' && [...rpc.threads.values()].some(thread => thread.state),
317
- execution: { connected: rpc.connected, mode: executionMode, enabled: [...session.enabled.keys()], grants: Object.fromEntries(session.enabled), owned: [...rpc.threads.keys()] },
261
+ execution: { connected: Boolean(rpc.connected || resumedIds.length), desktopConnected:Boolean(rpc.connected), resumedConnected:Boolean(resumedIds.length), mode: executionMode, enabled: [...session.enabled.keys()], grants: Object.fromEntries(session.enabled), owned: [...new Set([...rpc.threads.keys(), ...resumedIds])] },
318
262
  connectionUrls: [publicUrl || `http://127.0.0.1:${port}`, ...(host === '0.0.0.0' ? lanAddresses().map(address => `http://${address}:${port}`) : [])],
319
263
  secureTransport: Boolean(publicUrl), lanEnabled: host === '0.0.0.0'
320
264
  };
@@ -333,6 +277,7 @@ export function createOrbitServer(options = {}) {
333
277
  }
334
278
 
335
279
  async function applyQueuedDesktopSettings(id, runtime) {
280
+ if(executionMode!=='desktop'||resumeManager?.has(id))return;
336
281
  if (!runtime?.attached || runtime.turnId || queuedSettingsInFlight.has(id)) return;
337
282
  queuedSettingsInFlight.add(id);
338
283
  try {
@@ -371,7 +316,8 @@ export function createOrbitServer(options = {}) {
371
316
  if (count > 1) desktopWatchers.set(id, count - 1);
372
317
  else {
373
318
  desktopWatchers.delete(id);
374
- rpc.detach(id, '已取消实时跟随。');
319
+ if (resumeManager?.has(id)) void resumeManager.release(id).catch(() => {});
320
+ else rpc.detach(id, '已取消实时跟随。');
375
321
  }
376
322
  }
377
323
 
@@ -420,7 +366,9 @@ export function createOrbitServer(options = {}) {
420
366
  const session = sessionFor(request);
421
367
  if (!session) return json(response, 401, { error: '请先配对这台电脑。' });
422
368
  if (request.method === 'GET' && url.pathname === '/api/status') return json(response, 200, status(session));
369
+ if (request.method === 'GET' && url.pathname === '/api/updates') return json(response, 200, await updateChecker.check({force:true}));
423
370
  if (request.method === 'GET' && url.pathname === '/api/threads') return json(response, 200, { threads: store.list(), revision });
371
+ if (request.method === 'GET' && url.pathname === '/api/workspaces') return json(response, 200, { workspaces:workspaceCatalog() });
424
372
  if (request.method === 'GET' && url.pathname === '/api/models') return json(response, 200, { models: await modelCatalog() });
425
373
  if (request.method === 'POST' && url.pathname === '/api/logout') {
426
374
  sessions.delete(session.key);
@@ -429,9 +377,11 @@ export function createOrbitServer(options = {}) {
429
377
  return json(response, 200, { ok: true }, { 'Set-Cookie': 'orbit_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0' });
430
378
  }
431
379
  if (request.method === 'POST' && url.pathname === '/api/pair/qr') {
380
+ if(clientOnly)return json(response,404,{error:'电脑连接器不提供网页配对,请在工作台登录账号。'});
432
381
  const body = await readBody(request);
433
382
  const connection = status(session).connectionUrls.find(value => value === body.url);
434
383
  if (!connection) return json(response, 400, { error: '请选择桥接服务提供的连接地址。' });
384
+ const {default:QRCode}=await import('qrcode');
435
385
  return json(response, 200, { image: await QRCode.toDataURL(`${connection}/#pair=${encodeURIComponent(pairingKey)}`, { width: 280, margin: 2 }), warning: '扫码者将能查看本机会话。仅向可信设备展示。' });
436
386
  }
437
387
  const uploadMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/attachments$/);
@@ -445,18 +395,6 @@ export function createOrbitServer(options = {}) {
445
395
  removeAttachment(session,body.attachmentId);
446
396
  return json(response,200,{removed:true});
447
397
  }
448
- const filesMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/files$/);
449
- if(request.method==='GET'&&filesMatch){
450
- const row=store.row(filesMatch[1]);
451
- if(!row)return json(response,404,{error:'会话不存在。'});
452
- return json(response,200,projectFileList(row,url.searchParams.get('path')||''));
453
- }
454
- const fileMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/file$/);
455
- if(request.method==='GET'&&fileMatch){
456
- const row=store.row(fileMatch[1]);
457
- if(!row)return json(response,404,{error:'会话不存在。'});
458
- return sendFileRange(request,response,row,url.searchParams.get('path')||'');
459
- }
460
398
  const match = url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/messages$/);
461
399
  if (request.method === 'GET' && match) {
462
400
  const history = store.historyFor(match[1], url.searchParams.get('before'), url.searchParams.get('limit') || 60);
@@ -464,6 +402,7 @@ export function createOrbitServer(options = {}) {
464
402
  }
465
403
  return json(response, 404, { error: '接口不存在。' });
466
404
  }
405
+ if(clientOnly)return json(response,404,{error:'这是 Orbit 电脑连接器。请在 Orbit 工作台登录并选择这台电脑。'});
467
406
  if (request.method !== 'GET' && request.method !== 'HEAD') return json(response, 405, { error: '不支持的方法。' });
468
407
  let file;
469
408
  let type;
@@ -472,7 +411,7 @@ export function createOrbitServer(options = {}) {
472
411
  file = path.join(root, relative);
473
412
  type = mime;
474
413
  } else {
475
- 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'] };
414
+ const files = { '/': ['index.html','text/html'], '/app.js': ['app.js','text/javascript'], '/style.css': ['style.css','text/css'], '/layout.css': ['layout.css','text/css'], '/goal.css':['goal.css','text/css'], '/icon.svg': ['icon.svg','image/svg+xml'], '/manifest.webmanifest': ['manifest.webmanifest','application/manifest+json'] };
476
415
  if (!files[url.pathname]) return json(response, 404, { error: '页面不存在。' });
477
416
  const [name, mime] = files[url.pathname]; file = path.join(root, 'public', name); type = mime;
478
417
  }
@@ -521,9 +460,34 @@ export function createOrbitServer(options = {}) {
521
460
  async function command(socket, session, message) {
522
461
  const body = message.params || {};
523
462
  const id = body.threadId;
463
+ if (message.method === 'thread.create') {
464
+ if (executionMode !== 'desktop' || !resumeManager) throw new Error('当前执行通道不支持在本机 Codex 会话库创建会话。');
465
+ if (typeof body.text !== 'string' || !body.text.trim() || body.text.length > 20000) throw new Error('新会话的首条消息不能为空,且不得超过 20000 个字符。');
466
+ if (!['default','plan'].includes(body.mode || 'default')) throw new Error('新会话模式无效。');
467
+ const workspace=workspaceCatalog().find(entry=>entry.id===body.workspaceId);
468
+ if(!workspace)throw new Error('项目空间不可用,请刷新后重新选择。');
469
+ if(workspace.isDefault)fs.mkdirSync(workspace.cwd,{recursive:true,mode:0o700});
470
+ let model=null;
471
+ if(body.model!==undefined&&body.model!==null){
472
+ if(typeof body.model!=='string')throw new Error('模型选择无效。');
473
+ model=(await modelCatalog()).find(entry=>entry.model===body.model)?.model||null;
474
+ if(!model)throw new Error('所选模型不可用,请刷新模型列表。');
475
+ }
476
+ const created=await resumeManager.startNew({cwd:workspace.cwd,projectId:workspace.projectId,model,text:body.text.trim(),writable:true,mode:body.mode||'default',onCreated:threadId=>{
477
+ unwatchDesktop(socket);
478
+ session.enabled.set(threadId,{sharedExecution:false,desktop:false,writable:true,expires:Date.now()+1800000});
479
+ if(socket.readyState!==WebSocket.OPEN)return;
480
+ socket.subscription=threadId;
481
+ socket.desktopSubscription=threadId;
482
+ desktopWatchers.set(threadId,(desktopWatchers.get(threadId)||0)+1);
483
+ }});
484
+ if(!(desktopWatchers.get(created.threadId)>0))void resumeManager.release(created.threadId).catch(()=>{});
485
+ if(created.startError)created.startError=safeError(new Error(created.startError));
486
+ return created;
487
+ }
524
488
  if (message.method === 'thread.subscribe') {
525
489
  const row = id ? store.row(id) : null;
526
- if (id && !row) throw new Error('会话不存在。');
490
+ if (id && !row && !resumeManager?.has(id)) throw new Error('会话不存在。');
527
491
  if (socket.desktopSubscription && socket.desktopSubscription !== (id || null)) unwatchDesktop(socket);
528
492
  socket.subscription = id || null;
529
493
  let runtime = null;
@@ -532,8 +496,10 @@ export function createOrbitServer(options = {}) {
532
496
  socket.desktopSubscription = id;
533
497
  desktopWatchers.set(id, (desktopWatchers.get(id) || 0) + 1);
534
498
  }
535
- try { runtime = await rpc.attach(id); }
536
- catch (error) {
499
+ try {
500
+ runtime = await attachThread(id);
501
+ if (!runtime?.attached) throw new Error('原会话没有可用的 Codex 执行器。');
502
+ } catch (error) {
537
503
  unwatchDesktop(socket);
538
504
  lostListener(id,error.message,error.code);
539
505
  runtime = {
@@ -541,14 +507,17 @@ export function createOrbitServer(options = {}) {
541
507
  reason:safeError(error),reasonCode:error.code || 'DESKTOP_NOT_READY'
542
508
  };
543
509
  }
510
+ if (resumeManager?.has(id) && (desktopWatchers.get(id) || 0) === 0) void resumeManager.release(id).catch(() => {});
544
511
  }
545
512
  if (executionMode === 'desktop' && runtime?.attached && body.autoControl === true
546
- && !row.archived && !String(row.source).startsWith('{')) {
547
- session.enabled.set(id, {sharedExecution:true,desktop:true,writable:false,expires:Date.now() + 1800000});
513
+ && !row?.archived && !String(row?.source||'').startsWith('{')) {
514
+ const desktop = runtime.mode !== 'same-id-resume';
515
+ session.enabled.set(id, {sharedExecution:desktop,desktop,writable:!desktop&&runtime.permissionMode!=='read-only',expires:Date.now() + 1800000});
548
516
  }
549
517
  const control = controlFor(session, id);
550
- 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 }))) : [];
551
- return { subscribed: id || null, control, runtime, settings: control ? session.preferences.get(id) || null : null, turnId: runtime?.turnId || rpc.threads.get(id)?.turnId || null, approvals, attachments:[...session.attachments.values()].filter(item=>item.threadId===id&&!item.consumed&&item.expires>Date.now()).map(({id,label,size,mimeType})=>({id,label,size,mimeType})) };
518
+ const activeRpc = resumeManager?.rpcFor(id) || rpc;
519
+ const approvals = control ? (executionMode === 'desktop' ? runtime?.approvals || [] : [...activeRpc.approvals.entries()].filter(([,request]) => request.params.threadId === id).map(([requestId,request]) => ({ ...request.params, requestId, method: request.method }))) : [];
520
+ return { subscribed: id || null, control, runtime, settings: control ? session.preferences.get(id) || null : null, turnId: runtime?.turnId || activeRpc.threads.get(id)?.turnId || null, approvals, attachments:[...session.attachments.values()].filter(item=>item.threadId===id&&!item.consumed&&item.expires>Date.now()).map(({id,label,size,mimeType})=>({id,label,size,mimeType})) };
552
521
  }
553
522
  if (message.method === 'thread.enable') {
554
523
  const row = store.row(id);
@@ -557,12 +526,13 @@ export function createOrbitServer(options = {}) {
557
526
  if (body.confirm !== true) throw new Error('必须明确确认接续方式。');
558
527
  if (executionMode === 'desktop') {
559
528
  let runtime;
560
- try { runtime = await rpc.attach(id); }
529
+ try { runtime = await attachThread(id); }
561
530
  catch (error) { lostListener(id,error.message,error.code); throw error; }
562
531
  if (!runtime.attached) throw new Error('桌面实时快照未就绪,未授权控制。');
563
- const control = { sharedExecution: true, desktop: true, writable: false, expires: Date.now() + 1800000 };
532
+ const desktop = runtime.mode !== 'same-id-resume';
533
+ const control = { sharedExecution:desktop, desktop, writable:!desktop&&runtime.permissionMode!=='read-only', expires: Date.now() + 1800000 };
564
534
  session.enabled.set(id, control);
565
- return { threadId: id, ...control, mode: 'desktop', turnId: runtime.turnId };
535
+ return { threadId: id, ...control, mode:runtime.mode || 'desktop', turnId: runtime.turnId };
566
536
  }
567
537
  let sharedExecution = false;
568
538
  if (endpoint) {
@@ -575,11 +545,52 @@ export function createOrbitServer(options = {}) {
575
545
  session.enabled.set(id, control);
576
546
  return { threadId: id, ...control, mode: endpoint ? 'endpoint' : 'cold-resume' };
577
547
  }
548
+ if (message.method === 'thread.goal.get') {
549
+ if (!store.row(id)) throw new Error('会话不存在。');
550
+ if (!controlFor(session, id)) throw new Error('请先连接并接管这条会话。');
551
+ const runtime = runtimeForThread(id);
552
+ if (runtime?.mode === 'same-id-resume') return { goal:runtime.goal || null };
553
+ if (executionMode === 'desktop' && runtime?.attached) return { goal:runtime.goal || null };
554
+ if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/get', { threadId:id });
555
+ return { goal:null };
556
+ }
557
+ if (message.method === 'thread.goal.set') {
558
+ const row = store.row(id);
559
+ if (!row || row.archived || String(row.source).startsWith('{')) throw new Error('归档会话或子代理不能设置持续目标。');
560
+ if (!controlFor(session, id)) throw new Error('请先连接并接管这条会话。');
561
+ if (typeof body.objective !== 'string' || !body.objective.trim() || body.objective.length > 5000) throw new Error('目标内容不能为空,且不得超过 5000 个字符。');
562
+ const tokenBudget = body.tokenBudget === undefined || body.tokenBudget === null ? null : body.tokenBudget;
563
+ if (tokenBudget !== null && (!Number.isSafeInteger(tokenBudget) || tokenBudget < 1 || tokenBudget > 10000000)) throw new Error('目标 token 预算必须在 1 至 10000000 之间。');
564
+ const runtime = runtimeForThread(id);
565
+ if (runtime?.mode === 'same-id-resume') return { goal:await resumeManager.setGoal(id, { objective:body.objective.trim(), tokenBudget }) };
566
+ if (executionMode === 'desktop' && runtime?.attached) throw new Error('这条会话由 Codex 桌面持有,目标修改需要桌面 owner 支持。草稿可以继续编辑,当前未保存。');
567
+ if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/set', {
568
+ threadId:id, origin:'user', objective:body.objective.trim(), status:'active', tokenBudget
569
+ });
570
+ throw new Error('当前执行通道不支持原生 Goal 写入。');
571
+ }
572
+ if (message.method === 'thread.goal.status') {
573
+ if (!controlFor(session, id) || !store.row(id)) throw new Error('请先连接并接管这条会话。');
574
+ if (!['active','paused','blocked','complete'].includes(body.status)) throw new Error('无效的目标状态。');
575
+ const runtime = runtimeForThread(id);
576
+ if (runtime?.mode === 'same-id-resume') return { goal:await resumeManager.setGoalStatus(id, body.status) };
577
+ if (executionMode === 'desktop' && runtime?.attached) throw new Error('这条会话由 Codex 桌面持有,目标状态修改需要桌面 owner 支持。');
578
+ if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/set', { threadId:id, origin:'user', status:body.status });
579
+ throw new Error('当前执行通道不支持原生 Goal 状态更新。');
580
+ }
581
+ if (message.method === 'thread.goal.clear') {
582
+ if (!controlFor(session, id) || !store.row(id)) throw new Error('请先连接并接管这条会话。');
583
+ const runtime = runtimeForThread(id);
584
+ if (runtime?.mode === 'same-id-resume') return resumeManager.clearGoal(id);
585
+ if (executionMode === 'desktop' && runtime?.attached) throw new Error('这条会话由 Codex 桌面持有,目标清除需要桌面 owner 支持。');
586
+ if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/clear', { threadId:id, origin:'user' });
587
+ throw new Error('当前执行通道不支持原生 Goal 清除。');
588
+ }
578
589
  if (message.method === 'turn.start') {
579
590
  const approval = controlFor(session, id);
580
591
  if (!approval) throw new Error('请先确认这条会话的接续方式。');
581
592
  const mode=body.mode??'default';
582
- if(!['default','plan','goal'].includes(mode))throw new Error('对话模式无效。');
593
+ if(!['default','plan'].includes(mode))throw new Error('目标已改为会话级原生 Goal,请使用“目标”管理;不再用提示词模拟。');
583
594
  const attachmentIds=body.attachmentIds===undefined?[]:body.attachmentIds;
584
595
  if(!Array.isArray(attachmentIds)||attachmentIds.length>maxPendingAttachments||new Set(attachmentIds).size!==attachmentIds.length
585
596
  ||attachmentIds.some(attachmentId=>typeof attachmentId!=='string'))throw new Error('文件附件列表无效。');
@@ -590,16 +601,23 @@ export function createOrbitServer(options = {}) {
590
601
  return attachment;
591
602
  });
592
603
  let text=body.text.trim()||(attachments.length?`请查看并处理以下附件:${attachments.map(file=>file.label).join('、')}`:'');
593
- if(mode==='goal')text=`请将本轮请求作为需要完成的目标,主动推进直到完成,并汇报结果;如果受阻,请明确说明原因。\n\n本轮目标:\n${text}`;
594
- if(body.mode==='plan')text=`请先分析任务并制定清晰的分步计划,说明后等待我确认;在我确认前不要执行任何修改。\n\n任务:\n${text}`;
595
604
  if (executionMode === 'desktop') {
605
+ const runtime=runtimeForThread(id);
606
+ if(runtime?.mode==='same-id-resume'){
607
+ if(attachments.length)throw new Error('文件传输目前需要桌面热接管;请在电脑上打开这条原会话后重新连接。');
608
+ const resumedRpc=resumeManager?.rpcFor(id);
609
+ if(!resumedRpc)throw new Error('原会话接续连接已失效,请重新打开这条会话。');
610
+ const settings=session.preferences.get(id);
611
+ return resumedRpc.startTurn(id,text,approval.writable,settings?.pending?settings:settings||null,{mode});
612
+ }
596
613
  const result=await rpc.startTurn(id,text,{mode,attachments});
597
614
  for(const attachment of attachments){attachment.consumed=true;attachment.expires=Date.now()+attachmentRetention;consumedAttachments.set(attachment.id,attachment);}
598
615
  return result;
599
616
  }
600
- if(mode!=='default'||attachments.length)throw new Error('计划/目标模式和文件附件仅支持 Codex 桌面热接管。');
617
+ if(attachments.length)throw new Error('文件附件仅支持 Codex 桌面热接管。');
601
618
  if (!rpc.threads.has(id) && !endpoint && store.locked(id)) throw new Error('会话已被其他进程占用,未发送消息。');
602
- return rpc.startTurn(id,text,approval.writable,session.preferences.get(id)||null);
619
+ if(mode==='plan'&&typeof rpc.startTurn!=='function')throw new Error('当前执行通道不支持原生协作模式。');
620
+ return rpc.startTurn(id,text,approval.writable,session.preferences.get(id)||null,{mode});
603
621
  }
604
622
  if (message.method === 'thread.settings') {
605
623
  if (!controlFor(session, id) || !store.row(id)) throw new Error('没有此会话的控制授权。');
@@ -608,8 +626,16 @@ export function createOrbitServer(options = {}) {
608
626
  if (!model || (body.effort !== null && !model.supportedReasoningEfforts.includes(body.effort))) throw new Error('所选模型或推理级别不可用,请重新加载目录。');
609
627
  if (executionMode === 'desktop') {
610
628
  if (typeof body.expectedModel !== 'string' || !Object.hasOwn(body, 'expectedEffort')) throw new Error('缺少当前模型版本,请刷新会话。');
611
- const runtime = rpc.runtime(id);
629
+ const runtime = runtimeForThread(id);
612
630
  if (!runtime.attached) throw new Error('桌面实时快照未就绪,请重新连接会话。');
631
+ if(runtime.mode==='same-id-resume'){
632
+ if(runtime.turnId)throw new Error('当前任务正在执行,请等待完成后再切换模型参数。');
633
+ const settings={model:body.model,effort:body.effort};
634
+ session.preferences.set(id,settings);
635
+ const resumedSession=resumeManager.sessions.get(id);
636
+ if(resumedSession)resumedSession.model=body.model;
637
+ return {...settings,applied:true,mode:'next-turn'};
638
+ }
613
639
  if (runtime.model !== body.expectedModel || runtime.effort !== body.expectedEffort) throw new Error('模型设置已变化,请刷新后重试。');
614
640
  if (runtime.turnId) {
615
641
  session.preferences.set(id,{
@@ -630,6 +656,7 @@ export function createOrbitServer(options = {}) {
630
656
  if (message.method === 'turn.steer') {
631
657
  if (!controlFor(session, id)) throw new Error('没有此会话的控制授权。');
632
658
  if (executionMode !== 'desktop') throw new Error('当前执行通道不支持向桌面任务追加指令。');
659
+ if(runtimeForThread(id)?.mode==='same-id-resume')throw new Error('当前原会话通过 App Server 接续;请等待本轮完成后再发送下一条消息。');
633
660
  const attachmentIds=body.attachmentIds===undefined?[]:body.attachmentIds;
634
661
  if(!Array.isArray(attachmentIds)||attachmentIds.length>maxPendingAttachments||new Set(attachmentIds).size!==attachmentIds.length
635
662
  ||attachmentIds.some(attachmentId=>typeof attachmentId!=='string'))throw new Error('文件附件列表无效。');
@@ -647,19 +674,26 @@ export function createOrbitServer(options = {}) {
647
674
  }
648
675
  if (message.method === 'turn.interrupt') {
649
676
  if (!controlFor(session, id)) throw new Error('没有此会话的控制授权。');
650
- if (executionMode === 'desktop') return rpc.interrupt(id, body.turnId);
677
+ if (executionMode === 'desktop') {
678
+ if(resumeManager?.has(id))return resumeManager.rpcFor(id).interrupt(id);
679
+ return rpc.interrupt(id, body.turnId);
680
+ }
651
681
  return rpc.interrupt(id);
652
682
  }
653
683
  if (message.method === 'approval.reply') {
654
- const approval = rpc.approvals.get(String(body.requestId));
684
+ const approvalRpc=resumeManager?.rpcForApproval(body.requestId)||rpc;
685
+ const approval = approvalRpc.approvals.get(String(body.requestId));
655
686
  if (!approval || !controlFor(session, approval.params.threadId)) throw new Error('没有此审批的控制授权。');
656
- return rpc.replyApproval(body.requestId, body);
687
+ return approvalRpc.replyApproval(body.requestId, body);
657
688
  }
658
689
  throw new Error('不支持的操作。');
659
690
  }
660
691
 
661
692
  const eventListener = event => {
662
- if (!/^(item\/|turn\/|thread\/status|orbit\/|serverRequest\/resolved|error$)/.test(event.method)) return;
693
+ if (!/^(item\/|turn\/|thread\/(status|goal\/)|orbit\/|serverRequest\/resolved|error$)/.test(event.method)) return;
694
+ if(event.method==='orbit/engine'&&event.params?.connected===false&&resumeManager?.ids().length){
695
+ event={...event,params:{...event.params,connected:true,desktopConnected:false,resumedConnected:true}};
696
+ }
663
697
  if(event.method==='turn/completed'&&event.params?.threadId){
664
698
  for(const session of sessions.values())for(const [id,attachment]of session.attachments){
665
699
  if(attachment.threadId===event.params.threadId&&attachment.consumed)removeAttachment(session,id);
@@ -688,9 +722,16 @@ export function createOrbitServer(options = {}) {
688
722
  };
689
723
  rpc.on('event', eventListener);
690
724
  rpc.on('lost', lostListener);
725
+ resumeManager?.on('event', eventListener);
726
+ resumeManager?.on('lost', lostListener);
691
727
  async function listen() {
692
728
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); });
693
729
  port = server.address().port;
730
+ if(clientOnly){
731
+ void updateChecker.check();
732
+ updateTimer=setInterval(()=>{void updateChecker.check();},6*60*60*1000);
733
+ updateTimer.unref();
734
+ }
694
735
  timer = setInterval(() => {
695
736
  try {
696
737
  tryDesktopConnect();
@@ -720,11 +761,14 @@ export function createOrbitServer(options = {}) {
720
761
  }, 30000);
721
762
  heartbeat.unref();
722
763
  async function close() {
723
- clearInterval(timer); clearInterval(heartbeat);
764
+ clearInterval(timer); clearInterval(heartbeat); clearInterval(updateTimer);
724
765
  for (const socket of sockets) unwatchDesktop(socket);
725
766
  for(const session of sessions.values())removeSessionAttachments(session);
726
767
  for (const socket of sockets) socket.terminate();
727
768
  rpc.off('event', eventListener); rpc.off('lost', lostListener); rpc.close();
769
+ resumeManager?.off('event', eventListener);
770
+ resumeManager?.off('lost', lostListener);
771
+ if(resumeManager)await resumeManager.close();
728
772
  await new Promise(resolve => wss.close(resolve));
729
773
  server.closeAllConnections();
730
774
  await new Promise(resolve => server.close(resolve));
@@ -1,69 +0,0 @@
1
- import crypto from 'node:crypto';
2
-
3
- const digest = value => crypto.createHash('sha256').update(value).digest('hex');
4
-
5
- export class DeviceAuthorization {
6
- constructor({ lifetime = 5 * 60 * 1000, now = Date.now } = {}) {
7
- this.lifetime = lifetime;
8
- this.now = now;
9
- this.pending = new Map();
10
- this.codes = new Map();
11
- }
12
-
13
- cleanup() {
14
- for (const [key, value] of this.pending) {
15
- if (value.expiresAt > this.now()) continue;
16
- this.pending.delete(key);
17
- this.codes.delete(value.userCode);
18
- }
19
- }
20
-
21
- start(name) {
22
- this.cleanup();
23
- if (typeof name !== 'string' || !name.trim() || name.length > 120 || /[\u0000-\u001f\u007f]/.test(name)) {
24
- throw new Error('电脑名称格式无效。');
25
- }
26
- if (this.pending.size >= 1000) throw Object.assign(new Error('连接请求过多,请稍后再试。'), {status:429});
27
- const deviceCode = crypto.randomBytes(32).toString('base64url');
28
- let userCode;
29
- do { userCode = crypto.randomBytes(5).toString('hex').toUpperCase(); } while (this.codes.has(userCode));
30
- const key = digest(deviceCode);
31
- const entry = {name:name.trim(), userCode, expiresAt:this.now() + this.lifetime, result:null};
32
- this.pending.set(key, entry);
33
- this.codes.set(userCode, key);
34
- return {deviceCode, userCode, expiresIn:Math.floor(this.lifetime / 1000), interval:2};
35
- }
36
-
37
- preview(userCode) {
38
- this.cleanup();
39
- if (typeof userCode !== 'string') return null;
40
- const entry = this.pending.get(this.codes.get(userCode));
41
- return entry && !entry.result ? {name:entry.name, userCode:entry.userCode, expiresAt:entry.expiresAt} : null;
42
- }
43
-
44
- approve(userCode, issue) {
45
- if (!this.preview(userCode)) throw Object.assign(new Error('连接请求已失效,请在电脑重新运行 orbit login。'), {status:410});
46
- const entry = this.pending.get(this.codes.get(userCode));
47
- entry.result = issue(entry.name);
48
- }
49
-
50
- poll(deviceCode) {
51
- this.cleanup();
52
- if (typeof deviceCode !== 'string' || !/^[a-zA-Z0-9_-]{43}$/.test(deviceCode)) return {status:410};
53
- const key = digest(deviceCode);
54
- const entry = this.pending.get(key);
55
- if (!entry) return {status:410};
56
- if (!entry.result) return {status:202};
57
- return {status:200, result:structuredClone(entry.result)};
58
- }
59
-
60
- acknowledge(deviceCode) {
61
- if (typeof deviceCode !== 'string' || !/^[a-zA-Z0-9_-]{43}$/.test(deviceCode)) return false;
62
- const key = digest(deviceCode);
63
- const entry = this.pending.get(key);
64
- if (!entry?.result) return false;
65
- this.pending.delete(key);
66
- this.codes.delete(entry.userCode);
67
- return true;
68
- }
69
- }