orbit-codex-bridge 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
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}`);
@@ -230,7 +231,23 @@ async function main() {
230
231
  return;
231
232
  }
232
233
  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');
234
+ 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');
235
+ return;
236
+ }
237
+ if (command === 'check-update') {
238
+ const service = await connectorStatus(configDirectory());
239
+ const currentVersion = service?.version || JSON.parse(await fs.readFile(path.join(packageDirectory,'package.json'),'utf8')).version;
240
+ const result = await createUpdateChecker(currentVersion).check();
241
+ console.log(`电脑连接器版本:${result.currentVersion}`);
242
+ if (result.latestVersion) console.log(`npm 最新版本:${result.latestVersion}`);
243
+ const messages = {
244
+ 'update-available':'发现新版本。请在这台电脑上执行以下命令,更新后会复用现有授权:',
245
+ 'up-to-date':'当前已是最新版本。',
246
+ ahead:'当前版本高于 npm 最新发布版本。',
247
+ unavailable:'暂时无法检查更新,请稍后重试;现有连接不受影响。'
248
+ };
249
+ console.log(messages[result.status]);
250
+ if (result.available) console.log(result.command);
234
251
  return;
235
252
  }
236
253
  const namedIndex = args.indexOf('--name');
@@ -254,7 +271,7 @@ async function main() {
254
271
  const service = await connectorStatus(configDirectory());
255
272
  const labels = {online:'在线',connecting:'连接中',reconnecting:'断线重连中',blocked:'连接被拒绝',starting:'启动中',restarting:'异常恢复中'};
256
273
  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')}`
274
+ ? `电脑:${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
275
  : '此电脑尚未连接 Orbit。运行 orbit login 完成连接。');
259
276
  return;
260
277
  }
@@ -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); }
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;