orbit-codex-bridge 0.1.6 → 0.1.8

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
@@ -4,6 +4,8 @@
4
4
 
5
5
  Orbit 将电脑连接到 [Orbit 工作台](https://orbit.cubesugar.tech)。在网页中选择电脑和会话,即可查看历史、接收实时回复、发送指令、停止任务和处理审批。桌面已加载的会话会自动热接管,沿用原会话 ID 和执行上下文。
6
6
 
7
+ 此 npm 包仅安装电脑连接器。网页在 Orbit 工作台打开,账号服务和管理后台单独部署,不包含在电脑客户端中。
8
+
7
9
  ## 安装与连接
8
10
 
9
11
  需要 Node.js 24+,并在电脑上安装、运行 Codex 桌面应用。
@@ -36,13 +38,18 @@ orbit login
36
38
 
37
39
  ## 会话输入
38
40
 
41
+ - `新建会话`:会话列表右上角的加号可在指定项目目录或个人空间创建 Codex 原生会话,可选模型、普通/计划模式并发送首条消息。项目会保留原生项目归属,个人空间不绑定项目;两种会话均由本机 Codex App Server 执行并持久化。
39
42
  - `普通`:按原会话的 Codex 模型、权限和设置继续。
40
43
  - `计划`:通过 Codex App Server `turn/start.collaborationMode` 或桌面 IPC 使用原生 Plan 协作模式;不会要求切回电脑,也不会改动正在运行的轮次。
41
- - `目标`:通过 Codex 原生 `thread/goal/*` API 为原会话设置持久 Goal,支持预算、暂停、继续和清除;桌面 owner 模式可查看目标状态,但当前 IPC 不公开 Goal 写操作,因此需在 Codex 桌面管理。
44
+ - `目标`:新建会话和 App Server 接续会话支持原生 Goal、预算、暂停、继续和清除。桌面热接管会话支持查看目标、编辑及暂存草稿;当前桌面 IPC 不公开 Goal 写操作,页面会明确标注保存限制。草稿保存在当前网页内存,刷新或退出后清除。
45
+ - `人工确认`:命令/文件批准、原生问答选项、MCP 确认表单和权限请求均可在网页处理。仅展示执行器允许的批准选项,权限只能授予该请求提供的范围;已由另一端处理的请求不能再次提交。
42
46
  - `添加文件`:每次最多 5 个、每个最多 8 MiB。文件经账号设备中继传到本机,并暂存在项目内 `.orbit-attachments`;Codex 读取原路径。待发送文件 30 分钟清理,已发送文件保留最多 24 小时,任务完成后会提前删除。
43
- - `电脑文件`:浏览当前会话项目并下载文件;限制在项目根目录内,排除 `.git`、`.codex`、密钥/环境文件和符号链接,单文件最大 8 MiB。
44
47
  - 文件附件仅支持已登录账号、在线的自有电脑,以及已加载并已自动热接管的 Codex 桌面原会话。归档会话、子代理和独立续接不支持。
45
48
 
49
+ ## 账号设置
50
+
51
+ 点击工作台右上角头像,或电脑选择页的“账号设置”,可修改显示名称及密码。用户名是账号的唯一标识,注册后不可修改。修改密码需要输入当前密码和两次新密码,新密码至少 6 个字符;更新后需要重新登录,已绑定的电脑和原有会话保留。
52
+
46
53
  ## 常用命令
47
54
 
48
55
  | 命令 | 用途 |
@@ -50,6 +57,7 @@ orbit login
50
57
  | `orbit login` | 首次授权,或复用已有授权启动后台连接器 |
51
58
  | `orbit start` | 启动后台连接器 |
52
59
  | `orbit status` | 查看后台运行状态、云端连接状态及日志路径 |
60
+ | `orbit check-update` | 检查电脑连接器当前版本和 npm 最新版本 |
53
61
  | `orbit stop` | 停止后台连接器,保留授权 |
54
62
  | `orbit restart` | 重启后台连接器 |
55
63
  | `orbit logout` | 停止连接并撤销这台电脑的授权 |
@@ -64,6 +72,16 @@ orbit login --server https://your-orbit-domain.com
64
72
 
65
73
  ## 更新
66
74
 
75
+ 在工作台“设备与连接”中点击“检查更新”,可以查看所选电脑的连接器版本和 npm 最新版本。发现新版后,复制页面提供的命令,在对应电脑上运行即可。已运行的新版连接器还会在启动时及每 6 小时检查一次;检查超时或网络失败不影响会话,不会自动安装或重启。
76
+
77
+ 也可以在电脑终端手动检查:
78
+
79
+ ```sh
80
+ orbit check-update
81
+ ```
82
+
83
+ 检查优先显示后台正在运行的连接器版本。npm 尚未发布的本地测试版会显示“当前版本高于 npm 最新发布版本”,不会提示降级。旧连接器未提供检查接口时,网页会给出先更新连接器的指引。
84
+
67
85
  使用 npx 的用户重新执行安装命令即可获取最新版。全局安装的用户运行:
68
86
 
69
87
  ```sh
@@ -95,6 +113,16 @@ npx.cmd --yes --package=orbit-codex-bridge@latest orbit rebind
95
113
 
96
114
  先确认电脑开机并联网,运行 `orbit status` 查看状态。短暂断网会自动恢复;电脑重启后运行 `orbit start`。仍有问题时,按状态命令显示的路径查看 `connector.log`,或使用 `orbit login --foreground` 诊断。
97
115
 
116
+ **云端拒绝电脑凭证**
117
+
118
+ 这表示云端没有接受这台电脑保存的连接凭证,电脑尚未上线。请先确认账号没有停用或到期、电脑绑定没有被移除;重复 `orbit login` 会复用原有凭证,不会重新授权。需要重新绑定时运行:
119
+
120
+ ```powershell
121
+ npx.cmd --yes --package=orbit-codex-bridge@latest orbit rebind
122
+ ```
123
+
124
+ 网页确认后会生成新的电脑凭证,原 Codex 会话不会删除。如果提示设备额度已满,在账号的电脑列表移除不再使用的旧绑定后重试。若多台电脑在云端升级后同时被拒绝,请由运维检查服务是否仍使用原来的 Relay 数据库。
125
+
98
126
  **能看历史,但会话尚未连接**
99
127
 
100
128
  在 Codex 桌面打开同一条会话,再点击网页中的“重新连接”。桌面热接管依赖 Codex 内部接口,桌面版本更新后可能需要更新 Orbit。
package/cli.mjs CHANGED
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
9
9
  import { createOrbitServer } from './server.mjs';
10
10
  import { createRelayAgent } from './relay-agent.mjs';
11
11
  import {connectorStatus,installConnectorRuntime,startConnector,stopConnector,superviseConnector} from './lib/connector-service.mjs';
12
+ import {createUpdateChecker} from './lib/updates.mjs';
12
13
 
13
14
  const defaultServerUrl = 'https://orbit.cubesugar.tech';
14
15
  const packageDirectory = path.dirname(fileURLToPath(import.meta.url));
@@ -156,7 +157,7 @@ async function runComputer(config, background = false) {
156
157
  if(localUrl)console.log(`检测到本机 Orbit 已运行,将复用 ${localUrl} 并连接云端。`);
157
158
  try {
158
159
  if(!localUrl){
159
- app=createOrbitServer({dataDir:configDirectory(),publicUrl:config.publicUrl});
160
+ app=createOrbitServer({dataDir:configDirectory(),publicUrl:config.publicUrl,clientOnly:true});
160
161
  const info=await app.listen();
161
162
  localUrl=info.url;
162
163
  console.log(`Orbit 电脑端运行中:${localUrl}`);
@@ -199,13 +200,18 @@ async function runBackground(config) {
199
200
  console.log('正在启动 Orbit 后台连接器…');
200
201
  const runtime = await installConnectorRuntime(packageDirectory,configDirectory());
201
202
  const service = await startConnector({home:configDirectory(),...runtime});
202
- const label = service.status === 'online' ? '已连接云端' : service.status === 'blocked' ? service.reason : '正在自动连接云端';
203
+ if (service.status === 'blocked') {
204
+ throw new Error(`电脑“${config.deviceName}”尚未上线。${service.reason || '云端拒绝了电脑连接。'}\n诊断日志:${path.join(configDirectory(),'connector.log')}`);
205
+ }
206
+ const label = service.status === 'online' ? '已连接云端' : service.status === 'reconnecting' ? '连接暂时中断,正在自动重连' : '正在等待云端确认连接';
203
207
  console.log(`电脑“${config.deviceName}”后台运行中 · ${label}`);
204
208
  console.log(`手机工作台:${config.publicUrl}`);
205
209
  console.log('可以关闭此终端。断网自动重连,异常退出自动恢复;电脑休眠期间无法远程使用。');
206
210
  console.log('停止:orbit stop;状态:orbit status;前台诊断:orbit login --foreground');
207
211
  console.log(`诊断日志:${path.join(configDirectory(),'connector.log')}`);
208
- console.log('手机登录同一 Orbit 账号,选择这台电脑即可,无需输入配对密钥。');
212
+ console.log(service.status === 'online'
213
+ ? '手机登录同一 Orbit 账号,选择这台电脑即可,无需输入配对密钥。'
214
+ : '电脑上线后,手机登录同一 Orbit 账号并选择这台电脑。运行 orbit status 可查看连接结果。');
209
215
  }
210
216
 
211
217
  async function revokeComputer(config) {
@@ -230,7 +236,23 @@ async function main() {
230
236
  return;
231
237
  }
232
238
  if (command === '--help' || command === '-h' || command === 'help') {
233
- console.log('Orbit computer connector\n\nUsage:\n orbit login [--name "My computer"] [--server https://orbit.example.com] [--foreground]\n orbit start\n orbit stop\n orbit restart\n orbit status\n orbit logout\n orbit rebind');
239
+ console.log('Orbit computer connector\n\nUsage:\n orbit login [--name "My computer"] [--server https://orbit.example.com] [--foreground]\n orbit start\n orbit stop\n orbit restart\n orbit status\n orbit check-update\n orbit logout\n orbit rebind');
240
+ return;
241
+ }
242
+ if (command === 'check-update') {
243
+ const service = await connectorStatus(configDirectory());
244
+ const currentVersion = service?.version || JSON.parse(await fs.readFile(path.join(packageDirectory,'package.json'),'utf8')).version;
245
+ const result = await createUpdateChecker(currentVersion).check();
246
+ console.log(`电脑连接器版本:${result.currentVersion}`);
247
+ if (result.latestVersion) console.log(`npm 最新版本:${result.latestVersion}`);
248
+ const messages = {
249
+ 'update-available':'发现新版本。请在这台电脑上执行以下命令,更新后会复用现有授权:',
250
+ 'up-to-date':'当前已是最新版本。',
251
+ ahead:'当前版本高于 npm 最新发布版本。',
252
+ unavailable:'暂时无法检查更新,请稍后重试;现有连接不受影响。'
253
+ };
254
+ console.log(messages[result.status]);
255
+ if (result.available) console.log(result.command);
234
256
  return;
235
257
  }
236
258
  const namedIndex = args.indexOf('--name');
@@ -254,7 +276,7 @@ async function main() {
254
276
  const service = await connectorStatus(configDirectory());
255
277
  const labels = {online:'在线',connecting:'连接中',reconnecting:'断线重连中',blocked:'连接被拒绝',starting:'启动中',restarting:'异常恢复中'};
256
278
  console.log(existing
257
- ? `电脑:${existing.deviceName}\n工作台:${existing.publicUrl}\n后台服务:${service ? labels[service.status] || service.status : '未运行'}${service?.reason ? ` · ${service.reason}` : ''}\n配置文件:${configPath()}\n日志文件:${path.join(configDirectory(),'connector.log')}`
279
+ ? `电脑:${existing.deviceName}\n工作台:${existing.publicUrl}\n连接器版本:${service?.version || JSON.parse(await fs.readFile(path.join(packageDirectory,'package.json'),'utf8')).version}\n后台服务:${service ? labels[service.status] || service.status : '未运行'}${service?.reason ? ` · ${service.reason}` : ''}\n配置文件:${configPath()}\n日志文件:${path.join(configDirectory(),'connector.log')}`
258
280
  : '此电脑尚未连接 Orbit。运行 orbit login 完成连接。');
259
281
  return;
260
282
  }
@@ -0,0 +1,97 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+
3
+ const simpleDecisions = ['accept', 'acceptForSession', 'decline', 'cancel'];
4
+
5
+ function userAnswers(params, body) {
6
+ if (body.decision !== 'answers') throw new Error('请提交问题的回答。');
7
+ const answers = {};
8
+ for (const question of params.questions || []) {
9
+ const value = body.answers?.[question.id];
10
+ if (typeof value !== 'string' || !value.trim() || value.length > 4000) throw new Error('请回答所有问题。');
11
+ if (question.options?.length && !question.isOther && !question.options.some(option => option.label === value)) {
12
+ throw new Error('请选择 Codex 提供的回答选项。');
13
+ }
14
+ Object.defineProperty(answers, question.id, { value:{ answers:[value] }, enumerable:true });
15
+ }
16
+ return { answers };
17
+ }
18
+
19
+ function permissionResponse(params, body) {
20
+ if (body.decision === 'decline') return { permissions:{}, scope:'turn' };
21
+ if (body.decision !== 'grant' || !['turn', 'session'].includes(body.scope || 'turn')) throw new Error('无效的权限决定或授权范围。');
22
+ const requested = params.permissions || {};
23
+ const granted = body.permissions;
24
+ if (!granted || Array.isArray(granted) || typeof granted !== 'object'
25
+ || Object.keys(granted).some(key => !['network', 'fileSystem'].includes(key))) throw new Error('权限超出 Codex 请求范围。');
26
+ const permissions = {};
27
+ if (granted.network !== undefined) {
28
+ if (requested.network?.enabled !== true || granted.network?.enabled !== true
29
+ || Object.keys(granted.network).some(key => key !== 'enabled')) throw new Error('网络权限超出 Codex 请求范围。');
30
+ permissions.network = { enabled:true };
31
+ }
32
+ if (granted.fileSystem !== undefined) {
33
+ const requestedFs = requested.fileSystem || {};
34
+ const grantedFs = granted.fileSystem;
35
+ if (!grantedFs || Array.isArray(grantedFs) || typeof grantedFs !== 'object'
36
+ || Object.keys(grantedFs).some(key => !['entries', 'read', 'write'].includes(key))) throw new Error('文件权限超出 Codex 请求范围。');
37
+ const fileSystem = {};
38
+ for (const key of ['entries', 'read', 'write']) {
39
+ if (grantedFs[key] === undefined) continue;
40
+ if (!Array.isArray(grantedFs[key]) || !Array.isArray(requestedFs[key])
41
+ || grantedFs[key].some(value => !requestedFs[key].some(candidate => isDeepStrictEqual(candidate, value)))) {
42
+ throw new Error('文件权限超出 Codex 请求范围。');
43
+ }
44
+ fileSystem[key] = grantedFs[key];
45
+ }
46
+ if (requestedFs.globScanMaxDepth !== undefined) fileSystem.globScanMaxDepth = requestedFs.globScanMaxDepth;
47
+ permissions.fileSystem = fileSystem;
48
+ }
49
+ return { permissions, scope:body.scope || 'turn' };
50
+ }
51
+
52
+ function elicitationResponse(params, body) {
53
+ if (!['accept', 'decline', 'cancel'].includes(body.decision)) throw new Error('无效的人工确认决定。');
54
+ if (body.decision !== 'accept') return { action:body.decision, content:null };
55
+ if (params.mode === 'url') return { action:'accept', content:null };
56
+ const schema = params.requestedSchema;
57
+ const content = body.content;
58
+ if (!schema?.properties || !content || Array.isArray(content) || typeof content !== 'object') throw new Error('人工确认表单格式无效。');
59
+ for (const key of schema.required || []) if (!Object.hasOwn(content, key)) throw new Error('请填写所有必填项。');
60
+ for (const [key, value] of Object.entries(content)) {
61
+ const property = schema.properties[key];
62
+ if (!Object.hasOwn(schema.properties, key) || !property) throw new Error('表单字段已变化,请刷新后重试。');
63
+ if (property.type === 'boolean' ? typeof value !== 'boolean'
64
+ : property.type === 'integer' ? !Number.isSafeInteger(value)
65
+ : property.type === 'number' ? typeof value !== 'number' || !Number.isFinite(value)
66
+ : typeof value !== 'string' || value.length > 4000) throw new Error('表单字段类型无效。');
67
+ if (Array.isArray(property.enum) && !property.enum.some(entry => isDeepStrictEqual(entry, value))) throw new Error('请选择提供的表单选项。');
68
+ if (typeof value === 'number' && (property.minimum !== undefined && value < property.minimum
69
+ || property.maximum !== undefined && value > property.maximum)) throw new Error('数值超出表单允许范围。');
70
+ if (typeof value === 'string' && (property.minLength !== undefined && value.length < property.minLength
71
+ || property.maxLength !== undefined && value.length > property.maxLength)) throw new Error('文本长度不符合表单要求。');
72
+ }
73
+ return { action:'accept', content };
74
+ }
75
+
76
+ export function approvalResponse(request, body) {
77
+ const params = request.params || {};
78
+ if (request.method === 'item/tool/requestUserInput') return userAnswers(params, body);
79
+ if (request.method === 'item/permissions/requestApproval') return permissionResponse(params, body);
80
+ if (request.method === 'mcpServer/elicitation/request') return elicitationResponse(params, body);
81
+ if (!['item/commandExecution/requestApproval', 'item/fileChange/requestApproval'].includes(request.method)) throw new Error('不支持的审批类型。');
82
+ let decision = body.decision;
83
+ if (params.kind === 'humanCommand' && !['accept', 'decline', 'cancel'].includes(decision)) throw new Error('此请求需要本次人工确认。');
84
+ if (decision === 'acceptWithExecpolicyAmendment' && request.method === 'item/commandExecution/requestApproval') {
85
+ const proposed = params.proposedExecpolicyAmendment;
86
+ if (!Array.isArray(proposed) || !proposed.length || !isDeepStrictEqual(body.execpolicyAmendment, proposed)) throw new Error('命令策略建议已变化,未应用。');
87
+ decision = { acceptWithExecpolicyAmendment:{ execpolicy_amendment:proposed } };
88
+ } else if (decision === 'applyNetworkPolicyAmendment' && request.method === 'item/commandExecution/requestApproval') {
89
+ const amendment = (params.proposedNetworkPolicyAmendments || []).find(entry => isDeepStrictEqual(entry, body.networkPolicyAmendment));
90
+ if (!amendment) throw new Error('网络策略建议已变化,未应用。');
91
+ decision = { applyNetworkPolicyAmendment:{ network_policy_amendment:amendment } };
92
+ } else if (!simpleDecisions.includes(decision)) throw new Error('无效审批决定。');
93
+ if (Array.isArray(params.availableDecisions) && !params.availableDecisions.some(entry => isDeepStrictEqual(entry, decision))) {
94
+ throw new Error('Codex 不允许此审批选项,请刷新后重试。');
95
+ }
96
+ return { decision };
97
+ }
@@ -0,0 +1,37 @@
1
+ export const clientFiles = Object.freeze([
2
+ 'cli.mjs',
3
+ 'orbit',
4
+ 'server.mjs',
5
+ 'relay-agent.mjs',
6
+ 'lib/approvals.mjs',
7
+ 'lib/client-files.mjs',
8
+ 'lib/connector-service.mjs',
9
+ 'lib/desktop.mjs',
10
+ 'lib/resume-manager.mjs',
11
+ 'lib/rpc.mjs',
12
+ 'lib/store.mjs',
13
+ 'lib/updates.mjs',
14
+ 'scripts/verify-client-package.mjs'
15
+ ]);
16
+
17
+ export function clientManifest(source) {
18
+ if (!/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(source.version) || typeof source.dependencies?.ws !== 'string') {
19
+ throw new Error('客户端版本或依赖配置无效。');
20
+ }
21
+ return {
22
+ name:'orbit-codex-bridge',
23
+ version:source.version,
24
+ type:'module',
25
+ description:'Connect your computer and original Codex conversations to Orbit.',
26
+ engines:{ node:'>=24' },
27
+ bin:{ orbit:'orbit' },
28
+ files:[...clientFiles],
29
+ scripts:{
30
+ start:'node server.mjs',
31
+ prepublishOnly:'node scripts/verify-client-package.mjs'
32
+ },
33
+ dependencies:{ ws:source.dependencies.ws },
34
+ publishConfig:{ registry:'https://registry.npmjs.org/', access:'public' },
35
+ orbitDistribution:'client'
36
+ };
37
+ }
@@ -4,6 +4,7 @@ import http from 'node:http';
4
4
  import path from 'node:path';
5
5
  import {spawn} from 'node:child_process';
6
6
  import {createRequire} from 'node:module';
7
+ import {clientFiles,clientManifest} from './client-files.mjs';
7
8
 
8
9
  const stateFile = home => path.join(home,'service.json');
9
10
  const pause = milliseconds => new Promise(resolve => setTimeout(resolve,milliseconds));
@@ -51,7 +52,7 @@ async function packageRoot(resolver, name) {
51
52
  }
52
53
 
53
54
  export async function installConnectorRuntime(source, home) {
54
- const manifest = JSON.parse(await fs.readFile(path.join(source,'package.json'),'utf8'));
55
+ const manifest = clientManifest(JSON.parse(await fs.readFile(path.join(source,'package.json'),'utf8')));
55
56
  const target = path.join(home,'runtime',manifest.version);
56
57
  try { await fs.access(path.join(target,'ready')); return {cliPath:path.join(target,'cli.mjs'),version:manifest.version}; }
57
58
  catch {}
@@ -73,9 +74,12 @@ export async function installConnectorRuntime(source, home) {
73
74
  }
74
75
  }
75
76
  try {
76
- for (const filename of ['package.json',...manifest.files]) {
77
- await fs.cp(path.join(source,filename),path.join(temporary,filename),{recursive:true});
77
+ for (const filename of clientFiles) {
78
+ await fs.mkdir(path.dirname(path.join(temporary,filename)),{recursive:true});
79
+ await fs.copyFile(path.join(source,filename),path.join(temporary,filename));
78
80
  }
81
+ await fs.copyFile(path.join(source,'README.md'),path.join(temporary,'README.md'));
82
+ await fs.writeFile(path.join(temporary,'package.json'),`${JSON.stringify(manifest,null,2)}\n`);
79
83
  for (const name of Object.keys(manifest.dependencies || {})) await copyDependency(name,createRequire(path.join(source,'package.json')));
80
84
  await fs.writeFile(path.join(temporary,'ready'),manifest.version,{mode:0o600});
81
85
  try { await fs.rename(temporary,target); }
@@ -109,17 +113,22 @@ export async function startConnector({home,cliPath,version}) {
109
113
  });
110
114
  } finally {await diagnostic.close();}
111
115
  let exited = false;
116
+ let latestState = null;
112
117
  child.once('exit',() => {exited = true;});
113
118
  child.unref();
114
119
  for (let attempt = 0; attempt < 100; attempt++) {
115
120
  const current = await connectorStatus(home);
116
- if (current?.version === version && current.workerReady) return current;
121
+ if (current?.version === version && current.workerReady) {
122
+ latestState = current;
123
+ if (['online','blocked','reconnecting'].includes(current.status)) return current;
124
+ }
117
125
  if (exited) {
118
126
  const diagnostic = (await fs.readFile(startupLog,'utf8').catch(() => '')).slice(-1500);
119
127
  throw new Error(`后台连接器启动失败。${diagnostic}\n诊断日志:${startupLog}`);
120
128
  }
121
129
  await pause(150);
122
130
  }
131
+ if (latestState) return latestState;
123
132
  throw new Error(`后台连接器未就绪。诊断日志:${path.join(home,'connector.log')}`);
124
133
  }
125
134
 
package/lib/desktop.mjs CHANGED
@@ -4,14 +4,16 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { EventEmitter } from 'node:events';
6
6
  import { messageFromItem } from './store.mjs';
7
+ import { approvalResponse } from './approvals.mjs';
7
8
 
8
9
  const maxFrame = 32 * 1024 * 1024;
9
- const versions = { initialize: 0, 'thread-owner-discovery': 1, 'thread-follower-read-model-settings': 1, 'thread-follower-update-thread-settings': 2, 'thread-follower-start-turn': 2, 'thread-follower-steer-turn': 1, 'thread-follower-interrupt-turn': 4, 'thread-follower-command-approval-decision': 1, 'thread-follower-file-approval-decision': 1, 'thread-follower-submit-user-input': 1, 'thread-follower-permissions-request-approval-response': 1 };
10
+ const versions = { initialize: 0, 'thread-owner-discovery': 1, 'thread-follower-read-model-settings': 1, 'thread-follower-update-thread-settings': 2, 'thread-follower-start-turn': 2, 'thread-follower-steer-turn': 1, 'thread-follower-interrupt-turn': 4, 'thread-follower-command-approval-decision': 1, 'thread-follower-file-approval-decision': 1, 'thread-follower-submit-user-input': 1, 'thread-follower-permissions-request-approval-response': 1, 'thread-follower-submit-mcp-server-elicitation-response': 1 };
10
11
  const approvalMethods = new Map([
11
12
  ['item/commandExecution/requestApproval', 'thread-follower-command-approval-decision'],
12
13
  ['item/fileChange/requestApproval', 'thread-follower-file-approval-decision'],
13
14
  ['item/tool/requestUserInput', 'thread-follower-submit-user-input'],
14
- ['item/permissions/requestApproval', 'thread-follower-permissions-request-approval-response']
15
+ ['item/permissions/requestApproval', 'thread-follower-permissions-request-approval-response'],
16
+ ['mcpServer/elicitation/request', 'thread-follower-submit-mcp-server-elicitation-response']
15
17
  ]);
16
18
 
17
19
  export class DesktopUnavailableError extends Error {
@@ -87,6 +89,7 @@ export class DesktopRpc extends EventEmitter {
87
89
  this.clientId = 'initializing-client';
88
90
  this.threads = new Map();
89
91
  this.approvals = new Map();
92
+ this.replyingApprovals = new Set();
90
93
  this.pending = new Map();
91
94
  this.attaching = new Map();
92
95
  this.starting = new Set();
@@ -397,28 +400,19 @@ export class DesktopRpc extends EventEmitter {
397
400
  async replyApproval(key, body) {
398
401
  const request = this.approvals.get(String(key));
399
402
  if (!request) throw new Error('审批已被桌面处理或已失效。');
400
- const thread = await this.assertOwner(request.params.threadId);
401
- if (this.approvals.get(String(key)) !== request) throw new Error('审批状态已变化,请刷新。');
402
- let payload;
403
- if (request.method === 'item/tool/requestUserInput') {
404
- const answers = {};
405
- for (const question of request.params.questions || []) {
406
- const answer = body.answers?.[question.id];
407
- if (typeof answer !== 'string' || !answer.trim() || answer.length > 4000) throw new Error('请回答所有问题。');
408
- answers[question.id] = { answers: [answer] };
409
- }
410
- payload = { response: { answers } };
411
- } else if (request.method === 'item/permissions/requestApproval') {
412
- if (body.decision !== 'decline') throw new Error('扩大环境权限仍需在桌面处理。');
413
- payload = { response: { permissions: {}, scope: 'turn' } };
414
- } else {
415
- if (!['accept', 'decline'].includes(body.decision)) throw new Error('无效审批决定。');
416
- payload = { decision: body.decision };
417
- }
418
- await this.request(approvalMethods.get(request.method), { conversationId: request.params.threadId, requestId: request.originalId, ...payload }, thread.ownerClientId);
419
- this.approvals.delete(String(key));
420
- this.publish(request.params.threadId);
421
- return { resolved: true };
403
+ if (this.replyingApprovals.has(String(key))) throw new Error('审批回复正在提交,请勿重复操作。');
404
+ this.replyingApprovals.add(String(key));
405
+ try {
406
+ const thread = await this.assertOwner(request.params.threadId);
407
+ if (this.approvals.get(String(key)) !== request) throw new Error('审批状态已变化,请刷新。');
408
+ const response = approvalResponse(request, body);
409
+ const payload = Object.hasOwn(response, 'decision') ? response : { response };
410
+ const params = { conversationId:request.params.threadId, requestId:request.originalId, ...payload };
411
+ await this.request(approvalMethods.get(request.method), params, thread.ownerClientId);
412
+ if (this.approvals.get(String(key)) === request) this.approvals.delete(String(key));
413
+ this.publish(request.params.threadId);
414
+ return { resolved: true };
415
+ } finally { this.replyingApprovals.delete(String(key)); }
422
416
  }
423
417
 
424
418
  detach(id, reason, reasonCode = 'DESKTOP_THREAD_UNAVAILABLE') {
@@ -35,11 +35,11 @@ export class ResumeManager extends EventEmitter {
35
35
  threadId:id, attached:Boolean(session.rpc.connected), mode:'same-id-resume',
36
36
  ownerClientId:null, connectionId:null, turnId:thread.turnId || null, revision:null,
37
37
  permissionMode:thread.writable ? 'workspace-write' : 'read-only',
38
- model:session.model || null, effort:null, collaborationMode:'default',
38
+ model:thread.model || session.model || null, effort:thread.effort ?? null, collaborationMode:thread.collaborationMode?.mode || 'default',
39
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 }))
40
+ approvals:[...session.rpc.approvals.entries()]
41
+ .filter(([,request]) => request.params?.threadId === id)
42
+ .map(([requestId,request]) => ({ ...request.params, requestId, method:request.method }))
43
43
  };
44
44
  }
45
45
 
@@ -83,6 +83,38 @@ export class ResumeManager extends EventEmitter {
83
83
  finally { if (this.pending.get(id) === operation) this.pending.delete(id); }
84
84
  }
85
85
 
86
+ async startNew({ cwd, projectId = null, model = null, text, writable = true, mode = 'default', onCreated = () => {} } = {}) {
87
+ const rpc = this.createRpc({ home:this.home });
88
+ let session;
89
+ try {
90
+ const created = await rpc.startThread({ cwd, model, projectId, writable });
91
+ session = {
92
+ id:created.threadId, rpc, model:created.model || model, goal:null,
93
+ releaseWhenIdle:false, closing:null, eventListener:null
94
+ };
95
+ session.eventListener = event => this.onEvent(session, event);
96
+ rpc.on('event', session.eventListener);
97
+ this.sessions.set(session.id, session);
98
+ onCreated(created.threadId);
99
+ this.emit('event', { method:'orbit/engine', params:{
100
+ connected:true, mode:'desktop', desktopConnected:false, resumedConnected:true
101
+ } });
102
+ try {
103
+ const turn = await rpc.startTurn(session.id, text, writable, model ? { model, effort:null } : null, { mode });
104
+ return { ...created, ...turn, mode:'same-id-resume' };
105
+ } catch(error) {
106
+ return { ...created, turnId:null, completed:true, mode:'same-id-resume', startError:error.message };
107
+ }
108
+ } catch(error) {
109
+ if(session) {
110
+ this.sessions.delete(session.id);
111
+ rpc.off('event',session.eventListener);
112
+ }
113
+ await rpc.close();
114
+ throw error;
115
+ }
116
+ }
117
+
86
118
  onEvent(session, event) {
87
119
  if (event.method === 'thread/goal/updated' && event.params?.threadId === session.id) {
88
120
  session.goal = event.params.goal || null;
@@ -112,8 +144,8 @@ export class ResumeManager extends EventEmitter {
112
144
  return;
113
145
  }
114
146
  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) {
147
+ if (['turn/completed','orbit/turn-settled'].includes(event.method) && event.params?.threadId === session.id
148
+ && session.releaseWhenIdle && !session.rpc.threads.get(session.id)?.turnId && !session.rpc.starting?.has(session.id)) {
117
149
  void this.closeSession(session);
118
150
  }
119
151
  }
@@ -147,7 +179,7 @@ export class ResumeManager extends EventEmitter {
147
179
  async release(id) {
148
180
  const session = this.sessions.get(id);
149
181
  if (!session) return;
150
- if (session.rpc.threads.get(id)?.turnId) {
182
+ if (session.rpc.threads.get(id)?.turnId || session.rpc.starting?.has(id)) {
151
183
  session.releaseWhenIdle = true;
152
184
  return;
153
185
  }
package/lib/rpc.mjs CHANGED
@@ -3,7 +3,9 @@ import { spawn } from 'node:child_process';
3
3
  import { createInterface } from 'node:readline';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import crypto from 'node:crypto';
6
7
  import WebSocket from 'ws';
8
+ import { approvalResponse } from './approvals.mjs';
7
9
 
8
10
  export function findCodexBinary() {
9
11
  if (process.env.ORBIT_CODEX_BIN) return process.env.ORBIT_CODEX_BIN;
@@ -25,6 +27,7 @@ export class CodexRpc extends EventEmitter {
25
27
  this.tokenFile = tokenFile;
26
28
  this.pending = new Map();
27
29
  this.approvals = new Map();
30
+ this.approvalNamespace = crypto.randomUUID();
28
31
  this.threads = new Map();
29
32
  this.starting = new Set();
30
33
  this.sequence = 0;
@@ -106,16 +109,21 @@ export class CodexRpc extends EventEmitter {
106
109
  return;
107
110
  }
108
111
  if (message.id !== undefined && message.method) {
109
- const supported = ['item/commandExecution/requestApproval','item/fileChange/requestApproval','item/tool/requestUserInput','item/permissions/requestApproval'];
112
+ const supported = ['item/commandExecution/requestApproval','item/fileChange/requestApproval','item/tool/requestUserInput','item/permissions/requestApproval','mcpServer/elicitation/request'];
110
113
  if (!supported.includes(message.method) || !this.threads.has(message.params?.threadId)) {
111
114
  this.send({ id: message.id, error: { code: -32601, message: 'This client does not implement the requested host tool.' } });
112
115
  return;
113
116
  }
114
- this.approvals.set(String(message.id), message);
115
- this.emit('event', { method: 'orbit/approval', params: { requestId: String(message.id), method: message.method, ...message.params } });
117
+ const requestId = `${this.approvalNamespace}:${message.id}`;
118
+ this.approvals.set(requestId, message);
119
+ this.emit('event', { method: 'orbit/approval', params: { ...message.params, requestId, method: message.method } });
116
120
  return;
117
121
  }
118
- if (message.method === 'serverRequest/resolved') this.approvals.delete(String(message.params?.requestId));
122
+ if (message.method === 'serverRequest/resolved') {
123
+ const requestId = `${this.approvalNamespace}:${message.params?.requestId}`;
124
+ this.approvals.delete(requestId);
125
+ message = { ...message, params:{ ...message.params, requestId } };
126
+ }
119
127
  if (message.method === 'turn/started' && this.threads.has(message.params?.threadId)) this.threads.get(message.params.threadId).turnId = message.params.turn.id;
120
128
  if (message.method === 'turn/completed' && this.threads.has(message.params?.threadId)) {
121
129
  const thread = this.threads.get(message.params.threadId);
@@ -138,6 +146,28 @@ export class CodexRpc extends EventEmitter {
138
146
  });
139
147
  }
140
148
 
149
+ async startThread({ cwd, model = null, projectId = null, writable = true } = {}) {
150
+ await this.connect();
151
+ const result = await this.request('thread/start', {
152
+ cwd,
153
+ projectId,
154
+ approvalPolicy: 'on-request',
155
+ sandbox: writable ? 'workspace-write' : 'read-only',
156
+ ...(model ? { model } : {})
157
+ });
158
+ const thread = result?.thread;
159
+ if (typeof thread?.id !== 'string' || !/^[a-zA-Z0-9_-]{8,120}$/.test(thread.id)) {
160
+ throw new Error('Codex 未返回有效的新会话 ID。');
161
+ }
162
+ this.threads.set(thread.id, {
163
+ turnId:null,lastCompletedTurnId:null,writable:Boolean(writable),
164
+ model:result.model||thread.model||null,
165
+ effort:result.reasoningEffort??thread.reasoningEffort??null,
166
+ collaborationMode:result.collaborationMode||{mode:'default',settings:{}}
167
+ });
168
+ return { threadId:thread.id, model:result.model||thread.model||null, cwd:thread.cwd||cwd, projectId:thread.projectId||projectId };
169
+ }
170
+
141
171
  async startTurn(id, text, writable, settings = null, options = {}) {
142
172
  if (this.starting.has(id)) throw new Error('此会话正在发送,请勿并发提交。');
143
173
  this.starting.add(id);
@@ -169,7 +199,10 @@ export class CodexRpc extends EventEmitter {
169
199
  if(settings?.model)thread.model=settings.model;
170
200
  if(settings&&Object.hasOwn(settings,'effort'))thread.effort=settings.effort;
171
201
  return { threadId: id, turnId: result.turn.id, completed };
172
- } finally { this.starting.delete(id); }
202
+ } finally {
203
+ this.starting.delete(id);
204
+ this.emit('event', { method:'orbit/turn-settled', params:{ threadId:id } });
205
+ }
173
206
  }
174
207
 
175
208
  async interrupt(id) {
@@ -181,24 +214,10 @@ export class CodexRpc extends EventEmitter {
181
214
  replyApproval(id, body) {
182
215
  const request = this.approvals.get(String(id));
183
216
  if (!request) throw new Error('审批已结束或不属于当前桥接实例。');
184
- let result;
185
- if (request.method === 'item/tool/requestUserInput') {
186
- const answers = {};
187
- for (const question of request.params.questions || []) {
188
- const value = body.answers?.[question.id];
189
- if (typeof value !== 'string' || !value.trim() || value.length > 4000) throw new Error('请回答所有问题。');
190
- answers[question.id] = { answers: [value] };
191
- }
192
- result = { answers };
193
- } else if (request.method === 'item/permissions/requestApproval') {
194
- if (body.decision !== 'decline') throw new Error('首版暂不支持扩大权限,请在本机处理。');
195
- result = { permissions: {}, scope: 'turn' };
196
- } else {
197
- if (!['accept','decline'].includes(body.decision)) throw new Error('无效审批决定。');
198
- result = { decision: body.decision };
199
- }
217
+ const result = approvalResponse(request, body);
200
218
  this.send({ id: request.id, result });
201
219
  this.approvals.delete(String(id));
220
+ this.emit('event', { method:'serverRequest/resolved', params:{ threadId:request.params.threadId, requestId:String(id) } });
202
221
  return { resolved: true };
203
222
  }
204
223
 
package/lib/store.mjs CHANGED
@@ -92,6 +92,7 @@ export class NativeStore {
92
92
  id: row.id, title: row.name || row.title || '未命名会话',
93
93
  preview: cleanText(row.preview || row.first_user_message || '').slice(0, 220), cwd,
94
94
  projectId: row.project_id || cwd || 'ungrouped', projectName: projects.get(row.project_id)?.name || leaf,
95
+ nativeProjectId: row.project_id || null,
95
96
  source: row.source, provider: row.model_provider, model: row.model || null,
96
97
  archived: Boolean(row.archived), pinned: Boolean(row.is_pinned), locked: this.locked(row.id),
97
98
  createdAt: row.created_at_ms || row.created_at * 1000, updatedAt: row.updated_at_ms || row.updated_at * 1000,
@@ -103,6 +104,11 @@ export class NativeStore {
103
104
  try { projects = new Map(this.state.prepare('SELECT id,name FROM projects').all().map(project => [project.id, project])); } catch {}
104
105
  return this.state.prepare('SELECT * FROM threads ORDER BY updated_at DESC').all().map(row => this.summary(row, projects));
105
106
  }
107
+ projectWorkspaces() {
108
+ try {
109
+ return this.state.prepare('SELECT projects.id AS projectId,projects.name AS label,project_roots.path AS cwd FROM projects JOIN project_roots ON project_roots.project_id=projects.id ORDER BY projects.position,project_roots.position').all();
110
+ } catch { return []; }
111
+ }
106
112
  historyFor(id, before = null, limit = 60) {
107
113
  const row = this.row(id);
108
114
  if (!row) return null;