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 +4 -4
- package/lib/desktop.mjs +10 -1
- package/lib/resume-manager.mjs +177 -0
- package/lib/rpc.mjs +28 -2
- package/package.json +2 -2
- package/public/app.js +166 -32
- package/public/goal.css +7 -0
- package/public/index.html +2 -0
- package/server.mjs +125 -24
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.
|
|
33
|
+
4. 选择任意普通原有会话即可连接。Orbit 优先接入 Codex 桌面当前执行者;桌面未加载时尝试由 Codex App Server 按原会话 ID 接续,不会新建对话。若该会话仍被其他执行者占用,会明确提示并拒绝重复写入。
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
授权信息保存在电脑本地,下次启动会自动复用。连接器默认在当前用户的后台运行,可以关闭安装终端;断网后自动重连,工作进程异常退出后自动恢复。电脑需要保持开机、联网。桌面热接管需 Codex 桌面运行;若会话未由桌面持有,则尝试通过 Codex App Server 按原 ID 接续。电脑重启后运行 `orbit start` 恢复连接。
|
|
36
36
|
|
|
37
37
|
## 会话输入
|
|
38
38
|
|
|
39
39
|
- `普通`:按原会话的 Codex 模型、权限和设置继续。
|
|
40
|
-
-
|
|
41
|
-
-
|
|
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
|
-
|
|
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, {
|
|
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
|
+
"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();
|
|
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)
|
|
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'&¶ms.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'&¶ms.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();
|
|
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();
|
|
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
|
|
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
|
|
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 ?
|
|
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
|
-
: !
|
|
732
|
-
:
|
|
733
|
-
:
|
|
734
|
-
|
|
735
|
-
|
|
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 ?
|
|
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=!
|
|
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?'
|
|
782
|
+
select('#turn-mode').title=state.busy?'当前任务中无法切换模式。':!nativePlanAvailable?'当前执行通道不支持原生协作模式。':'通过 Codex 原生协作模式控制下一轮。';
|
|
754
783
|
const mode=select('#turn-mode').value;
|
|
755
|
-
select('#composer-note').textContent=
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
|
860
|
-
const
|
|
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('、')}`:'');
|
package/public/goal.css
ADDED
|
@@ -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
|
|
160
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
536
|
-
|
|
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
|
-
|
|
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
|
|
551
|
-
|
|
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
|
|
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
|
|
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'
|
|
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(
|
|
679
|
+
if(attachments.length)throw new Error('文件附件仅支持 Codex 桌面热接管。');
|
|
601
680
|
if (!rpc.threads.has(id) && !endpoint && store.locked(id)) throw new Error('会话已被其他进程占用,未发送消息。');
|
|
602
|
-
|
|
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 =
|
|
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')
|
|
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
|
|
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
|
|
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));
|