orbit-codex-bridge 0.1.3 → 0.1.6

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/README.md CHANGED
@@ -30,15 +30,15 @@ orbit login
30
30
  1. 命令会打开电脑授权页面,登录或注册 Orbit 账号,确认添加这台电脑。
31
31
  2. 手机打开 [orbit.cubesugar.tech](https://orbit.cubesugar.tech),登录同一账号。
32
32
  3. 登录后选择这台电脑;账号会自动完成安全配对,无需扫码或输入本机密钥。
33
- 4. 选择原有会话,开始使用。尚未加载的会话可先在 Codex 桌面打开,再点“重新连接”。
33
+ 4. 选择任意普通原有会话即可连接。Orbit 优先接入 Codex 桌面当前执行者;桌面未加载时尝试由 Codex App Server 按原会话 ID 接续,不会新建对话。若该会话仍被其他执行者占用,会明确提示并拒绝重复写入。
34
34
 
35
- 授权信息保存在电脑本地,下次启动会自动复用。连接器默认在当前用户的后台运行,可以关闭安装终端;断网后自动重连,工作进程异常退出后自动恢复。电脑需要保持开机、联网,Codex 桌面需要保持运行。电脑重启后运行 `orbit start` 恢复连接。
35
+ 授权信息保存在电脑本地,下次启动会自动复用。连接器默认在当前用户的后台运行,可以关闭安装终端;断网后自动重连,工作进程异常退出后自动恢复。电脑需要保持开机、联网。桌面热接管需 Codex 桌面运行;若会话未由桌面持有,则尝试通过 Codex App Server 按原 ID 接续。电脑重启后运行 `orbit start` 恢复连接。
36
36
 
37
37
  ## 会话输入
38
38
 
39
39
  - `普通`:按原会话的 Codex 模型、权限和设置继续。
40
- - `计划`:使用 Codex 原生 Plan 协作模式开始新一轮;不会改动正在运行的轮次。
41
- - `目标(本轮)`:将本轮消息作为明确目标继续推进。本版桌面 IPC 尚未开放持久 Goal 状态 API,因此不会伪装成 Codex 侧的长期目标追踪。
40
+ - `计划`:通过 Codex App Server `turn/start.collaborationMode` 或桌面 IPC 使用原生 Plan 协作模式;不会要求切回电脑,也不会改动正在运行的轮次。
41
+ - `目标`:通过 Codex 原生 `thread/goal/*` API 为原会话设置持久 Goal,支持预算、暂停、继续和清除;桌面 owner 模式可查看目标状态,但当前 IPC 不公开 Goal 写操作,因此需在 Codex 桌面管理。
42
42
  - `添加文件`:每次最多 5 个、每个最多 8 MiB。文件经账号设备中继传到本机,并暂存在项目内 `.orbit-attachments`;Codex 读取原路径。待发送文件 30 分钟清理,已发送文件保留最多 24 小时,任务完成后会提前删除。
43
43
  - `电脑文件`:浏览当前会话项目并下载文件;限制在项目根目录内,排除 `.git`、`.codex`、密钥/环境文件和符号链接,单文件最大 8 MiB。
44
44
  - 文件附件仅支持已登录账号、在线的自有电脑,以及已加载并已自动热接管的 Codex 桌面原会话。归档会话、子代理和独立续接不支持。
package/lib/desktop.mjs CHANGED
@@ -233,7 +233,16 @@ export class DesktopRpc extends EventEmitter {
233
233
  }
234
234
  if (this.attaching.has(id)) return this.attaching.get(id);
235
235
  const operation = (async () => {
236
- const owner = await this.request('thread-owner-discovery', { conversationId: id, hostId: 'local' });
236
+ let owner=null;
237
+ for(let attempt=0;attempt<2;attempt++){
238
+ try{
239
+ owner=await this.request('thread-owner-discovery',{conversationId:id,hostId:'local'});
240
+ }catch(error){
241
+ if(error.code!=='DESKTOP_THREAD_UNAVAILABLE'||attempt===1)throw error;
242
+ }
243
+ if(owner?.ownerClientId)break;
244
+ if(attempt===0)await new Promise(resolve=>setTimeout(resolve,180));
245
+ }
237
246
  if (!owner.ownerClientId) throw new DesktopUnavailableError('这条原会话尚未在 Codex 桌面加载。请在电脑上打开它,再点击“重新连接”。');
238
247
  const result = await this.request('thread-follower-read-model-settings', { conversationId: id }, owner.ownerClientId);
239
248
  if (result.settings?.resumeState !== 'resumed') throw new Error('桌面会话尚未就绪,请先在 Codex 中打开。');
@@ -0,0 +1,177 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { CodexRpc } from './rpc.mjs';
3
+
4
+ export class ResumeManager extends EventEmitter {
5
+ constructor({ home, createRpc = () => new CodexRpc({ home }) } = {}) {
6
+ super();
7
+ this.home = home;
8
+ this.createRpc = createRpc;
9
+ this.sessions = new Map();
10
+ this.pending = new Map();
11
+ }
12
+
13
+ has(id) {
14
+ return this.sessions.has(id);
15
+ }
16
+
17
+ ids() {
18
+ return [...this.sessions.keys()];
19
+ }
20
+
21
+ rpcFor(id) {
22
+ return this.sessions.get(id)?.rpc || null;
23
+ }
24
+
25
+ rpcForApproval(requestId) {
26
+ for (const { rpc } of this.sessions.values()) if (rpc.approvals.has(String(requestId))) return rpc;
27
+ return null;
28
+ }
29
+
30
+ runtime(id) {
31
+ const session = this.sessions.get(id);
32
+ const thread = session?.rpc.threads.get(id);
33
+ if (!session || !thread) return null;
34
+ return {
35
+ threadId:id, attached:Boolean(session.rpc.connected), mode:'same-id-resume',
36
+ ownerClientId:null, connectionId:null, turnId:thread.turnId || null, revision:null,
37
+ permissionMode:thread.writable ? 'workspace-write' : 'read-only',
38
+ model:session.model || null, effort:null, collaborationMode:'default',
39
+ goal:session.goal || null, context:null, messages:[],
40
+ approvals:[...session.rpc.approvals.values()]
41
+ .filter(request => request.params?.threadId === id)
42
+ .map(request => ({ ...request.params, requestId:String(request.id), method:request.method }))
43
+ };
44
+ }
45
+
46
+ async resume(id, { model = null } = {}) {
47
+ const current = this.sessions.get(id);
48
+ if (current) {
49
+ if (!current.closing) {
50
+ current.releaseWhenIdle = false;
51
+ return this.runtime(id);
52
+ }
53
+ await current.closing;
54
+ }
55
+ const pending = this.pending.get(id);
56
+ if (pending) return pending;
57
+
58
+ const operation = (async () => {
59
+ const rpc = this.createRpc({ home:this.home, threadId:id });
60
+ try {
61
+ await rpc.resume(id, true);
62
+ const goalResult = typeof rpc.request === 'function'
63
+ ? await rpc.request('thread/goal/get', { threadId:id }).catch(() => ({ goal:null }))
64
+ : { goal:null };
65
+ const session = { id, rpc, model, goal:goalResult.goal || null, releaseWhenIdle:false, closing:null, eventListener:null };
66
+ session.eventListener = event => this.onEvent(session, event);
67
+ rpc.on('event', session.eventListener);
68
+ this.sessions.set(id, session);
69
+ this.emit('event', { method:'orbit/engine', params:{
70
+ connected:true, mode:'desktop', desktopConnected:false, resumedConnected:true
71
+ } });
72
+ return this.runtime(id);
73
+ } catch (error) {
74
+ await rpc.close();
75
+ if (/already has an active writer/i.test(error.message)) {
76
+ throw Object.assign(new Error('这条原会话仍由 Codex 桌面或其他执行器占用;未启动第二个执行器。请稍后重试。'), { code:'DESKTOP_THREAD_BUSY' });
77
+ }
78
+ throw error;
79
+ }
80
+ })();
81
+ this.pending.set(id, operation);
82
+ try { return await operation; }
83
+ finally { if (this.pending.get(id) === operation) this.pending.delete(id); }
84
+ }
85
+
86
+ onEvent(session, event) {
87
+ if (event.method === 'thread/goal/updated' && event.params?.threadId === session.id) {
88
+ session.goal = event.params.goal || null;
89
+ this.emit('event', event);
90
+ return;
91
+ }
92
+ if (event.method === 'thread/goal/cleared' && event.params?.threadId === session.id) {
93
+ session.goal = null;
94
+ this.emit('event', event);
95
+ return;
96
+ }
97
+ if (event.method === 'orbit/engine') {
98
+ if (!event.params?.connected && !session.closing) {
99
+ this.sessions.delete(session.id);
100
+ this.emit('released', session.id);
101
+ this.emit('lost', session.id, '本机 Codex App Server 已断开;请重新连接这条原会话。', 'APP_SERVER_DISCONNECTED');
102
+ this.emit('event', { method:'orbit/desktop', params:{
103
+ threadId:session.id, mode:'same-id-resume', attached:false, turnId:null,
104
+ reason:'本机 Codex App Server 已断开;请重新连接这条原会话。',
105
+ reasonCode:'APP_SERVER_DISCONNECTED'
106
+ } });
107
+ this.emit('event', { method:'orbit/engine', params:{
108
+ connected:this.sessions.size>0, mode:'desktop', desktopConnected:false,
109
+ resumedConnected:this.sessions.size>0
110
+ } });
111
+ }
112
+ return;
113
+ }
114
+ this.emit('event', event);
115
+ if (event.method === 'turn/completed' && event.params?.threadId === session.id
116
+ && session.releaseWhenIdle && !session.rpc.threads.get(session.id)?.turnId) {
117
+ void this.closeSession(session);
118
+ }
119
+ }
120
+
121
+ async setGoal(id, { objective, tokenBudget = null }) {
122
+ const session = this.sessions.get(id);
123
+ if (!session || session.closing) throw new Error('原会话 App Server 接续已失效,请重新连接。');
124
+ const result = await session.rpc.request('thread/goal/set', {
125
+ threadId:id, origin:'user', objective, status:'active', tokenBudget
126
+ });
127
+ session.goal = result.goal;
128
+ return result.goal;
129
+ }
130
+
131
+ async setGoalStatus(id, status) {
132
+ const session = this.sessions.get(id);
133
+ if (!session || session.closing) throw new Error('原会话 App Server 接续已失效,请重新连接。');
134
+ const result = await session.rpc.request('thread/goal/set', { threadId:id, origin:'user', status });
135
+ session.goal = result.goal;
136
+ return result.goal;
137
+ }
138
+
139
+ async clearGoal(id) {
140
+ const session = this.sessions.get(id);
141
+ if (!session || session.closing) throw new Error('原会话 App Server 接续已失效,请重新连接。');
142
+ const result = await session.rpc.request('thread/goal/clear', { threadId:id, origin:'user' });
143
+ if (result.cleared) session.goal = null;
144
+ return result;
145
+ }
146
+
147
+ async release(id) {
148
+ const session = this.sessions.get(id);
149
+ if (!session) return;
150
+ if (session.rpc.threads.get(id)?.turnId) {
151
+ session.releaseWhenIdle = true;
152
+ return;
153
+ }
154
+ await this.closeSession(session);
155
+ }
156
+
157
+ async closeSession(session) {
158
+ if (session.closing) return session.closing;
159
+ session.closing = (async () => {
160
+ await session.rpc.close();
161
+ session.rpc.off('event', session.eventListener);
162
+ if (this.sessions.get(session.id) === session) this.sessions.delete(session.id);
163
+ this.emit('released', session.id);
164
+ this.emit('event', { method:'orbit/engine', params:{
165
+ connected:this.sessions.size>0, mode:'desktop', desktopConnected:false,
166
+ resumedConnected:this.sessions.size>0
167
+ } });
168
+ })();
169
+ return session.closing;
170
+ }
171
+
172
+ async close() {
173
+ const sessions = [...this.sessions.values()];
174
+ await Promise.all(sessions.map(session => this.closeSession(session)));
175
+ this.sessions.clear();
176
+ }
177
+ }
package/lib/rpc.mjs CHANGED
@@ -130,23 +130,44 @@ export class CodexRpc extends EventEmitter {
130
130
  if (this.threads.has(id)) return;
131
131
  const result = await this.request('thread/resume', { threadId: id, excludeTurns: true, sandbox: writable ? 'workspace-write' : 'read-only', approvalPolicy: 'on-request' });
132
132
  if (result.thread.id !== id) throw new Error('返回的会话 ID 不一致,已拒绝发送消息。');
133
- this.threads.set(id, { turnId: null, lastCompletedTurnId: null });
133
+ this.threads.set(id, {
134
+ turnId:null,lastCompletedTurnId:null,writable:Boolean(writable),
135
+ model:result.model||result.thread.model||null,
136
+ effort:result.reasoningEffort??result.thread.reasoningEffort??null,
137
+ collaborationMode:result.collaborationMode||{mode:'default',settings:{}}
138
+ });
134
139
  }
135
140
 
136
- async startTurn(id, text, writable, settings = null) {
141
+ async startTurn(id, text, writable, settings = null, options = {}) {
137
142
  if (this.starting.has(id)) throw new Error('此会话正在发送,请勿并发提交。');
138
143
  this.starting.add(id);
139
144
  try {
140
145
  await this.resume(id, writable);
141
146
  const thread = this.threads.get(id);
142
147
  if (thread.turnId) throw new Error('此会话仍在执行,请等待完成或停止后再发送。');
148
+ const baseMode=thread.collaborationMode||{mode:'default',settings:{}};
149
+ const collaborationMode={
150
+ mode:options.mode==='plan'?'plan':baseMode.mode==='plan'?'default':baseMode.mode||'default',
151
+ settings:{
152
+ ...baseMode.settings,
153
+ model:settings?.model||baseMode.settings?.model||thread.model||'',
154
+ reasoning_effort:settings?.effort??baseMode.settings?.reasoning_effort??thread.effort??null,
155
+ developer_instructions:options.mode==='plan'||baseMode.mode==='plan'
156
+ ? null
157
+ : baseMode.settings?.developer_instructions??null
158
+ }
159
+ };
143
160
  const result = await this.request('turn/start', {
144
161
  threadId: id, input: [{ type: 'text', text }], approvalPolicy: 'on-request',
145
162
  sandboxPolicy: writable ? { type: 'workspaceWrite', networkAccess: false, excludeTmpdirEnvVar: true, excludeSlashTmp: true } : { type: 'readOnly' },
163
+ collaborationMode,
146
164
  ...(settings ? { model: settings.model, effort: settings.effort } : {})
147
165
  });
148
166
  const completed = thread.lastCompletedTurnId === result.turn.id;
149
167
  thread.turnId = completed ? null : result.turn.id;
168
+ thread.collaborationMode=collaborationMode;
169
+ if(settings?.model)thread.model=settings.model;
170
+ if(settings&&Object.hasOwn(settings,'effort'))thread.effort=settings.effort;
150
171
  return { threadId: id, turnId: result.turn.id, completed };
151
172
  } finally { this.starting.delete(id); }
152
173
  }
@@ -198,6 +219,11 @@ export class CodexRpc extends EventEmitter {
198
219
  }
199
220
 
200
221
  close() {
222
+ const child = this.child;
223
+ const exited = !child || child.exitCode !== null || child.signalCode !== null
224
+ ? Promise.resolve()
225
+ : new Promise(resolve => child.once('exit', resolve));
201
226
  this.disconnect(new Error('服务已停止。'));
227
+ return exited;
202
228
  }
203
229
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orbit-codex-bridge",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Connect your original Codex desktop conversations to Orbit on the web.",
6
6
  "engines": {
@@ -27,7 +27,7 @@
27
27
  "test": "node --test test/*.test.mjs",
28
28
  "test:browser": "node --test test/browser/*.test.mjs",
29
29
  "test:native": "node test/native-smoke.mjs",
30
- "check": "node --check cli.mjs && node --check server.mjs && node --check lib/store.mjs && node --check lib/rpc.mjs && node --check lib/desktop.mjs && node --check lib/device-auth.mjs && node --check lib/connector-service.mjs && node --check public/app.js && node --check public/connect.js && node --check relay-server.mjs && node --check relay-store.mjs && node --check relay-agent.mjs && node --check public/admin.js"
30
+ "check": "node --check cli.mjs && node --check server.mjs && node --check lib/store.mjs && node --check lib/rpc.mjs && node --check lib/desktop.mjs && node --check lib/resume-manager.mjs && node --check lib/device-auth.mjs && node --check lib/connector-service.mjs && node --check public/app.js && node --check public/connect.js && node --check relay-server.mjs && node --check relay-store.mjs && node --check relay-agent.mjs && node --check public/admin.js"
31
31
  },
32
32
  "dependencies": {
33
33
  "dompurify": "^3.3.0",
package/public/app.js CHANGED
@@ -25,7 +25,7 @@ const paths = {
25
25
  const icon = name => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.inbox}</svg>`;
26
26
  function decorate(parent = document) { parent.querySelectorAll('[data-icon]').forEach(element => { element.innerHTML = icon(element.dataset.icon); }); }
27
27
  function markdown(text) { return DOMPurify.sanitize(marked.parse(String(text || ''), { breaks: true }), { ALLOWED_TAGS: ['p','br','strong','b','em','i','s','del','ul','ol','li','blockquote','pre','code','h1','h2','h3','h4','h5','h6','hr','a','table','thead','tbody','tr','th','td','sup','sub','kbd','details','summary'], ALLOWED_ATTR: ['href','title','start','colspan','rowspan'], ALLOW_DATA_ATTR: false, ALLOW_ARIA_ATTR: false }); }
28
- const state = { threads: [], status: null, selected: null, selectionVersion: 0, historyStatus: 'idle', historyError: '', filter: 'all', project: null, search: '', view: 'sessions', socket: null, pending: new Map(), enabled: new Map(), baseMessages: [], olderMessages: [], cursor: null, liveMessages: new Map(), desktopMessages: [], desktopRuntime: null, selectedSettings: null, models: [], contextUsage: null, settingsBusy: false, reopenModelAfterGrant: false, pendingModelChoice: null, cloudMode: false, cloudUser: null, cloudDevices: [], cloudDeviceId: null, approvals: new Map(), pendingAttachments: [], reconnect: 0, loggedOut: false, busy: false, turnId: null };
28
+ const state = { threads: [], status: null, selected: null, selectionVersion: 0, historyStatus: 'idle', historyError: '', filter: 'all', project: null, search: '', view: 'sessions', socket: null, pending: new Map(), enabled: new Map(), baseMessages: [], olderMessages: [], cursor: null, liveMessages: new Map(), desktopMessages: [], desktopRuntime: null, selectedSettings: null, goal:null, models: [], contextUsage: null, settingsBusy: false, reopenModelAfterGrant: false, pendingModelChoice: null, cloudMode: false, cloudUser: null, cloudDevices: [], cloudDeviceId: null, approvals: new Map(), pendingAttachments: [], reconnect: 0, loggedOut: false, busy: false, turnId: null };
29
29
  let refreshTimer;
30
30
  let toastTimer;
31
31
  let reconnectTimer;
@@ -314,12 +314,13 @@ function connectSocket() {
314
314
  if (message.method === 'turn/completed') { state.busy = false; state.turnId = null; updateMode(); scheduleRefresh(); }
315
315
  if (message.method === 'orbit/desktop' && state.status?.execution.mode === 'desktop') {
316
316
  state.desktopRuntime = params;
317
+ if(params.threadId===state.selected?.id)state.goal=params.attached?params.goal||null:null;
317
318
  if (!params.attached) { state.enabled.delete(params.threadId); state.selectedSettings = null; }
318
319
  if (params.attached) mergeDesktopMessages(params.messages || []);
319
320
  state.turnId = params.attached ? params.turnId : null;
320
321
  state.busy = Boolean(state.turnId);
321
322
  state.approvals = new Map((params.attached ? params.approvals || [] : []).map(approval => [approval.requestId, approval]));
322
- renderMessages(); renderApprovals(); updateMode();
323
+ renderMessages();renderApprovals();renderList();updateMode();
323
324
  }
324
325
  if (message.method === 'thread/tokenUsage/updated' && state.status?.execution.mode !== 'desktop') {
325
326
  const usage = params.tokenUsage || params.tokenUsageInfo;
@@ -340,6 +341,7 @@ function connectSocket() {
340
341
  state.busy = false; state.turnId = null;
341
342
  state.reopenModelAfterGrant = false;
342
343
  state.desktopRuntime = {threadId:params.threadId,attached:false,reason:params.reason,reasonCode:params.reasonCode};
344
+ state.goal=null;renderGoalStatus();
343
345
  if (select('#model-dialog').open) select('#model-dialog').close();
344
346
  if (select('#control-dialog').open) select('#control-dialog').close();
345
347
  state.approvals.clear(); renderApprovals(); updateMode();
@@ -357,9 +359,15 @@ function connectSocket() {
357
359
  toast(params.error || '下一轮模型参数未应用,请重新选择。');
358
360
  }
359
361
  if (message.method === 'orbit/engine') {
360
- if (state.status?.execution) state.status.execution.connected = Boolean(params.connected);
362
+ if (state.status?.execution) {
363
+ state.status.execution.connected = Boolean(params.connected);
364
+ if(params.desktopConnected!==undefined)state.status.execution.desktopConnected=Boolean(params.desktopConnected);
365
+ else if(params.mode==='desktop')state.status.execution.desktopConnected=Boolean(params.connected);
366
+ if(params.resumedConnected!==undefined)state.status.execution.resumedConnected=Boolean(params.resumedConnected);
367
+ }
361
368
  if (!params.connected) {
362
369
  state.enabled.clear(); state.desktopRuntime = null; state.selectedSettings = null; state.pendingModelChoice = null;
370
+ state.goal=null;renderGoalStatus();
363
371
  if (select('#model-dialog').open) select('#model-dialog').close();
364
372
  if (select('#control-dialog').open) select('#control-dialog').close();
365
373
  state.reopenModelAfterGrant = false;
@@ -368,6 +376,16 @@ function connectSocket() {
368
376
  }
369
377
  updateMode();
370
378
  }
379
+ if(message.method==='thread/goal/updated'&&params.threadId===state.selected?.id){
380
+ state.goal=params.goal||null;
381
+ if(state.desktopRuntime)state.desktopRuntime={...state.desktopRuntime,goal:state.goal};
382
+ renderGoalStatus();
383
+ }
384
+ if(message.method==='thread/goal/cleared'&&params.threadId===state.selected?.id){
385
+ state.goal=null;
386
+ if(state.desktopRuntime)state.desktopRuntime={...state.desktopRuntime,goal:null};
387
+ renderGoalStatus();
388
+ }
371
389
  if (message.method === 'orbit/approval') { state.approvals.set(params.requestId, params); renderApprovals(); }
372
390
  if (message.method === 'serverRequest/resolved') { state.approvals.delete(String(params.requestId)); renderApprovals(); }
373
391
  if (message.method === 'error') toast(params.error?.message || params.message || '执行发生错误,请查看会话状态。');
@@ -418,6 +436,7 @@ async function subscribeThread(id) {
418
436
  state.busy = Boolean(result.turnId);
419
437
  state.approvals = new Map((result.approvals || []).map(approval => [approval.requestId, approval]));
420
438
  state.pendingAttachments=result.attachments||[];
439
+ state.goal=result.runtime?.goal||null;
421
440
  renderComposerAttachments();
422
441
  if (desktop && result.control) {
423
442
  controlRenewTimer = setTimeout(() => {
@@ -426,12 +445,12 @@ async function subscribeThread(id) {
426
445
  }
427
446
  },Math.max(1000,result.control.expires - Date.now() - 120000));
428
447
  }
429
- renderMessages(); renderApprovals();
448
+ renderMessages();renderApprovals();renderList();
430
449
  } catch (error) {
431
450
  if (id === state.selected?.id && selectionVersion === state.selectionVersion && attempt === desktopConnectionAttempt) {
432
451
  state.enabled.delete(id); state.selectedSettings = null; state.turnId = null; state.busy = false;
433
452
  state.desktopRuntime = {threadId:id,attached:false,reason:error.message,reasonCode:error.code};
434
- state.approvals.clear(); renderApprovals();
453
+ state.approvals.clear();renderApprovals();renderList();
435
454
  }
436
455
  throw error;
437
456
  } finally {
@@ -469,17 +488,21 @@ function renderProjects() {
469
488
  function filteredThreads() {
470
489
  return state.threads.filter(thread => (!state.project || thread.projectId === state.project)
471
490
  && (state.filter !== 'archived' || thread.archived)
472
- && (state.filter !== 'locked' || thread.locked)
473
491
  && (state.filter !== 'pinned' || thread.pinned)
474
492
  && `${thread.title} ${thread.projectName} ${thread.preview}`.toLowerCase().includes(state.search.toLowerCase()))
475
493
  .sort((left, right) => Number(right.pinned) - Number(left.pinned) || right.updatedAt - left.updatedAt);
476
494
  }
495
+ function threadAvailabilityBadge(thread) {
496
+ if(thread.archived)return '<span class="tag archived">已归档</span>';
497
+ if(String(thread.source).startsWith('{'))return '<span class="tag">子代理</span>';
498
+ return '<span class="tag">原会话</span>';
499
+ }
477
500
  function renderList() {
478
501
  const threads = filteredThreads();
479
502
  select('#thread-count').textContent = threads.length;
480
503
  select('#list-title').textContent = state.project ? (state.threads.find(thread => thread.projectId === state.project)?.projectName || '项目会话') : state.filter === 'archived' ? '已归档' : state.filter === 'pinned' ? '已置顶' : '所有会话';
481
504
  select('#list-footer-text').textContent = `${state.threads.length} 条原生记录 · 自动同步`;
482
- select('#thread-list').innerHTML = threads.map(thread => `<button class="thread-item ${state.selected?.id === thread.id ? 'selected' : ''}" data-thread="${escapeHtml(thread.id)}"><div class="thread-meta"><span class="project-initial">${escapeHtml(thread.projectName.slice(0,1).toUpperCase())}</span><span class="project-label">${escapeHtml(thread.projectName)}</span><time>${ago(thread.updatedAt)}</time></div><h3>${escapeHtml(thread.title)}</h3><p>${escapeHtml(thread.preview || '点击查看原生会话记录')}</p><div class="thread-badges">${thread.pinned ? '<span class="tag pinned">已置顶</span>' : ''}${thread.locked ? '<span class="tag busy"><i class="tiny-dot"></i>原客户端占用</span>' : '<span class="tag">可查看</span>'}${thread.archived ? '<span class="tag archived">已归档</span>' : ''}<span class="tag">${escapeHtml(thread.provider || 'Codex')}</span></div></button>`).join('') || '<div class="empty">没有找到匹配的会话。<br>试试其他关键词或筛选条件。</div>';
505
+ select('#thread-list').innerHTML = threads.map(thread => `<button class="thread-item ${state.selected?.id === thread.id ? 'selected' : ''}" data-thread="${escapeHtml(thread.id)}"><div class="thread-meta"><span class="project-initial">${escapeHtml(thread.projectName.slice(0,1).toUpperCase())}</span><span class="project-label">${escapeHtml(thread.projectName)}</span><time>${ago(thread.updatedAt)}</time></div><h3>${escapeHtml(thread.title)}</h3><p>${escapeHtml(thread.preview || '点击查看原生会话记录')}</p><div class="thread-badges">${thread.pinned ? '<span class="tag pinned">已置顶</span>' : ''}${threadAvailabilityBadge(thread)}${thread.archived ? '<span class="tag archived">已归档</span>' : ''}<span class="tag">${escapeHtml(thread.provider || 'Codex')}</span></div></button>`).join('') || '<div class="empty">没有找到匹配的会话。<br>试试其他关键词或筛选条件。</div>';
483
506
  document.querySelectorAll('[data-filter]').forEach(button => {
484
507
  const active = button.dataset.filter === state.filter;
485
508
  button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active));
@@ -489,6 +512,8 @@ function renderWelcome() {
489
512
  const projects = new Set(state.threads.map(thread => thread.projectId));
490
513
  const archived = state.threads.filter(thread => thread.archived).length;
491
514
  select('#welcome').innerHTML = `<div class="welcome-heading"><div><h2>你的工作,都在这里。</h2><p>连接本机 Codex,从上次停下的地方找到灵感。</p></div><span class="date-label">${new Date().toLocaleDateString('zh-CN', { timeZone:'Asia/Shanghai', month:'long', day:'numeric', weekday:'long' })}</span></div><div class="hero"><div class="hero-grid"></div><div class="hero-orbit"><i></i></div><span class="eyebrow">KEEP THE CONTEXT. FIND YOUR FLOW.</span><h2>工作在电脑上,<br>灵感不必。</h2><p>一个入口,查看真实的项目与原生会话。<br>不再复制粘贴,也不必重新解释。</p><span class="hero-status"><i class="tiny-dot"></i> 本机直读 · 默认只读保护</span></div><div class="stats-grid"><div class="stat-card"><span>${icon('inbox')}原生会话</span><strong>${state.threads.length}</strong><small>来自本机,而非演示数据</small></div><div class="stat-card"><span>${icon('folder')}项目空间</span><strong>${projects.size}</strong><small>按原生归属 / 工作目录分组</small></div><div class="stat-card"><span>${icon('archive')}归档记录</span><strong>${archived}</strong><small>旧上下文,也值得保留</small></div></div><div class="section-heading"><h3>最近的上下文</h3><span>从熟悉的对话开始 ↗</span></div>${state.threads.filter(thread => !thread.archived).slice(0,3).map(thread => `<button class="recent-card" data-thread="${escapeHtml(thread.id)}"><span class="recent-icon">${icon('inbox')}</span><div><strong>${escapeHtml(thread.title)}</strong><p>${escapeHtml(thread.projectName)} · ${ago(thread.updatedAt)}</p></div><span>↗</span></button>`).join('')}<div class="honesty-note">${icon('info')}<span>原生会话列表与历史记录直接读取。授权后可通过桌面 IPC 接入已加载的 Codex 会话;尚未加载的会话需先在 Codex 桌面打开,不会新建或迁移执行器。</span></div>`;
515
+ select('#welcome .hero-status').innerHTML=`<i class="tiny-dot"></i> 已连接电脑 · 打开原会话即可继续`;
516
+ select('#welcome .honesty-note span').textContent='点击任意普通原会话,即可从当前上下文继续;不会新建会话。计划和目标使用 Codex 原生功能。';
492
517
  }
493
518
  async function openThread(id) {
494
519
  const thread = state.threads.find(entry => entry.id === id); if (!thread) return;
@@ -499,6 +524,7 @@ async function openThread(id) {
499
524
  select('#turn-mode').value='default';
500
525
  state.historyStatus = 'loading'; state.historyError = '';
501
526
  state.selected = thread; state.baseMessages = []; state.olderMessages = []; state.liveMessages.clear(); state.desktopMessages = []; state.desktopRuntime = null; state.selectedSettings = null; state.contextUsage = null; state.approvals.clear(); state.cursor = null; state.busy = false; state.turnId = null;
527
+ state.goal=null;
502
528
  select('#welcome').hidden = true; select('#chat').hidden = false; select('#workspace').classList.add('chat-open');
503
529
  select('#chat-title').textContent = thread.title;
504
530
  select('#chat-subtitle').textContent = `${thread.projectName} / ${thread.provider || 'Codex'}${thread.model ? ` · ${thread.model}` : ''}`;
@@ -722,18 +748,21 @@ function updateMode() {
722
748
  const desktop = state.status?.execution.mode === 'desktop';
723
749
  const desktopConnected = desktop && state.status.execution.connected;
724
750
  const attached = desktopConnected && state.desktopRuntime?.attached === true;
725
- const blocked = thread.archived || (desktop ? !attached || desktopConnecting : thread.locked && !control && state.status?.execution.mode !== 'endpoint');
751
+ const sameIdResume = attached && state.desktopRuntime?.mode === 'same-id-resume';
752
+ const appServerExecution=['same-id-resume','endpoint','cold-resume'].includes(state.desktopRuntime?.mode)
753
+ || ['endpoint','cold-resume'].includes(state.status?.execution.mode);
754
+ const nativePlanAvailable=Boolean(control&&(desktop?attached:appServerExecution));
755
+ const blocked = thread.archived || String(thread.source).startsWith('{') || desktopConnecting;
726
756
  select('#enable-control').disabled = Boolean(blocked || control);
727
- select('#enable-control').textContent = control ? desktop ? '热接管中' : '接续已授权' : thread.archived ? '归档只读' : desktopConnecting && desktop ? '连接中…' : desktop && !desktopConnected ? '桌面未连接' : desktop && !attached ? '原会话未连接' : blocked ? '原客户端占用' : desktop ? '重新接管' : '接续会话';
757
+ select('#enable-control').textContent = control ? '已连接' : desktopConnecting && desktop ? '连接中…' : thread.archived ? '已归档' : String(thread.source).startsWith('{') ? '请打开父会话' : '继续会话';
728
758
  select('#mode-banner').classList.toggle('controlled', Boolean(control));
729
759
  const banner = desktop
730
- ? desktopConnecting ? '正在连接这条原会话;历史仍可查看。'
731
- : !desktopConnected ? 'Codex 桌面连接已断开。请保持桌面应用运行,再点击“重新连接”。'
732
- : !attached ? state.desktopRuntime?.reason || '这条原会话尚未连接。请先在 Codex 桌面打开它,再点击“重新连接”。'
733
- : control ? '热接管中 · 与电脑共用同一 Codex 执行实例;消息、运行状态和审批实时同步。'
734
- : '这条原会话已连接 · 点击“重新接管”恢复控制。'
735
- : control ? `原 ID 接续 · ${control.writable ? '工作区可写 / 操作需审批' : '只读执行沙箱'} · ${control.sharedExecution ? '目标服务已加载此会话' : '独立执行实例'}`
736
- : thread.locked ? '原客户端持有此会话:独立接续不会抢占桌面执行。' : '只读镜像:查看原生历史不会启动执行实例。';
760
+ ? desktopConnecting ? '正在连接原会话…'
761
+ : !attached ? state.desktopRuntime?.reason || '正在连接原会话…'
762
+ : control ? sameIdResume ? '已连接;在原会话中使用 Codex 原生功能。' : '已连接;操作在电脑上的 Codex 中执行。'
763
+ : '正在连接原会话…'
764
+ : control ? '已连接;继续在原会话中工作。'
765
+ : '连接后直接在这条原会话中继续。';
737
766
  const modeBanner = select('#mode-banner');
738
767
  if (!modeBanner.querySelector('.mode-copy')) modeBanner.innerHTML = `${icon('shield')}<span class="mode-copy"></span><button type="button" class="button small" id="reconnect-desktop">重新连接</button>`;
739
768
  modeBanner.querySelector('.mode-copy').textContent = banner;
@@ -741,24 +770,88 @@ function updateMode() {
741
770
  retryButton.hidden = !desktop || attached && !desktopConnecting;
742
771
  retryButton.disabled = !connected || desktopConnecting;
743
772
  retryButton.textContent = desktopConnecting ? '连接中…' : '重新连接';
744
- select('#composer-mode').innerHTML = `${icon('shield')}${control ? desktop ? '热接管 · 桌面权限' : control.writable ? '工作区可写 · 按需审批' : '接续执行 · 只读沙箱' : '原生记录 · 只读镜像'}`;
745
- const waitingForTurn = state.busy && (!desktop || !state.turnId);
773
+ select('#composer-mode').innerHTML = `${icon('shield')}${control ? '原会话' : '连接中'}`;
774
+ const waitingForTurn = state.busy && (!desktop || !state.turnId || sameIdResume);
746
775
  select('#send-message').disabled = !control || !connected || desktop && (!attached || desktopConnecting) || waitingForTurn || state.settingsBusy || !select('#message-input').value.trim();
747
- select('#send-message').title = state.busy && desktop ? '追加指令到当前桌面任务' : '沿用原会话发送';
776
+ select('#send-message').title = state.busy && desktop && !sameIdResume ? '追加指令到当前桌面任务' : '沿用原会话发送';
748
777
  select('#stop-turn').hidden = !state.busy || !state.turnId || !control;
749
- select('#message-input').placeholder = state.busy && desktop ? '追加指令给电脑上正在运行的任务…' : control ? '沿着原来的上下文继续…' : desktop ? '连接原桌面会话后继续…' : '先确认接续方式,再沿着这条会话继续…';
750
- select('#turn-mode').disabled=!control||!desktop||desktopConnecting||state.busy;
751
- select('#attach-files').disabled=!control||!desktop||desktopConnecting||state.pendingAttachments.length>=5||state.settingsBusy;
778
+ select('#message-input').placeholder = state.busy && desktop && !sameIdResume ? '追加指令给电脑上正在运行的任务…' : state.busy && sameIdResume ? '原会话正在执行,请等待本轮完成…' : control ? '沿着原来的上下文继续…' : desktop ? '连接后在原会话中继续…' : '继续这条原会话…';
779
+ select('#turn-mode').disabled=!nativePlanAvailable||desktopConnecting||state.busy;
780
+ select('#attach-files').disabled=!control||!desktop||sameIdResume||desktopConnecting||state.pendingAttachments.length>=5||state.settingsBusy;
752
781
  select('#file-input').disabled=select('#attach-files').disabled;
753
- select('#turn-mode').title=state.busy?'当前桌面任务中无法切换模式。':!desktop?'仅 Codex 桌面原会话支持模式选择。':'模式仅作用于新一轮消息。';
782
+ select('#turn-mode').title=state.busy?'当前任务中无法切换模式。':!nativePlanAvailable?'当前执行通道不支持原生协作模式。':'通过 Codex 原生协作模式控制下一轮。';
754
783
  const mode=select('#turn-mode').value;
755
- select('#composer-note').textContent=mode==='plan'
756
- ? '计划模式先分析并列出步骤,等待你确认后再执行。只影响下一轮。'
757
- : mode==='goal'
758
- ? '目标模式将本轮消息作为明确目标;Codex 桌面 IPC 暂不提供持久目标状态设置。'
759
- : control ? desktop ? '消息进入原 Codex 桌面会话;任务不会迁移或重启。文件暂存 30 分钟。断线不会自动重发。' : '原会话 ID 不变;断线不会自动重发消息。'
760
- : '只读查看不会唤醒或打断电脑上的执行。';
784
+ select('#composer-note').textContent=sameIdResume
785
+ ? state.busy ? '原会话正在执行;请等待当前轮次结束。'
786
+ : mode==='plan'?'Codex 原生计划模式;只作用于本轮。'
787
+ :'消息和目标都直接作用于这条原会话。'
788
+ : mode==='plan'
789
+ ? '使用 Codex 原生计划模式;计划完成后按原生流程继续。'
790
+ : control ? '消息继续发送到这条原会话;不会创建新会话。'
791
+ : '连接原会话后即可继续。';
761
792
  renderModelStatus();
793
+ renderGoalStatus();
794
+ }
795
+ const goalStatusNames={active:'进行中',paused:'已暂停',blocked:'受阻',usageLimited:'用量受限',budgetLimited:'预算已用尽',complete:'已完成'};
796
+ function nativeGoalWritable() {
797
+ const runtime=state.desktopRuntime;
798
+ return Boolean(state.selected&&state.enabled.has(state.selected.id)
799
+ &&(runtime?.mode==='same-id-resume'||state.status?.execution.mode!=='desktop'));
800
+ }
801
+ function renderGoalStatus() {
802
+ const button=select('#goal-button');
803
+ if(!button)return;
804
+ const goal=state.goal||state.desktopRuntime?.goal||null;
805
+ button.textContent=goal?`目标 · ${goalStatusNames[goal.status]||goal.status}`:'设置目标';
806
+ button.classList.toggle('active',Boolean(goal));
807
+ button.disabled=!state.selected||state.selected.archived||String(state.selected.source).startsWith('{');
808
+ button.title='管理这条原会话中的 Codex 原生持续目标。';
809
+ }
810
+ function openGoalDialog() {
811
+ if(!state.selected)return;
812
+ const goal=state.goal||state.desktopRuntime?.goal||null;
813
+ const writable=nativeGoalWritable();
814
+ select('#goal-objective').value=goal?.objective||'';
815
+ select('#goal-token-budget').value=goal?.tokenBudget??'';
816
+ select('#goal-current-status').textContent=goal
817
+ ? `${goalStatusNames[goal.status]||goal.status} · 已用 ${goal.tokensUsed||0} tokens${goal.tokenBudget?` / ${goal.tokenBudget}`:''}`
818
+ : '尚未设置持续目标。';
819
+ const desktopOwned=state.status?.execution.mode==='desktop'&&state.desktopRuntime?.mode!=='same-id-resume';
820
+ select('#goal-description').textContent=desktopOwned
821
+ ? '目标状态与 Codex 桌面同步。目标编辑暂不可用,请在本机 Codex 中管理。'
822
+ : writable
823
+ ? '使用 Codex 原生 Goal;目标会跨轮次保留,不会转换成提示词。'
824
+ : '连接原会话后,可在这里创建和管理 Codex 原生持续目标。';
825
+ select('#goal-objective').disabled=!writable;
826
+ select('#goal-token-budget').disabled=!writable;
827
+ select('#save-goal').disabled=!writable;
828
+ select('#pause-goal').disabled=!writable||!goal;
829
+ select('#clear-goal').disabled=!writable||!goal;
830
+ select('#pause-goal').textContent=goal?.status==='paused'?'继续目标':'暂停目标';
831
+ select('#save-goal').textContent=goal?'更新目标':'开始追求目标';
832
+ select('#goal-error').textContent='';
833
+ select('#goal-dialog').showModal();
834
+ }
835
+ async function updateGoalStatus(status) {
836
+ if(!state.selected||!nativeGoalWritable())return;
837
+ const id=state.selected.id,selectionVersion=state.selectionVersion;
838
+ try{
839
+ const result=await rpc('thread.goal.status',{threadId:id,status});
840
+ if(selectionVersion!==state.selectionVersion||id!==state.selected?.id)return;
841
+ state.goal=result.goal||null;
842
+ if(state.desktopRuntime)state.desktopRuntime={...state.desktopRuntime,goal:state.goal};
843
+ select('#goal-current-status').textContent=state.goal?`${goalStatusNames[state.goal.status]||state.goal.status} · 已用 ${state.goal.tokensUsed||0} tokens${state.goal.tokenBudget?` / ${state.goal.tokenBudget}`:''}`:'尚未设置持续目标。';
844
+ renderGoalStatus();toast(status==='paused'?'已暂停原生目标。':'已继续原生目标。');
845
+ }catch(error){select('#goal-error').textContent=error.message;}
846
+ }
847
+ async function clearGoal() {
848
+ if(!state.selected||!nativeGoalWritable())return;
849
+ try{
850
+ await rpc('thread.goal.clear',{threadId:state.selected.id});
851
+ state.goal=null;
852
+ if(state.desktopRuntime)state.desktopRuntime={...state.desktopRuntime,goal:null};
853
+ select('#goal-dialog').close();renderGoalStatus();toast('已清除原生目标。');
854
+ }catch(error){select('#goal-error').textContent=error.message;}
762
855
  }
763
856
  function renderComposerAttachments() {
764
857
  select('#composer-attachments').innerHTML=state.pendingAttachments.map(file=>
@@ -856,8 +949,16 @@ function renderDevices() {
856
949
  const status = state.status;
857
950
  const url = status.connectionUrls.find(address => !address.includes('127.0.0.1')) || status.connectionUrls[0];
858
951
  const desktop = status.execution.mode === 'desktop';
859
- const executionStatus = desktop ? status.execution.connected ? 'Codex 桌面 IPC 已连接' : 'Codex 桌面 IPC 未连接' : status.execution.mode === 'endpoint' ? 'App Server 端点' : '独立执行器';
860
- const hotStatus = desktop && status.desktopHotAttachVerified ? '已验证实时会话' : desktop && status.desktopHotAttachReady ? '可授权热接管' : '未连接';
952
+ const desktopConnected = status.execution.desktopConnected ?? status.execution.connected;
953
+ const resumedConnected = Boolean(status.execution.resumedConnected);
954
+ const executionStatus = desktop
955
+ ? desktopConnected ? 'Codex 桌面 IPC 已连接'
956
+ : resumedConnected ? 'Codex App Server 原 ID 接续'
957
+ : '等待本机 Codex 执行器'
958
+ : status.execution.mode === 'endpoint' ? 'App Server 端点' : '独立执行器';
959
+ const hotStatus = desktop && desktopConnected
960
+ ? status.desktopHotAttachVerified ? '已发现实时会话' : '支持自动热接管'
961
+ : desktop && resumedConnected ? '原 ID 接续中' : '打开会话时自动连接';
861
962
  select('#devices-view').innerHTML = `<div class="devices-heading"><span class="eyebrow">CONNECTED TO YOUR WORLD</span><h1>设备与连接</h1><p>电脑负责执行。你的设备,只需要保持连接。</p></div><div class="device-grid"><section class="device-card"><div class="device-big-icon">${icon('monitor')}</div><h2>${escapeHtml(status.device)}<span class="capability-state">本机桥接在线</span></h2><p>直接读取本机原生会话与历史记录,不复制到第三方数据库。</p><dl><dt>原生会话</dt><dd>${state.threads.length} 条 / ${new Set(state.threads.map(thread => thread.projectId)).size} 个空间</dd><dt>数据目录</dt><dd>${escapeHtml(status.home)}</dd><dt>消息通道</dt><dd>${location.protocol === 'https:' ? 'WSS · TLS 加密' : 'WS · 本机 / 可信局域网演示'}</dd><dt>执行连接</dt><dd>${executionStatus}</dd><dt>热接管</dt><dd>${hotStatus}</dd></dl></section><section class="device-card"><div class="device-big-icon">${icon('devices')}</div><h2>把工作台带在身边</h2><p>${status.lanEnabled || status.secureTransport ? '在可信设备上打开地址并配对,即可查看同一份原生记录。二维码包含控制凭据,切勿公开。' : '当前仅监听本机。手机同 Wi-Fi 体验,请停止本次服务,再运行 start.ps1 -Lan。'}</p><div class="connection-url"><code>${escapeHtml(url)}</code><button class="icon-button" id="copy-url" aria-label="复制连接地址">${icon('copy')}</button></div><button id="show-qr" class="button small">显示配对二维码</button><div id="qr-container" class="qr-wrap" hidden></div><p>${status.secureTransport ? '已配置 HTTPS 入口;请确认反向代理正确转发 WebSocket。' : '注意:HTTP / WS 不加密,不要将此端口直接暴露公网。公网必须配置 HTTPS / WSS 与独立鉴权。'}</p></section><section class="device-card wide"><h2>能力状态,透明可见。</h2><div class="capability-row"><span>原生项目 / 全量本机会话 / 归档记录</span><span class="capability-state">真实数据已接入</span></div><div class="capability-row"><span>历史分页 / WebSocket 实时更新</span><span class="capability-state">可体验</span></div><div class="capability-row"><span>接入 Codex 桌面已加载的原会话</span><span class="capability-state ${hotStatus === '未连接' ? 'pending' : ''}">${hotStatus}</span></div><div class="capability-row"><span>追加指令 / 停止任务 / 审批同步</span><span class="capability-state ${status.execution.connected && desktop ? '' : 'pending'}">${desktop && status.execution.connected ? '同一桌面执行实例' : '桌面未连接'}</span></div><div class="capability-row"><span>公网中继 / 多用户 / 计费 / 推送</span><span class="capability-state pending">未实现</span></div><p>Orbit 是独立第三方原型,非 OpenAI 官方产品。桌面 IPC 属内部接口,升级后需重新做兼容测试;商业发布前还需独立安全审计。</p></section></div>`;
862
963
  const cloudRow = select('#devices-view .capability-row:last-of-type');
863
964
  cloudRow.querySelector('span:first-child').textContent = '多用户云中继 / 设备管理后台';
@@ -1190,7 +1291,40 @@ select('#model-form').addEventListener('submit', async event => {
1190
1291
  finally { button.disabled = false; state.settingsBusy = false; updateMode(); }
1191
1292
  });
1192
1293
  select('#message-input').addEventListener('input', updateMode);
1294
+ const goalButton=document.createElement('button');
1295
+ select('#turn-mode option[value="goal"]')?.remove();
1296
+ goalButton.id='goal-button';
1297
+ goalButton.type='button';
1298
+ goalButton.className='button small goal-button';
1299
+ goalButton.textContent='设置目标';
1300
+ goalButton.setAttribute('aria-label','设置 Codex 原生持久目标');
1301
+ select('#turn-mode').insertAdjacentElement('afterend',goalButton);
1302
+ goalButton.onclick=openGoalDialog;
1193
1303
  select('#turn-mode').addEventListener('change',updateMode);
1304
+ select('#goal-form').addEventListener('submit',async event=>{
1305
+ event.preventDefault();
1306
+ if(!state.selected||!nativeGoalWritable())return;
1307
+ const button=select('#save-goal');
1308
+ const id=state.selected.id,selectionVersion=state.selectionVersion;
1309
+ const budgetText=select('#goal-token-budget').value.trim();
1310
+ const tokenBudget=budgetText?Number(budgetText):null;
1311
+ button.disabled=true;select('#goal-error').textContent='';
1312
+ try{
1313
+ const result=await rpc('thread.goal.set',{
1314
+ threadId:id,objective:select('#goal-objective').value.trim(),tokenBudget
1315
+ });
1316
+ if(selectionVersion!==state.selectionVersion||id!==state.selected?.id)return;
1317
+ state.goal=result.goal||null;
1318
+ if(state.desktopRuntime)state.desktopRuntime={...state.desktopRuntime,goal:state.goal};
1319
+ select('#goal-dialog').close();renderGoalStatus();toast('已设置 Codex 原生持久目标。');
1320
+ }catch(error){select('#goal-error').textContent=error.message;}
1321
+ finally{button.disabled=false;}
1322
+ });
1323
+ select('#pause-goal').onclick=()=>updateGoalStatus(state.goal?.status==='paused'?'active':'paused');
1324
+ select('#clear-goal').onclick=clearGoal;
1325
+ select('#cancel-goal').onclick=()=>select('#goal-dialog').close();
1326
+ select('[data-filter="locked"]')?.remove();
1327
+ renderGoalStatus();
1194
1328
  select('#browse-files').onclick=()=>{
1195
1329
  if(!state.selected)return;
1196
1330
  browseProjectFiles();
@@ -1223,7 +1357,7 @@ select('#composer').addEventListener('submit', async event => {
1223
1357
  event.preventDefault(); if (select('#send-message').disabled) return;
1224
1358
  const id = state.selected.id; const inputText = select('#message-input').value.trim(); const wasBusy = state.busy;
1225
1359
  const turnId = state.turnId;
1226
- const steering = wasBusy && state.status.execution.mode === 'desktop';
1360
+ const steering = wasBusy && state.status.execution.mode === 'desktop' && state.desktopRuntime?.mode !== 'same-id-resume';
1227
1361
  const mode=select('#turn-mode').value;
1228
1362
  const attachmentIds=state.pendingAttachments.map(file=>file.id);
1229
1363
  const text=inputText|| (attachmentIds.length?`请查看并处理以下附件:${state.pendingAttachments.map(file=>file.label).join('、')}`:'');
@@ -0,0 +1,7 @@
1
+ .goal-button.active{background:#edf3e3;border-color:#dce7cd;color:#456337}
2
+ #goal-dialog label{display:block;font-size:11px;color:#718563;margin:16px 0 7px}
3
+ #goal-dialog textarea,#goal-dialog input{display:block;width:100%;border:1px solid #dce4d1;border-radius:8px;padding:11px 12px;background:#fff;color:#405139;font-size:12px}
4
+ #goal-dialog textarea{resize:vertical;min-height:100px;line-height:1.7}
5
+ #goal-dialog textarea:disabled,#goal-dialog input:disabled{background:#f0f2ec;color:#929b87}
6
+ #goal-dialog .dialog-actions{flex-wrap:wrap}
7
+ @media(max-width:760px){#goal-dialog{padding:22px 18px}#goal-dialog .dialog-actions{gap:7px}#goal-dialog .dialog-actions button{font-size:10px;padding:8px 10px}}
package/public/index.html CHANGED
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#153c37"><meta name="description" content="Orbit,让原生 Codex 会话随行。"><title>Orbit · 会话随行</title><link rel="icon" href="/icon.svg" type="image/svg+xml"><link rel="manifest" href="/manifest.webmanifest"><link rel="stylesheet" href="/style.css"><script src="/vendor/marked.js" defer></script><script src="/vendor/purify.js" defer></script><script src="/app.js" defer></script>
5
5
  <link rel="stylesheet" href="/layout.css">
6
+ <link rel="stylesheet" href="/goal.css">
6
7
  </head>
7
8
  <body>
8
9
  <section id="cloud-login-screen" class="pair-screen" hidden>
@@ -51,6 +52,7 @@
51
52
  </section><section id="devices-view" class="devices-view" hidden></section>
52
53
  </main>
53
54
  </div>
55
+ <dialog id="goal-dialog"><form id="goal-form"><h2>会话目标</h2><p id="goal-description">设置 Codex 持续追求的原生目标;目标会保存在这条原会话中。</p><label for="goal-objective">目标</label><textarea id="goal-objective" rows="4" maxlength="5000" placeholder="描述希望 Codex 持续完成的结果"></textarea><label for="goal-token-budget">Token 预算(可选)</label><input id="goal-token-budget" type="number" min="1" max="10000000" step="1" placeholder="不设置"><p id="goal-current-status" class="model-note"></p><p id="goal-error" class="error" role="alert"></p><div class="dialog-actions"><button id="clear-goal" type="button" class="button">清除目标</button><button id="pause-goal" type="button" class="button">暂停目标</button><button id="cancel-goal" type="button" class="button">取消</button><button id="save-goal" type="submit" class="primary">开始追求目标</button></div></form></dialog>
54
56
  <dialog id="control-dialog"><form id="control-form"><span class="mini-badge">CONTINUE, NOT FORK</span><h2 id="control-title">授权热接管</h2><p id="control-description">保持原会话与桌面执行实例,不创建新会话或独立执行器。</p><div class="notice"><span data-icon="shield"></span><span id="control-notice">指令会交给原 Codex 桌面会话执行,并沿用其权限与审批策略。</span></div><label class="check-row" id="allow-write-row"><input id="allow-write" type="checkbox"><span>允许修改此会话的工作区文件<small>默认只读。扩大权限仍需要审批;不会启用无限制执行。</small></span></label><label class="check-row"><input id="control-confirm" type="checkbox" required><span id="control-confirm-copy">我确认授权此设备控制这条原生会话。</span></label><div class="dialog-actions"><button type="button" id="cancel-control" class="button">先只读查看</button><button class="primary" id="confirm-control-button" type="submit">授权热接管 <span>↗</span></button></div><p id="control-error" class="error" role="alert"></p></form></dialog>
55
57
  <dialog id="model-dialog"><form id="model-form"><h2>会话参数</h2><p id="model-description">更改将在这条会话的下一轮生效。</p><label for="model-select">模型</label><select id="model-select" required></select><label for="effort-select">推理级别</label><select id="effort-select"></select><p id="model-note" class="model-note"></p><p id="model-error" class="error" role="alert"></p><div class="dialog-actions"><button id="cancel-model" type="button" class="button">取消</button><button id="authorize-model" type="button" class="primary" hidden>先授权热接管</button><button id="apply-model" type="submit" class="primary">应用到下一轮</button></div></form></dialog>
56
58
  <dialog id="file-browser-dialog" class="file-browser-dialog"><section class="file-browser-panel"><header><div><h2>电脑项目文件</h2><p id="file-browser-path">项目根目录</p></div><button id="close-file-browser" class="button small" type="button">关闭</button></header><p class="file-browser-notice">仅浏览当前会话工作目录;系统目录、凭证文件及符号链接不会开放。单个文件最大 8 MiB。</p><div id="file-browser-list" class="file-browser-list" aria-live="polite"></div><p id="file-browser-error" class="error" role="alert"></p></section></dialog>
package/server.mjs CHANGED
@@ -9,6 +9,7 @@ import QRCode from 'qrcode';
9
9
  import { NativeStore } from './lib/store.mjs';
10
10
  import { CodexRpc } from './lib/rpc.mjs';
11
11
  import { DesktopRpc } from './lib/desktop.mjs';
12
+ import { ResumeManager } from './lib/resume-manager.mjs';
12
13
 
13
14
  const root = path.dirname(fileURLToPath(import.meta.url));
14
15
  const packageVersion = JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8')).version;
@@ -156,8 +157,30 @@ export function createOrbitServer(options = {}) {
156
157
  const store = options.store || new NativeStore(home);
157
158
  cleanupStaleAttachments(store);
158
159
  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');
160
+ const injectedRpc = options.rpc || options.desktopRpc || null;
161
+ const rpc = injectedRpc || new DesktopRpc();
162
+ const executionMode = rpc.mode || (injectedRpc ? endpoint ? 'endpoint' : 'cold-resume' : 'desktop');
163
+ const resumeManager = options.resumeManager || (executionMode === 'desktop' && (!injectedRpc || options.enableResumeFallback === true)
164
+ ? new ResumeManager({
165
+ home,
166
+ createRpc:options.createResumeRpc || (({ home:resumeHome }) => new CodexRpc({
167
+ home:resumeHome, endpoint, tokenFile:process.env.ORBIT_APP_SERVER_TOKEN_FILE
168
+ }))
169
+ })
170
+ : null);
171
+ function runtimeForThread(id) {
172
+ return resumeManager?.runtime(id) || rpc.runtime?.(id) || null;
173
+ }
174
+ async function attachThread(id) {
175
+ if (executionMode !== 'desktop') return null;
176
+ if (resumeManager?.has(id)) return resumeManager.runtime(id);
177
+ try { return await rpc.attach(id); }
178
+ catch (error) {
179
+ const row = store.row(id);
180
+ if (!resumeManager || !row || row.archived || String(row.source).startsWith('{')) throw error;
181
+ return resumeManager.resume(id, { model:row.model || null });
182
+ }
183
+ }
161
184
  const listModels = options.listModels || (async () => {
162
185
  const catalogRpc = new CodexRpc({ home, endpoint, tokenFile: process.env.ORBIT_APP_SERVER_TOKEN_FILE });
163
186
  try {
@@ -266,7 +289,8 @@ export function createOrbitServer(options = {}) {
266
289
  if(!row||row.archived||String(row.source).startsWith('{'))return json(response,400,{error:'归档会话或子代理不能接收文件。'});
267
290
  if(executionMode!=='desktop'||!rpc.connected)return json(response,409,{error:'文件传输仅支持已连接的 Codex 桌面原会话。'});
268
291
  if(!controlFor(session,threadId))return json(response,403,{error:'请先连接并接管这条原会话。'});
269
- if(!rpc.runtime(threadId)?.attached)return json(response,409,{error:'请先在 Codex 桌面打开此会话并重新连接。'});
292
+ const runtime=runtimeForThread(threadId);
293
+ if(!runtime?.attached||runtime.mode==='same-id-resume')return json(response,409,{error:'该会话当前通过原 ID 接续运行;文件传输需要桌面热接管。'});
270
294
  for(const [id,attachment]of session.attachments)if(attachment.expires<Date.now())removeAttachment(session,id);
271
295
  if([...session.attachments.values()].filter(item=>item.threadId===threadId&&!item.consumed).length>=maxPendingAttachments)return json(response,429,{error:`这条会话最多可暂存 ${maxPendingAttachments} 个待发送文件。`});
272
296
  const rawName=url.searchParams.get('name')||'';
@@ -306,6 +330,7 @@ export function createOrbitServer(options = {}) {
306
330
 
307
331
  function status(session) {
308
332
  const threads = store.list();
333
+ const resumedIds = resumeManager?.ids() || [];
309
334
  for (const id of session.enabled.keys()) controlFor(session, id);
310
335
  return {
311
336
  product: 'Orbit', version: packageVersion, device: os.hostname(), platform: process.platform, home,
@@ -314,7 +339,7 @@ export function createOrbitServer(options = {}) {
314
339
  bridge: 'connected', transport: 'websocket', readOnlyMirror: true,
315
340
  desktopHotAttachReady: executionMode === 'desktop' && rpc.connected,
316
341
  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()] },
342
+ 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
343
  connectionUrls: [publicUrl || `http://127.0.0.1:${port}`, ...(host === '0.0.0.0' ? lanAddresses().map(address => `http://${address}:${port}`) : [])],
319
344
  secureTransport: Boolean(publicUrl), lanEnabled: host === '0.0.0.0'
320
345
  };
@@ -333,6 +358,7 @@ export function createOrbitServer(options = {}) {
333
358
  }
334
359
 
335
360
  async function applyQueuedDesktopSettings(id, runtime) {
361
+ if(executionMode!=='desktop'||resumeManager?.has(id))return;
336
362
  if (!runtime?.attached || runtime.turnId || queuedSettingsInFlight.has(id)) return;
337
363
  queuedSettingsInFlight.add(id);
338
364
  try {
@@ -371,7 +397,8 @@ export function createOrbitServer(options = {}) {
371
397
  if (count > 1) desktopWatchers.set(id, count - 1);
372
398
  else {
373
399
  desktopWatchers.delete(id);
374
- rpc.detach(id, '已取消实时跟随。');
400
+ if (resumeManager?.has(id)) void resumeManager.release(id).catch(() => {});
401
+ else rpc.detach(id, '已取消实时跟随。');
375
402
  }
376
403
  }
377
404
 
@@ -472,7 +499,7 @@ export function createOrbitServer(options = {}) {
472
499
  file = path.join(root, relative);
473
500
  type = mime;
474
501
  } 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'] };
502
+ 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
503
  if (!files[url.pathname]) return json(response, 404, { error: '页面不存在。' });
477
504
  const [name, mime] = files[url.pathname]; file = path.join(root, 'public', name); type = mime;
478
505
  }
@@ -532,8 +559,10 @@ export function createOrbitServer(options = {}) {
532
559
  socket.desktopSubscription = id;
533
560
  desktopWatchers.set(id, (desktopWatchers.get(id) || 0) + 1);
534
561
  }
535
- try { runtime = await rpc.attach(id); }
536
- catch (error) {
562
+ try {
563
+ runtime = await attachThread(id);
564
+ if (!runtime?.attached) throw new Error('原会话没有可用的 Codex 执行器。');
565
+ } catch (error) {
537
566
  unwatchDesktop(socket);
538
567
  lostListener(id,error.message,error.code);
539
568
  runtime = {
@@ -541,14 +570,17 @@ export function createOrbitServer(options = {}) {
541
570
  reason:safeError(error),reasonCode:error.code || 'DESKTOP_NOT_READY'
542
571
  };
543
572
  }
573
+ if (resumeManager?.has(id) && (desktopWatchers.get(id) || 0) === 0) void resumeManager.release(id).catch(() => {});
544
574
  }
545
575
  if (executionMode === 'desktop' && runtime?.attached && body.autoControl === true
546
576
  && !row.archived && !String(row.source).startsWith('{')) {
547
- session.enabled.set(id, {sharedExecution:true,desktop:true,writable:false,expires:Date.now() + 1800000});
577
+ const desktop = runtime.mode !== 'same-id-resume';
578
+ session.enabled.set(id, {sharedExecution:desktop,desktop,writable:!desktop,expires:Date.now() + 1800000});
548
579
  }
549
580
  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})) };
581
+ const activeRpc = resumeManager?.rpcFor(id) || rpc;
582
+ const approvals = control ? (executionMode === 'desktop' ? runtime?.approvals || [] : [...activeRpc.approvals.values()].filter(request => request.params.threadId === id).map(request => ({ ...request.params, requestId: String(request.id), method: request.method }))) : [];
583
+ 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
584
  }
553
585
  if (message.method === 'thread.enable') {
554
586
  const row = store.row(id);
@@ -557,12 +589,13 @@ export function createOrbitServer(options = {}) {
557
589
  if (body.confirm !== true) throw new Error('必须明确确认接续方式。');
558
590
  if (executionMode === 'desktop') {
559
591
  let runtime;
560
- try { runtime = await rpc.attach(id); }
592
+ try { runtime = await attachThread(id); }
561
593
  catch (error) { lostListener(id,error.message,error.code); throw error; }
562
594
  if (!runtime.attached) throw new Error('桌面实时快照未就绪,未授权控制。');
563
- const control = { sharedExecution: true, desktop: true, writable: false, expires: Date.now() + 1800000 };
595
+ const desktop = runtime.mode !== 'same-id-resume';
596
+ const control = { sharedExecution:desktop, desktop, writable:!desktop, expires: Date.now() + 1800000 };
564
597
  session.enabled.set(id, control);
565
- return { threadId: id, ...control, mode: 'desktop', turnId: runtime.turnId };
598
+ return { threadId: id, ...control, mode:runtime.mode || 'desktop', turnId: runtime.turnId };
566
599
  }
567
600
  let sharedExecution = false;
568
601
  if (endpoint) {
@@ -575,11 +608,51 @@ export function createOrbitServer(options = {}) {
575
608
  session.enabled.set(id, control);
576
609
  return { threadId: id, ...control, mode: endpoint ? 'endpoint' : 'cold-resume' };
577
610
  }
611
+ if (message.method === 'thread.goal.get') {
612
+ if (!store.row(id)) throw new Error('会话不存在。');
613
+ if (!controlFor(session, id)) throw new Error('请先连接并接管这条会话。');
614
+ const runtime = runtimeForThread(id);
615
+ if (runtime?.mode === 'same-id-resume' || executionMode === 'desktop' && runtime?.attached) return { goal:runtime.goal || null };
616
+ if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/get', { threadId:id });
617
+ return { goal:null };
618
+ }
619
+ if (message.method === 'thread.goal.set') {
620
+ const row = store.row(id);
621
+ if (!row || row.archived || String(row.source).startsWith('{')) throw new Error('归档会话或子代理不能设置持续目标。');
622
+ if (!controlFor(session, id)) throw new Error('请先连接并接管这条会话。');
623
+ if (typeof body.objective !== 'string' || !body.objective.trim() || body.objective.length > 5000) throw new Error('目标内容不能为空,且不得超过 5000 个字符。');
624
+ const tokenBudget = body.tokenBudget === undefined || body.tokenBudget === null ? null : body.tokenBudget;
625
+ if (tokenBudget !== null && (!Number.isSafeInteger(tokenBudget) || tokenBudget < 1 || tokenBudget > 10000000)) throw new Error('目标 token 预算必须在 1 至 10000000 之间。');
626
+ const runtime = runtimeForThread(id);
627
+ if (runtime?.mode === 'same-id-resume') return { goal:await resumeManager.setGoal(id, { objective:body.objective.trim(), tokenBudget }) };
628
+ if (executionMode === 'desktop' && runtime?.attached) throw new Error('当前 Codex 桌面 IPC 未公开原生 Goal 写入接口;请在电脑端设置目标。不会用提示词模拟原生 Goal。');
629
+ if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/set', {
630
+ threadId:id, origin:'user', objective:body.objective.trim(), status:'active', tokenBudget
631
+ });
632
+ throw new Error('当前执行通道不支持原生 Goal 写入。');
633
+ }
634
+ if (message.method === 'thread.goal.status') {
635
+ if (!controlFor(session, id) || !store.row(id)) throw new Error('请先连接并接管这条会话。');
636
+ if (!['active','paused','blocked','complete'].includes(body.status)) throw new Error('无效的目标状态。');
637
+ const runtime = runtimeForThread(id);
638
+ if (runtime?.mode === 'same-id-resume') return { goal:await resumeManager.setGoalStatus(id, body.status) };
639
+ if (executionMode === 'desktop' && runtime?.attached) throw new Error('当前 Codex 桌面 IPC 未公开原生 Goal 状态接口;不会伪造目标状态。');
640
+ if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/set', { threadId:id, origin:'user', status:body.status });
641
+ throw new Error('当前执行通道不支持原生 Goal 状态更新。');
642
+ }
643
+ if (message.method === 'thread.goal.clear') {
644
+ if (!controlFor(session, id) || !store.row(id)) throw new Error('请先连接并接管这条会话。');
645
+ const runtime = runtimeForThread(id);
646
+ if (runtime?.mode === 'same-id-resume') return resumeManager.clearGoal(id);
647
+ if (executionMode === 'desktop' && runtime?.attached) throw new Error('当前 Codex 桌面 IPC 未公开原生 Goal 清除接口;请在电脑端清除目标。');
648
+ if (rpc.threads?.has(id) && typeof rpc.request === 'function') return rpc.request('thread/goal/clear', { threadId:id, origin:'user' });
649
+ throw new Error('当前执行通道不支持原生 Goal 清除。');
650
+ }
578
651
  if (message.method === 'turn.start') {
579
652
  const approval = controlFor(session, id);
580
653
  if (!approval) throw new Error('请先确认这条会话的接续方式。');
581
654
  const mode=body.mode??'default';
582
- if(!['default','plan','goal'].includes(mode))throw new Error('对话模式无效。');
655
+ if(!['default','plan'].includes(mode))throw new Error('目标已改为会话级原生 Goal,请使用“目标”管理;不再用提示词模拟。');
583
656
  const attachmentIds=body.attachmentIds===undefined?[]:body.attachmentIds;
584
657
  if(!Array.isArray(attachmentIds)||attachmentIds.length>maxPendingAttachments||new Set(attachmentIds).size!==attachmentIds.length
585
658
  ||attachmentIds.some(attachmentId=>typeof attachmentId!=='string'))throw new Error('文件附件列表无效。');
@@ -590,16 +663,23 @@ export function createOrbitServer(options = {}) {
590
663
  return attachment;
591
664
  });
592
665
  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
666
  if (executionMode === 'desktop') {
667
+ const runtime=runtimeForThread(id);
668
+ if(runtime?.mode==='same-id-resume'){
669
+ if(attachments.length)throw new Error('文件传输目前需要桌面热接管;请在电脑上打开这条原会话后重新连接。');
670
+ const resumedRpc=resumeManager?.rpcFor(id);
671
+ if(!resumedRpc)throw new Error('原会话接续连接已失效,请重新打开这条会话。');
672
+ const settings=session.preferences.get(id);
673
+ return resumedRpc.startTurn(id,text,approval.writable,settings?.pending?settings:settings||null,{mode});
674
+ }
596
675
  const result=await rpc.startTurn(id,text,{mode,attachments});
597
676
  for(const attachment of attachments){attachment.consumed=true;attachment.expires=Date.now()+attachmentRetention;consumedAttachments.set(attachment.id,attachment);}
598
677
  return result;
599
678
  }
600
- if(mode!=='default'||attachments.length)throw new Error('计划/目标模式和文件附件仅支持 Codex 桌面热接管。');
679
+ if(attachments.length)throw new Error('文件附件仅支持 Codex 桌面热接管。');
601
680
  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);
681
+ if(mode==='plan'&&typeof rpc.startTurn!=='function')throw new Error('当前执行通道不支持原生协作模式。');
682
+ return rpc.startTurn(id,text,approval.writable,session.preferences.get(id)||null,{mode});
603
683
  }
604
684
  if (message.method === 'thread.settings') {
605
685
  if (!controlFor(session, id) || !store.row(id)) throw new Error('没有此会话的控制授权。');
@@ -608,8 +688,16 @@ export function createOrbitServer(options = {}) {
608
688
  if (!model || (body.effort !== null && !model.supportedReasoningEfforts.includes(body.effort))) throw new Error('所选模型或推理级别不可用,请重新加载目录。');
609
689
  if (executionMode === 'desktop') {
610
690
  if (typeof body.expectedModel !== 'string' || !Object.hasOwn(body, 'expectedEffort')) throw new Error('缺少当前模型版本,请刷新会话。');
611
- const runtime = rpc.runtime(id);
691
+ const runtime = runtimeForThread(id);
612
692
  if (!runtime.attached) throw new Error('桌面实时快照未就绪,请重新连接会话。');
693
+ if(runtime.mode==='same-id-resume'){
694
+ if(runtime.turnId)throw new Error('当前任务正在执行,请等待完成后再切换模型参数。');
695
+ const settings={model:body.model,effort:body.effort};
696
+ session.preferences.set(id,settings);
697
+ const resumedSession=resumeManager.sessions.get(id);
698
+ if(resumedSession)resumedSession.model=body.model;
699
+ return {...settings,applied:true,mode:'next-turn'};
700
+ }
613
701
  if (runtime.model !== body.expectedModel || runtime.effort !== body.expectedEffort) throw new Error('模型设置已变化,请刷新后重试。');
614
702
  if (runtime.turnId) {
615
703
  session.preferences.set(id,{
@@ -630,6 +718,7 @@ export function createOrbitServer(options = {}) {
630
718
  if (message.method === 'turn.steer') {
631
719
  if (!controlFor(session, id)) throw new Error('没有此会话的控制授权。');
632
720
  if (executionMode !== 'desktop') throw new Error('当前执行通道不支持向桌面任务追加指令。');
721
+ if(runtimeForThread(id)?.mode==='same-id-resume')throw new Error('当前原会话通过 App Server 接续;请等待本轮完成后再发送下一条消息。');
633
722
  const attachmentIds=body.attachmentIds===undefined?[]:body.attachmentIds;
634
723
  if(!Array.isArray(attachmentIds)||attachmentIds.length>maxPendingAttachments||new Set(attachmentIds).size!==attachmentIds.length
635
724
  ||attachmentIds.some(attachmentId=>typeof attachmentId!=='string'))throw new Error('文件附件列表无效。');
@@ -647,19 +736,26 @@ export function createOrbitServer(options = {}) {
647
736
  }
648
737
  if (message.method === 'turn.interrupt') {
649
738
  if (!controlFor(session, id)) throw new Error('没有此会话的控制授权。');
650
- if (executionMode === 'desktop') return rpc.interrupt(id, body.turnId);
739
+ if (executionMode === 'desktop') {
740
+ if(resumeManager?.has(id))return resumeManager.rpcFor(id).interrupt(id);
741
+ return rpc.interrupt(id, body.turnId);
742
+ }
651
743
  return rpc.interrupt(id);
652
744
  }
653
745
  if (message.method === 'approval.reply') {
654
- const approval = rpc.approvals.get(String(body.requestId));
746
+ const approvalRpc=resumeManager?.rpcForApproval(body.requestId)||rpc;
747
+ const approval = approvalRpc.approvals.get(String(body.requestId));
655
748
  if (!approval || !controlFor(session, approval.params.threadId)) throw new Error('没有此审批的控制授权。');
656
- return rpc.replyApproval(body.requestId, body);
749
+ return approvalRpc.replyApproval(body.requestId, body);
657
750
  }
658
751
  throw new Error('不支持的操作。');
659
752
  }
660
753
 
661
754
  const eventListener = event => {
662
- if (!/^(item\/|turn\/|thread\/status|orbit\/|serverRequest\/resolved|error$)/.test(event.method)) return;
755
+ if (!/^(item\/|turn\/|thread\/(status|goal\/)|orbit\/|serverRequest\/resolved|error$)/.test(event.method)) return;
756
+ if(event.method==='orbit/engine'&&event.params?.connected===false&&resumeManager?.ids().length){
757
+ event={...event,params:{...event.params,connected:true,desktopConnected:false,resumedConnected:true}};
758
+ }
663
759
  if(event.method==='turn/completed'&&event.params?.threadId){
664
760
  for(const session of sessions.values())for(const [id,attachment]of session.attachments){
665
761
  if(attachment.threadId===event.params.threadId&&attachment.consumed)removeAttachment(session,id);
@@ -688,6 +784,8 @@ export function createOrbitServer(options = {}) {
688
784
  };
689
785
  rpc.on('event', eventListener);
690
786
  rpc.on('lost', lostListener);
787
+ resumeManager?.on('event', eventListener);
788
+ resumeManager?.on('lost', lostListener);
691
789
  async function listen() {
692
790
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); });
693
791
  port = server.address().port;
@@ -725,6 +823,9 @@ export function createOrbitServer(options = {}) {
725
823
  for(const session of sessions.values())removeSessionAttachments(session);
726
824
  for (const socket of sockets) socket.terminate();
727
825
  rpc.off('event', eventListener); rpc.off('lost', lostListener); rpc.close();
826
+ resumeManager?.off('event', eventListener);
827
+ resumeManager?.off('lost', lostListener);
828
+ if(resumeManager)await resumeManager.close();
728
829
  await new Promise(resolve => wss.close(resolve));
729
830
  server.closeAllConnections();
730
831
  await new Promise(resolve => server.close(resolve));